openai_gateway_ollama_cloud_max_tokens_test.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "testing"
  8. "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
  9. "github.com/gin-gonic/gin"
  10. "github.com/stretchr/testify/require"
  11. "github.com/tidwall/gjson"
  12. )
  13. // ollamaMaxTokensCapTestAccount 构造带自定义 cap 的 Ollama Cloud usage 账号。
  14. func ollamaMaxTokensCapTestAccount(id int64, cap any) *Account {
  15. account := ollamaUsageAccount(id)
  16. account.Extra[OllamaCloudMaxTokensCapExtraKey] = cap
  17. return account
  18. }
  19. func TestOllamaCloudMaxTokensClamp(t *testing.T) {
  20. ollama := ollamaUsageAccount(101)
  21. tests := []struct {
  22. name string
  23. account *Account
  24. body string
  25. want string
  26. raw bool // want 非法 JSON 时按原始字节比较
  27. }{
  28. {
  29. name: "max_tokens above default cap is clamped",
  30. account: ollama,
  31. body: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`,
  32. want: `{"model":"gpt-oss:120b-cloud","max_tokens":65535}`,
  33. },
  34. {
  35. name: "max_completion_tokens above default cap is clamped",
  36. account: ollama,
  37. body: `{"model":"gpt-oss:120b-cloud","max_completion_tokens":131072}`,
  38. want: `{"model":"gpt-oss:120b-cloud","max_completion_tokens":65535}`,
  39. },
  40. {
  41. name: "both fields above cap are clamped",
  42. account: ollama,
  43. body: `{"model":"m","max_tokens":80000,"max_completion_tokens":90000}`,
  44. want: `{"model":"m","max_tokens":65535,"max_completion_tokens":65535}`,
  45. },
  46. {
  47. name: "values at or below default cap are kept",
  48. account: ollama,
  49. body: `{"model":"m","max_tokens":65535,"max_completion_tokens":4096}`,
  50. want: `{"model":"m","max_tokens":65535,"max_completion_tokens":4096}`,
  51. },
  52. {
  53. name: "custom extra cap is applied",
  54. account: ollamaMaxTokensCapTestAccount(102, 32768),
  55. body: `{"model":"m","max_tokens":50000}`,
  56. want: `{"model":"m","max_tokens":32768}`,
  57. },
  58. {
  59. name: "extra cap zero disables clamping",
  60. account: ollamaMaxTokensCapTestAccount(103, 0),
  61. body: `{"model":"m","max_tokens":50000}`,
  62. want: `{"model":"m","max_tokens":50000}`,
  63. },
  64. {
  65. name: "non-numeric extra cap falls back to default",
  66. account: ollamaMaxTokensCapTestAccount(104, "abc"),
  67. body: `{"model":"m","max_tokens":100000}`,
  68. want: `{"model":"m","max_tokens":65535}`,
  69. },
  70. {
  71. name: "invalid json is left untouched",
  72. account: ollama,
  73. body: `{"model":"m","max_tokens":`,
  74. want: `{"model":"m","max_tokens":`,
  75. raw: true,
  76. },
  77. {
  78. name: "non-integer max_tokens is left untouched",
  79. account: ollama,
  80. body: `{"model":"m","max_tokens":1.5}`,
  81. want: `{"model":"m","max_tokens":1.5}`,
  82. },
  83. {
  84. name: "missing max_tokens is left untouched",
  85. account: ollama,
  86. body: `{"model":"m"}`,
  87. want: `{"model":"m"}`,
  88. },
  89. }
  90. for _, test := range tests {
  91. t.Run(test.name, func(t *testing.T) {
  92. got := clampOllamaCloudMaxTokens(test.account, []byte(test.body))
  93. if test.raw {
  94. require.Equal(t, test.want, string(got))
  95. return
  96. }
  97. require.JSONEq(t, test.want, string(got))
  98. })
  99. }
  100. }
  101. func TestOllamaCloudMaxTokensCap(t *testing.T) {
  102. require.Equal(t, int64(65535), ollamaCloudMaxTokensCap(nil))
  103. require.Equal(t, int64(65535), ollamaCloudMaxTokensCap(ollamaUsageAccount(201)))
  104. tests := []struct {
  105. name string
  106. cap any
  107. want int64
  108. }{
  109. {"float64", float64(32768), 32768},
  110. {"int", 40000, 40000},
  111. {"int64", int64(50000), 50000},
  112. {"json.Number", json.Number("60000"), 60000},
  113. {"json.Number invalid", json.Number("abc"), 65535},
  114. {"zero disables", 0, 0},
  115. {"negative disables", int64(-1), -1},
  116. {"string falls back", "abc", 65535},
  117. {"bool falls back", true, 65535},
  118. }
  119. for _, test := range tests {
  120. t.Run(test.name, func(t *testing.T) {
  121. account := ollamaMaxTokensCapTestAccount(202, test.cap)
  122. require.Equal(t, test.want, ollamaCloudMaxTokensCap(account))
  123. })
  124. }
  125. }
  126. // TestApplyOllamaCloudRawChatCompletionsRequestClampsMaxTokens 验证 reasoning 钩子
  127. // 与 token clamp 已解耦:reasoning 钩子只做 reasoning 归一化,clamp 由独立 token
  128. // 钩子 clampOllamaCloudUpstreamMaxTokens 在出站时接续执行(两条 raw CC 出站路径
  129. // 均依次调用两者,端到端行为不变)。
  130. func TestApplyOllamaCloudRawChatCompletionsRequestClampsMaxTokens(t *testing.T) {
  131. body := []byte(`{"model":"deepseek-chat","max_tokens":100000}`)
  132. // Ollama Cloud 账号:reasoning 钩子不再 clamp,字节级原样。
  133. ollama := ollamaCloudRawChatCompletionsTestAccount()
  134. require.Equal(t, string(body), string(applyOllamaCloudRawChatCompletionsRequest(ollama, body)))
  135. // 独立 token 钩子接续 clamp 到既有默认 cap。
  136. require.JSONEq(t, `{"model":"deepseek-chat","max_tokens":65535}`,
  137. string(clampOllamaCloudUpstreamMaxTokens(ollama, body)))
  138. // 官方 DeepSeek(api.deepseek.com + force_chat_completions)→ 字节级不变。
  139. official := rawChatCompletionsTestAccount()
  140. official.Credentials["base_url"] = "https://api.deepseek.com"
  141. official.Extra = map[string]any{
  142. openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions),
  143. }
  144. require.Equal(t, body, applyOllamaCloudRawChatCompletionsRequest(official, body))
  145. require.Equal(t, string(body), string(clampOllamaCloudUpstreamMaxTokens(official, body)))
  146. // ollama.com 但无 force_chat_completions:reasoning 钩子不生效;独立钩子按
  147. // DeepSeek 系模型判定仍 clamp(DeepSeek 覆盖不依赖 responses_mode extra)。
  148. noForce := ollamaCloudRawChatCompletionsTestAccount()
  149. noForce.Extra = nil
  150. require.Equal(t, body, applyOllamaCloudRawChatCompletionsRequest(noForce, body))
  151. require.JSONEq(t, `{"model":"deepseek-chat","max_tokens":65535}`,
  152. string(clampOllamaCloudUpstreamMaxTokens(noForce, body)))
  153. // 空 body → 原样返回。
  154. require.Equal(t, []byte(nil), applyOllamaCloudRawChatCompletionsRequest(ollama, nil))
  155. require.Equal(t, []byte{}, applyOllamaCloudRawChatCompletionsRequest(ollama, []byte{}))
  156. }
  157. // ollamaUpstreamTestAccount 构造挂在实际 ollama.com 上游的 APIKey 账号。平台标签
  158. // 不参与 clamp 判定,这里用不同平台仅为了覆盖生产里的挂载方式;base_url 与生产
  159. // 实测一致(https://ollama.com/v1)。
  160. func ollamaUpstreamTestAccount(platform string, id int64) *Account {
  161. return &Account{
  162. ID: id,
  163. Name: "ollama-cloud",
  164. Platform: platform,
  165. Type: AccountTypeAPIKey,
  166. Credentials: map[string]any{
  167. "api_key": "sk-test",
  168. "base_url": "https://ollama.com/v1",
  169. },
  170. Extra: map[string]any{},
  171. }
  172. }
  173. // officialDeepSeekTestAccount 构造官方 DeepSeek 账号,extra 里带残留的 Ollama
  174. // usage 快照键:usage extra 不得把非 Ollama host 识别为 Ollama 上游。
  175. func officialDeepSeekTestAccount(id int64) *Account {
  176. return &Account{
  177. ID: id,
  178. Name: "official-deepseek",
  179. Platform: PlatformDeepseek,
  180. Type: AccountTypeAPIKey,
  181. Credentials: map[string]any{
  182. "api_key": "sk-test",
  183. "base_url": "https://api.deepseek.com",
  184. },
  185. Extra: map[string]any{
  186. OllamaCloudUsageSnapshotExtraKey: map[string]any{"status": "ok"},
  187. },
  188. }
  189. }
  190. func TestClampOllamaCloudUpstreamMaxTokens(t *testing.T) {
  191. t.Parallel()
  192. tests := []struct {
  193. name string
  194. account *Account
  195. body string
  196. want string
  197. }{
  198. {
  199. // 生产问题场景:deepseek 平台 Ollama 账号,无 openai_responses_mode extra,
  200. // raw CC 直转时 max_tokens 超限被上游 400。
  201. name: "deepseek platform ollama.com without responses_mode is clamped",
  202. account: ollamaUpstreamTestAccount(PlatformDeepseek, 301),
  203. body: `{"model":"deepseek-v4-flash","max_tokens":256000}`,
  204. want: `{"model":"deepseek-v4-flash","max_tokens":65535}`,
  205. },
  206. {
  207. name: "max_completion_tokens is clamped too",
  208. account: ollamaUpstreamTestAccount(PlatformDeepseek, 301),
  209. body: `{"model":"deepseek-v4-flash","max_completion_tokens":256000}`,
  210. want: `{"model":"deepseek-v4-flash","max_completion_tokens":65535}`,
  211. },
  212. {
  213. // 新增范围只限 DeepSeek 系模型:kimi/zhipu 平台挂 ollama.com 跑非
  214. // DeepSeek 模型的请求保持不变。
  215. name: "kimi platform non-deepseek model untouched",
  216. account: ollamaUpstreamTestAccount(PlatformKimi, 302),
  217. body: `{"model":"k3-256k","max_tokens":256000}`,
  218. want: `{"model":"k3-256k","max_tokens":256000}`,
  219. },
  220. {
  221. name: "zhipu platform non-deepseek model untouched",
  222. account: ollamaUpstreamTestAccount(PlatformZhipu, 302),
  223. body: `{"model":"glm-4.7","max_tokens":256000}`,
  224. want: `{"model":"glm-4.7","max_tokens":256000}`,
  225. },
  226. {
  227. // 既有 openai 平台 Ollama 账号对 DeepSeek 模型的 clamp 幂等保持。
  228. name: "openai platform ollama.com deepseek model stays clamped",
  229. account: ollamaUpstreamTestAccount(PlatformOpenAI, 303),
  230. body: `{"model":"deepseek-v4-flash","max_tokens":70000}`,
  231. want: `{"model":"deepseek-v4-flash","max_tokens":65535}`,
  232. },
  233. {
  234. // 既有 openai 平台 Ollama(真实 ollama.com + force_chat_completions)对
  235. // 非 DeepSeek 模型的 clamp 保留。
  236. name: "openai platform ollama.com non-deepseek model keeps legacy clamp",
  237. account: ollamaCloudRawChatCompletionsTestAccount(),
  238. body: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`,
  239. want: `{"model":"gpt-oss:120b-cloud","max_tokens":65535}`,
  240. },
  241. {
  242. // 非 DeepSeek 模型不扩展到其它平台。
  243. name: "openai platform ollama.com non-deepseek model without force_cc untouched",
  244. account: func() *Account {
  245. account := ollamaUpstreamTestAccount(PlatformOpenAI, 303)
  246. account.Extra = nil
  247. return account
  248. }(),
  249. body: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`,
  250. want: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`,
  251. },
  252. {
  253. // 残留 usage extra 不得把非 Ollama host 变成 Ollama(旧 reasoning 钩子内的
  254. // clamp 会命中该账号,解耦后已封堵)。
  255. name: "openai platform non-ollama host with leftover usage extra untouched",
  256. account: func() *Account {
  257. account := ollamaCloudRawChatCompletionsTestAccount()
  258. account.Credentials["base_url"] = "https://example.invalid/v1"
  259. account.Extra[OllamaCloudUsageSnapshotExtraKey] = map[string]any{"status": "ok"}
  260. return account
  261. }(),
  262. body: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`,
  263. want: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`,
  264. },
  265. {
  266. name: "extra cap zero disables clamping",
  267. account: func() *Account {
  268. account := ollamaUpstreamTestAccount(PlatformDeepseek, 304)
  269. account.Extra[OllamaCloudMaxTokensCapExtraKey] = 0
  270. return account
  271. }(),
  272. body: `{"model":"deepseek-v4-flash","max_tokens":256000}`,
  273. want: `{"model":"deepseek-v4-flash","max_tokens":256000}`,
  274. },
  275. {
  276. name: "value at cap is untouched",
  277. account: ollamaUpstreamTestAccount(PlatformDeepseek, 304),
  278. body: `{"model":"deepseek-v4-flash","max_tokens":65535}`,
  279. want: `{"model":"deepseek-v4-flash","max_tokens":65535}`,
  280. },
  281. }
  282. for _, test := range tests {
  283. t.Run(test.name, func(t *testing.T) {
  284. t.Parallel()
  285. require.JSONEq(t, test.want, string(clampOllamaCloudUpstreamMaxTokens(test.account, []byte(test.body))))
  286. })
  287. }
  288. // 非 Ollama 实际上游(api.deepseek.com)即使残留 usage extra 也不 clamp。
  289. official := officialDeepSeekTestAccount(305)
  290. body := []byte(`{"model":"deepseek-v4-flash","max_tokens":256000,"max_completion_tokens":256000}`)
  291. require.Equal(t, string(body), string(clampOllamaCloudUpstreamMaxTokens(official, body)))
  292. // 空 body / nil 账号原样返回。
  293. require.Equal(t, []byte(nil), clampOllamaCloudUpstreamMaxTokens(official, nil))
  294. }
  295. // TestForwardAsRawChatCompletions_DeepseekOllamaCloudClampsMaxTokens 端到端验证 raw
  296. // CC 出站钩子:判定用模型映射后的真实出站 model,官方 DeepSeek 字节级不变。
  297. func TestForwardAsRawChatCompletions_DeepseekOllamaCloudClampsMaxTokens(t *testing.T) {
  298. gin.SetMode(gin.TestMode)
  299. account := ollamaUpstreamTestAccount(PlatformDeepseek, 311)
  300. account.Credentials["api_protocol"] = APIProtocolChatCompletions
  301. account.Credentials["model_mapping"] = map[string]any{"deepseek-chat": "deepseek-v4-flash"}
  302. body := []byte(`{"model":"deepseek-chat","max_tokens":256000,"messages":[{"role":"user","content":"hi"}],"stream":false}`)
  303. upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
  304. svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
  305. _, err := svc.forwardAsRawChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", body), account, body, "")
  306. require.Error(t, err)
  307. require.Equal(t, "deepseek-v4-flash", gjson.GetBytes(upstream.lastBody, "model").String())
  308. require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_tokens").Int())
  309. // 官方 DeepSeek(api.deepseek.com)+ 残留 usage extra:字节级不变。
  310. official := officialDeepSeekTestAccount(312)
  311. official.Credentials["api_protocol"] = APIProtocolChatCompletions
  312. officialBody := []byte(`{"model":"deepseek-v4-flash","max_tokens":256000,"messages":[{"role":"user","content":"hi"}],"stream":false}`)
  313. officialUpstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
  314. officialSvc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: officialUpstream}
  315. _, err = officialSvc.forwardAsRawChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", officialBody), official, officialBody, "")
  316. require.Error(t, err)
  317. require.Equal(t, string(officialBody), string(officialUpstream.lastBody))
  318. }
  319. // TestForwardResponsesViaRawChatCompletions_DeepseekOllamaCloudClampsMaxTokens 验证
  320. // /v1/responses 降级到 raw CC 的路径确实经过同一个独立 token 钩子(Responses 请求的
  321. // max_output_tokens 经 apicompat 转成 CC 的 max_completion_tokens)。
  322. func TestForwardResponsesViaRawChatCompletions_DeepseekOllamaCloudClampsMaxTokens(t *testing.T) {
  323. gin.SetMode(gin.TestMode)
  324. account := ollamaUpstreamTestAccount(PlatformDeepseek, 321)
  325. account.Credentials["api_protocol"] = APIProtocolChatCompletions
  326. body := []byte(`{"model":"deepseek-v4-flash","input":"Reply with exactly OK and nothing else.","max_output_tokens":256000,"stream":false}`)
  327. upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
  328. svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
  329. _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body)
  330. require.Error(t, err)
  331. require.Equal(t, "https://ollama.com/v1/chat/completions", upstream.lastReq.URL.String())
  332. require.Equal(t, "deepseek-v4-flash", gjson.GetBytes(upstream.lastBody, "model").String())
  333. require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_completion_tokens").Int())
  334. }
  335. // TestForwardResponsesClampsOllamaCloudMaxOutputTokens 覆盖原生 /v1/responses 路径
  336. // (openai force_responses 与 deepseek api_protocol=responses)的 max_output_tokens
  337. // clamp,实测依据:POST ollama.com/v1/responses max_output_tokens=256000 被上游以
  338. // "max_tokens (256000) exceeds model's maximum output tokens (65536)" 400 拒绝。
  339. func TestForwardResponsesClampsOllamaCloudMaxOutputTokens(t *testing.T) {
  340. gin.SetMode(gin.TestMode)
  341. responsesBody := []byte(`{"model":"deepseek-v4-flash","input":"Reply with exactly OK and nothing else.","max_output_tokens":256000,"stream":false}`)
  342. run := func(account *Account, body []byte) (*httpUpstreamRecorder, error) {
  343. upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
  344. svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
  345. _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body)
  346. return upstream, err
  347. }
  348. t.Run("deepseek platform native responses is clamped", func(t *testing.T) {
  349. account := ollamaUpstreamTestAccount(PlatformDeepseek, 331)
  350. account.Credentials["api_protocol"] = APIProtocolResponses
  351. upstream, err := run(account, responsesBody)
  352. require.Error(t, err)
  353. require.Equal(t, "https://ollama.com/v1/responses", upstream.lastReq.URL.String())
  354. require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int())
  355. })
  356. t.Run("openai platform force_responses is clamped", func(t *testing.T) {
  357. account := ollamaUpstreamTestAccount(PlatformOpenAI, 332)
  358. account.Extra[openai_compat.ExtraKeyResponsesMode] = string(openai_compat.ResponsesSupportModeForceResponses)
  359. upstream, err := run(account, responsesBody)
  360. require.Error(t, err)
  361. require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int())
  362. })
  363. t.Run("official deepseek with leftover usage extra is untouched", func(t *testing.T) {
  364. account := officialDeepSeekTestAccount(333)
  365. account.Credentials["api_protocol"] = APIProtocolResponses
  366. upstream, err := run(account, responsesBody)
  367. require.Error(t, err)
  368. require.Equal(t, int64(256000), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int())
  369. })
  370. t.Run("at cap is untouched", func(t *testing.T) {
  371. account := ollamaUpstreamTestAccount(PlatformDeepseek, 334)
  372. account.Credentials["api_protocol"] = APIProtocolResponses
  373. atCap := []byte(`{"model":"deepseek-v4-flash","input":"hi","max_output_tokens":65535,"stream":false}`)
  374. upstream, err := run(account, atCap)
  375. require.Error(t, err)
  376. require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int())
  377. })
  378. t.Run("extra cap zero disables clamping", func(t *testing.T) {
  379. account := ollamaUpstreamTestAccount(PlatformDeepseek, 335)
  380. account.Credentials["api_protocol"] = APIProtocolResponses
  381. account.Extra[OllamaCloudMaxTokensCapExtraKey] = 0
  382. upstream, err := run(account, responsesBody)
  383. require.Error(t, err)
  384. require.Equal(t, int64(256000), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int())
  385. })
  386. }
  387. // TestForwardResponsesClampUsesResponsesUpstreamBaseURL 验证原生 Responses clamp 按
  388. // 实际 Responses 上游判定:adaptive 账号取 api_base_urls 的 responses 地址,而非 CC
  389. // 地址(与 buildUpstreamRequest 的 URL 选择一致)。
  390. func TestForwardResponsesClampUsesResponsesUpstreamBaseURL(t *testing.T) {
  391. gin.SetMode(gin.TestMode)
  392. body := []byte(`{"model":"deepseek-v4-flash","input":"hi","max_output_tokens":256000,"stream":false}`)
  393. run := func(account *Account) (*httpUpstreamRecorder, error) {
  394. upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
  395. svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
  396. _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body)
  397. return upstream, err
  398. }
  399. t.Run("ollama CC base but non-ollama responses base is not clamped", func(t *testing.T) {
  400. account := ollamaUpstreamTestAccount(PlatformDeepseek, 341)
  401. account.Credentials["api_protocol"] = APIProtocolAdaptive
  402. account.Credentials["api_base_urls"] = map[string]any{
  403. APIProtocolChatCompletions: "https://ollama.com/v1",
  404. APIProtocolResponses: "https://api.deepseek.com",
  405. }
  406. upstream, err := run(account)
  407. require.Error(t, err)
  408. require.Equal(t, "https://api.deepseek.com/responses", upstream.lastReq.URL.String())
  409. require.Equal(t, int64(256000), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int())
  410. })
  411. t.Run("non-ollama CC base but ollama responses base is clamped", func(t *testing.T) {
  412. account := ollamaUpstreamTestAccount(PlatformDeepseek, 342)
  413. account.Credentials["api_protocol"] = APIProtocolAdaptive
  414. account.Credentials["api_base_urls"] = map[string]any{
  415. APIProtocolChatCompletions: "https://api.deepseek.com",
  416. APIProtocolResponses: "https://ollama.com/v1",
  417. }
  418. upstream, err := run(account)
  419. require.Error(t, err)
  420. require.Equal(t, "https://ollama.com/v1/responses", upstream.lastReq.URL.String())
  421. require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int())
  422. })
  423. }
  424. // TestForwardResponsesClampsOllamaCloudMaxOutputTokensForCodexClients 回归:原生
  425. // /v1/responses 的 Ollama Cloud clamp 必须对真实 Codex 客户端同样生效(clamp 曾位于
  426. // !isCodexCLI 块内被跳过,ollama.com 对 >65535 的 max_output_tokens 一律 400)。
  427. func TestForwardResponsesClampsOllamaCloudMaxOutputTokensForCodexClients(t *testing.T) {
  428. gin.SetMode(gin.TestMode)
  429. responsesBody := []byte(`{"model":"deepseek-v4-flash","input":"Reply with exactly OK and nothing else.","max_output_tokens":256000,"stream":false}`)
  430. ollamaResponsesAccount := func() *Account {
  431. account := ollamaUpstreamTestAccount(PlatformDeepseek, 336)
  432. account.Credentials["api_protocol"] = APIProtocolResponses
  433. return account
  434. }
  435. officialResponsesAccount := func() *Account {
  436. account := officialDeepSeekTestAccount(337)
  437. account.Credentials["api_protocol"] = APIProtocolResponses
  438. return account
  439. }
  440. codexUA := func(c *gin.Context) {
  441. c.Request.Header.Set("User-Agent", "codex_cli_rs/0.55.0 (Ubuntu 24.04; x86_64)")
  442. }
  443. codexOriginator := func(c *gin.Context) { c.Request.Header.Set("originator", "codex-tui") }
  444. tests := []struct {
  445. name string
  446. account *Account
  447. setHeaders func(*gin.Context)
  448. forceCodexCLI bool
  449. wantCap int64
  450. wantURL string
  451. }{
  452. {
  453. name: "codex UA is clamped",
  454. account: ollamaResponsesAccount(),
  455. setHeaders: codexUA,
  456. wantCap: 65535,
  457. wantURL: "https://ollama.com/v1/responses",
  458. },
  459. {
  460. name: "codex originator is clamped",
  461. account: ollamaResponsesAccount(),
  462. setHeaders: codexOriginator,
  463. wantCap: 65535,
  464. wantURL: "https://ollama.com/v1/responses",
  465. },
  466. {
  467. name: "ForceCodexCLI is clamped",
  468. account: ollamaResponsesAccount(),
  469. forceCodexCLI: true,
  470. wantCap: 65535,
  471. wantURL: "https://ollama.com/v1/responses",
  472. },
  473. {
  474. // 非 Codex 正常路径对照:与既有用例行为一致,不回归。
  475. name: "non-codex control is clamped",
  476. account: ollamaResponsesAccount(),
  477. wantCap: 65535,
  478. wantURL: "https://ollama.com/v1/responses",
  479. },
  480. {
  481. // 非 Ollama 上游:同样的 Codex 头不 clamp。
  482. name: "official deepseek with codex UA is untouched",
  483. account: officialResponsesAccount(),
  484. setHeaders: codexUA,
  485. wantCap: 256000,
  486. wantURL: "https://api.deepseek.com/responses",
  487. },
  488. }
  489. for _, test := range tests {
  490. t.Run(test.name, func(t *testing.T) {
  491. upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
  492. cfg := rawChatCompletionsTestConfig()
  493. cfg.Gateway.ForceCodexCLI = test.forceCodexCLI
  494. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
  495. c := adaptiveProtocolTestContext("/v1/responses", responsesBody)
  496. if test.setHeaders != nil {
  497. test.setHeaders(c)
  498. }
  499. _, err := svc.Forward(context.Background(), c, test.account, responsesBody)
  500. require.Error(t, err)
  501. require.Equal(t, test.wantURL, upstream.lastReq.URL.String())
  502. require.Equal(t, test.wantCap, gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int())
  503. })
  504. }
  505. }