From 78561f0239ed9c1aa88e711eba8929d8545cdff0 Mon Sep 17 00:00:00 2001 From: Joseph Yaksich <294273268+gitcommit90@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:33:56 +0000 Subject: [PATCH] feat: route by provider and model --- CHANGELOG.md | 10 +- README.md | 4 +- docs/architecture.md | 24 ++++- package-lock.json | 4 +- package.json | 2 +- scripts/capture-ui.js | 18 ++-- src/cli/setup.js | 18 +++- src/lib/control-plane.js | 7 +- src/lib/router.js | 26 +++++- src/lib/store.js | 24 ++++- src/renderer/app.js | 112 ++++++++++++++-------- src/renderer/route-picker.js | 130 ++++++++++++++++++++------ src/renderer/styles.css | 7 ++ tests/catalog-migration.test.js | 31 +++++- tests/cli.test.js | 35 ++++++- tests/route-picker.test.js | 161 ++++++++++++++++++++++---------- tests/router-fallback.test.js | 120 ++++++++++++++++++++++++ 17 files changed, 581 insertions(+), 152 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3e0e4e..9b0916d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ Release tags use the form `vX.Y.Z` and match `package.json`. GitHub Releases car ## [Unreleased] +## [0.5.7] - 2026-07-20 + +### Changed + +- Routes now select Provider, then Model instead of exposing individual accounts. ReRouted tries all eligible accounts for that provider/model before advancing to the next fallback or round-robin member. +- Existing standard-provider route members migrate to provider/model destinations; custom OpenAI-compatible endpoints remain connection-specific. + ## [0.5.6] - 2026-07-20 ### Fixed @@ -81,7 +88,8 @@ Release tags use the form `vX.Y.Z` and match `package.json`. GitHub Releases car See [GitHub Releases](https://github.com/gitcommit90/rerouted/releases) for artifact digests and notes prior to the Keep a Changelog narrative. Notable themes in late 0.4.x included signed/notarized distribution, in-app updates, named routes, OAuth account pools, OpenAI chat completions and Responses routing, and launch hardening. -[Unreleased]: https://github.com/gitcommit90/rerouted/compare/v0.5.5...HEAD +[Unreleased]: https://github.com/gitcommit90/rerouted/compare/v0.5.7...HEAD +[0.5.7]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.7 [0.5.5]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.5 [0.5.4]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.4 [0.5.3]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.3 diff --git a/README.md b/README.md index a088644..57ee17c 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Timeouts and retryable `408`, `429`, and `5xx` responses can advance the route. - **API-key presets:** OpenRouter, NVIDIA NIM, Cloudflare, and GLM Coding. - **Custom upstreams:** any service that exposes the OpenAI chat-completions shape ReRouted expects. - **Local credential discovery:** supported credentials already stored in known files, or in the macOS Keychain where available, can be imported instead of re-entered. -- **Multiple accounts:** connect more than one account for the same provider and use shared or account-specific model routes. +- **Multiple accounts:** connect more than one account for the same provider. Routes select Provider then Model; ReRouted tries every eligible account for that provider/model before advancing to the next route member. OAuth accounts and keyed providers can live in the same route. ReRouted handles request translation and normalizes supported upstream responses back into the shape your client expects. @@ -192,7 +192,7 @@ ReRouted accepts both `/v1/messages` and `/v1/v1/messages`, so current Claude Co - **Status:** gateway health, endpoint, latest route, and recent traffic. - **Accounts:** OAuth sessions, imported credentials, API keys, and model availability. -- **Routes:** named fallback or round-robin model groups with explicit ordering controls. +- **Routes:** named fallback or round-robin provider/model groups with explicit ordering controls; same-provider accounts stay inside the member’s automatic fallback pool. - **Activity:** requests, failures, token counts, route choices, and account usage. - **Quota:** provider-specific subscription windows where supported. - **Settings:** gateway keys, localhost or network binding, security controls, and platform-appropriate startup/update information. diff --git a/docs/architecture.md b/docs/architecture.md index f8ad8f8..b0825a5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,15 +71,20 @@ Anthropic Messages requests are normalized into the same internal OpenAI chat-co Provider model IDs are generated by `src/lib/providers/index.js`. Custom OpenAI-compatible connections use the readable form `/custom/` while legacy hash-qualified IDs remain resolvable. A direct model resolves to one enabled provider/model pair. -A route is a persisted virtual model with members shaped like: +A route is a persisted virtual model. Standard providers are represented by a +provider/model destination, not by an individual credential: ```json { - "providerId": "prov_...", + "providerType": "chatgpt", "model": "upstream-model-id" } ``` +Custom OpenAI-compatible connections remain connection-specific because their +base URLs can represent different services, so those members retain a +`providerId`. + The router supports: - `fallback`: members are attempted in their configured order. @@ -87,7 +92,20 @@ The router supports: Named routes and OAuth account pools continue through every untried target until an upstream returns a usable `2xx` response. Any non-`2xx` status advances fallback regardless of error type. A `2xx` response that contains an immediate stream error, ends without usable output, exceeds the bounded pre-output inspection budget, has no response body, contains invalid JSON, or carries an explicit error payload also advances fallback. Failure classification controls account cooldown locks and diagnostics; it never stops routing. Capability failures do not create account cooldown locks. The per-member timeout defaults to 60 seconds. Caller cancellation stops immediately, and a stream cannot be transparently rerouted after output has already reached the client. -OAuth providers add an account-pool layer beneath model routing. Accounts receive monotonic, never-reused aliases (`oauth1`, `oauth2`, ...). Model discovery advertises one canonical pooled id such as `chatgpt/gpt-5.4`; account-qualified ids such as `chatgpt/oauth2/gpt-5.4` and legacy stored-account ids remain resolvable but are not advertised. Quota failures create an account-wide lock using provider reset hints when available; authentication and transient failures use shorter model-scoped cooldowns. Early streaming quota events are inspected before the client stream starts so fallback can still occur. Selection, failure, fallback, locked-account skips, and terminal exhaustion are written as structured logs. +Standard providers add an account-pool layer beneath model routing. The route +editor and terminal setup select Provider, then Model; individual OAuth or +API-key accounts are internal to that member. Every eligible account for the +selected provider/model is tried before routing advances to the next member. +Round robin rotates these outer members only, retaining the account-pool retry +inside each one. OAuth accounts receive monotonic, never-reused aliases +(`oauth1`, `oauth2`, ...). Model discovery advertises one canonical pooled id +such as `chatgpt/gpt-5.4`; account-qualified ids such as +`chatgpt/oauth2/gpt-5.4` and legacy stored-account ids remain resolvable but +are not advertised. Quota failures create an account-wide lock using provider +reset hints when available; authentication and transient failures use shorter +model-scoped cooldowns. Early streaming quota events are inspected before the +client stream starts so fallback can still occur. Selection, failure, fallback, +locked-account skips, and terminal exhaustion are written as structured logs. ## Provider adapters diff --git a/package-lock.json b/package-lock.json index 3901349..82bda63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@gitcommit90/rerouted", - "version": "0.5.6", + "version": "0.5.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@gitcommit90/rerouted", - "version": "0.5.6", + "version": "0.5.7", "license": "MIT", "bin": { "rerouted": "src/cli/index.js" diff --git a/package.json b/package.json index 4057d2b..8f96f04 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@gitcommit90/rerouted", "productName": "ReRouted", - "version": "0.5.6", + "version": "0.5.7", "description": "A local AI router for connected accounts, models, named routes, and automatic fallback.", "author": "gitcommit90", "license": "MIT", diff --git a/scripts/capture-ui.js b/scripts/capture-ui.js index 04d97d4..7c17314 100644 --- a/scripts/capture-ui.js +++ b/scripts/capture-ui.js @@ -654,21 +654,21 @@ app.whenReady().then(async () => { window.__rr_goto_page("combos"); document.querySelector("button[data-edit-index]")?.click(); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); - let account = document.getElementById("c-add-account"); + let provider = document.getElementById("c-add-provider"); let model = document.getElementById("c-add-model"); let add = document.getElementById("btn-add-member"); - if (!account || !model?.disabled || !add?.disabled) { + if (!provider || !model?.disabled || !add?.disabled) { throw new Error("Route picker did not start with Model and Add disabled"); } - account.value = "prov_chatgpt_demo"; - account.dispatchEvent(new Event("change", { bubbles: true })); + provider.value = "provider:chatgpt"; + provider.dispatchEvent(new Event("change", { bubbles: true })); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); - account = document.getElementById("c-add-account"); + provider = document.getElementById("c-add-provider"); model = document.getElementById("c-add-model"); add = document.getElementById("btn-add-member"); const modelOptions = [...model.options].filter((option) => option.value); - if (account.value !== "prov_chatgpt_demo" || model.disabled || !modelOptions.length) { - throw new Error("Selecting an account did not enable and populate its models"); + if (provider.value !== "provider:chatgpt" || model.disabled || !modelOptions.length) { + throw new Error("Selecting a provider did not enable and populate its models"); } model.value = modelOptions[1]?.value || modelOptions[0].value; model.dispatchEvent(new Event("change", { bubbles: true })); @@ -682,9 +682,9 @@ app.whenReady().then(async () => { const before = document.querySelectorAll(".member-row").length; document.getElementById("btn-add-member")?.click(); const after = document.querySelectorAll(".member-row").length; - const account = document.getElementById("c-add-account"); + const provider = document.getElementById("c-add-provider"); const model = document.getElementById("c-add-model"); - if (after !== before + 1 || account.value || !model.disabled) { + if (after !== before + 1 || provider.value || !model.disabled) { throw new Error("Adding a route member did not reset the dependent picker"); } return true; diff --git a/src/cli/setup.js b/src/cli/setup.js index 7dfa54e..ce3862a 100644 --- a/src/cli/setup.js +++ b/src/cli/setup.js @@ -1,20 +1,28 @@ "use strict"; const { KEYED_PRESETS } = require("../lib/constants"); +const { isCustomProviderType } = require("../lib/model-ids"); const OAUTH_NOTICE = "Subscription OAuth sessions are not officially licensed for router use and may carry account risk."; function publicModelOptions(state) { const options = []; + const seen = new Set(); for (const provider of state.providers || []) { if (provider.enabled === false) continue; + const providerType = provider.type === "codex" ? "chatgpt" : provider.type; + const connectionScoped = isCustomProviderType(provider.type); for (const model of provider.models || []) { if (model.enabled === false) continue; + const modelId = model.id; + const key = `${connectionScoped ? provider.id : providerType}::${modelId}`; + if (seen.has(key)) continue; + seen.add(key); options.push({ - label: `${provider.name}${provider.accountAlias ? ` · ${provider.accountAlias}` : ""}: ${model.name || model.id}`, - providerId: provider.id, - model: model.id, + label: `${connectionScoped ? provider.name : providerType === "chatgpt" ? "ChatGPT" : provider.name}: ${model.name || modelId}`, + ...(connectionScoped ? { providerId: provider.id } : { providerType }), + model: modelId, }); } } @@ -131,7 +139,9 @@ async function createRoute(prompts, invoke, output) { name, strategy: strategyIndex === 1 ? "round-robin" : "fallback", members: indexes.map((index) => ({ - providerId: models[index].providerId, + ...(models[index].providerType + ? { providerType: models[index].providerType } + : { providerId: models[index].providerId }), model: models[index].model, })), }); diff --git a/src/lib/control-plane.js b/src/lib/control-plane.js index cec105c..817c298 100644 --- a/src/lib/control-plane.js +++ b/src/lib/control-plane.js @@ -386,9 +386,14 @@ handle("app:test-keyed-provider", async (_e, payload) => { handle("app:remove-provider", async (_e, id) => { store.update((cfg) => { + const removed = cfg.providers.find((p) => p.id === id); cfg.providers = cfg.providers.filter((p) => p.id !== id); for (const c of cfg.combos) { - c.members = (c.members || []).filter((m) => m.providerId !== id); + // Provider/model members are account-agnostic. Removing one account + // leaves the route intact when another account for that provider remains. + if (isCustomProviderType(removed?.type)) { + c.members = (c.members || []).filter((m) => m?.providerId !== id); + } } }); return { ok: true }; diff --git a/src/lib/router.js b/src/lib/router.js index 97b1e96..ef1a361 100644 --- a/src/lib/router.js +++ b/src/lib/router.js @@ -15,6 +15,7 @@ const { extractUsage } = require("./usage"); const appLogger = require("./logger"); const { canonicalProviderType, isOAuthProvider, getActiveModelLock } = require("./store"); const { publicComboId, comboMatchesId } = require("./combos"); +const { isCustomProviderType } = require("./model-ids"); const { createSseParser } = require("./sse"); const ANTHROPIC_METADATA = Symbol.for("rerouted.anthropic.metadata"); @@ -60,14 +61,20 @@ function providerHasModel(provider, upstreamModel) { ); } +function isAccountPoolProvider(provider) { + return !!provider && !isCustomProviderType(provider.type); +} + function accountCandidatesFor(cfg, preferredProvider, upstreamModel, { preferRequested = true } = {}) { - if (!preferredProvider || !isOAuthProvider(preferredProvider)) return preferredProvider ? [preferredProvider] : []; + if (!preferredProvider || !isAccountPoolProvider(preferredProvider)) { + return preferredProvider ? [preferredProvider] : []; + } const family = canonicalProviderType(preferredProvider.type); const candidates = (cfg.providers || []) .filter( (provider) => provider.enabled !== false && - isOAuthProvider(provider) && + isAccountPoolProvider(provider) && canonicalProviderType(provider.type) === family && providerHasModel(provider, upstreamModel) ) @@ -95,11 +102,24 @@ function resolveTargets(cfg, modelId) { if (typeof m === "string") { return resolveSingle(cfg, m); } + const mid = m.model || m.upstreamModel; + if (m.providerType) { + const family = canonicalProviderType(m.providerType); + const prov = (cfg.providers || []) + .filter( + (p) => + p.enabled !== false && + isAccountPoolProvider(p) && + canonicalProviderType(p.type) === family && + providerHasModel(p, mid) + ) + .sort(compareAccounts)[0]; + return prov ? makeMember(cfg, prov, mid, { preferRequested: false }) : null; + } const prov = (cfg.providers || []).find((p) => p.id === m.providerId); if (!prov || prov.enabled === false) return null; // Skip disabled models on the provider const models = prov.models || []; - const mid = m.model || m.upstreamModel; const modelEntry = models.find((x) => (typeof x === "string" ? x : x.id) === mid); if (modelEntry && typeof modelEntry !== "string" && modelEntry.enabled === false) { return null; diff --git a/src/lib/store.js b/src/lib/store.js index 76667da..619b39b 100644 --- a/src/lib/store.js +++ b/src/lib/store.js @@ -10,10 +10,10 @@ const { OAUTH_MODEL_RENAMES, } = require("./constants"); const { ensureUniqueComboNames, providerRouteIds } = require("./combos"); -const { ensureUniqueCustomConnectionNames } = require("./model-ids"); +const { ensureUniqueCustomConnectionNames, isCustomProviderType } = require("./model-ids"); const { backfillTokenIdentity } = require("./oauth-identity"); -const CONFIG_VERSION = 8; +const CONFIG_VERSION = 9; const COMBO_NAME_MIGRATION_VERSION = 5; const XAI_LOCK_RESET_VERSION = 6; const RETIRED_OAUTH_CLEANUP_VERSION = 8; @@ -233,12 +233,28 @@ function migrate(cfg) { ensureUniqueCustomConnectionNames(cfg.providers, cfg.combos); const providerById = new Map((cfg.providers || []).map((provider) => [provider.id, provider])); for (const combo of cfg.combos) { - for (const member of combo.members || []) { + const seenMembers = new Set(); + combo.members = (combo.members || []).flatMap((member) => { + if (!member || typeof member !== "object") return [member]; const provider = providerById.get(member.providerId); const family = canonicalProviderType(provider?.type); const rename = OAUTH_MODEL_RENAMES[family]?.[member.model]; if (rename) member.model = rename; - } + // Named routes describe a provider/model destination. Credentials are an + // implementation detail: every matching account is tried before the + // route advances. Custom endpoints remain connection-scoped because + // their base URLs can be different services. + if (provider && !isCustomProviderType(provider.type)) { + delete member.providerId; + member.providerType = family; + } else if (member.providerType) { + member.providerType = canonicalProviderType(member.providerType); + } + const key = `${member.providerType || member.providerId || ""}::${member.model || member.upstreamModel || ""}`; + if (!key || seenMembers.has(key)) return []; + seenMembers.add(key); + return [member]; + }); } if (needsComboNameMigration) { ensureUniqueComboNames(cfg.combos, providerRouteIds(cfg.providers)); diff --git a/src/renderer/app.js b/src/renderer/app.js index 1dbf742..9db2e48 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -7,8 +7,13 @@ const { compactNumber: fmtNum } = window.ReroutedNumberFormat; const { createLatestRequestGate, guardSensitiveRender } = window.ReroutedRendererLockState; const { buildEnabledProviderGroups, buildProviderCatalog, canonicalProviderType } = window.ReroutedProviderCatalog; -const { buildRouteAccountOptions, modelsForRouteAccount, moveRouteMember } = - window.ReroutedRoutePicker; +const { + buildRouteProviderOptions, + modelsForRouteProvider, + routeMemberForProvider, + normalizeRouteMember, + moveRouteMember, +} = window.ReroutedRoutePicker; const { oauthPrompt } = window.ReroutedOAuthPrompt; const $ = (sel, el = document) => el.querySelector(sel); const view = $("#view"); @@ -820,7 +825,7 @@ function renderTutorial() { } function renderFirstCombo() { - const models = flatModels(); + const providers = buildRouteProviderOptions(state.providers || []); const draft = onboardingDraft.firstCombo; view.innerHTML = ` ${stepProgress("first-combo")} @@ -835,11 +840,18 @@ function renderFirstCombo() {
Members
${ - models.length - ? models - .map( - (m) => - `` + providers.length + ? providers + .flatMap((provider) => + provider.models.map((model) => { + const member = routeMemberForProvider(provider, model.upstreamModel); + const accountNote = provider.connectionScoped + ? "This connection" + : provider.accountCount > 1 + ? `${provider.accountCount} accounts · tries each before the next member` + : "1 connected account"; + return ``; + }) ) .join("") : `
No models yet — add a provider first.
` @@ -856,7 +868,10 @@ function renderFirstCombo() { }; view.querySelectorAll(".member-pick input").forEach((input) => { input.onchange = () => { - const key = memberKey({ providerId: input.dataset.pid, model: input.dataset.model }); + const member = input.dataset.providerType + ? { providerType: input.dataset.providerType, model: input.dataset.model } + : { providerId: input.dataset.providerId, model: input.dataset.model }; + const key = memberKey(member); if (input.checked) draft.members.add(key); else draft.members.delete(key); }; @@ -871,10 +886,11 @@ function renderFirstCombo() { wireOnboardingBack({ tutorial: true }); $("#btn-skip").onclick = () => finishOnboarding(); $("#btn-create").onclick = async () => { - const members = [...view.querySelectorAll(".member-pick input:checked")].map((el) => ({ - providerId: el.dataset.pid, - model: el.dataset.model, - })); + const members = [...view.querySelectorAll(".member-pick input:checked")].map((el) => + el.dataset.providerType + ? { providerType: el.dataset.providerType, model: el.dataset.model } + : { providerId: el.dataset.providerId, model: el.dataset.model } + ); const name = $("#c-name").value.trim(); if (!name) return toast("Enter a model ID"); if (!members.length) return toast("Pick at least one model"); @@ -1729,7 +1745,7 @@ function renderProviders() { } function memberKey(m) { - return `${m.providerId}::${m.model || m.upstreamModel}`; + return `${m.providerType || m.providerId}::${m.model || m.upstreamModel}`; } let comboDraft = null; @@ -1739,10 +1755,12 @@ function beginComboEdit(combo) { id: combo?.storageId || combo?.id || null, name: combo?.name || "", strategy: combo?.strategy || "fallback", - pickerAccountId: null, + pickerProviderId: null, pickerModelId: null, members: (combo?.members || []).map((member) => ({ - providerId: member.providerId, + ...(member.providerType + ? { providerType: canonicalProviderType(member.providerType) } + : { providerId: member.providerId }), model: member.model || member.upstreamModel, })), }; @@ -1766,30 +1784,33 @@ function syncComboDraft() { if (name) comboDraft.name = name.value; } -function comboMemberInfo(member, models) { - return models.find( - (model) => model.providerId === member.providerId && model.upstreamModel === member.model +function comboMemberInfo(member, providers) { + const normalized = normalizeRouteMember(member, providers); + const provider = providers.find((entry) => + normalized.providerType + ? entry.providerType === normalized.providerType && !entry.connectionScoped + : entry.providerId === normalized.providerId ); + const model = provider?.models.find((entry) => entry.upstreamModel === normalized.model); + return { member: normalized, provider, model }; } -function routeAccountLabel(account) { - if (account.accountAlias) return `${account.name} · ${aliasLabel(account.accountAlias)}`; - if (account.connectionCount > 1) return `${account.name} · Connection ${account.connectionIndex}`; - return account.name; +function routeProviderLabel(provider) { + if (!provider) return "Provider"; + return provider.name; } function renderCombos(options = {}) { if (blockSensitiveRenderIfLocked()) return; const combos = state.combos || []; - const models = flatModels(); - const accounts = buildRouteAccountOptions(state.providers || []); + const providers = buildRouteProviderOptions(state.providers || []); const editor = comboDraft; - if (editor && !accounts.some((account) => account.id === editor.pickerAccountId)) { - editor.pickerAccountId = null; + if (editor && !providers.some((provider) => provider.id === editor.pickerProviderId)) { + editor.pickerProviderId = null; editor.pickerModelId = null; } const pickerModels = editor - ? modelsForRouteAccount(accounts, editor.pickerAccountId) + ? modelsForRouteProvider(providers, editor.pickerProviderId) : []; if ( editor?.pickerModelId && @@ -1842,12 +1863,19 @@ function renderCombos(options = {}) { editor.members.length ? editor.members .map((member, index) => { - const info = comboMemberInfo(member, models); + const info = comboMemberInfo(member, providers); + const providerName = info.provider?.name || member.providerType || "Provider"; + const accountCount = info.model?.accountCount || info.provider?.accountCount || 0; + const accountNote = info.provider?.connectionScoped + ? "This connection" + : accountCount > 1 + ? `Tries ${accountCount} accounts before the next member` + : "Uses the connected account"; return `
${index + 1}
-
${esc(info?.name || member.model)}
${esc(info?.id || member.model)}
-
+
${esc(providerName)} · ${esc(info.model?.name || member.model)}
${esc(info.model?.upstreamModel || member.model)} · ${esc(accountNote)}
+
`; }) .join("") @@ -1855,10 +1883,11 @@ function renderCombos(options = {}) { }
- - - + + +
+
When a provider has multiple accounts, ReRouted tries that model on every eligible account before moving to the next route member.
` : ""; @@ -1966,30 +1995,31 @@ function renderCombos(options = {}) { renderCombos(); }; }); - $("#c-add-account").onchange = () => { + $("#c-add-provider").onchange = () => { syncComboDraft(); - editor.pickerAccountId = $("#c-add-account").value || null; + editor.pickerProviderId = $("#c-add-provider").value || null; editor.pickerModelId = null; renderCombos(); }; $("#c-add-model").onchange = () => { editor.pickerModelId = $("#c-add-model").value || null; - $("#btn-add-member").disabled = !editor.pickerAccountId || !editor.pickerModelId; + $("#btn-add-member").disabled = !editor.pickerProviderId || !editor.pickerModelId; }; $("#btn-add-member").onclick = () => { syncComboDraft(); - if (!editor.pickerAccountId) return toast("Choose an account"); + if (!editor.pickerProviderId) return toast("Choose a provider"); if (!editor.pickerModelId) return toast("Choose a model"); const model = pickerModels.find( (item) => item.upstreamModel === editor.pickerModelId ); - if (!model) return toast("Choose a model"); - const member = { providerId: model.providerId, model: model.upstreamModel }; + const provider = providers.find((item) => item.id === editor.pickerProviderId); + if (!provider || !model) return toast("Choose a model"); + const member = routeMemberForProvider(provider, model.upstreamModel); if (editor.members.some((item) => memberKey(item) === memberKey(member))) { return toast("That model is already in the route"); } editor.members.push(member); - editor.pickerAccountId = null; + editor.pickerProviderId = null; editor.pickerModelId = null; renderCombos(); }; diff --git a/src/renderer/route-picker.js b/src/renderer/route-picker.js index cd0c564..d022d0d 100644 --- a/src/renderer/route-picker.js +++ b/src/renderer/route-picker.js @@ -1,6 +1,32 @@ "use strict"; (function exposeRoutePicker(root) { + function canonicalProviderType(type) { + return String(type || "").toLowerCase() === "codex" ? "chatgpt" : String(type || ""); + } + + function isConnectionScopedProvider(provider) { + const type = canonicalProviderType(provider?.type); + return type === "custom" || type === "openai-compat"; + } + + function providerDisplayName(provider, type) { + if (isConnectionScopedProvider(provider)) { + return String(provider?.name || "Provider").trim() || "Provider"; + } + const names = { + chatgpt: "ChatGPT", + claude: "Claude", + antigravity: "Antigravity", + xai: "xAI", + openrouter: "OpenRouter", + nvidia: "NVIDIA NIM", + cloudflare: "Cloudflare", + glm: "GLM Coding", + }; + return names[type] || String(provider?.name || type || "Provider").trim() || "Provider"; + } + function enabledModelOptions(provider) { const models = []; for (const model of provider?.models || []) { @@ -11,46 +37,88 @@ id, name: typeof model === "string" ? model : model.name || id, gatewayId: typeof model === "string" ? null : model.gatewayId || null, - providerId: provider.id, upstreamModel: id, }); } return models; } - function buildRouteAccountOptions(providers) { - const accounts = []; + /** + * Turns connected accounts into route choices. Subscription and preset API-key + * providers are intentionally grouped by provider family; their individual + * accounts are an internal fallback pool. Custom OpenAI-compatible endpoints + * stay distinct because each connection can point at a different base URL. + */ + function buildRouteProviderOptions(providers) { + const groups = []; + const groupsByKey = new Map(); + for (const provider of providers || []) { - if (!provider?.id || provider.enabled === false) continue; + if (!provider?.id || provider.enabled === false || provider.hasToken === false) continue; const models = enabledModelOptions(provider); if (!models.length) continue; - accounts.push({ - id: provider.id, - name: String(provider.name || provider.type || "Account").trim() || "Account", - accountAlias: provider.accountAlias || null, - providerType: provider.type || "", - models, - }); - } - const duplicateGroups = new Map(); - for (const account of accounts) { - if (account.accountAlias) continue; - const key = `${account.providerType}:${account.name}`.toLowerCase(); - if (!duplicateGroups.has(key)) duplicateGroups.set(key, []); - duplicateGroups.get(key).push(account); + + const providerType = canonicalProviderType(provider.type); + const connectionScoped = isConnectionScopedProvider(provider); + const id = connectionScoped ? `connection:${provider.id}` : `provider:${providerType}`; + let group = groupsByKey.get(id); + if (!group) { + group = { + id, + name: providerDisplayName(provider, providerType), + providerType, + providerId: connectionScoped ? provider.id : null, + providerIds: [], + connectionScoped, + models: [], + }; + groups.push(group); + groupsByKey.set(id, group); + } + group.providerIds.push(provider.id); + + for (const model of models) { + let merged = group.models.find((entry) => entry.upstreamModel === model.upstreamModel); + if (!merged) { + merged = { ...model, providerIds: [] }; + group.models.push(merged); + } + merged.providerIds.push(provider.id); + } } - for (const group of duplicateGroups.values()) { - if (group.length < 2) continue; - group.forEach((account, index) => { - account.connectionIndex = index + 1; - account.connectionCount = group.length; - }); + + for (const group of groups) { + group.accountCount = group.providerIds.length; + for (const model of group.models) model.accountCount = model.providerIds.length; } - return accounts; + return groups; } - function modelsForRouteAccount(accounts, accountId) { - return (accounts || []).find((account) => account.id === accountId)?.models || []; + function modelsForRouteProvider(providers, providerId) { + return (providers || []).find((provider) => provider.id === providerId)?.models || []; + } + + function routeMemberForProvider(provider, upstreamModel) { + if (!provider || !upstreamModel) return null; + return provider.connectionScoped + ? { providerId: provider.providerId, model: upstreamModel } + : { providerType: provider.providerType, model: upstreamModel }; + } + + function normalizeRouteMember(member, providers) { + const model = member?.model || member?.upstreamModel; + if (!model) return member; + if (member?.providerType) { + const type = canonicalProviderType(member.providerType); + const provider = (providers || []).find( + (entry) => !entry.connectionScoped && entry.providerType === type + ); + return provider ? routeMemberForProvider(provider, model) : { providerType: type, model }; + } + const provider = (providers || []).find((entry) => + entry.providerIds.includes(member?.providerId) + ); + return provider ? routeMemberForProvider(provider, model) : { ...member, model }; } function moveRouteMember(members, fromIndex, toIndex) { @@ -73,7 +141,13 @@ return members; } - const api = { buildRouteAccountOptions, modelsForRouteAccount, moveRouteMember }; + const api = { + buildRouteProviderOptions, + modelsForRouteProvider, + routeMemberForProvider, + normalizeRouteMember, + moveRouteMember, + }; if (typeof module !== "undefined" && module.exports) module.exports = api; if (root) root.ReroutedRoutePicker = api; })(typeof window !== "undefined" ? window : null); diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 456e021..029da91 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -1749,6 +1749,13 @@ summary:focus-visible, width: 100%; } +.route-picker-note { + margin-top: 8px; + color: var(--muted); + font-size: 9px; + line-height: 1.45; +} + .member-row { display: grid; grid-template-columns: 8px 24px minmax(0, 1fr) auto; diff --git a/tests/catalog-migration.test.js b/tests/catalog-migration.test.js index c2732cd..6e3ee30 100644 --- a/tests/catalog-migration.test.js +++ b/tests/catalog-migration.test.js @@ -86,7 +86,7 @@ describe("OAuth catalog migration", () => { combos: [], }); - assert.equal(cfg.version, 8); + assert.equal(cfg.version, 9); assert.equal( cfg.providers[0].models.some((model) => model.id === "claude-sonnet-4-6"), false @@ -193,7 +193,7 @@ describe("OAuth catalog migration", () => { combos: [], }); - assert.equal(migrated.version, 8); + assert.equal(migrated.version, 9); assert.deepEqual(migrated.providers[0].modelLocks, {}); assert.deepEqual(migrated.providers[1].modelLocks, { "*": oldLock }); @@ -216,4 +216,31 @@ describe("OAuth catalog migration", () => { assert.equal(next.providers[0].accountAlias, "oauth8"); assert.equal(next.providerAliasCounters.xai, 8); }); + + it("upgrades standard route members to provider/model destinations", () => { + const cfg = migrate({ + version: 8, + providers: [ + { id: "prov_xai_a", type: "xai", models: ["grok-4.5"] }, + { id: "prov_xai_b", type: "xai", models: ["grok-4.5"] }, + { id: "prov_custom", type: "openai-compat", name: "Lab", models: ["local-model"] }, + ], + combos: [ + { + id: "provider-first", + members: [ + { providerId: "prov_xai_a", model: "grok-4.5" }, + { providerId: "prov_xai_b", model: "grok-4.5" }, + { providerId: "prov_custom", model: "local-model" }, + ], + }, + ], + }); + + assert.equal(cfg.version, 9); + assert.deepEqual(cfg.combos[0].members, [ + { providerType: "xai", model: "grok-4.5" }, + { providerId: "prov_custom", model: "local-model" }, + ]); + }); }); diff --git a/tests/cli.test.js b/tests/cli.test.js index 6abf47f..2f999e4 100644 --- a/tests/cli.test.js +++ b/tests/cli.test.js @@ -11,7 +11,7 @@ const { createProcessLock, defaultUserData, } = require("../src/lib/headless-runtime"); -const { runFirstSetup } = require("../src/cli/setup"); +const { publicModelOptions, runFirstSetup } = require("../src/cli/setup"); const { verifyPassword } = require("../src/lib/password"); function memoryOutput() { @@ -27,6 +27,39 @@ function memoryOutput() { } describe("ReRouted CLI", () => { + it("builds route choices by provider and model instead of by account", () => { + const options = publicModelOptions({ + providers: [ + { + id: "prov_chatgpt_one", + type: "chatgpt", + name: "ChatGPT Plus", + enabled: true, + models: [{ id: "gpt-5.6", name: "GPT 5.6", enabled: true }], + }, + { + id: "prov_chatgpt_two", + type: "codex", + name: "ChatGPT Team", + enabled: true, + models: [{ id: "gpt-5.6", name: "GPT 5.6", enabled: true }], + }, + { + id: "prov_lab", + type: "openai-compat", + name: "Local Lab", + enabled: true, + models: [{ id: "lab-model", name: "Lab model", enabled: true }], + }, + ], + }); + + assert.deepEqual(options, [ + { label: "ChatGPT: GPT 5.6", providerType: "chatgpt", model: "gpt-5.6" }, + { label: "Local Lab: Lab model", providerId: "prov_lab", model: "lab-model" }, + ]); + }); + it("parses start options and validates network boundaries", () => { assert.deepEqual(parseArgs(["--host", "localhost", "--port", "5050", "--no-interactive"]), { command: "start", diff --git a/tests/route-picker.test.js b/tests/route-picker.test.js index fe6df8c..c11f11a 100644 --- a/tests/route-picker.test.js +++ b/tests/route-picker.test.js @@ -4,20 +4,23 @@ const assert = require("node:assert/strict"); const { describe, it } = require("node:test"); const { - buildRouteAccountOptions, - modelsForRouteAccount, + buildRouteProviderOptions, + modelsForRouteProvider, + routeMemberForProvider, + normalizeRouteMember, moveRouteMember, } = require("../src/renderer/route-picker"); describe("route member picker", () => { - it("exposes only enabled accounts with enabled models", () => { - const accounts = buildRouteAccountOptions([ + it("groups enabled OAuth accounts into one provider choice and merges their models", () => { + const providers = buildRouteProviderOptions([ { id: "prov_a", type: "chatgpt", - name: "ChatGPT", + name: "ChatGPT Plus", accountAlias: "oauth1", enabled: true, + hasToken: true, models: [ { id: "gpt-a", name: "GPT A", gatewayId: "chatgpt/gpt-a", enabled: true }, { id: "gpt-off", name: "GPT Off", enabled: false }, @@ -25,77 +28,135 @@ describe("route member picker", () => { }, { id: "prov_b", + type: "codex", + name: "ChatGPT Team", + accountAlias: "oauth2", + enabled: true, + hasToken: true, + models: [ + { id: "gpt-a", name: "GPT A", enabled: true }, + { id: "gpt-b", name: "GPT B", enabled: true }, + ], + }, + { + id: "prov_disabled", type: "claude", name: "Claude", enabled: false, + hasToken: true, models: [{ id: "claude-a", enabled: true }], }, { - id: "prov_empty", + id: "prov_signed_out", type: "xai", name: "xAI", enabled: true, - models: [{ id: "grok-off", enabled: false }], + hasToken: false, + models: [{ id: "grok-a", enabled: true }], + }, + ]); + + assert.deepEqual(providers, [ + { + id: "provider:chatgpt", + name: "ChatGPT", + providerType: "chatgpt", + providerId: null, + providerIds: ["prov_a", "prov_b"], + connectionScoped: false, + accountCount: 2, + models: [ + { + id: "gpt-a", + name: "GPT A", + gatewayId: "chatgpt/gpt-a", + upstreamModel: "gpt-a", + providerIds: ["prov_a", "prov_b"], + accountCount: 2, + }, + { + id: "gpt-b", + name: "GPT B", + gatewayId: null, + upstreamModel: "gpt-b", + providerIds: ["prov_b"], + accountCount: 1, + }, + ], }, + ]); + }); + + it("keeps custom endpoints distinct because a connection is its provider", () => { + const providers = buildRouteProviderOptions([ { - id: "prov_custom", + id: "prov_local", type: "openai-compat", name: "Local Lab", enabled: true, + hasToken: true, models: ["local-model"], }, + { + id: "prov_backup", + type: "custom", + name: "Backup Lab", + enabled: true, + hasToken: true, + models: ["backup-model"], + }, ]); assert.deepEqual( - accounts.map((account) => account.id), - ["prov_a", "prov_custom"] - ); - assert.deepEqual(accounts[0], { - id: "prov_a", - name: "ChatGPT", - accountAlias: "oauth1", - providerType: "chatgpt", - models: [ + providers.map(({ id, name, providerId, connectionScoped, models }) => ({ + id, + name, + providerId, + connectionScoped, + models: models.map((model) => model.upstreamModel), + })), + [ { - id: "gpt-a", - name: "GPT A", - gatewayId: "chatgpt/gpt-a", - providerId: "prov_a", - upstreamModel: "gpt-a", + id: "connection:prov_local", + name: "Local Lab", + providerId: "prov_local", + connectionScoped: true, + models: ["local-model"], }, - ], - }); - }); - - it("returns models only for the selected account", () => { - const accounts = buildRouteAccountOptions([ - { id: "prov_a", name: "A", models: ["model-a"] }, - { id: "prov_b", name: "B", models: ["model-b"] }, - ]); - - assert.deepEqual( - modelsForRouteAccount(accounts, "prov_b").map((model) => model.upstreamModel), - ["model-b"] + { + id: "connection:prov_backup", + name: "Backup Lab", + providerId: "prov_backup", + connectionScoped: true, + models: ["backup-model"], + }, + ] ); - assert.deepEqual(modelsForRouteAccount(accounts, "missing"), []); - assert.deepEqual(modelsForRouteAccount(accounts, null), []); }); - it("numbers duplicate keyed connections without exposing internal ids", () => { - const accounts = buildRouteAccountOptions([ - { id: "prov_private_a", type: "openrouter", name: "OpenRouter", models: ["a"] }, - { id: "prov_private_b", type: "openrouter", name: "OpenRouter", models: ["b"] }, + it("creates account-agnostic members and upgrades legacy account-specific members", () => { + const providers = buildRouteProviderOptions([ + { + id: "prov_a", + type: "xai", + name: "xAI", + enabled: true, + hasToken: true, + models: ["grok-4.5"], + }, ]); - + const provider = providers[0]; + assert.deepEqual(routeMemberForProvider(provider, "grok-4.5"), { + providerType: "xai", + model: "grok-4.5", + }); + assert.deepEqual(normalizeRouteMember({ providerId: "prov_a", model: "grok-4.5" }, providers), { + providerType: "xai", + model: "grok-4.5", + }); assert.deepEqual( - accounts.map(({ connectionIndex, connectionCount }) => ({ - connectionIndex, - connectionCount, - })), - [ - { connectionIndex: 1, connectionCount: 2 }, - { connectionIndex: 2, connectionCount: 2 }, - ] + modelsForRouteProvider(providers, "provider:xai").map((model) => model.upstreamModel), + ["grok-4.5"] ); }); diff --git a/tests/router-fallback.test.js b/tests/router-fallback.test.js index 80d7127..ec29d32 100644 --- a/tests/router-fallback.test.js +++ b/tests/router-fallback.test.js @@ -710,6 +710,126 @@ describe("same-provider OAuth account fallback", () => { assert.deepEqual(calls, ["token-b", "backup-key"]); }); + it("exhausts every account for a provider/model member before its route fallback", async () => { + const store = createStore(tmpConfig()); + store.seed({ + providers: [ + oauthAccount("prov_a", "token-a", 100), + oauthAccount("prov_b", "token-b", 200), + { + id: "prov_backup", + type: "openai-compat", + name: "Backup", + baseUrl: "https://backup.test/v1", + apiKey: "backup-key", + enabled: true, + models: [{ id: "backup-model", name: "Backup model", enabled: true }], + }, + ], + combos: [ + { + id: "provider-first", + name: "provider-first", + strategy: "fallback", + members: [ + { providerType: "xai", model: "grok-4.5" }, + { providerId: "prov_backup", model: "backup-model" }, + ], + }, + ], + }); + const calls = []; + const router = createRouter({ + store, + logger: captureLogger(), + fetchImpl: async (_url, options) => { + const token = authToken(options); + calls.push(token); + if (token === "backup-key") return successResponse("backup after account pool"); + return new Response(JSON.stringify({ error: { message: "quota exhausted" } }), { + status: 429, + headers: { "Content-Type": "application/json" }, + }); + }, + }); + + const resolved = router.resolveTargets(store.load(), "provider-first"); + assert.deepEqual( + resolved.members.map((member) => [member.upstreamModel, member.accounts.map((account) => account.id)]), + [ + ["grok-4.5", ["prov_a", "prov_b"]], + ["backup-model", ["prov_backup"]], + ] + ); + + const result = await router.chatCompletions({ + body: { + model: "provider-first", + messages: [{ role: "user", content: "hello" }], + stream: false, + }, + }); + + assert.equal(result.ok, true, JSON.stringify(result.error)); + assert.equal(result.openAiJson.choices[0].message.content, "backup after account pool"); + assert.deepEqual(calls, ["token-a", "token-b", "backup-key"]); + }); + + it("round-robins route members while retaining account fallback inside each member", async () => { + const store = createStore(tmpConfig()); + store.seed({ + providers: [ + oauthAccount("prov_a", "token-a", 100), + oauthAccount("prov_b", "token-b", 200), + { + id: "prov_backup", + type: "openai-compat", + name: "Backup", + baseUrl: "https://backup.test/v1", + apiKey: "backup-key", + enabled: true, + models: [{ id: "backup-model", name: "Backup model", enabled: true }], + }, + ], + combos: [ + { + id: "provider-round-robin", + name: "provider-round-robin", + strategy: "round-robin", + members: [ + { providerType: "xai", model: "grok-4.5" }, + { providerId: "prov_backup", model: "backup-model" }, + ], + }, + ], + }); + const calls = []; + const router = createRouter({ + store, + logger: captureLogger(), + fetchImpl: async (_url, options) => { + const token = authToken(options); + calls.push(token); + if (token === "backup-key") return successResponse("backup"); + return new Response(JSON.stringify({ error: { message: "quota exhausted" } }), { + status: 429, + headers: { "Content-Type": "application/json" }, + }); + }, + }); + + const first = await router.chatCompletions({ + body: { model: "provider-round-robin", messages: [{ role: "user", content: "one" }] }, + }); + const second = await router.chatCompletions({ + body: { model: "provider-round-robin", messages: [{ role: "user", content: "two" }] }, + }); + + assert.equal(first.ok, true, JSON.stringify(first.error)); + assert.equal(second.ok, true, JSON.stringify(second.error)); + assert.deepEqual(calls, ["token-a", "token-b", "backup-key", "backup-key"]); + }); + it("does not fall back or lock accounts after caller cancellation", async () => { const store = createStore(tmpConfig()); store.seed({