9router/src/app/api/v1beta/models/[...path]/route.js
Владимир Акимов 0c832c9c59
feat(gemini): convert OpenAI SSE to Gemini SSE format in /v1beta/models route (#225)
* fix(translator): filter nameless hosted tools when converting Responses API to Chat format

Codex CLI sends "hosted" tools (e.g. `request_user_input`) via the OpenAI
Responses API. These tools have no explicit `name` field. The previous
`body.tools.map()` pass propagated `name: undefined` into the resulting
Chat Completions function declarations, which then became anonymous
`functionDeclarations` after the OpenAI→Gemini translation step.

Gemini strictly requires every function declaration to have a valid name
and rejects the entire request with:

  GenerateContentRequest.tools[0].function_declarations[4].name:
  Invalid function name. Must start with a letter or an underscore.

Fix: filter out any Responses API tool that lacks a non-empty `name`
string before converting to `{ type: "function", function: { name, ... } }`.
Named function tools are unaffected; only unnamed hosted tools are skipped.

Fixes: Gemini 400 error when Codex CLI is routed through 9router.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(gemini): convert OpenAI SSE to Gemini SSE format in /v1beta/models route

The @google/genai SDK always uses :streamGenerateContent?alt=sse for chat
and expects Gemini SSE chunk format. The upstream handleChat returns OpenAI
SSE format, causing the SDK to crash on the [DONE] sentinel.

Changes:
- Add transformOpenAISSEToGeminiSSE() using TransformStream that converts
  each OpenAI SSE chunk (choices[0].delta) to Gemini SSE format
  (candidates[0].content.parts) on the fly
- Drop the OpenAI [DONE] sentinel (Gemini SSE ends by stream close)
- Map finish_reason -> finishReason, attach usageMetadata on final chunk
- Support reasoning_content -> thought: true parts for thinking models
- Refactor finishReasonMap to shared FINISH_REASON_MAP constant
- Fix streaming dispatch: stream=true now calls transformOpenAISSEToGeminiSSE
  instead of passing OpenAI SSE through raw

Fixes: SyntaxError: "[DONE]" is not valid JSON in Gemini CLI

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-02 09:36:08 +07:00

329 lines
10 KiB
JavaScript

import { handleChat } from "@/sse/handlers/chat.js";
import { initTranslators } from "open-sse/translator/index.js";
let initialized = false;
/**
* Initialize translators once
*/
async function ensureInitialized() {
if (!initialized) {
await initTranslators();
initialized = true;
console.log("[SSE] Translators initialized for /v1beta/models");
}
}
/**
* Handle CORS preflight
*/
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*"
}
});
}
/**
* POST /v1beta/models/{model}:generateContent — non-streaming
* POST /v1beta/models/{model}:streamGenerateContent — streaming (SSE)
*
* Streaming intent is determined by the URL action suffix (canonical Gemini API
* convention), NOT by a body field. generationConfig.stream is not a real
* Gemini API field and Gemini CLI never sets it.
*
* The @google/genai SDK always uses :streamGenerateContent?alt=sse for chat.
* The upstream handleChat returns OpenAI SSE format; we transform it to
* Gemini SSE format on the fly via transformOpenAISSEToGeminiSSE().
*/
export async function POST(request, { params }) {
await ensureInitialized();
try {
const { path } = await params;
// path = ["provider", "model:action"] or ["model:action"]
let model;
let action; // ":generateContent" | ":streamGenerateContent"
if (path.length >= 2) {
// Format: /v1beta/models/provider/model:generateContent
const provider = path[0];
const modelAction = path[1];
action = modelAction.includes(":streamGenerateContent")
? ":streamGenerateContent"
: ":generateContent";
const modelName = modelAction
.replace(":streamGenerateContent", "")
.replace(":generateContent", "");
model = provider + "/" + modelName;
} else {
// Format: /v1beta/models/model:generateContent
const modelAction = path[0];
action = modelAction.includes(":streamGenerateContent")
? ":streamGenerateContent"
: ":generateContent";
model = modelAction
.replace(":streamGenerateContent", "")
.replace(":generateContent", "");
}
const body = await request.json();
// Streaming is determined by URL action suffix:
// :streamGenerateContent => stream: true (SSE)
// :generateContent => stream: false (plain JSON)
const stream = action === ":streamGenerateContent";
// Convert Gemini request format to OpenAI/internal format
const convertedBody = convertGeminiToInternal(body, model, stream);
// Create new request with converted body
const newRequest = new Request(request.url, {
method: "POST",
headers: request.headers,
body: JSON.stringify(convertedBody),
});
const response = await handleChat(newRequest);
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);
} else {
// Convert OpenAI JSON response => Gemini GenerateContentResponse
return await convertOpenAIResponseToGemini(response, model);
}
} catch (error) {
console.log("Error handling Gemini request:", error);
return Response.json(
{ error: { message: error.message, code: 500 } },
{ status: 500 }
);
}
}
/**
* Convert Gemini request format to OpenAI/internal format.
*
* @param {object} geminiBody - parsed Gemini request body
* @param {string} model - resolved model string (e.g. "gemini-pro-high")
* @param {boolean} stream - whether to stream (from URL action)
*/
function convertGeminiToInternal(geminiBody, model, stream) {
const messages = [];
// Convert system instruction
if (geminiBody.systemInstruction) {
const systemText = geminiBody.systemInstruction.parts
?.map(p => p.text)
.join("\n") || "";
if (systemText) {
messages.push({ role: "system", content: systemText });
}
}
// Convert contents to messages
if (geminiBody.contents) {
for (const content of geminiBody.contents) {
const role = content.role === "model" ? "assistant" : "user";
const text = content.parts?.map(p => p.text).join("\n") || "";
messages.push({ role, content: text });
}
}
return {
model,
messages,
stream,
max_tokens: geminiBody.generationConfig?.maxOutputTokens,
temperature: geminiBody.generationConfig?.temperature,
top_p: geminiBody.generationConfig?.topP,
};
}
/** Map OpenAI finish_reason => Gemini finishReason */
const FINISH_REASON_MAP = {
stop: "STOP",
length: "MAX_TOKENS",
tool_calls: "STOP",
content_filter: "SAFETY",
};
/**
* Transform an OpenAI SSE stream into a Gemini SSE stream.
*
* OpenAI SSE format (what handleChat returns):
* data: {"choices":[{"delta":{"content":"Hi"},"finish_reason":null}]}
* data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{...}}
* data: [DONE]
*
* Gemini SSE format (what @google/genai SDK expects):
* data: {"candidates":[{"content":{"role":"model","parts":[{"text":"Hi"}]},"index":0}]}
* data: {"candidates":[{"content":{"role":"model","parts":[{"text":""}]},"finishReason":"STOP","index":0}],"usageMetadata":{...}}
* (stream closes — no [DONE])
*/
function transformOpenAISSEToGeminiSSE(upstreamResponse, model) {
if (!upstreamResponse.ok || !upstreamResponse.body) {
return upstreamResponse;
}
const decoder = new TextDecoder();
const encoder = new TextEncoder();
const transformStream = new TransformStream({
transform(chunk, controller) {
const text = decoder.decode(chunk, { stream: true });
const lines = text.split("\n");
for (const line of lines) {
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;
try {
parsed = JSON.parse(data);
} catch {
continue;
}
const choice = parsed.choices?.[0];
if (!choice) continue;
const delta = choice.delta || {};
const parts = [];
if (delta.reasoning_content) {
parts.push({ text: delta.reasoning_content, thought: true });
}
if (delta.content) {
parts.push({ text: delta.content });
}
// Skip pure role-only deltas with no content and no finish signal
if (parts.length === 0 && !choice.finish_reason) continue;
const candidate = {
content: {
role: "model",
parts: parts.length > 0 ? parts : [{ text: "" }],
},
index: 0,
};
if (choice.finish_reason) {
candidate.finishReason = FINISH_REASON_MAP[choice.finish_reason] || "STOP";
}
const geminiChunk = { candidates: [candidate] };
// Attach usage + modelVersion on the final chunk (when finish_reason is set)
if (choice.finish_reason && parsed.usage) {
geminiChunk.usageMetadata = {
promptTokenCount: parsed.usage.prompt_tokens || 0,
candidatesTokenCount: parsed.usage.completion_tokens || 0,
totalTokenCount: parsed.usage.total_tokens || 0,
};
const reasoningTokens =
parsed.usage.completion_tokens_details?.reasoning_tokens;
if (reasoningTokens) {
geminiChunk.usageMetadata.thoughtsTokenCount = reasoningTokens;
}
geminiChunk.modelVersion = parsed.model || model;
}
controller.enqueue(
encoder.encode("data: " + JSON.stringify(geminiChunk) + "\r\n\r\n")
);
}
},
// No flush() needed: Gemini SSE ends by stream close, not a sentinel
});
return new Response(upstreamResponse.body.pipeThrough(transformStream), {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Access-Control-Allow-Origin": "*",
},
});
}
/**
* Convert an OpenAI chat.completion JSON response into a Gemini
* GenerateContentResponse so that Gemini CLI can parse it.
*/
async function convertOpenAIResponseToGemini(response, model) {
if (!response.ok) return response;
let body;
try {
body = await response.json();
} catch {
return response;
}
if (body.candidates) return Response.json(body, {
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }
});
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) {
return Response.json(body, {
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }
});
}
const { message, finish_reason } = choice;
const parts = [];
if (message.reasoning_content) {
parts.push({ text: message.reasoning_content, thought: true });
}
parts.push({ text: message.content || "" });
const finishReason = FINISH_REASON_MAP[finish_reason] || "STOP";
const geminiResponse = {
candidates: [
{
content: { role: "model", parts },
finishReason,
index: 0,
},
],
modelVersion: body.model || model,
};
if (body.usage) {
geminiResponse.usageMetadata = {
promptTokenCount: body.usage.prompt_tokens || 0,
candidatesTokenCount: body.usage.completion_tokens || 0,
totalTokenCount: body.usage.total_tokens || 0,
};
const reasoningTokens = body.usage.completion_tokens_details?.reasoning_tokens;
if (reasoningTokens) {
geminiResponse.usageMetadata.thoughtsTokenCount = reasoningTokens;
}
}
return Response.json(geminiResponse, {
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }
});
}