From ee82dcf299f2d7757313251755ad1062ad808d83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=9C=E5=BC=A6?= Date: Tue, 1 Sep 2026 15:24:01 +0800 Subject: [PATCH 1/2] feat(chat-completions): complete Gemini protocol conversion --- src/chat-completions/__tests__/gemini.test.ts | 305 ++++++++++- src/chat-completions/gemini.ts | 495 +++++++++++------- 2 files changed, 605 insertions(+), 195 deletions(-) diff --git a/src/chat-completions/__tests__/gemini.test.ts b/src/chat-completions/__tests__/gemini.test.ts index c43fce1..a24b0d0 100644 --- a/src/chat-completions/__tests__/gemini.test.ts +++ b/src/chat-completions/__tests__/gemini.test.ts @@ -98,6 +98,7 @@ describe("ChatCompletionToGeminiConverter", () => { const toolParts = (result.contents as any[])[2].parts!; expect(toolParts[0].functionResponse).toBeDefined(); expect(toolParts[0].functionResponse!.id).toBe("call_1"); + expect(toolParts[0].functionResponse!.name).toBe("get_weather"); expect(toolParts[0].functionResponse!.response).toEqual({ output: "72F", }); @@ -136,6 +137,17 @@ describe("ChatCompletionToGeminiConverter", () => { expect(result.config?.maxOutputTokens).toBe(1000); }); + it("prefers max_completion_tokens over deprecated max_tokens", () => { + const result = converter.convertRequest({ + model: "gemini-2.0-flash", + messages: [{ role: "user", content: "Hi" }], + max_completion_tokens: 1000, + max_tokens: 500, + }); + + expect(result.config?.maxOutputTokens).toBe(1000); + }); + it("maps temperature and top_p", () => { const result = converter.convertRequest({ model: "gemini-2.0-flash", @@ -257,6 +269,16 @@ describe("ChatCompletionToGeminiConverter", () => { expect(result.config?.responseMimeType).toBe("application/json"); }); + it("converts text response_format", () => { + const result = converter.convertRequest({ + model: "gemini-2.0-flash", + messages: [{ role: "user", content: "Hi" }], + response_format: { type: "text" }, + }); + + expect(result.config?.responseMimeType).toBe("text/plain"); + }); + it("maps reasoning_effort to thinkingConfig", () => { const result = converter.convertRequest({ model: "gemini-2.0-flash", @@ -324,7 +346,7 @@ describe("ChatCompletionToGeminiConverter", () => { const parts = (result.contents as any[])[0].parts!; expect(parts[0]).toEqual({ - fileData: { fileUri: "https://example.com/img.png", mimeType: "image/*" }, + fileData: { fileUri: "https://example.com/img.png", mimeType: "image/jpeg" }, }); }); @@ -392,6 +414,27 @@ describe("ChatCompletionToGeminiConverter", () => { }); }); + it("infers PDF MIME type for remote file data", () => { + const result = converter.convertRequest({ + model: "gemini-2.0-flash", + messages: [ + { + role: "user", + content: [ + { type: "file", file: { file_data: "https://example.com/document.pdf?x=1" } }, + ], + }, + ], + } as any); + + expect((result.contents as any[])[0].parts![0]).toEqual({ + fileData: { + fileUri: "https://example.com/document.pdf?x=1", + mimeType: "application/pdf", + }, + }); + }); + it("converts input_audio content part", () => { const result = converter.convertRequest({ model: "gemini-2.0-flash", @@ -503,7 +546,7 @@ describe("ChatCompletionToGeminiConverter", () => { ]); }); - it("converts thought parts to reasoning", () => { + it("leaves non-standard thought fields out of the standard response", () => { const result = converter.convertResponse( makeResponse({ candidates: [ @@ -522,12 +565,8 @@ describe("ChatCompletionToGeminiConverter", () => { ); expect(result.choices[0].message.content).toBe("42"); - expect((result.choices[0].message as any).reasoning).toBe("Let me think..."); - expect((result.choices[0].message as any).reasoning_details[0]).toEqual({ - type: "reasoning.text", - text: "Let me think...", - signature: "sig123", - }); + expect((result.choices[0].message as any).reasoning).toBeUndefined(); + expect((result.choices[0].message as any).reasoning_details).toBeUndefined(); }); it("maps MAX_TOKENS finish reason to length", () => { @@ -576,6 +615,7 @@ describe("ChatCompletionToGeminiConverter", () => { expect(result.usage?.prompt_tokens).toBe(100); expect(result.usage?.completion_tokens).toBe(70); expect(result.usage?.prompt_tokens_details?.cached_tokens).toBe(30); + expect(result.usage?.prompt_tokens_details?.audio_tokens).toBe(0); expect(result.usage?.completion_tokens_details?.reasoning_tokens).toBe(20); }); @@ -643,8 +683,106 @@ describe("ChatCompletionToGeminiConverter", () => { it("handles empty candidates", () => { const result = converter.convertResponse(makeResponse({ candidates: [] })); - expect(result.choices[0].message.content).toBeNull(); - expect(result.choices[0].finish_reason).toBe("stop"); + expect(result.choices).toEqual([]); + }); + + it("converts prompt safety feedback to a filtered refusal", () => { + const result = converter.convertResponse( + makeResponse({ + candidates: [], + promptFeedback: { + blockReason: "SAFETY", + blockReasonMessage: "Blocked by safety policy", + } as any, + }) + ); + + expect(result.choices).toEqual([ + { + index: 0, + message: { + role: "assistant", + content: null, + refusal: "Blocked by safety policy", + }, + finish_reason: "content_filter", + logprobs: null, + }, + ]); + }); + + it("converts all candidates and preserves candidate indexes", () => { + const result = converter.convertResponse( + makeResponse({ + candidates: [ + { + index: 2, + content: { role: "model", parts: [{ text: "Second" }] }, + finishReason: "STOP", + } as Candidate, + { + index: 4, + content: { role: "model", parts: [{ text: "Fourth" }] }, + finishReason: "MAX_TOKENS", + } as Candidate, + ], + }) + ); + + expect(result.choices.map(choice => choice.index)).toEqual([2, 4]); + expect(result.choices.map(choice => choice.message.content)).toEqual(["Second", "Fourth"]); + expect(result.choices.map(choice => choice.finish_reason)).toEqual(["stop", "length"]); + }); + + it("omits usage when Gemini does not return usage metadata", () => { + const result = converter.convertResponse(makeResponse({ usageMetadata: undefined })); + + expect(result.usage).toBeUndefined(); + }); + + it("omits usage when Gemini returns empty usage metadata", () => { + const result = converter.convertResponse(makeResponse({ usageMetadata: {} as any })); + + expect(result.usage).toBeUndefined(); + }); + + it("converts Gemini token log probabilities", () => { + const result = converter.convertResponse( + makeResponse({ + candidates: [ + { + content: { role: "model", parts: [{ text: "A" }] }, + finishReason: "STOP", + logprobsResult: { + chosenCandidates: [{ token: "A", logProbability: -0.1 }], + topCandidates: [ + { + candidates: [ + { token: "A", logProbability: -0.1 }, + { token: "B", logProbability: -1.2 }, + ], + }, + ], + }, + } as Candidate, + ], + }) + ); + + expect(result.choices[0].logprobs).toEqual({ + content: [ + { + token: "A", + bytes: [65], + logprob: -0.1, + top_logprobs: [ + { token: "A", bytes: [65], logprob: -0.1 }, + { token: "B", bytes: [66], logprob: -1.2 }, + ], + }, + ], + refusal: null, + }); }); }); @@ -692,6 +830,60 @@ describe("ChatCompletionToGeminiConverter", () => { expect(textChunk).toBeDefined(); }); + it("emits explicit null logprobs for stream choices without probability data", () => { + const c = new ChatCompletionToGeminiConverter(); + const events = c.convertStreamChunk( + makeStreamChunk({ + candidates: [ + { + content: { + role: "model", + parts: [ + { text: "Hello" }, + { + functionCall: { + id: "call_1", + name: "get_weather", + args: { city: "SF" }, + }, + }, + ], + }, + finishReason: "STOP", + } as Candidate, + ], + }) + ); + + const choices = events.flatMap(event => event.choices); + expect(choices.length).toBeGreaterThan(0); + expect(choices.every(choice => choice.logprobs === null)).toBe(true); + }); + + it("preserves real logprobs on stream content choices", () => { + const c = new ChatCompletionToGeminiConverter(); + const events = c.convertStreamChunk( + makeStreamChunk({ + candidates: [ + { + content: { role: "model", parts: [{ text: "Hello" }] }, + logprobsResult: { + chosenCandidates: [{ token: "Hello", logProbability: -0.1 }], + }, + } as Candidate, + ], + }) + ); + + const textChoice = events + .flatMap(event => event.choices) + .find(choice => choice.delta.content === "Hello"); + expect(textChoice?.logprobs?.content?.[0]).toMatchObject({ + token: "Hello", + logprob: -0.1, + }); + }); + it("emits each incremental text chunk", () => { const c = new ChatCompletionToGeminiConverter(); c.convertStreamChunk(makeStreamChunk()); @@ -751,7 +943,42 @@ describe("ChatCompletionToGeminiConverter", () => { expect(toolChunk).toBeDefined(); }); - it("emits reasoning for thought parts", () => { + it("keeps a stable tool call index when Gemini repeats the same call ID", () => { + const c = new ChatCompletionToGeminiConverter(); + c.convertStreamChunk(makeStreamChunk()); + + const functionCall = { + id: "call_1", + name: "get_weather", + args: { city: "SF" }, + }; + const firstEvents = c.convertStreamChunk( + makeStreamChunk({ + candidates: [ + { + content: { role: "model", parts: [{ functionCall }] }, + } as Candidate, + ], + }) + ); + const repeatedEvents = c.convertStreamChunk( + makeStreamChunk({ + candidates: [ + { + content: { role: "model", parts: [{ functionCall }] }, + } as Candidate, + ], + }) + ); + + const firstToolCall = firstEvents.flatMap(event => event.choices)[0].delta.tool_calls?.[0]; + const repeatedToolCall = repeatedEvents.flatMap(event => event.choices)[0].delta + .tool_calls?.[0]; + expect(firstToolCall?.index).toBe(0); + expect(repeatedToolCall?.index).toBe(0); + }); + + it("leaves non-standard thought fields out of standard stream chunks", () => { const c = new ChatCompletionToGeminiConverter(); c.convertStreamChunk(makeStreamChunk()); @@ -771,7 +998,7 @@ describe("ChatCompletionToGeminiConverter", () => { const reasoningChunk = events.find( e => (e.choices[0]?.delta as any)?.reasoning === "Thinking..." ); - expect(reasoningChunk).toBeDefined(); + expect(reasoningChunk).toBeUndefined(); }); it("emits finish_reason on final chunk", () => { @@ -804,9 +1031,59 @@ describe("ChatCompletionToGeminiConverter", () => { ); const finishChunk = events.find(e => e.choices[0]?.finish_reason === "stop"); + const usageChunk = events.find(e => e.choices.length === 0 && e.usage != null); expect(finishChunk).toBeDefined(); - expect(finishChunk!.usage?.prompt_tokens).toBe(10); - expect(finishChunk!.usage?.completion_tokens).toBe(5); + expect(usageChunk?.usage?.prompt_tokens).toBe(10); + expect(usageChunk?.usage?.completion_tokens).toBe(5); + }); + + it("emits stream choices for every Gemini candidate", () => { + const c = new ChatCompletionToGeminiConverter(); + const events = c.convertStreamChunk( + makeStreamChunk({ + candidates: [ + { + index: 1, + content: { role: "model", parts: [{ text: "One" }] }, + finishReason: "STOP", + } as Candidate, + { + index: 3, + content: { role: "model", parts: [{ text: "Three" }] }, + finishReason: "STOP", + } as Candidate, + ], + }) + ); + + const choices = events.flatMap(event => event.choices); + expect(choices.some(choice => choice.index === 1 && choice.delta.content === "One")).toBe( + true + ); + expect(choices.some(choice => choice.index === 3 && choice.delta.content === "Three")).toBe( + true + ); + expect( + choices.filter(choice => choice.finish_reason === "stop").map(choice => choice.index) + ).toEqual([1, 3]); + }); + + it("emits a filtered refusal for prompt safety feedback", () => { + const c = new ChatCompletionToGeminiConverter(); + const events = c.convertStreamChunk( + makeStreamChunk({ + candidates: [], + promptFeedback: { + blockReason: "SAFETY", + blockReasonMessage: "Blocked by safety policy", + } as any, + }) + ); + + const refusalChoice = events + .flatMap(event => event.choices) + .find(choice => choice.finish_reason === "content_filter"); + expect(refusalChoice?.delta.refusal).toBe("Blocked by safety policy"); }); }); diff --git a/src/chat-completions/gemini.ts b/src/chat-completions/gemini.ts index f9e8010..77ff4a4 100644 --- a/src/chat-completions/gemini.ts +++ b/src/chat-completions/gemini.ts @@ -3,6 +3,8 @@ import type { GenerateContentParameters, GenerateContentConfig, GenerateContentResponse, + GenerateContentResponseUsageMetadata, + Candidate, Content, Part, FunctionDeclaration, @@ -10,14 +12,16 @@ import type { FinishReason, } from "@google/genai"; +interface ChoiceStreamState { + toolCallCounter: number; + toolCallIndexes: Map; +} + interface StreamState { id: string; model: string; started: boolean; - toolCallCounter: number; - prevText: string; - prevThought: string; - seenFunctionCallIds: Set; + choices: Map; } export class ChatCompletionToGeminiConverter { @@ -32,6 +36,7 @@ export class ChatCompletionToGeminiConverter { convertRequest(params: OpenAI.ChatCompletionCreateParams): GenerateContentParameters { const systemParts: Part[] = []; const contents: Content[] = []; + const toolCallNames = new Map(); for (const msg of params.messages) { if (msg.role === "system" || msg.role === "developer") { @@ -44,12 +49,17 @@ export class ChatCompletionToGeminiConverter { parts: this.convertUserParts(msg.content), }); } else if (msg.role === "assistant") { + for (const toolCall of msg.tool_calls ?? []) { + if (toolCall.type === "function") { + toolCallNames.set(toolCall.id, toolCall.function.name); + } + } contents.push({ role: "model", parts: this.convertAssistantParts(msg), }); } else if (msg.role === "tool") { - this.appendToolResponse(contents, msg); + this.appendToolResponse(contents, msg, toolCallNames.get(msg.tool_call_id)); } } @@ -58,8 +68,8 @@ export class ChatCompletionToGeminiConverter { if (systemParts.length > 0) { config.systemInstruction = { parts: systemParts }; } - if (params.max_tokens != null || params.max_completion_tokens != null) { - config.maxOutputTokens = params.max_tokens ?? params.max_completion_tokens ?? undefined; + if (params.max_completion_tokens != null || params.max_tokens != null) { + config.maxOutputTokens = params.max_completion_tokens ?? params.max_tokens ?? undefined; } if (params.temperature != null) { config.temperature = params.temperature as number; @@ -123,93 +133,31 @@ export class ChatCompletionToGeminiConverter { // --- Response conversion (Gemini → CC, backward) --- convertResponse(response: GenerateContentResponse): OpenAI.ChatCompletion { - const candidate = response.candidates?.[0]; - const parts = candidate?.content?.parts ?? []; - - const textParts: string[] = []; - const thinkingParts: string[] = []; - const reasoningDetails: Array> = []; - const toolCalls: OpenAI.ChatCompletionMessageToolCall[] = []; - const annotations: OpenAI.ChatCompletionMessage.Annotation[] = []; - - for (const part of parts) { - if (part.thought && part.text) { - thinkingParts.push(part.text); - reasoningDetails.push({ - type: "reasoning.text", - text: part.text, - signature: part.thoughtSignature ?? undefined, - }); - } else if (part.functionCall) { - const fc = part.functionCall; - toolCalls.push({ - id: fc.id ?? fc.name ?? `call_${this.generateId()}`, - type: "function", - function: { - name: fc.name ?? "", - arguments: JSON.stringify(fc.args ?? {}), - }, + const choices = (response.candidates ?? []).map((candidate, index) => + this.convertCandidate(candidate, candidate.index ?? index) + ); + + if (choices.length === 0) { + const refusal = this.getPromptFeedbackRefusal(response); + if (refusal) { + choices.push({ + index: 0, + message: { role: "assistant", content: null, refusal }, + finish_reason: "content_filter", + logprobs: null, }); - } else if (part.text != null) { - textParts.push(part.text); } } - this.extractGroundingAnnotations(candidate, annotations); - - const assistantMessage: OpenAI.ChatCompletionMessage = { - role: "assistant", - content: textParts.length > 0 ? textParts.join("") : null, - refusal: null, - }; - - if (thinkingParts.length > 0) { - Object.assign(assistantMessage, { - reasoning: thinkingParts.join(""), - reasoning_details: reasoningDetails, - }); - } - if (toolCalls.length > 0) { - assistantMessage.tool_calls = toolCalls; - } - if (annotations.length > 0) { - assistantMessage.annotations = annotations; - } - - let finishReason = this.mapFinishReason(candidate?.finishReason); - if (toolCalls.length > 0) { - finishReason = "tool_calls"; - } - - const usage = response.usageMetadata; - const promptTokens = (usage?.promptTokenCount ?? 0) + (usage?.toolUsePromptTokenCount ?? 0); - const thoughtsTokens = usage?.thoughtsTokenCount ?? 0; - const candidatesTokens = (usage?.candidatesTokenCount ?? 0) + thoughtsTokens; + const usage = this.convertUsage(response.usageMetadata); return { id: response.responseId ?? `chatcmpl-${this.generateId()}`, object: "chat.completion", created: Math.floor(Date.now() / 1000), model: response.modelVersion ?? "", - choices: [ - { - index: 0, - message: assistantMessage, - finish_reason: finishReason, - logprobs: null, - }, - ], - usage: { - prompt_tokens: promptTokens, - completion_tokens: candidatesTokens, - total_tokens: usage?.totalTokenCount ?? 0, - prompt_tokens_details: { - cached_tokens: usage?.cachedContentTokenCount ?? 0, - }, - completion_tokens_details: { - reasoning_tokens: thoughtsTokens, - }, - }, + choices, + ...(usage && { usage }), }; } @@ -218,6 +166,7 @@ export class ChatCompletionToGeminiConverter { async *convertStream( stream: AsyncIterable ): AsyncIterable { + this.streamState = this.createStreamState(); for await (const chunk of stream) { const events = this.convertStreamChunk(chunk); for (const event of events) { @@ -229,8 +178,7 @@ export class ChatCompletionToGeminiConverter { convertStreamChunk(chunk: GenerateContentResponse): OpenAI.ChatCompletionChunk[] { const state = this.streamState; const events: OpenAI.ChatCompletionChunk[] = []; - const candidate = chunk.candidates?.[0]; - const parts = candidate?.content?.parts ?? []; + const candidates = chunk.candidates ?? []; if (chunk.modelVersion) { state.model = chunk.modelVersion; @@ -244,91 +192,124 @@ export class ChatCompletionToGeminiConverter { if (!state.id) { state.id = `chatcmpl-${this.generateId()}`; } - events.push(this.makeChunk({ role: "assistant" })); + const indexes = + candidates.length > 0 + ? candidates.map((candidate, index) => candidate.index ?? index) + : [0]; + events.push( + this.makeChunk( + indexes.map(index => ({ + index, + delta: { role: "assistant" }, + finish_reason: null, + })) + ) + ); } - for (const part of parts) { - if (part.thought && part.text) { + if (candidates.length === 0) { + const refusal = this.getPromptFeedbackRefusal(chunk); + if (refusal) { events.push( - this.makeChunk({ - content: "", - ...{ - reasoning: part.text, + this.makeChunk([ + { + index: 0, + delta: { refusal }, + finish_reason: "content_filter", }, - }) + ]) ); - } else if (part.functionCall) { - const fc = part.functionCall; - const fcId = fc.id ?? fc.name ?? ""; - if (!state.seenFunctionCallIds.has(fcId)) { - state.seenFunctionCallIds.add(fcId); - const index = state.toolCallCounter++; + } + } + + for (let position = 0; position < candidates.length; position++) { + const candidate = candidates[position]; + const choiceIndex = candidate.index ?? position; + const choiceState = this.getChoiceStreamState(choiceIndex); + + for (const part of candidate.content?.parts ?? []) { + if (part.thought) { + continue; + } + if (part.functionCall) { + const functionCall = part.functionCall; + const functionCallId = functionCall.id ?? `call_${this.generateId()}`; + let toolCallIndex = choiceState.toolCallIndexes.get(functionCallId); + if (toolCallIndex == null) { + toolCallIndex = choiceState.toolCallCounter++; + choiceState.toolCallIndexes.set(functionCallId, toolCallIndex); + } events.push( - this.makeChunk({ - tool_calls: [ - { - index, - id: fc.id ?? `call_${this.generateId()}`, - type: "function", - function: { - name: fc.name ?? "", - arguments: JSON.stringify(fc.args ?? {}), - }, + this.makeChunk([ + { + index: choiceIndex, + delta: { + tool_calls: [ + { + index: toolCallIndex, + id: functionCallId, + type: "function", + function: { + name: functionCall.name ?? "", + arguments: JSON.stringify(functionCall.args ?? {}), + }, + }, + ], }, - ], - }) + finish_reason: null, + }, + ]) + ); + } else if (part.text != null && part.text !== "") { + events.push( + this.makeChunk([ + { + index: choiceIndex, + delta: { content: part.text }, + finish_reason: null, + logprobs: this.convertLogprobs(candidate), + }, + ]) ); } - } else if (part.text != null && part.text !== "") { - events.push(this.makeChunk({ content: part.text })); } - } - const annotations: OpenAI.ChatCompletionMessage.Annotation[] = []; - this.extractGroundingAnnotations(candidate, annotations); - if (annotations.length > 0) { - events.push( - this.makeChunk({ - content: "", - ...{ annotations }, - }) - ); - } - - if (candidate?.finishReason) { - let finishReason = this.mapFinishReason(candidate.finishReason); - if (state.toolCallCounter > 0) { - finishReason = "tool_calls"; + const annotations: OpenAI.ChatCompletionMessage.Annotation[] = []; + this.extractGroundingAnnotations(candidate, annotations); + if (annotations.length > 0) { + events.push( + this.makeChunk([ + { + index: choiceIndex, + delta: { content: "", ...{ annotations } }, + finish_reason: null, + }, + ]) + ); } - const usage = chunk.usageMetadata; - - const finishChunk: OpenAI.ChatCompletionChunk = { - id: state.id, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: state.model, - choices: [{ index: 0, delta: {}, finish_reason: finishReason }], - }; - if (usage) { - const promptTokens = (usage.promptTokenCount ?? 0) + (usage.toolUsePromptTokenCount ?? 0); - const thoughtsTokens = usage.thoughtsTokenCount ?? 0; - const candidatesTokens = (usage.candidatesTokenCount ?? 0) + thoughtsTokens; - - finishChunk.usage = { - prompt_tokens: promptTokens, - completion_tokens: candidatesTokens, - total_tokens: usage.totalTokenCount ?? 0, - prompt_tokens_details: { - cached_tokens: usage.cachedContentTokenCount ?? 0, - }, - completion_tokens_details: { - reasoning_tokens: thoughtsTokens, - }, - }; + if (candidate.finishReason) { + events.push( + this.makeChunk([ + { + index: choiceIndex, + delta: {}, + finish_reason: + choiceState.toolCallCounter > 0 + ? "tool_calls" + : this.mapFinishReason(candidate.finishReason), + }, + ]) + ); } + } - events.push(finishChunk); + const usage = this.convertUsage(chunk.usageMetadata); + if (usage) { + events.push({ + ...this.makeChunk([]), + usage, + }); } return events; @@ -340,20 +321,20 @@ export class ChatCompletionToGeminiConverter { if (typeof content === "string") { return [{ text: content }]; } - return content.map(part => { + return content.flatMap(part => { if (part.type === "text") { - return { text: part.text }; + return [{ text: part.text }]; } if (part.type === "image_url") { - return this.convertImageUrl(part); + return [this.convertImageUrl(part)]; } if (part.type === "file") { - return this.convertFile(part as any); + return [this.convertFile(part)]; } if (part.type === "input_audio") { - return this.convertInputAudio(part as any); + return [this.convertInputAudio(part)]; } - return { text: `[Unsupported content type: ${(part as any).type}]` }; + return []; }); } @@ -373,7 +354,7 @@ export class ChatCompletionToGeminiConverter { return { fileData: { fileUri: url, - mimeType: "image/*", + mimeType: "image/jpeg", }, }; } @@ -418,12 +399,13 @@ export class ChatCompletionToGeminiConverter { private appendToolResponse( contents: Content[], - msg: OpenAI.ChatCompletionToolMessageParam + msg: OpenAI.ChatCompletionToolMessageParam, + functionName?: string ): void { const responsePart: Part = { functionResponse: { id: msg.tool_call_id, - name: msg.tool_call_id, + name: functionName ?? msg.tool_call_id, response: { output: typeof msg.content === "string" ? msg.content : msg.content.map(p => p.text).join("\n"), @@ -443,9 +425,11 @@ export class ChatCompletionToGeminiConverter { contents.push({ role: "user", parts: [responsePart] }); } - private convertFile(part: any): Part { + private convertFile(part: OpenAI.ChatCompletionContentPart.File): Part { const fileData = part.file?.file_data; - if (!fileData) return { text: "[Missing file data]" }; + if (!fileData) { + throw new Error("Chat Completions file content requires file_data for Gemini conversion"); + } if (fileData.startsWith("data:")) { const match = fileData.match(/^data:([^;]+);base64,(.+)$/); @@ -454,12 +438,33 @@ export class ChatCompletionToGeminiConverter { } } - return { fileData: { fileUri: fileData, mimeType: "application/octet-stream" } }; + return { + fileData: { + fileUri: fileData, + mimeType: this.inferFileMimeType(part.file.filename ?? fileData), + }, + }; } - private convertInputAudio(part: any): Part { + private inferFileMimeType(source: string): string { + const pathname = source.split(/[?#]/, 1)[0].toLowerCase(); + const mimeTypes: Record = { + ".pdf": "application/pdf", + ".mp4": "video/mp4", + ".avi": "video/x-msvideo", + ".mov": "video/quicktime", + ".mpeg": "video/mpeg", + ".webm": "video/webm", + }; + const extension = Object.keys(mimeTypes).find(candidate => pathname.endsWith(candidate)); + return extension ? mimeTypes[extension] : "application/octet-stream"; + } + + private convertInputAudio(part: OpenAI.ChatCompletionContentPartInputAudio): Part { const inputAudio = part.input_audio; - if (!inputAudio) return { inlineData: {} }; + if (!inputAudio) { + return { inlineData: {} }; + } const mimeMap: Record = { mp3: "audio/mp3", @@ -524,7 +529,9 @@ export class ChatCompletionToGeminiConverter { config: GenerateContentConfig, format: NonNullable ): void { - if ("json_schema" in format && format.type === "json_schema") { + if (format.type === "text") { + config.responseMimeType = "text/plain"; + } else if ("json_schema" in format && format.type === "json_schema") { config.responseMimeType = "application/json"; config.responseJsonSchema = format.json_schema.schema; } else if (format.type === "json_object") { @@ -553,6 +560,124 @@ export class ChatCompletionToGeminiConverter { // --- Private: response helpers --- + private convertCandidate(candidate: Candidate, index: number): OpenAI.ChatCompletion.Choice { + const textParts: string[] = []; + const toolCalls: OpenAI.ChatCompletionMessageToolCall[] = []; + const annotations: OpenAI.ChatCompletionMessage.Annotation[] = []; + + for (const part of candidate.content?.parts ?? []) { + if (part.thought) { + continue; + } + if (part.functionCall) { + const functionCall = part.functionCall; + toolCalls.push({ + id: functionCall.id ?? functionCall.name ?? `call_${this.generateId()}`, + type: "function", + function: { + name: functionCall.name ?? "", + arguments: JSON.stringify(functionCall.args ?? {}), + }, + }); + } else if (part.text != null) { + textParts.push(part.text); + } + } + + this.extractGroundingAnnotations(candidate, annotations); + + return { + index, + message: { + role: "assistant", + content: textParts.join(""), + refusal: null, + ...(toolCalls.length > 0 && { tool_calls: toolCalls }), + ...(annotations.length > 0 && { annotations }), + }, + finish_reason: + toolCalls.length > 0 ? "tool_calls" : this.mapFinishReason(candidate.finishReason), + logprobs: this.convertLogprobs(candidate), + }; + } + + private getPromptFeedbackRefusal(response: GenerateContentResponse): string | null { + const feedback = response.promptFeedback; + if (!feedback?.blockReason) { + return null; + } + return feedback.blockReasonMessage ?? feedback.blockReason; + } + + private convertUsage( + usage?: GenerateContentResponseUsageMetadata + ): OpenAI.CompletionUsage | undefined { + if ( + !usage || + [ + usage.promptTokenCount, + usage.candidatesTokenCount, + usage.totalTokenCount, + usage.cachedContentTokenCount, + usage.thoughtsTokenCount, + usage.toolUsePromptTokenCount, + usage.promptTokensDetails, + usage.candidatesTokensDetails, + usage.cacheTokensDetails, + usage.toolUsePromptTokensDetails, + ].every(value => value == null) + ) { + return undefined; + } + + const thoughtsTokens = usage.thoughtsTokenCount ?? 0; + const promptTokens = (usage.promptTokenCount ?? 0) + (usage.toolUsePromptTokenCount ?? 0); + const completionTokens = (usage.candidatesTokenCount ?? 0) + thoughtsTokens; + const promptAudioTokens = usage.promptTokensDetails?.find( + detail => detail.modality === "AUDIO" + )?.tokenCount; + + return { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: usage.totalTokenCount ?? 0, + prompt_tokens_details: { + cached_tokens: usage.cachedContentTokenCount ?? 0, + audio_tokens: promptAudioTokens ?? 0, + }, + completion_tokens_details: { + reasoning_tokens: thoughtsTokens, + }, + }; + } + + private convertLogprobs(candidate: Candidate): OpenAI.ChatCompletion.Choice.Logprobs | null { + const chosenCandidates = candidate.logprobsResult?.chosenCandidates; + if (!chosenCandidates || chosenCandidates.length === 0) { + return null; + } + + return { + content: chosenCandidates.map((chosen, index) => ({ + token: chosen.token ?? "", + bytes: this.toUtf8Bytes(chosen.token), + logprob: chosen.logProbability ?? -9999, + top_logprobs: (candidate.logprobsResult?.topCandidates?.[index]?.candidates ?? []).map( + topCandidate => ({ + token: topCandidate.token ?? "", + bytes: this.toUtf8Bytes(topCandidate.token), + logprob: topCandidate.logProbability ?? -9999, + }) + ), + })), + refusal: null, + }; + } + + private toUtf8Bytes(value?: string): number[] | null { + return value == null ? null : Array.from(new TextEncoder().encode(value)); + } + private extractGroundingAnnotations( candidate: any, annotations: OpenAI.ChatCompletionMessage.Annotation[] @@ -583,7 +708,6 @@ export class ChatCompletionToGeminiConverter { case "MAX_TOKENS": return "length"; case "SAFETY": - case "RECITATION": case "BLOCKLIST": case "PROHIBITED_CONTENT": return "content_filter"; @@ -603,23 +727,32 @@ export class ChatCompletionToGeminiConverter { id: "", model: "", started: false, - toolCallCounter: 0, - prevText: "", - prevThought: "", - seenFunctionCallIds: new Set(), + choices: new Map(), }; } - private makeChunk( - delta: OpenAI.ChatCompletionChunk.Choice.Delta, - finish_reason: OpenAI.ChatCompletionChunk.Choice["finish_reason"] = null - ): OpenAI.ChatCompletionChunk { + private getChoiceStreamState(index: number): ChoiceStreamState { + let choiceState = this.streamState.choices.get(index); + if (!choiceState) { + choiceState = { + toolCallCounter: 0, + toolCallIndexes: new Map(), + }; + this.streamState.choices.set(index, choiceState); + } + return choiceState; + } + + private makeChunk(choices: OpenAI.ChatCompletionChunk.Choice[]): OpenAI.ChatCompletionChunk { return { id: this.streamState.id, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model: this.streamState.model, - choices: [{ index: 0, delta, finish_reason }], + choices: choices.map(choice => ({ + ...choice, + logprobs: choice.logprobs ?? null, + })), }; } } From 6f550778428bb0bb69cc91e2962c8c67fa63a525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=9C=E5=BC=A6?= Date: Tue, 1 Sep 2026 20:21:22 +0800 Subject: [PATCH 2/2] chore: update version to 1.0.20 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f896dcb..419be57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zenmux/rosetta-ai", - "version": "1.0.19", + "version": "1.0.20", "description": "Universal translator between AI provider protocols", "main": "dist/index.js", "types": "dist/index.d.ts",