openai_reasoning_effort_policy.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "strings"
  7. "github.com/Wei-Shaw/sub2api/internal/domain"
  8. "github.com/tidwall/gjson"
  9. "github.com/tidwall/sjson"
  10. )
  11. const (
  12. maxReasoningEffortMappings = 64
  13. maxReasoningEffortValueLen = 64
  14. maxReasoningEffortModelLen = 200
  15. // ReasoningEffortOverLimitDowngrade rewrites values above the ceiling.
  16. ReasoningEffortOverLimitDowngrade = "downgrade"
  17. // ReasoningEffortOverLimitDeny rejects the request when the ceiling is exceeded.
  18. ReasoningEffortOverLimitDeny = "deny"
  19. // ReasoningEffortMappingDeny is a mapping target that rejects the request
  20. // when the matching source reasoning effort is present.
  21. ReasoningEffortMappingDeny = "deny"
  22. )
  23. var openAIReasoningEffortValues = []string{"minimal", "low", "medium", "high", "xhigh", "max"}
  24. var anthropicReasoningEffortValues = []string{"low", "medium", "high", "xhigh", "max"}
  25. func normalizeReasoningEffortMappingSource(raw string) string {
  26. if strings.EqualFold(strings.TrimSpace(raw), "none") {
  27. return "none"
  28. }
  29. return NormalizeMaxReasoningEffort(raw)
  30. }
  31. func normalizeReasoningEffortMappingTarget(raw string) string {
  32. if isReasoningEffortMappingDeny(raw) {
  33. return ReasoningEffortMappingDeny
  34. }
  35. return NormalizeMaxReasoningEffort(raw)
  36. }
  37. func isReasoningEffortMappingDeny(raw string) bool {
  38. return strings.EqualFold(strings.TrimSpace(raw), ReasoningEffortMappingDeny)
  39. }
  40. type openAIReasoningEffortPolicyContextKey struct{}
  41. type requestedReasoningEffortContextKey struct{}
  42. type openAIReasoningEffortPolicy struct {
  43. maxEffort string
  44. overLimit string
  45. mappings []ReasoningEffortMapping
  46. }
  47. // ReasoningEffortOverLimitError is returned when a group policy is set to deny
  48. // requests whose explicit reasoning effort exceeds the ceiling.
  49. type ReasoningEffortOverLimitError struct {
  50. Requested string
  51. Max string
  52. }
  53. func (e *ReasoningEffortOverLimitError) Error() string {
  54. if e == nil {
  55. return "reasoning effort exceeds this group's limit"
  56. }
  57. requested := strings.TrimSpace(e.Requested)
  58. max := strings.TrimSpace(e.Max)
  59. if requested == "" && max == "" {
  60. return "reasoning effort exceeds this group's limit"
  61. }
  62. if requested == "" {
  63. return fmt.Sprintf("reasoning effort exceeds this group's limit of %q", max)
  64. }
  65. if max == "" {
  66. return fmt.Sprintf("reasoning effort %q exceeds this group's limit", requested)
  67. }
  68. return fmt.Sprintf("reasoning effort %q exceeds this group's limit of %q", requested, max)
  69. }
  70. // ReasoningEffortMappingDeniedError is returned when a group mapping target is
  71. // set to deny the explicit reasoning effort on the request.
  72. type ReasoningEffortMappingDeniedError struct {
  73. Requested string
  74. }
  75. func (e *ReasoningEffortMappingDeniedError) Error() string {
  76. if e == nil {
  77. return "reasoning effort is denied by this group's mapping policy"
  78. }
  79. requested := strings.TrimSpace(e.Requested)
  80. if requested == "" {
  81. return "reasoning effort is denied by this group's mapping policy"
  82. }
  83. return fmt.Sprintf("reasoning effort %q is denied by this group's mapping policy", requested)
  84. }
  85. // IsReasoningEffortPolicyDenied reports whether err is a local reasoning-effort
  86. // policy rejection (ceiling deny or mapping deny).
  87. func IsReasoningEffortPolicyDenied(err error) bool {
  88. if err == nil {
  89. return false
  90. }
  91. var overLimit *ReasoningEffortOverLimitError
  92. if errors.As(err, &overLimit) {
  93. return true
  94. }
  95. var mappingDenied *ReasoningEffortMappingDeniedError
  96. return errors.As(err, &mappingDenied)
  97. }
  98. // NormalizeMaxReasoningEffort validates and canonicalizes a group policy value.
  99. // Empty means that the group does not impose a ceiling.
  100. func NormalizeMaxReasoningEffort(raw string) string {
  101. value := strings.ToLower(strings.TrimSpace(raw))
  102. value = strings.NewReplacer("-", "", "_", "", " ", "").Replace(value)
  103. switch value {
  104. case "":
  105. return ""
  106. case "minimal":
  107. return "minimal"
  108. case "low":
  109. return "low"
  110. case "medium":
  111. return "medium"
  112. case "high":
  113. return "high"
  114. case "xhigh", "extrahigh":
  115. return "xhigh"
  116. case "max":
  117. return "max"
  118. default:
  119. return ""
  120. }
  121. }
  122. func reasoningEffortValuesForPlatform(platform string) []string {
  123. switch platform {
  124. case PlatformOpenAI, PlatformComposite:
  125. return openAIReasoningEffortValues
  126. case PlatformAnthropic:
  127. return anthropicReasoningEffortValues
  128. default:
  129. return nil
  130. }
  131. }
  132. func normalizeMaxReasoningEffortForPlatform(platform, raw string) (string, error) {
  133. if strings.TrimSpace(raw) == "" {
  134. return "", nil
  135. }
  136. allowedValues := reasoningEffortValuesForPlatform(platform)
  137. if len(allowedValues) == 0 {
  138. return "", fmt.Errorf(
  139. "reasoning effort policy is only supported for platforms %q, %q, and %q",
  140. PlatformAnthropic,
  141. PlatformOpenAI,
  142. PlatformComposite,
  143. )
  144. }
  145. value := NormalizeMaxReasoningEffort(raw)
  146. for _, allowed := range allowedValues {
  147. if value == allowed {
  148. return value, nil
  149. }
  150. }
  151. return "", fmt.Errorf(
  152. "reasoning effort %q is not supported for platform %q; allowed values: %s",
  153. raw,
  154. platform,
  155. strings.Join(allowedValues, ", "),
  156. )
  157. }
  158. // NormalizeMaxReasoningEffortOverLimit canonicalizes the over-limit action.
  159. // Empty means the historical default: automatically downgrade to the ceiling.
  160. func NormalizeMaxReasoningEffortOverLimit(raw string) string {
  161. switch strings.ToLower(strings.TrimSpace(raw)) {
  162. case "", ReasoningEffortOverLimitDowngrade:
  163. return ReasoningEffortOverLimitDowngrade
  164. case ReasoningEffortOverLimitDeny:
  165. return ReasoningEffortOverLimitDeny
  166. default:
  167. return ""
  168. }
  169. }
  170. func normalizeMaxReasoningEffortOverLimitForPlatform(platform, raw string) (string, error) {
  171. value := NormalizeMaxReasoningEffortOverLimit(raw)
  172. if value == "" {
  173. return "", fmt.Errorf(
  174. "reasoning effort over-limit action %q is not supported; allowed values: %s, %s",
  175. raw,
  176. ReasoningEffortOverLimitDowngrade,
  177. ReasoningEffortOverLimitDeny,
  178. )
  179. }
  180. if value == ReasoningEffortOverLimitDowngrade {
  181. return value, nil
  182. }
  183. if len(reasoningEffortValuesForPlatform(platform)) == 0 {
  184. return "", fmt.Errorf(
  185. "reasoning effort policy is only supported for platforms %q, %q, and %q",
  186. PlatformAnthropic,
  187. PlatformOpenAI,
  188. PlatformComposite,
  189. )
  190. }
  191. return value, nil
  192. }
  193. func reasoningEffortRank(raw string) (int, bool) {
  194. switch NormalizeMaxReasoningEffort(raw) {
  195. case "minimal":
  196. return 1, true
  197. case "low":
  198. return 2, true
  199. case "medium":
  200. return 3, true
  201. case "high":
  202. return 4, true
  203. case "xhigh":
  204. return 5, true
  205. case "max":
  206. return 6, true
  207. default:
  208. return 0, false
  209. }
  210. }
  211. func normalizeReasoningEffortMatchType(matchType, model string) (string, error) {
  212. model = strings.TrimSpace(model)
  213. matchType = strings.ToLower(strings.TrimSpace(matchType))
  214. if model == "" {
  215. return "", nil
  216. }
  217. switch matchType {
  218. case "", domain.ReasoningEffortMatchExact:
  219. return domain.ReasoningEffortMatchExact, nil
  220. case domain.ReasoningEffortMatchPrefix:
  221. return domain.ReasoningEffortMatchPrefix, nil
  222. case domain.ReasoningEffortMatchSuffix:
  223. return domain.ReasoningEffortMatchSuffix, nil
  224. default:
  225. return "", fmt.Errorf("invalid match_type %q", matchType)
  226. }
  227. }
  228. func reasoningEffortMappingDuplicateKey(from, matchType, model string) string {
  229. return from + "\x00" + matchType + "\x00" + strings.ToLower(strings.TrimSpace(model))
  230. }
  231. func requestModelMatchesReasoningEffortMapping(requestModel, matchType, mappingModel string) bool {
  232. scope := strings.ToLower(strings.TrimSpace(mappingModel))
  233. req := strings.ToLower(strings.TrimSpace(requestModel))
  234. switch matchType {
  235. case "":
  236. return true
  237. case domain.ReasoningEffortMatchExact:
  238. return scope != "" && scope == req
  239. case domain.ReasoningEffortMatchPrefix:
  240. return scope != "" && req != "" && strings.HasPrefix(req, scope)
  241. case domain.ReasoningEffortMatchSuffix:
  242. return scope != "" && req != "" && strings.HasSuffix(req, scope)
  243. default:
  244. return false
  245. }
  246. }
  247. func selectReasoningEffortMapping(mappings []ReasoningEffortMapping, from, requestModel string) (ReasoningEffortMapping, bool) {
  248. type candidate struct {
  249. mapping ReasoningEffortMapping
  250. matchStrength int
  251. patternLen int
  252. index int
  253. }
  254. candidates := make([]candidate, 0, len(mappings))
  255. for i, mapping := range mappings {
  256. if normalizeReasoningEffortMappingSource(mapping.From) != from {
  257. continue
  258. }
  259. model := strings.TrimSpace(mapping.Model)
  260. matchType, err := normalizeReasoningEffortMatchType(mapping.MatchType, model)
  261. if err != nil {
  262. continue
  263. }
  264. if !requestModelMatchesReasoningEffortMapping(requestModel, matchType, model) {
  265. continue
  266. }
  267. strength := 1
  268. patternLen := 0
  269. switch matchType {
  270. case domain.ReasoningEffortMatchExact:
  271. strength = 3
  272. case domain.ReasoningEffortMatchPrefix, domain.ReasoningEffortMatchSuffix:
  273. strength = 2
  274. patternLen = len(strings.ToLower(model))
  275. }
  276. candidates = append(candidates, candidate{
  277. mapping: mapping,
  278. matchStrength: strength,
  279. patternLen: patternLen,
  280. index: i,
  281. })
  282. }
  283. if len(candidates) == 0 {
  284. return ReasoningEffortMapping{}, false
  285. }
  286. best := candidates[0]
  287. for _, item := range candidates[1:] {
  288. if item.matchStrength != best.matchStrength {
  289. if item.matchStrength > best.matchStrength {
  290. best = item
  291. }
  292. continue
  293. }
  294. if item.patternLen != best.patternLen {
  295. if item.patternLen > best.patternLen {
  296. best = item
  297. }
  298. continue
  299. }
  300. if item.index < best.index {
  301. best = item
  302. }
  303. }
  304. return best.mapping, true
  305. }
  306. // NormalizeReasoningEffortMappings validates group mapping rules against the
  307. // fixed effort values supported by the selected platform. Optional model scopes are
  308. // canonicalized to exact/prefix/suffix match; empty type and model keep the
  309. // global all-models rule.
  310. func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapping) ([]ReasoningEffortMapping, error) {
  311. if len(raw) > maxReasoningEffortMappings {
  312. return nil, fmt.Errorf("reasoning effort mappings cannot exceed %d entries", maxReasoningEffortMappings)
  313. }
  314. normalized := make([]ReasoningEffortMapping, 0, len(raw))
  315. seen := make(map[string]struct{}, len(raw))
  316. for i, mapping := range raw {
  317. from := normalizeReasoningEffortMappingSource(mapping.From)
  318. to := normalizeReasoningEffortMappingTarget(mapping.To)
  319. if from == "" || to == "" {
  320. return nil, fmt.Errorf("reasoning effort mapping %d contains an empty or unknown value", i+1)
  321. }
  322. if len(from) > maxReasoningEffortValueLen || len(to) > maxReasoningEffortValueLen {
  323. return nil, fmt.Errorf("reasoning effort mapping %d values cannot exceed %d characters", i+1, maxReasoningEffortValueLen)
  324. }
  325. if from != "none" {
  326. if _, err := normalizeMaxReasoningEffortForPlatform(platform, from); err != nil {
  327. return nil, fmt.Errorf("reasoning effort mapping %d source: %w", i+1, err)
  328. }
  329. } else if len(reasoningEffortValuesForPlatform(platform)) == 0 {
  330. return nil, fmt.Errorf(
  331. "reasoning effort mapping %d source: reasoning effort policy is only supported for platforms %q and %q",
  332. i+1, PlatformOpenAI, PlatformComposite,
  333. )
  334. }
  335. if to != ReasoningEffortMappingDeny {
  336. if _, err := normalizeMaxReasoningEffortForPlatform(platform, to); err != nil {
  337. return nil, fmt.Errorf("reasoning effort mapping %d target: %w", i+1, err)
  338. }
  339. } else if len(reasoningEffortValuesForPlatform(platform)) == 0 {
  340. return nil, fmt.Errorf(
  341. "reasoning effort mapping %d target: reasoning effort policy is only supported for platforms %q, %q, and %q",
  342. i+1,
  343. PlatformAnthropic,
  344. PlatformOpenAI,
  345. PlatformComposite,
  346. )
  347. }
  348. model := strings.TrimSpace(mapping.Model)
  349. if len(model) > maxReasoningEffortModelLen {
  350. return nil, fmt.Errorf("reasoning effort mapping %d model cannot exceed %d characters", i+1, maxReasoningEffortModelLen)
  351. }
  352. matchType, err := normalizeReasoningEffortMatchType(mapping.MatchType, model)
  353. if err != nil {
  354. return nil, fmt.Errorf("reasoning effort mapping %d: %w", i+1, err)
  355. }
  356. key := reasoningEffortMappingDuplicateKey(from, matchType, model)
  357. if _, exists := seen[key]; exists {
  358. if model == "" {
  359. return nil, fmt.Errorf("duplicate reasoning effort mapping source %q", from)
  360. }
  361. return nil, fmt.Errorf("duplicate reasoning effort mapping source %q for %s model %q", from, matchType, strings.ToLower(model))
  362. }
  363. seen[key] = struct{}{}
  364. normalized = append(normalized, ReasoningEffortMapping{
  365. From: from,
  366. To: to,
  367. MatchType: matchType,
  368. Model: model,
  369. })
  370. }
  371. return normalized, nil
  372. }
  373. // WithRequestedReasoningEffort stores the client-requested effort captured from
  374. // the inbound body before group policy or model-family remapping.
  375. func WithRequestedReasoningEffort(ctx context.Context, effort string) context.Context {
  376. if ctx == nil {
  377. ctx = context.Background()
  378. }
  379. effort = strings.TrimSpace(effort)
  380. if effort == "" {
  381. return ctx
  382. }
  383. return context.WithValue(ctx, requestedReasoningEffortContextKey{}, effort)
  384. }
  385. // RequestedReasoningEffortFromContext returns the inbound requested effort bound
  386. // to ctx, or nil when none was captured.
  387. func RequestedReasoningEffortFromContext(ctx context.Context) *string {
  388. if ctx == nil {
  389. return nil
  390. }
  391. value, ok := ctx.Value(requestedReasoningEffortContextKey{}).(string)
  392. if !ok {
  393. return nil
  394. }
  395. value = strings.TrimSpace(value)
  396. if value == "" {
  397. return nil
  398. }
  399. return &value
  400. }
  401. // WithOpenAIReasoningEffortPolicy binds a group policy to a request after its
  402. // concrete target platform has been resolved to OpenAI. The policy is copied so
  403. // retries and asynchronous forwarding cannot observe later slice mutations.
  404. func WithOpenAIReasoningEffortPolicy(ctx context.Context, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) context.Context {
  405. if ctx == nil {
  406. ctx = context.Background()
  407. }
  408. policy := openAIReasoningEffortPolicy{
  409. maxEffort: maxEffort,
  410. overLimit: overLimit,
  411. mappings: append([]ReasoningEffortMapping(nil), mappings...),
  412. }
  413. return context.WithValue(ctx, openAIReasoningEffortPolicyContextKey{}, policy)
  414. }
  415. // ApplyOpenAIReasoningEffortPolicyFromContext applies a policy previously bound
  416. // to the request. An unbound request is returned byte-for-byte unchanged.
  417. func ApplyOpenAIReasoningEffortPolicyFromContext(ctx context.Context, body []byte) ([]byte, bool, error) {
  418. if ctx == nil {
  419. return body, false, nil
  420. }
  421. policy, ok := ctx.Value(openAIReasoningEffortPolicyContextKey{}).(openAIReasoningEffortPolicy)
  422. if !ok {
  423. return body, false, nil
  424. }
  425. return ApplyOpenAIReasoningEffortPolicy(body, policy.maxEffort, policy.mappings, policy.overLimit)
  426. }
  427. func mapReasoningEffort(raw string, mappings []ReasoningEffortMapping, requestModel string) (string, bool) {
  428. value := strings.TrimSpace(raw)
  429. canonical := normalizeReasoningEffortMappingSource(value)
  430. if canonical == "" {
  431. return value, false
  432. }
  433. mapping, ok := selectReasoningEffortMapping(mappings, canonical, requestModel)
  434. if !ok {
  435. return value, false
  436. }
  437. return strings.TrimSpace(mapping.To), true
  438. }
  439. func sanitizeGroupReasoningEffortPolicy(group *Group) {
  440. if group == nil {
  441. return
  442. }
  443. maxEffort, maxErr := normalizeMaxReasoningEffortForPlatform(group.Platform, group.MaxReasoningEffort)
  444. mappings, mappingsErr := NormalizeReasoningEffortMappings(group.Platform, group.ReasoningEffortMappings)
  445. overLimit := NormalizeMaxReasoningEffortOverLimit(group.MaxReasoningEffortOverLimit)
  446. if maxErr != nil {
  447. maxEffort = ""
  448. }
  449. if mappingsErr != nil {
  450. mappings = []ReasoningEffortMapping{}
  451. }
  452. if overLimit == "" || (group.Platform != PlatformAnthropic && group.Platform != PlatformOpenAI && group.Platform != PlatformComposite) {
  453. overLimit = ReasoningEffortOverLimitDowngrade
  454. }
  455. group.MaxReasoningEffort = maxEffort
  456. group.MaxReasoningEffortOverLimit = overLimit
  457. group.ReasoningEffortMappings = mappings
  458. }
  459. // ApplyReasoningEffortPolicy applies one mapping (optionally scoped to
  460. // the request model by exact name, prefix, or suffix) and then either caps
  461. // known effort levels or rejects the request when the group is configured to
  462. // deny values above the ceiling. A mapping whose target is deny rejects the
  463. // request when that source value is present. Omitted values remain untouched
  464. // so upstream defaults stay in control. It understands both OpenAI and
  465. // Anthropic request field shapes.
  466. func ApplyReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) {
  467. maxRank, hasMax := reasoningEffortRank(maxEffort)
  468. if len(body) == 0 || (!hasMax && len(mappings) == 0) {
  469. return body, false, nil
  470. }
  471. deny := hasMax && NormalizeMaxReasoningEffortOverLimit(overLimit) == ReasoningEffortOverLimitDeny
  472. canonicalMax := NormalizeMaxReasoningEffort(maxEffort)
  473. requestModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
  474. result := body
  475. changed := false
  476. for _, path := range []string{"reasoning.effort", "reasoning_effort", "output_config.effort"} {
  477. field := gjson.GetBytes(result, path)
  478. if !field.Exists() || field.Type != gjson.String {
  479. continue
  480. }
  481. original := strings.TrimSpace(field.String())
  482. if original == "" {
  483. continue
  484. }
  485. effective, mapped := mapReasoningEffort(original, mappings, requestModel)
  486. if mapped && isReasoningEffortMappingDeny(effective) {
  487. requested := normalizeReasoningEffortMappingSource(original)
  488. if requested == "" {
  489. requested = original
  490. }
  491. return body, false, &ReasoningEffortMappingDeniedError{Requested: requested}
  492. }
  493. if currentRank, recognized := reasoningEffortRank(effective); recognized {
  494. effective = NormalizeMaxReasoningEffort(effective)
  495. if hasMax && currentRank > maxRank {
  496. if deny {
  497. return body, false, &ReasoningEffortOverLimitError{Requested: effective, Max: canonicalMax}
  498. }
  499. effective = canonicalMax
  500. }
  501. }
  502. if effective == original {
  503. continue
  504. }
  505. updated, err := sjson.SetBytes(result, path, effective)
  506. if err != nil {
  507. continue
  508. }
  509. result = updated
  510. changed = true
  511. }
  512. return result, changed, nil
  513. }
  514. func applyOpenAIWSReasoningEffortPolicy(payload []byte, hooks *OpenAIWSIngressHooks) ([]byte, error) {
  515. if hooks == nil || (hooks.MaxReasoningEffort == "" && len(hooks.ReasoningEffortMappings) == 0) {
  516. return payload, nil
  517. }
  518. capped, changed, err := ApplyOpenAIReasoningEffortPolicy(payload, hooks.MaxReasoningEffort, hooks.ReasoningEffortMappings, hooks.MaxReasoningEffortOverLimit)
  519. if err != nil {
  520. return payload, err
  521. }
  522. if changed {
  523. return capped, nil
  524. }
  525. return payload, nil
  526. }
  527. // ApplyOpenAIReasoningEffortPolicy is retained for OpenAI forwarding callers.
  528. func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) {
  529. return ApplyReasoningEffortPolicy(body, maxEffort, mappings, overLimit)
  530. }