diff --git a/js/plugins/compat-oai/src/model.ts b/js/plugins/compat-oai/src/model.ts index bda00486ff..d909bb5f42 100644 --- a/js/plugins/compat-oai/src/model.ts +++ b/js/plugins/compat-oai/src/model.ts @@ -277,19 +277,36 @@ export function fromOpenAIChoice( const toolRequestParts = choice.message.tool_calls?.map((toolCall) => fromOpenAIToolCall(toolCall, choice) ); + + // Build content array based on what's present in the message + let content: Part[] = []; + + if (toolRequestParts) { + content = toolRequestParts as ToolRequestPart[]; + } else { + // Handle reasoning_content if present + if ( + 'reasoning_content' in choice.message && + choice.message.reasoning_content + ) { + content.push({ reasoning: choice.message.reasoning_content as string }); + } + + // Handle regular content if present + if (choice.message.content) { + content.push( + jsonMode + ? { data: JSON.parse(choice.message.content!) } + : { text: choice.message.content! } + ); + } + } + return { finishReason: finishReasonMap[choice.finish_reason] || 'other', message: { role: 'model', - content: toolRequestParts - ? // Note: Not sure why I have to cast here exactly. - // Otherwise it thinks toolRequest must be 'undefined' if provided - (toolRequestParts as ToolRequestPart[]) - : [ - jsonMode - ? { data: JSON.parse(choice.message.content!) } - : { text: choice.message.content! }, - ], + content, }, }; } @@ -308,21 +325,35 @@ export function fromOpenAIChunkChoice( const toolRequestParts = choice.delta.tool_calls?.map((toolCall) => fromOpenAIToolCall(toolCall, choice) ); + + // Build content array based on what's present in the delta + let content: Part[] = []; + + if (toolRequestParts) { + content = toolRequestParts as ToolRequestPart[]; + } else { + // Handle reasoning_content if present + if ('reasoning_content' in choice.delta && choice.delta.reasoning_content) { + content.push({ reasoning: choice.delta.reasoning_content as string }); + } + + // Handle regular content if present + if (choice.delta.content) { + content.push( + jsonMode + ? { data: JSON.parse(choice.delta.content!) } + : { text: choice.delta.content! } + ); + } + } + return { finishReason: choice.finish_reason ? finishReasonMap[choice.finish_reason] || 'other' : 'unknown', message: { role: 'model', - content: toolRequestParts - ? // Note: Not sure why I have to cast here exactly. - // Otherwise it thinks toolRequest must be 'undefined' if provided - (toolRequestParts as ToolRequestPart[]) - : [ - jsonMode - ? { data: JSON.parse(choice.delta.content!) } - : { text: choice.delta.content! }, - ], + content, }, }; } @@ -383,9 +414,19 @@ export function toOpenAIRequestBody( } const response_format = request.output?.format; if (response_format === 'json') { - body.response_format = { - type: 'json_object', - }; + if (request.output?.schema) { + body.response_format = { + type: 'json_schema', + json_schema: { + name: 'output', + schema: request.output!.schema, + }, + }; + } else { + body.response_format = { + type: 'json_object', + }; + } } else if (response_format === 'text') { body.response_format = { type: 'text', diff --git a/js/plugins/compat-oai/tests/compat_oai_test.ts b/js/plugins/compat-oai/tests/compat_oai_test.ts index 258be3d06b..c89630bf22 100644 --- a/js/plugins/compat-oai/tests/compat_oai_test.ts +++ b/js/plugins/compat-oai/tests/compat_oai_test.ts @@ -412,6 +412,48 @@ describe('fromOpenAiChoice', () => { finishReason: 'stop', }, }, + { + should: 'should work with reasoning_content', + choice: { + index: 0, + message: { + role: 'assistant', + content: null, + reasoning_content: 'Let me think about this step by step...', + refusal: null, + } as any, + finish_reason: 'stop', + logprobs: null, + }, + expectedOutput: { + finishReason: 'stop', + message: { + role: 'model', + content: [{ reasoning: 'Let me think about this step by step...' }], + }, + }, + }, + { + should: 'should work with both reasoning_content and content', + choice: { + index: 0, + message: { + role: 'assistant', + content: 'Final answer', + reasoning_content: 'Let me think...', + refusal: null, + } as any, + finish_reason: 'stop', + logprobs: null, + }, + expectedOutput: { + finishReason: 'stop', + message: { + role: 'model', + content: [{ reasoning: 'Let me think...' }, { text: 'Final answer' }], + }, + }, + }, ]; for (const test of testCases) { @@ -503,6 +545,43 @@ describe('fromOpenAiChunkChoice', () => { finishReason: 'stop', }, }, + { + should: 'should work with reasoning_content', + chunkChoice: { + index: 0, + delta: { + role: 'assistant', + reasoning_content: 'Let me think about this step by step...', + } as any, + finish_reason: null, + }, + expectedOutput: { + finishReason: 'unknown', + message: { + role: 'model', + content: [{ reasoning: 'Let me think about this step by step...' }], + }, + }, + }, + { + should: 'should work with both reasoning_content and content', + chunkChoice: { + index: 0, + delta: { + role: 'assistant', + reasoning_content: 'Let me think...', + content: 'Final answer', + } as any, + finish_reason: 'stop', + }, + expectedOutput: { + finishReason: 'stop', + message: { + role: 'model', + content: [{ reasoning: 'Let me think...' }, { text: 'Final answer' }], + }, + }, + }, ]; for (const test of testCases) { @@ -1284,6 +1363,33 @@ describe('toOpenAiRequestBody', () => { }, }); }); + it('sets json_schema response_format when an output schema is provided', () => { + const schema = { + type: 'object', + properties: { foo: { type: 'string' } }, + required: ['foo'], + additionalProperties: false, + }; + const request = { + messages: [{ role: 'user', content: [{ text: 'hello' }] }], + output: { format: 'json', schema }, + } as unknown as GenerateRequest; + + const actualOutput = toOpenAIRequestBody('gpt-4o', request) as unknown as { + response_format?: { + type: string; + json_schema?: { name: string; schema: unknown }; + }; + }; + + expect(actualOutput.response_format).toStrictEqual({ + type: 'json_schema', + json_schema: { + name: 'output', + schema, + }, + }); + }); }); describe('openAIModelRunner', () => {