/** * OmniRoute Host plugin. * * Routes (all behind the connection trust fence, relative to the web origin): * GET /omniroute/status effective settings, key presence, last import * POST /omniroute/config save settings (+ optional write-only API key) * POST /omniroute/test probe the gateway with the given or saved credentials * POST /omniroute/import import models/combos into the llm-pi-ai route * GET /omniroute/quotas provider quotas (?model= narrows to that model's provider) * * Non-secret settings live in /omniroute.json; the API key lives in the * credential store under `keyRef` and never leaves the Host. */ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import crypto from 'node:crypto' export const name = 'omniroute' export const inject = ['webServer', 'connection', 'credentials', 'settings'] const LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] const LEVEL_ALIASES = { none: 'off', minimum: 'minimal', min: 'minimal', extra_high: 'xhigh', 'x-high': 'xhigh', maximum: 'max' } const NON_CHAT_TYPES = new Set(['embedding', 'image', 'rerank', 'audio', 'moderation', 'video', 'music', 'tts', 'stt']) const DEFAULTS = { url: '', keyRef: 'OMNIROUTE_API_KEY', provider: 'omni', discovery: true, updateOnly: false, include: '', exclude: '', discoverThinking: true, imported: {}, usage: true, pollSec: 120, syncMin: 60, lastImport: null, } const MAX_BODY = 64 * 1024 const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : undefined) function stateFile() { const dir = process.env.DSH_PROFILE_DIR || path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'profiles', 'web') return path.join(dir, 'omniroute.json') } function loadState() { try { const raw = JSON.parse(fs.readFileSync(stateFile(), 'utf8')) return { ...DEFAULTS, ...(isObj(raw) ? raw : {}) } } catch { return { ...DEFAULTS } } } function saveState(state) { const file = stateFile() const tmp = `${file}.${process.pid}.tmp` fs.writeFileSync(tmp, JSON.stringify(state, null, 2)) fs.renameSync(tmp, file) } /** Gateway root without trailing slashes or a trailing /v1 segment. */ function normalizeUrl(input) { let url = String(input ?? '').trim().replace(/\/+$/, '') url = url.replace(/\/v1$/i, '') return url } function sendJson(res, status, payload) { res.statusCode = status res.setHeader('content-type', 'application/json; charset=utf-8') res.setHeader('cache-control', 'no-store') res.end(JSON.stringify(payload)) } async function readJsonBody(req) { const chunks = [] let size = 0 for await (const chunk of req) { size += chunk.byteLength if (size > MAX_BODY) { req.resume(); throw new Error('request body too large') } chunks.push(chunk) } if (size === 0) return {} const body = JSON.parse(Buffer.concat(chunks, size).toString('utf8')) if (!isObj(body)) throw new Error('body must be a JSON object') return body } /** Fetch JSON from the gateway; throws an Error carrying `.status`. */ async function gateway(url, key, pathname, timeoutMs = 20000) { const res = await fetch(`${url}${pathname}`, { headers: { accept: 'application/json', ...(key ? { authorization: `Bearer ${key}` } : {}) }, signal: AbortSignal.timeout(timeoutMs), }) const text = await res.text() let json try { json = text ? JSON.parse(text) : undefined } catch { json = undefined } if (!res.ok) { const msg = json?.error?.message ?? json?.error ?? json?.message ?? text.slice(0, 200) ?? res.statusText const err = new Error(typeof msg === 'string' ? msg : JSON.stringify(msg)) err.status = res.status throw err } return json } // ---------------------------------------------------------------- model mapping function pickBool(...values) { for (const v of values) if (typeof v === 'boolean') return v return undefined } // What a model offers when OmniRoute says it thinks but publishes no explicit tier list. const FALLBACK_TIERS = ['low', 'medium', 'high'] function levelOf(item) { const raw = isObj(item) ? (item.id ?? item.name ?? item.value) : item const level = String(raw ?? '').trim().toLowerCase().replace(/[\s_-]+/g, '') const canon = LEVEL_ALIASES[level] ?? level return LEVELS.includes(canon) ? canon : undefined } /** * Collect every thinking level the gateway publishes for one entry: effort tiers, * supported-effort lists, and per-level capability maps. Order follows escalation order. */ export function discoverEfforts(entry) { const caps = isObj(entry.capabilities) ? entry.capabilities : {} const found = new Set() const add = (raw) => { if (Array.isArray(raw)) raw.forEach((item) => { const l = levelOf(item); if (l) found.add(l) }) else if (isObj(raw)) Object.keys(raw).forEach((k) => { const l = levelOf(k); if (l) found.add(l) }) } for (const source of [ entry.effort_tiers, entry.supported_thinking_efforts, entry.supportedThinkingEfforts, entry.reasoning_efforts, entry.thinking_levels, caps.effort_tiers, caps.supported_thinking_efforts, caps.supportedThinkingEfforts, caps.reasoning_efforts, caps.thinking_levels, ]) add(source) return LEVELS.filter((l) => found.has(l)) } // Claude models accept the two top tiers (`xhigh` = "extra", `max`) even when the gateway // only lists low/medium/high, so reasoning-capable Claude models always offer them. const CLAUDE_TOP_TIERS = ['xhigh', 'max'] export function isClaude(entry) { const hay = [entry?.id, entry?.owned_by, entry?.provider].map((v) => String(v ?? '').toLowerCase()).join(' ') return /claude|anthropic/.test(hay) } function withClaudeTiers(entry, efforts) { if (!efforts || !isClaude(entry)) return efforts const levels = LEVELS.filter((l) => l in efforts || CLAUDE_TOP_TIERS.includes(l)) return Object.fromEntries(levels.map((l) => [l, l in efforts ? efforts[l] : l])) } function mapEfforts(entry) { const levels = discoverEfforts(entry) if (levels.length === 0) return undefined const out = {} for (const level of levels) out[level] = level === 'off' ? null : level return out } /** Convert one OmniRoute /v1/models (or /v1/combos) entry into a pi-ai model profile; null to skip. */ export function toModelProfile(entry) { if (!isObj(entry) || typeof entry.id !== 'string' || entry.id.length === 0) return null if (typeof entry.type === 'string' && NON_CHAT_TYPES.has(entry.type)) return null const caps = isObj(entry.capabilities) ? entry.capabilities : {} const inMods = (Array.isArray(entry.input_modalities) ? entry.input_modalities : []).map((m) => String(m).toLowerCase()) const outMods = (Array.isArray(entry.output_modalities) ? entry.output_modalities : []).map((m) => String(m).toLowerCase()) if (outMods.length > 0 && !outMods.includes('text')) return null const vision = pickBool(entry.vision, caps.vision) ?? (inMods.length > 0 ? inMods.includes('image') : undefined) const input = ['text'] if (vision === true || inMods.includes('image')) input.push('image') const profile = { id: entry.id, name: String(entry.name ?? entry.display_name ?? entry.id), input } const ctxLen = num(entry.context_length) ?? num(entry.max_input_tokens) ?? num(caps.context_length) const maxOut = num(entry.max_output_tokens) ?? num(caps.max_output_tokens) if (ctxLen) profile.contextWindow = ctxLen if (maxOut) profile.maxTokens = maxOut const thinking = pickBool(entry.thinking, entry.supportsThinking, caps.thinking, caps.supportsThinking) const efforts = mapEfforts(entry) if (efforts) profile.reasoningEfforts = withClaudeTiers(entry, efforts) else if (thinking === true || caps.reasoning === true) profile.reasoningEfforts = withClaudeTiers(entry, Object.fromEntries(FALLBACK_TIERS.map((l) => [l, l]))) else if (thinking === false) profile.reasoningEfforts = false return profile } export function buildModelList(models, combos) { const byId = new Map() for (const entry of Array.isArray(models) ? models : []) { const p = toModelProfile(entry) if (p) byId.set(p.id, p) } // /v1/combos carries combo capability metadata; fill gaps without overriding catalog data. for (const combo of Array.isArray(combos) ? combos : []) { const id = typeof combo?.id === 'string' ? combo.id : typeof combo?.name === 'string' ? combo.name : undefined if (!id) continue const p = toModelProfile({ ...combo, id }) if (!p) continue const prev = byId.get(p.id) byId.set(p.id, prev ? { ...p, ...prev, input: prev.input.length >= p.input.length ? prev.input : p.input } : p) } return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id)) } // ---------------------------------------------------------------- filters /** Turn a comma/space separated pattern list ("claude*, gemini-*") into a matcher; empty list matches everything. */ export function makeMatcher(spec) { const parts = String(spec ?? '').split(/[\s,]+/).map((x) => x.trim().toLowerCase()).filter(Boolean) if (parts.length === 0) return null const res = parts.map((p) => new RegExp('^' + p.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$')) return (id) => res.some((re) => re.test(String(id).toLowerCase())) } /** Keep incoming profiles that match `include` (when set) and do not match `exclude`. */ export function filterIncoming(models, include, exclude) { const inc = makeMatcher(include) const exc = makeMatcher(exclude) return models.filter((m) => (!inc || inc(m.id)) && !(exc && exc(m.id))) } // ---------------------------------------------------------------- merge /** * Merge freshly discovered profiles into the user's existing model list. * - A name the user changed is never overwritten (a name still equal to what we imported last time may refresh). * - Fields we do not manage (compat, headers, ...) survive. * - updateOnly: only existing ids are touched; nothing is added or removed. * - Otherwise new ids are added, and ids we imported before that vanished from the gateway are dropped; * entries the user added by hand are always kept. * @returns {{models: object[], added: number, updated: number, removed: number}} */ export function mergeModels(existing, incoming, opts) { const { updateOnly, discoverThinking, imported } = opts const prior = Array.isArray(existing) ? existing.filter((m) => isObj(m) && typeof m.id === 'string') : [] const byId = new Map(incoming.map((m) => [m.id, m])) let added = 0 let updated = 0 let removed = 0 const out = [] for (const old of prior) { const fresh = byId.get(old.id) if (!fresh) { if (!updateOnly && Object.prototype.hasOwnProperty.call(imported, old.id)) { removed++; continue } out.push(old) continue } const keepName = typeof old.name === 'string' && old.name.length > 0 && old.name !== imported[old.id] const next = { ...old, ...fresh, name: keepName ? old.name : fresh.name } if (!discoverThinking) { if (old.reasoningEfforts === undefined) delete next.reasoningEfforts else next.reasoningEfforts = old.reasoningEfforts } else if (fresh.reasoningEfforts === undefined && old.reasoningEfforts !== undefined) { next.reasoningEfforts = old.reasoningEfforts } if (JSON.stringify(next) !== JSON.stringify(old)) updated++ out.push(next) } if (!updateOnly) { const known = new Set(prior.map((m) => m.id)) for (const m of incoming) { if (known.has(m.id)) continue const fresh = discoverThinking || m.reasoningEfforts === undefined ? m : (({ reasoningEfforts, ...rest }) => rest)(m) out.push(fresh) added++ } } return { models: out, added, updated, removed } } // ---------------------------------------------------------------- quota mapping export function mapQuotas(usage) { const quotas = isObj(usage?.quotas) ? usage.quotas : {} const out = [] for (const [key, q] of Object.entries(quotas)) { if (!isObj(q)) continue const total = typeof q.total === 'number' ? q.total : undefined const used = typeof q.used === 'number' ? q.used : undefined let remainingPct = typeof q.remainingPercentage === 'number' ? q.remainingPercentage : undefined if (remainingPct === undefined && total && total > 0) { const remaining = typeof q.remaining === 'number' ? q.remaining : used !== undefined ? total - used : undefined if (remaining !== undefined) remainingPct = (remaining / total) * 100 } out.push({ key, label: String(q.displayName ?? key), remainingPct: remainingPct === undefined ? null : Math.max(0, Math.min(100, Math.round(remainingPct * 10) / 10)), used: used ?? null, total: total ?? null, unlimited: q.unlimited === true, resetAt: typeof q.resetAt === 'string' ? q.resetAt : null, }) } return out } /** * Merge the same quota window across several accounts of one provider into a single entry * with the average percent left. Unlimited accounts do not pull the average; a window is * unlimited only when every account's is. The reset shown is the soonest upcoming one. * @param {Array<{id:string,provider:string,name:string,plan:string|null,quotas:object[]}>} accounts */ export function mergeQuotas(accounts) { const groups = new Map() for (const acct of accounts) { for (const q of acct.quotas) { const gk = `${String(acct.provider).toLowerCase()}\u0000${String(q.key).trim().toLowerCase()}` let g = groups.get(gk) if (!g) { g = { key: q.key, provider: acct.provider, label: q.label, items: [] }; groups.set(gk, g) } g.items.push({ account: acct.name, plan: acct.plan, ...q }) } } const now = Date.now() return [...groups.values()].map((g) => { const finite = g.items.filter((i) => !i.unlimited && typeof i.remainingPct === 'number') const unlimited = g.items.every((i) => i.unlimited) const avg = finite.length > 0 ? finite.reduce((a, i) => a + i.remainingPct, 0) / finite.length : null const resets = g.items.map((i) => Date.parse(i.resetAt)).filter((t) => Number.isFinite(t)) const upcoming = resets.filter((t) => t > now) const reset = upcoming.length > 0 ? Math.min(...upcoming) : resets.length > 0 ? Math.max(...resets) : null return { key: g.key, provider: g.provider, label: g.label, accounts: g.items.length, remainingPct: avg === null ? null : Math.round(avg * 10) / 10, unlimited, resetAt: reset === null ? null : new Date(reset).toISOString(), items: g.items.map((i) => ({ account: i.account, remainingPct: i.unlimited ? null : i.remainingPct, unlimited: i.unlimited, used: i.used, total: i.total, resetAt: i.resetAt, })), } }) } // ---------------------------------------------------------------- plugin export function apply(ctx) { let state = loadState() const persist = (patch) => { state = { ...state, ...patch } saveState(state) } /** The llm-pi-ai provider row this plugin imports into (undefined when not configured). */ function providerRow() { try { const doc = ctx.settings.describe({ redactSecrets: true }).find((d) => d.ns === 'llm-pi-ai') const row = doc?.value?.providers?.[state.provider] return { revision: doc?.revision, row: isObj(row) ? row : undefined, present: doc !== undefined } } catch { return { present: false } } } async function resolveKey(draftKey) { if (draftKey) return draftKey const refs = [state.keyRef, providerRow().row?.apiKeyEnv].filter((r) => typeof r === 'string' && r) for (const ref of refs) { const hit = await ctx.credentials.resolve(ref) if (hit?.value) return hit.value } return undefined } function effectiveUrl(draftUrl) { const fromProvider = providerRow().row?.baseURL return normalizeUrl(draftUrl || state.url || fromProvider || '') } async function describeKey() { const refs = [state.keyRef, providerRow().row?.apiKeyEnv].filter((r) => typeof r === 'string' && r) for (const ref of refs) { const info = await ctx.credentials.describe(ref) if (info?.configured) return { configured: true, ref, source: info.source } } return { configured: false, ref: state.keyRef } } // ---- import ------------------------------------------------------------ let importing = null async function importModels(mode) { if (importing) return importing // `mode` is a one-shot override of the saved toggle: 'update' (existing entries only) or 'full'. const updateOnly = mode === 'update' ? true : mode === 'full' ? false : state.updateOnly importing = (async () => { const url = effectiveUrl() const key = await resolveKey() if (!url) throw new Error('OmniRoute URL is not set') try { const modelsJson = await gateway(url, key, '/v1/models', 30000) let combosJson try { combosJson = await gateway(url, key, '/v1/combos', 15000) } catch { combosJson = undefined } const all = buildModelList(modelsJson?.data, combosJson?.data) if (all.length === 0) throw new Error('OmniRoute returned no chat models') // The filter decides what may be ADDED; existing entries are always refreshed by update mode. const models = updateOnly ? all : filterIncoming(all, state.include, state.exclude) if (models.length === 0) throw new Error('The include/exclude filter matches no models') const { revision, row, present } = providerRow() if (!present) throw new Error('the llm-pi-ai plugin is not mounted; cannot write models') if (updateOnly && (!row || !Array.isArray(row.models) || row.models.length === 0)) { throw new Error('There are no existing model entries to update yet. Use Re-discover models for the first import.') } const imported = isObj(state.imported) ? state.imported : {} const merged = mergeModels(row?.models, models, { updateOnly, discoverThinking: state.discoverThinking, imported }) if (updateOnly && (merged.added > 0 || merged.removed > 0)) throw new Error('internal error: update-only import tried to add or remove models; nothing was written') const changed = merged.added + merged.updated + merged.removed > 0 || !row if (changed) { const ops = row ? [{ op: 'set', path: ['providers', state.provider, 'models'], value: merged.models }] : [{ op: 'set', path: ['providers', state.provider], value: { displayName: 'OmniRoute', api: 'openai-completions', baseURL: `${url}/v1`, apiKeyEnv: state.keyRef, models: merged.models, }, }] await ctx.settings.mutate('llm-pi-ai', ops, revision) } // Remember what we imported (id -> name) so later runs can tell a user rename from our own value. const importedNext = updateOnly ? imported : Object.fromEntries(models.map((m) => [m.id, m.name])) const thinking = models.filter((m) => isObj(m.reasoningEfforts)).length const result = { at: new Date().toISOString(), count: merged.models.length, added: merged.added, updated: merged.updated, removed: merged.removed, thinking, changed, error: null, } persist({ lastImport: result, imported: importedNext }) return result } catch (error) { const result = { at: new Date().toISOString(), count: 0, changed: false, error: String(error?.message ?? error) } persist({ lastImport: result }) throw error } })().finally(() => { importing = null }) return importing } // ---- quotas ------------------------------------------------------------ const cache = { catalog: { at: 0, owners: new Map(), aliases: new Map() }, combos: { at: 0, list: [] }, usage: new Map() } /** id -> owned_by for every model, plus alias -> canonical provider from `alias/model` ids. */ async function catalog(url, key) { if (Date.now() - cache.catalog.at < 5 * 60_000) return cache.catalog const json = await gateway(url, key, '/v1/models', 30000) const owners = new Map() const aliases = new Map() for (const m of Array.isArray(json?.data) ? json.data : []) { if (typeof m?.id !== 'string' || typeof m.owned_by !== 'string') continue owners.set(m.id, m.owned_by) const slash = m.id.indexOf('/') if (slash > 0 && m.owned_by !== 'combo') aliases.set(m.id.slice(0, slash).toLowerCase(), m.owned_by) } cache.catalog = { at: Date.now(), owners, aliases } return cache.catalog } async function combosList(url, key) { if (Date.now() - cache.combos.at < 5 * 60_000) return cache.combos.list let list = [] try { // The management API caps `limit` at 200, so page through it. for (let offset = 0; offset < 5000; offset += 200) { const json = await gateway(url, key, `/api/combos?limit=200&offset=${offset}`, 20000) const page = Array.isArray(json?.combos) ? json.combos : Array.isArray(json) ? json : [] list = list.concat(page) if (page.length < 200 || (typeof json?.total === 'number' && list.length >= json.total)) break } } catch (error) { // Fall back to the API-key-safe listing, which may omit some routing detail. const json = await gateway(url, key, '/v1/combos', 20000).catch(() => { throw error }) list = Array.isArray(json?.data) ? json.data : [] } cache.combos = { at: Date.now(), list } return list } /** * Which provider connections can serve `modelId`? * @returns {{providers:Set, connections:Set}} lower-cased provider ids and explicit connection ids */ async function servingTargets(url, key, modelId) { const out = { providers: new Set(), connections: new Set(), tokens: new Set(), steps: 0 } const cat = await catalog(url, key) const canon = (raw) => { const p = String(raw ?? '').toLowerCase() return (cat.aliases.get(p) ?? p).toLowerCase() } const addStep = (step, seen) => { out.steps++ if (typeof step === 'string') { const i = step.indexOf('/') if (i > 0) out.providers.add(canon(step.slice(0, i))) return } if (!isObj(step)) return if (step.kind === 'combo-ref' || (typeof step.comboName === 'string' && !step.model)) { walk(step.comboName, seen) return } let provider = step.providerId ?? step.provider if (!provider && typeof step.model === 'string' && step.model.includes('/')) provider = step.model.slice(0, step.model.indexOf('/')) if (!provider) return out.tokens.add(String(provider)) const scoped = [step.connectionId, ...(Array.isArray(step.allowedConnectionIds) ? step.allowedConnectionIds : [])].filter(Boolean) if (scoped.length > 0) scoped.forEach((id) => out.connections.add(String(id))) else out.providers.add(canon(provider)) } let combos = [] const walk = async (name, seen = new Set()) => { if (!name || seen.has(name)) return seen.add(name) const combo = combos.find((c) => c?.name === name || c?.id === name) if (!combo) return for (const step of Array.isArray(combo.models) ? combo.models : []) { if (isObj(step) && (step.kind === 'combo-ref' || (typeof step.comboName === 'string' && !step.model))) await walk(step.comboName, seen) else addStep(step, seen) } } const owner = cat.owners.get(modelId) if (owner && owner !== 'combo') { out.providers.add(canon(owner)) return out } // A combo (or an unknown id): resolve its steps through the management API. combos = await combosList(url, key) await walk(modelId) return out } async function usageFor(url, key, id) { const hit = cache.usage.get(id) if (hit && Date.now() - hit.at < 45_000) return hit.value const value = await gateway(url, key, `/api/usage/${encodeURIComponent(id)}`, 25000) cache.usage.set(id, { at: Date.now(), value }) return value } /** Quotas for the selected model only; nothing at all unless the model is served by this plugin's provider. */ async function quotas(providerId, modelId) { if (!providerId || !modelId || providerId !== state.provider) return { providers: [], scope: 'foreign-model' } const url = effectiveUrl() const key = await resolveKey() if (!url || !key) return { providers: [], error: 'not-configured' } let connections try { const json = await gateway(url, key, '/api/providers?limit=200', 20000) connections = (Array.isArray(json?.connections) ? json.connections : []).filter((c) => c && c.isActive !== false) } catch (error) { if (error.status === 401 || error.status === 403) { return { providers: [], error: 'management-auth', message: 'This API key has no management access, so quotas cannot be read.' } } throw error } let serving try { serving = await servingTargets(url, key, modelId) } catch (error) { return { providers: [], scope: 'unresolved', message: String(error?.message ?? error) } } if (serving.providers.size === 0 && serving.connections.size === 0) { return { providers: [], scope: 'unresolved', message: serving.steps === 0 ? `Could not read the steps of "${modelId}" (is the key allowed to read combos?)` : `No provider found for the steps of "${modelId}"` } } const targets = connections .filter((c) => serving.providers.has(String(c.provider).toLowerCase()) || serving.connections.has(String(c.id))) .slice(0, 16) const settled = await Promise.allSettled(targets.map((c) => usageFor(url, key, c.id))) const connectionNames = new Set(connections.map((c) => String(c.provider).toLowerCase())) const providers = targets.map((c, i) => { const r = settled[i] const usage = r.status === 'fulfilled' ? r.value : undefined return { id: c.id, provider: c.provider, name: c.name ?? c.email ?? c.provider, plan: typeof usage?.plan === 'string' ? usage.plan : null, message: r.status === 'rejected' ? String(r.reason?.message ?? r.reason) : typeof usage?.message === 'string' ? usage.message : null, quotas: usage ? mapQuotas(usage) : [], } }).filter((p) => p.quotas.length > 0) if (providers.length === 0) { const wanted = [...serving.providers].join(', ') const have = [...connectionNames].join(', ') return { providers: [], scope: 'no-quota-data', message: targets.length === 0 ? `Combo uses [${wanted}] but no matching active connection exists (connections: ${have}).` : `Providers [${wanted}] report no quota data.` } } return { providers, merged: mergeQuotas(providers) } } // ---- routes ------------------------------------------------------------ const route = (pathname, methods, handler) => ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: pathname, handler: async (req, res) => { if (ctx.connection.requestRejection(req) !== undefined) { sendJson(res, ctx.connection.requestRejection(req), { code: 'rejected' }) return } if (!methods.includes(req.method)) { res.statusCode = 405 res.setHeader('allow', methods.join(', ')) res.end() return } try { sendJson(res, 200, await handler(req)) } catch (error) { sendJson(res, error?.status && error.status < 600 && error.status >= 400 ? 502 : 500, { code: 'error', message: String(error?.message ?? error), }) } }, }), `omniroute: ${pathname}`) route('/omniroute/status', ['GET'], async () => ({ api: 4, // 2 = import honours one-shot modes ('update' never adds or removes entries) url: state.url, effectiveUrl: effectiveUrl(), provider: state.provider, discovery: state.discovery, updateOnly: state.updateOnly, include: state.include, exclude: state.exclude, discoverThinking: state.discoverThinking, usage: state.usage, pollSec: state.pollSec, syncMin: state.syncMin, key: await describeKey(), lastImport: state.lastImport, })) route('/omniroute/config', ['POST'], async (req) => { const body = await readJsonBody(req) const patch = {} if (typeof body.url === 'string') patch.url = normalizeUrl(body.url) if (typeof body.provider === 'string' && body.provider.trim()) patch.provider = body.provider.trim() if (typeof body.discovery === 'boolean') patch.discovery = body.discovery if (typeof body.usage === 'boolean') patch.usage = body.usage if (typeof body.updateOnly === 'boolean') patch.updateOnly = body.updateOnly if (typeof body.include === 'string') patch.include = body.include.slice(0, 2000) if (typeof body.exclude === 'string') patch.exclude = body.exclude.slice(0, 2000) if (typeof body.discoverThinking === 'boolean') patch.discoverThinking = body.discoverThinking if (typeof body.pollSec === 'number') patch.pollSec = Math.max(30, Math.min(3600, Math.floor(body.pollSec))) if (typeof body.syncMin === 'number') patch.syncMin = Math.max(0, Math.min(1440, Math.floor(body.syncMin))) if (typeof body.apiKey === 'string' && body.apiKey.length > 0) await ctx.credentials.set(state.keyRef, body.apiKey) if (body.clearKey === true) await ctx.credentials.unset(state.keyRef) persist(patch) cache.catalog.at = 0 cache.combos.at = 0 cache.usage.clear() scheduleSync() return { ok: true } }) route('/omniroute/test', ['POST'], async (req) => { const body = await readJsonBody(req) const url = effectiveUrl(typeof body.url === 'string' ? normalizeUrl(body.url) : '') const key = await resolveKey(typeof body.apiKey === 'string' ? body.apiKey : '') if (!url) return { ok: false, message: 'Enter the OmniRoute URL first.' } const started = Date.now() let models try { const json = await gateway(url, key, '/v1/models', 20000) models = buildModelList(json?.data, undefined) } catch (error) { const hint = error.status === 401 || error.status === 403 ? ' (check the API key)' : '' return { ok: false, message: `${String(error.message)}${hint}` } } let management = false let connections = 0 try { const json = await gateway(url, key, '/api/providers?limit=200', 15000) management = true connections = Array.isArray(json?.connections) ? json.connections.length : 0 } catch { management = false } return { ok: true, models: models.length, management, connections, ms: Date.now() - started } }) /** Models currently in the provider, flagged by whether this plugin imported them. */ route('/omniroute/models', ['GET'], async () => { const { row } = providerRow() const imported = isObj(state.imported) ? state.imported : {} const list = Array.isArray(row?.models) ? row.models : [] return { provider: state.provider, total: list.length, models: list.filter((m) => isObj(m) && typeof m.id === 'string').map((m) => ({ id: m.id, name: typeof m.name === 'string' ? m.name : m.id, imported: Object.prototype.hasOwnProperty.call(imported, m.id), })), } }) /** * Remove models from the provider. * { ids: [...] } remove exactly these ids * { keep: [...] } remove every plugin-imported model not listed (hand-added entries are never touched) * Models referenced by the default model or the subagent allow-list are refused. */ route('/omniroute/prune', ['POST'], async (req) => { const body = await readJsonBody(req) const { revision, row, present } = providerRow() if (!present || !Array.isArray(row?.models)) throw new Error('the provider has no models to prune') const imported = isObj(state.imported) ? state.imported : {} const isImported = (id) => Object.prototype.hasOwnProperty.call(imported, id) let doomed if (Array.isArray(body.ids)) { const want = new Set(body.ids.map(String)) doomed = row.models.filter((m) => want.has(m.id)).map((m) => m.id) } else if (Array.isArray(body.keep)) { const keep = new Set(body.keep.map(String)) doomed = row.models.filter((m) => isImported(m.id) && !keep.has(m.id)).map((m) => m.id) } else throw new Error('send { ids: [...] } or { keep: [...] }') // Never remove a model the profile still points at (default model, subagent allow-list). const inUse = new Set() try { for (const d of ctx.settings.describe({ redactSecrets: true })) { const v = d?.value if (d.ns === 'agent-default-model' && v?.provider === state.provider && typeof v.model === 'string') inUse.add(v.model) for (const m of Array.isArray(v?.allowedModels) ? v.allowedModels : []) { if (m?.provider === state.provider && typeof m.model === 'string') inUse.add(m.model) } } } catch { /* settings unavailable: fall through, mutate will still validate */ } const blocked = doomed.filter((id) => inUse.has(id)) doomed = doomed.filter((id) => !inUse.has(id)) if (doomed.length === 0) return { removed: 0, remaining: row.models.length, protected: blocked } const gone = new Set(doomed) const remaining = row.models.filter((m) => !gone.has(m.id)) if (remaining.length === 0) throw new Error('Refusing to remove every model from the provider.') await ctx.settings.mutate('llm-pi-ai', [{ op: 'set', path: ['providers', state.provider, 'models'], value: remaining }], revision) const nextImported = { ...imported } for (const id of doomed) delete nextImported[id] persist({ imported: nextImported }) return { removed: doomed.length, remaining: remaining.length, protected: blocked } }) route('/omniroute/import', ['POST'], async (req) => { const body = await readJsonBody(req) const mode = body.mode === 'update' || body.mode === 'full' ? body.mode : undefined return importModels(mode) }) route('/omniroute/quotas', ['GET'], async (req) => { if (!state.usage) return { disabled: true, providers: [] } const params = new URL(req.url ?? '/', 'http://x').searchParams return quotas(params.get('provider') || undefined, params.get('model') || undefined) }) // ---- periodic discovery ------------------------------------------------ // settings.mutate runs through the HMR queue, which rejects calls made inside the // async context of a reload. Timers created in apply() inherit that context, so the // scheduler pokes a token-guarded loopback route whose handler runs in a clean one. const token = crypto.randomUUID() ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/omniroute/internal-sync', handler: async (req, res) => { const addr = req.socket.remoteAddress ?? '' const loopback = addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1' if (!loopback || req.headers['x-omniroute-token'] !== token) { res.statusCode = 404; res.end(); return } try { sendJson(res, 200, await importModels()) } catch (error) { sendJson(res, 500, { message: String(error?.message ?? error) }) } }, }), 'omniroute: internal sync') const pokeSync = async () => { const host = ctx.webServer.host === '0.0.0.0' ? '127.0.0.1' : ctx.webServer.host const res = await fetch(`http://${host}:${ctx.webServer.port}/omniroute/internal-sync`, { headers: { 'x-omniroute-token': token }, signal: AbortSignal.timeout(60_000), }) if (!res.ok) throw new Error(`sync failed (${res.status})`) } let timer function scheduleSync() { clearInterval(timer) timer = undefined if (state.discovery && state.syncMin > 0) { timer = setInterval(() => { pokeSync().catch(() => {}) }, state.syncMin * 60_000) } } ctx.effect(() => { scheduleSync() let boot let attempt = 0 const bootImport = () => { boot = undefined if (!state.discovery || !effectiveUrl()) return pokeSync().catch(() => { if (++attempt < 5) boot = setTimeout(bootImport, 15_000 * attempt) }) } boot = setTimeout(bootImport, 8000) return () => { clearTimeout(boot); clearInterval(timer) } }, 'omniroute: discovery scheduler') }