Skip to content

Commit 78561f0

Browse files
committed
feat: route by provider and model
1 parent 06c8de0 commit 78561f0

17 files changed

Lines changed: 581 additions & 152 deletions

CHANGELOG.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ Release tags use the form `vX.Y.Z` and match `package.json`. GitHub Releases car
99

1010
## [Unreleased]
1111

12+
## [0.5.7] - 2026-07-20
13+
14+
### Changed
15+
16+
- 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.
17+
- Existing standard-provider route members migrate to provider/model destinations; custom OpenAI-compatible endpoints remain connection-specific.
18+
1219
## [0.5.6] - 2026-07-20
1320

1421
### Fixed
@@ -81,7 +88,8 @@ Release tags use the form `vX.Y.Z` and match `package.json`. GitHub Releases car
8188

8289
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.
8390

84-
[Unreleased]: https://github.com/gitcommit90/rerouted/compare/v0.5.5...HEAD
91+
[Unreleased]: https://github.com/gitcommit90/rerouted/compare/v0.5.7...HEAD
92+
[0.5.7]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.7
8593
[0.5.5]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.5
8694
[0.5.4]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.4
8795
[0.5.3]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.3

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ Timeouts and retryable `408`, `429`, and `5xx` responses can advance the route.
8989
- **API-key presets:** OpenRouter, NVIDIA NIM, Cloudflare, and GLM Coding.
9090
- **Custom upstreams:** any service that exposes the OpenAI chat-completions shape ReRouted expects.
9191
- **Local credential discovery:** supported credentials already stored in known files, or in the macOS Keychain where available, can be imported instead of re-entered.
92-
- **Multiple accounts:** connect more than one account for the same provider and use shared or account-specific model routes.
92+
- **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.
9393

9494
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.
9595

@@ -192,7 +192,7 @@ ReRouted accepts both `/v1/messages` and `/v1/v1/messages`, so current Claude Co
192192

193193
- **Status:** gateway health, endpoint, latest route, and recent traffic.
194194
- **Accounts:** OAuth sessions, imported credentials, API keys, and model availability.
195-
- **Routes:** named fallback or round-robin model groups with explicit ordering controls.
195+
- **Routes:** named fallback or round-robin provider/model groups with explicit ordering controls; same-provider accounts stay inside the member’s automatic fallback pool.
196196
- **Activity:** requests, failures, token counts, route choices, and account usage.
197197
- **Quota:** provider-specific subscription windows where supported.
198198
- **Settings:** gateway keys, localhost or network binding, security controls, and platform-appropriate startup/update information.

docs/architecture.md

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,23 +71,41 @@ Anthropic Messages requests are normalized into the same internal OpenAI chat-co
7171

7272
Provider model IDs are generated by `src/lib/providers/index.js`. Custom OpenAI-compatible connections use the readable form `<connection name>/custom/<upstream model>` while legacy hash-qualified IDs remain resolvable. A direct model resolves to one enabled provider/model pair.
7373

74-
A route is a persisted virtual model with members shaped like:
74+
A route is a persisted virtual model. Standard providers are represented by a
75+
provider/model destination, not by an individual credential:
7576

7677
```json
7778
{
78-
"providerId": "prov_...",
79+
"providerType": "chatgpt",
7980
"model": "upstream-model-id"
8081
}
8182
```
8283

84+
Custom OpenAI-compatible connections remain connection-specific because their
85+
base URLs can represent different services, so those members retain a
86+
`providerId`.
87+
8388
The router supports:
8489

8590
- `fallback`: members are attempted in their configured order.
8691
- `round-robin`: each request rotates the starting member, then retains fallback behavior through the remaining members.
8792

8893
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.
8994

90-
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.
95+
Standard providers add an account-pool layer beneath model routing. The route
96+
editor and terminal setup select Provider, then Model; individual OAuth or
97+
API-key accounts are internal to that member. Every eligible account for the
98+
selected provider/model is tried before routing advances to the next member.
99+
Round robin rotates these outer members only, retaining the account-pool retry
100+
inside each one. OAuth accounts receive monotonic, never-reused aliases
101+
(`oauth1`, `oauth2`, ...). Model discovery advertises one canonical pooled id
102+
such as `chatgpt/gpt-5.4`; account-qualified ids such as
103+
`chatgpt/oauth2/gpt-5.4` and legacy stored-account ids remain resolvable but
104+
are not advertised. Quota failures create an account-wide lock using provider
105+
reset hints when available; authentication and transient failures use shorter
106+
model-scoped cooldowns. Early streaming quota events are inspected before the
107+
client stream starts so fallback can still occur. Selection, failure, fallback,
108+
locked-account skips, and terminal exhaustion are written as structured logs.
91109

92110
## Provider adapters
93111

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@gitcommit90/rerouted",
33
"productName": "ReRouted",
4-
"version": "0.5.6",
4+
"version": "0.5.7",
55
"description": "A local AI router for connected accounts, models, named routes, and automatic fallback.",
66
"author": "gitcommit90",
77
"license": "MIT",

