-
-
Save musistudio/1c13a65f35916a7ab690649d3df8d1cd to your computer and use it in GitHub Desktop.
const os = require("os"); | |
const path = require("path"); | |
const fs = require("fs/promises"); | |
const OAUTH_FILE = path.join(os.homedir(), ".gemini", "oauth_creds.json"); | |
// Type enum equivalent in JavaScript | |
const Type = { | |
TYPE_UNSPECIFIED: "TYPE_UNSPECIFIED", | |
STRING: "STRING", | |
NUMBER: "NUMBER", | |
INTEGER: "INTEGER", | |
BOOLEAN: "BOOLEAN", | |
ARRAY: "ARRAY", | |
OBJECT: "OBJECT", | |
NULL: "NULL", | |
}; | |
/** | |
* Transform the type field from an array of types to an array of anyOf fields. | |
* @param {string[]} typeList - List of types | |
* @param {Object} resultingSchema - The schema object to modify | |
*/ | |
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, | |
}); | |
} | |
} | |
} | |
/** | |
* Process a JSON schema to make it compatible with the GenAI API | |
* @param {Object} _jsonSchema - The JSON schema to process | |
* @returns {Object} - The processed schema | |
*/ | |
function processJsonSchema(_jsonSchema) { | |
const genAISchema = {}; | |
const schemaFieldNames = ["items"]; | |
const listSchemaFieldNames = ["anyOf"]; | |
const dictSchemaFieldNames = ["properties"]; | |
if (_jsonSchema["type"] && _jsonSchema["anyOf"]) { | |
throw new Error("type and anyOf cannot be both populated."); | |
} | |
/* | |
This is to handle the nullable array or object. The _jsonSchema will | |
be in the format of {anyOf: [{type: 'null'}, {type: 'object'}]}. The | |
logic is to check if anyOf has 2 elements and one of the element is null, | |
if so, the anyOf field is unnecessary, so we need to get rid of the anyOf | |
field and make the schema nullable. Then use the other element as the new | |
_jsonSchema for processing. This is because the backend doesn't have a null | |
type. | |
*/ | |
const incomingAnyOf = _jsonSchema["anyOf"]; | |
if ( | |
incomingAnyOf != null && | |
Array.isArray(incomingAnyOf) && | |
incomingAnyOf.length == 2 | |
) { | |
if (incomingAnyOf[0] && incomingAnyOf[0]["type"] === "null") { | |
genAISchema["nullable"] = true; | |
_jsonSchema = incomingAnyOf[1]; | |
} else if (incomingAnyOf[1] && incomingAnyOf[1]["type"] === "null") { | |
genAISchema["nullable"] = true; | |
_jsonSchema = incomingAnyOf[0]; | |
} | |
} | |
if (_jsonSchema["type"] && Array.isArray(_jsonSchema["type"])) { | |
flattenTypeArrayToAnyOf(_jsonSchema["type"], genAISchema); | |
} | |
for (const [fieldName, fieldValue] of Object.entries(_jsonSchema)) { | |
// Skip if the fieldValue is undefined or null. | |
if (fieldValue == null) { | |
continue; | |
} | |
if (fieldName == "type") { | |
if (fieldValue === "null") { | |
throw new Error( | |
"type: null can not be the only possible type for the field." | |
); | |
} | |
if (Array.isArray(fieldValue)) { | |
// we have already handled the type field with array of types in the | |
// beginning of this function. | |
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 { | |
// additionalProperties is not included in JSONSchema, skipping it. | |
if (fieldName === "additionalProperties") { | |
continue; | |
} | |
genAISchema[fieldName] = fieldValue; | |
} | |
} | |
return genAISchema; | |
} | |
/** | |
* Transform a tool object | |
* @param {Object} tool - The tool object to transform | |
* @returns {Object} - The transformed 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; | |
} | |
} | |
} | |
if (functionDeclaration.response) { | |
if (!Object.keys(functionDeclaration.response).includes("$schema")) { | |
functionDeclaration.response = processJsonSchema( | |
functionDeclaration.response | |
); | |
} else { | |
if (!functionDeclaration.responseJsonSchema) { | |
functionDeclaration.responseJsonSchema = | |
functionDeclaration.response; | |
delete functionDeclaration.response; | |
} | |
} | |
} | |
} | |
} | |
return tool; | |
} | |
class GeminiCLITransformer { | |
name = "gemini-cli"; | |
constructor(options) { | |
this.options = options; | |
try { | |
this.oauth_creds = require(OAUTH_FILE); | |
} catch {} | |
} | |
async transformRequestIn(request, provider) { | |
if (this.oauth_creds && this.oauth_creds.expiry_date < +new Date()) { | |
await this.refreshToken(this.oauth_creds.refresh_token); | |
} | |
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, | |
}) | |
); | |
} | |
const webSearch = request.tools?.find( | |
(tool) => tool.function.name === "web_search" | |
); | |
if (webSearch) { | |
tools.push({ | |
googleSearch: {}, | |
}); | |
} | |
return { | |
body: { | |
request: { | |
contents: request.messages.map((message) => { | |
let role; | |
if (message.role === "assistant") { | |
role = "model"; | |
} else if (["user", "system", "tool"].includes(message.role)) { | |
role = "user"; | |
} else { | |
role = "user"; // Default to user if role is not recognized | |
} | |
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, | |
file_uri: content.image_url.url, | |
}, | |
}; | |
} else { | |
return { | |
inlineData: { | |
mime_type: content.media_type, | |
data: | |
content.image_url.url?.split(",")?.pop() || | |
content.image_url.url, | |
}, | |
}; | |
} | |
} | |
}) | |
); | |
} | |
if (Array.isArray(message.tool_calls)) { | |
parts.push( | |
...message.tool_calls.map((toolCall) => { | |
return { | |
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, | |
}; | |
}), | |
tools: tools.length ? tools : undefined, | |
}, | |
model: request.model, | |
project: this.options?.project, | |
}, | |
config: { | |
url: new URL( | |
`https://cloudcode-pa.googleapis.com/v1internal:${ | |
request.stream ? "streamGenerateContent?alt=sse" : "generateContent" | |
}` | |
), | |
headers: { | |
Authorization: `Bearer ${this.oauth_creds.access_token}`, | |
}, | |
}, | |
}; | |
} | |
async transformResponseOut(response) { | |
if (response.headers.get("Content-Type")?.includes("application/json")) { | |
let jsonResponse = await response.json(); | |
jsonResponse = jsonResponse.response; | |
const tool_calls = jsonResponse.candidates[0].content.parts | |
?.filter((part) => part.functionCall) | |
?.map((part) => ({ | |
id: | |
part.functionCall?.id || | |
`tool_${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: | |
jsonResponse.candidates[0].finishReason?.toLowerCase() || null, | |
index: 0, | |
message: { | |
content: jsonResponse.candidates[0].content.parts | |
.filter((part) => part.text) | |
.map((part) => part.text) | |
.join("\n"), | |
role: "assistant", | |
tool_calls: tool_calls.length > 0 ? tool_calls : undefined, | |
}, | |
}, | |
], | |
created: parseInt(new Date().getTime() / 1000 + "", 10), | |
model: jsonResponse.modelVersion, | |
object: "chat.completion", | |
usage: { | |
completion_tokens: jsonResponse.usageMetadata.candidatesTokenCount, | |
prompt_tokens: jsonResponse.usageMetadata.promptTokenCount, | |
total_tokens: jsonResponse.usageMetadata.totalTokenCount, | |
}, | |
}; | |
return new Response(JSON.stringify(res), { | |
status: response.status, | |
statusText: response.statusText, | |
headers: response.headers, | |
}); | |
} 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) { | |
this.logger.debug({ chunkStr }, "gemini-cli chunk:"); | |
try { | |
let chunk = JSON.parse(chunkStr); | |
chunk = chunk.response; | |
const tool_calls = chunk.candidates[0].content.parts | |
?.filter((part) => part.functionCall) | |
?.map((part) => ({ | |
id: | |
part.functionCall?.id || | |
`tool_${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: chunk.candidates[0].content.parts | |
?.filter((part) => part.text) | |
?.map((part) => part.text) | |
?.join("\n"), | |
tool_calls: | |
tool_calls.length > 0 ? tool_calls : undefined, | |
}, | |
finish_reason: | |
chunk.candidates[0].finishReason?.toLowerCase() || null, | |
index: | |
chunk.candidates[0].index || tool_calls.length > 0 | |
? 1 | |
: 0, | |
logprobs: null, | |
}, | |
], | |
created: parseInt(new Date().getTime() / 1000 + "", 10), | |
id: chunk.responseId || "", | |
model: chunk.modelVersion || "", | |
object: "chat.completion.chunk", | |
system_fingerprint: "fp_a49d71b8a1", | |
usage: { | |
completion_tokens: chunk.usageMetadata.candidatesTokenCount, | |
prompt_tokens: chunk.usageMetadata.promptTokenCount, | |
total_tokens: chunk.usageMetadata.totalTokenCount, | |
}, | |
}; | |
if ( | |
chunk.candidates[0]?.groundingMetadata?.groundingChunks?.length | |
) { | |
res.choices[0].delta.annotations = | |
chunk.candidates[0].groundingMetadata.groundingChunks.map( | |
(groundingChunk, index) => { | |
const support = | |
chunk.candidates[0]?.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 (error) { | |
this.logger.error( | |
{ chunkStr, error }, | |
"Error parsing Gemini stream chunk" | |
); | |
} | |
} | |
} | |
}; | |
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() || ""; | |
for (const line of lines) { | |
processLine(line, controller); | |
} | |
} | |
} catch (error) { | |
controller.error(error); | |
} finally { | |
controller.close(); | |
} | |
}, | |
}); | |
return new Response(stream, { | |
status: response.status, | |
statusText: response.statusText, | |
headers: response.headers, | |
}); | |
} | |
return response; | |
} | |
refreshToken(refresh_token) { | |
return fetch("https://oauth2.googleapis.com/token", { | |
method: "POST", | |
headers: { | |
"Content-Type": "application/json", | |
}, | |
body: JSON.stringify({ | |
client_id: | |
"681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com", | |
client_secret: "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", | |
refresh_token: refresh_token, | |
grant_type: "refresh_token", | |
}), | |
}) | |
.then((response) => response.json()) | |
.then(async (data) => { | |
data.expiry_date = | |
new Date().getTime() + data.expires_in * 1000 - 1000 * 60; | |
data.refresh_token = refresh_token; | |
delete data.expires_in; | |
this.oauth_creds = data; | |
await fs.writeFile(OAUTH_FILE, JSON.stringify(data, null, 2)); | |
}); | |
} | |
} | |
module.exports = GeminiCLITransformer; |
Oh, didn't know that, thanks for clarifying
Hey there, can GeminiCLITransformer handle multi-account rotation? Is there a way to use the quota of multiple accounts simultaneously? How is the quota limit implemented for Gemini CLI? I tried multi-account rotation, but the actual available quota was only that of a single account. However, those accounts in Gemini CLI can still access Gemini-2.5-Pro, which is a bit confusing.
使用免费账户尝试,transformers
的project必须填写,否则会报错。
{
"path": "$HOME/.claude-code-router/plugins/gemini-cli.js",
"options": {
"project": "your_project_id**"
}
},
此外,Providers中,api_base_url填两个都可以:
https://cloudcode-pa.googleapis.com/v1internal
或者
https://generativelanguage.googleapis.com/v1beta/models/
{
"LOG": true,
"LOG_LEVEL": "info",
"HOST": "127.0.0.1",
"PORT": 4356,
"APIKEY": "",
"API_TIMEOUT_MS": "600000",
"PROXY_URL": "",
"transformers": [
{
"path": "$HOME/.claude-code-router/plugins/gemini-cli.js",
"options": {
"project": "key"
}
},
],
"Providers": [
{
"name": "gemini",
"api_base_url": "https://cloudcode-pa.googleapis.com/v1internal",
"api_key": "oauth-managed",
"models": [
"gemini-2.5-flash",
"gemini-2.5-pro"
],
"transformer": {
"use": [
"gemini-cli"
]
}
},
],
"Router": {
"default": "gemini,gemini-2.5-pro"
},
"stream": false
}
{ "LOG": true, "LOG_LEVEL": "info", "HOST": "127.0.0.1", "PORT": 4356, "APIKEY": "", "API_TIMEOUT_MS": "600000", "PROXY_URL": "", "transformers": [ { "path": "$HOME/.claude-code-router/plugins/gemini-cli.js", "options": { "project": "key" } }, ], "Providers": [ { "name": "gemini", "api_base_url": "https://cloudcode-pa.googleapis.com/v1internal", "api_key": "oauth-managed", "models": [ "gemini-2.5-flash", "gemini-2.5-pro" ], "transformer": { "use": [ "gemini-cli" ] } }, ],
"Router": { "default": "gemini,gemini-2.5-pro" }, "stream": false }
插件的路径似乎不能写$HOME,不会自动展开,要写完整的路径。
接口 rate limit 怎么处理啊
@anthrotype The
client_id
andclient_secret
fields are also hardcoded in gemini-cli itself. That's why I think it's normal, or even good. Otherwise, it would be more difficult to use their API.packages/core/src/code_assist/oauth2.ts:30-39