Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions web-app/src/lib/__tests__/remoteModelCatalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
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 () => {
Expand Down Expand Up @@ -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<string, string>
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)
Expand Down
25 changes: 21 additions & 4 deletions web-app/src/lib/remoteModelCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>,
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
Expand Down Expand Up @@ -162,10 +182,7 @@ function inferAnthropicCapabilities(id: string): string[] | null {

function buildHeaders(p: ProviderLike, key: string | undefined): Record<string, string> {
const headers: Record<string, string> = { '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
}
Expand Down
8 changes: 5 additions & 3 deletions web-app/src/routes/settings/providers/$providerName.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ import {
import {
supportsRemoteCatalog,
fetchTopRemoteModels,
applyProviderAuthHeader,
ensureAnthropicHeaders,
} from '@/lib/remoteModelCatalog'

// as route.threadsDetail
Expand Down Expand Up @@ -450,15 +452,15 @@ function ProviderDetail() {
if (!key) continue
const headers: Record<string, string> = {
'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`, {
Expand Down Expand Up @@ -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 () => {
Expand Down
36 changes: 26 additions & 10 deletions web-app/src/services/providers/__tests__/tauri.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, string>
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<string, string>
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 () => {
Expand Down
7 changes: 2 additions & 5 deletions web-app/src/services/providers/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) => {
Expand Down