mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-14 03:02:14 +03:00
feat(translator): OpenAI SSE → Gemini SSE conversion for /v1beta/models route (#4453)
Integrated into release/v3.8.32
This commit is contained in:
committed by
GitHub
parent
ce3909be86
commit
7abace590d
@@ -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
|
||||
|
||||
|
||||
351
open-sse/translator/response/openai-to-gemini-sse.ts
Normal file
351
open-sse/translator/response/openai-to-gemini-sse.ts
Normal 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": "*" },
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
228
tests/unit/translator-openai-to-gemini-sse.test.ts
Normal file
228
tests/unit/translator-openai-to-gemini-sse.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user