commit 45310dd2a441b932e9d508e506c8d235cc964cc1 Author: Agent Date: Sat Oct 10 17:37:32 2026 +0000 Initial commit: omniroute-plugin DSH plugin with docs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb38758 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.bak +*.orig +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..3a0acfe --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# omniroute-plugin + +OmniRoute gateway integration for the DeepSeek Harness (DSH): **model auto-import** (context window, max output, modalities, thinking levels) and **provider quota tracking** under the composer. + +## Features + +- **Model discovery / import**: pulls models and combos from your OmniRoute gateway into an `llm-pi-ai` provider (default id `omni`, created if missing), with context window, output limit, input modalities and supported thinking levels (`off/minimal/low/medium/high/xhigh/max`). +- **Periodic re-import** (`syncMin`, default 60 min; 0 = manual only) and an *update existing models only* mode. +- **Include / exclude filters**: comma-separated glob patterns (`*`, `?`) on model ids, e.g. `claude*, gemini-*`; exclude always wins. +- **Manage imported models**: search, select and prune models from the provider. +- **Quota tracking**: provider quotas (5-hour, weekly, monthly, …) shown in the composer dock, refreshed every `pollSec` seconds (default 120); requires a management-scoped API key. +- English and Chinese UI. + +## Files + +| File | Role | +| --- | --- | +| `index.js` | Host plugin. Injects `webServer`, `connection`, `credentials`, `settings`. | +| `client.js` | Web client: settings panel (`plugins.bundle.config`) and quota dock (`conversation.composer.dock`). | +| `locale/*.json` | Plugin title/description (en, zh). | +| `cordis.patch.yml` | Bundle patch inserting the `omniroute` row. | + +### Host routes (relative to the Web origin, behind the connection trust fence) + +| Route | Purpose | +| --- | --- | +| `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 given or saved credentials | +| `GET /omniroute/models` | Models currently imported | +| `POST /omniroute/prune` | Remove selected imported models | +| `POST /omniroute/import` | Import models/combos into the `llm-pi-ai` provider | +| `GET /omniroute/quotas` | Provider quotas (`?model=` narrows to that model's provider) | + +## Installation + +Requirements: a working DeepSeek Harness (`dsh`) install with the **web** profile (or any profile that includes the Web UI). + +### Option A — install from git (recommended) + +```bash +dsh plugin --profile web add git+https://git.rizzy.dev/DSH-Plugins/omniroute-plugin.git +``` + +You can also paste `git+https://git.rizzy.dev/DSH-Plugins/omniroute-plugin.git` into **Settings → Plugins → Install** in the Web UI, or ask the agent to install it with its `plugin_manager` tool (`install_bundle`). + +The plugin manager adds `@local/omniroute` to `dependencies` and to `dsh.profile.bundles` in `~/.dsh/profiles/web/package.json`. + +### Option B — local checkout (for development) + +```bash +git clone https://git.rizzy.dev/DSH-Plugins/omniroute-plugin.git ~/dsh-plugins/omniroute-plugin +dsh plugin --profile web add link:$HOME/dsh-plugins/omniroute-plugin +``` + +With a `link:` install, edits in the checkout are picked up directly (client changes reload on page refresh, Host changes after a DSH restart or HMR reload). + +### Enable / verify + +1. Make sure `@local/omniroute` is listed in `dsh.profile.bundles` of `~/.dsh/profiles/web/package.json` (the installer does this; add it by hand if you edited `package.json` manually). +2. Restart DSH (`dsh web`) or reload the page. +3. Check **Settings → Plugins**: the `@local/omniroute` bundle should be enabled. + +### Uninstall + +```bash +dsh plugin --profile web remove @local/omniroute +``` + +### Updating + +Re-run the `add` command from Option A (or `git pull` in your Option B checkout). + +## Setup after install + +1. Open **Settings → Plugins → OmniRoute**. +2. Enter the **Gateway URL** (e.g. `https://omniroute.example.com`) and paste your **API key**. + - The key is stored in the DSH credential store under `OMNIROUTE_API_KEY` (`keyRef`) and never leaves the Host. + - Use a management-scoped key if you want quota tracking. +3. Press **Test**, then **Save**, then **Re-discover models**. +4. Pick an `omni/…` model in the model selector. Quotas appear under the composer. + +### Configuration file + +Non-secret settings live in `/omniroute.json` (`$DSH_PROFILE_DIR` or `~/.dsh/profiles/web/omniroute.json`): + +```json +{ + "url": "https://omniroute.example.com", + "keyRef": "OMNIROUTE_API_KEY", + "provider": "omni", + "discovery": true, + "updateOnly": false, + "include": "", + "exclude": "", + "discoverThinking": true, + "usage": true, + "pollSec": 120, + "syncMin": 60 +} +``` + +All of these are editable from the settings panel; `imported` and `lastImport` are maintained by the plugin. diff --git a/client.js b/client.js new file mode 100644 index 0000000..86f334a --- /dev/null +++ b/client.js @@ -0,0 +1,543 @@ +window.__ModuleLoader__.load({ + id: '@local/omniroute', + factory(require) { + const React = require('react'); + const h = React.createElement; + const NS = 'omniroute'; + const PKG = '@local/omniroute'; + + const en = { + 'settings.title': 'OmniRoute gateway', + 'settings.url': 'Gateway URL', + 'settings.url.hint': 'Base URL of your OmniRoute instance, for example https://omniroute.example.com', + 'settings.key': 'API key', + 'settings.key.set': 'A key is stored ({source}). Leave blank to keep it.', + 'settings.key.unset': 'No key stored yet.', + 'settings.key.placeholder': 'Paste a new key to replace it', + 'settings.key.clear': 'Remove stored key', + 'settings.provider': 'Provider id for imported models', + 'settings.provider.hint': 'The llm-pi-ai provider that receives the models. It is created if missing.', + 'settings.discovery': 'Model discovery', + 'settings.discovery.hint': 'Import models and combos with context window, output limit, modalities and thinking levels.', + 'settings.syncMin': 'Re-import every (minutes, 0 = manual only)', + 'settings.updateOnly': 'Update existing entries only', + 'settings.updateOnly.hint': 'Refresh context window, output limit, modalities and thinking levels of models you already have. Never adds or removes entries.', + 'settings.thinking': 'Auto-discover thinking levels', + 'settings.thinking.hint': 'Offer every thinking level OmniRoute reports for each model. When off, existing levels are kept as they are.', + 'settings.usage': 'Usage tracking', + 'settings.usage.hint': 'Show provider quotas (5-hour, weekly, monthly, …) under the composer.', + 'settings.pollSec': 'Refresh quotas every (seconds)', + 'settings.test': 'Test connection', + 'settings.save': 'Save', + 'settings.saved': 'Saved.', + 'settings.import': 'Re-discover models', + 'settings.filter.title': 'Import filter', + 'settings.filter.hint': 'Comma-separated patterns with * and ?, matched against model ids (for example claude*, gemini-*). Include limits what Re-discover may add; exclude always wins. Empty include means everything.', + 'settings.include': 'Include', + 'settings.exclude': 'Exclude', + 'manage.title': 'Manage models', + 'manage.load': 'Show models', + 'manage.search': 'Search models', + 'manage.count': '{shown} shown of {total} ({selected} selected)', + 'manage.imported': 'imported', + 'manage.mine': 'yours', + 'manage.selectShown': 'Select shown', + 'manage.clear': 'Clear selection', + 'manage.removeSel': 'Remove selected', + 'manage.keepSel': 'Keep selected, remove other imported', + 'manage.confirm': 'Remove {n} models from this provider? Models used as the default or in the subagent list are skipped.', + 'manage.done': 'Removed {removed}; {remaining} left.{protected}', + 'manage.protected': ' Skipped (in use): {ids}.', + 'settings.update': 'Update existing models', + 'settings.restart': 'Restart DSH to enable this button: the running server is an older version and would add models instead of only updating.', + 'settings.update.hint': 'Update refreshes only the models you already have. Re-discover also adds new ones and drops ones OmniRoute no longer lists.', + 'settings.importing': 'Importing…', + 'settings.testing': 'Testing…', + 'settings.test.ok': 'Connected in {ms} ms. {models} chat models available.', + 'settings.test.mgmt': ' Management access OK ({connections} provider connections).', + 'settings.test.nomgmt': ' This key has no management access, so quotas will not be available.', + 'settings.import.ok': '{count} models: {added} added, {updated} updated, {removed} removed; {thinking} with thinking levels.', + 'settings.lastImport': 'Last import: {count} models ({added} added, {updated} updated) at {at}', + 'settings.lastImport.error': 'Last import failed: {error}', + 'quota.resets': 'resets {when}', + 'quota.unlimited': 'unlimited', + 'quota.left': '{pct}% left', + 'quota.average': 'Average of {n} accounts', + 'quota.dialog.accounts': 'Accounts', + 'quota.dialog.left': 'left', + 'quota.error.mgmt': 'Quotas need a management-scoped API key', + }; + const zh = { + 'settings.title': 'OmniRoute 网关', + 'settings.url': '网关地址', + 'settings.url.hint': 'OmniRoute 实例的基础地址,例如 https://omniroute.example.com', + 'settings.key': 'API 密钥', + 'settings.key.set': '已保存密钥({source})。留空则保持不变。', + 'settings.key.unset': '尚未保存密钥。', + 'settings.key.placeholder': '粘贴新密钥以替换', + 'settings.key.clear': '删除已保存的密钥', + 'settings.provider': '导入模型使用的提供商 ID', + 'settings.provider.hint': '接收模型的 llm-pi-ai 提供商;不存在时自动创建。', + 'settings.discovery': '模型发现', + 'settings.discovery.hint': '导入模型和组合,自动填充上下文窗口、输出上限、模态和思考强度。', + 'settings.syncMin': '重新导入间隔(分钟,0 为仅手动)', + 'settings.updateOnly': '仅更新已有条目', + 'settings.updateOnly.hint': '只刷新已有模型的上下文窗口、输出上限、模态和思考强度,不会新增或删除条目。', + 'settings.thinking': '自动发现思考强度', + 'settings.thinking.hint': '为每个模型提供 OmniRoute 报告的全部思考强度。关闭时保持现有强度不变。', + 'settings.usage': '用量跟踪', + 'settings.usage.hint': '在输入框下方显示提供商配额(5 小时、每周、每月等)。', + 'settings.pollSec': '配额刷新间隔(秒)', + 'settings.test': '测试连接', + 'settings.save': '保存', + 'settings.saved': '已保存。', + 'settings.import': '重新发现模型', + 'settings.filter.title': '导入过滤', + 'settings.filter.hint': '以逗号分隔的模式,支持 * 和 ?,匹配模型 ID(例如 claude*, gemini-*)。“包含”限制重新发现可添加的模型,“排除”优先生效。包含为空表示全部。', + 'settings.include': '包含', + 'settings.exclude': '排除', + 'manage.title': '管理模型', + 'manage.load': '显示模型', + 'manage.search': '搜索模型', + 'manage.count': '显示 {shown} / 共 {total}(已选 {selected})', + 'manage.imported': '已导入', + 'manage.mine': '自有', + 'manage.selectShown': '选择当前显示', + 'manage.clear': '清除选择', + 'manage.removeSel': '移除所选', + 'manage.keepSel': '保留所选,移除其他已导入', + 'manage.confirm': '从此提供商移除 {n} 个模型?默认模型和子代理列表中使用的模型会被跳过。', + 'manage.done': '已移除 {removed} 个;剩余 {remaining} 个。{protected}', + 'manage.protected': ' 已跳过(使用中):{ids}。', + 'settings.update': '更新现有模型', + 'settings.restart': '请重启 DSH 以启用此按钮:当前运行的服务端是旧版本,会新增模型而不是只更新。', + 'settings.update.hint': '“更新”只刷新已有模型;“重新发现”还会添加新模型,并移除 OmniRoute 不再提供的模型。', + 'settings.importing': '导入中…', + 'settings.testing': '测试中…', + 'settings.test.ok': '已连接,用时 {ms} 毫秒,共 {models} 个对话模型。', + 'settings.test.mgmt': ' 管理权限正常({connections} 个提供商连接)。', + 'settings.test.nomgmt': ' 此密钥没有管理权限,无法读取配额。', + 'settings.import.ok': '共 {count} 个模型:新增 {added},更新 {updated},移除 {removed};{thinking} 个带思考强度。', + 'settings.lastImport': '上次导入:{count} 个模型(新增 {added},更新 {updated}),{at}', + 'settings.lastImport.error': '上次导入失败:{error}', + 'quota.resets': '{when}后重置', + 'quota.unlimited': '无限制', + 'quota.left': '剩余 {pct}%', + 'quota.average': '{n} 个账号的平均值', + 'quota.dialog.accounts': '账号', + 'quota.dialog.left': '剩余', + 'quota.error.mgmt': '读取配额需要具有管理权限的 API 密钥', + }; + + const fill = (s, vars) => String(s).replace(/\{(\w+)\}/g, (_, k) => (vars && vars[k] !== undefined ? vars[k] : '')); + + async function call(path, init) { + const res = await fetch('omniroute/' + path, { + ...init, + headers: { accept: 'application/json', ...(init && init.body ? { 'content-type': 'application/json' } : {}) }, + }); + let json; + try { json = await res.json(); } catch { json = {}; } + if (!res.ok) throw new Error(json.message || res.statusText || String(res.status)); + return json; + } + const post = (path, body) => call(path, { method: 'POST', body: JSON.stringify(body || {}) }); + + const field = { + display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 14, + }; + const labelStyle = { fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary, inherit)' }; + const hintStyle = { fontSize: 12, color: 'var(--dsw-alias-label-secondary, inherit)', opacity: 0.85 }; + const inputStyle = { + padding: '6px 10px', borderRadius: 6, font: 'inherit', fontSize: 13, + border: '1px solid var(--dsw-alias-border-l3, rgba(128,128,128,.4))', + background: 'var(--dsw-specific-input-major, transparent)', + color: 'var(--dsw-alias-label-primary, inherit)', width: '100%', boxSizing: 'border-box', + }; + const btnStyle = (primary, disabled) => ({ + padding: '6px 14px', borderRadius: 6, font: 'inherit', fontSize: 13, cursor: disabled ? 'default' : 'pointer', + border: primary ? 0 : '1px solid var(--dsw-alias-border-l3, rgba(128,128,128,.4))', + background: primary ? 'var(--dsw-alias-state-business-primary, #247bbf)' : 'transparent', + color: primary ? '#fff' : 'var(--dsw-alias-label-primary, inherit)', + opacity: disabled ? 0.55 : 1, + }); + + function Toggle({ checked, onChange, label, hint }) { + return h('label', { style: { ...field, flexDirection: 'row', alignItems: 'flex-start', gap: 10, cursor: 'pointer' } }, + h('input', { type: 'checkbox', checked, onChange: (e) => onChange(e.target.checked), style: { marginTop: 3 } }), + h('span', { style: { display: 'flex', flexDirection: 'column', gap: 2 } }, + h('span', { style: labelStyle }, label), + hint ? h('span', { style: hintStyle }, hint) : null)); + } + + function ModelManager({ t, onChanged }) { + const [data, setData] = React.useState(null); + const [q, setQ] = React.useState(''); + const [sel, setSel] = React.useState(() => new Set()); + const [busy, setBusy] = React.useState(false); + const [msg, setMsg] = React.useState(null); + const load = async () => { + setBusy(true); setMsg(null); + try { setData(await call('models')); } catch (e) { setMsg({ bad: true, text: String(e.message) }); } finally { setBusy(false); } + }; + const shown = data ? data.models.filter((m) => !q.trim() || (m.id + ' ' + m.name).toLowerCase().includes(q.trim().toLowerCase())) : []; + const visible = shown.slice(0, 200); + const toggle = (id) => setSel((prev) => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; }); + const prune = async (body, count) => { + if (!window.confirm(fill(t('manage.confirm'), { n: count }))) return; + setBusy(true); setMsg(null); + try { + const r = await post('prune', body); + setMsg({ text: fill(t('manage.done'), { removed: r.removed, remaining: r.remaining, protected: r.protected && r.protected.length ? fill(t('manage.protected'), { ids: r.protected.join(', ') }) : '' }) }); + setSel(new Set()); + setData(await call('models')); + if (onChanged) onChanged(); + } catch (e) { setMsg({ bad: true, text: String(e.message) }); } finally { setBusy(false); } + }; + const importedCount = data ? data.models.filter((m) => m.imported && !sel.has(m.id)).length : 0; + return h('div', { style: { marginTop: 18, paddingTop: 14, borderTop: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.3))' } }, + h('div', { style: { ...labelStyle, marginBottom: 8 } }, t('manage.title')), + !data ? h('button', { type: 'button', onClick: load, disabled: busy, style: btnStyle(false, busy) }, t('manage.load')) : h('div', null, + h('input', { value: q, placeholder: t('manage.search'), onChange: (e) => setQ(e.target.value), style: { ...inputStyle, marginBottom: 6 } }), + h('div', { style: { ...hintStyle, marginBottom: 6 } }, fill(t('manage.count'), { shown: visible.length, total: data.total, selected: sel.size })), + h('div', { style: { maxHeight: 240, overflowY: 'auto', border: '1px solid var(--dsw-alias-border-l3, rgba(128,128,128,.4))', borderRadius: 6, padding: 4 } }, + visible.map((m) => h('label', { key: m.id, style: { display: 'flex', alignItems: 'center', gap: 8, padding: '2px 4px', fontSize: 13, cursor: 'pointer' } }, + h('input', { type: 'checkbox', checked: sel.has(m.id), onChange: () => toggle(m.id) }), + h('span', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: m.id }, m.name === m.id ? m.id : m.name + ' · ' + m.id), + h('span', { style: { ...hintStyle, fontSize: 11 } }, m.imported ? t('manage.imported') : t('manage.mine'))))), + h('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 8 } }, + h('button', { type: 'button', onClick: () => setSel((prev) => { const n = new Set(prev); visible.forEach((m) => n.add(m.id)); return n; }), disabled: busy, style: btnStyle(false, busy) }, t('manage.selectShown')), + h('button', { type: 'button', onClick: () => setSel(new Set()), disabled: busy || sel.size === 0, style: btnStyle(false, busy || sel.size === 0) }, t('manage.clear')), + h('button', { type: 'button', onClick: () => prune({ ids: [...sel] }, sel.size), disabled: busy || sel.size === 0, style: btnStyle(true, busy || sel.size === 0) }, t('manage.removeSel')), + h('button', { type: 'button', onClick: () => prune({ keep: [...sel] }, importedCount), disabled: busy || importedCount === 0, style: btnStyle(false, busy || importedCount === 0) }, t('manage.keepSel')))), + msg ? h('div', { role: 'status', style: { fontSize: 13, marginTop: 8, color: msg.bad ? 'var(--dsw-alias-state-danger, #d33)' : 'var(--dsw-alias-label-primary, inherit)' } }, msg.text) : null); + } + + function Settings({ view, t }) { + const [status, setStatus] = React.useState(null); + const [form, setForm] = React.useState(null); + const [apiKey, setApiKey] = React.useState(''); + const [busy, setBusy] = React.useState(''); + const [msg, setMsg] = React.useState(null); + + const refresh = React.useCallback(async () => { + try { + const s = await call('status'); + setStatus(s); + setForm((f) => f || { url: s.url || s.effectiveUrl || '', provider: s.provider, discovery: s.discovery, updateOnly: !!s.updateOnly, include: s.include || '', exclude: s.exclude || '', discoverThinking: s.discoverThinking !== false, usage: s.usage, pollSec: s.pollSec, syncMin: s.syncMin }); + } catch (e) { setMsg({ bad: true, text: String(e.message) }); } + }, []); + React.useEffect(() => { refresh(); }, [refresh]); + + if (view === 'summary') return null; + if (!form || !status) return h('div', { style: hintStyle }, '…'); + const set = (k) => (v) => setForm((f) => ({ ...f, [k]: v })); + const run = (name, fn) => async () => { + setBusy(name); setMsg(null); + try { await fn(); } catch (e) { setMsg({ bad: true, text: String(e.message) }); } finally { setBusy(''); } + }; + + const save = run('save', async () => { + await post('config', { ...form, ...(apiKey ? { apiKey } : {}) }); + setApiKey(''); + await refresh(); + setMsg({ text: t('settings.saved') }); + }); + const test = run('test', async () => { + const r = await post('test', { url: form.url, ...(apiKey ? { apiKey } : {}) }); + if (!r.ok) { setMsg({ bad: true, text: r.message }); return; } + setMsg({ text: fill(t('settings.test.ok'), r) + (r.management ? fill(t('settings.test.mgmt'), r) : t('settings.test.nomgmt')) }); + }); + const importWith = (name, mode) => run(name, async () => { + await post('config', { ...form, ...(apiKey ? { apiKey } : {}) }); + setApiKey(''); + const r = await post('import', { mode }); + await refresh(); + setMsg({ text: fill(t('settings.import.ok'), r) }); + }); + const doImport = importWith('import', 'full'); + const doUpdate = importWith('update', 'update'); + const canUpdate = status.api >= 2; // an older Host ignores the mode and would add models + const clearKey = run('clear', async () => { await post('config', { clearKey: true }); await refresh(); }); + + const li = status.lastImport; + return h('div', { style: { maxWidth: 560, padding: '4px 0 12px' } }, + h('div', { style: field }, + h('label', { style: labelStyle }, t('settings.url')), + h('input', { type: 'url', value: form.url, placeholder: 'https://', onChange: (e) => set('url')(e.target.value), style: inputStyle }), + h('span', { style: hintStyle }, t('settings.url.hint'))), + h('div', { style: field }, + h('label', { style: labelStyle }, t('settings.key')), + h('input', { type: 'password', autoComplete: 'new-password', value: apiKey, placeholder: t('settings.key.placeholder'), onChange: (e) => setApiKey(e.target.value), style: inputStyle }), + h('span', { style: hintStyle }, + status.key.configured ? fill(t('settings.key.set'), { source: status.key.source || status.key.ref }) : t('settings.key.unset'), + status.key.configured ? h('button', { type: 'button', onClick: clearKey, disabled: !!busy, style: { ...btnStyle(false, !!busy), marginLeft: 8, padding: '1px 8px', fontSize: 12 } }, t('settings.key.clear')) : null)), + h('div', { style: field }, + h('label', { style: labelStyle }, t('settings.provider')), + h('input', { value: form.provider, onChange: (e) => set('provider')(e.target.value), style: { ...inputStyle, maxWidth: 220 } }), + h('span', { style: hintStyle }, t('settings.provider.hint'))), + h(Toggle, { checked: form.discovery, onChange: set('discovery'), label: t('settings.discovery'), hint: t('settings.discovery.hint') }), + form.discovery ? h('div', { style: { marginLeft: 26 } }, + h(Toggle, { checked: form.updateOnly, onChange: set('updateOnly'), label: t('settings.updateOnly'), hint: t('settings.updateOnly.hint') }), + h(Toggle, { checked: form.discoverThinking, onChange: set('discoverThinking'), label: t('settings.thinking'), hint: t('settings.thinking.hint') })) : null, + form.discovery ? h('div', { style: { ...field, marginLeft: 26 } }, + h('label', { style: labelStyle }, t('settings.filter.title')), + h('div', { style: { display: 'flex', gap: 8 } }, + h('input', { value: form.include, placeholder: t('settings.include') + ': claude*, gemini-*', onChange: (e) => set('include')(e.target.value), style: inputStyle }), + h('input', { value: form.exclude, placeholder: t('settings.exclude') + ': *mini, *preview', onChange: (e) => set('exclude')(e.target.value), style: inputStyle })), + h('span', { style: hintStyle }, t('settings.filter.hint'))) : null, + form.discovery ? h('div', { style: { ...field, marginLeft: 26 } }, + h('label', { style: hintStyle }, t('settings.syncMin')), + h('input', { type: 'number', min: 0, max: 1440, value: form.syncMin, onChange: (e) => set('syncMin')(Number(e.target.value)), style: { ...inputStyle, maxWidth: 120 } })) : null, + h(Toggle, { checked: form.usage, onChange: set('usage'), label: t('settings.usage'), hint: t('settings.usage.hint') }), + form.usage ? h('div', { style: { ...field, marginLeft: 26 } }, + h('label', { style: hintStyle }, t('settings.pollSec')), + h('input', { type: 'number', min: 30, max: 3600, value: form.pollSec, onChange: (e) => set('pollSec')(Number(e.target.value)), style: { ...inputStyle, maxWidth: 120 } })) : null, + h('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 10 } }, + h('button', { type: 'button', onClick: test, disabled: !!busy, style: btnStyle(false, !!busy) }, busy === 'test' ? t('settings.testing') : t('settings.test')), + h('button', { type: 'button', onClick: save, disabled: !!busy, style: btnStyle(true, !!busy) }, t('settings.save')), + h('button', { type: 'button', onClick: doUpdate, disabled: !!busy || !canUpdate, title: canUpdate ? undefined : t('settings.restart'), style: btnStyle(false, !!busy || !canUpdate) }, busy === 'update' ? t('settings.importing') : t('settings.update')), + h('button', { type: 'button', onClick: doImport, disabled: !!busy, style: btnStyle(false, !!busy) }, busy === 'import' ? t('settings.importing') : t('settings.import'))), + h('div', { style: { ...hintStyle, marginBottom: 8 } }, canUpdate ? t('settings.update.hint') : t('settings.restart')), + msg ? h('div', { role: 'status', style: { fontSize: 13, color: msg.bad ? 'var(--dsw-alias-state-danger, #d33)' : 'var(--dsw-alias-label-primary, inherit)' } }, msg.text) : null, + h(ModelManager, { t, onChanged: refresh }), + li ? h('div', { style: { ...hintStyle, marginTop: 8 } }, + li.error ? fill(t('settings.lastImport.error'), { error: li.error }) : fill(t('settings.lastImport'), { count: li.count, added: li.added ?? 0, updated: li.updated ?? 0, at: new Date(li.at).toLocaleString() })) : null); + } + + // ---------------------------------------------------------------- quota strip + + function relTime(iso) { + const ms = Date.parse(iso) - Date.now(); + if (!Number.isFinite(ms)) return ''; + if (ms <= 0) return '0m'; + const m = Math.round(ms / 60000); + if (m < 60) return m + 'm'; + const hrs = Math.floor(m / 60); + if (hrs < 48) return hrs + 'h' + (m % 60 ? (m % 60) + 'm' : ''); + return Math.round(hrs / 24) + 'd'; + } + + // Same geometry and tokens as the host's context meter ring. + const RADIUS = 5.5; + const CIRC = 2 * Math.PI * RADIUS; + const LOW = 'var(--dsw-alias-state-danger, #e5484d)'; + + + // Icons sit a little low next to the text in the stats bar. Lift them 1px: the bar's own pills, + // the context meter ring, and ours. Only the icon moves; nothing else in the host is restyled. + const ICON_LIFT = '-1px'; + const CENTER_CSS = + '[data-omniroute-quotas] > * { align-items: center; justify-content: center; text-align: center; }' + + '[data-composer-stats] svg, [data-omniroute-quotas] svg, .omr-title-label svg,' + + 'button[class*="_trigger"][aria-haspopup="dialog"] > svg[viewBox="0 0 14 14"],' + + '[role="dialog"] [class*="_titleLabel"] svg' + + ' { flex: none; transform: translateY(' + ICON_LIFT + '); }'; + + function Gauge({ pct, unlimited }) { + const shown = unlimited || pct === null ? 100 : Math.max(0, Math.min(100, pct)); + return h('svg', { viewBox: '0 0 14 14', width: 14, height: 14, 'aria-hidden': true, style: { flex: 'none' } }, + h('circle', { cx: 7, cy: 7, r: RADIUS, fill: 'none', stroke: 'var(--dsw-alias-border-l3)', strokeWidth: 2 }), + shown > 0 ? h('circle', { + cx: 7, cy: 7, r: RADIUS, fill: 'none', strokeWidth: 2, strokeLinecap: 'round', + stroke: !unlimited && pct !== null && pct <= 10 ? LOW : 'currentColor', + strokeDasharray: (CIRC * shown / 100) + ' ' + CIRC, transform: 'rotate(-90 7 7)', + }) : null); + } + + const rootStyle = { + boxSizing: 'border-box', minWidth: 0, maxWidth: '100%', display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: 12, + fontSize: 'calc(var(--dsh-content-font-size-secondary, 13px) - 1px)', + lineHeight: 'calc(20px + var(--dsh-content-font-delta-secondary, 0px))', + }; + const pillStyle = (hover) => ({ + boxSizing: 'border-box', maxWidth: '100%', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', textAlign: 'center', gap: 6, padding: '1px 8px', + border: 'none', borderRadius: 999, font: 'inherit', lineHeight: 'inherit', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap', + background: hover ? 'var(--dsw-alias-interactive-bg-hover)' : 'transparent', + color: hover ? 'var(--dsw-alias-label-secondary)' : 'var(--dsw-alias-label-tertiary)', + }); + + // Panel styled with the host's own tokens, mirroring its token-usage dialog. + const PANEL_CSS = + '.omr-panel{z-index:1100;box-sizing:border-box;border-radius:var(--dsw-radius-lg);background:var(--dsw-specific-menu);' + + 'width:max-content;min-width:min(300px,100vw - 24px);max-width:min(440px,100vw - 24px);backdrop-filter:var(--dsw-menu-backdrop-filter);' + + '--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-secondary);' + + 'cursor:default;border:0;padding:16px;font-size:12px;line-height:18px;position:fixed}' + + '.omr-title{color:var(--dsw-alias-label-primary);justify-content:space-between;gap:16px;margin-bottom:8px;font-weight:500;display:flex}' + + '.omr-title-label{align-items:center;gap:6px;min-width:0;display:inline-flex}' + + '.omr-title-value{font-variant-numeric:tabular-nums}' + + '.omr-rule{border-top:.5px solid var(--dsw-alias-border-l2);margin-bottom:10px}' + + '.omr-details{color:var(--dsw-alias-label-tertiary);grid-template-columns:minmax(76px,auto) minmax(0,1fr);gap:6px 16px;margin:0;display:grid}' + + '.omr-details dt,.omr-details dd{min-width:0;margin:0;overflow-wrap:anywhere}' + + '.omr-details dd{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;text-align:right}'; + + const MEASURE = { visibility: 'hidden', left: 0, top: 0 }; + let primitives; + try { primitives = require('@deepseek-ai/dsh-client-ui-primitives'); } catch { primitives = undefined; } + const portal = (() => { try { return require('react-dom').createPortal; } catch { return undefined; } })(); + + /** The host's anchoring and outside-click hooks when reachable, otherwise equivalent local ones. */ + function useAnchored(open, anchorRef, panelRef) { + const [pos, setPos] = React.useState(null); + React.useLayoutEffect(() => { + if (!open) { setPos(null); return undefined; } + const place = () => { + const a = anchorRef.current, p = panelRef.current; + if (!a || !p) return; + const r = a.getBoundingClientRect(), w = p.offsetWidth, hgt = p.offsetHeight, m = 12, gap = 8; + const left = Math.max(m, Math.min(window.innerWidth - w - m, r.left + r.width / 2 - w / 2)); + const top = Math.max(m, r.top - hgt - gap); + setPos({ left, top }); + }; + place(); + window.addEventListener('resize', place); + window.addEventListener('scroll', place, true); + return () => { window.removeEventListener('resize', place); window.removeEventListener('scroll', place, true); }; + }, [open]); + return pos; + } + function useDismiss(rootRef, open, setOpen, panelRef) { + React.useEffect(() => { + if (!open) return undefined; + const down = (e) => { + const el = e.target; + if (rootRef.current && rootRef.current.contains(el)) return; + if (panelRef.current && panelRef.current.contains(el)) return; + setOpen(false); + }; + const key = (e) => { if (e.key === 'Escape') setOpen(false); }; + document.addEventListener('pointerdown', down, true); + document.addEventListener('keydown', key); + return () => { document.removeEventListener('pointerdown', down, true); document.removeEventListener('keydown', key); }; + }, [open]); + } + + // Chosen once at load, so every render calls the same hook in the same order. + const useAnchorHook = primitives && primitives.useAnchoredPosition + ? (open, anchorRef, panelRef) => primitives.useAnchoredPosition({ open, anchorRef, panelRef, side: 'top', gap: 8, margin: 12 }) + : useAnchored; + const useDismissHook = primitives && primitives.useDismissOnOutsidePointer + ? (rootRef, open, setOpen, panelRef) => primitives.useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef) + : useDismiss; + + function Pill({ g, multi, t }) { + const [open, setOpen] = React.useState(false); + const [hover, setHover] = React.useState(false); + const rootRef = React.useRef(null); + const panelRef = React.useRef(null); + const pos = useAnchorHook(open, rootRef, panelRef); + useDismissHook(rootRef, open, setOpen, panelRef); + // The host's dismiss hook only handles outside clicks; Escape is the dialog's own job (as in the host's stat dialogs). + React.useEffect(() => { + if (!open) return undefined; + const onKey = (e) => { if (e.key === 'Escape') setOpen(false); }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [open]); + + const pct = g.unlimited ? null : g.remainingPct; + const reading = g.unlimited ? '∞' : pct === null ? '—' : Math.round(pct) + '%'; + const label = (multi ? g.provider + ' ' : '') + g.label; + const panel = open ? h('div', { + ref: panelRef, className: 'omr-panel', role: 'dialog', 'aria-label': g.provider + ' ' + g.label, style: pos || MEASURE, + }, + h('div', { className: 'omr-title' }, + h('span', { className: 'omr-title-label' }, h(Gauge, { pct, unlimited: g.unlimited }), g.provider + ' · ' + g.label), + h('span', { className: 'omr-title-value' }, reading)), + h('div', { className: 'omr-rule' }), + h('dl', { className: 'omr-details' }, + g.accounts > 1 && !g.unlimited ? [h('dt', { key: 'avg-k' }, t('quota.average').replace('{n}', g.accounts)), h('dd', { key: 'avg-v' }, reading)] : null, + g.resetAt ? [h('dt', { key: 'rs-k' }, t('quota.resets').replace('{when}', '').trim() || 'resets'), h('dd', { key: 'rs-v' }, relTime(g.resetAt))] : null, + g.items.map((i, n) => [ + h('dt', { key: 'a' + n }, g.accounts > 1 ? i.account : t('quota.dialog.accounts')), + h('dd', { key: 'v' + n }, (i.unlimited ? t('quota.unlimited') : i.remainingPct === null ? '—' : Math.round(i.remainingPct) + '%') + + (i.resetAt ? ' · ' + relTime(i.resetAt) : '')), + ]))) : null; + + return h('span', { ref: rootRef, style: { minWidth: 0, display: 'inline-flex' } }, + h('button', { + type: 'button', 'aria-haspopup': 'dialog', 'aria-expanded': open, 'aria-label': label + ' ' + reading, + onClick: () => setOpen(!open), + onMouseEnter: () => setHover(true), onMouseLeave: () => setHover(false), + style: { ...pillStyle(hover || open), cursor: 'pointer' }, + }, + h(Gauge, { pct, unlimited: g.unlimited }), + h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0, textAlign: 'center' } }, + label + ' ' + reading + (g.resetAt ? ' · ' + relTime(g.resetAt) : ''))), + panel && portal ? portal(panel, document.body) : panel); + } + + function QuotaStrip({ directory, t }) { + const state = React.useSyncExternalStore((fn) => directory.subscribe(fn), () => directory.getSnapshot()); + const providerId = state.current ? state.current.provider : undefined; + const modelId = state.current ? state.current.model : undefined; + const [data, setData] = React.useState(null); + const [pollSec, setPollSec] = React.useState(120); + const [enabled, setEnabled] = React.useState(true); + + React.useEffect(() => { + let dead = false; + let timer; + setData(null); // never show the previous model's quotas while the new one loads + const tick = async () => { + try { + const s = await call('status'); + if (dead) return; + setEnabled(!!s.usage); + setPollSec(s.pollSec || 120); + if (!s.usage) { setData(null); return; } + if (!providerId || !modelId || providerId !== s.provider) { setData(null); return; } + const q = await call('quotas?provider=' + encodeURIComponent(providerId) + '&model=' + encodeURIComponent(modelId)); + if (!dead) setData(q); + } catch { if (!dead) setData(null); } + if (!dead) timer = setTimeout(tick, Math.max(30, pollSec) * 1000); + }; + tick(); + return () => { dead = true; clearTimeout(timer); }; + }, [providerId, modelId, pollSec]); + + if (!enabled || !data || !providerId) return null; + if (data.error === 'management-auth') { + return h('div', { style: { ...rootStyle, color: 'var(--dsw-alias-label-tertiary)' } }, t('quota.error.mgmt')); + } + // An older Host sends per-account rows only; show nothing rather than three duplicate windows. + const groups = Array.isArray(data.merged) ? data.merged : []; + if (groups.length === 0) { + if (!data.message || data.scope === 'foreign-model') return null; + return h('div', { 'data-omniroute-quotas': true, title: data.message, style: { ...rootStyle, color: 'var(--dsw-alias-label-tertiary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, 'OmniRoute quotas: ' + data.message); + } + const multi = new Set(groups.map((g) => g.provider)).size > 1; + + return h('div', { 'data-omniroute-quotas': true, style: rootStyle }, + groups.map((g) => h(Pill, { key: g.provider + ':' + g.key, g, multi, t }))); + } + + return { + inject: ['slots', 'locale', 'modelDirectories'], + apply(ctx) { + ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'omniroute: dictionaries'); + ctx.effect(() => { + const el = document.createElement('style'); + el.setAttribute('data-omniroute-style', ''); + el.textContent = CENTER_CSS + PANEL_CSS; + document.head.appendChild(el); + return () => el.remove(); + }, 'omniroute: centring style'); + + ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({ + name: 'plugins.bundle.config', + key: PKG, + locale: NS, + }, Settings)); + + ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({ + name: 'conversation.composer.dock', + id: 'omniroute-quotas', + order: 5, + locale: NS, + inject: (sessionId) => ({ directory: ctx.modelDirectories.directoryFor(sessionId).store }), + }, QuotaStrip)); + }, + }; + }, +}); diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..7b1b132 --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,3 @@ +- insert: + - id: omniroute + name: '@local/omniroute' diff --git a/index.js b/index.js new file mode 100644 index 0000000..59559b0 --- /dev/null +++ b/index.js @@ -0,0 +1,821 @@ +/** + * 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') +} diff --git a/locale/en.json b/locale/en.json new file mode 100644 index 0000000..f6113eb --- /dev/null +++ b/locale/en.json @@ -0,0 +1 @@ +{ "meta": { "title": "OmniRoute", "description": "Connect to an OmniRoute gateway: auto-import models with context window, output limit, modalities and thinking levels, and show provider quotas under the composer." } } diff --git a/locale/zh.json b/locale/zh.json new file mode 100644 index 0000000..852f3cd --- /dev/null +++ b/locale/zh.json @@ -0,0 +1 @@ +{ "meta": { "title": "OmniRoute", "description": "连接 OmniRoute 网关:自动导入模型(上下文窗口、输出上限、模态、思考强度),并在输入框下方显示各提供商配额。" } } diff --git a/package.json b/package.json new file mode 100644 index 0000000..db740f7 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "@local/omniroute", + "version": "1.0.0", + "description": "OmniRoute gateway integration: model auto-import (context window, max output, modalities, thinking levels) and provider quota tracking.", + "private": true, + "type": "module", + "exports": { + ".": "./index.js", + "./client": "./client.js", + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" + }, + "files": ["index.js", "client.js", "cordis.patch.yml", "locale/*.json"], + "dsh": { + "bundle": { "patch": "./cordis.patch.yml" }, + "client": { + "platform": "web", + "immediately": true, + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-model-selection", + "@deepseek-ai/dsh-client-ui-plugin-manager" + ] + } + } +}