openai_gateway_chat_completions_raw_test.go 53 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242
  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. "time"
  13. "github.com/Wei-Shaw/sub2api/internal/config"
  14. "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
  15. "github.com/gin-gonic/gin"
  16. "github.com/stretchr/testify/require"
  17. "github.com/tidwall/gjson"
  18. )
  19. func TestBuildOpenAIChatCompletionsURL(t *testing.T) {
  20. t.Parallel()
  21. tests := []struct {
  22. name string
  23. base string
  24. want string
  25. }{
  26. // 已是 /chat/completions:原样返回
  27. {"already chat/completions", "https://api.openai.com/v1/chat/completions", "https://api.openai.com/v1/chat/completions"},
  28. // 以 /v1 结尾:追加 /chat/completions
  29. {"bare /v1", "https://api.openai.com/v1", "https://api.openai.com/v1/chat/completions"},
  30. // 其他情况:追加 /v1/chat/completions
  31. {"bare domain", "https://api.openai.com", "https://api.openai.com/v1/chat/completions"},
  32. {"domain with trailing slash", "https://api.openai.com/", "https://api.openai.com/v1/chat/completions"},
  33. // 第三方上游常见形式
  34. {"third-party bare domain", "https://api.deepseek.com", "https://api.deepseek.com/v1/chat/completions"},
  35. {"third-party with path prefix", "https://api.gptgod.online/api", "https://api.gptgod.online/api/v1/chat/completions"},
  36. {"third-party versioned path", "https://open.bigmodel.cn/api/paas/v4", "https://open.bigmodel.cn/api/paas/v4/chat/completions"},
  37. // 带空白字符
  38. {"whitespace trimmed", " https://api.openai.com/v1 ", "https://api.openai.com/v1/chat/completions"},
  39. }
  40. for _, tt := range tests {
  41. t.Run(tt.name, func(t *testing.T) {
  42. t.Parallel()
  43. got := buildOpenAIChatCompletionsURL(tt.base)
  44. require.Equal(t, tt.want, got)
  45. })
  46. }
  47. }
  48. // TestBuildOpenAIResponsesURL_ProbeURL 锁定 probe/测试端点使用的 URL 构建逻辑,
  49. // 确保 buildOpenAIResponsesURL 对标准 OpenAI base_url 格式均拼出 `/v1/responses`。
  50. func TestBuildOpenAIResponsesURL_ProbeURL(t *testing.T) {
  51. t.Parallel()
  52. tests := []struct {
  53. name string
  54. base string
  55. want string
  56. }{
  57. {"bare domain", "https://api.openai.com", "https://api.openai.com/v1/responses"},
  58. {"domain trailing slash", "https://api.openai.com/", "https://api.openai.com/v1/responses"},
  59. {"bare /v1", "https://api.openai.com/v1", "https://api.openai.com/v1/responses"},
  60. {"already /responses", "https://api.openai.com/v1/responses", "https://api.openai.com/v1/responses"},
  61. {"third-party bare domain", "https://api.deepseek.com", "https://api.deepseek.com/v1/responses"},
  62. {"third-party versioned path", "https://open.bigmodel.cn/api/paas/v4", "https://open.bigmodel.cn/api/paas/v4/responses"},
  63. {"only domain, no scheme", "api.gptgod.online", "api.gptgod.online/v1/responses"},
  64. }
  65. for _, tt := range tests {
  66. t.Run(tt.name, func(t *testing.T) {
  67. t.Parallel()
  68. got := buildOpenAIResponsesURL(tt.base)
  69. require.Equal(t, tt.want, got)
  70. })
  71. }
  72. }
  73. func TestForwardAsRawChatCompletions_ForcesStreamUsageUpstreamAndPassesUsageDownstream(t *testing.T) {
  74. gin.SetMode(gin.TestMode)
  75. body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":true}`)
  76. rec := httptest.NewRecorder()
  77. c, _ := gin.CreateTestContext(rec)
  78. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  79. c.Request.Header.Set("Content-Type", "application/json")
  80. upstreamBody := strings.Join([]string{
  81. `data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"content":"ok"}}]}`,
  82. "",
  83. `data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-5.4","choices":[],"usage":{"prompt_tokens":9,"completion_tokens":4,"total_tokens":13,"prompt_tokens_details":{"cached_tokens":3}}}`,
  84. "",
  85. "data: [DONE]",
  86. "",
  87. }, "\n")
  88. upstream := &httpUpstreamRecorder{resp: &http.Response{
  89. StatusCode: http.StatusOK,
  90. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_raw_usage"}},
  91. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  92. }}
  93. svc := &OpenAIGatewayService{
  94. cfg: rawChatCompletionsTestConfig(),
  95. httpUpstream: upstream,
  96. }
  97. account := rawChatCompletionsTestAccount()
  98. result, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "")
  99. require.NoError(t, err)
  100. require.NotNil(t, result)
  101. require.Equal(t, 9, result.Usage.InputTokens)
  102. require.Equal(t, 4, result.Usage.OutputTokens)
  103. require.Equal(t, 3, result.Usage.CacheReadInputTokens)
  104. require.NotNil(t, upstream.lastReq)
  105. require.NoError(t, upstream.lastReq.Context().Err())
  106. require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.lastReq.Context()))
  107. require.True(t, gjson.GetBytes(upstream.lastBody, "stream_options.include_usage").Bool())
  108. require.Contains(t, rec.Body.String(), `"usage"`)
  109. require.Contains(t, rec.Body.String(), "data: [DONE]")
  110. }
  111. func TestForwardAsChatCompletions_OpenAICompatibleGrokRawMissingUsageFailsBeforeWrite(t *testing.T) {
  112. gin.SetMode(gin.TestMode)
  113. body := []byte(`{"model":"grok-4.5","messages":[{"role":"user","content":"hello"}],"stream":false}`)
  114. recorder := httptest.NewRecorder()
  115. c, _ := gin.CreateTestContext(recorder)
  116. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  117. upstream := &httpUpstreamRecorder{resp: &http.Response{
  118. StatusCode: http.StatusOK,
  119. Header: http.Header{
  120. "Content-Type": []string{"application/json"},
  121. "X-Request-Id": []string{"rid-openai-compat-grok-no-usage"},
  122. },
  123. Body: io.NopCloser(strings.NewReader(
  124. `{"id":"resp_missing_usage","object":"chat.completion","model":"grok-4.5","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}]}`,
  125. )),
  126. }}
  127. svc := &OpenAIGatewayService{
  128. cfg: rawChatCompletionsTestConfig(),
  129. httpUpstream: upstream,
  130. }
  131. account := rawChatCompletionsTestAccount()
  132. account.Name = "openai-compatible-grok"
  133. account.Extra = map[string]any{openai_compat.ExtraKeyResponsesSupported: false}
  134. result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
  135. require.Nil(t, result)
  136. var failoverErr *UpstreamFailoverError
  137. require.ErrorAs(t, err, &failoverErr)
  138. require.False(t, c.Writer.Written(), "unbilled Grok content must not be returned by an OpenAI-compatible account")
  139. require.Empty(t, recorder.Body.String())
  140. }
  141. func TestForwardAsChatCompletions_OpenAICompatibleRawUsageGuard(t *testing.T) {
  142. tests := []struct {
  143. name string
  144. model string
  145. upstreamResponse string
  146. modelMapping map[string]any
  147. wantGuarded bool
  148. }{
  149. {
  150. name: "Grok response without usage",
  151. model: "grok-4.5",
  152. upstreamResponse: `{"id":"resp_missing","object":"chat.completion","model":"grok-4.5","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}]}`,
  153. wantGuarded: true,
  154. },
  155. {
  156. name: "namespaced Grok response without usage",
  157. model: "x-ai/grok-4.5",
  158. upstreamResponse: `{"id":"resp_namespaced","object":"chat.completion","model":"x-ai/grok-4.5","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}]}`,
  159. wantGuarded: true,
  160. },
  161. {
  162. name: "Grok response with aggregate usage passes",
  163. model: "grok-4.5",
  164. upstreamResponse: `{"id":"resp_usage","object":"chat.completion","model":"grok-4.5","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":3,"total_tokens":12}}`,
  165. wantGuarded: false,
  166. },
  167. {
  168. name: "Grok alias mapped to non-Grok remains unchanged",
  169. model: "grok-alias",
  170. upstreamResponse: `{"id":"resp_mapped","object":"chat.completion","model":"gpt-5.4","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}]}`,
  171. modelMapping: map[string]any{"grok-alias": "gpt-5.4"},
  172. wantGuarded: false,
  173. },
  174. {
  175. name: "Grok response with detail-only usage",
  176. model: "grok-4.5",
  177. upstreamResponse: `{"id":"resp_detail_only","object":"chat.completion","model":"grok-4.5","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}],"usage":{"input_tokens_details":{"text_tokens":9,"image_tokens":2},"output_tokens_details":{"image_tokens":1}}}`,
  178. wantGuarded: true,
  179. },
  180. {
  181. name: "non-Grok response without usage remains unchanged",
  182. model: "gpt-5.4",
  183. upstreamResponse: `{"id":"resp_openai","object":"chat.completion","model":"gpt-5.4","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}]}`,
  184. wantGuarded: false,
  185. },
  186. }
  187. for _, tt := range tests {
  188. t.Run(tt.name, func(t *testing.T) {
  189. gin.SetMode(gin.TestMode)
  190. body := []byte(`{"model":"` + tt.model + `","messages":[{"role":"user","content":"hello"}],"stream":false}`)
  191. recorder := httptest.NewRecorder()
  192. c, _ := gin.CreateTestContext(recorder)
  193. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  194. upstream := &httpUpstreamRecorder{resp: &http.Response{
  195. StatusCode: http.StatusOK,
  196. Header: http.Header{"Content-Type": []string{"application/json"}, "X-Request-Id": []string{"rid-openai-compatible"}},
  197. Body: io.NopCloser(strings.NewReader(tt.upstreamResponse)),
  198. }}
  199. svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
  200. account := rawChatCompletionsTestAccount()
  201. account.Name = "openai-compatible"
  202. account.Extra = map[string]any{openai_compat.ExtraKeyResponsesSupported: false}
  203. if tt.modelMapping != nil {
  204. account.Credentials["model_mapping"] = tt.modelMapping
  205. }
  206. result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
  207. if !tt.wantGuarded {
  208. require.NoError(t, err)
  209. require.NotNil(t, result)
  210. require.True(t, c.Writer.Written())
  211. return
  212. }
  213. require.Nil(t, result)
  214. var failoverErr *UpstreamFailoverError
  215. require.ErrorAs(t, err, &failoverErr)
  216. require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
  217. require.Equal(t, "grok_missing_usage", gjson.GetBytes(failoverErr.ResponseBody, "error.code").String())
  218. require.False(t, c.Writer.Written(), "unbilled Grok content must not be returned")
  219. })
  220. }
  221. }
  222. func TestForwardAsRawChatCompletions_PreservesMappedGPT56MaxEffort(t *testing.T) {
  223. gin.SetMode(gin.TestMode)
  224. body := []byte(`{"model":"sol","messages":[{"role":"user","content":"hello"}],"reasoning_effort":"max","stream":false}`)
  225. rec := httptest.NewRecorder()
  226. c, _ := gin.CreateTestContext(rec)
  227. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  228. c.Request.Header.Set("Content-Type", "application/json")
  229. upstream := &httpUpstreamRecorder{resp: &http.Response{
  230. StatusCode: http.StatusOK,
  231. Header: http.Header{"Content-Type": []string{"application/json"}},
  232. Body: io.NopCloser(strings.NewReader(
  233. `{"id":"chatcmpl_max","object":"chat.completion","model":"gpt-5.6-sol","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}`,
  234. )),
  235. }}
  236. svc := &OpenAIGatewayService{
  237. cfg: rawChatCompletionsTestConfig(),
  238. httpUpstream: upstream,
  239. }
  240. account := rawChatCompletionsTestAccount()
  241. account.Credentials["model_mapping"] = map[string]any{"sol": "gpt-5.6-sol"}
  242. result, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "")
  243. require.NoError(t, err)
  244. require.NotNil(t, result)
  245. require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(upstream.lastBody, "model").String())
  246. require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning_effort").String())
  247. require.NotNil(t, result.ReasoningEffort)
  248. require.Equal(t, "max", *result.ReasoningEffort)
  249. }
  250. func TestForwardAsRawChatCompletions_NonStreamingCapturesCacheWriteUsage(t *testing.T) {
  251. gin.SetMode(gin.TestMode)
  252. tests := []struct {
  253. name string
  254. usageJSON string
  255. wantWrite int
  256. }{
  257. {
  258. name: "positive cache write",
  259. usageJSON: `{"prompt_tokens":12,"completion_tokens":3,"total_tokens":15,"prompt_tokens_details":{"cached_tokens":4,"cache_write_tokens":6}}`,
  260. wantWrite: 6,
  261. },
  262. {
  263. name: "nested zero overrides legacy alias",
  264. usageJSON: `{"prompt_tokens":12,"completion_tokens":3,"total_tokens":15,"cache_creation_input_tokens":19,"prompt_tokens_details":{"cached_tokens":4,"cache_write_tokens":0}}`,
  265. wantWrite: 0,
  266. },
  267. }
  268. for _, tt := range tests {
  269. t.Run(tt.name, func(t *testing.T) {
  270. body := []byte(`{"model":"gpt-5.6","messages":[{"role":"user","content":"hello"}],"stream":false}`)
  271. rec := httptest.NewRecorder()
  272. c, _ := gin.CreateTestContext(rec)
  273. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  274. c.Request.Header.Set("Content-Type", "application/json")
  275. upstream := &httpUpstreamRecorder{resp: &http.Response{
  276. StatusCode: http.StatusOK,
  277. Header: http.Header{"Content-Type": []string{"application/json"}},
  278. Body: io.NopCloser(strings.NewReader(
  279. `{"id":"chatcmpl_cache","object":"chat.completion","model":"gpt-5.6","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":` + tt.usageJSON + `}`,
  280. )),
  281. }}
  282. svc := &OpenAIGatewayService{
  283. cfg: rawChatCompletionsTestConfig(),
  284. httpUpstream: upstream,
  285. }
  286. result, err := svc.forwardAsRawChatCompletions(context.Background(), c, rawChatCompletionsTestAccount(), body, "")
  287. require.NoError(t, err)
  288. require.NotNil(t, result)
  289. require.Equal(t, 12, result.Usage.InputTokens)
  290. require.Equal(t, 4, result.Usage.CacheReadInputTokens)
  291. require.Equal(t, tt.wantWrite, result.Usage.CacheCreationInputTokens)
  292. })
  293. }
  294. }
  295. func TestForwardAsRawChatCompletions_PreservesDeepSeekReasoningContentNonStreaming(t *testing.T) {
  296. gin.SetMode(gin.TestMode)
  297. body := []byte(`{"model":"deepseek-reasoner","messages":[{"role":"user","content":"hello"}],"stream":false}`)
  298. rec := httptest.NewRecorder()
  299. c, _ := gin.CreateTestContext(rec)
  300. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  301. c.Request.Header.Set("Content-Type", "application/json")
  302. upstreamJSON := `{"id":"chatcmpl_reasoning","object":"chat.completion","model":"deepseek-reasoner","choices":[{"index":0,"message":{"role":"assistant","reasoning_content":"think first","content":"final answer"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":5,"total_tokens":8}}`
  303. upstream := &httpUpstreamRecorder{resp: &http.Response{
  304. StatusCode: http.StatusOK,
  305. Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_deepseek_reasoning_json"}},
  306. Body: io.NopCloser(strings.NewReader(upstreamJSON)),
  307. }}
  308. svc := &OpenAIGatewayService{
  309. cfg: rawChatCompletionsTestConfig(),
  310. httpUpstream: upstream,
  311. }
  312. account := rawChatCompletionsTestAccount()
  313. result, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "")
  314. require.NoError(t, err)
  315. require.NotNil(t, result)
  316. require.Equal(t, 3, result.Usage.InputTokens)
  317. require.Equal(t, 5, result.Usage.OutputTokens)
  318. require.Equal(t, "think first", gjson.Get(rec.Body.String(), "choices.0.message.reasoning_content").String())
  319. require.Equal(t, "final answer", gjson.Get(rec.Body.String(), "choices.0.message.content").String())
  320. }
  321. func TestForwardAsRawChatCompletions_PreservesDeepSeekReasoningContentStreaming(t *testing.T) {
  322. gin.SetMode(gin.TestMode)
  323. body := []byte(`{"model":"deepseek-reasoner","messages":[{"role":"user","content":"hello"}],"stream":true}`)
  324. rec := httptest.NewRecorder()
  325. c, _ := gin.CreateTestContext(rec)
  326. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  327. c.Request.Header.Set("Content-Type", "application/json")
  328. upstreamBody := strings.Join([]string{
  329. `data: {"id":"chatcmpl_reasoning","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}`,
  330. "",
  331. `data: {"id":"chatcmpl_reasoning","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"reasoning_content":"think first"},"finish_reason":null}]}`,
  332. "",
  333. `data: {"id":"chatcmpl_reasoning","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"content":"final answer"},"finish_reason":null}]}`,
  334. "",
  335. `data: {"id":"chatcmpl_reasoning","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[],"usage":{"prompt_tokens":3,"completion_tokens":5,"total_tokens":8}}`,
  336. "",
  337. "data: [DONE]",
  338. "",
  339. }, "\n")
  340. upstream := &httpUpstreamRecorder{resp: &http.Response{
  341. StatusCode: http.StatusOK,
  342. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_deepseek_reasoning_stream"}},
  343. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  344. }}
  345. svc := &OpenAIGatewayService{
  346. cfg: rawChatCompletionsTestConfig(),
  347. httpUpstream: upstream,
  348. }
  349. account := rawChatCompletionsTestAccount()
  350. result, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "")
  351. require.NoError(t, err)
  352. require.NotNil(t, result)
  353. require.Equal(t, 3, result.Usage.InputTokens)
  354. require.Equal(t, 5, result.Usage.OutputTokens)
  355. require.Contains(t, rec.Body.String(), `"reasoning_content":"think first"`)
  356. require.Contains(t, rec.Body.String(), `"content":"final answer"`)
  357. require.Contains(t, rec.Body.String(), "data: [DONE]")
  358. }
  359. func TestForwardAsRawChatCompletions_PreservesDeepSeekReasoningContentInRequest(t *testing.T) {
  360. gin.SetMode(gin.TestMode)
  361. body := []byte(`{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"weather"},{"role":"assistant","reasoning_content":"need tool","content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{}"}}]},{"role":"tool","tool_call_id":"call_1","content":"cloudy"}],"stream":false}`)
  362. rec := httptest.NewRecorder()
  363. c, _ := gin.CreateTestContext(rec)
  364. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  365. c.Request.Header.Set("Content-Type", "application/json")
  366. upstream := &httpUpstreamRecorder{resp: &http.Response{
  367. StatusCode: http.StatusOK,
  368. Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_deepseek_reasoning_request"}},
  369. Body: io.NopCloser(strings.NewReader(`{"id":"chatcmpl_request","object":"chat.completion","model":"deepseek-v4-pro","choices":[{"index":0,"message":{"role":"assistant","content":"done"},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":2,"total_tokens":6}}`)),
  370. }}
  371. svc := &OpenAIGatewayService{
  372. cfg: rawChatCompletionsTestConfig(),
  373. httpUpstream: upstream,
  374. }
  375. account := rawChatCompletionsTestAccount()
  376. result, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "")
  377. require.NoError(t, err)
  378. require.NotNil(t, result)
  379. require.Equal(t, "need tool", gjson.GetBytes(upstream.lastBody, "messages.1.reasoning_content").String())
  380. require.Equal(t, "get_weather", gjson.GetBytes(upstream.lastBody, "messages.1.tool_calls.0.function.name").String())
  381. }
  382. func TestForwardAsRawChatCompletions_NormalizesGLMReasoningEffortForUpstream(t *testing.T) {
  383. gin.SetMode(gin.TestMode)
  384. body := []byte(`{"model":"glm-5.2","messages":[{"role":"user","content":"hello"}],"reasoning_effort":"xhigh","stream":false}`)
  385. rec := httptest.NewRecorder()
  386. c, _ := gin.CreateTestContext(rec)
  387. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  388. c.Request.Header.Set("Content-Type", "application/json")
  389. upstream := &httpUpstreamRecorder{resp: &http.Response{
  390. StatusCode: http.StatusOK,
  391. Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_glm_effort"}},
  392. Body: io.NopCloser(strings.NewReader(`{"id":"chatcmpl_glm","object":"chat.completion","model":"glm-5.2","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`)),
  393. }}
  394. svc := &OpenAIGatewayService{
  395. cfg: rawChatCompletionsTestConfig(),
  396. httpUpstream: upstream,
  397. }
  398. account := rawChatCompletionsTestAccount()
  399. result, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "")
  400. require.NoError(t, err)
  401. require.NotNil(t, result)
  402. require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning_effort").String())
  403. }
  404. func TestForwardAsRawChatCompletions_SilentRefusalTriggersFailover(t *testing.T) {
  405. gin.SetMode(gin.TestMode)
  406. body := largeRawChatCompletionsBody()
  407. rec := httptest.NewRecorder()
  408. c, _ := gin.CreateTestContext(rec)
  409. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  410. c.Request.Header.Set("Content-Type", "application/json")
  411. upstreamBody := strings.Join([]string{
  412. `data: {"id":"chatcmpl_silent","object":"chat.completion.chunk","model":"gpt-5.5","choices":[{"index":0,"delta":{"role":"assistant"}}]}`,
  413. "",
  414. `data: {"id":"chatcmpl_silent","object":"chat.completion.chunk","model":"gpt-5.5","choices":[{"index":0,"delta":{"content":""},"finish_reason":"stop"}]}`,
  415. "",
  416. "data: [DONE]",
  417. "",
  418. }, "\n")
  419. upstream := &httpUpstreamRecorder{resp: &http.Response{
  420. StatusCode: http.StatusOK,
  421. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_silent"}},
  422. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  423. }}
  424. svc := &OpenAIGatewayService{
  425. cfg: rawChatCompletionsTestConfig(),
  426. httpUpstream: upstream,
  427. }
  428. result, err := svc.forwardAsRawChatCompletions(context.Background(), c, rawChatCompletionsTestAccount(), body, "")
  429. require.Nil(t, result)
  430. var failoverErr *UpstreamFailoverError
  431. require.True(t, errors.As(err, &failoverErr))
  432. require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
  433. require.True(t, IsOpenAISilentRefusalErrorBody(failoverErr.ResponseBody))
  434. require.False(t, c.Writer.Written(), "silent refusal must not commit a 200 response before failover")
  435. require.Empty(t, rec.Body.String())
  436. }
  437. func TestForwardAsRawChatCompletions_SilentRefusalToolCallsExempt(t *testing.T) {
  438. gin.SetMode(gin.TestMode)
  439. body := largeRawChatCompletionsBody()
  440. rec := httptest.NewRecorder()
  441. c, _ := gin.CreateTestContext(rec)
  442. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  443. c.Request.Header.Set("Content-Type", "application/json")
  444. upstreamBody := strings.Join([]string{
  445. `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"gpt-5.5","choices":[{"index":0,"delta":{"role":"assistant"}}]}`,
  446. "",
  447. `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"gpt-5.5","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"lookup","arguments":""}}]}}]}`,
  448. "",
  449. `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"gpt-5.5","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
  450. "",
  451. "data: [DONE]",
  452. "",
  453. }, "\n")
  454. upstream := &httpUpstreamRecorder{resp: &http.Response{
  455. StatusCode: http.StatusOK,
  456. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_tool"}},
  457. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  458. }}
  459. svc := &OpenAIGatewayService{
  460. cfg: rawChatCompletionsTestConfig(),
  461. httpUpstream: upstream,
  462. }
  463. result, err := svc.forwardAsRawChatCompletions(context.Background(), c, rawChatCompletionsTestAccount(), body, "")
  464. require.NoError(t, err)
  465. require.NotNil(t, result)
  466. require.Contains(t, rec.Body.String(), `"tool_calls"`)
  467. require.Contains(t, rec.Body.String(), `"finish_reason":"tool_calls"`)
  468. }
  469. func TestHandleChatStreamingResponse_SilentRefusalReasoningSummaryExempt(t *testing.T) {
  470. gin.SetMode(gin.TestMode)
  471. rec := httptest.NewRecorder()
  472. c, _ := gin.CreateTestContext(rec)
  473. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
  474. upstreamBody := strings.Join([]string{
  475. `data: {"type":"response.created","response":{"id":"resp_reasoning","model":"gpt-5.5"}}`,
  476. "",
  477. `data: {"type":"response.reasoning_summary_text.delta","delta":"thinking only"}`,
  478. "",
  479. `data: {"type":"response.completed","response":{"id":"resp_reasoning","model":"gpt-5.5","status":"completed"}}`,
  480. "",
  481. }, "\n")
  482. resp := &http.Response{
  483. StatusCode: http.StatusOK,
  484. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_reasoning"}},
  485. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  486. }
  487. svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig()}
  488. result, err := svc.handleChatStreamingResponse(
  489. resp,
  490. c,
  491. rawChatCompletionsTestAccount(),
  492. "gpt-5.5",
  493. "gpt-5.5",
  494. "gpt-5.5",
  495. time.Now(),
  496. openAISilentRefusalMinRequestBodyBytes,
  497. )
  498. require.NoError(t, err)
  499. require.NotNil(t, result)
  500. require.Contains(t, rec.Body.String(), `"reasoning_content":"thinking only"`)
  501. require.Contains(t, rec.Body.String(), "data: [DONE]")
  502. }
  503. func TestForwardAsRawChatCompletions_SilentRefusalNormalContentExempt(t *testing.T) {
  504. gin.SetMode(gin.TestMode)
  505. body := largeRawChatCompletionsBody()
  506. rec := httptest.NewRecorder()
  507. c, _ := gin.CreateTestContext(rec)
  508. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  509. c.Request.Header.Set("Content-Type", "application/json")
  510. upstreamBody := strings.Join([]string{
  511. `data: {"id":"chatcmpl_ok","object":"chat.completion.chunk","model":"gpt-5.5","choices":[{"index":0,"delta":{"role":"assistant"}}]}`,
  512. "",
  513. `data: {"id":"chatcmpl_ok","object":"chat.completion.chunk","model":"gpt-5.5","choices":[{"index":0,"delta":{"content":"ok"}}]}`,
  514. "",
  515. `data: {"id":"chatcmpl_ok","object":"chat.completion.chunk","model":"gpt-5.5","choices":[{"index":0,"delta":{"content":""},"finish_reason":"stop"}]}`,
  516. "",
  517. "data: [DONE]",
  518. "",
  519. }, "\n")
  520. upstream := &httpUpstreamRecorder{resp: &http.Response{
  521. StatusCode: http.StatusOK,
  522. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_ok"}},
  523. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  524. }}
  525. svc := &OpenAIGatewayService{
  526. cfg: rawChatCompletionsTestConfig(),
  527. httpUpstream: upstream,
  528. }
  529. result, err := svc.forwardAsRawChatCompletions(context.Background(), c, rawChatCompletionsTestAccount(), body, "")
  530. require.NoError(t, err)
  531. require.NotNil(t, result)
  532. require.Contains(t, rec.Body.String(), `"content":"ok"`)
  533. require.Contains(t, rec.Body.String(), "data: [DONE]")
  534. }
  535. // TestForwardAsRawChatCompletions_StripsEmptyToolCallIdentity 端到端验证 raw
  536. // CC 流式直转路径剔除 DashScope/DeepSeek 后续参数 delta 的空 id/name:
  537. // 下游仍保留首包合法 id/name 与 arguments 碎片,但后续 delta 不再带
  538. // `"id":""` / `"name":""`,避免 dsh 等客户端用 `!== undefined` 合并时把
  539. // 首包合法值覆盖掉(ToolNotFoundError: unknown tool "")。
  540. func TestForwardAsRawChatCompletions_StripsEmptyToolCallIdentity(t *testing.T) {
  541. gin.SetMode(gin.TestMode)
  542. body := []byte(`{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"weather"}],"stream":true}`)
  543. rec := httptest.NewRecorder()
  544. c, _ := gin.CreateTestContext(rec)
  545. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  546. c.Request.Header.Set("Content-Type", "application/json")
  547. upstreamBody := strings.Join([]string{
  548. `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_example","type":"function","function":{"name":"web_search","arguments":""}}]}}]}`,
  549. "",
  550. `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{\"query\":"}}]}}]}`,
  551. "",
  552. `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"\"example\"}"}}]}}]}`,
  553. "",
  554. `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
  555. "",
  556. "data: [DONE]",
  557. "",
  558. }, "\n")
  559. upstream := &httpUpstreamRecorder{resp: &http.Response{
  560. StatusCode: http.StatusOK,
  561. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_tool_identity"}},
  562. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  563. }}
  564. svc := &OpenAIGatewayService{
  565. cfg: rawChatCompletionsTestConfig(),
  566. httpUpstream: upstream,
  567. }
  568. account := rawChatCompletionsTestAccount()
  569. result, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "")
  570. require.NoError(t, err)
  571. require.NotNil(t, result)
  572. downstream := rec.Body.String()
  573. require.Contains(t, downstream, `"id":"call_example"`)
  574. require.Contains(t, downstream, `"name":"web_search"`)
  575. require.Contains(t, downstream, `{\"query\":`)
  576. require.Contains(t, downstream, `\"example\"}`)
  577. require.Contains(t, downstream, "data: [DONE]")
  578. require.NotContains(t, downstream, `"id":""`)
  579. require.NotContains(t, downstream, `"name":""`)
  580. // 逐条扫下游 data payload:后续参数 delta 的 tool_calls.0.id /
  581. // function.name 必须已剔除(Exists() == false),首包合法值保留。
  582. followUpSeen := false
  583. for _, line := range strings.Split(downstream, "\n") {
  584. payload, ok := extractOpenAISSEDataLine(line)
  585. if !ok {
  586. continue
  587. }
  588. trimmed := strings.TrimSpace(payload)
  589. if trimmed == "" || trimmed == "[DONE]" {
  590. continue
  591. }
  592. delta := gjson.Get(payload, "choices.0.delta")
  593. if !delta.Exists() || !delta.Get("tool_calls").Exists() {
  594. continue
  595. }
  596. id := delta.Get("tool_calls.0.id")
  597. if id.String() == "call_example" {
  598. require.Equal(t, "web_search", delta.Get("tool_calls.0.function.name").String())
  599. continue
  600. }
  601. require.False(t, id.Exists(), "empty id must be stripped: %s", payload)
  602. require.False(t, delta.Get("tool_calls.0.function.name").Exists(), "empty name must be stripped: %s", payload)
  603. require.NotEmpty(t, delta.Get("tool_calls.0.function.arguments").String())
  604. followUpSeen = true
  605. }
  606. require.True(t, followUpSeen)
  607. }
  608. // 上游在生成中途干净 EOF(无 [DONE]/usage/finish_reason)且已向客户端写出内容:
  609. // 不能再记成 HTTP 200 成功,必须回带类型化的上游截断错误,由 handler 补 SSE error
  610. // 帧并计入 SLA 失败。
  611. func TestForwardAsRawChatCompletions_TruncatedStreamAfterOutputFailsRequest(t *testing.T) {
  612. gin.SetMode(gin.TestMode)
  613. body := []byte(`{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"hello"}],"stream":true}`)
  614. rec := httptest.NewRecorder()
  615. c, _ := gin.CreateTestContext(rec)
  616. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  617. c.Request.Header.Set("Content-Type", "application/json")
  618. upstreamBody := strings.Join([]string{
  619. `data: {"id":"chatcmpl_cut","object":"chat.completion.chunk","model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"content":"half an ans"},"finish_reason":null}]}`,
  620. "",
  621. }, "\n")
  622. upstream := &httpUpstreamRecorder{resp: &http.Response{
  623. StatusCode: http.StatusOK,
  624. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_truncated"}},
  625. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  626. }}
  627. svc := &OpenAIGatewayService{
  628. cfg: rawChatCompletionsTestConfig(),
  629. httpUpstream: upstream,
  630. }
  631. result, err := svc.forwardAsRawChatCompletions(context.Background(), c, rawChatCompletionsTestAccount(), body, "")
  632. require.Error(t, err)
  633. require.NotNil(t, result, "已收字节的用量仍需带回,供 ops 记录首 token 时延")
  634. var failoverErr *UpstreamFailoverError
  635. require.False(t, errors.As(err, &failoverErr), "已写出语义字节后不得再 failover")
  636. code, message, ok := OpenAIUpstreamStreamReadErrorDetails(err)
  637. require.True(t, ok)
  638. require.Equal(t, OpenAIUpstreamStreamTruncatedCode, code)
  639. require.NotEmpty(t, message)
  640. // 已写出的内容保持原样透传,客户端拿到的仍是它已经收到的那部分。
  641. require.Contains(t, rec.Body.String(), `"content":"half an ans"`)
  642. require.NotContains(t, rec.Body.String(), "data: [DONE]")
  643. rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
  644. require.True(t, ok)
  645. events := rawEvents.([]*OpsUpstreamErrorEvent)
  646. require.Len(t, events, 1)
  647. require.Equal(t, "http_error", events[0].Kind)
  648. require.Nil(t, events[0].ProxyID)
  649. require.Equal(t, opsProxyNameDirect, events[0].ProxyName)
  650. }
  651. // 截断 failover 事件必须带上本次传输真实使用的托管代理快照。
  652. func TestForwardAsRawChatCompletions_TruncationFailoverAttributesManagedProxy(t *testing.T) {
  653. gin.SetMode(gin.TestMode)
  654. body := []byte(`{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"hello"}],"stream":true}`)
  655. rec := httptest.NewRecorder()
  656. c, _ := gin.CreateTestContext(rec)
  657. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  658. c.Request.Header.Set("Content-Type", "application/json")
  659. upstream := &httpUpstreamRecorder{resp: &http.Response{
  660. StatusCode: http.StatusOK,
  661. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_empty_proxy"}},
  662. Body: io.NopCloser(strings.NewReader("")),
  663. }}
  664. svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
  665. proxy := &Proxy{ID: 8001, Name: "oxylabs-uk-8001", Protocol: "http", Host: "proxy.example", Port: 8080}
  666. account := rawChatCompletionsTestAccount()
  667. account.ProxyID = &proxy.ID
  668. account.Proxy = proxy
  669. _, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "")
  670. var failoverErr *UpstreamFailoverError
  671. require.ErrorAs(t, err, &failoverErr)
  672. require.Equal(t, proxy.URL(), upstream.lastProxyURL)
  673. rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
  674. require.True(t, ok)
  675. events := rawEvents.([]*OpsUpstreamErrorEvent)
  676. require.Len(t, events, 1)
  677. require.Equal(t, "failover", events[0].Kind)
  678. require.NotNil(t, events[0].ProxyID)
  679. require.Equal(t, proxy.ID, *events[0].ProxyID)
  680. require.Equal(t, proxy.Name, events[0].ProxyName)
  681. }
  682. // 上游 200 但一个 SSE 字节都没发:响应头尚未提交,应换号重试而不是回 200 空流。
  683. func TestForwardAsRawChatCompletions_EmptyStreamBeforeOutputTriggersFailover(t *testing.T) {
  684. gin.SetMode(gin.TestMode)
  685. body := []byte(`{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"hello"}],"stream":true}`)
  686. rec := httptest.NewRecorder()
  687. c, _ := gin.CreateTestContext(rec)
  688. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  689. c.Request.Header.Set("Content-Type", "application/json")
  690. upstream := &httpUpstreamRecorder{resp: &http.Response{
  691. StatusCode: http.StatusOK,
  692. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_empty"}},
  693. Body: io.NopCloser(strings.NewReader("")),
  694. }}
  695. svc := &OpenAIGatewayService{
  696. cfg: rawChatCompletionsTestConfig(),
  697. httpUpstream: upstream,
  698. }
  699. result, err := svc.forwardAsRawChatCompletions(context.Background(), c, rawChatCompletionsTestAccount(), body, "")
  700. require.Nil(t, result)
  701. var failoverErr *UpstreamFailoverError
  702. require.True(t, errors.As(err, &failoverErr))
  703. require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
  704. require.Equal(t, OpenAIUpstreamStreamTruncatedCode,
  705. gjson.GetBytes(failoverErr.ResponseBody, "error.code").String())
  706. require.True(t, failoverErr.ShouldRetryNextAccount())
  707. require.False(t, c.Writer.Written(), "换号重试前不得提交 200 响应头")
  708. require.Empty(t, rec.Body.String())
  709. }
  710. // 传输层错误(Cloudflare edge reset 等)在写出后同样不能记成功,且分类要区别于
  711. // 干净 EOF,便于 ops 分辨 reset 与静默截断。
  712. func TestForwardAsRawChatCompletions_StreamReadErrorAfterOutputFailsRequest(t *testing.T) {
  713. gin.SetMode(gin.TestMode)
  714. body := []byte(`{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"hello"}],"stream":true}`)
  715. rec := httptest.NewRecorder()
  716. c, _ := gin.CreateTestContext(rec)
  717. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  718. c.Request.Header.Set("Content-Type", "application/json")
  719. upstream := &httpUpstreamRecorder{resp: &http.Response{
  720. StatusCode: http.StatusOK,
  721. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_reset"}},
  722. Body: &openAIChatStreamReadErrorCloser{
  723. payload: []byte(`data: {"id":"chatcmpl_reset","object":"chat.completion.chunk","model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"content":"partial"}}]}` + "\n\n"),
  724. err: errors.New("read tcp 172.18.0.4->172.65.90.23:443: read: connection reset by peer"),
  725. },
  726. }}
  727. svc := &OpenAIGatewayService{
  728. cfg: rawChatCompletionsTestConfig(),
  729. httpUpstream: upstream,
  730. }
  731. result, err := svc.forwardAsRawChatCompletions(context.Background(), c, rawChatCompletionsTestAccount(), body, "")
  732. require.Error(t, err)
  733. require.NotNil(t, result)
  734. code, _, ok := OpenAIUpstreamStreamReadErrorDetails(err)
  735. require.True(t, ok)
  736. require.Equal(t, OpenAIUpstreamStreamReadErrorCode, code)
  737. require.Contains(t, rec.Body.String(), `"content":"partial"`)
  738. }
  739. // 边界:缺 [DONE] 但收到了 usage 帧 —— 生成已完整,只是尾巴丢失。必须继续按成功
  740. // 计费,否则会误伤那些跑完就直接 EOF 的兼容上游并白送 token。
  741. func TestForwardAsRawChatCompletions_MissingDoneWithUsageStillSucceeds(t *testing.T) {
  742. gin.SetMode(gin.TestMode)
  743. body := []byte(`{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"hello"}],"stream":true}`)
  744. rec := httptest.NewRecorder()
  745. c, _ := gin.CreateTestContext(rec)
  746. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  747. c.Request.Header.Set("Content-Type", "application/json")
  748. upstreamBody := strings.Join([]string{
  749. `data: {"id":"chatcmpl_nodone","object":"chat.completion.chunk","model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"content":"ok"}}]}`,
  750. "",
  751. `data: {"id":"chatcmpl_nodone","object":"chat.completion.chunk","model":"deepseek-v4-pro","choices":[],"usage":{"prompt_tokens":11,"completion_tokens":6,"total_tokens":17}}`,
  752. "",
  753. }, "\n")
  754. upstream := &httpUpstreamRecorder{resp: &http.Response{
  755. StatusCode: http.StatusOK,
  756. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_nodone_usage"}},
  757. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  758. }}
  759. svc := &OpenAIGatewayService{
  760. cfg: rawChatCompletionsTestConfig(),
  761. httpUpstream: upstream,
  762. }
  763. result, err := svc.forwardAsRawChatCompletions(context.Background(), c, rawChatCompletionsTestAccount(), body, "")
  764. require.NoError(t, err)
  765. require.NotNil(t, result)
  766. require.Equal(t, 11, result.Usage.InputTokens)
  767. require.Equal(t, 6, result.Usage.OutputTokens)
  768. }
  769. // 边界:缺 [DONE] 与 usage,但末帧带 finish_reason —— 生成正常结束,同样不判截断。
  770. func TestForwardAsRawChatCompletions_MissingDoneWithFinishReasonStillSucceeds(t *testing.T) {
  771. gin.SetMode(gin.TestMode)
  772. body := []byte(`{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"hello"}],"stream":true}`)
  773. rec := httptest.NewRecorder()
  774. c, _ := gin.CreateTestContext(rec)
  775. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  776. c.Request.Header.Set("Content-Type", "application/json")
  777. upstreamBody := strings.Join([]string{
  778. `data: {"id":"chatcmpl_finish","object":"chat.completion.chunk","model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"content":"done"},"finish_reason":null}]}`,
  779. "",
  780. `data: {"id":"chatcmpl_finish","object":"chat.completion.chunk","model":"deepseek-v4-pro","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
  781. "",
  782. }, "\n")
  783. upstream := &httpUpstreamRecorder{resp: &http.Response{
  784. StatusCode: http.StatusOK,
  785. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_nodone_finish"}},
  786. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  787. }}
  788. svc := &OpenAIGatewayService{
  789. cfg: rawChatCompletionsTestConfig(),
  790. httpUpstream: upstream,
  791. }
  792. result, err := svc.forwardAsRawChatCompletions(context.Background(), c, rawChatCompletionsTestAccount(), body, "")
  793. require.NoError(t, err)
  794. require.NotNil(t, result)
  795. require.Contains(t, rec.Body.String(), `"finish_reason":"stop"`)
  796. }
  797. // openAIRawStreamDisconnectedWriter 模拟客户端已断开:raw 直转路径经
  798. // WriteString 写出,故两个方法都必须失败(只覆盖 Write 会被内嵌 writer 绕过)。
  799. type openAIRawStreamDisconnectedWriter struct {
  800. gin.ResponseWriter
  801. }
  802. func (w *openAIRawStreamDisconnectedWriter) Write([]byte) (int, error) {
  803. return 0, errors.New("write failed: client disconnected")
  804. }
  805. func (w *openAIRawStreamDisconnectedWriter) WriteString(string) (int, error) {
  806. return 0, errors.New("write failed: client disconnected")
  807. }
  808. // 客户端已断开时上游随后截断:两者不可区分,沿用既有语义按已收用量正常收尾计费,
  809. // 不得把客户端离场记成上游故障。
  810. func TestForwardAsRawChatCompletions_ClientDisconnectTruncationStillBills(t *testing.T) {
  811. gin.SetMode(gin.TestMode)
  812. body := []byte(`{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"hello"}],"stream":true}`)
  813. rec := httptest.NewRecorder()
  814. c, _ := gin.CreateTestContext(rec)
  815. c.Writer = &openAIRawStreamDisconnectedWriter{ResponseWriter: c.Writer}
  816. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  817. c.Request.Header.Set("Content-Type", "application/json")
  818. upstreamBody := strings.Join([]string{
  819. `data: {"id":"chatcmpl_gone","object":"chat.completion.chunk","model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"content":"ok"}}]}`,
  820. "",
  821. }, "\n")
  822. upstream := &httpUpstreamRecorder{resp: &http.Response{
  823. StatusCode: http.StatusOK,
  824. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_gone"}},
  825. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  826. }}
  827. svc := &OpenAIGatewayService{
  828. cfg: rawChatCompletionsTestConfig(),
  829. httpUpstream: upstream,
  830. }
  831. result, err := svc.forwardAsRawChatCompletions(context.Background(), c, rawChatCompletionsTestAccount(), body, "")
  832. require.NoError(t, err)
  833. require.NotNil(t, result)
  834. }
  835. // 客户端取消会连带取消上游请求,上游读因此报 context.Canceled:同样不判为上游截断。
  836. func TestForwardAsRawChatCompletions_ClientCancelTruncationStillBills(t *testing.T) {
  837. gin.SetMode(gin.TestMode)
  838. body := []byte(`{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"hello"}],"stream":true}`)
  839. rec := httptest.NewRecorder()
  840. c, _ := gin.CreateTestContext(rec)
  841. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  842. c.Request.Header.Set("Content-Type", "application/json")
  843. upstream := &httpUpstreamRecorder{resp: &http.Response{
  844. StatusCode: http.StatusOK,
  845. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_cancel"}},
  846. Body: &openAIChatStreamReadErrorCloser{
  847. payload: []byte(`data: {"id":"chatcmpl_cancel","object":"chat.completion.chunk","model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"content":"ok"}}]}` + "\n\n"),
  848. err: context.Canceled,
  849. },
  850. }}
  851. svc := &OpenAIGatewayService{
  852. cfg: rawChatCompletionsTestConfig(),
  853. httpUpstream: upstream,
  854. }
  855. result, err := svc.forwardAsRawChatCompletions(context.Background(), c, rawChatCompletionsTestAccount(), body, "")
  856. require.NoError(t, err)
  857. require.NotNil(t, result)
  858. }
  859. func TestOpenAIRawStreamTerminalState(t *testing.T) {
  860. t.Parallel()
  861. tests := []struct {
  862. name string
  863. payloads []string
  864. clientStarted bool
  865. wantTerminated bool
  866. wantTruncated bool
  867. }{
  868. {
  869. name: "done sentinel",
  870. payloads: []string{`{"choices":[{"delta":{"content":"a"}}]}`, "[DONE]"},
  871. clientStarted: true,
  872. wantTerminated: true,
  873. },
  874. {
  875. name: "usage chunk",
  876. payloads: []string{`{"choices":[],"usage":{"prompt_tokens":1,"completion_tokens":1}}`},
  877. clientStarted: true,
  878. wantTerminated: true,
  879. },
  880. {
  881. name: "finish reason",
  882. payloads: []string{`{"choices":[{"delta":{},"finish_reason":"length"}]}`},
  883. clientStarted: true,
  884. wantTerminated: true,
  885. },
  886. {
  887. name: "null finish reason is not terminal",
  888. payloads: []string{`{"choices":[{"delta":{"content":"a"},"finish_reason":null}]}`},
  889. clientStarted: true,
  890. wantTruncated: true,
  891. },
  892. {
  893. name: "usage null is not terminal",
  894. payloads: []string{`{"choices":[{"delta":{"content":"a"}}],"usage":null}`},
  895. clientStarted: true,
  896. wantTruncated: true,
  897. },
  898. {
  899. // 上游对 stream 请求回了裸 JSON:无 data: 行,既有行为是原样透传。
  900. name: "non-sse body already forwarded",
  901. payloads: nil,
  902. clientStarted: true,
  903. },
  904. {
  905. name: "no bytes at all",
  906. payloads: nil,
  907. clientStarted: false,
  908. wantTruncated: true,
  909. },
  910. }
  911. for _, tt := range tests {
  912. t.Run(tt.name, func(t *testing.T) {
  913. t.Parallel()
  914. var state openAIRawStreamTerminalState
  915. for _, payload := range tt.payloads {
  916. state.ObserveDataLine(payload)
  917. }
  918. require.Equal(t, tt.wantTerminated, state.Terminated())
  919. require.Equal(t, tt.wantTruncated, state.IsTruncated(tt.clientStarted))
  920. })
  921. }
  922. }
  923. func TestForwardAsRawChatCompletions_ClientDisconnectDrainsUsage(t *testing.T) {
  924. gin.SetMode(gin.TestMode)
  925. body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":true}`)
  926. rec := httptest.NewRecorder()
  927. c, _ := gin.CreateTestContext(rec)
  928. c.Writer = &openAIChatFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
  929. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  930. c.Request.Header.Set("Content-Type", "application/json")
  931. upstreamBody := strings.Join([]string{
  932. `data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"content":"ok"}}]}`,
  933. "",
  934. `data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-5.4","choices":[],"usage":{"prompt_tokens":17,"completion_tokens":8,"total_tokens":25,"prompt_tokens_details":{"cached_tokens":6}}}`,
  935. "",
  936. "data: [DONE]",
  937. "",
  938. }, "\n")
  939. upstream := &httpUpstreamRecorder{resp: &http.Response{
  940. StatusCode: http.StatusOK,
  941. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_raw_disconnect"}},
  942. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  943. }}
  944. svc := &OpenAIGatewayService{
  945. cfg: rawChatCompletionsTestConfig(),
  946. httpUpstream: upstream,
  947. }
  948. account := rawChatCompletionsTestAccount()
  949. result, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "")
  950. require.NoError(t, err)
  951. require.NotNil(t, result)
  952. require.Equal(t, 17, result.Usage.InputTokens)
  953. require.Equal(t, 8, result.Usage.OutputTokens)
  954. require.Equal(t, 6, result.Usage.CacheReadInputTokens)
  955. require.True(t, gjson.GetBytes(upstream.lastBody, "stream_options.include_usage").Bool())
  956. }
  957. func TestForwardAsRawChatCompletions_UpstreamRequestIgnoresClientCancel(t *testing.T) {
  958. gin.SetMode(gin.TestMode)
  959. reqCtx, cancel := context.WithCancel(context.Background())
  960. body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":true}`)
  961. rec := httptest.NewRecorder()
  962. c, _ := gin.CreateTestContext(rec)
  963. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)).WithContext(reqCtx)
  964. c.Request.Header.Set("Content-Type", "application/json")
  965. cancel()
  966. upstreamBody := strings.Join([]string{
  967. `data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-5.4","choices":[],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}`,
  968. "",
  969. "data: [DONE]",
  970. "",
  971. }, "\n")
  972. upstream := &httpUpstreamRecorder{resp: &http.Response{
  973. StatusCode: http.StatusOK,
  974. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_raw_ctx"}},
  975. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  976. }}
  977. svc := &OpenAIGatewayService{
  978. cfg: rawChatCompletionsTestConfig(),
  979. httpUpstream: upstream,
  980. }
  981. account := rawChatCompletionsTestAccount()
  982. result, err := svc.forwardAsRawChatCompletions(reqCtx, c, account, body, "")
  983. require.NoError(t, err)
  984. require.NotNil(t, result)
  985. require.NotNil(t, upstream.lastReq)
  986. require.NoError(t, upstream.lastReq.Context().Err())
  987. }
  988. func TestForwardAsChatCompletions_UnknownResponsesSupportFallbackUsesVersionedChatURL(t *testing.T) {
  989. gin.SetMode(gin.TestMode)
  990. body := []byte(`{"model":"glm-4.5-air","messages":[{"role":"user","content":"hello"}],"stream":false}`)
  991. rec := httptest.NewRecorder()
  992. c, _ := gin.CreateTestContext(rec)
  993. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  994. c.Request.Header.Set("Content-Type", "application/json")
  995. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  996. {
  997. StatusCode: http.StatusNotFound,
  998. Header: http.Header{"Content-Type": []string{"application/json"}},
  999. Body: io.NopCloser(strings.NewReader(`{"error":{"message":"not found"}}`)),
  1000. },
  1001. {
  1002. StatusCode: http.StatusOK,
  1003. Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_raw_fallback"}},
  1004. Body: io.NopCloser(strings.NewReader(
  1005. `{"id":"chatcmpl_1","object":"chat.completion","model":"glm-4.5-air","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`,
  1006. )),
  1007. },
  1008. }}
  1009. svc := &OpenAIGatewayService{
  1010. cfg: rawChatCompletionsTestConfig(),
  1011. httpUpstream: upstream,
  1012. }
  1013. account := rawChatCompletionsTestAccount()
  1014. account.Credentials["base_url"] = "https://open.bigmodel.cn/api/paas/v4"
  1015. result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
  1016. require.NoError(t, err)
  1017. require.NotNil(t, result)
  1018. require.Equal(t, 1, result.Usage.InputTokens)
  1019. require.Equal(t, 2, result.Usage.OutputTokens)
  1020. require.Len(t, upstream.requests, 2)
  1021. require.Equal(t, "https://open.bigmodel.cn/api/paas/v4/responses", upstream.requests[0].URL.String())
  1022. require.Equal(t, "https://open.bigmodel.cn/api/paas/v4/chat/completions", upstream.requests[1].URL.String())
  1023. require.Equal(t, http.StatusOK, rec.Code)
  1024. require.Contains(t, rec.Body.String(), `"content":"ok"`)
  1025. }
  1026. func TestIsOpenAIChatUsageOnlyStreamChunk(t *testing.T) {
  1027. t.Parallel()
  1028. require.True(t, isOpenAIChatUsageOnlyStreamChunk(`{"choices":[],"usage":{"prompt_tokens":1,"completion_tokens":2}}`))
  1029. require.False(t, isOpenAIChatUsageOnlyStreamChunk(`{"choices":[{"index":0}],"usage":{"prompt_tokens":1,"completion_tokens":2}}`))
  1030. require.False(t, isOpenAIChatUsageOnlyStreamChunk(`{"choices":[]}`))
  1031. require.False(t, isOpenAIChatUsageOnlyStreamChunk(``))
  1032. }
  1033. func TestEnsureOpenAIChatStreamUsage(t *testing.T) {
  1034. t.Parallel()
  1035. body, err := ensureOpenAIChatStreamUsage([]byte(`{"model":"gpt-5.4"}`))
  1036. require.NoError(t, err)
  1037. require.True(t, gjson.GetBytes(body, "stream_options.include_usage").Bool())
  1038. body, err = ensureOpenAIChatStreamUsage([]byte(`{"model":"gpt-5.4","stream_options":{"include_usage":false}}`))
  1039. require.NoError(t, err)
  1040. require.True(t, gjson.GetBytes(body, "stream_options.include_usage").Bool())
  1041. }
  1042. func TestBufferRawChatCompletions_RejectsOversizedResponse(t *testing.T) {
  1043. gin.SetMode(gin.TestMode)
  1044. rec := httptest.NewRecorder()
  1045. c, _ := gin.CreateTestContext(rec)
  1046. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
  1047. resp := &http.Response{
  1048. StatusCode: http.StatusOK,
  1049. Header: http.Header{"Content-Type": []string{"application/json"}},
  1050. Body: io.NopCloser(strings.NewReader("toolong")),
  1051. }
  1052. svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig()}
  1053. svc.cfg.Gateway.UpstreamResponseReadMaxBytes = 3
  1054. result, err := svc.bufferRawChatCompletions(c, resp, rawChatCompletionsTestAccount(), "gpt-5.4", "gpt-5.4", "gpt-5.4", nil, nil, time.Now())
  1055. require.ErrorIs(t, err, ErrUpstreamResponseBodyTooLarge)
  1056. require.Nil(t, result)
  1057. require.Equal(t, http.StatusBadGateway, rec.Code)
  1058. }
  1059. func rawChatCompletionsTestConfig() *config.Config {
  1060. return &config.Config{
  1061. Security: config.SecurityConfig{
  1062. URLAllowlist: config.URLAllowlistConfig{
  1063. Enabled: false,
  1064. AllowInsecureHTTP: true,
  1065. },
  1066. },
  1067. }
  1068. }
  1069. func rawChatCompletionsTestAccount() *Account {
  1070. return &Account{
  1071. ID: 101,
  1072. Name: "raw-openai-apikey",
  1073. Platform: PlatformOpenAI,
  1074. Type: AccountTypeAPIKey,
  1075. Concurrency: 1,
  1076. Credentials: map[string]any{
  1077. "api_key": "sk-test",
  1078. "base_url": "http://upstream.example",
  1079. },
  1080. }
  1081. }
  1082. func largeRawChatCompletionsBody() []byte {
  1083. return []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"` +
  1084. strings.Repeat("x", openAISilentRefusalMinRequestBodyBytes) +
  1085. `"}],"stream":true}`)
  1086. }