diff --git a/backend/internal/service/ollama_cloud_messages_max_tokens.go b/backend/internal/service/ollama_cloud_messages_max_tokens.go index 66bb4831a3..d178488460 100644 --- a/backend/internal/service/ollama_cloud_messages_max_tokens.go +++ b/backend/internal/service/ollama_cloud_messages_max_tokens.go @@ -1,6 +1,8 @@ package service import ( + "strings" + "github.com/tidwall/gjson" ) @@ -9,13 +11,14 @@ import ( // 与 raw CC / Responses 路径共用 cap 配置与实现。判定与出站 URL 组装同源:调用方 // 传入本次实际选用的 Anthropic 上游 base_url(GetBaseURL 或 // GetAnthropicProtocolBaseURL,不用 GetOpenAIBaseURL——adaptive 时那是 CC/Responses -// 地址),仅当其为 ollama.com 且 body 中映射后出站 model 为 DeepSeek 系时压到 cap, -// 否则原样返回。 +// 地址),与真实出站组装一样先 TrimRight "/"(urlvalidator 与 +// nativeAnthropicTargetURL 同款归一化),仅当其为 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) { + if !isOllamaCloudBaseURL(strings.TrimRight(strings.TrimSpace(baseURL), "/")) { return body } if !isDeepSeekModel(gjson.GetBytes(body, "model").String()) { diff --git a/backend/internal/service/ollama_cloud_messages_max_tokens_test.go b/backend/internal/service/ollama_cloud_messages_max_tokens_test.go index e8fab5d1fe..66b3e6e157 100644 --- a/backend/internal/service/ollama_cloud_messages_max_tokens_test.go +++ b/backend/internal/service/ollama_cloud_messages_max_tokens_test.go @@ -243,3 +243,100 @@ func TestBuildNativeAnthropicUpstreamRequest_ClampsOllamaCloudDeepSeekMaxTokens( require.Equal(t, int64(256000), gjson.GetBytes(wireBody, "max_tokens").Int()) }) } + +// messagesClampProductionAccount 复刻生产账号 162:platform=deepseek、type=apikey、 +// api_protocol=adaptive、base_url 与 api_base_urls 均指向 ollama.com(anthropic 地址 +// 带 trailing '/',CC/Responses 地址带 /v1 后缀)。 +func messagesClampProductionAccount(id int64) *Account { + return &Account{ + ID: id, + Name: "ollama-cloud-prod", + Platform: PlatformDeepseek, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "api_protocol": APIProtocolAdaptive, + "base_url": "https://ollama.com/v1", + "api_base_urls": map[string]any{ + APIProtocolAnthropic: "https://ollama.com/", + APIProtocolResponses: "https://ollama.com/v1", + APIProtocolChatCompletions: "https://ollama.com/v1", + }, + }, + Extra: map[string]any{}, + } +} + +// TestBuildNativeAnthropicUpstreamRequest_ClampsTrailingSlashOllamaBase 复刻生产 +// 失败:anthropic 协议地址配置为 "https://ollama.com/"(trailing '/')。真实出站 +// URL 组装(ValidateURLFormat 与 nativeAnthropicTargetURL 均做 TrimRight "/")会 +// 正确打到 https://ollama.com/v1/messages,因此 clamp 判定也必须对同一归一化后的 +// base 生效,max_tokens=256000 不得原样透传被上游 400。 +func TestBuildNativeAnthropicUpstreamRequest_ClampsTrailingSlashOllamaBase(t *testing.T) { + svc := &OpenAIGatewayService{cfg: messagesClampTestConfig()} + c := newMessagesClampTestContext(t) + account := messagesClampProductionAccount(431) + body := messagesClampBody("deepseek-v4-flash", 256000) + + // 真实出站 URL:trailing '/' 被既有组装归一化掉,请求确实可达 ollama.com。 + 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, "deepseek-v4-flash", gjson.GetBytes(wireBody, "model").String()) + require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int(), + "anthropic base trailing '/' 时仍须命中 clamp(生产 400 回归)") +} + +// TestBuildUpstreamRequest_ClampsTrailingSlashOllamaBase 覆盖 builder A / B 的相同 +// 场景:GetBaseURL() 原样返回 trailing '/',而 validateUpstreamBaseURL 归一化后 +// 实际出站 URL 可达 ollama.com/v1/messages,clamp 判定须与归一化同源。 +func TestBuildUpstreamRequest_ClampsTrailingSlashOllamaBase(t *testing.T) { + svc := &GatewayService{cfg: messagesClampTestConfig()} + c := newMessagesClampTestContext(t) + body := messagesClampBody("deepseek-v4-flash", 256000) + + newAccount := func(id int64) *Account { + account := messagesClampOllamaAccount(id, PlatformAnthropic) + account.Credentials["base_url"] = "https://ollama.com/" + return account + } + + t.Run("builder A trailing slash base is clamped", func(t *testing.T) { + req, wireBody, err := svc.buildUpstreamRequest( + context.Background(), c, newAccount(441), 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, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int()) + }) + + t.Run("builder B trailing slash base is clamped", func(t *testing.T) { + req, wireBody, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough( + context.Background(), c, newAccount(442), 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()) + }) + + t.Run("evil suffix and custom path never match", func(t *testing.T) { + for _, base := range []string{"https://ollama.com.evil.com/", "https://ollama.com/anthropic/"} { + account := newAccount(443) + account.Credentials["base_url"] = base + _, 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, int64(256000), gjson.GetBytes(wireBody, "max_tokens").Int(), + "base %q 不得被识别为 Ollama Cloud", base) + } + }) +}