Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
97 changes: 68 additions & 29 deletions bin/cli/commands/setup-claude.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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>}
Expand All @@ -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} …`);
Expand Down Expand Up @@ -89,36 +147,17 @@ export async function runSetupClaudeCommand(opts = {}) {

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);
const { written, skipped, profiles } = await syncClaudeProfilesFromModels(models, {
claudeHome,
baseUrl,
dryRun,
only: opts.only,
});

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)`);
Expand Down
8 changes: 8 additions & 0 deletions docs/guides/CLAUDE-CODE-CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/CODEX-CLI-CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
Original file line number Diff line number Diff line change
@@ -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>
);
}
3 changes: 3 additions & 0 deletions src/app/(dashboard)/dashboard/providers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -865,6 +866,8 @@ export default function ProvidersPage() {
testingMode={testingMode}
/>

<CliProfileAutoSyncToggles />

{/* Expiration Banner */}
{expirations?.summary &&
(expirations.summary.expired > 0 || expirations.summary.expiringSoon > 0) && (
Expand Down
20 changes: 20 additions & 0 deletions src/app/api/providers/[id]/sync-models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading