openai_gateway_request_body_reasoning_test.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. package service
  2. import (
  3. "testing"
  4. "github.com/stretchr/testify/assert"
  5. "github.com/stretchr/testify/require"
  6. "github.com/tidwall/gjson"
  7. )
  8. func TestTrimOpenAIEncryptedReasoningItems_ContentNull(t *testing.T) {
  9. reqBody := map[string]any{
  10. "model": "grok-4.5",
  11. "input": []any{
  12. map[string]any{"type": "message", "role": "user", "content": "hi"},
  13. map[string]any{
  14. "type": "reasoning",
  15. "summary": []any{map[string]any{"type": "summary_text", "text": "thinking..."}},
  16. "content": nil,
  17. "encrypted_content": nil,
  18. },
  19. map[string]any{"type": "message", "role": "assistant", "content": "Hello!"},
  20. },
  21. }
  22. changed := trimOpenAIEncryptedReasoningItems(reqBody)
  23. require.True(t, changed)
  24. input, ok := reqBody["input"].([]any)
  25. require.True(t, ok)
  26. require.Len(t, input, 3)
  27. reasoning, ok := input[1].(map[string]any)
  28. require.True(t, ok)
  29. assert.Equal(t, "reasoning", reasoning["type"])
  30. assert.NotNil(t, reasoning["summary"])
  31. _, hasContent := reasoning["content"]
  32. assert.False(t, hasContent, "content: null should be stripped")
  33. _, hasEncrypted := reasoning["encrypted_content"]
  34. assert.False(t, hasEncrypted, "encrypted_content should be stripped")
  35. }
  36. func TestTrimOpenAIEncryptedReasoningItems_ContentNullOnly(t *testing.T) {
  37. reqBody := map[string]any{
  38. "model": "grok-4.5",
  39. "input": []any{
  40. map[string]any{
  41. "type": "reasoning",
  42. "summary": []any{map[string]any{"type": "summary_text", "text": "ok"}},
  43. "content": nil,
  44. },
  45. },
  46. }
  47. changed := trimOpenAIEncryptedReasoningItems(reqBody)
  48. require.True(t, changed)
  49. input, ok := reqBody["input"].([]any)
  50. require.True(t, ok)
  51. require.Len(t, input, 1)
  52. reasoning, ok := input[0].(map[string]any)
  53. require.True(t, ok)
  54. _, hasContent := reasoning["content"]
  55. assert.False(t, hasContent, "content: null should be stripped even without encrypted_content")
  56. }
  57. func TestTrimOpenAIEncryptedReasoningItems_ContentNonNull(t *testing.T) {
  58. reqBody := map[string]any{
  59. "model": "grok-4.5",
  60. "input": []any{
  61. map[string]any{
  62. "type": "reasoning",
  63. "summary": []any{map[string]any{"type": "summary_text", "text": "ok"}},
  64. "content": "some actual content",
  65. },
  66. },
  67. }
  68. changed := trimOpenAIEncryptedReasoningItems(reqBody)
  69. assert.False(t, changed, "non-null content should not be stripped")
  70. input, ok := reqBody["input"].([]any)
  71. require.True(t, ok)
  72. reasoning, ok := input[0].(map[string]any)
  73. require.True(t, ok)
  74. assert.Equal(t, "some actual content", reasoning["content"])
  75. }
  76. func TestTrimOpenAIEncryptedReasoningItems_NoReasoningItems(t *testing.T) {
  77. reqBody := map[string]any{
  78. "model": "grok-4.5",
  79. "input": []any{
  80. map[string]any{"type": "message", "role": "user", "content": "hi"},
  81. },
  82. }
  83. changed := trimOpenAIEncryptedReasoningItems(reqBody)
  84. assert.False(t, changed)
  85. }
  86. func TestTrimOpenAIEncryptedReasoningItems_Compaction(t *testing.T) {
  87. tests := []struct {
  88. name string
  89. itemType string
  90. encrypted bool
  91. changed bool
  92. }{
  93. {name: "compaction", itemType: "compaction", encrypted: true, changed: true},
  94. {name: "compaction summary", itemType: "compaction_summary", encrypted: true, changed: true},
  95. {name: "unencrypted compaction", itemType: "compaction", encrypted: false, changed: false},
  96. }
  97. for _, tt := range tests {
  98. t.Run(tt.name, func(t *testing.T) {
  99. item := map[string]any{"type": tt.itemType, "id": "cmp_stale"}
  100. if tt.encrypted {
  101. item["encrypted_content"] = "gAAA"
  102. }
  103. reqBody := map[string]any{"input": []any{
  104. item,
  105. map[string]any{"type": "message", "content": "hi"},
  106. }}
  107. changed := trimOpenAIEncryptedReasoningItems(reqBody)
  108. assert.Equal(t, tt.changed, changed)
  109. input, ok := reqBody["input"].([]any)
  110. require.True(t, ok)
  111. require.NotEmpty(t, input)
  112. first, ok := input[0].(map[string]any)
  113. require.True(t, ok)
  114. if tt.changed {
  115. require.Len(t, input, 1)
  116. assert.Equal(t, "message", first["type"])
  117. return
  118. }
  119. require.Len(t, input, 2)
  120. assert.Equal(t, tt.itemType, first["type"])
  121. })
  122. }
  123. }
  124. func TestSanitizeOpenAICrossModeFailoverReasoning_DropsWholeEncryptedItem(t *testing.T) {
  125. body := []byte(`{"model":"gpt-5.1","input":[` +
  126. `{"type":"message","role":"user","content":"hi"},` +
  127. `{"type":"reasoning","id":"rs_kiro_1","encrypted_content":"ENC","summary":[{"type":"summary_text","text":"t"}]},` +
  128. `{"type":"message","role":"assistant","content":"yo"}` +
  129. `]}`)
  130. sanitized, changed, err := SanitizeOpenAICrossModeFailoverReasoning(body)
  131. require.NoError(t, err)
  132. require.True(t, changed)
  133. // The whole reasoning item is gone — id and summary go with encrypted_content,
  134. // unlike trimOpenAIEncryptedReasoningItems which keeps the skeleton.
  135. require.NotContains(t, string(sanitized), "reasoning")
  136. require.NotContains(t, string(sanitized), "rs_kiro_1")
  137. require.NotContains(t, string(sanitized), "summary_text")
  138. require.Equal(t, int64(2), gjson.GetBytes(sanitized, "input.#").Int())
  139. }
  140. func TestSanitizeOpenAICrossModeFailoverReasoning_NoEncryptedIsNoop(t *testing.T) {
  141. body := []byte(`{"model":"gpt-5.1","input":[{"type":"reasoning","summary":[{"type":"summary_text","text":"t"}]}]}`)
  142. sanitized, changed, err := SanitizeOpenAICrossModeFailoverReasoning(body)
  143. require.NoError(t, err)
  144. require.False(t, changed, "reasoning without encrypted_content must be preserved")
  145. require.Equal(t, string(body), string(sanitized))
  146. }
  147. func TestSanitizeOpenAICrossModeFailoverReasoning_NoInputIsNoop(t *testing.T) {
  148. body := []byte(`{"model":"gpt-5.1"}`)
  149. sanitized, changed, err := SanitizeOpenAICrossModeFailoverReasoning(body)
  150. require.NoError(t, err)
  151. require.False(t, changed)
  152. require.Equal(t, string(body), string(sanitized))
  153. }
  154. func TestSanitizeOpenAICrossModeFailoverReasoning_PreservesLargeIntegers(t *testing.T) {
  155. body := []byte(`{"model":"gpt-5.1","input":[` +
  156. `{"type":"reasoning","id":"rs_kiro_1","encrypted_content":"ENC"},` +
  157. `{"type":"message","role":"user","content":"hi"}` +
  158. `],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object","properties":{"id":{"const":9007199254740993}}}}}]}`)
  159. sanitized, changed, err := SanitizeOpenAICrossModeFailoverReasoning(body)
  160. require.NoError(t, err)
  161. require.True(t, changed)
  162. require.Contains(t, string(sanitized), `"const":9007199254740993`,
  163. "sanitization must not round JSON integers through float64")
  164. }
  165. func TestTrimOpenAIEncryptedReasoningItems_ContentNullDropsBareSkeleton(t *testing.T) {
  166. reqBody := map[string]any{
  167. "input": []any{
  168. map[string]any{"type": "reasoning", "content": nil},
  169. },
  170. }
  171. changed := trimOpenAIEncryptedReasoningItems(reqBody)
  172. require.True(t, changed)
  173. _, hasInput := reqBody["input"]
  174. assert.False(t, hasInput, "bare reasoning skeleton should be dropped, emptying input")
  175. }
  176. func TestNormalizeOpenAIAPIKeyStoreFalseReasoningReplay(t *testing.T) {
  177. body := []byte(`{"model":"gpt-5.5","store":false,"input":[` +
  178. `{"type":"reasoning","id":"rs_encrypted","call_id":"remove","encrypted_content":"cipher","summary":null,"opaque":9007199254740993},` +
  179. `{"type":"reasoning","id":"rs_server_only","summary":[{"type":"summary_text","text":"drop"}]},` +
  180. `{"type":"item_reference","id":"rs_server_only"},` +
  181. `{"type":"item_reference","id":"msg_keep"},` +
  182. `{"type":"message","id":"msg_keep","role":"user","content":"continue"}` +
  183. `]}`)
  184. normalized, changed, err := normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body, false)
  185. require.NoError(t, err)
  186. require.True(t, changed)
  187. require.Equal(t, int64(3), gjson.GetBytes(normalized, "input.#").Int())
  188. require.Equal(t, "reasoning", gjson.GetBytes(normalized, "input.0.type").String())
  189. require.False(t, gjson.GetBytes(normalized, "input.0.id").Exists())
  190. require.False(t, gjson.GetBytes(normalized, "input.0.call_id").Exists())
  191. require.Equal(t, "cipher", gjson.GetBytes(normalized, "input.0.encrypted_content").String())
  192. require.True(t, gjson.GetBytes(normalized, "input.0.summary").IsArray())
  193. require.Equal(t, "9007199254740993", gjson.GetBytes(normalized, "input.0.opaque").Raw)
  194. require.Equal(t, "msg_keep", gjson.GetBytes(normalized, "input.1.id").String())
  195. require.Equal(t, "message", gjson.GetBytes(normalized, "input.2.type").String())
  196. }
  197. func TestNormalizeOpenAIAPIKeyStoreFalseReasoningReplayRequiresExplicitStoreFalse(t *testing.T) {
  198. for _, body := range []string{
  199. `{"input":[{"type":"reasoning","id":"rs_keep"}]}`,
  200. `{"store":true,"input":[{"type":"reasoning","id":"rs_keep"}]}`,
  201. } {
  202. normalized, changed, err := normalizeOpenAIAPIKeyStoreFalseReasoningReplay([]byte(body), false)
  203. require.NoError(t, err)
  204. require.False(t, changed)
  205. require.Equal(t, body, string(normalized))
  206. }
  207. }
  208. func TestNormalizeOpenAIAPIKeyStoreFalseReasoningReplayKnownCompactMode(t *testing.T) {
  209. body := []byte(`{"input":[{"type":"reasoning","id":"rs_drop","summary":[]},{"type":"message","content":"continue"}]}`)
  210. normalized, changed, err := normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body, true)
  211. require.NoError(t, err)
  212. require.True(t, changed)
  213. require.Equal(t, int64(1), gjson.GetBytes(normalized, "input.#").Int())
  214. require.Equal(t, "message", gjson.GetBytes(normalized, "input.0.type").String())
  215. }
  216. func TestNormalizeOpenAIAPIKeyStoreFalseReasoningReplayRejectsEmptyEncryptedContent(t *testing.T) {
  217. for _, encrypted := range []string{"null", `""`, `" "`, "123"} {
  218. body := []byte(`{"store":false,"input":[{"type":"reasoning","id":"rs_drop","encrypted_content":` + encrypted + `},{"type":"message","content":"continue"}]}`)
  219. normalized, changed, err := normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body, false)
  220. require.NoError(t, err)
  221. require.True(t, changed)
  222. require.Equal(t, int64(1), gjson.GetBytes(normalized, "input.#").Int())
  223. require.Equal(t, "message", gjson.GetBytes(normalized, "input.0.type").String())
  224. }
  225. }
  226. func TestNormalizeOpenAIParallelToolCallsWithoutTools(t *testing.T) {
  227. withTools := []byte(`{"tools":[{"type":"function","name":"lookup"}],"parallel_tool_calls":false}`)
  228. normalized, changed, err := normalizeOpenAIParallelToolCallsWithoutTools(withTools, false)
  229. require.NoError(t, err)
  230. require.False(t, changed)
  231. require.Equal(t, string(withTools), string(normalized))
  232. withoutTools := []byte(`{"input":"hi","parallel_tool_calls":true}`)
  233. normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(withoutTools, false)
  234. require.NoError(t, err)
  235. require.True(t, changed)
  236. require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists())
  237. }
  238. func TestFilterOpenAIResponsesNoneReasoningEffortForAccount(t *testing.T) {
  239. tests := []struct {
  240. name string
  241. account *Account
  242. body string
  243. wantNested bool
  244. wantFlat bool
  245. wantSummary bool
  246. wantReasoning bool
  247. }{
  248. {
  249. name: "custom compatible endpoint strips none placeholders",
  250. account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://compat.example/v1"}},
  251. body: `{"reasoning":{"effort":"none"},"reasoning_effort":"NONE"}`,
  252. wantReasoning: false,
  253. },
  254. {
  255. name: "third-party platform keeps other reasoning members",
  256. account: &Account{Platform: PlatformGrok, Type: AccountTypeAPIKey},
  257. body: `{"reasoning":{"effort":" none ","summary":"auto"}}`,
  258. wantSummary: true,
  259. wantReasoning: true,
  260. },
  261. {
  262. name: "non-none effort is unchanged",
  263. account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://compat.example/v1"}},
  264. body: `{"reasoning":{"effort":"high"},"reasoning_effort":"low"}`,
  265. wantNested: true,
  266. wantFlat: true,
  267. wantReasoning: true,
  268. },
  269. {
  270. name: "official OpenAI API key preserves none",
  271. account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
  272. body: `{"reasoning":{"effort":"none"},"reasoning_effort":"none"}`,
  273. wantNested: true,
  274. wantFlat: true,
  275. wantReasoning: true,
  276. },
  277. {
  278. name: "OpenAI OAuth preserves none",
  279. account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth},
  280. body: `{"reasoning":{"effort":"none"}}`,
  281. wantNested: true,
  282. wantReasoning: true,
  283. },
  284. }
  285. for _, tt := range tests {
  286. t.Run(tt.name, func(t *testing.T) {
  287. got, err := filterOpenAIResponsesNoneReasoningEffortForAccount(tt.account, []byte(tt.body))
  288. require.NoError(t, err)
  289. require.Equal(t, tt.wantNested, gjson.GetBytes(got, "reasoning.effort").Exists())
  290. require.Equal(t, tt.wantFlat, gjson.GetBytes(got, "reasoning_effort").Exists())
  291. require.Equal(t, tt.wantSummary, gjson.GetBytes(got, "reasoning.summary").Exists())
  292. require.Equal(t, tt.wantReasoning, gjson.GetBytes(got, "reasoning").Exists())
  293. })
  294. }
  295. }
  296. func TestFilterOpenAIResponsesNoneReasoningEffortForAccount_APIKeyAutomaticPassthroughPreservesRequest(t *testing.T) {
  297. body := []byte(`{"model":"qwen3.8-27b","input":"hi","max_output_tokens":20,"reasoning":{"effort":"none"},"presence_penalty":1.5}`)
  298. account := &Account{
  299. Platform: PlatformOpenAI,
  300. Type: AccountTypeAPIKey,
  301. Credentials: map[string]any{
  302. "base_url": "https://compat.example/v1",
  303. },
  304. Extra: map[string]any{"openai_passthrough": true},
  305. }
  306. got, err := filterOpenAIResponsesNoneReasoningEffortForAccount(account, body)
  307. require.NoError(t, err)
  308. require.JSONEq(t, string(body), string(got))
  309. }
  310. // Lite 工具迁移到 input[].additional_tools 后,仍应按有工具请求处理。
  311. func TestNormalizeOpenAIParallelToolCallsWithoutTools_KeepsResponsesLiteAdditionalTools(t *testing.T) {
  312. liteBody := []byte(`{"input":[{"type":"message","role":"user","content":"hi"},{"type":"additional_tools","tools":[{"type":"function","name":"spawn_agent"}]}],"parallel_tool_calls":false}`)
  313. normalized, changed, err := normalizeOpenAIParallelToolCallsWithoutTools(liteBody, false)
  314. require.NoError(t, err)
  315. require.False(t, changed)
  316. require.Equal(t, gjson.False, gjson.GetBytes(normalized, "parallel_tool_calls").Type)
  317. // 非 Lite 请求的空 additional_tools 不构成有效工具声明,字段仍需删除。
  318. emptyLiteBody := []byte(`{"input":[{"type":"additional_tools","tools":[]}],"parallel_tool_calls":true}`)
  319. normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(emptyLiteBody, false)
  320. require.NoError(t, err)
  321. require.True(t, changed)
  322. require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists())
  323. // Lite 请求即使没有工具,也必须保留已经固定的 false。
  324. toolLessLiteBody := []byte(`{"input":"hi","parallel_tool_calls":false}`)
  325. normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(toolLessLiteBody, true)
  326. require.NoError(t, err)
  327. require.False(t, changed)
  328. require.Equal(t, gjson.False, gjson.GetBytes(normalized, "parallel_tool_calls").Type)
  329. }
  330. func TestNormalizeOpenAIResponsesReasoningContentReplayStripsCrossProviderArray(t *testing.T) {
  331. body := []byte(`{"model":"gpt-5.6-sol","input":[` +
  332. `{"type":"message","role":"user","content":"one"},` +
  333. `{"type":"message","role":"assistant","content":"two"},` +
  334. `{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},` +
  335. `{"type":"function_call_output","call_id":"call_1","output":"ok"},` +
  336. `{"type":"message","role":"user","content":"five"},` +
  337. `{"type":"reasoning","id":"rs_provider","summary":[{"type":"summary_text","text":"portable"}],"content":[{"type":"reasoning_text","text":"visible reasoning"}],"opaque":9007199254740993},` +
  338. `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}` +
  339. `]}`)
  340. normalized, changed, err := normalizeOpenAIResponsesReasoningContentReplay(body)
  341. require.NoError(t, err)
  342. require.True(t, changed)
  343. require.Equal(t, "reasoning", gjson.GetBytes(normalized, "input.5.type").String())
  344. require.False(t, gjson.GetBytes(normalized, "input.5.content").Exists())
  345. require.Equal(t, "portable", gjson.GetBytes(normalized, "input.5.summary.0.text").String())
  346. require.Equal(t, "9007199254740993", gjson.GetBytes(normalized, "input.5.opaque").Raw)
  347. require.Equal(t, "answer", gjson.GetBytes(normalized, "input.6.content.0.text").String())
  348. }
  349. func TestNormalizeOpenAIResponsesReasoningContentReplayKeepsPortableShapes(t *testing.T) {
  350. for _, body := range []string{
  351. `{"input":[{"type":"reasoning","summary":[]}]}`,
  352. `{"input":[{"type":"reasoning","content":[],"summary":[]}]}`,
  353. `{"input":[{"type":"message","content":[{"type":"input_text","text":"keep"}]}]}`,
  354. } {
  355. normalized, changed, err := normalizeOpenAIResponsesReasoningContentReplay([]byte(body))
  356. require.NoError(t, err)
  357. require.False(t, changed)
  358. require.JSONEq(t, body, string(normalized))
  359. }
  360. }
  361. func TestNormalizeOpenAIResponsesWebSocketCompatibilityBodyStripsReasoningContentOnlyForOpenAI(t *testing.T) {
  362. body := []byte(`{"type":"response.create","model":"gpt-5.6-sol","store":true,"input":[{"type":"reasoning","summary":[{"type":"summary_text","text":"keep"}],"content":[{"type":"reasoning_text","text":"remove"}]}]}`)
  363. for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} {
  364. normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
  365. Platform: PlatformOpenAI,
  366. Type: accountType,
  367. }, false)
  368. require.NoError(t, err)
  369. require.True(t, changed)
  370. require.False(t, gjson.GetBytes(normalized, "input.0.content").Exists())
  371. require.Equal(t, "keep", gjson.GetBytes(normalized, "input.0.summary.0.text").String())
  372. }
  373. normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
  374. Platform: PlatformZhipu,
  375. Type: AccountTypeAPIKey,
  376. }, false)
  377. require.NoError(t, err)
  378. require.False(t, changed)
  379. require.JSONEq(t, string(body), string(normalized))
  380. }