diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index 99ff3b267c..b202e7dbba 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -325,6 +325,10 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( body = sanitized } + // Ollama Cloud DeepSeek 出站 max_tokens clamp:判定与上方 targetURL 的 + // base 取值同源(GetBaseURL),详见 helper 注释。 + body = clampOllamaCloudAnthropicMessagesMaxTokens(account, account.GetBaseURL(), body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body)) if err != nil { return nil, nil, err diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index 675cf40001..93367eef89 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -117,6 +117,11 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex body = sanitized } + // Ollama Cloud DeepSeek 出站 max_tokens clamp:判定与上方 targetURL 的 + // base 取值同源(GetBaseURL),仅实际上游为 ollama.com 且映射后出站模型 + // 为 DeepSeek 系时压到 cap,详见 helper 注释。 + body = clampOllamaCloudAnthropicMessagesMaxTokens(account, account.GetBaseURL(), body) + req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) if err != nil { return nil, nil, err diff --git a/backend/internal/service/ollama_cloud_messages_max_tokens.go b/backend/internal/service/ollama_cloud_messages_max_tokens.go new file mode 100644 index 0000000000..66bb4831a3 --- /dev/null +++ b/backend/internal/service/ollama_cloud_messages_max_tokens.go @@ -0,0 +1,25 @@ +package service + +import ( + "github.com/tidwall/gjson" +) + +// clampOllamaCloudAnthropicMessagesMaxTokens 是 Anthropic Messages 出站 +// (/v1/messages,含 CC / Responses 桥接与 passthrough)的 max_tokens clamp 钩子, +// 与 raw CC / Responses 路径共用 cap 配置与实现。判定与出站 URL 组装同源:调用方 +// 传入本次实际选用的 Anthropic 上游 base_url(GetBaseURL 或 +// GetAnthropicProtocolBaseURL,不用 GetOpenAIBaseURL——adaptive 时那是 CC/Responses +// 地址),仅当其为 ollama.com 且 body 中映射后出站 model 为 DeepSeek 系时压到 cap, +// 否则原样返回。 +func clampOllamaCloudAnthropicMessagesMaxTokens(account *Account, baseURL string, body []byte) []byte { + if account == nil || len(body) == 0 { + return body + } + if !isOllamaCloudBaseURL(baseURL) { + return body + } + if !isDeepSeekModel(gjson.GetBytes(body, "model").String()) { + return body + } + return clampOllamaCloudMaxTokens(account, body) +} diff --git a/backend/internal/service/ollama_cloud_messages_max_tokens_test.go b/backend/internal/service/ollama_cloud_messages_max_tokens_test.go new file mode 100644 index 0000000000..e8fab5d1fe --- /dev/null +++ b/backend/internal/service/ollama_cloud_messages_max_tokens_test.go @@ -0,0 +1,245 @@ +//go:build unit + +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// Anthropic Messages 出站(三个 builder)的 Ollama Cloud DeepSeek max_tokens clamp +// 接线验证。实测背景:POST ollama.com/v1/messages(Bearer)max_tokens=256000 被上游 +// 以 "max_tokens (256000) exceeds model's maximum output tokens (65536)" 400 拒绝。 +// 用例直接调用各 builder 实际生成 *http.Request,同时检查出站 URL 与 wire body。 + +func messagesClampTestConfig() *config.Config { + cfg := rawChatCompletionsTestConfig() + cfg.Gateway = config.GatewayConfig{MaxLineSize: defaultMaxLineSize} + return cfg +} + +func newMessagesClampTestContext(t *testing.T) *gin.Context { + t.Helper() + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + return c +} + +// messagesClampOllamaAccount 构造挂在实际 ollama.com 上游的 APIKey 账号。 +// builder A / B 的 Messages base 取 GetBaseURL()(凭证 base_url),builder C 的 +// Anthropic 协议 base 取 GetAnthropicProtocolBaseURL()(anthropic 协议时同为 +// 凭证 base_url)。 +func messagesClampOllamaAccount(id int64, platform string) *Account { + return &Account{ + ID: id, + Name: "ollama-cloud-messages", + Platform: platform, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://ollama.com", + }, + Extra: map[string]any{}, + } +} + +func messagesClampBody(model string, maxTokens int) []byte { + return []byte(`{"model":"` + model + `","max_tokens":` + strconv.Itoa(maxTokens) + + `,"stream":false,"messages":[{"role":"user","content":"hi"}]}`) +} + +// TestBuildUpstreamRequest_ClampsOllamaCloudDeepSeekMaxTokens 覆盖 GatewayService +// .buildUpstreamRequest(Anthropic 平台原生 Messages + 共享重试路径)。 +func TestBuildUpstreamRequest_ClampsOllamaCloudDeepSeekMaxTokens(t *testing.T) { + svc := &GatewayService{cfg: messagesClampTestConfig()} + c := newMessagesClampTestContext(t) + + body := messagesClampBody("deepseek-v4-flash", 256000) + account := messagesClampOllamaAccount(401, PlatformAnthropic) + + req, wireBody, err := svc.buildUpstreamRequest( + context.Background(), c, account, body, "sk-test", "api_key", + "deepseek-v4-flash", false, false, + ) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages?beta=true", req.URL.String()) + require.Equal(t, "deepseek-v4-flash", gjson.GetBytes(wireBody, "model").String()) + require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int()) + + t.Run("official anthropic base is untouched", func(t *testing.T) { + official := messagesClampOllamaAccount(402, PlatformAnthropic) + official.Credentials["base_url"] = "https://api.anthropic.com" + _, wire, err := svc.buildUpstreamRequest( + context.Background(), c, official, body, "sk-test", "api_key", + "deepseek-v4-flash", false, false, + ) + require.NoError(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(wire, "max_tokens").Int()) + }) + + t.Run("non deepseek model untouched", func(t *testing.T) { + nonDeepSeek := messagesClampBody("k3-256k", 256000) + _, wire, err := svc.buildUpstreamRequest( + context.Background(), c, account, nonDeepSeek, "sk-test", "api_key", + "k3-256k", false, false, + ) + require.NoError(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(wire, "max_tokens").Int()) + }) + + t.Run("at cap and extra cap zero untouched", func(t *testing.T) { + atCap := messagesClampBody("deepseek-v4-flash", 65535) + _, wire, err := svc.buildUpstreamRequest( + context.Background(), c, account, atCap, "sk-test", "api_key", + "deepseek-v4-flash", false, false, + ) + require.NoError(t, err) + require.Equal(t, int64(65535), gjson.GetBytes(wire, "max_tokens").Int()) + + disabled := messagesClampOllamaAccount(403, PlatformAnthropic) + disabled.Extra[OllamaCloudMaxTokensCapExtraKey] = 0 + _, wire, err = svc.buildUpstreamRequest( + context.Background(), c, disabled, body, "sk-test", "api_key", + "deepseek-v4-flash", false, false, + ) + require.NoError(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(wire, "max_tokens").Int()) + }) +} + +// TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_ClampsOllamaCloudDeepSeekMaxTokens +// 覆盖 Anthropic 平台 APIKey passthrough builder。 +func TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_ClampsOllamaCloudDeepSeekMaxTokens(t *testing.T) { + svc := &GatewayService{cfg: messagesClampTestConfig()} + c := newMessagesClampTestContext(t) + + body := messagesClampBody("deepseek-v4-flash", 256000) + account := messagesClampOllamaAccount(411, PlatformAnthropic) + + req, wireBody, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough( + context.Background(), c, account, body, "sk-test", + ) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages?beta=true", req.URL.String()) + require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int()) + + // 官方 Anthropic(api.anthropic.com)即使 max_tokens 超过 65535 也字节级不变。 + official := newAnthropicAPIKeyAccountForTest() + _, wire, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough( + context.Background(), c, official, body, "upstream-anthropic-key", + ) + require.NoError(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(wire, "max_tokens").Int()) +} + +// TestBuildNativeAnthropicUpstreamRequest_ClampsOllamaCloudDeepSeekMaxTokens 覆盖 +// 国产供应商原生 Anthropic 协议 builder(Messages / Responses / CC 桥接共享)。 +func TestBuildNativeAnthropicUpstreamRequest_ClampsOllamaCloudDeepSeekMaxTokens(t *testing.T) { + svc := &OpenAIGatewayService{cfg: messagesClampTestConfig()} + c := newMessagesClampTestContext(t) + + body := messagesClampBody("deepseek-v4-flash", 256000) + + t.Run("anthropic protocol ollama base is clamped", func(t *testing.T) { + account := messagesClampOllamaAccount(421, PlatformDeepseek) + account.Credentials["api_protocol"] = APIProtocolAnthropic + + targetURL, err := svc.nativeAnthropicTargetURL(account) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages", targetURL) + + req, wireBody, err := svc.buildNativeAnthropicUpstreamRequest( + context.Background(), c, account, body, "sk-test", targetURL, + ) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages", req.URL.String()) + require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int()) + }) + + // adaptive 账号按 Anthropic 协议地址(api_base_urls[anthropic])判定,而非 CC + // 地址:anthropic 指向 ollama.com、chat_completions 指向官方 DeepSeek 时命中。 + t.Run("adaptive uses anthropic base not cc base", func(t *testing.T) { + account := messagesClampOllamaAccount(422, PlatformDeepseek) + account.Credentials["api_protocol"] = APIProtocolAdaptive + account.Credentials["api_base_urls"] = map[string]any{ + APIProtocolAnthropic: "https://ollama.com", + APIProtocolChatCompletions: "https://api.deepseek.com", + } + + targetURL, err := svc.nativeAnthropicTargetURL(account) + require.NoError(t, err) + req, wireBody, err := svc.buildNativeAnthropicUpstreamRequest( + context.Background(), c, account, body, "sk-test", targetURL, + ) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages", req.URL.String()) + require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int()) + }) + + // 反向:实际 Anthropic 上游非 ollama.com(官方 anthropic 端点),即使 CC 地址 + // 指向 ollama.com 且残留 usage extra,也不 clamp。 + t.Run("non-ollama anthropic base with cc ollama base and usage extra untouched", func(t *testing.T) { + account := messagesClampOllamaAccount(423, PlatformDeepseek) + account.Credentials["api_protocol"] = APIProtocolAdaptive + account.Credentials["api_base_urls"] = map[string]any{ + APIProtocolAnthropic: "https://api.deepseek.com/anthropic", + APIProtocolChatCompletions: "https://ollama.com", + } + account.Extra[OllamaCloudUsageSnapshotExtraKey] = map[string]any{"status": "ok"} + + targetURL, err := svc.nativeAnthropicTargetURL(account) + require.NoError(t, err) + req, wireBody, err := svc.buildNativeAnthropicUpstreamRequest( + context.Background(), c, account, body, "sk-test", targetURL, + ) + require.NoError(t, err) + require.Equal(t, "https://api.deepseek.com/anthropic/v1/messages", req.URL.String()) + require.Equal(t, int64(256000), gjson.GetBytes(wireBody, "max_tokens").Int()) + }) + + // 官方 DeepSeek Anthropic 协议(平台默认端点)字节级不变。 + t.Run("official deepseek anthropic default untouched", func(t *testing.T) { + account := &Account{ + ID: 424, + Name: "official-deepseek-anthropic", + Platform: PlatformDeepseek, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "api_protocol": APIProtocolAnthropic, + }, + Extra: map[string]any{}, + } + targetURL, err := svc.nativeAnthropicTargetURL(account) + require.NoError(t, err) + _, wireBody, err := svc.buildNativeAnthropicUpstreamRequest( + context.Background(), c, account, body, "sk-test", targetURL, + ) + require.NoError(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(wireBody, "max_tokens").Int()) + }) + + // 非 DeepSeek 模型(映射后出站 model)不变。 + t.Run("non deepseek model untouched", func(t *testing.T) { + account := messagesClampOllamaAccount(425, PlatformDeepseek) + account.Credentials["api_protocol"] = APIProtocolAnthropic + nonDeepSeek := messagesClampBody("glm-4.7", 256000) + targetURL, err := svc.nativeAnthropicTargetURL(account) + require.NoError(t, err) + _, wireBody, err := svc.buildNativeAnthropicUpstreamRequest( + context.Background(), c, account, nonDeepSeek, "sk-test", targetURL, + ) + require.NoError(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(wireBody, "max_tokens").Int()) + }) +} diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 67ffc3d265..98394c07e7 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -159,6 +159,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( } } upstreamBody = applyOllamaCloudRawChatCompletionsRequest(account, upstreamBody) + upstreamBody = clampOllamaCloudUpstreamMaxTokens(account, upstreamBody) logger.L().Debug("openai chat_completions raw: forwarding without protocol conversion", zap.Int64("account_id", account.ID), diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 56673c1c23..6837cb8236 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -610,6 +610,14 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } } } + // Ollama Cloud(实际 Responses 上游为 ollama.com)输出上限 clamp:对 Codex 与 + // 非 Codex 客户端一律执行(真实 Codex 客户端同样会带超限 max_output_tokens 被 + // ollama.com 以 400 拒绝)。在 `!isCodexCLI` 归一化块之后独立调用:非 Codex 时 + // 位于平台字段归一化之后,不跳过原有平台 switch(patch 按追加顺序应用,set 在 + // 先前的 delete/set 之后生效);Codex 请求不做归一化,直接按 body 现值判定。 + if clampedCap, ok := ollamaCloudResponsesMaxOutputTokensClamp(account, upstreamModel, body); ok { + markPatchSet("max_output_tokens", clampedCap) + } if wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 && !account.IsOpenAIApiKey() && gjson.GetBytes(body, "previous_response_id").Exists() { markPatchDelete("previous_response_id") diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index d5daef8442..a629e977df 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -158,6 +158,11 @@ func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest( body = sanitized } + // Ollama Cloud DeepSeek 出站 max_tokens clamp:判定与 nativeAnthropicTargetURL + // 的 base 取值同源(GetAnthropicProtocolBaseURL,adaptive 时是 Anthropic 协议 + // 地址而非 CC/Responses 地址),详见 helper 注释。 + body = clampOllamaCloudAnthropicMessagesMaxTokens(account, account.GetAnthropicProtocolBaseURL(), body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body)) if err != nil { return nil, nil, err diff --git a/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning.go b/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning.go index f78a375860..4fd1c317b6 100644 --- a/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning.go +++ b/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning.go @@ -47,12 +47,13 @@ func accountHasOllamaCloudUsageExtra(account *Account) bool { return false } +// applyOllamaCloudRawChatCompletionsRequest 只做 Ollama Cloud reasoning 归一化; +// max_tokens clamp 已解耦到独立钩子 clampOllamaCloudUpstreamMaxTokens,由出站方依次调用。 func applyOllamaCloudRawChatCompletionsRequest(account *Account, body []byte) []byte { if !isOllamaCloudRawChatCompletionsAccount(account) || len(body) == 0 { return body } - body = normalizeOllamaCloudChatCompletionsRequest(body) - return clampOllamaCloudMaxTokens(account, body) + return normalizeOllamaCloudChatCompletionsRequest(body) } func applyOllamaCloudRawChatCompletionsResponse(account *Account, body []byte) []byte { diff --git a/backend/internal/service/openai_gateway_ollama_cloud_max_tokens.go b/backend/internal/service/openai_gateway_ollama_cloud_max_tokens.go index 32a508481a..4c69a32b23 100644 --- a/backend/internal/service/openai_gateway_ollama_cloud_max_tokens.go +++ b/backend/internal/service/openai_gateway_ollama_cloud_max_tokens.go @@ -18,9 +18,60 @@ const OllamaCloudMaxTokensCapExtraKey = "ollama_max_tokens_cap" // (约 65535),max_tokens 超过该值会被上游直接 400 拒绝;该上限与模型无关,不做模型过滤。 const ollamaCloudDefaultMaxTokensCap = 65535 -// 本文件的 clampOllamaCloudMaxTokens 被 -// applyOllamaCloudRawChatCompletionsRequest(openai_gateway_ollama_cloud_cc_reasoning.go) -// 调用,账号检测(isOllamaCloudRawChatCompletionsAccount)由调用方完成,此处不再重复判断。 +// clampOllamaCloudUpstreamMaxTokens 是 raw CC 出站(forwardAsRawChatCompletions 与 +// /v1/responses 降级 forwardResponsesViaRawChatCompletions 共用)的独立 token 钩子, +// 与 reasoning 钩子解耦:只看本次请求实际选用的 CC 上游 base_url(GetOpenAIBaseURL, +// 与 openAIChatCompletionsTargetURL 的取值一致)是否 ollama.com,不读 usage extra。 +// DeepSeek 系模型(用模型映射后的真实出站 model,body 中 model 已改写)任意平台均 +// clamp;非 DeepSeek 模型仅保留既有 openai 平台 Ollama 账号(force_chat_completions +// 判定)的 clamp,不扩展到其它平台。 +func clampOllamaCloudUpstreamMaxTokens(account *Account, body []byte) []byte { + if account == nil || len(body) == 0 { + return body + } + if !isOllamaCloudBaseURL(account.GetOpenAIBaseURL()) { + return body + } + if !isDeepSeekModel(gjson.GetBytes(body, "model").String()) && !isOllamaCloudRawChatCompletionsAccount(account) { + return body + } + return clampOllamaCloudMaxTokens(account, body) +} + +// ollamaCloudResponsesUpstreamBaseURL 返回原生 /v1/responses 本次实际选用的上游 +// base_url,取值与 buildUpstreamRequest 一致:adaptive 原生 CN 账号用 api_base_urls +// 的 responses 地址,其余用 GetOpenAIBaseURL。 +func ollamaCloudResponsesUpstreamBaseURL(account *Account) string { + if account.UsesNativeCNResponses() && account.IsAdaptiveAPIProtocol() { + return account.GetCNProtocolBaseURL(APIProtocolResponses) + } + return account.GetOpenAIBaseURL() +} + +// ollamaCloudResponsesMaxOutputTokensClamp 是原生 /v1/responses 路径的 clamp:在 +// 平台字段归一化之后独立调用,不改写原有的 max_output_tokens 平台 switch。判定: +// 实际 Responses 上游(ollamaCloudResponsesUpstreamBaseURL)为 ollama.com 的 +// APIKey 账号 + 映射后的出站模型是 DeepSeek 系;openai 平台 max_tokens 刚被归一化 +// patch 到 max_output_tokens 时取 max_tokens 为生效值。未命中返回 ok=false。 +// 实测:ollama.com/v1/responses max_output_tokens=256000 被上游以 "max_tokens +// (256000) exceeds model's maximum output tokens (65536)" 400 拒绝(DeepSeek 模型)。 +func ollamaCloudResponsesMaxOutputTokensClamp(account *Account, upstreamModel string, body []byte) (int64, bool) { + if account == nil || account.Type != AccountTypeAPIKey || !isDeepSeekModel(upstreamModel) { + return 0, false + } + if !isOllamaCloudBaseURL(ollamaCloudResponsesUpstreamBaseURL(account)) { + return 0, false + } + value := gjson.GetBytes(body, "max_output_tokens") + if !value.Exists() && account.Platform == PlatformOpenAI { + value = gjson.GetBytes(body, "max_tokens") + } + cap := ollamaCloudMaxTokensCap(account) + if cap <= 0 || !value.Exists() || value.Type != gjson.Number || value.Int() <= cap { + return 0, false + } + return cap, true +} // ollamaCloudMaxTokensCap 返回账号配置的 max_tokens 上限。账号为 nil 或 extra 中 // 无该键时返回默认值;键值为数值类型(float64/int64/int/json.Number)时返回其整数 diff --git a/backend/internal/service/openai_gateway_ollama_cloud_max_tokens_test.go b/backend/internal/service/openai_gateway_ollama_cloud_max_tokens_test.go index 6bbc2d7e5f..f818c37973 100644 --- a/backend/internal/service/openai_gateway_ollama_cloud_max_tokens_test.go +++ b/backend/internal/service/openai_gateway_ollama_cloud_max_tokens_test.go @@ -3,11 +3,15 @@ package service import ( + "context" "encoding/json" + "errors" "testing" "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) // ollamaMaxTokensCapTestAccount 构造带自定义 cap 的 Ollama Cloud usage 账号。 @@ -129,17 +133,19 @@ func TestOllamaCloudMaxTokensCap(t *testing.T) { } } -// TestApplyOllamaCloudRawChatCompletionsRequestClampsMaxTokens 验证 max_tokens clamp -// 已接入组合钩子 applyOllamaCloudRawChatCompletionsRequest,并遵循该钩子的账号判定门槛 -// (isOllamaCloudRawChatCompletionsAccount:platform openai + type apikey + -// force_chat_completions + ollama.com 或 Ollama usage extra)。 +// TestApplyOllamaCloudRawChatCompletionsRequestClampsMaxTokens 验证 reasoning 钩子 +// 与 token clamp 已解耦:reasoning 钩子只做 reasoning 归一化,clamp 由独立 token +// 钩子 clampOllamaCloudUpstreamMaxTokens 在出站时接续执行(两条 raw CC 出站路径 +// 均依次调用两者,端到端行为不变)。 func TestApplyOllamaCloudRawChatCompletionsRequestClampsMaxTokens(t *testing.T) { body := []byte(`{"model":"deepseek-chat","max_tokens":100000}`) - // Ollama Cloud 账号(ollama.com + force_chat_completions)→ clamp 到 65535。 + // Ollama Cloud 账号:reasoning 钩子不再 clamp,字节级原样。 ollama := ollamaCloudRawChatCompletionsTestAccount() + require.Equal(t, string(body), string(applyOllamaCloudRawChatCompletionsRequest(ollama, body))) + // 独立 token 钩子接续 clamp 到既有默认 cap。 require.JSONEq(t, `{"model":"deepseek-chat","max_tokens":65535}`, - string(applyOllamaCloudRawChatCompletionsRequest(ollama, body))) + string(clampOllamaCloudUpstreamMaxTokens(ollama, body))) // 官方 DeepSeek(api.deepseek.com + force_chat_completions)→ 字节级不变。 official := rawChatCompletionsTestAccount() @@ -148,13 +154,400 @@ func TestApplyOllamaCloudRawChatCompletionsRequestClampsMaxTokens(t *testing.T) openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions), } require.Equal(t, body, applyOllamaCloudRawChatCompletionsRequest(official, body)) + require.Equal(t, string(body), string(clampOllamaCloudUpstreamMaxTokens(official, body))) - // ollama.com 但无 force_chat_completions(Extra 缺键)→ 不通过钩子判定门槛,字节级不变。 + // ollama.com 但无 force_chat_completions:reasoning 钩子不生效;独立钩子按 + // DeepSeek 系模型判定仍 clamp(DeepSeek 覆盖不依赖 responses_mode extra)。 noForce := ollamaCloudRawChatCompletionsTestAccount() noForce.Extra = nil require.Equal(t, body, applyOllamaCloudRawChatCompletionsRequest(noForce, body)) + require.JSONEq(t, `{"model":"deepseek-chat","max_tokens":65535}`, + string(clampOllamaCloudUpstreamMaxTokens(noForce, body))) // 空 body → 原样返回。 require.Equal(t, []byte(nil), applyOllamaCloudRawChatCompletionsRequest(ollama, nil)) require.Equal(t, []byte{}, applyOllamaCloudRawChatCompletionsRequest(ollama, []byte{})) } + +// ollamaUpstreamTestAccount 构造挂在实际 ollama.com 上游的 APIKey 账号。平台标签 +// 不参与 clamp 判定,这里用不同平台仅为了覆盖生产里的挂载方式;base_url 与生产 +// 实测一致(https://ollama.com/v1)。 +func ollamaUpstreamTestAccount(platform string, id int64) *Account { + return &Account{ + ID: id, + Name: "ollama-cloud", + Platform: platform, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://ollama.com/v1", + }, + Extra: map[string]any{}, + } +} + +// officialDeepSeekTestAccount 构造官方 DeepSeek 账号,extra 里带残留的 Ollama +// usage 快照键:usage extra 不得把非 Ollama host 识别为 Ollama 上游。 +func officialDeepSeekTestAccount(id int64) *Account { + return &Account{ + ID: id, + Name: "official-deepseek", + Platform: PlatformDeepseek, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://api.deepseek.com", + }, + Extra: map[string]any{ + OllamaCloudUsageSnapshotExtraKey: map[string]any{"status": "ok"}, + }, + } +} + +func TestClampOllamaCloudUpstreamMaxTokens(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + account *Account + body string + want string + }{ + { + // 生产问题场景:deepseek 平台 Ollama 账号,无 openai_responses_mode extra, + // raw CC 直转时 max_tokens 超限被上游 400。 + name: "deepseek platform ollama.com without responses_mode is clamped", + account: ollamaUpstreamTestAccount(PlatformDeepseek, 301), + body: `{"model":"deepseek-v4-flash","max_tokens":256000}`, + want: `{"model":"deepseek-v4-flash","max_tokens":65535}`, + }, + { + name: "max_completion_tokens is clamped too", + account: ollamaUpstreamTestAccount(PlatformDeepseek, 301), + body: `{"model":"deepseek-v4-flash","max_completion_tokens":256000}`, + want: `{"model":"deepseek-v4-flash","max_completion_tokens":65535}`, + }, + { + // 新增范围只限 DeepSeek 系模型:kimi/zhipu 平台挂 ollama.com 跑非 + // DeepSeek 模型的请求保持不变。 + name: "kimi platform non-deepseek model untouched", + account: ollamaUpstreamTestAccount(PlatformKimi, 302), + body: `{"model":"k3-256k","max_tokens":256000}`, + want: `{"model":"k3-256k","max_tokens":256000}`, + }, + { + name: "zhipu platform non-deepseek model untouched", + account: ollamaUpstreamTestAccount(PlatformZhipu, 302), + body: `{"model":"glm-4.7","max_tokens":256000}`, + want: `{"model":"glm-4.7","max_tokens":256000}`, + }, + { + // 既有 openai 平台 Ollama 账号对 DeepSeek 模型的 clamp 幂等保持。 + name: "openai platform ollama.com deepseek model stays clamped", + account: ollamaUpstreamTestAccount(PlatformOpenAI, 303), + body: `{"model":"deepseek-v4-flash","max_tokens":70000}`, + want: `{"model":"deepseek-v4-flash","max_tokens":65535}`, + }, + { + // 既有 openai 平台 Ollama(真实 ollama.com + force_chat_completions)对 + // 非 DeepSeek 模型的 clamp 保留。 + name: "openai platform ollama.com non-deepseek model keeps legacy clamp", + account: ollamaCloudRawChatCompletionsTestAccount(), + body: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`, + want: `{"model":"gpt-oss:120b-cloud","max_tokens":65535}`, + }, + { + // 非 DeepSeek 模型不扩展到其它平台。 + name: "openai platform ollama.com non-deepseek model without force_cc untouched", + account: func() *Account { + account := ollamaUpstreamTestAccount(PlatformOpenAI, 303) + account.Extra = nil + return account + }(), + body: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`, + want: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`, + }, + { + // 残留 usage extra 不得把非 Ollama host 变成 Ollama(旧 reasoning 钩子内的 + // clamp 会命中该账号,解耦后已封堵)。 + name: "openai platform non-ollama host with leftover usage extra untouched", + account: func() *Account { + account := ollamaCloudRawChatCompletionsTestAccount() + account.Credentials["base_url"] = "https://example.invalid/v1" + account.Extra[OllamaCloudUsageSnapshotExtraKey] = map[string]any{"status": "ok"} + return account + }(), + body: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`, + want: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`, + }, + { + name: "extra cap zero disables clamping", + account: func() *Account { + account := ollamaUpstreamTestAccount(PlatformDeepseek, 304) + account.Extra[OllamaCloudMaxTokensCapExtraKey] = 0 + return account + }(), + body: `{"model":"deepseek-v4-flash","max_tokens":256000}`, + want: `{"model":"deepseek-v4-flash","max_tokens":256000}`, + }, + { + name: "value at cap is untouched", + account: ollamaUpstreamTestAccount(PlatformDeepseek, 304), + body: `{"model":"deepseek-v4-flash","max_tokens":65535}`, + want: `{"model":"deepseek-v4-flash","max_tokens":65535}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + require.JSONEq(t, test.want, string(clampOllamaCloudUpstreamMaxTokens(test.account, []byte(test.body)))) + }) + } + + // 非 Ollama 实际上游(api.deepseek.com)即使残留 usage extra 也不 clamp。 + official := officialDeepSeekTestAccount(305) + body := []byte(`{"model":"deepseek-v4-flash","max_tokens":256000,"max_completion_tokens":256000}`) + require.Equal(t, string(body), string(clampOllamaCloudUpstreamMaxTokens(official, body))) + + // 空 body / nil 账号原样返回。 + require.Equal(t, []byte(nil), clampOllamaCloudUpstreamMaxTokens(official, nil)) +} + +// TestForwardAsRawChatCompletions_DeepseekOllamaCloudClampsMaxTokens 端到端验证 raw +// CC 出站钩子:判定用模型映射后的真实出站 model,官方 DeepSeek 字节级不变。 +func TestForwardAsRawChatCompletions_DeepseekOllamaCloudClampsMaxTokens(t *testing.T) { + gin.SetMode(gin.TestMode) + + account := ollamaUpstreamTestAccount(PlatformDeepseek, 311) + account.Credentials["api_protocol"] = APIProtocolChatCompletions + account.Credentials["model_mapping"] = map[string]any{"deepseek-chat": "deepseek-v4-flash"} + + body := []byte(`{"model":"deepseek-chat","max_tokens":256000,"messages":[{"role":"user","content":"hi"}],"stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + + _, err := svc.forwardAsRawChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", body), account, body, "") + require.Error(t, err) + require.Equal(t, "deepseek-v4-flash", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_tokens").Int()) + + // 官方 DeepSeek(api.deepseek.com)+ 残留 usage extra:字节级不变。 + official := officialDeepSeekTestAccount(312) + official.Credentials["api_protocol"] = APIProtocolChatCompletions + officialBody := []byte(`{"model":"deepseek-v4-flash","max_tokens":256000,"messages":[{"role":"user","content":"hi"}],"stream":false}`) + officialUpstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + officialSvc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: officialUpstream} + + _, err = officialSvc.forwardAsRawChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", officialBody), official, officialBody, "") + require.Error(t, err) + require.Equal(t, string(officialBody), string(officialUpstream.lastBody)) +} + +// TestForwardResponsesViaRawChatCompletions_DeepseekOllamaCloudClampsMaxTokens 验证 +// /v1/responses 降级到 raw CC 的路径确实经过同一个独立 token 钩子(Responses 请求的 +// max_output_tokens 经 apicompat 转成 CC 的 max_completion_tokens)。 +func TestForwardResponsesViaRawChatCompletions_DeepseekOllamaCloudClampsMaxTokens(t *testing.T) { + gin.SetMode(gin.TestMode) + + account := ollamaUpstreamTestAccount(PlatformDeepseek, 321) + account.Credentials["api_protocol"] = APIProtocolChatCompletions + + body := []byte(`{"model":"deepseek-v4-flash","input":"Reply with exactly OK and nothing else.","max_output_tokens":256000,"stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + + _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body) + require.Error(t, err) + require.Equal(t, "https://ollama.com/v1/chat/completions", upstream.lastReq.URL.String()) + require.Equal(t, "deepseek-v4-flash", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_completion_tokens").Int()) +} + +// TestForwardResponsesClampsOllamaCloudMaxOutputTokens 覆盖原生 /v1/responses 路径 +// (openai force_responses 与 deepseek api_protocol=responses)的 max_output_tokens +// clamp,实测依据:POST ollama.com/v1/responses max_output_tokens=256000 被上游以 +// "max_tokens (256000) exceeds model's maximum output tokens (65536)" 400 拒绝。 +func TestForwardResponsesClampsOllamaCloudMaxOutputTokens(t *testing.T) { + gin.SetMode(gin.TestMode) + + responsesBody := []byte(`{"model":"deepseek-v4-flash","input":"Reply with exactly OK and nothing else.","max_output_tokens":256000,"stream":false}`) + + run := func(account *Account, body []byte) (*httpUpstreamRecorder, error) { + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body) + return upstream, err + } + + t.Run("deepseek platform native responses is clamped", func(t *testing.T) { + account := ollamaUpstreamTestAccount(PlatformDeepseek, 331) + account.Credentials["api_protocol"] = APIProtocolResponses + upstream, err := run(account, responsesBody) + require.Error(t, err) + require.Equal(t, "https://ollama.com/v1/responses", upstream.lastReq.URL.String()) + require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) + + t.Run("openai platform force_responses is clamped", func(t *testing.T) { + account := ollamaUpstreamTestAccount(PlatformOpenAI, 332) + account.Extra[openai_compat.ExtraKeyResponsesMode] = string(openai_compat.ResponsesSupportModeForceResponses) + upstream, err := run(account, responsesBody) + require.Error(t, err) + require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) + + t.Run("official deepseek with leftover usage extra is untouched", func(t *testing.T) { + account := officialDeepSeekTestAccount(333) + account.Credentials["api_protocol"] = APIProtocolResponses + upstream, err := run(account, responsesBody) + require.Error(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) + + t.Run("at cap is untouched", func(t *testing.T) { + account := ollamaUpstreamTestAccount(PlatformDeepseek, 334) + account.Credentials["api_protocol"] = APIProtocolResponses + atCap := []byte(`{"model":"deepseek-v4-flash","input":"hi","max_output_tokens":65535,"stream":false}`) + upstream, err := run(account, atCap) + require.Error(t, err) + require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) + + t.Run("extra cap zero disables clamping", func(t *testing.T) { + account := ollamaUpstreamTestAccount(PlatformDeepseek, 335) + account.Credentials["api_protocol"] = APIProtocolResponses + account.Extra[OllamaCloudMaxTokensCapExtraKey] = 0 + upstream, err := run(account, responsesBody) + require.Error(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) +} + +// TestForwardResponsesClampUsesResponsesUpstreamBaseURL 验证原生 Responses clamp 按 +// 实际 Responses 上游判定:adaptive 账号取 api_base_urls 的 responses 地址,而非 CC +// 地址(与 buildUpstreamRequest 的 URL 选择一致)。 +func TestForwardResponsesClampUsesResponsesUpstreamBaseURL(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"deepseek-v4-flash","input":"hi","max_output_tokens":256000,"stream":false}`) + run := func(account *Account) (*httpUpstreamRecorder, error) { + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body) + return upstream, err + } + + t.Run("ollama CC base but non-ollama responses base is not clamped", func(t *testing.T) { + account := ollamaUpstreamTestAccount(PlatformDeepseek, 341) + account.Credentials["api_protocol"] = APIProtocolAdaptive + account.Credentials["api_base_urls"] = map[string]any{ + APIProtocolChatCompletions: "https://ollama.com/v1", + APIProtocolResponses: "https://api.deepseek.com", + } + upstream, err := run(account) + require.Error(t, err) + require.Equal(t, "https://api.deepseek.com/responses", upstream.lastReq.URL.String()) + require.Equal(t, int64(256000), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) + + t.Run("non-ollama CC base but ollama responses base is clamped", func(t *testing.T) { + account := ollamaUpstreamTestAccount(PlatformDeepseek, 342) + account.Credentials["api_protocol"] = APIProtocolAdaptive + account.Credentials["api_base_urls"] = map[string]any{ + APIProtocolChatCompletions: "https://api.deepseek.com", + APIProtocolResponses: "https://ollama.com/v1", + } + upstream, err := run(account) + require.Error(t, err) + require.Equal(t, "https://ollama.com/v1/responses", upstream.lastReq.URL.String()) + require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) +} + +// TestForwardResponsesClampsOllamaCloudMaxOutputTokensForCodexClients 回归:原生 +// /v1/responses 的 Ollama Cloud clamp 必须对真实 Codex 客户端同样生效(clamp 曾位于 +// !isCodexCLI 块内被跳过,ollama.com 对 >65535 的 max_output_tokens 一律 400)。 +func TestForwardResponsesClampsOllamaCloudMaxOutputTokensForCodexClients(t *testing.T) { + gin.SetMode(gin.TestMode) + + responsesBody := []byte(`{"model":"deepseek-v4-flash","input":"Reply with exactly OK and nothing else.","max_output_tokens":256000,"stream":false}`) + + ollamaResponsesAccount := func() *Account { + account := ollamaUpstreamTestAccount(PlatformDeepseek, 336) + account.Credentials["api_protocol"] = APIProtocolResponses + return account + } + officialResponsesAccount := func() *Account { + account := officialDeepSeekTestAccount(337) + account.Credentials["api_protocol"] = APIProtocolResponses + return account + } + codexUA := func(c *gin.Context) { + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.55.0 (Ubuntu 24.04; x86_64)") + } + codexOriginator := func(c *gin.Context) { c.Request.Header.Set("originator", "codex-tui") } + + tests := []struct { + name string + account *Account + setHeaders func(*gin.Context) + forceCodexCLI bool + wantCap int64 + wantURL string + }{ + { + name: "codex UA is clamped", + account: ollamaResponsesAccount(), + setHeaders: codexUA, + wantCap: 65535, + wantURL: "https://ollama.com/v1/responses", + }, + { + name: "codex originator is clamped", + account: ollamaResponsesAccount(), + setHeaders: codexOriginator, + wantCap: 65535, + wantURL: "https://ollama.com/v1/responses", + }, + { + name: "ForceCodexCLI is clamped", + account: ollamaResponsesAccount(), + forceCodexCLI: true, + wantCap: 65535, + wantURL: "https://ollama.com/v1/responses", + }, + { + // 非 Codex 正常路径对照:与既有用例行为一致,不回归。 + name: "non-codex control is clamped", + account: ollamaResponsesAccount(), + wantCap: 65535, + wantURL: "https://ollama.com/v1/responses", + }, + { + // 非 Ollama 上游:同样的 Codex 头不 clamp。 + name: "official deepseek with codex UA is untouched", + account: officialResponsesAccount(), + setHeaders: codexUA, + wantCap: 256000, + wantURL: "https://api.deepseek.com/responses", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + cfg := rawChatCompletionsTestConfig() + cfg.Gateway.ForceCodexCLI = test.forceCodexCLI + svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream} + c := adaptiveProtocolTestContext("/v1/responses", responsesBody) + if test.setHeaders != nil { + test.setHeaders(c) + } + _, err := svc.Forward(context.Background(), c, test.account, responsesBody) + require.Error(t, err) + require.Equal(t, test.wantURL, upstream.lastReq.URL.String()) + require.Equal(t, test.wantCap, gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) + } +} diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index 9939c5b469..67d3e2b4e7 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -87,6 +87,9 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( } return nil, err } + // /v1/responses 降级到 raw CC 的出站与 forwardAsRawChatCompletions 共用同一个 + // 独立 Ollama Cloud token 钩子;chatReq.Model 已是模型映射后的 upstreamModel。 + chatBody = clampOllamaCloudUpstreamMaxTokens(account, chatBody) // Keep the final outbound tier for usage-time reconciliation. A policy // filter that removes the field therefore leaves this nil. serviceTier := extractOpenAIServiceTierFromBody(chatBody)