From ddb53934c7d5692ccd4978adbf68e4ea67b10ae8 Mon Sep 17 00:00:00 2001 From: inquerium Date: Tue, 15 Sep 2026 16:56:48 -0500 Subject: [PATCH 01/10] feat: a model that fails hands the request to the next one The agent loop takes a fallback chain. A model the gateway rejects (retired free id, missing provider key, quota) or that returns nothing hands the same request to the next model; the one that answers becomes the session's model, so a failure costs one request, not one per step. A dead gateway is still a connection error, and a provider that only rejects streaming still gets its non-streaming retry on the same model. When every model fails the error names each one and why. --- electron/agent.js | 99 +++++++++++++++++++++++++++++++---- test/fallback.js | 131 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+), 11 deletions(-) create mode 100644 test/fallback.js diff --git a/electron/agent.js b/electron/agent.js index a9a9ce9..bf17a79 100644 --- a/electron/agent.js +++ b/electron/agent.js @@ -89,11 +89,36 @@ function subLabel(name, args) { return `${label} ${String(a || "").slice(0, 40)}`.trim(); } +const hasContent = (m) => Boolean(m && ((m.content && m.content.trim()) || (m.tool_calls && m.tool_calls.length))); + +// Accepts an array or a comma-separated string; drops blanks, dups, and the primary. +function normalizeFallbacks(list, primary) { + const raw = Array.isArray(list) ? list : String(list || "").split(","); + const out = []; + for (const m of raw) { + const id = String(m || "").trim(); + if (id && id !== primary && !out.includes(id)) out.push(id); + } + return out; +} + +// Which failures are worth trying another model for. Anything the gateway +// answered with an HTTP status is model-specific — a retired id, a provider +// the user has no key for, a quota, a provider 5xx — and the gateway itself is +// fine. A dead socket is not: no model on the same gateway will do better, so +// that propagates untouched. +const isModelFailure = (err) => Boolean(err && (err.modelFailure || Number.isFinite(err.status))); + class Agent { - constructor({ baseUrl, apiKey, model, workspace, emit, mcp, memory = null, skillsDir = null, browser = null, canSpawn = true, depth = 0, approvalMode = "auto", approver = null, streaming = true }) { + constructor({ baseUrl, apiKey, model, fallbackModels = [], workspace, emit, mcp, memory = null, skillsDir = null, browser = null, canSpawn = true, depth = 0, approvalMode = "auto", approver = null, streaming = true }) { this.baseUrl = baseUrl.replace(/\/$/, ""); this.apiKey = apiKey; this.model = model || "auto"; + // Tried in order when `model` fails. A fallback that answers becomes the + // session's model from then on — one failed request per session, not one + // per step — and the switch is announced through `model_switch`. + this.fallbackModels = normalizeFallbacks(fallbackModels, this.model); + this.modelSwitches = []; // [{ from, to, reason }] — what callers report back this.workspace = workspace; this.emit = emit; // (event, payload) => void this.mcp = mcp || null; @@ -234,31 +259,80 @@ class Agent { return force ? "Nothing to compact yet — the conversation is still short." : null; } + // One model round-trip, walking the fallback chain when the current model + // fails. Free catalogs go stale — an advertised model can 401 as "not + // supported" the day a provider retires it — and a pinned paid model can be + // out of quota. Either way the caller wants an answer, not a dead turn, so + // the next model in the chain gets the same request. When every model fails, + // the error names each one and why, which is what a driving harness needs to + // fix its configuration. async callModel() { + const chain = [this.model, ...this.fallbackModels.filter((m) => m !== this.model)]; + const failures = []; + let lastErr = null; + for (let i = 0; i < chain.length; i++) { + const model = chain[i]; + const last = i === chain.length - 1; + let msg; + try { msg = await this.#callOnce(model); } + catch (err) { + if (this.aborted || !isModelFailure(err)) throw err; + lastErr = err; + failures.push(`${model}: ${err.message}`); + continue; + } + // An empty reply is the other way a free provider "fails". Worth another + // model while one is queued; otherwise returned as before, so the turn + // ends visibly instead of erroring on a reply that merely had no text. + if (!last && !hasContent(msg)) { failures.push(`${model}: empty response`); continue; } + if (model !== this.model) this.#switchModel(model, failures.join("; ")); + return msg; + } + // One model, no chain: the gateway's own error, exactly as before. + if (chain.length === 1 && lastErr) throw lastErr; + const err = new Error(`All models failed:\n- ${failures.join("\n- ")}`); + err.modelFailure = true; + throw err; + } + + #switchModel(to, reason) { + const from = this.model; + this.model = to; + this.modelSwitches.push({ from, to, reason }); + this.emit("model_switch", { from, to, reason }); + this.emit("system", { content: `⇄ ${from} failed (${reason.slice(0, 160)}) — continuing on ${to}` }); + } + + async #callOnce(model) { // Headless callers (MCP delegation) throw the deltas away, so the streaming // attempt buys nothing — and when free `auto` routing lands on a provider // that returns an empty stream, it costs a whole second request per step. // One request per step is dramatically faster there. - if (!this.streaming) return await this.#requestModel(false); + if (!this.streaming) return await this.#requestModel(false, model); // Try streaming for a live feel. Free `auto` routing hits many providers and // some return empty streams — if that happens, fall back to a reliable // non-streaming request. let streamed = null; - try { streamed = await this.#requestModel(true); } catch { streamed = null; } - if (streamed && ((streamed.content && streamed.content.trim()) || (streamed.tool_calls && streamed.tool_calls.length))) { - return streamed; + try { streamed = await this.#requestModel(true, model); } + catch (err) { + // Auth and unknown-model rejections don't depend on streaming — don't pay + // a second request to learn the same thing. Anything else (a provider that + // rejects stream_options, a flaky 5xx) still gets the non-streaming retry. + if (err.status === 401 || err.status === 403 || err.status === 404) throw err; + streamed = null; } - return await this.#requestModel(false); + if (hasContent(streamed)) return streamed; + return await this.#requestModel(false, model); } - async #requestModel(stream) { + async #requestModel(stream, model = this.model) { const res = await fetch(`${this.baseUrl}/chat/completions`, { method: "POST", signal: this.abortCtl?.signal, headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}` }, body: JSON.stringify({ - model: this.model, + model, messages: this.messages, tools: this.toolSchema(), tool_choice: "auto", @@ -272,7 +346,9 @@ class Agent { }); if (!res.ok) { const body = await res.text().catch(() => ""); - throw new Error(`Gateway ${res.status}: ${body.slice(0, 500)}`); + const err = new Error(`Gateway ${res.status}: ${body.slice(0, 500)}`); + err.status = res.status; + throw err; } const ctype = res.headers.get("content-type") || ""; if (stream && ctype.includes("event-stream") && res.body) { @@ -282,7 +358,7 @@ class Agent { } const data = await res.json(); const choice = data.choices && data.choices[0]; - if (!choice) throw new Error("No choices returned from gateway"); + if (!choice) { const err = new Error("No choices returned from gateway"); err.modelFailure = true; throw err; } this.#addUsage(data.usage, ((choice.message && choice.message.content) || "").length); if (choice.message && choice.message.content) this.emit("assistant_delta", { chunk: choice.message.content }); return choice.message; @@ -359,7 +435,7 @@ class Agent { const title = t.title || `subagent ${i + 1}`; this.emit("subagent", { groupId, subId, title, kind: "start" }); const child = new Agent({ - baseUrl: this.baseUrl, apiKey: this.apiKey, model: this.model, + baseUrl: this.baseUrl, apiKey: this.apiKey, model: this.model, fallbackModels: this.fallbackModels, workspace: this.workspace, mcp: this.mcp, browser: this.browser, canSpawn: false, depth: this.depth + 1, // Nobody reads a subagent's token-by-token output — only its tool // labels and final summary. Skipping the stream attempt halves the @@ -369,6 +445,7 @@ class Agent { if (type === "tool_call") this.emit("subagent", { groupId, subId, kind: "tool", tool: subLabel(payload.name, payload.args) }); else if (type === "assistant") this.emit("subagent", { groupId, subId, kind: "text", snippet: String(payload.content || "").slice(0, 160) }); else if (type === "error") this.emit("subagent", { groupId, subId, kind: "error", message: payload.message }); + else if (type === "model_switch") this.emit("subagent", { groupId, subId, kind: "model", from: payload.from, to: payload.to }); }, }); try { await child.send(t.prompt || t.task || title); } diff --git a/test/fallback.js b/test/fallback.js new file mode 100644 index 0000000..180d547 --- /dev/null +++ b/test/fallback.js @@ -0,0 +1,131 @@ +"use strict"; +// The model fallback chain, against a fake OpenAI-compatible gateway (no +// OmniRoute boot): a model the gateway rejects — retired id, missing provider +// key, quota — hands the same request to the next model; the switch sticks for +// the session; when every model fails the error names each one. Also pins the +// pre-existing behaviour it must not break: a provider that rejects streaming +// still gets the non-streaming retry on the *same* model, and a dead gateway is +// not a reason to walk the chain. +// +// Run: node test/fallback.js +const http = require("node:http"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { Agent } = require("../electron/agent"); + +let fails = 0; +const check = (name, ok) => { console.log((ok ? "✓" : "✗"), name); if (!ok) fails++; }; + +const reply = (text) => ({ status: 200, json: { choices: [{ message: { role: "assistant", content: text } }], usage: { prompt_tokens: 1, completion_tokens: 1 } } }); +const retired = (m) => ({ status: 401, json: { error: { message: `[401]: Model ${m} is not supported`, type: "authentication_error", code: "invalid_api_key" } } }); + +// route(body) → { status, json }; every request is recorded in `calls`. +function gateway(route) { + const calls = []; + const server = http.createServer((req, res) => { + let raw = ""; + req.on("data", (d) => { raw += d; }); + req.on("end", () => { + const body = JSON.parse(raw || "{}"); + calls.push({ model: body.model, stream: Boolean(body.stream) }); + const out = route(body); + res.writeHead(out.status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(out.json)); + }); + }); + return new Promise((resolve) => server.listen(0, "127.0.0.1", () => { + resolve({ baseUrl: `http://127.0.0.1:${server.address().port}/v1`, calls, close: () => server.close() }); + })); +} + +const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "omniwork-fallback-")); + +async function turn(gw, opts, prompt = "hi") { + const events = []; + const agent = new Agent({ baseUrl: gw.baseUrl, apiKey: "test", workspace, canSpawn: false, streaming: false, ...opts, emit: (type, p) => events.push({ type, ...p }) }); + await agent.send(prompt); + return { agent, events }; +} +const errorOf = (events) => (events.find((e) => e.type === "error") || {}).message || ""; + +(async () => { + // ── a rejected model falls through, and the switch sticks ── + { + const gw = await gateway((b) => (b.model === "oc/retired-coder" ? retired(b.model) : reply(`hello from ${b.model}`))); + const { agent, events } = await turn(gw, { model: "oc/retired-coder", fallbackModels: ["free/coder"] }); + check("a 401 on the pinned model falls through to the next one", agent.lastText === "hello from free/coder"); + check("the fallback becomes the session's model", agent.model === "free/coder"); + const sw = events.find((e) => e.type === "model_switch"); + check("the switch is emitted with from/to/reason", sw && sw.from === "oc/retired-coder" && sw.to === "free/coder" && /401/.test(sw.reason)); + check("the switch is narrated as a system message", events.some((e) => e.type === "system" && /oc\/retired-coder failed/.test(e.content))); + check("modelSwitches records it for callers", agent.modelSwitches.length === 1 && agent.modelSwitches[0].to === "free/coder"); + const before = gw.calls.length; + await agent.send("again"); + check("the next step does not retry the failed model", gw.calls.length === before + 1 && gw.calls[before].model === "free/coder"); + gw.close(); + } + + // ── every model fails: the error names each one ── + { + const gw = await gateway((b) => retired(b.model)); + const { agent, events } = await turn(gw, { model: "a/one", fallbackModels: ["b/two"] }); + const msg = errorOf(events); + check("when the whole chain fails the turn errors", Boolean(msg)); + check("…and the error lists every model with its failure", /All models failed/.test(msg) && /a\/one: Gateway 401/.test(msg) && /b\/two: Gateway 401/.test(msg)); + check("no switch is recorded on total failure", agent.modelSwitches.length === 0 && agent.model === "a/one"); + gw.close(); + } + + // ── a single model with no chain keeps the plain error ── + { + const gw = await gateway((b) => retired(b.model)); + const { events } = await turn(gw, { model: "a/one" }); + check("without a chain the error is the gateway's own", /^Gateway 401/.test(errorOf(events))); + gw.close(); + } + + // ── an empty reply is a failure while a fallback is queued ── + { + const gw = await gateway((b) => (b.model === "free/empty" ? reply("") : reply("real answer"))); + const { agent } = await turn(gw, { model: "free/empty", fallbackModels: ["free/ok"] }); + check("an empty response moves to the next model", agent.lastText === "real answer" && agent.model === "free/ok"); + check("…with 'empty response' as the reason", /empty response/.test(agent.modelSwitches[0].reason)); + gw.close(); + } + + // ── streaming rejected ≠ model rejected: same model, non-streaming retry ── + { + const gw = await gateway((b) => (b.stream ? { status: 400, json: { error: { message: "stream_options not supported" } } } : reply("nonstream"))); + const { agent } = await turn(gw, { model: "free/coder", fallbackModels: ["other/model"], streaming: true }); + check("a streaming 400 retries the same model without streaming", agent.lastText === "nonstream" && agent.modelSwitches.length === 0); + check("…as exactly one extra request", gw.calls.length === 2 && gw.calls.every((c) => c.model === "free/coder")); + gw.close(); + } + + // ── streaming 401 is final for that model: no non-streaming retry ── + { + const gw = await gateway((b) => (b.model === "oc/retired-coder" ? retired(b.model) : reply("ok"))); + const { agent } = await turn(gw, { model: "oc/retired-coder", fallbackModels: ["free/coder"], streaming: true }); + check("a streaming 401 skips the non-streaming retry and walks the chain", agent.model === "free/coder" && gw.calls.filter((c) => c.model === "oc/retired-coder").length === 1); + gw.close(); + } + + // ── a dead gateway is not a model failure ── + { + const { agent, events } = await turn({ baseUrl: "http://127.0.0.1:9/v1" }, { model: "a/one", fallbackModels: ["b/two"] }); + check("a dead gateway surfaces the connection error", /connection|fetch|engine/i.test(errorOf(events))); + check("…without walking the chain", agent.modelSwitches.length === 0); + } + + // ── chain normalisation ── + { + const a = new Agent({ baseUrl: "http://x", apiKey: "k", workspace, model: "p", fallbackModels: " a, b,,a ,p", emit: () => {} }); + check("a comma string becomes a deduped chain without the primary", JSON.stringify(a.fallbackModels) === JSON.stringify(["a", "b"])); + const b = new Agent({ baseUrl: "http://x", apiKey: "k", workspace, model: "p", fallbackModels: ["p", "q"], emit: () => {} }); + check("an array chain drops the primary too", JSON.stringify(b.fallbackModels) === JSON.stringify(["q"])); + } + + console.log(fails ? `\n❌ ${fails} FAILED` : "\n✅ FALLBACK TEST PASSED"); + process.exit(fails ? 1 : 0); +})().catch((e) => { console.error("crash:", e); process.exit(1); }); From 1b30679de71160a3e7ab95ccccae21d5471ebb05 Mon Sep 17 00:00:00 2001 From: inquerium Date: Tue, 15 Sep 2026 16:56:48 -0500 Subject: [PATCH 02/10] feat: OpenCode as an engine OpenCode Zen serves its free models (Nemotron 3.5 Lightning, MiMo V2.5, Big Pickle, Ling 3.0 Flash, Nemotron 3 Ultra) only to OpenCode itself. So OmniWork runs OpenCode: one `opencode serve` per process, sessions scoped to the workspace with the official directory header, events (text, reasoning, tools, permissions, errors) mapped onto OmniWork's agent events by OpenCodeAgent, which has the same surface as Agent. Snapshots and the file watcher are off for engine sessions (a home-folder session went from 174 s to 4 s). The binary is found by absolute path wherever it landed: an env override, the packaged app's resources, a clone's node_modules, a download in the data dir, or an install the user already has; download() fetches OpenCode's official release archive. The server stops with the process, on exit or signal. --- electron/opencode-engine.js | 506 +++++++++++++++++++++++++++++++++ test/fixtures/fake-opencode.js | 98 +++++++ 2 files changed, 604 insertions(+) create mode 100644 electron/opencode-engine.js create mode 100755 test/fixtures/fake-opencode.js diff --git a/electron/opencode-engine.js b/electron/opencode-engine.js new file mode 100644 index 0000000..da508fc --- /dev/null +++ b/electron/opencode-engine.js @@ -0,0 +1,506 @@ +"use strict"; +// OpenCode as an engine inside OmniWork. +// +// OpenCode Zen's free models (Nemotron 3 Ultra, Nemotron 3.5 Lightning, +// MiMo V2.5, Ling 3.0 Flash, Big Pickle…) are real, keyless, tool-capable, and +// generous — and Zen refuses them to anything that isn't OpenCode ("OpenCode's +// free tier can only be used in OpenCode"). We don't fake being OpenCode; we +// run it. `opencode serve` is the headless server OpenCode ships for exactly +// this — Zed and acpx drive it the same way — and its sessions do the work on +// those models with OpenCode's own tools, scoped to our workspace through the +// `x-opencode-directory` header the official SDK uses. +// +// One server per OmniWork process, started on first use, killed on exit. +// `OpenCodeAgent` wraps a server session in the same interface `Agent` has, so +// the desktop app, the MCP server, and the ACP server can run a session on +// `opencode/` without knowing the difference. + +const { spawn, execFileSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const PROVIDER_IDS = ["opencode", "opencode-go"]; +const PREFIX = "opencode/"; + +// ── locating / getting the binary ──────────────────────────────── +// Nobody should have to touch their PATH. The binary is looked for where each +// way of getting OmniWork puts it, and spawned by absolute path: +// 1. OMNIWORK_OPENCODE_BIN an explicit override +// 2. /resources/runtime/opencode the packaged desktop app (staged per platform at build time) +// 3. node_modules/opencode-- a clone after `npm install` (opencode-ai is an optional dependency) +// 4. ~/.omniwork/engine/opencode downloaded on first use from OpenCode's GitHub release +// 5. ~/.opencode/bin, npm's global bin, Homebrew, and the PATH — an install the user already has +const REPO_ROOT = path.join(__dirname, ".."); +const DOWNLOAD_DIR = path.join(os.homedir(), ".omniwork", "engine", "opencode"); +const RELEASES = "https://github.com/anomalyco/opencode/releases/download"; + +function target() { + const platform = process.platform === "win32" ? "windows" : process.platform === "darwin" ? "darwin" : "linux"; + const arch = process.arch === "arm64" ? "arm64" : "x64"; + return { platform, arch }; +} +const exeName = () => (process.platform === "win32" ? "opencode.exe" : "opencode"); + +function candidates() { + const exe = exeName(); + const { platform, arch } = target(); + const list = []; + if (process.env.OMNIWORK_OPENCODE_BIN) list.push(process.env.OMNIWORK_OPENCODE_BIN); + if (process.resourcesPath) list.push(path.join(process.resourcesPath, "runtime", "opencode", exe)); + const pkg = `opencode-${platform}-${arch}`; + for (const root of [REPO_ROOT, path.join(REPO_ROOT, "node_modules", "opencode-ai")]) { + list.push(path.join(root, "node_modules", pkg, "bin", exe)); + list.push(path.join(root, "node_modules", `${pkg}-baseline`, "bin", exe)); + } + list.push(path.join(REPO_ROOT, "node_modules", "opencode-ai", "bin", "opencode.exe")); // the package's postinstall copy (that name on every OS) + list.push(path.join(DOWNLOAD_DIR, exe)); + const npmPrefix = process.env.npm_config_prefix || process.env.NPM_CONFIG_PREFIX; + list.push( + path.join(os.homedir(), ".opencode", "bin", exe), + "/opt/homebrew/bin/opencode", "/usr/local/bin/opencode", + path.join(os.homedir(), ".bun", "bin", exe), + path.join(os.homedir(), ".npm-global", "bin", exe), + path.join(os.homedir(), ".npm", "bin", exe), + ...(npmPrefix ? [path.join(npmPrefix, "bin", exe)] : []), + ...(process.platform === "win32" ? [path.join(process.env.APPDATA || "", "npm", "opencode.cmd")] : []), + ); + for (const dir of String(process.env.PATH || "").split(path.delimiter)) if (dir) list.push(path.join(dir, exe)); + return list; +} + +function findBinary() { + for (const c of candidates()) { try { fs.accessSync(c, fs.constants.X_OK); return c; } catch {} } + return null; +} +const available = () => Boolean(findBinary()); + +// The version OmniWork ships and downloads: whatever package.json pins for +// opencode-ai, so a clone and a download agree. +function pinnedVersion() { + try { + const pkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8")); + const v = (pkg.optionalDependencies || {})["opencode-ai"] || (pkg.dependencies || {})["opencode-ai"] || ""; + return v.replace(/^[^0-9]*/, "") || "1.18.31"; + } catch { return "1.18.31"; } +} +function assetName(t = target()) { return t.platform === "linux" ? `opencode-linux-${t.arch}.tar.gz` : `opencode-${t.platform}-${t.arch}.zip`; } +const INSTALL_COMMAND = "npm run providers connect opencode"; +const INSTALL_SCRIPT = "curl -fsSL https://opencode.ai/install | bash"; + +function version() { + const bin = findBinary(); + if (!bin) return null; + try { return execFileSync(bin, ["--version"], { encoding: "utf8", timeout: 15_000 }).trim().split("\n").pop(); } + catch { return "installed"; } +} + +// Fetch OpenCode's official release archive for this machine (~45 MB) into the +// data dir and unpack it. The same archive the `opencode.ai/install` script +// uses; no npm, no PATH, no shell profile. Only ever run from an explicit user +// gesture — a click, a typed command, an ACP auth method — never a tool call. +async function download({ version: ver = pinnedVersion(), baseUrl = RELEASES, dir = DOWNLOAD_DIR, onProgress = () => {} } = {}) { + const { Readable, Transform } = require("node:stream"); + const { pipeline } = require("node:stream/promises"); + const asset = assetName(); + const url = `${baseUrl}/v${ver}/${asset}`; + fs.mkdirSync(dir, { recursive: true }); + const archive = path.join(dir, asset); + onProgress({ phase: "download", detail: `Downloading OpenCode v${ver} (${asset})…`, url }); + const res = await fetch(url); + if (!res.ok || !res.body) throw new Error(`OpenCode download failed: HTTP ${res.status} for ${url}`); + const total = Number(res.headers.get("content-length")) || 0; + let received = 0; + const meter = new Transform({ transform(chunk, _enc, cb) { received += chunk.length; onProgress({ phase: "download", received, total }); cb(null, chunk); } }); + await pipeline(Readable.fromWeb(res.body), meter, fs.createWriteStream(archive + ".part")); + fs.renameSync(archive + ".part", archive); + onProgress({ phase: "extract", detail: "Unpacking…" }); + // bsdtar (macOS, Windows 10+) reads zip as well as tar; GNU tar handles the Linux tarball. + execFileSync("tar", ["-xf", archive, "-C", dir], { stdio: "ignore" }); + fs.rmSync(archive, { force: true }); + const bin = path.join(dir, exeName()); + if (!fs.existsSync(bin)) throw new Error(`OpenCode archive unpacked but ${bin} is missing`); + if (process.platform !== "win32") fs.chmodSync(bin, 0o755); + const v = execFileSync(bin, ["--version"], { encoding: "utf8", timeout: 20_000 }).trim().split("\n").pop(); + onProgress({ phase: "done", detail: `OpenCode ${v} ready` }); + return bin; +} + +// Kept as the one entry point every surface calls: get a binary by whatever +// means is left. Today that is the release download. +async function install({ log = () => {} } = {}) { + const found = findBinary(); + if (found) return found; + let lastPct = -1; + return await download({ onProgress: (p) => { + if (p.detail) log(p.detail); + else if (p.total) { const pct = Math.floor((p.received / p.total) * 100); if (pct !== lastPct && pct % 10 === 0) { lastPct = pct; log(`${pct}%`); } } + } }); +} + +// ── the server ──────────────────────────────────────────────────── +class Engine { + constructor({ bin = findBinary(), log = () => {} } = {}) { + this.bin = bin; + this.log = log; + this.proc = null; + this.baseUrl = null; + this.starting = null; + this.listeners = new Map(); // sessionID -> Set + this.pumps = new Map(); // directory -> promise that resolves once its event stream is connected + this.modelsCache = { at: 0, list: [] }; + } + + async start() { + if (this.baseUrl) return this.baseUrl; + if (this.starting) return this.starting; + if (!this.bin) throw new Error(`OpenCode is not installed — run: ${INSTALL_COMMAND}`); + this.starting = new Promise((resolve, reject) => { + // OpenCode snapshots the whole session directory with `git add --all` + // before every message so its UI can revert. Scoped to a home folder that + // is three minutes per turn (measured: 174 s → 4 s without). OmniWork has + // its own undo, so engine sessions run with snapshots off — merged into + // any config the user already passes by env. + let extra = { snapshot: false }; + if (process.env.OPENCODE_CONFIG_CONTENT) { try { extra = { ...JSON.parse(process.env.OPENCODE_CONFIG_CONTENT), ...extra }; } catch {} } + const env = { + ...process.env, + OPENCODE_DISABLE_AUTOUPDATE: "1", + OPENCODE_DISABLE_TERMINAL_TITLE: "1", + OPENCODE_DISABLE_PRUNE: "1", + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "1", + OPENCODE_CONFIG_CONTENT: JSON.stringify(extra), + }; + // A neutral cwd: the workspace comes per request, never from where we + // happened to start. + const proc = spawn(this.bin, ["serve", "--port", "0", "--hostname", "127.0.0.1"], { cwd: os.tmpdir(), env, stdio: ["ignore", "pipe", "pipe"] }); + this.proc = proc; + let buf = ""; + const timer = setTimeout(() => reject(new Error("opencode serve did not report a port within 60s")), 60_000); + const onLine = (line) => { + const m = /listening on (http:\/\/[^\s]+)/.exec(line); + if (m && !this.baseUrl) { + this.baseUrl = m[1].replace(/\/$/, ""); + clearTimeout(timer); + resolve(this.baseUrl); + } else if (/error/i.test(line)) this.log("[opencode]", line.slice(0, 200)); + }; + const feed = (d) => { buf += d; let i; while ((i = buf.indexOf("\n")) >= 0) { onLine(buf.slice(0, i)); buf = buf.slice(i + 1); } }; + proc.stdout.on("data", feed); + proc.stderr.on("data", feed); + proc.on("error", (e) => { clearTimeout(timer); reject(e); }); + proc.on("exit", (code) => { + clearTimeout(timer); + const was = this.baseUrl; + this.baseUrl = null; this.proc = null; this.starting = null; this.pumps.clear(); + if (!was) reject(new Error(`opencode serve exited with ${code} before listening`)); + else this.log("[opencode] server exited", code); + }); + }); + try { return await this.starting; } + finally { this.starting = null; } + } + + stop() { + if (this.proc) { try { this.proc.kill(); } catch {} } + this.proc = null; this.baseUrl = null; + } + + async #req(method, p, { body, directory, timeoutMs = 30_000, signal } = {}) { + const base = await this.start(); + const headers = { "Content-Type": "application/json" }; + if (directory) headers["x-opencode-directory"] = encodeURIComponent(directory); + const res = await fetch(base + p, { + method, headers, + body: body ? JSON.stringify(body) : undefined, + // A prompt runs as long as the task takes; everything else gets a deadline. + signal: signal || (timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined), + }); + const text = await res.text().catch(() => ""); + let json = null; try { json = JSON.parse(text); } catch {} + if (!res.ok) throw new Error(`opencode ${method} ${p}: ${res.status} ${(json && json.error && (json.error.message || json.error.type)) || text.slice(0, 200)}`); + return json; + } + + // OpenCode's event stream is scoped to a directory — without the header it + // carries only server heartbeats — so there is one subscription per + // workspace, fanned out per session, kept alive for the life of the server. + async #pump(directory, onConnected) { + const base = this.baseUrl; + while (this.baseUrl === base) { + try { + const res = await fetch(`${base}/event`, { headers: { Accept: "text/event-stream", "x-opencode-directory": encodeURIComponent(directory) } }); + if (onConnected) { onConnected(); onConnected = null; } + const reader = res.body.getReader(); + const dec = new TextDecoder(); + let buf = ""; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + let i; + while ((i = buf.indexOf("\n\n")) >= 0) { + const chunk = buf.slice(0, i); buf = buf.slice(i + 2); + for (const line of chunk.split("\n")) { + if (!line.startsWith("data:")) continue; + let ev; try { ev = JSON.parse(line.slice(5).trim()); } catch { continue; } + const sid = ev && ev.properties && ev.properties.sessionID; + const set = sid && this.listeners.get(sid); + if (set) for (const fn of set) { try { fn(ev); } catch {} } + } + } + } + } catch {} + if (this.baseUrl === base) await new Promise((r) => setTimeout(r, 500)); + } + } + + // Make sure the workspace's event stream is connected before a prompt goes + // out — the first tool part can arrive within milliseconds. + ensurePump(directory) { + if (!this.pumps.has(directory)) { + this.pumps.set(directory, new Promise((resolve) => { this.#pump(directory, resolve); setTimeout(resolve, 3000); })); + } + return this.pumps.get(directory); + } + + subscribe(sessionID, fn) { + if (!this.listeners.has(sessionID)) this.listeners.set(sessionID, new Set()); + this.listeners.get(sessionID).add(fn); + return () => { const s = this.listeners.get(sessionID); if (s) { s.delete(fn); if (!s.size) this.listeners.delete(sessionID); } }; + } + + async health() { return await this.#req("GET", "/global/health", { timeoutMs: 5000 }); } + + // Free models on OpenCode's own providers: `opencode/` in OmniWork terms. + async models({ force = false } = {}) { + if (!force && Date.now() - this.modelsCache.at < 300_000) return this.modelsCache.list; + const data = await this.#req("GET", "/provider", { timeoutMs: 20_000 }); + const list = []; + for (const prov of (data && data.all) || []) { + if (!PROVIDER_IDS.includes(prov.id)) continue; + for (const [id, m] of Object.entries(prov.models || {})) { + const cost = m && m.cost; + const free = cost && Number(cost.input) === 0 && Number(cost.output) === 0; + if (!free) continue; + list.push({ id: `${PREFIX}${id}`, providerID: prov.id, modelID: id, name: m.name || id, tools: m.tool_call !== false, context: m.limit && m.limit.context }); + } + } + // Strongest coders first: Nemotron Ultra, then the rest in catalog order. + list.sort((a, b) => rank(a.modelID) - rank(b.modelID)); + this.modelsCache = { at: Date.now(), list }; + return list; + } + + async createSession({ directory, title, permission }) { + const body = { title }; + if (permission) body.permission = permission; + const s = await this.#req("POST", "/session", { body, directory }); + return s.id; + } + async prompt(sessionID, { directory, providerID, modelID, text, signal }) { + return await this.#req("POST", `/session/${sessionID}/message`, { + body: { model: { providerID, modelID }, parts: [{ type: "text", text }] }, + directory, signal, timeoutMs: 0, + }); + } + async abort(sessionID, directory) { return await this.#req("POST", `/session/${sessionID}/abort`, { directory, timeoutMs: 5000 }).catch(() => false); } + async replyPermission(sessionID, permissionID, response, directory) { + return await this.#req("POST", `/session/${sessionID}/permissions/${permissionID}`, { body: { response }, directory, timeoutMs: 5000 }); + } + async diff(sessionID, directory) { return await this.#req("GET", `/session/${sessionID}/diff`, { directory, timeoutMs: 10_000 }).catch(() => []); } +} + +// Measured 2026-09-15 on a one-line reply: Lightning 4s, MiMo 5s, Big Pickle +// 6s, Ling 3s, Ultra ~100s per step. Ultra is the strongest and stays +// available by name; the default has to answer in seconds. +const RANK = ["nemotron-3.5-lightning", "mimo-v2.5", "big-pickle", "ling-3.0-flash", "nemotron-3-ultra"]; +function rank(id) { const i = RANK.findIndex((r) => id.startsWith(r)); return i < 0 ? RANK.length : i; } + +// The server must not outlive the process that started it. `exit` covers a +// normal end; a SIGTERM/SIGINT (a harness killing its agent, Ctrl-C) skips the +// exit hook unless we turn it into one. +let shared = null; +function getEngine(opts) { + if (!shared) { + shared = new Engine(opts); + process.on("exit", () => shared && shared.stop()); + for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) { + if (process.listenerCount(sig) === 0) process.on(sig, () => { if (shared) shared.stop(); process.exit(0); }); + } + } + return shared; +} + +// ── the agent ───────────────────────────────────────────────────── +// Same surface as electron/agent.js's Agent, backed by an OpenCode session. +// Events map one-to-one: text deltas → assistant_delta, tool parts → +// tool_call / tool_result, permission requests → the approver, session errors +// → error. What OmniWork can't offer here is its own skills and memory — +// OpenCode runs its own tools — which is the honest price of the free tier. +class OpenCodeAgent { + constructor({ model, workspace, emit, approvalMode = "auto", approver = null, engine = getEngine(), fallbackModels = [] }) { + this.engine = engine; + this.model = model || `${PREFIX}nemotron-3.5-lightning-free`; + this.fallbackModels = fallbackModels; // accepted for interface parity; the engine picks its own + this.workspace = workspace; + this.emit = emit; + this.approvalMode = approvalMode; + this.approver = approver; + this.messages = [{ role: "system", content: "(OpenCode engine session)" }]; + this.lastText = ""; + this.modelSwitches = []; + this.contextTokens = 200_000; + this.memory = null; + this.sessionID = null; + this.aborted = false; + this.undoAvailable = false; + this.turnStats = null; + } + + get isEngine() { return true; } + setWorkspace(dir) { this.workspace = dir; this.sessionID = null; } + abort() { this.aborted = true; if (this.sessionID) this.engine.abort(this.sessionID, this.workspace); } + async compactNow() { return null; } + async oneShot(prompt) { + const { providerID, modelID } = this.#target(); + const sid = await this.engine.createSession({ directory: this.workspace, title: "oneshot", permission: [{ permission: "*", pattern: "*", action: "deny" }] }); + const res = await this.engine.prompt(sid, { directory: this.workspace, providerID, modelID, text: prompt }); + return textOf(res); + } + undo() { return "Undo isn't available for OpenCode engine sessions — use git."; } + + #target() { + const id = this.model.startsWith(PREFIX) ? this.model.slice(PREFIX.length) : this.model; + // opencode-go models are namespaced by the engine's provider list; default provider is Zen. + const cached = this.engine.modelsCache.list.find((m) => m.modelID === id); + return { providerID: cached ? cached.providerID : "opencode", modelID: id }; + } + + // OpenCode's per-session ruleset: an ordered list of {permission, pattern, + // action}, mirroring OmniWork's approval modes. + #permission() { + const rule = (permission, action) => ({ permission, pattern: "*", action }); + if (this.approvalMode === "auto") return [rule("*", "allow")]; + if (this.approvalMode === "plan") return [rule("*", "ask"), rule("read", "allow"), rule("edit", "deny"), rule("write", "deny"), rule("bash", "deny")]; + if (this.approvalMode === "edits") return [rule("*", "ask"), rule("read", "allow"), rule("edit", "allow"), rule("write", "allow")]; + return [rule("*", "ask"), rule("read", "allow")]; + } + + async send(userText, images) { + this.aborted = false; + this.turnStats = { startedAt: Date.now(), inTokens: 0, outTokens: 0, estimated: true }; + const text = images && images.length ? `${userText}\n\n(${images.length} image(s) attached — not forwarded to the OpenCode engine)` : userText; + this.messages.push({ role: "user", content: text }); + this.emit("thinking", { step: 0 }); + + const { providerID, modelID } = this.#target(); + let unsubscribe = () => {}; + let streamedText = ""; + const partType = new Map(); // partID -> "text" | "reasoning" | … (deltas don't say) + const held = new Map(); // partID -> delta text that arrived before the part's type + const seen = new Map(); // callID -> "called" | "done" + const flush = (partID, type, delta) => { + if (type === "text") { streamedText += delta; this.emit("assistant_delta", { chunk: delta }); } + else if (type === "reasoning") this.emit("reasoning_delta", { chunk: delta }); + }; + try { + if (!this.sessionID) this.sessionID = await this.engine.createSession({ directory: this.workspace, title: `OmniWork · ${path.basename(this.workspace || "")}`, permission: this.#permission() }); + const sid = this.sessionID; + await this.engine.ensurePump(this.workspace); + unsubscribe = this.engine.subscribe(sid, (ev) => { + const p = ev.properties || {}; + switch (ev.type) { + // A delta only names its part; the part's type came (or comes) with a + // message.part.updated. Reasoning streams on field "text" too, so a + // delta is held until its part is known and never mistaken for the reply. + case "message.part.delta": { + if (p.field !== "text" || !p.delta) break; + const type = partType.get(p.partID); + if (type) flush(p.partID, type, p.delta); + else held.set(p.partID, (held.get(p.partID) || "") + p.delta); + break; + } + case "message.part.updated": { + const part = p.part || {}; + if (part.id && part.type && !partType.has(part.id)) { + partType.set(part.id, part.type); + if (held.has(part.id)) { flush(part.id, part.type, held.get(part.id)); held.delete(part.id); } + } + if (part.type !== "tool") break; + const st = part.state || {}; + const id = part.callID || part.id; + // Tool input streams in; announce the call once its arguments exist + // (or it finished), never with an empty object. + const hasInput = st.input && Object.keys(st.input).length > 0; + if (!seen.has(id) && (hasInput || st.status === "completed" || st.status === "error")) { + seen.set(id, "called"); + this.emit("tool_call", { id, name: part.tool, args: st.input || {} }); + } + if (seen.get(id) === "called" && (st.status === "completed" || st.status === "error")) { + seen.set(id, "done"); + this.emit("tool_result", { id, result: st.status === "error" ? `Error in ${part.tool}: ${st.error || "failed"}` : String(st.output ?? st.title ?? "") }); + } + break; + } + case "permission.asked": + this.#onPermission(sid, p); + break; + case "session.error": + this.emit("system", { content: `OpenCode: ${(p.error && (p.error.data && p.error.data.message || p.error.name)) || "error"}` }); + break; + } + }); + + const res = await this.engine.prompt(sid, { directory: this.workspace, providerID, modelID, text }); + if (this.aborted) { this.emit("aborted", this.#stats()); return; } + const err = res && res.info && res.info.error; + if (err) { + const msg = (err.data && err.data.message) || err.name || "OpenCode session error"; + this.emit("error", { message: `OpenCode (${modelID}): ${msg}` }); + return; + } + const out = textOf(res); + this.lastText = out; + this.messages.push({ role: "assistant", content: out }); + // Clients that render only the stream (ACP) must still see the whole + // answer when the event feed lagged behind the response. + if (out && !streamedText) this.emit("assistant_delta", { chunk: out }); + else if (out && out.startsWith(streamedText) && out.length > streamedText.length) this.emit("assistant_delta", { chunk: out.slice(streamedText.length) }); + if (out) this.emit("assistant", { content: out }); + this.emit("done", this.#stats()); + } catch (e) { + if (this.aborted) { this.emit("aborted", this.#stats()); return; } + this.emit("error", { message: e.message }); + } finally { + unsubscribe(); + } + } + + async #onPermission(sid, p) { + let response = "reject"; + try { + if (this.approvalMode === "auto") response = "once"; + else if (this.approver) { + const name = p.permission || "tool"; + const args = { ...(p.metadata || {}), patterns: p.patterns }; + response = (await this.approver(p.id, name, args, null)) ? "once" : "reject"; + } + } catch {} + await this.engine.replyPermission(sid, p.id, response, this.workspace).catch(() => {}); + } + + #stats() { const s = this.turnStats; return s ? { elapsedMs: Date.now() - s.startedAt, inTokens: 0, outTokens: 0, estimated: true } : {}; } + + async runSubagents() { return "Subagents aren't available on the OpenCode engine — delegate the pieces separately."; } + loadProjectMemory() { return ""; } +} + +function textOf(res) { + const parts = (res && res.parts) || []; + return parts.filter((p) => p && p.type === "text" && !p.synthetic).map((p) => p.text || "").join("").trim(); +} + +const isEngineModel = (id) => typeof id === "string" && id.startsWith(PREFIX); + +module.exports = { PREFIX, findBinary, candidates, available, version, install, download, pinnedVersion, assetName, target, DOWNLOAD_DIR, INSTALL_COMMAND, INSTALL_SCRIPT, Engine, getEngine, OpenCodeAgent, isEngineModel }; diff --git a/test/fixtures/fake-opencode.js b/test/fixtures/fake-opencode.js new file mode 100755 index 0000000..9926067 --- /dev/null +++ b/test/fixtures/fake-opencode.js @@ -0,0 +1,98 @@ +#!/usr/bin/env node +"use strict"; +// A stand-in for `opencode serve`: the slice of OpenCode's HTTP API that +// OmniWork's engine uses. Prints the same "listening on" line, streams the +// same events, honours the same permission handshake. Behaviour knobs come +// from the prompt text so tests can steer it: +// "TOOL" — run one fake tool (running → completed) before answering +// "PERMISSION" — ask permission for a tool first (reply decides the answer) +// "FAIL" — finish with a session error +// "SLOW" — take 2s (for abort tests) +const http = require("node:http"); +if (process.argv[2] !== "serve") { console.log("fake-opencode 0.0.0-test"); process.exit(0); } + +const clients = new Set(); +const sessions = new Map(); +const pending = new Map(); // permissionID -> resolve(response) +let seq = 0; +const nid = (p) => `${p}${(++seq).toString(36).padStart(6, "0")}`; +const send = (ev) => { const line = `data: ${JSON.stringify({ id: nid("evt_"), ...ev })}\n\n`; for (const c of clients) c.write(line); }; +const json = (res, code, body) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify(body)); }; +const read = (req) => new Promise((r) => { let s = ""; req.on("data", (d) => { s += d; }); req.on("end", () => r(s ? JSON.parse(s) : {})); }); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const PROVIDERS = { all: [ + { id: "opencode", name: "OpenCode Zen", source: "api", env: ["OPENCODE_API_KEY"], options: {}, models: { + "nemotron-3.5-lightning-free": { id: "nemotron-3.5-lightning-free", name: "Nemotron 3.5 Lightning Free", cost: { input: 0, output: 0 }, tool_call: true, limit: { context: 262144, output: 262144 } }, + "nemotron-3-ultra-free": { id: "nemotron-3-ultra-free", name: "Nemotron 3 Ultra Free", cost: { input: 0, output: 0 }, tool_call: true, limit: { context: 1000000, output: 128000 } }, + "big-pickle": { id: "big-pickle", name: "Big Pickle", cost: { input: 0, output: 0 }, tool_call: true, limit: { context: 200000, output: 32000 } }, + "claude-sonnet-4-6": { id: "claude-sonnet-4-6", name: "Claude Sonnet", cost: { input: 3, output: 15 }, tool_call: true, limit: { context: 1000000, output: 64000 } }, + } }, + { id: "opencode-go", name: "OpenCode Go", source: "api", env: ["OPENCODE_API_KEY"], options: {}, models: { + "ox-alpha-free": { id: "ox-alpha-free", name: "Ox Alpha Free", cost: { input: 0, output: 0 }, tool_call: true, limit: { context: 1000000, output: 131072 } }, + } }, +], default: {}, connected: ["opencode"] }; + +const server = http.createServer(async (req, res) => { + const u = new URL(req.url, "http://x"); + const dir = req.headers["x-opencode-directory"] ? decodeURIComponent(req.headers["x-opencode-directory"]) : null; + if (u.pathname === "/global/health") return json(res, 200, { healthy: true, version: "0.0.0-test" }); + if (u.pathname === "/provider") return json(res, 200, PROVIDERS); + if (u.pathname === "/event") { + res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }); + res.write(":ok\n\n"); clients.add(res); req.on("close", () => clients.delete(res)); return; + } + if (req.method === "POST" && u.pathname === "/session") { + const b = await read(req); const id = nid("ses_"); + sessions.set(id, { id, title: b.title, directory: dir, permission: b.permission || null, aborted: false }); + return json(res, 200, { id, title: b.title, directory: dir }); + } + const m = /^\/session\/([^/]+)\/(message|abort|permissions\/([^/]+)|diff)$/.exec(u.pathname); + if (m) { + const s = sessions.get(m[1]); if (!s) return json(res, 404, { error: { type: "NotFound", message: "no session" } }); + if (m[2] === "abort") { s.aborted = true; return json(res, 200, true); } + if (m[2] === "diff") return json(res, 200, []); + if (m[2].startsWith("permissions/")) { const b = await read(req); const r = pending.get(m[3]); if (r) { r(b.response); pending.delete(m[3]); } return json(res, 200, true); } + // message + const b = await read(req); + const text = (b.parts || []).map((p) => p.text || "").join("\n"); + const messageID = nid("msg_"); + s.aborted = false; + const partID = nid("prt_"); + if (/PERMISSION/.test(text)) { + const pid = nid("per_"); + const response = await new Promise((resolve) => { pending.set(pid, resolve); send({ type: "permission.asked", properties: { id: pid, sessionID: s.id, permission: "bash", patterns: ["rm -rf build"], metadata: { command: "rm -rf build" }, always: [], tool: { messageID, callID: "call_perm" } } }); }); + if (response === "reject") { + return json(res, 200, { info: { id: messageID, sessionID: s.id, role: "assistant", time: { created: Date.now(), completed: Date.now() }, modelID: b.model.modelID, providerID: b.model.providerID }, parts: [{ id: partID, sessionID: s.id, messageID, type: "text", text: "permission was rejected" }] }); + } + } + if (/TOOL/.test(text)) { + const callID = "call_1"; + const tid = nid("prt_"); + send({ type: "message.part.updated", properties: { sessionID: s.id, time: Date.now(), part: { id: tid, sessionID: s.id, messageID, type: "tool", callID, tool: "bash", state: { status: "pending", input: {}, time: { start: Date.now() } } } } }); + await sleep(10); + send({ type: "message.part.updated", properties: { sessionID: s.id, time: Date.now(), part: { id: tid, sessionID: s.id, messageID, type: "tool", callID, tool: "bash", state: { status: "running", input: { command: "echo hi" }, title: "echo hi", time: { start: Date.now() } } } } }); + await sleep(30); + send({ type: "message.part.updated", properties: { sessionID: s.id, time: Date.now(), part: { id: nid("prt_"), sessionID: s.id, messageID, type: "tool", callID, tool: "bash", state: { status: "completed", input: { command: "echo hi" }, output: "hi\n", title: "echo hi", metadata: {}, time: { start: Date.now(), end: Date.now() } } } } }); + } + if (/SLOW/.test(text)) { for (let i = 0; i < 20 && !s.aborted; i++) await sleep(100); if (s.aborted) return json(res, 200, { info: { id: messageID, sessionID: s.id, role: "assistant", time: { created: Date.now() }, error: { name: "MessageAbortedError", data: { message: "aborted" } } }, parts: [] }); } + if (/FAIL/.test(text)) { + send({ type: "session.error", properties: { sessionID: s.id, error: { name: "APIError", data: { message: "upstream exploded" } } } }); + return json(res, 200, { info: { id: messageID, sessionID: s.id, role: "assistant", time: { created: Date.now() }, error: { name: "APIError", data: { message: "upstream exploded" } } }, parts: [] }); + } + const answer = `READY from ${b.model.providerID}/${b.model.modelID} in ${s.directory || "(no dir)"}`; + // reasoning first, on the same "text" field — a client must not show it as the reply + const rid = nid("prt_"); + send({ type: "message.part.updated", properties: { sessionID: s.id, time: Date.now(), part: { id: rid, sessionID: s.id, messageID, type: "reasoning", text: "" } } }); + send({ type: "message.part.delta", properties: { sessionID: s.id, messageID, partID: rid, field: "text", delta: "thinking about it…" } }); + // the text part: first delta arrives BEFORE its part.updated, to exercise the hold + send({ type: "message.part.delta", properties: { sessionID: s.id, messageID, partID, field: "text", delta: answer.slice(0, 5) } }); + send({ type: "message.part.updated", properties: { sessionID: s.id, time: Date.now(), part: { id: partID, sessionID: s.id, messageID, type: "text", text: answer.slice(0, 5) } } }); + await sleep(5); + send({ type: "message.part.delta", properties: { sessionID: s.id, messageID, partID, field: "text", delta: answer.slice(5) } }); + send({ type: "session.idle", properties: { sessionID: s.id } }); + return json(res, 200, { info: { id: messageID, sessionID: s.id, role: "assistant", time: { created: Date.now(), completed: Date.now() }, modelID: b.model.modelID, providerID: b.model.providerID }, parts: [{ id: partID, sessionID: s.id, messageID, type: "text", text: answer }] }); + } + json(res, 404, { error: { type: "NotFound", message: u.pathname } }); +}); +server.listen(0, "127.0.0.1", () => { console.log(`opencode server listening on http://127.0.0.1:${server.address().port}`); }); From ba9310e7236c18fa2c512f287e78bf765f9ab523 Mon Sep 17 00:00:00 2001 From: inquerium Date: Tue, 15 Sep 2026 16:56:48 -0500 Subject: [PATCH 03/10] feat: free providers that stay up, and the chain they become MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit providers.js connects real free tiers through OmniRoute's loopback management API: OpenRouter via its PKCE flow (browser sign-in, no key to paste), Ollama Cloud, Kilo, Groq, Cerebras, NVIDIA, Gemini and Mistral by pasted key (exercised once; a rejected key is not kept), local Ollama / LM Studio / llama.cpp / vLLM by detection, and the OpenCode engine. headless.js resolves a session's model and chain live: an explicit choice, else the env, else whatever is connected, strongest coder first, local last; makeAgent() picks the engine for opencode/… models; noModelHint() tells a caller whose every model failed what to do. --- electron/headless.js | 95 +++++++++++ electron/providers.js | 373 ++++++++++++++++++++++++++++++++++++++++++ test/providers.js | 166 +++++++++++++++++++ 3 files changed, 634 insertions(+) create mode 100644 electron/providers.js create mode 100644 test/providers.js diff --git a/electron/headless.js b/electron/headless.js index 8667414..f7af33c 100644 --- a/electron/headless.js +++ b/electron/headless.js @@ -12,6 +12,9 @@ const path = require("node:path"); const { Gateway, PORT } = require("./sidecar"); const { ProjectManager } = require("./projects"); const { BrowserManager } = require("./browser"); +const providers = require("./providers"); +const { Agent } = require("./agent"); +const opencode = require("./opencode-engine"); const HOST = "127.0.0.1"; @@ -93,8 +96,100 @@ async function ensureGateway(log = () => {}) { finally { booting = null; } } +// ── models ──────────────────────────────────────────────────────── +// Which model a headless agent runs on, and what to try if it fails. An +// explicit choice (a delegate call's `model`, an ACP session's config) wins +// over the env, which wins over `auto` — the gateway's free pool. The fallback +// chain is what lets a harness pin a specific coding model and still get an +// answer when that model is retired, out of quota, or behind a provider key it +// doesn't have: "free first, paid if needed" is spelled as a chain that ends in +// a paid model. +function parseModelList(v) { + const raw = Array.isArray(v) ? v : String(v || "").split(","); + return raw.map((m) => String(m || "").trim()).filter(Boolean); +} + +function resolveModels({ model, fallbackModels } = {}) { + const primary = String(model || process.env.OMNIWORK_MODEL || "auto").trim() || "auto"; + const chain = parseModelList(fallbackModels != null ? fallbackModels : process.env.OMNIWORK_MODEL_FALLBACKS); + return { model: primary, fallbackModels: chain.filter((m) => m !== primary) }; +} + +// The gateway's catalog, cached briefly: an ACP client asks for it on every +// session, and it only changes when someone adds a provider key. +let modelCache = { at: 0, ids: [] }; +async function listModels(gw) { + if (Date.now() - modelCache.at < 30_000) return modelCache.ids; + let ids = modelCache.ids; + try { + const res = await fetch(`${gw.baseUrl}/models`, { + headers: { Authorization: `Bearer ${gw.apiKey}` }, + signal: AbortSignal.timeout(5000), + }); + if (res.ok) { + const data = await res.json(); + ids = (data.data || []).map((m) => m && m.id).filter((id) => typeof id === "string" && id); + } + } catch {} + modelCache = { at: Date.now(), ids }; + return ids; +} + +// The chain a headless agent falls back to when the caller named none: the +// best model of every connected free provider, then any local server. Set +// OMNIWORK_MODEL_FALLBACKS (even to empty) to take over the decision. +let chainCache = { at: 0, chain: [] }; +async function defaultFallbacks(gw) { + if (Date.now() - chainCache.at < 30_000) return chainCache.chain; + const chain = providers.suggestChain(await listModels(gw)); + chainCache = { at: Date.now(), chain }; + return chain; +} + +// Connecting a provider changes both caches; call this right after. +function invalidateModels() { modelCache = { at: 0, ids: [] }; chainCache = { at: 0, chain: [] }; } + +async function resolveModelsLive(gw, opts = {}) { + const r = resolveModels(opts); + const decided = opts.fallbackModels != null || process.env.OMNIWORK_MODEL_FALLBACKS != null; + if (!decided) r.fallbackModels = (await defaultFallbacks(gw)).filter((m) => m !== r.model); + return r; +} + +// What to tell a caller whose every model failed. The built-in free pool is +// the usual culprit, and the fix is one connection away. +function noModelHint() { + const oc = opencode.available() + ? "OpenCode is installed, so `opencode/…` models are available as an engine — pick one, or let the fallback switch to it." + : `Getting OpenCode (${opencode.INSTALL_COMMAND}, a ~45 MB download) adds its free Zen models as an engine that needs no account at all.`; + return "No model answered. The gateway's built-in free pool is unreliable (its keyless endpoints get shut off upstream) — connect a real free provider once: run `npm run providers` in the omniwork checkout (or `npx omniwork-providers`), use the free-models panel in the OmniWork app, or the OpenRouter auth method on ACP. " + oc; +} + +// ── agents ──────────────────────────────────────────────────────── +// `opencode/` runs on the OpenCode engine (OpenCode's own server and +// tools, on Zen's free models); anything else on OmniWork's loop via the gateway. +function makeAgent(opts) { + return opencode.isEngineModel(opts.model) ? new opencode.OpenCodeAgent(opts) : new Agent(opts); +} + +// A turn that ended because no model answered — as opposed to a tool error, +// a cancel, or a model that answered badly. +const isNoModelFailure = (msg) => /All models failed|Gateway (401|403|404|429|503)|No choices returned|empty response/.test(String(msg || "")); + +// The engine's best free model, when OpenCode is installed; null otherwise. +async function engineFallbackModel() { + if (!opencode.available()) return null; + try { + const list = await opencode.getEngine().models(); + return list.length ? list[0].id : `${opencode.PREFIX}nemotron-3.5-lightning-free`; + } catch { return null; } +} + + module.exports = { DATA_DIR, SKILLS_DIR, GLOBAL_MEMORY_DIR, browser, projects, agentEnv, ensureGateway, prewarmGateway, + resolveModels, resolveModelsLive, listModels, defaultFallbacks, invalidateModels, noModelHint, providers, + makeAgent, isNoModelFailure, engineFallbackModel, opencode, }; diff --git a/electron/providers.js b/electron/providers.js new file mode 100644 index 0000000..0d74e35 --- /dev/null +++ b/electron/providers.js @@ -0,0 +1,373 @@ +"use strict"; +// Free-model providers for the gateway: what to connect, how to connect it in +// as few clicks as possible, and which connected models a headless agent +// should fall back to. +// +// Why this exists: OmniRoute's out-of-the-box "free" pool is a handful of +// unofficial keyless endpoints (a scraped hobby site, a restaurant's chat +// widget, a CLI shim) that vanish without notice — on a bad day every one is +// gone and `auto` has nothing left to route. The providers here are the +// opposite: real free tiers with published limits, behind a free account key. +// OmniRoute already speaks to all of them; this module gets the key in with +// one click (OpenRouter through its PKCE flow, the rest by paste), registers +// local model servers it finds running, and turns "what's connected" into the +// fallback chain the ACP/MCP servers and the desktop app use. + +const crypto = require("node:crypto"); +const http = require("node:http"); +const { spawn } = require("node:child_process"); +const opencode = require("./opencode-engine"); + +// Ordered by how good a fallback each is: strongest free coders first, the +// widest catalogs before the narrow ones. `prefer` lists model ids (without +// the gateway's alias prefix) in the order we'd pick them for a coding agent. +const CATALOG = [ + { + id: "openrouter", alias: "openrouter", name: "OpenRouter", connect: "pkce", + keyUrl: "https://openrouter.ai/settings/keys", + free: "20 free models (18 with tool calling) — 20 req/min, 50 req/day; 1,000/day after a one-time $10 top-up", + prefer: ["cohere/north-mini-code:free", "nvidia/nemotron-3-super-120b-a12b:free", "google/gemma-4-31b-it:free", "nvidia/nemotron-3.5-lightning:free"], + preferPattern: /:free$/, + }, + { + id: "ollama-cloud", alias: "ollamacloud", name: "Ollama Cloud", connect: "key", + keyUrl: "https://ollama.com/settings/keys", + free: "free 'light usage' tier — DeepSeek V4, Kimi K2.6, GLM 5.1, Gemma 4 (sign in, no card)", + prefer: ["deepseek-v4-flash", "glm-5.1", "gemma4:31b", "kimi-k2.6"], + }, + { + id: "kilo-gateway", alias: "kg", name: "Kilo Gateway", connect: "key", + keyUrl: "https://app.kilo.ai", + free: "kilo-auto/free router plus Nemotron 3 Super and MiniMax M2.5 :free (free account)", + prefer: ["kilo-auto/free", "nvidia/nemotron-3-super-120b-a12b:free", "minimax/minimax-m2.5:free"], + }, + { + id: "groq", alias: "groq", name: "Groq", connect: "key", + keyUrl: "https://console.groq.com/keys", + free: "gpt-oss-120b at 30 req/min, 1,000 req/day; Qwen 3 32B, Llama 3.3 70B — very fast", + prefer: ["openai/gpt-oss-120b", "qwen/qwen3-32b", "llama-3.3-70b-versatile"], + }, + { + id: "cerebras", alias: "cerebras", name: "Cerebras", connect: "key", + keyUrl: "https://cloud.cerebras.ai", + free: "free tier on gpt-oss-120b, GLM 4.7, Gemma 4 — fastest inference around", + prefer: ["gpt-oss-120b", "zai-glm-4.7", "gemma-4-31b"], + }, + { + id: "nvidia", alias: "nvidia", name: "NVIDIA NIM", connect: "key", + keyUrl: "https://build.nvidia.com/settings/api-keys", + free: "developer tier, ~40 req/min — Qwen 3.5, GLM 5.2, Gemma 4", + prefer: ["qwen/qwen3.5-122b-a10b", "z-ai/glm-5.2", "google/gemma-4-31b-it"], + }, + { + id: "gemini", alias: "gemini", name: "Google Gemini", connect: "key", + keyUrl: "https://aistudio.google.com/apikey", + free: "Gemini Flash and Flash-Lite free tier (Google account, no card)", + prefer: ["gemini-2.5-flash", "gemini-3-flash-preview", "gemini-2.5-flash-lite"], + }, + { + id: "mistral", alias: "mistral", name: "Mistral", connect: "key", + keyUrl: "https://console.mistral.ai/api-keys", + free: "free tier incl. Codestral (phone verification)", + prefer: ["codestral-latest"], + }, +]; + +// Local OpenAI-compatible servers, probed on their default ports. Slower and +// smaller than the cloud tiers, so they sit at the end of the chain — but a +// local model is the only thing that is *always* there. +const LOCAL = [ + { id: "ollama-local", alias: "ollama", name: "Ollama", url: "http://127.0.0.1:11434/v1" }, + { id: "lm-studio", alias: "lmstudio", name: "LM Studio", url: "http://127.0.0.1:1234/v1" }, + { id: "llama-cpp", alias: "llamacpp", name: "llama.cpp", url: "http://127.0.0.1:8080/v1" }, + { id: "vllm", alias: "vllm", name: "vLLM", url: "http://127.0.0.1:8000/v1" }, +]; + +const MAX_CHAIN = 6; + +// ── gateway management API ──────────────────────────────────────── +// OmniRoute's dashboard API, served openly on loopback. `gw.baseUrl` is the +// OpenAI-compatible `/v1` URL the agents use; the management API lives beside it. +function origin(gw) { return String(gw.baseUrl).replace(/\/v1\/?$/, ""); } + +async function call(gw, method, path, body, timeoutMs = 10_000) { + const res = await fetch(origin(gw) + path, { + method, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${gw.apiKey || "omniwork"}` }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(timeoutMs), + }); + const text = await res.text().catch(() => ""); + let json = null; + try { json = JSON.parse(text); } catch {} + if (!res.ok) { + const msg = (json && json.error && (json.error.message || json.error)) || text.slice(0, 200) || `HTTP ${res.status}`; + const err = new Error(`gateway ${method} ${path}: ${typeof msg === "string" ? msg : JSON.stringify(msg)}`); + err.status = res.status; + throw err; + } + return json; +} + +async function connections(gw) { + const data = await call(gw, "GET", "/api/providers"); + return (data && data.connections) || []; +} + +async function modelIds(gw) { + const data = await call(gw, "GET", "/v1/models", null, 8000); + return ((data && data.data) || []).map((m) => m && m.id).filter((id) => typeof id === "string" && id); +} + +// One connection per provider+name: adding again replaces, so a re-pasted key +// never leaves a dead twin behind that the pool keeps trying. +async function addConnection(gw, { provider, name, apiKey, baseUrl }) { + const existing = (await connections(gw)).filter((c) => c.provider === provider && c.name === name); + for (const c of existing) await call(gw, "DELETE", `/api/providers/${c.id}`).catch(() => {}); + const body = { provider, authType: "apikey", name, apiKey }; + if (baseUrl) body.providerSpecificData = { baseUrl }; + const data = await call(gw, "POST", "/api/providers", body); + return data && data.connection; +} + +// The gateway discovers a passthrough provider's models asynchronously after +// the connection lands — a few seconds for a local server. Anyone computing +// "what's routable now" right after a connect has to wait for that. +async function waitForModels(gw, alias, { timeoutMs = 12_000 } = {}) { + const until = Date.now() + timeoutMs; + let ids = []; + while (Date.now() < until) { + ids = await modelIds(gw).catch(() => []); + if (ids.some((id) => id.startsWith(alias + "/"))) return ids; + await new Promise((r) => setTimeout(r, 500)); + } + return ids; +} + +async function removeProvider(gw, provider) { + const mine = (await connections(gw)).filter((c) => c.provider === provider); + for (const c of mine) await call(gw, "DELETE", `/api/providers/${c.id}`); + return mine.length; +} + +// ── what's connected, what to fall back to ──────────────────────── +function entry(id) { return CATALOG.find((p) => p.id === id) || LOCAL.find((l) => l.id === id) || null; } + +function modelsFor(p, ids) { + const prefix = p.alias + "/"; + return ids.filter((id) => id.startsWith(prefix)).map((id) => id.slice(prefix.length)); +} + +// The fallback chain: for each connected provider, in catalog order, the best +// model it exposes right now — the preferred ids first, then anything the +// provider's free pattern matches, then whatever it lists. Local servers +// close the chain. Capped so a turn that hits every failure still ends. +function suggestChain(ids, { max = MAX_CHAIN } = {}) { + const chain = []; + for (const p of [...CATALOG, ...LOCAL]) { + const have = modelsFor(p, ids); + if (!have.length) continue; + const pick = (p.prefer || []).find((m) => have.includes(m)) + || (p.preferPattern ? have.find((m) => p.preferPattern.test(m)) : null) + || have[0]; + chain.push(`${p.alias}/${pick}`); + if (chain.length >= max) break; + } + return chain; +} + +async function probeLocal(url, timeoutMs = 1500) { + try { + const res = await fetch(`${url}/models`, { signal: AbortSignal.timeout(timeoutMs) }); + if (!res.ok) return null; + const data = await res.json(); + return ((data && data.data) || []).map((m) => m && m.id).filter(Boolean); + } catch { return null; } +} + +async function status(gw, { detectLocal = true, candidates = LOCAL } = {}) { + const [conns, ids] = await Promise.all([connections(gw), modelIds(gw)]); + const connected = (id) => conns.some((c) => c.provider === id && c.isActive !== false); + const providers = CATALOG.map((p) => ({ + id: p.id, name: p.name, connect: p.connect, keyUrl: p.keyUrl, free: p.free, + connected: connected(p.id), models: modelsFor(p, ids).length, + })); + const local = []; + for (const l of candidates) { + const running = detectLocal ? await probeLocal(l.url) : null; + local.push({ id: l.id, name: l.name, url: l.url, connected: connected(l.id), running: running != null, runningModels: running || [], models: modelsFor(l, ids).length }); + } + // The OpenCode engine: free Zen models with no account, if OpenCode is + // installed. Listing them starts its server once per process. + const oc = { installed: opencode.available(), version: null, models: [], installCommand: opencode.INSTALL_COMMAND, installScript: opencode.INSTALL_SCRIPT }; + if (oc.installed) { + oc.version = opencode.version(); + try { oc.models = (await opencode.getEngine().models()).map((m) => m.id); } catch (e) { oc.error = e.message; } + } + return { + providers, local, opencode: oc, + chain: suggestChain(ids), + freePool: ids.filter((id) => id === "auto" || id.startsWith("auto/")).length, + anyConnected: providers.some((p) => p.connected) || local.some((l) => l.connected) || oc.models.length > 0, + }; +} + +// ── connecting ──────────────────────────────────────────────────── +// A key that the provider rejects is worse than no key: the pool would keep +// trying it. So a pasted key is exercised once before it's kept. +async function verifyModel(gw, modelId) { + const res = await fetch(`${gw.baseUrl}/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${gw.apiKey || "omniwork"}` }, + body: JSON.stringify({ model: modelId, messages: [{ role: "user", content: "Reply with the single word READY." }], max_tokens: 8 }), + signal: AbortSignal.timeout(45_000), + }); + if (res.ok) return true; + const body = await res.text().catch(() => ""); + const err = new Error(`${modelId} answered HTTP ${res.status}: ${body.slice(0, 200)}`); + err.status = res.status; + throw err; +} + +async function connectWithKey(gw, providerId, apiKey, { verify = true } = {}) { + const p = CATALOG.find((c) => c.id === providerId); + if (!p) throw new Error(`unknown provider: ${providerId} (known: ${CATALOG.map((c) => c.id).join(", ")})`); + const key = String(apiKey || "").trim(); + if (!key) throw new Error(`${p.name} needs an API key — create one at ${p.keyUrl}`); + const conn = await addConnection(gw, { provider: p.id, name: p.name, apiKey: key }); + const ids = await waitForModels(gw, p.alias); + const have = modelsFor(p, ids); + const model = (p.prefer || []).find((m) => have.includes(m)) || (p.preferPattern ? have.find((m) => p.preferPattern.test(m)) : null) || have[0]; + if (verify && model) { + try { await verifyModel(gw, `${p.alias}/${model}`); } + catch (e) { + // 401/403 is the key itself; anything else (quota, a flaky model) is + // not a reason to throw the key away. + if (e.status === 401 || e.status === 403) { + if (conn && conn.id) await call(gw, "DELETE", `/api/providers/${conn.id}`).catch(() => {}); + throw new Error(`${p.name} rejected that key (${e.message}). Check it at ${p.keyUrl}.`); + } + } + } + return { provider: p.id, name: p.name, models: have.length, chain: suggestChain(ids) }; +} + +async function connectLocal(gw, { candidates = LOCAL, only = null } = {}) { + const added = []; + const skipped = []; + for (const l of candidates) { + if (only && l.id !== only) continue; + const running = await probeLocal(l.url); + if (!running) { skipped.push({ id: l.id, name: l.name, reason: "not running" }); continue; } + await addConnection(gw, { provider: l.id, name: l.name, apiKey: "local", baseUrl: l.url }); + added.push({ id: l.id, name: l.name, url: l.url, models: running }); + } + const ids = added.length ? await waitForModels(gw, added[added.length - 1].alias || entry(added[added.length - 1].id).alias) : await modelIds(gw); + return { added, skipped, chain: suggestChain(ids) }; +} + +// OpenRouter's PKCE flow: the browser creates a key for us — no dashboard, +// no copy-paste. A loopback server catches the redirect; the code is only +// useful together with our verifier, so a stray callback can't mint a key. +function b64url(buf) { return Buffer.from(buf).toString("base64url"); } + +function defaultOpen(url) { + const cmd = process.platform === "darwin" ? ["open", [url]] + : process.platform === "win32" ? ["cmd", ["/c", "start", "", url.replace(/&/g, "^&")]] + : ["xdg-open", [url]]; + try { spawn(cmd[0], cmd[1], { stdio: "ignore", detached: true }).unref(); return true; } + catch { return false; } +} + +const DONE_PAGE = (ok) => `OmniWork + +
${ok ? "✅" : "❌"}
+

