| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571 |
- package service
- import (
- "context"
- "errors"
- "fmt"
- "strings"
- "github.com/Wei-Shaw/sub2api/internal/domain"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
- )
- const (
- maxReasoningEffortMappings = 64
- maxReasoningEffortValueLen = 64
- maxReasoningEffortModelLen = 200
- // ReasoningEffortOverLimitDowngrade rewrites values above the ceiling.
- ReasoningEffortOverLimitDowngrade = "downgrade"
- // ReasoningEffortOverLimitDeny rejects the request when the ceiling is exceeded.
- ReasoningEffortOverLimitDeny = "deny"
- // ReasoningEffortMappingDeny is a mapping target that rejects the request
- // when the matching source reasoning effort is present.
- ReasoningEffortMappingDeny = "deny"
- )
- var openAIReasoningEffortValues = []string{"minimal", "low", "medium", "high", "xhigh", "max"}
- var anthropicReasoningEffortValues = []string{"low", "medium", "high", "xhigh", "max"}
- func normalizeReasoningEffortMappingSource(raw string) string {
- if strings.EqualFold(strings.TrimSpace(raw), "none") {
- return "none"
- }
- return NormalizeMaxReasoningEffort(raw)
- }
- func normalizeReasoningEffortMappingTarget(raw string) string {
- if isReasoningEffortMappingDeny(raw) {
- return ReasoningEffortMappingDeny
- }
- return NormalizeMaxReasoningEffort(raw)
- }
- func isReasoningEffortMappingDeny(raw string) bool {
- return strings.EqualFold(strings.TrimSpace(raw), ReasoningEffortMappingDeny)
- }
- type openAIReasoningEffortPolicyContextKey struct{}
- type requestedReasoningEffortContextKey struct{}
- type openAIReasoningEffortPolicy struct {
- maxEffort string
- overLimit string
- mappings []ReasoningEffortMapping
- }
- // ReasoningEffortOverLimitError is returned when a group policy is set to deny
- // requests whose explicit reasoning effort exceeds the ceiling.
- type ReasoningEffortOverLimitError struct {
- Requested string
- Max string
- }
- func (e *ReasoningEffortOverLimitError) Error() string {
- if e == nil {
- return "reasoning effort exceeds this group's limit"
- }
- requested := strings.TrimSpace(e.Requested)
- max := strings.TrimSpace(e.Max)
- if requested == "" && max == "" {
- return "reasoning effort exceeds this group's limit"
- }
- if requested == "" {
- return fmt.Sprintf("reasoning effort exceeds this group's limit of %q", max)
- }
- if max == "" {
- return fmt.Sprintf("reasoning effort %q exceeds this group's limit", requested)
- }
- return fmt.Sprintf("reasoning effort %q exceeds this group's limit of %q", requested, max)
- }
- // ReasoningEffortMappingDeniedError is returned when a group mapping target is
- // set to deny the explicit reasoning effort on the request.
- type ReasoningEffortMappingDeniedError struct {
- Requested string
- }
- func (e *ReasoningEffortMappingDeniedError) Error() string {
- if e == nil {
- return "reasoning effort is denied by this group's mapping policy"
- }
- requested := strings.TrimSpace(e.Requested)
- if requested == "" {
- return "reasoning effort is denied by this group's mapping policy"
- }
- return fmt.Sprintf("reasoning effort %q is denied by this group's mapping policy", requested)
- }
- // IsReasoningEffortPolicyDenied reports whether err is a local reasoning-effort
- // policy rejection (ceiling deny or mapping deny).
- func IsReasoningEffortPolicyDenied(err error) bool {
- if err == nil {
- return false
- }
- var overLimit *ReasoningEffortOverLimitError
- if errors.As(err, &overLimit) {
- return true
- }
- var mappingDenied *ReasoningEffortMappingDeniedError
- return errors.As(err, &mappingDenied)
- }
- // 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 {
- switch platform {
- case PlatformOpenAI, PlatformComposite:
- return openAIReasoningEffortValues
- case PlatformAnthropic:
- return anthropicReasoningEffortValues
- default:
- return nil
- }
- }
- 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, %q, and %q",
- PlatformAnthropic,
- 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, ", "),
- )
- }
- // NormalizeMaxReasoningEffortOverLimit canonicalizes the over-limit action.
- // Empty means the historical default: automatically downgrade to the ceiling.
- func NormalizeMaxReasoningEffortOverLimit(raw string) string {
- switch strings.ToLower(strings.TrimSpace(raw)) {
- case "", ReasoningEffortOverLimitDowngrade:
- return ReasoningEffortOverLimitDowngrade
- case ReasoningEffortOverLimitDeny:
- return ReasoningEffortOverLimitDeny
- default:
- return ""
- }
- }
- func normalizeMaxReasoningEffortOverLimitForPlatform(platform, raw string) (string, error) {
- value := NormalizeMaxReasoningEffortOverLimit(raw)
- if value == "" {
- return "", fmt.Errorf(
- "reasoning effort over-limit action %q is not supported; allowed values: %s, %s",
- raw,
- ReasoningEffortOverLimitDowngrade,
- ReasoningEffortOverLimitDeny,
- )
- }
- if value == ReasoningEffortOverLimitDowngrade {
- return value, nil
- }
- if len(reasoningEffortValuesForPlatform(platform)) == 0 {
- return "", fmt.Errorf(
- "reasoning effort policy is only supported for platforms %q, %q, and %q",
- PlatformAnthropic,
- PlatformOpenAI,
- PlatformComposite,
- )
- }
- return value, nil
- }
- 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
- }
- }
- func normalizeReasoningEffortMatchType(matchType, model string) (string, error) {
- model = strings.TrimSpace(model)
- matchType = strings.ToLower(strings.TrimSpace(matchType))
- if model == "" {
- return "", nil
- }
- switch matchType {
- case "", domain.ReasoningEffortMatchExact:
- return domain.ReasoningEffortMatchExact, nil
- case domain.ReasoningEffortMatchPrefix:
- return domain.ReasoningEffortMatchPrefix, nil
- case domain.ReasoningEffortMatchSuffix:
- return domain.ReasoningEffortMatchSuffix, nil
- default:
- return "", fmt.Errorf("invalid match_type %q", matchType)
- }
- }
- func reasoningEffortMappingDuplicateKey(from, matchType, model string) string {
- return from + "\x00" + matchType + "\x00" + strings.ToLower(strings.TrimSpace(model))
- }
- func requestModelMatchesReasoningEffortMapping(requestModel, matchType, mappingModel string) bool {
- scope := strings.ToLower(strings.TrimSpace(mappingModel))
- req := strings.ToLower(strings.TrimSpace(requestModel))
- switch matchType {
- case "":
- return true
- case domain.ReasoningEffortMatchExact:
- return scope != "" && scope == req
- case domain.ReasoningEffortMatchPrefix:
- return scope != "" && req != "" && strings.HasPrefix(req, scope)
- case domain.ReasoningEffortMatchSuffix:
- return scope != "" && req != "" && strings.HasSuffix(req, scope)
- default:
- return false
- }
- }
- func selectReasoningEffortMapping(mappings []ReasoningEffortMapping, from, requestModel string) (ReasoningEffortMapping, bool) {
- type candidate struct {
- mapping ReasoningEffortMapping
- matchStrength int
- patternLen int
- index int
- }
- candidates := make([]candidate, 0, len(mappings))
- for i, mapping := range mappings {
- if normalizeReasoningEffortMappingSource(mapping.From) != from {
- continue
- }
- model := strings.TrimSpace(mapping.Model)
- matchType, err := normalizeReasoningEffortMatchType(mapping.MatchType, model)
- if err != nil {
- continue
- }
- if !requestModelMatchesReasoningEffortMapping(requestModel, matchType, model) {
- continue
- }
- strength := 1
- patternLen := 0
- switch matchType {
- case domain.ReasoningEffortMatchExact:
- strength = 3
- case domain.ReasoningEffortMatchPrefix, domain.ReasoningEffortMatchSuffix:
- strength = 2
- patternLen = len(strings.ToLower(model))
- }
- candidates = append(candidates, candidate{
- mapping: mapping,
- matchStrength: strength,
- patternLen: patternLen,
- index: i,
- })
- }
- if len(candidates) == 0 {
- return ReasoningEffortMapping{}, false
- }
- best := candidates[0]
- for _, item := range candidates[1:] {
- if item.matchStrength != best.matchStrength {
- if item.matchStrength > best.matchStrength {
- best = item
- }
- continue
- }
- if item.patternLen != best.patternLen {
- if item.patternLen > best.patternLen {
- best = item
- }
- continue
- }
- if item.index < best.index {
- best = item
- }
- }
- return best.mapping, true
- }
- // NormalizeReasoningEffortMappings validates group mapping rules against the
- // fixed effort values supported by the selected platform. Optional model scopes are
- // canonicalized to exact/prefix/suffix match; empty type and model keep the
- // global all-models rule.
- 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 := normalizeReasoningEffortMappingSource(mapping.From)
- to := normalizeReasoningEffortMappingTarget(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 from != "none" {
- if _, err := normalizeMaxReasoningEffortForPlatform(platform, from); err != nil {
- return nil, fmt.Errorf("reasoning effort mapping %d source: %w", i+1, err)
- }
- } else if len(reasoningEffortValuesForPlatform(platform)) == 0 {
- return nil, fmt.Errorf(
- "reasoning effort mapping %d source: reasoning effort policy is only supported for platforms %q and %q",
- i+1, PlatformOpenAI, PlatformComposite,
- )
- }
- if to != ReasoningEffortMappingDeny {
- if _, err := normalizeMaxReasoningEffortForPlatform(platform, to); err != nil {
- return nil, fmt.Errorf("reasoning effort mapping %d target: %w", i+1, err)
- }
- } else if len(reasoningEffortValuesForPlatform(platform)) == 0 {
- return nil, fmt.Errorf(
- "reasoning effort mapping %d target: reasoning effort policy is only supported for platforms %q, %q, and %q",
- i+1,
- PlatformAnthropic,
- PlatformOpenAI,
- PlatformComposite,
- )
- }
- model := strings.TrimSpace(mapping.Model)
- if len(model) > maxReasoningEffortModelLen {
- return nil, fmt.Errorf("reasoning effort mapping %d model cannot exceed %d characters", i+1, maxReasoningEffortModelLen)
- }
- matchType, err := normalizeReasoningEffortMatchType(mapping.MatchType, model)
- if err != nil {
- return nil, fmt.Errorf("reasoning effort mapping %d: %w", i+1, err)
- }
- key := reasoningEffortMappingDuplicateKey(from, matchType, model)
- if _, exists := seen[key]; exists {
- if model == "" {
- return nil, fmt.Errorf("duplicate reasoning effort mapping source %q", from)
- }
- return nil, fmt.Errorf("duplicate reasoning effort mapping source %q for %s model %q", from, matchType, strings.ToLower(model))
- }
- seen[key] = struct{}{}
- normalized = append(normalized, ReasoningEffortMapping{
- From: from,
- To: to,
- MatchType: matchType,
- Model: model,
- })
- }
- return normalized, nil
- }
- // WithRequestedReasoningEffort stores the client-requested effort captured from
- // the inbound body before group policy or model-family remapping.
- func WithRequestedReasoningEffort(ctx context.Context, effort string) context.Context {
- if ctx == nil {
- ctx = context.Background()
- }
- effort = strings.TrimSpace(effort)
- if effort == "" {
- return ctx
- }
- return context.WithValue(ctx, requestedReasoningEffortContextKey{}, effort)
- }
- // RequestedReasoningEffortFromContext returns the inbound requested effort bound
- // to ctx, or nil when none was captured.
- func RequestedReasoningEffortFromContext(ctx context.Context) *string {
- if ctx == nil {
- return nil
- }
- value, ok := ctx.Value(requestedReasoningEffortContextKey{}).(string)
- if !ok {
- return nil
- }
- value = strings.TrimSpace(value)
- if value == "" {
- return nil
- }
- return &value
- }
- // 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, overLimit string) context.Context {
- if ctx == nil {
- ctx = context.Background()
- }
- policy := openAIReasoningEffortPolicy{
- maxEffort: maxEffort,
- overLimit: overLimit,
- 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, error) {
- if ctx == nil {
- return body, false, nil
- }
- policy, ok := ctx.Value(openAIReasoningEffortPolicyContextKey{}).(openAIReasoningEffortPolicy)
- if !ok {
- return body, false, nil
- }
- return ApplyOpenAIReasoningEffortPolicy(body, policy.maxEffort, policy.mappings, policy.overLimit)
- }
- func mapReasoningEffort(raw string, mappings []ReasoningEffortMapping, requestModel string) (string, bool) {
- value := strings.TrimSpace(raw)
- canonical := normalizeReasoningEffortMappingSource(value)
- if canonical == "" {
- return value, false
- }
- mapping, ok := selectReasoningEffortMapping(mappings, canonical, requestModel)
- if !ok {
- return value, false
- }
- return strings.TrimSpace(mapping.To), true
- }
- func sanitizeGroupReasoningEffortPolicy(group *Group) {
- if group == nil {
- return
- }
- maxEffort, maxErr := normalizeMaxReasoningEffortForPlatform(group.Platform, group.MaxReasoningEffort)
- mappings, mappingsErr := NormalizeReasoningEffortMappings(group.Platform, group.ReasoningEffortMappings)
- overLimit := NormalizeMaxReasoningEffortOverLimit(group.MaxReasoningEffortOverLimit)
- if maxErr != nil {
- maxEffort = ""
- }
- if mappingsErr != nil {
- mappings = []ReasoningEffortMapping{}
- }
- if overLimit == "" || (group.Platform != PlatformAnthropic && group.Platform != PlatformOpenAI && group.Platform != PlatformComposite) {
- overLimit = ReasoningEffortOverLimitDowngrade
- }
- group.MaxReasoningEffort = maxEffort
- group.MaxReasoningEffortOverLimit = overLimit
- group.ReasoningEffortMappings = mappings
- }
- // ApplyReasoningEffortPolicy applies one mapping (optionally scoped to
- // the request model by exact name, prefix, or suffix) and then either caps
- // known effort levels or rejects the request when the group is configured to
- // deny values above the ceiling. A mapping whose target is deny rejects the
- // request when that source value is present. Omitted values remain untouched
- // so upstream defaults stay in control. It understands both OpenAI and
- // Anthropic request field shapes.
- func ApplyReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) {
- maxRank, hasMax := reasoningEffortRank(maxEffort)
- if len(body) == 0 || (!hasMax && len(mappings) == 0) {
- return body, false, nil
- }
- deny := hasMax && NormalizeMaxReasoningEffortOverLimit(overLimit) == ReasoningEffortOverLimitDeny
- canonicalMax := NormalizeMaxReasoningEffort(maxEffort)
- requestModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
- result := body
- changed := false
- for _, path := range []string{"reasoning.effort", "reasoning_effort", "output_config.effort"} {
- field := gjson.GetBytes(result, path)
- if !field.Exists() || field.Type != gjson.String {
- continue
- }
- original := strings.TrimSpace(field.String())
- if original == "" {
- continue
- }
- effective, mapped := mapReasoningEffort(original, mappings, requestModel)
- if mapped && isReasoningEffortMappingDeny(effective) {
- requested := normalizeReasoningEffortMappingSource(original)
- if requested == "" {
- requested = original
- }
- return body, false, &ReasoningEffortMappingDeniedError{Requested: requested}
- }
- if currentRank, recognized := reasoningEffortRank(effective); recognized {
- effective = NormalizeMaxReasoningEffort(effective)
- if hasMax && currentRank > maxRank {
- if deny {
- return body, false, &ReasoningEffortOverLimitError{Requested: effective, Max: canonicalMax}
- }
- effective = canonicalMax
- }
- }
- if effective == original {
- continue
- }
- updated, err := sjson.SetBytes(result, path, effective)
- if err != nil {
- continue
- }
- result = updated
- changed = true
- }
- return result, changed, nil
- }
- func applyOpenAIWSReasoningEffortPolicy(payload []byte, hooks *OpenAIWSIngressHooks) ([]byte, error) {
- if hooks == nil || (hooks.MaxReasoningEffort == "" && len(hooks.ReasoningEffortMappings) == 0) {
- return payload, nil
- }
- capped, changed, err := ApplyOpenAIReasoningEffortPolicy(payload, hooks.MaxReasoningEffort, hooks.ReasoningEffortMappings, hooks.MaxReasoningEffortOverLimit)
- if err != nil {
- return payload, err
- }
- if changed {
- return capped, nil
- }
- return payload, nil
- }
- // ApplyOpenAIReasoningEffortPolicy is retained for OpenAI forwarding callers.
- func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) {
- return ApplyReasoningEffortPolicy(body, maxEffort, mappings, overLimit)
- }
|