diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index 937888bc2d..8126291066 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -69,6 +69,7 @@ import { registerSetupCrush } from "./setup-crush.mjs"; import { registerSetupGoose } from "./setup-goose.mjs"; import { registerSetupQwen } from "./setup-qwen.mjs"; import { registerSetupAider } from "./setup-aider.mjs"; +import { registerSetupGemini } from "./setup-gemini.mjs"; import { registerConnect } from "./connect.mjs"; import { registerTokens } from "./tokens.mjs"; import { registerConfigure } from "./configure.mjs"; @@ -148,6 +149,7 @@ export function registerCommands(program) { registerSetupGoose(program); registerSetupQwen(program); registerSetupAider(program); + registerSetupGemini(program); registerConnect(program); registerTokens(program); registerConfigure(program); diff --git a/bin/cli/commands/setup-gemini.mjs b/bin/cli/commands/setup-gemini.mjs new file mode 100644 index 0000000000..3db242336c --- /dev/null +++ b/bin/cli/commands/setup-gemini.mjs @@ -0,0 +1,148 @@ +/** + * omniroute setup-gemini — point the Gemini CLI at OmniRoute's Gemini endpoint. + * + * The Gemini CLI is NOT OpenAI-compatible — it speaks the native Gemini API. + * OmniRoute exposes a Gemini-native surface at /v1beta (e.g. + * /v1beta/models/:generateContent), so the CLI can target it via the + * @google/genai SDK env `GOOGLE_GEMINI_BASE_URL` (ROOT — the SDK appends /v1beta) + * + `GEMINI_API_KEY`. There is no settings.json key for the base URL, so this is + * primarily an env recipe; we optionally write ~/.gemini/settings.json `model`. + * + * ⚠ Known Gemini CLI caveat: it may ignore GOOGLE_GEMINI_BASE_URL if a cached + * Google login exists — run `gemini` logged-out / API-key-only for it to take. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import os from "node:os"; +import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs"; +import { resolveActiveContext } from "../contexts.mjs"; + +function stripToRoot(url) { + const s = String(url || "").replace(/\/+$/, ""); + return s.endsWith("/v1beta") ? s.slice(0, -7) : s.endsWith("/v1") ? s.slice(0, -3) : s; +} + +/** Resolve GOOGLE_GEMINI_BASE_URL (ROOT — SDK appends /v1beta) + apiKey. */ +export function resolveGeminiTarget(opts = {}) { + let root; + if (opts.remote) root = stripToRoot(opts.remote); + else { + try { + root = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl); + } catch { + /* none */ + } + if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`; + } + let apiKey = opts.apiKey ?? opts["api-key"]; + if (!apiKey) { + try { + const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT); + apiKey = c?.accessToken || c?.apiKey; + } catch { + /* none */ + } + } + if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || ""; + return { baseUrl: root, apiKey }; +} + +/** The guaranteed env recipe (pure → testable). */ +export function buildGeminiRecipe({ baseUrl, model }) { + return [ + `export GOOGLE_GEMINI_BASE_URL=${baseUrl}`, + "export GEMINI_API_KEY=$OMNIROUTE_API_KEY", + `export GEMINI_MODEL=${model}`, + `gemini -p "reply OK" # or: gemini (interactive)`, + ].join("\n"); +} + +/** Merge the model into ~/.gemini/settings.json (base URL is env-only). */ +export function buildGeminiSettings(existing, { model }) { + const s = existing && typeof existing === "object" ? { ...existing } : {}; + if (model) s.model = model; + return s; +} + +function readJson(path) { + try { + if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8")); + } catch { + /* corrupt/missing */ + } + return {}; +} + +async function fetchGeminiModelIds(baseUrl, apiKey) { + try { + const res = await fetch(`${baseUrl}/v1beta/models`, { + headers: { "x-goog-api-key": apiKey || "" }, + signal: AbortSignal.timeout(8000), + }); + if (!res.ok) return []; + const body = await res.json(); + return (body.models || []).map((m) => String(m.name || "").replace(/^models\//, "")).filter(Boolean); + } catch { + return []; + } +} + +export async function runSetupGeminiCommand(opts = {}) { + const { baseUrl, apiKey } = resolveGeminiTarget(opts); + const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); + const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".gemini", "settings.json"); + + printHeading("OmniRoute → Gemini CLI (native Gemini /v1beta endpoint)"); + printInfo(`GOOGLE_GEMINI_BASE_URL: ${baseUrl} (root — SDK appends /v1beta)`); + + let model = opts.model; + if (!model) { + const ids = await fetchGeminiModelIds(baseUrl, apiKey); + if (ids.length && !opts.yes) { + printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`); + const prompt = createPrompt(); + try { + model = await prompt.ask("Model id for Gemini CLI"); + } finally { + prompt.close(); + } + } + } + if (!model) { + printError("A model is required. Pass --model ."); + return 2; + } + + if (dryRun) { + console.log(`\n── [dry-run] ${configPath} ── { "model": "${model}" }`); + } else { + const merged = buildGeminiSettings(readJson(configPath), { model }); + mkdirSync(join(configPath, ".."), { recursive: true }); + writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n", "utf8"); + printSuccess(`Wrote ${configPath} (model)`); + } + + printInfo("\nThe base URL is env-only for Gemini CLI — export these:"); + console.log(buildGeminiRecipe({ baseUrl, model })); + printInfo("\n⚠ If Gemini CLI ignores the base URL, you have a cached Google login —"); + printInfo(" run logged-out (API-key only) so GOOGLE_GEMINI_BASE_URL takes effect."); + return 0; +} + +export function registerSetupGemini(program) { + program + .command("setup-gemini") + .description("Point the Gemini CLI at OmniRoute's native Gemini /v1beta endpoint (env recipe + settings model)") + .option("--port ", "Local OmniRoute port (ignored when --remote is set)", "20128") + .option("--remote ", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128") + .option("--api-key ", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)") + .option("--model ", "Model id for Gemini CLI (required unless picked interactively)") + .option("--config-path ", "settings.json path (default: ~/.gemini/settings.json)") + .option("--yes", "Non-interactive: do not prompt (requires --model)") + .option("--dry-run", "Print what would be written without touching the filesystem") + .action(async (opts) => { + const code = await runSetupGeminiCommand(opts); + if (code !== 0) process.exit(code); + }); +} diff --git a/docs/guides/REMOTE-MODE.md b/docs/guides/REMOTE-MODE.md index 01566f6382..ab1cd097fe 100644 --- a/docs/guides/REMOTE-MODE.md +++ b/docs/guides/REMOTE-MODE.md @@ -156,6 +156,7 @@ context, or `--remote --api-key `): | Goose | `omniroute setup-goose` | `~/.config/goose/config.yaml` (`GOOSE_PROVIDER=openai` + `OPENAI_HOST` **without** `/v1` + `GOOSE_MODEL`) + env recipe | | Qwen Code | `omniroute setup-qwen` | `~/.qwen/settings.json` — openai `modelProvider`, `baseUrl` **with** `/v1`, key via `envKey` (OMNIROUTE_API_KEY) | | Aider | `omniroute setup-aider` | `~/.aider.conf.yml` (`openai-api-base` **without** `/v1` + `model: openai/`) + env recipe (`aider --message --yes`) | +| Gemini CLI | `omniroute setup-gemini` | **native** Gemini API (not OpenAI-compatible) → `GOOGLE_GEMINI_BASE_URL` (root, SDK appends `/v1beta`) + `GEMINI_API_KEY` + `~/.gemini/settings.json` (`model`). ⚠ a cached Google login can override the base URL — run API-key-only | ```bash # OpenCode (openai-compatible provider, all catalog models, remote VPS) diff --git a/tests/unit/cli/setup-gemini.test.ts b/tests/unit/cli/setup-gemini.test.ts new file mode 100644 index 0000000000..a61a6498ac --- /dev/null +++ b/tests/unit/cli/setup-gemini.test.ts @@ -0,0 +1,24 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { resolveGeminiTarget, buildGeminiRecipe, buildGeminiSettings } from "../../../bin/cli/commands/setup-gemini.mjs"; + +test("resolveGeminiTarget strips /v1beta and /v1 to the root (SDK appends /v1beta)", () => { + assert.equal(resolveGeminiTarget({ remote: "http://vps:20128/v1beta" }).baseUrl, "http://vps:20128"); + assert.equal(resolveGeminiTarget({ remote: "http://vps:20128/v1/" }).baseUrl, "http://vps:20128"); + assert.equal(resolveGeminiTarget({ remote: "http://vps:20128" }).baseUrl, "http://vps:20128"); +}); +test("resolveGeminiTarget: explicit --api-key wins", () => { + assert.equal(resolveGeminiTarget({ remote: "http://x:20128", apiKey: "sk-x" }).apiKey, "sk-x"); +}); +test("buildGeminiRecipe sets GOOGLE_GEMINI_BASE_URL (root) + GEMINI_API_KEY env-ref + model", () => { + const r = buildGeminiRecipe({ baseUrl: "http://vps:20128", model: "gemini-3-flash" }); + assert.ok(r.includes("GOOGLE_GEMINI_BASE_URL=http://vps:20128")); + assert.ok(r.includes("GEMINI_API_KEY=$OMNIROUTE_API_KEY")); + assert.ok(r.includes("GEMINI_MODEL=gemini-3-flash")); + assert.ok(r.includes("gemini -p")); +}); +test("buildGeminiSettings sets model, preserves other settings", () => { + const s = buildGeminiSettings({ theme: "Default" }, { model: "gemini-3-flash" }); + assert.equal(s.model, "gemini-3-flash"); + assert.equal(s.theme, "Default"); +});