Skip to content

I got the Router working with Gemini #1018

Description

@NubeBuster

~/.claude-code-router/plugins/gemini-cli.js

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

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": ""
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions