feat(translator): OpenAI SSE → Gemini SSE conversion for /v1beta/models route (#4453)

Integrated into release/v3.8.32
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-20 20:26:19 -03:00
committed by GitHub
parent ce3909be86
commit 7abace590d
4 changed files with 633 additions and 15 deletions

View File

@@ -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

View File

@@ -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<string, string> = {
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<Uint8Array, Uint8Array>({
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<Response> {
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": "*" },
});
}

View File

@@ -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,

View File

@@ -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<string>): Response {
const body = events.map((e) => e + "\n\n").join("");
const stream = new ReadableStream<Uint8Array>({
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<Array<Record<string, unknown>>> {
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
const out: Array<Record<string, unknown>> = [];
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<string, unknown>);
}
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<string, unknown>;
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<Uint8Array>({
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<string, unknown>;
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<string, unknown>;
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);
});