From 1c057d074784fcd538849016def5f65c09c9733a Mon Sep 17 00:00:00 2001 From: Its-My-Work <122925955+Its-My-Work@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:49:33 +0300 Subject: [PATCH 01/21] feat(providers): model refs, resolution, run env, SQLite store, catalogue fetch A model is one string 'provider::model' (bare values keep resolving on the default provider), so it travels through every existing column and frame. Store: providers/provider_models/llm_usage tables, keys encrypted with the SSH-host AES-GCM key, env ANTHROPIC_BASE_URL seeded as the default provider. Co-Authored-By: Claude Opus 5.5 --- providers-catalog.js | 95 ++++++++ providers-store.js | 425 +++++++++++++++++++++++++++++++++ providers.js | 527 +++++++++++++++++++++++++++++++++++++++++ test/providers.test.js | 237 ++++++++++++++++++ 4 files changed, 1284 insertions(+) create mode 100644 providers-catalog.js create mode 100644 providers-store.js create mode 100644 providers.js create mode 100644 test/providers.test.js diff --git a/providers-catalog.js b/providers-catalog.js new file mode 100644 index 0000000..c8e38b9 --- /dev/null +++ b/providers-catalog.js @@ -0,0 +1,95 @@ +// Reading a provider's model catalogue — the one network call the registry makes itself. +// (Model TRAFFIC goes through llm-bridge/; this is only GET /models.) +// +// Two shapes: +// openai-compatible GET /models Authorization: Bearer +// anthropic / -compatible GET /v1/models x-api-key and/or Bearer, anthropic-version +// A provider that publishes no catalogue (404) is not an error for "Test": the endpoint +// answered, so the key and URL are fine; the user adds model ids by hand. +'use strict'; + +const { normalizeCatalog } = require('./providers'); + +const TIMEOUT_MS = 10000; +const MAX_PAGES = 5; + +function authHeaders(p) { + const h = { accept: 'application/json', ...(p.headers || {}) }; + const key = p.apiKey || ''; + if (key) { + const scheme = p.type === 'openai-compatible' ? 'bearer' : (p.authScheme || 'bearer'); + if (scheme === 'bearer' || scheme === 'both') h.authorization = `Bearer ${key}`; + if (scheme === 'x-api-key' || scheme === 'both') h['x-api-key'] = key; + } + if (p.type !== 'openai-compatible') h['anthropic-version'] = '2023-06-01'; + return h; +} + +function modelsUrl(p) { + const base = String(p.baseUrl || '').replace(/\/+$/, ''); + if (p.type === 'openai-compatible') return `${base}/models`; + return /\/v1$/.test(base) ? `${base}/models` : `${base}/v1/models`; +} + +async function getJson(url, headers, fetchImpl, timeoutMs) { + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), timeoutMs); + try { + const r = await fetchImpl(url, { headers, signal: ctl.signal }); + let body = null; + try { body = await r.json(); } catch {} + return { status: r.status, ok: r.ok, body }; + } finally { clearTimeout(timer); } +} + +function errText(e) { + if (e && e.name === 'AbortError') return 'timeout'; + const c = e && (e.cause && (e.cause.code || e.cause.message)); + return String(c || (e && e.message) || e).substring(0, 160); +} + +function upstreamMessage(body) { + if (!body || typeof body !== 'object') return ''; + const e = body.error; + if (typeof e === 'string') return e; + if (e && typeof e.message === 'string') return e.message; + if (typeof body.message === 'string') return body.message; + if (typeof body.detail === 'string') return body.detail; + return ''; +} + +/** + * @returns {Promise<{ok:boolean, models:Array, status?:number, error?:string, noCatalog?:boolean}>} + */ +async function fetchCatalog(p, { fetchImpl = globalThis.fetch, timeoutMs = TIMEOUT_MS } = {}) { + if (!p || p.type === 'claude-subscription') return { ok: true, models: [], noCatalog: true }; + if (!p.baseUrl) return { ok: false, models: [], error: 'no_base_url' }; + const headers = authHeaders(p); + const all = []; + let url = modelsUrl(p), pages = 0; + try { + while (url && pages < MAX_PAGES) { + pages++; + const r = await getJson(url, headers, fetchImpl, timeoutMs); + if (r.status === 404 || r.status === 405) return { ok: true, models: all, status: r.status, noCatalog: all.length === 0 }; + if (!r.ok) { + const msg = upstreamMessage(r.body); + return { ok: false, models: [], status: r.status, error: `HTTP ${r.status}${msg ? ': ' + msg.substring(0, 160) : ''}` }; + } + all.push(...normalizeCatalog(r.body)); + // Anthropic's list paginates with has_more/last_id; OpenAI-style lists do not. + if (r.body && r.body.has_more && r.body.last_id && p.type !== 'openai-compatible') { + const u = new URL(modelsUrl(p)); + u.searchParams.set('after_id', r.body.last_id); + u.searchParams.set('limit', '1000'); + url = u.toString(); + } else url = null; + } + } catch (e) { + return { ok: false, models: [], error: errText(e) }; + } + const seen = new Set(); + return { ok: true, models: all.filter(m => (seen.has(m.id) ? false : (seen.add(m.id), true))) }; +} + +module.exports = { fetchCatalog, authHeaders, modelsUrl, TIMEOUT_MS }; diff --git a/providers-store.js b/providers-store.js new file mode 100644 index 0000000..73a982d --- /dev/null +++ b/providers-store.js @@ -0,0 +1,425 @@ +// Provider registry — the persistent half (SQLite). providers.js holds the pure rules. +// +// Kept in SQLite rather than config.json for the same reasons as bots: catalogues run +// to hundreds of models, config.json is rewritten wholesale on every settings change, +// and the usage ledger joins against sessions/tasks. +// +// Secrets (API keys, extra headers) are encrypted with the SAME function the SSH host +// passwords use (AES-256-GCM, key in data/hosts.key) and never leave this module in a +// public view: the API answers `hasKey`, and a header value whose NAME looks secret is +// masked as '***' (sending '***' back on save keeps the stored value — the SSH-host +// "blank means keep" rule, adapted to a map). +'use strict'; + +const P = require('./providers'); + +const SECRET_HEADER_RE = /(SECRET|TOKEN|PASSWORD|AUTH|API_?KEY|KEY|COOKIE)/i; +const USAGE_RETENTION_DAYS = 90; +// A catalogue this small is enabled wholesale on first import; a big one (OpenRouter +// lists hundreds) is imported switched off, so the pickers are not flooded. +const AUTO_ENABLE_MAX = 40; + +function j(v, fallback) { + if (v == null || v === '') return fallback; + try { const x = JSON.parse(v); return x == null ? fallback : x; } catch { return fallback; } +} + +function createProviderStore(db, { encrypt, decrypt, log } = {}) { + const L = log || { info() {}, warn() {}, error() {} }; + const enc = (s) => (s ? encrypt(String(s)) : ''); + const dec = (s) => (s ? decrypt(String(s)) : ''); + + db.exec(` + CREATE TABLE IF NOT EXISTS providers ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + label TEXT NOT NULL, + base_url TEXT NOT NULL DEFAULT '', + auth_scheme TEXT NOT NULL DEFAULT 'bearer', + api_key_enc TEXT NOT NULL DEFAULT '', + headers_enc TEXT NOT NULL DEFAULT '', + dialect TEXT NOT NULL DEFAULT 'generic', + options TEXT NOT NULL DEFAULT '{}', + roles TEXT NOT NULL DEFAULT '{}', + aliases INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + -- 'builtin' (the CLI login row), 'env' (seeded from ANTHROPIC_* and kept in sync + -- with it at boot until edited here), 'user' + source TEXT NOT NULL DEFAULT 'user', + sort_order INTEGER NOT NULL DEFAULT 0, + last_test TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + -- caps/pricing come from the provider's catalogue and are overwritten on refresh; + -- caps_user/pricing_user are the user's corrections and are never touched by one. + CREATE TABLE IF NOT EXISTS provider_models ( + provider_id TEXT NOT NULL, + model_id TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + source TEXT NOT NULL DEFAULT 'catalog', + caps TEXT NOT NULL DEFAULT '{}', + caps_user TEXT NOT NULL DEFAULT '{}', + pricing TEXT, + pricing_user TEXT, + verified TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (provider_id, model_id) + ); + CREATE TABLE IF NOT EXISTS provider_settings ( + key TEXT PRIMARY KEY, + value TEXT + ); + -- One row per upstream request the llm-bridge served. Before this, usage existed + -- only on the WS 'done' frame of the CLI's own result and was never stored. + CREATE TABLE IF NOT EXISTS llm_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + run_id TEXT, + purpose TEXT, + session_id TEXT, + task_id TEXT, + bot_id TEXT, + provider_id TEXT, + model TEXT, + requested_model TEXT, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0, + reasoning_tokens INTEGER NOT NULL DEFAULT 0, + cost_usd REAL, + latency_ms INTEGER, + status TEXT, + http_status INTEGER, + error_type TEXT, + cli_session_id TEXT + ); + CREATE INDEX IF NOT EXISTS idx_llm_usage_ts ON llm_usage(ts); + CREATE INDEX IF NOT EXISTS idx_llm_usage_run ON llm_usage(run_id); + CREATE INDEX IF NOT EXISTS idx_llm_usage_session ON llm_usage(session_id); + `); + + const st = { + all: db.prepare(`SELECT * FROM providers ORDER BY sort_order, created_at`), + get: db.prepare(`SELECT * FROM providers WHERE id = ?`), + insert: db.prepare(`INSERT INTO providers (id, type, label, base_url, auth_scheme, api_key_enc, headers_enc, dialect, options, roles, aliases, enabled, source, sort_order) + VALUES (@id, @type, @label, @base_url, @auth_scheme, @api_key_enc, @headers_enc, @dialect, @options, @roles, @aliases, @enabled, @source, @sort_order)`), + del: db.prepare(`DELETE FROM providers WHERE id = ?`), + delModels: db.prepare(`DELETE FROM provider_models WHERE provider_id = ?`), + models: db.prepare(`SELECT * FROM provider_models WHERE provider_id = ? ORDER BY sort_order, model_id`), + allModels: db.prepare(`SELECT * FROM provider_models ORDER BY provider_id, sort_order, model_id`), + model: db.prepare(`SELECT * FROM provider_models WHERE provider_id = ? AND model_id = ?`), + upsertCatalogModel: db.prepare(`INSERT INTO provider_models (provider_id, model_id, label, enabled, source, caps, pricing, sort_order) + VALUES (?, ?, ?, ?, 'catalog', ?, ?, ?) + ON CONFLICT(provider_id, model_id) DO UPDATE SET label = excluded.label, caps = excluded.caps, + pricing = excluded.pricing, sort_order = excluded.sort_order, updated_at = datetime('now')`), + insertManual: db.prepare(`INSERT INTO provider_models (provider_id, model_id, label, enabled, source, caps, caps_user, sort_order) + VALUES (?, ?, ?, 1, 'manual', '{}', ?, 100000) + ON CONFLICT(provider_id, model_id) DO UPDATE SET enabled = 1, caps_user = excluded.caps_user, updated_at = datetime('now')`), + delModel: db.prepare(`DELETE FROM provider_models WHERE provider_id = ? AND model_id = ?`), + getSetting: db.prepare(`SELECT value FROM provider_settings WHERE key = ?`), + setSetting: db.prepare(`INSERT INTO provider_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`), + delSetting: db.prepare(`DELETE FROM provider_settings WHERE key = ?`), + usageInsert: db.prepare(`INSERT INTO llm_usage (ts, run_id, purpose, session_id, task_id, bot_id, provider_id, model, requested_model, + input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, cost_usd, latency_ms, status, http_status, error_type, cli_session_id) + VALUES (@ts, @run_id, @purpose, @session_id, @task_id, @bot_id, @provider_id, @model, @requested_model, + @input_tokens, @output_tokens, @cache_read_tokens, @cache_write_tokens, @reasoning_tokens, @cost_usd, @latency_ms, @status, @http_status, @error_type, @cli_session_id)`), + usagePrune: db.prepare(`DELETE FROM llm_usage WHERE ts < ?`), + usageByRun: db.prepare(`SELECT COALESCE(SUM(input_tokens),0) AS input, COALESCE(SUM(output_tokens),0) AS output, COALESCE(SUM(cache_read_tokens),0) AS cache_read, + COALESCE(SUM(reasoning_tokens),0) AS reasoning, SUM(cost_usd) AS cost, COUNT(*) AS requests, + SUM(CASE WHEN cost_usd IS NULL THEN 1 ELSE 0 END) AS unpriced + FROM llm_usage WHERE run_id = ?`), + usageLastByRun: db.prepare(`SELECT input_tokens, cache_read_tokens, cache_write_tokens FROM llm_usage WHERE run_id = ? AND status = 'ok' ORDER BY id DESC LIMIT 1`), + usageSummary: db.prepare(`SELECT provider_id, model, date(ts, 'unixepoch') AS day, COUNT(*) AS requests, + SUM(input_tokens) AS input, SUM(output_tokens) AS output, SUM(cache_read_tokens) AS cache_read, + SUM(cost_usd) AS cost, SUM(CASE WHEN status != 'ok' THEN 1 ELSE 0 END) AS errors + FROM llm_usage WHERE ts >= ? GROUP BY provider_id, model, day ORDER BY day DESC, cost DESC`), + lastStatus: db.prepare(`SELECT provider_id, status, error_type, http_status, ts FROM llm_usage + WHERE id IN (SELECT MAX(id) FROM llm_usage GROUP BY provider_id)`), + }; + + let _cache = null; + const invalidate = () => { _cache = null; }; + + function rowToModel(r) { + const caps = { ...P.normalizeCaps(j(r.caps, {})) }; + const user = j(r.caps_user, {}); + for (const k of P.CAP_KEYS) if (user[k] !== undefined && user[k] !== null) caps[k] = user[k]; + const pricing = P.normalizePricing(j(r.pricing_user, null)) || P.normalizePricing(j(r.pricing, null)); + return { id: r.model_id, label: r.label || r.model_id, enabled: !!r.enabled, source: r.source, caps: P.normalizeCaps(caps), + capsUser: j(r.caps_user, {}), pricing, pricingUser: j(r.pricing_user, null), verified: j(r.verified, null) }; + } + + function rowToProvider(r, models) { + return { + id: r.id, type: r.type, label: r.label, baseUrl: r.base_url, authScheme: r.auth_scheme, + apiKey: dec(r.api_key_enc), headers: j(dec(r.headers_enc), {}), dialect: r.dialect, + options: j(r.options, {}), roles: j(r.roles, {}), aliases: !!r.aliases, enabled: !!r.enabled, + source: r.source, sortOrder: r.sort_order, lastTest: j(r.last_test, null), models: models || [], + }; + } + + /** Decrypted registry for resolveModel(). Cached; every write invalidates. */ + function registry() { + if (_cache) return _cache; + const byProv = new Map(); + for (const m of st.allModels.all()) { + if (!byProv.has(m.provider_id)) byProv.set(m.provider_id, []); + byProv.get(m.provider_id).push(rowToModel(m)); + } + const providers = st.all.all().map(r => rowToProvider(r, byProv.get(r.id))); + const defaultProviderId = getSetting('defaultProvider') || P.BUILTIN_CLAUDE_ID; + const utilityModel = getSetting('utilityModel') || ''; + _cache = { providers, defaultProviderId, utilityModel }; + return _cache; + } + + function getSetting(k) { const r = st.getSetting.get(k); return r ? r.value : null; } + function setSetting(k, v) { if (v == null || v === '') st.delSetting.run(k); else st.setSetting.run(k, String(v)); invalidate(); } + + function maskHeaders(h) { + const out = {}; + for (const [k, v] of Object.entries(h || {})) out[k] = SECRET_HEADER_RE.test(k) ? (v ? '***' : '') : v; + return out; + } + + /** What the browser may see. */ + function publicProvider(p) { + return { + id: p.id, type: p.type, label: p.label, baseUrl: p.baseUrl, authScheme: p.authScheme, + hasKey: !!p.apiKey, headers: maskHeaders(p.headers), dialect: p.dialect, options: p.options, + roles: p.roles, aliases: P.supportsAliases(p), aliasesSetting: p.aliases, enabled: p.enabled, source: p.source, + builtin: p.id === P.BUILTIN_CLAUDE_ID, lastTest: p.lastTest, + models: p.models.map(m => ({ id: m.id, label: m.label, enabled: m.enabled, source: m.source, caps: m.caps, capsUser: m.capsUser, pricing: m.pricing, verified: m.verified })), + }; + } + + function publicList() { + const reg = registry(); + return { providers: reg.providers.map(publicProvider), defaultProviderId: reg.defaultProviderId, utilityModel: reg.utilityModel }; + } + + function get(id) { return registry().providers.find(p => p.id === id) || null; } + + function create(v) { + if (st.get.get(v.id)) throw Object.assign(new Error('exists'), { code: 'exists' }); + const maxOrder = registry().providers.reduce((m, p) => Math.max(m, p.sortOrder || 0), 0); + st.insert.run({ + id: v.id, type: v.type, label: v.label, base_url: v.baseUrl || '', auth_scheme: v.authScheme || 'bearer', + api_key_enc: enc(v.apiKey), headers_enc: v.headers && Object.keys(v.headers).length ? enc(JSON.stringify(v.headers)) : '', + dialect: v.dialect || 'generic', options: JSON.stringify(v.options || {}), roles: JSON.stringify(v.roles || {}), + aliases: v.aliases ? 1 : 0, enabled: v.enabled === false ? 0 : 1, source: v.source || 'user', sort_order: v.sortOrder ?? (maxOrder + 1), + }); + invalidate(); + return get(v.id); + } + + /** Patch semantics: an absent field is unchanged; apiKey '' is unchanged, `clearApiKey` clears it; + * a header value '***' keeps the stored one. Any edit of an env-seeded row makes it the user's. */ + function update(id, patch, { fromEnv } = {}) { + const cur = get(id); + if (!cur) return null; + const sets = [], vals = {}; + const put = (col, val) => { sets.push(`${col} = @${col}`); vals[col] = val; }; + if (patch.label !== undefined) put('label', patch.label); + if (patch.baseUrl !== undefined && cur.type !== 'claude-subscription') put('base_url', patch.baseUrl); + if (patch.authScheme !== undefined) put('auth_scheme', patch.authScheme); + if (patch.dialect !== undefined) put('dialect', patch.dialect); + if (patch.clearApiKey) put('api_key_enc', ''); + else if (typeof patch.apiKey === 'string' && patch.apiKey !== '') put('api_key_enc', enc(patch.apiKey)); + if (patch.headers !== undefined) { + const merged = {}; + for (const [k, v] of Object.entries(patch.headers || {})) merged[k] = (v === '***' && cur.headers[k] !== undefined) ? cur.headers[k] : v; + put('headers_enc', Object.keys(merged).length ? enc(JSON.stringify(merged)) : ''); + } + if (patch.options !== undefined) put('options', JSON.stringify({ ...cur.options, ...patch.options })); + if (patch.roles !== undefined) put('roles', JSON.stringify(patch.roles || {})); + if (patch.aliases !== undefined) put('aliases', patch.aliases ? 1 : 0); + if (patch.enabled !== undefined) put('enabled', patch.enabled ? 1 : 0); + if (patch.lastTest !== undefined) put('last_test', patch.lastTest ? JSON.stringify(patch.lastTest) : null); + if (patch.sortOrder !== undefined) put('sort_order', patch.sortOrder); + const userEdit = sets.some(s => !/^(last_test|sort_order|enabled) /.test(s)); + if (!fromEnv && cur.source === 'env' && userEdit) put('source', 'user'); + if (!sets.length) return cur; + sets.push(`updated_at = datetime('now')`); + db.prepare(`UPDATE providers SET ${sets.join(', ')} WHERE id = @id`).run({ ...vals, id }); + invalidate(); + return get(id); + } + + function remove(id) { + if (id === P.BUILTIN_CLAUDE_ID) return false; + const r = st.del.run(id); + st.delModels.run(id); + if (getSetting('defaultProvider') === id) setSetting('defaultProvider', P.BUILTIN_CLAUDE_ID); + invalidate(); + return r.changes > 0; + } + + function setDefault(id) { + const p = get(id); + if (!p || !p.enabled) return false; + setSetting('defaultProvider', id); + return true; + } + + /** Merge a fetched catalogue. Enabled/manual models and the user's corrections survive. */ + function upsertCatalog(providerId, models) { + const existing = new Map(st.models.all(providerId).map(r => [r.model_id, r])); + const firstImport = ![...existing.values()].some(r => r.source === 'catalog'); + const autoEnable = firstImport && models.length <= AUTO_ENABLE_MAX; + const seen = new Set(); + const tx = db.transaction(() => { + models.forEach((m, i) => { + seen.add(m.id); + const prev = existing.get(m.id); + const enabled = prev ? prev.enabled : (autoEnable ? 1 : 0); + st.upsertCatalogModel.run(providerId, m.id, m.label || m.id, enabled, JSON.stringify(m.caps || {}), m.pricing ? JSON.stringify(m.pricing) : null, i); + }); + // A model that left the catalogue is dropped unless the user switched it on — + // then it stays (a chat may be pinned to it) until they remove it. + for (const [id, r] of existing) { + if (!seen.has(id) && r.source === 'catalog' && !r.enabled) st.delModel.run(providerId, id); + } + }); + tx(); + invalidate(); + return { total: models.length, added: models.filter(m => !existing.has(m.id)).length, autoEnabled: autoEnable }; + } + + function setModel(providerId, modelId, patch) { + const r = st.model.get(providerId, modelId); + if (!r) return null; + const sets = [], vals = { p: providerId, m: modelId }; + if (patch.enabled !== undefined) { sets.push('enabled = @enabled'); vals.enabled = patch.enabled ? 1 : 0; } + if (patch.label !== undefined) { sets.push('label = @label'); vals.label = String(patch.label).substring(0, 120); } + if (patch.caps !== undefined) { + const cu = {}; + for (const k of P.CAP_KEYS) { + const v = patch.caps ? patch.caps[k] : undefined; + if (v === true || v === false) cu[k] = v; + else if ((k === 'contextWindow' || k === 'maxOutput') && Number(v) > 0) cu[k] = Math.trunc(Number(v)); + } + sets.push('caps_user = @caps_user'); vals.caps_user = JSON.stringify(cu); + } + if (patch.pricing !== undefined) { sets.push('pricing_user = @pricing_user'); const pr = P.normalizePricing(patch.pricing); vals.pricing_user = pr ? JSON.stringify(pr) : null; } + if (patch.verified !== undefined) { sets.push('verified = @verified'); vals.verified = patch.verified ? JSON.stringify(patch.verified) : null; } + if (!sets.length) return rowToModel(r); + sets.push(`updated_at = datetime('now')`); + db.prepare(`UPDATE provider_models SET ${sets.join(', ')} WHERE provider_id = @p AND model_id = @m`).run(vals); + invalidate(); + return rowToModel(st.model.get(providerId, modelId)); + } + + function addManualModel(providerId, modelId, { label, caps } = {}) { + if (!P.MODEL_ID_RE.test(modelId)) return null; + st.insertManual.run(providerId, modelId, label || modelId, JSON.stringify(P.normalizeCaps(caps))); + invalidate(); + return rowToModel(st.model.get(providerId, modelId)); + } + + function removeModel(providerId, modelId) { + const r = st.delModel.run(providerId, modelId); + invalidate(); + return r.changes > 0; + } + + // ── usage ledger ────────────────────────────────────────────────────────── + function recordUsage(u) { + try { + st.usageInsert.run({ + ts: Math.floor((u.ts || Date.now()) / 1000), run_id: u.runId || null, purpose: u.purpose || null, + session_id: u.sessionId || null, task_id: u.taskId || null, bot_id: u.botId || null, + provider_id: u.providerId || null, model: u.model || null, requested_model: u.requestedModel || null, + input_tokens: u.inputTokens | 0, output_tokens: u.outputTokens | 0, cache_read_tokens: u.cacheReadTokens | 0, + cache_write_tokens: u.cacheWriteTokens | 0, reasoning_tokens: u.reasoningTokens | 0, + cost_usd: u.costUsd == null ? null : Number(u.costUsd), latency_ms: u.latencyMs == null ? null : Math.round(u.latencyMs), + status: u.status || 'ok', http_status: u.httpStatus || null, error_type: u.errorType || null, cli_session_id: u.cliSessionId || null, + }); + } catch (e) { L.warn('llm_usage insert failed', { err: e.message }); } + } + + function usageForRun(runId) { + if (!runId) return null; + const s = st.usageByRun.get(runId); + if (!s || !s.requests) return null; + const last = st.usageLastByRun.get(runId); + return { ...s, lastTurn: last ? { input_tokens: last.input_tokens, cache_read_input_tokens: last.cache_read_tokens, cache_creation_input_tokens: last.cache_write_tokens } : null }; + } + + function usageSummary(days = 30) { + const since = Math.floor(Date.now() / 1000) - Math.max(1, Math.min(366, days | 0)) * 86400; + return st.usageSummary.all(since); + } + + function lastStatusByProvider() { + const out = {}; + for (const r of st.lastStatus.all()) out[r.provider_id] = { status: r.status, errorType: r.error_type, httpStatus: r.http_status, ts: r.ts }; + return out; + } + + function pruneUsage() { + try { st.usagePrune.run(Math.floor(Date.now() / 1000) - USAGE_RETENTION_DAYS * 86400); } catch {} + } + + // ── boot: seed and keep the env-sourced row in sync ─────────────────────── + /** + * First boot: the CLI-login row always exists; ANTHROPIC_BASE_URL (+ its token) becomes + * a provider 'gateway' and the DEFAULT, because that is what every run used until now — + * a bare "sonnet" in an existing row must keep landing on the same endpoint. + * Later boots: an env-sourced row follows the env (edit .env + restart still works, as + * before), until it is edited in the UI — then it is the user's and env is ignored. + */ + function seedFromEnv(env) { + if (!st.get.get(P.BUILTIN_CLAUDE_ID)) { + st.insert.run({ id: P.BUILTIN_CLAUDE_ID, type: 'claude-subscription', label: 'Claude', base_url: '', auth_scheme: 'bearer', + api_key_enc: '', headers_enc: '', dialect: 'generic', options: '{}', roles: '{}', aliases: 1, enabled: 1, source: 'builtin', sort_order: 0 }); + invalidate(); + } + const baseUrl = String(env.ANTHROPIC_BASE_URL || '').trim().replace(/\/+$/, ''); + const authToken = String(env.ANTHROPIC_AUTH_TOKEN || '').trim(); + const apiKey = String(env.ANTHROPIC_API_KEY || '').trim(); + const seeded = getSetting('seededFromEnv'); + const envRow = registry().providers.find(p => p.source === 'env'); + + if (baseUrl && /^https?:\/\//i.test(baseUrl)) { + const key = authToken || apiKey; + const scheme = authToken ? 'bearer' : 'x-api-key'; + if (envRow) { + if (envRow.baseUrl !== baseUrl || envRow.apiKey !== key || envRow.authScheme !== scheme) { + update(envRow.id, { baseUrl, apiKey: key || undefined, clearApiKey: !key, authScheme: scheme }, { fromEnv: true }); + L.info('provider synced from env', { id: envRow.id }); + } + } else if (!seeded) { + let host = baseUrl; + try { host = new URL(baseUrl).host; } catch {} + const id = st.get.get('gateway') ? 'gateway-env' : 'gateway'; + create({ id, type: 'anthropic-compatible', label: host.substring(0, 60), baseUrl, apiKey: key, authScheme: scheme, + aliases: true, source: 'env', options: { quietCli: false } }); + setSetting('defaultProvider', id); + L.info('provider seeded from ANTHROPIC_BASE_URL', { id, host }); + } + } else if (!seeded && authToken) { + // Without a base URL the old code still passed ANTHROPIC_AUTH_TOKEN through (and + // dropped ANTHROPIC_API_KEY so the CLI used the subscription): keep both behaviours. + create({ id: 'anthropic', type: 'anthropic', label: 'Anthropic API', baseUrl: 'https://api.anthropic.com', apiKey: authToken, + authScheme: 'bearer', aliases: true, source: 'env' }); + setSetting('defaultProvider', 'anthropic'); + } else if (!seeded && apiKey) { + create({ id: 'anthropic', type: 'anthropic', label: 'Anthropic API', baseUrl: 'https://api.anthropic.com', apiKey, + authScheme: 'x-api-key', aliases: true, source: 'env' }); + } + if (!seeded) setSetting('seededFromEnv', String(Date.now())); + } + + return { + registry, invalidate, publicList, publicProvider, get, create, update, remove, setDefault, + getSetting, setSetting, upsertCatalog, setModel, addManualModel, removeModel, + recordUsage, usageForRun, usageSummary, lastStatusByProvider, pruneUsage, seedFromEnv, + }; +} + +module.exports = { createProviderStore, SECRET_HEADER_RE, AUTO_ENABLE_MAX, USAGE_RETENTION_DAYS }; diff --git a/providers.js b/providers.js new file mode 100644 index 0000000..92abc22 --- /dev/null +++ b/providers.js @@ -0,0 +1,527 @@ +// Provider registry — the pure half (no fs, no db, no network). +// +// Before this module the studio had exactly one provider: whatever ANTHROPIC_BASE_URL / +// ANTHROPIC_AUTH_TOKEN said at boot, and four hardcoded aliases (haiku/sonnet/opus/fable) +// to pick from. Every run still goes through the `claude` CLI — that is what gives each +// mode its tools, MCP, hooks, resume and compaction — so "another provider" means +// "another endpoint + model for THIS run", decided here and handed to the spawn. +// +// A model is stored as ONE string everywhere it already travels (sessions.model, +// tasks.model, bots.model, WS frames, MCP create_task, plan files, bot exports): +// +// "::" e.g. "deepseek::deepseek-chat", "kilo::z-ai/glm-5:free" +// "" legacy / bare: resolved on the DEFAULT provider +// +// `::` because model ids themselves carry `/` and `:` (":free"), never `::`. A bare value +// keeps every row written before this module meaning exactly what it meant: "sonnet" on +// the provider the studio used to talk to. +// +// Four provider types: +// claude-subscription — the CLI's own login (OAuth). No bridge, no key. The only type +// the tmux "Subscription" engine runs on. +// anthropic — Anthropic API key. Bridge in passthrough mode. +// anthropic-compatible — any /v1/messages endpoint (a gateway such as kilo-gateway, +// DeepSeek/Kimi/GLM/MiniMax Anthropic endpoints). Passthrough. +// openai-compatible — /v1/chat/completions. Bridge TRANSLATES (llm-bridge/). +'use strict'; + +const crypto = require('crypto'); + +const PROVIDER_TYPES = ['claude-subscription', 'anthropic', 'anthropic-compatible', 'openai-compatible']; +const DIALECTS = ['generic', 'openai', 'openrouter', 'deepseek', 'gemini', 'qwen', 'mistral', 'ollama']; +const AUTH_SCHEMES = ['bearer', 'x-api-key', 'both']; +const CLAUDE_ALIASES = ['haiku', 'sonnet', 'opus', 'fable']; +const ROLE_KEYS = ['main', 'fast', 'strong', 'subagent']; +const REF_SEP = '::'; +const BUILTIN_CLAUDE_ID = 'claude'; + +const PROVIDER_ID_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/; +// Wider than gateway-models.MODEL_ID_RE on purpose: Vertex/Bedrock-style ids carry `@`, +// some local servers `+`. Still no whitespace, quotes or shell metacharacters — the value +// reaches argv (`--model`) and, for the subscription engine, a generated shell script. +const MODEL_ID_RE = /^[A-Za-z0-9._:/@+-]{1,160}$/; + +/** Split a stored model value. Bare values keep `providerId: null` (= default provider). */ +function parseModelRef(value) { + if (typeof value !== 'string') return null; + const v = value.trim(); + if (!v) return null; + const i = v.indexOf(REF_SEP); + if (i === -1) return MODEL_ID_RE.test(v) ? { providerId: null, modelId: v } : null; + const providerId = v.slice(0, i), modelId = v.slice(i + REF_SEP.length); + if (!PROVIDER_ID_RE.test(providerId) || !MODEL_ID_RE.test(modelId)) return null; + return { providerId, modelId }; +} + +function formatModelRef(providerId, modelId) { + return providerId ? `${providerId}${REF_SEP}${modelId}` : modelId; +} + +/** True for `provider::model` (not for a bare id) — what chat-defaults accepts beyond its alias list. */ +function isQualifiedRef(value) { + const r = parseModelRef(value); + return !!(r && r.providerId); +} + +/** The model id without its provider — what a REMOTE `claude` (SSH) or the tmux engine can use. */ +function bareModelId(value) { + const r = parseModelRef(value); + return r ? r.modelId : null; +} + +// ── Presets ───────────────────────────────────────────────────────────────── +// A preset only pre-fills the form; every field stays editable. Base URLs are the +// providers' documented OpenAI- / Anthropic-compatible roots. +const PRESETS = [ + { id: 'anthropic', label: 'Anthropic API', type: 'anthropic', baseUrl: 'https://api.anthropic.com', authScheme: 'x-api-key', aliases: true }, + { id: 'gateway', label: 'Anthropic-compatible gateway', type: 'anthropic-compatible', baseUrl: '', authScheme: 'bearer', aliases: true }, + { id: 'openai', label: 'OpenAI', type: 'openai-compatible', baseUrl: 'https://api.openai.com/v1', dialect: 'openai' }, + { id: 'openrouter', label: 'OpenRouter', type: 'openai-compatible', baseUrl: 'https://openrouter.ai/api/v1', dialect: 'openrouter' }, + { id: 'deepseek', label: 'DeepSeek (Anthropic API)', type: 'anthropic-compatible', baseUrl: 'https://api.deepseek.com/anthropic', authScheme: 'x-api-key' }, + { id: 'deepseek-oai', label: 'DeepSeek (OpenAI API)', type: 'openai-compatible', baseUrl: 'https://api.deepseek.com/v1', dialect: 'deepseek' }, + { id: 'moonshot', label: 'Kimi / Moonshot (Anthropic API)', type: 'anthropic-compatible', baseUrl: 'https://api.moonshot.ai/anthropic', authScheme: 'bearer' }, + { id: 'zai', label: 'Z.ai GLM (Anthropic API)', type: 'anthropic-compatible', baseUrl: 'https://api.z.ai/api/anthropic', authScheme: 'bearer' }, + { id: 'minimax', label: 'MiniMax (Anthropic API)', type: 'anthropic-compatible', baseUrl: 'https://api.minimax.io/anthropic', authScheme: 'bearer' }, + { id: 'qwen', label: 'Qwen / DashScope', type: 'openai-compatible', baseUrl: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', dialect: 'qwen' }, + { id: 'gemini', label: 'Google Gemini', type: 'openai-compatible', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', dialect: 'gemini' }, + { id: 'groq', label: 'Groq', type: 'openai-compatible', baseUrl: 'https://api.groq.com/openai/v1', dialect: 'generic' }, + { id: 'mistral', label: 'Mistral', type: 'openai-compatible', baseUrl: 'https://api.mistral.ai/v1', dialect: 'mistral' }, + { id: 'xai', label: 'xAI Grok', type: 'openai-compatible', baseUrl: 'https://api.x.ai/v1', dialect: 'generic' }, + { id: 'ollama', label: 'Ollama (local)', type: 'openai-compatible', baseUrl: 'http://localhost:11434/v1', dialect: 'ollama', noKey: true }, + { id: 'lmstudio', label: 'LM Studio (local)', type: 'openai-compatible', baseUrl: 'http://localhost:1234/v1', dialect: 'generic', noKey: true }, + { id: 'custom-oai', label: 'OpenAI-compatible (custom)', type: 'openai-compatible', baseUrl: '', dialect: 'generic' }, +]; + +// ── Capabilities ──────────────────────────────────────────────────────────── +// null = unknown. Only an explicit `false` restricts anything: an unknown model is +// offered everywhere (like gateway-models did), a model that SAYS it has no tools is +// kept out of agent modes. +const CAP_KEYS = ['tools', 'vision', 'reasoning', 'pdf', 'contextWindow', 'maxOutput']; +const CLAUDE_CAPS = Object.freeze({ tools: true, vision: true, reasoning: true, pdf: true, contextWindow: null, maxOutput: null }); +const UNKNOWN_CAPS = Object.freeze({ tools: null, vision: null, reasoning: null, pdf: null, contextWindow: null, maxOutput: null }); + +function normalizeCaps(raw) { + const out = { ...UNKNOWN_CAPS }; + if (!raw || typeof raw !== 'object') return out; + for (const k of ['tools', 'vision', 'reasoning', 'pdf']) { + if (raw[k] === true || raw[k] === false) out[k] = raw[k]; + } + for (const k of ['contextWindow', 'maxOutput']) { + const n = Number(raw[k]); + if (Number.isFinite(n) && n > 0) out[k] = Math.trunc(n); + } + return out; +} + +/** Pricing in USD per 1M tokens. */ +function normalizePricing(raw) { + if (!raw || typeof raw !== 'object') return null; + const out = {}; + for (const k of ['in', 'out', 'cacheRead']) { + // Number(null) is 0: an ABSENT price must stay absent, not become "free". + if (raw[k] == null || raw[k] === '') continue; + const n = Number(raw[k]); + if (Number.isFinite(n) && n >= 0) out[k] = n; + } + return Object.keys(out).length ? out : null; +} + +// ── Catalogue normalisation ───────────────────────────────────────────────── +// One parser for every /models shape we meet: OpenAI (`{data:[{id}]}`, ids only), +// OpenRouter/Kilo (context_length, supported_parameters, architecture, pricing per +// TOKEN as strings), Anthropic (`{data:[{type:'model', id, display_name}]}`), Ollama. +function perMillion(v) { + const n = Number(v); + return Number.isFinite(n) && n >= 0 ? Math.round(n * 1e6 * 1e6) / 1e6 : null; +} + +function normalizeCatalog(json) { + const arr = Array.isArray(json) ? json : (json && (json.data || json.models)); + if (!Array.isArray(arr)) return []; + const seen = new Set(), out = []; + for (const m of arr) { + if (!m || typeof m !== 'object') continue; + const id = typeof m.id === 'string' ? m.id.trim() : (typeof m.name === 'string' && !m.id ? m.name.trim() : ''); + if (!id || !MODEL_ID_RE.test(id) || seen.has(id)) continue; + seen.add(id); + const params = Array.isArray(m.supported_parameters) ? m.supported_parameters : null; + const mods = Array.isArray(m.architecture?.input_modalities) ? m.architecture.input_modalities + : (Array.isArray(m.input_modalities) ? m.input_modalities : null); + const caps = normalizeCaps({ + tools: params ? params.includes('tools') : (m.capabilities?.tools ?? m.supports_tools), + reasoning: params ? (params.includes('reasoning') || params.includes('include_reasoning')) : (m.capabilities?.reasoning ?? m.supports_reasoning), + vision: mods ? mods.includes('image') : (m.capabilities?.vision ?? m.supports_vision), + pdf: mods ? mods.includes('file') : null, + contextWindow: m.context_length ?? m.context_window ?? m.max_input_tokens ?? m.top_provider?.context_length, + maxOutput: m.top_provider?.max_completion_tokens ?? m.max_output_tokens ?? m.max_tokens, + }); + let pricing = null; + if (m.pricing && typeof m.pricing === 'object') { + pricing = normalizePricing({ in: perMillion(m.pricing.prompt), out: perMillion(m.pricing.completion), cacheRead: perMillion(m.pricing.input_cache_read) }); + } + const label = [m.display_name, m.name].find(s => typeof s === 'string' && s.trim()) || id; + out.push({ id, label: String(label).trim().substring(0, 120), caps, pricing }); + } + return out; +} + +// ── Resolution ────────────────────────────────────────────────────────────── +// registry: { providers: ProviderRecord[], defaultProviderId, utilityModel } +// ProviderRecord (decrypted, server-side only): +// { id, type, label, enabled, source, baseUrl, apiKey, authScheme, headers, dialect, +// options, roles: {main, fast, strong, subagent}, aliases: bool, +// models: [{ id, label, enabled, source, caps, pricing }] } + +function findProvider(registry, id) { + return (registry && Array.isArray(registry.providers)) ? registry.providers.find(p => p.id === id) || null : null; +} + +function builtinClaude(registry) { + return findProvider(registry, BUILTIN_CLAUDE_ID) + || { id: BUILTIN_CLAUDE_ID, type: 'claude-subscription', label: 'Claude', enabled: true, aliases: true, models: [], roles: {}, options: {} }; +} + +/** The provider a BARE value means for this engine. */ +function defaultProviderFor(registry, engine) { + if (engine === 'subscription') return builtinClaude(registry); + const d = findProvider(registry, registry && registry.defaultProviderId); + if (d && d.enabled !== false) return d; + return builtinClaude(registry); +} + +function supportsAliases(p) { + if (p.type === 'claude-subscription' || p.type === 'anthropic') return true; + if (p.type === 'openai-compatible') return false; + return p.aliases === true; +} + +function enabledModels(p) { + return (Array.isArray(p.models) ? p.models : []).filter(m => m && m.enabled !== false); +} + +/** A role → upstream model id, falling back to `main`, then the first enabled model. */ +function roleModel(p, role) { + const r = p.roles || {}; + if (r[role]) return r[role]; + if (r.main) return r.main; + const first = enabledModels(p)[0]; + return first ? first.id : null; +} + +const ALIAS_ROLE = { haiku: 'fast', sonnet: 'main', opus: 'strong', fable: 'main' }; + +function capsFor(p, modelId) { + const m = (Array.isArray(p.models) ? p.models : []).find(x => x && x.id === modelId); + if (m) return normalizeCaps(m.caps); + if ((p.type === 'claude-subscription' || p.type === 'anthropic') || CLAUDE_ALIASES.includes(modelId) || /^claude-/i.test(modelId)) return { ...CLAUDE_CAPS }; + return { ...UNKNOWN_CAPS }; +} + +function pricingFor(p, modelId) { + const m = (Array.isArray(p.models) ? p.models : []).find(x => x && x.id === modelId); + return m ? normalizePricing(m.pricing) : null; +} + +/** + * Resolve a stored model value for one run. + * @param value what the row/frame carries ('sonnet', 'deepseek::deepseek-chat', …) + * @param opts { engine: 'api'|'subscription'|'ssh' } + * @returns {{ok:true, ref, provider, modelId, cliModel, caps, pricing, engine, routing, fallback?:string, engineChanged?:boolean} + * | {ok:false, error:string}} + * routing: 'oauth' — the CLI's own login, nothing injected + * 'bridge' — ANTHROPIC_BASE_URL = the local llm-bridge, run token as the key + * 'remote' — SSH: the remote host's own `claude` config decides + */ +function resolveModel(value, registry, opts = {}) { + const engine = opts.engine || 'api'; + const parsed = parseModelRef(value == null || value === '' ? 'sonnet' : String(value)); + if (!parsed) return { ok: false, error: 'invalid_model' }; + + let provider, fallback = null; + if (parsed.providerId) { + provider = findProvider(registry, parsed.providerId); + if (!provider || provider.enabled === false) { + // A chat pinned to a provider that was deleted or switched off must still run — + // on the default, and the caller says so — rather than fail every turn forever. + fallback = provider ? 'provider_disabled' : 'provider_missing'; + provider = defaultProviderFor(registry, engine); + } + } else { + provider = defaultProviderFor(registry, engine); + } + + let modelId = parsed.modelId; + if (fallback) { + modelId = supportsAliases(provider) ? 'sonnet' : roleModel(provider, 'main'); + } else if (CLAUDE_ALIASES.includes(modelId) && !supportsAliases(provider)) { + modelId = roleModel(provider, ALIAS_ROLE[modelId]); + } + if (!modelId) return { ok: false, error: 'no_model', providerId: provider.id }; + + let effEngine = engine, engineChanged = false; + if (engine === 'subscription' && provider.type !== 'claude-subscription') { effEngine = 'api'; engineChanged = true; } + const routing = effEngine === 'ssh' ? 'remote' : (provider.type === 'claude-subscription' ? 'oauth' : 'bridge'); + + return { + ok: true, + ref: formatModelRef(provider.id, modelId), + provider, modelId, + cliModel: modelId, + caps: capsFor(provider, modelId), + pricing: pricingFor(provider, modelId), + engine: effEngine, engineChanged, routing, + ...(fallback ? { fallback } : {}), + }; +} + +/** Engine a run should really use: the tmux Subscription engine runs Claude's own login only. */ +function effectiveEngine(engine, value, registry) { + if (engine !== 'subscription') return engine; + const r = resolveModel(value, registry, { engine }); + return r.ok ? r.engine : engine; +} + +// ── Environment for one run ───────────────────────────────────────────────── +// Every variable a provider decision may set. They are stripped from the inherited +// environment FIRST, so a stale ANTHROPIC_BASE_URL in the server's own env (docker +// compose still passes it) can never leak into a run that chose another provider. +const PROVIDER_ENV_VARS = [ + 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_DEFAULT_FABLE_MODEL', 'ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL', + 'ANTHROPIC_CUSTOM_HEADERS', 'CLAUDE_CODE_MAX_CONTEXT_TOKENS', 'CLAUDE_CODE_MAX_OUTPUT_TOKENS', + 'CLAUDE_CODE_ALWAYS_ENABLE_EFFORT', +]; + +// The CLI asks for up to 64K output tokens by default; many models answer that with a 400. +const MAX_OUTPUT_CEILING = 32000; + +/** + * @param target resolveModel() result + * @param bridge { baseUrl, token } for routing 'bridge' + * @returns {{ set: Object, unset: string[], extraArgs: string[] }} + */ +function buildRunEnv(target, bridge = {}) { + const set = {}, unset = PROVIDER_ENV_VARS.slice(), extraArgs = []; + if (!target || !target.ok || target.routing === 'oauth' || target.routing === 'remote') { + return { set, unset, extraArgs }; + } + const p = target.provider, caps = target.caps || UNKNOWN_CAPS, o = p.options || {}; + const firstParty = p.type === 'anthropic'; + set.ANTHROPIC_BASE_URL = bridge.baseUrl; + set.ANTHROPIC_AUTH_TOKEN = bridge.token; + + // Which model the CLI's OWN background calls use. It asks for "haiku" (WebFetch + // summaries, Explore sub-agents) and "sonnet"/"opus" by alias; a provider that does + // not serve Claude would answer every one of those with 404. + // A provider that serves the aliases itself (a Claude gateway) keeps its own mapping + // unless a role was pinned explicitly; any other provider always gets one. + const aliases = supportsAliases(p); + const r = p.roles || {}; + const haiku = aliases ? (r.fast || null) : (roleModel(p, 'fast') || target.modelId); + const sonnet = aliases ? (r.main || null) : (roleModel(p, 'main') || target.modelId); + const opus = aliases ? (r.strong || null) : (roleModel(p, 'strong') || target.modelId); + if (haiku) { set.ANTHROPIC_DEFAULT_HAIKU_MODEL = haiku; set.ANTHROPIC_SMALL_FAST_MODEL = haiku; } + if (sonnet) { set.ANTHROPIC_DEFAULT_SONNET_MODEL = sonnet; set.ANTHROPIC_DEFAULT_FABLE_MODEL = sonnet; } + if (opus) set.ANTHROPIC_DEFAULT_OPUS_MODEL = opus; + if (r.subagent) set.CLAUDE_CODE_SUBAGENT_MODEL = r.subagent; + + if (!firstParty) { + // For an id the CLI does not recognise it assumes a 200K window and compacts on + // that — too late for a 128K model, far too early for a 1M one. + if (caps.contextWindow) set.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(caps.contextWindow); + // The CLI sends --effort only for models it knows support it; the bridge maps the + // run's effort itself, but an Anthropic-compatible upstream needs the field. + if (caps.reasoning !== false) set.CLAUDE_CODE_ALWAYS_ENABLE_EFFORT = '1'; + // WebSearch is an Anthropic SERVER tool: behind any other endpoint the nested call + // answers without searching. The `web` MCP server (SearXNG) is the working substitute. + extraArgs.push('--disallowedTools', 'WebSearch'); + } + if (caps.maxOutput) set.CLAUDE_CODE_MAX_OUTPUT_TOKENS = String(Math.min(caps.maxOutput, MAX_OUTPUT_CEILING)); + if (caps.reasoning === false) set.CLAUDE_CODE_DISABLE_THINKING = '1'; + if (p.type === 'openai-compatible' || o.stripBetas) set.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = '1'; + if (o.quietCli !== false && !firstParty) set.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1'; + if (Number(o.apiTimeoutMs) > 0) set.API_TIMEOUT_MS = String(Math.trunc(Number(o.apiTimeoutMs))); + return { set, unset, extraArgs }; +} + +/** Apply buildRunEnv() to a copied environment object (mutates and returns it). */ +function applyRunEnv(env, runEnv) { + if (!runEnv) return env; + for (const k of runEnv.unset) delete env[k]; + for (const [k, v] of Object.entries(runEnv.set)) { if (v != null && v !== '') env[k] = String(v); } + return env; +} + +// ── Bridge run context ────────────────────────────────────────────────────── +/** What the llm-bridge needs to serve one run (see llm-bridge/server.js RunCtx). */ +function buildRunCtx(target, meta = {}) { + const p = target.provider; + const models = {}, modelMap = {}; + const add = (id) => { if (id && !models[id]) models[id] = capsFor(p, id); }; + add(target.modelId); + for (const role of ROLE_KEYS) add((p.roles || {})[role]); + if (!supportsAliases(p)) { + for (const a of CLAUDE_ALIASES) { + const id = roleModel(p, ALIAS_ROLE[a]) || target.modelId; + modelMap[a] = id; add(id); + } + } + return { + runId: meta.runId || crypto.randomBytes(8).toString('hex'), + purpose: meta.purpose || 'chat', + sessionId: meta.sessionId || null, + taskId: meta.taskId || null, + botId: meta.botId || null, + provider: { + id: p.id, label: p.label, type: p.type, + baseUrl: p.baseUrl, apiKey: p.apiKey || '', authScheme: p.authScheme || 'bearer', + headers: p.headers || {}, dialect: p.dialect || 'generic', options: p.options || {}, + }, + model: target.modelId, + models, modelMap, + fallbackModel: supportsAliases(p) ? null : (roleModel(p, 'fast') || target.modelId), + effort: ['low', 'medium', 'high', 'xhigh', 'max'].includes(meta.effort) ? meta.effort : null, + }; +} + +// ── Choices for the pickers ───────────────────────────────────────────────── +function publicModel(p, m) { + return { ref: formatModelRef(p.id, m.id), id: m.id, label: m.label || m.id, caps: normalizeCaps(m.caps), pricing: normalizePricing(m.pricing) }; +} + +/** Grouped list every model picker renders. Never carries a key. */ +function listChoices(registry) { + const providers = []; + for (const p of (registry && registry.providers) || []) { + if (p.enabled === false) continue; + const models = []; + if (supportsAliases(p)) { + for (const a of CLAUDE_ALIASES) models.push({ ref: formatModelRef(p.id, a), id: a, label: a, alias: true, caps: { ...CLAUDE_CAPS }, pricing: null }); + } + for (const m of enabledModels(p)) { + if (CLAUDE_ALIASES.includes(m.id)) continue; + models.push(publicModel(p, m)); + } + providers.push({ id: p.id, label: p.label, type: p.type, builtin: p.id === BUILTIN_CLAUDE_ID, isDefault: p.id === (registry.defaultProviderId || BUILTIN_CLAUDE_ID), models }); + } + return { defaultProviderId: registry && registry.defaultProviderId || BUILTIN_CLAUDE_ID, utilityModel: registry && registry.utilityModel || 'haiku', providers }; +} + +/** Is this value something a run can use right now? (for write-time validation) */ +function isKnownModel(value, registry) { + const r = parseModelRef(value); + if (!r) return false; + if (!r.providerId) return true; // bare: always resolvable on the default provider + const p = findProvider(registry, r.providerId); + if (!p || p.enabled === false) return false; + if (supportsAliases(p) && CLAUDE_ALIASES.includes(r.modelId)) return true; + if (!supportsAliases(p) && CLAUDE_ALIASES.includes(r.modelId)) return !!roleModel(p, ALIAS_ROLE[r.modelId]); + // A model the catalogue does not list is still accepted: catalogues lag (and some + // providers publish none), and a hand-typed id is a legitimate choice. + return true; +} + +/** Human label for a stored value, e.g. "DeepSeek · deepseek-chat". */ +function describeModel(value, registry) { + const r = parseModelRef(value); + if (!r) return String(value || ''); + if (!r.providerId) return r.modelId; + const p = findProvider(registry, r.providerId); + const m = p && (p.models || []).find(x => x.id === r.modelId); + return `${p ? p.label : r.providerId} · ${m && m.label ? m.label : r.modelId}`; +} + +/** Cost in USD of one usage record under a pricing table (USD per 1M tokens). */ +function computeCost(u, pricing) { + if (!u || !pricing) return null; + const inp = Number(u.inputTokens) || 0, out = Number(u.outputTokens) || 0, cr = Number(u.cacheReadTokens) || 0; + const c = (inp * (pricing.in || 0) + out * (pricing.out || 0) + cr * (pricing.cacheRead != null ? pricing.cacheRead : (pricing.in || 0))) / 1e6; + return Math.round(c * 1e6) / 1e6; +} + +// ── Validation of a provider record coming from the API ───────────────────── +function validateProviderInput(raw, { creating } = {}) { + const errors = []; + const out = {}; + if (!raw || typeof raw !== 'object') return { errors: ['body'], value: out }; + if (creating) { + if (!PROVIDER_ID_RE.test(String(raw.id || ''))) errors.push('id'); + else out.id = raw.id; + } + if (raw.type !== undefined) { + if (!PROVIDER_TYPES.includes(raw.type) || raw.type === 'claude-subscription') errors.push('type'); + else out.type = raw.type; + } else if (creating) errors.push('type'); + if (raw.label !== undefined) { + const l = String(raw.label).trim(); + if (!l || l.length > 60) errors.push('label'); else out.label = l; + } else if (creating) errors.push('label'); + if (raw.baseUrl !== undefined) { + const u = String(raw.baseUrl).trim().replace(/\/+$/, ''); + if (!/^https?:\/\/[^\s/?#]+[^\s]*$/i.test(u)) errors.push('baseUrl'); else out.baseUrl = u; + } else if (creating) errors.push('baseUrl'); + if (raw.authScheme !== undefined) { + if (!AUTH_SCHEMES.includes(raw.authScheme)) errors.push('authScheme'); else out.authScheme = raw.authScheme; + } + if (raw.dialect !== undefined) { + if (!DIALECTS.includes(raw.dialect)) errors.push('dialect'); else out.dialect = raw.dialect; + } + if (raw.apiKey !== undefined && raw.apiKey !== null) { + const k = String(raw.apiKey); + if (k.length > 4096 || /[\r\n]/.test(k)) errors.push('apiKey'); else out.apiKey = k.trim(); + } + if (raw.headers !== undefined) { + if (!raw.headers || typeof raw.headers !== 'object' || Array.isArray(raw.headers)) errors.push('headers'); + else { + const h = {}; + for (const [k, v] of Object.entries(raw.headers)) { + if (!/^[A-Za-z0-9-]{1,64}$/.test(k) || typeof v !== 'string' || /[\r\n]/.test(v) || v.length > 2048) { errors.push('headers'); break; } + h[k] = v; + } + out.headers = h; + } + } + if (raw.roles !== undefined) { + if (!raw.roles || typeof raw.roles !== 'object') errors.push('roles'); + else { + const r = {}; + for (const k of ROLE_KEYS) { + const v = raw.roles[k]; + if (v == null || v === '') continue; + if (typeof v !== 'string' || !MODEL_ID_RE.test(v)) { errors.push('roles'); break; } + r[k] = v; + } + out.roles = r; + } + } + if (raw.aliases !== undefined) out.aliases = !!raw.aliases; + if (raw.enabled !== undefined) out.enabled = !!raw.enabled; + if (raw.options !== undefined) { + if (!raw.options || typeof raw.options !== 'object' || Array.isArray(raw.options)) errors.push('options'); + else { + const o = {}; + const num = (k, min, max) => { if (raw.options[k] == null || raw.options[k] === '') return; const n = Number(raw.options[k]); if (!Number.isFinite(n) || n < min || n > max) errors.push(`options.${k}`); else o[k] = Math.trunc(n); }; + num('timeoutMs', 10000, 3600000); + num('apiTimeoutMs', 10000, 3600000); + num('maxConcurrency', 1, 64); + for (const b of ['stripBetas', 'quietCli']) if (raw.options[b] !== undefined) o[b] = !!raw.options[b]; + if (raw.options.extraBody !== undefined && raw.options.extraBody !== null) { + const eb = raw.options.extraBody; + if (typeof eb !== 'object' || Array.isArray(eb) || JSON.stringify(eb).length > 8192) errors.push('options.extraBody'); else o.extraBody = eb; + } + out.options = o; + } + } + return { errors: [...new Set(errors)], value: out }; +} + +module.exports = { + PROVIDER_TYPES, DIALECTS, AUTH_SCHEMES, CLAUDE_ALIASES, ROLE_KEYS, REF_SEP, BUILTIN_CLAUDE_ID, + PROVIDER_ID_RE, MODEL_ID_RE, PRESETS, CAP_KEYS, CLAUDE_CAPS, UNKNOWN_CAPS, PROVIDER_ENV_VARS, MAX_OUTPUT_CEILING, + parseModelRef, formatModelRef, isQualifiedRef, bareModelId, + normalizeCaps, normalizePricing, normalizeCatalog, + findProvider, defaultProviderFor, supportsAliases, roleModel, capsFor, + resolveModel, effectiveEngine, buildRunEnv, applyRunEnv, buildRunCtx, + listChoices, isKnownModel, describeModel, computeCost, validateProviderInput, +}; diff --git a/test/providers.test.js b/test/providers.test.js new file mode 100644 index 0000000..0b59096 --- /dev/null +++ b/test/providers.test.js @@ -0,0 +1,237 @@ +// Provider registry: model refs, resolution, run environment, catalogue parsing, the +// SQLite store and the catalogue fetcher. Run: node test/providers.test.js +'use strict'; +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const crypto = require('crypto'); +const P = require('../providers'); +const { createProviderStore } = require('../providers-store'); +const { fetchCatalog } = require('../providers-catalog'); +const openDatabase = require('../db-adapter'); + +let pass = 0, fail = 0; +function check(label, actual, expected) { + try { assert.deepStrictEqual(actual, expected); pass++; console.log(` ok ${label}`); } + catch { fail++; console.error(` FAIL ${label} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); } +} + +// ── fixtures ──────────────────────────────────────────────────────────────── +const claude = { id: 'claude', type: 'claude-subscription', label: 'Claude', enabled: true, aliases: true, models: [], roles: {}, options: {} }; +const gateway = { id: 'gateway', type: 'anthropic-compatible', label: 'kilo', enabled: true, aliases: true, baseUrl: 'https://kilo.example', apiKey: 'kgw', authScheme: 'bearer', + models: [{ id: 'z-ai/glm-5:free', enabled: true, caps: { tools: true, contextWindow: 200000 } }], roles: {}, options: { quietCli: false } }; +const deepseek = { id: 'deepseek', type: 'openai-compatible', label: 'DeepSeek', enabled: true, aliases: false, baseUrl: 'https://api.deepseek.com/v1', apiKey: 'sk-ds', dialect: 'deepseek', + models: [ + { id: 'deepseek-chat', enabled: true, caps: { tools: true, vision: false, reasoning: false, contextWindow: 128000, maxOutput: 8192 } }, + { id: 'deepseek-reasoner', enabled: true, caps: { tools: true, reasoning: true, contextWindow: 128000, maxOutput: 64000 } }, + ], roles: { main: 'deepseek-chat', fast: 'deepseek-chat', strong: 'deepseek-reasoner' }, options: {} }; +const off = { id: 'off', type: 'openai-compatible', label: 'Off', enabled: false, models: [{ id: 'm', enabled: true }], roles: {}, options: {} }; +const reg = { providers: [claude, gateway, deepseek, off], defaultProviderId: 'gateway', utilityModel: '' }; + +console.log('model refs:'); +check('bare alias', P.parseModelRef('sonnet'), { providerId: null, modelId: 'sonnet' }); +check('bare gateway id keeps its colon suffix', P.parseModelRef('z-ai/glm-5:free'), { providerId: null, modelId: 'z-ai/glm-5:free' }); +check('qualified ref', P.parseModelRef('kilo::z-ai/glm-5:free'), { providerId: 'kilo', modelId: 'z-ai/glm-5:free' }); +check('garbage is refused', ['', ' ', 'a b', 'x;rm', 'Bad::m', '::m', 'p::', 'p::a b', null, 5].map(v => P.parseModelRef(v)), [null, null, null, null, null, null, null, null, null, null]); +check('format round-trips', P.formatModelRef('deepseek', 'deepseek-chat'), 'deepseek::deepseek-chat'); +check('bareModelId strips the provider', [P.bareModelId('claude::opus'), P.bareModelId('opus'), P.bareModelId('x y')], ['opus', 'opus', null]); +check('a qualified ref still passes the bot model check (gateway-models.MODEL_ID_RE)', require('../gateway-models').MODEL_ID_RE.test('deepseek::deepseek-chat'), true); + +console.log('resolution:'); +{ + const r = P.resolveModel('sonnet', reg, { engine: 'api' }); + check('a bare alias lands on the default provider, unchanged (what every old row meant)', [r.ok, r.provider.id, r.cliModel, r.routing, r.ref], [true, 'gateway', 'sonnet', 'bridge', 'gateway::sonnet']); +} +{ + const r = P.resolveModel('z-ai/glm-5:free', reg, { engine: 'api' }); + check('a bare gateway id (old bot row) lands on the default provider', [r.provider.id, r.cliModel, r.caps.contextWindow], ['gateway', 'z-ai/glm-5:free', 200000]); +} +{ + const r = P.resolveModel('deepseek::deepseek-reasoner', reg, { engine: 'api' }); + check('a qualified ref picks its provider and model', [r.provider.id, r.cliModel, r.caps.reasoning, r.pricing], ['deepseek', 'deepseek-reasoner', true, null]); +} +{ + const r = P.resolveModel('haiku', { ...reg, defaultProviderId: 'deepseek' }, { engine: 'api' }); + check('an alias on a provider that serves no Claude maps through its roles', [r.provider.id, r.cliModel], ['deepseek', 'deepseek-chat']); + const o = P.resolveModel('deepseek::opus', reg, { engine: 'api' }); + check('…opus → the strong role', o.cliModel, 'deepseek-reasoner'); +} +{ + const r = P.resolveModel('sonnet', reg, { engine: 'subscription' }); + check('the Subscription engine means the CLI login, whatever the default is', [r.provider.id, r.routing, r.engine, r.engineChanged], ['claude', 'oauth', 'subscription', false]); + const d = P.resolveModel('deepseek::deepseek-chat', reg, { engine: 'subscription' }); + check('a non-Claude model on the Subscription engine runs on the API engine instead, and says so', [d.provider.id, d.engine, d.engineChanged, d.routing], ['deepseek', 'api', true, 'bridge']); + check('effectiveEngine mirrors it', [P.effectiveEngine('subscription', 'deepseek::deepseek-chat', reg), P.effectiveEngine('subscription', 'opus', reg), P.effectiveEngine('api', 'opus', reg)], ['api', 'subscription', 'api']); +} +{ + const r = P.resolveModel('ghost::gpt-9', reg, { engine: 'api' }); + check('a deleted provider falls back to the default, flagged', [r.ok, r.provider.id, r.cliModel, r.fallback], [true, 'gateway', 'sonnet', 'provider_missing']); + const o = P.resolveModel('off::m', reg, { engine: 'api' }); + check('a switched-off provider falls back too', [o.provider.id, o.fallback], ['gateway', 'provider_disabled']); + const n = P.resolveModel('ghost::x', { ...reg, defaultProviderId: 'deepseek' }, { engine: 'api' }); + check('…onto the default provider\'s main model when it serves no aliases', [n.provider.id, n.cliModel], ['deepseek', 'deepseek-chat']); +} +{ + check('the SSH engine routes remote (the remote host decides)', P.resolveModel('deepseek::deepseek-chat', reg, { engine: 'ssh' }).routing, 'remote'); + check('no default configured → the CLI login', P.resolveModel('sonnet', { providers: [claude] }, {}).provider.id, 'claude'); + check('empty value means the built-in default model', P.resolveModel('', reg, {}).cliModel, 'sonnet'); + check('an invalid value is an error, not a silent sonnet', P.resolveModel('x;rm -rf', reg, {}), { ok: false, error: 'invalid_model' }); + const empty = { id: 'e', type: 'openai-compatible', label: 'E', enabled: true, models: [], roles: {}, options: {} }; + check('a provider with no models at all cannot resolve an alias', P.resolveModel('e::sonnet', { providers: [claude, empty] }, {}).error, 'no_model'); +} + +console.log('run environment:'); +{ + const oauth = P.buildRunEnv(P.resolveModel('opus', reg, { engine: 'subscription' })); + check('the CLI login injects nothing and strips every provider variable', [Object.keys(oauth.set).length, oauth.unset.includes('ANTHROPIC_BASE_URL'), oauth.unset.includes('ANTHROPIC_API_KEY'), oauth.extraArgs], [0, true, true, []]); + const env = P.applyRunEnv({ ANTHROPIC_BASE_URL: 'https://stale', ANTHROPIC_AUTH_TOKEN: 'leak', PATH: '/bin' }, oauth); + check('…so a stale base URL in the server env cannot reach that run', env, { PATH: '/bin' }); +} +{ + const t = P.resolveModel('deepseek::deepseek-chat', reg, { engine: 'api' }); + const e = P.buildRunEnv(t, { baseUrl: 'http://127.0.0.1:4567', token: 'ccsr_x' }); + check('a bridge run gets the bridge and its run token — never the provider key', [e.set.ANTHROPIC_BASE_URL, e.set.ANTHROPIC_AUTH_TOKEN, JSON.stringify(e).includes('sk-ds')], ['http://127.0.0.1:4567', 'ccsr_x', false]); + check('the CLI\'s own alias calls are mapped onto the provider\'s roles', [e.set.ANTHROPIC_DEFAULT_HAIKU_MODEL, e.set.ANTHROPIC_DEFAULT_SONNET_MODEL, e.set.ANTHROPIC_DEFAULT_OPUS_MODEL, e.set.ANTHROPIC_DEFAULT_FABLE_MODEL, e.set.ANTHROPIC_SMALL_FAST_MODEL], ['deepseek-chat', 'deepseek-chat', 'deepseek-reasoner', 'deepseek-chat', 'deepseek-chat']); + check('the real window, the output cap and the thinking switch reach the CLI', [e.set.CLAUDE_CODE_MAX_CONTEXT_TOKENS, e.set.CLAUDE_CODE_MAX_OUTPUT_TOKENS, e.set.CLAUDE_CODE_DISABLE_THINKING], ['128000', '8192', '1']); + check('non-Anthropic: betas off, quiet CLI, WebSearch disallowed', [e.set.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS, e.set.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, e.extraArgs], ['1', '1', ['--disallowedTools', 'WebSearch']]); + const r = P.buildRunEnv(P.resolveModel('deepseek::deepseek-reasoner', reg, {}), { baseUrl: 'b', token: 't' }); + check('a reasoning model gets effort forced on and the output cap clamped to 32K', [r.set.CLAUDE_CODE_ALWAYS_ENABLE_EFFORT, r.set.CLAUDE_CODE_MAX_OUTPUT_TOKENS, r.set.CLAUDE_CODE_DISABLE_THINKING], ['1', '32000', undefined]); +} +{ + const e = P.buildRunEnv(P.resolveModel('sonnet', reg, {}), { baseUrl: 'b', token: 't' }); + check('the migrated gateway keeps its own alias mapping and stays chatty (behaviour unchanged)', [e.set.ANTHROPIC_DEFAULT_SONNET_MODEL, e.set.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, e.set.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS], [undefined, undefined, undefined]); + const pinned = P.buildRunEnv(P.resolveModel('sonnet', { ...reg, providers: [claude, { ...gateway, roles: { fast: 'x/fast' } }] }, {}), { baseUrl: 'b', token: 't' }); + check('…unless a role was pinned', [pinned.set.ANTHROPIC_DEFAULT_HAIKU_MODEL, pinned.set.ANTHROPIC_DEFAULT_SONNET_MODEL], ['x/fast', undefined]); + const anth = { id: 'anthropic', type: 'anthropic', label: 'A', enabled: true, models: [], roles: {}, options: {} }; + const a = P.buildRunEnv(P.resolveModel('anthropic::opus', { providers: [claude, anth] }, {}), { baseUrl: 'b', token: 't' }); + check('first-party Anthropic keeps WebSearch and the CLI defaults', [a.extraArgs, a.set.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, a.set.CLAUDE_CODE_ALWAYS_ENABLE_EFFORT], [[], undefined, undefined]); +} + +console.log('bridge run context:'); +{ + const ctx = P.buildRunCtx(P.resolveModel('deepseek::deepseek-reasoner', reg, {}), { purpose: 'task', taskId: 't1', effort: 'high' }); + check('carries provider credentials for the bridge only', [ctx.provider.apiKey, ctx.provider.baseUrl, ctx.provider.dialect], ['sk-ds', 'https://api.deepseek.com/v1', 'deepseek']); + check('main model, caps of every role model, alias map, fallback', [ctx.model, Object.keys(ctx.models).sort(), ctx.modelMap.haiku, ctx.modelMap.opus, ctx.fallbackModel], ['deepseek-reasoner', ['deepseek-chat', 'deepseek-reasoner'], 'deepseek-chat', 'deepseek-reasoner', 'deepseek-chat']); + check('effort is the source of truth, validated', [ctx.effort, P.buildRunCtx(P.resolveModel('deepseek::deepseek-chat', reg, {}), { effort: 'auto' }).effort], ['high', null]); + check('metadata for the usage ledger', [ctx.purpose, ctx.taskId, typeof ctx.runId], ['task', 't1', 'string']); +} + +console.log('catalogues:'); +{ + const or = P.normalizeCatalog({ data: [ + { id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5', context_length: 1000000, supported_parameters: ['tools', 'reasoning'], + architecture: { input_modalities: ['text', 'image', 'file'] }, top_provider: { max_completion_tokens: 64000 }, pricing: { prompt: '0.000003', completion: '0.000015', input_cache_read: '0.0000003' } }, + { id: 'meta/llama:free', supported_parameters: ['temperature'], architecture: { input_modalities: ['text'] }, pricing: { prompt: '0', completion: '0' } }, + { id: 'bad id' }, { id: 'meta/llama:free' }, + ] }); + check('OpenRouter/Kilo shape: caps, window, output cap', or[0].caps, { tools: true, vision: true, reasoning: true, pdf: true, contextWindow: 1000000, maxOutput: 64000 }); + check('…pricing converted from per-token to per-1M', or[0].pricing, { in: 3, out: 15, cacheRead: 0.3 }); + check('…an explicit "no tools" survives, junk and duplicates dropped', [or.length, or[1].caps.tools, or[1].pricing], [2, false, { in: 0, out: 0 }]); + check('OpenAI shape (ids only) → unknown caps', P.normalizeCatalog({ object: 'list', data: [{ id: 'gpt-5', object: 'model' }] })[0], { id: 'gpt-5', label: 'gpt-5', caps: { ...P.UNKNOWN_CAPS }, pricing: null }); + check('Anthropic shape uses display_name', P.normalizeCatalog({ data: [{ type: 'model', id: 'claude-opus-5', display_name: 'Claude Opus 5' }] })[0].label, 'Claude Opus 5'); +} + +console.log('pickers and validation:'); +{ + const c = P.listChoices(reg); + check('disabled providers are not offered', c.providers.map(p => p.id), ['claude', 'gateway', 'deepseek']); + check('Claude-serving providers offer the four aliases first', c.providers[1].models.map(m => m.ref), ['gateway::haiku', 'gateway::sonnet', 'gateway::opus', 'gateway::fable', 'gateway::z-ai/glm-5:free']); + check('an OpenAI-compatible provider offers its own models only', c.providers[2].models.map(m => m.id), ['deepseek-chat', 'deepseek-reasoner']); + check('no key ever reaches a choice list', JSON.stringify(c).includes('sk-ds') || JSON.stringify(c).includes('kgw'), false); + check('the default is marked', c.providers.filter(p => p.isDefault).map(p => p.id), ['gateway']); + check('isKnownModel', ['sonnet', 'gateway::opus', 'deepseek::anything-new', 'off::m', 'ghost::x', 'a b'].map(v => P.isKnownModel(v, reg)), [true, true, true, false, false, false]); + check('describeModel', [P.describeModel('deepseek::deepseek-chat', reg), P.describeModel('opus', reg)], ['DeepSeek · deepseek-chat', 'opus']); + check('computeCost', P.computeCost({ inputTokens: 1000000, outputTokens: 500000, cacheReadTokens: 2000000 }, { in: 3, out: 15, cacheRead: 0.3 }), 11.1); + check('computeCost without a price is unknown, not zero', P.computeCost({ inputTokens: 5 }, null), null); +} +{ + const v = P.validateProviderInput({ id: 'my-oai', type: 'openai-compatible', label: 'Mine', baseUrl: 'https://x.example/v1/', apiKey: ' sk ', dialect: 'openai', + headers: { 'HTTP-Referer': 'https://me' }, roles: { main: 'gpt-5', fast: '' }, options: { maxConcurrency: 4, extraBody: { a: 1 } } }, { creating: true }); + check('a good provider validates and is normalised', [v.errors, v.value.baseUrl, v.value.apiKey, v.value.roles], [[], 'https://x.example/v1', 'sk', { main: 'gpt-5' }]); + const b = P.validateProviderInput({ id: 'Bad Id', type: 'claude-subscription', label: '', baseUrl: 'ftp://x', dialect: 'nope', headers: { 'a b': 'x' }, options: { maxConcurrency: 999 } }, { creating: true }); + check('each bad field is named', b.errors.sort(), ['baseUrl', 'dialect', 'headers', 'id', 'label', 'options.maxConcurrency', 'type'].sort()); + check('a key with a newline is refused (header injection)', P.validateProviderInput({ apiKey: 'a\nb' }).errors, ['apiKey']); +} + +// ── store ─────────────────────────────────────────────────────────────────── +(async () => { + console.log('store:'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-prov-')); + const db = openDatabase(path.join(dir, 't.db')); + const key = crypto.randomBytes(32); + const encrypt = (s) => { const iv = crypto.randomBytes(16); const c = crypto.createCipheriv('aes-256-gcm', key, iv); const e = Buffer.concat([c.update(s, 'utf8'), c.final()]); return 'enc:' + Buffer.concat([iv, c.getAuthTag(), e]).toString('base64'); }; + const decrypt = (s) => { const b = Buffer.from(s.slice(4), 'base64'); const d = crypto.createDecipheriv('aes-256-gcm', key, b.subarray(0, 16)); d.setAuthTag(b.subarray(16, 32)); return d.update(b.subarray(32)).toString('utf8') + d.final('utf8'); }; + const store = createProviderStore(db, { encrypt, decrypt }); + + store.seedFromEnv({ ANTHROPIC_BASE_URL: 'https://kilo.example/', ANTHROPIC_AUTH_TOKEN: 'kgw_live_secret' }); + let r = store.registry(); + check('first boot: the CLI login row and the env gateway, gateway is the default', [r.providers.map(p => p.id), r.defaultProviderId], [['claude', 'gateway'], 'gateway']); + const gw = store.get('gateway'); + check('…seeded as an alias-serving Anthropic-compatible provider with the env token (bearer)', [gw.type, gw.aliases, gw.baseUrl, gw.apiKey, gw.authScheme, gw.source, gw.options.quietCli], ['anthropic-compatible', true, 'https://kilo.example', 'kgw_live_secret', 'bearer', 'env', false]); + const raw = db.prepare(`SELECT api_key_enc FROM providers WHERE id='gateway'`).get().api_key_enc; + check('the key is encrypted at rest', [raw.startsWith('enc:'), raw.includes('kgw_live')], [true, false]); + const pub = store.publicList(); + check('the public view never carries the key', [JSON.stringify(pub).includes('kgw_live'), pub.providers[1].hasKey], [false, true]); + + store.seedFromEnv({ ANTHROPIC_BASE_URL: 'https://kilo2.example', ANTHROPIC_AUTH_TOKEN: 'kgw_live_new' }); + check('a later boot follows a changed env (the .env-and-restart habit still works)', [store.get('gateway').baseUrl, store.get('gateway').apiKey], ['https://kilo2.example', 'kgw_live_new']); + store.update('gateway', { label: 'Kilo' }); + check('an edit in the UI makes the row the user\'s…', store.get('gateway').source, 'user'); + store.seedFromEnv({ ANTHROPIC_BASE_URL: 'https://kilo3.example', ANTHROPIC_AUTH_TOKEN: 'x' }); + check('…and env no longer rewrites it', store.get('gateway').baseUrl, 'https://kilo2.example'); + store.remove('gateway'); + store.seedFromEnv({ ANTHROPIC_BASE_URL: 'https://kilo.example', ANTHROPIC_AUTH_TOKEN: 'x' }); + check('a deleted env row is not resurrected on the next boot', [store.registry().providers.map(p => p.id), store.registry().defaultProviderId], [['claude'], 'claude']); + + store.create({ id: 'ds', type: 'openai-compatible', label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', apiKey: 'sk-1', dialect: 'deepseek', headers: { 'X-Api-Key': 'hdr-secret', 'X-Title': 'studio' } }); + check('a secret-looking header is masked, a plain one is not', store.publicProvider(store.get('ds')).headers, { 'X-Api-Key': '***', 'X-Title': 'studio' }); + store.update('ds', { apiKey: '', headers: { 'X-Api-Key': '***', 'X-Title': 'studio2' } }); + check('blank key and "***" header keep the stored secrets', [store.get('ds').apiKey, store.get('ds').headers], ['sk-1', { 'X-Api-Key': 'hdr-secret', 'X-Title': 'studio2' }]); + store.update('ds', { clearApiKey: true }); + check('clearApiKey clears', store.get('ds').apiKey, ''); + + const res = store.upsertCatalog('ds', [{ id: 'deepseek-chat', label: 'Chat', caps: { tools: true, contextWindow: 128000 } }, { id: 'deepseek-reasoner', caps: { reasoning: true } }]); + check('a small first catalogue is enabled wholesale', [res.autoEnabled, store.get('ds').models.map(m => [m.id, m.enabled])], [true, [['deepseek-chat', true], ['deepseek-reasoner', true]]]); + store.setModel('ds', 'deepseek-chat', { caps: { contextWindow: 64000, vision: false } }); + store.upsertCatalog('ds', [{ id: 'deepseek-chat', caps: { tools: true, contextWindow: 128000 } }, { id: 'deepseek-v4', caps: {} }]); + const m = store.get('ds').models; + check('a refresh keeps the user\'s corrections over the catalogue', [m.find(x => x.id === 'deepseek-chat').caps.contextWindow, m.find(x => x.id === 'deepseek-chat').caps.vision], [64000, false]); + check('a model that left the catalogue survives only because it was enabled; a new one arrives off', [m.map(x => x.id).sort(), m.find(x => x.id === 'deepseek-v4').enabled], [['deepseek-chat', 'deepseek-reasoner', 'deepseek-v4'], false]); + const big = Array.from({ length: 50 }, (_, i) => ({ id: `m${i}`, caps: {} })); + store.create({ id: 'or', type: 'openai-compatible', label: 'OR', baseUrl: 'https://openrouter.ai/api/v1' }); + check('a big first catalogue is imported switched off', [store.upsertCatalog('or', big).autoEnabled, store.get('or').models.filter(x => x.enabled).length], [false, 0]); + store.addManualModel('or', 'my/model', { caps: { tools: true } }); + check('a manual model is enabled and kept across refreshes', (store.upsertCatalog('or', big), store.get('or').models.find(x => x.id === 'my/model').enabled), true); + + check('setDefault refuses a disabled provider', (store.update('or', { enabled: false }), store.setDefault('or')), false); + check('the built-in row cannot be removed', store.remove('claude'), false); + + store.recordUsage({ runId: 'r1', providerId: 'ds', model: 'deepseek-chat', inputTokens: 100, outputTokens: 20, cacheReadTokens: 50, costUsd: 0.001, status: 'ok' }); + store.recordUsage({ runId: 'r1', providerId: 'ds', model: 'deepseek-chat', inputTokens: 300, outputTokens: 10, costUsd: null, status: 'ok' }); + const u = store.usageForRun('r1'); + check('usage per run sums tokens, flags unpriced requests, keeps the last turn', [u.input, u.output, u.requests, u.unpriced, u.lastTurn.input_tokens], [400, 30, 2, 1, 300]); + check('the summary groups by provider/model/day', store.usageSummary(7).map(x => [x.provider_id, x.requests]), [['ds', 2]]); + db.close(); + + console.log('catalogue fetch:'); + const calls = []; + const fake = (routes) => async (url, o) => { calls.push({ url, h: o.headers }); const r = routes(url); return { ok: r.status < 400, status: r.status, json: async () => r.body }; }; + { + const res = await fetchCatalog({ type: 'openai-compatible', baseUrl: 'https://api.x/v1', apiKey: 'sk', authScheme: 'x-api-key' }, { fetchImpl: fake(() => ({ status: 200, body: { data: [{ id: 'a' }, { id: 'b' }] } })) }); + check('OpenAI: /models with a Bearer key whatever the scheme says', [res.models.map(m => m.id), calls[0].url, calls[0].h.authorization, calls[0].h['anthropic-version']], [['a', 'b'], 'https://api.x/v1/models', 'Bearer sk', undefined]); + } + calls.length = 0; + { + let n = 0; + const res = await fetchCatalog({ type: 'anthropic', baseUrl: 'https://api.anthropic.com', apiKey: 'k', authScheme: 'x-api-key' }, + { fetchImpl: fake(() => (++n === 1 ? { status: 200, body: { data: [{ id: 'claude-a' }], has_more: true, last_id: 'claude-a' } } : { status: 200, body: { data: [{ id: 'claude-b' }], has_more: false } })) }); + check('Anthropic: /v1/models, x-api-key + version header, follows pagination', [res.models.map(m => m.id), calls[0].url, calls[0].h['x-api-key'], calls[0].h['anthropic-version'], /after_id=claude-a/.test(calls[1].url)], [['claude-a', 'claude-b'], 'https://api.anthropic.com/v1/models', 'k', '2023-06-01', true]); + } + check('a provider without a catalogue is reachable, not broken', await fetchCatalog({ type: 'anthropic-compatible', baseUrl: 'https://x' }, { fetchImpl: fake(() => ({ status: 404, body: {} })) }), { ok: true, models: [], status: 404, noCatalog: true }); + check('a refusal names the status and the upstream message', await fetchCatalog({ type: 'openai-compatible', baseUrl: 'https://x/v1', apiKey: 'bad' }, { fetchImpl: fake(() => ({ status: 401, body: { error: { message: 'Incorrect API key' } } })) }), { ok: false, models: [], status: 401, error: 'HTTP 401: Incorrect API key' }); + check('the CLI login has no catalogue to fetch', await fetchCatalog({ type: 'claude-subscription' }), { ok: true, models: [], noCatalog: true }); + + console.log(`\n${pass} passed, ${fail} failed`); + process.exit(fail ? 1 : 0); +})().catch(e => { console.error(e); process.exit(1); }); From 47b097e0043c7580314cc86bc24fa7507f390bbe Mon Sep 17 00:00:00 2001 From: Its-My-Work <122925955+Its-My-Work@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:59:41 +0300 Subject: [PATCH 02/21] feat(providers): route every CLI run through the registry ClaudeCLI.send() asks one hook where a run goes (model id, ANTHROPIC_* for the child, extra flags), so chat, bots, rooms, multi-agent, dispatch, Kanban and the utility calls cannot forget to route. /api/translate asks the same hook and no longer inherits server secrets. SSH strips the provider prefix; the tmux engine takes claude:: refs, now passes --effort, and unsets every provider variable in the spawn script (tmux hands panes its SERVER's env). A subscription-engine run whose model belongs to another provider runs headless and says so. Task runs on the subscription engine now honour the bot's model, as the card always claimed. Result meta takes cost/window from the bridge ledger off Anthropic. REST API for providers, models, probes, choices and usage. Co-Authored-By: Claude Opus 5.5 --- bots.js | 7 +- claude-cli.js | 76 +++++++- claude-interactive.js | 35 +++- claude-ssh.js | 5 +- server.js | 425 ++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 515 insertions(+), 33 deletions(-) diff --git a/bots.js b/bots.js index f35e4c8..225ff6d 100644 --- a/bots.js +++ b/bots.js @@ -462,7 +462,12 @@ const CLAUDE_MODEL_RE = /^[A-Za-z0-9._-]+$/; function botModel(bot, engine, chatModel) { const own = bot && typeof bot.model === 'string' ? bot.model : ''; if (!own) return chatModel; - if (engine === 'subscription' && !CLAUDE_MODEL_RE.test(own)) return chatModel; + if (engine === 'subscription') { + // A provider ref to the CLI-login row ("claude::opus", providers.js) IS a Claude + // model; the tmux engine takes it without the prefix. Any other ref is not. + const bare = own.startsWith('claude::') ? own.slice('claude::'.length) : own; + return CLAUDE_MODEL_RE.test(bare) ? bare : chatModel; + } return own; } diff --git a/claude-cli.js b/claude-cli.js index 0c823af..5d6115c 100644 --- a/claude-cli.js +++ b/claude-cli.js @@ -140,6 +140,20 @@ const MODEL_MAP = { 'fable': 'fable', }; +// ─── Provider routing (providers.js; wired by server.js) ───────────────────── +// Every headless run asks this hook where it goes: which model id the CLI gets, which +// ANTHROPIC_* the child sees (the local llm-bridge + a run token, or the CLI's own +// login), and extra flags. One choke point on purpose — chat, bots, rooms, multi-agent, +// tasks and the utility calls all reach the CLI through send(), so none of them can +// forget to route. null = the pre-registry behaviour (inherit the server's env). +let _runTargetHook = null; +function setRunTargetHook(fn) { _runTargetHook = typeof fn === 'function' ? fn : null; } +function resolveRunTarget(req) { + if (!_runTargetHook) return null; + try { return _runTargetHook(req) || null; } + catch (e) { return { error: `provider routing failed: ${e.message}` }; } +} + // ─── MCP config file cache ────────────────────────────────────────────────── // Reuses temp files by content hash instead of creating/deleting per request. // Key: SHA-256 hash of JSON content → { path, refCount } @@ -184,6 +198,30 @@ process.on('exit', () => { } }); +/** Same shape providers.applyRunEnv() uses; kept local so this file needs no registry. */ +function applyRunEnvTo(env, runEnv) { + for (const k of runEnv.unset || []) delete env[k]; + for (const [k, v] of Object.entries(runEnv.set || {})) { if (v != null && v !== '') env[k] = String(v); } + return env; +} + +/** A run that never starts: the caller still gets its onError and exactly one onDone, + * on a later tick so the handlers it chains after send() are attached. */ +function failedHandle(message) { + const h = {}; + setImmediate(() => { + try { if (h.onError) h.onError(String(message)); } catch {} + try { if (h.onDone) h.onDone(null); } catch {} + }); + const self = { + onText() { return self; }, onTool() { return self; }, onThinking() { return self; }, onRateLimit() { return self; }, + onResult() { return self; }, onUsage() { return self; }, onSessionId() { return self; }, + onError(fn) { h.onError = fn; return self; }, onDone(fn) { h.onDone = fn; return self; }, + process: null, runTarget: null, + }; + return self; +} + class ClaudeCLI { constructor(options = {}) { // A POSIX-absolute cwd on Windows is almost always a REMOTE path that leaked into a @@ -200,7 +238,13 @@ class ClaudeCLI { this.claudeBin = options.claudeBin || CLAUDE_BIN; } - send({ prompt, contentBlocks, sessionId, model, maxTurns, mcpServers, systemPrompt, allowedTools, tools, abortController, settingSources, forkSession, addDirs, extraEnv, extraSettings, name, effort, jsonSchema }) { + send({ prompt, contentBlocks, sessionId, model, maxTurns, mcpServers, systemPrompt, allowedTools, tools, abortController, settingSources, forkSession, addDirs, extraEnv, extraSettings, name, effort, jsonSchema, runMeta }) { + // Where this run goes. An unresolvable choice fails the run the normal way (onError + // then onDone, asynchronously) instead of silently falling back to some other model. + const target = resolveRunTarget({ model, effort, engine: 'api', meta: runMeta || {} }); + this.lastRunTarget = target && !target.error ? (target.info || null) : null; + if (target && target.error) return failedHandle(target.error); + const args = ['--print']; // --setting-sources: control which setting sources to load (user, project, local) @@ -226,7 +270,13 @@ class ClaudeCLI { console.warn('[claude-cli] rejected non-UUID sessionId for --resume:', typeof sessionId, String(sessionId).substring(0, 60)); } - if (model) args.push('--model', MODEL_MAP[model] || model); + // With a routing target the CLI gets the provider's own model id (a bare alias on a + // provider that serves no Claude has already been mapped through its roles). A run + // on the CLI login with no model chosen keeps passing no flag, as before. + const cliModel = target + ? ((model || target.routing !== 'oauth') ? target.cliModel : null) + : (model ? (MODEL_MAP[model] || model) : null); + if (cliModel) args.push('--model', cliModel); if (maxTurns) args.push('--max-turns', String(maxTurns)); // --effort: thinking-effort dial for the current session. @@ -309,6 +359,10 @@ class ClaudeCLI { args.push('--settings', JSON.stringify(extraSettings)); } + // Provider-specific flags (e.g. `--disallowedTools WebSearch` off Anthropic, where the + // server-side search tool does not exist and the nested call answers without searching). + if (target && Array.isArray(target.extraArgs) && target.extraArgs.length) args.push(...target.extraArgs); + // CRITICAL: bypass permission prompts in non-interactive mode args.push('--dangerously-skip-permissions'); @@ -362,6 +416,10 @@ class ClaudeCLI { // Unset CLAUDECODE to allow nested invocation from dev environment. const env = { ...process.env, ...(extraEnv || {}) }; delete env.CLAUDECODE; + // The provider decision: every inherited ANTHROPIC_* is stripped first, then the + // run's own set (bridge URL + run token, role models, window/output caps). A routed + // run therefore never sees the server's long-lived gateway token. + if (target && target.runEnv) applyRunEnvTo(env, target.runEnv); // The child runs with Bash on a prompt an untrusted document can steer, so it must // not inherit this server's own credentials — none of them are its to use. Deleted // AFTER the extraEnv spread so a caller cannot re-introduce one by accident. @@ -405,6 +463,14 @@ class ClaudeCLI { // Track temp attachment files + parent dir for cleanup let attFiles = _tempFiles.slice(); let attDir = _tempDir; + // The run token dies with the process (the bridge keeps a short grace for the + // CLI's last in-flight request). + let targetReleased = false; + const releaseTarget = () => { + if (targetReleased || !target || typeof target.release !== 'function') return; + targetReleased = true; + try { target.release(); } catch {} + }; // ─── Watchdog timers ───────────────────────────────────────────────────── // Escalate to SIGKILL 3 s after SIGTERM if the process ignores it (Unix only — @@ -526,6 +592,7 @@ class ClaudeCLI { } } releaseMcpConfig(mcpHash); mcpHash = null; + releaseTarget(); for (const f of attFiles) { try { fs.unlinkSync(f); } catch {} } if (attDir) { try { fs.rmSync(attDir, { recursive: true, force: true }); } catch {} attDir = null; } attFiles = []; @@ -556,6 +623,7 @@ class ClaudeCLI { if (sigkillTimer) { clearTimeout(sigkillTimer); sigkillTimer = null; } // Clean up MCP config and temp attachments even when the process fails to start releaseMcpConfig(mcpHash); mcpHash = null; + releaseTarget(); for (const f of attFiles) { try { fs.unlinkSync(f); } catch {} } if (attDir) { try { fs.rmSync(attDir, { recursive: true, force: true }); } catch {} attDir = null; } attFiles = []; @@ -583,6 +651,7 @@ class ClaudeCLI { onResult(fn) { h.onResult = fn; return this; }, onUsage(fn) { h.onUsage = fn; return this; }, process: proc, + runTarget: this.lastRunTarget, }; } @@ -670,3 +739,6 @@ class ClaudeCLI { module.exports = ClaudeCLI; module.exports.findClaudeBin = findClaudeBin; module.exports.claudeCliStatus = claudeCliStatus; +module.exports.setRunTargetHook = setRunTargetHook; +module.exports.resolveRunTarget = resolveRunTarget; +module.exports.SERVER_ONLY_ENV = SERVER_ONLY_ENV; diff --git a/claude-interactive.js b/claude-interactive.js index 34e4d51..dabfd10 100644 --- a/claude-interactive.js +++ b/claude-interactive.js @@ -31,6 +31,7 @@ const crypto = require('node:crypto'); const { findClaudeBin } = require('./claude-cli'); const { agentsMdPreamble } = require('./agents-md'); +const { PROVIDER_ENV_VARS } = require('./providers'); // ONE definition of the socket name, imported — never a second copy of the string. const { TMUX_SOCKET } = require('./terminal-bridge'); @@ -279,8 +280,19 @@ function spawnScriptDir() { // Full `claude` invocation for the interactive engine. Pure — the prompt is inline // here on purpose: this string becomes the script body, i.e. the child's argv. -function buildInteractiveCommand({ claudeBin, idFlag, modelAlias, sp, mcpPath }) { - let cmd = `env -u CLAUDECODE ${shq(claudeBin)} ${idFlag} --model ${shq(modelAlias)} --dangerously-skip-permissions`; +// +// Every provider variable is unset HERE, in the script, and not only in the spawn env +// below: tmux builds a pane's environment from its SERVER's global environment, not +// from the client that ran `new-session`. The ccstudio tmux server is often started +// first by terminal-bridge.js, which keeps the server's ANTHROPIC_* — so deleting them +// from our spawn env alone could still hand this pane the gateway token, and `claude` +// ranks a token above the stored OAuth login (the Subscription engine's whole point). +const PANE_UNSET_ENV = ['CLAUDECODE', ...PROVIDER_ENV_VARS]; +const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']; +function buildInteractiveCommand({ claudeBin, idFlag, modelAlias, sp, mcpPath, effort }) { + const unset = PANE_UNSET_ENV.map(v => `-u ${v}`).join(' '); + let cmd = `env ${unset} ${shq(claudeBin)} ${idFlag} --model ${shq(modelAlias)} --dangerously-skip-permissions`; + if (EFFORTS.includes(effort)) cmd += ` --effort ${effort}`; if (sp) cmd += ` --append-system-prompt ${shq(sp)}`; if (mcpPath) cmd += ` --mcp-config ${shq(mcpPath)}`; return cmd; @@ -353,7 +365,7 @@ function killInteractiveTmux(localSessionId) { // mode, ws, sessionId, abortController, claudeSessionId, workdir, tabId; ignores the rest. // Returns { cid, completed, resultMeta, fullText, fullThinking, toolEvents }. async function runInteractiveSingle(params) { - const { prompt, systemPrompt, model, mode, ws, sessionId, abortController, claudeSessionId, workdir, tabId, mcpServers, userContent, drainInterrupts, markInterruptsDelivered, requeueInterrupts, fanout, agent } = params; + const { prompt, systemPrompt, model, mode, ws, sessionId, abortController, claudeSessionId, workdir, tabId, mcpServers, userContent, drainInterrupts, markInterruptsDelivered, requeueInterrupts, fanout, agent, effort } = params; const start = Date.now(); // `agent` tags every frame with which bot produced it — set only when this call is one // seat in a bots room/multi turn, not a plain single-agent chat. Without it the UI has @@ -400,11 +412,18 @@ async function runInteractiveSingle(params) { // project without AGENTS.md keeps its previous hash and does NOT respawn. const agentsMd = agentsMdPreamble(workdir || process.cwd()); const sp = [(mp + (systemPrompt || '')).trim(), agentsMd].filter(Boolean).join('\n\n'); - const modelAlias = /^[a-zA-Z0-9._-]+$/.test(String(model || '')) ? model : 'sonnet'; + // A ref to the CLI-login provider ("claude::opus", providers.js) is a plain Claude + // model here. The router never sends another provider's ref to this engine + // (providers.effectiveEngine); if one slips through, it still becomes `sonnet`. + const bareModel = String(model || '').startsWith('claude::') ? String(model).slice('claude::'.length) : String(model || ''); + const modelAlias = /^[a-zA-Z0-9._-]+$/.test(bareModel) ? bareModel : 'sonnet'; + // Effort used to be dropped on this engine; it is a spawn flag, so it joins cfgHash + // and changing it respawns the pane with --resume like a model change does. + const effortFlag = EFFORTS.includes(effort) ? effort : ''; const mcpPath = mcpConfigPath(mcpServers); let mcpJson = ''; if (mcpPath) { try { mcpJson = fs.readFileSync(mcpPath, 'utf8'); } catch {} } - const cfgHash = crypto.createHash('sha256').update(JSON.stringify([sp, mcpJson, modelAlias])).digest('hex').slice(0, 16); + const cfgHash = crypto.createHash('sha256').update(JSON.stringify(effortFlag ? [sp, mcpJson, modelAlias, effortFlag] : [sp, mcpJson, modelAlias])).digest('hex').slice(0, 16); // ── Spawn (or reuse) the tmux session ────────────────────────────────── // tmux alive but no recorded claude session id: without a cid we cannot @@ -425,7 +444,7 @@ async function runInteractiveSingle(params) { const idFlag = resuming ? `--resume ${shq(cid)}` : `--session-id ${shq(cid)}`; const claudeBin = findClaudeBin(); - const innerCmd = buildInteractiveCommand({ claudeBin, idFlag, modelAlias, sp, mcpPath }); + const innerCmd = buildInteractiveCommand({ claudeBin, idFlag, modelAlias, sp, mcpPath, effort: effortFlag }); const env = utf8Env(); delete env.CLAUDECODE; // parent Claude Code session sets this and it confuses the child @@ -437,9 +456,7 @@ async function runInteractiveSingle(params) { // routes every "Subscription" turn through that token instead — same // symptom as no subscription selection existing at all, just with an extra // layer of "why is this billing wrong" on top. - delete env.ANTHROPIC_API_KEY; - delete env.ANTHROPIC_AUTH_TOKEN; - delete env.ANTHROPIC_BASE_URL; + for (const v of PROVIDER_ENV_VARS) delete env[v]; // stderr is PIPED, not ignored: `command too long` was the whole content of // issue #96 and it used to be discarded, leaving only the generic line below. let tmuxErr = ''; diff --git a/claude-ssh.js b/claude-ssh.js index 2938c9c..73d0b2f 100644 --- a/claude-ssh.js +++ b/claude-ssh.js @@ -346,7 +346,10 @@ class ClaudeSSH { const cleanName = name.replace(/[\r\n\t]+/g, ' ').trim().slice(0, 100); if (cleanName) args.push('--name', cleanName); } - if (model) args.push('--model', MODEL_MAP[model] || model); + // A provider ref ("deepseek::deepseek-chat", providers.js) means nothing to the + // REMOTE claude: it runs on that host's own login/config, so it gets the bare id. + const bare = model && String(model).includes('::') ? String(model).slice(String(model).indexOf('::') + 2) : model; + if (bare) args.push('--model', MODEL_MAP[bare] || bare); if (maxTurns) args.push('--max-turns', String(maxTurns)); if (typeof effort === 'string') { const e = effort.trim().toLowerCase(); diff --git a/server.js b/server.js index a92cc00..e7dad66 100644 --- a/server.js +++ b/server.js @@ -128,6 +128,11 @@ const { const termBridge = require('./terminal-bridge'); const botsLogic = require('./bots'); const gatewayModels = require('./gateway-models'); +// Provider registry (pure rules / SQLite rows / catalogue fetch). Wired below, after the +// secret-encryption key exists — see "PROVIDERS & LLM BRIDGE". +const providersLib = require('./providers'); +const { createProviderStore } = require('./providers-store'); +const providersCatalog = require('./providers-catalog'); // One catalogue for the process: the gateway the CLI talks to, read with the server's own token. const modelCatalog = gatewayModels.createCatalog({ baseUrl: process.env.ANTHROPIC_BASE_URL, token: process.env.ANTHROPIC_AUTH_TOKEN || process.env.ANTHROPIC_API_KEY }); // A message that is nothing but a mention ("@@analyst") strips to an empty string — @@ -2052,6 +2057,7 @@ async function startTask(task) { blockedTasks.delete(task.id); // clear any stale flag an earlier, crashed run left behind let fullText = '', newCid = claudeSessionId, hasError = false; taskBuffers.set(task.id, ''); + const taskRunInfos = []; // provider routing of every CLI run of this task (withProviderUsage) // Notify watchers — use task_retrying for restarts, task_started for first run // Include prompt so client can show user message bubble during live streaming if (isRetry) { @@ -2117,7 +2123,10 @@ async function startTask(task) { // Engine selection: subscription tasks run via the persistent interactive // tmux Claude session (billed on Claude Max), one-shot, no auto-continue. - const _taskEngine = (task.run_engine === 'subscription') ? 'subscription' : 'api'; + const _taskEngineDial = (task.run_engine === 'subscription') ? 'subscription' : 'api'; + // The tmux engine runs the CLI's own login only (providers.effectiveEngine): a task + // whose model belongs to another provider runs headless instead of silently on Claude. + const _taskEngine = engineForModel(_taskEngineDial, taskBot?.model || session?.model || task.model || 'sonnet'); while (true) { lastTaskResult = null; @@ -2130,7 +2139,8 @@ async function startTask(task) { // returned value below (same shape the chat path uses). const _proxy = { send: (str) => { try { broadcastToSession(sessionId, JSON.parse(str)); } catch {} } }; const r = await runInteractiveSingle({ - prompt: currentTaskPrompt, systemPrompt: '', model: session?.model || task.model || 'sonnet', + // The bot's model wins here too — the Kanban card has always said so (#84). + prompt: currentTaskPrompt, systemPrompt: '', model: taskBot?.model || session?.model || task.model || 'sonnet', effort: task.effort || null, mode: task.mode || 'auto', ws: _proxy, sessionId, abortController: taskAbort, claudeSessionId: currentTaskCid, workdir: task.workdir || WORKDIR, mcpServers: taskMcpServers, // Same gap the chat path had, and found while reviewing that fix: the api @@ -2238,7 +2248,9 @@ async function startTask(task) { // personal CLAUDE.md — same boundary as a bot answering in chat, or every // reply opens with the user's own activation preamble. ...(taskBot ? { settingSources: 'project,local' } : {}), - maxTurns: effectiveTaskMaxTurns, mcpServers: taskMcpServers, abortController: taskAbort, name: taskBot?.label || task.title, effort: task.effort || null, extraEnv: taskInterruptEnv, extraSettings: taskInterruptSettings }); + maxTurns: effectiveTaskMaxTurns, mcpServers: taskMcpServers, abortController: taskAbort, name: taskBot?.label || task.title, effort: task.effort || null, extraEnv: taskInterruptEnv, extraSettings: taskInterruptSettings, + runMeta: { purpose: 'task', sessionId, taskId: task.id, botId: taskBot?.id || null } }); + if (!_taskRemote) taskRunInfos.push(cli.lastRunTarget); // Save subprocess PID so startup recovery can kill orphans on restart if (stream.process?.pid) { db.prepare(`UPDATE tasks SET worker_pid=? WHERE id=?`).run(stream.process.pid, task.id); @@ -2661,7 +2673,7 @@ async function startTask(task) { console.error(`[taskWorker] task ${task.id} onDone DB error:`, e); } const _taskModelInfo = lastTaskResult?.modelUsage ? Object.values(lastTaskResult.modelUsage)[0] : null; - const _taskMeta = lastTaskResult ? { cost: lastTaskResult.total_cost_usd, usage: lastTaskResult.usage, lastTurnUsage: lastTaskTurnUsage, numTurns: lastTaskResult.num_turns, durationMs: lastTaskResult.duration_ms, contextWindow: _taskModelInfo?.contextWindow || 0 } : null; + const _taskMeta = withProviderUsage(lastTaskResult ? { cost: lastTaskResult.total_cost_usd, usage: lastTaskResult.usage, lastTurnUsage: lastTaskTurnUsage, numTurns: lastTaskResult.num_turns, durationMs: lastTaskResult.duration_ms, contextWindow: _taskModelInfo?.contextWindow || 0 } : null, taskRunInfos); broadcastToSession(sessionId, { type: 'done', tabId: sessionId, taskId: task.id, duration: Date.now() - _taskStartedAt, ...(_taskMeta ? { resultMeta: _taskMeta } : {}) }); } catch (err) { log.error(`[taskWorker] task ${task.id} exception`, { message: err.message, name: err.name, stack: err.stack }); @@ -3827,7 +3839,8 @@ async function classifyTask(userMessage, currentSkills, config, workdir) { cli.send({ prompt, - model: 'haiku', + model: utilityModel(), + runMeta: { purpose: 'utility' }, maxTurns: 1, settingSources: 'user', // skip project CLAUDE.md — service call, everything explicit tools: '', // disable all built-in tools (--tools "") @@ -4171,6 +4184,144 @@ function decryptPassword(stored) { // testSshConnection is now exported from claude-ssh.js (uses ssh2 library, supports password auth) +// ============================================ +// PROVIDERS & LLM BRIDGE +// ============================================ +// The registry decides, per run, which endpoint and model the `claude` CLI talks to +// (providers.js has the rules, providers-store.js the rows). Non-login providers are +// reached through llm-bridge/ — a separate node process on 127.0.0.1 that holds the +// real keys, translates for OpenAI-compatible endpoints and records usage. The CLI gets +// only a short-lived run token for it. +// +// Created HERE, not next to the schema: the store encrypts with encryptPassword(), +// whose key constant above is in its temporal dead zone until this point. +const providerStore = createProviderStore(db, { encrypt: encryptPassword, decrypt: decryptPassword, log }); +try { providerStore.seedFromEnv(process.env); } catch (e) { log.warn('provider seeding failed', { err: e.message }); } +providerStore.pruneUsage(); + +let createBridgeHost = null; +try { ({ createBridgeHost } = require('./llm-bridge/host')); } +catch (e) { log.warn('llm-bridge unavailable — Anthropic-type providers run with the key in the CLI env, OpenAI-compatible ones cannot run', { err: e.message }); } + +function onBridgeUsage(rec) { + if (!rec || typeof rec !== 'object') return; + let costUsd = null; + try { + const p = providerStore.get(rec.providerId); + const pricing = p ? (p.models.find(m => m.id === rec.model) || {}).pricing : null; + costUsd = providersLib.computeCost(rec, pricing); + } catch {} + providerStore.recordUsage({ ...rec, costUsd }); +} + +const llmBridge = (createBridgeHost && process.env.CCS_BRIDGE !== 'off') ? createBridgeHost({ + nodeCmd: NODE_CMD, + childPath: helperPath('llm-bridge', 'child.js'), + port: parseInt(process.env.CCS_BRIDGE_PORT || '0', 10) || 0, + onUsage: onBridgeUsage, + log: { + debug: (m, d) => log.debug(`[llm-bridge] ${m}`, d), + info: (m, d) => log.info(`[llm-bridge] ${m}`, d), + warn: (m, d) => log.warn(`[llm-bridge] ${m}`, d), + error: (m, d) => log.error(`[llm-bridge] ${m}`, d), + }, +}) : null; +let llmBridgeError = llmBridge ? null : (createBridgeHost ? 'disabled (CCS_BRIDGE=off)' : 'module unavailable'); +if (llmBridge) { + llmBridge.start().then(() => log.info('llm-bridge ready', { url: llmBridge.baseUrl() })) + .catch(e => { llmBridgeError = e.message; log.warn('llm-bridge failed to start', { err: e.message }); }); +} + +function bridgeUp() { return !!(llmBridge && llmBridge.running() && llmBridge.baseUrl()); } + +/** Public summary of one resolution — what a WS frame / result meta may carry. */ +function runTargetInfo(t, extra = {}) { + return { + ref: t.ref, providerId: t.provider.id, providerLabel: t.provider.label, providerType: t.provider.type, + modelId: t.modelId, routing: t.routing, caps: t.caps, fallback: t.fallback || null, engineChanged: !!t.engineChanged, ...extra, + }; +} + +/** + * The ClaudeCLI hook (claude-cli.js setRunTargetHook). Returns what one headless run needs: + * { cliModel, runEnv, extraArgs, info, release } or { error }. + */ +function resolveRunTarget({ model, effort, engine, meta }) { + const t = providersLib.resolveModel(model, providerStore.registry(), { engine: engine || 'api' }); + if (!t.ok) return { error: `Model "${model}" cannot run: ${t.error === 'no_model' ? `provider "${t.providerId}" has no model enabled` : 'not a valid model'}` }; + if (t.fallback) log.warn('model pinned to an unavailable provider — running on the default', { model, fallback: t.fallback, ref: t.ref }); + + if (t.routing === 'oauth' || t.routing === 'remote') { + return { cliModel: t.cliModel, runEnv: providersLib.buildRunEnv(t), extraArgs: [], info: runTargetInfo(t), release: null }; + } + if (bridgeUp()) { + const ctx = providersLib.buildRunCtx(t, { ...(meta || {}), effort: effort || null }); + const token = llmBridge.registerRun(ctx); + const runEnv = providersLib.buildRunEnv(t, { baseUrl: llmBridge.baseUrl(), token }); + return { cliModel: t.cliModel, runEnv, extraArgs: runEnv.extraArgs, info: runTargetInfo(t, { runId: ctx.runId }), release: () => llmBridge.releaseRun(token) }; + } + // Bridge down: an Anthropic-shaped endpoint is still reachable the pre-bridge way — + // its key in the CLI's env. Worse isolation, same behaviour as before this registry. + if (t.provider.type !== 'openai-compatible') { + const runEnv = providersLib.buildRunEnv(t, { baseUrl: t.provider.baseUrl, token: t.provider.apiKey }); + if (t.provider.authScheme === 'x-api-key') { runEnv.set.ANTHROPIC_API_KEY = t.provider.apiKey; delete runEnv.set.ANTHROPIC_AUTH_TOKEN; } + return { cliModel: t.cliModel, runEnv, extraArgs: runEnv.extraArgs, info: runTargetInfo(t, { routing: 'direct' }), release: null }; + } + return { error: `Provider "${t.provider.label}" is OpenAI-compatible and needs the LLM bridge, which is not running (${llmBridgeError || 'starting'}).` }; +} +ClaudeCLI.setRunTargetHook(resolveRunTarget); + +/** The model a stored value really runs on for `engine` — for engine decisions and badges. */ +function resolveModelFor(model, engine) { + return providersLib.resolveModel(model, providerStore.registry(), { engine: engine || 'api' }); +} + +/** The tmux Subscription engine runs the CLI's own login only; anything else goes headless. */ +function engineForModel(engine, model) { + return providersLib.effectiveEngine(engine, model, providerStore.registry()); +} + +/** Model the utility calls use (classifier/titles, compact, translate). */ +function utilityModel() { + return providerStore.registry().utilityModel || 'haiku'; +} + +/** + * Fold the bridge's ledger into a run's result meta. The CLI prices usage with ITS Claude + * table and reports a 200K window for any id it does not know — both wrong off Anthropic. + * `infos` = the lastRunTarget of every CLI invocation of this turn (auto-continues included). + */ +function withProviderUsage(resultMeta, infos) { + const list = (infos || []).filter(Boolean); + const last = list[list.length - 1]; + if (!resultMeta || !last) return resultMeta; + const out = { ...resultMeta, provider: { id: last.providerId, label: last.providerLabel, model: last.modelId, routing: last.routing, ref: last.ref } }; + if (last.providerType === 'openai-compatible' || (last.providerType === 'anthropic-compatible' && !CLAUDE_ALIAS_SET.has(last.modelId))) { + let cost = 0, any = false, priced = true; + for (const i of list) { + const u = i.runId ? providerStore.usageForRun(i.runId) : null; + if (!u) continue; + any = true; if (u.unpriced) priced = false; cost += u.cost || 0; + } + out.cost = any && priced ? Math.round(cost * 1e6) / 1e6 : null; + if (last.caps && last.caps.contextWindow) out.contextWindow = last.caps.contextWindow; + } + return out; +} +const CLAUDE_ALIAS_SET = new Set(providersLib.CLAUDE_ALIASES); + +// A chat pinned to a provider that was deleted or switched off runs on the default +// instead of failing — and says so, once per (chat, pinned model), not on every run. +const _fallbackNoticed = new Set(); +function noticeProviderFallback(info, { ws, sessionId, tabId } = {}) { + if (!info || !info.fallback || !ws) return; + const key = `${sessionId}|${info.ref}|${info.fallback}`; + if (_fallbackNoticed.has(key)) return; + if (_fallbackNoticed.size > 1000) _fallbackNoticed.clear(); + _fallbackNoticed.add(key); + try { ws.send(JSON.stringify({ type: 'provider_notice', kind: info.fallback, ref: info.ref, providerLabel: info.providerLabel, ...(tabId ? { tabId } : {}) })); } catch {} +} + // ============================================ // EXECUTION ENGINES // ============================================ @@ -4291,6 +4442,9 @@ async function runCliSingle(p) { const cli = new ClaudeCLI({ cwd: workdir || WORKDIR }); let pendingFork = !!forkSession; // only fork on first CLI call + // Where each CLI invocation of this turn went (provider, bridge run id) — the ledger + // behind withProviderUsage(); auto-continues are separate runs of the same turn. + const runInfos = []; // Run a single CLI invocation and return { resultData, sid, errorText, rateLimitInfo } const runOnce = (runPrompt, contentBlocks, resumeId) => new Promise((resolve) => { @@ -4320,7 +4474,10 @@ async function runCliSingle(p) { CCS_INTERRUPT_SECRET: INTERRUPT_SECRET, }; - cli.send({ prompt: runPrompt, contentBlocks, sessionId: resumeId, model, maxTurns: effectiveMaxTurns, systemPrompt: sp, mcpServers, allowedTools: tools, abortController, forkSession: useFork, extraEnv: interruptEnv, extraSettings: interruptHookSettings, name, effort }) + const _h = cli.send({ prompt: runPrompt, contentBlocks, sessionId: resumeId, model, maxTurns: effectiveMaxTurns, systemPrompt: sp, mcpServers, allowedTools: tools, abortController, forkSession: useFork, extraEnv: interruptEnv, extraSettings: interruptHookSettings, name, effort, runMeta: { purpose: 'chat', sessionId } }); + runInfos.push(cli.lastRunTarget); + noticeProviderFallback(cli.lastRunTarget, { ws, sessionId, tabId }); + _h .onText(t => { fullText += t; { const _cb = (chatBuffers.get(sessionId) || '') + t; chatBuffers.set(sessionId, _cb.length > MAX_CHAT_BUFFER ? _cb.slice(-MAX_CHAT_BUFFER) : _cb); } @@ -4648,14 +4805,14 @@ async function runCliSingle(p) { try { if (fullText) stmts.addMsg.run(sessionId, 'assistant', 'text', fullText, null, null, null, null); } catch (e) { log.error('[THINKING-SAVE-ERR] CLI text save failed', { sessionId, error: e.message }); } try { stmts.setPartialText.run(null, sessionId); } catch {} const _modelInfo = lastResult?.modelUsage ? Object.values(lastResult.modelUsage)[0] : null; - const resultMeta = lastResult ? { + const resultMeta = withProviderUsage(lastResult ? { cost: totalCostUsd || lastResult.total_cost_usd, usage: lastResult.usage, lastTurnUsage, numTurns: lastResult.num_turns, durationMs: lastResult.duration_ms, contextWindow: _modelInfo?.contextWindow || 0, - } : null; + } : null, runInfos); return { cid: newCid, completed: lastResult?.subtype === 'success', resultMeta }; } @@ -5081,7 +5238,7 @@ async function pickRoomSeating({ cwd, model, prompt, mode, bots, abortController .send({ prompt: botsLogic.seatingPrompt({ prompt, mode, bots }), model, maxTurns: 1, allowedTools: [], tools: '', settingSources: '', - abortController: ac, jsonSchema: botsLogic.SEATING_SCHEMA, + abortController: ac, jsonSchema: botsLogic.SEATING_SCHEMA, runMeta: { purpose: 'utility' }, }) .onText(t => { out += t; }) .onTool((name, input) => { if (name === 'StructuredOutput' && input) out = typeof input === 'string' ? input : JSON.stringify(input); }) @@ -5111,7 +5268,8 @@ function seatingNote({ room, skipped }) { async function runConversationRoom(p, { bots, prompt, rosterBots }) { const { mcpServers, model, maxTurns, ws, sessionId, abortController, workdir, tabId, effort, userContent, engine, mode } = p; // The engine THIS bot's turn runs on: its own choice (web chat only, see botsLogic.botEngine), else the chat's. - const botEngine = (bot) => botsLogic.botEngine(bot, engine, { enabled: !!p.perBotEngine, tmux: tmuxAvailable() }); + // …and never the tmux engine for a model of another provider (providers.effectiveEngine). + const botEngine = (bot) => engineForModel(botsLogic.botEngine(bot, engine, { enabled: !!p.perBotEngine, tmux: tmuxAvailable() }), (bot && bot.model) || model); const cli = new ClaudeCLI({ cwd: workdir || WORKDIR }); const send = (o) => { try { ws.send(JSON.stringify({ ...o, ...(tabId ? { tabId } : {}) })); } catch {} }; const save = (text, agentId) => { try { stmts.addMsg.run(sessionId, 'assistant', 'text', text, null, agentId, null, null); } catch {} }; @@ -5283,6 +5441,7 @@ async function runConversationRoom(p, { bots, prompt, rosterBots }) { // multiply its cost by the size of the room. contentBlocks: first && attachments.length ? attachments : null, model: botsLogic.botModel(bot, 'api', model), + runMeta: { purpose: 'room', sessionId, botId: bot.id }, maxTurns: deliverable ? closeCap : turnCap, systemPrompt: botsLogic.buildBotSystemPrompt(bot, rosterBots, botLangName(), { discussion: !deliverable }), // No _ccs_bots: the room is the dispatcher. See the invariants above. @@ -5459,7 +5618,8 @@ async function runConversationRoom(p, { bots, prompt, rosterBots }) { async function runBotTurns(p, { bots, prompt, rosterBots }) { const { mcpServers, model, maxTurns, ws, sessionId, abortController, claudeSessionId, workdir, tabId, effort, userContent, engine } = p; - const botEngine = (bot) => botsLogic.botEngine(bot, engine, { enabled: !!p.perBotEngine, tmux: tmuxAvailable() }); + // …and never the tmux engine for a model of another provider (providers.effectiveEngine). + const botEngine = (bot) => engineForModel(botsLogic.botEngine(bot, engine, { enabled: !!p.perBotEngine, tmux: tmuxAvailable() }), (bot && bot.model) || model); const cli = new ClaudeCLI({ cwd: workdir || WORKDIR }); // Same tool set as a normal turn, plus the internal MCP tools — without // check_user_messages a bot cannot see a clarification the user sends mid-run. @@ -5670,6 +5830,7 @@ async function runBotTurns(p, { bots, prompt, rosterBots }) { sessionId: botSession, // A bot may pin its own model; otherwise it follows the chat's. model: botsLogic.botModel(bot, 'api', model), + runMeta: { purpose: 'bot', sessionId, botId: bot.id }, maxTurns: turnCap, // Only applied when there is no session to resume — that is exactly why each // bot needs its own session rather than sharing the chat's. @@ -5840,7 +6001,7 @@ async function runMultiAgent(p) { // tools='' → disables all built-in tools (Read, Bash, …) // settingSources='' → skips user/project/local CLAUDE.md and skills // StructuredOutput is injected by --json-schema and remains available. - cli.send({ prompt:planPrompt, sessionId: currentSessionId, model, maxTurns:1, allowedTools:[], tools:'', settingSources:'', abortController, effort, jsonSchema: planSchema }) + cli.send({ prompt:planPrompt, sessionId: currentSessionId, model, maxTurns:1, allowedTools:[], tools:'', settingSources:'', abortController, effort, jsonSchema: planSchema, runMeta: { purpose: 'multi', sessionId } }) .onText(t => { planText+=t; }) // With --json-schema the model emits the result via the synthetic // "StructuredOutput" tool_use rather than plain text. Capture its input @@ -5989,7 +6150,7 @@ async function runMultiAgent(p) { // worker would pay for it once per agent. contentBlocks: (agentContinues === 0 && isFirstWave && Array.isArray(userContent)) ? userContent : null, sessionId: agentSessionId, model, maxTurns:agentTurnCap, systemPrompt:agentSp, mcpServers, allowedTools:agentTools, abortController, forkSession: agentFork, effort, - extraEnv: agentInterruptEnv, extraSettings: agentInterruptSettings }) + extraEnv: agentInterruptEnv, extraSettings: agentInterruptSettings, runMeta: { purpose: 'multi', sessionId } }) .onText(t => { agentText+=t; { const _cb = (chatBuffers.get(sessionId) || '') + t; chatBuffers.set(sessionId, _cb.length > MAX_CHAT_BUFFER ? _cb.slice(-MAX_CHAT_BUFFER) : _cb); } try { ws.send(JSON.stringify({ type:'text', text:t, agent:agent.id, ...(tabId ? { tabId } : {}) })); } catch {} }) .onTool((n,i) => { if (n !== 'ask_user' && n !== 'notify_user' && n !== 'set_ui_state') { try { ws.send(JSON.stringify({ type:'tool', tool:n, input:(i||'').substring(0,600), agent:agent.id, ...(tabId ? { tabId } : {}) })); } catch {} } try { stmts.addMsg.run(sessionId,'assistant','tool',(i||'').substring(0,500),n,agent.id,null,null); } catch {} }) .onResult(r => { agentResult = r; }) @@ -6090,7 +6251,7 @@ Provide a clear summary of what was accomplished. Be concise.`; await new Promise(res => { let _settled = false; const _res = () => { if (!_settled) { _settled = true; res(); } }; - cli.send({ prompt:summaryPrompt, sessionId: currentSessionId, model, maxTurns:1, allowedTools:[], abortController, effort }) + cli.send({ prompt:summaryPrompt, sessionId: currentSessionId, model, maxTurns:1, allowedTools:[], abortController, effort, runMeta: { purpose: 'multi', sessionId } }) .onText(t => { summaryText+=t; { const _cb = (chatBuffers.get(sessionId) || '') + t; chatBuffers.set(sessionId, _cb.length > MAX_CHAT_BUFFER ? _cb.slice(-MAX_CHAT_BUFFER) : _cb); } try { ws.send(JSON.stringify({ type:'text', text:t, agent:'summarizer', ...(tabId ? { tabId } : {}) })); } catch {} }) .onSessionId(sid => { currentSessionId = sid; try { stmts.updateClaudeId.run(sid, sessionId); } catch {} }) .onError(() => _res()) @@ -6994,9 +7155,17 @@ app.post('/api/translate', express.json({ limit: '500kb' }), (req, res) => { const langName = LANG_NAMES[targetLang] || 'English'; const bin = claudeCli.claudeBin; + // Routed like every other run (the utility model, on its provider); the only direct + // spawn left outside ClaudeCLI.send, so it asks the same hook itself. + const target = ClaudeCLI.resolveRunTarget({ model: utilityModel(), engine: 'api', meta: { purpose: 'utility' } }); + if (target && target.error) return res.status(502).json({ error: 'Translation failed', detail: target.error }); const env = { ...process.env }; delete env.CLAUDECODE; + if (target && target.runEnv) providersLib.applyRunEnv(env, target.runEnv); + // Same rule as claude-cli.js: this child has Read/Write on a prompt built from user text. + for (const k of ClaudeCLI.SERVER_ONLY_ENV) delete env[k]; if (!env.ANTHROPIC_BASE_URL) delete env.ANTHROPIC_API_KEY; + const releaseTarget = () => { if (target && typeof target.release === 'function') { try { target.release(); } catch {} } }; // Write source text to temp file — avoids CLI argument length limits const tmpId = `claude-translate-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`; @@ -7010,9 +7179,10 @@ app.post('/api/translate', express.json({ limit: '500kb' }), (req, res) => { const prompt = `Read the file ${srcFile}. Translate the entire content to ${langName}. Write ONLY the pure translation (no comments, no explanations, preserve all line breaks and structure) to ${dstFile}.`; const args = [ '--print', - '--model', 'haiku', + '--model', (target && target.cliModel) || 'haiku', '--max-turns', '3', '--no-session-persistence', + ...((target && target.extraArgs) || []), '--dangerously-skip-permissions', '--tools', 'Read,Write', '--output-format', 'text', @@ -7034,6 +7204,7 @@ app.post('/api/translate', express.json({ limit: '500kb' }), (req, res) => { proc.on('close', code => { clearTimeout(timeout); + releaseTarget(); if (responded) return; responded = true; try { @@ -7060,6 +7231,7 @@ app.post('/api/translate', express.json({ limit: '500kb' }), (req, res) => { proc.on('error', err => { clearTimeout(timeout); + releaseTarget(); if (responded) return; responded = true; console.error('[translate] spawn error', err.message); @@ -9106,7 +9278,8 @@ ${transcript}`; cli.send({ prompt: compactPrompt, - model: 'haiku', + model: utilityModel(), + runMeta: { purpose: 'utility', sessionId: sid }, maxTurns: 1, tools: '', mcpServers: {}, @@ -10773,11 +10946,14 @@ async function processTelegramChat({ sessionId, text, userId, chatId, threadId, mode, workdir, tabId: sessionId, + // The chat's own effort dial — Telegram used to drop it and run every turn on Auto. + effort: chatDefaults.effortToFlag(session.effort) || null, }; // Check if the active project is a remote SSH project const _activeProj = findRemoteProject(workdir); - const _isSubscriptionEngine = (session.run_engine || 'api') === 'subscription'; + // Same rule as the web chat: the tmux engine is the CLI login only. + const _isSubscriptionEngine = engineForModel((session.run_engine || 'api') === 'subscription' ? 'subscription' : 'api', model) === 'subscription'; const _botsAvail = botsLogic.botsAvailability({ remote: !!_activeProj, runEngine: session.run_engine }); if (tgBots.length && !_botsAvail.available) { // Bots run through the headless `api` engine only (runBotTurns spawns its own @@ -11524,6 +11700,208 @@ app.get('/api/models', async (req, res) => { res.json({ models: r.models, ...(r.error ? { error: r.error } : {}), ...(r.stale ? { stale: true } : {}) }); }); +// ─── Providers (providers.js / providers-store.js / llm-bridge) ───────────────── +// Keys go in, never out: every answer is providerStore.publicProvider() (hasKey, masked +// secret headers). Model ids carry `/` and `:`, so they travel in the body or the +// query string, never as a path segment. +function providersPayload() { + const pub = providerStore.publicList(); + return { + ...pub, + presets: providersLib.PRESETS, + dialects: providersLib.DIALECTS, + bridge: { running: bridgeUp(), error: bridgeUp() ? null : (llmBridgeError || 'starting') }, + lastStatus: providerStore.lastStatusByProvider(), + }; +} + +async function refreshProviderCatalog(id) { + const p = providerStore.get(id); + if (!p) return { ok: false, error: 'not_found' }; + if (p.type === 'claude-subscription') { + const st = require('./claude-cli').claudeCliStatus(); + const lastTest = { ok: st.available && st.authenticated, at: Date.now(), error: !st.available ? 'cli_missing' : (!st.authenticated ? 'not_logged_in' : null) }; + providerStore.update(id, { lastTest }); + return { ok: lastTest.ok, total: 0, noCatalog: true, error: lastTest.error }; + } + const r = await providersCatalog.fetchCatalog(p); + let merged = null; + if (r.ok && r.models.length) merged = providerStore.upsertCatalog(id, r.models); + providerStore.update(id, { lastTest: { ok: r.ok, at: Date.now(), models: r.models.length, error: r.error || null, noCatalog: !!r.noCatalog } }); + return { ok: r.ok, total: r.models.length, added: merged ? merged.added : 0, autoEnabled: merged ? merged.autoEnabled : false, noCatalog: !!r.noCatalog, error: r.error || null }; +} + +/** Two real requests through the bridge: a forced tool call and a plain reply. */ +async function probeProviderModel(providerId, modelId) { + if (!bridgeUp()) return { ok: false, error: 'bridge_down' }; + const t = providersLib.resolveModel(providersLib.formatModelRef(providerId, modelId), providerStore.registry(), { engine: 'api' }); + if (!t.ok || t.fallback || t.routing !== 'bridge') return { ok: false, error: t.error || t.fallback || 'not_probeable' }; + const ctx = providersLib.buildRunCtx(t, { purpose: 'probe' }); + const token = llmBridge.registerRun(ctx); + const call = async (body) => { + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), 90000); + const t0 = Date.now(); + try { + const r = await fetch(`${llmBridge.baseUrl()}/v1/messages`, { method: 'POST', signal: ctl.signal, + headers: { 'content-type': 'application/json', 'x-api-key': token, 'anthropic-version': '2023-06-01' }, + body: JSON.stringify({ model: t.cliModel, max_tokens: 512, ...body }) }); + const j = await r.json().catch(() => null); + return { status: r.status, body: j, ms: Date.now() - t0 }; + } finally { clearTimeout(timer); } + }; + try { + const tool = await call({ + messages: [{ role: 'user', content: 'Connectivity probe. Call the tool `ping` with value 7. Do not answer in text.' }], + tools: [{ name: 'ping', description: 'Connectivity probe — call it with the number you were given.', input_schema: { type: 'object', properties: { value: { type: 'integer' } }, required: ['value'] } }], + tool_choice: { type: 'any' }, + }); + const text = await call({ messages: [{ role: 'user', content: 'Reply with exactly the word OK and nothing else.' }] }); + const errOf = (x) => (x.status >= 400 ? (x.body && x.body.error && x.body.error.message) || `HTTP ${x.status}` : null); + const use = tool.body && Array.isArray(tool.body.content) ? tool.body.content.find(b => b.type === 'tool_use') : null; + const txt = text.body && Array.isArray(text.body.content) ? text.body.content.filter(b => b.type === 'text').map(b => b.text).join('') : ''; + const verified = { + at: Date.now(), + tools: !!(use && use.name === 'ping' && Number(use.input && use.input.value) === 7), + text: /\bOK\b/i.test(txt), + latencyMs: text.ms, + error: errOf(tool) || errOf(text), + }; + providerStore.setModel(providerId, modelId, { verified }); + return { ok: verified.text || verified.tools, verified }; + } catch (e) { + return { ok: false, error: e.name === 'AbortError' ? 'timeout' : e.message }; + } finally { + llmBridge.releaseRun(token, { graceMs: 0 }); + } +} + +app.get('/api/providers', (req, res) => res.json(providersPayload())); + +app.post('/api/providers', async (req, res) => { + const v = providersLib.validateProviderInput(req.body || {}, { creating: true }); + if (v.errors.length) return res.status(400).json({ error: 'invalid', fields: v.errors }); + try { providerStore.create(v.value); } + catch (e) { if (e.code === 'exists') return res.status(409).json({ error: 'exists' }); throw e; } + const refresh = await refreshProviderCatalog(v.value.id).catch(e => ({ ok: false, error: e.message })); + res.json({ ok: true, provider: providerStore.publicProvider(providerStore.get(v.value.id)), refresh }); +}); + +app.put('/api/providers/:id', (req, res) => { + const cur = providerStore.get(req.params.id); + if (!cur) return res.status(404).json({ error: 'not_found' }); + const body = req.body || {}; + // The CLI-login row has no URL, key or type to edit — only whether it is offered. + const raw = cur.type === 'claude-subscription' ? { enabled: body.enabled } : body; + const v = providersLib.validateProviderInput(raw, { creating: false }); + if (v.errors.length) return res.status(400).json({ error: 'invalid', fields: v.errors }); + providerStore.update(cur.id, { ...v.value, clearApiKey: cur.type !== 'claude-subscription' && body.clearApiKey === true }); + const reg = providerStore.registry(); + if (v.value.enabled === false && reg.defaultProviderId === cur.id) providerStore.setDefault(providersLib.BUILTIN_CLAUDE_ID); + if (llmBridge && llmBridge.updateProviders) { try { llmBridge.updateProviders({}); } catch {} } + res.json({ ok: true, provider: providerStore.publicProvider(providerStore.get(cur.id)) }); +}); + +app.delete('/api/providers/:id', (req, res) => { + if (req.params.id === providersLib.BUILTIN_CLAUDE_ID) return res.status(400).json({ error: 'builtin' }); + if (!providerStore.get(req.params.id)) return res.status(404).json({ error: 'not_found' }); + providerStore.remove(req.params.id); + res.json({ ok: true }); +}); + +app.post('/api/providers/:id/default', (req, res) => { + if (!providerStore.setDefault(req.params.id)) return res.status(400).json({ error: 'unavailable' }); + res.json({ ok: true, defaultProviderId: req.params.id }); +}); + +app.put('/api/providers-settings', (req, res) => { + const { utilityModel: um } = req.body || {}; + if (um !== undefined) { + if (um !== '' && um !== null && !providersLib.isKnownModel(String(um), providerStore.registry())) return res.status(400).json({ error: 'invalid', fields: ['utilityModel'] }); + providerStore.setSetting('utilityModel', um || ''); + } + res.json({ ok: true, utilityModel: providerStore.registry().utilityModel }); +}); + +app.post('/api/providers/:id/test', async (req, res) => { + if (!providerStore.get(req.params.id)) return res.status(404).json({ error: 'not_found' }); + res.json(await refreshProviderCatalog(req.params.id).catch(e => ({ ok: false, error: e.message }))); +}); + +// Test a DRAFT before saving it (the SSH-host "test-new" pattern). A blank key on an +// existing id means "the stored one" — the form never receives the key to resend. +app.post('/api/providers-test', async (req, res) => { + const body = req.body || {}; + const v = providersLib.validateProviderInput({ ...body, id: undefined }, { creating: false }); + if (v.errors.length) return res.status(400).json({ error: 'invalid', fields: v.errors }); + const stored = body.id ? providerStore.get(String(body.id)) : null; + const draft = { type: v.value.type || (stored && stored.type), baseUrl: v.value.baseUrl || (stored && stored.baseUrl), + apiKey: v.value.apiKey || (stored && stored.apiKey) || '', authScheme: v.value.authScheme || (stored && stored.authScheme) || 'bearer', + headers: v.value.headers || (stored && stored.headers) || {} }; + if (!draft.type || !draft.baseUrl) return res.status(400).json({ error: 'invalid', fields: ['baseUrl'] }); + const r = await providersCatalog.fetchCatalog(draft).catch(e => ({ ok: false, models: [], error: e.message })); + res.json({ ok: r.ok, total: r.models.length, sample: r.models.slice(0, 8).map(m => m.id), noCatalog: !!r.noCatalog, error: r.error || null }); +}); + +app.post('/api/providers/:id/models/refresh', async (req, res) => { + if (!providerStore.get(req.params.id)) return res.status(404).json({ error: 'not_found' }); + const r = await refreshProviderCatalog(req.params.id).catch(e => ({ ok: false, error: e.message })); + res.json({ ...r, provider: providerStore.publicProvider(providerStore.get(req.params.id)) }); +}); + +app.post('/api/providers/:id/models', (req, res) => { + const p = providerStore.get(req.params.id); + if (!p) return res.status(404).json({ error: 'not_found' }); + if (p.type === 'claude-subscription') return res.status(400).json({ error: 'builtin' }); + const { modelId, label, caps } = req.body || {}; + const m = providerStore.addManualModel(p.id, String(modelId || '').trim(), { label, caps }); + if (!m) return res.status(400).json({ error: 'invalid', fields: ['modelId'] }); + res.json({ ok: true, model: m }); +}); + +app.put('/api/providers/:id/models', (req, res) => { + const { modelId, enabled, label, caps, pricing } = req.body || {}; + const m = providerStore.setModel(req.params.id, String(modelId || ''), { enabled, label, caps, pricing }); + if (!m) return res.status(404).json({ error: 'not_found' }); + res.json({ ok: true, model: m }); +}); + +// Bulk on/off for a big catalogue: { modelIds:[…], enabled }. +app.put('/api/providers/:id/models/bulk', (req, res) => { + const { modelIds, enabled } = req.body || {}; + if (!Array.isArray(modelIds) || modelIds.length > 2000) return res.status(400).json({ error: 'invalid' }); + let n = 0; + for (const id of modelIds) if (providerStore.setModel(req.params.id, String(id), { enabled: !!enabled })) n++; + res.json({ ok: true, changed: n }); +}); + +app.delete('/api/providers/:id/models', (req, res) => { + const modelId = String(req.query.modelId || ''); + if (!providerStore.removeModel(req.params.id, modelId)) return res.status(404).json({ error: 'not_found' }); + res.json({ ok: true }); +}); + +app.post('/api/providers/:id/models/probe', async (req, res) => { + const { modelId } = req.body || {}; + if (!providerStore.get(req.params.id)) return res.status(404).json({ error: 'not_found' }); + res.json(await probeProviderModel(req.params.id, String(modelId || ''))); +}); + +// What every model picker renders (chat toolbar, bot editor, Kanban, schedule, Telegram). +app.get('/api/models/choices', (req, res) => { + res.json({ ...providersLib.listChoices(providerStore.registry()), bridge: { running: bridgeUp() } }); +}); + +app.get('/api/llm-usage', (req, res) => { + res.json({ rows: providerStore.usageSummary(parseInt(req.query.days, 10) || 30) }); +}); + +app.get('/api/llm-bridge/log', async (req, res) => { + if (!llmBridge) return res.json({ running: false, entries: [] }); + try { res.json({ running: bridgeUp(), entries: await llmBridge.recentLog() }); } + catch (e) { res.json({ running: bridgeUp(), entries: [], error: e.message }); } +}); + // A bot row carries the projects it is available in. The UI needs this to say // "also in Alpha, Beta" before an edit and to warn that a delete is global — // without it every bot list would cost one extra request per bot. @@ -12440,7 +12818,14 @@ wss.on('connection', (ws) => { } } - const { text:userMessage, attachments=[], skills:sIds=[], mcpServers:mIds=[], mode=_cd.mode, agentMode=_cd.agent, model=_cd.model, maxTurns=_cd.turns, workdir=null, reply_to=null, retry=false, autoSkill=false, effort=_cd.effort, engine='api' } = msg; + const { text:userMessage, attachments=[], skills:sIds=[], mcpServers:mIds=[], mode=_cd.mode, agentMode=_cd.agent, model=_cd.model, maxTurns=_cd.turns, workdir=null, reply_to=null, retry=false, autoSkill=false, effort=_cd.effort, engine: engineDial='api' } = msg; + // What the toolbar says (persisted as the chat's run_engine) vs what the run can + // use: the tmux Subscription engine is the CLI's own login, so a model of another + // provider runs headless (providers.effectiveEngine) — and the user is told once. + const engine = engineForModel(engineDial === 'subscription' ? 'subscription' : 'api', model); + if (engineDial === 'subscription' && engine !== 'subscription') { + try { ws.send(JSON.stringify({ type: 'provider_notice', kind: 'engine_api', ref: model, ...(effectiveTabId ? { tabId: effectiveTabId } : {}) })); } catch {} + } // Two different things, deliberately kept apart: `effort` is what the chat is // SET to (may be the spelled-out 'auto'), `_effortFlag` is what the CLI gets // (no flag at all for auto). Storing the flag would erase the difference @@ -12531,7 +12916,7 @@ wss.on('connection', (ws) => { localSessionId); } catch (e) { log.error('updateConfig failed', { sessionId: localSessionId, mode, agentMode, model, mIdsLen: mIds.length, skillsLen: effectiveSkills.length, err: e.message, stack: e.stack }); throw e; } // Persist engine choice (coerce anything other than 'subscription' to 'api') - try { db.prepare(`UPDATE sessions SET run_engine=? WHERE id=?`).run(engine === 'subscription' ? 'subscription' : 'api', localSessionId); } catch {} + try { db.prepare(`UPDATE sessions SET run_engine=? WHERE id=?`).run(engineDial === 'subscription' ? 'subscription' : 'api', localSessionId); } catch {} // Auto-title: use LLM-generated title if available, otherwise smart-truncate message if (isNewSession || DEFAULT_SESSION_TITLES.has(existSess?.title)) { @@ -13555,7 +13940,7 @@ wss.on('connection', (ws) => { // Same hardening as runMultiAgent's plan call: tools='' disables built-in tools, // settingSources='' skips CLAUDE.md + skills, --json-schema makes the model emit // via the synthetic StructuredOutput tool — captured in .onTool below. - cli.send({ prompt: planPrompt, sessionId: sanitizeSessionId(session?.claude_session_id), model: model || 'sonnet', maxTurns: 1, allowedTools: [], tools: '', settingSources: '', jsonSchema: planSchema }) + cli.send({ prompt: planPrompt, sessionId: sanitizeSessionId(session?.claude_session_id), model: model || 'sonnet', maxTurns: 1, allowedTools: [], tools: '', settingSources: '', jsonSchema: planSchema, effort: effort || null, runMeta: { purpose: 'dispatch', sessionId: session?.id || null } }) .onText(t => { planText += t; }) .onTool((name, input) => { if (name === 'StructuredOutput' && input) planText = typeof input === 'string' ? input : JSON.stringify(input); }) .onError(() => { if (!done) { done = true; resolve(); } }) From 89832cd8e99569d48c3e3e622faa900561747c88 Mon Sep 17 00:00:00 2001 From: Its-My-Work <122925955+Its-My-Work@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:02:37 +0300 Subject: [PATCH 03/21] feat(providers): accept provider refs wherever a model is chosen chat-defaults takes 'provider::model' besides the four aliases; a bot saved against an unknown/disabled provider is refused; create_task/create_chain take any configured model (listed to the agent via env, re-validated by the endpoint); set_ui_state gains fable and refs. A provider's rejected key gets its own auth kind pointing at Providers instead of 'claude login'. Co-Authored-By: Claude Opus 5.5 --- auth-errors.js | 9 +++++++++ chat-defaults.js | 6 ++++++ mcp-set-ui-state.js | 11 +++++++---- mcp-task-manager.js | 11 ++++++++--- server.js | 28 +++++++++++++++++++++++++--- 5 files changed, 55 insertions(+), 10 deletions(-) diff --git a/auth-errors.js b/auth-errors.js index 013e397..f310f1d 100644 --- a/auth-errors.js +++ b/auth-errors.js @@ -72,6 +72,15 @@ function isAuthError(text = '') { // Each carries the ACTION that resolves it, because "authentication failed" without // a next step is the complaint the issue was filed about. const AUTH_KINDS = [ + { + // A provider from the registry, reached through llm-bridge: the bridge prefixes every + // upstream error with `provider "