ollama_cloud_messages_max_tokens_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "net/http"
  6. "net/http/httptest"
  7. "strconv"
  8. "testing"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/gin-gonic/gin"
  11. "github.com/stretchr/testify/require"
  12. "github.com/tidwall/gjson"
  13. )
  14. // Anthropic Messages 出站(三个 builder)的 Ollama Cloud DeepSeek max_tokens clamp
  15. // 接线验证。实测背景:POST ollama.com/v1/messages(Bearer)max_tokens=256000 被上游
  16. // 以 "max_tokens (256000) exceeds model's maximum output tokens (65536)" 400 拒绝。
  17. // 用例直接调用各 builder 实际生成 *http.Request,同时检查出站 URL 与 wire body。
  18. func messagesClampTestConfig() *config.Config {
  19. cfg := rawChatCompletionsTestConfig()
  20. cfg.Gateway = config.GatewayConfig{MaxLineSize: defaultMaxLineSize}
  21. return cfg
  22. }
  23. func newMessagesClampTestContext(t *testing.T) *gin.Context {
  24. t.Helper()
  25. gin.SetMode(gin.TestMode)
  26. rec := httptest.NewRecorder()
  27. c, _ := gin.CreateTestContext(rec)
  28. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  29. return c
  30. }
  31. // messagesClampOllamaAccount 构造挂在实际 ollama.com 上游的 APIKey 账号。
  32. // builder A / B 的 Messages base 取 GetBaseURL()(凭证 base_url),builder C 的
  33. // Anthropic 协议 base 取 GetAnthropicProtocolBaseURL()(anthropic 协议时同为
  34. // 凭证 base_url)。
  35. func messagesClampOllamaAccount(id int64, platform string) *Account {
  36. return &Account{
  37. ID: id,
  38. Name: "ollama-cloud-messages",
  39. Platform: platform,
  40. Type: AccountTypeAPIKey,
  41. Credentials: map[string]any{
  42. "api_key": "sk-test",
  43. "base_url": "https://ollama.com",
  44. },
  45. Extra: map[string]any{},
  46. }
  47. }
  48. func messagesClampBody(model string, maxTokens int) []byte {
  49. return []byte(`{"model":"` + model + `","max_tokens":` + strconv.Itoa(maxTokens) +
  50. `,"stream":false,"messages":[{"role":"user","content":"hi"}]}`)
  51. }
  52. // TestBuildUpstreamRequest_ClampsOllamaCloudDeepSeekMaxTokens 覆盖 GatewayService
  53. // .buildUpstreamRequest(Anthropic 平台原生 Messages + 共享重试路径)。
  54. func TestBuildUpstreamRequest_ClampsOllamaCloudDeepSeekMaxTokens(t *testing.T) {
  55. svc := &GatewayService{cfg: messagesClampTestConfig()}
  56. c := newMessagesClampTestContext(t)
  57. body := messagesClampBody("deepseek-v4-flash", 256000)
  58. account := messagesClampOllamaAccount(401, PlatformAnthropic)
  59. req, wireBody, err := svc.buildUpstreamRequest(
  60. context.Background(), c, account, body, "sk-test", "api_key",
  61. "deepseek-v4-flash", false, false,
  62. )
  63. require.NoError(t, err)
  64. require.Equal(t, "https://ollama.com/v1/messages?beta=true", req.URL.String())
  65. require.Equal(t, "deepseek-v4-flash", gjson.GetBytes(wireBody, "model").String())
  66. require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int())
  67. t.Run("official anthropic base is untouched", func(t *testing.T) {
  68. official := messagesClampOllamaAccount(402, PlatformAnthropic)
  69. official.Credentials["base_url"] = "https://api.anthropic.com"
  70. _, wire, err := svc.buildUpstreamRequest(
  71. context.Background(), c, official, body, "sk-test", "api_key",
  72. "deepseek-v4-flash", false, false,
  73. )
  74. require.NoError(t, err)
  75. require.Equal(t, int64(256000), gjson.GetBytes(wire, "max_tokens").Int())
  76. })
  77. t.Run("non deepseek model untouched", func(t *testing.T) {
  78. nonDeepSeek := messagesClampBody("k3-256k", 256000)
  79. _, wire, err := svc.buildUpstreamRequest(
  80. context.Background(), c, account, nonDeepSeek, "sk-test", "api_key",
  81. "k3-256k", false, false,
  82. )
  83. require.NoError(t, err)
  84. require.Equal(t, int64(256000), gjson.GetBytes(wire, "max_tokens").Int())
  85. })
  86. t.Run("at cap and extra cap zero untouched", func(t *testing.T) {
  87. atCap := messagesClampBody("deepseek-v4-flash", 65535)
  88. _, wire, err := svc.buildUpstreamRequest(
  89. context.Background(), c, account, atCap, "sk-test", "api_key",
  90. "deepseek-v4-flash", false, false,
  91. )
  92. require.NoError(t, err)
  93. require.Equal(t, int64(65535), gjson.GetBytes(wire, "max_tokens").Int())
  94. disabled := messagesClampOllamaAccount(403, PlatformAnthropic)
  95. disabled.Extra[OllamaCloudMaxTokensCapExtraKey] = 0
  96. _, wire, err = svc.buildUpstreamRequest(
  97. context.Background(), c, disabled, body, "sk-test", "api_key",
  98. "deepseek-v4-flash", false, false,
  99. )
  100. require.NoError(t, err)
  101. require.Equal(t, int64(256000), gjson.GetBytes(wire, "max_tokens").Int())
  102. })
  103. }
  104. // TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_ClampsOllamaCloudDeepSeekMaxTokens
  105. // 覆盖 Anthropic 平台 APIKey passthrough builder。
  106. func TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_ClampsOllamaCloudDeepSeekMaxTokens(t *testing.T) {
  107. svc := &GatewayService{cfg: messagesClampTestConfig()}
  108. c := newMessagesClampTestContext(t)
  109. body := messagesClampBody("deepseek-v4-flash", 256000)
  110. account := messagesClampOllamaAccount(411, PlatformAnthropic)
  111. req, wireBody, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
  112. context.Background(), c, account, body, "sk-test",
  113. )
  114. require.NoError(t, err)
  115. require.Equal(t, "https://ollama.com/v1/messages?beta=true", req.URL.String())
  116. require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int())
  117. // 官方 Anthropic(api.anthropic.com)即使 max_tokens 超过 65535 也字节级不变。
  118. official := newAnthropicAPIKeyAccountForTest()
  119. _, wire, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
  120. context.Background(), c, official, body, "upstream-anthropic-key",
  121. )
  122. require.NoError(t, err)
  123. require.Equal(t, int64(256000), gjson.GetBytes(wire, "max_tokens").Int())
  124. }
  125. // TestBuildNativeAnthropicUpstreamRequest_ClampsOllamaCloudDeepSeekMaxTokens 覆盖
  126. // 国产供应商原生 Anthropic 协议 builder(Messages / Responses / CC 桥接共享)。
  127. func TestBuildNativeAnthropicUpstreamRequest_ClampsOllamaCloudDeepSeekMaxTokens(t *testing.T) {
  128. svc := &OpenAIGatewayService{cfg: messagesClampTestConfig()}
  129. c := newMessagesClampTestContext(t)
  130. body := messagesClampBody("deepseek-v4-flash", 256000)
  131. t.Run("anthropic protocol ollama base is clamped", func(t *testing.T) {
  132. account := messagesClampOllamaAccount(421, PlatformDeepseek)
  133. account.Credentials["api_protocol"] = APIProtocolAnthropic
  134. targetURL, err := svc.nativeAnthropicTargetURL(account)
  135. require.NoError(t, err)
  136. require.Equal(t, "https://ollama.com/v1/messages", targetURL)
  137. req, wireBody, err := svc.buildNativeAnthropicUpstreamRequest(
  138. context.Background(), c, account, body, "sk-test", targetURL,
  139. )
  140. require.NoError(t, err)
  141. require.Equal(t, "https://ollama.com/v1/messages", req.URL.String())
  142. require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int())
  143. })
  144. // adaptive 账号按 Anthropic 协议地址(api_base_urls[anthropic])判定,而非 CC
  145. // 地址:anthropic 指向 ollama.com、chat_completions 指向官方 DeepSeek 时命中。
  146. t.Run("adaptive uses anthropic base not cc base", func(t *testing.T) {
  147. account := messagesClampOllamaAccount(422, PlatformDeepseek)
  148. account.Credentials["api_protocol"] = APIProtocolAdaptive
  149. account.Credentials["api_base_urls"] = map[string]any{
  150. APIProtocolAnthropic: "https://ollama.com",
  151. APIProtocolChatCompletions: "https://api.deepseek.com",
  152. }
  153. targetURL, err := svc.nativeAnthropicTargetURL(account)
  154. require.NoError(t, err)
  155. req, wireBody, err := svc.buildNativeAnthropicUpstreamRequest(
  156. context.Background(), c, account, body, "sk-test", targetURL,
  157. )
  158. require.NoError(t, err)
  159. require.Equal(t, "https://ollama.com/v1/messages", req.URL.String())
  160. require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int())
  161. })
  162. // 反向:实际 Anthropic 上游非 ollama.com(官方 anthropic 端点),即使 CC 地址
  163. // 指向 ollama.com 且残留 usage extra,也不 clamp。
  164. t.Run("non-ollama anthropic base with cc ollama base and usage extra untouched", func(t *testing.T) {
  165. account := messagesClampOllamaAccount(423, PlatformDeepseek)
  166. account.Credentials["api_protocol"] = APIProtocolAdaptive
  167. account.Credentials["api_base_urls"] = map[string]any{
  168. APIProtocolAnthropic: "https://api.deepseek.com/anthropic",
  169. APIProtocolChatCompletions: "https://ollama.com",
  170. }
  171. account.Extra[OllamaCloudUsageSnapshotExtraKey] = map[string]any{"status": "ok"}
  172. targetURL, err := svc.nativeAnthropicTargetURL(account)
  173. require.NoError(t, err)
  174. req, wireBody, err := svc.buildNativeAnthropicUpstreamRequest(
  175. context.Background(), c, account, body, "sk-test", targetURL,
  176. )
  177. require.NoError(t, err)
  178. require.Equal(t, "https://api.deepseek.com/anthropic/v1/messages", req.URL.String())
  179. require.Equal(t, int64(256000), gjson.GetBytes(wireBody, "max_tokens").Int())
  180. })
  181. // 官方 DeepSeek Anthropic 协议(平台默认端点)字节级不变。
  182. t.Run("official deepseek anthropic default untouched", func(t *testing.T) {
  183. account := &Account{
  184. ID: 424,
  185. Name: "official-deepseek-anthropic",
  186. Platform: PlatformDeepseek,
  187. Type: AccountTypeAPIKey,
  188. Credentials: map[string]any{
  189. "api_key": "sk-test",
  190. "api_protocol": APIProtocolAnthropic,
  191. },
  192. Extra: map[string]any{},
  193. }
  194. targetURL, err := svc.nativeAnthropicTargetURL(account)
  195. require.NoError(t, err)
  196. _, wireBody, err := svc.buildNativeAnthropicUpstreamRequest(
  197. context.Background(), c, account, body, "sk-test", targetURL,
  198. )
  199. require.NoError(t, err)
  200. require.Equal(t, int64(256000), gjson.GetBytes(wireBody, "max_tokens").Int())
  201. })
  202. // 非 DeepSeek 模型(映射后出站 model)不变。
  203. t.Run("non deepseek model untouched", func(t *testing.T) {
  204. account := messagesClampOllamaAccount(425, PlatformDeepseek)
  205. account.Credentials["api_protocol"] = APIProtocolAnthropic
  206. nonDeepSeek := messagesClampBody("glm-4.7", 256000)
  207. targetURL, err := svc.nativeAnthropicTargetURL(account)
  208. require.NoError(t, err)
  209. _, wireBody, err := svc.buildNativeAnthropicUpstreamRequest(
  210. context.Background(), c, account, nonDeepSeek, "sk-test", targetURL,
  211. )
  212. require.NoError(t, err)
  213. require.Equal(t, int64(256000), gjson.GetBytes(wireBody, "max_tokens").Int())
  214. })
  215. }
  216. // messagesClampProductionAccount 复刻生产账号 162:platform=deepseek、type=apikey、
  217. // api_protocol=adaptive、base_url 与 api_base_urls 均指向 ollama.com(anthropic 地址
  218. // 带 trailing '/',CC/Responses 地址带 /v1 后缀)。
  219. func messagesClampProductionAccount(id int64) *Account {
  220. return &Account{
  221. ID: id,
  222. Name: "ollama-cloud-prod",
  223. Platform: PlatformDeepseek,
  224. Type: AccountTypeAPIKey,
  225. Credentials: map[string]any{
  226. "api_key": "sk-test",
  227. "api_protocol": APIProtocolAdaptive,
  228. "base_url": "https://ollama.com/v1",
  229. "api_base_urls": map[string]any{
  230. APIProtocolAnthropic: "https://ollama.com/",
  231. APIProtocolResponses: "https://ollama.com/v1",
  232. APIProtocolChatCompletions: "https://ollama.com/v1",
  233. },
  234. },
  235. Extra: map[string]any{},
  236. }
  237. }
  238. // TestBuildNativeAnthropicUpstreamRequest_ClampsTrailingSlashOllamaBase 复刻生产
  239. // 失败:anthropic 协议地址配置为 "https://ollama.com/"(trailing '/')。真实出站
  240. // URL 组装(ValidateURLFormat 与 nativeAnthropicTargetURL 均做 TrimRight "/")会
  241. // 正确打到 https://ollama.com/v1/messages,因此 clamp 判定也必须对同一归一化后的
  242. // base 生效,max_tokens=256000 不得原样透传被上游 400。
  243. func TestBuildNativeAnthropicUpstreamRequest_ClampsTrailingSlashOllamaBase(t *testing.T) {
  244. svc := &OpenAIGatewayService{cfg: messagesClampTestConfig()}
  245. c := newMessagesClampTestContext(t)
  246. account := messagesClampProductionAccount(431)
  247. body := messagesClampBody("deepseek-v4-flash", 256000)
  248. // 真实出站 URL:trailing '/' 被既有组装归一化掉,请求确实可达 ollama.com。
  249. targetURL, err := svc.nativeAnthropicTargetURL(account)
  250. require.NoError(t, err)
  251. require.Equal(t, "https://ollama.com/v1/messages", targetURL)
  252. req, wireBody, err := svc.buildNativeAnthropicUpstreamRequest(
  253. context.Background(), c, account, body, "sk-test", targetURL,
  254. )
  255. require.NoError(t, err)
  256. require.Equal(t, "https://ollama.com/v1/messages", req.URL.String())
  257. require.Equal(t, "deepseek-v4-flash", gjson.GetBytes(wireBody, "model").String())
  258. require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int(),
  259. "anthropic base trailing '/' 时仍须命中 clamp(生产 400 回归)")
  260. }
  261. // TestBuildUpstreamRequest_ClampsTrailingSlashOllamaBase 覆盖 builder A / B 的相同
  262. // 场景:GetBaseURL() 原样返回 trailing '/',而 validateUpstreamBaseURL 归一化后
  263. // 实际出站 URL 可达 ollama.com/v1/messages,clamp 判定须与归一化同源。
  264. func TestBuildUpstreamRequest_ClampsTrailingSlashOllamaBase(t *testing.T) {
  265. svc := &GatewayService{cfg: messagesClampTestConfig()}
  266. c := newMessagesClampTestContext(t)
  267. body := messagesClampBody("deepseek-v4-flash", 256000)
  268. newAccount := func(id int64) *Account {
  269. account := messagesClampOllamaAccount(id, PlatformAnthropic)
  270. account.Credentials["base_url"] = "https://ollama.com/"
  271. return account
  272. }
  273. t.Run("builder A trailing slash base is clamped", func(t *testing.T) {
  274. req, wireBody, err := svc.buildUpstreamRequest(
  275. context.Background(), c, newAccount(441), body, "sk-test", "api_key",
  276. "deepseek-v4-flash", false, false,
  277. )
  278. require.NoError(t, err)
  279. require.Equal(t, "https://ollama.com/v1/messages?beta=true", req.URL.String())
  280. require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int())
  281. })
  282. t.Run("builder B trailing slash base is clamped", func(t *testing.T) {
  283. req, wireBody, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
  284. context.Background(), c, newAccount(442), body, "sk-test",
  285. )
  286. require.NoError(t, err)
  287. require.Equal(t, "https://ollama.com/v1/messages?beta=true", req.URL.String())
  288. require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int())
  289. })
  290. t.Run("evil suffix and custom path never match", func(t *testing.T) {
  291. for _, base := range []string{"https://ollama.com.evil.com/", "https://ollama.com/anthropic/"} {
  292. account := newAccount(443)
  293. account.Credentials["base_url"] = base
  294. _, wireBody, err := svc.buildUpstreamRequest(
  295. context.Background(), c, account, body, "sk-test", "api_key",
  296. "deepseek-v4-flash", false, false,
  297. )
  298. require.NoError(t, err)
  299. require.Equal(t, int64(256000), gjson.GetBytes(wireBody, "max_tokens").Int(),
  300. "base %q 不得被识别为 Ollama Cloud", base)
  301. }
  302. })
  303. }