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
130 changes: 130 additions & 0 deletions src/providers/anthropic/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import AnthropicAPIConfig from './api';

const createMockContext = (headers: Record<string, string> = {}) => ({
req: {
header: (name: string) => headers[name.toLowerCase()],
},
});

describe('AnthropicAPIConfig', () => {
describe('headers', () => {
it('should use anthropicBeta from providerOptions when available', () => {
const result = AnthropicAPIConfig.headers({
c: createMockContext() as any,
providerOptions: {
apiKey: 'test-key',
provider: 'anthropic',
anthropicBeta: 'prompt-caching-scope-2026-01-05',
},
fn: 'chatComplete',
transformedRequestBody: {},
transformedRequestUrl: '',
gatewayRequestBody: {},
});

expect(result).toEqual(
expect.objectContaining({
'anthropic-beta': 'prompt-caching-scope-2026-01-05',
})
);
});

it('should use anthropic_beta from request body when providerOptions lacks it', () => {
const result = AnthropicAPIConfig.headers({
c: createMockContext() as any,
providerOptions: { apiKey: 'test-key', provider: 'anthropic' },
fn: 'chatComplete',
transformedRequestBody: {},
transformedRequestUrl: '',
gatewayRequestBody: {
anthropic_beta: 'prompt-caching-scope-2026-01-05',
} as any,
});

expect(result).toEqual(
expect.objectContaining({
'anthropic-beta': 'prompt-caching-scope-2026-01-05',
})
);
});

it('should fall back to reading anthropic-beta from request headers via Hono context', () => {
const result = AnthropicAPIConfig.headers({
c: createMockContext({
'anthropic-beta': 'prompt-caching-scope-2026-01-05',
}) as any,
providerOptions: { apiKey: 'test-key', provider: 'anthropic' },
fn: 'chatComplete',
transformedRequestBody: {},
transformedRequestUrl: '',
gatewayRequestBody: {},
});

expect(result).toEqual(
expect.objectContaining({
'anthropic-beta': 'prompt-caching-scope-2026-01-05',
})
);
});

it('should fall back to reading anthropic-version from request headers via Hono context', () => {
const result = AnthropicAPIConfig.headers({
c: createMockContext({
'anthropic-version': '2024-01-01',
}) as any,
providerOptions: { apiKey: 'test-key', provider: 'anthropic' },
fn: 'chatComplete',
transformedRequestBody: {},
transformedRequestUrl: '',
gatewayRequestBody: {},
});

expect(result).toEqual(
expect.objectContaining({
'anthropic-version': '2024-01-01',
})
);
});

it('should use default beta header when no source provides it', () => {
const result = AnthropicAPIConfig.headers({
c: createMockContext() as any,
providerOptions: { apiKey: 'test-key', provider: 'anthropic' },
fn: 'chatComplete',
transformedRequestBody: {},
transformedRequestUrl: '',
gatewayRequestBody: {},
});

expect(result).toEqual(
expect.objectContaining({
'anthropic-beta': 'messages-2023-12-15',
'anthropic-version': '2023-06-01',
})
);
});

it('should prefer providerOptions over request headers', () => {
const result = AnthropicAPIConfig.headers({
c: createMockContext({
'anthropic-beta': 'from-request-header',
}) as any,
providerOptions: {
apiKey: 'test-key',
anthropicBeta: 'from-provider-options',
provider: 'anthropic',
},
fn: 'chatComplete',
transformedRequestBody: {},
transformedRequestUrl: '',
gatewayRequestBody: {},
});

expect(result).toEqual(
expect.objectContaining({
'anthropic-beta': 'from-provider-options',
})
);
});
});
});
6 changes: 5 additions & 1 deletion src/providers/anthropic/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,25 @@ import { ProviderAPIConfig } from '../types';
const AnthropicAPIConfig: ProviderAPIConfig = {
getBaseURL: () => 'https://api.anthropic.com/v1',

headers: ({ providerOptions, fn, gatewayRequestBody }) => {
headers: ({ c, providerOptions, fn, gatewayRequestBody }) => {
const apiKey =
providerOptions.apiKey || providerOptions.anthropicApiKey || '';
const headers: Record<string, string> = {
'X-API-Key': apiKey,
};

// Accept anthropic_beta and anthropic_version in body to support enviroments which cannot send it in headers.
// Also fall back to reading from the original request headers (via Hono context)
// to handle targets-based configs where providerOptions may not include these.
const betaHeader =
providerOptions?.['anthropicBeta'] ??
gatewayRequestBody?.['anthropic_beta'] ??
c?.req?.header('anthropic-beta') ??
'messages-2023-12-15';
const version =
providerOptions?.['anthropicVersion'] ??
gatewayRequestBody?.['anthropic_version'] ??
c?.req?.header('anthropic-version') ??
'2023-06-01';

headers['anthropic-beta'] = betaHeader;
Expand Down
220 changes: 220 additions & 0 deletions src/providers/anthropic/chatComplete.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { AnthropicChatCompleteConfig } from './chatComplete';

describe('AnthropicChatCompleteConfig', () => {
describe('cache_control.scope preservation in message transforms', () => {
const getMessagesTransform = () => {
const messagesConfig = AnthropicChatCompleteConfig.messages;
// messages is an array of param configs; the first one transforms messages
return Array.isArray(messagesConfig)
? messagesConfig[0].transform!
: messagesConfig.transform!;
};

const getSystemTransform = () => {
const messagesConfig = AnthropicChatCompleteConfig.messages;
// the second param config in the messages array transforms system messages
return Array.isArray(messagesConfig)
? messagesConfig[1].transform!
: undefined;
};

it('should preserve cache_control with scope on text content items', () => {
const transform = getMessagesTransform();
const params = {
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: 'Hello',
cache_control: { type: 'ephemeral', scope: 'global' },
},
],
},
],
};

const result = transform(params, {} as any);
expect(result[0].content[0].cache_control).toEqual({
type: 'ephemeral',
scope: 'global',
});
});

it('should preserve cache_control without scope (backward compat)', () => {
const transform = getMessagesTransform();
const params = {
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: 'Hello',
cache_control: { type: 'ephemeral' },
},
],
},
],
};

const result = transform(params, {} as any);
expect(result[0].content[0].cache_control).toEqual({
type: 'ephemeral',
});
});

it('should strip unknown fields from cache_control', () => {
const transform = getMessagesTransform();
const params = {
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: 'Hello',
cache_control: {
type: 'ephemeral',
scope: 'global',
malicious_field: 'should be stripped',
},
},
],
},
],
};

const result = transform(params, {} as any);
expect(result[0].content[0].cache_control).toEqual({
type: 'ephemeral',
scope: 'global',
});
expect(result[0].content[0].cache_control).not.toHaveProperty(
'malicious_field'
);
});

it('should not include cache_control when not present in source', () => {
const transform = getMessagesTransform();
const params = {
messages: [
{
role: 'user',
content: [{ type: 'text', text: 'Hello' }],
},
],
};

const result = transform(params, {} as any);
expect(result[0].content[0]).not.toHaveProperty('cache_control');
});

it('should preserve cache_control with scope on system messages (array form)', () => {
const transform = getSystemTransform();
if (!transform) throw new Error('System transform not found');

const params = {
messages: [
{
role: 'system',
content: [
{
text: 'You are helpful',
cache_control: { type: 'ephemeral', scope: 'global' },
},
],
},
],
};

const result = transform(params, {} as any);
expect(result[0].cache_control).toEqual({
type: 'ephemeral',
scope: 'global',
});
});

it('should preserve cache_control with scope on system messages (string form)', () => {
const transform = getSystemTransform();
if (!transform) throw new Error('System transform not found');

const params = {
messages: [
{
role: 'system',
content: 'You are helpful',
cache_control: { type: 'ephemeral', scope: 'global' },
},
],
};

const result = transform(params, {} as any);
expect(result[0].cache_control).toEqual({
type: 'ephemeral',
scope: 'global',
});
});
});

describe('cache_control.scope preservation in tool transforms', () => {
const getToolsTransform = () => {
const toolsConfig = AnthropicChatCompleteConfig.tools;
return Array.isArray(toolsConfig)
? toolsConfig[0].transform!
: (toolsConfig as any).transform!;
};

it('should preserve cache_control with scope on function tools', () => {
const transform = getToolsTransform();
const params = {
tools: [
{
type: 'function',
function: {
name: 'get_weather',
description: 'Get weather',
parameters: { type: 'object', properties: {}, required: [] },
},
cache_control: { type: 'ephemeral', scope: 'global' },
},
],
};

const result = transform(params, {} as any);
expect(result[0].cache_control).toEqual({
type: 'ephemeral',
scope: 'global',
});
});

it('should strip unknown fields from tool cache_control', () => {
const transform = getToolsTransform();
const params = {
tools: [
{
type: 'function',
function: {
name: 'get_weather',
description: 'Get weather',
parameters: { type: 'object', properties: {}, required: [] },
},
cache_control: {
type: 'ephemeral',
scope: 'global',
injected: true,
},
},
],
};

const result = transform(params, {} as any);
expect(result[0].cache_control).toEqual({
type: 'ephemeral',
scope: 'global',
});
expect(result[0].cache_control).not.toHaveProperty('injected');
});
});
});
Loading