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
5 changes: 3 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ JSON request bodies are limited to 32 MiB. Oversized requests receive a JSON `41

## Model IDs and routes

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

Expand All @@ -73,7 +73,7 @@ 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.

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

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.

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

- `openai-compat.js` handles OpenAI-shaped keyed services.
- `cloudflare.js` discovers runnable Workers AI models through the paginated `/ai/models/search` API and delegates requests to Cloudflare's OpenAI-compatible chat endpoint.
- `chatgpt.js` translates chat-completion requests to the ChatGPT Codex Responses surface and normalizes Responses SSE.
- `claude.js` translates OpenAI messages and tools to Anthropic Messages, applies the current OAuth client contract, and converts JSON/SSE back to OpenAI shapes.
- `antigravity.js` translates Gemini-style upstream requests and SSE.
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,6 +1,6 @@
{
"name": "rerouted",
"version": "0.4.7",
"version": "0.4.8",
"description": "A macOS menu-bar router for accounts, models, and automatic fallback.",
"author": "gitcommit90",
"license": "MIT",
Expand Down
81 changes: 68 additions & 13 deletions scripts/capture-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const { app, BrowserWindow, ipcMain } = require("electron");
const { createStore } = require("../src/lib/store");
const { generateApiKey } = require("../src/lib/password");
const { KEYED_PRESETS, ONBOARDING_STEPS, DEFAULT_PORT, OAUTH } = require("../src/lib/constants");
const { defaultModelsForType } = require("../src/lib/providers");
const { defaultModelsForType, listProviderModels } = require("../src/lib/providers");
const { publicCombo } = require("../src/lib/combos");
const packageInfo = require("../package.json");

Expand Down Expand Up @@ -204,18 +204,26 @@ function seedOnboarded() {
function registerIpc() {
ipcMain.handle("app:get-state", async () => {
const cfg = store.load();
const publicProviders = (cfg.providers || []).map((p) => ({
id: p.id,
type: p.type,
name: p.name,
accountAlias: p.accountAlias || null,
email: p.email,
profileName: p.profileName,
enabled: p.enabled !== false,
hasToken: !!(p.accessToken || p.apiKey),
models: p.models || defaultModelsForType(p.type),
baseUrl: p.baseUrl,
}));
const publicProviders = (cfg.providers || []).map((p) => {
const models = listProviderModels(p, { includeDisabled: true }).map((model) => ({
id: model.upstreamModel,
gatewayId: model.id,
name: model.name,
enabled: model.enabled !== false,
}));
return {
id: p.id,
type: p.type,
name: p.name,
accountAlias: p.accountAlias || null,
email: p.email,
profileName: p.profileName,
enabled: p.enabled !== false,
hasToken: !!(p.accessToken || p.apiKey),
models: models.length ? models : defaultModelsForType(p.type),
baseUrl: p.baseUrl,
};
});
return {
onboardingComplete: !!cfg.onboardingComplete,
appVersion: packageInfo.version,
Expand Down Expand Up @@ -644,6 +652,53 @@ app.whenReady().then(async () => {
}
}

if (process.env.CAPTURE_ROUTE_PICKER_ONLY === "1") {
await win.webContents.executeJavaScript(`
(async () => {
window.__rr_goto_page("combos");
document.querySelector("button[data-edit]")?.click();
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
let account = document.getElementById("c-add-account");
let model = document.getElementById("c-add-model");
let add = document.getElementById("btn-add-member");
if (!account || !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 }));
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
account = document.getElementById("c-add-account");
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");
}
model.value = modelOptions[1]?.value || modelOptions[0].value;
model.dispatchEvent(new Event("change", { bubbles: true }));
if (add.disabled) throw new Error("Selecting a model did not enable Add to route");
return true;
})()
`);
await capture("app-combos-editor.png", ".route-editor", ".route-editor");
await win.webContents.executeJavaScript(`
(() => {
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 model = document.getElementById("c-add-model");
if (after !== before + 1 || account.value || !model.disabled) {
throw new Error("Adding a route member did not reset the dependent picker");
}
return true;
})()
`);
console.log("done", outDir);
app.exit(0);
return;
}

await win.webContents.executeJavaScript(`
(() => {
window.__rr_goto_page("home");
Expand Down
5 changes: 5 additions & 0 deletions src/lib/combos.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"use strict";

const { isCustomProviderType, customProviderModelId } = require("./model-ids");

function publicComboId(combo) {
const name = String(combo?.name || "").trim();
return name || String(combo?.id || "");
Expand Down Expand Up @@ -31,6 +33,9 @@ function providerRouteIds(providers) {
ids.add(modelId.toLowerCase());
ids.add(`${type}/${modelId}`.toLowerCase());
ids.add(`${family}/${modelId}`.toLowerCase());
if (isCustomProviderType(type)) {
ids.add(customProviderModelId(provider, modelId).toLowerCase());
}
if (account) {
ids.add(`${type}/${account}/${modelId}`.toLowerCase());
ids.add(`${family}/${account}/${modelId}`.toLowerCase());
Expand Down
12 changes: 7 additions & 5 deletions src/lib/keyed-provider-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,31 @@

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

async function testKeyedProvider(
{ baseUrl, apiKey, modelId } = {},
{ adapter = openaiCompat, logger } = {}
{ baseUrl, apiKey, modelId, providerType } = {},
{ adapter, logger } = {}
) {
const finalBaseUrl = String(baseUrl || "").trim().replace(/\/+$/, "");
const finalApiKey = String(apiKey || "").trim();
const exactModelId = String(modelId || "").trim();
const selectedAdapter = adapter || getAdapter(providerType) || openaiCompat;

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

const provider = {
type: "openai-compat",
type: providerType || "openai-compat",
name: "Custom",
baseUrl: finalBaseUrl,
apiKey: finalApiKey,
};

if (exactModelId) {
const result = await runProviderModelTest({
adapter,
adapter: selectedAdapter,
provider,
model: exactModelId,
logger,
Expand All @@ -38,7 +40,7 @@ async function testKeyedProvider(
}

try {
const models = await adapter.listModels(provider);
const models = await selectedAdapter.listModels(provider);
return { ok: true, models, validation: "models" };
} catch (error) {
return { ok: false, error: error.message };
Expand Down
71 changes: 71 additions & 0 deletions src/lib/model-ids.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"use strict";

function isCustomProviderType(type) {
const value = String(type || "").toLowerCase();
return value === "openai-compat" || value === "custom";
}

function customConnectionName(provider) {
return String(provider?.name || "").trim() || "Custom";
}

function customProviderModelId(provider, model) {
const modelId = typeof model === "string" ? model : model?.id;
return `${customConnectionName(provider)}/custom/${modelId}`;
}

function customModelRouteConflict(name, models = [], combos = []) {
const routeIds = new Set(
combos
.map((combo) => String(combo?.name || combo?.id || "").trim().toLowerCase())
.filter(Boolean)
);
const provider = { type: "openai-compat", name };
return (
models.find((model) => {
const modelId = typeof model === "string" ? model : model?.id;
return modelId && routeIds.has(customProviderModelId(provider, modelId).toLowerCase());
}) || null
);
}

function ensureUniqueCustomConnectionNames(providers = [], combos = []) {
const used = new Set();
for (const provider of providers) {
if (!isCustomProviderType(provider?.type)) continue;
const base = customConnectionName(provider);
let candidate = base;
let suffix = 2;
while (
used.has(candidate.toLowerCase()) ||
customModelRouteConflict(candidate, provider.models, combos)
) {
candidate = `${base} ${suffix}`;
suffix += 1;
}
provider.name = candidate;
used.add(candidate.toLowerCase());
}
return providers;
}

function customConnectionNameError(name, providers = []) {
const candidate = String(name || "").trim();
if (!candidate) return "Custom connection name required";
if (candidate.includes("/")) return "Custom connection names cannot contain /";
const duplicate = providers.some(
(provider) =>
isCustomProviderType(provider?.type) &&
customConnectionName(provider).toLowerCase() === candidate.toLowerCase()
);
return duplicate ? `A custom connection named ${candidate} already exists` : null;
}

module.exports = {
isCustomProviderType,
customConnectionNameError,
customConnectionName,
customModelRouteConflict,
customProviderModelId,
ensureUniqueCustomConnectionNames,
};
97 changes: 97 additions & 0 deletions src/lib/providers/cloudflare.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"use strict";

const openaiCompat = require("./openai-compat");

const MODELS_TIMEOUT_MS = 15_000;
const MODELS_PAGE_SIZE = 100;
const MAX_MODEL_PAGES = 10;

function modelsSearchUrl(baseUrl, page = 1) {
const base = String(baseUrl || "").trim().replace(/\/+$/, "");
const url = new URL(`${base.replace(/\/v1$/, "")}/models/search`);
url.searchParams.set("page", String(page));
url.searchParams.set("per_page", String(MODELS_PAGE_SIZE));
return url.toString();
}

function taskName(model) {
const task = model?.task;
return String(typeof task === "string" ? task : task?.name || "")
.trim()
.toLowerCase()
.replace(/[_-]+/g, " ");
}

function isChatModel(model) {
const task = taskName(model);
return task === "text generation" || task === "text to text" || task.includes("chat");
}

function cloudflareError(data) {
const messages = (data?.errors || [])
.map((error) => [error?.code, error?.message].filter(Boolean).join(": "))
.filter(Boolean);
return messages.join("; ") || "invalid Cloudflare models response";
}

async function listModels(provider, { fetchImpl = fetch, timeoutMs = MODELS_TIMEOUT_MS } = {}) {
const controller = new AbortController();
let timer;
const timeoutError = new Error(`models fetch timed out after ${timeoutMs}ms`);
timeoutError.name = "TimeoutError";
timeoutError.code = "ETIMEDOUT";
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => {
reject(timeoutError);
controller.abort(timeoutError);
}, timeoutMs);
});
const request = (async () => {
const catalog = [];
let reachedEnd = false;
for (let page = 1; page <= MAX_MODEL_PAGES; page += 1) {
const res = await fetchImpl(modelsSearchUrl(provider.baseUrl, page), {
headers: {
Authorization: `Bearer ${provider.apiKey}`,
"Content-Type": "application/json",
},
signal: controller.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
const error = new Error(`models fetch failed: ${res.status} ${text}`);
error.status = res.status;
throw error;
}
const data = await res.json();
if (data?.success === false || !Array.isArray(data?.result)) {
throw new Error(`models fetch failed: ${cloudflareError(data)}`);
}
if (!data.result.length) {
reachedEnd = true;
break;
}
catalog.push(...data.result);
}
if (!reachedEnd) throw new Error("models fetch failed: Cloudflare catalog exceeded 10 pages");

const seen = new Set();
return catalog
.filter(isChatModel)
.map((model) => String(model?.name || "").trim())
.filter((id) => id && !seen.has(id) && seen.add(id))
.map((id) => ({ id, name: id }));
})();

try {
return await Promise.race([request, timeout]);
} finally {
clearTimeout(timer);
}
}

async function chat(provider, options) {
return openaiCompat.chat(provider, options);
}

module.exports = { chat, listModels, modelsSearchUrl };
Loading
Loading