diff --git a/packages/core/src/core/openaiContentGenerator/converter.test.ts b/packages/core/src/core/openaiContentGenerator/converter.test.ts index cad4777096f..0cc2681b796 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.test.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.test.ts @@ -22,6 +22,19 @@ import { import type OpenAI from 'openai'; import { convertToFunctionResponse } from '../coreToolScheduler.js'; +function legacyFunctionCallPart( + name = 'read_file', + args: unknown = { path: 'README.md' }, +) { + return { + functionCall: { + id: expect.any(String), + name, + args, + }, + }; +} + describe('OpenAIContentConverter', () => { let converter: typeof OpenAIContentConverter; let requestContext: RequestContext; @@ -2677,6 +2690,13 @@ describe('OpenAIContentConverter', () => { }); describe('convertOpenAIResponseToGemini', () => { + type LegacyFunctionCallMessage = Omit< + Partial, + 'function_call' + > & { + function_call?: { name?: string; arguments?: string }; + }; + it('should handle empty choices array without crashing', () => { const response = converter.convertOpenAIResponseToGemini( { @@ -2692,6 +2712,152 @@ describe('OpenAIContentConverter', () => { expect(response.candidates).toEqual([]); }); + function convertLegacyFunctionCall(message: LegacyFunctionCallMessage) { + return converter.convertOpenAIResponseToGemini( + { + object: 'chat.completion', + id: 'chatcmpl-legacy-function-call', + created: 123, + model: 'test-model', + choices: [ + { + index: 0, + message: { role: 'assistant', content: null, ...message }, + finish_reason: 'function_call', + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletion, + requestContext, + ); + } + + it.each([ + { + name: 'legacy function_call responses', + message: { + function_call: { + name: 'read_file', + arguments: '{"path":"README.md"}', + }, + }, + expectedParts: [legacyFunctionCallPart()], + }, + { + name: 'legacy function_call when tool_calls is empty', + message: { + tool_calls: [], + function_call: { + name: 'read_file', + arguments: '{"path":"README.md"}', + }, + }, + expectedParts: [legacyFunctionCallPart()], + }, + { + name: 'modern tool_calls over legacy function_call', + message: { + tool_calls: [ + { + id: 'call_modern', + type: 'function' as const, + function: { + name: 'read_file', + arguments: '{"path":"modern.md"}', + }, + }, + ], + function_call: { + name: 'read_file', + arguments: '{"path":"legacy.md"}', + }, + }, + expectedParts: [ + legacyFunctionCallPart('read_file', { path: 'modern.md' }), + ], + }, + { + name: 'modern tool_calls preserve array arguments', + message: { + tool_calls: [ + { + id: 'call_modern_array', + type: 'function' as const, + function: { + name: 'read_file', + arguments: '[1,2,3]', + }, + }, + ], + function_call: { + name: 'read_file', + arguments: '{"path":"legacy.md"}', + }, + }, + expectedParts: [legacyFunctionCallPart('read_file', [1, 2, 3])], + }, + { + name: 'modern tool_calls preserve scalar arguments', + message: { + tool_calls: [ + { + id: 'call_modern_scalar', + type: 'function' as const, + function: { + name: 'read_file', + arguments: '"literal"', + }, + }, + ], + function_call: { + name: 'read_file', + arguments: '{"path":"legacy.md"}', + }, + }, + expectedParts: [legacyFunctionCallPart('read_file', 'literal')], + }, + { + name: 'invalid legacy function_call arguments', + message: { + function_call: { name: 'read_file', arguments: 'not-json' }, + }, + expectedParts: [legacyFunctionCallPart('read_file', {})], + }, + { + name: 'legacy function_call without arguments', + message: { + function_call: { name: 'ping' }, + }, + expectedParts: [legacyFunctionCallPart('ping', {})], + }, + { + name: 'legacy function_call without name does not emit functionCall', + message: { + function_call: { arguments: '{"path":"README.md"}' }, + }, + expectedParts: [], + }, + { + name: 'text content plus legacy function_call', + message: { + content: 'Let me read that file.', + function_call: { + name: 'read_file', + arguments: '{"path":"README.md"}', + }, + }, + expectedParts: [ + { text: 'Let me read that file.' }, + legacyFunctionCallPart(), + ], + }, + ])('should convert $name', ({ message, expectedParts }) => { + const response = convertLegacyFunctionCall(message); + + expect(response.candidates?.[0]?.content?.parts).toEqual(expectedParts); + expect(response.candidates?.[0]?.finishReason).toBe(FinishReason.STOP); + }); + it('keeps the estimated prompt/completion split summing to total tokens', () => { // When a provider reports only total_tokens, the 70/30 estimate must // still add back up to the total instead of rounding each half on its @@ -5205,6 +5371,255 @@ describe('Truncated tool call detection in streaming', () => { expect(result.candidates?.[0]?.finishReason).toBe(FinishReason.MAX_TOKENS); }); + + function feedLegacyFunctionCallChunks({ + chunks, + finishReason = 'function_call', + includeEmptyToolCallsOnFirstChunk = false, + includeModernToolCallOnFirstChunk = false, + }: { + chunks: Array<{ name?: string; arguments?: string }>; + finishReason?: string; + includeEmptyToolCallsOnFirstChunk?: boolean; + includeModernToolCallOnFirstChunk?: boolean; + }) { + const ctx = createStreamingRequestContext(); + + for (const [index, functionCall] of chunks.entries()) { + converter.convertOpenAIChunkToGemini( + { + object: 'chat.completion.chunk', + id: `legacy-c${index + 1}`, + created: 100, + model: 'test-model', + choices: [ + { + index: 0, + delta: { + ...(index === 0 && includeEmptyToolCallsOnFirstChunk + ? { tool_calls: [] } + : {}), + ...(index === 0 && includeModernToolCallOnFirstChunk + ? { + tool_calls: [ + { + index: 0, + id: 'call_modern', + type: 'function' as const, + function: { + name: 'read_file', + arguments: '{"path":"modern.md"}', + }, + }, + ], + } + : {}), + function_call: functionCall, + }, + finish_reason: null, + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletionChunk, + ctx, + ); + } + + return converter.convertOpenAIChunkToGemini( + { + object: 'chat.completion.chunk', + id: 'legacy-final', + created: 101, + model: 'test-model', + choices: [ + { + index: 0, + delta: {}, + finish_reason: finishReason, + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletionChunk, + ctx, + ); + } + + it.each([ + { + name: 'legacy streaming function_call chunks', + chunks: [ + { name: 'read_file' }, + { arguments: '{"path"' }, + { arguments: ':"README.md"}' }, + ], + expectedFinishReason: FinishReason.STOP, + expectedParts: [legacyFunctionCallPart()], + }, + { + name: 'legacy streaming function_call when tool_calls is empty', + chunks: [ + { name: 'read_file' }, + { arguments: '{"path"' }, + { arguments: ':"README.md"}' }, + ], + includeEmptyToolCallsOnFirstChunk: true, + expectedFinishReason: FinishReason.STOP, + expectedParts: [legacyFunctionCallPart()], + }, + { + name: 'modern streaming tool_calls over legacy function_call', + chunks: [{ name: 'read_file', arguments: '{"path":"legacy.md"}' }], + includeModernToolCallOnFirstChunk: true, + finishReason: 'tool_calls', + expectedFinishReason: FinishReason.STOP, + expectedParts: [ + legacyFunctionCallPart('read_file', { path: 'modern.md' }), + ], + }, + { + name: 'legacy streaming function_call scalar arguments', + chunks: [{ name: 'read_file', arguments: '"not-object"' }], + expectedFinishReason: FinishReason.STOP, + expectedParts: [legacyFunctionCallPart('read_file', {})], + }, + { + name: 'truncated legacy streaming function_call', + chunks: [{ name: 'read_file', arguments: '{"path"' }], + expectedFinishReason: FinishReason.MAX_TOKENS, + expectedParts: [legacyFunctionCallPart('read_file', { path: null })], + }, + { + name: 'truncated legacy streaming function_call without arguments', + chunks: [{ name: 'read_file' }], + finishReason: 'length', + expectedFinishReason: FinishReason.MAX_TOKENS, + expectedParts: [], + }, + { + name: 'legacy streaming function_call without arguments', + chunks: [{ name: 'ping' }], + expectedFinishReason: FinishReason.STOP, + expectedParts: [legacyFunctionCallPart('ping', {})], + }, + ])( + 'should convert $name', + ({ + chunks, + includeEmptyToolCallsOnFirstChunk, + includeModernToolCallOnFirstChunk, + finishReason, + expectedFinishReason, + expectedParts, + }) => { + const result = feedLegacyFunctionCallChunks({ + chunks, + finishReason, + includeEmptyToolCallsOnFirstChunk, + includeModernToolCallOnFirstChunk, + }); + + expect(result.candidates?.[0]?.finishReason).toBe(expectedFinishReason); + if (expectedParts) { + expect(result.candidates?.[0]?.content?.parts).toEqual(expectedParts); + } + }, + ); + + it('should convert legacy streaming content plus function_call in the same chunk', () => { + const ctx = createStreamingRequestContext(); + + const result = converter.convertOpenAIChunkToGemini( + { + object: 'chat.completion.chunk', + id: 'legacy-content-and-function-call', + created: 102, + model: 'test-model', + choices: [ + { + index: 0, + delta: { + content: 'Let me read that file.', + function_call: { + name: 'read_file', + arguments: '{"path":"README.md"}', + }, + }, + finish_reason: 'function_call', + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletionChunk, + ctx, + ); + + expect(result.candidates?.[0]?.finishReason).toBe(FinishReason.STOP); + expect(result.candidates?.[0]?.content?.parts).toEqual([ + { text: 'Let me read that file.' }, + legacyFunctionCallPart(), + ]); + }); + + it('should reset stale legacy function_call parser state when modern tool_calls take over', () => { + const ctx = createStreamingRequestContext(); + + converter.convertOpenAIChunkToGemini( + { + object: 'chat.completion.chunk', + id: 'legacy-partial-before-modern', + created: 102, + model: 'test-model', + choices: [ + { + index: 0, + delta: { + function_call: { + name: 'read_file', + arguments: '{"path"', + }, + }, + finish_reason: null, + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletionChunk, + ctx, + ); + + const result = converter.convertOpenAIChunkToGemini( + { + object: 'chat.completion.chunk', + id: 'modern-after-legacy-partial', + created: 103, + model: 'test-model', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_modern', + type: 'function' as const, + function: { + name: 'read_file', + arguments: '{"path":"modern.md"}', + }, + }, + ], + }, + finish_reason: 'tool_calls', + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletionChunk, + ctx, + ); + + expect(result.candidates?.[0]?.finishReason).toBe(FinishReason.STOP); + expect(result.candidates?.[0]?.content?.parts).toEqual([ + legacyFunctionCallPart('read_file', { path: 'modern.md' }), + ]); + }); }); describe('mapGeminiFinishReasonToOpenAI', () => { diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index 633b478093c..02d4e85cc16 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -1081,6 +1081,72 @@ function convertOpenAITextToParts( return parseTaggedThinkingText(text); } +function normalizeToolCallArgs( + args: unknown, + logDiscard = false, +): Record { + if (args && typeof args === 'object' && !Array.isArray(args)) { + return args as Record; + } + + if (logDiscard && args !== undefined && args !== null) { + debugLogger.debug( + `Discarding non-object tool call arguments, using {}: ${ + Array.isArray(args) ? 'array' : typeof args + }`, + ); + } + return {}; +} + +function parseToolCallArgs(argsJson?: string | null): Record { + if (!argsJson) return {}; + + const parsed = safeJsonParse(argsJson, {}); + const args = normalizeToolCallArgs(parsed); + if ( + argsJson.trim() && + (parsed !== args || + (Object.keys(args).length === 0 && argsJson.trim() !== '{}')) + ) { + debugLogger.debug( + `Failed to parse tool call arguments, using {}: "${argsJson.slice(0, 200)}"`, + ); + } + return args; +} + +function parseModernToolCallArgs( + argsJson?: string | null, +): Record { + return safeJsonParse>(argsJson ?? '', {}); +} + +function generateToolCallId(): string { + return `call_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; +} + +function createFunctionCallPart( + name: string, + argsJson?: string | null, + id = generateToolCallId(), + parseArgs = parseToolCallArgs, +): Part { + return { + functionCall: { + id, + name, + args: parseArgs(argsJson), + }, + }; +} + +function getLegacyFunctionCallChunk( + functionCall: OpenAI.Chat.ChatCompletionChunk.Choice.Delta.FunctionCall, +): string { + return functionCall.arguments ?? ''; +} + /** * Convert OpenAI response to Gemini format. */ @@ -1115,23 +1181,33 @@ export function convertOpenAIResponseToGemini( } // Handle tool calls - if (choice.message.tool_calls) { + if (choice.message.tool_calls?.length) { + if (choice.message.function_call) { + debugLogger.debug( + `Ignoring legacy function_call "${choice.message.function_call.name}" because tool_calls is non-empty`, + ); + } + for (const toolCall of choice.message.tool_calls) { if (toolCall.function) { - let args: Record = {}; - if (toolCall.function.arguments) { - args = safeJsonParse(toolCall.function.arguments, {}); - } - - parts.push({ - functionCall: { - id: toolCall.id, - name: toolCall.function.name, - args, - }, - }); + parts.push( + createFunctionCallPart( + toolCall.function.name, + toolCall.function.arguments, + toolCall.id, + parseModernToolCallArgs, + ), + ); } } + } else if (choice.message.function_call?.name) { + const functionCall = choice.message.function_call; + debugLogger.debug( + `Using legacy function_call fallback (non-streaming): ${functionCall.name}`, + ); + parts.push( + createFunctionCallPart(functionCall.name, functionCall.arguments), + ); } response.candidates = [ @@ -1275,7 +1351,21 @@ export function convertOpenAIChunkToGemini( } // Handle tool calls using the stream-local parser - if (choice.delta?.tool_calls) { + if (choice.delta?.tool_calls?.length) { + const hadLegacyFunctionCallState = + Boolean(requestContext.legacyFunctionCallWithoutArguments) || + Boolean(requestContext.legacyFunctionCallInProgress); + requestContext.legacyFunctionCallWithoutArguments = undefined; + requestContext.legacyFunctionCallInProgress = undefined; + if (hadLegacyFunctionCallState) { + toolCallParser.resetIndex(0); + } + if (choice.delta.function_call) { + debugLogger.debug( + `Ignoring legacy function_call "${choice.delta.function_call.name ?? ''}" because tool_calls is non-empty`, + ); + } + for (const toolCall of choice.delta.tool_calls) { const index = toolCall.index ?? 0; @@ -1297,37 +1387,85 @@ export function convertOpenAIChunkToGemini( ); } } + } else if (choice.delta?.function_call) { + const functionCall = choice.delta.function_call; + requestContext.legacyFunctionCallInProgress = true; + if (functionCall.name) { + debugLogger.debug( + `Using legacy function_call fallback (streaming): ${functionCall.name}`, + ); + requestContext.legacyFunctionCallWithoutArguments = { + name: functionCall.name, + }; + } + if (functionCall.arguments) { + requestContext.legacyFunctionCallWithoutArguments = undefined; + } + toolCallParser.addChunk( + 0, + getLegacyFunctionCallChunk(functionCall), + undefined, + functionCall.name, + ); } // Only emit function calls when streaming is complete (finish_reason is present) let toolCallsTruncated = false; + let legacyFunctionCallNameOnlyTruncated = false; if (choice.finish_reason) { // Detect truncation the provider may not report correctly. // Some providers (e.g. DashScope/Qwen) send "stop" or "tool_calls" // even when output was cut off mid-JSON due to max_tokens. toolCallsTruncated = toolCallParser.hasIncompleteToolCalls(); + legacyFunctionCallNameOnlyTruncated = + choice.finish_reason === 'length' && + Boolean(requestContext.legacyFunctionCallWithoutArguments) && + Boolean(requestContext.legacyFunctionCallInProgress); const completedToolCalls = toolCallParser.getCompletedToolCalls(); for (const toolCall of completedToolCalls) { if (toolCall.name) { + requestContext.legacyFunctionCallWithoutArguments = undefined; parts.push({ functionCall: { - id: - toolCall.id || - `call_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, + id: toolCall.id || generateToolCallId(), name: toolCall.name, - args: toolCall.args, + args: normalizeToolCallArgs(toolCall.args, true), }, }); } } + if ( + requestContext.legacyFunctionCallWithoutArguments && + !legacyFunctionCallNameOnlyTruncated + ) { + parts.push( + createFunctionCallPart( + requestContext.legacyFunctionCallWithoutArguments.name, + '{}', + ), + ); + } + requestContext.legacyFunctionCallWithoutArguments = undefined; + requestContext.legacyFunctionCallInProgress = undefined; + } + + if (legacyFunctionCallNameOnlyTruncated) { + debugLogger.debug( + 'Suppressing zero-argument legacy function_call fallback: stream ended with finish_reason "length" before arguments arrived', + ); + } else if (toolCallsTruncated && choice.finish_reason !== 'length') { + debugLogger.debug( + `Overriding finish_reason "${choice.finish_reason}" to "length": tool call arguments were truncated`, + ); } // If tool call JSON was truncated, override to "length" so downstream // (turn.ts) correctly sets wasOutputTruncated=true. const effectiveFinishReason = - toolCallsTruncated && choice.finish_reason !== 'length' + (toolCallsTruncated || legacyFunctionCallNameOnlyTruncated) && + choice.finish_reason !== 'length' ? 'length' : choice.finish_reason; diff --git a/packages/core/src/core/openaiContentGenerator/types.ts b/packages/core/src/core/openaiContentGenerator/types.ts index 6a57235f5af..ac4ce683f21 100644 --- a/packages/core/src/core/openaiContentGenerator/types.ts +++ b/packages/core/src/core/openaiContentGenerator/types.ts @@ -42,6 +42,25 @@ export interface RequestContext { modalities: InputModalities; startTime: number; toolCallParser?: StreamingToolCallParser; + /** + * Sentinel for legacy `function_call` (pre-`tool_calls`) streaming. + * + * Set when a name-only legacy delta arrives. Cleared when argument chunks + * arrive, when modern `tool_calls` takes precedence, when the parser emits + * a buffered call, or during stream-finalization cleanup. + * + * INVARIANT: this flag must be cleared whenever the parser buffer for the + * same legacy call is non-empty; otherwise a zero-argument fallback could + * duplicate a call emitted by `StreamingToolCallParser`. + */ + legacyFunctionCallWithoutArguments?: { name: string }; + /** + * Marks that this stream used legacy `function_call` chunks. At finalization, + * the converter uses this with the name-only sentinel to suppress only the + * zero-argument fallback when the stream is explicitly truncated, while still + * allowing `StreamingToolCallParser` to emit repaired partial argument calls. + */ + legacyFunctionCallInProgress?: boolean; responseParsingOptions?: OpenAIResponseParsingOptions; taggedThinkingParser?: TaggedThinkingParser; // When true, media parts in tool-result messages are split into a follow-up