From 7abace590d480c9c6b7477972abf6008244cdced Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:26:19 -0300 Subject: [PATCH] =?UTF-8?q?feat(translator):=20OpenAI=20SSE=20=E2=86=92=20?= =?UTF-8?q?Gemini=20SSE=20conversion=20for=20/v1beta/models=20route=20(#44?= =?UTF-8?q?53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.32 --- CHANGELOG.md | 1 + .../response/openai-to-gemini-sse.ts | 351 ++++++++++++++++++ src/app/api/v1beta/models/[...path]/route.ts | 68 +++- .../translator-openai-to-gemini-sse.test.ts | 228 ++++++++++++ 4 files changed, 633 insertions(+), 15 deletions(-) create mode 100644 open-sse/translator/response/openai-to-gemini-sse.ts create mode 100644 tests/unit/translator-openai-to-gemini-sse.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 84e34931b9..4b77e88d7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ _In development — bullets added per PR; finalized at release._ ### ✨ New Features - **feat(providers): expand the openai and gemini direct registries with first-class variants already known elsewhere** — the `openai` provider entry now exposes `gpt-4.1-mini`, `gpt-4.1-nano`, `o3-mini`, and `o4-mini` (the latter two carry `REASONING_UNSUPPORTED` like `o3`), and the `gemini` entry now exposes `gemini-2.0-flash-lite` and `gemini-3-flash-lite-preview`. These models were already first-class throughout sibling subsystems (cost estimator, task fitness, free-model catalog, multiple aggregator registries) but happened to be missing from the direct openai/gemini namespaces. Embedding/TTS/image-gen models stay in their dedicated registries (`embeddingRegistry.ts`, `audioRegistry.ts`, `imageRegistry.ts`); legacy ids OmniRoute curated out (o1, gpt-4-turbo, …) are not restored. (thanks @East-rayyy) +- **feat(translator): OpenAI SSE → Gemini SSE conversion for `/v1beta/models/{model}:streamGenerateContent`** — the `@google/genai` SDK (Gemini CLI) always calls `:streamGenerateContent?alt=sse` for chat and expects Gemini SSE chunks (no `[DONE]` sentinel — the stream just closes). The v1beta route was forwarding OpenAI SSE from `handleChat` unchanged, so the SDK crashed on the OpenAI `[DONE]` line with `SyntaxError: Unexpected token 'D', "[DONE]" is not valid JSON`. A new `transformOpenAISSEToGeminiSSE()` (in `open-sse/translator/response/openai-to-gemini-sse.ts`) rewrites each OpenAI delta into `candidates[].content.parts[]`, maps `finish_reason` → `finishReason` (STOP / MAX_TOKENS / SAFETY), attaches `usageMetadata` + `modelVersion` on the final chunk, and surfaces `reasoning_content` as `{ thought: true }` parts for thinking models. The non-streaming `:generateContent` action gets a sibling `convertOpenAIResponseToGemini()` for the JSON path. Streaming intent is now keyed off the URL action suffix (canonical Gemini convention) rather than the non-standard `generationConfig.stream` body field. (thanks @SteelMorgan) ### 🐛 Fixed diff --git a/open-sse/translator/response/openai-to-gemini-sse.ts b/open-sse/translator/response/openai-to-gemini-sse.ts new file mode 100644 index 0000000000..087af65ee8 --- /dev/null +++ b/open-sse/translator/response/openai-to-gemini-sse.ts @@ -0,0 +1,351 @@ +/** + * Convert an OpenAI Chat Completions stream/response into the Gemini + * `:streamGenerateContent` / `:generateContent` shape used by the + * `@google/genai` SDK (Gemini CLI). + * + * Why this exists + * --------------- + * The `/v1beta/models/{model}:streamGenerateContent` route delegates the + * actual LLM call to `handleChat`, which always returns OpenAI-format SSE: + * + * data: {"choices":[{"delta":{"content":"Hi"},"finish_reason":null}],...} + * data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{...},...} + * data: [DONE] + * + * `@google/genai` expects Gemini SSE, which has a different chunk shape + * AND no terminal sentinel — the stream simply closes: + * + * data: {"candidates":[{"content":{"role":"model","parts":[{"text":"Hi"}]},"index":0}]} + * data: {"candidates":[{"content":{"role":"model","parts":[{"text":""}]}, + * "finishReason":"STOP","index":0}],"usageMetadata":{...},"modelVersion":"..."} + * (stream closes — no [DONE]) + * + * Forwarding the raw OpenAI SSE to Gemini CLI made it crash with + * `SyntaxError: Unexpected token 'D', "[DONE]" is not valid JSON`, because + * the SDK tries to `JSON.parse("[DONE]")`. + * + * Ported from upstream decolua/9router#225 by @SteelMorgan. + */ + +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +/** Map OpenAI finish_reason → Gemini finishReason */ +export const OPENAI_TO_GEMINI_FINISH_REASON: Record = { + stop: "STOP", + length: "MAX_TOKENS", + tool_calls: "STOP", + content_filter: "SAFETY", +}; + +interface OpenAIChoiceDelta { + content?: string | null; + reasoning_content?: string | null; + role?: string; +} + +interface OpenAIChoice { + delta?: OpenAIChoiceDelta; + finish_reason?: string | null; +} + +interface OpenAIUsage { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + completion_tokens_details?: { + reasoning_tokens?: number; + }; +} + +interface OpenAIStreamChunk { + choices?: OpenAIChoice[]; + usage?: OpenAIUsage | null; + model?: string; +} + +interface GeminiPart { + text: string; + thought?: boolean; +} + +interface GeminiCandidate { + content: { role: "model"; parts: GeminiPart[] }; + index: number; + finishReason?: string; +} + +interface GeminiUsageMetadata { + promptTokenCount: number; + candidatesTokenCount: number; + totalTokenCount: number; + thoughtsTokenCount?: number; +} + +interface GeminiStreamChunk { + candidates: GeminiCandidate[]; + usageMetadata?: GeminiUsageMetadata; + modelVersion?: string; +} + +/** + * Build a Gemini-shape chunk from a single OpenAI delta event. + * + * Returns `null` when the event has nothing to forward (pure role-only + * delta with no content and no finish_reason) so the caller can skip it. + * + * Exported for unit testing the per-chunk mapping in isolation. + */ +export function openAIChunkToGeminiChunk( + parsed: OpenAIStreamChunk, + fallbackModel: string +): GeminiStreamChunk | null { + const choice = parsed.choices?.[0]; + if (!choice) return null; + + const delta: OpenAIChoiceDelta = choice.delta || {}; + + const parts: GeminiPart[] = []; + if (delta.reasoning_content) { + parts.push({ text: String(delta.reasoning_content), thought: true }); + } + if (delta.content) { + parts.push({ text: String(delta.content) }); + } + + // Skip pure role-only deltas with no content and no finish signal. + if (parts.length === 0 && !choice.finish_reason) return null; + + const candidate: GeminiCandidate = { + content: { + role: "model", + parts: parts.length > 0 ? parts : [{ text: "" }], + }, + index: 0, + }; + + if (choice.finish_reason) { + candidate.finishReason = + OPENAI_TO_GEMINI_FINISH_REASON[choice.finish_reason] ?? "STOP"; + } + + const out: GeminiStreamChunk = { candidates: [candidate] }; + + // Attach usage + modelVersion on the final chunk (when finish_reason is set). + if (choice.finish_reason && parsed.usage) { + const u = parsed.usage; + const usageMetadata: GeminiUsageMetadata = { + promptTokenCount: u.prompt_tokens || 0, + candidatesTokenCount: u.completion_tokens || 0, + totalTokenCount: u.total_tokens || 0, + }; + const reasoningTokens = u.completion_tokens_details?.reasoning_tokens; + if (reasoningTokens) { + usageMetadata.thoughtsTokenCount = reasoningTokens; + } + out.usageMetadata = usageMetadata; + out.modelVersion = parsed.model || fallbackModel; + } + + return out; +} + +/** + * Wrap an OpenAI-SSE upstream `Response` and return a new `Response` whose + * body is the equivalent Gemini SSE stream. + * + * Non-OK / no-body responses are passed through unchanged so that callers + * upstream of the route can surface the error to the client untouched. + */ +export function transformOpenAISSEToGeminiSSE( + upstreamResponse: Response, + model: string +): Response { + if (!upstreamResponse.ok || !upstreamResponse.body) { + return upstreamResponse; + } + + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + + // OpenAI SSE events are delimited by a blank line. A single `chunk` may + // contain partial lines; carry the trailing fragment over to the next + // chunk so we never JSON.parse a half-event. + let buffer = ""; + + const transform = new TransformStream({ + transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }); + const lines = buffer.split("\n"); + // Last entry may be a partial line — keep it for the next chunk. + buffer = lines.pop() ?? ""; + + for (const rawLine of lines) { + // Strip a trailing CR from CRLF-terminated upstreams. + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + if (!line.startsWith("data:")) continue; + + const data = line.slice(5).trim(); + // Drop empty lines and the OpenAI `[DONE]` sentinel — Gemini SSE + // ends by stream close, no sentinel needed. + if (!data || data === "[DONE]") continue; + + let parsed: OpenAIStreamChunk; + try { + parsed = JSON.parse(data) as OpenAIStreamChunk; + } catch { + continue; + } + + const geminiChunk = openAIChunkToGeminiChunk(parsed, model); + if (!geminiChunk) continue; + + controller.enqueue(encoder.encode("data: " + JSON.stringify(geminiChunk) + "\r\n\r\n")); + } + }, + flush(controller) { + // Drain any final buffered line. Gemini SSE ends on stream close — + // no `[DONE]` sentinel is emitted. + const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer; + buffer = ""; + if (!line.startsWith("data:")) return; + const data = line.slice(5).trim(); + if (!data || data === "[DONE]") return; + let parsed: OpenAIStreamChunk; + try { + parsed = JSON.parse(data) as OpenAIStreamChunk; + } catch { + return; + } + const geminiChunk = openAIChunkToGeminiChunk(parsed, model); + if (!geminiChunk) return; + controller.enqueue(encoder.encode("data: " + JSON.stringify(geminiChunk) + "\r\n\r\n")); + }, + }); + + return new Response(upstreamResponse.body.pipeThrough(transform), { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Access-Control-Allow-Origin": "*", + }, + }); +} + +interface OpenAIMessage { + content?: string | null; + reasoning_content?: string | null; + role?: string; +} + +interface OpenAINonStreamChoice { + message?: OpenAIMessage; + finish_reason?: string | null; +} + +interface OpenAINonStreamResponse { + candidates?: unknown; + error?: unknown; + choices?: OpenAINonStreamChoice[]; + usage?: OpenAIUsage | null; + model?: string; +} + +interface GeminiNonStreamResponse { + candidates: Array<{ + content: { role: "model"; parts: GeminiPart[] }; + finishReason: string; + index: number; + }>; + modelVersion: string; + usageMetadata?: GeminiUsageMetadata; +} + +/** + * Convert an OpenAI Chat Completions JSON response into a Gemini + * `GenerateContentResponse` JSON. Used by the non-streaming + * `:generateContent` path. + */ +export async function convertOpenAIResponseToGemini( + response: Response, + model: string +): Promise { + if (!response.ok) return response; + + let body: OpenAINonStreamResponse; + try { + body = (await response.json()) as OpenAINonStreamResponse; + } catch (err) { + // Body wasn't JSON. Surface a Gemini-shape error so the SDK doesn't + // choke on an unexpected payload. + return Response.json( + { error: { message: sanitizeErrorMessage(err), code: response.status } }, + { + status: response.status, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + } + ); + } + + // Already Gemini-shape (some upstreams may pre-translate) — pass through. + if (body.candidates) { + return Response.json(body, { + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }); + } + + // Surface upstream error objects untouched. + if (body.error) { + return Response.json(body, { + status: response.status, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }); + } + + const choice = body.choices?.[0]; + if (!choice || !choice.message) { + return Response.json(body, { + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }); + } + + const { message, finish_reason } = choice; + + const parts: GeminiPart[] = []; + if (message.reasoning_content) { + parts.push({ text: String(message.reasoning_content), thought: true }); + } + parts.push({ text: String(message.content ?? "") }); + + const finishReason = + OPENAI_TO_GEMINI_FINISH_REASON[finish_reason ?? "stop"] ?? "STOP"; + + const geminiResponse: GeminiNonStreamResponse = { + candidates: [ + { + content: { role: "model", parts }, + finishReason, + index: 0, + }, + ], + modelVersion: body.model || model, + }; + + if (body.usage) { + const u = body.usage; + const usageMetadata: GeminiUsageMetadata = { + promptTokenCount: u.prompt_tokens || 0, + candidatesTokenCount: u.completion_tokens || 0, + totalTokenCount: u.total_tokens || 0, + }; + const reasoningTokens = u.completion_tokens_details?.reasoning_tokens; + if (reasoningTokens) { + usageMetadata.thoughtsTokenCount = reasoningTokens; + } + geminiResponse.usageMetadata = usageMetadata; + } + + return Response.json(geminiResponse, { + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }); +} diff --git a/src/app/api/v1beta/models/[...path]/route.ts b/src/app/api/v1beta/models/[...path]/route.ts index 7ed236d71c..7d4307f43e 100644 --- a/src/app/api/v1beta/models/[...path]/route.ts +++ b/src/app/api/v1beta/models/[...path]/route.ts @@ -1,5 +1,9 @@ import { buildClientRawRequest, handleChat } from "@/sse/handlers/chat"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; +import { + convertOpenAIResponseToGemini, + transformOpenAISSEToGeminiSSE, +} from "@omniroute/open-sse/translator/response/openai-to-gemini-sse"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { v1betaGeminiGenerateSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; @@ -30,8 +34,18 @@ export async function OPTIONS() { } /** - * POST /v1beta/models/{model}:generateContent - Gemini compatible endpoint - * Converts Gemini format to internal format and handles via handleChat + * POST /v1beta/models/{model}:generateContent — non-streaming (JSON) + * POST /v1beta/models/{model}:streamGenerateContent — streaming (SSE) + * + * Streaming intent is determined by the URL action suffix (the canonical + * Gemini API convention), NOT by a body field. `generationConfig.stream` is + * not a real Gemini API field and the @google/genai SDK never sets it. + * + * The SDK always uses `:streamGenerateContent?alt=sse` for chat. handleChat + * returns OpenAI SSE; transformOpenAISSEToGeminiSSE() converts it to Gemini + * SSE on the fly so the SDK doesn't crash on the `[DONE]` sentinel. + * + * Ported from upstream decolua/9router#225 by @SteelMorgan. */ export async function POST(request, { params }) { await ensureInitialized(); @@ -53,21 +67,30 @@ export async function POST(request, { params }) { try { const { path } = await params; - // path = ["provider", "model:generateContent"] or ["model:generateContent"] + // path = ["provider", "model:action"] or ["model:action"] let model; + let action: ":generateContent" | ":streamGenerateContent"; if (path.length >= 2) { - // Format: /v1beta/models/provider/model:generateContent + // Format: /v1beta/models/provider/model:action const provider = path[0]; const modelAction = path[1]; + action = modelAction.includes(":streamGenerateContent") + ? ":streamGenerateContent" + : ":generateContent"; const modelName = modelAction - .replace(":generateContent", "") - .replace(":streamGenerateContent", ""); + .replace(":streamGenerateContent", "") + .replace(":generateContent", ""); model = `${provider}/${modelName}`; } else { - // Format: /v1beta/models/model:generateContent + // Format: /v1beta/models/model:action const modelAction = path[0]; - model = modelAction.replace(":generateContent", "").replace(":streamGenerateContent", ""); + action = modelAction.includes(":streamGenerateContent") + ? ":streamGenerateContent" + : ":generateContent"; + model = modelAction + .replace(":streamGenerateContent", "") + .replace(":generateContent", ""); } const validation = validateBody(v1betaGeminiGenerateSchema, rawBody); @@ -76,8 +99,13 @@ export async function POST(request, { params }) { } const body = validation.data; + // Streaming is determined by URL action suffix: + // :streamGenerateContent => stream: true (SSE) + // :generateContent => stream: false (plain JSON) + const stream = action === ":streamGenerateContent"; + // Convert Gemini format to OpenAI/internal format - const convertedBody = convertGeminiToInternal(body, model); + const convertedBody = convertGeminiToInternal(body, model, stream); // Create new request with converted body const newRequest = new Request(request.url, { @@ -86,7 +114,16 @@ export async function POST(request, { params }) { body: JSON.stringify(convertedBody), }); - return await handleChat(newRequest, buildClientRawRequest(request, rawBody)); + const response = await handleChat(newRequest, buildClientRawRequest(request, rawBody)); + + if (stream) { + // Transform OpenAI SSE => Gemini SSE on the fly. The @google/genai SDK + // always uses :streamGenerateContent?alt=sse and expects Gemini SSE + // chunks (no [DONE] sentinel — stream just closes). + return transformOpenAISSEToGeminiSSE(response, model); + } + // Convert OpenAI JSON => Gemini GenerateContentResponse JSON. + return await convertOpenAIResponseToGemini(response, model); } catch (error) { console.log("Error handling Gemini request:", error); return Response.json( @@ -97,9 +134,13 @@ export async function POST(request, { params }) { } /** - * Convert Gemini request format to internal format + * Convert Gemini request format to OpenAI/internal format. + * + * @param geminiBody parsed Gemini request body + * @param model resolved model string (e.g. "gemini/gemini-pro") + * @param stream whether to stream (derived from URL action suffix) */ -function convertGeminiToInternal(geminiBody, model) { +function convertGeminiToInternal(geminiBody, model, stream) { const messages = []; // Convert system instruction @@ -119,9 +160,6 @@ function convertGeminiToInternal(geminiBody, model) { } } - // Determine if streaming - const stream = geminiBody.generationConfig?.stream !== false; - return { model, messages, diff --git a/tests/unit/translator-openai-to-gemini-sse.test.ts b/tests/unit/translator-openai-to-gemini-sse.test.ts new file mode 100644 index 0000000000..755a58429d --- /dev/null +++ b/tests/unit/translator-openai-to-gemini-sse.test.ts @@ -0,0 +1,228 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + OPENAI_TO_GEMINI_FINISH_REASON, + openAIChunkToGeminiChunk, + transformOpenAISSEToGeminiSSE, + convertOpenAIResponseToGemini, +} from "../../open-sse/translator/response/openai-to-gemini-sse.ts"; + +/** + * Build a `Response` whose body is the given list of OpenAI SSE events + * concatenated as a single SSE stream (each event terminated by a blank line). + */ +function makeOpenAISSEResponse(events: Array): Response { + const body = events.map((e) => e + "\n\n").join(""); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(body)); + controller.close(); + }, + }); + return new Response(stream, { status: 200, headers: { "Content-Type": "text/event-stream" } }); +} + +async function readGeminiSSE(response: Response): Promise>> { + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + const out: Array> = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + } + buffer += decoder.decode(); + for (const line of buffer.split("\n")) { + const trimmed = line.endsWith("\r") ? line.slice(0, -1) : line; + if (!trimmed.startsWith("data:")) continue; + const data = trimmed.slice(5).trim(); + if (!data) continue; + out.push(JSON.parse(data) as Record); + } + return out; +} + +test("OPENAI_TO_GEMINI_FINISH_REASON maps the four canonical finish reasons", () => { + assert.equal(OPENAI_TO_GEMINI_FINISH_REASON.stop, "STOP"); + assert.equal(OPENAI_TO_GEMINI_FINISH_REASON.length, "MAX_TOKENS"); + assert.equal(OPENAI_TO_GEMINI_FINISH_REASON.tool_calls, "STOP"); + assert.equal(OPENAI_TO_GEMINI_FINISH_REASON.content_filter, "SAFETY"); +}); + +test("openAIChunkToGeminiChunk: skips role-only deltas with no content/finish_reason", () => { + const out = openAIChunkToGeminiChunk( + { choices: [{ delta: { role: "assistant" } }] }, + "gemini-pro" + ); + assert.equal(out, null); +}); + +test("openAIChunkToGeminiChunk: text delta becomes Gemini content part", () => { + const out = openAIChunkToGeminiChunk( + { choices: [{ delta: { content: "Hello" }, finish_reason: null }] }, + "gemini-pro" + ); + assert.deepEqual(out, { + candidates: [ + { + content: { role: "model", parts: [{ text: "Hello" }] }, + index: 0, + }, + ], + }); +}); + +test("openAIChunkToGeminiChunk: reasoning_content becomes a `thought: true` part", () => { + const out = openAIChunkToGeminiChunk( + { + choices: [ + { + delta: { reasoning_content: "think", content: "answer" }, + finish_reason: null, + }, + ], + }, + "gemini-pro" + ); + assert.deepEqual(out!.candidates[0].content.parts, [ + { text: "think", thought: true }, + { text: "answer" }, + ]); +}); + +test("openAIChunkToGeminiChunk: final chunk attaches usageMetadata + modelVersion + maps finishReason", () => { + const out = openAIChunkToGeminiChunk( + { + choices: [{ delta: {}, finish_reason: "length" }], + usage: { + prompt_tokens: 12, + completion_tokens: 34, + total_tokens: 46, + completion_tokens_details: { reasoning_tokens: 7 }, + }, + model: "gemini-2.5-pro", + }, + "fallback-model" + ); + assert.equal(out!.candidates[0].finishReason, "MAX_TOKENS"); + // Empty parts -> [{ text: "" }] so the SDK still sees a valid content shape. + assert.deepEqual(out!.candidates[0].content.parts, [{ text: "" }]); + assert.deepEqual(out!.usageMetadata, { + promptTokenCount: 12, + candidatesTokenCount: 34, + totalTokenCount: 46, + thoughtsTokenCount: 7, + }); + assert.equal(out!.modelVersion, "gemini-2.5-pro"); +}); + +test("transformOpenAISSEToGeminiSSE: full OpenAI SSE → Gemini SSE conversion (no [DONE] sentinel)", async () => { + // This is the original bug from upstream #225: the Gemini CLI SDK crashed on + // `[DONE]` because OpenAI SSE ends with that sentinel and Gemini SSE doesn't. + const upstream = makeOpenAISSEResponse([ + 'data: {"choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}', + 'data: {"choices":[{"delta":{"content":"Hi"},"finish_reason":null}]}', + 'data: {"choices":[{"delta":{"content":" there"},"finish_reason":null}]}', + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7},"model":"gemini-pro"}', + "data: [DONE]", + ]); + + const out = transformOpenAISSEToGeminiSSE(upstream, "fallback"); + assert.equal(out.status, 200); + assert.equal(out.headers.get("Content-Type"), "text/event-stream"); + + const events = await readGeminiSSE(out); + // role-only delta dropped, two content chunks, one final chunk = 3 events. + // No `[DONE]` should appear in the output (assert via readGeminiSSE which would push it). + assert.equal(events.length, 3); + assert.deepEqual(events[0], { + candidates: [{ content: { role: "model", parts: [{ text: "Hi" }] }, index: 0 }], + }); + assert.deepEqual(events[1], { + candidates: [{ content: { role: "model", parts: [{ text: " there" }] }, index: 0 }], + }); + const final = events[2] as Record; + assert.equal((final.candidates as Array<{ finishReason: string }>)[0].finishReason, "STOP"); + assert.deepEqual(final.usageMetadata, { + promptTokenCount: 5, + candidatesTokenCount: 2, + totalTokenCount: 7, + }); + assert.equal(final.modelVersion, "gemini-pro"); +}); + +test("transformOpenAISSEToGeminiSSE: handles chunked input that splits an SSE event mid-line", async () => { + // Real upstreams flush partial chunks. The transformer must buffer across + // TextEncoder boundaries so it never `JSON.parse`s a half-event. + const stream = new ReadableStream({ + start(controller) { + const enc = new TextEncoder(); + // Split the JSON payload in the middle of a string literal. + controller.enqueue(enc.encode('data: {"choices":[{"delta":{"content":"Hel')); + controller.enqueue(enc.encode('lo"},"finish_reason":null}]}\n\ndata: [DONE]\n\n')); + controller.close(); + }, + }); + const upstream = new Response(stream, { status: 200 }); + const out = transformOpenAISSEToGeminiSSE(upstream, "gemini-pro"); + const events = await readGeminiSSE(out); + assert.equal(events.length, 1); + assert.deepEqual(events[0], { + candidates: [{ content: { role: "model", parts: [{ text: "Hello" }] }, index: 0 }], + }); +}); + +test("transformOpenAISSEToGeminiSSE: passes non-OK upstream responses through unchanged", () => { + const upstream = new Response("upstream 500", { status: 500 }); + const out = transformOpenAISSEToGeminiSSE(upstream, "gemini-pro"); + assert.strictEqual(out, upstream); +}); + +test("convertOpenAIResponseToGemini: maps a Chat Completions JSON to Gemini GenerateContentResponse", async () => { + const upstream = Response.json({ + choices: [ + { + message: { role: "assistant", content: "Final answer" }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 3, completion_tokens: 4, total_tokens: 7 }, + model: "gemini-2.5-pro", + }); + const out = await convertOpenAIResponseToGemini(upstream, "fallback"); + const body = (await out.json()) as Record; + const candidates = body.candidates as Array<{ + content: { parts: Array<{ text: string }> }; + finishReason: string; + }>; + assert.equal(candidates[0].finishReason, "STOP"); + assert.equal(candidates[0].content.parts[0].text, "Final answer"); + assert.equal(body.modelVersion, "gemini-2.5-pro"); + assert.deepEqual(body.usageMetadata, { + promptTokenCount: 3, + candidatesTokenCount: 4, + totalTokenCount: 7, + }); +}); + +test("convertOpenAIResponseToGemini: passes through bodies that are already Gemini-shape", async () => { + const upstream = Response.json({ + candidates: [{ content: { role: "model", parts: [{ text: "x" }] }, index: 0 }], + }); + const out = await convertOpenAIResponseToGemini(upstream, "gemini-pro"); + const body = (await out.json()) as Record; + assert.ok(Array.isArray(body.candidates)); +}); + +test("convertOpenAIResponseToGemini: surfaces upstream error bodies untouched", async () => { + const upstream = Response.json( + { error: { message: "quota exceeded", code: 429 } }, + { status: 429 } + ); + const out = await convertOpenAIResponseToGemini(upstream, "gemini-pro"); + assert.equal(out.status, 429); + const body = (await out.json()) as { error: { code: number } }; + assert.equal(body.error.code, 429); +});