diff --git a/.env.example b/.env.example index dfa8d54684..294f1127ac 100644 --- a/.env.example +++ b/.env.example @@ -529,10 +529,13 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # Allow OmniRoute to write CLI config files (token refresh, etc.). # CLI_ALLOW_CONFIG_WRITES=true -# Auto-sync Codex profile files after provider model discovery changes. OPT-IN, default OFF. -# When enabled, writes only ~/.codex/*.config.toml profiles; never changes the active/default -# Codex config. Also requires CLI_ALLOW_CONFIG_WRITES (default on). Leave unset to disable. +# Auto-sync CLI profile files after provider model discovery changes. OPT-IN, default OFF for +# both. When enabled, writes only the tool's profile files (~/.codex/*.config.toml or +# ~/.claude/profiles//settings.json); never changes the active/default config. Both also +# require CLI_ALLOW_CONFIG_WRITES (default on). Toggle from the providers dashboard, or set here. +# Leave unset to disable. (Feature flags — a DB/dashboard override takes precedence over env.) # OMNIROUTE_AUTO_SYNC_CODEX_PROFILES=true +# OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES=true # Override binary paths for individual CLI tools. # CLI_CLAUDE_BIN=claude diff --git a/CHANGELOG.md b/CHANGELOG.md index 426a8fc16a..9fd775c3e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### ✨ New Features +- **providers (CLI profile auto-sync):** opt-in toggles to auto-regenerate CLI tool profiles after a provider model sync. When enabled, a model-catalog change (re)writes that tool's profile files from the live catalog — Codex (`~/.codex/*.config.toml`) and now **Claude Code** (`~/.claude/profiles//settings.json`, via an extracted `syncClaudeProfilesFromModels` + a new `claudeProfileAutoSync.ts` mirroring the Codex path). Both are **off by default** and never touch the active/default CLI config; they are backed by the `OMNIROUTE_AUTO_SYNC_CODEX_PROFILES` / `OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES` feature flags (DB/dashboard override > env > default "false") and additionally gated behind the existing `CLI_ALLOW_CONFIG_WRITES` write-guard. A **"CLI profile auto-sync"** card on the providers dashboard toggles each. Regression guards: `tests/unit/claude-profile-auto-sync-gate.test.ts`, `tests/unit/codex-profile-auto-sync-gate.test.ts`, `tests/unit/cli/setup-claude.test.ts` (follow-up to #5737). + - **analytics (subscription cost):** flat-rate providers now show **$0** in cost analytics instead of an inflated per-token estimate. Subscription / coding-plan providers (every cookie-web provider — ChatGPT Web, grok-web, … — plus the dedicated **Minimax Coding**, **Kimi Coding**, **GLM Coding**, **Alibaba Coding Plan**, and **Xiaomi MiMo** plans) bill a flat fee, not per token, yet still carry per-token pricing rows used for estimates — so the analytics dashboard over-reported their cost. A new flat-rate classifier (`src/lib/usage/flatRateProviders.ts`) is consulted by the analytics surfaces (analytics route, usage stats, usage analytics) via an opt-in `flatRateAsZero` cost option, so those providers read $0 while **budget / quota / routing keep estimating unchanged**. Deliberately NOT zeroed: `codex`/`cx` (OmniRoute actively tracks Codex token cost — Fast-tier multipliers, GPT-5.x pricing — and Codex can be a metered account), `byteplus` (metered ModelArk), `minimax-cn` (metered China API). Regression guard: `tests/unit/flat-rate-cost-5552.test.ts`. ([#5552](https://github.com/diegosouzapw/OmniRoute/issues/5552)) - **mcp (RTK):** expose the RTK tool-output **learn/discover** workflow as two new MCP tools so an agent can grow the RTK filter catalog without leaving the protocol. `omniroute_rtk_discover` analyzes recently captured raw tool output (`discoverRepeatedNoise` / `suggestFilter`) and returns candidate noise patterns plus a suggested filter; `omniroute_rtk_learn` lists the captured command samples (`listRtkCommandSamples`) and resolves a command to its RTK filter id (`commandToId`). Both are read-only (scope `read:compression`), wrap the existing RTK discovery primitives (no new logic in the engine), and log to the MCP audit trail. Regression guard: `tests/unit/compression/rtk-mcp-tools.test.ts` (4). gaps v3.8.42 — T07. diff --git a/bin/cli/commands/setup-claude.mjs b/bin/cli/commands/setup-claude.mjs index 5161e7b9ee..b1c5f85b76 100644 --- a/bin/cli/commands/setup-claude.mjs +++ b/bin/cli/commands/setup-claude.mjs @@ -50,6 +50,65 @@ export function buildProfileSettings(modelId, baseUrl, cfg) { return JSON.stringify(settings, null, 2) + "\n"; } +/** + * Generate Claude Code profile files for a live model catalog. Shared by the + * `setup-claude` CLI command and the post-model-sync auto-sync so both stay + * behaviorally identical. Writes `/profiles//settings.json` + * (directory-per-profile); never touches the active/default Claude config. + * @param {Array} models + * @param {{claudeHome?:string, baseUrl:string, dryRun?:boolean, only?:string}} opts + * @returns {Promise<{written:number, skipped:number, profiles:Array<{name:string, model:string, filePath:string}>}>} + */ +export async function syncClaudeProfilesFromModels(models, opts = {}) { + const claudeHome = opts.claudeHome || join(os.homedir(), ".claude"); + const profilesRoot = join(claudeHome, "profiles"); + const baseUrl = opts.baseUrl; + const dryRun = Boolean(opts.dryRun); + const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null; + + if (!dryRun && !existsSync(profilesRoot)) { + mkdirSync(profilesRoot, { recursive: true }); + } + + let written = 0; + let skipped = 0; + const profiles = []; + + for (const m of models) { + const id = typeof m === "string" ? m : m.id ?? ""; + if (!id) { + skipped++; + continue; + } + if (onlyFilter && !onlyFilter.some((f) => id.includes(f))) { + skipped++; + continue; + } + + const cfg = categoriseModel(id); + if (!cfg) { + skipped++; + 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"); + } + profiles.push({ name: cfg.name, model: id, filePath }); + written++; + } + + return { written, skipped, profiles }; +} + /** * @param {{remote?:string, port?:string, apiKey?:string, claudeHome?:string, dryRun?:boolean, only?:string}} opts * @returns {Promise} @@ -61,7 +120,6 @@ export async function runSetupClaudeCommand(opts = {}) { 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} …`); @@ -89,36 +147,17 @@ export async function runSetupClaudeCommand(opts = {}) { printInfo(`Received ${models.length} models from ${baseUrl}`); - if (!dryRun && !existsSync(profilesRoot)) { - mkdirSync(profilesRoot, { recursive: true }); - } + const { written, skipped, profiles } = await syncClaudeProfilesFromModels(models, { + claudeHome, + baseUrl, + dryRun, + only: opts.only, + }); - 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) { + for (const profile of profiles) { + printSuccess(` ✓ profiles/${profile.name}/settings.json (${profile.model})`); + } console.log(""); printSuccess(`${written} Claude Code profiles written to ${profilesRoot}`); if (skipped > 0) printInfo(`${skipped} models skipped (no matching profile pattern)`); diff --git a/docs/guides/CLAUDE-CODE-CONFIGURATION.md b/docs/guides/CLAUDE-CODE-CONFIGURATION.md index 96ab1f918d..1820adef02 100644 --- a/docs/guides/CLAUDE-CODE-CONFIGURATION.md +++ b/docs/guides/CLAUDE-CODE-CONFIGURATION.md @@ -80,6 +80,14 @@ profile per model at `~/.claude/profiles//settings.json`, reusing the > active context), or export `ANTHROPIC_AUTH_TOKEN` yourself and run > `CLAUDE_CONFIG_DIR=~/.claude/profiles/ claude`. +**Auto-sync after model discovery (opt-in).** OmniRoute can regenerate these same +`~/.claude/profiles//settings.json` files automatically whenever a provider model +sync changes the live catalog — so new/renamed models get profiles without re-running the +command. It is **off by default**: toggle it from the **providers dashboard** ("CLI profile +auto-sync" → Claude Code), or set `OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES=true` (it also honors +`CLI_ALLOW_CONFIG_WRITES`, on by default). When enabled it only writes profile files; it never +changes your active/default Claude config, auth, or the `~/.claude/settings.json`. + ### Generating + using profiles ```bash diff --git a/docs/guides/CODEX-CLI-CONFIGURATION.md b/docs/guides/CODEX-CLI-CONFIGURATION.md index 5ff52751a3..5a059bcd95 100644 --- a/docs/guides/CODEX-CLI-CONFIGURATION.md +++ b/docs/guides/CODEX-CLI-CONFIGURATION.md @@ -243,7 +243,7 @@ omniroute setup-codex --codex-home /path/to/.codex The command fetches `/v1/models`, uses tuned profiles for known models, falls back to catalog metadata for other compatible text models, and writes `~/.codex/.config.toml` for each. Idempotent — safe to re-run. -OmniRoute can also **auto-sync** these same profile files after a successful provider model discovery/import changes the live catalog. This is **opt-in and off by default**: enable it with `OMNIROUTE_AUTO_SYNC_CODEX_PROFILES=true` (it also honors `CLI_ALLOW_CONFIG_WRITES`, on by default). When enabled it only writes separate `~/.codex/*.config.toml` profile files; it never changes the active/default `~/.codex/config.toml`, Codex-lb settings, auth, or provider selection. +OmniRoute can also **auto-sync** these same profile files after a successful provider model discovery/import changes the live catalog. This is **opt-in and off by default**: toggle it from the **providers dashboard** ("CLI profile auto-sync" → Codex), or set `OMNIROUTE_AUTO_SYNC_CODEX_PROFILES=true` (it also honors `CLI_ALLOW_CONFIG_WRITES`, on by default). When enabled it only writes separate `~/.codex/*.config.toml` profile files; it never changes the active/default `~/.codex/config.toml`, Codex-lb settings, auth, or provider selection. --- diff --git a/src/app/(dashboard)/dashboard/providers/components/CliProfileAutoSyncToggles.tsx b/src/app/(dashboard)/dashboard/providers/components/CliProfileAutoSyncToggles.tsx new file mode 100644 index 0000000000..b4e3f800cc --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/components/CliProfileAutoSyncToggles.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Toggle } from "@/shared/components"; + +type FlagEntry = { key: string; effectiveValue: string }; + +const CODEX_KEY = "OMNIROUTE_AUTO_SYNC_CODEX_PROFILES"; +const CLAUDE_KEY = "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES"; + +function isOn(value: string | undefined): boolean { + return value === "true" || value === "1" || value === "yes" || value === "on"; +} + +/** + * Providers-dashboard toggle card for the opt-in "auto-sync CLI profiles after model + * discovery" feature. Reads/writes the OMNIROUTE_AUTO_SYNC_{CODEX,CLAUDE}_PROFILES feature + * flags via /api/settings/feature-flags. Both default off; enabling one makes a provider + * model sync regenerate that tool's profile files from the live catalog. + */ +export default function CliProfileAutoSyncToggles() { + const [codexOn, setCodexOn] = useState(false); + const [claudeOn, setClaudeOn] = useState(false); + const [loading, setLoading] = useState(true); + const [savingKey, setSavingKey] = useState(null); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await fetch("/api/settings/feature-flags"); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + const flags: FlagEntry[] = Array.isArray(data.flags) ? data.flags : []; + setCodexOn(isOn(flags.find((f) => f.key === CODEX_KEY)?.effectiveValue)); + setClaudeOn(isOn(flags.find((f) => f.key === CLAUDE_KEY)?.effectiveValue)); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load settings"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const persist = useCallback( + async (key: string, next: boolean, previous: boolean, apply: (v: boolean) => void) => { + apply(next); // optimistic + setSavingKey(key); + setError(null); + try { + const res = await fetch("/api/settings/feature-flags", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key, value: next ? "true" : "false" }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (err) { + apply(previous); // revert on failure + setError(err instanceof Error ? err.message : "Failed to save"); + } finally { + setSavingKey(null); + } + }, + [] + ); + + return ( +
+
+

