diff --git a/backend/internal/service/account_anthropic_passthrough_test.go b/backend/internal/service/account_anthropic_passthrough_test.go index e66407a31b..d39a8ce979 100644 --- a/backend/internal/service/account_anthropic_passthrough_test.go +++ b/backend/internal/service/account_anthropic_passthrough_test.go @@ -60,3 +60,59 @@ func TestAccount_IsAnthropicAPIKeyPassthroughEnabled(t *testing.T) { require.False(t, openai.IsAnthropicAPIKeyPassthroughEnabled()) }) } + +func TestAccount_GetAnthropicAPIKeyAuthScheme(t *testing.T) { + tests := []struct { + name string + account *Account + want string + }{ + { + name: "missing extra defaults to x-api-key", + account: &Account{ + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + }, + want: AnthropicAPIKeyAuthSchemeXAPIKey, + }, + { + name: "explicit bearer", + account: &Account{ + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Extra: map[string]any{ + "anthropic_apikey_auth_scheme": AnthropicAPIKeyAuthSchemeAuthorizationBearer, + }, + }, + want: AnthropicAPIKeyAuthSchemeAuthorizationBearer, + }, + { + name: "invalid value defaults to x-api-key", + account: &Account{ + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Extra: map[string]any{ + "anthropic_apikey_auth_scheme": "bearer", + }, + }, + want: AnthropicAPIKeyAuthSchemeXAPIKey, + }, + { + name: "non Anthropic API key defaults to x-api-key", + account: &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Extra: map[string]any{ + "anthropic_apikey_auth_scheme": AnthropicAPIKeyAuthSchemeAuthorizationBearer, + }, + }, + want: AnthropicAPIKeyAuthSchemeXAPIKey, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, tt.account.GetAnthropicAPIKeyAuthScheme()) + }) + } +} diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 7eecc1f86f..80a862b971 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -228,20 +228,15 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account // Determine authentication method and API URL var authToken string - var useBearer bool var apiURL string if account.IsOAuth() { - // OAuth or Setup Token - use Bearer token - useBearer = true apiURL = testClaudeAPIURL authToken = account.GetCredential("access_token") if authToken == "" { return s.sendErrorAndEnd(c, "No access token available") } } else if account.Type == "apikey" { - // API Key - use x-api-key header - useBearer = false authToken = account.GetCredential("api_key") if authToken == "" { return s.sendErrorAndEnd(c, "No API key available") @@ -292,12 +287,12 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account } // Set authentication header - if useBearer { + if account.IsOAuth() { req.Header.Set("anthropic-beta", claude.DefaultBetaHeader) req.Header.Set("Authorization", "Bearer "+authToken) } else { req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader) - req.Header.Set("x-api-key", authToken) + setAnthropicAPIKeyAuthHeader(req.Header, account, authToken) } // Get proxy URL diff --git a/backend/internal/service/anthropic_apikey_auth.go b/backend/internal/service/anthropic_apikey_auth.go new file mode 100644 index 0000000000..7752d6fe3a --- /dev/null +++ b/backend/internal/service/anthropic_apikey_auth.go @@ -0,0 +1,37 @@ +package service + +import ( + "net/http" + "strings" +) + +const ( + anthropicAPIKeyAuthSchemeExtraKey = "anthropic_apikey_auth_scheme" + + AnthropicAPIKeyAuthSchemeXAPIKey = "x_api_key" + AnthropicAPIKeyAuthSchemeAuthorizationBearer = "authorization_bearer" +) + +// GetAnthropicAPIKeyAuthScheme returns the upstream authentication scheme for +// Anthropic API-key accounts. Missing or invalid values keep the historical +// x-api-key behavior. +func (a *Account) GetAnthropicAPIKeyAuthScheme() string { + if a == nil || a.Platform != PlatformAnthropic || a.Type != AccountTypeAPIKey { + return AnthropicAPIKeyAuthSchemeXAPIKey + } + + switch strings.TrimSpace(a.GetExtraString(anthropicAPIKeyAuthSchemeExtraKey)) { + case AnthropicAPIKeyAuthSchemeAuthorizationBearer: + return AnthropicAPIKeyAuthSchemeAuthorizationBearer + default: + return AnthropicAPIKeyAuthSchemeXAPIKey + } +} + +func setAnthropicAPIKeyAuthHeader(header http.Header, account *Account, token string) { + if account.GetAnthropicAPIKeyAuthScheme() == AnthropicAPIKeyAuthSchemeAuthorizationBearer { + header.Set("Authorization", "Bearer "+token) + return + } + header.Set("x-api-key", token) +} diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 30fc976ef3..721212f6a5 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -261,6 +261,56 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardCountTokensPreservesBo require.Empty(t, rec.Header().Get("Set-Cookie")) } +func TestGatewayService_AnthropicAPIKeyPassthrough_BearerAuthScheme(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + c.Request.Header.Set("Authorization", "Bearer inbound-token") + c.Request.Header.Set("X-Api-Key", "inbound-api-key") + c.Request.Header.Set("Cookie", "secret=1") + + svc := &GatewayService{ + cfg: &config.Config{ + Security: config.SecurityConfig{ + URLAllowlist: config.URLAllowlistConfig{Enabled: false}, + }, + }, + } + account := &Account{ + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "ollama-key", + "base_url": "https://ollama.com", + }, + Extra: map[string]any{ + "anthropic_passthrough": true, + "anthropic_apikey_auth_scheme": AnthropicAPIKeyAuthSchemeAuthorizationBearer, + }, + } + + msgReq, wireBody, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough( + context.Background(), c, account, []byte(`{"model":"gpt-oss:20b","messages":[]}`), "ollama-key", + ) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages?beta=true", msgReq.URL.String()) + require.JSONEq(t, `{"model":"gpt-oss:20b","messages":[]}`, string(wireBody)) + require.Equal(t, "Bearer ollama-key", getHeaderRaw(msgReq.Header, "authorization")) + require.Empty(t, getHeaderRaw(msgReq.Header, "x-api-key")) + require.Empty(t, getHeaderRaw(msgReq.Header, "cookie")) + + countReq, err := svc.buildCountTokensRequestAnthropicAPIKeyPassthrough( + context.Background(), c, account, []byte(`{"model":"gpt-oss:20b","messages":[]}`), "ollama-key", + ) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages/count_tokens?beta=true", countReq.URL.String()) + require.Equal(t, "Bearer ollama-key", getHeaderRaw(countReq.Header, "authorization")) + require.Empty(t, getHeaderRaw(countReq.Header, "x-api-key")) + require.Empty(t, getHeaderRaw(countReq.Header, "cookie")) +} + // TestGatewayService_AnthropicAPIKeyPassthrough_ModelMappingEdgeCases 覆盖透传模式下模型映射的各种边界情况 func TestGatewayService_AnthropicAPIKeyPassthrough_ModelMappingEdgeCases(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 08a8080788..160a92a8e1 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -5947,7 +5947,7 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( req.Header.Del("x-api-key") req.Header.Del("x-goog-api-key") req.Header.Del("cookie") - setHeaderRaw(req.Header, "x-api-key", token) + setAnthropicAPIKeyAuthHeader(req.Header, account, token) if getHeaderRaw(req.Header, "content-type") == "" { setHeaderRaw(req.Header, "content-type", "application/json") @@ -6900,7 +6900,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex if tokenType == "oauth" { setHeaderRaw(req.Header, "authorization", "Bearer "+token) } else { - setHeaderRaw(req.Header, "x-api-key", token) + setAnthropicAPIKeyAuthHeader(req.Header, account, token) } // 白名单透传 headers @@ -10429,7 +10429,7 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough( req.Header.Del("x-api-key") req.Header.Del("x-goog-api-key") req.Header.Del("cookie") - req.Header.Set("x-api-key", token) + setAnthropicAPIKeyAuthHeader(req.Header, account, token) if req.Header.Get("content-type") == "" { req.Header.Set("content-type", "application/json") @@ -10521,7 +10521,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con if tokenType == "oauth" { setHeaderRaw(req.Header, "authorization", "Bearer "+token) } else { - setHeaderRaw(req.Header, "x-api-key", token) + setAnthropicAPIKeyAuthHeader(req.Header, account, token) } // 白名单透传 headers(恢复真实 wire casing) diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 77e8d1e49a..ee3e6bfc04 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -154,6 +154,7 @@ func (s *AccountTestService) buildAnthropicUpstreamModelsRequest(ctx context.Con baseURL := "https://api.anthropic.com" authHeaderName := "" authHeaderValue := "" + apiKeyAuthToken := "" betaHeader := "" if account.IsOAuth() { @@ -180,8 +181,7 @@ func (s *AccountTestService) buildAnthropicUpstreamModelsRequest(ctx context.Con if strings.TrimSpace(baseURL) == "" { baseURL = "https://api.anthropic.com" } - authHeaderName = "x-api-key" - authHeaderValue = apiKey + apiKeyAuthToken = apiKey betaHeader = claude.APIKeyBetaHeader } else { return nil, newUpstreamModelSyncUnsupportedError( @@ -203,7 +203,11 @@ func (s *AccountTestService) buildAnthropicUpstreamModelsRequest(ctx context.Con req.Header.Set("Accept", "application/json") req.Header.Set("anthropic-version", "2023-06-01") req.Header.Set("anthropic-beta", betaHeader) - req.Header.Set(authHeaderName, authHeaderValue) + if authHeaderName != "" { + req.Header.Set(authHeaderName, authHeaderValue) + } else { + setAnthropicAPIKeyAuthHeader(req.Header, account, apiKeyAuthToken) + } return req, nil } diff --git a/backend/internal/service/upstream_models_test.go b/backend/internal/service/upstream_models_test.go index 6831e79187..1fe9415d34 100644 --- a/backend/internal/service/upstream_models_test.go +++ b/backend/internal/service/upstream_models_test.go @@ -93,6 +93,23 @@ func TestBuildUpstreamModelsRequestsForAPIKeyAccounts(t *testing.T) { require.Equal(t, "anthropic-key", anthropicReq.Header.Get("x-api-key")) require.Equal(t, "2023-06-01", anthropicReq.Header.Get("anthropic-version")) + anthropicBearerReq, err := svc.buildAnthropicUpstreamModelsRequest(ctx, &Account{ + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "ollama-key", + "base_url": "https://ollama.com", + }, + Extra: map[string]any{ + "anthropic_apikey_auth_scheme": AnthropicAPIKeyAuthSchemeAuthorizationBearer, + }, + }) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/models", anthropicBearerReq.URL.String()) + require.Equal(t, "Bearer ollama-key", anthropicBearerReq.Header.Get("Authorization")) + require.Empty(t, anthropicBearerReq.Header.Get("x-api-key")) + require.Equal(t, "2023-06-01", anthropicBearerReq.Header.Get("anthropic-version")) + openAIReq, err := svc.buildOpenAIUpstreamModelsRequest(ctx, &Account{ Platform: PlatformOpenAI, Type: AccountTypeAPIKey, diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 806f05598f..c43709a7ac 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2653,6 +2653,24 @@ +
+
+
+ +

+ {{ t('admin.accounts.anthropic.apiKeyAuthSchemeDesc') }} +

+
+ +
+
+
(OPENAI_WS_MODE_OFF const openaiAPIKeyResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) const codexCLIOnlyEnabled = ref(false) const codexCLIOnlyAppServerEnabled = ref(false) +type AnthropicAPIKeyAuthScheme = 'x_api_key' | 'authorization_bearer' const anthropicPassthroughEnabled = ref(false) +const anthropicAPIKeyAuthScheme = ref('x_api_key') const webSearchEmulationMode = ref('default') const webSearchGlobalEnabled = ref(false) const { @@ -3945,6 +3965,7 @@ watch( } if (newPlatform !== 'anthropic') { anthropicPassthroughEnabled.value = false + anthropicAPIKeyAuthScheme.value = 'x_api_key' webSearchEmulationMode.value = 'default' } // Reset OAuth states @@ -3967,6 +3988,7 @@ watch( } if (platform !== 'anthropic' || category !== 'apikey') { anthropicPassthroughEnabled.value = false + anthropicAPIKeyAuthScheme.value = 'x_api_key' webSearchEmulationMode.value = 'default' } } @@ -4346,6 +4368,7 @@ const resetForm = () => { codexCLIOnlyEnabled.value = false codexCLIOnlyAppServerEnabled.value = false anthropicPassthroughEnabled.value = false + anthropicAPIKeyAuthScheme.value = 'x_api_key' webSearchEmulationMode.value = 'default' // Reset quota control state windowCostEnabled.value = false @@ -4465,6 +4488,11 @@ const buildAnthropicExtra = (base?: Record): Record
+
+
+
+ +

+ {{ t('admin.accounts.anthropic.apiKeyAuthSchemeDesc') }} +

+
+ +
+
+
('inherit') +type AnthropicAPIKeyAuthScheme = 'x_api_key' | 'authorization_bearer' const anthropicPassthroughEnabled = ref(false) +const anthropicAPIKeyAuthScheme = ref('x_api_key') const webSearchEmulationMode = ref('default') const webSearchGlobalEnabled = ref(false) const { @@ -3015,6 +3035,7 @@ const syncFormFromAccount = (newAccount: Account | null) => { codexCLIOnlyAppServerEnabled.value = false codexImageGenerationBridgeMode.value = 'inherit' anthropicPassthroughEnabled.value = false + anthropicAPIKeyAuthScheme.value = 'x_api_key' webSearchEmulationMode.value = 'default' if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) { openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true @@ -3061,6 +3082,9 @@ const syncFormFromAccount = (newAccount: Account | null) => { } if (newAccount.platform === 'anthropic' && newAccount.type === 'apikey') { anthropicPassthroughEnabled.value = extra?.anthropic_passthrough === true + anthropicAPIKeyAuthScheme.value = extra?.anthropic_apikey_auth_scheme === 'authorization_bearer' + ? 'authorization_bearer' + : 'x_api_key' // 三态:string "default"/"enabled"/"disabled",向后兼容旧 bool const wsVal = extra?.web_search_emulation if (wsVal === 'enabled' || wsVal === 'disabled') { @@ -4101,6 +4125,11 @@ const handleSubmit = async () => { } else { delete newExtra.anthropic_passthrough } + if (anthropicAPIKeyAuthScheme.value === 'authorization_bearer') { + newExtra.anthropic_apikey_auth_scheme = 'authorization_bearer' + } else { + delete newExtra.anthropic_apikey_auth_scheme + } if (webSearchEmulationMode.value === 'default') { delete newExtra.web_search_emulation } else { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 085687ea4f..7b9f2bdeb4 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3595,6 +3595,10 @@ export default { apiKeyPassthrough: 'Auto passthrough (auth only)', apiKeyPassthroughDesc: 'Only applies to Anthropic API Key accounts. When enabled, messages/count_tokens are forwarded in passthrough mode with auth replacement only, while billing/concurrency/audit and safety filtering are preserved. Disable to roll back immediately.', + apiKeyAuthScheme: 'Upstream auth scheme', + apiKeyAuthSchemeDesc: 'Choose the API key auth header used when forwarding to an Anthropic-compatible upstream. Ollama Cloud uses Authorization: Bearer.', + apiKeyAuthSchemeXApiKey: 'x-api-key', + apiKeyAuthSchemeBearer: 'Authorization: Bearer', webSearchEmulation: 'Web Search Emulation', webSearchEmulationDesc: 'Enable web search emulation for this API Key account. When a pure web_search request is detected, the gateway calls a third-party search API and constructs the response locally. Default follows channel config.', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 59ccb038b7..72e6496be5 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -3763,6 +3763,10 @@ export default { apiKeyPassthrough: '自动透传(仅替换认证)', apiKeyPassthroughDesc: '仅对 Anthropic API Key 生效。开启后,messages/count_tokens 请求将透传上游并仅替换认证,保留计费/并发/审计及必要安全过滤;关闭即可回滚到现有兼容链路。', + apiKeyAuthScheme: '上游认证方式', + apiKeyAuthSchemeDesc: '选择转发到 Anthropic-compatible 上游时使用的 API Key 认证头。Ollama Cloud 使用 Authorization: Bearer。', + apiKeyAuthSchemeXApiKey: 'x-api-key', + apiKeyAuthSchemeBearer: 'Authorization: Bearer', webSearchEmulation: 'Web Search 模拟', webSearchEmulationDesc: '为该 API Key 账号启用 web search 模拟。客户端发送纯 web_search 请求时,由网关调用第三方搜索 API 并构造响应返回。默认跟随渠道配置。',