Files
sub2api/backend/internal/service/openai_reasoning_effort_policy.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

234 lines
6.8 KiB
Go

package service
import (
"context"
"fmt"
"strings"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
const (
maxReasoningEffortMappings = 64
maxReasoningEffortValueLen = 64
)
var openAIReasoningEffortValues = []string{"minimal", "low", "medium", "high", "xhigh", "max"}
type openAIReasoningEffortPolicyContextKey struct{}
type openAIReasoningEffortPolicy struct {
maxEffort string
mappings []ReasoningEffortMapping
}
// NormalizeMaxReasoningEffort validates and canonicalizes a group policy value.
// Empty means that the group does not impose a ceiling.
func NormalizeMaxReasoningEffort(raw string) string {
value := strings.ToLower(strings.TrimSpace(raw))
value = strings.NewReplacer("-", "", "_", "", " ", "").Replace(value)
switch value {
case "":
return ""
case "minimal":
return "minimal"
case "low":
return "low"
case "medium":
return "medium"
case "high":
return "high"
case "xhigh", "extrahigh":
return "xhigh"
case "max":
return "max"
default:
return ""
}
}
func reasoningEffortValuesForPlatform(platform string) []string {
if platform != PlatformOpenAI && platform != PlatformComposite {
return nil
}
return openAIReasoningEffortValues
}
func normalizeMaxReasoningEffortForPlatform(platform, raw string) (string, error) {
if strings.TrimSpace(raw) == "" {
return "", nil
}
allowedValues := reasoningEffortValuesForPlatform(platform)
if len(allowedValues) == 0 {
return "", fmt.Errorf(
"reasoning effort policy is only supported for platforms %q and %q",
PlatformOpenAI,
PlatformComposite,
)
}
value := NormalizeMaxReasoningEffort(raw)
for _, allowed := range allowedValues {
if value == allowed {
return value, nil
}
}
return "", fmt.Errorf(
"reasoning effort %q is not supported for platform %q; allowed values: %s",
raw,
platform,
strings.Join(allowedValues, ", "),
)
}
func reasoningEffortRank(raw string) (int, bool) {
switch NormalizeMaxReasoningEffort(raw) {
case "minimal":
return 1, true
case "low":
return 2, true
case "medium":
return 3, true
case "high":
return 4, true
case "xhigh":
return 5, true
case "max":
return 6, true
default:
return 0, false
}
}
// NormalizeReasoningEffortMappings validates group mapping rules against the
// fixed effort values supported by OpenAI routes.
func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapping) ([]ReasoningEffortMapping, error) {
if len(raw) > maxReasoningEffortMappings {
return nil, fmt.Errorf("reasoning effort mappings cannot exceed %d entries", maxReasoningEffortMappings)
}
normalized := make([]ReasoningEffortMapping, 0, len(raw))
seen := make(map[string]struct{}, len(raw))
for i, mapping := range raw {
from := NormalizeMaxReasoningEffort(mapping.From)
to := NormalizeMaxReasoningEffort(mapping.To)
if from == "" || to == "" {
return nil, fmt.Errorf("reasoning effort mapping %d contains an empty or unknown value", i+1)
}
if len(from) > maxReasoningEffortValueLen || len(to) > maxReasoningEffortValueLen {
return nil, fmt.Errorf("reasoning effort mapping %d values cannot exceed %d characters", i+1, maxReasoningEffortValueLen)
}
if _, err := normalizeMaxReasoningEffortForPlatform(platform, from); err != nil {
return nil, fmt.Errorf("reasoning effort mapping %d source: %w", i+1, err)
}
if _, err := normalizeMaxReasoningEffortForPlatform(platform, to); err != nil {
return nil, fmt.Errorf("reasoning effort mapping %d target: %w", i+1, err)
}
key := from
if _, exists := seen[key]; exists {
return nil, fmt.Errorf("duplicate reasoning effort mapping source %q", from)
}
seen[key] = struct{}{}
normalized = append(normalized, ReasoningEffortMapping{From: from, To: to})
}
return normalized, nil
}
// WithOpenAIReasoningEffortPolicy binds a group policy to a request after its
// concrete target platform has been resolved to OpenAI. The policy is copied so
// retries and asynchronous forwarding cannot observe later slice mutations.
func WithOpenAIReasoningEffortPolicy(ctx context.Context, maxEffort string, mappings []ReasoningEffortMapping) context.Context {
if ctx == nil {
ctx = context.Background()
}
policy := openAIReasoningEffortPolicy{
maxEffort: maxEffort,
mappings: append([]ReasoningEffortMapping(nil), mappings...),
}
return context.WithValue(ctx, openAIReasoningEffortPolicyContextKey{}, policy)
}
// ApplyOpenAIReasoningEffortPolicyFromContext applies a policy previously bound
// to the request. An unbound request is returned byte-for-byte unchanged.
func ApplyOpenAIReasoningEffortPolicyFromContext(ctx context.Context, body []byte) ([]byte, bool) {
if ctx == nil {
return body, false
}
policy, ok := ctx.Value(openAIReasoningEffortPolicyContextKey{}).(openAIReasoningEffortPolicy)
if !ok {
return body, false
}
return ApplyOpenAIReasoningEffortPolicy(body, policy.maxEffort, policy.mappings)
}
func mapReasoningEffort(raw string, mappings []ReasoningEffortMapping) (string, bool) {
value := strings.TrimSpace(raw)
canonical := NormalizeMaxReasoningEffort(value)
for _, mapping := range mappings {
if canonical != "" && canonical == NormalizeMaxReasoningEffort(mapping.From) {
return strings.TrimSpace(mapping.To), true
}
}
return value, false
}
func sanitizeGroupReasoningEffortPolicy(group *Group) {
if group == nil {
return
}
maxEffort, maxErr := normalizeMaxReasoningEffortForPlatform(group.Platform, group.MaxReasoningEffort)
mappings, mappingsErr := NormalizeReasoningEffortMappings(group.Platform, group.ReasoningEffortMappings)
if maxErr != nil {
maxEffort = ""
}
if mappingsErr != nil {
mappings = []ReasoningEffortMapping{}
}
group.MaxReasoningEffort = maxEffort
group.ReasoningEffortMappings = mappings
}
// ApplyOpenAIReasoningEffortPolicy applies one exact mapping and then caps
// known effort levels. Omitted values remain untouched so upstream defaults
// stay in control.
func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping) ([]byte, bool) {
maxRank, hasMax := reasoningEffortRank(maxEffort)
if len(body) == 0 || (!hasMax && len(mappings) == 0) {
return body, false
}
result := body
changed := false
for _, path := range []string{"reasoning.effort", "reasoning_effort"} {
field := gjson.GetBytes(result, path)
if !field.Exists() || field.Type != gjson.String {
continue
}
original := strings.TrimSpace(field.String())
if original == "" {
continue
}
effective, _ := mapReasoningEffort(original, mappings)
if currentRank, recognized := reasoningEffortRank(effective); recognized {
effective = NormalizeMaxReasoningEffort(effective)
if hasMax && currentRank > maxRank {
effective = NormalizeMaxReasoningEffort(maxEffort)
}
}
if effective == original {
continue
}
updated, err := sjson.SetBytes(result, path, effective)
if err != nil {
continue
}
result = updated
changed = true
}
return result, changed
}