openai_gateway_responses_chat_fallback_test.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. //go:build unit
  2. package service
  3. import (
  4. "bytes"
  5. "context"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "sync"
  11. "testing"
  12. "time"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
  14. "github.com/gin-gonic/gin"
  15. "github.com/stretchr/testify/require"
  16. "github.com/tidwall/gjson"
  17. )
  18. func TestForwardResponses_ForceChatCompletionsRoutesNonStreamingToChatCompletions(t *testing.T) {
  19. gin.SetMode(gin.TestMode)
  20. body := []byte(`{"model":"gpt-5.4","input":"hello","stream":false,"service_tier":"priority"}`)
  21. rec := httptest.NewRecorder()
  22. c, _ := gin.CreateTestContext(rec)
  23. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  24. c.Request.Header.Set("Content-Type", "application/json")
  25. upstream := &httpUpstreamRecorder{resp: &http.Response{
  26. StatusCode: http.StatusOK,
  27. Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_resp_chat_json"}},
  28. Body: io.NopCloser(strings.NewReader(
  29. `{"id":"chatcmpl_json","object":"chat.completion","model":"gpt-5.4","service_tier":"default","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5,"prompt_tokens_details":{"cached_tokens":1}}}`,
  30. )),
  31. }}
  32. svc := &OpenAIGatewayService{
  33. cfg: rawChatCompletionsTestConfig(),
  34. httpUpstream: upstream,
  35. }
  36. SetActualOpenAIUpstreamEndpoint(c, "/v1/responses")
  37. result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
  38. require.NoError(t, err)
  39. require.NotNil(t, result)
  40. require.Equal(t, "http://upstream.example/v1/chat/completions", upstream.lastReq.URL.String())
  41. require.Equal(t, "/v1/chat/completions", GetActualOpenAIUpstreamEndpoint(c))
  42. require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.lastReq.Context()))
  43. require.Equal(t, "hello", gjson.GetBytes(upstream.lastBody, "messages.0.content").String())
  44. require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
  45. require.Equal(t, "response", gjson.Get(rec.Body.String(), "object").String())
  46. require.Equal(t, "ok", gjson.Get(rec.Body.String(), "output.0.content.0.text").String())
  47. require.Equal(t, 3, result.Usage.InputTokens)
  48. require.Equal(t, 2, result.Usage.OutputTokens)
  49. require.Equal(t, 1, result.Usage.CacheReadInputTokens)
  50. require.NotNil(t, result.ServiceTier)
  51. require.Equal(t, "priority", *result.ServiceTier)
  52. require.Equal(t, "default", result.UpstreamResponseServiceTier)
  53. require.False(t, result.Stream)
  54. }
  55. // Scenario: 第三方无推理模型不收到兼容档位。
  56. func TestForwardResponses_ForceChatCompletionsOmitsNoneReasoningEffort(t *testing.T) {
  57. gin.SetMode(gin.TestMode)
  58. body := []byte(`{"model":"company-coding-model","input":"hello","reasoning":{"effort":"none"},"stream":false}`)
  59. rec := httptest.NewRecorder()
  60. c, _ := gin.CreateTestContext(rec)
  61. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  62. c.Request.Header.Set("Content-Type", "application/json")
  63. upstream := &httpUpstreamRecorder{resp: &http.Response{
  64. StatusCode: http.StatusOK,
  65. Header: http.Header{"Content-Type": []string{"application/json"}},
  66. Body: io.NopCloser(strings.NewReader(
  67. `{"id":"chatcmpl_none","object":"chat.completion","model":"company-coding-model","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`,
  68. )),
  69. }}
  70. svc := &OpenAIGatewayService{
  71. cfg: rawChatCompletionsTestConfig(),
  72. httpUpstream: upstream,
  73. }
  74. result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
  75. require.NoError(t, err)
  76. require.NotNil(t, result)
  77. require.Equal(t, "company-coding-model", gjson.GetBytes(upstream.lastBody, "model").String())
  78. require.False(t, gjson.GetBytes(upstream.lastBody, "reasoning_effort").Exists())
  79. require.Nil(t, result.ReasoningEffort)
  80. }
  81. func TestForwardResponses_PassthroughFlagWithUnsupportedResponsesUsesAccountMapping(t *testing.T) {
  82. gin.SetMode(gin.TestMode)
  83. for _, path := range []string{"/v1/responses", "/v1/responses/compact"} {
  84. path := path
  85. t.Run(path, func(t *testing.T) {
  86. body := []byte(`{"model":"gpt-5.4-channel","input":"hello","stream":false}`)
  87. rec := httptest.NewRecorder()
  88. c, _ := gin.CreateTestContext(rec)
  89. c.Request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
  90. c.Request.Header.Set("Content-Type", "application/json")
  91. upstream := &httpUpstreamRecorder{resp: &http.Response{
  92. StatusCode: http.StatusOK,
  93. Header: http.Header{"Content-Type": []string{"application/json"}},
  94. Body: io.NopCloser(strings.NewReader(
  95. `{"id":"chatcmpl_mapping","object":"chat.completion","model":"gpt-5.4-account","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`,
  96. )),
  97. }}
  98. svc := &OpenAIGatewayService{
  99. cfg: rawChatCompletionsTestConfig(),
  100. httpUpstream: upstream,
  101. }
  102. account := rawChatCompletionsTestAccount()
  103. account.Credentials["model_mapping"] = map[string]any{
  104. "gpt-5.4-channel": "gpt-5.4-account",
  105. }
  106. account.Credentials["compact_model_mapping"] = map[string]any{
  107. "gpt-5.4-account": "gpt-5.4-compact",
  108. }
  109. account.Extra = map[string]any{
  110. "openai_passthrough": true,
  111. openai_compat.ExtraKeyResponsesSupported: false,
  112. }
  113. result, err := svc.Forward(context.Background(), c, account, body)
  114. require.NoError(t, err)
  115. require.NotNil(t, result)
  116. require.Equal(t, "http://upstream.example/v1/chat/completions", upstream.lastReq.URL.String())
  117. require.Equal(t, "gpt-5.4-account", gjson.GetBytes(upstream.lastBody, "model").String())
  118. })
  119. }
  120. }
  121. func TestForwardResponses_ForceChatCompletionsRoutesStreamingToChatCompletions(t *testing.T) {
  122. gin.SetMode(gin.TestMode)
  123. body := []byte(`{"model":"gpt-5.4","input":"hello","stream":true}`)
  124. rec := httptest.NewRecorder()
  125. c, _ := gin.CreateTestContext(rec)
  126. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  127. c.Request.Header.Set("Content-Type", "application/json")
  128. upstreamBody := strings.Join([]string{
  129. `data: {"id":"chatcmpl_stream","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}`,
  130. "",
  131. `data: {"id":"chatcmpl_stream","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"content":"he"},"finish_reason":null}]}`,
  132. "",
  133. `data: {"id":"chatcmpl_stream","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"content":"llo"},"finish_reason":null}]}`,
  134. "",
  135. `data: {"id":"chatcmpl_stream","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
  136. "",
  137. `data: {"id":"chatcmpl_stream","object":"chat.completion.chunk","model":"gpt-5.4","choices":[],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}}`,
  138. "",
  139. "data: [DONE]",
  140. "",
  141. }, "\n")
  142. upstream := &httpUpstreamRecorder{resp: &http.Response{
  143. StatusCode: http.StatusOK,
  144. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_resp_chat_stream"}},
  145. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  146. }}
  147. svc := &OpenAIGatewayService{
  148. cfg: rawChatCompletionsTestConfig(),
  149. httpUpstream: upstream,
  150. }
  151. result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
  152. require.NoError(t, err)
  153. require.NotNil(t, result)
  154. require.Equal(t, "http://upstream.example/v1/chat/completions", upstream.lastReq.URL.String())
  155. require.True(t, gjson.GetBytes(upstream.lastBody, "stream_options.include_usage").Bool())
  156. require.Contains(t, rec.Body.String(), "event: response.output_text.delta")
  157. require.Contains(t, rec.Body.String(), `"delta":"he"`)
  158. require.Contains(t, rec.Body.String(), "event: response.completed")
  159. require.Contains(t, rec.Body.String(), `"input_tokens":4`)
  160. require.Contains(t, rec.Body.String(), "data: [DONE]")
  161. require.Equal(t, 4, result.Usage.InputTokens)
  162. require.Equal(t, 3, result.Usage.OutputTokens)
  163. require.True(t, result.Stream)
  164. require.NotNil(t, result.FirstTokenMs)
  165. }
  166. func TestForwardResponses_ChatFallbackRejectsInvalidToolArgumentsAtOutputLimit(t *testing.T) {
  167. gin.SetMode(gin.TestMode)
  168. body := []byte(`{"model":"deepseek-v4-flash","input":"run the command","stream":true}`)
  169. rec := httptest.NewRecorder()
  170. c, _ := gin.CreateTestContext(rec)
  171. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  172. c.Request.Header.Set("Content-Type", "application/json")
  173. upstreamBody := strings.Join([]string{
  174. `data: {"id":"chatcmpl_length_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_length","type":"function","function":{"name":"exec_command","arguments":"{\"cmd\":\"ssh root@HOST"}}]},"finish_reason":null}]}`,
  175. "",
  176. `data: {"id":"chatcmpl_length_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{},"finish_reason":"length"}],"usage":{"prompt_tokens":4,"completion_tokens":6492,"total_tokens":6496}}`,
  177. "",
  178. "data: [DONE]",
  179. "",
  180. }, "\n")
  181. upstream := &httpUpstreamRecorder{resp: &http.Response{
  182. StatusCode: http.StatusOK,
  183. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_length_tool"}},
  184. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  185. }}
  186. svc := &OpenAIGatewayService{
  187. cfg: rawChatCompletionsTestConfig(),
  188. httpUpstream: upstream,
  189. }
  190. result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
  191. require.ErrorContains(t, err, "invalid JSON")
  192. require.NotNil(t, result)
  193. require.Equal(t, 4, result.Usage.InputTokens)
  194. require.Equal(t, 6492, result.Usage.OutputTokens)
  195. require.NotContains(t, rec.Body.String(), "response.function_call_arguments.done")
  196. require.NotContains(t, rec.Body.String(), "response.output_item.done")
  197. require.NotContains(t, rec.Body.String(), "data: [DONE]")
  198. }
  199. func TestForwardResponses_DeepSeekReasoningOnlyStreamProducesVisibleText(t *testing.T) {
  200. gin.SetMode(gin.TestMode)
  201. body := []byte(`{"model":"deepseek-reasoner","input":"hello","stream":true}`)
  202. rec := httptest.NewRecorder()
  203. c, _ := gin.CreateTestContext(rec)
  204. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  205. c.Request.Header.Set("Content-Type", "application/json")
  206. upstreamBody := strings.Join([]string{
  207. `data: {"id":"chatcmpl_reasoning","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":""},"finish_reason":null}]}`,
  208. "",
  209. `data: {"id":"chatcmpl_reasoning","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"reasoning_content":"visible fallback"},"finish_reason":null}]}`,
  210. "",
  211. `data: {"id":"chatcmpl_reasoning","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"content":""},"finish_reason":"length"}],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}}`,
  212. "",
  213. "data: [DONE]",
  214. "",
  215. }, "\n")
  216. upstream := &httpUpstreamRecorder{resp: &http.Response{
  217. StatusCode: http.StatusOK,
  218. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_deepseek_reasoning_responses_stream"}},
  219. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  220. }}
  221. svc := &OpenAIGatewayService{
  222. cfg: rawChatCompletionsTestConfig(),
  223. httpUpstream: upstream,
  224. }
  225. result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
  226. require.NoError(t, err)
  227. require.NotNil(t, result)
  228. require.True(t, result.Stream)
  229. require.Contains(t, rec.Body.String(), "event: response.output_text.delta")
  230. require.Contains(t, rec.Body.String(), `"delta":"visible fallback"`)
  231. require.Contains(t, rec.Body.String(), `"status":"incomplete"`)
  232. require.Contains(t, rec.Body.String(), "data: [DONE]")
  233. }
  234. func TestForwardResponses_AutoSupportedAccountStillUsesResponsesEndpoint(t *testing.T) {
  235. gin.SetMode(gin.TestMode)
  236. body := []byte(`{"model":"gpt-5.4","input":"hello","stream":false}`)
  237. rec := httptest.NewRecorder()
  238. c, _ := gin.CreateTestContext(rec)
  239. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  240. c.Request.Header.Set("Content-Type", "application/json")
  241. upstream := &httpUpstreamRecorder{resp: &http.Response{
  242. StatusCode: http.StatusOK,
  243. Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_resp_native"}},
  244. Body: io.NopCloser(strings.NewReader(
  245. `{"id":"resp_native","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}],"status":"completed"}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}`,
  246. )),
  247. }}
  248. svc := &OpenAIGatewayService{
  249. cfg: rawChatCompletionsTestConfig(),
  250. httpUpstream: upstream,
  251. }
  252. account := rawChatCompletionsTestAccount()
  253. account.Extra = map[string]any{
  254. openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeAuto),
  255. openai_compat.ExtraKeyResponsesSupported: true,
  256. }
  257. result, err := svc.Forward(context.Background(), c, account, body)
  258. require.NoError(t, err)
  259. require.NotNil(t, result)
  260. require.Equal(t, "http://upstream.example/v1/responses", upstream.lastReq.URL.String())
  261. require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
  262. require.False(t, gjson.GetBytes(upstream.lastBody, "messages").Exists())
  263. require.Equal(t, "ok", gjson.Get(rec.Body.String(), "output.0.content.0.text").String())
  264. }
  265. func forceChatResponsesFallbackAccount() *Account {
  266. account := rawChatCompletionsTestAccount()
  267. account.Extra = map[string]any{
  268. openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions),
  269. }
  270. return account
  271. }
  272. // reasoningRecordingCache 记录 reasoning 缓存写入、并按需响应回查。
  273. type reasoningRecordingCache struct {
  274. stubGatewayCache
  275. mu sync.Mutex
  276. sets map[string]string
  277. getResp map[string]string
  278. }
  279. func (c *reasoningRecordingCache) SetReasoningContent(_ context.Context, itemID string, content string, _ time.Duration) error {
  280. c.mu.Lock()
  281. defer c.mu.Unlock()
  282. if c.sets == nil {
  283. c.sets = make(map[string]string)
  284. }
  285. c.sets[itemID] = content
  286. return nil
  287. }
  288. func (c *reasoningRecordingCache) GetReasoningContent(_ context.Context, itemID string) (string, error) {
  289. if v, ok := c.getResp[itemID]; ok {
  290. return v, nil
  291. }
  292. return "", ErrReasoningContentNotFound
  293. }
  294. func (c *reasoningRecordingCache) snapshotSets() map[string]string {
  295. c.mu.Lock()
  296. defer c.mu.Unlock()
  297. out := make(map[string]string, len(c.sets))
  298. for k, v := range c.sets {
  299. out[k] = v
  300. }
  301. return out
  302. }
  303. // 流式响应里的 reasoning_content 应按 reasoning item id 写入缓存,供后续轮次
  304. // 客户端不回传明文 summary 时回注(DeepSeek thinking mode 400 修复的写入侧)。
  305. func TestForwardResponses_ChatFallbackCachesStreamedReasoning(t *testing.T) {
  306. gin.SetMode(gin.TestMode)
  307. body := []byte(`{"model":"deepseek-reasoner","input":"hello","stream":true}`)
  308. rec := httptest.NewRecorder()
  309. c, _ := gin.CreateTestContext(rec)
  310. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  311. c.Request.Header.Set("Content-Type", "application/json")
  312. upstreamBody := strings.Join([]string{
  313. `data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}`,
  314. "",
  315. `data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"reasoning_content":"think "},"finish_reason":null}]}`,
  316. "",
  317. `data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"reasoning_content":"first"},"finish_reason":null}]}`,
  318. "",
  319. `data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"content":"answer"},"finish_reason":"stop"}]}`,
  320. "",
  321. `data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}}`,
  322. "",
  323. "data: [DONE]",
  324. "",
  325. }, "\n")
  326. upstream := &httpUpstreamRecorder{resp: &http.Response{
  327. StatusCode: http.StatusOK,
  328. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_reasoning_cache_stream"}},
  329. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  330. }}
  331. cache := &reasoningRecordingCache{}
  332. svc := &OpenAIGatewayService{
  333. cfg: rawChatCompletionsTestConfig(),
  334. httpUpstream: upstream,
  335. cache: cache,
  336. }
  337. result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
  338. require.NoError(t, err)
  339. require.NotNil(t, result)
  340. sets := cache.snapshotSets()
  341. require.Len(t, sets, 1, "应恰好缓存一个 reasoning item")
  342. for itemID, content := range sets {
  343. require.NotEmpty(t, itemID)
  344. require.Equal(t, "think first", content)
  345. }
  346. }
  347. // 请求侧:encrypted-only reasoning item(无明文 summary)经缓存回查补回
  348. // reasoning_content;带明文 summary 的 item 顺手回写缓存(自愈)。
  349. func TestForwardResponses_ChatFallbackRestoresReasoningFromCache(t *testing.T) {
  350. gin.SetMode(gin.TestMode)
  351. body := []byte(`{
  352. "model":"deepseek-reasoner",
  353. "stream":false,
  354. "input":[
  355. {"type":"reasoning","id":"item_plain","summary":[{"type":"summary_text","text":"plain thinking"}]},
  356. {"type":"function_call","call_id":"call_0","name":"get_value","arguments":"{}"},
  357. {"type":"function_call_output","call_id":"call_0","output":"ok"},
  358. {"type":"reasoning","id":"item_enc1","summary":[],"encrypted_content":"opaque"},
  359. {"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"},
  360. {"type":"function_call_output","call_id":"call_1","output":"ok"},
  361. {"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]}
  362. ]
  363. }`)
  364. rec := httptest.NewRecorder()
  365. c, _ := gin.CreateTestContext(rec)
  366. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  367. c.Request.Header.Set("Content-Type", "application/json")
  368. upstream := &httpUpstreamRecorder{resp: &http.Response{
  369. StatusCode: http.StatusOK,
  370. Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_reasoning_cache_restore"}},
  371. Body: io.NopCloser(strings.NewReader(
  372. `{"id":"chatcmpl_restore","object":"chat.completion","model":"deepseek-reasoner","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}`,
  373. )),
  374. }}
  375. cache := &reasoningRecordingCache{
  376. getResp: map[string]string{"item_enc1": "cached thinking"},
  377. }
  378. svc := &OpenAIGatewayService{
  379. cfg: rawChatCompletionsTestConfig(),
  380. httpUpstream: upstream,
  381. cache: cache,
  382. }
  383. result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
  384. require.NoError(t, err)
  385. require.NotNil(t, result)
  386. // 明文 summary 的 assistant 工具调用消息:reasoning_content 来自 summary 本身。
  387. require.Equal(t, "plain thinking", gjson.GetBytes(upstream.lastBody, "messages.0.reasoning_content").String())
  388. require.Equal(t, "call_0", gjson.GetBytes(upstream.lastBody, "messages.0.tool_calls.0.id").String())
  389. require.Equal(t, "tool", gjson.GetBytes(upstream.lastBody, "messages.1.role").String())
  390. // encrypted-only 的 assistant 工具调用消息:reasoning_content 来自缓存回查。
  391. require.Equal(t, "cached thinking", gjson.GetBytes(upstream.lastBody, "messages.2.reasoning_content").String())
  392. require.Equal(t, "call_1", gjson.GetBytes(upstream.lastBody, "messages.2.tool_calls.0.id").String())
  393. require.Equal(t, "tool", gjson.GetBytes(upstream.lastBody, "messages.3.role").String())
  394. // 明文 summary 的 item 被回写进缓存(自愈)。
  395. require.Equal(t, "plain thinking", cache.snapshotSets()["item_plain"])
  396. }