Prepare protocol compatibility release
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
convertAnthropicToolsToOpenAi,
|
||||
convertOpenAiBodyToAnthropicMessagesBody,
|
||||
convertOpenAiToolChoiceToAnthropic,
|
||||
convertOpenAiToolsToAnthropic,
|
||||
sanitizeAnthropicMessagesBody,
|
||||
} from './conversion.js';
|
||||
import { anthropicMessagesInbound } from './inbound.js';
|
||||
@@ -29,6 +31,24 @@ describe('sanitizeAnthropicMessagesBody', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('maps web_search server tools between OpenAI-compatible and Anthropic Messages requests', () => {
|
||||
expect(convertOpenAiToolsToAnthropic([
|
||||
{ type: 'web_search', max_results: 3 },
|
||||
{ type: 'google_search' },
|
||||
])).toEqual([
|
||||
{ type: 'web_search_20250305', max_results: 3, name: 'web_search' },
|
||||
{ type: 'web_search_20250305', name: 'web_search' },
|
||||
]);
|
||||
|
||||
expect(convertAnthropicToolsToOpenAi([
|
||||
{ type: 'web_search_20250305', max_uses: 2 },
|
||||
{ name: 'lookup_weather', input_schema: { type: 'object' } },
|
||||
])).toEqual([
|
||||
{ type: 'web_search', max_uses: 2, name: 'web_search' },
|
||||
{ name: 'lookup_weather', input_schema: { type: 'object' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes string system and message content before rebuilding cache anchors', () => {
|
||||
const result = sanitizeAnthropicMessagesBody({
|
||||
model: 'claude-opus-4-6',
|
||||
|
||||
@@ -12,6 +12,11 @@ const VALID_ANTHROPIC_THINKING_TYPES = new Set(['enabled', 'disabled', 'adaptive
|
||||
const VALID_ANTHROPIC_EFFORTS = new Set(['low', 'medium', 'high', 'max']);
|
||||
const MAX_ANTHROPIC_CACHE_CONTROL_BREAKPOINTS = 4;
|
||||
const ADAPTIVE_ANTHROPIC_CACHE_CONTROL_BLOCK_WINDOW = 20;
|
||||
const ANTHROPIC_WEB_SEARCH_TOOL_TYPES = new Set([
|
||||
'web_search',
|
||||
'web_search_20250305',
|
||||
'google_search',
|
||||
]);
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
@@ -181,6 +186,20 @@ function normalizeOpenAiToolArguments(raw: unknown): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function isAnthropicWebSearchTool(value: Record<string, unknown>): boolean {
|
||||
const type = asTrimmedString(value.type).toLowerCase();
|
||||
const name = asTrimmedString(value.name).toLowerCase();
|
||||
return ANTHROPIC_WEB_SEARCH_TOOL_TYPES.has(type) || name === 'web_search' || name === 'google_search';
|
||||
}
|
||||
|
||||
function convertAnthropicWebSearchToolToOpenAi(value: Record<string, unknown>): Record<string, unknown> {
|
||||
const next: Record<string, unknown> = { ...value };
|
||||
const type = asTrimmedString(value.type).toLowerCase();
|
||||
next.type = type === 'google_search' ? 'web_search' : 'web_search';
|
||||
next.name = 'web_search';
|
||||
return next;
|
||||
}
|
||||
|
||||
export function normalizeAnthropicToolInput(raw: unknown): unknown {
|
||||
if (raw === undefined || raw === null) return {};
|
||||
if (isRecord(raw) || Array.isArray(raw)) return raw;
|
||||
@@ -802,6 +821,14 @@ export function convertOpenAiToolsToAnthropic(rawTools: unknown): unknown {
|
||||
if (!isRecord(item)) return null;
|
||||
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (isAnthropicWebSearchTool(item)) {
|
||||
return {
|
||||
...item,
|
||||
type: 'web_search_20250305',
|
||||
name: 'web_search',
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'function' && isRecord(item.function)) {
|
||||
const fn = item.function;
|
||||
const name = asTrimmedString(fn.name);
|
||||
@@ -826,6 +853,16 @@ export function convertOpenAiToolsToAnthropic(rawTools: unknown): unknown {
|
||||
return converted.length > 0 ? converted : rawTools;
|
||||
}
|
||||
|
||||
export function convertAnthropicToolsToOpenAi(rawTools: unknown): unknown {
|
||||
if (!Array.isArray(rawTools)) return rawTools;
|
||||
|
||||
return rawTools.map((item) => {
|
||||
if (!isRecord(item)) return item;
|
||||
if (isAnthropicWebSearchTool(item)) return convertAnthropicWebSearchToolToOpenAi(item);
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
export function convertOpenAiToolChoiceToAnthropic(rawToolChoice: unknown): unknown {
|
||||
if (rawToolChoice === undefined) return undefined;
|
||||
|
||||
|
||||
@@ -1059,6 +1059,79 @@ describe('convertOpenAiBodyToResponsesBody', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('maps legacy chat functions, function_call and role:function messages into Responses request fields', () => {
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [
|
||||
{ role: 'user', content: 'lookup weather' },
|
||||
{ role: 'function', name: 'legacy_weather', content: '{"temp":21}' },
|
||||
],
|
||||
functions: [
|
||||
{
|
||||
name: 'legacy_weather',
|
||||
description: 'Lookup weather',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { city: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
function_call: { name: 'legacy_weather' },
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.tools).toEqual([
|
||||
{
|
||||
type: 'function',
|
||||
name: 'legacy_weather',
|
||||
description: 'Lookup weather',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { city: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(result.tool_choice).toEqual({
|
||||
type: 'function',
|
||||
name: 'legacy_weather',
|
||||
});
|
||||
expect(result.input).toEqual([
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'lookup weather' }],
|
||||
},
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'legacy_weather',
|
||||
output: '{"temp":21}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves OpenAI web_search and google_search server tools in Responses request bodies', () => {
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [{ role: 'user', content: 'search docs' }],
|
||||
tools: [
|
||||
{ type: 'web_search_preview', search_context_size: 'low' },
|
||||
{ type: 'google_search', max_results: 3 },
|
||||
],
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.tools).toEqual([
|
||||
{ type: 'web_search_preview', search_context_size: 'low', name: 'web_search' },
|
||||
{ type: 'web_search', max_results: 3, name: 'web_search' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops function tools with blank names when converting OpenAI-compatible input into Responses bodies', () => {
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
|
||||
@@ -71,6 +71,19 @@ function cloneJsonValue<T>(value: T): T {
|
||||
return value;
|
||||
}
|
||||
|
||||
const OPENAI_WEB_SEARCH_TOOL_TYPES = new Set([
|
||||
'web_search',
|
||||
'web_search_preview',
|
||||
'web_search_preview_2025_03_11',
|
||||
'google_search',
|
||||
]);
|
||||
|
||||
function isOpenAiWebSearchToolRecord(item: Record<string, unknown>): boolean {
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
const name = asTrimmedString(item.name).toLowerCase();
|
||||
return OPENAI_WEB_SEARCH_TOOL_TYPES.has(type) || name === 'web_search' || name === 'google_search';
|
||||
}
|
||||
|
||||
function normalizeOptionalTrimmedString(value: unknown): string | undefined {
|
||||
const trimmed = asTrimmedString(value);
|
||||
return trimmed || undefined;
|
||||
@@ -287,11 +300,22 @@ function collectOpenAiToolNames(body: Record<string, unknown>): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
const rawFunctions = Array.isArray(body.functions) ? body.functions : [];
|
||||
for (const item of rawFunctions) {
|
||||
if (!isRecord(item)) continue;
|
||||
pushName(item.name);
|
||||
}
|
||||
|
||||
const toolChoice = isRecord(body.tool_choice) ? body.tool_choice : null;
|
||||
if (toolChoice && asTrimmedString(toolChoice.type).toLowerCase() === 'function') {
|
||||
pushName(isRecord(toolChoice.function) ? toolChoice.function.name : toolChoice.name);
|
||||
}
|
||||
|
||||
const legacyFunctionCall = isRecord(body.function_call) ? body.function_call : null;
|
||||
if (legacyFunctionCall) {
|
||||
pushName(legacyFunctionCall.name);
|
||||
}
|
||||
|
||||
const rawMessages = Array.isArray(body.messages) ? body.messages : [];
|
||||
for (const message of rawMessages) {
|
||||
if (!isRecord(message) || asTrimmedString(message.role).toLowerCase() !== 'assistant') continue;
|
||||
@@ -344,6 +368,14 @@ function convertOpenAiToolsToResponses(
|
||||
return item;
|
||||
}
|
||||
|
||||
if (isOpenAiWebSearchToolRecord(item)) {
|
||||
return {
|
||||
...item,
|
||||
type: type === 'google_search' ? 'web_search' : (type || 'web_search'),
|
||||
name: asTrimmedString(item.name) || 'web_search',
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'custom' && asTrimmedString(item.name)) {
|
||||
return item;
|
||||
}
|
||||
@@ -355,6 +387,43 @@ function convertOpenAiToolsToResponses(
|
||||
return converted;
|
||||
}
|
||||
|
||||
function convertOpenAiFunctionsToResponses(
|
||||
rawFunctions: unknown,
|
||||
toolNameMap: Record<string, string>,
|
||||
): Array<Record<string, unknown>> {
|
||||
if (!Array.isArray(rawFunctions)) return [];
|
||||
|
||||
return rawFunctions
|
||||
.map((item) => {
|
||||
if (!isRecord(item)) return null;
|
||||
const name = asTrimmedString(item.name);
|
||||
if (!name) return null;
|
||||
|
||||
const mapped: Record<string, unknown> = {
|
||||
type: 'function',
|
||||
name: getShortToolName(name, toolNameMap),
|
||||
};
|
||||
const description = asTrimmedString(item.description);
|
||||
if (description) mapped.description = description;
|
||||
if (item.parameters !== undefined) mapped.parameters = item.parameters;
|
||||
if (item.strict !== undefined) mapped.strict = item.strict;
|
||||
return mapped;
|
||||
})
|
||||
.filter((item): item is Record<string, unknown> => !!item);
|
||||
}
|
||||
|
||||
function mergeOpenAiToolsAndFunctionsToResponses(
|
||||
rawTools: unknown,
|
||||
rawFunctions: unknown,
|
||||
toolNameMap: Record<string, string>,
|
||||
): unknown {
|
||||
const convertedTools = convertOpenAiToolsToResponses(rawTools, toolNameMap);
|
||||
const toolList = Array.isArray(convertedTools) ? convertedTools : [];
|
||||
const functionList = convertOpenAiFunctionsToResponses(rawFunctions, toolNameMap);
|
||||
if (toolList.length <= 0 && functionList.length <= 0) return convertedTools;
|
||||
return [...toolList, ...functionList];
|
||||
}
|
||||
|
||||
function convertOpenAiToolChoiceToResponses(
|
||||
rawToolChoice: unknown,
|
||||
toolNameMap: Record<string, string>,
|
||||
@@ -373,6 +442,26 @@ function convertOpenAiToolChoiceToResponses(
|
||||
return rawToolChoice;
|
||||
}
|
||||
|
||||
function convertOpenAiFunctionCallToResponsesToolChoice(
|
||||
rawFunctionCall: unknown,
|
||||
toolNameMap: Record<string, string>,
|
||||
): unknown {
|
||||
if (rawFunctionCall === undefined) return undefined;
|
||||
if (typeof rawFunctionCall === 'string') {
|
||||
const normalized = rawFunctionCall.trim().toLowerCase();
|
||||
if (normalized === 'none' || normalized === 'auto' || normalized === 'required') return normalized;
|
||||
return rawFunctionCall;
|
||||
}
|
||||
if (!isRecord(rawFunctionCall)) return rawFunctionCall;
|
||||
|
||||
const name = asTrimmedString(rawFunctionCall.name);
|
||||
if (!name) return 'required';
|
||||
return {
|
||||
type: 'function',
|
||||
name: getShortToolName(name, toolNameMap),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeResponsesBodyForCompatibility(
|
||||
body: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
@@ -596,6 +685,23 @@ export function convertOpenAiBodyToResponsesBody(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === 'function') {
|
||||
const callId = asTrimmedString(item.name) || asTrimmedString(item.id);
|
||||
if (!callId) continue;
|
||||
const output = normalizeToolOutput(item.content);
|
||||
inputItems.push({
|
||||
type: 'function_call_output',
|
||||
call_id: callId,
|
||||
output: (
|
||||
(typeof output === 'string' && output === '')
|
||||
|| (Array.isArray(output) && output.length === 0)
|
||||
)
|
||||
? '(empty)'
|
||||
: output,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedContent = normalizeResponsesMessageContent('user', item.content);
|
||||
if (normalizedContent.length <= 0) continue;
|
||||
inputItems.push({
|
||||
@@ -639,7 +745,13 @@ export function convertOpenAiBodyToResponsesBody(
|
||||
if (openaiBody.reasoning_budget !== undefined) body.reasoning_budget = openaiBody.reasoning_budget;
|
||||
if (openaiBody.reasoning_summary !== undefined) body.reasoning_summary = openaiBody.reasoning_summary;
|
||||
if (openaiBody.parallel_tool_calls !== undefined) body.parallel_tool_calls = openaiBody.parallel_tool_calls;
|
||||
if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToResponses(openaiBody.tools, toolNameMap);
|
||||
if (openaiBody.tools !== undefined || openaiBody.functions !== undefined) {
|
||||
body.tools = mergeOpenAiToolsAndFunctionsToResponses(
|
||||
openaiBody.tools,
|
||||
openaiBody.functions,
|
||||
toolNameMap,
|
||||
);
|
||||
}
|
||||
if (openaiBody.safety_identifier !== undefined) body.safety_identifier = openaiBody.safety_identifier;
|
||||
if (openaiBody.max_tool_calls !== undefined) body.max_tool_calls = openaiBody.max_tool_calls;
|
||||
if (openaiBody.prompt_cache_key !== undefined) body.prompt_cache_key = openaiBody.prompt_cache_key;
|
||||
@@ -665,7 +777,9 @@ export function convertOpenAiBodyToResponsesBody(
|
||||
body.text = textConfig;
|
||||
}
|
||||
|
||||
const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice, toolNameMap);
|
||||
const responsesToolChoice = openaiBody.tool_choice !== undefined
|
||||
? convertOpenAiToolChoiceToResponses(openaiBody.tool_choice, toolNameMap)
|
||||
: convertOpenAiFunctionCallToResponsesToolChoice(openaiBody.function_call, toolNameMap);
|
||||
if (responsesToolChoice !== undefined) body.tool_choice = responsesToolChoice;
|
||||
if (Array.isArray(body.tools) && body.tools.length === 0) {
|
||||
delete body.tool_choice;
|
||||
|
||||
@@ -973,11 +973,25 @@ function convertClaudeToolsToOpenAiChat(rawTools: unknown): unknown {
|
||||
if (!isRecord(item)) return item;
|
||||
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
const name = asTrimmedString(item.name);
|
||||
if (
|
||||
type === 'web_search'
|
||||
|| type === 'web_search_20250305'
|
||||
|| type === 'google_search'
|
||||
|| name === 'web_search'
|
||||
|| name === 'google_search'
|
||||
) {
|
||||
return {
|
||||
...item,
|
||||
type: 'web_search',
|
||||
name: 'web_search',
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'function' || type === 'custom' || type === 'image_generation') {
|
||||
return item;
|
||||
}
|
||||
|
||||
const name = asTrimmedString(item.name);
|
||||
if (!name) return item;
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user