scripts/capture-ui.js

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -654,21 +654,21 @@ app.whenReady().then(async () => {
654654
window.__rr_goto_page("combos");
655655
document.querySelector("button[data-edit-index]")?.click();
656656
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
657-
let account = document.getElementById("c-add-account");
657+
let provider = document.getElementById("c-add-provider");
658658
let model = document.getElementById("c-add-model");
659659
let add = document.getElementById("btn-add-member");
660-
if (!account || !model?.disabled || !add?.disabled) {
660+
if (!provider || !model?.disabled || !add?.disabled) {
661661
throw new Error("Route picker did not start with Model and Add disabled");
662662
}
663-
account.value = "prov_chatgpt_demo";
664-
account.dispatchEvent(new Event("change", { bubbles: true }));
663+
provider.value = "provider:chatgpt";
664+
provider.dispatchEvent(new Event("change", { bubbles: true }));
665665
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
666-
account = document.getElementById("c-add-account");
666+
provider = document.getElementById("c-add-provider");
667667
model = document.getElementById("c-add-model");
668668
add = document.getElementById("btn-add-member");
669669
const modelOptions = [...model.options].filter((option) => option.value);
670-
if (account.value !== "prov_chatgpt_demo" || model.disabled || !modelOptions.length) {
671-
throw new Error("Selecting an account did not enable and populate its models");
670+
if (provider.value !== "provider:chatgpt" || model.disabled || !modelOptions.length) {
671+
throw new Error("Selecting a provider did not enable and populate its models");
672672
}
673673
model.value = modelOptions[1]?.value || modelOptions[0].value;
674674
model.dispatchEvent(new Event("change", { bubbles: true }));
@@ -682,9 +682,9 @@ app.whenReady().then(async () => {
682682
const before = document.querySelectorAll(".member-row").length;
683683
document.getElementById("btn-add-member")?.click();
684684
const after = document.querySelectorAll(".member-row").length;
685-
const account = document.getElementById("c-add-account");
685+
const provider = document.getElementById("c-add-provider");
686686
const model = document.getElementById("c-add-model");
687-
if (after !== before + 1 || account.value || !model.disabled) {
687+
if (after !== before + 1 || provider.value || !model.disabled) {
688688
throw new Error("Adding a route member did not reset the dependent picker");
689689
}
690690
return true;

src/cli/setup.js

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
11
"use strict";
22

33
const { KEYED_PRESETS } = require("../lib/constants");
4+
const { isCustomProviderType } = require("../lib/model-ids");
45

56
const OAUTH_NOTICE =
67
"Subscription OAuth sessions are not officially licensed for router use and may carry account risk.";
78

89
function publicModelOptions(state) {
910
const options = [];
11+
const seen = new Set();
1012
for (const provider of state.providers || []) {
1113
if (provider.enabled === false) continue;
14+
const providerType = provider.type === "codex" ? "chatgpt" : provider.type;
15+
const connectionScoped = isCustomProviderType(provider.type);
1216
for (const model of provider.models || []) {
1317
if (model.enabled === false) continue;
18+
const modelId = model.id;
19+
const key = `${connectionScoped ? provider.id : providerType}::${modelId}`;
20+
if (seen.has(key)) continue;
21+
seen.add(key);
1422
options.push({
15-
label: `${provider.name}${provider.accountAlias ? ` · ${provider.accountAlias}` : ""}: ${model.name || model.id}`,
16-
providerId: provider.id,
17-
model: model.id,
23+
label: `${connectionScoped ? provider.name : providerType === "chatgpt" ? "ChatGPT" : provider.name}: ${model.name || modelId}`,
24+
...(connectionScoped ? { providerId: provider.id } : { providerType }),
25+
model: modelId,
1826
});
1927
}
2028
}
@@ -131,7 +139,9 @@ async function createRoute(prompts, invoke, output) {
131139
name,
132140
strategy: strategyIndex === 1 ? "round-robin" : "fallback",
133141
members: indexes.map((index) => ({
134-
providerId: models[index].providerId,
142+
...(models[index].providerType
143+
? { providerType: models[index].providerType }
144+
: { providerId: models[index].providerId }),
135145
model: models[index].model,
136146
})),
137147
});

src/lib/control-plane.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,9 +386,14 @@ handle("app:test-keyed-provider", async (_e, payload) => {
386386

387387
handle("app:remove-provider", async (_e, id) => {
388388
store.update((cfg) => {
389+
const removed = cfg.providers.find((p) => p.id === id);
389390
cfg.providers = cfg.providers.filter((p) => p.id !== id);
390391
for (const c of cfg.combos) {
391-
c.members = (c.members || []).filter((m) => m.providerId !== id);
392+
// Provider/model members are account-agnostic. Removing one account
393+
// leaves the route intact when another account for that provider remains.
394+
if (isCustomProviderType(removed?.type)) {
395+
c.members = (c.members || []).filter((m) => m?.providerId !== id);
396+
}
392397
}
393398
});
394399
return { ok: true };

src/lib/router.js

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const { extractUsage } = require("./usage");
1515
const appLogger = require("./logger");
1616
const { canonicalProviderType, isOAuthProvider, getActiveModelLock } = require("./store");
1717
const { publicComboId, comboMatchesId } = require("./combos");
18+
const { isCustomProviderType } = require("./model-ids");
1819
const { createSseParser } = require("./sse");
1920
const ANTHROPIC_METADATA = Symbol.for("rerouted.anthropic.metadata");
2021

@@ -60,14 +61,20 @@ function providerHasModel(provider, upstreamModel) {
6061
);
6162
}
6263

64+
function isAccountPoolProvider(provider) {
65+
return !!provider && !isCustomProviderType(provider.type);
66+
}
67+
6368
function accountCandidatesFor(cfg, preferredProvider, upstreamModel, { preferRequested = true } = {}) {
64-
if (!preferredProvider || !isOAuthProvider(preferredProvider)) return preferredProvider ? [preferredProvider] : [];
69+
if (!preferredProvider || !isAccountPoolProvider(preferredProvider)) {
70+
return preferredProvider ? [preferredProvider] : [];
71+
}
6572
const family = canonicalProviderType(preferredProvider.type);
6673
const candidates = (cfg.providers || [])
6774
.filter(
6875
(provider) =>
6976
provider.enabled !== false &&
70-
isOAuthProvider(provider) &&
77+
isAccountPoolProvider(provider) &&
7178
canonicalProviderType(provider.type) === family &&
7279
providerHasModel(provider, upstreamModel)
7380
)
@@ -95,11 +102,24 @@ function resolveTargets(cfg, modelId) {
95102
if (typeof m === "string") {
96103
return resolveSingle(cfg, m);
97104
}
105+
const mid = m.model || m.upstreamModel;
106+
if (m.providerType) {
107+
const family = canonicalProviderType(m.providerType);
108+
const prov = (cfg.providers || [])
109+
.filter(
110+
(p) =>
111+
p.enabled !== false &&
112+
isAccountPoolProvider(p) &&
113+
canonicalProviderType(p.type) === family &&
114+
providerHasModel(p, mid)
115+
)
116+
.sort(compareAccounts)[0];
117+
return prov ? makeMember(cfg, prov, mid, { preferRequested: false }) : null;
118+
}
98119
const prov = (cfg.providers || []).find((p) => p.id === m.providerId);
99120
if (!prov || prov.enabled === false) return null;
100121
// Skip disabled models on the provider
101122
const models = prov.models || [];
102-
const mid = m.model || m.upstreamModel;
103123
const modelEntry = models.find((x) => (typeof x === "string" ? x : x.id) === mid);
104124
if (modelEntry && typeof modelEntry !== "string" && modelEntry.enabled === false) {
105125
return null;

src/lib/store.js

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ const {
1010
OAUTH_MODEL_RENAMES,
1111
} = require("./constants");
1212
const { ensureUniqueComboNames, providerRouteIds } = require("./combos");
13-
const { ensureUniqueCustomConnectionNames } = require("./model-ids");
13+
const { ensureUniqueCustomConnectionNames, isCustomProviderType } = require("./model-ids");
1414
const { backfillTokenIdentity } = require("./oauth-identity");
1515

16-
const CONFIG_VERSION = 8;
16+
const CONFIG_VERSION = 9;
1717
const COMBO_NAME_MIGRATION_VERSION = 5;
1818
const XAI_LOCK_RESET_VERSION = 6;
1919
const RETIRED_OAUTH_CLEANUP_VERSION = 8;
@@ -233,12 +233,28 @@ function migrate(cfg) {
233233
ensureUniqueCustomConnectionNames(cfg.providers, cfg.combos);
234234
const providerById = new Map((cfg.providers || []).map((provider) => [provider.id, provider]));
235235
for (const combo of cfg.combos) {
236-
for (const member of combo.members || []) {
236+
const seenMembers = new Set();
237+
combo.members = (combo.members || []).flatMap((member) => {
238+
if (!member || typeof member !== "object") return [member];
237239
const provider = providerById.get(member.providerId);
238240
const family = canonicalProviderType(provider?.type);
239241
const rename = OAUTH_MODEL_RENAMES[family]?.[member.model];
240242
if (rename) member.model = rename;
241-
}
243+
// Named routes describe a provider/model destination. Credentials are an
244+
// implementation detail: every matching account is tried before the
245+
// route advances. Custom endpoints remain connection-scoped because
246+
// their base URLs can be different services.
247+
if (provider && !isCustomProviderType(provider.type)) {
248+
delete member.providerId;
249+
member.providerType = family;
250+
} else if (member.providerType) {
251+
member.providerType = canonicalProviderType(member.providerType);
252+
}
253+
const key = `${member.providerType || member.providerId || ""}::${member.model || member.upstreamModel || ""}`;
254+
if (!key || seenMembers.has(key)) return [];
255+
seenMembers.add(key);
256+
return [member];
257+
});
242258
}
243259
if (needsComboNameMigration) {
244260
ensureUniqueComboNames(cfg.combos, providerRouteIds(cfg.providers));

0 commit comments

Comments
 (0)