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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
24 changes: 21 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,23 +71,41 @@ 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 `<connection name>/custom/<upstream model>` 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.
- `round-robin`: each request rotates the starting member, then retains fallback behavior through the remaining members.

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

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
18 changes: 9 additions & 9 deletions scripts/capture-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand All @@ -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;
Expand Down
18 changes: 14 additions & 4 deletions src/cli/setup.js
Original file line number Diff line number Diff line change
@@ -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,
});
}
}
Expand Down Expand Up @@ -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,
})),
});
Expand Down
7 changes: 6 additions & 1 deletion src/lib/control-plane.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
26 changes: 23 additions & 3 deletions src/lib/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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)
)
Expand Down Expand Up @@ -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;
Expand Down
24 changes: 20 additions & 4 deletions src/lib/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down
Loading
Loading