fix(sse): dedupe case-variant Anthropic version/beta headers (#4846)

Integrated into release/v3.8.38 (leva 5)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-26 10:55:21 -03:00
committed by GitHub
parent f8a17c4126
commit f52863c80a
5 changed files with 123 additions and 2 deletions

View File

@@ -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)
---

View File

@@ -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 | undefined | null>): 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<string, string>): 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";

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<string, string> = {
"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<string, string> = {
"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<string, string> = { "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<string, string> = { "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<string, string>;
// 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(","));
}
});