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:
@@ -931,6 +931,90 @@ describe('chat proxy stream behavior', () => {
|
|||||||
expect(targetUrl).not.toContain('/v1/messages');
|
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 () => {
|
it('forces claude platform to use /v1/messages with x-api-key auth for openai downstream requests', async () => {
|
||||||
selectChannelMock.mockReturnValue({
|
selectChannelMock.mockReturnValue({
|
||||||
channel: { id: 11, routeId: 22 },
|
channel: { id: 11, routeId: 22 },
|
||||||
@@ -972,6 +1056,277 @@ describe('chat proxy stream behavior', () => {
|
|||||||
expect(options.headers.Authorization).toBeUndefined();
|
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 () => {
|
it('routes gemini platform to OpenAI-compatible upstream endpoint path', async () => {
|
||||||
selectChannelMock.mockReturnValue({
|
selectChannelMock.mockReturnValue({
|
||||||
channel: { id: 11, routeId: 22 },
|
channel: { id: 11, routeId: 22 },
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ export type NormalizedFinalResponse = {
|
|||||||
content: string;
|
content: string;
|
||||||
reasoningContent: string;
|
reasoningContent: string;
|
||||||
finishReason: string;
|
finishReason: string;
|
||||||
|
toolCalls: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
arguments: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ParsedDownstreamChatRequest = {
|
export type ParsedDownstreamChatRequest = {
|
||||||
@@ -282,16 +287,132 @@ function parseResponsesOutputText(payload: Record<string, unknown>): string {
|
|||||||
return parts.join('\n\n');
|
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>): {
|
function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
|
||||||
model: string;
|
model: string;
|
||||||
stream: boolean;
|
stream: boolean;
|
||||||
messages: Array<{ role: string; content: string }>;
|
messages: Array<Record<string, unknown>>;
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
} {
|
} {
|
||||||
const model = typeof body.model === 'string' ? body.model.trim() : '';
|
const model = typeof body.model === 'string' ? body.model.trim() : '';
|
||||||
const stream = body.stream === true;
|
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 appendMessage = (role: string, content: unknown) => {
|
||||||
const text = parseClaudeMessageContent(content);
|
const text = parseClaudeMessageContent(content);
|
||||||
@@ -299,6 +420,23 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
|
|||||||
messages.push({ role, content: text });
|
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;
|
const system = body.system;
|
||||||
if (typeof system === 'string') {
|
if (typeof system === 'string') {
|
||||||
appendMessage('system', system);
|
appendMessage('system', system);
|
||||||
@@ -312,7 +450,78 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
|
|||||||
if (!isRecord(message)) continue;
|
if (!isRecord(message)) continue;
|
||||||
const role = typeof message.role === 'string' ? message.role : 'user';
|
const role = typeof message.role === 'string' ? message.role : 'user';
|
||||||
const mappedRole = role === 'assistant' || role === 'system' ? 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> = {
|
const payload: Record<string, unknown> = {
|
||||||
@@ -424,35 +633,45 @@ export function normalizeUpstreamFinalResponse(
|
|||||||
const choice = payload.choices[0] ?? {};
|
const choice = payload.choices[0] ?? {};
|
||||||
const content = extractAssistantContent(choice) || extractAssistantContent(payload);
|
const content = extractAssistantContent(choice) || extractAssistantContent(payload);
|
||||||
const reasoning = extractAssistantReasoning(choice) || extractAssistantReasoning(payload);
|
const reasoning = extractAssistantReasoning(choice) || extractAssistantReasoning(payload);
|
||||||
|
const toolCalls = collectToolCallsFromOpenAiChoice(choice);
|
||||||
return {
|
return {
|
||||||
id: isNonEmptyString(payload.id) ? payload.id : fallbackId,
|
id: isNonEmptyString(payload.id) ? payload.id : fallbackId,
|
||||||
model: isNonEmptyString(payload.model) ? payload.model : fallbackModel,
|
model: isNonEmptyString(payload.model) ? payload.model : fallbackModel,
|
||||||
created: ensureIntegerTimestamp(payload.created, now),
|
created: ensureIntegerTimestamp(payload.created, now),
|
||||||
content: content || fallbackText,
|
content: content || (toolCalls.length > 0 ? '' : fallbackText),
|
||||||
reasoningContent: reasoning,
|
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') {
|
if (isRecord(payload) && typeof payload.type === 'string' && payload.type === 'message') {
|
||||||
|
const toolCalls = collectToolCallsFromClaudeContent(payload.content);
|
||||||
return {
|
return {
|
||||||
id: isNonEmptyString(payload.id) ? payload.id : fallbackId,
|
id: isNonEmptyString(payload.id) ? payload.id : fallbackId,
|
||||||
model: isNonEmptyString(payload.model) ? payload.model : fallbackModel,
|
model: isNonEmptyString(payload.model) ? payload.model : fallbackModel,
|
||||||
created: now,
|
created: now,
|
||||||
content: parseClaudeMessageContent(payload.content) || fallbackText,
|
content: parseClaudeMessageContent(payload.content) || (toolCalls.length > 0 ? '' : fallbackText),
|
||||||
reasoningContent: extractTextAndReasoning(payload.content).reasoning,
|
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))) {
|
if (isRecord(payload) && ((payload as any).object === 'response' || Array.isArray((payload as any).output))) {
|
||||||
|
const toolCalls = collectToolCallsFromResponsesPayload(payload);
|
||||||
return {
|
return {
|
||||||
id: isNonEmptyString(payload.id) ? payload.id : fallbackId,
|
id: isNonEmptyString(payload.id) ? payload.id : fallbackId,
|
||||||
model: isNonEmptyString(payload.model) ? payload.model : fallbackModel,
|
model: isNonEmptyString(payload.model) ? payload.model : fallbackModel,
|
||||||
created: ensureIntegerTimestamp(payload.created, now),
|
created: ensureIntegerTimestamp(payload.created, now),
|
||||||
content: parseResponsesOutputText(payload) || fallbackText,
|
content: parseResponsesOutputText(payload) || (toolCalls.length > 0 ? '' : fallbackText),
|
||||||
reasoningContent: '',
|
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,
|
content: parsedCandidate.content || fallbackText,
|
||||||
reasoningContent: parsedCandidate.reasoning,
|
reasoningContent: parsedCandidate.reasoning,
|
||||||
finishReason: normalizeStopReason(candidate?.finishReason || (payload as any).finishReason) || 'stop',
|
finishReason: normalizeStopReason(candidate?.finishReason || (payload as any).finishReason) || 'stop',
|
||||||
|
toolCalls: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,6 +699,7 @@ export function normalizeUpstreamFinalResponse(
|
|||||||
content: payload,
|
content: payload,
|
||||||
reasoningContent: '',
|
reasoningContent: '',
|
||||||
finishReason: 'stop',
|
finishReason: 'stop',
|
||||||
|
toolCalls: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,6 +710,7 @@ export function normalizeUpstreamFinalResponse(
|
|||||||
content: fallbackText,
|
content: fallbackText,
|
||||||
reasoningContent: '',
|
reasoningContent: '',
|
||||||
finishReason: 'stop',
|
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)) {
|
if (type === 'response.completed' && isRecord((payload as any).response)) {
|
||||||
const responsePayload = (payload as any).response as Record<string, unknown>;
|
const responsePayload = (payload as any).response as Record<string, unknown>;
|
||||||
if (isNonEmptyString(responsePayload.id)) context.id = responsePayload.id;
|
if (isNonEmptyString(responsePayload.id)) context.id = responsePayload.id;
|
||||||
@@ -1020,21 +1300,61 @@ export function serializeStreamDone(
|
|||||||
return buildClaudeDoneEvents(context, claudeContext, 'stop');
|
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(
|
export function serializeFinalResponse(
|
||||||
downstreamFormat: DownstreamFormat,
|
downstreamFormat: DownstreamFormat,
|
||||||
normalized: NormalizedFinalResponse,
|
normalized: NormalizedFinalResponse,
|
||||||
usage: { promptTokens: number; completionTokens: number; totalTokens: number },
|
usage: { promptTokens: number; completionTokens: number; totalTokens: number },
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
|
const toolCalls = Array.isArray(normalized.toolCalls) ? normalized.toolCalls : [];
|
||||||
|
|
||||||
if (downstreamFormat === 'claude') {
|
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 {
|
return {
|
||||||
id: buildClaudeMessageId(normalized.id),
|
id: buildClaudeMessageId(normalized.id),
|
||||||
type: 'message',
|
type: 'message',
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
model: normalized.model,
|
model: normalized.model,
|
||||||
content: [{
|
content,
|
||||||
type: 'text',
|
|
||||||
text: normalized.content,
|
|
||||||
}],
|
|
||||||
stop_reason: toClaudeStopReason(normalized.finishReason),
|
stop_reason: toClaudeStopReason(normalized.finishReason),
|
||||||
stop_sequence: null,
|
stop_sequence: null,
|
||||||
usage: {
|
usage: {
|
||||||
@@ -1051,6 +1371,16 @@ export function serializeFinalResponse(
|
|||||||
if (normalized.reasoningContent) {
|
if (normalized.reasoningContent) {
|
||||||
message.reasoning_content = 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 {
|
return {
|
||||||
id: normalized.id,
|
id: normalized.id,
|
||||||
@@ -1060,7 +1390,7 @@ export function serializeFinalResponse(
|
|||||||
choices: [{
|
choices: [{
|
||||||
index: 0,
|
index: 0,
|
||||||
message,
|
message,
|
||||||
finish_reason: normalizeStopReason(normalized.finishReason) || 'stop',
|
finish_reason: finishReason,
|
||||||
}],
|
}],
|
||||||
usage: {
|
usage: {
|
||||||
prompt_tokens: usage.promptTokens,
|
prompt_tokens: usage.promptTokens,
|
||||||
@@ -1071,6 +1401,28 @@ export function serializeFinalResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildSyntheticOpenAiChunks(normalized: NormalizedFinalResponse): Array<Record<string, unknown>> {
|
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> = {
|
const startChunk: Record<string, unknown> = {
|
||||||
id: normalized.id,
|
id: normalized.id,
|
||||||
object: 'chat.completion.chunk',
|
object: 'chat.completion.chunk',
|
||||||
@@ -1078,17 +1430,11 @@ export function buildSyntheticOpenAiChunks(normalized: NormalizedFinalResponse):
|
|||||||
model: normalized.model,
|
model: normalized.model,
|
||||||
choices: [{
|
choices: [{
|
||||||
index: 0,
|
index: 0,
|
||||||
delta: normalized.content
|
delta: startDelta,
|
||||||
? { role: 'assistant', content: normalized.content }
|
|
||||||
: { role: 'assistant' },
|
|
||||||
finish_reason: null,
|
finish_reason: null,
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (normalized.reasoningContent) {
|
|
||||||
(startChunk.choices as any[])[0].delta.reasoning_content = normalized.reasoningContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
const endChunk = {
|
const endChunk = {
|
||||||
id: normalized.id,
|
id: normalized.id,
|
||||||
object: 'chat.completion.chunk',
|
object: 'chat.completion.chunk',
|
||||||
@@ -1097,7 +1443,7 @@ export function buildSyntheticOpenAiChunks(normalized: NormalizedFinalResponse):
|
|||||||
choices: [{
|
choices: [{
|
||||||
index: 0,
|
index: 0,
|
||||||
delta: {},
|
delta: {},
|
||||||
finish_reason: normalizeStopReason(normalized.finishReason) || 'stop',
|
finish_reason: finishReason,
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -216,6 +216,166 @@ function normalizeContentText(content: unknown): string {
|
|||||||
return '';
|
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(
|
function convertOpenAiBodyToMessagesBody(
|
||||||
openaiBody: Record<string, unknown>,
|
openaiBody: Record<string, unknown>,
|
||||||
modelName: string,
|
modelName: string,
|
||||||
@@ -223,21 +383,98 @@ function convertOpenAiBodyToMessagesBody(
|
|||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : [];
|
const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : [];
|
||||||
const systemContents: string[] = [];
|
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) {
|
for (const item of rawMessages) {
|
||||||
if (!isRecord(item)) continue;
|
if (!isRecord(item)) continue;
|
||||||
const role = asTrimmedString(item.role) || 'user';
|
const role = asTrimmedString(item.role).toLowerCase() || 'user';
|
||||||
const content = normalizeContentText(item.content);
|
|
||||||
if (!content) continue;
|
|
||||||
|
|
||||||
if (role === 'system') {
|
if (role === 'system' || role === 'developer') {
|
||||||
|
const content = normalizeContentText(item.content);
|
||||||
|
if (!content) continue;
|
||||||
systemContents.push(content);
|
systemContents.push(content);
|
||||||
continue;
|
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({
|
messages.push({
|
||||||
role: role === 'assistant' ? 'assistant' : 'user',
|
role: 'user',
|
||||||
content,
|
content,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -263,8 +500,11 @@ function convertOpenAiBodyToMessagesBody(
|
|||||||
body.stop_sequences = openaiBody.stop;
|
body.stop_sequences = openaiBody.stop;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (openaiBody.tools !== undefined) body.tools = openaiBody.tools;
|
if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToAnthropic(openaiBody.tools);
|
||||||
if (openaiBody.tool_choice !== undefined) body.tool_choice = openaiBody.tool_choice;
|
|
||||||
|
const anthropicToolChoice = convertOpenAiToolChoiceToAnthropic(openaiBody.tool_choice);
|
||||||
|
if (anthropicToolChoice !== undefined) body.tool_choice = anthropicToolChoice;
|
||||||
|
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,38 +515,107 @@ function convertOpenAiBodyToResponsesBody(
|
|||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : [];
|
const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : [];
|
||||||
const systemContents: string[] = [];
|
const systemContents: string[] = [];
|
||||||
const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [];
|
const inputItems: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
for (const item of rawMessages) {
|
for (const item of rawMessages) {
|
||||||
if (!isRecord(item)) continue;
|
if (!isRecord(item)) continue;
|
||||||
const role = asTrimmedString(item.role) || 'user';
|
const role = asTrimmedString(item.role).toLowerCase() || 'user';
|
||||||
const content = normalizeContentText(item.content);
|
|
||||||
if (!content) continue;
|
|
||||||
|
|
||||||
if (role === 'system') {
|
if (role === 'system' || role === 'developer') {
|
||||||
|
const content = normalizeContentText(item.content).trim();
|
||||||
|
if (!content) continue;
|
||||||
systemContents.push(content);
|
systemContents.push(content);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
messages.push({
|
if (role === 'assistant') {
|
||||||
role: role === 'assistant' ? 'assistant' : 'user',
|
const textContent = normalizeContentText(item.content).trim();
|
||||||
content,
|
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> = {
|
const body: Record<string, unknown> = {
|
||||||
model: modelName,
|
model: modelName,
|
||||||
stream,
|
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) {
|
if (systemContents.length > 0) {
|
||||||
body.input = messages[0].content;
|
body.instructions = systemContents.join('\n\n');
|
||||||
} else {
|
|
||||||
body.input = messages;
|
|
||||||
if (systemContents.length > 0) {
|
|
||||||
body.instructions = systemContents.join('\n\n');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const temperature = toFiniteNumber(openaiBody.temperature);
|
const temperature = toFiniteNumber(openaiBody.temperature);
|
||||||
@@ -315,8 +624,15 @@ function convertOpenAiBodyToResponsesBody(
|
|||||||
const topP = toFiniteNumber(openaiBody.top_p);
|
const topP = toFiniteNumber(openaiBody.top_p);
|
||||||
if (topP !== null) body.top_p = topP;
|
if (topP !== null) body.top_p = topP;
|
||||||
|
|
||||||
if (openaiBody.tools !== undefined) body.tools = openaiBody.tools;
|
if (openaiBody.metadata !== undefined) body.metadata = openaiBody.metadata;
|
||||||
if (openaiBody.tool_choice !== undefined) body.tool_choice = openaiBody.tool_choice;
|
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;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user