From 4ff653577b575bc7eec4c8c2d42e1b140aaf5aad Mon Sep 17 00:00:00 2001 From: Xinwei Gao Date: Tue, 28 Jul 2026 11:26:38 +0800 Subject: [PATCH 1/4] fix(anthropic): honor bearer key auth for gateway models Use apiKeyTransport when building Anthropic key-auth requests and normalize Anthropic model discovery URLs so /v1 base URLs do not become /v1/v1/models. --- src/adapters/anthropic.ts | 7 ++++++- src/oauth/index.ts | 11 +++++++---- src/types.ts | 6 ++++++ tests/google-models-listing.test.ts | 28 ++++++++++++++++++++++++++++ tests/umans-provider.test.ts | 11 ++++++++++- 5 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index cb8e73d7b..714fedac0 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -250,6 +250,10 @@ function usesNativeAnthropicEndpoint(provider: OcxProviderConfig): boolean { } } +function anthropicKeyUsesBearer(provider: OcxProviderConfig): boolean { + return provider.apiKeyTransport === "bearer"; +} + /** Map a Responses reasoning effort to an Anthropic extended-thinking budget (tokens, >= 1024). */ function reasoningBudget(effort: string): number { switch (effort) { @@ -694,7 +698,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti headers["X-Claude-Code-Session-Id"] = claudeCodeSessionId(provider.apiKey); headers["x-client-request-id"] = crypto.randomUUID(); } else { - headers["x-api-key"] = provider.apiKey; + if (anthropicKeyUsesBearer(provider)) headers["Authorization"] = `Bearer ${provider.apiKey}`; + else headers["x-api-key"] = provider.apiKey; } if (provider.headers) Object.assign(headers, provider.headers); diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 2eb48de77..c6dca4bc0 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -464,8 +464,9 @@ export async function resolveModelsAuthToken(name: string, prov: OcxProviderConf /** * Provider-correct `GET /models` request (URL + headers), so both model-listing paths fetch the * LIVE catalog correctly per adapter. Anthropic is the special case: its endpoint is `/v1/models` - * (not `/models`), it needs `anthropic-version`, and it authenticates with `x-api-key` (key) or - * `Authorization: Bearer` + the OAuth beta (oauth) — not a bare Bearer. Google (ai-studio mode) + * (not `/models`), it needs `anthropic-version`, and it authenticates with `x-api-key` by default + * (or `Authorization: Bearer` when `apiKeyTransport = "bearer"`), plus the OAuth beta for oauth + * mode — not a bare Bearer. Google (ai-studio mode) * is the other special case: `x-goog-api-key` + `/v1beta/models`, returning `{ models: [...] }`. * The catalog authority gate intentionally degrades that non-OpenAI shape to stale/static data. * Everyone else uses the OpenAI-style `/models` + Bearer with a `{ data: [{ id, owned_by? }] }` @@ -488,14 +489,16 @@ export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | und return { url: `${effectiveProvider.baseUrl}/v1beta/models?pageSize=1000`, headers }; } if (effectiveProvider.adapter === "anthropic") { + const base = effectiveProvider.baseUrl.replace(/\/v1\/?$/, ""); headers["anthropic-version"] = "2023-06-01"; if (effectiveProvider.authMode === "oauth") { headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA; if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; } else if (apiKey) { - headers["x-api-key"] = apiKey; + if (effectiveProvider.apiKeyTransport === "bearer") headers["Authorization"] = `Bearer ${apiKey}`; + else headers["x-api-key"] = apiKey; } - return { url: `${effectiveProvider.baseUrl}/v1/models?limit=1000`, headers }; + return { url: `${base}/v1/models?limit=1000`, headers }; } if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; return { url: `${effectiveProvider.baseUrl}/models`, headers }; diff --git a/src/types.ts b/src/types.ts index 622e4ec16..d8d286858 100644 --- a/src/types.ts +++ b/src/types.ts @@ -873,6 +873,12 @@ export interface OcxProviderConfig { */ codexAccountMode?: CodexAccountMode; apiKey?: string; + /** + * Key-auth header style for Anthropic-compatible providers. + * Defaults to the native Anthropic `x-api-key`; gateways may require + * `Authorization: Bearer ` instead. + */ + apiKeyTransport?: "x-api-key" | "bearer"; /** * Multi-key pool (API-key twin of OAuth multiauth). `apiKey` always mirrors the ACTIVE * entry so routing stays single-key; managed via /api/providers/keys. A legacy bare diff --git a/tests/google-models-listing.test.ts b/tests/google-models-listing.test.ts index f31bd1341..c0d61fd3b 100644 --- a/tests/google-models-listing.test.ts +++ b/tests/google-models-listing.test.ts @@ -49,6 +49,34 @@ describe("buildModelsRequest google routing", () => { }); }); +describe("buildModelsRequest anthropic routing", () => { + test("normalizes a /v1 baseUrl and keeps the Anthropic models path singular", () => { + const prov = { + adapter: "anthropic", + authMode: "key", + apiKeyTransport: "bearer", + baseUrl: "https://gateway.example.com/v1", + } as OcxProviderConfig; + const { url, headers } = buildModelsRequest(prov, "sk-ant", "gateway"); + expect(url).toBe("https://gateway.example.com/v1/models?limit=1000"); + expect(headers["Authorization"]).toBe("Bearer sk-ant"); + expect(headers["x-api-key"]).toBeUndefined(); + expect(headers["anthropic-version"]).toBe("2023-06-01"); + }); + + test("uses x-api-key by default for key-auth Anthropic providers", () => { + const prov = { + adapter: "anthropic", + authMode: "key", + baseUrl: "https://gateway.example.com", + } as OcxProviderConfig; + const { url, headers } = buildModelsRequest(prov, "sk-ant", "gateway"); + expect(url).toBe("https://gateway.example.com/v1/models?limit=1000"); + expect(headers["x-api-key"]).toBe("sk-ant"); + expect(headers["Authorization"]).toBeUndefined(); + }); +}); + describe("google models listing via catalog", () => { test("treats a { models } 2xx shape as malformed and degrades to the static seed", async () => { clearModelCache("google"); diff --git a/tests/umans-provider.test.ts b/tests/umans-provider.test.ts index 5e8ba72ba..c71ca1b20 100644 --- a/tests/umans-provider.test.ts +++ b/tests/umans-provider.test.ts @@ -4,11 +4,12 @@ import { providerConfigFromKeyLoginProvider } from "../src/oauth/login-cli"; import { enrichProviderFromCatalog, KEY_LOGIN_PROVIDERS, validateApiKey } from "../src/oauth/key-providers"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types"; -function umansProvider(apiKey = "sk-umans"): OcxProviderConfig { +function umansProvider(apiKey = "sk-umans", apiKeyTransport?: OcxProviderConfig["apiKeyTransport"]): OcxProviderConfig { return { adapter: "anthropic", baseUrl: "https://api.code.umans.ai", apiKey, + ...(apiKeyTransport ? { apiKeyTransport } : {}), defaultModel: "umans-coder", escapeBuiltinToolNames: true, }; @@ -113,6 +114,14 @@ describe("Umans provider", () => { expect(body.tool_choice).toEqual({ type: "tool", name: "cx_web_search" }); }); + test("Anthropic adapter honors apiKeyTransport=bearer for key-auth gateways", async () => { + const req = await createAnthropicAdapter(umansProvider("sk-umans", "bearer")).buildRequest(parsedWithWebSearchTool()); + + expect(req.headers.Authorization).toBe("Bearer sk-umans"); + expect(req.headers["x-api-key"]).toBeUndefined(); + expect(req.headers["anthropic-version"]).toBe("2023-06-01"); + }); + test("Anthropic adapter filters Umans tools for Responses allowed_tools choices", async () => { const req = await createAnthropicAdapter(umansProvider()).buildRequest({ modelId: "umans-kimi-k2.7", From 24c71bba26a4046b144ef50d8741f4a4ba18ce3c Mon Sep 17 00:00:00 2001 From: Xinwei Gao Date: Tue, 28 Jul 2026 11:39:04 +0800 Subject: [PATCH 2/4] fix(auth): propagate anthropic key transport to validation Carry apiKeyTransport through key-login provider derivation and use the same bearer vs x-api-key selection during Anthropic API-key validation. --- src/oauth/key-providers.ts | 23 +++++++++++++++++------ src/oauth/login-cli.ts | 1 + src/providers/derive.ts | 4 ++++ src/providers/registry.ts | 3 ++- tests/umans-provider.test.ts | 31 ++++++++++++++++++++++++++++++- 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/oauth/key-providers.ts b/src/oauth/key-providers.ts index a0ef0a820..f33223678 100644 --- a/src/oauth/key-providers.ts +++ b/src/oauth/key-providers.ts @@ -5,7 +5,8 @@ import { deriveKeyLoginMap, enrichProviderFromRegistry, type DerivedKeyLoginProv * API-key "login" providers: not OAuth — the flow opens the provider's dashboard so the user can * create/copy a key, then validates + stores it as the provider's `apiKey` (authMode "key"). * Most use the OpenAI-compatible chat API (`openai-chat` adapter, `Authorization: Bearer `); a - * few expose only an Anthropic-compatible endpoint and set `adapter: "anthropic"` (`x-api-key`). + * few expose only an Anthropic-compatible endpoint and set `adapter: "anthropic"` (default + * `x-api-key`, optional bearer via `apiKeyTransport`). */ export interface KeyLoginProvider extends DerivedKeyLoginProvider {} @@ -29,6 +30,20 @@ export function listKeyLoginProviders(): Array<{ id: string } & KeyLoginProvider return Object.entries(KEY_LOGIN_PROVIDERS).map(([id, p]) => ({ id, ...p })); } +function anthropicKeyValidationHeaders(provider: Pick, key: string): HeadersInit { + return provider.apiKeyTransport === "bearer" + ? { + "Content-Type": "application/json", + "anthropic-version": "2023-06-01", + "Authorization": `Bearer ${key}`, + } + : { + "Content-Type": "application/json", + "anthropic-version": "2023-06-01", + "x-api-key": key, + }; +} + /** Best-effort key validation. Returns true/false/unknown; never persists the key itself. */ export async function validateApiKey(provider: KeyLoginProvider, key: string): Promise { try { @@ -36,11 +51,7 @@ export async function validateApiKey(provider: KeyLoginProvider, key: string): P const base = provider.baseUrl.replace(/\/v1\/?$/, ""); const res = await fetch(`${base}/v1/messages`, { method: "POST", - headers: { - "Content-Type": "application/json", - "anthropic-version": "2023-06-01", - "x-api-key": key, - }, + headers: anthropicKeyValidationHeaders(provider, key), body: JSON.stringify({ model: provider.defaultModel ?? "claude-haiku-4-5", max_tokens: 1, diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index 1c7dfae2e..cf225b9ce 100644 --- a/src/oauth/login-cli.ts +++ b/src/oauth/login-cli.ts @@ -80,6 +80,7 @@ export function providerConfigFromKeyLoginProvider(def: KeyLoginProvider, key: s adapter: def.adapter, baseUrl: baseUrlOverride ?? def.baseUrl, apiKey: key, + ...(def.apiKeyTransport !== undefined ? { apiKeyTransport: def.apiKeyTransport } : {}), ...(def.defaultModel ? { defaultModel: def.defaultModel } : {}), ...(def.models ? { models: [...def.models] } : {}), ...(def.contextWindow !== undefined ? { contextWindow: def.contextWindow } : {}), diff --git a/src/providers/derive.ts b/src/providers/derive.ts index d24f852dd..b3a677ea5 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -5,6 +5,7 @@ export interface DerivedKeyLoginProvider { label: string; baseUrl: string; adapter: string; + apiKeyTransport?: OcxProviderConfig["apiKeyTransport"]; dashboardUrl: string; models?: string[]; liveModels?: boolean; @@ -103,6 +104,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon return { adapter: entry.adapter, baseUrl: entry.baseUrl, + ...(entry.apiKeyTransport !== undefined ? { apiKeyTransport: entry.apiKeyTransport } : {}), authMode: entry.authKind === "local" ? undefined : entry.authKind, ...(entry.codexAccountMode ? { codexAccountMode: entry.codexAccountMode } : {}), ...(entry.keyOptional !== undefined ? { keyOptional: entry.keyOptional } : {}), @@ -150,6 +152,7 @@ export function deriveKeyLoginMap(): Record { label: entry.label, baseUrl: entry.baseUrl, adapter: entry.adapter, + ...(entry.apiKeyTransport !== undefined ? { apiKeyTransport: entry.apiKeyTransport } : {}), dashboardUrl: entry.dashboardUrl, ...(entry.models ? { models: [...entry.models] } : {}), ...(entry.liveModels !== undefined ? { liveModels: entry.liveModels } : {}), @@ -221,6 +224,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig const entry = PROVIDER_REGISTRY.find(row => row.id === name); if (!entry) return; const seed = providerConfigSeed(entry); + if (prov.apiKeyTransport === undefined && seed.apiKeyTransport !== undefined) prov.apiKeyTransport = seed.apiKeyTransport; if (!prov.defaultModel && seed.defaultModel) prov.defaultModel = seed.defaultModel; // Fill mode only when absent: an explicit persisted `direct` must never be overwritten. if (prov.codexAccountMode === undefined && seed.codexAccountMode !== undefined) prov.codexAccountMode = seed.codexAccountMode; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 5552b845f..235a20f58 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -22,6 +22,7 @@ export interface ProviderRegistryEntry { label: string; adapter: string; baseUrl: string; + apiKeyTransport?: OcxProviderConfig["apiKeyTransport"]; authKind: ProviderAuthKind; codexAccountMode?: CodexAccountMode; /** OAuth preset may explicitly honor a persisted API-key billing mode. */ @@ -88,7 +89,7 @@ export interface ProviderRegistryEntry { export type ProviderConfigSeed = Pick< OcxProviderConfig, - "adapter" | "baseUrl" | "authMode" | "keyOptional" | "freeTier" | "modelSuffixBracketStrip" | "defaultModel" | "models" + "adapter" | "baseUrl" | "apiKeyTransport" | "authMode" | "keyOptional" | "freeTier" | "modelSuffixBracketStrip" | "defaultModel" | "models" | "liveModels" | "contextWindow" | "modelContextWindows" | "modelInputModalities" | "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens" | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" diff --git a/tests/umans-provider.test.ts b/tests/umans-provider.test.ts index c71ca1b20..36f2faa4a 100644 --- a/tests/umans-provider.test.ts +++ b/tests/umans-provider.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createAnthropicAdapter } from "../src/adapters/anthropic"; import { providerConfigFromKeyLoginProvider } from "../src/oauth/login-cli"; -import { enrichProviderFromCatalog, KEY_LOGIN_PROVIDERS, validateApiKey } from "../src/oauth/key-providers"; +import { enrichProviderFromCatalog, KEY_LOGIN_PROVIDERS, validateApiKey, type KeyLoginProvider } from "../src/oauth/key-providers"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types"; function umansProvider(apiKey = "sk-umans", apiKeyTransport?: OcxProviderConfig["apiKeyTransport"]): OcxProviderConfig { @@ -82,6 +82,16 @@ describe("Umans provider", () => { expect(provider.modelInputModalities?.["umans-kimi-k2.7"]).toEqual(["text", "image"]); }); + test("CLI key-login save payload preserves apiKeyTransport when configured", () => { + const bearerGateway = { + ...KEY_LOGIN_PROVIDERS.umans, + apiKeyTransport: "bearer", + } satisfies KeyLoginProvider; + const provider = providerConfigFromKeyLoginProvider(bearerGateway, "sk-umans"); + + expect(provider.apiKeyTransport).toBe("bearer"); + }); + test("OpenAI API key-login clones max-input metadata and never persists virtual maps", () => { const source = KEY_LOGIN_PROVIDERS["openai-apikey"]; const provider = providerConfigFromKeyLoginProvider(source, "sk-openai"); @@ -217,4 +227,23 @@ describe("Umans provider", () => { expect(body.model).toBe("umans-coder"); expect(body.max_tokens).toBe(1); }); + + test("Anthropic API-key validation honors apiKeyTransport=bearer", async () => { + let seenInit: RequestInit | undefined; + globalThis.fetch = (async (_url, init) => { + seenInit = init; + return new Response("{}", { status: 200 }); + }) as typeof fetch; + + const valid = await validateApiKey({ + ...KEY_LOGIN_PROVIDERS.umans, + apiKeyTransport: "bearer", + }, "sk-umans-valid"); + const headers = new Headers(seenInit?.headers); + + expect(valid).toBe(true); + expect(headers.get("authorization")).toBe("Bearer sk-umans-valid"); + expect(headers.get("x-api-key")).toBeNull(); + expect(headers.get("anthropic-version")).toBe("2023-06-01"); + }); }); From fbcc8311808db04da493ed80d847d434a6107d45 Mon Sep 17 00:00:00 2001 From: Xinwei Gao Date: Tue, 28 Jul 2026 12:10:43 +0800 Subject: [PATCH 3/4] fix(anthropic): expose bearer key auth setup --- .../src/content/docs/ja/reference/adapters.md | 2 +- .../docs/ja/reference/configuration.md | 1 + .../src/content/docs/ko/reference/adapters.md | 2 +- .../docs/ko/reference/configuration.md | 1 + .../src/content/docs/reference/adapters.md | 2 +- .../content/docs/reference/configuration.md | 1 + .../src/content/docs/ru/reference/adapters.md | 2 +- .../docs/ru/reference/configuration.md | 1 + .../content/docs/zh-cn/reference/adapters.md | 2 +- .../docs/zh-cn/reference/configuration.md | 1 + gui/src/components/AddProviderModal.tsx | 1 + gui/src/components/add-provider-form-pane.tsx | 12 +++++++ .../components/add-provider-modal-reducer.ts | 2 +- .../provider-workspace/ProviderSettings.tsx | 18 +++++++++- .../components/provider-workspace/types.ts | 1 + gui/src/i18n/de.ts | 3 ++ gui/src/i18n/en.ts | 3 ++ gui/src/i18n/ja.ts | 3 ++ gui/src/i18n/ko.ts | 3 ++ gui/src/i18n/zh.ts | 3 ++ gui/src/provider-payload.ts | 5 +++ gui/src/provider-workspace/catalog.ts | 1 + src/cli/provider-runtime.ts | 3 ++ src/cli/provider.ts | 34 +++++++++++++------ src/config.ts | 26 ++++++++++++++ src/server/auth-cors.ts | 4 +++ src/server/management/provider-routes.ts | 13 +++++++ tests/cli-headless-parity.test.ts | 4 +-- tests/config.test.ts | 20 +++++++++++ tests/management-provider-validation.test.ts | 12 +++++++ tests/provider-payload.test.ts | 19 +++++++++++ 31 files changed, 186 insertions(+), 19 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/adapters.md b/docs-site/src/content/docs/ja/reference/adapters.md index c71df875e..ea97ddbcf 100644 --- a/docs-site/src/content/docs/ja/reference/adapters.md +++ b/docs-site/src/content/docs/ja/reference/adapters.md @@ -45,7 +45,7 @@ interface ProviderAdapter { ## `anthropic` **対象:** Anthropic **Messages**(`/v1/messages`)。 -**認証:** `key`(`x-api-key`)または `oauth`(Bearer + `anthropic-beta`、Claude Pro/Max 用)。 +**認証:** `key`(デフォルトは `x-api-key`、または `apiKeyTransport: "bearer"` による `Authorization: Bearer`)または `oauth`(Bearer + `anthropic-beta`、Claude Pro/Max 用)。 - メッセージを Anthropic content block(text、base64 image、`tool_use`、`thinking`)に変換します。 - **Extended thinking の計算:** Anthropic は `max_tokens > thinking.budget_tokens` を要求します。 diff --git a/docs-site/src/content/docs/ja/reference/configuration.md b/docs-site/src/content/docs/ja/reference/configuration.md index e9a42e87d..5443729e6 100644 --- a/docs-site/src/content/docs/ja/reference/configuration.md +++ b/docs-site/src/content/docs/ja/reference/configuration.md @@ -136,6 +136,7 @@ token の代わりに使えます。すべての候補は timing side channel | `responsesPath?` | `string` | `key` 認証の `openai-responses` リクエストに使う任意の相対 resource path。`/` で始め、URL scheme、query、fragment を含めてはいけません。省略時は従来の `/v1/responses` URL 構築を維持します。 | | `disabled?` | `boolean` | 設定はディスクに残すがルーティングとモデル/カタログ一覧から除外します。 | | `apiKey?` | `string` | API キーまたはリクエスト時に解釈する `${ENV_VAR}` / `$ENV_VAR` 参照。 | +| `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic API キーのヘッダー方式。デフォルトはネイティブの `x-api-key` です。`Authorization: Bearer ` が必要な互換 gateway では `"bearer"` を設定します。key 認証の `anthropic` プロバイダーでのみ有効です。 | | `apiKeyPool?` | `ApiKeyPoolEntry[]` | 複数キーを納める pool。`apiKey` はアクティブ項目を反映します。各項目には `id`、`key`、選択 `label`、選択数値 `addedAt` があります。 | | `defaultModel?` | `string` | 明示的なモデルなしでこのプロバイダーを選んだときに使うモデル。 | | `models?` | `string[]` | seed/fallback モデル一覧。`liveModels` が `false` ならここにあるモデルだけが発見されます。 | diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index 9610162ae..ddfe86864 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -54,7 +54,7 @@ interface ProviderAdapter { ## `anthropic` **대상:** Anthropic **Messages**(`/v1/messages`). -**인증:** `key`(`x-api-key`) 또는 `oauth`(Bearer + `anthropic-beta`, Claude Pro/Max용). +**인증:** `key`(기본 `x-api-key`, 또는 `apiKeyTransport: "bearer"` 설정 시 `Authorization: Bearer`) 또는 `oauth`(Bearer + `anthropic-beta`, Claude Pro/Max용). - 메시지를 Anthropic content block(text, base64 image, `tool_use`, `thinking`)으로 변환합니다. - **Extended thinking 계산:** Anthropic은 `max_tokens > thinking.budget_tokens`를 요구합니다. diff --git a/docs-site/src/content/docs/ko/reference/configuration.md b/docs-site/src/content/docs/ko/reference/configuration.md index f56e6288d..80f9649f3 100644 --- a/docs-site/src/content/docs/ko/reference/configuration.md +++ b/docs-site/src/content/docs/ko/reference/configuration.md @@ -139,6 +139,7 @@ token 대신 쓸 수 있습니다. 모든 후보는 timing side channel을 막 | `responsesPath?` | `string` | `key` 인증 `openai-responses` 요청에 사용할 선택적 상대 resource path. `/`로 시작해야 하며 URL scheme, query, fragment를 포함할 수 없습니다. 생략하면 기존 `/v1/responses` URL 구성을 유지합니다. | | `disabled?` | `boolean` | 설정은 디스크에 남기되 라우팅과 모델/카탈로그 목록에서 제외합니다. | | `apiKey?` | `string` | API 키 또는 요청 시점에 해석할 `${ENV_VAR}` / `$ENV_VAR` 참조. | +| `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic API 키 헤더 방식입니다. 기본값은 네이티브 `x-api-key`이며, `Authorization: Bearer `를 요구하는 호환 gateway에는 `"bearer"`를 설정합니다. key 인증 `anthropic` 프로바이더에서만 유효합니다. | | `apiKeyPool?` | `ApiKeyPoolEntry[]` | 여러 키를 담는 pool. `apiKey`는 활성 항목을 반영합니다. 각 항목에는 `id`, `key`, 선택 `label`, 선택 숫자 `addedAt`이 있습니다. | | `defaultModel?` | `string` | 명시적인 모델 없이 이 프로바이더를 선택했을 때 쓸 모델. | | `models?` | `string[]` | seed/fallback 모델 목록. `liveModels`가 `false`이면 여기 있는 모델만 발견됩니다. | diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index e2ab3ebee..25cd3b962 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -54,7 +54,7 @@ streams the response back **untranslated**. ## `anthropic` **Targets:** Anthropic **Messages** (`/v1/messages`). -**Auth:** `key` (`x-api-key`) or `oauth` (Bearer + `anthropic-beta`, for Claude Pro/Max). +**Auth:** `key` (`x-api-key` by default, or `Authorization: Bearer` with `apiKeyTransport: "bearer"`) or `oauth` (Bearer + `anthropic-beta`, for Claude Pro/Max). - Converts messages to Anthropic content blocks (text, base64 image, `tool_use`, `thinking`). - **Extended thinking math:** Anthropic requires `max_tokens > thinking.budget_tokens`. The adapter diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index ee28eb270..a704f12a1 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -241,6 +241,7 @@ or bind the forward explicitly to loopback (`ssh -L 127.0.0.1:20100:localhost:10 | `responsesPath?` | `string` | Optional relative resource path for key-auth `openai-responses` requests. It must start with `/` and contain no URL scheme, query, or fragment. When omitted, the adapter keeps its legacy `/v1/responses` URL construction. | | `disabled?` | `boolean` | Keep the provider on disk but exclude it from routing and model/catalog listings. | | `apiKey?` | `string` | API key, or an `${ENV_VAR}` / `$ENV_VAR` reference resolved at request time. | +| `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic API-key header style. Defaults to native `x-api-key`; set `"bearer"` for compatible gateways that require `Authorization: Bearer `. Valid only for key-auth `anthropic` providers. | | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Multi-key pool. `apiKey` mirrors the active entry; each item has `id`, `key`, optional `label`, and optional numeric `addedAt`. | | `defaultModel?` | `string` | Model used when this provider is selected without an explicit model. | | `models?` | `string[]` | Seed/fallback model list. When `liveModels` is `false`, these are the only discovered models. | diff --git a/docs-site/src/content/docs/ru/reference/adapters.md b/docs-site/src/content/docs/ru/reference/adapters.md index fac4fc6cf..80c7ae647 100644 --- a/docs-site/src/content/docs/ru/reference/adapters.md +++ b/docs-site/src/content/docs/ru/reference/adapters.md @@ -57,7 +57,7 @@ interface ProviderAdapter { ## `anthropic` **Назначение:** Anthropic **Messages** (`/v1/messages`). -**Аутентификация:** `key` (`x-api-key`) или `oauth` (Bearer + `anthropic-beta`, для Claude Pro/Max). +**Аутентификация:** `key` (по умолчанию `x-api-key`, либо `Authorization: Bearer` при `apiKeyTransport: "bearer"`) или `oauth` (Bearer + `anthropic-beta`, для Claude Pro/Max). - Преобразует сообщения в блоки контента Anthropic (text, base64 image, `tool_use`, `thinking`). - **Арифметика extended thinking:** Anthropic требует `max_tokens > thinking.budget_tokens`. diff --git a/docs-site/src/content/docs/ru/reference/configuration.md b/docs-site/src/content/docs/ru/reference/configuration.md index 4d426322f..a3101a9d7 100644 --- a/docs-site/src/content/docs/ru/reference/configuration.md +++ b/docs-site/src/content/docs/ru/reference/configuration.md @@ -158,6 +158,7 @@ x-opencodex-api-key: your-secret-token | `responsesPath?` | `string` | Необязательный относительный путь ресурса для запросов `openai-responses` с аутентификацией `key`. Должен начинаться с `/` и не содержать схему URL, query или fragment. Если поле опущено, сохраняется прежнее построение URL `/v1/responses`. | | `disabled?` | `boolean` | Провайдер остаётся на диске, но исключается из маршрутизации и списков моделей/каталога. | | `apiKey?` | `string` | API-ключ или ссылка `${ENV_VAR}` / `$ENV_VAR`, разрешаемая в момент запроса. | +| `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Способ передачи API-ключа Anthropic. По умолчанию используется нативный `x-api-key`; для совместимых gateway, требующих `Authorization: Bearer `, задайте `"bearer"`. Допустимо только для `anthropic` provider с key-аутентификацией. | | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Пул из нескольких ключей. `apiKey` отражает активную запись; каждый элемент содержит `id`, `key`, необязательный `label` и необязательное числовое `addedAt`. | | `defaultModel?` | `string` | Модель, используемая при выборе этого провайдера без явной модели. | | `models?` | `string[]` | Список seed/fallback-моделей. Когда `liveModels` равен `false`, обнаруживаются только эти модели. | diff --git a/docs-site/src/content/docs/zh-cn/reference/adapters.md b/docs-site/src/content/docs/zh-cn/reference/adapters.md index 6b52365cf..6a3d89a98 100644 --- a/docs-site/src/content/docs/zh-cn/reference/adapters.md +++ b/docs-site/src/content/docs/zh-cn/reference/adapters.md @@ -52,7 +52,7 @@ interface ProviderAdapter { ## `anthropic` **目标:** Anthropic **Messages**(`/v1/messages`)。 -**认证:** `key`(`x-api-key`)或 `oauth`(Bearer + `anthropic-beta`,用于 Claude Pro/Max)。 +**认证:** `key`(默认 `x-api-key`,或设置 `apiKeyTransport: "bearer"` 后使用 `Authorization: Bearer`)或 `oauth`(Bearer + `anthropic-beta`,用于 Claude Pro/Max)。 - 把消息转换成 Anthropic content block(text、base64 image、`tool_use`、`thinking`)。 - **Extended thinking 计算:** Anthropic 要求 `max_tokens > thinking.budget_tokens`。adapter 把 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index f41c59aea..fe4b47352 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -131,6 +131,7 @@ x-opencodex-api-key: your-secret-token | `responsesPath?` | `string` | `key` 认证的 `openai-responses` 请求可选相对 resource path。必须以 `/` 开头,且不得包含 URL scheme、query 或 fragment。省略时保留原有的 `/v1/responses` URL 构造。 | | `disabled?` | `boolean` | 配置保留在磁盘上,但从路由和模型/目录列表排除。 | | `apiKey?` | `string` | API key,或在请求时解析的 `${ENV_VAR}` / `$ENV_VAR` 引用。 | +| `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic API key 的请求头方式。默认使用原生 `x-api-key`;兼容 gateway 要求 `Authorization: Bearer ` 时设为 `"bearer"`。仅适用于使用 key 认证的 `anthropic` provider。 | | `apiKeyPool?` | `ApiKeyPoolEntry[]` | 多 key pool。`apiKey` 映射当前活动条目;每项包含 `id`、`key`、可选 `label` 和可选数字 `addedAt`。 | | `defaultModel?` | `string` | 选中该 provider 但未指定明确模型时使用的模型。 | | `models?` | `string[]` | seed/fallback 模型列表。`liveModels` 为 `false` 时,只会发现这些模型。 | diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index a17753fa4..333971bbb 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -144,6 +144,7 @@ export default function AddProviderModal({ : p.baseUrl, authMode: p.auth, apiKey: "", + apiKeyTransport: undefined, defaultModel: p.defaultModel ?? "", allowPrivateNetwork: false, }, diff --git a/gui/src/components/add-provider-form-pane.tsx b/gui/src/components/add-provider-form-pane.tsx index 89a3fbd8a..78cba31d3 100644 --- a/gui/src/components/add-provider-form-pane.tsx +++ b/gui/src/components/add-provider-form-pane.tsx @@ -143,6 +143,18 @@ export function AddProviderFormPane({ onFormChange({ ...form, apiKey: e.target.value })} placeholder={t("modal.apiKeyPlaceholder")} /> + {form.adapter === "anthropic" && form.authMode === "key" && ( + + + + )} )} {!isReservedForward && diff --git a/gui/src/components/add-provider-modal-reducer.ts b/gui/src/components/add-provider-modal-reducer.ts index db9775d62..97ee7cb17 100644 --- a/gui/src/components/add-provider-modal-reducer.ts +++ b/gui/src/components/add-provider-modal-reducer.ts @@ -56,7 +56,7 @@ export function createInitialAddProviderState( return { preset: initialCustom ? customPreset : null, form: initialCustom - ? { name: "", adapter: "openai-chat", baseUrl: "", authMode: "key", apiKey: "", defaultModel: "", allowPrivateNetwork: false } + ? { name: "", adapter: "openai-chat", baseUrl: "", authMode: "key", apiKey: "", apiKeyTransport: undefined, defaultModel: "", allowPrivateNetwork: false } : null, saving: false, error: "", diff --git a/gui/src/components/provider-workspace/ProviderSettings.tsx b/gui/src/components/provider-workspace/ProviderSettings.tsx index e88047366..c43776467 100644 --- a/gui/src/components/provider-workspace/ProviderSettings.tsx +++ b/gui/src/components/provider-workspace/ProviderSettings.tsx @@ -40,6 +40,7 @@ export default function ProviderSettings({ const [baseUrl, setBaseUrl] = useState(item.baseUrl); const [defaultModel, setDefaultModel] = useState(item.defaultModel ?? ""); const [authMode, setAuthMode] = useState(initialAuth); + const [apiKeyTransport, setApiKeyTransport] = useState(item.apiKeyTransport ?? "x-api-key"); const [note, setNote] = useState(item.note ?? ""); const [allowPrivateNetwork, setAllowPrivateNetwork] = useState(item.allowPrivateNetwork ?? false); const [liveModels, setLiveModels] = useState(item.liveModels !== false); @@ -55,12 +56,13 @@ export default function ProviderSettings({ setBaseUrl(item.baseUrl); setDefaultModel(item.defaultModel ?? ""); setAuthMode(String(item.authMode ?? (item.keyOptional ? "local" : "key"))); + setApiKeyTransport(item.apiKeyTransport ?? "x-api-key"); setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(item.liveModels !== false); setMsg(null); queueMicrotask(() => setEndpointChoice(matchChoiceId(baseUrlChoices, item.baseUrl))); - }, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.keyOptional, item.note, item.allowPrivateNetwork, item.liveModels, baseUrlChoices]); + }, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.apiKeyTransport, item.keyOptional, item.note, item.allowPrivateNetwork, item.liveModels, baseUrlChoices]); /* eslint-enable react-hooks/set-state-in-effect */ useEffect(() => { @@ -97,6 +99,7 @@ export default function ProviderSettings({ || baseUrl.trim() !== item.baseUrl || defaultModel.trim() !== (item.defaultModel ?? "") || authMode !== String(item.authMode ?? (item.keyOptional ? "local" : "key")) + || (adapter.trim() === "anthropic" && authMode === "key" && apiKeyTransport !== (item.apiKeyTransport ?? "x-api-key")) || note.trim() !== (item.note ?? "") || allowPrivateNetwork !== (item.allowPrivateNetwork ?? false) || liveModels !== (item.liveModels !== false); @@ -118,6 +121,7 @@ export default function ProviderSettings({ const isPreset = isCatalogProviderId(item.name); const hasEndpointPicker = choicesStatus === "ready" && !!(baseUrlChoices && baseUrlChoices.length > 0); + const supportsApiKeyTransport = adapter.trim() === "anthropic" && authMode === "key"; // Lock plain baseUrl for presets while loading or when there is no picker. // On fetch error, keep it editable so allowBaseUrlOverride providers are not trapped. const plainBaseUrlLocked = isPreset && choicesStatus !== "error"; @@ -132,6 +136,8 @@ export default function ProviderSettings({ setMsg(null); try { const patch: ProviderUpdatePatch = { adapter: adapter.trim(), baseUrl: nextBaseUrl, defaultModel: defaultModel.trim(), authMode, note: note.trim(), allowPrivateNetwork, liveModels }; + if (supportsApiKeyTransport) patch.apiKeyTransport = apiKeyTransport; + else if (item.apiKeyTransport !== undefined) patch.apiKeyTransport = ""; const res = await onUpdateProvider(item.name, patch); setMsg(res.ok ? { ok: true, text: t("pws.settingsSaved") } : { ok: false, text: res.error || t("prov.saveFailed") }); return res.ok; @@ -153,6 +159,7 @@ export default function ProviderSettings({ const discard = () => { setAdapter(item.adapter); setBaseUrl(item.baseUrl); setDefaultModel(item.defaultModel ?? ""); setAuthMode(initialAuth); + setApiKeyTransport(item.apiKeyTransport ?? "x-api-key"); setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(item.liveModels !== false); setMsg(null); setEndpointChoice(matchChoiceId(baseUrlChoices, item.baseUrl)); }; @@ -233,6 +240,15 @@ export default function ProviderSettings({ )} + {supportsApiKeyTransport && ( + + )}