mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(cli): Claude Code launcher + setup commands (remote mode + profiles) (#4274)
Brings Claude Code to parity with the Codex CLI integration. - `omniroute launch` is now remote-aware: --remote <url>, --profile <name>, --api-key. Resolves base URL + auth from the active context (so `omniroute connect <vps>` 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/<name>/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.
This commit is contained in:
committed by
GitHub
parent
3517d935e4
commit
3e6be47012
@@ -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
|
||||
|
||||
@@ -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<string,string>} baseEnv
|
||||
* @param {number} port
|
||||
* @param {number|string} baseUrlOrPort a port (→ http://localhost:<port>) or a full base URL
|
||||
* @param {string|undefined} authToken
|
||||
* @param {{ configDir?:string, model?:string }} [opts]
|
||||
* @returns {Record<string,string>}
|
||||
*/
|
||||
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:<port>.
|
||||
* @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<number>} 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 <port>", t("serve.port") || "Proxy port", "20128")
|
||||
.option("--token <token>", t("launch.token") || "API key the Claude client should send (ANTHROPIC_AUTH_TOKEN)")
|
||||
.option("--remote <url>", "Remote OmniRoute base URL (overrides --port and the active context)")
|
||||
.option("--profile <name>", "Claude Code profile to use (CLAUDE_CONFIG_DIR ~/.claude/profiles/<name>)")
|
||||
.option("--token <token>", t("launch.token") || "Token Claude sends (ANTHROPIC_AUTH_TOKEN)")
|
||||
.option("--api-key <key>", "Alias for --token (OmniRoute access token / API key)")
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.argument("[claudeArgs...]", "arguments passed through to the claude binary")
|
||||
|
||||
@@ -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);
|
||||
|
||||
152
bin/cli/commands/setup-claude.mjs
Normal file
152
bin/cli/commands/setup-claude.mjs
Normal file
@@ -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/<name>/settings.json` for each supported model,
|
||||
* reusing the SAME profile names as `setup-codex` (glm52, kimi-k27, …).
|
||||
*
|
||||
* Launch a profile with: omniroute launch --profile <name>
|
||||
* (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/<name> 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<number>}
|
||||
*/
|
||||
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 <name> # e.g. omniroute launch --profile glm52");
|
||||
console.log(" # or: CLAUDE_CONFIG_DIR=~/.claude/profiles/<name> 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/<name>/ Claude Code profiles (CLAUDE_CONFIG_DIR) for each model"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--claude-home <dir>", "Claude home dir (default: ~/.claude)")
|
||||
.option("--only <patterns>", "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);
|
||||
});
|
||||
}
|
||||
@@ -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) ────────────────────────
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
144
docs/guides/CLAUDE-CODE-CONFIGURATION.md
Normal file
144
docs/guides/CLAUDE-CODE-CONFIGURATION.md
Normal file
@@ -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 <host>`, 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/<name>/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 <vps>` 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/<name>.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/<name>/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 <name>` (it injects `ANTHROPIC_AUTH_TOKEN` from the
|
||||
> active context), or export `ANTHROPIC_AUTH_TOKEN` yourself and run
|
||||
> `CLAUDE_CONFIG_DIR=~/.claude/profiles/<name> 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 <host>` (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=<id>` (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/<name>`.
|
||||
71
tests/unit/cli/setup-claude.test.ts
Normal file
71
tests/unit/cli/setup-claude.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user