diff --git a/src/providers/anthropic/api.test.ts b/src/providers/anthropic/api.test.ts new file mode 100644 index 000000000..5d0d0e781 --- /dev/null +++ b/src/providers/anthropic/api.test.ts @@ -0,0 +1,130 @@ +import AnthropicAPIConfig from './api'; + +const createMockContext = (headers: Record = {}) => ({ + 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', + }) + ); + }); + }); +}); diff --git a/src/providers/anthropic/api.ts b/src/providers/anthropic/api.ts index ec4ac0aee..19254d9c6 100644 --- a/src/providers/anthropic/api.ts +++ b/src/providers/anthropic/api.ts @@ -3,7 +3,7 @@ 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 = { @@ -11,13 +11,17 @@ const AnthropicAPIConfig: ProviderAPIConfig = { }; // 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; diff --git a/src/providers/anthropic/chatComplete.test.ts b/src/providers/anthropic/chatComplete.test.ts new file mode 100644 index 000000000..f8ea87f6f --- /dev/null +++ b/src/providers/anthropic/chatComplete.test.ts @@ -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'); + }); + }); +}); diff --git a/src/providers/anthropic/chatComplete.ts b/src/providers/anthropic/chatComplete.ts index bbbed6f00..ae1188bb5 100644 --- a/src/providers/anthropic/chatComplete.ts +++ b/src/providers/anthropic/chatComplete.ts @@ -27,6 +27,21 @@ import { AnthropicErrorResponseTransform } from './utils'; // TODO: this configuration does not enforce the maximum token limit for the input parameter. If you want to enforce this, you might need to add a custom validation function or a max property to the ParameterConfig interface, and then use it in the input configuration. However, this might be complex because the token count is not a simple length check, but depends on the specific tokenization method used by the model. +/** + * Sanitize a cache_control object to only include known Anthropic fields. + * Prevents arbitrary client-supplied fields from being forwarded. + */ +const sanitizeCacheControl = ( + cacheControl: any +): { type: 'ephemeral'; scope?: string } | undefined => { + if (!cacheControl || cacheControl.type !== 'ephemeral') return undefined; + const result: { type: 'ephemeral'; scope?: string } = { type: 'ephemeral' }; + if (typeof cacheControl.scope === 'string') { + result.scope = cacheControl.scope; + } + return result; +}; + interface AnthropicTool extends PromptCache { name: string; description?: string; @@ -72,6 +87,7 @@ interface AnthropicToolResultContentItem { cache_control?: { type: string; ttl?: number; + scope?: string; }; }[] | string; @@ -228,7 +244,7 @@ const transformAndAppendImageContentItem = ( data: base64Image, }, ...((item as any).cache_control && { - cache_control: { type: 'ephemeral' }, + cache_control: sanitizeCacheControl((item as any).cache_control), }), }); } @@ -302,7 +318,9 @@ export const AnthropicChatCompleteConfig: ProviderConfig = { type: item.type, text: item.text, ...((item as any).cache_control && { - cache_control: { type: 'ephemeral' }, + cache_control: sanitizeCacheControl( + (item as any).cache_control + ), }), }); } else if (item.type === 'image_url') { @@ -343,7 +361,9 @@ export const AnthropicChatCompleteConfig: ProviderConfig = { text: _msg.text, type: 'text', ...((_msg as any)?.cache_control && { - cache_control: { type: 'ephemeral' }, + cache_control: sanitizeCacheControl( + (_msg as any).cache_control + ), }), }); }); @@ -353,7 +373,7 @@ export const AnthropicChatCompleteConfig: ProviderConfig = { ) { systemMessages.push({ ...(msg?.cache_control && { - cache_control: { type: 'ephemeral' }, + cache_control: sanitizeCacheControl(msg.cache_control), }), text: msg.content, type: 'text', @@ -383,7 +403,7 @@ export const AnthropicChatCompleteConfig: ProviderConfig = { $defs: tool.function.parameters?.['$defs'] || {}, }, ...(tool.cache_control && { - cache_control: { type: 'ephemeral' }, + cache_control: sanitizeCacheControl(tool.cache_control), }), // Advanced tool use properties (nested in function object per OpenAI format) ...(tool.function.defer_loading !== undefined && { @@ -404,7 +424,7 @@ export const AnthropicChatCompleteConfig: ProviderConfig = { name: tool.type, type: toolOptions?.name, ...(tool.cache_control && { - cache_control: { type: 'ephemeral' }, + cache_control: sanitizeCacheControl(tool.cache_control), }), }); } diff --git a/src/providers/bedrock/utils.ts b/src/providers/bedrock/utils.ts index 40b72983d..94ff4b415 100644 --- a/src/providers/bedrock/utils.ts +++ b/src/providers/bedrock/utils.ts @@ -154,7 +154,16 @@ export const transformAnthropicAdditionalModelRequestFields = ( name: tool.type, type: toolOptions?.name, ...(tool.cache_control && { - cache_control: { type: 'ephemeral' }, + cache_control: + tool.cache_control.type === 'ephemeral' + ? { + type: 'ephemeral' as const, + ...(typeof (tool.cache_control as any).scope === + 'string' && { + scope: (tool.cache_control as any).scope, + }), + } + : { type: 'ephemeral' as const }, }), }); } diff --git a/src/types/MessagesRequest.ts b/src/types/MessagesRequest.ts index a560618dc..2a39aebd4 100644 --- a/src/types/MessagesRequest.ts +++ b/src/types/MessagesRequest.ts @@ -1,5 +1,6 @@ export interface CacheControlEphemeral { type: 'ephemeral'; + scope?: string; } export interface ServerToolUseBlockParam { diff --git a/src/types/requestBody.ts b/src/types/requestBody.ts index cbc8395de..57491ce0a 100644 --- a/src/types/requestBody.ts +++ b/src/types/requestBody.ts @@ -327,7 +327,7 @@ export interface Message { } export interface PromptCache { - cache_control?: { type: 'ephemeral' }; + cache_control?: { type: 'ephemeral'; scope?: string }; } export interface CitationMetadata {