@@ -14,7 +14,6 @@ import {
1414 type ContentPart ,
1515 type Message ,
1616 type Tool ,
17- type ToolCall ,
1817} from "~/services/copilot/create-chat-completions"
1918
2019import {
@@ -86,6 +85,34 @@ export function translateGeminiToOpenAI(
8685 return result
8786}
8887
88+ // Helper function to match function name to tool call ID and emit tool response
89+ function matchAndEmitToolResponse ( options : {
90+ functionName : string
91+ functionResponse : unknown
92+ pendingToolCalls : Map < string , string >
93+ messages : Array < Message >
94+ } ) : void {
95+ const { functionName, functionResponse, pendingToolCalls, messages } = options
96+
97+ // Find tool call ID by searching through the map
98+ let matchedToolCallId : string | undefined
99+ for ( const [ toolCallId , mappedFunctionName ] of pendingToolCalls . entries ( ) ) {
100+ if ( mappedFunctionName === functionName ) {
101+ matchedToolCallId = toolCallId
102+ break
103+ }
104+ }
105+
106+ if ( matchedToolCallId ) {
107+ messages . push ( {
108+ role : "tool" ,
109+ tool_call_id : matchedToolCallId ,
110+ content : JSON . stringify ( functionResponse ) ,
111+ } )
112+ pendingToolCalls . delete ( matchedToolCallId )
113+ }
114+ }
115+
89116// Helper function to process function response arrays
90117function processFunctionResponseArray (
91118 responseArray : Array < {
@@ -96,46 +123,14 @@ function processFunctionResponseArray(
96123) : void {
97124 for ( const responseItem of responseArray ) {
98125 if ( "functionResponse" in responseItem ) {
99- const functionName = responseItem . functionResponse . name
100- // Find tool call ID by searching through the map
101- let matchedToolCallId : string | undefined
102- for ( const [
103- toolCallId ,
104- mappedFunctionName ,
105- ] of pendingToolCalls . entries ( ) ) {
106- if ( mappedFunctionName === functionName ) {
107- matchedToolCallId = toolCallId
108- break
109- }
110- }
111- if ( matchedToolCallId ) {
112- messages . push ( {
113- role : "tool" ,
114- tool_call_id : matchedToolCallId ,
115- content : JSON . stringify ( responseItem . functionResponse . response ) ,
116- } )
117- pendingToolCalls . delete ( matchedToolCallId )
118- }
119- }
120- }
121- }
122-
123- // Helper function to check if tool calls have corresponding tool responses
124- function hasCorrespondingToolResponses (
125- messages : Array < Message > ,
126- toolCalls : Array < ToolCall > ,
127- ) : boolean {
128- const toolCallIds = new Set ( toolCalls . map ( ( call ) => call . id ) )
129-
130- // Look for tool messages that respond to these tool calls
131- for ( const message of messages ) {
132- if ( message . role === "tool" && message . tool_call_id ) {
133- toolCallIds . delete ( message . tool_call_id )
126+ matchAndEmitToolResponse ( {
127+ functionName : responseItem . functionResponse . name ,
128+ functionResponse : responseItem . functionResponse . response ,
129+ pendingToolCalls,
130+ messages,
131+ } )
134132 }
135133 }
136-
137- // If any tool call ID remains, it means there's no corresponding response
138- return toolCallIds . size === 0
139134}
140135
141136// Helper function to process function responses in content
@@ -145,23 +140,12 @@ function processFunctionResponses(
145140 messages : Array < Message > ,
146141) : void {
147142 for ( const funcResponse of functionResponses ) {
148- const functionName = funcResponse . functionResponse . name
149- // Find tool call ID by searching through the map
150- let matchedToolCallId : string | undefined
151- for ( const [ toolCallId , mappedFunctionName ] of pendingToolCalls . entries ( ) ) {
152- if ( mappedFunctionName === functionName ) {
153- matchedToolCallId = toolCallId
154- break
155- }
156- }
157- if ( matchedToolCallId ) {
158- messages . push ( {
159- role : "tool" ,
160- tool_call_id : matchedToolCallId ,
161- content : JSON . stringify ( funcResponse . functionResponse . response ) ,
162- } )
163- pendingToolCalls . delete ( matchedToolCallId )
164- }
143+ matchAndEmitToolResponse ( {
144+ functionName : funcResponse . functionResponse . name ,
145+ functionResponse : funcResponse . functionResponse . response ,
146+ pendingToolCalls,
147+ messages,
148+ } )
165149 }
166150}
167151
@@ -237,29 +221,6 @@ function canMergeMessages(
237221 )
238222}
239223
240- // Helper function to check if message should be skipped
241- function shouldSkipMessage (
242- message : Message ,
243- messages : Array < Message > ,
244- seenToolCallIds : Set < string > ,
245- ) : boolean {
246- // Skip incomplete assistant messages with tool calls that have no responses
247- if (
248- message . role === "assistant"
249- && message . tool_calls
250- && ! hasCorrespondingToolResponses ( messages , message . tool_calls )
251- ) {
252- return true
253- }
254-
255- // Skip duplicate tool responses
256- if ( isDuplicateToolResponse ( message , seenToolCallIds ) ) {
257- return true
258- }
259-
260- return false
261- }
262-
263224// Helper function to process and add message to cleaned array
264225function processAndAddMessage (
265226 message : Message ,
@@ -295,8 +256,28 @@ function cleanupMessages(messages: Array<Message>): Array<Message> {
295256 const cleanedMessages : Array < Message > = [ ]
296257 const seenToolCallIds = new Set < string > ( )
297258
259+ // Pre-build a set of all tool_call_ids that have tool responses (O(n))
260+ const toolCallIdsWithResponses = new Set < string > ( )
298261 for ( const message of messages ) {
299- if ( shouldSkipMessage ( message , messages , seenToolCallIds ) ) {
262+ if ( message . role === "tool" && message . tool_call_id ) {
263+ toolCallIdsWithResponses . add ( message . tool_call_id )
264+ }
265+ }
266+
267+ for ( const message of messages ) {
268+ // Skip incomplete assistant messages with tool calls that have no responses
269+ if ( message . role === "assistant" && message . tool_calls ) {
270+ // Check if all tool calls have responses
271+ const hasAllResponses = message . tool_calls . every ( ( call ) =>
272+ toolCallIdsWithResponses . has ( call . id ) ,
273+ )
274+ if ( ! hasAllResponses ) {
275+ continue
276+ }
277+ }
278+
279+ // Skip duplicate tool responses
280+ if ( isDuplicateToolResponse ( message , seenToolCallIds ) ) {
300281 continue
301282 }
302283
@@ -306,6 +287,38 @@ function cleanupMessages(messages: Array<Message>): Array<Message> {
306287 return cleanedMessages
307288}
308289
290+ /**
291+ * Translates Gemini conversation contents to OpenAI message format.
292+ *
293+ * This function handles complex transformations including:
294+ * - Converting Gemini "model" role to OpenAI "assistant" role
295+ * - Processing tool calls (function calls) and their responses
296+ * - Managing tool call ID mapping through pendingToolCalls Map
297+ * - Handling special nested array format for function responses (Gemini CLI compatibility)
298+ * - Cleaning up incomplete tool calls and deduplicating tool responses
299+ *
300+ * @remarks
301+ * The `pendingToolCalls` Map maintains the relationship between generated tool_call_ids
302+ * and function names throughout the conversation. This is necessary because:
303+ * - Gemini function calls don't have IDs, but OpenAI tool calls require them
304+ * - We generate IDs when translating function calls to tool calls
305+ * - Later function responses need to reference these IDs via tool_call_id
306+ *
307+ * Tool Call Matching Strategy:
308+ * - When a function call is encountered, generate a tool_call_id and store it in pendingToolCalls
309+ * - When a function response is encountered, look up the corresponding tool_call_id by function name
310+ * - After matching, remove the tool_call_id from pendingToolCalls to prevent duplicate matches
311+ *
312+ * Special Cases:
313+ * - Nested array format: Gemini CLI sometimes sends function responses as `Array<{functionResponse: ...}>`
314+ * instead of inside GeminiContent.parts. We detect and handle this format separately.
315+ * - Incomplete tool calls: Assistant messages with tool calls that have no corresponding responses
316+ * are filtered out during the cleanup phase to avoid OpenAI API errors.
317+ *
318+ * @param contents - Array of Gemini conversation contents (may include nested arrays for function responses)
319+ * @param systemInstruction - Optional system instruction to prepend to the conversation
320+ * @returns Array of OpenAI-compatible messages
321+ */
309322function translateGeminiContentsToOpenAI (
310323 contents : Array <
311324 | GeminiContent
@@ -489,13 +502,25 @@ function translateOpenAIMessageToGeminiContent(
489502 parts . push ( {
490503 functionCall : {
491504 name : toolCall . function . name ,
492- args :
493- toolCall . function . arguments ?
494- ( JSON . parse ( toolCall . function . arguments ) as Record <
495- string ,
496- unknown
497- > )
498- : { } ,
505+ args : ( ( ) => {
506+ if ( toolCall . function . arguments ) {
507+ try {
508+ return JSON . parse ( toolCall . function . arguments ) as Record <
509+ string ,
510+ unknown
511+ >
512+ } catch ( error ) {
513+ if ( process . env . DEBUG_GEMINI_REQUESTS === "true" ) {
514+ console . warn (
515+ `[DEBUG] Failed to parse toolCall.function.arguments: "${ toolCall . function . arguments } ". Error:` ,
516+ error ,
517+ )
518+ }
519+ return { }
520+ }
521+ }
522+ return { }
523+ } ) ( ) ,
499524 } ,
500525 } )
501526 }
0 commit comments