fix(core): case-insensitive customHeaders[anthropic-beta] merge

Address yiliang114 review feedback (#3788).

HTTP header names are case-insensitive by spec, and the Anthropic SDK
lower-cases them during merge. Previously buildPerRequestHeaders only
read the lower-case `anthropic-beta` key from customHeaders, so a
user-configured `Anthropic-Beta` or `ANTHROPIC-BETA` would be silently
overwritten by the per-request computed value.

Replace the direct dict lookup with collectCustomBetaFlags() which
walks all customHeaders entries and matches the key case-insensitively.
Multiple matching entries (unlikely but possible) are concatenated; the
existing dedupe pass handles any duplicates.

Add a regression test for both `Anthropic-Beta` and `ANTHROPIC-BETA`
key shapes.

73 tests pass; lint + typecheck clean.
This commit is contained in:
wenshao 2026-05-02 23:02:05 +08:00
parent 4b81d674df
commit 0d8b5de18a
2 changed files with 53 additions and 8 deletions

View file

@ -246,6 +246,32 @@ describe('AnthropicContentGenerator', () => {
expect(headers['anthropic-beta']).toBe('experimental-x');
});
it('honors customHeaders[anthropic-beta] under mixed-case keys (Anthropic-Beta / ANTHROPIC-BETA)', async () => {
// HTTP header names are case-insensitive; Anthropic SDK lower-cases
// headers when merging. Make sure our merge logic also matches
// case-insensitively so the user-configured beta flag isn't silently
// overwritten by the per-request value.
const headersUpper = await callOnce({
...baseConfig,
reasoning: { effort: 'medium' },
customHeaders: { 'ANTHROPIC-BETA': 'experimental-x' },
});
expect(headersUpper['anthropic-beta']).toContain('experimental-x');
expect(headersUpper['anthropic-beta']).toContain(
'interleaved-thinking-2025-05-14',
);
const headersTitle = await callOnce({
...baseConfig,
reasoning: { effort: 'medium' },
customHeaders: { 'Anthropic-Beta': 'experimental-y' },
});
expect(headersTitle['anthropic-beta']).toContain('experimental-y');
expect(headersTitle['anthropic-beta']).toContain(
'interleaved-thinking-2025-05-14',
);
});
it('dedupes beta flags so duplicates from customHeaders are not repeated', async () => {
const headers = await callOnce({
...baseConfig,

View file

@ -208,20 +208,17 @@ export class AnthropicContentGenerator implements ContentGenerator {
*
* User-supplied `customHeaders['anthropic-beta']` flags are merged in (and
* deduped) so the per-request override doesn't wipe out the existing
* customHeaders escape hatch for unrelated beta features.
* customHeaders escape hatch for unrelated beta features. The lookup is
* case-insensitive HTTP header names are case-insensitive by spec, so a
* user-configured `Anthropic-Beta` or `ANTHROPIC-BETA` is honored too.
*/
private buildPerRequestHeaders(
anthropicRequest: MessageCreateParamsWithThinking,
): Record<string, string> | undefined {
const betas: string[] = [];
const userBeta =
this.contentGeneratorConfig.customHeaders?.['anthropic-beta'];
if (typeof userBeta === 'string' && userBeta) {
for (const flag of userBeta.split(',')) {
const trimmed = flag.trim();
if (trimmed) betas.push(trimmed);
}
for (const flag of this.collectCustomBetaFlags()) {
betas.push(flag);
}
if (anthropicRequest.thinking) {
@ -236,6 +233,28 @@ export class AnthropicContentGenerator implements ContentGenerator {
return { 'anthropic-beta': unique.join(',') };
}
/**
* Read every customHeaders entry whose key (case-insensitively) is
* `anthropic-beta` and yield the comma-separated flags from each. Multiple
* matching entries are concatenated; later ones may produce duplicates
* which the caller dedupes.
*/
private collectCustomBetaFlags(): string[] {
const customHeaders = this.contentGeneratorConfig.customHeaders;
if (!customHeaders) return [];
const flags: string[] = [];
for (const [key, value] of Object.entries(customHeaders)) {
if (key.toLowerCase() !== 'anthropic-beta') continue;
if (typeof value !== 'string' || !value) continue;
for (const flag of value.split(',')) {
const trimmed = flag.trim();
if (trimmed) flags.push(trimmed);
}
}
return flags;
}
private async buildRequest(
request: GenerateContentParameters,
): Promise<MessageCreateParamsWithThinking> {