diff --git a/src/index.ts b/src/index.ts index 33b15b9..b640acc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1561,32 +1561,37 @@ export class ClaudeCodeProxy { * Build request headers for a specific provider */ private buildProviderHeaders(provider: 'anthropic' | 'zai' | 'openrouter', reqHeaders: Record): Record { - if (provider === 'zai') { - const headers: Record = { - host: new URL(this.config.zai.baseUrl).host, - authorization: `Bearer ${this.config.zai.apiKey}`, - }; - // Copy allowed headers + // Headers that must NOT be forwarded to a non-Anthropic upstream. Beyond the + // hop-by-hop/auth headers, Anthropic-specific routing headers — especially + // `x-api-key` — make OpenRouter route the request into a restricted data-policy + // path and return `404: No endpoints available matching your guardrail + // restrictions`. `content-length` is recomputed after cleanBody, so drop the + // client's value too. + const blockedForwardHeaders = [ + "authorization", "transfer-encoding", "connection", "host", "content-length", + "x-api-key", "anthropic-version", "anthropic-beta", "anthropic-dangerous-direct-browser-access", + ]; + const copyAllowed = (headers: Record) => { for (const [key, value] of Object.entries(reqHeaders)) { - if (!["authorization", "transfer-encoding", "connection", "host"].includes(key)) { + if (!blockedForwardHeaders.includes(key.toLowerCase())) { headers[key] = value; } } return headers; + }; + + if (provider === 'zai') { + return copyAllowed({ + host: new URL(this.config.zai.baseUrl).host, + authorization: `Bearer ${this.config.zai.apiKey}`, + }); } else if (provider === 'openrouter') { - const headers: Record = { + return copyAllowed({ host: new URL(this.config.openrouter.baseUrl).host, authorization: `Bearer ${this.config.openrouter.apiKey}`, "HTTP-Referer": "https://claude.ai/code", "X-Title": "Claude Code", - }; - // Copy allowed headers - for (const [key, value] of Object.entries(reqHeaders)) { - if (!["authorization", "transfer-encoding", "connection", "host"].includes(key)) { - headers[key] = value; - } - } - return headers; + }); } else { // Anthropic headers return this.cleanHeaders(reqHeaders); diff --git a/tests/proxy.test.js b/tests/proxy.test.js index 0e50c3d..b8ca48c 100644 --- a/tests/proxy.test.js +++ b/tests/proxy.test.js @@ -557,10 +557,12 @@ describe("Claude Code Proxy provider request normalization", () => { const proxy = createProxy(); let capturedUrl = ""; let capturedBody = ""; + let capturedHeaders = {}; proxy.httpRequest = async (url, options) => { capturedUrl = url; capturedBody = String(options.body); + capturedHeaders = options.headers; return { status: 200, headers: options.headers, @@ -577,7 +579,14 @@ describe("Claude Code Proxy provider request normalization", () => { metadata: { source: "test" }, extra_field: "should-be-stripped", }), - { "content-type": "application/json" }, + { + "content-type": "application/json", + // Claude Code always sends these Anthropic-specific headers; forwarding + // x-api-key to OpenRouter triggers a guardrail 404. + "x-api-key": "sk-ant-should-not-leak", + "anthropic-version": "2023-06-01", + "anthropic-beta": "claude-code-20250219", + }, "/v1/messages?beta=true", "POST", ); @@ -588,6 +597,14 @@ describe("Claude Code Proxy provider request normalization", () => { assert.equal(parsedBody.model, "~anthropic/claude-sonnet-latest"); assert.equal(parsedBody.extra_field, undefined); assert.deepEqual(parsedBody.metadata, { source: "test" }); + + // Anthropic-specific headers must NOT reach OpenRouter (cause guardrail 404). + assert.equal(capturedHeaders["x-api-key"], undefined); + assert.equal(capturedHeaders["anthropic-version"], undefined); + assert.equal(capturedHeaders["anthropic-beta"], undefined); + // OpenRouter's own auth + attribution headers are present. + assert.equal(capturedHeaders.authorization, "Bearer openrouter-test-key"); + assert.equal(capturedHeaders["HTTP-Referer"], "https://claude.ai/code"); }); it("normalizes OpenRouter message responses to strict Anthropic shape", async () => {