diff --git a/docs/architecture.md b/docs/architecture.md index 92e921d..f5b1e34 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 `/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: @@ -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. @@ -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. diff --git a/package-lock.json b/package-lock.json index 57ef4a5..b2afe98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rerouted", - "version": "0.4.7", + "version": "0.4.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rerouted", - "version": "0.4.7", + "version": "0.4.8", "license": "MIT", "devDependencies": { "@electron/osx-sign": "1.3.3", diff --git a/package.json b/package.json index 6f1ed4d..850db43 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/capture-ui.js b/scripts/capture-ui.js index 5a3fbeb..0997697 100644 --- a/scripts/capture-ui.js +++ b/scripts/capture-ui.js @@ -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"); @@ -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, @@ -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"); diff --git a/src/lib/combos.js b/src/lib/combos.js index 4018039..78d5095 100644 --- a/src/lib/combos.js +++ b/src/lib/combos.js @@ -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 || ""); @@ -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()); diff --git a/src/lib/keyed-provider-test.js b/src/lib/keyed-provider-test.js index 67181c0..5ff658e 100644 --- a/src/lib/keyed-provider-test.js +++ b/src/lib/keyed-provider-test.js @@ -2,21 +2,23 @@ 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, @@ -24,7 +26,7 @@ async function testKeyedProvider( if (exactModelId) { const result = await runProviderModelTest({ - adapter, + adapter: selectedAdapter, provider, model: exactModelId, logger, @@ -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 }; diff --git a/src/lib/model-ids.js b/src/lib/model-ids.js new file mode 100644 index 0000000..81697ab --- /dev/null +++ b/src/lib/model-ids.js @@ -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, +}; diff --git a/src/lib/providers/cloudflare.js b/src/lib/providers/cloudflare.js new file mode 100644 index 0000000..28944fb --- /dev/null +++ b/src/lib/providers/cloudflare.js @@ -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 }; diff --git a/src/lib/providers/index.js b/src/lib/providers/index.js index e190042..395c10d 100644 --- a/src/lib/providers/index.js +++ b/src/lib/providers/index.js @@ -1,18 +1,20 @@ "use strict"; const openaiCompat = require("./openai-compat"); +const cloudflare = require("./cloudflare"); const claude = require("./claude"); const chatgpt = require("./chatgpt"); const antigravity = require("./antigravity"); const xai = require("./xai"); const { OAUTH } = require("../constants"); const { canonicalProviderType, isOAuthProvider } = require("../store"); +const { isCustomProviderType, customProviderModelId } = require("../model-ids"); const TYPE_ADAPTER = { "openai-compat": openaiCompat, openrouter: openaiCompat, nvidia: openaiCompat, - cloudflare: openaiCompat, + cloudflare, glm: openaiCompat, custom: openaiCompat, claude, @@ -26,19 +28,17 @@ function getAdapter(type) { return TYPE_ADAPTER[type] || null; } -/** - * Resolve models exposed for a provider account (prefix with provider id for uniqueness - * when multiple accounts exist — unless single-account simple id is enough). - * Model ids in the gateway are: for combos = combo id; for providers = - * `/` or if label set `: