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') }} +
++ {{ t('admin.accounts.anthropic.apiKeyAuthSchemeDesc') }} +
+