diff --git a/web-app/src/lib/__tests__/remoteModelCatalog.test.ts b/web-app/src/lib/__tests__/remoteModelCatalog.test.ts index 07ae3b970a..9e6dc4964a 100644 --- a/web-app/src/lib/__tests__/remoteModelCatalog.test.ts +++ b/web-app/src/lib/__tests__/remoteModelCatalog.test.ts @@ -130,19 +130,14 @@ describe('fetchTopRemoteModels openai', () => { expect(gpt35.capabilities).toEqual(['completion', 'tools']) }) - it('sends bearer + x-api-key headers', async () => { + it('sends only Authorization (no x-api-key) for OpenAI-compatible providers', async () => { const fetchImpl = vi.fn().mockResolvedValue(mkResponse({ data: [] })) await fetchTopRemoteModels(mkOpenAIProvider(), fetchImpl) - expect(fetchImpl).toHaveBeenCalledWith( - 'https://api.openai.com/v1/models', - expect.objectContaining({ - method: 'GET', - headers: expect.objectContaining({ - Authorization: 'Bearer sk-test', - 'x-api-key': 'sk-test', - }), - }) - ) + const headers = fetchImpl.mock.calls[0][1].headers as Record + expect(headers.Authorization).toBe('Bearer sk-test') + // Sending both Authorization and x-api-key breaks upstreams that reject + // mixed auth (e.g. AWS Bedrock Mantle returns 401). See issue #8444. + expect(headers).not.toHaveProperty('x-api-key') }) it('retries with fallback key on 401', async () => { @@ -232,6 +227,14 @@ describe('fetchTopRemoteModels anthropic', () => { ) }) + it('sends only x-api-key (no Authorization) for anthropic providers', async () => { + const fetchImpl = vi.fn().mockResolvedValue(mkResponse({ data: [] })) + await fetchTopRemoteModels(mkAnthropicProvider(), fetchImpl) + const headers = fetchImpl.mock.calls[0][1].headers as Record + expect(headers['x-api-key']).toBe('sk-ant') + expect(headers).not.toHaveProperty('Authorization') + }) + it('does not send anthropic headers for openai', async () => { const fetchImpl = vi.fn().mockResolvedValue(mkResponse({ data: [] })) await fetchTopRemoteModels(mkOpenAIProvider(), fetchImpl) diff --git a/web-app/src/lib/remoteModelCatalog.ts b/web-app/src/lib/remoteModelCatalog.ts index 632d64e604..cd5d9fa251 100644 --- a/web-app/src/lib/remoteModelCatalog.ts +++ b/web-app/src/lib/remoteModelCatalog.ts @@ -64,6 +64,26 @@ export function ensureAnthropicHeaders( setDefaultHeader(headers, ANTHROPIC_BROWSER_ACCESS_HEADER, 'true') } +/// Attach the single auth header a provider expects on model-list / key-test +/// requests. Anthropic-shaped providers authenticate via `x-api-key`; every +/// other (OpenAI-compatible) provider uses `Authorization: *** Sending both +/// at once breaks upstreams that reject mixed auth — e.g. AWS Bedrock Mantle +/// answers `401 "request must not include both 'authorization' and +/// 'x-api-key' headers"`. This mirrors how chat requests already pick one +/// header per provider in model-factory. +export function applyProviderAuthHeader( + provider: { provider?: string; base_url?: string; api_type?: string }, + headers: Record, + key: string | undefined +): void { + if (!key) return + if (isAnthropicProvider(provider)) { + headers['x-api-key'] = key + } else { + headers['Authorization'] = `Bearer ${key}` + } +} + type CatalogKind = 'openai' | 'anthropic' | 'gemini' /// Resolve which catalog shape a provider speaks. `api_type` is authoritative @@ -162,10 +182,7 @@ function inferAnthropicCapabilities(id: string): string[] | null { function buildHeaders(p: ProviderLike, key: string | undefined): Record { const headers: Record = { 'Content-Type': 'application/json' } - if (key) { - headers['x-api-key'] = key - headers['Authorization'] = `Bearer ${key}` - } + applyProviderAuthHeader(p, headers, key) if (p.custom_header) { for (const h of p.custom_header) headers[h.header] = h.value } diff --git a/web-app/src/routes/settings/providers/$providerName.tsx b/web-app/src/routes/settings/providers/$providerName.tsx index 25668e44d3..c229bf5835 100644 --- a/web-app/src/routes/settings/providers/$providerName.tsx +++ b/web-app/src/routes/settings/providers/$providerName.tsx @@ -51,6 +51,8 @@ import { import { supportsRemoteCatalog, fetchTopRemoteModels, + applyProviderAuthHeader, + ensureAnthropicHeaders, } from '@/lib/remoteModelCatalog' // as route.threadsDetail @@ -450,15 +452,15 @@ function ProviderDetail() { if (!key) continue const headers: Record = { 'Content-Type': 'application/json', - 'x-api-key': key, - Authorization: `Bearer ${key}`, } + applyProviderAuthHeader(provider, headers, key) if ( provider.base_url.includes('localhost:') || provider.base_url.includes('127.0.0.1:') ) { headers['Origin'] = 'tauri://localhost' } + ensureAnthropicHeaders(provider, headers) try { const response = await fetchImpl(`${provider.base_url}/models`, { @@ -492,7 +494,7 @@ function ProviderDetail() { } finally { setIsTestingKeys(false) } - }, [apiKeysDraft, maskApiKey, provider?.base_url, serviceHub, t]) + }, [apiKeysDraft, maskApiKey, provider, serviceHub, t]) // Auto-refresh provider settings to get updated backend configuration const refreshSettings = useCallback(async () => { diff --git a/web-app/src/services/providers/__tests__/tauri.test.ts b/web-app/src/services/providers/__tests__/tauri.test.ts index e9496bbb2a..4d1bfbccec 100644 --- a/web-app/src/services/providers/__tests__/tauri.test.ts +++ b/web-app/src/services/providers/__tests__/tauri.test.ts @@ -338,7 +338,7 @@ describe('TauriProvidersService', () => { ) }) - it('adds auth headers when api key is available', async () => { + it('adds only the Authorization header for OpenAI-compatible providers', async () => { vi.mocked(providerRemoteApiKeyChain).mockReturnValue(['sk-test']) vi.mocked(fetchTauri).mockResolvedValueOnce({ ok: true, @@ -347,15 +347,31 @@ describe('TauriProvidersService', () => { } as any) await svc.fetchModelsFromProvider(baseProvider) - expect(fetchTauri).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - headers: expect.objectContaining({ - 'x-api-key': 'sk-test', - Authorization: 'Bearer sk-test', - }), - }) - ) + const headers = vi.mocked(fetchTauri).mock.calls[0][1]! + .headers as Record + expect(headers.Authorization).toBe('Bearer sk-test') + // Both auth headers at once break upstreams that reject mixed auth + // (e.g. AWS Bedrock Mantle answers 401). See issue #8444. + expect(headers).not.toHaveProperty('x-api-key') + }) + + it('adds only the x-api-key header for anthropic-shaped providers', async () => { + vi.mocked(providerRemoteApiKeyChain).mockReturnValue(['sk-ant']) + vi.mocked(fetchTauri).mockResolvedValueOnce({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ data: [] }), + } as any) + + await svc.fetchModelsFromProvider({ + ...baseProvider, + provider: 'my-gateway', + api_type: 'anthropic', + }) + const headers = vi.mocked(fetchTauri).mock.calls[0][1]! + .headers as Record + expect(headers['x-api-key']).toBe('sk-ant') + expect(headers).not.toHaveProperty('Authorization') }) it('adds default anthropic-version header for anthropic-shaped custom providers', async () => { diff --git a/web-app/src/services/providers/tauri.ts b/web-app/src/services/providers/tauri.ts index fd2b25c157..97d84f813f 100644 --- a/web-app/src/services/providers/tauri.ts +++ b/web-app/src/services/providers/tauri.ts @@ -16,7 +16,7 @@ import { API_KEY_FALLBACKS_SETTING_KEY, providerRemoteApiKeyChain, } from '@/lib/provider-api-keys' -import { ensureAnthropicHeaders } from '@/lib/remoteModelCatalog' +import { ensureAnthropicHeaders, applyProviderAuthHeader } from '@/lib/remoteModelCatalog' export class TauriProvidersService extends DefaultProvidersService { fetch(): typeof fetch { @@ -170,10 +170,7 @@ export class TauriProvidersService extends DefaultProvidersService { headers['Origin'] = 'tauri://localhost' } - if (key) { - headers['x-api-key'] = key - headers['Authorization'] = `Bearer ${key}` - } + applyProviderAuthHeader(provider, headers, key) if (provider.custom_header) { provider.custom_header.forEach((header) => {