From 3e6be4701203d09b0d30f0f843d479b7e732d212 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:27:24 -0300 Subject: [PATCH] feat(cli): Claude Code launcher + setup commands (remote mode + profiles) (#4274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings Claude Code to parity with the Codex CLI integration. - `omniroute launch` is now remote-aware: --remote , --profile , --api-key. Resolves base URL + auth from the active context (so `omniroute connect ` then `omniroute launch` just works), accepts a bare port OR a full base URL, health-checks the (possibly remote) server, and sets CLAUDE_CONFIG_DIR for the chosen profile. - `omniroute setup-claude` (new): fetches the live /v1/models catalog and writes ~/.claude/profiles//settings.json per model. Claude Code has no native profile files, so CLAUDE_CONFIG_DIR is the idiomatic mechanism. Reuses the SAME profile names as setup-codex (glm52, kimi-k27, …) via the shared categoriseModel (now exported). The auth token is NEVER written to disk — launch injects it. - Docs: docs/guides/CLAUDE-CODE-CONFIGURATION.md + README index. i18n (en + pt-BR). - Tests: setup-claude profile generation (incl. "no token on disk"), buildClaudeEnv (port + URL + CLAUDE_CONFIG_DIR), resolveLaunchTarget. check:cli-i18n + check-docs-sync green; 13 unit tests pass. --- README.md | 1 + bin/cli/commands/launch.mjs | 107 +++++++++++++--- bin/cli/commands/registry.mjs | 2 + bin/cli/commands/setup-claude.mjs | 152 +++++++++++++++++++++++ bin/cli/commands/setup-codex.mjs | 5 +- bin/cli/locales/en.json | 9 +- bin/cli/locales/pt-BR.json | 9 +- docs/guides/CLAUDE-CODE-CONFIGURATION.md | 144 +++++++++++++++++++++ tests/unit/cli/setup-claude.test.ts | 71 +++++++++++ 9 files changed, 475 insertions(+), 25 deletions(-) create mode 100644 bin/cli/commands/setup-claude.mjs create mode 100644 docs/guides/CLAUDE-CODE-CONFIGURATION.md create mode 100644 tests/unit/cli/setup-claude.test.ts diff --git a/README.md b/README.md index 423b31168c..767a985092 100644 --- a/README.md +++ b/README.md @@ -861,6 +861,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo | [Setup Guide](docs/guides/SETUP_GUIDE.md) | Full install methods, CLI tool configs, protocol setup, timeout tuning | | [CLI Tools Guide](docs/reference/CLI-TOOLS.md) | Per-tool setup for Claude Code, Codex, Cursor, Cline, OpenClaw, Kilo, Copilot | | [Remote Mode](docs/guides/REMOTE-MODE.md) | Drive a remote OmniRoute (VPS) from your laptop CLI via scoped access tokens | +| [Claude Code Config](docs/guides/CLAUDE-CODE-CONFIGURATION.md) | Point Claude Code at OmniRoute (local/remote) with `launch` + per-model profiles | | [Quick Start](README.md#-quick-start) | 3-step install → connect → configure | ### 🔧 Operations & Deployment diff --git a/bin/cli/commands/launch.mjs b/bin/cli/commands/launch.mjs index 831b216fc0..2cc1b21a09 100644 --- a/bin/cli/commands/launch.mjs +++ b/bin/cli/commands/launch.mjs @@ -1,53 +1,119 @@ import { spawn } from "node:child_process"; +import { join } from "node:path"; +import os from "node:os"; import { t } from "../i18n.mjs"; +import { resolveActiveContext } from "../contexts.mjs"; + +function stripTrailingSlash(value) { + let s = String(value); + let end = s.length; + while (end > 0 && s.charCodeAt(end - 1) === 47) end--; + return end === s.length ? s : s.slice(0, end); +} /** - * Build a clean child env for Claude Code pointed at the local proxy. - * Strips any inherited ANTHROPIC_* (avoids a stale shell token leaking through), - * then injects the proxy base URL, gateway model discovery, and auto-compact window. + * Build a clean child env for Claude Code pointed at OmniRoute. + * + * Strips inherited ANTHROPIC_* (avoids a stale shell token leaking through), then + * injects the base URL, gateway model discovery, and auto-compact window. + * * @param {Record} baseEnv - * @param {number} port + * @param {number|string} baseUrlOrPort a port (→ http://localhost:) or a full base URL * @param {string|undefined} authToken + * @param {{ configDir?:string, model?:string }} [opts] * @returns {Record} */ -export function buildClaudeEnv(baseEnv, port, authToken) { +export function buildClaudeEnv(baseEnv, baseUrlOrPort, authToken, opts = {}) { const env = { ...baseEnv }; for (const key of Object.keys(env)) { if (key.startsWith("ANTHROPIC_")) delete env[key]; } - env.ANTHROPIC_BASE_URL = `http://localhost:${port}`; + + // Accept a bare port (number/numeric string → localhost) or a full base URL. + // Claude Code wants the ROOT URL (it appends /v1/messages itself) — no /v1 here. + let baseUrl; + if (typeof baseUrlOrPort === "number" || /^\d+$/.test(String(baseUrlOrPort))) { + baseUrl = `http://localhost:${Number(baseUrlOrPort) || 20128}`; + } else { + baseUrl = stripTrailingSlash(String(baseUrlOrPort)).replace(/\/v1$/, ""); + } + + env.ANTHROPIC_BASE_URL = baseUrl; if (authToken) env.ANTHROPIC_AUTH_TOKEN = authToken; env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1"; env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = "190000"; + // Profile isolation (Claude Code has no native profiles — CLAUDE_CONFIG_DIR is + // the idiomatic mechanism: separate settings/credentials/history/cache per dir). + if (opts.configDir) env.CLAUDE_CONFIG_DIR = opts.configDir; + if (opts.model) env.ANTHROPIC_MODEL = opts.model; return env; } /** - * @param {{port?:string, token?:string}} opts + * Resolve the OmniRoute base URL + auth for launch, honouring (in order): + * explicit flags → the active context (remote mode) → localhost:. + * @param {{port?:string, remote?:string, baseUrl?:string, token?:string, apiKey?:string, context?:string}} opts + * @returns {{ baseUrl:string, authToken:string|undefined }} + */ +export function resolveLaunchTarget(opts = {}) { + const explicit = opts.remote ?? opts.baseUrl; + let baseUrl; + if (explicit) { + baseUrl = stripTrailingSlash(explicit).replace(/\/v1$/, ""); + } else { + let fromCtx; + try { + const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT); + fromCtx = ctx?.baseUrl; + } catch { + /* no context */ + } + baseUrl = fromCtx + ? stripTrailingSlash(fromCtx).replace(/\/v1$/, "") + : `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`; + } + + let authToken = opts.token ?? opts.apiKey ?? opts["api-key"]; + if (!authToken) { + try { + const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT); + authToken = ctx?.accessToken || ctx?.apiKey || undefined; + } catch { + /* no context auth */ + } + } + if (!authToken) authToken = process.env.ANTHROPIC_AUTH_TOKEN ?? process.env.OMNIROUTE_API_KEY; + return { baseUrl, authToken }; +} + +/** + * @param {{port?:string, remote?:string, token?:string, apiKey?:string, profile?:string, claudeHome?:string}} opts * @param {string[]} claudeArgs pass-through args for the claude binary * @returns {Promise} exit code */ export async function runLaunchCommand(opts = {}, claudeArgs = []) { - const port = Number(opts.port ?? process.env.PORT ?? 20128) || 20128; + const { baseUrl, authToken } = resolveLaunchTarget(opts); - // Health check the proxy before launching. + // Health check the (possibly remote) proxy before launching. try { - const res = await fetch(`http://localhost:${port}/api/monitoring/health`, { - signal: AbortSignal.timeout(1500), + const res = await fetch(`${baseUrl}/api/monitoring/health`, { + signal: AbortSignal.timeout(3000), }); if (!res.ok) throw new Error(`status ${res.status}`); - } catch (e) { + } catch { console.error( - (t("launch.notRunning") || "OmniRoute is not running on port {port}. Start it with 'omniroute serve'.").replace( + (t("launch.notRunning") || "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.").replace( "{port}", - String(port) + baseUrl ) ); return 1; } - const token = opts.token ?? process.env.ANTHROPIC_AUTH_TOKEN ?? undefined; - const env = buildClaudeEnv(process.env, port, token); + const configDir = opts.profile + ? join(opts.claudeHome || join(os.homedir(), ".claude"), "profiles", opts.profile) + : undefined; + const env = buildClaudeEnv(process.env, baseUrl, authToken, { configDir }); return await new Promise((resolve) => { const child = spawn("claude", claudeArgs, { env, stdio: "inherit" }); @@ -67,9 +133,14 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) { export function registerLaunch(program) { program .command("launch") - .description(t("launch.description") || "Launch Claude Code pointed at the local OmniRoute proxy") + .description( + t("launch.description") || "Launch Claude Code pointed at OmniRoute (local or remote)" + ) .option("--port ", t("serve.port") || "Proxy port", "20128") - .option("--token ", t("launch.token") || "API key the Claude client should send (ANTHROPIC_AUTH_TOKEN)") + .option("--remote ", "Remote OmniRoute base URL (overrides --port and the active context)") + .option("--profile ", "Claude Code profile to use (CLAUDE_CONFIG_DIR ~/.claude/profiles/)") + .option("--token ", t("launch.token") || "Token Claude sends (ANTHROPIC_AUTH_TOKEN)") + .option("--api-key ", "Alias for --token (OmniRoute access token / API key)") .allowUnknownOption(true) .allowExcessArguments(true) .argument("[claudeArgs...]", "arguments passed through to the claude binary") diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index bffba542cc..181e7ce2e5 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -58,6 +58,7 @@ import { registerRepl } from "./repl.mjs"; import { registerLaunch } from "./launch.mjs"; import { registerLaunchCodex } from "./launch-codex.mjs"; import { registerSetupCodex } from "./setup-codex.mjs"; +import { registerSetupClaude } from "./setup-claude.mjs"; import { registerConnect } from "./connect.mjs"; import { registerTokens } from "./tokens.mjs"; import { registerConfigure } from "./configure.mjs"; @@ -126,6 +127,7 @@ export function registerCommands(program) { registerLaunch(program); registerLaunchCodex(program); registerSetupCodex(program); + registerSetupClaude(program); registerConnect(program); registerTokens(program); registerConfigure(program); diff --git a/bin/cli/commands/setup-claude.mjs b/bin/cli/commands/setup-claude.mjs new file mode 100644 index 0000000000..5161e7b9ee --- /dev/null +++ b/bin/cli/commands/setup-claude.mjs @@ -0,0 +1,152 @@ +/** + * omniroute setup-claude — Remote-aware Claude Code profile generator. + * + * Claude Code has no native profile files (unlike Codex). The idiomatic way to + * keep multiple named configs is `CLAUDE_CONFIG_DIR` — a separate config dir per + * profile (its own settings.json, credentials, history, cache). This command + * fetches the live /v1/models catalog from a (possibly remote) OmniRoute and + * writes `~/.claude/profiles//settings.json` for each supported model, + * reusing the SAME profile names as `setup-codex` (glm52, kimi-k27, …). + * + * Launch a profile with: omniroute launch --profile + * (which injects ANTHROPIC_AUTH_TOKEN from the active context — the token is + * never written to disk). Or export ANTHROPIC_AUTH_TOKEN and run: + * CLAUDE_CONFIG_DIR=~/.claude/profiles/ claude + * + * Idempotent: re-running overwrites each profile's settings.json in place. + */ + +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import os from "node:os"; +import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; +import { categoriseModel } from "./setup-codex.mjs"; + +/** Map a Codex-style effort to a Claude Code settings.json effortLevel. */ +function effortLevelFor(cfg) { + // Codex categories use xhigh/high/low/undefined; Claude Code accepts the same + // names (low|medium|high|xhigh). Pass through, omit for the "simple" tier. + return cfg.effort || undefined; +} + +/** Build the settings.json content for one Claude Code profile. */ +export function buildProfileSettings(modelId, baseUrl, cfg) { + const env = { + ANTHROPIC_BASE_URL: baseUrl, + ANTHROPIC_MODEL: modelId, + CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1", + CLAUDE_CODE_AUTO_COMPACT_WINDOW: "190000", + }; + const settings = { + $schema: "https://json.schemastore.org/claude-code-settings.json", + model: modelId, + env, + }; + const effort = effortLevelFor(cfg); + if (effort) settings.effortLevel = effort; + // NOTE: ANTHROPIC_AUTH_TOKEN is intentionally NOT written here — `omniroute + // launch --profile` injects it from the active context, keeping the secret off + // disk. For direct `CLAUDE_CONFIG_DIR=… claude` use, export it in your shell. + return JSON.stringify(settings, null, 2) + "\n"; +} + +/** + * @param {{remote?:string, port?:string, apiKey?:string, claudeHome?:string, dryRun?:boolean, only?:string}} opts + * @returns {Promise} + */ +export async function runSetupClaudeCommand(opts = {}) { + const port = Number(opts.port ?? process.env.PORT ?? 20128) || 20128; + const baseUrl = (opts.remote ?? `http://localhost:${port}`).replace(/\/+$/, "").replace(/\/v1$/, ""); + const apiKey = opts.apiKey ?? opts["api-key"] ?? process.env.OMNIROUTE_API_KEY ?? ""; + const claudeHome = opts.claudeHome ?? opts["claude-home"] ?? join(os.homedir(), ".claude"); + const profilesRoot = join(claudeHome, "profiles"); + const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); + const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null; + + printHeading("OmniRoute → Claude Code profile generator"); + printInfo(`Connecting to ${baseUrl} …`); + + // ── Fetch model catalog ─────────────────────────────────────────────────── + let models; + try { + const headers = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + const res = await fetch(`${baseUrl}/v1/models`, { + headers, + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`); + const body = await res.json(); + models = body.data ?? body.models ?? []; + } catch (err) { + printError(`Failed to fetch models: ${err.message}`); + printInfo( + "Make sure OmniRoute is running and the --remote URL is correct.\n" + + "You may also need --api-key if OmniRoute requires authentication." + ); + return 1; + } + + printInfo(`Received ${models.length} models from ${baseUrl}`); + + if (!dryRun && !existsSync(profilesRoot)) { + mkdirSync(profilesRoot, { recursive: true }); + } + + let written = 0; + for (const m of models) { + const id = typeof m === "string" ? m : m.id ?? ""; + if (!id) continue; + if (onlyFilter && !onlyFilter.some((f) => id.includes(f))) continue; + + const cfg = categoriseModel(id); + if (!cfg) continue; + + const dir = join(profilesRoot, cfg.name); + const filePath = join(dir, "settings.json"); + const content = buildProfileSettings(id, baseUrl, cfg); + + if (dryRun) { + console.log(`\n── [dry-run] ${filePath} ──`); + console.log(content); + } else { + mkdirSync(dir, { recursive: true }); + writeFileSync(filePath, content, "utf8"); + printSuccess(` ✓ profiles/${cfg.name}/settings.json (${id})`); + } + written++; + } + + const skipped = models.length - written; + if (!dryRun) { + console.log(""); + printSuccess(`${written} Claude Code profiles written to ${profilesRoot}`); + if (skipped > 0) printInfo(`${skipped} models skipped (no matching profile pattern)`); + console.log("\nTo use a profile:"); + console.log(" omniroute launch --profile # e.g. omniroute launch --profile glm52"); + console.log(" # or: CLAUDE_CONFIG_DIR=~/.claude/profiles/ claude (export ANTHROPIC_AUTH_TOKEN first)"); + } else { + console.log(`\n[dry-run] ${written} profiles would be written (${skipped} skipped)`); + } + + return 0; +} + +export function registerSetupClaude(program) { + program + .command("setup-claude") + .description( + "Fetch the live model catalog from OmniRoute (local or remote VPS) and generate " + + "~/.claude/profiles// Claude Code profiles (CLAUDE_CONFIG_DIR) for each 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("--claude-home ", "Claude home dir (default: ~/.claude)") + .option("--only ", "Comma-separated substrings — only matching model IDs (e.g. glm,kimi)") + .option("--dry-run", "Print what would be written without touching the filesystem") + .action(async (opts) => { + const exitCode = await runSetupClaudeCommand(opts); + if (exitCode !== 0) process.exit(exitCode); + }); +} diff --git a/bin/cli/commands/setup-codex.mjs b/bin/cli/commands/setup-codex.mjs index 39d86fe89b..76aee2fb61 100644 --- a/bin/cli/commands/setup-codex.mjs +++ b/bin/cli/commands/setup-codex.mjs @@ -24,10 +24,13 @@ import { t } from "../i18n.mjs"; * Map a model ID (as returned by /v1/models) to a Codex profile configuration. * Returns null for models that should not get their own profile (e.g. aliases). * + * Exported so the Claude Code profile generator (`setup-claude`) reuses the SAME + * profile names (glm52, kimi-k27, …) for cross-CLI consistency. + * * @param {string} modelId * @returns {{ name:string, ctx:number, compact:number, effort?:string, summary?:boolean, toolLimit:number }|null} */ -function categoriseModel(modelId) { +export function categoriseModel(modelId) { const id = modelId.toLowerCase(); // ── Thinking models (max effort, detailed summary) ──────────────────────── diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index c296607841..bdac80e58b 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -1258,11 +1258,14 @@ "scaffold": "Scaffold a new plugin boilerplate" }, "launch": { - "description": "Launch Claude Code pointed at the local OmniRoute proxy", - "token": "API key the Claude client should send (ANTHROPIC_AUTH_TOKEN)", - "notRunning": "OmniRoute is not running on port {port}. Start it with 'omniroute serve'.", + "description": "Launch Claude Code pointed at OmniRoute (local or remote, with --profile)", + "token": "Token the Claude client should send (ANTHROPIC_AUTH_TOKEN)", + "notRunning": "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.", "notFound": "The 'claude' CLI was not found in PATH." }, + "setupClaude": { + "description": "Generate ~/.claude/profiles Claude Code profiles from the OmniRoute model catalog" + }, "connect": { "description": "Connect to a remote OmniRoute server and enter remote mode" }, diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index e7d99eba45..44de1facc1 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -1257,11 +1257,14 @@ "scaffold": "Gerar boilerplate de novo plugin" }, "launch": { - "description": "Inicia o Claude Code apontando para o proxy local do OmniRoute", - "token": "Chave de API que o cliente Claude deve enviar (ANTHROPIC_AUTH_TOKEN)", - "notRunning": "OmniRoute não está rodando na porta {port}. Inicie com 'omniroute serve'.", + "description": "Inicia o Claude Code apontando para o OmniRoute (local ou remoto, com --profile)", + "token": "Token que o cliente Claude deve enviar (ANTHROPIC_AUTH_TOKEN)", + "notRunning": "OmniRoute não está acessível em {port}. Inicie com 'omniroute serve'.", "notFound": "O CLI 'claude' não foi encontrado no PATH." }, + "setupClaude": { + "description": "Gera profiles do Claude Code em ~/.claude/profiles a partir do catálogo de modelos do OmniRoute" + }, "connect": { "description": "Conecta a um servidor OmniRoute remoto e entra no modo remoto" }, diff --git a/docs/guides/CLAUDE-CODE-CONFIGURATION.md b/docs/guides/CLAUDE-CODE-CONFIGURATION.md new file mode 100644 index 0000000000..4cfea16d12 --- /dev/null +++ b/docs/guides/CLAUDE-CODE-CONFIGURATION.md @@ -0,0 +1,144 @@ +--- +title: "Claude Code CLI — Configuration with OmniRoute" +version: 3.8.30 +lastUpdated: 2026-06-19 +--- + +# Claude Code CLI — Configuration with OmniRoute + +Point the **Claude Code** CLI (`claude`) at OmniRoute — local or a remote VPS — +with per-model profiles, mirroring the Codex setup. + +--- + +## Quick start + +```bash +# Launch Claude Code against a local OmniRoute (auto-detects the active context) +omniroute launch + +# Against a remote OmniRoute (after `omniroute connect `, this is automatic) +omniroute launch --remote http://192.168.0.15:20128 --api-key oma_live_xxx + +# Generate per-model profiles, then launch one +omniroute setup-claude # writes ~/.claude/profiles//settings.json +omniroute launch --profile glm52 # Claude Code using glm/glm-5.2 via OmniRoute +``` + +--- + +## How Claude Code connects to a gateway + +Claude Code talks the **Anthropic Messages API** and is pointed at a custom +endpoint with environment variables (it has no `--base-url` flag): + +| Variable | Purpose | +|----------|---------| +| `ANTHROPIC_BASE_URL` | Gateway root URL (Claude Code appends `/v1/messages`). **No `/v1` suffix.** | +| `ANTHROPIC_AUTH_TOKEN` | Sent as `Authorization: Bearer …` — use your OmniRoute access token / API key | +| `ANTHROPIC_API_KEY` | Alternative: sent as `x-api-key`. If both set, `AUTH_TOKEN` wins | +| `ANTHROPIC_MODEL` | Force a specific model (overrides the `/model` picker default) | +| `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY` | `1` → the native `/model` picker lists `claude*`/`anthropic*` models from `/v1/models` | +| `CLAUDE_CODE_MAX_OUTPUT_TOKENS` | Cap output tokens per response (e.g. `65536`) | +| `CLAUDE_CODE_AUTO_COMPACT_WINDOW` | Token threshold for auto-compaction | + +> Env vars are read **once at startup** — restart Claude Code after changing them. + +`omniroute launch` sets all of these for you: it resolves the base URL + token +from the active context (so `omniroute connect ` then `omniroute launch` +just works), health-checks the server, and execs `claude`. + +--- + +## Profiles (`CLAUDE_CONFIG_DIR`) + +Claude Code has **no native profile files** (unlike Codex's `~/.codex/.config.toml`). +The idiomatic mechanism is `CLAUDE_CONFIG_DIR` — a separate config directory per +profile, each with its own `settings.json`, credentials, history and cache. + +`omniroute setup-claude` fetches the live `/v1/models` catalog and writes one +profile per model at `~/.claude/profiles//settings.json`, reusing the +**same names as `setup-codex`** (`glm52`, `kimi-k27`, `deepseek-pro`, …): + +```jsonc +// ~/.claude/profiles/glm52/settings.json +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "model": "glm/glm-5.2", + "effortLevel": "xhigh", + "env": { + "ANTHROPIC_BASE_URL": "http://192.168.0.15:20128", + "ANTHROPIC_MODEL": "glm/glm-5.2", + "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1", + "CLAUDE_CODE_AUTO_COMPACT_WINDOW": "190000" + } +} +``` + +> **The auth token is never written to the profile.** Launch with +> `omniroute launch --profile ` (it injects `ANTHROPIC_AUTH_TOKEN` from the +> active context), or export `ANTHROPIC_AUTH_TOKEN` yourself and run +> `CLAUDE_CONFIG_DIR=~/.claude/profiles/ claude`. + +### Generating + using profiles + +```bash +# Local OmniRoute +omniroute setup-claude + +# Remote VPS (bakes the VPS URL into every profile) +omniroute setup-claude --remote http://192.168.0.15:20128 --api-key oma_live_xxx + +# Only some providers +omniroute setup-claude --only glm,kimi + +# Preview without writing +omniroute setup-claude --dry-run + +# Launch a profile +omniroute launch --profile kimi-k27 +``` + +--- + +## Model tiers (optional) + +Claude Code routes to capability tiers. Map each to an OmniRoute model via env / +settings if you want different providers per tier: + +```bash +export ANTHROPIC_DEFAULT_OPUS_MODEL="glm/glm-5.2" +export ANTHROPIC_DEFAULT_SONNET_MODEL="kmc/kimi-k2.6" +export ANTHROPIC_DEFAULT_HAIKU_MODEL="glm/glm-4.7-flash" +``` + +Otherwise a single `ANTHROPIC_MODEL` (what profiles set) is used for everything. + +--- + +## Remote mode + +Once you've run `omniroute connect ` (see +[Remote Mode](./REMOTE-MODE.md)), `omniroute launch` and `omniroute setup-claude` +automatically target that remote server and use its scoped access token — no +extra flags needed. Override per-invocation with `--remote` / `--api-key`. + +--- + +## Troubleshooting + +**Claude Code ignores the gateway** — confirm `ANTHROPIC_BASE_URL` has **no +`/v1`** and restart `claude` (env is read once at startup). `omniroute launch` +handles this for you. + +**`/model` picker is empty / missing gateway models** — needs Claude Code +v2.1.129+ and `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`. Only `claude*` / +`anthropic*` model IDs appear in the picker; force any other model with +`ANTHROPIC_MODEL=` (this is what profiles do). + +**Auth errors** — the profile holds no token. Use `omniroute launch --profile` +(injects it) or export `ANTHROPIC_AUTH_TOKEN`. + +**Profiles don't isolate** — each profile is a distinct `CLAUDE_CONFIG_DIR`; +verify `echo $CLAUDE_CONFIG_DIR` inside the session points at +`~/.claude/profiles/`. diff --git a/tests/unit/cli/setup-claude.test.ts b/tests/unit/cli/setup-claude.test.ts new file mode 100644 index 0000000000..4d55a8d21e --- /dev/null +++ b/tests/unit/cli/setup-claude.test.ts @@ -0,0 +1,71 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildProfileSettings } from "../../../bin/cli/commands/setup-claude.mjs"; +import { buildClaudeEnv, resolveLaunchTarget } from "../../../bin/cli/commands/launch.mjs"; +import { categoriseModel } from "../../../bin/cli/commands/setup-codex.mjs"; + +// ── setup-claude profile generation ────────────────────────────────────────── + +test("buildProfileSettings pins the model + base URL + gateway discovery", () => { + const cfg = categoriseModel("glm/glm-5.2"); // thinking → effort xhigh + const json = JSON.parse(buildProfileSettings("glm/glm-5.2", "http://vps:20128", cfg)); + assert.equal(json.model, "glm/glm-5.2"); + assert.equal(json.env.ANTHROPIC_BASE_URL, "http://vps:20128"); + assert.equal(json.env.ANTHROPIC_MODEL, "glm/glm-5.2"); + assert.equal(json.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY, "1"); + assert.equal(json.effortLevel, "xhigh"); +}); + +test("buildProfileSettings NEVER writes the auth token to disk", () => { + const cfg = categoriseModel("kmc/kimi-k2.7"); + const raw = buildProfileSettings("kmc/kimi-k2.7", "http://vps:20128", cfg); + assert.equal(raw.includes("ANTHROPIC_AUTH_TOKEN"), false); + assert.equal(raw.includes("ANTHROPIC_API_KEY"), false); +}); + +test("buildProfileSettings omits effortLevel for the simple tier", () => { + const cfg = categoriseModel("ollamacloud/gemma4:31b"); // simple → no effort + const json = JSON.parse(buildProfileSettings("ollamacloud/gemma4:31b", "http://x:20128", cfg)); + assert.equal("effortLevel" in json, false); +}); + +test("profile names match setup-codex (cross-CLI consistency)", () => { + assert.equal(categoriseModel("glm/glm-5.2").name, "glm52"); + assert.equal(categoriseModel("kmc/kimi-k2.7").name, "kimi-k27"); +}); + +// ── launch env (Claude Code) ───────────────────────────────────────────────── + +test("buildClaudeEnv still accepts a bare port (backward compatible)", () => { + const env = buildClaudeEnv({ PATH: "/bin" }, 20128, "secret"); + assert.equal(env.ANTHROPIC_BASE_URL, "http://localhost:20128"); + assert.equal(env.ANTHROPIC_AUTH_TOKEN, "secret"); + assert.equal(env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY, "1"); +}); + +test("buildClaudeEnv accepts a full base URL and strips /v1", () => { + const env = buildClaudeEnv({}, "https://vps.example.com:20128/v1", "t"); + assert.equal(env.ANTHROPIC_BASE_URL, "https://vps.example.com:20128"); +}); + +test("buildClaudeEnv sets CLAUDE_CONFIG_DIR for a profile", () => { + const env = buildClaudeEnv({}, 20128, "t", { configDir: "/home/u/.claude/profiles/glm52" }); + assert.equal(env.CLAUDE_CONFIG_DIR, "/home/u/.claude/profiles/glm52"); +}); + +test("buildClaudeEnv strips inherited ANTHROPIC_* and does not mutate input", () => { + const input = { ANTHROPIC_API_KEY: "leak", PATH: "/bin" }; + const env = buildClaudeEnv(input, 20128, "x"); + assert.equal(env.ANTHROPIC_API_KEY, undefined); + assert.equal(input.ANTHROPIC_API_KEY, "leak"); +}); + +test("resolveLaunchTarget: explicit --remote wins, strips /v1", () => { + const { baseUrl } = resolveLaunchTarget({ remote: "https://vps:20128/v1" }); + assert.equal(baseUrl, "https://vps:20128"); +}); + +test("resolveLaunchTarget: explicit token wins over everything", () => { + const { authToken } = resolveLaunchTarget({ remote: "http://x:20128", token: "tok-explicit" }); + assert.equal(authToken, "tok-explicit"); +});