${ok ? "OpenRouter is connected to OmniWork. You can close this tab." : "OpenRouter did not return a code. Close this tab and try again."}

`; + +async function connectOpenRouter(gw, { open = defaultOpen, onUrl = () => {}, authBase = "https://openrouter.ai", timeoutMs = 300_000, label = "OmniWork" } = {}) { + const verifier = b64url(crypto.randomBytes(48)); + const challenge = b64url(crypto.createHash("sha256").update(verifier).digest()); + + const server = http.createServer(); + await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); + const port = server.address().port; + const callback = `http://127.0.0.1:${port}/callback`; + const url = `${authBase}/auth?callback_url=${encodeURIComponent(callback)}&code_challenge=${challenge}&code_challenge_method=S256&key_label=${encodeURIComponent(label)}`; + + let code; + try { + code = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timed out waiting for the OpenRouter sign-in (5 min)")), timeoutMs); + server.on("request", (req, res) => { + const u = new URL(req.url, "http://127.0.0.1"); + if (u.pathname !== "/callback") { res.writeHead(404); res.end(); return; } + const c = u.searchParams.get("code"); + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(DONE_PAGE(Boolean(c))); + clearTimeout(timer); + if (c) resolve(c); else reject(new Error("OpenRouter returned no code")); + }); + onUrl(url); + open(url); + }); + } finally { + server.close(); + } + + const res = await fetch(`${authBase}/api/v1/auth/keys`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: "S256" }), + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) throw new Error(`OpenRouter key exchange failed: HTTP ${res.status} ${(await res.text().catch(() => "")).slice(0, 200)}`); + const data = await res.json(); + if (!data || !data.key) throw new Error("OpenRouter key exchange returned no key"); + return await connectWithKey(gw, "openrouter", data.key, { verify: false }); +} + +// One entry point for every surface (CLI, MCP tool, ACP auth method, desktop). +async function connect(gw, providerId, { apiKey, open, onUrl, authBase, install = false, log } = {}) { + if (providerId === "opencode") { + if (!opencode.available()) { + if (!install) throw new Error(`OpenCode is not present yet — run: ${opencode.INSTALL_COMMAND} (downloads ~45 MB; or: ${opencode.INSTALL_SCRIPT})`); + await opencode.install({ log }); + } + const models = await opencode.getEngine().models({ force: true }); + return { provider: "opencode", name: "OpenCode engine", engine: true, version: opencode.version(), models: models.length, modelIds: models.map((m) => m.id), chain: suggestChain(await modelIds(gw)) }; + } + if (providerId === "local") return await connectLocal(gw); + if (LOCAL.some((l) => l.id === providerId)) return await connectLocal(gw, { only: providerId }); + if (providerId === "openrouter" && !apiKey) return await connectOpenRouter(gw, { open, onUrl, authBase }); + return await connectWithKey(gw, providerId, apiKey); +} + +// Human-readable status, shared by the CLI and the MCP tool. +function describe(st) { + const lines = []; + const on = [...st.providers.filter((p) => p.connected), ...st.local.filter((l) => l.connected)]; + lines.push(on.length + ? `Connected: ${on.map((p) => `${p.name} (${p.models} models)`).join(", ")}.` + : "No free provider connected yet — the gateway's built-in keyless pool is unreliable (its endpoints get shut off upstream), so connect at least one."); + lines.push(st.chain.length ? `Fallback chain: ${st.chain.join(" → ")}` : "Fallback chain: (empty — nothing connected)"); + lines.push(""); + lines.push("Free providers (free account, no card):"); + for (const p of st.providers) { + lines.push(`- ${p.connected ? "✓" : "○"} ${p.id} — ${p.name}: ${p.free}. ${p.connect === "pkce" ? "Connect: one click, no key to paste." : `Key: ${p.keyUrl}`}`); + } + const oc = st.opencode; + if (oc) { + lines.push(""); + lines.push(oc.installed + ? `OpenCode engine: installed (${oc.version || "?"}) — ${oc.models.length ? `free models, no account: ${oc.models.join(", ")}` : (oc.error ? `could not list models: ${oc.error}` : "no free models listed")}. Runs OpenCode's own server; the automatic fallback when nothing else answers.` + : `OpenCode engine: not present yet. ${oc.installCommand} downloads OpenCode's official release (~45 MB, no npm, no PATH) and adds Zen's free models (Nemotron 3.5 Lightning, MiMo V2.5, Big Pickle, Ling 3.0 Flash, Nemotron 3 Ultra) with no account at all.`); + } + const running = st.local.filter((l) => l.running); + lines.push(""); + lines.push(running.length + ? `Local model servers running: ${running.map((l) => `${l.name}${l.connected ? " (connected)" : ` — ${l.runningModels.length} models, not connected yet`}`).join("; ")}` + : "Local model servers: none running (Ollama on :11434, LM Studio on :1234, llama.cpp on :8080, vLLM on :8000 are auto-detected)."); + return lines.join("\n"); +} + +module.exports = { CATALOG, LOCAL, status, describe, connect, connectWithKey, connectOpenRouter, connectLocal, removeProvider, suggestChain, connections, modelIds, waitForModels, defaultOpen }; diff --git a/test/providers.js b/test/providers.js new file mode 100644 index 0000000..5182fe0 --- /dev/null +++ b/test/providers.js @@ -0,0 +1,166 @@ +"use strict"; +// Free-provider connect, against fakes: a gateway management API (in-memory +// connections; /v1/models grows with what is connected), an "OpenRouter" that +// runs the PKCE dance, and a local OpenAI-compatible server. Covers status, +// key connect with verification (a rejected key is not kept), the PKCE flow +// end to end, local detection, and the fallback chain that falls out. +// +// Run: node test/providers.js +const http = require("node:http"); +const crypto = require("node:crypto"); +// The engine section of status() would start a real OpenCode server when one +// is installed; pin it to the fixture so the test is hermetic. +const path = require("node:path"); +process.env.OMNIWORK_OPENCODE_BIN = path.join(__dirname, "fixtures", "fake-opencode.js"); +const providers = require("../electron/providers"); + +let fails = 0; +const check = (name, ok) => { console.log((ok ? "✓" : "✗"), name); if (!ok) fails++; }; +const json = (res, status, body) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(body)); }; +const readBody = (req) => new Promise((r) => { let s = ""; req.on("data", (d) => { s += d; }); req.on("end", () => r(s ? JSON.parse(s) : {})); }); +const listen = (server) => new Promise((r) => server.listen(0, "127.0.0.1", () => r(server.address().port))); + +// Alias → what a connected provider "exposes", mirroring OmniRoute's alias/ ids. +const ALIAS = { openrouter: "openrouter", groq: "groq", "ollama-local": "ollama", gemini: "gemini" }; +const EXPOSES = { + openrouter: ["~deepseek/deepseek-pro-latest", "cohere/north-mini-code:free", "google/gemma-4-31b-it:free", "openai/gpt-5"], + groq: ["llama-3.3-70b-versatile", "openai/gpt-oss-120b"], + gemini: ["gemini-2.5-flash"], + "ollama-local": ["qwen2.5-coder:7b"], +}; + +// ── fake gateway ── +function fakeGateway() { + const conns = []; + const chats = []; + const server = http.createServer(async (req, res) => { + const u = new URL(req.url, "http://x"); + if (req.method === "GET" && u.pathname === "/api/providers") return json(res, 200, { connections: conns.map((c) => ({ ...c, apiKey: "****" })) }); + if (req.method === "POST" && u.pathname === "/api/providers") { + const b = await readBody(req); + const c = { id: crypto.randomUUID(), provider: b.provider, authType: b.authType, name: b.name, apiKey: b.apiKey, isActive: true, providerSpecificData: b.providerSpecificData || null }; + conns.push(c); + return json(res, 200, { connection: { ...c, apiKey: undefined } }); + } + if (req.method === "DELETE" && u.pathname.startsWith("/api/providers/")) { + const id = u.pathname.split("/").pop(); const i = conns.findIndex((c) => c.id === id); + if (i < 0) return json(res, 404, { error: { message: "not found" } }); + conns.splice(i, 1); return json(res, 200, { message: "deleted" }); + } + if (u.pathname === "/v1/models") { + const ids = ["auto", "auto/best-coding"]; + for (const c of conns) for (const m of EXPOSES[c.provider] || []) ids.push(`${ALIAS[c.provider]}/${m}`); + return json(res, 200, { data: ids.map((id) => ({ id })) }); + } + if (u.pathname === "/v1/chat/completions") { + const b = await readBody(req); + chats.push(b.model); + const alias = b.model.split("/")[0]; + const c = conns.find((x) => ALIAS[x.provider] === alias); + if (!c) return json(res, 404, { error: { message: "no such model" } }); + if (c.apiKey === "bad-key") return json(res, 401, { error: { message: "invalid api key" } }); + return json(res, 200, { choices: [{ message: { role: "assistant", content: "READY" } }] }); + } + json(res, 404, { error: { message: "unknown route " + u.pathname } }); + }); + return listen(server).then((port) => ({ gw: { baseUrl: `http://127.0.0.1:${port}/v1`, apiKey: "test" }, conns, chats, close: () => server.close() })); +} + +// ── fake OpenRouter: /auth "signs the user in" by hitting our callback; /api/v1/auth/keys checks PKCE ── +function fakeOpenRouter() { + let challenge = null; + const server = http.createServer(async (req, res) => { + const u = new URL(req.url, "http://x"); + if (u.pathname === "/auth") { + challenge = u.searchParams.get("code_challenge"); + const cb = new URL(u.searchParams.get("callback_url")); cb.searchParams.set("code", "code-123"); + const r = await fetch(cb); await r.text(); + return json(res, 200, { ok: true }); + } + if (u.pathname === "/api/v1/auth/keys") { + const b = await readBody(req); + const expect = Buffer.from(crypto.createHash("sha256").update(b.code_verifier || "").digest()).toString("base64url"); + if (b.code !== "code-123" || b.code_challenge_method !== "S256" || expect !== challenge) return json(res, 403, { error: "Invalid code or code_verifier" }); + return json(res, 200, { key: "sk-or-v1-from-pkce" }); + } + json(res, 404, {}); + }); + return listen(server).then((port) => ({ base: `http://127.0.0.1:${port}`, close: () => server.close() })); +} + +function fakeLocal() { + const server = http.createServer((req, res) => { + if (req.url.startsWith("/v1/models")) return json(res, 200, { data: [{ id: "qwen2.5-coder:7b" }, { id: "llama3.2:3b" }] }); + json(res, 200, { choices: [{ message: { content: "local" } }] }); + }); + return listen(server).then((port) => ({ url: `http://127.0.0.1:${port}/v1`, close: () => server.close() })); +} + +(async () => { + const g = await fakeGateway(); + const or = await fakeOpenRouter(); + const local = await fakeLocal(); + const candidates = [{ id: "ollama-local", alias: "ollama", name: "Ollama", url: local.url }, { id: "lm-studio", alias: "lmstudio", name: "LM Studio", url: "http://127.0.0.1:9/v1" }]; + + // ── status before anything ── + let st = await providers.status(g.gw, { candidates }); + check("status lists the catalog, no provider connected", st.providers.length === providers.CATALOG.length && !st.providers.some((p) => p.connected)); + check("status reports the OpenCode engine and its free models", st.opencode && st.opencode.installed && st.opencode.models.includes("opencode/nemotron-3.5-lightning-free")); + check("status sees the free pool but an empty chain", st.freePool === 2 && st.chain.length === 0); + check("status detects the running local server", st.local.find((l) => l.id === "ollama-local").running && !st.local.find((l) => l.id === "lm-studio").running); + check("describe() tells the user nothing is connected", /No free provider connected/.test(providers.describe(st))); + + // ── key connect: good key ── + const groq = await providers.connectWithKey(g.gw, "groq", "gsk-good"); + check("a pasted key creates the connection", g.conns.some((c) => c.provider === "groq" && c.apiKey === "gsk-good")); + check("…and reports the models it exposes", groq.models === 2); + check("…after verifying the key with one request on the preferred model", g.chats.includes("groq/openai/gpt-oss-120b")); + check("…and the chain now leads with it", JSON.stringify(groq.chain) === JSON.stringify(["groq/openai/gpt-oss-120b"])); + + // ── key connect: rejected key is not kept ── + let rejected = null; + try { await providers.connectWithKey(g.gw, "gemini", "bad-key"); } catch (e) { rejected = e.message; } + check("a rejected key throws with the provider's key page", rejected && /rejected that key/.test(rejected) && /aistudio/.test(rejected)); + check("…and leaves no dead connection behind", !g.conns.some((c) => c.provider === "gemini")); + + // ── re-pasting replaces instead of duplicating ── + await providers.connectWithKey(g.gw, "groq", "gsk-better"); + check("re-connecting a provider replaces the old connection", g.conns.filter((c) => c.provider === "groq").length === 1 && g.conns.find((c) => c.provider === "groq").apiKey === "gsk-better"); + + // ── unknown provider / empty key ── + let bad = null; try { await providers.connectWithKey(g.gw, "nope", "x"); } catch (e) { bad = e.message; } + check("unknown providers are refused with the known list", bad && /unknown provider/.test(bad) && /openrouter/.test(bad)); + let empty = null; try { await providers.connectWithKey(g.gw, "cerebras", " "); } catch (e) { empty = e.message; } + check("an empty key points at where to get one", empty && /cloud\.cerebras\.ai/.test(empty)); + + // ── PKCE ── + let seenUrl = null; + // "Opening the browser" here means hitting the fake /auth page, which plays the signed-in user and calls our loopback back. + const orRes = await providers.connectOpenRouter(g.gw, { authBase: or.base, open: (u) => { fetch(u).catch(() => {}); }, onUrl: (u) => { seenUrl = u; } }); + check("PKCE opens an /auth URL with a loopback callback and an S256 challenge", seenUrl && /\/auth\?callback_url=http%3A%2F%2F127\.0\.0\.1%3A\d+%2Fcallback&code_challenge=[A-Za-z0-9_-]{40,}&code_challenge_method=S256/.test(seenUrl)); + check("…exchanges the code for a key and stores it", g.conns.some((c) => c.provider === "openrouter" && c.apiKey === "sk-or-v1-from-pkce")); + check("…and reports OpenRouter's models", orRes.provider === "openrouter" && orRes.models === 4); + + // ── chain ordering: catalog order, preferred model first, pattern fallback ── + st = await providers.status(g.gw, { candidates, detectLocal: false }); + check("the chain prefers the coder on OpenRouter and keeps catalog order", JSON.stringify(st.chain) === JSON.stringify(["openrouter/cohere/north-mini-code:free", "groq/openai/gpt-oss-120b"])); + const noPref = providers.suggestChain(["openrouter/x/y:free", "openrouter/openai/gpt-5", "groq/other-model"]); + check("without a preferred id it falls back to the :free pattern, then the first listed", JSON.stringify(noPref) === JSON.stringify(["openrouter/x/y:free", "groq/other-model"])); + check("the chain is capped", providers.suggestChain(["openrouter/a:free", "ollamacloud/b", "kg/c", "groq/d", "cerebras/e", "nvidia/f", "gemini/g", "mistral/h"]).length === 6); + + // ── local ── + const loc = await providers.connectLocal(g.gw, { candidates }); + check("connectLocal registers the running server with its base URL", loc.added.length === 1 && g.conns.some((c) => c.provider === "ollama-local" && c.providerSpecificData && c.providerSpecificData.baseUrl === local.url)); + check("…skips the ones not running", loc.skipped.length === 1 && loc.skipped[0].id === "lm-studio"); + check("…and local closes the chain", loc.chain[loc.chain.length - 1] === "ollama/qwen2.5-coder:7b"); + + // ── connect() dispatch + remove ── + const viaConnect = await providers.connect(g.gw, "local"); + check("connect('local') dispatches to local detection", viaConnect.added !== undefined); + const removed = await providers.removeProvider(g.gw, "groq"); + check("removeProvider deletes every connection for the provider", removed === 1 && !g.conns.some((c) => c.provider === "groq")); + + g.close(); or.close(); local.close(); + console.log(fails ? `\n❌ ${fails} FAILED` : "\n✅ PROVIDERS TEST PASSED"); + process.exit(fails ? 1 : 0); +})().catch((e) => { console.error("crash:", e); process.exit(1); }); From ef86d8ea25f5f32114c035bce14ff823b69e7513 Mon Sep 17 00:00:00 2001 From: inquerium Date: Tue, 15 Sep 2026 16:56:48 -0500 Subject: [PATCH 04/10] feat: ACP and MCP pick models, connect providers, and never end on a dead turn ACP: the gateway catalog and the engine's free models are a `model` config option (session/set_config_option, the older session/set_model, or _meta.model on session/new); auth methods `openrouter`, `local` and `opencode` connect a provider; when no gateway model answers the turn finishes on the OpenCode engine and the client sees the switch. MCP: delegate / delegate_parallel take model + fallback_models, list_models shows the engine section, list_providers / connect_provider manage providers (connect_provider never installs anything itself), and a delegation that got no model reruns on the engine and says so. --- electron/acp-server.js | 202 ++++++++++++++++++++++++++++++++++++----- electron/mcp-server.js | 165 ++++++++++++++++++++++++++++----- test/acp.js | 36 +++++++- test/mcp.js | 9 +- test/opencode.js | 157 ++++++++++++++++++++++++++++++++ 5 files changed, 522 insertions(+), 47 deletions(-) mode change 100644 => 100755 electron/acp-server.js mode change 100644 => 100755 electron/mcp-server.js create mode 100644 test/opencode.js diff --git a/electron/acp-server.js b/electron/acp-server.js old mode 100644 new mode 100755 index 5fd0f80..1af1566 --- a/electron/acp-server.js +++ b/electron/acp-server.js @@ -21,8 +21,7 @@ const fs = require("node:fs"); const path = require("node:path"); const { ensureShellPath } = require("./shell-path"); -const { Agent } = require("./agent"); -const { agentEnv, ensureGateway, prewarmGateway, DATA_DIR, SKILLS_DIR } = require("./headless"); +const { agentEnv, ensureGateway, prewarmGateway, resolveModelsLive, listModels, invalidateModels, noModelHint, providers, makeAgent, isNoModelFailure, engineFallbackModel, opencode, DATA_DIR, SKILLS_DIR } = require("./headless"); const skillsApi = require("./skills"); ensureShellPath(); // ACP clients can launch us with a minimal environment too @@ -45,6 +44,12 @@ const DEFAULT_MODE = MODES.some((m) => m.id === process.env.OMNIWORK_ACP_MODE) ? process.env.OMNIWORK_ACP_MODE : "ask"; +const AUTH_METHODS = [ + { id: "openrouter", name: "Connect OpenRouter (free models)", description: "Opens your browser to create a free OpenRouter key — 20 free models, 18 with tool calling. No key to paste." }, + { id: "local", name: "Use a local model server", description: "Registers Ollama, LM Studio, llama.cpp, or vLLM if one is running on this machine." }, + { id: "opencode", name: "Get OpenCode (free Zen models, no account)", description: "Downloads OpenCode's official release (~45 MB) into OmniWork's data folder — no npm, no PATH. OmniWork then runs OpenCode's own server as an engine for its free models: Nemotron 3.5 Lightning, MiMo V2.5, Big Pickle, Ling 3.0 Flash, Nemotron 3 Ultra." }, +]; + const TOOL_KIND = { list_dir: "read", read_file: "read", read_knowledge: "read", use_skill: "read", write_file: "edit", edit_file: "edit", @@ -143,13 +148,15 @@ function persist(sess) { fs.mkdirSync(STORE_DIR, { recursive: true }); fs.writeFileSync(sessionFile(sess.id), JSON.stringify({ id: sess.id, cwd: sess.cwd, mode: sess.mode, + model: sess.agent.model, fallbackModels: sess.agent.fallbackModels, messages: sess.agent.messages.slice(-200), })); } catch (e) { log("persist failed:", e.message); } } -async function buildSession({ id, cwd, mode, messages }) { +async function buildSession({ id, cwd, mode, messages, model, fallbackModels }) { const gw = await ensureGateway(log); + const models = await resolveModelsLive(gw, { model, fallbackModels }); const sess = { id, cwd, mode: mode || DEFAULT_MODE, sink: () => {}, // replaced per turn @@ -157,20 +164,30 @@ async function buildSession({ id, cwd, mode, messages }) { pendingPermissions: new Set(), agent: null, }; - sess.agent = new Agent({ + sess.gw = gw; + attachAgent(sess, models.model, models.fallbackModels); + if (Array.isArray(messages) && messages.length) sess.agent.messages = messages; + sessions.set(id, sess); + return sess; +} + +// The session's agent: OmniWork's loop on the gateway, or the OpenCode engine +// for `opencode/…` models. Rebuilt when a model change crosses that line. +function attachAgent(sess, model, fallbackModels) { + const gw = sess.gw; + sess.agent = makeAgent({ baseUrl: gw.baseUrl, apiKey: gw.apiKey, - model: process.env.OMNIWORK_MODEL || "auto", - workspace: cwd, + model, + fallbackModels, + workspace: sess.cwd, canSpawn: true, approvalMode: sess.mode, approver: (callId, name, args, preview) => sess.approve(callId, name, args, preview), emit: (type, payload) => sess.sink(type, payload), - ...agentEnv(cwd), + ...agentEnv(sess.cwd), }); - if (Array.isArray(messages) && messages.length) sess.agent.messages = messages; - sessions.set(id, sess); - return sess; + return sess.agent; } // Skills become ACP slash commands, so `/deep-research` in the client's prompt @@ -191,6 +208,71 @@ function pushCommands(sess) { }); } +// ── model selection ─────────────────────────────────────────────── +// The model is an ACP session config option (category "model"), so a client +// with a model picker shows the gateway's catalog and `session/set_config_option` +// switches it; the pre-config-option `session/set_model` is honored too. A +// harness without either sends `_meta.model` (and `_meta.fallbackModels`) on +// `session/new`. Whatever is chosen, the fallback chain still applies — a +// pinned model that fails falls through to the next one, and the switch is +// pushed back as a `config_option_update`. +const MAX_MODEL_OPTIONS = 300; + +async function modelOption(sess) { + const current = sess.agent.model; + const chain = sess.agent.fallbackModels; + let catalog = []; + try { catalog = await listModels(await ensureGateway(log)); } catch {} + const options = []; + const seen = new Set(); + let engine = []; + if (opencode.available()) { try { engine = await opencode.getEngine().models(); } catch {} } + for (const id of [current, ...chain, ...engine.map((m) => m.id), ...catalog]) { + if (!id || seen.has(id)) continue; + seen.add(id); + const n = chain.indexOf(id); + const notes = []; + if (n >= 0) notes.push(`Fallback #${n + 1}`); + if (id === "auto") notes.push("free pool, routed by the gateway"); + if (opencode.isEngineModel(id)) notes.push("OpenCode engine · free, no account"); + options.push({ value: id, name: id, ...(notes.length ? { description: notes.join(" · ") } : {}) }); + if (options.length >= MAX_MODEL_OPTIONS) break; + } + return { + id: "model", name: "Model", category: "model", type: "select", + description: "Model the agent runs on. If it fails (retired id, provider key, quota) OmniWork continues on the next model in its fallback chain.", + currentValue: current, options, + }; +} + +async function configOptionsFor(sess) { return [await modelOption(sess)]; } + +// What a scripted harness reads instead of parsing the option list. +function sessionMeta(sess) { + return { omniwork: { model: sess.agent.model, fallbackModels: sess.agent.fallbackModels } }; +} + +async function setModel(sess, modelId) { + const id = String(modelId == null ? "" : modelId).trim(); + if (!id) throw new RpcError(-32602, "model id is required"); + if (sess.running) throw new RpcError(-32000, "cannot change the model while a prompt is running"); + const catalog = await listModels(await ensureGateway(log)).catch(() => []); + // Not an error: passthrough providers accept ids the catalog doesn't list. + if (catalog.length && !catalog.includes(id)) log(`model ${id} is not in the gateway catalog — passing it through`); + if (Boolean(sess.agent.isEngine) !== opencode.isEngineModel(id)) { + const keep = sess.agent.messages; + attachAgent(sess, id, sess.agent.fallbackModels.filter((m) => m !== id)); + if (!sess.agent.isEngine && Array.isArray(keep) && keep.length) sess.agent.messages = keep; + } else { + sess.agent.model = id; + sess.agent.fallbackModels = sess.agent.fallbackModels.filter((m) => m !== id); + } + persist(sess); + const configOptions = await configOptionsFor(sess); + notify("session/update", { sessionId: sess.id, update: { sessionUpdate: "config_option_update", configOptions } }); + return configOptions; +} + // ── the prompt turn ─────────────────────────────────────────────── async function runTurn(sess, blocks) { const { text, images } = decodePrompt(blocks); @@ -217,6 +299,12 @@ async function runTurn(sess, blocks) { if (p.chunk) up({ sessionUpdate: "agent_message_chunk", messageId, content: { type: "text", text: p.chunk } }); break; + // The OpenCode engine streams the model's reasoning separately; ACP has + // a native lane for it. + case "reasoning_delta": + if (p.chunk) up({ sessionUpdate: "agent_thought_chunk", messageId, content: { type: "text", text: p.chunk } }); + break; + // `assistant` repeats what the deltas already streamed — dropping it // avoids showing every reply twice. case "assistant": @@ -274,10 +362,13 @@ async function runTurn(sess, blocks) { const acpId = `t${++toolSeq}`; tools.set("sub:" + p.subId, { acpId, name: "subagent", args: {} }); up({ sessionUpdate: "tool_call", toolCallId: acpId, title: `subagent: ${p.title}`, kind: "think", status: "in_progress" }); - } else if (p.kind === "tool" || p.kind === "text" || p.kind === "error") { + } else if (p.kind === "tool" || p.kind === "text" || p.kind === "error" || p.kind === "model") { const t = tools.get("sub:" + p.subId); if (!t) break; - const line = p.kind === "tool" ? `→ ${p.tool}` : p.kind === "error" ? `error: ${p.message}` : String(p.snippet || ""); + const line = p.kind === "tool" ? `→ ${p.tool}` + : p.kind === "error" ? `error: ${p.message}` + : p.kind === "model" ? `⇄ ${p.from} failed — continuing on ${p.to}` + : String(p.snippet || ""); up({ sessionUpdate: "tool_call_update", toolCallId: t.acpId, status: "in_progress", content: [{ type: "content", content: { type: "text", text: line + "\n" } }], @@ -289,6 +380,12 @@ async function runTurn(sess, blocks) { break; } + case "model_switch": + configOptionsFor(sess) + .then((configOptions) => up({ sessionUpdate: "config_option_update", configOptions })) + .catch(() => {}); + break; + case "context": up({ sessionUpdate: "usage_update", @@ -355,13 +452,30 @@ async function runTurn(sess, blocks) { try { await sess.agent.send(text, images); + // No gateway model answered, but OpenCode is installed: finish the turn on + // its engine and keep the session there. The client sees the switch as a + // message and a config_option_update, same as any other model change. + if (failure && isNoModelFailure(failure) && !sess.agent.isEngine && !sess.agent.aborted) { + const model = await engineFallbackModel(); + if (model) { + const prevFailure = failure; + failure = null; + attachAgent(sess, model, []); + up({ sessionUpdate: "agent_message_chunk", messageId: `sys${Date.now().toString(36)}`, content: { type: "text", text: `⇄ no gateway model answered (${prevFailure.split("\n")[0].slice(0, 120)}) — continuing on the OpenCode engine (${model})\n` } }); + configOptionsFor(sess).then((configOptions) => up({ sessionUpdate: "config_option_update", configOptions })).catch(() => {}); + await sess.agent.send(text, images); + } + } } finally { sess.sink = () => {}; sess.approve = async () => false; persist(sess); } - if (failure) throw new RpcError(-32000, failure); + if (failure) { + if (/All models failed|Gateway (401|403|429|503)/.test(failure)) failure += "\n\n" + noModelHint(); + throw new RpcError(-32000, failure); + } return { stopReason }; } @@ -380,23 +494,46 @@ const handlers = { mcpCapabilities: { http: false, sse: false }, }, agentInfo: { name: "omniwork", title: "OmniWork", version: VERSION }, - authMethods: [], + // Not required — the gateway is local and keyless — but the way an ACP + // client can give OmniWork a free provider that actually stays up. + authMethods: AUTH_METHODS, }; }, - // No auth: the gateway is local and keyless. Declared so clients that probe - // the method get a clean result instead of "unknown method". - async authenticate() { return {}; }, + // Sessions never *require* auth; `authenticate` is how a client connects a + // free provider. `openrouter` opens the user's browser for a one-click key + // (PKCE); `local` registers model servers running on this machine. + async authenticate(params) { + const methodId = params && params.methodId; + if (!methodId) return {}; + if (!AUTH_METHODS.some((m) => m.id === methodId)) throw new RpcError(-32602, `unknown auth method: ${methodId}`); + const gw = await ensureGateway(log); + const result = await providers.connect(gw, methodId, { install: true, onUrl: (url) => log("open this URL to connect OpenRouter:", url) }); + invalidateModels(); + const chain = providers.suggestChain(await listModels(gw)); + for (const sess of sessions.values()) sess.agent.fallbackModels = chain.filter((m) => m !== sess.agent.model); + log("connected", methodId, JSON.stringify(result).slice(0, 200)); + return {}; + }, async "session/new"(params) { const cwd = params && params.cwd; if (!cwd || !path.isAbsolute(cwd)) throw new RpcError(-32602, "cwd must be an absolute path"); if (!fs.existsSync(cwd)) throw new RpcError(-32602, `cwd does not exist: ${cwd}`); const id = `sess_${Date.now().toString(36)}_${++sessionSeq}`; - const sess = await buildSession({ id, cwd, mode: DEFAULT_MODE }); + const meta = (params && params._meta) || {}; + const sess = await buildSession({ + id, cwd, mode: DEFAULT_MODE, + model: meta.model, fallbackModels: meta.fallbackModels ?? meta.fallback_models, + }); persist(sess); setTimeout(() => pushCommands(sess), 0); // after we've returned the sessionId - return { sessionId: id, modes: { currentModeId: sess.mode, availableModes: MODES } }; + return { + sessionId: id, + modes: { currentModeId: sess.mode, availableModes: MODES }, + configOptions: await configOptionsFor(sess), + _meta: sessionMeta(sess), + }; }, async "session/load"(params) { @@ -408,7 +545,10 @@ const handlers = { let saved = null; try { saved = JSON.parse(fs.readFileSync(sessionFile(id), "utf8")); } catch {} if (!saved) throw new RpcError(-32602, `unknown session: ${id}`); - sess = await buildSession({ id, cwd: cwd || saved.cwd, mode: saved.mode, messages: saved.messages }); + sess = await buildSession({ + id, cwd: cwd || saved.cwd, mode: saved.mode, messages: saved.messages, + model: saved.model, fallbackModels: saved.fallbackModels, + }); } // ACP requires the whole conversation to be replayed as updates *before* we // answer the request. @@ -428,7 +568,11 @@ const handlers = { }); } pushCommands(sess); - return { modes: { currentModeId: sess.mode, availableModes: MODES } }; + return { + modes: { currentModeId: sess.mode, availableModes: MODES }, + configOptions: await configOptionsFor(sess), + _meta: sessionMeta(sess), + }; }, async "session/prompt"(params) { @@ -451,6 +595,22 @@ const handlers = { notify("session/update", { sessionId: sess.id, update: { sessionUpdate: "current_mode_update", currentModeId: modeId } }); return {}; }, + + async "session/set_config_option"(params) { + const sess = sessions.get(params && params.sessionId); + if (!sess) throw new RpcError(-32602, `unknown session: ${params && params.sessionId}`); + const configId = params && params.configId; + if (configId !== "model") throw new RpcError(-32602, `unknown config option: ${configId}`); + return { configOptions: await setModel(sess, params.value) }; + }, + + // The pre-config-option way to pick a model; some clients still speak it. + async "session/set_model"(params) { + const sess = sessions.get(params && params.sessionId); + if (!sess) throw new RpcError(-32602, `unknown session: ${params && params.sessionId}`); + await setModel(sess, params && params.modelId); + return {}; + }, }; const notifications = { diff --git a/electron/mcp-server.js b/electron/mcp-server.js old mode 100644 new mode 100755 index 229ee16..f49891b --- a/electron/mcp-server.js +++ b/electron/mcp-server.js @@ -15,8 +15,7 @@ const fs = require("node:fs"); const { ensureShellPath } = require("./shell-path"); -const { Agent } = require("./agent"); -const { agentEnv, browser, ensureGateway, prewarmGateway, SKILLS_DIR } = require("./headless"); +const { agentEnv, browser, ensureGateway, prewarmGateway, resolveModelsLive, listModels, invalidateModels, noModelHint, providers, makeAgent, isNoModelFailure, engineFallbackModel, opencode, SKILLS_DIR } = require("./headless"); const skillsApi = require("./skills"); ensureShellPath(); // MCP clients can launch us with a minimal environment too @@ -29,48 +28,113 @@ const log = (...a) => process.stderr.write("[omniwork-mcp] " + a.join(" ") + "\n const DELEGATE_TIMEOUT_MS = Number(process.env.OMNIWORK_DELEGATE_TIMEOUT_MS || 600_000); // Run one delegated task; capture a change log + final summary. -async function runDelegate({ task, cwd, progress }) { +async function runDelegate({ task, cwd, model, fallbackModels, progress }) { const gw = await ensureGateway(log); const workspace = cwd && fs.existsSync(cwd) ? cwd : process.cwd(); const changes = []; let steps = 0; - const agent = new Agent({ - baseUrl: gw.baseUrl, apiKey: gw.apiKey, model: process.env.OMNIWORK_MODEL || "auto", + let failure = null; + const emit = (type, p) => { + if (type === "thinking") progress(++steps, "thinking…"); + else if (type === "error") failure = String(p.message || ""); + else if (type === "tool_call") { + const a = p.args || {}; + const file = a.path || a.filePath || a.file; + if (/^(write_file|write)$/.test(p.name)) changes.push(`wrote ${file}`); + else if (/^(edit_file|edit|patch)$/.test(p.name)) changes.push(`edited ${file}`); + else if (/^(run_command|bash)$/.test(p.name)) changes.push(`ran: ${String(a.command).slice(0, 80)}`); + progress(steps, `${p.name} ${String(file || a.command || a.query || a.pattern || "").slice(0, 60)}`.trim()); + } + }; + const build = (models) => makeAgent({ + baseUrl: gw.baseUrl, apiKey: gw.apiKey, ...models, workspace, canSpawn: true, ...agentEnv(workspace), // The caller sees a single tool result, never the token stream. streaming: false, - emit: (type, p) => { - if (type === "thinking") progress(++steps, "thinking…"); - else if (type === "tool_call") { - if (p.name === "write_file") changes.push(`wrote ${p.args.path}`); - else if (p.name === "edit_file") changes.push(`edited ${p.args.path}`); - else if (p.name === "run_command") changes.push(`ran: ${String(p.args.command).slice(0, 80)}`); - progress(steps, `${p.name} ${String(p.args.path || p.args.command || p.args.query || "").slice(0, 60)}`.trim()); - } - }, + emit, }); + let agent = build(await resolveModelsLive(gw, { model, fallbackModels })); let timedOut = false; + let engineNote = ""; const timer = setTimeout(() => { timedOut = true; agent.abort(); }, DELEGATE_TIMEOUT_MS); - try { await agent.send(task); } - finally { clearTimeout(timer); } + try { + await agent.send(task); + // No gateway model answered, but OpenCode is installed: run the same task on + // its engine instead of returning a dead delegation. + if (failure && !agent.lastText && isNoModelFailure(failure) && !agent.isEngine && !timedOut) { + const engineModel = await engineFallbackModel(); + if (engineModel) { + engineNote = `\n\n[model: no gateway model answered (${failure.split("\n")[0].slice(0, 100)}) — ran on the OpenCode engine, ${engineModel}]`; + failure = null; + agent = build({ model: engineModel, fallbackModels: [] }); + await agent.send(task); + } + } + } finally { clearTimeout(timer); } + // A turn that produced nothing but an error is an error, not "(no summary)". + // When the error is "no model answered" it comes with the way out. + if (failure && !agent.lastText) { + throw new Error(isNoModelFailure(failure) ? `${failure}\n\n${noModelHint()}` : failure); + } const summary = agent.lastText || "(no summary)"; const changeLog = changes.length ? `\n\nChanges:\n- ${changes.join("\n- ")}` : ""; const note = timedOut ? `\n\n[stopped after ${Math.round(DELEGATE_TIMEOUT_MS / 1000)}s — this is partial work]` : ""; - return `${summary}${changeLog}${note}`; + return `${summary}${changeLog}${modelNote(agent)}${engineNote}${note}`; +} + +// The caller asked for a model; if it got a different one, it should know. +function modelNote(agent) { + if (!agent.modelSwitches.length) return ""; + const hops = agent.modelSwitches.map((s) => `${s.from} failed (${s.reason.slice(0, 120)})`).join("; "); + return `\n\n[model: ran on ${agent.model} — ${hops}]`; } +// The catalog, grouped by provider prefix so a long list reads at a glance. +async function listModelsText() { + const ids = await listModels(await ensureGateway(log)); + if (!ids.length) return "The gateway returned no models (is it still starting? retry in a few seconds)."; + const groups = new Map(); + for (const id of ids) { + const i = id.indexOf("/"); + const p = i > 0 ? id.slice(0, i) : "(other)"; + if (!groups.has(p)) groups.set(p, []); + groups.get(p).push(id); + } + const lines = [`${ids.length} models. \`auto\` routes the free pool; a provider prefix pins one. Models behind a provider key you haven't added will fail — put them in fallback_models after a free one, or use them alone only if the key is configured in the router dashboard.`, ""]; + for (const [p, list] of groups) lines.push(`## ${p} (${list.length})`, list.join(", "), ""); + if (opencode.available()) { + let engine = []; + try { engine = await opencode.getEngine().models(); } catch (e) { lines.push(`## opencode (engine)`, `could not list: ${e.message}`, ""); } + if (engine.length) lines.push(`## opencode (engine — free, no account; runs on OpenCode's own server and tools)`, engine.map((m) => m.id).join(", "), ""); + } else { + lines.push(`## opencode (engine) — not present yet`, `${opencode.INSTALL_COMMAND} (a ~45 MB download the user runs) adds its free Zen models (Nemotron 3.5 Lightning, MiMo V2.5, Big Pickle, Ling 3.0 Flash, Nemotron 3 Ultra) as an engine that needs no account.`, ""); + } + return lines.join("\n").trim(); +} + +const MODEL_PARAM = { + type: "string", + description: "Model id to run on (see list_models). Default: OMNIWORK_MODEL, else 'auto' (the free pool).", +}; +const FALLBACK_PARAM = { + type: "array", items: { type: "string" }, + description: "Tried in order if the model fails (retired id, no provider key, quota). Default: OMNIWORK_MODEL_FALLBACKS. End with a paid model you have a key for when the task must complete.", +}; + const TOOLS = [ { name: "delegate", description: - "Delegate ONE coding/research subtask to OmniWork, which executes it autonomously on FREE models (reads/writes/edits files, runs commands, browses the web) in the given working directory and returns a summary + change list. The agent has OmniWork's installed skills and remembers facts saved to its memory across delegations. USE for: mechanical/boilerplate work, scaffolding, repetitive edits, read-heavy research, anything cheap to verify. DON'T use for: tasks needing the current conversation's context (delegates start cold — write fully self-contained instructions), tiny tasks (~30s overhead), precision-critical specs, or destructive/hard-to-undo changes. Always pass cwd. Verify the result yourself — the summary is a claim, not evidence.", + "Delegate ONE coding/research subtask to OmniWork, which executes it autonomously (reads/writes/edits files, runs commands, browses the web) — on free models by default, or on the model you pick — in the given working directory and returns a summary + change list. The agent has OmniWork's installed skills and remembers facts saved to its memory across delegations. USE for: mechanical/boilerplate work, scaffolding, repetitive edits, read-heavy research, anything cheap to verify. DON'T use for: tasks needing the current conversation's context (delegates start cold — write fully self-contained instructions), tiny tasks (~30s overhead), precision-critical specs, or destructive/hard-to-undo changes. Always pass cwd. Verify the result yourself — the summary is a claim, not evidence.", inputSchema: { type: "object", properties: { task: { type: "string", description: "Full, self-contained instruction. Include every fact the agent needs — it cannot see your conversation." }, cwd: { type: "string", description: "Absolute working directory. Always pass this explicitly." }, + model: MODEL_PARAM, + fallback_models: FALLBACK_PARAM, }, required: ["task"], }, @@ -78,16 +142,43 @@ const TOOLS = [ { name: "delegate_parallel", description: - "Delegate MANY independent subtasks at once; OmniWork fans them out to parallel free-model subagents and returns all summaries. USE instead of N sequential delegate calls whenever tasks don't depend on each other (write N files, refactor N modules, research N topics). Each task must be fully self-contained.", + "Delegate MANY independent subtasks at once; OmniWork fans them out to parallel subagents (free models by default, or the model you pick) and returns all summaries. USE instead of N sequential delegate calls whenever tasks don't depend on each other (write N files, refactor N modules, research N topics). Each task must be fully self-contained.", inputSchema: { type: "object", properties: { tasks: { type: "array", items: { type: "string" }, description: "Independent, self-contained subtask instructions." }, cwd: { type: "string", description: "Absolute working directory. Always pass this explicitly." }, + model: MODEL_PARAM, + fallback_models: FALLBACK_PARAM, }, required: ["tasks"], }, }, + { + name: "list_providers", + description: + "Show which free model providers are connected to OmniWork's gateway, the fallback chain delegations will use, local model servers detected on this machine, and how to connect more. USE when a delegation reports 'All models failed' or before relying on OmniWork for unattended work — the built-in keyless pool is unreliable, a connected provider is not.", + inputSchema: { type: "object", properties: {}, required: [] }, + }, + { + name: "connect_provider", + description: + "Connect a free model provider to OmniWork's gateway so delegations always have a model. provider='openrouter' with no api_key OPENS THE USER'S BROWSER for a one-click sign-in (PKCE, no key to paste) — tell the user before calling. provider='local' registers any Ollama / LM Studio / llama.cpp / vLLM server running on this machine. Other providers (ollama-cloud, kilo-gateway, groq, cerebras, nvidia, gemini, mistral) take an api_key the user creates at the URL list_providers shows; prefer letting the user paste it in the OmniWork app or run 'npm run providers' rather than routing a secret through chat. The key is verified with one request before it is kept.", + inputSchema: { + type: "object", + properties: { + provider: { type: "string", description: "openrouter | local | ollama-cloud | kilo-gateway | groq | cerebras | nvidia | gemini | mistral" }, + api_key: { type: "string", description: "API key for key-based providers. Omit for openrouter (browser flow) and local." }, + }, + required: ["provider"], + }, + }, + { + name: "list_models", + description: + "List the model ids OmniWork's gateway can route right now, grouped by provider. USE before pinning a model in delegate/delegate_parallel, or when a pinned model failed — free catalogs change as providers retire models.", + inputSchema: { type: "object", properties: {}, required: [] }, + }, { name: "web_search", description: @@ -115,22 +206,50 @@ const TOOLS = [ ]; async function callTool(name, args, progress = () => {}) { - if (name === "delegate") return await runDelegate({ task: args.task, cwd: args.cwd, progress }); + if (name === "delegate") return await runDelegate({ task: args.task, cwd: args.cwd, model: args.model, fallbackModels: args.fallback_models, progress }); + if (name === "list_models") return await listModelsText(); + if (name === "list_providers") return providers.describe(await providers.status(await ensureGateway(log))); + if (name === "connect_provider") { + const gw = await ensureGateway(log); + if (args.provider === "opencode") { + if (opencode.available()) { + const list = await opencode.getEngine().models().catch(() => []); + return `OpenCode is installed (${opencode.version() || "?"}). Engine models: ${list.map((m) => m.id).join(", ") || "(none listed yet)"}. Pass one as \`model\` to delegate, or rely on the automatic fallback.`; + } + return `OpenCode is not present yet. It is a ~45 MB download, so it needs the user's own gesture (not a tool call): ${opencode.INSTALL_COMMAND} in the omniwork checkout, or the Download button in the app's free-models panel. Afterwards its free Zen models appear as opencode/… in list_models and become the automatic fallback.`; + } + let url = null; + const result = await providers.connect(gw, args.provider, { + apiKey: args.api_key, + onUrl: (u) => { url = u; progress(0, "waiting for the OpenRouter sign-in in the browser…"); }, + }); + invalidateModels(); + const st = await providers.status(gw, { detectLocal: false }); + const head = result.added + ? (result.added.length + ? `Registered local: ${result.added.map((a) => `${a.name} (${a.models.length} models)`).join(", ")}.` + : `No local model server is running (${result.skipped.map((s) => s.name).join(", ")} checked).`) + : `Connected ${result.name} — ${result.models} models now routable.`; + return `${head}${url ? `\n(sign-in URL was ${url})` : ""}\nFallback chain now: ${st.chain.length ? st.chain.join(" → ") : "(empty)"}`; + } if (name === "delegate_parallel") { const gw = await ensureGateway(log); const workspace = args.cwd && fs.existsSync(args.cwd) ? args.cwd : process.cwd(); let done = 0; const tasks = (args.tasks || []).map((t, i) => ({ title: `task ${i + 1}`, prompt: t })); - const agent = new Agent({ - baseUrl: gw.baseUrl, apiKey: gw.apiKey, model: process.env.OMNIWORK_MODEL || "auto", + const switched = []; + const agent = makeAgent({ + baseUrl: gw.baseUrl, apiKey: gw.apiKey, ...(await resolveModelsLive(gw, { model: args.model, fallbackModels: args.fallback_models })), workspace, canSpawn: true, ...agentEnv(workspace), streaming: false, emit: (type, p) => { if (type !== "subagent") return; if (p.kind === "done") progress(++done, `${done}/${tasks.length} subagents finished`, tasks.length); else if (p.kind === "tool") progress(done, `${p.title || "subagent"}: ${p.tool}`, tasks.length); + else if (p.kind === "model") { switched.push(`${p.from} → ${p.to}`); progress(done, `${p.title || "subagent"}: model ${p.from} failed, continuing on ${p.to}`, tasks.length); } }, }); - return await agent.runSubagents(tasks); + const out = await agent.runSubagents(tasks); + return switched.length ? `${out}\n\n[model: some subagents fell back — ${[...new Set(switched)].join(", ")}]` : out; } if (name === "web_search") return await browser.search(args.query); if (name === "browse_page") return await browser.open(args.url); diff --git a/test/acp.js b/test/acp.js index c9cc1b7..121a80f 100644 --- a/test/acp.js +++ b/test/acp.js @@ -5,6 +5,8 @@ // OMNIWORK_BASE_URL points the agent at a dead port so no gateway boots: session // setup still works, and session/prompt fails fast, which is exactly how we // check that a broken turn surfaces as a JSON-RPC error instead of hanging. +// The model env is blanked so a developer's own OMNIWORK_MODEL(_FALLBACKS) +// can't change what the defaults look like here. const { spawn } = require("child_process"); const path = require("path"); @@ -13,7 +15,7 @@ const check = (name, ok) => { console.log((ok ? "✓" : "✗"), name); if (!ok) const proc = spawn(process.execPath, [path.join(__dirname, "..", "electron", "acp-server.js")], { stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, OMNIWORK_NO_PREWARM: "1", OMNIWORK_BASE_URL: "http://127.0.0.1:9/v1" }, + env: { ...process.env, OMNIWORK_NO_PREWARM: "1", OMNIWORK_BASE_URL: "http://127.0.0.1:9/v1", OMNIWORK_MODEL: "", OMNIWORK_MODEL_FALLBACKS: "" }, }); const pending = new Map(); @@ -52,6 +54,12 @@ const updates = (type) => notes.filter((n) => n.method === "session/update" && n check("initialize identifies the agent", init.result.agentInfo.name === "omniwork"); check("advertises loadSession", init.result.agentCapabilities.loadSession === true); check("advertises image prompts", init.result.agentCapabilities.promptCapabilities.image === true); + check("offers openrouter, local and opencode as auth methods", init.result.authMethods.map((m) => m.id).join(",") === "openrouter,local,opencode"); + + const badAuth = await rpc("authenticate", { methodId: "telepathy" }); + check("authenticate rejects an unknown method", badAuth.error && badAuth.error.code === -32602); + const noAuth = await rpc("authenticate", {}); + check("authenticate with no method is a no-op", !noAuth.error); // ── session/new ── const made = await rpc("session/new", { cwd: repo, mcpServers: [] }); @@ -60,12 +68,24 @@ const updates = (type) => notes.filter((n) => n.method === "session/update" && n check("session/new advertises four modes", made.result.modes.availableModes.length === 4); check("session/new defaults to ask mode", made.result.modes.currentModeId === "ask"); + const modelOpt = (made.result.configOptions || []).find((o) => o.id === "model"); + check("session/new advertises a model config option", Boolean(modelOpt) && modelOpt.category === "model" && modelOpt.type === "select"); + check("the model defaults to auto", Boolean(modelOpt) && modelOpt.currentValue === "auto" && modelOpt.options.some((o) => o.value === "auto")); + check("_meta reports the model and its chain", made.result._meta && made.result._meta.omniwork.model === "auto" && Array.isArray(made.result._meta.omniwork.fallbackModels)); + const relative = await rpc("session/new", { cwd: "not/absolute", mcpServers: [] }); check("session/new rejects a relative cwd", relative.error && relative.error.code === -32602); await settle(); check("skills are pushed as available commands", updates("available_commands_update").length === 1); + // ── a pinned model via _meta ── + const pinned = await rpc("session/new", { cwd: repo, mcpServers: [], _meta: { model: "oc/some-coder", fallbackModels: ["auto", "oc/some-coder"] } }); + const pinnedOpt = pinned.result.configOptions.find((o) => o.id === "model"); + check("_meta.model pins the session's model", pinnedOpt.currentValue === "oc/some-coder"); + check("_meta.fallbackModels sets the chain, minus the primary", JSON.stringify(pinned.result._meta.omniwork.fallbackModels) === JSON.stringify(["auto"])); + check("fallbacks are listed as options", pinnedOpt.options.some((o) => o.value === "auto" && /fallback/i.test(o.description || ""))); + // ── modes ── const mode = await rpc("session/set_mode", { sessionId: sid, modeId: "plan" }); check("session/set_mode succeeds", !mode.error); @@ -76,9 +96,23 @@ const updates = (type) => notes.filter((n) => n.method === "session/update" && n const badMode = await rpc("session/set_mode", { sessionId: sid, modeId: "nonsense" }); check("session/set_mode rejects an unknown mode", badMode.error && badMode.error.code === -32602); + // ── model ── + const setOpt = await rpc("session/set_config_option", { sessionId: sid, configId: "model", value: "tllm/some-coder" }); + check("session/set_config_option switches the model", !setOpt.error && setOpt.result.configOptions.find((o) => o.id === "model").currentValue === "tllm/some-coder"); + await settle(); + const cfgNotes = updates("config_option_update"); + check("set_config_option notifies config_option_update", cfgNotes.length === 1 && cfgNotes[0].params.update.configOptions[0].currentValue === "tllm/some-coder"); + const badCfg = await rpc("session/set_config_option", { sessionId: sid, configId: "temperature", value: "1" }); + check("unknown config options are rejected", badCfg.error && badCfg.error.code === -32602); + const legacy = await rpc("session/set_model", { sessionId: sid, modelId: "auto" }); + check("session/set_model (pre-config-option clients) still works", !legacy.error); + const noModel = await rpc("session/set_model", { sessionId: sid, modelId: "" }); + check("an empty model id is rejected", noModel.error && noModel.error.code === -32602); + // ── load ── const loaded = await rpc("session/load", { sessionId: sid, cwd: repo, mcpServers: [] }); check("session/load restores a live session", !loaded.error && loaded.result.modes.currentModeId === "plan"); + check("session/load reports the current model", loaded.result.configOptions.find((o) => o.id === "model").currentValue === "auto"); const missing = await rpc("session/load", { sessionId: "sess_nope", cwd: repo, mcpServers: [] }); check("session/load rejects an unknown session", missing.error && missing.error.code === -32602); diff --git a/test/mcp.js b/test/mcp.js index ba4551a..554bcb4 100644 --- a/test/mcp.js +++ b/test/mcp.js @@ -1,4 +1,4 @@ -// Smoke test: the MCP server speaks JSON-RPC over stdio, lists all six tools, +// Smoke test: the MCP server speaks JSON-RPC over stdio, lists all nine tools, // and the electron-free tools (list_skills, static browse_page) work. No // gateway boot — delegation tools are only listed, not called. const { spawn } = require("child_process"); @@ -38,7 +38,12 @@ const rpc = (method, params) => new Promise((resolve, reject) => { const list = await rpc("tools/list", {}); const names = list.result.tools.map((t) => t.name).sort(); - check("all six tools listed", names.join(",") === "browse_page,delegate,delegate_parallel,install_skills,list_skills,web_search"); + check("all nine tools listed", names.join(",") === "browse_page,connect_provider,delegate,delegate_parallel,install_skills,list_models,list_providers,list_skills,web_search"); + check("connect_provider warns that openrouter opens the browser", /OPENS THE USER'S BROWSER/.test(list.result.tools.find((t) => t.name === "connect_provider").description)); + const delegateSchema = list.result.tools.find((t) => t.name === "delegate").inputSchema.properties; + check("delegate takes a model and a fallback chain", delegateSchema.model.type === "string" && delegateSchema.fallback_models.type === "array"); + const parallelSchema = list.result.tools.find((t) => t.name === "delegate_parallel").inputSchema.properties; + check("delegate_parallel takes them too", parallelSchema.model.type === "string" && parallelSchema.fallback_models.type === "array"); check("delegate description teaches when NOT to use it", /DON'T use/.test(list.result.tools.find((t) => t.name === "delegate").description)); check("browse_page discloses static fetch", /static fetch/.test(list.result.tools.find((t) => t.name === "browse_page").description)); check("every tool has a when-to-use description", list.result.tools.every((t) => t.description.length > 80)); diff --git a/test/opencode.js b/test/opencode.js new file mode 100644 index 0000000..8a24297 --- /dev/null +++ b/test/opencode.js @@ -0,0 +1,157 @@ +"use strict"; +// The OpenCode engine, against test/fixtures/fake-opencode.js standing in for +// `opencode serve`: model discovery (free only), the event → OmniWork-event +// mapping (deltas, tools, permissions, errors, abort), and the two headless +// servers falling through to the engine when the gateway has no model. +// +// Run: node test/opencode.js +const http = require("node:http"); +const path = require("node:path"); +const fs = require("node:fs"); +const os = require("node:os"); +const { spawn } = require("node:child_process"); + +const FAKE = path.join(__dirname, "fixtures", "fake-opencode.js"); +process.env.OMNIWORK_OPENCODE_BIN = FAKE; +const opencode = require("../electron/opencode-engine"); + +let fails = 0; +const check = (name, ok) => { console.log((ok ? "✓" : "✗"), name); if (!ok) fails++; }; +const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "omniwork-oc-")); + +// A gateway that lists models but can't serve any of them: the failure this +// engine exists for. +function deadPoolGateway() { + const server = http.createServer((req, res) => { + const json = (code, body) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify(body)); }; + if (req.url.startsWith("/v1/models")) return json(200, { data: [{ id: "auto" }, { id: "oc/retired-free" }] }); + if (req.url.startsWith("/api/providers")) return json(200, { connections: [] }); + let s = ""; req.on("data", (d) => { s += d; }); req.on("end", () => json(503, { error: { message: "Maximum combo retry limit reached", type: "server_error" } })); + }); + return new Promise((r) => server.listen(0, "127.0.0.1", () => r({ baseUrl: `http://127.0.0.1:${server.address().port}/v1`, close: () => server.close() }))); +} + +function rpcClient(file, env) { + const proc = spawn(process.execPath, [file], { stdio: ["pipe", "pipe", "pipe"], env }); + const pending = new Map(); const notes = []; let id = 0; let buf = ""; + proc.stdout.on("data", (d) => { buf += d; let i; while ((i = buf.indexOf("\n")) >= 0) { const l = buf.slice(0, i).trim(); buf = buf.slice(i + 1); if (!l) continue; let m; try { m = JSON.parse(l); } catch { continue; } if (m.id != null && m.method === undefined) { pending.get(m.id)?.(m); pending.delete(m.id); } else if (m.method) notes.push(m); } }); + const rpc = (method, params, ms = 60_000) => new Promise((res, rej) => { const i = ++id; pending.set(i, res); proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: i, method, params }) + "\n"); setTimeout(() => { if (pending.has(i)) { pending.delete(i); rej(new Error(method + " timed out")); } }, ms); }); + return { proc, rpc, notes, updates: (t) => notes.filter((n) => n.method === "session/update" && n.params.update.sessionUpdate === t) }; +} + +(async () => { + // ── engine + agent, in-process ── + const engine = opencode.getEngine(); + check("the binary is found via OMNIWORK_OPENCODE_BIN", opencode.available() && opencode.findBinary() === FAKE); + const health = await engine.health(); + check("serve starts and reports health", health && health.healthy === true); + const models = await engine.models(); + check("only free models are listed, fastest first, Ultra last", models.map((m) => m.id).join(",") === "opencode/nemotron-3.5-lightning-free,opencode/big-pickle,opencode/nemotron-3-ultra-free,opencode/ox-alpha-free"); + check("opencode-go models keep their provider", models.find((m) => m.modelID === "ox-alpha-free").providerID === "opencode-go"); + + const run = async (text, { approvalMode = "auto", approver = null, model } = {}) => { + const events = []; + const agent = new opencode.OpenCodeAgent({ model, workspace, approvalMode, approver, emit: (type, p) => events.push({ type, ...p }) }); + await agent.send(text); + return { agent, events }; + }; + const plain = await run("say READY"); + check("a plain turn answers on the default free model, scoped to the workspace", plain.agent.lastText === `READY from opencode/nemotron-3.5-lightning-free in ${workspace}`); + check("…streaming text deltas and ending with done", plain.events.filter((e) => e.type === "assistant_delta").map((e) => e.chunk).join("") === plain.agent.lastText && plain.events.some((e) => e.type === "done")); + check("reasoning streams on its own lane, never as reply text", plain.events.some((e) => e.type === "reasoning_delta" && /thinking/.test(e.chunk)) && !plain.events.some((e) => e.type === "assistant_delta" && /thinking/.test(e.chunk))); + check("…and records the exchange in messages", plain.agent.messages.filter((m) => m.role === "assistant").length === 1); + + const tool = await run("TOOL then say READY"); + const call = tool.events.find((e) => e.type === "tool_call"); const result = tool.events.find((e) => e.type === "tool_result"); + check("tool parts map to tool_call / tool_result, announced once the input exists", call && call.name === "bash" && call.args.command === "echo hi" && result && result.id === call.id && result.result === "hi\n" && tool.events.filter((e) => e.type === "tool_call").length === 1); + + let asked = null; + const perm = await run("PERMISSION then say READY", { approvalMode: "ask", approver: async (id, name, args) => { asked = { id, name, args }; return false; } }); + check("in ask mode a permission request reaches the approver", asked && asked.name === "bash" && asked.args.command === "rm -rf build"); + check("…and a refusal is sent back as reject", perm.agent.lastText === "permission was rejected"); + const permAuto = await run("PERMISSION then say READY"); + check("in auto mode permissions are granted without asking", /^READY/.test(permAuto.agent.lastText)); + + const failed = await run("FAIL"); + check("a session error becomes an error event naming OpenCode", failed.events.some((e) => e.type === "error" && /OpenCode/.test(e.message) && /upstream exploded/.test(e.message))); + + const slowEvents = []; + const slow = new opencode.OpenCodeAgent({ workspace, emit: (type, p) => slowEvents.push({ type, ...p }) }); + const turn = slow.send("SLOW then say READY"); + await new Promise((r) => setTimeout(r, 300)); + slow.abort(); + await turn; + check("abort ends the turn as aborted", slowEvents.some((e) => e.type === "aborted") && !slowEvents.some((e) => e.type === "done")); + + // ── getting the binary: discovery order and the release download ── + check("the env override is the first candidate", opencode.candidates()[0] === FAKE); + check("a clone's node_modules binary and the data-dir download are looked for by absolute path", opencode.candidates().some((c) => /node_modules\/opencode-(darwin|linux|windows)-(arm64|x64)\/bin\/opencode/.test(c)) && opencode.candidates().includes(path.join(opencode.DOWNLOAD_DIR, process.platform === "win32" ? "opencode.exe" : "opencode"))); + check("the download asset matches this platform and the pinned version", /^opencode-(darwin|windows)-(arm64|x64)\.zip$|^opencode-linux-(arm64|x64)\.tar\.gz$/.test(opencode.assetName()) && /^\d+\.\d+\.\d+$/.test(opencode.pinnedVersion())); + { + // A fake GitHub release: a tar.gz holding an `opencode` script that answers --version. + const relDir = fs.mkdtempSync(path.join(os.tmpdir(), "oc-release-")); + const stage = path.join(relDir, "stage"); fs.mkdirSync(stage); + fs.writeFileSync(path.join(stage, "opencode"), "#!/bin/sh\necho fake-release 9.9.9\n"); fs.chmodSync(path.join(stage, "opencode"), 0o755); + const { execFileSync } = require("node:child_process"); + const asset = opencode.assetName(); + execFileSync("tar", process.platform === "linux" || !asset.endsWith(".zip") ? ["-czf", path.join(relDir, asset), "-C", stage, "opencode"] : ["-caf", path.join(relDir, asset), "-C", stage, "opencode"]); + const rel = http.createServer((req, res) => { const p = path.join(relDir, path.basename(req.url)); if (fs.existsSync(p)) { res.writeHead(200, { "Content-Length": fs.statSync(p).size }); fs.createReadStream(p).pipe(res); } else { res.writeHead(404); res.end(); } }); + await new Promise((r) => rel.listen(0, "127.0.0.1", r)); + const dest = fs.mkdtempSync(path.join(os.tmpdir(), "oc-dl-")); + const phases = []; + const bin = await opencode.download({ baseUrl: `http://127.0.0.1:${rel.address().port}`, dir: dest, onProgress: (p) => phases.push(p.phase) }); + check("download() fetches the release archive, unpacks it, and returns an executable", bin === path.join(dest, "opencode") && fs.statSync(bin).mode & 0o111 && execFileSync(bin, ["--version"], { encoding: "utf8" }).includes("9.9.9")); + check("…reporting download, extract and done", phases.includes("download") && phases.includes("extract") && phases[phases.length - 1] === "done"); + check("…and leaves no archive behind", !fs.existsSync(path.join(dest, asset))); + rel.close(); + } + + const pickled = await run("say READY", { model: "opencode/big-pickle" }); + check("a pinned engine model is honoured", /opencode\/big-pickle/.test(pickled.agent.lastText)); + engine.stop(); + + // ── ACP: gateway 503s → engine ── + const gw = await deadPoolGateway(); + const env = { ...process.env, OMNIWORK_NO_PREWARM: "1", OMNIWORK_BASE_URL: gw.baseUrl, OMNIWORK_MODEL: "", OMNIWORK_OPENCODE_BIN: FAKE }; + delete env.OMNIWORK_MODEL_FALLBACKS; + const acp = rpcClient(path.join(__dirname, "..", "electron", "acp-server.js"), env); + const init = await acp.rpc("initialize", { protocolVersion: 1, clientCapabilities: {} }); + check("ACP offers opencode as an auth method", init.result.authMethods.some((m) => m.id === "opencode")); + const made = await acp.rpc("session/new", { cwd: workspace, mcpServers: [] }); + const opt = made.result.configOptions.find((o) => o.id === "model"); + check("ACP lists engine models as options", opt.options.some((o) => o.value === "opencode/nemotron-3.5-lightning-free" && /engine/.test(o.description || ""))); + const sid = made.result.sessionId; + await acp.rpc("session/set_mode", { sessionId: sid, modeId: "auto" }); + const turn1 = await acp.rpc("session/prompt", { sessionId: sid, prompt: [{ type: "text", text: "say READY" }] }); + const text1 = acp.updates("agent_message_chunk").map((u) => u.params.update.content.text).join(""); + check("a turn nothing on the gateway can serve finishes on the engine", turn1.result && turn1.result.stopReason === "end_turn" && /READY from opencode\/nemotron-3.5-lightning-free/.test(text1)); + check("…after telling the client why", /no gateway model answered/.test(text1)); + const cfg = acp.updates("config_option_update"); + check("…and the model option now shows the engine model", cfg.length && cfg[cfg.length - 1].params.update.configOptions[0].currentValue === "opencode/nemotron-3.5-lightning-free"); + const sw = await acp.rpc("session/set_config_option", { sessionId: sid, configId: "model", value: "opencode/big-pickle" }); + check("switching to another engine model works", !sw.error && sw.result.configOptions[0].currentValue === "opencode/big-pickle"); + const before = acp.updates("agent_message_chunk").length; + await acp.rpc("session/prompt", { sessionId: sid, prompt: [{ type: "text", text: "say READY" }] }); + const text2 = acp.updates("agent_message_chunk").slice(before).map((u) => u.params.update.content.text).join(""); + check("…and the next turn runs on it", /opencode\/big-pickle/.test(text2)); + const back = await acp.rpc("session/set_config_option", { sessionId: sid, configId: "model", value: "auto" }); + check("switching back to a gateway model rebuilds the gateway agent", !back.error && back.result.configOptions[0].currentValue === "auto"); + acp.proc.kill(); + + // ── MCP: delegate on an engine model, and the fallback ── + const mcp = rpcClient(path.join(__dirname, "..", "electron", "mcp-server.js"), env); + await mcp.rpc("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "t", version: "0" } }); + const d1 = await mcp.rpc("tools/call", { name: "delegate", arguments: { task: "say READY", cwd: workspace, model: "opencode/big-pickle" } }); + check("MCP delegate runs a pinned engine model", !d1.result.isError && /READY from opencode\/big-pickle/.test(d1.result.content[0].text)); + const d2 = await mcp.rpc("tools/call", { name: "delegate", arguments: { task: "say READY", cwd: workspace } }); + check("MCP delegate falls through to the engine when the gateway has nothing", !d2.result.isError && /READY from opencode\/nemotron-3.5-lightning-free/.test(d2.result.content[0].text) && /ran on the OpenCode engine/.test(d2.result.content[0].text)); + const lm = await mcp.rpc("tools/call", { name: "list_models", arguments: {} }); + check("list_models shows the engine section", /opencode \(engine/.test(lm.result.content[0].text) && /opencode\/nemotron-3.5-lightning-free/.test(lm.result.content[0].text)); + const cp = await mcp.rpc("tools/call", { name: "connect_provider", arguments: { provider: "opencode" } }); + check("connect_provider('opencode') reports the engine instead of installing", /OpenCode is installed/.test(cp.result.content[0].text)); + mcp.proc.kill(); + gw.close(); + + console.log(fails ? `\n❌ ${fails} FAILED` : "\n✅ OPENCODE ENGINE TEST PASSED"); + process.exit(fails ? 1 : 0); +})().catch((e) => { console.error("crash:", e); process.exit(1); }); From c69ba47081255206b824ecff22a2821d65dec0ef Mon Sep 17 00:00:00 2001 From: inquerium Date: Tue, 15 Sep 2026 16:56:48 -0500 Subject: [PATCH 05/10] feat: desktop free-models panel, engine models in the menu, live fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 🆓 free models panel in the rail: one-click OpenRouter, paste-connect for the key providers, detected local servers, and the OpenCode engine (Download when missing). Connected providers become every session's fallback chain on the spot; opencode/… models appear in the model menu and run on the engine; a session whose gateway model fails finishes on the engine and writes the switch into the transcript. --- electron/main.js | 48 +++++++++++++++++++++++++++++-- electron/preload.js | 4 +++ electron/sessions.js | 41 ++++++++++++++++++++++++-- renderer/app.js | 68 ++++++++++++++++++++++++++++++++++++++++++++ renderer/index.html | 16 +++++++++++ renderer/styles.css | 14 +++++++++ 6 files changed, 186 insertions(+), 5 deletions(-) diff --git a/electron/main.js b/electron/main.js index fa5b9f1..192b4d1 100644 --- a/electron/main.js +++ b/electron/main.js @@ -98,6 +98,7 @@ async function boot() { }); sessions.setModel(state.model); sessions.setApprovalMode(state.approval); + applyFallbacks(); // Restore saved sessions; start a fresh one only if none were persisted. const restored = sessions.restore(); if (!restored) sessions.create({ workspace: state.lastWorkspace, title: "Main" }); @@ -126,13 +127,56 @@ ipcMain.handle("app:setModel", (_e, model) => { state.model = model; if (session ipcMain.handle("app:copy", (_e, text) => { const t = String(text || ""); if (t) clipboard.writeText(t); return true; }); ipcMain.handle("app:setCopyOnSelect", (_e, on) => { state.copyOnSelect = !!on; savePrefs(); return state.copyOnSelect; }); ipcMain.handle("gateway:openDashboard", () => { if (gateway) shell.openExternal(gateway.dashboardUrl()); return true; }); + +// ── IPC: free providers ───────────────────────────────────────────── +// The built-in keyless pool comes and goes; a connected free provider stays. +// Whatever is connected becomes every session's fallback chain, live. +const providers = require("./providers"); +const opencodeEngine = require("./opencode-engine"); +async function applyFallbacks() { + if (!gateway || !sessions) return []; + try { + const res = await fetch(`${gateway.baseUrl}/models`, { signal: AbortSignal.timeout(6000) }); + const ids = ((await res.json()).data || []).map((m) => m.id).filter(Boolean); + const chain = providers.suggestChain(ids); + sessions.setFallbacks(chain); + return chain; + } catch { return []; } +} +ipcMain.handle("providers:status", async () => { + if (!gateway || !gateway.ready) return null; + try { return await providers.status(gateway); } catch (e) { return { error: e.message }; } +}); +ipcMain.handle("providers:connect", async (_e, { provider, apiKey }) => { + if (!gateway) throw new Error("gateway not ready"); + // `install: true`: the click in the panel is the user's explicit gesture for the ~140 MB OpenCode download. + const result = await providers.connect(gateway, provider, { apiKey, install: true, open: (url) => shell.openExternal(url) }); + const chain = await applyFallbacks(); + return { ...result, chain }; +}); +ipcMain.handle("providers:remove", async (_e, provider) => { + if (!gateway) return 0; + const n = await providers.removeProvider(gateway, provider); + await applyFallbacks(); + return n; +}); +// Only the catalog's own "get a key" pages — never an arbitrary URL from the renderer. +ipcMain.handle("app:openUrl", (_e, url) => { + const allowed = providers.CATALOG.some((p) => p.keyUrl === url); + if (allowed) shell.openExternal(url); + return allowed; +}); ipcMain.handle("models:list", async () => { if (!gateway) return []; + let ids = []; try { const res = await fetch(`${gateway.baseUrl}/models`, { signal: AbortSignal.timeout(4000) }); const data = await res.json(); - return (data.data || []).map((m) => m.id).filter(Boolean); - } catch { return []; } + ids = (data.data || []).map((m) => m.id).filter(Boolean); + } catch {} + // OpenCode's free models, when it is installed — run on its own engine. + try { if (opencodeEngine.available()) ids.push(...(await opencodeEngine.getEngine().models()).map((m) => m.id)); } catch {} + return ids; }); // ── IPC: sessions (Cowork) ────────────────────────────────────────── diff --git a/electron/preload.js b/electron/preload.js index f3171c1..fd35df5 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -52,6 +52,10 @@ contextBridge.exposeInMainWorld("omniwork", { listModels: () => ipcRenderer.invoke("models:list"), setModel: (model) => ipcRenderer.invoke("app:setModel", model), openDashboard: () => ipcRenderer.invoke("gateway:openDashboard"), + providersStatus: () => ipcRenderer.invoke("providers:status"), + providersConnect: (provider, apiKey) => ipcRenderer.invoke("providers:connect", { provider, apiKey }), + providersRemove: (provider) => ipcRenderer.invoke("providers:remove", provider), + openUrl: (url) => ipcRenderer.invoke("app:openUrl", url), copyText: (text) => ipcRenderer.invoke("app:copy", text), setCopyOnSelect: (on) => ipcRenderer.invoke("app:setCopyOnSelect", on), diff --git a/electron/sessions.js b/electron/sessions.js index 6622d4e..cebf125 100644 --- a/electron/sessions.js +++ b/electron/sessions.js @@ -9,6 +9,7 @@ const os = require("node:os"); const fs = require("node:fs"); const path = require("node:path"); const { Agent } = require("./agent"); +const opencode = require("./opencode-engine"); const { saveMemory, memoryFile } = require("./memory"); let counter = 0; @@ -34,6 +35,7 @@ class SessionManager { this.sessions = new Map(); this.activeId = null; this.model = "auto"; + this.fallbackModels = []; // tried in order when `model` fails — see providers.suggestChain this.approvalMode = "auto"; // "auto" | "ask" this.pendingApprovals = new Map(); // callId -> resolve this._saveTimer = null; @@ -115,6 +117,7 @@ class SessionManager { baseUrl: this.gateway.baseUrl, apiKey: this.gateway.apiKey, model: this.model, + fallbackModels: this.fallbackModels, workspace: sess.workspace, mcp: this.mcp, memory: this.projects ? { globalDir: this.globalMemoryDir, projectDir: this.projects.memoryDir(sess.projectId), knowledgeDir: this.projects.knowledgeDir(sess.projectId), instructionsFile: this.projects.instructionsFile(sess.projectId) } : null, @@ -131,6 +134,10 @@ class SessionManager { }), emit: (type, payload) => this.#onAgentEvent(id, type, payload), }); + if (opencode.isEngineModel(this.model)) { + const base = sess.agent; + sess.agent = new opencode.OpenCodeAgent({ model: this.model, workspace: sess.workspace, approvalMode: base.approvalMode, approver: base.approver, emit: base.emit }); + } if (this._ctxTokens) sess.agent.contextTokens = this._ctxTokens; } @@ -185,9 +192,9 @@ class SessionManager { #onAgentEvent(id, type, payload) { const sess = this.sessions.get(id); if (!sess) return; - if (type === "error") sess.status = "error"; + if (type === "error") { sess.status = "error"; sess.lastError = String(payload && payload.message || ""); } else if (type === "done" || type === "aborted") sess.status = "done"; - if (type !== "assistant_delta" && type !== "tool_stream" && type !== "thinking" && type !== "approval_request" && type !== "context" && type !== "stats") { + if (type !== "assistant_delta" && type !== "reasoning_delta" && type !== "tool_stream" && type !== "thinking" && type !== "approval_request" && type !== "context" && type !== "stats") { sess.transcript.push({ type, ...payload }); if (sess.transcript.length > 4000) sess.transcript.splice(0, 1000); } @@ -253,6 +260,19 @@ class SessionManager { this.emit(id, "user", { content: shown, pastes: ev.pastes }); try { await sess.agent.send(text, images); + // Nothing on the gateway answered, but OpenCode is installed: finish the + // turn on its engine rather than hand the user a dead prompt. The switch + // sticks for the session and is written into the transcript. + if (sess.status === "error" && !sess.agent.isEngine && opencode.available() && /All models failed|Gateway (401|403|404|429|503)|No choices returned/.test(sess.lastError || "")) { + let model = `${opencode.PREFIX}nemotron-3.5-lightning-free`; + try { const list = await opencode.getEngine().models(); if (list.length) model = list[0].id; } catch {} + const prev = sess.agent; + sess.agent = new opencode.OpenCodeAgent({ model, workspace: sess.workspace, approvalMode: prev.approvalMode, approver: prev.approver, emit: prev.emit }); + this.#onAgentEvent(id, "system", { content: `⇄ no gateway model answered — continuing on the OpenCode engine (${model})` }); + sess.status = "running"; + this.#pushList(); + await sess.agent.send(text, images); + } } catch (e) { sess.status = "error"; this.emit(id, "error", { message: e.message }); @@ -328,9 +348,24 @@ class SessionManager { this.save(); } + // The chain applies to live agents too: connecting a provider mid-session + // should rescue the very next turn, not the next session. + setFallbacks(list) { + this.fallbackModels = Array.isArray(list) ? list.slice() : []; + for (const s of this.sessions.values()) { + if (s.agent) s.agent.fallbackModels = this.fallbackModels.filter((m) => m !== s.agent.model); + } + } + setModel(model) { this.model = model; - for (const s of this.sessions.values()) if (s.agent) s.agent.model = model; + for (const s of this.sessions.values()) { + if (!s.agent) continue; + // Crossing between the gateway loop and the OpenCode engine is a new + // agent, not a field change; within one, it's just the model id. + if (Boolean(s.agent.isEngine) !== opencode.isEngineModel(model)) this.#buildAgent(s); + else s.agent.model = model; + } this.refreshContextLimit(); } diff --git a/renderer/app.js b/renderer/app.js index 3060148..332e2a3 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -14,6 +14,7 @@ const stub = { listDir: async () => ({ root: null, entries: [] }), readFile: async () => ({ content: "" }), getState: async () => ({ model: "auto", sessions: [], activeId: null, mcp: [] }), setModel: async () => {}, openDashboard: async () => {}, on: () => () => {}, + providersStatus: async () => null, providersConnect: async () => ({}), providersRemove: async () => 0, openUrl: async () => false, copyText: async () => {}, setCopyOnSelect: async () => {}, }; const api = window.omniwork || stub; @@ -1015,6 +1016,73 @@ async function addFromForm() { await api.addMcp(name, conf); } $("add-mcp").addEventListener("click", openModal); + +// ── Free providers ─────────────────────────────────────────────── +async function renderProviders() { + const st = await api.providersStatus(); + const list = $("prov-list"); const local = $("prov-local"); const status = $("prov-status"); + list.innerHTML = ""; local.innerHTML = ""; + if (!st || st.error) { status.textContent = st && st.error ? "Gateway error: " + st.error : "Gateway still starting — try again in a moment."; return; } + const on = st.providers.filter((p) => p.connected); + status.textContent = on.length + ? `Connected: ${on.map((p) => p.name).join(", ")} · fallback chain: ${st.chain.join(" → ") || "(empty)"}` + : "Nothing connected yet — pick one below (all free, no card)."; + st.providers.forEach((p) => { + const row = document.createElement("div"); row.className = "prow"; + const act = p.connected + ? `${p.models} models ` + : p.connect === "pkce" + ? `` + : ``; + row.innerHTML = `
${p.connected ? '✓' : ""}${esc(p.name)}
${esc(p.free)}
${act}
`; + list.appendChild(row); + }); + if (st.opencode) { + const oc = st.opencode; + const row = document.createElement("div"); row.className = "prow"; + const act = oc.installed + ? `${oc.models.length} free models · engine` + : ``; + row.innerHTML = `
${oc.installed ? '✓' : ""}OpenCode engine
${oc.installed ? esc(oc.models.join(", ") || "no free models listed") : "Zen's free models with no account: Nemotron 3 Ultra, Nemotron 3.5 Lightning, MiMo V2.5, Ling 3.0 Flash, Big Pickle. Runs OpenCode's own server as an engine."}
${act}
`; + local.appendChild(row); + } + const running = st.local.filter((l) => l.running); + if (!running.length) { + local.insertAdjacentHTML("beforeend", '
No local model server running. Ollama (:11434), LM Studio (:1234), llama.cpp (:8080) and vLLM (:8000) are detected automatically.
'); + } + running.forEach((l) => { + const row = document.createElement("div"); row.className = "prow"; + row.innerHTML = `
${l.connected ? '✓' : ""}${esc(l.name)}
${esc(l.runningModels.slice(0, 4).join(", "))}${l.runningModels.length > 4 ? " …" : ""}
${l.connected ? 'connected' : ``}
`; + local.appendChild(row); + }); +} +async function connectProvider(id, key) { + const modal = $("providers-modal"); modal.classList.add("prow-busy"); + try { + const r = await api.providersConnect(id, key); + addSystem(r.engine ? `🆓 OpenCode engine ready (${r.version || "installed"}) — ${r.models} free models: ${(r.modelIds || []).join(", ")}` + : r.added ? `🆓 local model server${r.added.length ? "s registered: " + r.added.map((a) => a.name).join(", ") : ": none running"}` + : `🆓 ${r.name} connected — ${r.models} models · fallback chain: ${(r.chain || []).join(" → ") || "(empty)"}`); + await populateModels(); + } catch (e) { addSystem("🆓 " + (e.message || String(e)).replace(/^Error invoking remote method '[^']+': Error: /, "")); } + modal.classList.remove("prow-busy"); + renderProviders(); +} +$("free-models").addEventListener("click", () => { $("providers-modal").classList.remove("hidden"); renderProviders(); }); +$("prov-close").addEventListener("click", () => $("providers-modal").classList.add("hidden")); +$("providers-modal").addEventListener("click", async (e) => { + if (e.target.id === "providers-modal") { $("providers-modal").classList.add("hidden"); return; } + const t = e.target; + if (t.dataset.url) { api.openUrl(t.dataset.url); return; } + if (t.dataset.remove) { await api.providersRemove(t.dataset.remove); await populateModels(); renderProviders(); return; } + if (t.dataset.connect) { + const input = $("providers-modal").querySelector(`input[data-key="${t.dataset.connect}"]`); + connectProvider(t.dataset.connect, input ? input.value.trim() : undefined); + } +}); +$("providers-modal").addEventListener("keydown", (e) => { + if (e.key === "Enter" && e.target.dataset && e.target.dataset.key) connectProvider(e.target.dataset.key, e.target.value.trim()); +}); $("m-cancel").addEventListener("click", closeModal); $("modal").addEventListener("click", (e) => { if (e.target.id === "modal") closeModal(); }); $("m-add").addEventListener("click", addFromForm); diff --git a/renderer/index.html b/renderer/index.html index 97c9007..68a47a7 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -26,6 +26,7 @@
+
@@ -114,6 +115,21 @@ + + +