openai_gateway_messages_chat_fallback_test.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. //go:build unit
  2. package service
  3. import (
  4. "bytes"
  5. "context"
  6. "errors"
  7. "io"
  8. "net/http"
  9. "net/http/httptest"
  10. "strings"
  11. "testing"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
  13. "github.com/gin-gonic/gin"
  14. "github.com/stretchr/testify/require"
  15. "github.com/tidwall/gjson"
  16. )
  17. func forceChatMessagesFallbackAccount() *Account {
  18. account := rawChatCompletionsTestAccount()
  19. account.Extra = map[string]any{
  20. openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions),
  21. }
  22. return account
  23. }
  24. // errTailReader yields the given data, then returns err instead of io.EOF,
  25. // simulating an upstream connection that breaks mid-stream.
  26. type errTailReader struct {
  27. data []byte
  28. off int
  29. err error
  30. }
  31. func (r *errTailReader) Read(p []byte) (int, error) {
  32. if r.off < len(r.data) {
  33. n := copy(p, r.data[r.off:])
  34. r.off += n
  35. return n, nil
  36. }
  37. return 0, r.err
  38. }
  39. func (r *errTailReader) Close() error { return nil }
  40. func TestForwardAsAnthropic_ForceChatCompletionsPreservesFinalModelReasoningEffort(t *testing.T) {
  41. gin.SetMode(gin.TestMode)
  42. tests := []struct {
  43. name string
  44. model string
  45. mapped string
  46. effortJSON string
  47. wantEffort string
  48. maxPolicy string
  49. }{
  50. {
  51. name: "policy caps converted effort",
  52. model: "gpt-5.6-luna",
  53. mapped: "gpt-5.6-luna",
  54. effortJSON: `,"output_config":{"effort":"max"}`,
  55. wantEffort: "medium",
  56. maxPolicy: "medium",
  57. },
  58. {
  59. name: "GPT56 max",
  60. model: "luna",
  61. mapped: "gpt-5.6-luna",
  62. effortJSON: `,"output_config":{"effort":"max"}`,
  63. wantEffort: "max",
  64. },
  65. {
  66. name: "old model max",
  67. model: "gpt-5.5",
  68. mapped: "gpt-5.5",
  69. effortJSON: `,"output_config":{"effort":"max"}`,
  70. wantEffort: "xhigh",
  71. },
  72. {
  73. name: "high remains high",
  74. model: "gpt-5.6-luna",
  75. mapped: "gpt-5.6-luna",
  76. effortJSON: `,"output_config":{"effort":"high"}`,
  77. wantEffort: "high",
  78. },
  79. {
  80. name: "omitted defaults medium",
  81. model: "gpt-5.6-luna",
  82. mapped: "gpt-5.6-luna",
  83. wantEffort: "medium",
  84. },
  85. }
  86. for _, tt := range tests {
  87. t.Run(tt.name, func(t *testing.T) {
  88. rec := httptest.NewRecorder()
  89. c, _ := gin.CreateTestContext(rec)
  90. body := `{"model":"` + tt.model + `","max_tokens":16,"messages":[{"role":"user","content":"hello"}]` + tt.effortJSON + `,"stream":false}`
  91. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader([]byte(body)))
  92. c.Request.Header.Set("Content-Type", "application/json")
  93. upstream := &httpUpstreamRecorder{resp: &http.Response{
  94. StatusCode: http.StatusOK,
  95. Header: http.Header{"Content-Type": []string{"application/json"}},
  96. Body: io.NopCloser(strings.NewReader(
  97. `{"id":"chatcmpl_effort","object":"chat.completion","model":"` + tt.mapped + `","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}`,
  98. )),
  99. }}
  100. account := forceChatMessagesFallbackAccount()
  101. account.Credentials["model_mapping"] = map[string]any{tt.model: tt.mapped}
  102. svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
  103. ctx := context.Background()
  104. if tt.maxPolicy != "" {
  105. ctx = WithOpenAIReasoningEffortPolicy(ctx, tt.maxPolicy, nil, "")
  106. }
  107. result, err := svc.ForwardAsAnthropic(ctx, c, account, []byte(body), "", "")
  108. require.NoError(t, err)
  109. require.NotNil(t, result)
  110. require.Equal(t, tt.mapped, gjson.GetBytes(upstream.lastBody, "model").String())
  111. require.Equal(t, tt.wantEffort, gjson.GetBytes(upstream.lastBody, "reasoning_effort").String())
  112. require.NotNil(t, result.ReasoningEffort)
  113. require.Equal(t, tt.wantEffort, *result.ReasoningEffort)
  114. })
  115. }
  116. }
  117. func TestForwardAsAnthropic_ForceChatCompletionsNonStreaming(t *testing.T) {
  118. gin.SetMode(gin.TestMode)
  119. body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
  120. rec := httptest.NewRecorder()
  121. c, _ := gin.CreateTestContext(rec)
  122. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  123. c.Request.Header.Set("Content-Type", "application/json")
  124. upstream := &httpUpstreamRecorder{resp: &http.Response{
  125. StatusCode: http.StatusOK,
  126. Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_msg_chat_json"}},
  127. Body: io.NopCloser(strings.NewReader(
  128. `{"id":"chatcmpl_json","object":"chat.completion","model":"gpt-5.4","service_tier":"priority","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}}}`,
  129. )),
  130. }}
  131. svc := &OpenAIGatewayService{
  132. cfg: rawChatCompletionsTestConfig(),
  133. httpUpstream: upstream,
  134. }
  135. result, err := svc.ForwardAsAnthropic(context.Background(), c, forceChatMessagesFallbackAccount(), body, "", "")
  136. require.NoError(t, err)
  137. require.NotNil(t, result)
  138. require.Equal(t, "http://upstream.example/v1/chat/completions", upstream.lastReq.URL.String())
  139. require.Equal(t, "hello", gjson.GetBytes(upstream.lastBody, "messages.0.content").String())
  140. require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
  141. require.True(t, gjson.GetBytes(upstream.lastBody, "stream_options").Exists() == false)
  142. require.Equal(t, http.StatusOK, rec.Code)
  143. require.Equal(t, "assistant", gjson.Get(rec.Body.String(), "role").String())
  144. require.Equal(t, "ok", gjson.Get(rec.Body.String(), "content.0.text").String())
  145. require.Equal(t, 3, result.Usage.InputTokens)
  146. require.Equal(t, 2, result.Usage.OutputTokens)
  147. require.Equal(t, 1, result.Usage.CacheReadInputTokens)
  148. require.Nil(t, result.ServiceTier)
  149. require.Equal(t, "priority", result.UpstreamResponseServiceTier)
  150. require.False(t, result.Stream)
  151. }
  152. // Covers the fully-new streaming composition: text block is still open when
  153. // [DONE] arrives, so finalization must close it (content_block_stop) before
  154. // message_delta / message_stop.
  155. func TestForwardAsAnthropic_ForceChatCompletionsStreamingClosesOpenBlockOnDone(t *testing.T) {
  156. gin.SetMode(gin.TestMode)
  157. body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
  158. rec := httptest.NewRecorder()
  159. c, _ := gin.CreateTestContext(rec)
  160. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  161. c.Request.Header.Set("Content-Type", "application/json")
  162. upstreamBody := strings.Join([]string{
  163. `data: {"id":"chatcmpl_s","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}`,
  164. "",
  165. `data: {"id":"chatcmpl_s","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"content":"he"},"finish_reason":null}]}`,
  166. "",
  167. `data: {"id":"chatcmpl_s","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"content":"llo"},"finish_reason":null}]}`,
  168. "",
  169. `data: {"id":"chatcmpl_s","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
  170. "",
  171. `data: {"id":"chatcmpl_s","object":"chat.completion.chunk","model":"gpt-5.4","choices":[],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}}`,
  172. "",
  173. "data: [DONE]",
  174. "",
  175. }, "\n")
  176. upstream := &httpUpstreamRecorder{resp: &http.Response{
  177. StatusCode: http.StatusOK,
  178. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_msg_chat_stream"}},
  179. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  180. }}
  181. svc := &OpenAIGatewayService{
  182. cfg: rawChatCompletionsTestConfig(),
  183. httpUpstream: upstream,
  184. }
  185. result, err := svc.ForwardAsAnthropic(context.Background(), c, forceChatMessagesFallbackAccount(), body, "", "")
  186. require.NoError(t, err)
  187. require.NotNil(t, result)
  188. require.True(t, gjson.GetBytes(upstream.lastBody, "stream_options.include_usage").Bool())
  189. out := rec.Body.String()
  190. require.Contains(t, out, "event: message_start")
  191. require.Contains(t, out, `"text":"he"`)
  192. require.Contains(t, out, `"text":"llo"`)
  193. require.Contains(t, out, "event: content_block_stop")
  194. require.Contains(t, out, `"stop_reason":"end_turn"`)
  195. require.Contains(t, out, "event: message_stop")
  196. blockStop := strings.Index(out, "event: content_block_stop")
  197. msgDelta := strings.Index(out, `"stop_reason":"end_turn"`)
  198. msgStop := strings.Index(out, "event: message_stop")
  199. require.Greater(t, msgDelta, blockStop, "content_block_stop must precede message_delta")
  200. require.Greater(t, msgStop, msgDelta, "message_delta must precede message_stop")
  201. require.Equal(t, 4, result.Usage.InputTokens)
  202. require.Equal(t, 3, result.Usage.OutputTokens)
  203. require.True(t, result.Stream)
  204. require.NotNil(t, result.FirstTokenMs)
  205. }
  206. // Covers multi-chunk tool_call fragments aggregated by index and finalized as
  207. // an Anthropic tool_use block with stop_reason=tool_use.
  208. func TestForwardAsAnthropic_ForceChatCompletionsStreamingToolCallAggregation(t *testing.T) {
  209. gin.SetMode(gin.TestMode)
  210. body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"weather in sf?"}],"stream":true}`)
  211. rec := httptest.NewRecorder()
  212. c, _ := gin.CreateTestContext(rec)
  213. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  214. c.Request.Header.Set("Content-Type", "application/json")
  215. upstreamBody := strings.Join([]string{
  216. `data: {"id":"chatcmpl_t","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}`,
  217. "",
  218. `data: {"id":"chatcmpl_t","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":"}}]},"finish_reason":null}]}`,
  219. "",
  220. `data: {"id":"chatcmpl_t","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"sf\"}"}}]},"finish_reason":null}]}`,
  221. "",
  222. `data: {"id":"chatcmpl_t","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
  223. "",
  224. `data: {"id":"chatcmpl_t","object":"chat.completion.chunk","model":"gpt-5.4","choices":[],"usage":{"prompt_tokens":6,"completion_tokens":5,"total_tokens":11}}`,
  225. "",
  226. "data: [DONE]",
  227. "",
  228. }, "\n")
  229. upstream := &httpUpstreamRecorder{resp: &http.Response{
  230. StatusCode: http.StatusOK,
  231. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_msg_chat_tool"}},
  232. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  233. }}
  234. svc := &OpenAIGatewayService{
  235. cfg: rawChatCompletionsTestConfig(),
  236. httpUpstream: upstream,
  237. }
  238. result, err := svc.ForwardAsAnthropic(context.Background(), c, forceChatMessagesFallbackAccount(), body, "", "")
  239. require.NoError(t, err)
  240. require.NotNil(t, result)
  241. out := rec.Body.String()
  242. require.Contains(t, out, `"type":"tool_use"`)
  243. require.Contains(t, out, `"name":"get_weather"`)
  244. require.Contains(t, out, `"input_json_delta"`)
  245. require.Contains(t, out, `"stop_reason":"tool_use"`)
  246. require.Contains(t, out, "event: message_stop")
  247. require.Equal(t, 6, result.Usage.InputTokens)
  248. require.Equal(t, 5, result.Usage.OutputTokens)
  249. }
  250. // finish_reason=length must survive the double conversion (CC → Responses →
  251. // Anthropic) as stop_reason=max_tokens.
  252. func TestForwardAsAnthropic_ForceChatCompletionsStreamingLengthMapsToMaxTokens(t *testing.T) {
  253. gin.SetMode(gin.TestMode)
  254. body := []byte(`{"model":"gpt-5.4","max_tokens":8,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
  255. rec := httptest.NewRecorder()
  256. c, _ := gin.CreateTestContext(rec)
  257. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  258. c.Request.Header.Set("Content-Type", "application/json")
  259. upstreamBody := strings.Join([]string{
  260. `data: {"id":"chatcmpl_l","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"role":"assistant","content":"truncat"},"finish_reason":null}]}`,
  261. "",
  262. `data: {"id":"chatcmpl_l","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{},"finish_reason":"length"}],"usage":{"prompt_tokens":4,"completion_tokens":8,"total_tokens":12}}`,
  263. "",
  264. "data: [DONE]",
  265. "",
  266. }, "\n")
  267. upstream := &httpUpstreamRecorder{resp: &http.Response{
  268. StatusCode: http.StatusOK,
  269. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_msg_chat_len"}},
  270. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  271. }}
  272. svc := &OpenAIGatewayService{
  273. cfg: rawChatCompletionsTestConfig(),
  274. httpUpstream: upstream,
  275. }
  276. result, err := svc.ForwardAsAnthropic(context.Background(), c, forceChatMessagesFallbackAccount(), body, "", "")
  277. require.NoError(t, err)
  278. require.NotNil(t, result)
  279. out := rec.Body.String()
  280. require.Contains(t, out, `"stop_reason":"max_tokens"`)
  281. require.Contains(t, out, "event: message_stop")
  282. }
  283. // An upstream that ends immediately with [DONE] must still produce a fully
  284. // framed (message_start → message_delta → message_stop) Anthropic stream.
  285. func TestForwardAsAnthropic_ForceChatCompletionsEmptyStreamStillFramesMessage(t *testing.T) {
  286. gin.SetMode(gin.TestMode)
  287. body := []byte(`{"model":"gpt-5.4","max_tokens":8,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
  288. rec := httptest.NewRecorder()
  289. c, _ := gin.CreateTestContext(rec)
  290. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  291. c.Request.Header.Set("Content-Type", "application/json")
  292. upstream := &httpUpstreamRecorder{resp: &http.Response{
  293. StatusCode: http.StatusOK,
  294. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_msg_chat_empty"}},
  295. Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")),
  296. }}
  297. svc := &OpenAIGatewayService{
  298. cfg: rawChatCompletionsTestConfig(),
  299. httpUpstream: upstream,
  300. }
  301. result, err := svc.ForwardAsAnthropic(context.Background(), c, forceChatMessagesFallbackAccount(), body, "", "")
  302. require.NoError(t, err)
  303. require.NotNil(t, result)
  304. out := rec.Body.String()
  305. require.Contains(t, out, "event: message_start")
  306. require.Contains(t, out, "event: message_delta")
  307. require.Contains(t, out, "event: message_stop")
  308. }
  309. // Non-failover 4xx responses must go through the shared compat error handler:
  310. // status-specific Anthropic error type, upstream message preserved, and ops
  311. // upstream-error events recorded (previously this branch bypassed all three).
  312. func TestForwardAsAnthropic_ForceChatCompletionsNonFailover400UsesSharedErrorHandler(t *testing.T) {
  313. gin.SetMode(gin.TestMode)
  314. body := []byte(`{"model":"gpt-5.4","max_tokens":8,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
  315. rec := httptest.NewRecorder()
  316. c, _ := gin.CreateTestContext(rec)
  317. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  318. c.Request.Header.Set("Content-Type", "application/json")
  319. upstream := &httpUpstreamRecorder{resp: &http.Response{
  320. StatusCode: http.StatusBadRequest,
  321. Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_msg_chat_400"}},
  322. Body: io.NopCloser(strings.NewReader(`{"error":{"message":"invalid roles","type":"invalid_request_error"}}`)),
  323. }}
  324. svc := &OpenAIGatewayService{
  325. cfg: rawChatCompletionsTestConfig(),
  326. httpUpstream: upstream,
  327. }
  328. result, err := svc.ForwardAsAnthropic(context.Background(), c, forceChatMessagesFallbackAccount(), body, "", "")
  329. require.Error(t, err)
  330. require.Nil(t, result)
  331. require.Equal(t, http.StatusBadRequest, rec.Code)
  332. require.Equal(t, "error", gjson.Get(rec.Body.String(), "type").String())
  333. require.Equal(t, "invalid_request_error", gjson.Get(rec.Body.String(), "error.type").String())
  334. require.Equal(t, "invalid roles", gjson.Get(rec.Body.String(), "error.message").String())
  335. statusVal, ok := c.Get(OpsUpstreamStatusCodeKey)
  336. require.True(t, ok, "shared handler must record the upstream status for ops")
  337. require.Equal(t, http.StatusBadRequest, statusVal)
  338. eventsVal, ok := c.Get(OpsUpstreamErrorsKey)
  339. require.True(t, ok, "shared handler must append an ops upstream error event")
  340. events, castOK := eventsVal.([]*OpsUpstreamErrorEvent)
  341. require.True(t, castOK)
  342. require.Len(t, events, 1)
  343. require.Equal(t, http.StatusBadRequest, events[0].UpstreamStatusCode)
  344. require.Equal(t, "http_error", events[0].Kind)
  345. require.Equal(t, "invalid roles", events[0].Message)
  346. }
  347. // A broken upstream read mid-stream must surface an error and must NOT emit a
  348. // synthetic message_stop that would disguise the truncation as a completion.
  349. func TestForwardAsAnthropic_ForceChatCompletionsStreamReadErrorSkipsFinalize(t *testing.T) {
  350. gin.SetMode(gin.TestMode)
  351. body := []byte(`{"model":"gpt-5.4","max_tokens":8,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
  352. rec := httptest.NewRecorder()
  353. c, _ := gin.CreateTestContext(rec)
  354. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  355. c.Request.Header.Set("Content-Type", "application/json")
  356. partial := strings.Join([]string{
  357. `data: {"id":"chatcmpl_e","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}`,
  358. "",
  359. `data: {"id":"chatcmpl_e","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"content":"he"},"finish_reason":null}]}`,
  360. "",
  361. "",
  362. }, "\n")
  363. upstream := &httpUpstreamRecorder{resp: &http.Response{
  364. StatusCode: http.StatusOK,
  365. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_msg_chat_err"}},
  366. Body: &errTailReader{data: []byte(partial), err: errors.New("simulated upstream read failure")},
  367. }}
  368. svc := &OpenAIGatewayService{
  369. cfg: rawChatCompletionsTestConfig(),
  370. httpUpstream: upstream,
  371. }
  372. result, err := svc.ForwardAsAnthropic(context.Background(), c, forceChatMessagesFallbackAccount(), body, "", "")
  373. require.Error(t, err)
  374. require.Contains(t, err.Error(), "stream usage incomplete")
  375. require.NotNil(t, result)
  376. require.True(t, result.Stream)
  377. out := rec.Body.String()
  378. require.Contains(t, out, `"text":"he"`, "delta emitted before the failure must reach the client")
  379. require.NotContains(t, out, "event: message_stop", "no synthetic completion after a broken read")
  380. }
  381. // Gate regression: an API-key account whose upstream is confirmed to support
  382. // the Responses API must keep using /v1/responses, never the CC fallback.
  383. func TestForwardAsAnthropic_ResponsesSupportedAccountStillUsesResponsesEndpoint(t *testing.T) {
  384. gin.SetMode(gin.TestMode)
  385. body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"output_config":{"effort":"high"},"stream":false}`)
  386. rec := httptest.NewRecorder()
  387. c, _ := gin.CreateTestContext(rec)
  388. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  389. c.Request.Header.Set("Content-Type", "application/json")
  390. c.Request.Header.Set("User-Agent", "third-party-client/1.0.0")
  391. c.Request.Header.Set("originator", "opencode")
  392. upstreamBody := strings.Join([]string{
  393. `data: {"type":"response.completed","response":{"id":"resp_native","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
  394. "",
  395. "data: [DONE]",
  396. "",
  397. }, "\n")
  398. upstream := &httpUpstreamRecorder{resp: &http.Response{
  399. StatusCode: http.StatusOK,
  400. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_msg_native"}},
  401. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  402. }}
  403. svc := &OpenAIGatewayService{
  404. cfg: rawChatCompletionsTestConfig(),
  405. httpUpstream: upstream,
  406. }
  407. account := rawChatCompletionsTestAccount()
  408. account.Extra = map[string]any{
  409. openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeAuto),
  410. openai_compat.ExtraKeyResponsesSupported: true,
  411. }
  412. ctx := WithOpenAIReasoningEffortPolicy(context.Background(), "medium", nil, "")
  413. result, err := svc.ForwardAsAnthropic(ctx, c, account, body, "", "")
  414. require.NoError(t, err)
  415. require.NotNil(t, result)
  416. require.True(t, strings.HasSuffix(upstream.lastReq.URL.Path, "/responses"),
  417. "responses-capable account must stay on /v1/responses, got %s", upstream.lastReq.URL.String())
  418. require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
  419. require.Equal(t, "medium", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
  420. require.NotNil(t, result.ReasoningEffort)
  421. require.Equal(t, "medium", *result.ReasoningEffort)
  422. require.False(t, gjson.GetBytes(upstream.lastBody, "messages").Exists())
  423. require.Equal(t, "third-party-client/1.0.0", upstream.lastReq.Header.Get("User-Agent"))
  424. require.Equal(t, "opencode", upstream.lastReq.Header.Get("originator"))
  425. require.Empty(t, upstream.lastReq.Header.Get("version"))
  426. require.Empty(t, upstream.lastReq.Header.Get("OpenAI-Beta"))
  427. require.Equal(t, "ok", gjson.Get(rec.Body.String(), "content.0.text").String())
  428. }