fix: preserve protocol parity across compatibility hops (#259)
* wip: preserve local changes before upstream merge * Improve proxy routing stability and add stable-first strategy * Sync explicit-group routing strategy to eligible source routes * Fix proxy retry and runtime health review issues * fix: make proxy channel retry attempts configurable * fix: harden proxy retry recovery and bookkeeping * fix: address pr 257 review comments * fix: preserve oe tool_result structure * fix: preserve protocol parity across compatibility hops * fix: satisfy server build and typecheck * fix: preserve codex and mcp fallback semantics * fix: address ci and review follow-ups --------- Co-authored-by: bnvnvnv <bnvnvnv@gmail.com>
This commit is contained in:
@@ -562,6 +562,53 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves image blocks inside tool_result content arrays', () => {
|
||||
const result = sanitizeAnthropicMessagesBody({
|
||||
model: 'claude-opus-4-6',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool_1',
|
||||
content: [
|
||||
{ type: 'text', text: 'found 1 image' },
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: 'https://example.com/cat.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool_1',
|
||||
content: [
|
||||
{ type: 'text', text: 'found 1 image' },
|
||||
{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'url',
|
||||
url: 'https://example.com/cat.png',
|
||||
},
|
||||
},
|
||||
],
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds a second message cache anchor around the 20-block window for long prompts', () => {
|
||||
const longContent = Array.from({ length: 25 }, (_, index) => ({
|
||||
type: 'text',
|
||||
@@ -603,6 +650,57 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => {
|
||||
expect(body.output_config).toEqual({ effort: 'high' });
|
||||
});
|
||||
|
||||
it('copies metadata.user_id into the anthropic payload', () => {
|
||||
const metadata = { user_id: 'anthropic-user-1', trace_id: 'trace-xyz' };
|
||||
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
metadata,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
},
|
||||
'claude-opus-4-6',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(body.metadata).toEqual(metadata);
|
||||
});
|
||||
|
||||
it('marks tool_choice.disable_parallel_tool_use when parallel_tool_calls is false', () => {
|
||||
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
parallel_tool_calls: false,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
},
|
||||
'claude-opus-4-6',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(body.tool_choice).toMatchObject({
|
||||
type: 'auto',
|
||||
disable_parallel_tool_use: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('augments explicit tool_choice with disable_parallel_tool_use when parallel_tool_calls is false', () => {
|
||||
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
parallel_tool_calls: false,
|
||||
tool_choice: { type: 'tool', name: 'search' },
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
},
|
||||
'claude-opus-4-6',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(body.tool_choice).toMatchObject({
|
||||
type: 'tool',
|
||||
name: 'search',
|
||||
disable_parallel_tool_use: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps inbound claude bodies thin instead of rebuilding cache anchors', () => {
|
||||
const result = anthropicMessagesInbound.parse(
|
||||
{
|
||||
|
||||
@@ -29,6 +29,14 @@ function safeJsonStringify(value: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
function cloneJsonValue<T>(value: T): T | undefined {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonString(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
@@ -187,6 +195,16 @@ function normalizeTextCandidate(value: unknown): string {
|
||||
return asTrimmedString(value.text ?? value.content ?? value.output_text);
|
||||
}
|
||||
|
||||
function ensureAnthropicToolChoiceRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
if (isRecord(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) return undefined;
|
||||
return { type: normalized };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sanitizeToolReferenceBlock(item: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const toolName = asTrimmedString(item.tool_name ?? item.toolName ?? item.name);
|
||||
if (!toolName) return null;
|
||||
@@ -215,6 +233,13 @@ function normalizeToolMessageContent(raw: unknown): string | Array<Record<string
|
||||
if (type === 'text' || type === 'input_text' || type === 'output_text') {
|
||||
return toAnthropicTextBlock(normalizeTextCandidate(item));
|
||||
}
|
||||
if (type === 'image_url' || type === 'input_image') {
|
||||
return toAnthropicImageBlock(item);
|
||||
}
|
||||
if (type === 'file' || type === 'input_file') {
|
||||
const fileBlock = normalizeInputFileBlock(item);
|
||||
return fileBlock ? toAnthropicDocumentBlock(fileBlock) : null;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is Record<string, unknown> => item !== null);
|
||||
@@ -232,6 +257,17 @@ function normalizeToolMessageContent(raw: unknown): string | Array<Record<string
|
||||
}
|
||||
|
||||
if (isRecord(raw)) {
|
||||
const type = asTrimmedString(raw.type).toLowerCase();
|
||||
if (type === 'image_url' || type === 'input_image') {
|
||||
const imageBlock = toAnthropicImageBlock(raw);
|
||||
return imageBlock ? [imageBlock] : '';
|
||||
}
|
||||
if (type === 'file' || type === 'input_file') {
|
||||
const fileBlock = normalizeInputFileBlock(raw);
|
||||
if (!fileBlock) return '';
|
||||
const documentBlock = toAnthropicDocumentBlock(fileBlock);
|
||||
return documentBlock ? [documentBlock] : '';
|
||||
}
|
||||
const text = normalizeTextCandidate(raw);
|
||||
return text || safeJsonStringify(raw);
|
||||
}
|
||||
@@ -940,6 +976,14 @@ export function convertOpenAiBodyToAnthropicMessagesBody(
|
||||
max_tokens: toFiniteNumber(openaiBody.max_tokens) ?? 4096,
|
||||
};
|
||||
|
||||
const openAiMetadata = openaiBody.metadata;
|
||||
if (isRecord(openAiMetadata)) {
|
||||
const clonedMetadata = cloneJsonValue(openAiMetadata);
|
||||
if (isRecord(clonedMetadata) && 'user_id' in clonedMetadata) {
|
||||
body.metadata = clonedMetadata;
|
||||
}
|
||||
}
|
||||
|
||||
if (systemContents.length > 0) {
|
||||
body.system = systemContents.join('\n\n');
|
||||
}
|
||||
@@ -956,8 +1000,17 @@ export function convertOpenAiBodyToAnthropicMessagesBody(
|
||||
|
||||
if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToAnthropic(openaiBody.tools);
|
||||
|
||||
const anthropicToolChoice = convertOpenAiToolChoiceToAnthropic(openaiBody.tool_choice);
|
||||
if (anthropicToolChoice !== undefined) body.tool_choice = anthropicToolChoice;
|
||||
const parallelToolCalls = openaiBody.parallel_tool_calls;
|
||||
let anthropicToolChoice = convertOpenAiToolChoiceToAnthropic(openaiBody.tool_choice);
|
||||
if (parallelToolCalls === false) {
|
||||
const choiceRecord = ensureAnthropicToolChoiceRecord(anthropicToolChoice) ?? { type: 'auto' };
|
||||
choiceRecord.disable_parallel_tool_use = true;
|
||||
anthropicToolChoice = choiceRecord;
|
||||
}
|
||||
|
||||
if (anthropicToolChoice !== undefined) {
|
||||
body.tool_choice = anthropicToolChoice;
|
||||
}
|
||||
|
||||
const reasoningSettings = resolveOpenAiReasoningSettings(openaiBody);
|
||||
if (reasoningSettings.thinking) body.thinking = reasoningSettings.thinking;
|
||||
|
||||
@@ -214,4 +214,162 @@ describe('canonical request helpers', () => {
|
||||
include: ['reasoning.encrypted_content', 'message.input_image.image_url'],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves extra fields on tool-shaped raw tool_choice objects', () => {
|
||||
const request = canonicalRequestFromOpenAiBody({
|
||||
body: {
|
||||
model: 'gpt-5',
|
||||
tool_choice: {
|
||||
type: 'tool',
|
||||
name: 'browser',
|
||||
mode: 'required',
|
||||
disable_parallel_tool_use: true,
|
||||
},
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
},
|
||||
surface: 'openai-responses',
|
||||
});
|
||||
|
||||
const body = canonicalRequestToOpenAiChatBody(request);
|
||||
|
||||
expect(body.tool_choice).toEqual({
|
||||
type: 'tool',
|
||||
name: 'browser',
|
||||
mode: 'required',
|
||||
disable_parallel_tool_use: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves structured tool outputs and top-level attachments through canonical round-trips', () => {
|
||||
const request = createCanonicalRequestEnvelope({
|
||||
requestedModel: 'gpt-5',
|
||||
surface: 'openai-chat',
|
||||
attachments: [{
|
||||
kind: 'file',
|
||||
fileId: 'file-top-level',
|
||||
}],
|
||||
messages: [{
|
||||
role: 'tool',
|
||||
parts: [{
|
||||
type: 'tool_result',
|
||||
toolCallId: 'call_1',
|
||||
resultContent: [
|
||||
{ type: 'text', text: 'tool result' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/tool.png' } },
|
||||
],
|
||||
} as any],
|
||||
}],
|
||||
});
|
||||
|
||||
const body = canonicalRequestToOpenAiChatBody(request);
|
||||
|
||||
expect(body.attachments).toEqual([{
|
||||
kind: 'file',
|
||||
fileId: 'file-top-level',
|
||||
}]);
|
||||
expect(body.messages).toEqual([{
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_1',
|
||||
content: [
|
||||
{ type: 'text', text: 'tool result' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/tool.png' } },
|
||||
],
|
||||
}]);
|
||||
});
|
||||
|
||||
it('preserves richer Responses tools, raw tool_choice, assistant phase, and reasoning signatures through canonical round-trips', () => {
|
||||
const request = canonicalRequestFromOpenAiBody({
|
||||
body: {
|
||||
model: 'gpt-5',
|
||||
parallel_tool_calls: false,
|
||||
tools: [
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'browser',
|
||||
description: 'browse the web',
|
||||
format: { type: 'text' },
|
||||
},
|
||||
{
|
||||
type: 'image_generation',
|
||||
background: 'transparent',
|
||||
},
|
||||
],
|
||||
tool_choice: {
|
||||
type: 'allowed_tools',
|
||||
mode: 'auto',
|
||||
tools: [{ type: 'custom', name: 'browser' }],
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
phase: 'analysis',
|
||||
reasoning_signature: 'sig_123',
|
||||
content: 'thinking',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: 'hello',
|
||||
},
|
||||
],
|
||||
},
|
||||
surface: 'openai-responses',
|
||||
});
|
||||
|
||||
const body = canonicalRequestToOpenAiChatBody(request);
|
||||
|
||||
expect(body.parallel_tool_calls).toBe(false);
|
||||
expect(body.tools).toEqual([
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'browser',
|
||||
description: 'browse the web',
|
||||
format: { type: 'text' },
|
||||
},
|
||||
{
|
||||
type: 'image_generation',
|
||||
background: 'transparent',
|
||||
},
|
||||
]);
|
||||
expect(body.tool_choice).toEqual({
|
||||
type: 'allowed_tools',
|
||||
mode: 'auto',
|
||||
tools: [{ type: 'custom', name: 'browser' }],
|
||||
});
|
||||
expect(body.messages).toMatchObject([
|
||||
{
|
||||
role: 'assistant',
|
||||
phase: 'analysis',
|
||||
reasoning_signature: 'sig_123',
|
||||
content: 'thinking',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: 'hello',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('writes raw canonical tool types back into OpenAI-compatible bodies when the raw payload omits the discriminator', () => {
|
||||
const request = createCanonicalRequestEnvelope({
|
||||
requestedModel: 'gpt-5',
|
||||
surface: 'openai-responses',
|
||||
tools: [{
|
||||
type: 'custom',
|
||||
raw: {
|
||||
name: 'browser',
|
||||
description: 'browse the web',
|
||||
format: { type: 'text' },
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
const body = canonicalRequestToOpenAiChatBody(request);
|
||||
|
||||
expect(body.tools).toEqual([{
|
||||
type: 'custom',
|
||||
name: 'browser',
|
||||
description: 'browse the web',
|
||||
format: { type: 'text' },
|
||||
}]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,6 +104,9 @@ export function createCanonicalRequestEnvelope(
|
||||
: {}),
|
||||
...(input.metadata ? { metadata: input.metadata } : {}),
|
||||
...(input.passthrough ? { passthrough: input.passthrough } : {}),
|
||||
...(Array.isArray(input.attachments) && input.attachments.length > 0
|
||||
? { attachments: cloneJsonValue(input.attachments) as CanonicalAttachment[] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -190,7 +193,7 @@ function parseToolChoice(rawToolChoice: unknown): CanonicalToolChoice | undefine
|
||||
const normalized = rawToolChoice.trim().toLowerCase();
|
||||
if (normalized === 'auto' || normalized === 'none' || normalized === 'required') return normalized;
|
||||
if (normalized === 'any') return 'required';
|
||||
return undefined;
|
||||
return rawToolChoice.trim() ? { type: 'raw', value: rawToolChoice } : undefined;
|
||||
}
|
||||
|
||||
if (!isRecord(rawToolChoice)) return undefined;
|
||||
@@ -204,20 +207,28 @@ function parseToolChoice(rawToolChoice: unknown): CanonicalToolChoice | undefine
|
||||
);
|
||||
return name ? { type: 'tool', name } : undefined;
|
||||
}
|
||||
if (type !== 'tool') return undefined;
|
||||
if (type && type !== 'tool') {
|
||||
return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record<string, unknown> };
|
||||
}
|
||||
|
||||
const name = asTrimmedString(
|
||||
rawToolChoice.name
|
||||
?? (isRecord(rawToolChoice.tool) ? rawToolChoice.tool.name : undefined),
|
||||
);
|
||||
return name ? { type: 'tool', name } : undefined;
|
||||
const toolChoiceKeys = Object.keys(rawToolChoice);
|
||||
const hasExtraToolFields = toolChoiceKeys.some((key) => key !== 'type' && key !== 'name' && key !== 'tool');
|
||||
if (hasExtraToolFields) {
|
||||
return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record<string, unknown> };
|
||||
}
|
||||
if (name) return { type: 'tool', name };
|
||||
return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record<string, unknown> };
|
||||
}
|
||||
|
||||
function parseTools(rawTools: unknown): CanonicalTool[] | undefined {
|
||||
if (!Array.isArray(rawTools)) return undefined;
|
||||
|
||||
const tools = rawTools
|
||||
.flatMap((item) => {
|
||||
const tools: CanonicalTool[] = rawTools
|
||||
.flatMap((item): CanonicalTool[] => {
|
||||
if (!isRecord(item)) return [];
|
||||
const itemType = asTrimmedString(item.type).toLowerCase();
|
||||
|
||||
@@ -264,6 +275,13 @@ function parseTools(rawTools: unknown): CanonicalTool[] | undefined {
|
||||
});
|
||||
}
|
||||
|
||||
if (itemType) {
|
||||
return [{
|
||||
type: itemType,
|
||||
raw: cloneJsonValue(item) as Record<string, unknown>,
|
||||
}];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
@@ -277,6 +295,9 @@ export function canonicalRequestFromOpenAiBody(
|
||||
const metadata = isRecord(input.metadata)
|
||||
? input.metadata
|
||||
: (isRecord(body.metadata) ? cloneJsonValue(body.metadata) : undefined);
|
||||
const attachments = Array.isArray(body.attachments)
|
||||
? cloneJsonValue(body.attachments) as CanonicalAttachment[]
|
||||
: undefined;
|
||||
const messages: CanonicalMessage[] = [];
|
||||
const rawMessages = Array.isArray(body.messages) ? body.messages : [];
|
||||
|
||||
@@ -286,15 +307,21 @@ export function canonicalRequestFromOpenAiBody(
|
||||
|
||||
if (role === 'tool') {
|
||||
const toolCallId = asTrimmedString(rawMessage.tool_call_id ?? rawMessage.id);
|
||||
const resultText = typeof rawMessage.content === 'string'
|
||||
? rawMessage.content
|
||||
: safeJsonStringify(rawMessage.content ?? '');
|
||||
const rawContent = rawMessage.content;
|
||||
const resultText = typeof rawContent === 'string'
|
||||
? rawContent
|
||||
: (!Array.isArray(rawContent) && !isRecord(rawContent) ? safeJsonStringify(rawContent ?? '') : '');
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
parts: [{
|
||||
type: 'tool_result',
|
||||
toolCallId: toolCallId || 'tool',
|
||||
...(resultText ? { resultText } : {}),
|
||||
...(Array.isArray(rawContent)
|
||||
? { resultContent: cloneJsonValue(rawContent) as Array<string | Record<string, unknown>> }
|
||||
: (isRecord(rawContent)
|
||||
? { resultContent: [cloneJsonValue(rawContent) as Record<string, unknown>] }
|
||||
: {})),
|
||||
}],
|
||||
});
|
||||
continue;
|
||||
@@ -322,6 +349,10 @@ export function canonicalRequestFromOpenAiBody(
|
||||
messages.push({
|
||||
role,
|
||||
parts,
|
||||
...(asTrimmedString(rawMessage.phase) ? { phase: asTrimmedString(rawMessage.phase) } : {}),
|
||||
...(asTrimmedString(rawMessage.reasoning_signature)
|
||||
? { reasoningSignature: asTrimmedString(rawMessage.reasoning_signature) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -343,6 +374,14 @@ export function canonicalRequestFromOpenAiBody(
|
||||
: {}),
|
||||
};
|
||||
|
||||
const passthrough = {
|
||||
...(input.passthrough ?? {}),
|
||||
...(typeof body.parallel_tool_calls === 'boolean'
|
||||
? { parallel_tool_calls: body.parallel_tool_calls }
|
||||
: {}),
|
||||
...(reasoningResult.metadata ? { transformerMetadata: reasoningResult.metadata } : {}),
|
||||
};
|
||||
|
||||
return createCanonicalRequestEnvelope({
|
||||
operation: input.operation ?? 'generate',
|
||||
surface: input.surface,
|
||||
@@ -355,14 +394,8 @@ export function canonicalRequestFromOpenAiBody(
|
||||
...(parseToolChoice(body.tool_choice) !== undefined ? { toolChoice: parseToolChoice(body.tool_choice) } : {}),
|
||||
...(Object.keys(continuation).length > 0 ? { continuation } : {}),
|
||||
...(metadata ? { metadata } : {}),
|
||||
...((reasoningResult.metadata || input.passthrough)
|
||||
? {
|
||||
passthrough: {
|
||||
...(input.passthrough ?? {}),
|
||||
...(reasoningResult.metadata ? { transformerMetadata: reasoningResult.metadata } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(attachments ? { attachments } : {}),
|
||||
...(Object.keys(passthrough).length > 0 ? { passthrough } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -418,7 +451,10 @@ function canonicalPartsToOpenAiContent(
|
||||
continue;
|
||||
}
|
||||
if (part.type === 'tool_result' && role !== 'tool') {
|
||||
const text = part.resultText ?? safeJsonStringify(part.resultJson ?? '');
|
||||
const text = part.resultText
|
||||
?? (typeof part.resultContent === 'string'
|
||||
? part.resultContent
|
||||
: safeJsonStringify(part.resultJson ?? part.resultContent ?? ''));
|
||||
if (text) {
|
||||
visibleText.push(text);
|
||||
}
|
||||
@@ -451,6 +487,7 @@ function canonicalToolChoiceToOpenAi(toolChoice: CanonicalToolChoice | undefined
|
||||
if (!toolChoice) return undefined;
|
||||
if (toolChoice === 'auto' || toolChoice === 'none') return toolChoice;
|
||||
if (toolChoice === 'required') return 'required';
|
||||
if (toolChoice.type === 'raw') return cloneJsonValue(toolChoice.value);
|
||||
return {
|
||||
type: 'function',
|
||||
function: {
|
||||
@@ -471,7 +508,9 @@ export function canonicalRequestToOpenAiChatBody(
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: part.toolCallId,
|
||||
content: part.resultText ?? safeJsonStringify(part.resultJson ?? ''),
|
||||
content: part.resultContent
|
||||
?? part.resultText
|
||||
?? safeJsonStringify(part.resultJson ?? ''),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -485,6 +524,8 @@ export function canonicalRequestToOpenAiChatBody(
|
||||
if (message.role === 'assistant' && converted.reasoning) {
|
||||
nextMessage.reasoning_content = converted.reasoning;
|
||||
}
|
||||
if (message.phase) nextMessage.phase = message.phase;
|
||||
if (message.reasoningSignature) nextMessage.reasoning_signature = message.reasoningSignature;
|
||||
if (message.role === 'assistant' && converted.toolCalls && converted.toolCalls.length > 0) {
|
||||
nextMessage.tool_calls = converted.toolCalls;
|
||||
if (typeof nextMessage.content !== 'string' && (nextMessage.content as Array<unknown>).length <= 0) {
|
||||
@@ -517,18 +558,30 @@ export function canonicalRequestToOpenAiChatBody(
|
||||
].filter((item, index, all) => all.indexOf(item) === index);
|
||||
if (mergedInclude.length > 0) body.include = mergedInclude;
|
||||
if (request.metadata !== undefined) body.metadata = cloneJsonValue(request.metadata);
|
||||
if (Array.isArray(request.attachments) && request.attachments.length > 0) {
|
||||
body.attachments = cloneJsonValue(request.attachments);
|
||||
}
|
||||
if (request.continuation?.promptCacheKey) body.prompt_cache_key = request.continuation.promptCacheKey;
|
||||
if (request.continuation?.previousResponseId) body.previous_response_id = request.continuation.previousResponseId;
|
||||
if (Array.isArray(request.tools) && request.tools.length > 0) {
|
||||
body.tools = request.tools.map((tool) => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
...(tool.description ? { description: tool.description } : {}),
|
||||
...(typeof tool.strict === 'boolean' ? { strict: tool.strict } : {}),
|
||||
parameters: cloneJsonValue(tool.inputSchema ?? { type: 'object' }),
|
||||
},
|
||||
}));
|
||||
body.tools = request.tools.map((tool) => {
|
||||
if ('raw' in tool) {
|
||||
const raw = cloneJsonValue(tool.raw) as Record<string, unknown>;
|
||||
if (typeof raw.type !== 'string' || raw.type.trim().length === 0) {
|
||||
raw.type = tool.type;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
return {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
...(tool.description ? { description: tool.description } : {}),
|
||||
...(typeof tool.strict === 'boolean' ? { strict: tool.strict } : {}),
|
||||
parameters: cloneJsonValue(tool.inputSchema ?? { type: 'object' }),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
const toolChoice = canonicalToolChoiceToOpenAi(request.toolChoice);
|
||||
if (toolChoice !== undefined) body.tool_choice = toolChoice;
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
export type CanonicalTool = {
|
||||
export type CanonicalFunctionTool = {
|
||||
name: string;
|
||||
description?: string;
|
||||
strict?: boolean;
|
||||
inputSchema?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type CanonicalRawTool = {
|
||||
type: string;
|
||||
raw: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CanonicalTool =
|
||||
| CanonicalFunctionTool
|
||||
| CanonicalRawTool;
|
||||
|
||||
export type CanonicalToolChoice =
|
||||
| 'auto'
|
||||
| 'none'
|
||||
@@ -12,4 +21,18 @@ export type CanonicalToolChoice =
|
||||
| {
|
||||
type: 'tool';
|
||||
name: string;
|
||||
}
|
||||
| {
|
||||
type: 'raw';
|
||||
value: string | Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function isCanonicalFunctionTool(tool: CanonicalTool): tool is CanonicalFunctionTool {
|
||||
return 'name' in tool;
|
||||
}
|
||||
|
||||
export function isCanonicalNamedToolChoice(
|
||||
toolChoice: CanonicalToolChoice | undefined,
|
||||
): toolChoice is { type: 'tool'; name: string } {
|
||||
return !!toolChoice && typeof toolChoice === 'object' && toolChoice.type === 'tool';
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CanonicalAttachment } from './attachments.js';
|
||||
import type { CanonicalTool, CanonicalToolChoice } from './tools.js';
|
||||
|
||||
export type CanonicalOperation =
|
||||
@@ -57,6 +58,7 @@ export type CanonicalToolResultPart = {
|
||||
toolCallId: string;
|
||||
resultText?: string;
|
||||
resultJson?: unknown;
|
||||
resultContent?: string | Array<string | Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type CanonicalContentPart =
|
||||
@@ -69,6 +71,8 @@ export type CanonicalContentPart =
|
||||
export type CanonicalMessage = {
|
||||
role: CanonicalMessageRole;
|
||||
parts: CanonicalContentPart[];
|
||||
phase?: string;
|
||||
reasoningSignature?: string;
|
||||
};
|
||||
|
||||
export type CanonicalReasoningEffort =
|
||||
@@ -105,4 +109,5 @@ export type CanonicalRequestEnvelope = {
|
||||
continuation?: CanonicalContinuation;
|
||||
metadata?: Record<string, unknown>;
|
||||
passthrough?: Record<string, unknown>;
|
||||
attachments?: CanonicalAttachment[];
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { canonicalRequestFromOpenAiBody } from '../../canonical/request.js';
|
||||
import {
|
||||
canonicalRequestFromOpenAiBody,
|
||||
isCanonicalFunctionTool,
|
||||
isCanonicalNamedToolChoice,
|
||||
} from '../../canonical/request.js';
|
||||
import type { CanonicalContentPart, CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||
import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js';
|
||||
import {
|
||||
@@ -218,13 +222,16 @@ function buildGeminiRequestFromCanonical(request: CanonicalRequestEnvelope): Rec
|
||||
}
|
||||
|
||||
if (Array.isArray(request.tools) && request.tools.length > 0) {
|
||||
payload.tools = [{
|
||||
functionDeclarations: request.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
...(tool.description ? { description: tool.description } : {}),
|
||||
...(tool.inputSchema ? { parameters: tool.inputSchema } : {}),
|
||||
})),
|
||||
}];
|
||||
const functionTools = request.tools.filter(isCanonicalFunctionTool);
|
||||
if (functionTools.length > 0) {
|
||||
payload.tools = [{
|
||||
functionDeclarations: functionTools.map((tool) => ({
|
||||
name: tool.name,
|
||||
...(tool.description ? { description: tool.description } : {}),
|
||||
...(tool.inputSchema ? { parameters: tool.inputSchema } : {}),
|
||||
})),
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
if (request.toolChoice) {
|
||||
@@ -234,7 +241,7 @@ function buildGeminiRequestFromCanonical(request: CanonicalRequestEnvelope): Rec
|
||||
payload.toolConfig = { functionCallingConfig: { mode: 'AUTO' } };
|
||||
} else if (request.toolChoice === 'required') {
|
||||
payload.toolConfig = { functionCallingConfig: { mode: 'ANY' } };
|
||||
} else {
|
||||
} else if (isCanonicalNamedToolChoice(request.toolChoice)) {
|
||||
payload.toolConfig = {
|
||||
functionCallingConfig: {
|
||||
mode: 'ANY',
|
||||
|
||||
@@ -683,6 +683,86 @@ describe('convertOpenAiBodyToResponsesBody', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves structured tool outputs and assistant phase when converting Responses bodies to OpenAI-compatible messages', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: [
|
||||
{
|
||||
type: 'function_call',
|
||||
call_id: 'call_1',
|
||||
name: 'lookup_weather',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
},
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_1',
|
||||
output: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'tool result',
|
||||
},
|
||||
{
|
||||
type: 'input_image',
|
||||
image_url: 'https://example.com/tool.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
phase: 'analysis',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'done',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup_weather',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
},
|
||||
}],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_1',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'tool result',
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: 'https://example.com/tool.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
phase: 'analysis',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'done',
|
||||
}],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('shortens long MCP tool names consistently across tools, tool_choice and assistant tool calls', () => {
|
||||
const sharedSuffix = 'server__execute_super_long_nested_tool_name_that_needs_shortening';
|
||||
const firstName = `mcp__alpha_workspace__${sharedSuffix}`;
|
||||
@@ -944,6 +1024,65 @@ describe('convertResponsesBodyToOpenAiBody', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('round-trips mcp item families through OpenAI-compatible fallback using compatibility tool calls', () => {
|
||||
const source = {
|
||||
model: 'gpt-5',
|
||||
input: [
|
||||
{
|
||||
type: 'mcp_call',
|
||||
id: 'mcp_call_1',
|
||||
call_id: 'mcp_call_1',
|
||||
name: 'read_file',
|
||||
server_label: 'filesystem',
|
||||
arguments: {
|
||||
path: '/tmp/demo.txt',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'mcp_approval_request',
|
||||
id: 'mcp_approval_request_1',
|
||||
approval_request_id: 'mcp_approval_request_1',
|
||||
name: 'read_file',
|
||||
server_label: 'filesystem',
|
||||
arguments: {
|
||||
path: '/tmp/demo.txt',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'mcp_approval_response',
|
||||
id: 'mcp_approval_response_1',
|
||||
approval_request_id: 'mcp_approval_request_1',
|
||||
approve: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const openAiBody = convertResponsesBodyToOpenAiBody(
|
||||
source,
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(openAiBody.messages).toHaveLength(1);
|
||||
expect(openAiBody.messages[0]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{ id: 'mcp_call_1', type: 'function' },
|
||||
{ id: 'mcp_approval_request_1', type: 'function' },
|
||||
{ id: 'mcp_approval_response_1', type: 'function' },
|
||||
],
|
||||
});
|
||||
|
||||
const roundTripped = convertOpenAiBodyToResponsesBody(
|
||||
openAiBody,
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(roundTripped.input).toEqual(source.input);
|
||||
});
|
||||
|
||||
it('preserves remaining request fields needed for OpenAI-compatible downstream fallback', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
|
||||
@@ -3,6 +3,11 @@ import {
|
||||
normalizeResponsesMessageContentBlocks,
|
||||
normalizeResponsesMessageItem,
|
||||
} from './normalization.js';
|
||||
import {
|
||||
decodeResponsesMcpCompatToolCall,
|
||||
isResponsesMcpItem,
|
||||
toResponsesMcpCompatToolCall,
|
||||
} from './mcpCompatibility.js';
|
||||
import { normalizeInputFileBlock, toOpenAiChatFileBlock } from '../../shared/inputFile.js';
|
||||
import { buildShortToolNameMap, getShortToolName } from '../../shared/toolNameShortener.js';
|
||||
|
||||
@@ -241,9 +246,12 @@ function normalizeOpenAiToolArguments(raw: unknown): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeToolOutput(raw: unknown): string {
|
||||
const text = extractTextContent(raw).trim();
|
||||
if (text) return text;
|
||||
function normalizeToolOutput(raw: unknown): string | Array<string | Record<string, unknown>> {
|
||||
const normalizedContent = toOpenAiMessageContent(raw);
|
||||
const hasNormalizedContent = typeof normalizedContent === 'string'
|
||||
? normalizedContent.trim().length > 0
|
||||
: Array.isArray(normalizedContent) && normalizedContent.length > 0;
|
||||
if (hasNormalizedContent) return normalizedContent;
|
||||
if (raw === undefined || raw === null) return '';
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw);
|
||||
@@ -498,6 +506,14 @@ export function convertOpenAiBodyToResponsesBody(
|
||||
const toolCall = rawToolCalls[index];
|
||||
if (!isRecord(toolCall)) continue;
|
||||
const functionPart = isRecord(toolCall.function) ? toolCall.function : {};
|
||||
const mcpItem = decodeResponsesMcpCompatToolCall(
|
||||
functionPart.name ?? toolCall.name,
|
||||
functionPart.arguments ?? toolCall.arguments,
|
||||
);
|
||||
if (mcpItem) {
|
||||
inputItems.push(mcpItem);
|
||||
continue;
|
||||
}
|
||||
const callId = asTrimmedString(toolCall.id) || `call_${Date.now()}_${index}`;
|
||||
const name = (
|
||||
asTrimmedString(functionPart.name)
|
||||
@@ -810,6 +826,15 @@ export function convertResponsesBodyToOpenAiBody(
|
||||
if (!isRecord(item)) return;
|
||||
|
||||
const itemType = asTrimmedString(item.type).toLowerCase();
|
||||
if (itemType.startsWith('mcp_') && isResponsesMcpItem(item)) {
|
||||
const toolCall = toResponsesMcpCompatToolCall(item, `call_${Date.now()}_${functionCallIndex}`);
|
||||
if (toolCall) {
|
||||
pendingToolCalls.push(toolCall as OpenAiToolCall);
|
||||
functionCallIndex += 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (itemType === 'function_call' || itemType === 'custom_tool_call') {
|
||||
const toolCall = toOpenAiToolCall(item, functionCallIndex);
|
||||
functionCallIndex += 1;
|
||||
@@ -858,10 +883,13 @@ export function convertResponsesBodyToOpenAiBody(
|
||||
: Array.isArray(content) && content.length > 0;
|
||||
if (!hasContent) return;
|
||||
|
||||
messages.push({
|
||||
const message: Record<string, unknown> = {
|
||||
role: normalizedRole,
|
||||
content,
|
||||
});
|
||||
};
|
||||
const phase = asTrimmedString(item.phase);
|
||||
if (phase) message.phase = phase;
|
||||
messages.push(message);
|
||||
};
|
||||
|
||||
if (typeof input === 'string') {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function cloneJsonValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneJsonValue(item)) as T;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const RESPONSES_MCP_COMPAT_MARKER = 'responses_mcp_item';
|
||||
const RESPONSES_MCP_COMPAT_PREFIX = 'metapi_mcp_item__';
|
||||
|
||||
type ResponsesMcpCompatEnvelope = {
|
||||
metapi_compat: typeof RESPONSES_MCP_COMPAT_MARKER;
|
||||
itemType: string;
|
||||
item: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function isResponsesMcpItem(item: unknown): item is Record<string, unknown> {
|
||||
return isRecord(item) && asTrimmedString(item.type).toLowerCase().startsWith('mcp_');
|
||||
}
|
||||
|
||||
function createResponsesMcpCompatEnvelope(
|
||||
item: Record<string, unknown>,
|
||||
): ResponsesMcpCompatEnvelope | null {
|
||||
const itemType = asTrimmedString(item.type).toLowerCase();
|
||||
if (!itemType.startsWith('mcp_')) return null;
|
||||
|
||||
return {
|
||||
metapi_compat: RESPONSES_MCP_COMPAT_MARKER,
|
||||
itemType,
|
||||
item: cloneJsonValue(item),
|
||||
};
|
||||
}
|
||||
|
||||
export function toResponsesMcpCompatToolName(itemType: string): string {
|
||||
const normalizedType = asTrimmedString(itemType).toLowerCase();
|
||||
return `${RESPONSES_MCP_COMPAT_PREFIX}${normalizedType || 'item'}`;
|
||||
}
|
||||
|
||||
export function toResponsesMcpCompatToolCall(
|
||||
item: Record<string, unknown>,
|
||||
fallbackId: string,
|
||||
): Record<string, unknown> | null {
|
||||
const envelope = createResponsesMcpCompatEnvelope(item);
|
||||
if (!envelope) return null;
|
||||
|
||||
const id = (
|
||||
asTrimmedString(item.call_id)
|
||||
|| asTrimmedString(item.id)
|
||||
|| asTrimmedString(item.approval_request_id)
|
||||
|| fallbackId
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toResponsesMcpCompatToolName(envelope.itemType),
|
||||
arguments: JSON.stringify(envelope),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseCompatEnvelope(rawArguments: unknown): ResponsesMcpCompatEnvelope | null {
|
||||
if (typeof rawArguments !== 'string') return null;
|
||||
const trimmed = rawArguments.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (!isRecord(parsed)) return null;
|
||||
if (asTrimmedString(parsed.metapi_compat) !== RESPONSES_MCP_COMPAT_MARKER) return null;
|
||||
|
||||
const itemType = asTrimmedString(parsed.itemType).toLowerCase();
|
||||
if (!itemType.startsWith('mcp_')) return null;
|
||||
if (!isRecord(parsed.item)) return null;
|
||||
|
||||
return {
|
||||
metapi_compat: RESPONSES_MCP_COMPAT_MARKER,
|
||||
itemType,
|
||||
item: cloneJsonValue(parsed.item),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeResponsesMcpCompatToolCall(
|
||||
toolName: unknown,
|
||||
rawArguments: unknown,
|
||||
): Record<string, unknown> | null {
|
||||
const normalizedName = asTrimmedString(toolName).toLowerCase();
|
||||
if (!normalizedName.startsWith(RESPONSES_MCP_COMPAT_PREFIX)) return null;
|
||||
|
||||
const envelope = parseCompatEnvelope(rawArguments);
|
||||
if (!envelope) return null;
|
||||
return envelope.item;
|
||||
}
|
||||
@@ -254,6 +254,52 @@ describe('serializeResponsesFinalPayload', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('restores mcp items from compatibility tool calls when serializing fallback response payloads', () => {
|
||||
const mcpCall = {
|
||||
type: 'mcp_call',
|
||||
id: 'mcp_call_1',
|
||||
call_id: 'mcp_call_1',
|
||||
name: 'read_file',
|
||||
server_label: 'filesystem',
|
||||
arguments: {
|
||||
path: '/tmp/demo.txt',
|
||||
},
|
||||
};
|
||||
|
||||
const payload = serializeResponsesFinalPayload({
|
||||
upstreamPayload: {
|
||||
id: 'opaque_mcp_1',
|
||||
model: 'gpt-5',
|
||||
},
|
||||
normalized: {
|
||||
id: 'opaque_mcp_1',
|
||||
model: 'gpt-5',
|
||||
created: 1700000000,
|
||||
content: '',
|
||||
reasoningContent: '',
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'mcp_call_1',
|
||||
name: 'metapi_mcp_item__mcp_call',
|
||||
arguments: JSON.stringify({
|
||||
metapi_compat: 'responses_mcp_item',
|
||||
itemType: 'mcp_call',
|
||||
item: mcpCall,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
usage: {
|
||||
promptTokens: 1,
|
||||
completionTokens: 2,
|
||||
totalTokens: 3,
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.output).toEqual([mcpCall]);
|
||||
});
|
||||
|
||||
it('preserves response-like custom tool and image generation items when synthesizing object=response payloads', () => {
|
||||
const payload = serializeResponsesFinalPayload({
|
||||
upstreamPayload: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type NormalizedFinalResponse,
|
||||
} from '../../shared/normalized.js';
|
||||
import { decodeOpenAiEncryptedReasoning } from '../../shared/reasoningTransport.js';
|
||||
import { decodeResponsesMcpCompatToolCall } from './mcpCompatibility.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
@@ -228,6 +229,10 @@ function extractSyntheticOutputItemsFromUpstream(payload: unknown): ResponsesOut
|
||||
};
|
||||
}
|
||||
|
||||
if (itemType.startsWith('mcp_')) {
|
||||
return cloneJson(rawItem);
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is ResponsesOutputItem => item !== null);
|
||||
@@ -394,6 +399,12 @@ export function serializeResponsesFinalPayload(input: {
|
||||
|
||||
if (toolCalls.length > 0 && !hasToolLikeItem) {
|
||||
for (const toolCall of toolCalls) {
|
||||
const mcpItem = decodeResponsesMcpCompatToolCall(toolCall.name, toolCall.arguments);
|
||||
if (mcpItem) {
|
||||
output.push(mcpItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
output.push({
|
||||
id: toolCall.id,
|
||||
type: 'function_call',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createStreamTransformContext, normalizeUpstreamStreamEvent } from './chatFormatsCore.js';
|
||||
import { createStreamTransformContext, normalizeUpstreamStreamEvent, convertClaudeRequestToOpenAiBody } from './chatFormatsCore.js';
|
||||
|
||||
describe('chatFormatsCore inline think parsing', () => {
|
||||
it('tracks split think tags across stream chunks', () => {
|
||||
@@ -67,3 +67,50 @@ describe('chatFormatsCore inline think parsing', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertClaudeRequestToOpenAiBody', () => {
|
||||
it('keeps Claude tool_result content structured when a tool produces image blocks', () => {
|
||||
const payload = {
|
||||
model: 'gpt-test',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'call-image',
|
||||
name: 'ImageTool',
|
||||
input: { query: 'cat' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'call-image',
|
||||
content: [
|
||||
{ type: 'text', text: 'found 1' },
|
||||
{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'url',
|
||||
url: 'https://example.com/cat.png',
|
||||
media_type: 'image/png',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { messages } = convertClaudeRequestToOpenAiBody(payload);
|
||||
const toolMessage = messages.find((message) => message.role === 'tool');
|
||||
expect(toolMessage).toBeTruthy();
|
||||
expect(Array.isArray(toolMessage?.content)).toBe(true);
|
||||
expect(toolMessage?.content.some((part: any) => part?.type === 'image_url')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -778,7 +778,7 @@ function extractClaudeReasoningRequest(
|
||||
};
|
||||
}
|
||||
|
||||
function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
|
||||
export function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
|
||||
model: string;
|
||||
stream: boolean;
|
||||
messages: Array<Record<string, unknown>>;
|
||||
@@ -789,6 +789,42 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
|
||||
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
|
||||
const convertToolResultContent = (content: unknown): string | Array<Record<string, unknown>> | null => {
|
||||
const blocks: Array<Record<string, unknown>> = [];
|
||||
|
||||
const appendContentBlock = (item: unknown) => {
|
||||
if (isRecord(item)) {
|
||||
const block = toOpenAiContentBlockFromClaudeBlock(item);
|
||||
if (block) {
|
||||
blocks.push(block);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const text = parseClaudeMessageContent(item);
|
||||
if (text) {
|
||||
blocks.push({ type: 'text', text });
|
||||
}
|
||||
};
|
||||
|
||||
const valueToProcess = (isRecord(content) && Array.isArray(content.content))
|
||||
? (content.content as unknown[])
|
||||
: content;
|
||||
|
||||
if (Array.isArray(valueToProcess)) {
|
||||
for (const item of valueToProcess) {
|
||||
appendContentBlock(item);
|
||||
}
|
||||
} else {
|
||||
appendContentBlock(valueToProcess);
|
||||
}
|
||||
|
||||
if (blocks.length <= 0) return null;
|
||||
const collapsed = collapseOpenAiContentBlocks(blocks);
|
||||
if (!collapsed) return null;
|
||||
if (typeof collapsed === 'string' && collapsed.length === 0) return null;
|
||||
return collapsed;
|
||||
};
|
||||
|
||||
const appendMessage = (role: string, content: unknown) => {
|
||||
const text = parseClaudeMessageContent(content);
|
||||
if (!text) return;
|
||||
@@ -799,16 +835,13 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
|
||||
const toolCallId = typeof toolUseId === 'string' ? toolUseId.trim() : '';
|
||||
if (!toolCallId) return;
|
||||
|
||||
const text = (
|
||||
parseClaudeMessageContent(content)
|
||||
|| stringifyUnknownValue(content)
|
||||
).trim();
|
||||
if (!text) return;
|
||||
const contentPayload = convertToolResultContent(content);
|
||||
if (!contentPayload) return;
|
||||
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCallId,
|
||||
content: text,
|
||||
content: contentPayload,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user