feat: enhance tool call handling and serialization in chat formats

- Added support for tool calls in NormalizedFinalResponse type.
- Implemented functions to collect and serialize tool calls from OpenAI and Claude responses.
- Updated convertClaudeRequestToOpenAiBody to handle tool use and tool result messages.
- Enhanced normalizeUpstreamFinalResponse to include tool calls in the response structure.
- Introduced utility functions for safe JSON stringification and parsing.
- Improved conversion functions for OpenAI tools to Anthropic format and vice versa.
- Updated input handling in convertOpenAiBodyToMessagesBody and convertOpenAiBodyToResponsesBody to accommodate tool messages.
- Refactored serialization logic in serializeFinalResponse and buildSyntheticOpenAiChunks to include tool calls.
This commit is contained in:
ciat-777
2026-03-02 22:18:13 +08:00
parent 515d083b2c
commit 555f6e15ab
3 changed files with 1065 additions and 48 deletions
+355
View File
@@ -931,6 +931,90 @@ describe('chat proxy stream behavior', () => {
expect(targetUrl).not.toContain('/v1/messages');
});
it('preserves claude tool_use/tool_result when claude downstream is routed to openai chat endpoint', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-openai',
actualModel: 'gpt-4o-mini',
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'chatcmpl-openai-tools',
object: 'chat.completion',
created: 1_706_000_006,
model: 'gpt-4o-mini',
choices: [{
index: 0,
message: { role: 'assistant', content: 'tool payload received' },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/messages',
payload: {
model: 'gpt-4o-mini',
max_tokens: 256,
messages: [
{
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'toolu_abc',
name: 'Glob',
input: { pattern: 'README*' },
},
],
},
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'toolu_abc',
content: [{ type: 'text', text: '{\"matches\":1}' }],
},
{
type: 'text',
text: 'continue',
},
],
},
],
},
});
expect(response.statusCode).toBe(200);
const [_targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
const forwardedBody = JSON.parse(options.body);
const forwardedMessages = Array.isArray(forwardedBody.messages) ? forwardedBody.messages : [];
const assistantWithToolCalls = forwardedMessages.find((item: any) =>
item?.role === 'assistant'
&& Array.isArray(item?.tool_calls)
&& item.tool_calls.length > 0,
);
expect(assistantWithToolCalls).toBeTruthy();
expect(assistantWithToolCalls.tool_calls[0].id).toBe('toolu_abc');
expect(assistantWithToolCalls.tool_calls[0].function?.name).toBe('Glob');
expect(assistantWithToolCalls.tool_calls[0].function?.arguments).toContain('README*');
const toolMessage = forwardedMessages.find((item: any) => item?.role === 'tool');
expect(toolMessage).toBeTruthy();
expect(toolMessage.tool_call_id).toBe('toolu_abc');
expect(toolMessage.content).toContain('matches');
});
it('forces claude platform to use /v1/messages with x-api-key auth for openai downstream requests', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
@@ -972,6 +1056,277 @@ describe('chat proxy stream behavior', () => {
expect(options.headers.Authorization).toBeUndefined();
});
it('preserves openai tool context when /v1/chat/completions is routed to /v1/messages upstream', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'claude-site', url: 'https://api.anthropic.com', platform: 'claude' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-claude',
actualModel: 'claude-sonnet-4-5-20250929',
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'msg_claude_tool_context',
type: 'message',
model: 'claude-sonnet-4-5-20250929',
content: [{ type: 'text', text: 'ok' }],
stop_reason: 'end_turn',
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'claude-sonnet-4-5-20250929',
stream: false,
tools: [{
type: 'function',
function: {
name: 'Glob',
description: 'Search files',
parameters: {
type: 'object',
properties: {
pattern: { type: 'string' },
},
required: ['pattern'],
},
},
}],
tool_choice: {
type: 'function',
function: {
name: 'Glob',
},
},
messages: [
{
role: 'assistant',
tool_calls: [{
id: 'call_abc',
type: 'function',
function: {
name: 'Glob',
arguments: '{"pattern":"README*"}',
},
}],
},
{
role: 'tool',
tool_call_id: 'call_abc',
content: '{"matches":1}',
},
{
role: 'user',
content: 'continue',
},
],
},
});
expect(response.statusCode).toBe(200);
const [_targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
const forwardedBody = JSON.parse(options.body);
expect(Array.isArray(forwardedBody.messages)).toBe(true);
const assistantMessage = forwardedBody.messages.find((item: any) => item?.role === 'assistant');
expect(Array.isArray(assistantMessage?.content)).toBe(true);
expect(assistantMessage.content.some((part: any) => part?.type === 'tool_use')).toBe(true);
const userToolResultMessage = forwardedBody.messages.find((item: any) =>
item?.role === 'user'
&& Array.isArray(item?.content)
&& item.content.some((part: any) => part?.type === 'tool_result'),
);
expect(userToolResultMessage).toBeTruthy();
expect(userToolResultMessage.content[0].tool_use_id).toBe('call_abc');
expect(forwardedBody.tools?.[0]?.name).toBe('Glob');
expect(forwardedBody.tools?.[0]?.input_schema?.properties?.pattern?.type).toBe('string');
expect(forwardedBody.tool_choice).toEqual({ type: 'tool', name: 'Glob' });
});
it('converts /v1/responses function_call SSE to OpenAI tool_calls on /v1/chat/completions', async () => {
fetchModelPricingCatalogMock.mockResolvedValue({
models: [
{
modelName: 'upstream-gpt',
supportedEndpointTypes: ['/v1/responses'],
},
],
groupRatio: {},
});
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('event: response.created\ndata: {"type":"response.created","response":{"id":"resp_123","model":"upstream-gpt","created_at":1706000000,"status":"in_progress","output":[]}}\n\n'));
controller.enqueue(encoder.encode('event: response.output_item.added\ndata: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_abc","name":"Glob"}}\n\n'));
controller.enqueue(encoder.encode('event: response.function_call_arguments.delta\ndata: {"type":"response.function_call_arguments.delta","output_index":0,"call_id":"call_abc","delta":"{\\"pattern\\":\\"README*\\"}"}\n\n'));
controller.enqueue(encoder.encode('event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_123","model":"upstream-gpt","status":"completed","usage":{"input_tokens":5,"output_tokens":3,"total_tokens":8}}}\n\n'));
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
controller.close();
},
});
fetchMock.mockResolvedValue(new Response(upstreamBody, {
status: 200,
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'claude-haiku-4-5-20251001',
stream: true,
messages: [{ role: 'user', content: 'find readme' }],
},
});
expect(response.statusCode).toBe(200);
expect(response.body).toContain('"tool_calls"');
expect(response.body).toContain('"id":"call_abc"');
expect(response.body).toContain('"name":"Glob"');
expect(response.body).toContain('\\"pattern\\":\\"README*\\"');
});
it('preserves non-stream function_call output when /v1/chat/completions falls back to /v1/responses', async () => {
fetchModelPricingCatalogMock.mockResolvedValue({
models: [
{
modelName: 'upstream-gpt',
supportedEndpointTypes: ['/v1/responses'],
},
],
groupRatio: {},
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'resp_tool_nonstream',
object: 'response',
model: 'upstream-gpt',
status: 'completed',
output: [{
type: 'function_call',
id: 'fc_1',
call_id: 'call_abc',
name: 'Glob',
arguments: '{"pattern":"README*"}',
}],
output_text: '',
usage: { input_tokens: 3, output_tokens: 2, total_tokens: 5 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'claude-haiku-4-5-20251001',
stream: false,
messages: [{ role: 'user', content: 'find readme' }],
},
});
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body?.choices?.[0]?.message?.tool_calls?.[0]?.id).toBe('call_abc');
expect(body?.choices?.[0]?.message?.tool_calls?.[0]?.function?.name).toBe('Glob');
expect(body?.choices?.[0]?.message?.tool_calls?.[0]?.function?.arguments).toContain('README*');
expect(body?.choices?.[0]?.finish_reason).toBe('tool_calls');
});
it('preserves openai tool context when /v1/chat/completions is routed to /v1/responses upstream', async () => {
fetchModelPricingCatalogMock.mockResolvedValue({
models: [
{
modelName: 'upstream-gpt',
supportedEndpointTypes: ['/v1/responses'],
},
],
groupRatio: {},
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'resp_tool_forward',
object: 'response',
model: 'upstream-gpt',
status: 'completed',
output: [],
output_text: 'ok',
usage: { input_tokens: 3, output_tokens: 2, total_tokens: 5 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'claude-haiku-4-5-20251001',
stream: false,
tools: [{
type: 'function',
function: {
name: 'Glob',
parameters: {
type: 'object',
properties: { pattern: { type: 'string' } },
required: ['pattern'],
},
},
}],
tool_choice: {
type: 'function',
function: { name: 'Glob' },
},
messages: [
{
role: 'assistant',
tool_calls: [{
id: 'call_abc',
type: 'function',
function: {
name: 'Glob',
arguments: '{"pattern":"README*"}',
},
}],
},
{
role: 'tool',
tool_call_id: 'call_abc',
content: '{"matches":1}',
},
{
role: 'user',
content: 'continue',
},
],
},
});
expect(response.statusCode).toBe(200);
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
expect(targetUrl).toContain('/v1/responses');
const forwardedBody = JSON.parse(options.body);
expect(Array.isArray(forwardedBody.input)).toBe(true);
expect(forwardedBody.input.some((item: any) => item?.type === 'function_call')).toBe(true);
expect(forwardedBody.input.some((item: any) => item?.type === 'function_call_output')).toBe(true);
expect(forwardedBody.tools?.[0]?.name).toBe('Glob');
expect(forwardedBody.tool_choice).toEqual({ type: 'function', name: 'Glob' });
});
it('routes gemini platform to OpenAI-compatible upstream endpoint path', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
+368 -22
View File
@@ -49,6 +49,11 @@ export type NormalizedFinalResponse = {
content: string;
reasoningContent: string;
finishReason: string;
toolCalls: Array<{
id: string;
name: string;
arguments: string;
}>;
};
export type ParsedDownstreamChatRequest = {
@@ -282,16 +287,132 @@ function parseResponsesOutputText(payload: Record<string, unknown>): string {
return parts.join('\n\n');
}
function stringifyUnknownValue(value: unknown): string {
if (typeof value === 'string') return value;
if (value === undefined || value === null) return '';
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (Array.isArray(value) || isRecord(value)) {
try {
return JSON.stringify(value);
} catch {
return '';
}
}
return '';
}
function parseJsonLike(value: string): unknown {
const trimmed = value.trim();
if (!trimmed) return {};
try {
return JSON.parse(trimmed);
} catch {
return { value };
}
}
function collectToolCallsFromOpenAiChoice(choice: any): Array<{ id: string; name: string; arguments: string }> {
const message = isRecord(choice?.message) ? choice.message : {};
const rawToolCalls = Array.isArray((message as any).tool_calls)
? (message as any).tool_calls
: (Array.isArray(choice?.tool_calls) ? choice.tool_calls : []);
const toolCalls: Array<{ id: string; name: string; arguments: string }> = [];
for (let index = 0; index < rawToolCalls.length; index += 1) {
const rawToolCall = rawToolCalls[index];
if (!isRecord(rawToolCall)) continue;
const fn = isRecord(rawToolCall.function) ? rawToolCall.function : {};
const id = (
typeof rawToolCall.id === 'string' && rawToolCall.id.trim().length > 0
? rawToolCall.id.trim()
: `call_${index}`
);
const name = (
typeof fn.name === 'string' && fn.name.trim().length > 0
? fn.name.trim()
: (typeof rawToolCall.name === 'string' ? rawToolCall.name.trim() : '')
);
const argumentsText = (
typeof fn.arguments === 'string'
? fn.arguments
: stringifyUnknownValue(fn.arguments ?? rawToolCall.arguments)
);
toolCalls.push({
id,
name,
arguments: argumentsText,
});
}
return toolCalls;
}
function collectToolCallsFromClaudeContent(content: unknown): Array<{ id: string; name: string; arguments: string }> {
const contentItems = Array.isArray(content) ? content : [];
const toolCalls: Array<{ id: string; name: string; arguments: string }> = [];
for (let index = 0; index < contentItems.length; index += 1) {
const block = contentItems[index];
if (!isRecord(block)) continue;
if (block.type !== 'tool_use') continue;
const id = (
typeof block.id === 'string' && block.id.trim().length > 0
? block.id.trim()
: `toolu_${index}`
);
const name = typeof block.name === 'string' ? block.name.trim() : '';
const argumentsText = stringifyUnknownValue(block.input);
toolCalls.push({
id,
name,
arguments: argumentsText,
});
}
return toolCalls;
}
function collectToolCallsFromResponsesPayload(payload: Record<string, unknown>): Array<{ id: string; name: string; arguments: string }> {
const output = Array.isArray(payload.output) ? payload.output : [];
const toolCalls: Array<{ id: string; name: string; arguments: string }> = [];
for (let index = 0; index < output.length; index += 1) {
const item = output[index];
if (!isRecord(item)) continue;
if (item.type !== 'function_call') continue;
const id = (
typeof item.call_id === 'string' && item.call_id.trim().length > 0
? item.call_id.trim()
: (typeof item.id === 'string' && item.id.trim().length > 0 ? item.id.trim() : `call_${index}`)
);
const name = typeof item.name === 'string' ? item.name.trim() : '';
const argumentsText = (
typeof item.arguments === 'string'
? item.arguments
: stringifyUnknownValue(item.arguments)
);
toolCalls.push({
id,
name,
arguments: argumentsText,
});
}
return toolCalls;
}
function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
model: string;
stream: boolean;
messages: Array<{ role: string; content: string }>;
messages: Array<Record<string, unknown>>;
payload: Record<string, unknown>;
} {
const model = typeof body.model === 'string' ? body.model.trim() : '';
const stream = body.stream === true;
const messages: Array<{ role: string; content: string }> = [];
const messages: Array<Record<string, unknown>> = [];
const appendMessage = (role: string, content: unknown) => {
const text = parseClaudeMessageContent(content);
@@ -299,6 +420,23 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
messages.push({ role, content: text });
};
const appendToolResultMessage = (toolUseId: unknown, content: unknown) => {
const toolCallId = typeof toolUseId === 'string' ? toolUseId.trim() : '';
if (!toolCallId) return;
const text = (
parseClaudeMessageContent(content)
|| stringifyUnknownValue(content)
).trim();
if (!text) return;
messages.push({
role: 'tool',
tool_call_id: toolCallId,
content: text,
});
};
const system = body.system;
if (typeof system === 'string') {
appendMessage('system', system);
@@ -312,7 +450,78 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
if (!isRecord(message)) continue;
const role = typeof message.role === 'string' ? message.role : 'user';
const mappedRole = role === 'assistant' || role === 'system' ? role : 'user';
appendMessage(mappedRole, message.content);
const content = message.content;
if (!Array.isArray(content)) {
appendMessage(mappedRole, content);
continue;
}
// Claude tool blocks need explicit OpenAI mapping:
// - assistant.tool_use -> assistant.tool_calls
// - user.tool_result -> tool messages with tool_call_id
const textParts: string[] = [];
const toolCalls: Array<Record<string, unknown>> = [];
const flushTextAsMessage = () => {
const merged = textParts.map((item) => item.trim()).filter(Boolean).join('\n\n');
textParts.length = 0;
if (!merged) return;
messages.push({ role: mappedRole, content: merged });
};
for (const block of content) {
if (!isRecord(block)) continue;
const blockType = typeof block.type === 'string' ? block.type : '';
if (blockType === 'tool_result') {
flushTextAsMessage();
appendToolResultMessage(block.tool_use_id, block.content);
continue;
}
if (mappedRole === 'assistant' && blockType === 'tool_use') {
const id = typeof block.id === 'string' && block.id.trim().length > 0
? block.id.trim()
: `call_${toolCalls.length}`;
const name = typeof block.name === 'string' ? block.name.trim() : '';
const rawInput = block.input;
const args = (
typeof rawInput === 'string'
? rawInput
: stringifyUnknownValue(rawInput) || '{}'
);
const fn: Record<string, unknown> = { arguments: args };
if (name) fn.name = name;
toolCalls.push({
id,
type: 'function',
function: fn,
});
continue;
}
const text = parseClaudeMessageContent(block);
if (text) textParts.push(text);
}
const merged = textParts.map((item) => item.trim()).filter(Boolean).join('\n\n');
if (toolCalls.length > 0) {
const assistantMessage: Record<string, unknown> = {
role: 'assistant',
tool_calls: toolCalls,
};
// Keep textual assistant preface when present.
assistantMessage.content = merged || '';
messages.push(assistantMessage);
} else if (merged) {
messages.push({
role: mappedRole,
content: merged,
});
}
}
const payload: Record<string, unknown> = {
@@ -424,35 +633,45 @@ export function normalizeUpstreamFinalResponse(
const choice = payload.choices[0] ?? {};
const content = extractAssistantContent(choice) || extractAssistantContent(payload);
const reasoning = extractAssistantReasoning(choice) || extractAssistantReasoning(payload);
const toolCalls = collectToolCallsFromOpenAiChoice(choice);
return {
id: isNonEmptyString(payload.id) ? payload.id : fallbackId,
model: isNonEmptyString(payload.model) ? payload.model : fallbackModel,
created: ensureIntegerTimestamp(payload.created, now),
content: content || fallbackText,
content: content || (toolCalls.length > 0 ? '' : fallbackText),
reasoningContent: reasoning,
finishReason: normalizeStopReason(choice?.finish_reason ?? payload.stop_reason) || 'stop',
finishReason: toolCalls.length > 0
? 'tool_calls'
: (normalizeStopReason(choice?.finish_reason ?? payload.stop_reason) || 'stop'),
toolCalls,
};
}
if (isRecord(payload) && typeof payload.type === 'string' && payload.type === 'message') {
const toolCalls = collectToolCallsFromClaudeContent(payload.content);
return {
id: isNonEmptyString(payload.id) ? payload.id : fallbackId,
model: isNonEmptyString(payload.model) ? payload.model : fallbackModel,
created: now,
content: parseClaudeMessageContent(payload.content) || fallbackText,
content: parseClaudeMessageContent(payload.content) || (toolCalls.length > 0 ? '' : fallbackText),
reasoningContent: extractTextAndReasoning(payload.content).reasoning,
finishReason: normalizeStopReason(payload.stop_reason) || 'stop',
finishReason: toolCalls.length > 0 ? 'tool_calls' : (normalizeStopReason(payload.stop_reason) || 'stop'),
toolCalls,
};
}
if (isRecord(payload) && ((payload as any).object === 'response' || Array.isArray((payload as any).output))) {
const toolCalls = collectToolCallsFromResponsesPayload(payload);
return {
id: isNonEmptyString(payload.id) ? payload.id : fallbackId,
model: isNonEmptyString(payload.model) ? payload.model : fallbackModel,
created: ensureIntegerTimestamp(payload.created, now),
content: parseResponsesOutputText(payload) || fallbackText,
content: parseResponsesOutputText(payload) || (toolCalls.length > 0 ? '' : fallbackText),
reasoningContent: '',
finishReason: normalizeStopReason(payload.finish_reason ?? payload.status) || 'stop',
finishReason: toolCalls.length > 0
? 'tool_calls'
: (normalizeStopReason(payload.finish_reason ?? payload.status) || 'stop'),
toolCalls,
};
}
@@ -468,6 +687,7 @@ export function normalizeUpstreamFinalResponse(
content: parsedCandidate.content || fallbackText,
reasoningContent: parsedCandidate.reasoning,
finishReason: normalizeStopReason(candidate?.finishReason || (payload as any).finishReason) || 'stop',
toolCalls: [],
};
}
@@ -479,6 +699,7 @@ export function normalizeUpstreamFinalResponse(
content: payload,
reasoningContent: '',
finishReason: 'stop',
toolCalls: [],
};
}
@@ -489,6 +710,7 @@ export function normalizeUpstreamFinalResponse(
content: fallbackText,
reasoningContent: '',
finishReason: 'stop',
toolCalls: [],
};
}
@@ -570,6 +792,64 @@ export function normalizeUpstreamStreamEvent(
};
}
if (type === 'response.reasoning_summary_text.delta') {
const deltaText = typeof payload.delta === 'string'
? payload.delta
: extractTextAndReasoning(payload.delta).content;
return {
reasoningDelta: deltaText || undefined,
};
}
if (type === 'response.output_item.added' && isRecord((payload as any).item)) {
const item = (payload as any).item as Record<string, unknown>;
if (item.type === 'function_call') {
const outputIndex = (
typeof (payload as any).output_index === 'number' && Number.isFinite((payload as any).output_index)
? Math.max(0, Math.trunc((payload as any).output_index))
: 0
);
const toolCallId = (
isNonEmptyString(item.call_id) ? item.call_id
: (isNonEmptyString(item.id) ? item.id : undefined)
);
const toolName = isNonEmptyString(item.name) ? item.name : undefined;
return {
toolCallDeltas: [{
index: outputIndex,
id: toolCallId,
name: toolName,
}],
};
}
}
if (type === 'response.function_call_arguments.delta' || type === 'response.function_call_arguments.done') {
const outputIndex = (
typeof (payload as any).output_index === 'number' && Number.isFinite((payload as any).output_index)
? Math.max(0, Math.trunc((payload as any).output_index))
: 0
);
const toolCallId = (
isNonEmptyString((payload as any).call_id) ? (payload as any).call_id
: (isNonEmptyString((payload as any).item_id) ? (payload as any).item_id : undefined)
);
const toolName = isNonEmptyString((payload as any).name) ? (payload as any).name : undefined;
const argumentsDelta = (
typeof payload.delta === 'string'
? payload.delta
: (typeof (payload as any).arguments === 'string' ? (payload as any).arguments : undefined)
);
return {
toolCallDeltas: [{
index: outputIndex,
id: toolCallId,
name: toolName,
argumentsDelta,
}],
};
}
if (type === 'response.completed' && isRecord((payload as any).response)) {
const responsePayload = (payload as any).response as Record<string, unknown>;
if (isNonEmptyString(responsePayload.id)) context.id = responsePayload.id;
@@ -1020,21 +1300,61 @@ export function serializeStreamDone(
return buildClaudeDoneEvents(context, claudeContext, 'stop');
}
function toOpenAiToolCalls(
toolCalls: Array<{ id: string; name: string; arguments: string }>,
): Array<Record<string, unknown>> {
return toolCalls.map((toolCall, index) => ({
index,
id: toolCall.id || `call_${index}`,
type: 'function',
function: {
name: toolCall.name || '',
arguments: toolCall.arguments || '',
},
}));
}
export function serializeFinalResponse(
downstreamFormat: DownstreamFormat,
normalized: NormalizedFinalResponse,
usage: { promptTokens: number; completionTokens: number; totalTokens: number },
): Record<string, unknown> {
const toolCalls = Array.isArray(normalized.toolCalls) ? normalized.toolCalls : [];
if (downstreamFormat === 'claude') {
const contentBlocks: Array<Record<string, unknown>> = [];
if (normalized.reasoningContent) {
contentBlocks.push({
type: 'thinking',
thinking: normalized.reasoningContent,
});
}
if (normalized.content) {
contentBlocks.push({
type: 'text',
text: normalized.content,
});
}
for (let index = 0; index < toolCalls.length; index += 1) {
const toolCall = toolCalls[index];
contentBlocks.push({
type: 'tool_use',
id: toolCall.id || `toolu_${index}`,
name: toolCall.name || `tool_${index}`,
input: parseJsonLike(toolCall.arguments || ''),
});
}
const content = contentBlocks.length > 0
? contentBlocks
: [{ type: 'text', text: '' }];
return {
id: buildClaudeMessageId(normalized.id),
type: 'message',
role: 'assistant',
model: normalized.model,
content: [{
type: 'text',
text: normalized.content,
}],
content,
stop_reason: toClaudeStopReason(normalized.finishReason),
stop_sequence: null,
usage: {
@@ -1051,6 +1371,16 @@ export function serializeFinalResponse(
if (normalized.reasoningContent) {
message.reasoning_content = normalized.reasoningContent;
}
if (toolCalls.length > 0) {
message.tool_calls = toOpenAiToolCalls(toolCalls);
if (!normalized.content) message.content = '';
}
const finishReason = (
toolCalls.length > 0
? 'tool_calls'
: (normalizeStopReason(normalized.finishReason) || 'stop')
);
return {
id: normalized.id,
@@ -1060,7 +1390,7 @@ export function serializeFinalResponse(
choices: [{
index: 0,
message,
finish_reason: normalizeStopReason(normalized.finishReason) || 'stop',
finish_reason: finishReason,
}],
usage: {
prompt_tokens: usage.promptTokens,
@@ -1071,6 +1401,28 @@ export function serializeFinalResponse(
}
export function buildSyntheticOpenAiChunks(normalized: NormalizedFinalResponse): Array<Record<string, unknown>> {
const toolCalls = Array.isArray(normalized.toolCalls) ? normalized.toolCalls : [];
const finishReason = (
toolCalls.length > 0
? 'tool_calls'
: (normalizeStopReason(normalized.finishReason) || 'stop')
);
const startDelta: Record<string, unknown> = {
role: 'assistant',
};
if (normalized.content) {
startDelta.content = normalized.content;
} else {
startDelta.content = '';
}
if (normalized.reasoningContent) {
startDelta.reasoning_content = normalized.reasoningContent;
}
if (toolCalls.length > 0) {
startDelta.tool_calls = toOpenAiToolCalls(toolCalls);
}
const startChunk: Record<string, unknown> = {
id: normalized.id,
object: 'chat.completion.chunk',
@@ -1078,17 +1430,11 @@ export function buildSyntheticOpenAiChunks(normalized: NormalizedFinalResponse):
model: normalized.model,
choices: [{
index: 0,
delta: normalized.content
? { role: 'assistant', content: normalized.content }
: { role: 'assistant' },
delta: startDelta,
finish_reason: null,
}],
};
if (normalized.reasoningContent) {
(startChunk.choices as any[])[0].delta.reasoning_content = normalized.reasoningContent;
}
const endChunk = {
id: normalized.id,
object: 'chat.completion.chunk',
@@ -1097,7 +1443,7 @@ export function buildSyntheticOpenAiChunks(normalized: NormalizedFinalResponse):
choices: [{
index: 0,
delta: {},
finish_reason: normalizeStopReason(normalized.finishReason) || 'stop',
finish_reason: finishReason,
}],
};
+342 -26
View File
@@ -216,6 +216,166 @@ function normalizeContentText(content: unknown): string {
return '';
}
function safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value);
} catch {
return '';
}
}
function parseJsonString(raw: string): unknown {
const trimmed = raw.trim();
if (!trimmed) return {};
try {
return JSON.parse(trimmed);
} catch {
return { value: raw };
}
}
function normalizeOpenAiToolArguments(raw: unknown): string {
if (typeof raw === 'string') return raw;
if (raw === undefined || raw === null) return '';
if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw);
if (Array.isArray(raw) || isRecord(raw)) {
return safeJsonStringify(raw);
}
return '';
}
function normalizeAnthropicToolInput(raw: unknown): unknown {
if (raw === undefined || raw === null) return {};
if (isRecord(raw) || Array.isArray(raw)) return raw;
if (typeof raw === 'string') return parseJsonString(raw);
if (typeof raw === 'number' || typeof raw === 'boolean') return raw;
return {};
}
function normalizeToolMessageContent(raw: unknown): string {
const contentText = normalizeContentText(raw).trim();
if (contentText) return contentText;
if (raw === undefined || raw === null) return '';
if (typeof raw === 'string') return raw;
if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw);
if (Array.isArray(raw) || isRecord(raw)) return safeJsonStringify(raw);
return '';
}
function convertOpenAiToolsToAnthropic(rawTools: unknown): unknown {
if (!Array.isArray(rawTools)) return rawTools;
const converted = rawTools
.map((item) => {
if (!isRecord(item)) return null;
const type = asTrimmedString(item.type).toLowerCase();
if (type === 'function' && isRecord(item.function)) {
const fn = item.function;
const name = asTrimmedString(fn.name);
if (!name) return null;
const mapped: Record<string, unknown> = { name };
const description = asTrimmedString(fn.description);
if (description) mapped.description = description;
if (fn.parameters !== undefined) mapped.input_schema = fn.parameters;
if (item.cache_control !== undefined) mapped.cache_control = item.cache_control;
return mapped;
}
if (asTrimmedString(item.name) && item.input_schema !== undefined) {
return item;
}
return null;
})
.filter((item): item is Record<string, unknown> => !!item);
return converted.length > 0 ? converted : rawTools;
}
function convertOpenAiToolChoiceToAnthropic(rawToolChoice: unknown): unknown {
if (rawToolChoice === undefined) return undefined;
if (typeof rawToolChoice === 'string') {
const normalized = rawToolChoice.trim().toLowerCase();
if (normalized === 'required') return { type: 'any' };
if (normalized === 'none') return { type: 'none' };
if (normalized === 'auto') return { type: 'auto' };
if (normalized === 'any') return { type: 'any' };
return rawToolChoice;
}
if (!isRecord(rawToolChoice)) return rawToolChoice;
const type = asTrimmedString(rawToolChoice.type).toLowerCase();
if (type === 'function' && isRecord(rawToolChoice.function)) {
const name = asTrimmedString(rawToolChoice.function.name);
return name ? { type: 'tool', name } : { type: 'any' };
}
if (type === 'tool' || type === 'auto' || type === 'any' || type === 'none') {
return rawToolChoice;
}
return rawToolChoice;
}
function convertOpenAiToolsToResponses(rawTools: unknown): unknown {
if (!Array.isArray(rawTools)) return rawTools;
const converted = rawTools
.map((item) => {
if (!isRecord(item)) return null;
const type = asTrimmedString(item.type).toLowerCase();
if (type === 'function' && isRecord(item.function)) {
const fn = item.function;
const name = asTrimmedString(fn.name);
if (!name) return null;
const mapped: Record<string, unknown> = {
type: 'function',
name,
};
const description = asTrimmedString(fn.description);
if (description) mapped.description = description;
if (fn.parameters !== undefined) mapped.parameters = fn.parameters;
if (fn.strict !== undefined) mapped.strict = fn.strict;
return mapped;
}
if (type === 'function' && asTrimmedString(item.name)) {
return item;
}
if (type === 'image_generation') {
return item;
}
return null;
})
.filter((item): item is Record<string, unknown> => !!item);
return converted.length > 0 ? converted : rawTools;
}
function convertOpenAiToolChoiceToResponses(rawToolChoice: unknown): unknown {
if (rawToolChoice === undefined) return undefined;
if (typeof rawToolChoice === 'string') return rawToolChoice;
if (!isRecord(rawToolChoice)) return rawToolChoice;
const type = asTrimmedString(rawToolChoice.type).toLowerCase();
if (type === 'function' && isRecord(rawToolChoice.function)) {
const name = asTrimmedString(rawToolChoice.function.name);
if (!name) return 'required';
return { type: 'function', name };
}
return rawToolChoice;
}
function convertOpenAiBodyToMessagesBody(
openaiBody: Record<string, unknown>,
modelName: string,
@@ -223,21 +383,98 @@ function convertOpenAiBodyToMessagesBody(
): Record<string, unknown> {
const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : [];
const systemContents: string[] = [];
const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [];
const messages: Array<{
role: 'user' | 'assistant';
content: string | Array<Record<string, unknown>>;
}> = [];
for (const item of rawMessages) {
if (!isRecord(item)) continue;
const role = asTrimmedString(item.role) || 'user';
const content = normalizeContentText(item.content);
if (!content) continue;
const role = asTrimmedString(item.role).toLowerCase() || 'user';
if (role === 'system') {
if (role === 'system' || role === 'developer') {
const content = normalizeContentText(item.content);
if (!content) continue;
systemContents.push(content);
continue;
}
if (role === 'tool') {
const toolUseId = asTrimmedString(item.tool_call_id) || asTrimmedString(item.id);
if (!toolUseId) continue;
const toolResultContent = normalizeToolMessageContent(item.content).trim();
if (!toolResultContent) continue;
messages.push({
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: toolUseId,
content: toolResultContent,
}],
});
continue;
}
if (role === 'assistant') {
const contentBlocks: Array<Record<string, unknown>> = [];
const textContent = normalizeContentText(item.content).trim();
if (textContent) {
contentBlocks.push({
type: 'text',
text: textContent,
});
}
const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : [];
for (let index = 0; index < rawToolCalls.length; index += 1) {
const toolCall = rawToolCalls[index];
if (!isRecord(toolCall)) continue;
const functionPart = isRecord(toolCall.function) ? toolCall.function : {};
const id = asTrimmedString(toolCall.id) || `toolu_${Date.now()}_${index}`;
const name = (
asTrimmedString(functionPart.name)
|| asTrimmedString(toolCall.name)
|| `tool_${index}`
);
const input = normalizeAnthropicToolInput(functionPart.arguments ?? toolCall.arguments);
contentBlocks.push({
type: 'tool_use',
id,
name,
input,
});
}
if (contentBlocks.length <= 0) continue;
if (contentBlocks.length === 1 && contentBlocks[0].type === 'text') {
const singleText = asTrimmedString(contentBlocks[0].text);
if (singleText) {
messages.push({
role: 'assistant',
content: singleText,
});
}
continue;
}
messages.push({
role: 'assistant',
content: contentBlocks,
});
continue;
}
const content = normalizeContentText(item.content).trim();
if (!content) continue;
messages.push({
role: role === 'assistant' ? 'assistant' : 'user',
role: 'user',
content,
});
}
@@ -263,8 +500,11 @@ function convertOpenAiBodyToMessagesBody(
body.stop_sequences = openaiBody.stop;
}
if (openaiBody.tools !== undefined) body.tools = openaiBody.tools;
if (openaiBody.tool_choice !== undefined) body.tool_choice = openaiBody.tool_choice;
if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToAnthropic(openaiBody.tools);
const anthropicToolChoice = convertOpenAiToolChoiceToAnthropic(openaiBody.tool_choice);
if (anthropicToolChoice !== undefined) body.tool_choice = anthropicToolChoice;
return body;
}
@@ -275,38 +515,107 @@ function convertOpenAiBodyToResponsesBody(
): Record<string, unknown> {
const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : [];
const systemContents: string[] = [];
const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [];
const inputItems: Array<Record<string, unknown>> = [];
for (const item of rawMessages) {
if (!isRecord(item)) continue;
const role = asTrimmedString(item.role) || 'user';
const content = normalizeContentText(item.content);
if (!content) continue;
const role = asTrimmedString(item.role).toLowerCase() || 'user';
if (role === 'system') {
if (role === 'system' || role === 'developer') {
const content = normalizeContentText(item.content).trim();
if (!content) continue;
systemContents.push(content);
continue;
}
messages.push({
role: role === 'assistant' ? 'assistant' : 'user',
content,
if (role === 'assistant') {
const textContent = normalizeContentText(item.content).trim();
if (textContent) {
inputItems.push({
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: textContent }],
});
}
const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : [];
for (let index = 0; index < rawToolCalls.length; index += 1) {
const toolCall = rawToolCalls[index];
if (!isRecord(toolCall)) continue;
const functionPart = isRecord(toolCall.function) ? toolCall.function : {};
const callId = asTrimmedString(toolCall.id) || `call_${Date.now()}_${index}`;
const name = (
asTrimmedString(functionPart.name)
|| asTrimmedString(toolCall.name)
|| `tool_${index}`
);
const argumentsValue = normalizeOpenAiToolArguments(
functionPart.arguments ?? toolCall.arguments,
);
inputItems.push({
type: 'function_call',
call_id: callId,
name,
arguments: argumentsValue,
});
}
continue;
}
if (role === 'tool') {
const callId = asTrimmedString(item.tool_call_id) || asTrimmedString(item.id);
if (!callId) continue;
inputItems.push({
type: 'function_call_output',
call_id: callId,
output: normalizeToolMessageContent(item.content),
});
continue;
}
const content = normalizeContentText(item.content).trim();
if (!content) continue;
inputItems.push({
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: content }],
});
}
let input: unknown = inputItems;
if (systemContents.length === 0 && inputItems.length === 1) {
const first = inputItems[0];
const firstRole = asTrimmedString(first.role).toLowerCase();
const firstContent = Array.isArray(first.content) ? first.content : [];
if (
firstRole === 'user'
&& firstContent.length === 1
&& isRecord(firstContent[0])
&& asTrimmedString(firstContent[0].type).toLowerCase() === 'input_text'
&& asTrimmedString(firstContent[0].text)
) {
input = asTrimmedString(firstContent[0].text);
}
}
const maxOutputTokens = (
toFiniteNumber(openaiBody.max_output_tokens)
?? toFiniteNumber(openaiBody.max_completion_tokens)
?? toFiniteNumber(openaiBody.max_tokens)
?? 4096
);
const body: Record<string, unknown> = {
model: modelName,
stream,
max_output_tokens: toFiniteNumber(openaiBody.max_tokens) ?? 4096,
max_output_tokens: maxOutputTokens,
input,
};
if (messages.length === 1 && messages[0].role === 'user' && systemContents.length === 0) {
body.input = messages[0].content;
} else {
body.input = messages;
if (systemContents.length > 0) {
body.instructions = systemContents.join('\n\n');
}
if (systemContents.length > 0) {
body.instructions = systemContents.join('\n\n');
}
const temperature = toFiniteNumber(openaiBody.temperature);
@@ -315,8 +624,15 @@ function convertOpenAiBodyToResponsesBody(
const topP = toFiniteNumber(openaiBody.top_p);
if (topP !== null) body.top_p = topP;
if (openaiBody.tools !== undefined) body.tools = openaiBody.tools;
if (openaiBody.tool_choice !== undefined) body.tool_choice = openaiBody.tool_choice;
if (openaiBody.metadata !== undefined) body.metadata = openaiBody.metadata;
if (openaiBody.reasoning !== undefined) body.reasoning = openaiBody.reasoning;
if (openaiBody.parallel_tool_calls !== undefined) body.parallel_tool_calls = openaiBody.parallel_tool_calls;
if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToResponses(openaiBody.tools);
const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice);
if (responsesToolChoice !== undefined) body.tool_choice = responsesToolChoice;
return body;
}