openai_reasoning_effort_policy_test.go 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. package service
  2. import (
  3. "context"
  4. "testing"
  5. "github.com/Wei-Shaw/sub2api/internal/domain"
  6. "github.com/stretchr/testify/require"
  7. "github.com/tidwall/gjson"
  8. )
  9. func TestCanonicalRequestedReasoningEffort(t *testing.T) {
  10. t.Parallel()
  11. max := CanonicalRequestedReasoningEffort([]byte(`{"model":"gpt-5.4","reasoning":{"effort":"MAX"}}`), "gpt-5.4")
  12. require.NotNil(t, max)
  13. require.Equal(t, "max", *max)
  14. fromSuffix := CanonicalRequestedReasoningEffort([]byte(`{"model":"gpt-5.4-max"}`), "gpt-5.4-max")
  15. require.NotNil(t, fromSuffix)
  16. require.Equal(t, "max", *fromSuffix)
  17. claude := CanonicalRequestedReasoningEffort([]byte(`{"model":"claude-sonnet-4","output_config":{"effort":"high"}}`))
  18. require.NotNil(t, claude)
  19. require.Equal(t, "high", *claude)
  20. require.Nil(t, CanonicalRequestedReasoningEffort([]byte(`{"model":"gpt-5.4"}`), "gpt-5.4"))
  21. }
  22. func TestRequestedReasoningEffortContext(t *testing.T) {
  23. t.Parallel()
  24. require.Nil(t, RequestedReasoningEffortFromContext(context.Background()))
  25. ctx := WithRequestedReasoningEffort(context.Background(), " max ")
  26. got := RequestedReasoningEffortFromContext(ctx)
  27. require.NotNil(t, got)
  28. require.Equal(t, "max", *got)
  29. }
  30. func TestNormalizeMaxReasoningEffort(t *testing.T) {
  31. tests := []struct {
  32. name string
  33. in string
  34. want string
  35. }{
  36. {name: "empty", in: "", want: ""},
  37. {name: "separator", in: "x-high", want: "xhigh"},
  38. {name: "max is distinct", in: "max", want: "max"},
  39. {name: "none is unsupported", in: "none", want: ""},
  40. {name: "invalid", in: "banana", want: ""},
  41. }
  42. for _, tt := range tests {
  43. t.Run(tt.name, func(t *testing.T) {
  44. require.Equal(t, tt.want, NormalizeMaxReasoningEffort(tt.in))
  45. })
  46. }
  47. }
  48. func TestNormalizeReasoningEffortMappings(t *testing.T) {
  49. t.Run("canonicalizes fixed OpenAI values", func(t *testing.T) {
  50. for _, platform := range []string{PlatformOpenAI, PlatformComposite} {
  51. got, err := NormalizeReasoningEffortMappings(platform, []ReasoningEffortMapping{
  52. {From: " MAX ", To: " x-high "},
  53. {From: "minimal", To: "high"},
  54. })
  55. require.NoError(t, err)
  56. require.Equal(t, []ReasoningEffortMapping{
  57. {From: "max", To: "xhigh"},
  58. {From: "minimal", To: "high"},
  59. }, got)
  60. }
  61. })
  62. t.Run("rejects empty values", func(t *testing.T) {
  63. _, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "max"}})
  64. require.ErrorContains(t, err, "empty or unknown")
  65. })
  66. t.Run("rejects duplicate sources case insensitively", func(t *testing.T) {
  67. _, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{
  68. {From: "max", To: "xhigh"},
  69. {From: " MAX ", To: "high"},
  70. })
  71. require.ErrorContains(t, err, "duplicate")
  72. })
  73. t.Run("allows same source across different model scopes", func(t *testing.T) {
  74. got, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{
  75. {From: "max", To: "low", MatchType: "prefix", Model: " gpt "},
  76. {From: "max", To: "medium", MatchType: "exact", Model: "GPT-5.4"},
  77. {From: "max", To: "high"},
  78. })
  79. require.NoError(t, err)
  80. require.Equal(t, []ReasoningEffortMapping{
  81. {From: "max", To: "low", MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt"},
  82. {From: "max", To: "medium", MatchType: domain.ReasoningEffortMatchExact, Model: "GPT-5.4"},
  83. {From: "max", To: "high"},
  84. }, got)
  85. })
  86. t.Run("defaults missing match type to exact when model is set", func(t *testing.T) {
  87. got, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{
  88. {From: "max", To: "low", Model: "gpt-5.4"},
  89. })
  90. require.NoError(t, err)
  91. require.Equal(t, []ReasoningEffortMapping{
  92. {From: "max", To: "low", MatchType: domain.ReasoningEffortMatchExact, Model: "gpt-5.4"},
  93. }, got)
  94. })
  95. t.Run("empty type and model collapse to a global mapping", func(t *testing.T) {
  96. got, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{
  97. {From: "max", To: "low", MatchType: "prefix"},
  98. {From: "high", To: "low", MatchType: "suffix"},
  99. })
  100. require.NoError(t, err)
  101. require.Equal(t, []ReasoningEffortMapping{
  102. {From: "max", To: "low"},
  103. {From: "high", To: "low"},
  104. }, got)
  105. })
  106. t.Run("canonicalizes suffix match", func(t *testing.T) {
  107. got, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{
  108. {From: "max", To: "low", MatchType: " SUFFIX ", Model: " mini "},
  109. })
  110. require.NoError(t, err)
  111. require.Equal(t, []ReasoningEffortMapping{
  112. {From: "max", To: "low", MatchType: domain.ReasoningEffortMatchSuffix, Model: "mini"},
  113. }, got)
  114. })
  115. t.Run("rejects invalid match type", func(t *testing.T) {
  116. _, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{
  117. {From: "max", To: "low", MatchType: "wildcard", Model: "gpt"},
  118. })
  119. require.ErrorContains(t, err, "invalid match_type")
  120. })
  121. t.Run("rejects duplicate source within the same model scope", func(t *testing.T) {
  122. _, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{
  123. {From: "max", To: "low", MatchType: "prefix", Model: "gpt"},
  124. {From: "MAX", To: "high", MatchType: "PREFIX", Model: " GPT "},
  125. })
  126. require.ErrorContains(t, err, "duplicate")
  127. require.ErrorContains(t, err, "gpt")
  128. })
  129. t.Run("rejects mappings for non OpenAI platforms", func(t *testing.T) {
  130. for _, platform := range []string{PlatformGemini, PlatformAntigravity, PlatformGrok} {
  131. _, err := NormalizeReasoningEffortMappings(platform, []ReasoningEffortMapping{{From: "low", To: "high"}})
  132. require.ErrorContains(t, err, "only supported for platforms")
  133. }
  134. _, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "ultra", To: "high"}})
  135. require.ErrorContains(t, err, "empty or unknown")
  136. })
  137. t.Run("allows none only as a source for OpenAI routes", func(t *testing.T) {
  138. for _, platform := range []string{PlatformOpenAI, PlatformComposite} {
  139. got, err := NormalizeReasoningEffortMappings(platform, []ReasoningEffortMapping{{From: " NONE ", To: "low"}})
  140. require.NoError(t, err)
  141. require.Equal(t, []ReasoningEffortMapping{{From: "none", To: "low"}}, got)
  142. }
  143. _, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "low", To: "none"}})
  144. require.ErrorContains(t, err, "empty or unknown")
  145. })
  146. t.Run("allows deny as a mapping target", func(t *testing.T) {
  147. for _, platform := range []string{PlatformOpenAI, PlatformComposite, PlatformAnthropic} {
  148. got, err := NormalizeReasoningEffortMappings(platform, []ReasoningEffortMapping{{From: " XHIGH ", To: " DENY "}})
  149. require.NoError(t, err)
  150. require.Equal(t, []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny}}, got)
  151. }
  152. got, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{
  153. {From: "none", To: "deny"},
  154. {From: "max", To: "deny", MatchType: "prefix", Model: "gpt"},
  155. })
  156. require.NoError(t, err)
  157. require.Equal(t, []ReasoningEffortMapping{
  158. {From: "none", To: ReasoningEffortMappingDeny},
  159. {From: "max", To: ReasoningEffortMappingDeny, MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt"},
  160. }, got)
  161. _, err = NormalizeReasoningEffortMappings(PlatformGemini, []ReasoningEffortMapping{{From: "high", To: "deny"}})
  162. require.ErrorContains(t, err, "only supported for platforms")
  163. })
  164. t.Run("supports Anthropic values except minimal", func(t *testing.T) {
  165. got, err := NormalizeReasoningEffortMappings(PlatformAnthropic, []ReasoningEffortMapping{{From: " MAX ", To: " x-high "}})
  166. require.NoError(t, err)
  167. require.Equal(t, []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, got)
  168. _, err = NormalizeReasoningEffortMappings(PlatformAnthropic, []ReasoningEffortMapping{{From: "minimal", To: "low"}})
  169. require.ErrorContains(t, err, "not supported for platform \"anthropic\"")
  170. })
  171. }
  172. func TestNormalizeMaxReasoningEffortForPlatform(t *testing.T) {
  173. value, err := normalizeMaxReasoningEffortForPlatform(PlatformOpenAI, "max")
  174. require.NoError(t, err)
  175. require.Equal(t, "max", value)
  176. value, err = normalizeMaxReasoningEffortForPlatform(PlatformComposite, "max")
  177. require.NoError(t, err)
  178. require.Equal(t, "max", value)
  179. value, err = normalizeMaxReasoningEffortForPlatform(PlatformAnthropic, "xhigh")
  180. require.NoError(t, err)
  181. require.Equal(t, "xhigh", value)
  182. _, err = normalizeMaxReasoningEffortForPlatform(PlatformAnthropic, "minimal")
  183. require.ErrorContains(t, err, "not supported")
  184. for _, platform := range []string{PlatformGemini, PlatformAntigravity, PlatformGrok} {
  185. _, err = normalizeMaxReasoningEffortForPlatform(platform, "low")
  186. require.ErrorContains(t, err, "only supported for platforms")
  187. }
  188. _, err = normalizeMaxReasoningEffortForPlatform(PlatformOpenAI, "none")
  189. require.ErrorContains(t, err, "not supported")
  190. }
  191. func TestNormalizeMaxReasoningEffortOverLimit(t *testing.T) {
  192. require.Equal(t, ReasoningEffortOverLimitDowngrade, NormalizeMaxReasoningEffortOverLimit(""))
  193. require.Equal(t, ReasoningEffortOverLimitDowngrade, NormalizeMaxReasoningEffortOverLimit(" downgrade "))
  194. require.Equal(t, ReasoningEffortOverLimitDeny, NormalizeMaxReasoningEffortOverLimit("DENY"))
  195. require.Empty(t, NormalizeMaxReasoningEffortOverLimit("block"))
  196. }
  197. func TestNormalizeMaxReasoningEffortOverLimitForPlatform(t *testing.T) {
  198. value, err := normalizeMaxReasoningEffortOverLimitForPlatform(PlatformOpenAI, "")
  199. require.NoError(t, err)
  200. require.Equal(t, ReasoningEffortOverLimitDowngrade, value)
  201. value, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformComposite, "deny")
  202. require.NoError(t, err)
  203. require.Equal(t, ReasoningEffortOverLimitDeny, value)
  204. value, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformAnthropic, "")
  205. require.NoError(t, err)
  206. require.Equal(t, ReasoningEffortOverLimitDowngrade, value)
  207. value, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformAnthropic, "deny")
  208. require.NoError(t, err)
  209. require.Equal(t, ReasoningEffortOverLimitDeny, value)
  210. _, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformOpenAI, "block")
  211. require.ErrorContains(t, err, "not supported")
  212. }
  213. func TestOpenAIReasoningEffortPolicyContext(t *testing.T) {
  214. body := []byte(`{"reasoning":{"effort":"max"}}`)
  215. unbound, changed, err := ApplyOpenAIReasoningEffortPolicyFromContext(context.Background(), body)
  216. require.NoError(t, err)
  217. require.False(t, changed)
  218. require.Equal(t, body, unbound)
  219. mappings := []ReasoningEffortMapping{{From: "max", To: "xhigh"}}
  220. ctx := WithOpenAIReasoningEffortPolicy(context.Background(), "medium", mappings, "")
  221. mappings[0].To = "low"
  222. got, changed, err := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, body)
  223. require.NoError(t, err)
  224. require.True(t, changed)
  225. require.Equal(t, "medium", gjson.GetBytes(got, "reasoning.effort").String())
  226. denyCtx := WithOpenAIReasoningEffortPolicy(context.Background(), "medium", nil, ReasoningEffortOverLimitDeny)
  227. _, _, err = ApplyOpenAIReasoningEffortPolicyFromContext(denyCtx, body)
  228. require.Error(t, err)
  229. var overLimit *ReasoningEffortOverLimitError
  230. require.ErrorAs(t, err, &overLimit)
  231. require.Equal(t, "max", overLimit.Requested)
  232. require.Equal(t, "medium", overLimit.Max)
  233. }
  234. func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) {
  235. tests := []struct {
  236. name string
  237. body string
  238. max string
  239. overLimit string
  240. mappings []ReasoningEffortMapping
  241. path string
  242. want string
  243. changed bool
  244. deny bool
  245. mappingDeny bool
  246. }{
  247. {name: "nested caps high", body: `{"reasoning":{"effort":"xhigh"}}`, max: "medium", path: "reasoning.effort", want: "medium", changed: true},
  248. {name: "flat caps high", body: `{"reasoning_effort":"high"}`, max: "low", path: "reasoning_effort", want: "low", changed: true},
  249. {name: "Anthropic output config caps max", body: `{"output_config":{"effort":"max"}}`, max: "xhigh", path: "output_config.effort", want: "xhigh", changed: true},
  250. {name: "Anthropic output config maps before cap", body: `{"output_config":{"effort":"max"}}`, max: "high", mappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, path: "output_config.effort", want: "high", changed: true},
  251. {name: "does not raise omitted", body: `{"model":"gpt-5"}`, max: "low", path: "reasoning_effort", want: "", changed: false},
  252. {name: "keeps lower value", body: `{"reasoning_effort":"low"}`, max: "high", path: "reasoning_effort", want: "low", changed: false},
  253. {name: "normalizes request alias", body: `{"reasoning_effort":"x-high"}`, max: "xhigh", path: "reasoning_effort", want: "xhigh", changed: true},
  254. {name: "caps max below its distinct rank", body: `{"reasoning_effort":"max"}`, max: "xhigh", path: "reasoning_effort", want: "xhigh", changed: true},
  255. {name: "keeps xhigh below max", body: `{"reasoning_effort":"xhigh"}`, max: "max", path: "reasoning_effort", want: "xhigh", changed: false},
  256. {name: "ignores stale none ceiling", body: `{"reasoning_effort":"high"}`, max: "none", path: "reasoning_effort", want: "high", changed: false},
  257. {name: "caps both shapes", body: `{"reasoning":{"effort":"high"},"reasoning_effort":"xhigh"}`, max: "low", path: "reasoning.effort", want: "low", changed: true},
  258. {name: "maps before cap", body: `{"reasoning":{"effort":"MAX"}}`, max: "medium", mappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, path: "reasoning.effort", want: "medium", changed: true},
  259. {name: "does not chain mappings", body: `{"reasoning_effort":"max"}`, mappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}, {From: "xhigh", To: "low"}}, path: "reasoning_effort", want: "xhigh", changed: true},
  260. {name: "maps nested none", body: `{"reasoning":{"effort":"none"}}`, mappings: []ReasoningEffortMapping{{From: "none", To: "low"}}, path: "reasoning.effort", want: "low", changed: true},
  261. {name: "maps flat none", body: `{"reasoning_effort":"none"}`, mappings: []ReasoningEffortMapping{{From: "none", To: "low"}}, path: "reasoning_effort", want: "low", changed: true},
  262. {name: "none mapping runs before cap", body: `{"reasoning_effort":"none"}`, max: "low", mappings: []ReasoningEffortMapping{{From: "none", To: "high"}}, path: "reasoning_effort", want: "low", changed: true},
  263. {name: "keeps none byte for byte without mappings", body: `{ "reasoning_effort": " none " }`, max: "low", path: "reasoning_effort", want: " none ", changed: false},
  264. {name: "keeps none byte for byte when scope does not match", body: `{ "model":"o3", "reasoning_effort":"none" }`, mappings: []ReasoningEffortMapping{{From: "none", To: "low", MatchType: domain.ReasoningEffortMatchExact, Model: "gpt-5"}}, path: "reasoning_effort", want: "none", changed: false},
  265. {name: "exact scope maps none", body: `{"model":"gpt-5","reasoning_effort":"none"}`, mappings: []ReasoningEffortMapping{{From: "none", To: "low", MatchType: domain.ReasoningEffortMatchExact, Model: "gpt-5"}}, path: "reasoning_effort", want: "low", changed: true},
  266. {name: "prefix scope maps none", body: `{"model":"gpt-5-mini","reasoning_effort":"none"}`, mappings: []ReasoningEffortMapping{{From: "none", To: "low", MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt-5"}}, path: "reasoning_effort", want: "low", changed: true},
  267. {name: "suffix scope maps none", body: `{"model":"gpt-5-mini","reasoning_effort":"none"}`, mappings: []ReasoningEffortMapping{{From: "none", To: "low", MatchType: domain.ReasoningEffortMatchSuffix, Model: "mini"}}, path: "reasoning_effort", want: "low", changed: true},
  268. {name: "global scope maps none", body: `{"model":"o3","reasoning_effort":"none"}`, mappings: []ReasoningEffortMapping{{From: "none", To: "low"}}, path: "reasoning_effort", want: "low", changed: true},
  269. {name: "keeps unknown without mapping", body: `{"reasoning_effort":"future"}`, max: "low", path: "reasoning_effort", want: "future", changed: false},
  270. {name: "keeps non string value", body: `{"reasoning_effort":{"level":"high"}}`, max: "low", path: "reasoning_effort.level", want: "high", changed: false},
  271. {
  272. name: "prefix mapping applies to matching model",
  273. body: `{"model":"gpt-5.4","reasoning_effort":"max"}`,
  274. mappings: []ReasoningEffortMapping{{From: "max", To: "low", MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt"}},
  275. path: "reasoning_effort",
  276. want: "low",
  277. changed: true,
  278. },
  279. {
  280. name: "prefix mapping skips non matching model",
  281. body: `{"model":"o3","reasoning_effort":"max"}`,
  282. mappings: []ReasoningEffortMapping{{From: "max", To: "low", MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt"}},
  283. path: "reasoning_effort",
  284. want: "max",
  285. changed: false,
  286. },
  287. {
  288. name: "exact mapping beats prefix",
  289. body: `{"model":"gpt-5.4","reasoning":{"effort":"max"}}`,
  290. mappings: []ReasoningEffortMapping{
  291. {From: "max", To: "low", MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt"},
  292. {From: "max", To: "medium", MatchType: domain.ReasoningEffortMatchExact, Model: "gpt-5.4"},
  293. },
  294. path: "reasoning.effort",
  295. want: "medium",
  296. changed: true,
  297. },
  298. {
  299. name: "longer prefix beats shorter prefix",
  300. body: `{"model":"gpt-5.4","reasoning_effort":"max"}`,
  301. mappings: []ReasoningEffortMapping{
  302. {From: "max", To: "low", MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt"},
  303. {From: "max", To: "high", MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt-5"},
  304. },
  305. path: "reasoning_effort",
  306. want: "high",
  307. changed: true,
  308. },
  309. {
  310. name: "falls back to global mapping when no model scope hits",
  311. body: `{"model":"o3","reasoning_effort":"max"}`,
  312. mappings: []ReasoningEffortMapping{
  313. {From: "max", To: "low", MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt"},
  314. {From: "max", To: "medium"},
  315. },
  316. path: "reasoning_effort",
  317. want: "medium",
  318. changed: true,
  319. },
  320. {
  321. name: "model scoped mapping still respects ceiling",
  322. body: `{"model":"gpt-5.4","reasoning":{"effort":"max"}}`,
  323. max: "medium",
  324. mappings: []ReasoningEffortMapping{
  325. {From: "max", To: "xhigh", MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt"},
  326. },
  327. path: "reasoning.effort",
  328. want: "medium",
  329. changed: true,
  330. },
  331. {
  332. name: "model match is case insensitive",
  333. body: `{"model":"GPT-5.4","reasoning_effort":"MAX"}`,
  334. mappings: []ReasoningEffortMapping{{From: "max", To: "low", MatchType: domain.ReasoningEffortMatchExact, Model: "gpt-5.4"}},
  335. path: "reasoning_effort",
  336. want: "low",
  337. changed: true,
  338. },
  339. {
  340. name: "suffix mapping applies to matching model",
  341. body: `{"model":"gpt-5.4-mini","reasoning_effort":"max"}`,
  342. mappings: []ReasoningEffortMapping{{From: "max", To: "low", MatchType: domain.ReasoningEffortMatchSuffix, Model: "mini"}},
  343. path: "reasoning_effort",
  344. want: "low",
  345. changed: true,
  346. },
  347. {
  348. name: "suffix mapping skips non matching model",
  349. body: `{"model":"gpt-5.4","reasoning_effort":"max"}`,
  350. mappings: []ReasoningEffortMapping{{From: "max", To: "low", MatchType: domain.ReasoningEffortMatchSuffix, Model: "mini"}},
  351. path: "reasoning_effort",
  352. want: "max",
  353. changed: false,
  354. },
  355. {
  356. name: "exact mapping beats suffix",
  357. body: `{"model":"gpt-5.4-mini","reasoning":{"effort":"max"}}`,
  358. mappings: []ReasoningEffortMapping{
  359. {From: "max", To: "low", MatchType: domain.ReasoningEffortMatchSuffix, Model: "mini"},
  360. {From: "max", To: "medium", MatchType: domain.ReasoningEffortMatchExact, Model: "gpt-5.4-mini"},
  361. },
  362. path: "reasoning.effort",
  363. want: "medium",
  364. changed: true,
  365. },
  366. {
  367. name: "longer suffix beats shorter suffix",
  368. body: `{"model":"gpt-5.4-mini","reasoning_effort":"max"}`,
  369. mappings: []ReasoningEffortMapping{
  370. {From: "max", To: "low", MatchType: domain.ReasoningEffortMatchSuffix, Model: "i"},
  371. {From: "max", To: "high", MatchType: domain.ReasoningEffortMatchSuffix, Model: "mini"},
  372. },
  373. path: "reasoning_effort",
  374. want: "high",
  375. changed: true,
  376. },
  377. {
  378. name: "longer affix beats the other affix",
  379. body: `{"model":"gpt-5.4-mini","reasoning_effort":"max"}`,
  380. mappings: []ReasoningEffortMapping{
  381. {From: "max", To: "low", MatchType: domain.ReasoningEffortMatchSuffix, Model: "mini"},
  382. {From: "max", To: "high", MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt-5.4"},
  383. },
  384. path: "reasoning_effort",
  385. want: "high",
  386. changed: true,
  387. },
  388. {
  389. name: "empty type and model apply to every model",
  390. body: `{"model":"o3","reasoning_effort":"max"}`,
  391. mappings: []ReasoningEffortMapping{{From: "max", To: "low"}},
  392. path: "reasoning_effort",
  393. want: "low",
  394. changed: true,
  395. },
  396. {name: "deny rejects over ceiling", body: `{"reasoning_effort":"high"}`, max: "low", overLimit: ReasoningEffortOverLimitDeny, path: "reasoning_effort", want: "high", deny: true},
  397. {name: "deny keeps value at ceiling", body: `{"reasoning_effort":"low"}`, max: "low", overLimit: ReasoningEffortOverLimitDeny, path: "reasoning_effort", want: "low"},
  398. {name: "deny after mapping still over ceiling", body: `{"reasoning":{"effort":"max"}}`, max: "medium", overLimit: ReasoningEffortOverLimitDeny, mappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, path: "reasoning.effort", want: "max", deny: true},
  399. {name: "deny allows mapping under ceiling", body: `{"reasoning":{"effort":"max"}}`, max: "medium", overLimit: ReasoningEffortOverLimitDeny, mappings: []ReasoningEffortMapping{{From: "max", To: "low"}}, path: "reasoning.effort", want: "low", changed: true},
  400. {name: "deny ignored without ceiling", body: `{"reasoning_effort":"high"}`, overLimit: ReasoningEffortOverLimitDeny, path: "reasoning_effort", want: "high"},
  401. {name: "mapping deny rejects matching nested effort", body: `{"reasoning":{"effort":"xhigh"}}`, mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny}}, path: "reasoning.effort", want: "xhigh", mappingDeny: true},
  402. {name: "mapping deny rejects matching flat effort", body: `{"reasoning_effort":"X-HIGH"}`, mappings: []ReasoningEffortMapping{{From: "xhigh", To: " DENY "}}, path: "reasoning_effort", want: "X-HIGH", mappingDeny: true},
  403. {name: "mapping deny rejects Anthropic output config", body: `{"output_config":{"effort":"max"}}`, mappings: []ReasoningEffortMapping{{From: "max", To: ReasoningEffortMappingDeny}}, path: "output_config.effort", want: "max", mappingDeny: true},
  404. {name: "mapping deny rejects none source", body: `{"reasoning_effort":"none"}`, mappings: []ReasoningEffortMapping{{From: "none", To: ReasoningEffortMappingDeny}}, path: "reasoning_effort", want: "none", mappingDeny: true},
  405. {
  406. name: "mapping deny respects model scope",
  407. body: `{"model":"gpt-5.6-sol","reasoning":{"effort":"xhigh"}}`,
  408. mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny, MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt-5.6-sol"}},
  409. path: "reasoning.effort",
  410. want: "xhigh",
  411. mappingDeny: true,
  412. },
  413. {
  414. name: "mapping deny skips non matching model",
  415. body: `{"model":"gpt-5.6-terra","reasoning":{"effort":"xhigh"}}`,
  416. mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny, MatchType: domain.ReasoningEffortMatchExact, Model: "gpt-5.6-sol"}},
  417. path: "reasoning.effort",
  418. want: "xhigh",
  419. },
  420. {
  421. name: "mapping deny leaves other efforts unchanged",
  422. body: `{"reasoning_effort":"high"}`,
  423. mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny}},
  424. path: "reasoning_effort",
  425. want: "high",
  426. },
  427. {
  428. name: "mapping deny takes precedence over ceiling rewrite",
  429. body: `{"reasoning":{"effort":"xhigh"}}`,
  430. max: "medium",
  431. mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny}},
  432. path: "reasoning.effort",
  433. want: "xhigh",
  434. mappingDeny: true,
  435. },
  436. }
  437. for _, tt := range tests {
  438. t.Run(tt.name, func(t *testing.T) {
  439. got, changed, err := ApplyOpenAIReasoningEffortPolicy([]byte(tt.body), tt.max, tt.mappings, tt.overLimit)
  440. if tt.deny {
  441. require.Error(t, err)
  442. var overLimit *ReasoningEffortOverLimitError
  443. require.ErrorAs(t, err, &overLimit)
  444. require.True(t, IsReasoningEffortPolicyDenied(err))
  445. require.False(t, changed)
  446. require.Equal(t, tt.body, string(got))
  447. return
  448. }
  449. if tt.mappingDeny {
  450. require.Error(t, err)
  451. var mappingDenied *ReasoningEffortMappingDeniedError
  452. require.ErrorAs(t, err, &mappingDenied)
  453. require.True(t, IsReasoningEffortPolicyDenied(err))
  454. require.Contains(t, mappingDenied.Error(), "denied by this group's mapping policy")
  455. if requested := normalizeReasoningEffortMappingSource(tt.want); requested != "" {
  456. require.Equal(t, requested, mappingDenied.Requested)
  457. }
  458. require.False(t, changed)
  459. require.Equal(t, tt.body, string(got))
  460. return
  461. }
  462. require.NoError(t, err)
  463. require.Equal(t, tt.changed, changed)
  464. if !tt.changed {
  465. require.Equal(t, tt.body, string(got))
  466. }
  467. if tt.path != "" {
  468. require.Equal(t, tt.want, gjson.GetBytes(got, tt.path).String())
  469. }
  470. })
  471. }
  472. }
  473. func TestReasoningEffortMappingDeniedError(t *testing.T) {
  474. t.Parallel()
  475. err := &ReasoningEffortMappingDeniedError{Requested: "xhigh"}
  476. require.Equal(t, `reasoning effort "xhigh" is denied by this group's mapping policy`, err.Error())
  477. require.True(t, IsReasoningEffortPolicyDenied(err))
  478. require.True(t, IsReasoningEffortPolicyDenied(&ReasoningEffortOverLimitError{Requested: "max", Max: "low"}))
  479. require.False(t, IsReasoningEffortPolicyDenied(nil))
  480. require.False(t, IsReasoningEffortPolicyDenied(context.Canceled))
  481. }