Merge pull request #3627 from alfadb/fix/ollama-anthropic-bearer-auth

fix(anthropic): 支持 API Key Bearer 认证方式
This commit is contained in:
Wesley Liddick
2026-07-02 17:37:30 +08:00
committed by GitHub
11 changed files with 238 additions and 14 deletions
@@ -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())
})
}
}
@@ -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
@@ -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)
}
@@ -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)
+4 -4
View File
@@ -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)
+7 -3
View File
@@ -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
}
@@ -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,
@@ -2653,6 +2653,24 @@
</div>
</div>
<div
v-if="form.platform === 'anthropic' && accountCategory === 'apikey'"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between gap-4">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.anthropic.apiKeyAuthScheme') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.anthropic.apiKeyAuthSchemeDesc') }}
</p>
</div>
<select v-model="anthropicAPIKeyAuthScheme" class="input w-52 text-sm">
<option value="x_api_key">{{ t('admin.accounts.anthropic.apiKeyAuthSchemeXApiKey') }}</option>
<option value="authorization_bearer">{{ t('admin.accounts.anthropic.apiKeyAuthSchemeBearer') }}</option>
</select>
</div>
</div>
<!-- Anthropic API Key: Web Search Emulation (hidden when global disabled) -->
<div
v-if="form.platform === 'anthropic' && accountCategory === 'apikey' && webSearchGlobalEnabled"
@@ -3503,7 +3521,9 @@ const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(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<AnthropicAPIKeyAuthScheme>('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<string, unknown>): Record<string, unk
} else {
delete extra.anthropic_passthrough
}
if (anthropicAPIKeyAuthScheme.value === 'authorization_bearer') {
extra.anthropic_apikey_auth_scheme = 'authorization_bearer'
} else {
delete extra.anthropic_apikey_auth_scheme
}
if (webSearchEmulationMode.value === 'default') {
delete extra.web_search_emulation
} else {
@@ -1537,6 +1537,24 @@
</div>
</div>
<div
v-if="account?.platform === 'anthropic' && account?.type === 'apikey'"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between gap-4">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.anthropic.apiKeyAuthScheme') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.anthropic.apiKeyAuthSchemeDesc') }}
</p>
</div>
<select v-model="anthropicAPIKeyAuthScheme" class="input w-52 text-sm">
<option value="x_api_key">{{ t('admin.accounts.anthropic.apiKeyAuthSchemeXApiKey') }}</option>
<option value="authorization_bearer">{{ t('admin.accounts.anthropic.apiKeyAuthSchemeBearer') }}</option>
</select>
</div>
</div>
<!-- Anthropic API Key: Web Search Emulation (hidden when global disabled) -->
<div
v-if="account?.platform === 'anthropic' && account?.type === 'apikey' && webSearchGlobalEnabled"
@@ -2610,7 +2628,9 @@ const codexCLIOnlyEnabled = ref(false)
const codexCLIOnlyAppServerEnabled = ref(false)
type CodexImageGenerationBridgeMode = 'inherit' | 'enabled' | 'disabled'
const codexImageGenerationBridgeMode = ref<CodexImageGenerationBridgeMode>('inherit')
type AnthropicAPIKeyAuthScheme = 'x_api_key' | 'authorization_bearer'
const anthropicPassthroughEnabled = ref(false)
const anthropicAPIKeyAuthScheme = ref<AnthropicAPIKeyAuthScheme>('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 {
+4
View File
@@ -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.',
+4
View File
@@ -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 并构造响应返回。默认跟随渠道配置。',