mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +03:00
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:
committed by
GitHub
parent
f3b5dc3533
commit
fbcfafd201
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
91
src/lib/cli-helper/claudeProfileAutoSync.ts
Normal file
91
src/lib/cli-helper/claudeProfileAutoSync.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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) ────────────────
|
||||
{
|
||||
|
||||
80
tests/unit/claude-profile-auto-sync-gate.test.ts
Normal file
80
tests/unit/claude-profile-auto-sync-gate.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
@@ -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)", () => {
|
||||
|
||||
Reference in New Issue
Block a user