Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/shiny-cups-speak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@ai-sdk/provider-utils': patch
'@ai-sdk/anthropic': patch
---

Fix the custom anthropic-beta merge issue.
28 changes: 28 additions & 0 deletions packages/anthropic/src/anthropic-messages-language-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3678,6 +3678,34 @@ describe('AnthropicMessagesLanguageModel', () => {
`);
});

it('should merge custom anthropic-beta header with fine-grained-tool-streaming beta', async () => {
server.urls['https://api.anthropic.com/v1/messages'].response = {
type: 'stream-chunks',
chunks: [
`data: {"type":"message_start","message":{"id":"msg_01KfpJoAEabmH2iHRRFjQMAG","type":"message","role":"assistant","content":[],"model":"claude-3-haiku-20240307","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":17,"output_tokens":1}}}\n\n`,
`data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n`,
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello, World!"}}\n\n`,
`data: {"type":"content_block_stop","index":0}\n\n`,
`data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":227}}\n\n`,
`data: {"type":"message_stop"}\n\n`,
],
};

const provider = createAnthropic({
apiKey: 'test-api-key',
headers: {
'anthropic-beta': 'context-1m-2025-08-07',
},
});

await provider('claude-3-haiku-20240307').doStream({
prompt: TEST_PROMPT,
});

expect(server.calls[0].requestHeaders['anthropic-beta']).toContain('fine-grained-tool-streaming-2025-05-14');
expect(server.calls[0].requestHeaders['anthropic-beta']).toContain('context-1m-2025-08-07');
});

it('should support cache control', async () => {
server.urls['https://api.anthropic.com/v1/messages'].response = {
type: 'stream-chunks',
Expand Down
15 changes: 9 additions & 6 deletions packages/anthropic/src/anthropic-messages-language-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
UnsupportedFunctionalityError,
} from '@ai-sdk/provider';
import {
combineHeaders,
combineAndMergeMergeableHeaders,
createEventSourceResponseHandler,
createJsonResponseHandler,
FetchFunction,
Expand Down Expand Up @@ -394,11 +394,14 @@ export class AnthropicMessagesLanguageModel implements LanguageModelV3 {
betas: Set<string>;
headers: Record<string, string | undefined> | undefined;
}) {
return combineHeaders(
await resolve(this.config.headers),
betas.size > 0 ? { 'anthropic-beta': Array.from(betas).join(',') } : {},
headers,
);
return combineAndMergeMergeableHeaders({
headers: [
await resolve(this.config.headers),
betas.size > 0 ? { 'anthropic-beta': Array.from(betas).join(',') } : {},
headers,
],
mergeableKeys: ['anthropic-beta'],
});
}

private buildRequestUrl(isStreaming: boolean): string {
Expand Down
36 changes: 36 additions & 0 deletions packages/provider-utils/src/combine-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,39 @@ export function combineHeaders(
{},
) as Record<string, string | undefined>;
}

export function combineAndMergeMergeableHeaders({
headers,
mergeableKeys = [],
}: {
headers: Array<Record<string, string | undefined> | undefined>,
mergeableKeys?: string[],
}): Record<string, string | undefined> {
const mergeableLowerCaseKeys = mergeableKeys.map(key => key.toLowerCase());

return headers.reduce(
(combinedHeaders, currentHeaders) => {
if (!currentHeaders) return combinedHeaders;

const result = { ...combinedHeaders };

for (const [key, value] of Object.entries(currentHeaders)) {
if (value === undefined) continue;

// Check if the key is mergeable (case-insensitive) and already exists
if (mergeableLowerCaseKeys.includes(key.toLowerCase()) && result[key]) {
const existingValues = result[key].split(',').map(val => val.trim());
const newValues = value.split(',').map(val => val.trim());
// Combine and deduplicate values
const allValues = Array.from(new Set([...existingValues, ...newValues]));
result[key] = allValues.join(',');
Comment on lines +31 to +37
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Check if the key is mergeable (case-insensitive) and already exists
if (mergeableLowerCaseKeys.includes(key.toLowerCase()) && result[key]) {
const existingValues = result[key].split(',').map(val => val.trim());
const newValues = value.split(',').map(val => val.trim());
// Combine and deduplicate values
const allValues = Array.from(new Set([...existingValues, ...newValues]));
result[key] = allValues.join(',');
// Check if the key is mergeable (case-insensitive)
if (mergeableLowerCaseKeys.includes(key.toLowerCase())) {
// Find existing key with case-insensitive comparison
const existingKey = Object.keys(result).find(
k => k.toLowerCase() === key.toLowerCase(),
);
if (existingKey && result[existingKey]) {
const existingValues = result[existingKey]!.split(',').map(val => val.trim());
const newValues = value.split(',').map(val => val.trim());
// Combine and deduplicate values
const allValues = Array.from(new Set([...existingValues, ...newValues]));
result[existingKey] = allValues.join(',');
} else {
result[key] = value;
}

The case-sensitivity check for mergeable headers is incomplete. The code checks if a key is mergeable using case-insensitive comparison (key.toLowerCase()) but then checks if the key exists in the result using exact casing (result[key]), which will fail to find existing keys with different casing.

View Details

Analysis

Case-insensitive header merging fails with different casing

What fails: combineAndMergeMergeableHeaders() in packages/provider-utils/src/combine-headers.ts fails to merge headers when the same logical header is provided with different casing (e.g., 'Anthropic-Beta' and 'anthropic-beta').

How to reproduce:

const result = combineAndMergeMergeableHeaders({
  headers: [
    { 'Anthropic-Beta': 'value1' },
    { 'anthropic-beta': 'value2' }
  ],
  mergeableKeys: ['anthropic-beta']
});

Result: Returns object with both keys present:

{
  'Anthropic-Beta': 'value1',
  'anthropic-beta': 'value2'
}

Expected: Single merged header:

{
  'Anthropic-Beta': 'value1,value2'
}

Root cause: Line 32 checked if a mergeable key exists using case-insensitive comparison (key.toLowerCase()) but then searched for it in the result object using exact case matching (result[key]). This caused case-mismatched headers to not be found and merged.

Impact: Users providing headers with different casing will end up with duplicate headers that may not be properly combined, potentially causing API request failures.

} else {
result[key] = value;
}
}

return result;
},
{},
) as Record<string, string | undefined>;
}