Skip to content

Commit fcc7cb7

Browse files
committed
Fix routing and provider workflows
1 parent 3815df1 commit fcc7cb7

24 files changed

Lines changed: 1141 additions & 49 deletions

docs/architecture.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ JSON request bodies are limited to 32 MiB. Oversized requests receive a JSON `41
5757

5858
## Model IDs and routes
5959

60-
Provider model IDs are generated by `src/lib/providers/index.js`. A direct model resolves to one enabled provider/model pair.
60+
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.
6161

6262
A route is a persisted virtual model with members shaped like:
6363

@@ -73,7 +73,7 @@ The router supports:
7373
- `fallback`: members are attempted in their configured order.
7474
- `round-robin`: each request rotates the starting member, then retains fallback behavior through the remaining members.
7575

76-
Timeouts, `408`, `429`, and `5xx` responses are retryable. The per-member timeout defaults to 60 seconds. An explicit route stops immediately when any account or member returns a non-retryable failure; a later account lock cannot replace that terminal response.
76+
Timeouts, `408`, `429`, `5xx`, and provider/model capability incompatibilities such as an unsupported model can advance fallback. Capability failures do not create account cooldown locks. The per-member timeout defaults to 60 seconds. An explicit route stops immediately on other non-retryable failures; a later account lock cannot replace that terminal response.
7777

7878
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.
7979

@@ -82,6 +82,7 @@ OAuth providers add an account-pool layer beneath model routing. Accounts receiv
8282
`src/lib/providers/index.js` selects an adapter by provider type.
8383

8484
- `openai-compat.js` handles OpenAI-shaped keyed services.
85+
- `cloudflare.js` discovers runnable Workers AI models through the paginated `/ai/models/search` API and delegates requests to Cloudflare's OpenAI-compatible chat endpoint.
8586
- `chatgpt.js` translates chat-completion requests to the ChatGPT Codex Responses surface and normalizes Responses SSE.
8687
- `claude.js` translates OpenAI messages and tools to Anthropic Messages, applies the current OAuth client contract, and converts JSON/SSE back to OpenAI shapes.
8788
- `antigravity.js` translates Gemini-style upstream requests and SSE.

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,6 +1,6 @@
11
{
22
"name": "rerouted",
3-
"version": "0.4.7",
3+
"version": "0.4.8",
44
"description": "A macOS menu-bar router for accounts, models, and automatic fallback.",
55
"author": "gitcommit90",
66
"license": "MIT",

scripts/capture-ui.js

Lines changed: 68 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const { app, BrowserWindow, ipcMain } = require("electron");
1212
const { createStore } = require("../src/lib/store");
1313
const { generateApiKey } = require("../src/lib/password");
1414
const { KEYED_PRESETS, ONBOARDING_STEPS, DEFAULT_PORT, OAUTH } = require("../src/lib/constants");
15-
const { defaultModelsForType } = require("../src/lib/providers");
15+
const { defaultModelsForType, listProviderModels } = require("../src/lib/providers");
1616
const { publicCombo } = require("../src/lib/combos");
1717
const packageInfo = require("../package.json");
1818

@@ -204,18 +204,26 @@ function seedOnboarded() {
204204
function registerIpc() {
205205
ipcMain.handle("app:get-state", async () => {
206206
const cfg = store.load();
207-
const publicProviders = (cfg.providers || []).map((p) => ({
208-
id: p.id,
209-
type: p.type,
210-
name: p.name,
211-
accountAlias: p.accountAlias || null,
212-
email: p.email,
213-
profileName: p.profileName,
214-
enabled: p.enabled !== false,
215-
hasToken: !!(p.accessToken || p.apiKey),
216-
models: p.models || defaultModelsForType(p.type),
217-
baseUrl: p.baseUrl,
218-
}));
207+
const publicProviders = (cfg.providers || []).map((p) => {
208+
const models = listProviderModels(p, { includeDisabled: true }).map((model) => ({
209+
id: model.upstreamModel,
210+
gatewayId: model.id,
211+
name: model.name,
212+
enabled: model.enabled !== false,
213+
}));
214+
return {
215+
id: p.id,
216+
type: p.type,
217+
name: p.name,
218+
accountAlias: p.accountAlias || null,
219+
email: p.email,
220+
profileName: p.profileName,
221+
enabled: p.enabled !== false,
222+
hasToken: !!(p.accessToken || p.apiKey),
223+
models: models.length ? models : defaultModelsForType(p.type),
224+
baseUrl: p.baseUrl,
225+
};
226+
});
219227
return {
220228
onboardingComplete: !!cfg.onboardingComplete,
221229
appVersion: packageInfo.version,
@@ -644,6 +652,53 @@ app.whenReady().then(async () => {
644652
}
645653
}
646654

655+
if (process.env.CAPTURE_ROUTE_PICKER_ONLY === "1") {
656+
await win.webContents.executeJavaScript(`
657+
(async () => {
658+
window.__rr_goto_page("combos");
659+
document.querySelector("button[data-edit]")?.click();
660+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
661+
let account = document.getElementById("c-add-account");
662+
let model = document.getElementById("c-add-model");
663+
let add = document.getElementById("btn-add-member");
664+
if (!account || !model?.disabled || !add?.disabled) {
665+
throw new Error("Route picker did not start with Model and Add disabled");
666+
}
667+
account.value = "prov_chatgpt_demo";
668+
account.dispatchEvent(new Event("change", { bubbles: true }));
669+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
670+
account = document.getElementById("c-add-account");
671+
model = document.getElementById("c-add-model");
672+
add = document.getElementById("btn-add-member");
673+
const modelOptions = [...model.options].filter((option) => option.value);
674+
if (account.value !== "prov_chatgpt_demo" || model.disabled || !modelOptions.length) {
675+
throw new Error("Selecting an account did not enable and populate its models");
676+
}
677+
model.value = modelOptions[1]?.value || modelOptions[0].value;
678+
model.dispatchEvent(new Event("change", { bubbles: true }));
679+
if (add.disabled) throw new Error("Selecting a model did not enable Add to route");
680+
return true;
681+
})()
682+
`);
683+
await capture("app-combos-editor.png", ".route-editor", ".route-editor");
684+
await win.webContents.executeJavaScript(`
685+
(() => {
686+
const before = document.querySelectorAll(".member-row").length;
687+
document.getElementById("btn-add-member")?.click();
688+
const after = document.querySelectorAll(".member-row").length;
689+
const account = document.getElementById("c-add-account");
690+
const model = document.getElementById("c-add-model");
691+
if (after !== before + 1 || account.value || !model.disabled) {
692+
throw new Error("Adding a route member did not reset the dependent picker");
693+
}
694+
return true;
695+
})()
696+
`);
697+
console.log("done", outDir);
698+
app.exit(0);
699+
return;
700+
}
701+
647702
await win.webContents.executeJavaScript(`
648703
(() => {
649704
window.__rr_goto_page("home");

src/lib/combos.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"use strict";
22

3+
const { isCustomProviderType, customProviderModelId } = require("./model-ids");
4+
35
function publicComboId(combo) {
46
const name = String(combo?.name || "").trim();
57
return name || String(combo?.id || "");
@@ -31,6 +33,9 @@ function providerRouteIds(providers) {
3133
ids.add(modelId.toLowerCase());
3234
ids.add(`${type}/${modelId}`.toLowerCase());
3335
ids.add(`${family}/${modelId}`.toLowerCase());
36+
if (isCustomProviderType(type)) {
37+
ids.add(customProviderModelId(provider, modelId).toLowerCase());
38+
}
3439
if (account) {
3540
ids.add(`${type}/${account}/${modelId}`.toLowerCase());
3641
ids.add(`${family}/${account}/${modelId}`.toLowerCase());

src/lib/keyed-provider-test.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,31 @@
22

33
const { runProviderModelTest } = require("./model-test");
44
const openaiCompat = require("./providers/openai-compat");
5+
const { getAdapter } = require("./providers");
56

67
async function testKeyedProvider(
7-
{ baseUrl, apiKey, modelId } = {},
8-
{ adapter = openaiCompat, logger } = {}
8+
{ baseUrl, apiKey, modelId, providerType } = {},
9+
{ adapter, logger } = {}
910
) {
1011
const finalBaseUrl = String(baseUrl || "").trim().replace(/\/+$/, "");
1112
const finalApiKey = String(apiKey || "").trim();
1213
const exactModelId = String(modelId || "").trim();
14+
const selectedAdapter = adapter || getAdapter(providerType) || openaiCompat;
1315

1416
if (!finalBaseUrl || !finalApiKey) {
1517
return { ok: false, error: "Base URL and API key required" };
1618
}
1719

1820
const provider = {
19-
type: "openai-compat",
21+
type: providerType || "openai-compat",
2022
name: "Custom",
2123
baseUrl: finalBaseUrl,
2224
apiKey: finalApiKey,
2325
};
2426

2527
if (exactModelId) {
2628
const result = await runProviderModelTest({
27-
adapter,
29+
adapter: selectedAdapter,
2830
provider,
2931
model: exactModelId,
3032
logger,
@@ -38,7 +40,7 @@ async function testKeyedProvider(
3840
}
3941

4042
try {
41-
const models = await adapter.listModels(provider);
43+
const models = await selectedAdapter.listModels(provider);
4244
return { ok: true, models, validation: "models" };
4345
} catch (error) {
4446
return { ok: false, error: error.message };

src/lib/model-ids.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"use strict";
2+
3+
function isCustomProviderType(type) {
4+
const value = String(type || "").toLowerCase();
5+
return value === "openai-compat" || value === "custom";
6+
}
7+
8+
function customConnectionName(provider) {
9+
return String(provider?.name || "").trim() || "Custom";
10+
}
11+
12+
function customProviderModelId(provider, model) {
13+
const modelId = typeof model === "string" ? model : model?.id;
14+
return `${customConnectionName(provider)}/custom/${modelId}`;
15+
}
16+
17+
function customModelRouteConflict(name, models = [], combos = []) {
18+
const routeIds = new Set(
19+
combos
20+
.map((combo) => String(combo?.name || combo?.id || "").trim().toLowerCase())
21+
.filter(Boolean)
22+
);
23+
const provider = { type: "openai-compat", name };
24+
return (
25+
models.find((model) => {
26+
const modelId = typeof model === "string" ? model : model?.id;
27+
return modelId && routeIds.has(customProviderModelId(provider, modelId).toLowerCase());
28+
}) || null
29+
);
30+
}
31+
32+
function ensureUniqueCustomConnectionNames(providers = [], combos = []) {
33+
const used = new Set();
34+
for (const provider of providers) {
35+
if (!isCustomProviderType(provider?.type)) continue;
36+
const base = customConnectionName(provider);
37+
let candidate = base;
38+
let suffix = 2;
39+
while (
40+
used.has(candidate.toLowerCase()) ||
41+
customModelRouteConflict(candidate, provider.models, combos)
42+
) {
43+
candidate = `${base} ${suffix}`;
44+
suffix += 1;
45+
}
46+
provider.name = candidate;
47+
used.add(candidate.toLowerCase());
48+
}
49+
return providers;
50+
}
51+
52+
function customConnectionNameError(name, providers = []) {
53+
const candidate = String(name || "").trim();
54+
if (!candidate) return "Custom connection name required";
55+
if (candidate.includes("/")) return "Custom connection names cannot contain /";
56+
const duplicate = providers.some(
57+
(provider) =>
58+
isCustomProviderType(provider?.type) &&
59+
customConnectionName(provider).toLowerCase() === candidate.toLowerCase()
60+
);
61+
return duplicate ? `A custom connection named ${candidate} already exists` : null;
62+
}
63+
64+
module.exports = {
65+
isCustomProviderType,
66+
customConnectionNameError,
67+
customConnectionName,
68+
customModelRouteConflict,
69+
customProviderModelId,
70+
ensureUniqueCustomConnectionNames,
71+
};

src/lib/providers/cloudflare.js

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"use strict";
2+
3+
const openaiCompat = require("./openai-compat");
4+
5+
const MODELS_TIMEOUT_MS = 15_000;
6+
const MODELS_PAGE_SIZE = 100;
7+
const MAX_MODEL_PAGES = 10;
8+
9+
function modelsSearchUrl(baseUrl, page = 1) {
10+
const base = String(baseUrl || "").trim().replace(/\/+$/, "");
11+
const url = new URL(`${base.replace(/\/v1$/, "")}/models/search`);
12+
url.searchParams.set("page", String(page));
13+
url.searchParams.set("per_page", String(MODELS_PAGE_SIZE));
14+
return url.toString();
15+
}
16+
17+
function taskName(model) {
18+
const task = model?.task;
19+
return String(typeof task === "string" ? task : task?.name || "")
20+
.trim()
21+
.toLowerCase()
22+
.replace(/[_-]+/g, " ");
23+
}
24+
25+
function isChatModel(model) {
26+
const task = taskName(model);
27+
return task === "text generation" || task === "text to text" || task.includes("chat");
28+
}
29+
30+
function cloudflareError(data) {
31+
const messages = (data?.errors || [])
32+
.map((error) => [error?.code, error?.message].filter(Boolean).join(": "))
33+
.filter(Boolean);
34+
return messages.join("; ") || "invalid Cloudflare models response";
35+
}
36+
37+
async function listModels(provider, { fetchImpl = fetch, timeoutMs = MODELS_TIMEOUT_MS } = {}) {
38+
const controller = new AbortController();
39+
let timer;
40+
const timeoutError = new Error(`models fetch timed out after ${timeoutMs}ms`);
41+
timeoutError.name = "TimeoutError";
42+
timeoutError.code = "ETIMEDOUT";
43+
const timeout = new Promise((_, reject) => {
44+
timer = setTimeout(() => {
45+
reject(timeoutError);
46+
controller.abort(timeoutError);
47+
}, timeoutMs);
48+
});
49+
const request = (async () => {
50+
const catalog = [];
51+
let reachedEnd = false;
52+
for (let page = 1; page <= MAX_MODEL_PAGES; page += 1) {
53+
const res = await fetchImpl(modelsSearchUrl(provider.baseUrl, page), {
54+
headers: {
55+
Authorization: `Bearer ${provider.apiKey}`,
56+
"Content-Type": "application/json",
57+
},
58+
signal: controller.signal,
59+
});
60+
if (!res.ok) {
61+
const text = await res.text().catch(() => "");
62+
const error = new Error(`models fetch failed: ${res.status} ${text}`);
63+
error.status = res.status;
64+
throw error;
65+
}
66+
const data = await res.json();
67+
if (data?.success === false || !Array.isArray(data?.result)) {
68+
throw new Error(`models fetch failed: ${cloudflareError(data)}`);
69+
}
70+
if (!data.result.length) {
71+
reachedEnd = true;
72+
break;
73+
}
74+
catalog.push(...data.result);
75+
}
76+
if (!reachedEnd) throw new Error("models fetch failed: Cloudflare catalog exceeded 10 pages");
77+
78+
const seen = new Set();
79+
return catalog
80+
.filter(isChatModel)
81+
.map((model) => String(model?.name || "").trim())
82+
.filter((id) => id && !seen.has(id) && seen.add(id))
83+
.map((id) => ({ id, name: id }));
84+
})();
85+
86+
try {
87+
return await Promise.race([request, timeout]);
88+
} finally {
89+
clearTimeout(timer);
90+
}
91+
}
92+
93+
async function chat(provider, options) {
94+
return openaiCompat.chat(provider, options);
95+
}
96+
97+
module.exports = { chat, listModels, modelsSearchUrl };

0 commit comments

Comments
 (0)