feat(providers): opt-in CLI profile auto-sync toggles + Claude Code auto-sync (#5755)

Providers-dashboard 'CLI profile auto-sync' card (Codex + Claude Code toggles), feature-flag backed (default off), + Claude Code auto-sync mirroring the Codex path. Follow-up to #5737.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-01 00:13:20 -03:00
committed by GitHub
parent f3b5dc3533
commit fbcfafd201
13 changed files with 460 additions and 40 deletions

View File

@@ -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/<name>/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

View File

@@ -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/<name>/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.

View File

@@ -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 `<claudeHome>/profiles/<name>/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<number>}
@@ -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)`);

View File

@@ -80,6 +80,14 @@ profile per model at `~/.claude/profiles/<name>/settings.json`, reusing the
> active context), or export `ANTHROPIC_AUTH_TOKEN` yourself and run
> `CLAUDE_CONFIG_DIR=~/.claude/profiles/<name> claude`.
**Auto-sync after model discovery (opt-in).** OmniRoute can regenerate these same
`~/.claude/profiles/<name>/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

View File

@@ -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/<name>.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.
---

View File

@@ -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<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="rounded-xl border border-border bg-surface/40 p-4">
<div className="mb-3">
<h3 className="text-sm font-semibold text-text-main">CLI profile auto-sync</h3>
<p className="text-xs text-text-muted">
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.
</p>
</div>
<div className="flex flex-col gap-3">
<Toggle
checked={codexOn}
disabled={loading || savingKey === CODEX_KEY}
onChange={(v) => persist(CODEX_KEY, v, codexOn, setCodexOn)}
label="Codex profiles"
description="Regenerate ~/.codex/*.config.toml after model discovery."
/>
<Toggle
checked={claudeOn}
disabled={loading || savingKey === CLAUDE_KEY}
onChange={(v) => persist(CLAUDE_KEY, v, claudeOn, setClaudeOn)}
label="Claude Code profiles"
description="Regenerate ~/.claude/profiles/<name>/settings.json after model discovery."
/>
</div>
{error ? <p className="mt-2 text-xs text-red-500">{error}</p> : null}
</div>
);
}

View File

@@ -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}
/>
<CliProfileAutoSyncToggles />
{/* Expiration Banner */}
{expirations?.summary &&
(expirations.summary.expired > 0 || expirations.summary.expiringSoon > 0) && (

View File

@@ -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) {

View File

@@ -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/<name>/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<string, string> {
const headers: Record<string, string> = {};
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<SyncResult> {
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 <claudeHome>/profiles/<name>/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,
};
}

View File

@@ -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<string, string> {

View File

@@ -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/<name>/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) ────────────────
{

View File

@@ -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<string, string | undefined> {
const snap: Record<string, string | undefined> = {};
for (const k of GATE_ENV) snap[k] = process.env[k];
return snap;
}
function restoreEnv(snap: Record<string, string | undefined>): 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);
}
});

View File

@@ -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: <claudeHome>/profiles/<name>/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)", () => {