const os = require("os");
const path = require("path");
const fs = require("fs/promises");
// Auth file paths (only needed for OAuth mode)
const OAUTH_FILE = process.env.GOOGLE_APPLICATION_CREDENTIALS;
const CLIENT_FILE = process.env.GOOGLE_APPLICATION_CCR_CLIENT_FILE;
const TOKEN_FILE = path.join(os.homedir(), ".claude-code-router", "gemini_oauth_creds.json");
// Type enum equivalent in JavaScript for Schema Processing
const Type = {
TYPE_UNSPECIFIED: "TYPE_UNSPECIFIED",
STRING: "STRING",
NUMBER: "NUMBER",
INTEGER: "INTEGER",
BOOLEAN: "BOOLEAN",
ARRAY: "ARRAY",
OBJECT: "OBJECT",
NULL: "NULL",
};
/**
* HELPER: Flatten type arrays to anyOf
*/
function flattenTypeArrayToAnyOf(typeList, resultingSchema) {
if (typeList.includes("null")) {
resultingSchema["nullable"] = true;
}
const listWithoutNull = typeList.filter((type) => type !== "null");
if (listWithoutNull.length === 1) {
const upperCaseType = listWithoutNull[0].toUpperCase();
resultingSchema["type"] = Object.values(Type).includes(upperCaseType)
? upperCaseType
: Type.TYPE_UNSPECIFIED;
} else {
resultingSchema["anyOf"] = [];
for (const i of listWithoutNull) {
const upperCaseType = i.toUpperCase();
resultingSchema["anyOf"].push({
type: Object.values(Type).includes(upperCaseType)
? upperCaseType
: Type.TYPE_UNSPECIFIED,
});
}
}
}
/**
* HELPER: Robust JSON Schema Processor for Gemini
*/
function processJsonSchema(_jsonSchema) {
const genAISchema = {};
const schemaFieldNames = ["items"];
const listSchemaFieldNames = ["anyOf"];
const dictSchemaFieldNames = ["properties"];
if (_jsonSchema["type"] && _jsonSchema["anyOf"]) {
const incomingAnyOf = _jsonSchema["anyOf"];
if (incomingAnyOf && Array.isArray(incomingAnyOf) && incomingAnyOf.length === 2) {
const nullIndex = incomingAnyOf.findIndex(item => item.type === 'null');
if (nullIndex !== -1) {
genAISchema["nullable"] = true;
_jsonSchema = incomingAnyOf[nullIndex === 0 ? 1 : 0];
}
}
}
if (_jsonSchema["type"] && Array.isArray(_jsonSchema["type"])) {
flattenTypeArrayToAnyOf(_jsonSchema["type"], genAISchema);
}
for (const [fieldName, fieldValue] of Object.entries(_jsonSchema)) {
if (fieldValue == null) continue;
if (fieldName === "type") {
if (fieldValue === "null") continue;
if (Array.isArray(fieldValue)) continue;
const upperCaseValue = fieldValue.toUpperCase();
genAISchema["type"] = Object.values(Type).includes(upperCaseValue)
? upperCaseValue
: Type.TYPE_UNSPECIFIED;
} else if (schemaFieldNames.includes(fieldName)) {
genAISchema[fieldName] = processJsonSchema(fieldValue);
} else if (listSchemaFieldNames.includes(fieldName)) {
const listSchemaFieldValue = [];
for (const item of fieldValue) {
if (item["type"] === "null") {
genAISchema["nullable"] = true;
continue;
}
listSchemaFieldValue.push(processJsonSchema(item));
}
genAISchema[fieldName] = listSchemaFieldValue;
} else if (dictSchemaFieldNames.includes(fieldName)) {
const dictSchemaFieldValue = {};
for (const [key, value] of Object.entries(fieldValue)) {
dictSchemaFieldValue[key] = processJsonSchema(value);
}
genAISchema[fieldName] = dictSchemaFieldValue;
} else {
if (fieldName === "additionalProperties" || fieldName === "$schema") continue;
genAISchema[fieldName] = fieldValue;
}
}
return genAISchema;
}
/**
* HELPER: Transform Tool Object
*/
function tTool(tool) {
if (tool.functionDeclarations) {
for (const functionDeclaration of tool.functionDeclarations) {
if (functionDeclaration.parameters) {
if (!Object.keys(functionDeclaration.parameters).includes("$schema")) {
functionDeclaration.parameters = processJsonSchema(
functionDeclaration.parameters
);
} else {
if (!functionDeclaration.parametersJsonSchema) {
functionDeclaration.parametersJsonSchema = functionDeclaration.parameters;
delete functionDeclaration.parameters;
}
}
}
}
}
return tool;
}
class GeminiCLITransformer {
constructor(options = {}) {
this.name = "gemini-cli";
this.options = options;
}
loadOAuthCredentials() {
try {
if (OAUTH_FILE) this.oauth_creds = require(OAUTH_FILE);
} catch (e) {
this.logger.error ? this.logger.error({ e, file: OAUTH_FILE }, "OAUTH_FILE load error") : console.error("OAUTH_FILE load error", e);
}
try {
if (CLIENT_FILE) this.client_creds = require(CLIENT_FILE);
} catch (e) {
this.logger.error ? this.logger.error({ e, file: CLIENT_FILE }, "CLIENT_FILE load error") : console.error("CLIENT_FILE load error", e);
}
try {
this.token_creds = require(TOKEN_FILE);
} catch (e) {
// Token file might not exist yet
}
}
async transformRequestIn(request, provider) {
let authMode;
let apiKey;
this.logger.debug(provider, "Prov In");
this.logger.debug(request, "Req In");
// Determine auth mode and key
const rawKey = provider.apiKey || process.env.API_KEY;
if (rawKey) {
apiKey = rawKey.trim();
authMode = 'api-key';
} else {
authMode = 'oauth';
}
// --- Debug Logging ---
if (this.options?.debug) {
const debugInfo = {
authMode: authMode,
model: request.model,
hasApiKey: !!apiKey
};
if (this.logger.debug) {
this.logger.debug(debugInfo, "Gemini Request In");
} else {
console.log("[GeminiUniversal] Request Debug:", JSON.stringify(debugInfo, null, 2));
}
}
this.logger.debug("options: " + JSON.stringify(this.options));
// --- Authentication Header Generation ---
let headers = {
'Content-Type': 'application/json'
};
if (authMode === 'api-key') {
if (!apiKey) throw new Error("Auth mode is api-key but no key found.");
// Removed x-goog-api-key header in favor of query param below
// delete headers['x-goog-api-key'];
// Critical: Explicitly remove Authorization to prevent conflict
delete headers['Authorization'];
headers['Authorization'] = null;
} else {
// OAuth Mode: Load credentials if not already loaded
if (!this.oauth_creds || !this.client_creds) {
this.loadOAuthCredentials();
}
// OAuth Mode checks
if (!this.oauth_creds) {
throw new Error("Missing Google OAuth credentials (GOOGLE_APPLICATION_CREDENTIALS). Set this or an API_KEY.");
}
if (!this.client_creds) {
throw new Error("Missing Google Client credentials (GOOGLE_APPLICATION_CCR_CLIENT_FILE). Set this or an API_KEY.");
}
// Refresh token if expired
if (!this.token_creds || this.token_creds.expiry_date < Date.now()) {
await this.refreshToken(this.oauth_creds);
}
headers['Authorization'] = `Bearer ${this.token_creds.access_token}`;
}
// --- Tool Transformation ---
const tools = [];
const functionDeclarations = request.tools
?.filter((tool) => tool.function.name !== "web_search")
?.map((tool) => {
return {
name: tool.function.name,
description: tool.function.description,
parametersJsonSchema: tool.function.parameters,
};
});
if (functionDeclarations?.length) {
tools.push(tTool({ functionDeclarations }));
}
// Handle Google Search Grounding
const webSearch = request.tools?.find(
(tool) => tool.function.name === "web_search"
);
if (webSearch) {
tools.push({ googleSearch: {} });
}
// --- Message Transformation ---
const contents = request.messages.map((message) => {
let role = message.role;
if (role === "assistant") role = "model";
else if (["user", "system", "tool"].includes(role)) role = "user";
else role = "user";
const parts = [];
if (typeof message.content === "string") {
parts.push({ text: message.content });
} else if (Array.isArray(message.content)) {
parts.push(
...message.content.map((content) => {
if (content.type === "text") {
return { text: content.text || "" };
}
if (content.type === "image_url") {
if (content.image_url.url.startsWith("http")) {
return {
file_data: {
mime_type: content.media_type || "image/jpeg",
file_uri: content.image_url.url,
},
};
} else {
const base64Data = content.image_url.url.includes("base64,")
? content.image_url.url.split("base64,")[1]
: content.image_url.url;
return {
inlineData: {
mime_type: content.media_type || "image/jpeg",
data: base64Data,
},
};
}
}
return null;
}).filter(Boolean)
);
}
if (Array.isArray(message.tool_calls)) {
parts.push(
...message.tool_calls.map((toolCall) => ({
functionCall: {
id: toolCall.id || `tool_${Math.random().toString(36).substring(2, 15)}`,
name: toolCall.function.name,
args: JSON.parse(toolCall.function.arguments || "{}"),
},
}))
);
}
return { role, parts };
});
const rawModel = request.model;
const model = (rawModel && rawModel.trim().length > 0) ? rawModel.trim() : 'gemini-2.5-flash';
let baseUrl = provider.api_base_url || 'https://generativelanguage.googleapis.com/v1beta/models/';
if (!baseUrl.endsWith('/')) {
baseUrl += '/';
}
// Construct URL
const method = request.stream ? 'streamGenerateContent?alt=sse' : 'generateContent';
const fullUrlString = `${baseUrl}${model}:${method}`;
const url = new URL(fullUrlString);
// CRITICAL FIX: Append API Key as query param if in API Key mode
if (authMode === 'api-key') {
url.searchParams.append('key', apiKey);
}
// Debug log to catch URL issues
if (this.options.debug) {
// Log URL but redact the key for security in logs
const logUrl = new URL(url.toString());
if (logUrl.searchParams.has('key')) {
logUrl.searchParams.set('key', 'REDACTED');
}
console.log("[GeminiUniversal] Constructed URL:", logUrl.toString());
}
return {
body: {
contents,
tools: tools.length ? tools : undefined,
generationConfig: {
temperature: request.temperature,
topP: request.top_p,
maxOutputTokens: request.max_tokens,
}
},
config: {
url,
headers,
method: 'POST'
},
};
}
async transformResponseOut(response) {
const requestUrl = response.url;
// CASE 1: Standard JSON Response
if (response.headers.get("Content-Type")?.includes("application/json")) {
let jsonResponse = await response.json();
if (this.options?.debug) {
if (this.logger.debug) this.logger.debug({ jsonResponse }, "Gemini JSON Response");
else console.log("[GeminiUniversal] JSON Response:", JSON.stringify(jsonResponse, null, 2));
}
if (!response.ok || jsonResponse.error) {
const errorMsg = jsonResponse.error?.message || response.statusText;
throw new Error(`Gemini API Error (${response.status}) at ${requestUrl}: ${errorMsg}`);
}
const candidate = jsonResponse.candidates?.[0];
if (!candidate) throw new Error("No candidates returned from Gemini.");
const tool_calls = candidate.content?.parts
?.filter((part) => part.functionCall)
?.map((part) => ({
id: `call_${Math.random().toString(36).substring(2, 15)}`,
type: "function",
function: {
name: part.functionCall?.name,
arguments: JSON.stringify(part.functionCall?.args || {}),
},
}));
const res = {
id: jsonResponse.responseId,
choices: [
{
finish_reason: candidate.finishReason?.toLowerCase() || "stop",
index: 0,
message: {
content: candidate.content?.parts
?.filter((part) => part.text)
.map((part) => part.text)
.join("\n") || null,
role: "assistant",
tool_calls: tool_calls?.length > 0 ? tool_calls : undefined,
},
},
],
created: Math.floor(Date.now() / 1000),
model: jsonResponse.modelVersion,
object: "chat.completion",
usage: {
completion_tokens: jsonResponse.usageMetadata?.candidatesTokenCount || 0,
prompt_tokens: jsonResponse.usageMetadata?.promptTokenCount || 0,
total_tokens: jsonResponse.usageMetadata?.totalTokenCount || 0,
},
};
return new Response(JSON.stringify(res), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
// CASE 2: SSE Streaming Response
} else if (response.headers.get("Content-Type")?.includes("stream")) {
if (!response.body) return response;
const decoder = new TextDecoder();
const encoder = new TextEncoder();
const processLine = (line, controller) => {
if (line.startsWith("data: ")) {
const chunkStr = line.slice(6).trim();
if (chunkStr && chunkStr !== '[DONE]') {
if (this.options?.debug && this.logger.debug) {
this.logger.debug({ chunkStr }, "Gemini Stream Chunk");
}
try {
const chunk = JSON.parse(chunkStr);
const candidate = chunk.candidates?.[0];
if (!candidate) return;
const tool_calls = candidate.content?.parts
?.filter((part) => part.functionCall)
?.map((part) => ({
id: `call_${Math.random().toString(36).substring(2, 15)}`,
type: 'function',
function: {
name: part.functionCall.name,
arguments: JSON.stringify(part.functionCall.args)
}
}));
const res = {
choices: [{
delta: {
role: "assistant",
content: candidate.content?.parts
?.filter((part) => part.text)
?.map((part) => part.text)
?.join("\n"),
tool_calls: tool_calls?.length ? tool_calls : undefined
},
finish_reason: candidate.finishReason?.toLowerCase() || null,
index: 0
}],
created: Math.floor(Date.now() / 1000),
id: chunk.responseId || "",
model: chunk.modelVersion || "",
object: "chat.completion.chunk",
usage: {
completion_tokens: chunk.usageMetadata?.candidatesTokenCount || 0,
prompt_tokens: chunk.usageMetadata?.promptTokenCount || 0,
total_tokens: chunk.usageMetadata?.totalTokenCount || 0,
},
};
if (candidate.groundingMetadata?.groundingChunks?.length) {
res.choices[0].delta.annotations =
candidate.groundingMetadata.groundingChunks.map(
(groundingChunk, index) => {
const support =
candidate.groundingMetadata?.groundingSupports?.filter(
(item) => item.groundingChunkIndices.includes(index)
);
return {
type: "url_citation",
url_citation: {
url: groundingChunk.web.uri,
title: groundingChunk.web.title,
content: support?.[0]?.segment?.text,
start_index: support?.[0]?.segment?.startIndex,
end_index: support?.[0]?.segment?.endIndex,
},
};
}
);
}
controller.enqueue(encoder.encode(`data: ${JSON.stringify(res)}\n\n`));
} catch (e) {
if (this.options?.debug) console.error("Gemini stream parse error:", e);
}
}
}
};
const stream = new ReadableStream({
async start(controller) {
const reader = response.body.getReader();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
if (buffer) processLine(buffer, controller);
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
lines.forEach(line => processLine(line, controller));
}
} catch (e) { controller.error(e); }
finally { controller.close(); }
},
});
return new Response(stream, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' }
});
}
return response;
}
refreshToken(oauth_creds) {
return fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: this.client_creds.installed.client_id,
client_secret: this.client_creds.installed.client_secret,
refresh_token: oauth_creds.refresh_token,
grant_type: "refresh_token",
}),
})
.then((response) => response.json())
.then(async (data) => {
data.expiry_date = new Date().getTime() + data.expires_in * 1000 - 60000;
this.token_creds = data;
try {
await fs.writeFile(TOKEN_FILE, JSON.stringify(data, null, 2));
} catch (e) {
if (this.options?.debug) console.error("Failed to save refreshed token:", e);
}
});
}
}
module.exports = GeminiCLITransformer;
It supports gcloud tokens but that was becuase I couldn't get API key working. But it works now so recommended example setup
{
"LOG": true,
"LOG_LEVEL": "debug",
"CLAUDE_PATH": "",
"HOST": "127.0.0.1",
"PORT": 3456,
"APIKEY": "",
"API_TIMEOUT_MS": "600000",
"PROXY_URL": "",
"transformers": [
{
"path": "/home/mark/.claude-code-router/plugins/gemini-cli.js",
"options": {
"project": "ccr",
"debug": true
}
}
],
"Providers": [
{
"name": "gpt-oss-local",
"api_base_url": "http://127.0.0.1:8000/v1/chat/completions",
"api_key": "dummy",
"models": ["gpt-oss-20b-ccr"],
"transformer": {
"use": [
[
"model_params",
{
"temperature": 0.2,
"top_p": 0.1,
"reasoning_effort": "high"
}
]
]
}
},
{
"name": "gemini-cli",
"api_base_url": "https://generativelanguage.googleapis.com/v1beta/models/",
"api_key": "get one at https://aistudio.google.com/app/api-keys",
"models": ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-3-pro-preview"],
"transformer": {
"use": ["gemini-cli"]
}
}
],
"StatusLine": {
"enabled": true,
"currentStyle": "default",
"default": {
"modules": [
{
"type": "script",
"icon": "📁",
"text": "{{workDirName}}",
"color": "bright_blue",
"scriptPath": "/home/mark/Scripts/agentsettings/ccr/statusline/pwd.js"
},
{
"type": "gitBranch",
"icon": "🌐",
"text": "{{gitBranch}}",
"color": "bright_green"
},
{
"type": "model",
"icon": "🧠",
"text": "{{model}}",
"color": "bright_yellow"
},
{
"type": "usage",
"icon": "💳",
"text": "{{inputTokens}} ▲▼ {{outputTokens}}",
"color": "bright_magenta"
}
]
},
"powerline": {
"modules": []
}
},
"Router": {
"default": "gemini-cli,gemini-2.5-pro",
"background": "gpt-oss-local,gpt-oss-20b-ccr",
"think": "gemini-cli,gemini-3-pro-preview",
"longContext": "gemini-cli,gemini-2.5-flash",
"longContextThreshold": 200000,
"webSearch": "gemini-cli,gemini-2.5-flash",
"image": "gemini-cli,gemini-2.5-pro"
},
"CUSTOM_ROUTER_PATH": ""
}
~/.claude-code-router/plugins/gemini-cli.js
It supports gcloud tokens but that was becuase I couldn't get API key working. But it works now so recommended example setup
config.json
{ "LOG": true, "LOG_LEVEL": "debug", "CLAUDE_PATH": "", "HOST": "127.0.0.1", "PORT": 3456, "APIKEY": "", "API_TIMEOUT_MS": "600000", "PROXY_URL": "", "transformers": [ { "path": "/home/mark/.claude-code-router/plugins/gemini-cli.js", "options": { "project": "ccr", "debug": true } } ], "Providers": [ { "name": "gpt-oss-local", "api_base_url": "http://127.0.0.1:8000/v1/chat/completions", "api_key": "dummy", "models": ["gpt-oss-20b-ccr"], "transformer": { "use": [ [ "model_params", { "temperature": 0.2, "top_p": 0.1, "reasoning_effort": "high" } ] ] } }, { "name": "gemini-cli", "api_base_url": "https://generativelanguage.googleapis.com/v1beta/models/", "api_key": "get one at https://aistudio.google.com/app/api-keys", "models": ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-3-pro-preview"], "transformer": { "use": ["gemini-cli"] } } ], "StatusLine": { "enabled": true, "currentStyle": "default", "default": { "modules": [ { "type": "script", "icon": "📁", "text": "{{workDirName}}", "color": "bright_blue", "scriptPath": "/home/mark/Scripts/agentsettings/ccr/statusline/pwd.js" }, { "type": "gitBranch", "icon": "🌐", "text": "{{gitBranch}}", "color": "bright_green" }, { "type": "model", "icon": "🧠", "text": "{{model}}", "color": "bright_yellow" }, { "type": "usage", "icon": "💳", "text": "{{inputTokens}} ▲▼ {{outputTokens}}", "color": "bright_magenta" } ] }, "powerline": { "modules": [] } }, "Router": { "default": "gemini-cli,gemini-2.5-pro", "background": "gpt-oss-local,gpt-oss-20b-ccr", "think": "gemini-cli,gemini-3-pro-preview", "longContext": "gemini-cli,gemini-2.5-flash", "longContextThreshold": 200000, "webSearch": "gemini-cli,gemini-2.5-flash", "image": "gemini-cli,gemini-2.5-pro" }, "CUSTOM_ROUTER_PATH": "" }