forked from boos/BoosAPI
feat: 增强测试用例以支持函数调用和输出,优化工具参数处理逻辑
This commit is contained in:
@@ -796,6 +796,171 @@ describe('chat proxy stream behavior', () => {
|
||||
expect(options.headers.accept).toBe('text/event-stream');
|
||||
});
|
||||
|
||||
it('preserves function_call/function_call_output when /v1/responses falls back to /v1/chat/completions', async () => {
|
||||
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||
models: [
|
||||
{
|
||||
modelName: 'upstream-gpt',
|
||||
supportedEndpointTypes: ['/v1/chat/completions'],
|
||||
},
|
||||
],
|
||||
groupRatio: {},
|
||||
});
|
||||
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'chatcmpl_responses_fallback_chat',
|
||||
object: 'chat.completion',
|
||||
created: 1_706_000_111,
|
||||
model: 'upstream-gpt',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'ok' },
|
||||
finish_reason: 'stop',
|
||||
}],
|
||||
usage: { prompt_tokens: 9, completion_tokens: 3, total_tokens: 12 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
payload: {
|
||||
model: 'gpt-5.2',
|
||||
input: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'find readme' }],
|
||||
},
|
||||
{
|
||||
type: 'function_call',
|
||||
call_id: 'call_abc',
|
||||
name: 'Glob',
|
||||
arguments: '{"pattern":"README*"}',
|
||||
},
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_abc',
|
||||
output: '{"matches":1}',
|
||||
},
|
||||
],
|
||||
tools: [{
|
||||
type: 'function',
|
||||
name: 'Glob',
|
||||
description: 'Search files',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { pattern: { type: 'string' } },
|
||||
required: ['pattern'],
|
||||
},
|
||||
strict: true,
|
||||
}],
|
||||
tool_choice: {
|
||||
type: 'function',
|
||||
name: 'Glob',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
||||
expect(targetUrl).toContain('/v1/chat/completions');
|
||||
|
||||
const forwarded = JSON.parse(options.body);
|
||||
expect(Array.isArray(forwarded.messages)).toBe(true);
|
||||
|
||||
const assistantWithToolCall = forwarded.messages.find((item: any) =>
|
||||
item?.role === 'assistant'
|
||||
&& Array.isArray(item?.tool_calls)
|
||||
&& item.tool_calls.length > 0,
|
||||
);
|
||||
expect(assistantWithToolCall).toBeTruthy();
|
||||
expect(assistantWithToolCall.tool_calls[0].id).toBe('call_abc');
|
||||
expect(assistantWithToolCall.tool_calls[0].function?.name).toBe('Glob');
|
||||
expect(assistantWithToolCall.tool_calls[0].function?.arguments).toContain('README*');
|
||||
|
||||
const toolMessage = forwarded.messages.find((item: any) => item?.role === 'tool');
|
||||
expect(toolMessage).toBeTruthy();
|
||||
expect(toolMessage.tool_call_id).toBe('call_abc');
|
||||
expect(toolMessage.content).toContain('matches');
|
||||
|
||||
expect(forwarded.tools?.[0]?.function?.name).toBe('Glob');
|
||||
expect(forwarded.tool_choice?.function?.name).toBe('Glob');
|
||||
});
|
||||
|
||||
it('preserves function_call/function_call_output when /v1/responses falls back to /v1/messages', async () => {
|
||||
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||
models: [
|
||||
{
|
||||
modelName: 'upstream-gpt',
|
||||
supportedEndpointTypes: ['anthropic'],
|
||||
},
|
||||
],
|
||||
groupRatio: {},
|
||||
});
|
||||
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'msg_responses_fallback_messages',
|
||||
type: 'message',
|
||||
model: 'upstream-gpt',
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
stop_reason: 'end_turn',
|
||||
usage: { input_tokens: 8, output_tokens: 2, total_tokens: 10 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
payload: {
|
||||
model: 'gpt-5.2',
|
||||
input: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'find readme' }],
|
||||
},
|
||||
{
|
||||
type: 'function_call',
|
||||
call_id: 'call_abc',
|
||||
name: 'Glob',
|
||||
arguments: '{"pattern":"README*"}',
|
||||
},
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_abc',
|
||||
output: '{"matches":1}',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
||||
expect(targetUrl).toContain('/v1/messages');
|
||||
|
||||
const forwarded = JSON.parse(options.body);
|
||||
expect(Array.isArray(forwarded.messages)).toBe(true);
|
||||
|
||||
const assistantMessage = forwarded.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 = forwarded.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');
|
||||
});
|
||||
|
||||
it('routes /v1/responses to /v1/messages when upstream catalog is anthropic-only', async () => {
|
||||
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||
models: [
|
||||
@@ -1196,6 +1361,95 @@ describe('chat proxy stream behavior', () => {
|
||||
expect(response.body).toContain('\\"pattern\\":\\"README*\\"');
|
||||
});
|
||||
|
||||
it('uses response.function_call_arguments.done when upstream omits delta 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_done_only","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_done_only","name":"Glob"}}\n\n'));
|
||||
controller.enqueue(encoder.encode('event: response.function_call_arguments.done\ndata: {"type":"response.function_call_arguments.done","output_index":0,"call_id":"call_done_only","arguments":"{\\"pattern\\":\\"README*\\"}"}\n\n'));
|
||||
controller.enqueue(encoder.encode('event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_done_only","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_done_only"');
|
||||
expect(response.body).toContain('"name":"Glob"');
|
||||
expect(response.body).toContain('\\"pattern\\":\\"README*\\"');
|
||||
});
|
||||
|
||||
it('does not duplicate tool arguments when upstream sends both delta and done 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_delta_done","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_delta_done","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_delta_done","delta":"{\\"pattern\\":\\"README*\\"}"}\n\n'));
|
||||
controller.enqueue(encoder.encode('event: response.function_call_arguments.done\ndata: {"type":"response.function_call_arguments.done","output_index":0,"call_id":"call_delta_done","arguments":"{\\"pattern\\":\\"README*\\"}"}\n\n'));
|
||||
controller.enqueue(encoder.encode('event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_delta_done","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);
|
||||
const matches = response.body.match(/\\"pattern\\":\\"README\*\\"/g) || [];
|
||||
expect(matches.length).toBe(1);
|
||||
});
|
||||
|
||||
it('preserves non-stream function_call output when /v1/chat/completions falls back to /v1/responses', async () => {
|
||||
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||
models: [
|
||||
|
||||
@@ -11,7 +11,7 @@ export type StreamTransformContext = {
|
||||
created: number;
|
||||
roleSent: boolean;
|
||||
doneSent: boolean;
|
||||
toolCalls: Record<number, { id?: string; name?: string }>;
|
||||
toolCalls: Record<number, { id?: string; name?: string; arguments?: string }>;
|
||||
};
|
||||
|
||||
export type ClaudeDownstreamContext = {
|
||||
@@ -167,13 +167,15 @@ export function normalizeStopReason(raw: unknown): string | null {
|
||||
|| value === 'end'
|
||||
|| value === 'eos'
|
||||
|| value === 'finished'
|
||||
|| value === 'completed'
|
||||
|| value === 'stop_sequence'
|
||||
) {
|
||||
return 'stop';
|
||||
}
|
||||
|
||||
if (
|
||||
value === 'max_tokens'
|
||||
value === 'incomplete'
|
||||
|| value === 'max_tokens'
|
||||
|| value === 'length'
|
||||
|| value === 'max_output_tokens'
|
||||
|| value === 'max_tokens_exceeded'
|
||||
@@ -835,11 +837,33 @@ export function normalizeUpstreamStreamEvent(
|
||||
: (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)
|
||||
const rawArguments = (
|
||||
type === 'response.function_call_arguments.done'
|
||||
? (typeof (payload as any).arguments === 'string' ? (payload as any).arguments : undefined)
|
||||
: (
|
||||
typeof payload.delta === 'string'
|
||||
? payload.delta
|
||||
: (typeof (payload as any).arguments === 'string' ? (payload as any).arguments : undefined)
|
||||
)
|
||||
);
|
||||
let argumentsDelta = rawArguments;
|
||||
if (type === 'response.function_call_arguments.done' && typeof rawArguments === 'string') {
|
||||
const existingArguments = context.toolCalls[outputIndex]?.arguments || '';
|
||||
if (existingArguments && rawArguments.startsWith(existingArguments)) {
|
||||
const missingSuffix = rawArguments.slice(existingArguments.length);
|
||||
argumentsDelta = missingSuffix.length > 0 ? missingSuffix : undefined;
|
||||
} else if (existingArguments === rawArguments) {
|
||||
argumentsDelta = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const knownTool = context.toolCalls[outputIndex] || {};
|
||||
const shouldBackfillId = !!toolCallId && !knownTool.id;
|
||||
const shouldBackfillName = !!toolName && !knownTool.name;
|
||||
if (argumentsDelta === undefined && !shouldBackfillId && !shouldBackfillName) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
toolCallDeltas: [{
|
||||
index: outputIndex,
|
||||
@@ -1019,9 +1043,11 @@ function buildOpenAiStreamChunk(
|
||||
const existing = context.toolCalls[index] || {};
|
||||
const id = toolDelta.id || existing.id || `call_meta_${index}`;
|
||||
const name = toolDelta.name || existing.name || '';
|
||||
const nextArguments = `${existing.arguments || ''}${toolDelta.argumentsDelta ?? ''}`;
|
||||
context.toolCalls[index] = {
|
||||
id,
|
||||
name: name || existing.name,
|
||||
arguments: nextArguments,
|
||||
};
|
||||
|
||||
const fn: Record<string, unknown> = {};
|
||||
|
||||
@@ -35,6 +35,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (Array.isArray(value)) {
|
||||
@@ -53,31 +57,303 @@ function normalizeText(value: unknown): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function stringifyJson(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeToolArguments(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)) return stringifyJson(value);
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeToolOutput(value: unknown): string {
|
||||
const text = normalizeText(value).trim();
|
||||
if (text) return text;
|
||||
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)) return stringifyJson(value);
|
||||
return '';
|
||||
}
|
||||
|
||||
type OpenAiToolCall = {
|
||||
id: string;
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
};
|
||||
|
||||
function toOpenAiToolCall(item: Record<string, unknown>, fallbackIndex: number): OpenAiToolCall | null {
|
||||
const callId = (
|
||||
asTrimmedString(item.call_id)
|
||||
|| asTrimmedString(item.id)
|
||||
|| `call_${Date.now()}_${fallbackIndex}`
|
||||
);
|
||||
const name = asTrimmedString(item.name);
|
||||
if (!name) return null;
|
||||
|
||||
return {
|
||||
id: callId,
|
||||
type: 'function',
|
||||
function: {
|
||||
name,
|
||||
arguments: normalizeToolArguments(item.arguments),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function extractTextAndToolsFromResponsesMessage(
|
||||
rawContent: unknown,
|
||||
fallbackPrefix: string,
|
||||
): {
|
||||
text: string;
|
||||
toolCalls: OpenAiToolCall[];
|
||||
toolResults: Array<{ id: string; content: string }>;
|
||||
} {
|
||||
const contentItems = Array.isArray(rawContent) ? rawContent : [rawContent];
|
||||
const textParts: string[] = [];
|
||||
const toolCalls: OpenAiToolCall[] = [];
|
||||
const toolResults: Array<{ id: string; content: string }> = [];
|
||||
|
||||
for (let index = 0; index < contentItems.length; index += 1) {
|
||||
const part = contentItems[index];
|
||||
if (!isRecord(part)) {
|
||||
const text = normalizeText(part).trim();
|
||||
if (text) textParts.push(text);
|
||||
continue;
|
||||
}
|
||||
|
||||
const partType = asTrimmedString(part.type).toLowerCase();
|
||||
if (partType === 'tool_use') {
|
||||
const callId = asTrimmedString(part.id) || `call_${fallbackPrefix}_${index}`;
|
||||
const name = asTrimmedString(part.name);
|
||||
if (!name) continue;
|
||||
|
||||
toolCalls.push({
|
||||
id: callId,
|
||||
type: 'function',
|
||||
function: {
|
||||
name,
|
||||
arguments: normalizeToolArguments(part.input),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (partType === 'tool_result') {
|
||||
const id = asTrimmedString(part.tool_use_id) || asTrimmedString(part.id);
|
||||
if (!id) continue;
|
||||
toolResults.push({
|
||||
id,
|
||||
content: normalizeToolOutput(part.content),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = normalizeText(part).trim();
|
||||
if (text) textParts.push(text);
|
||||
}
|
||||
|
||||
return {
|
||||
text: textParts.join('\n').trim(),
|
||||
toolCalls,
|
||||
toolResults,
|
||||
};
|
||||
}
|
||||
|
||||
function convertResponsesToolsToOpenAi(rawTools: unknown): unknown {
|
||||
if (!Array.isArray(rawTools)) return rawTools;
|
||||
|
||||
const converted = rawTools
|
||||
.map((item) => {
|
||||
if (!isRecord(item)) return item;
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
|
||||
if (type !== 'function') return item;
|
||||
|
||||
if (isRecord(item.function) && asTrimmedString(item.function.name)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const name = asTrimmedString(item.name);
|
||||
if (!name) return item;
|
||||
|
||||
const fn: Record<string, unknown> = { name };
|
||||
const description = asTrimmedString(item.description);
|
||||
if (description) fn.description = description;
|
||||
if (item.parameters !== undefined) fn.parameters = item.parameters;
|
||||
if (item.strict !== undefined) fn.strict = item.strict;
|
||||
|
||||
return {
|
||||
type: 'function',
|
||||
function: fn,
|
||||
};
|
||||
});
|
||||
|
||||
return converted;
|
||||
}
|
||||
|
||||
function convertResponsesToolChoiceToOpenAi(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') {
|
||||
if (isRecord(rawToolChoice.function) && asTrimmedString(rawToolChoice.function.name)) {
|
||||
return rawToolChoice;
|
||||
}
|
||||
|
||||
const name = asTrimmedString(rawToolChoice.name);
|
||||
if (!name) return 'required';
|
||||
return {
|
||||
type: 'function',
|
||||
function: { name },
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'auto' || type === 'none' || type === 'required') {
|
||||
return type;
|
||||
}
|
||||
|
||||
return rawToolChoice;
|
||||
}
|
||||
|
||||
function convertResponsesBodyToOpenAiBody(
|
||||
body: Record<string, unknown>,
|
||||
modelName: string,
|
||||
stream: boolean,
|
||||
): Record<string, unknown> {
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
const input = body.input;
|
||||
let functionCallIndex = 0;
|
||||
let pendingToolCalls: OpenAiToolCall[] = [];
|
||||
|
||||
const flushPendingToolCalls = () => {
|
||||
if (pendingToolCalls.length <= 0) return;
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: pendingToolCalls,
|
||||
});
|
||||
pendingToolCalls = [];
|
||||
};
|
||||
|
||||
const pushToolOutputMessage = (callIdRaw: unknown, outputRaw: unknown) => {
|
||||
const toolCallId = asTrimmedString(callIdRaw);
|
||||
if (!toolCallId) return;
|
||||
const content = normalizeToolOutput(outputRaw);
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCallId,
|
||||
content,
|
||||
});
|
||||
};
|
||||
|
||||
const pushUserMessage = (content: string) => {
|
||||
if (!content) return;
|
||||
messages.push({ role: 'user', content });
|
||||
};
|
||||
|
||||
const processInputItem = (item: unknown) => {
|
||||
if (typeof item === 'string') {
|
||||
flushPendingToolCalls();
|
||||
const text = item.trim();
|
||||
if (text) pushUserMessage(text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRecord(item)) return;
|
||||
|
||||
const itemType = asTrimmedString(item.type).toLowerCase();
|
||||
if (itemType === 'function_call') {
|
||||
const toolCall = toOpenAiToolCall(item, functionCallIndex);
|
||||
functionCallIndex += 1;
|
||||
if (!toolCall) return;
|
||||
pendingToolCalls.push(toolCall);
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemType === 'function_call_output') {
|
||||
flushPendingToolCalls();
|
||||
pushToolOutputMessage(item.call_id ?? item.id, item.output ?? item.content);
|
||||
return;
|
||||
}
|
||||
|
||||
flushPendingToolCalls();
|
||||
|
||||
const role = asTrimmedString(item.role).toLowerCase() || 'user';
|
||||
const messageContent = item.content ?? item.input ?? item;
|
||||
const parsed = extractTextAndToolsFromResponsesMessage(
|
||||
messageContent,
|
||||
`${Date.now()}_${messages.length}`,
|
||||
);
|
||||
|
||||
if (role === 'assistant') {
|
||||
if (!parsed.text && parsed.toolCalls.length <= 0) return;
|
||||
|
||||
const assistantMessage: Record<string, unknown> = {
|
||||
role: 'assistant',
|
||||
content: parsed.text,
|
||||
};
|
||||
if (parsed.toolCalls.length > 0) {
|
||||
assistantMessage.tool_calls = parsed.toolCalls;
|
||||
}
|
||||
messages.push(assistantMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedRole = role === 'system' || role === 'developer'
|
||||
? 'system'
|
||||
: role === 'tool'
|
||||
? 'tool'
|
||||
: 'user';
|
||||
|
||||
if (normalizedRole === 'tool') {
|
||||
pushToolOutputMessage(item.tool_call_id ?? item.call_id ?? item.id, messageContent);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.text) {
|
||||
messages.push({
|
||||
role: normalizedRole,
|
||||
content: parsed.text,
|
||||
});
|
||||
}
|
||||
|
||||
for (const toolResult of parsed.toolResults) {
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolResult.id,
|
||||
content: toolResult.content,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof input === 'string') {
|
||||
const text = input.trim();
|
||||
if (text) messages.push({ role: 'user', content: text });
|
||||
if (text) {
|
||||
messages.push({ role: 'user', content: text });
|
||||
}
|
||||
} else if (Array.isArray(input)) {
|
||||
for (const item of input) {
|
||||
if (!isRecord(item)) continue;
|
||||
const role = typeof item.role === 'string' ? item.role : 'user';
|
||||
const text = normalizeText(item.content ?? item).trim();
|
||||
if (!text) continue;
|
||||
messages.push({ role: role === 'assistant' ? 'assistant' : (role === 'system' ? 'system' : 'user'), content: text });
|
||||
processInputItem(item);
|
||||
}
|
||||
} else if (isRecord(input)) {
|
||||
const text = normalizeText(input).trim();
|
||||
if (text) messages.push({ role: 'user', content: text });
|
||||
processInputItem(input);
|
||||
}
|
||||
flushPendingToolCalls();
|
||||
|
||||
const instructions = typeof body.instructions === 'string' ? body.instructions.trim() : '';
|
||||
const instructions = asTrimmedString(body.instructions);
|
||||
if (instructions) {
|
||||
messages.unshift({ role: 'system', content: instructions });
|
||||
}
|
||||
@@ -97,8 +373,9 @@ function convertResponsesBodyToOpenAiBody(
|
||||
if (typeof body.max_output_tokens === 'number' && Number.isFinite(body.max_output_tokens)) {
|
||||
payload.max_tokens = body.max_output_tokens;
|
||||
}
|
||||
if (body.tools !== undefined) payload.tools = body.tools;
|
||||
if (body.tool_choice !== undefined) payload.tool_choice = body.tool_choice;
|
||||
if (body.parallel_tool_calls !== undefined) payload.parallel_tool_calls = body.parallel_tool_calls;
|
||||
if (body.tools !== undefined) payload.tools = convertResponsesToolsToOpenAi(body.tools);
|
||||
if (body.tool_choice !== undefined) payload.tool_choice = convertResponsesToolChoiceToOpenAi(body.tool_choice);
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user