From f52863c80a81bbfe3eb1923f384573313e2ca20f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:55:21 -0300 Subject: [PATCH] fix(sse): dedupe case-variant Anthropic version/beta headers (#4846) Integrated into release/v3.8.38 (leva 5) --- CHANGELOG.md | 1 + open-sse/config/anthropicHeaders.ts | 43 ++++++++++++ open-sse/executors/base.ts | 7 +- open-sse/executors/default.ts | 7 +- .../unit/anthropic-header-dedupe-1475.test.ts | 67 +++++++++++++++++++ 5 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 tests/unit/anthropic-header-dedupe-1475.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d22ee394b..6e864c33b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ _In development — bullets added per PR; finalized at release._ - **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr) - **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr) - **fix(sse):** prevent non-JSON SSE lines and duplicate `[DONE]` from breaking clients. (thanks @qianze0628) +- **fix(sse):** dedupe case-variant Anthropic headers in the executor `buildHeaders` path — Node/undici's `fetch` merges `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value that the Anthropic API rejects, so both case variants are now collapsed to one canonical lowercase header (same for `anthropic-beta`). (thanks @Delcado19) --- diff --git a/open-sse/config/anthropicHeaders.ts b/open-sse/config/anthropicHeaders.ts index 3ddcadc550..ca2979bbb7 100644 --- a/open-sse/config/anthropicHeaders.ts +++ b/open-sse/config/anthropicHeaders.ts @@ -64,6 +64,49 @@ export function mergeClientAnthropicBeta( return baseList.join(","); } +/** + * Collapse a list of comma-list header values into a deduped, trimmed token + * array. Empty/undefined/null entries are dropped. Used to reconcile the + * case-variant `anthropic-version` / `anthropic-beta` headers below. + */ +function uniqueCommaValues(values: Array): string[] { + return [ + ...new Set( + values + .filter((value) => value !== undefined && value !== null && value !== "") + .flatMap((value) => String(value).split(",")) + .map((value) => value.trim()) + .filter(Boolean) + ), + ]; +} + +/** + * Dedupe case-variant Anthropic headers in-place. Node/undici's fetch merges + * `anthropic-version` and `Anthropic-Version` into a single `"v, v"` value, + * which the Anthropic API rejects (#1475). Collapse both case variants down to + * one canonical lowercase header carrying a single value. Same for + * `anthropic-beta` (joined comma-list, deduped). Mutates `headers`. + */ +export function normalizeAnthropicHeaderVariants(headers: Record): void { + const versionValues = uniqueCommaValues([ + headers["anthropic-version"], + headers["Anthropic-Version"], + ]); + delete headers["Anthropic-Version"]; + delete headers["anthropic-version"]; + if (versionValues.length > 0) { + headers["anthropic-version"] = versionValues[0]; + } + + const betaValues = uniqueCommaValues([headers["anthropic-beta"], headers["Anthropic-Beta"]]); + delete headers["Anthropic-Beta"]; + delete headers["anthropic-beta"]; + if (betaValues.length > 0) { + headers["anthropic-beta"] = betaValues.join(","); + } +} + export const CLAUDE_CLI_VERSION = "2.1.187"; export const CLAUDE_CLI_USER_AGENT = `claude-cli/${CLAUDE_CLI_VERSION} (external, cli)`; export const CLAUDE_CLI_STAINLESS_PACKAGE_VERSION = "0.94.0"; diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 7d36eb7e1d..a0c8265feb 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -1,5 +1,8 @@ import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; -import { mergeClientAnthropicBeta } from "../config/anthropicHeaders.ts"; +import { + mergeClientAnthropicBeta, + normalizeAnthropicHeaderVariants, +} from "../config/anthropicHeaders.ts"; import { applyContextEditingToBody } from "../config/contextEditing.ts"; import { findOffendingField, stripGroqUnsupportedFields } from "../config/providerFieldStrips.ts"; import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts"; @@ -576,6 +579,8 @@ export class BaseExecutor { headers["Accept"] = stream ? "text/event-stream" : "application/json"; + normalizeAnthropicHeaderVariants(headers); + return headers; } diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 8b3a6a3a9d..5a855e332a 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -14,7 +14,10 @@ import { } from "../services/claudeCodeCompatible.ts"; import { getGigachatAccessToken } from "../services/gigachatAuth.ts"; import { getRegistryEntry } from "../config/providerRegistry.ts"; -import { mergeClientAnthropicBeta } from "../config/anthropicHeaders.ts"; +import { + mergeClientAnthropicBeta, + normalizeAnthropicHeaderVariants, +} from "../config/anthropicHeaders.ts"; import { applyProviderRequestDefaults } from "../services/providerRequestDefaults.ts"; import { stripUnsupportedParams } from "../translator/paramSupport.ts"; import { @@ -573,6 +576,8 @@ export class DefaultExecutor extends BaseExecutor { } } + normalizeAnthropicHeaderVariants(headers); + return headers; } diff --git a/tests/unit/anthropic-header-dedupe-1475.test.ts b/tests/unit/anthropic-header-dedupe-1475.test.ts new file mode 100644 index 0000000000..fd6bc5b192 --- /dev/null +++ b/tests/unit/anthropic-header-dedupe-1475.test.ts @@ -0,0 +1,67 @@ +// Regression test for #1475 — duplicate case-variant Anthropic headers. +// +// Node/undici's fetch lowercases and MERGES same-name headers, so an outbound +// set carrying both "anthropic-version" and "Anthropic-Version" collapses into +// a single "anthropic-version: 2023-06-01, 2023-06-01" value, which the +// Anthropic API rejects. normalizeAnthropicHeaderVariants() reconciles the two +// case variants down to one canonical lowercase header with a single value +// (and a deduped, joined comma-list for anthropic-beta) before the request is +// dispatched in BaseExecutor.buildHeaders / DefaultExecutor.buildHeaders. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { normalizeAnthropicHeaderVariants } from "../../open-sse/config/anthropicHeaders.ts"; +import { DefaultExecutor } from "../../open-sse/executors/default.ts"; + +test("normalizeAnthropicHeaderVariants collapses case-variant anthropic-version into one value", () => { + const headers: Record = { + "anthropic-version": "2023-06-01", + "Anthropic-Version": "2023-06-01", + }; + + normalizeAnthropicHeaderVariants(headers); + + // The mixed-case variant must be gone, and the lowercase one must carry a + // single value (no "v, v" duplication that Anthropic rejects). + assert.equal(headers["Anthropic-Version"], undefined); + assert.equal(headers["anthropic-version"], "2023-06-01"); + assert.ok(!headers["anthropic-version"].includes(",")); +}); + +test("normalizeAnthropicHeaderVariants dedupes and joins anthropic-beta variants", () => { + const headers: Record = { + "anthropic-beta": "oauth-2025-04-20, prompt-caching", + "Anthropic-Beta": "prompt-caching, fine-grained", + }; + + normalizeAnthropicHeaderVariants(headers); + + assert.equal(headers["Anthropic-Beta"], undefined); + assert.equal(headers["anthropic-beta"], "oauth-2025-04-20,prompt-caching,fine-grained"); +}); + +test("normalizeAnthropicHeaderVariants leaves a single lowercase header untouched", () => { + const headers: Record = { "anthropic-version": "2023-06-01" }; + normalizeAnthropicHeaderVariants(headers); + assert.equal(headers["anthropic-version"], "2023-06-01"); + assert.equal(headers["Anthropic-Version"], undefined); +}); + +test("normalizeAnthropicHeaderVariants is a no-op when no anthropic headers are present", () => { + const headers: Record = { "Content-Type": "application/json" }; + normalizeAnthropicHeaderVariants(headers); + assert.deepEqual(headers, { "Content-Type": "application/json" }); +}); + +test("DefaultExecutor.buildHeaders does not emit duplicate anthropic-version variants for an anthropic-compatible provider", () => { + const executor = new DefaultExecutor("anthropic-compatible-test"); + const headers = executor.buildHeaders({ apiKey: "sk-test" }, true) as Record; + + // Whatever case the upstream registry/auth path used, only the canonical + // lowercase header survives, with exactly one value. + assert.equal(headers["Anthropic-Version"], undefined); + if (headers["anthropic-version"] !== undefined) { + assert.ok(!headers["anthropic-version"].includes(",")); + } +});