CLI profile auto-sync

+

+ After a provider model sync, automatically regenerate CLI tool profiles from the live + catalog. Off by default — only profile files are written, never the active/default + config. +

+
+
+ persist(CODEX_KEY, v, codexOn, setCodexOn)} + label="Codex profiles" + description="Regenerate ~/.codex/*.config.toml after model discovery." + /> + persist(CLAUDE_KEY, v, claudeOn, setClaudeOn)} + label="Claude Code profiles" + description="Regenerate ~/.claude/profiles//settings.json after model discovery." + /> +
+ {error ?

{error}

: null} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index 466b3e53d3..ec6259a2a3 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -43,6 +43,7 @@ import NoAuthProvidersSection from "./components/NoAuthProvidersSection"; import ProviderCard from "./components/ProviderCard"; import ProviderCountBadge from "./components/ProviderCountBadge"; import ProviderSummaryCard from "./components/ProviderSummaryCard"; +import CliProfileAutoSyncToggles from "./components/CliProfileAutoSyncToggles"; import { buildCompactProviderEntriesForPage, getCompactProviderAuthType, @@ -865,6 +866,8 @@ export default function ProvidersPage() { testingMode={testingMode} /> + + {/* Expiration Banner */} {expirations?.summary && (expirations.summary.expired > 0 || expirations.summary.expiringSoon > 0) && ( diff --git a/src/app/api/providers/[id]/sync-models/route.ts b/src/app/api/providers/[id]/sync-models/route.ts index 79fc038e4c..47094dfbf5 100644 --- a/src/app/api/providers/[id]/sync-models/route.ts +++ b/src/app/api/providers/[id]/sync-models/route.ts @@ -13,6 +13,7 @@ import { isModelSyncInternalRequest, } from "@/shared/services/modelSyncScheduler"; import { autoSyncCodexProfilesFromLiveCatalog } from "@/lib/cli-helper/codexProfileAutoSync"; +import { autoSyncClaudeProfilesFromLiveCatalog } from "@/lib/cli-helper/claudeProfileAutoSync"; import { GET as getProviderModels } from "../models/route"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; @@ -541,6 +542,25 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: err?.message || err ); }); + + void autoSyncClaudeProfilesFromLiveCatalog(request, `model-sync:${logProvider}`) + .then((syncResult) => { + if (syncResult.ok) { + console.log( + `[ModelSync] Claude profile auto-sync wrote ${syncResult.written} profile(s), skipped ${syncResult.skipped} (${logProvider})` + ); + } else { + console.log( + `[ModelSync] Claude profile auto-sync skipped for ${logProvider}: ${syncResult.reason}` + ); + } + }) + .catch((err) => { + console.log( + `[ModelSync] Claude profile auto-sync failed for ${logProvider}:`, + err?.message || err + ); + }); } if (shouldLog) { diff --git a/src/lib/cli-helper/claudeProfileAutoSync.ts b/src/lib/cli-helper/claudeProfileAutoSync.ts new file mode 100644 index 0000000000..72b7fc4fc0 --- /dev/null +++ b/src/lib/cli-helper/claudeProfileAutoSync.ts @@ -0,0 +1,91 @@ +import path from "node:path"; +import { ensureCliConfigWriteAllowed, getCliConfigPaths } from "@/shared/services/cliRuntime"; +import { getModelSyncInternalBaseUrl } from "@/shared/services/modelSyncScheduler"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; + +type SyncResult = + | { + ok: true; + written: number; + skipped: number; + reason: string; + } + | { + ok: false; + skipped: true; + reason: string; + }; + +function isAutoSyncEnabled() { + // Opt-in, default OFF. Backed by the OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES feature flag + // (resolver precedence: DB/dashboard-toggle override > env > default "false"), so a + // provider model sync never silently writes ~/.claude/profiles//settings.json + // unless the operator turned it on — via the providers-dashboard toggle or the env var. + return isFeatureFlagEnabled("OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES"); +} + +function forwardAuthHeaders(request: Request): Record { + const headers: Record = {}; + const cookie = request.headers.get("cookie"); + const authorization = request.headers.get("authorization"); + if (cookie) headers.cookie = cookie; + if (authorization) headers.authorization = authorization; + return headers; +} + +export async function autoSyncClaudeProfilesFromLiveCatalog( + request: Request, + reason: string +): Promise { + if (!isAutoSyncEnabled()) { + return { ok: false, skipped: true, reason: "disabled" }; + } + + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return { ok: false, skipped: true, reason: writeGuard }; + } + + const internalBase = getModelSyncInternalBaseUrl().replace(/\/$/, ""); + const res = await fetch(`${internalBase}/v1/models`, { + headers: forwardAuthHeaders(request), + signal: AbortSignal.timeout(10_000), + }); + + if (!res.ok) { + return { ok: false, skipped: true, reason: `catalog_http_${res.status}` }; + } + + const body = await res.json(); + const candidateModels = Array.isArray(body) ? body : body.data || body.models || []; + const models = Array.isArray(candidateModels) ? candidateModels : []; + + // Claude Code has no flat config file; its per-tool config lives at ~/.claude/settings.json, + // so getCliConfigPaths("claude") exposes `settings` (NOT `config`). The profiles are written + // under /profiles//settings.json, where claudeHome = dirname(settings). + const claudePaths = getCliConfigPaths("claude"); + if (!claudePaths?.settings) { + return { ok: false, skipped: true, reason: "claude_config_path_unavailable" }; + } + const claudeHome = path.dirname(claudePaths.settings); + + // Each generated profile points ANTHROPIC_BASE_URL at the OmniRoute this server serves. + // Strip a trailing /v1 (Claude Code appends the version segment itself). + const profileBaseUrl = internalBase.replace(/\/v1$/, ""); + + // Reuse the CLI generator so automatic sync and `omniroute setup-claude` stay + // behaviorally identical. + // @ts-ignore - bin CLI modules are shipped as ESM JavaScript, without TS declarations. + const { syncClaudeProfilesFromModels } = await import("../../../bin/cli/commands/setup-claude.mjs"); + const result = await syncClaudeProfilesFromModels(models, { + claudeHome, + baseUrl: profileBaseUrl, + }); + + return { + ok: true, + written: result.written, + skipped: result.skipped, + reason, + }; +} diff --git a/src/lib/cli-helper/codexProfileAutoSync.ts b/src/lib/cli-helper/codexProfileAutoSync.ts index c9b1cb9159..c3040ed09d 100644 --- a/src/lib/cli-helper/codexProfileAutoSync.ts +++ b/src/lib/cli-helper/codexProfileAutoSync.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { ensureCliConfigWriteAllowed, getCliConfigPaths } from "@/shared/services/cliRuntime"; import { getModelSyncInternalBaseUrl } from "@/shared/services/modelSyncScheduler"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; type SyncResult = | { @@ -16,11 +17,11 @@ type SyncResult = }; function isAutoSyncEnabled() { - // Opt-in, default OFF. Auto-writing profile files into ~/.codex is a side effect on the - // operator's machine, so it must be explicitly enabled (via env, or a settings/UI toggle - // that sets this env at runtime) — never silently on. An unset flag means disabled. - const raw = String(process.env.OMNIROUTE_AUTO_SYNC_CODEX_PROFILES ?? "false").toLowerCase(); - return ["1", "true", "yes", "on"].includes(raw); + // Opt-in, default OFF. Backed by the OMNIROUTE_AUTO_SYNC_CODEX_PROFILES feature flag + // (resolver precedence: DB/dashboard-toggle override > env > default "false"), so a + // provider model sync never silently writes ~/.codex/*.config.toml unless the operator + // turned it on — via the providers-dashboard toggle or the env var. + return isFeatureFlagEnabled("OMNIROUTE_AUTO_SYNC_CODEX_PROFILES"); } function forwardAuthHeaders(request: Request): Record { diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index 1c4d19d628..01be15921a 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -386,7 +386,7 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ warningLevel: "info", }, - // ──────────────── CLI (3) ──────────────── + // ──────────────── CLI (5) ──────────────── { key: "CLI_COMPAT_ALL", label: "CLI Compat All", @@ -421,6 +421,30 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "info", }, + { + key: "OMNIROUTE_AUTO_SYNC_CODEX_PROFILES", + label: "Auto-Sync Codex Profiles", + description: + "After a provider model sync, automatically (re)write ~/.codex/*.config.toml profile files from the live catalog. Never changes the active/default Codex config. Off by default.", + descriptionI18nKey: "featureFlagOmnirouteAutoSyncCodexProfilesDescription", + category: "cli", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, + { + key: "OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES", + label: "Auto-Sync Claude Code Profiles", + description: + "After a provider model sync, automatically (re)write ~/.claude/profiles//settings.json Claude Code profiles from the live catalog. Never changes the active/default Claude config. Off by default.", + descriptionI18nKey: "featureFlagOmnirouteAutoSyncClaudeProfilesDescription", + category: "cli", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, // ──────────────── Health (3) ──────────────── { diff --git a/tests/unit/claude-profile-auto-sync-gate.test.ts b/tests/unit/claude-profile-auto-sync-gate.test.ts new file mode 100644 index 0000000000..b168d2c95d --- /dev/null +++ b/tests/unit/claude-profile-auto-sync-gate.test.ts @@ -0,0 +1,80 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { autoSyncClaudeProfilesFromLiveCatalog } from "@/lib/cli-helper/claudeProfileAutoSync"; + +// Claude Code profile auto-sync writes files into the operator's ~/.claude/profiles, so it +// MUST be opt-in (default OFF) and short-circuit before any catalog fetch / file write when +// either gate is closed. These tests pin the two gates (the OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES +// feature flag, read env-first here with no DB override, + the CLI_ALLOW_CONFIG_WRITES +// write-guard) so a future change can't silently turn it back on. + +const GATE_ENV = ["OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES", "CLI_ALLOW_CONFIG_WRITES"] as const; + +function snapshotEnv(): Record { + const snap: Record = {}; + for (const k of GATE_ENV) snap[k] = process.env[k]; + return snap; +} + +function restoreEnv(snap: Record): void { + for (const k of GATE_ENV) { + if (snap[k] === undefined) delete process.env[k]; + else process.env[k] = snap[k]; + } +} + +function mockSyncRequest(): Request { + return new Request("http://127.0.0.1:20128/api/providers/openai/sync-models", { method: "POST" }); +} + +test("Claude auto-sync is OFF by default (flag unset) — returns disabled, never fetches or writes", async () => { + const snap = snapshotEnv(); + try { + delete process.env.OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES; + const result = await autoSyncClaudeProfilesFromLiveCatalog(mockSyncRequest(), "test:default-off"); + assert.equal(result.ok, false); + assert.equal(result.reason, "disabled"); + } finally { + restoreEnv(snap); + } +}); + +test("explicit OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES=false stays disabled", async () => { + const snap = snapshotEnv(); + try { + process.env.OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES = "false"; + const result = await autoSyncClaudeProfilesFromLiveCatalog(mockSyncRequest(), "test:explicit-false"); + assert.equal(result.ok, false); + assert.equal(result.reason, "disabled"); + } finally { + restoreEnv(snap); + } +}); + +test("non-truthy flag values stay disabled", async () => { + const snap = snapshotEnv(); + try { + for (const v of ["0", "no", "off", "maybe", ""]) { + process.env.OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES = v; + const result = await autoSyncClaudeProfilesFromLiveCatalog(mockSyncRequest(), `test:${v}`); + assert.equal(result.ok, false, `value '${v}' must not enable auto-sync`); + assert.equal(result.reason, "disabled", `value '${v}' must report disabled`); + } + } finally { + restoreEnv(snap); + } +}); + +test("enabled flag but CLI_ALLOW_CONFIG_WRITES=false is blocked by the write-guard (no write)", async () => { + const snap = snapshotEnv(); + try { + process.env.OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES = "true"; + process.env.CLI_ALLOW_CONFIG_WRITES = "false"; + const result = await autoSyncClaudeProfilesFromLiveCatalog(mockSyncRequest(), "test:write-guard"); + assert.equal(result.ok, false); + assert.match(result.reason, /CLI config writes are disabled/); + } finally { + restoreEnv(snap); + } +}); diff --git a/tests/unit/cli/setup-claude.test.ts b/tests/unit/cli/setup-claude.test.ts index 4d55a8d21e..c40bdfdb03 100644 --- a/tests/unit/cli/setup-claude.test.ts +++ b/tests/unit/cli/setup-claude.test.ts @@ -1,6 +1,12 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { buildProfileSettings } from "../../../bin/cli/commands/setup-claude.mjs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + buildProfileSettings, + syncClaudeProfilesFromModels, +} 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"; @@ -34,6 +40,49 @@ test("profile names match setup-codex (cross-CLI consistency)", () => { assert.equal(categoriseModel("kmc/kimi-k2.7").name, "kimi-k27"); }); +test("syncClaudeProfilesFromModels writes directory-per-profile settings + threads baseUrl, skips non-ids", async () => { + const claudeHome = await fs.mkdtemp(path.join(os.tmpdir(), "omniroute-claude-profiles-")); + try { + const result = await syncClaudeProfilesFromModels( + [{ id: "glm/glm-5.2" }, { id: "" }], + { claudeHome, baseUrl: "http://vps:20128" } + ); + + assert.equal(result.written, 1); + assert.equal(result.skipped, 1); + assert.deepEqual(result.profiles.map((p) => p.name), ["glm52"]); + + // Directory-per-profile: /profiles//settings.json + const settingsPath = path.join(claudeHome, "profiles", "glm52", "settings.json"); + const json = JSON.parse(await fs.readFile(settingsPath, "utf8")); + assert.equal(json.model, "glm/glm-5.2"); + assert.equal(json.env.ANTHROPIC_BASE_URL, "http://vps:20128"); + assert.equal(json.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY, "1"); + // The auth token must never be written to disk. + assert.equal(JSON.stringify(json).includes("ANTHROPIC_AUTH_TOKEN"), false); + } finally { + await fs.rm(claudeHome, { recursive: true, force: true }); + } +}); + +test("syncClaudeProfilesFromModels dry-run writes nothing", async () => { + const claudeHome = await fs.mkdtemp(path.join(os.tmpdir(), "omniroute-claude-dry-")); + try { + const result = await syncClaudeProfilesFromModels([{ id: "glm/glm-5.2" }], { + claudeHome, + baseUrl: "http://vps:20128", + dryRun: true, + }); + assert.equal(result.written, 1); + await assert.rejects( + fs.stat(path.join(claudeHome, "profiles", "glm52", "settings.json")), + /ENOENT/ + ); + } finally { + await fs.rm(claudeHome, { recursive: true, force: true }); + } +}); + // ── launch env (Claude Code) ───────────────────────────────────────────────── test("buildClaudeEnv still accepts a bare port (backward compatible)", () => {