From eaea0347ace2477991446acd490a64f008be025d Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 4 Aug 2026 21:36:04 -0300 Subject: [PATCH] fix(executor): guard claude/anthropic buildHeaders against empty credentials and extend dual-Bearer parity for third-party baseUrls (#8653) --- changelog.d/fixes/8653-fix.plan.md | 1 + open-sse/executors/default.ts | 23 ++- ...ecutor-default-anthropic-auth-8653.test.ts | 160 ++++++++++++++++++ 3 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 changelog.d/fixes/8653-fix.plan.md create mode 100644 tests/unit/executor-default-anthropic-auth-8653.test.ts diff --git a/changelog.d/fixes/8653-fix.plan.md b/changelog.d/fixes/8653-fix.plan.md new file mode 100644 index 0000000000..14b0215a24 --- /dev/null +++ b/changelog.d/fixes/8653-fix.plan.md @@ -0,0 +1 @@ +- fix(executor): guard claude/anthropic buildHeaders against empty credentials and extend dual-Bearer parity for third-party baseUrls (#8653) diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 98e53ab03c..1820e48a01 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -395,9 +395,26 @@ export class DefaultExecutor extends BaseExecutor { } case "claude": case "anthropic": - effectiveKey - ? (headers["x-api-key"] = effectiveKey) - : (headers["Authorization"] = `Bearer ${credentials.accessToken}`); + if (effectiveKey) { + headers["x-api-key"] = effectiveKey; + // Port of decolua/9router commit b977bf74: + // Third-party Anthropic-compatible gateways frequently require + // Authorization: Bearer ALONGSIDE x-api-key — without it they + // return 401 missing_api_key on every forward. Only emit the + // Bearer fallback for non-official upstreams; api.anthropic.com + // (and the empty/default baseUrl that targets it) must keep the + // x-api-key-only behavior to avoid regressing the official path. + const baseUrl = credentials?.providerSpecificData?.baseUrl || ""; + const isOfficial = isOfficialAnthropicBaseUrl(baseUrl); + if (!isOfficial) { + headers["Authorization"] = `Bearer ${effectiveKey}`; + } + } else if (credentials.accessToken) { + headers["Authorization"] = `Bearer ${credentials.accessToken}`; + } + // If neither effectiveKey nor accessToken is available, emit no + // auth header — the handler will produce a clean "no credentials" + // 4xx instead of forwarding garbage auth headers to the upstream. break; case "glm": case "glmt": diff --git a/tests/unit/executor-default-anthropic-auth-8653.test.ts b/tests/unit/executor-default-anthropic-auth-8653.test.ts new file mode 100644 index 0000000000..92104e5c05 --- /dev/null +++ b/tests/unit/executor-default-anthropic-auth-8653.test.ts @@ -0,0 +1,160 @@ +/** + * Regression tests for #8653: Claude Code 2.1.220 returns 401 Missing API key + * + * Root cause: DefaultExecutor.buildHeaders for the built-in `claude`/`anthropic` + * providers emitted `Authorization: Bearer null` when the connection has an + * empty apiKey and no accessToken, and for `anthropic-compatible-*` nodes omitted + * the auth header entirely — both get forwarded to the upstream, producing the + * relayed "401 Missing API key" error. + * + * Fix: Guard against falsy credentials (no garbage headers), and extend the + * 9router b977bf74 dual-header fix (Bearer alongside x-api-key) to the built-in + * `claude`/`anthropic` providers for non-official baseUrls. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { DefaultExecutor } from "../../open-sse/executors/default.ts"; + +// ── claude / anthropic — empty credentials guard ───────────────────────── + +test("claude provider with empty apiKey and no accessToken does NOT emit Authorization header", () => { + const executor = new DefaultExecutor("claude"); + const headers = executor.buildHeaders( + { apiKey: "", providerSpecificData: {} } as Record, + true + ) as Record; + // Must not emit 'Bearer null' / 'Bearer undefined' + assert.equal(headers["Authorization"], undefined); + assert.equal(headers["x-api-key"], undefined); +}); + +test("anthropic provider with empty apiKey and no accessToken does NOT emit Authorization header", () => { + const executor = new DefaultExecutor("anthropic"); + const headers = executor.buildHeaders( + { apiKey: "", providerSpecificData: {} } as Record, + true + ) as Record; + assert.equal(headers["Authorization"], undefined); + assert.equal(headers["x-api-key"], undefined); +}); + +test("claude provider with both apiKey and accessToken as null/undefined does NOT emit Bearer null", () => { + const executor = new DefaultExecutor("claude"); + const headers = executor.buildHeaders( + { providerSpecificData: {} } as Record, + true + ) as Record; + assert.equal(headers["Authorization"], undefined); + assert.equal(headers["x-api-key"], undefined); +}); + +// ── claude / anthropic — dual-header parity (9router b977bf74) ────────── + +test("claude provider with non-official baseUrl sends BOTH x-api-key and Authorization: Bearer", () => { + const executor = new DefaultExecutor("claude"); + const headers = executor.buildHeaders( + { + apiKey: "k-third-party", + providerSpecificData: { baseUrl: "https://gateway.example/v1" }, + } as Record, + true + ) as Record; + assert.equal(headers["x-api-key"], "k-third-party"); + assert.equal( + headers["Authorization"], + "Bearer k-third-party", + "third-party claude upstream needs the Bearer fallback alongside x-api-key" + ); +}); + +test("anthropic provider with non-official baseUrl sends BOTH x-api-key and Authorization: Bearer", () => { + const executor = new DefaultExecutor("anthropic"); + const headers = executor.buildHeaders( + { + apiKey: "k-third-party", + providerSpecificData: { baseUrl: "https://anthropic-proxy.example/v1" }, + } as Record, + true + ) as Record; + assert.equal(headers["x-api-key"], "k-third-party"); + assert.equal( + headers["Authorization"], + "Bearer k-third-party", + "third-party anthropic upstream needs the Bearer fallback alongside x-api-key" + ); +}); + +// ── claude / anthropic — official api.anthropic.com stays x-api-key-only ─ + +test("claude provider with official api.anthropic.com baseUrl: x-api-key only, no Bearer", () => { + const executor = new DefaultExecutor("claude"); + const headers = executor.buildHeaders( + { + apiKey: "k-official", + providerSpecificData: { baseUrl: "https://api.anthropic.com/v1" }, + } as Record, + true + ) as Record; + assert.equal(headers["x-api-key"], "k-official"); + assert.equal( + headers["Authorization"], + undefined, + "official api.anthropic.com must NOT receive a Bearer header alongside x-api-key" + ); +}); + +test("anthropic provider with official api.anthropic.com baseUrl: x-api-key only, no Bearer", () => { + const executor = new DefaultExecutor("anthropic"); + const headers = executor.buildHeaders( + { + apiKey: "k-official", + providerSpecificData: { baseUrl: "https://api.anthropic.com/v1" }, + } as Record, + true + ) as Record; + assert.equal(headers["x-api-key"], "k-official"); + assert.equal(headers["Authorization"], undefined); +}); + +test("claude provider with empty baseUrl (defaults to official): x-api-key only, no Bearer", () => { + const executor = new DefaultExecutor("claude"); + const headers = executor.buildHeaders( + { apiKey: "k-empty", providerSpecificData: {} } as Record, + true + ) as Record; + assert.equal(headers["x-api-key"], "k-empty"); + assert.equal(headers["Authorization"], undefined); +}); + +// ── claude OAuth (accessToken-only) keeps Authorization: Bearer ────────── + +test("claude provider with accessToken-only (OAuth mode): Authorization Bearer, no x-api-key", () => { + const executor = new DefaultExecutor("claude"); + const headers = executor.buildHeaders( + { accessToken: "oauth-token", providerSpecificData: {} } as Record, + true + ) as Record; + assert.equal(headers["Authorization"], "Bearer oauth-token"); + assert.equal(headers["x-api-key"], undefined); +}); + +test("anthropic provider with accessToken-only (OAuth mode): Authorization Bearer, no x-api-key", () => { + const executor = new DefaultExecutor("anthropic"); + const headers = executor.buildHeaders( + { accessToken: "oauth-token", providerSpecificData: {} } as Record, + true + ) as Record; + assert.equal(headers["Authorization"], "Bearer oauth-token"); + assert.equal(headers["x-api-key"], undefined); +}); + +// ── existing behavior preserved ───────────────────────────────────────── + +test("claude provider with apiKey on default baseUrl: x-api-key only, respects existing behavior", () => { + const executor = new DefaultExecutor("claude"); + const headers = executor.buildHeaders({ apiKey: "claude-key" } as Record, true) as Record; + assert.equal(headers["x-api-key"], "claude-key"); + assert.equal(headers["Authorization"], undefined); +});