Compare commits

..

2 Commits

Author SHA1 Message Date
Xiangzhe
519cec95db chore(skills): regenerate cli-routing after combo create --models (#10954) 2026-08-21 12:44:52 -03:00
Xiangzhe
0a578a4f14 fix(cli): combo create accepts --models (#10954) 2026-08-21 12:35:46 -03:00
14 changed files with 1102 additions and 41 deletions

View File

@@ -4,6 +4,7 @@ import { withRuntime } from "../runtime.mjs";
import { t } from "../i18n.mjs";
import { apiFetch } from "../api.mjs";
import { emit } from "../output.mjs";
import { resolveComboModels, collectModel } from "./comboModels.mjs";
const VALID_STRATEGIES = [
"priority",
@@ -125,10 +126,31 @@ export function registerCombo(program) {
.choices(VALID_STRATEGIES)
.default("priority")
)
.option(
"--models <spec>",
"Models for the combo: comma-separated provider/model entries, or a JSON array " +
'(e.g. --models "openai/gpt-4o,anthropic/claude-3-opus" or ' +
'--models \'[{"model":"gpt-4o","providerId":"openai"}]\')'
)
.option(
"--model <spec>",
"Add one model to the combo (provider/model or bare model id) — repeatable",
collectModel,
[]
)
.action(async (name, opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
let models;
try {
models = resolveComboModels(opts);
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
return;
}
const exitCode = await runComboCreateCommand(name, opts.strategy, {
...opts,
models,
output: globalOpts.output,
});
if (exitCode !== 0) process.exit(exitCode);
@@ -284,12 +306,14 @@ export async function runComboCreateCommand(name, strategy = "priority", opts =
return 1;
}
const models = Array.isArray(opts.models) ? opts.models : [];
try {
return await withRuntime(async ({ kind, api, db }) => {
if (kind === "http") {
const res = await api("/api/combos", {
method: "POST",
body: { name, strategy, enabled: true, models: [], config: {} },
body: { name, strategy, enabled: true, models, config: {} },
retry: false,
acceptNotOk: true,
});
@@ -305,7 +329,7 @@ export async function runComboCreateCommand(name, strategy = "priority", opts =
console.error(`Combo '${name}' already exists. Delete it first.`);
return 1;
}
await db.combos.createCombo({ name, strategy, enabled: true, models: [], config: {} });
await db.combos.createCombo({ name, strategy, enabled: true, models, config: {} });
}
console.log(t("combo.created", { name }));

View File

@@ -0,0 +1,142 @@
// Parses the `--models` / `--model` options for `omniroute combo create` (#10954).
//
// Root cause of #10954: `combo create` only ever registered `--strategy`; the
// HTTP body (POST /api/combos) and the local-db fallback (db.combos.createCombo)
// both hardcoded `models: []`, so every combo created via the CLI came out
// empty regardless of what the operator intended to route to.
//
// Accepted shapes mirror the server-side Zod union in
// `src/shared/validation/schemas/combo.ts` (`comboModelEntry` /
// `createComboSchema.models`) so a CLI-built payload never gets rejected by
// the API that ultimately validates it:
// - a plain string ("provider/model" or a bare model id) — the server's
// `normalizeComboModels` (src/lib/combos/steps.ts) already splits the
// leading "provider/" segment off a plain string, so passing the raw
// token through is sufficient for the common case;
// - a structured `{ kind?: "model", model, providerId?, provider?, ... }`
// object;
// - a structured `{ kind: "combo-ref", comboName, ... }` object (nested
// combo reference).
//
// The CLI (bin/cli/**) ships as plain `.mjs` with relative-only imports — no
// `@/` path aliases and no TS transpilation at runtime — so importing the
// real Zod schema from `src/shared/validation/schemas/combo.ts` is not
// viable here. This module instead validates the same minimal shape by hand
// and stays a thin, independently testable unit.
/**
* Validates one already-parsed combo model entry against the shape accepted
* by `comboModelEntry` (string | model-step | combo-ref). Throws with a
* 1-based, human-readable position when the entry does not match.
*
* @param {unknown} entry
* @param {number} index
* @returns {string | Record<string, unknown>}
*/
export function validateComboModelEntryShape(entry, index) {
const position = index + 1;
if (typeof entry === "string") {
const trimmed = entry.trim();
if (trimmed.length === 0) {
throw new Error(`--models entry #${position}: empty model string`);
}
if (trimmed.length > 300) {
throw new Error(`--models entry #${position}: model string exceeds 300 characters`);
}
return trimmed;
}
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
throw new Error(`--models entry #${position}: must be a string or a JSON object`);
}
const kind = entry.kind;
if (kind === "combo-ref") {
if (typeof entry.comboName !== "string" || entry.comboName.trim().length === 0) {
throw new Error(
`--models entry #${position}: kind "combo-ref" requires a non-empty "comboName"`
);
}
return entry;
}
if (kind !== undefined && kind !== "model") {
throw new Error(`--models entry #${position}: unknown "kind" value ${JSON.stringify(kind)}`);
}
if (typeof entry.model !== "string" || entry.model.trim().length === 0) {
throw new Error(`--models entry #${position}: requires a non-empty "model"`);
}
if (entry.providerId !== undefined && typeof entry.providerId !== "string") {
throw new Error(`--models entry #${position}: "providerId" must be a string`);
}
if (entry.provider !== undefined && typeof entry.provider !== "string") {
throw new Error(`--models entry #${position}: "provider" must be a string`);
}
return entry;
}
/**
* Parses one `--models` spec — either a JSON array (`--models '[{"model":"gpt-4o"}]'`)
* or a comma-separated list of provider/model tokens
* (`--models 'openai/gpt-4o,anthropic/claude-3-opus'`) — into an array of
* combo model entries.
*
* @param {string} spec
* @returns {Array<string | Record<string, unknown>>}
*/
export function parseModelsSpec(spec) {
const trimmed = String(spec ?? "").trim();
if (trimmed.length === 0) return [];
if (trimmed.startsWith("[")) {
let parsed;
try {
parsed = JSON.parse(trimmed);
} catch (err) {
throw new Error(`--models: invalid JSON array (${err.message})`);
}
if (!Array.isArray(parsed)) {
throw new Error("--models: JSON value must be an array");
}
return parsed.map((entry, i) => validateComboModelEntryShape(entry, i));
}
return trimmed
.split(",")
.map((token) => token.trim())
.filter((token) => token.length > 0)
.map((token, i) => validateComboModelEntryShape(token, i));
}
/**
* Resolves the final `models` array for `combo create` from Commander opts:
* `--models <csv-or-json>` and/or repeatable `--model <spec>`.
*
* @param {{ models?: string, model?: string[] }} opts
* @returns {Array<string | Record<string, unknown>>}
*/
export function resolveComboModels(opts = {}) {
const result = [];
if (typeof opts.models === "string" && opts.models.trim().length > 0) {
result.push(...parseModelsSpec(opts.models));
}
if (Array.isArray(opts.model)) {
opts.model.forEach((token, i) => {
result.push(validateComboModelEntryShape(String(token).trim(), i));
});
}
return result;
}
/** Commander `collect`-style reducer for the repeatable `--model` option. */
export function collectModel(value, previous) {
previous.push(value);
return previous;
}

View File

@@ -0,0 +1 @@
- fix(cli): combo create accepts --models and no longer creates empty combos (#10954)

View File

@@ -1,3 +0,0 @@
- fix(api): repair broken `@/lib/db/connections` import in the usage utilization route that failed the production build (#10939 follow-up)
- chore(docs): regenerate PROVIDER_REFERENCE and refresh README diagram SVGs to the real provider count (347)
- chore(lint): prune ESLint suppressions orphaned on the release branch

View File

@@ -291,6 +291,109 @@
"src/app/(dashboard)/dashboard/HomePageClient.tsx": {
"react-hooks/exhaustive-deps": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/a2a/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/acp-agents/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/activity/ActivityFeedClient.tsx": {
"react-hooks/purity": {
"count": 1
},
"react-hooks/refs": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/analytics/CacheHealthTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/analytics/ProviderUtilizationTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/analytics/RouteExplainabilityTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": {
"react-hooks/immutability": {
"count": 4
},
"react-hooks/preserve-manual-memoization": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/audit/ComplianceTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/audit/McpAuditTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/batch/components/wizard/CostEstimateStep.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/batch/components/wizard/JsonlValidationStep.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/batch/files/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/cache/components/CacheEntriesTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/cache/components/ReasoningCacheTab.tsx": {
"react-hooks/purity": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/cache/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/cli-agents/CliAgentsPageClient.tsx": {
@@ -301,6 +404,124 @@
"src/app/(dashboard)/dashboard/cli-code/components/AntigravityToolCard.tsx": {
"react-hooks/exhaustive-deps": {
"count": 1
},
"react-hooks/immutability": {
"count": 3
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/cli-code/components/ClaudeClassifierCompatToggle.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard.tsx": {
"react-hooks/immutability": {
"count": 3
},
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/cli-code/components/CliProfileAutoSyncToggles.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/cli-code/components/ClineToolCard.tsx": {
"react-hooks/immutability": {
"count": 3
},
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/cli-code/components/CliproxyapiToolCard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": {
"react-hooks/immutability": {
"count": 4
},
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx": {
"react-hooks/immutability": {
"count": 3
},
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/cli-code/components/GrokBuildToolCard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx": {
"react-hooks/immutability": {
"count": 1
},
"react-hooks/purity": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/cli-code/components/KiloToolCard.tsx": {
"react-hooks/immutability": {
"count": 3
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard.tsx": {
"react-hooks/immutability": {
"count": 3
},
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/combos/ComboControlCenterClient.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/combos/page.tsx": {
"react-hooks/immutability": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 7
}
},
"src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/conductor/FaroChat.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/conversations/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/costs/components/ApiKeyUsageLimitCard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/costs/costExplorerUtils.ts": {
@@ -308,11 +529,258 @@
"count": 1
}
},
"src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 3
}
},
"src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePoolUsage.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePools.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/costs/useApiKeyUsageLimits.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/discovery/DiscoveryPageClient.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": {
"react-hooks/immutability": {
"count": 3
}
},
"src/app/(dashboard)/dashboard/endpoint/components/A2ADashboard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/endpoint/components/MCPDashboard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/endpoint/components/NotionSourceCard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/endpoint/components/ObsidianSourceCard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/free-provider-rankings/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/health/ProviderHealthAutopilotCard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/health/ProviderHealthMatrixCard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/health/TelemetryCard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/mcp/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/memory/components/EditMemoryModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/memory/hooks/useEngineStatus.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/memory/hooks/useMemorySettings.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx": {
"react-hooks/exhaustive-deps": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/plugins/[name]/config/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/plugins/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/provider-stats/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
},
"react-hooks/static-components": {
"count": 7
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx": {
"react-hooks/refs": {
"count": 4
},
"react-hooks/set-state-in-effect": {
"count": 3
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderCcAliasSection.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderInterceptionSection.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderParamFilterSection.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderSettings.ts": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/hooks/useProviderModels.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/hooks/useProviderUrlFilters.ts": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/providers/hooks/useRiskAcknowledged.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/providers/services/components/DarioAccountPanel.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/services/components/NinerouterModelList.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/radar/RadarCatalogTable.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/radar/intel/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/radar/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/radar/setup/page.tsx": {
"react-hooks/preserve-manual-memoization": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/relay/RelayProxyClient.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/resilience/connections/components/ResilienceConnectionsClient.tsx": {
"react-hooks/purity": {
"count": 1
},
"react-hooks/refs": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/runtime/components/ModelCooldownsCard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/settings/components/AccessTokensTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx": {
"@next/next/no-img-element": {
"count": 4
@@ -321,16 +789,75 @@
"src/app/(dashboard)/dashboard/settings/components/AuthzSection.tsx": {
"no-restricted-syntax": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/settings/components/FallbackChainsEditor.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/settings/components/IPFilterSection.tsx": {
"react-hooks/immutability": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/settings/components/MitmProxyTab.tsx": {
"@next/next/no-html-link-for-pages": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/settings/components/ModelCapabilityOverridesTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/settings/components/ModelsDevSyncTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/settings/components/OneproxyTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/settings/components/PayloadRulesTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/settings/components/PoliciesPanel.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/settings/components/ProviderAccountRoutingCard.tsx": {
"react-hooks/exhaustive-deps": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 7
}
},
"src/app/(dashboard)/dashboard/settings/components/RoutingStrategyCard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/settings/components/SessionInfoCard.tsx": {
@@ -338,11 +865,92 @@
"count": 1
}
},
"src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/settings/components/proxy/GlobalConfigTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/settings/components/proxy/SubscriptionTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentList.tsx": {
"no-restricted-syntax": {
"count": 3
}
},
"src/app/(dashboard)/dashboard/tools/agent-bridge/components/ModelSelectorModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/tools/agent-bridge/components/SetupWizard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CustomHostsManager.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/translator/components/MonitorTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/useCodexResetCreditRedemption.ts": {
"react-hooks/immutability": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/usage/components/RateLimitStatus.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/usage/components/SessionsTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/webhooks/WebhooksPageClient.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/webhooks/components/AddWebhookWizard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/webhooks/components/WebhookDeliveriesPanel.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/home/ProviderQuotaWidget.tsx": {
"react-hooks/purity": {
"count": 1
},
"react-hooks/refs": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/home/page.tsx": {
"no-restricted-imports": {
"count": 1
@@ -988,6 +1596,16 @@
"count": 1
}
},
"src/app/global-error.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/status/page.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/domain/costRules.ts": {
"no-restricted-syntax": {
"count": 1
@@ -1256,14 +1874,55 @@
"count": 1
}
},
"src/shared/components/CursorAuthModal.tsx": {
"react-hooks/exhaustive-deps": {
"count": 1
}
},
"src/shared/components/KiroAuthModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/shared/components/LanguageSelector.tsx": {
"@next/next/no-img-element": {
"count": 1
}
},
"src/shared/components/ModelSelectModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 4
}
},
"src/shared/components/OAuthModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 4
}
},
"src/shared/components/PricingModal.tsx": {
"react-hooks/immutability": {
"count": 1
}
},
"src/shared/components/ProxyConfigModal.tsx": {
"react-hooks/exhaustive-deps": {
"count": 1
},
"react-hooks/immutability": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/shared/components/ReasoningRoutingRules.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/shared/components/RequestLoggerDetail.sections.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/shared/components/RequestLoggerV2.tsx": {
@@ -1274,6 +1933,27 @@
"src/shared/components/Sidebar.tsx": {
"@next/next/no-img-element": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/shared/components/UsageStats.tsx": {
"react-hooks/preserve-manual-memoization": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/shared/components/analytics/useProviderDailyUsage.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/shared/components/compression/ComboCompressionModeSelect.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/shared/contracts/quota.ts": {
@@ -1281,6 +1961,11 @@
"count": 1
}
},
"src/shared/hooks/cli/useToolBatchStatuses.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/shared/services/apiKeyResolver.ts": {
"no-restricted-imports": {
"count": 1

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 1200 350" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Animated terminal demoing the OmniRoute CLI: omniroute providers list (347 providers registered, anthropic, codex, glm, kimi shown active), omniroute combo list (always-on priority, cost-saver, fusion-panel, context-relay) and omniroute health (healthy, 18412 requests in 24h, p95 412ms, circuit breakers 24 closed, 1 half-open, 0 open), cycling over the 80+ command surface: providers, oauth, keys, combo, nodes, models, cache, compression, cost, usage, quota, health, resilience, telemetry, logs, audit, mcp, a2a, cloud, memory, skills, eval, doctor, repl, tunnel, backup, sync, webhooks, policy, pricing, translator, simulate and more.">
<svg viewBox="0 0 1200 350" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Animated terminal demoing the OmniRoute CLI: omniroute providers list (346 providers registered, anthropic, codex, glm, kimi shown active), omniroute combo list (always-on priority, cost-saver, fusion-panel, context-relay) and omniroute health (healthy, 18412 requests in 24h, p95 412ms, circuit breakers 24 closed, 1 half-open, 0 open), cycling over the 80+ command surface: providers, oauth, keys, combo, nodes, models, cache, compression, cost, usage, quota, health, resilience, telemetry, logs, audit, mcp, a2a, cloud, memory, skills, eval, doctor, repl, tunnel, backup, sync, webhooks, policy, pricing, translator, simulate and more.">
<desc>Compact animated terminal cycling three real OmniRoute CLI commands with a typewriter effect and a scrolling subcommand ticker; the first frame shows the completed providers-list screen.</desc>
<defs><clipPath id="tickerClip"><rect x="12" y="304" width="1176" height="40"/></clipPath><clipPath id="tw0"><rect x="64" y="46" height="26" width="0"><animate attributeName="width" calcMode="discrete" values="0;31;61;92;122;153;184;214;245;245" keyTimes="0;0.012;0.018;0.024;0.030;0.036;0.042;0.048;0.054;1" dur="18s" repeatCount="indefinite"/></rect></clipPath><clipPath id="tw1"><rect x="64" y="46" height="26" width="0"><animate attributeName="width" calcMode="discrete" values="0;26;51;76;102;128;153;178;204;204" keyTimes="0;0.346;0.351;0.357;0.363;0.369;0.375;0.381;0.387;1" dur="18s" repeatCount="indefinite"/></rect></clipPath><clipPath id="tw2"><rect x="64" y="46" height="26" width="0"><animate attributeName="width" calcMode="discrete" values="0;20;41;61;82;102;122;143;163;163" keyTimes="0;0.678;0.684;0.690;0.696;0.702;0.708;0.714;0.720;1" dur="18s" repeatCount="indefinite"/></rect></clipPath></defs>
<rect width="1200" height="350" fill="#0d1117"/>

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 1200 780" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Comparison table: OmniRoute versus 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute is the only one with the full set: 347 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, a built-in MCP server with 109 tools, A2A protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, desktop/Termux/PWA, 43 UI locales and 100% MIT self-hosted. 9router has free providers, RTK compression and translation but no MCP, A2A, memory, guardrails, cloud agents or stealth. OpenRouter is a hosted SaaS with 400+ models, guardrails and a hosted MCP but is not self-hosted and lacks A2A, memory, cloud agents and stealth. CLIProxyAPI is a light OAuth proxy with two routing strategies. LiteLLM has 100+ providers, A2A and extensive guardrails but no memory, compression, free tier, stealth or cloud agents.">
<svg viewBox="0 0 1200 780" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Comparison table: OmniRoute versus 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute is the only one with the full set: 346 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, a built-in MCP server with 109 tools, A2A protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, desktop/Termux/PWA, 43 UI locales and 100% MIT self-hosted. 9router has free providers, RTK compression and translation but no MCP, A2A, memory, guardrails, cloud agents or stealth. OpenRouter is a hosted SaaS with 400+ models, guardrails and a hosted MCP but is not self-hosted and lacks A2A, memory, cloud agents and stealth. CLIProxyAPI is a light OAuth proxy with two routing strategies. LiteLLM has 100+ providers, A2A and extensive guardrails but no memory, compression, free tier, stealth or cloud agents.">
<desc>Static-header comparison table where each capability row fades in top to bottom; the OmniRoute column is highlighted and shows a check or a leading value in every row, while competitors show a mix of checks, partials and crosses.</desc>
<defs>
<pattern id="gC" width="32" height="32" patternUnits="userSpaceOnUse"><path d="M 32 0 L 0 0 0 32" fill="none" stroke="#ffffff" stroke-opacity="0.05" stroke-width="1"/></pattern>

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 1200 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The OmniRoute promise: one endpoint, 347 providers — never stop building, OmniRoute picks the cheapest one that works. Six pillars. Never hit limits: auto-fallback across 347 providers in milliseconds, quota out means the next provider takes over with zero downtime. Save up to 95 percent of tokens: RTK plus Caveman stacked compression cuts 15 to 95 percent of eligible tokens, about 89 percent average on tool-heavy sessions. Zero dollars to start: 90+ providers with a free tier, 57 free forever — Qoder, Pollinations, Cloudflare, SiliconFlow — no card needed. Every tool works: 33 coding agents including Claude Code, Codex, Cursor, Cline, Copilot and Antigravity through one config. One endpoint: OpenAI, Claude, Gemini and Responses API translation — point any tool at /v1 and it just works. Production-grade: circuit breakers, TLS stealth, MCP with 109 tools, A2A, memory, guardrails, evals — 25,000+ tests.">
<svg viewBox="0 0 1200 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The OmniRoute promise: one endpoint, 346 providers — never stop building, OmniRoute picks the cheapest one that works. Six pillars. Never hit limits: auto-fallback across 346 providers in milliseconds, quota out means the next provider takes over with zero downtime. Save up to 95 percent of tokens: RTK plus Caveman stacked compression cuts 15 to 95 percent of eligible tokens, about 89 percent average on tool-heavy sessions. Zero dollars to start: 90+ providers with a free tier, 57 free forever — Qoder, Pollinations, Cloudflare, SiliconFlow — no card needed. Every tool works: 33 coding agents including Claude Code, Codex, Cursor, Cline, Copilot and Antigravity through one config. One endpoint: OpenAI, Claude, Gemini and Responses API translation — point any tool at /v1 and it just works. Production-grade: circuit breakers, TLS stealth, MCP with 109 tools, A2A, memory, guardrails, evals — 25,000+ tests.">
<desc>Animated promise card: six pillar tiles fade in in reading order, then a soft colored border highlight sweeps from tile to tile in a continuous cycle.</desc>
<defs>
<pattern id="gridPaperP" width="32" height="32" patternUnits="userSpaceOnUse">
@@ -21,7 +21,7 @@
<line x1="150" y1="53" x2="1160" y2="53" stroke="#232b38" stroke-width="1.5"/>
</g>
<g>
<text x="40" y="100" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="23" font-weight="600" fill="#c9d1d9">One endpoint. <tspan fill="#a78bfa" font-weight="800">347 providers.</tspan> Never stop building — OmniRoute picks <tspan fill="#7ee787" font-weight="700">the cheapest one that works</tspan>.</text>
<text x="40" y="100" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="23" font-weight="600" fill="#c9d1d9">One endpoint. <tspan fill="#a78bfa" font-weight="800">346 providers.</tspan> Never stop building — OmniRoute picks <tspan fill="#7ee787" font-weight="700">the cheapest one that works</tspan>.</text>
</g>
<g font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif">
@@ -38,7 +38,7 @@
<line x1="3.9" y1="3.9" x2="18.1" y2="18.1"/>
</g>
<text x="102" y="170" font-size="18" font-weight="800" fill="#74b9ff">Never hit limits</text>
<text x="66" y="204" font-size="13.5" fill="#a1a1aa">Auto-fallback across 347 providers in</text>
<text x="66" y="204" font-size="13.5" fill="#a1a1aa">Auto-fallback across 346 providers in</text>
<text x="66" y="226" font-size="13.5" fill="#a1a1aa">milliseconds. Quota out? The next provider</text>
<text x="66" y="248" font-size="13.5" fill="#a1a1aa">takes over — zero downtime.</text>
</g>

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 1200 548" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OmniRoute hero: Never stop coding. Every AI tool to 347 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot and Antigravity into free Claude, GPT and Gemini with auto-fallback. RTK + Caveman stacked compression saves 15 to 95 percent of tokens — about 89 percent average on tool-heavy sessions — so you never hit limits. Stats: 347 AI providers, 90+ free tiers, about 1.51B free tokens per month, 15 to 95 percent token savings, 19 routing strategies, zero dollars to start.">
<svg viewBox="0 0 1200 548" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OmniRoute hero: Never stop coding. Every AI tool to 346 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot and Antigravity into free Claude, GPT and Gemini with auto-fallback. RTK + Caveman stacked compression saves 15 to 95 percent of tokens — about 89 percent average on tool-heavy sessions — so you never hit limits. Stats: 346 AI providers, 90+ free tiers, about 1.51B free tokens per month, 15 to 95 percent token savings, 19 routing strategies, zero dollars to start.">
<desc>Animated hero card: a pulse travels the divider line and a compression bar demo repeatedly shrinks a prompt by up to 95 percent; all headline content is static and readable on the first frame.</desc>
<defs>
<pattern id="gridPaperH" width="32" height="32" patternUnits="userSpaceOnUse">
@@ -28,7 +28,7 @@
<text x="48" y="138" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="60" font-weight="800" fill="#e9edf3">Never stop coding<tspan fill="#a855f7">.</tspan></text>
<!-- subheadline -->
<text x="48" y="184" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="25" font-weight="600" fill="#c9d1d9">Every AI tool → <tspan fill="#a78bfa" font-weight="800">347 providers</tspan><tspan fill="#7ee787" font-weight="800">90+ free</tspan> — through one endpoint.</text>
<text x="48" y="184" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="25" font-weight="600" fill="#c9d1d9">Every AI tool → <tspan fill="#a78bfa" font-weight="800">346 providers</tspan><tspan fill="#7ee787" font-weight="800">90+ free</tspan> — through one endpoint.</text>
<!-- plug line -->
<text x="48" y="222" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="16.5" fill="#a1a1aa">Claude Code · Codex · Cursor · Cline · Copilot · Antigravity&#160;&#160;&#160;&#160;<tspan fill="#7ee787" font-weight="700">FREE</tspan> Claude / GPT / Gemini · auto-fallback</text>

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -1,16 +1,16 @@
---
title: "Provider Reference"
version: 3.8.50
lastUpdated: 2026-08-21
lastUpdated: 2026-08-20
---
# Provider Reference
> **Auto-generated** from `src/shared/constants/providers.ts` — do not edit by hand.
> Regenerate with: `npm run gen:provider-reference`
> **Last generated:** 2026-08-21
> **Last generated:** 2026-08-20
Total providers: **347**. See category breakdown below.
Total providers: **346**. See category breakdown below.
## Categories
@@ -62,8 +62,8 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `clinepass` | `cp` | ClinePass | OAuth | [link](https://cline.bot/cline-pass) | ClinePass is Cline's $9.99/mo subscription bundling 10 open coding models. Sign in with your Cline account (same login as the Cline CLI/IDE), or paste a direct ClinePass API key (app.cline.bot → Settings → API Keys). A ClinePass subscription unlocks the cline-pass/* models. Reuses the Cline WorkOS OAuth flow. |
| `codebuddy-cn` | `cbcn` | CodeBuddy CN | OAuth | [link](https://copilot.tencent.com) | Tencent CodeBuddy CN (copilot.tencent.com). Sign in via the official CLI device-code flow, or paste a direct API key (sent as Authorization: Bearer). Catalog: GLM / Kimi / MiniMax / DeepSeek / Hunyuan. |
| `codex` | `cx` | OpenAI Codex | OAuth | — | — |
| `cursor` | `cu` | Cursor IDE | OAuth | — | — |
| `devin-cli` | `dv` | Devin CLI | OAuth | [link](https://cli.devin.ai) | Requires the Devin CLI binary. Run `devin auth login` to authenticate, or provide your WINDSURF_API_KEY. Install: https://cli.devin.ai |
| `cursor` | `cu` | Cursor IDE | OAuth, image | — | Image via Agent CLI (`CURSOR_AGENT_BIN`); same seat as chat |
| `devin-cli` | `dv` | Devin CLI (Official) | OAuth | [link](https://cli.devin.ai) | Requires the Devin CLI binary. Run `devin auth login` to authenticate, or provide your WINDSURF_API_KEY. Install: https://cli.devin.ai |
| `devin-desktop` | — | Devin Desktop | OAuth | [link](https://devin.ai) | Paste an existing Devin API key from an authenticated Devin session. Key export availability and steps vary by Devin version and account. |
| `ghe-copilot` | `ghe-copilot` | GitHub Enterprise Copilot | OAuth | — | Enter your GHE instance URL (e.g., https://ghe.company.com) in provider settings, then authenticate via device flow. |
| `github` | `gh` | GitHub Copilot | OAuth | — | — |
@@ -120,7 +120,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `zai-web` | `zw` | Z.ai Web | Web cookie | [link](https://chat.z.ai) | Copy the "token" value from chat.z.ai → DevTools → Application → Local Storage. Do not copy cookies; OmniRoute handles the per-request CAPTCHA through its browser transport. | — |
| `zenmux-free` | `zmf` | ZenMux Free (Web) | Web cookie | [link](https://zenmux.ai) | Login at zenmux.ai, then export all cookies using EditThisCookie or Cookie-Editor and paste the full Cookie header string here. Refresh every ~30 days. | — |
## API Key Providers (paid / paid-with-free-credits) (232)
## API Key Providers (paid / paid-with-free-credits) (231)
| ID | Alias | Name | Tags | Website | Notes |
|----|-------|------|------|---------|-------|
@@ -192,9 +192,9 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `fireworks` | `fireworks` | Fireworks AI | API key | [link](https://fireworks.ai) | $1 free starter credits on signup for API testing |
| `free-ai` | `free-ai` | Free.ai | API key, aggregator | [link](https://free.ai) | 30,000 tokens/day cover self-hosted models after email verification. Usage beyond the pool can bill at raw cost, and premium external models are paid. |
| `freeaiapikey` | `faik` | FreeAIAPIKey | API key | [link](https://freeaiapikey.com) | — |
| `freebuff` | `freebuff` | Freebuff | API key | [link](https://freebuff.com) | Enter Freebuff / Codebuff Auth Token (obtained via CLI login or automated harvester). |
| `freeinference` | `freeinference` | FreeInference | API key, aggregator | [link](https://freeinference.org) | Free research access without a card; non-Harvard applicants require manual approval and no numeric quota is publicly guaranteed. |
| `freemodel-dev` | `fmd` | FreeModel.dev | API key | [link](https://freemodel.dev) | $300 free credits on signup — no credit card required. Access GPT-5.4 and GPT-5.5 (OpenAI's latest flagship models) through an OpenAI-compatible API. |
| `freepik` | `fpk` | Freepik (Mystic) | API key, image | [link](https://freepik.com) | Get API key at freepik.com/developers (Mystic image endpoint) |
| `freetheai` | `fta` | FreeTheAi | API key, aggregator | [link](https://freetheai.xyz) | Join the FreeTheAi Discord to get your free API key. |
| `friendliai` | `friendli` | FriendliAI | API key | [link](https://friendli.ai) | Free tier for serverless inference — no credit card required |
| `g4f-gemini` | `g4fgem` | g4f.space — Gemini | API key, aggregator | [link](https://g4f.space) | No auth required. Free tier is limited to 5 requests/minute — sign up at g4f.dev/members.html for higher limits. |
@@ -243,7 +243,6 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `llm7` | `llm7` | LLM7.io | API key | [link](https://llm7.io) | Use any non-empty key (for example 'unused'). If older built-in models return model_unavailable, use Available Models → Import from /models or Auto-Sync; verified live model: gemini-3.1-flash-lite. |
| `llmgateway` | `llmgateway` | LLM Gateway | API key, aggregator | [link](https://llmgateway.io) | Hosted Free plan: free-priced models are limited to 5 requests per 10 minutes when the account has no credits. |
| `longcat` | `lc` | LongCat AI | API key | [link](https://longcat.chat/platform/docs) | Free: one-time 10M-token grant after account signup + KYC verification (LongCat-2.0). One-time only — not a recurring daily/monthly allowance. |
| `magnific` | `freepik` | Magnific | API key, image | [link](https://www.magnific.com) | Get an API key at magnific.com/user/api-keys (header x-magnific-api-key). Legacy Freepik developer keys still work. |
| `maritalk` | `maritalk` | Maritalk | API key | [link](https://www.maritaca.ai) | — |
| `meganova-ai` | `meganova-ai` | MegaNova AI | API key, aggregator | [link](https://meganova.ai) | Free signup without a card. Published Tier 1 per-model quotas total 550 requests/day; they are not a shared global pool, and paid overage can apply if enabled. |
| `meta-llama` | `meta` | Meta Llama API | API key | [link](https://llama.developer.meta.com) | — |
@@ -367,8 +366,8 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `llama-cpp` | `llamacpp` | llama.cpp | Local, self-hosted | [link](https://github.com/ggml-org/llama.cpp) | API key optional (use any value, e.g. sk-no-key-required). Configure the llama-server OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). Note: if Llamafile is also installed, both default to port 8080 — run only one at a time or override the port. |
| `llamafile` | `llamafile` | Llamafile | Local, self-hosted | [link](https://github.com/Mozilla-Ocho/llamafile) | API key optional. Configure the local Llamafile OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). |
| `lm-studio` | `lmstudio` | LM Studio | Local, self-hosted | [link](https://lmstudio.ai) | API key optional. Configure the local LM Studio OpenAI-compatible base URL (default: http://localhost:1234/v1). |
| `mlx-gemma` | `mlx-gemma` | MLX Gemma 26B | Local, self-hosted | [link](https://github.com/ml-explore/mlx) | No API key required. Runs mlx-lm server locally on port 11435. Requires uv and mlx-lm installed. Model: mlx-community/gemma-4-26B-A4B-it-qat-q4_0-mlx-aligned (~15.9GB peak memory). |
| `mlx-qwen` | `mlx-qwen` | MLX Qwen 3.8 27B | Local, self-hosted | [link](https://github.com/ml-explore/mlx) | No API key required. Runs mlx-lm server locally on port 11436. Requires uv and mlx-lm installed. Model: maglun/Qwen3.8-27B-MLX-Mixed-3.80bpw (~13.1GB peak memory). |
| `mlx-gemma` | `mlx-gemma` | MLX Gemma 26B | Local, self-hosted | [link](https://github.com/ml-explore/mlx) | No API key required. Runs mlx-lm server locally on port 11435. Requires `uv` and `mlx-lm` installed. Model: `mlx-community/gemma-4-26B-A4B-it-qat-q4_0-mlx-aligned` (~15.9GB peak memory). |
| `mlx-qwen` | `mlx-qwen` | MLX Qwen 3.8 27B | Local, self-hosted | [link](https://github.com/ml-explore/mlx) | No API key required. Runs mlx-lm server locally on port 11436. Requires `uv` and `mlx-lm` installed. Model: `maglun/Qwen3.8-27B-MLX-Mixed-3.80bpw` (~13.1GB peak memory). |
| `ollama-local` | `ollama` | Ollama | Local, self-hosted | [link](https://ollama.com) | No API key required. Ollama runs locally — configure its OpenAI-compatible base URL (default: http://localhost:11434/v1) and make sure Ollama is running before connecting. |
| `oobabooga` | `ooba` | oobabooga | Local, self-hosted | [link](https://github.com/oobabooga/text-generation-webui) | API key optional. Configure the local oobabooga OpenAI-compatible base URL (default: http://localhost:5000/v1). |
| `sdwebui` | `sdwebui` | SD WebUI | Local | [link](https://github.com/AUTOMATIC1111/stable-diffusion-webui) | No API key required. Configure the local WebUI base URL (default: http://localhost:7860). |
@@ -435,7 +434,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
- Catalog: [`src/shared/constants/providers.ts`](../../src/shared/constants/providers.ts)
- Registry (per-model details): [`open-sse/config/providerRegistry.ts`](../../open-sse/config/providerRegistry.ts)
- Executors: [`open-sse/executors/`](../../open-sse/executors/) (106 implementations)
- Executors: [`open-sse/executors/`](../../open-sse/executors/) (105 implementations)
- Translators: [`open-sse/translator/`](../../open-sse/translator/)
## See Also

View File

@@ -70,6 +70,11 @@ omniroute combo switch <name>
Create a new routing combo
**Flags:**
- `--models <spec>`
- `--model <spec>`
**Example:**
```bash

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { getAggregatedSnapshots } from "@/lib/db/quotaSnapshots";
import { getProviderConnectionById } from "@/lib/db/providers";
import { getConnection } from "@/lib/db/connections";
import type {
ProviderUtilizationResponse,
UtilizationTimeRange,
@@ -67,7 +67,7 @@ export async function GET(request: Request) {
);
connectionMeta = {};
for (const cid of uniqueConnectionIds) {
const conn = await getProviderConnectionById(cid);
const conn = getConnection(cid);
connectionMeta[cid] = {
email: conn?.email ?? null,
name: conn?.name ?? null,

View File

@@ -0,0 +1,224 @@
// Regression for #10954: `omniroute combo create` did not accept any way to
// specify models — `bin/cli/commands/combo.mjs` only ever registered
// `--strategy`, and both the HTTP body (POST /api/combos) and the local-db
// fallback (db.combos.createCombo) hardcoded `models: []`. Every combo
// created via the CLI came out empty.
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { Command } from "commander";
type CapturedOpts = Record<string, unknown>;
interface MockFetchInit {
method?: string;
body?: string;
}
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
const ORIGINAL_FETCH = globalThis.fetch;
function createTempDataDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cli-combo-models-"));
}
async function withComboEnv(fn: (dataDir: string) => Promise<void>) {
const dataDir = createTempDataDir();
process.env.DATA_DIR = dataDir;
// Mock fetch → simulates server offline so withRuntime falls back to DB.
globalThis.fetch = (async () => {
throw new Error("server offline");
}) as typeof fetch;
const originalLog = console.log;
console.log = () => {};
try {
await fn(dataDir);
} finally {
console.log = originalLog;
globalThis.fetch = ORIGINAL_FETCH;
fs.rmSync(dataDir, { recursive: true, force: true });
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
}
}
function makeHealthAndComboFetch(capture: { body: CapturedOpts | null }) {
return (async (url: string, opts?: MockFetchInit) => {
if (String(url).includes("/api/health")) {
return {
ok: true,
status: 200,
json: async () => ({ status: "ok" }),
text: async () => "{}",
headers: new Headers(),
};
}
if (String(url).includes("/api/combos") && opts?.method === "POST") {
capture.body = opts?.body ? JSON.parse(opts.body) : null;
return {
ok: true,
status: 201,
json: async () => ({ id: "combo-1", ...capture.body }),
text: async () => JSON.stringify(capture.body),
headers: new Headers(),
};
}
throw new Error(`unexpected fetch: ${url}`);
}) as unknown as typeof fetch;
}
// RED (on untouched code): `combo create` only registers `--strategy` — an
// unrecognized `--models` option makes Commander (in strict `exitOverride`
// mode) throw "unknown option '--models'" instead of parsing.
test("combo create — parses --models without throwing (Commander option registered)", async () => {
const { registerCombo } = await import("../../bin/cli/commands/combo.mjs");
const { Command } = await import("commander");
const prog = new Command().exitOverride();
registerCombo(prog);
const comboCmd = prog.commands.find((c: Command) => c.name() === "combo") as Command;
const createCmd = comboCmd.commands.find((c: Command) => c.name() === "create") as Command;
let capturedOpts: CapturedOpts | null = null;
createCmd.action((_name: string, opts: CapturedOpts) => {
capturedOpts = opts;
});
await prog.parseAsync(
["node", "x", "combo", "create", "my-combo", "--models", "openai/gpt-4o,anthropic/claude-3-opus"],
{ from: "node" }
);
assert.ok(capturedOpts, "action should have been called");
assert.equal(capturedOpts.models, "openai/gpt-4o,anthropic/claude-3-opus");
});
test("combo create — repeatable --model is registered and collected", async () => {
const { registerCombo } = await import("../../bin/cli/commands/combo.mjs");
const { Command } = await import("commander");
const prog = new Command().exitOverride();
registerCombo(prog);
const comboCmd = prog.commands.find((c: Command) => c.name() === "combo") as Command;
const createCmd = comboCmd.commands.find((c: Command) => c.name() === "create") as Command;
let capturedOpts: CapturedOpts | null = null;
createCmd.action((_name: string, opts: CapturedOpts) => {
capturedOpts = opts;
});
await prog.parseAsync(
[
"node",
"x",
"combo",
"create",
"my-combo",
"--model",
"openai/gpt-4o",
"--model",
"anthropic/claude-3-opus",
],
{ from: "node" }
);
assert.deepEqual(capturedOpts.model, ["openai/gpt-4o", "anthropic/claude-3-opus"]);
});
test("comboModels.resolveComboModels — parses CSV provider/model tokens", async () => {
const { resolveComboModels } = await import("../../bin/cli/commands/comboModels.mjs");
const models = resolveComboModels({ models: "openai/gpt-4o, anthropic/claude-3-opus" });
assert.deepEqual(models, ["openai/gpt-4o", "anthropic/claude-3-opus"]);
});
test("comboModels.resolveComboModels — parses a JSON array of structured entries", async () => {
const { resolveComboModels } = await import("../../bin/cli/commands/comboModels.mjs");
const models = resolveComboModels({
models: JSON.stringify([
{ model: "gpt-4o", providerId: "openai" },
{ kind: "combo-ref", comboName: "fallback-combo" },
]),
});
assert.deepEqual(models, [
{ model: "gpt-4o", providerId: "openai" },
{ kind: "combo-ref", comboName: "fallback-combo" },
]);
});
test("comboModels.resolveComboModels — rejects an invalid JSON entry shape", async () => {
const { resolveComboModels } = await import("../../bin/cli/commands/comboModels.mjs");
assert.throws(
() => resolveComboModels({ models: JSON.stringify([{ providerId: "openai" }]) }),
/requires a non-empty "model"/
);
});
test("comboModels.resolveComboModels — merges --models and repeated --model", async () => {
const { resolveComboModels } = await import("../../bin/cli/commands/comboModels.mjs");
const models = resolveComboModels({
models: "openai/gpt-4o",
model: ["anthropic/claude-3-opus"],
});
assert.deepEqual(models, ["openai/gpt-4o", "anthropic/claude-3-opus"]);
});
// GREEN: end-to-end through runComboCreateCommand — local-db fallback path.
test("combo create (db fallback) — stores the parsed --models, no longer creates an empty combo", async () => {
await withComboEnv(async () => {
const { runComboCreateCommand } = await import("../../bin/cli/commands/combo.mjs");
const { resolveComboModels } = await import("../../bin/cli/commands/comboModels.mjs");
const models = resolveComboModels({ models: "openai/gpt-4o,anthropic/claude-3-opus" });
const result = await runComboCreateCommand("models-combo", "priority", { models });
assert.equal(result, 0);
const { getComboByName } = await import("../../src/lib/db/combos.ts");
const combo = await getComboByName("models-combo");
assert.ok(combo);
// The repository layer (src/lib/db/repositories/sqliteComboRepository.ts)
// normalizes plain "provider/model" strings into structured ComboStep
// objects on write — assert on the normalized shape rather than raw
// string equality, and above all assert the combo is no longer empty
// (the actual #10954 regression).
const storedModels = combo.models as Array<Record<string, unknown>>;
assert.equal(storedModels.length, 2, "combo must not be created empty");
assert.equal(storedModels[0].model, "openai/gpt-4o");
assert.equal(storedModels[0].providerId, "openai");
assert.equal(storedModels[1].model, "anthropic/claude-3-opus");
assert.equal(storedModels[1].providerId, "anthropic");
});
});
// GREEN: end-to-end through runComboCreateCommand — HTTP path, verifies the
// POST /api/combos body actually carries the parsed models.
test("combo create (HTTP) — POST /api/combos body carries the parsed models", async () => {
const dataDir = createTempDataDir();
process.env.DATA_DIR = dataDir;
const capture: { body: CapturedOpts | null } = { body: null };
globalThis.fetch = makeHealthAndComboFetch(capture);
const originalLog = console.log;
console.log = () => {};
try {
const { runComboCreateCommand } = await import("../../bin/cli/commands/combo.mjs");
const { resolveComboModels } = await import("../../bin/cli/commands/comboModels.mjs");
const models = resolveComboModels({ models: "openai/gpt-4o,anthropic/claude-3-opus" });
const result = await runComboCreateCommand("http-models-combo", "priority", { models });
assert.equal(result, 0);
assert.ok(capture.body, "POST /api/combos should have been called");
assert.deepEqual(capture.body.models, ["openai/gpt-4o", "anthropic/claude-3-opus"]);
} finally {
console.log = originalLog;
globalThis.fetch = ORIGINAL_FETCH;
fs.rmSync(dataDir, { recursive: true, force: true });
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
}
});

View File

@@ -1,16 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// #9985 base-red (introduced by #10939): the utilization route imported
// `getConnection` from "@/lib/db/connections", a module that does not exist —
// typecheck:core does not cover app routes, so only `next build` (and any
// runtime import, like this test) catches it. Importing the route module is
// the repro: ERR_MODULE_NOT_FOUND before the fix, resolves after.
test("utilization route module resolves all its imports (#10939 broken-import regression)", async () => {
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-utilization-import-"));
const mod = await import("../../src/app/api/usage/utilization/route.ts");
assert.equal(typeof mod.GET, "function", "route must export a GET handler");
});