From 621bcdc44b5b99cb5ee97f1650f0f7d5ae59973c Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Thu, 3 Sep 2026 15:47:32 +0000 Subject: [PATCH] fix: refresh OpenCode's model catalog before listing its models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Harnesses page's "Refresh models" button for OpenCode reported a model list weeks out of date — a model already listed on another machine signed in to the same account never appeared here, and the refresh still reported success. `opencode models` does not go to the network: it prints from `~/.cache/opencode/models.json`, which OpenCode refreshes from a task forked at startup whose failures it swallows (`opencode models --refresh` prints "Models cache refreshed" either way). Its HTTP client connects without Happy Eyeballs, so a host advertising an IPv6 default route it cannot actually reach — a VPN interface installing one with no global v6 address — fails instantly with "Unable to connect" and the catalog freezes at whatever day the fetch last worked. Fetch that catalog from PortOS (Node falls back to IPv4) and write it where OpenCode reads it, just before the probe. This unsticks the vendor's own TUI as well as the page. It refuses to write when `OPENCODE_MODELS_PATH`, a custom `OPENCODE_MODELS_URL`, or `OPENCODE_DISABLE_MODELS_FETCH` means PortOS cannot be sure which file OpenCode reads, when the file is under five minutes old, or when the body did not parse as a catalog — a stale list beats an empty picker. Every refusal is best-effort: the probe runs regardless. Claude-Session: https://claude.ai/code/session_01BKLx7uomKwxNJgnzVwbXiU --- server/lib/README.md | 1 + server/lib/index.js | 1 + server/lib/opencodeCatalogCache.js | 128 ++++++++++++++++++++++++ server/lib/opencodeCatalogCache.test.js | 98 ++++++++++++++++++ server/services/harnesses.js | 12 +++ server/services/harnesses.test.js | 29 ++++++ 6 files changed, 269 insertions(+) create mode 100644 server/lib/opencodeCatalogCache.js create mode 100644 server/lib/opencodeCatalogCache.test.js diff --git a/server/lib/README.md b/server/lib/README.md index 4516759b07..6536a8c8ab 100644 --- a/server/lib/README.md +++ b/server/lib/README.md @@ -177,6 +177,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub | `harnessOutput.js` | Parsers for what a coding-agent HARNESS prints about itself: `parseHarnessVersion(stdout)` (the one semver run in a `--version` banner, `null` when unparseable), `compareHarnessVersions(a, b)` (the null-guarding wrapper around `versionUtils.js#compareSemver` — `null` when either side is unparseable, so a version that did not parse never reads as "out of date"), `parseHarnessModels(harnessId, stdout)` + `HARNESS_MODEL_PARSER_IDS` (OpenCode's `provider/model` lines and Grok's bulleted list are parsed here; Antigravity and Cursor DELEGATE to `antigravity.js#parseAntigravityModelList` / `aiToolkit/internal/cursor.js#parseCursorModelList`, which the provider-card refresh has used for far longer), `MAX_MODELS`, and `parseNpmLatestVersion`. Pure: the service layer runs the child and hands the captured stdout here, so the vendor output shapes are pinned by table-driven tests instead of by running six real binaries in CI. Model ids come back in the exact spelling `--model` takes — namespaces kept where the vendor keeps them. Consumed by `services/providerRuntimeInstaller.js` and `services/harnesses.js`. | | `providerGateways.js` | `PROVIDER_GATEWAYS` — one row per hosted OpenAI-compatible gateway an OpenCode CLI/TUI wrapper can front-end (`orcarouter`, `openrouter`), plus `PROVIDER_GATEWAY_IDS`, `gatewayById`, `isGatewayNamespace(ns)` and `gatewayForProvider(config)` → row or null. Each row's `id` is simultaneously the OpenCode provider namespace, the `gatewayBacked` marker value, and the id of the sibling `api` record that owns the key — so the sibling lookup is `providers[gateway.id]` and an OrcaRouter key can never satisfy an OpenRouter wrapper. Replaces the `orcarouterBacked` boolean + literal `'orcarouter'` that had been hand-copied across ~15 server and client files (namespace resolution, the OpenCode config builder, both zod schemas, the model-fetcher table, the sibling-key attach, the prerequisite check, and the two "not a local runtime" carve-outs in `cliChildEnv.js`/`localProviderRuntime.js`). Reads the legacy per-gateway boolean FOREVER, so stored records are never rewritten. Distinct from a local runtime (`ollamaBacked`, `vllmBacked`, …): remote, always authenticating, and no thinking toggle. Deliberately mirrored in `aiToolkit/internal/gateways.js` (the vendored toolkit may not import out) and `client/src/utils/providers.js` (the browser cannot import server code) — `providerGateways.parity.test.js` fails when the first two drift. Dependency-light: imports nothing. | | `providerTranscriptUsage.js` | Parsers for the session files the coding CLIs write to disk (0 tokens to read) — `parseClaudeTranscript` (`~/.claude/projects//*.jsonl`), `parseCodexRollout` (`~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`), `parseGrokTurns`/`parseGrokChatHistory`/`decodeGrokSessionDir` (`~/.grok/sessions///`), `parseAgyTranscript`/`parseAgyHistory` (`~/.gemini/antigravity-cli/`), `claudeProjectSlug`, `totalTranscriptTokens`. Each de-duplicates a format hazard that otherwise inflates counts badly: Claude repeats one response across several lines sharing a `message.id`, Codex's `total_token_usage` is cumulative and repeated, grok's `turn_completed.usage` has shipped in both per-prompt and cumulative shapes (detected and delta'd, never summed raw) while its `_meta.totalTokens` is context occupancy and never billed. Antigravity writes no token fields at all, so its parser returns chars for the caller to estimate from. Each parser returns per-model buckets (`byModel`) plus the message keys it counted (`countedKeys`), and accepts an `exclude` set — that is what stops two overlapping PortOS runs from both billing the same messages. Tolerant of truncated (mid-write) files; consumed by `services/usageReconciler.js`. | +| `opencodeCatalogCache.js` | Primes the on-disk catalog `opencode models` prints from — `primeOpencodeCatalogCache()` fetches OpenCode's `api.json` with Node's fetch and atomically writes `$XDG_CACHE_HOME/opencode/models.json` (`~/.cache` when unset). OpenCode refreshes that file from a forked task whose failures it swallows (`opencode models --refresh` still prints `Models cache refreshed`) and its HTTP client has no Happy Eyeballs, so a host advertising an unreachable IPv6 default route freezes the catalog indefinitely while other machines on the same account list newer models. Refuses to fetch or write when `OPENCODE_MODELS_PATH` / a custom `OPENCODE_MODELS_URL` / `OPENCODE_DISABLE_MODELS_FETCH` means PortOS cannot be sure which file OpenCode reads, when the file is under five minutes old, or when the body did not parse as a catalog — a stale list beats an empty picker. Never throws; the caller probes either way. | | `opencodeConfig.js` | OpenCode config builder — `buildOpencodeEnvVars(provider, model)` builds dynamic `OPENCODE_CONFIG_CONTENT` declaring model ids under the namespace the provider's marker selects: a local runtime (`ollama` / `mtplx` / `llama` / `vllm` / `sglang`, bare ids) or a hosted gateway from `providerGateways.js` (`vendor/model` ids kept whole). Fixes --model rejection. Also attaches the key for a key-bearing namespace, and pins `small_model` to the run model for a gateway so OpenCode's own side calls (titles, summarization) can't land on its built-in default — a billed model the operator never chose. | | `localProviderRuntime.js` | Which LOCAL daemon a provider talks to, and where — `LOCAL_RUNTIMES` (llama.cpp / Ollama / LM Studio / MTPLX / vLLM: label, binary, canonical base URL read from `opencodeConfig.js` rather than re-typed, manage/docs links, model-download hint), `localBackendForProvider` + `localEndpointPort` + `isLocalInstanceHost` (moved here from `services/localModelHealing.js`, which re-exports them, so the healing path and the readiness checklist classify a provider identically — loopback/bind-all only, so a LAN/Tailscale peer on port 11434 is NOT claimed as a local daemon), `localRuntimeKind(provider)` (the `*Backed` markers first, then that classifier; `orcarouter` excluded as a remote API), `localRuntimeForProvider(provider)` → the row with the endpoint the provider ITSELF configures (`OPENCODE_CONFIG_CONTENT`'s `baseURL`, `ANTHROPIC_BASE_URL`, or `endpoint`), then the `OLLAMA_URL`/`OLLAMA_HOST`/`LM_STUDIO_URL` override the backend managers read, then the canonical default — and `null` when that resolved endpoint fails `isLocalInstanceEndpoint` (an API provider on another machine has no local daemon to check, whatever its name says) — plus `normalizeOpenAiBaseUrl`. Pure; the probing half is `services/providerReadiness.js`. Optional `setupStateDetail` overrides `providerReadiness`'s per-state prose for a runtime whose local setup is not a model cache (vLLM's is a compose project); `standbyWhenStopped` marks an installed runtime such as llama.cpp whose stopped state is intentional standby rather than incomplete setup. | | `managedDaemon.js` | Shared mechanism for the local daemons PortOS runs as optional PM2 processes (`services/llamaServerManager.js` → `portos-llama-server`, `services/mtplxServerManager.js` → `portos-mtplx`, `services/slotstreamServerManager.js` → `portos-slotstream`). Owns their PM2 process names — `LLAMA_APP`, `MTPLX_APP`, `SLOTSTREAM_APP`, and the `isModelServerProcess(name)` predicate over them — so a caller like the CoS health monitor can recognize a model server without importing a manager; the managers re-export those names. `createDaemonWatcher({...})` supplies the common PM2 launch-line re-adoption, endpoint probe, status skeleton, bounded log view, and port-release wait while managers retain daemon-specific parsing and lifecycle policy. `createDaemonLogBuffer({maxLines?})` is the bounded timestamped ring buffer of what PortOS logged around a launch, plus `withPm2Logs(output)` → that buffer followed by anything `pm2 logs` has which it doesn't already hold, deduped and re-capped (PM2's lines are a VIEW, never folded into the buffer — PM2 owns them and re-reads them every status call). `pm2ArgValue(args, flag)` reads one value back out of a PM2 process's recorded argv so a manager can recover a still-online daemon's launch config after a PortOS restart; `null` means the flag was absent, which a relaunch must leave off rather than defaulting. Also the shared **idle reaper**, for a daemon that cannot release its weights any other way: `registerIdleDaemon({name, getIdleMs, stop})` (seeds `lastUsedAt` to NOW, so a hand-started daemon gets a full window), `markDaemonUsed(name)` — call on real traffic, NEVER on a status poll — `daemonLastUsedAt(name)`, `idleWindowMs(minutes)` (minutes → ms; `0` = never, `null` = not configured, kept distinct), `reapIdleDaemons(now?)` → the names stopped, and `startIdleReaper({intervalMs?})` / `stopIdleReaper()` (ONE interval for all registrants, `unref`'d, idempotent). `mtplxServerManager` and `slotstreamServerManager` register: llama.cpp releases its checkpoint in place via `--sleep-idle-seconds` and must NOT be stopped for it. Deliberately mechanism only — what a launch line means and when a daemon may start is exactly what differs between the two. | diff --git a/server/lib/index.js b/server/lib/index.js index 6e9a43f3f2..f44be2b575 100644 --- a/server/lib/index.js +++ b/server/lib/index.js @@ -184,6 +184,7 @@ export * from './quotaBurnValidation.js'; export * from './quotaReset.js'; export * from './quotaWindows.js'; export * from './recurrenceValidation.js'; +export * from './opencodeCatalogCache.js'; export * from './opencodeConfig.js'; export * from './localProviderRuntime.js'; export * from './mtplxModels.js'; diff --git a/server/lib/opencodeCatalogCache.js b/server/lib/opencodeCatalogCache.js new file mode 100644 index 0000000000..9fb148aa8e --- /dev/null +++ b/server/lib/opencodeCatalogCache.js @@ -0,0 +1,128 @@ +/** + * Primes OpenCode's on-disk model catalog, so a "Refresh models" click reports + * what OpenCode actually offers today rather than what it offered the last time + * its own background fetch happened to succeed. + * + * **The failure this exists for.** `opencode models` does NOT go to the network: + * it reads `$XDG_CACHE_HOME/opencode/models.json` (falling back to + * `~/.cache/opencode/models.json`) and prints the models it can resolve out of + * that file. OpenCode keeps that file current from a task forked at startup + * that re-fetches when the file is older than five minutes — but the fetch is + * wrapped in `ignore`, so when it fails, nothing surfaces: `opencode models + * --refresh` still prints `Models cache refreshed` and still lists the stale + * catalog. On a host where that fetch keeps failing, the file freezes at + * whatever day it last worked and every consumer — OpenCode's own TUI, and the + * PortOS Harnesses page reading through it — silently shows a weeks-old model + * list while a second machine on the same account shows the current one. + * + * That fetch fails for an ordinary reason: OpenCode's HTTP client connects + * without Happy Eyeballs, so a host advertising an IPv6 default route it cannot + * actually reach (a VPN interface installing one, with no global v6 address) + * fails instantly with "Unable to connect" while `curl` and Node — both of + * which fall back to IPv4 — fetch the same URL fine. PortOS is Node, so + * fetching the catalog HERE and handing OpenCode the file it wanted is enough + * to unstick it, for the Harnesses page and for the vendor's own TUI alike. + * + * **This writes another tool's cache file, so it is narrow on purpose.** It + * refuses whenever it cannot be certain which file OpenCode would read + * (`OPENCODE_MODELS_PATH` pins a different one; a custom `OPENCODE_MODELS_URL` + * moves the cache to a name derived from a hash of that URL) or whenever the + * user has opted out of catalog fetching (`OPENCODE_DISABLE_MODELS_FETCH`), and + * it only ever replaces the file with a payload that parsed as a real catalog. + * A refusal is not an error: the caller probes anyway and gets today's stale + * answer, exactly as before this module existed. + * + * No AI provider call happens here — this is a vendor catalog endpoint, the + * same class of read as `npm view`, and it runs only from an explicit click. + */ + +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { stat } from 'node:fs/promises'; + +import { atomicWrite } from './fileUtils.js'; + +/** Where OpenCode fetches its catalog from, when `OPENCODE_MODELS_URL` says nothing else. */ +const OPENCODE_CATALOG_URL = 'https://models.opencode.ai'; + +/** + * How old the cache may be before it is worth re-fetching — OpenCode's own + * staleness window. Matching it keeps a double-click from pulling several MB + * twice while still meaning "fresh" for anyone who clicked the button. + */ +const CATALOG_MAX_AGE_MS = 5 * 60 * 1000; + +/** A multi-megabyte JSON body over a slow link must not hold the click open. */ +const CATALOG_FETCH_TIMEOUT_MS = 20_000; + +/** + * The catalog file `opencode models` will read, or `null` when PortOS cannot be + * sure which file that is (see the module note). `null` is a REFUSAL, not a + * failure — the caller carries on and probes the harness regardless. + */ +const catalogCachePath = (env) => { + // The user pinned an explicit catalog file; it is theirs, not a cache. + if (env.OPENCODE_MODELS_PATH) return null; + // Catalog fetching is switched off — priming it would be exactly the network + // read that setting exists to prevent. + if (env.OPENCODE_DISABLE_MODELS_FETCH) return null; + // A custom endpoint moves the cache to `models-.json`, and the + // hash is OpenCode's internal one. Guessing the filename would leave a file + // it never reads. + if (env.OPENCODE_MODELS_URL && env.OPENCODE_MODELS_URL !== OPENCODE_CATALOG_URL) return null; + const home = homedir(); + const cacheRoot = env.XDG_CACHE_HOME || (home ? join(home, '.cache') : null); + return cacheRoot ? join(cacheRoot, 'opencode', 'models.json') : null; +}; + +/** + * Is this text a catalog, rather than an error page or a truncated body? + * + * The point is the REFUSAL: overwriting a working catalog with a gateway error + * page would take the user from a stale model list to an empty one. A catalog is + * a JSON object keyed by provider id, each entry carrying a `models` object. + * Async so a malformed body answers `false` instead of throwing. + */ +const isCatalogPayload = async (text) => { + const parsed = await Promise.resolve().then(() => JSON.parse(text)).catch(() => null); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false; + return Object.values(parsed).some((entry) => entry && typeof entry === 'object' && typeof entry.models === 'object'); +}; + +/** + * Fetch OpenCode's model catalog and write it where `opencode models` reads it, + * unless it is already fresh or this host is one of the refusal cases. + * + * Never throws: every outcome is a reason string, because the caller's job (probe + * the harness) is still worth doing when priming was skipped or failed. + * + * @param {object} [deps] + * @param {Record} [deps.env] + * @param {typeof fetch} [deps.fetchImpl] + * @returns {Promise<{primed: boolean, reason: string, path?: string}>} + */ +export async function primeOpencodeCatalogCache({ env = process.env, fetchImpl = fetch } = {}) { + const path = catalogCachePath(env); + if (!path) return { primed: false, reason: 'this install reads its catalog from somewhere PortOS should not write' }; + + // A missing file is not an error — OpenCode falls back to the catalog built + // into the binary — but it IS a reason to fetch, so absence and staleness + // take the same branch. + const mtimeMs = await stat(path).then((s) => s.mtimeMs, () => null); + if (mtimeMs !== null && Date.now() - mtimeMs < CATALOG_MAX_AGE_MS) { + return { primed: false, reason: 'catalog is already fresh', path }; + } + + const url = `${OPENCODE_CATALOG_URL}/api.json`; + const text = await fetchImpl(url, { signal: AbortSignal.timeout(CATALOG_FETCH_TIMEOUT_MS) }) + .then((res) => (res.ok ? res.text() : null)) + .catch(() => null); + if (text === null) return { primed: false, reason: `could not reach ${url}`, path }; + // Parse failures land here too — a body that is not JSON is not a catalog. + if (!(await isCatalogPayload(text))) return { primed: false, reason: `${url} did not return a model catalog`, path }; + + const written = await atomicWrite(path, text).then(() => true, () => false); + return written + ? { primed: true, reason: `wrote ${Buffer.byteLength(text)} bytes`, path } + : { primed: false, reason: `could not write ${path}`, path }; +} diff --git a/server/lib/opencodeCatalogCache.test.js b/server/lib/opencodeCatalogCache.test.js new file mode 100644 index 0000000000..a11c63bcdd --- /dev/null +++ b/server/lib/opencodeCatalogCache.test.js @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, readFile, utimes } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { primeOpencodeCatalogCache } from './opencodeCatalogCache.js'; + +const CATALOG = JSON.stringify({ + opencode: { id: 'opencode', name: 'OpenCode Zen', models: { 'muse-spark-1.3-contributor-free': { id: 'muse-spark-1.3-contributor-free' } } }, +}); + +const respondWith = (body, ok = true) => async () => ({ ok, text: async () => body }); + +let cacheHome; +let env; +let cachePath; + +/** Seed a catalog on disk and age it past the staleness window. */ +const seedStaleCatalog = async (body = CATALOG) => { + await primeOpencodeCatalogCache({ env, fetchImpl: respondWith(body) }); + const longAgo = new Date(Date.now() - 60 * 60 * 1000); + await utimes(cachePath, longAgo, longAgo); +}; + +beforeEach(async () => { + cacheHome = await mkdtemp(join(tmpdir(), 'opencode-catalog-')); + env = { XDG_CACHE_HOME: cacheHome }; + cachePath = join(cacheHome, 'opencode', 'models.json'); +}); + +describe('primeOpencodeCatalogCache', () => { + // The path is the whole point: `opencode models` prints from this exact file, + // so a catalog written anywhere else is a catalog the harness never reads. + it('writes the fetched catalog where the harness will read it', async () => { + const fetchImpl = vi.fn(respondWith(CATALOG)); + + const result = await primeOpencodeCatalogCache({ env, fetchImpl }); + + expect(result.primed).toBe(true); + expect(fetchImpl.mock.calls[0][0]).toBe('https://models.opencode.ai/api.json'); + await expect(readFile(cachePath, 'utf8')).resolves.toBe(CATALOG); + }); + + // OpenCode's own staleness window. Without it, a double-click pulls several + // megabytes twice for an answer that cannot have changed. + it('leaves a cache younger than the max age alone', async () => { + await primeOpencodeCatalogCache({ env, fetchImpl: respondWith(CATALOG) }); + const fetchImpl = vi.fn(respondWith(CATALOG)); + + const result = await primeOpencodeCatalogCache({ env, fetchImpl }); + + expect(result.primed).toBe(false); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('re-fetches once the cache has aged past the max age', async () => { + await seedStaleCatalog('{"old":{"models":{}}}'); + + const result = await primeOpencodeCatalogCache({ env, fetchImpl: respondWith(CATALOG) }); + + expect(result.primed).toBe(true); + await expect(readFile(cachePath, 'utf8')).resolves.toBe(CATALOG); + }); + + // The failure that matters: a stale-but-real catalog is a far better answer + // than an empty picker, so nothing but a parsed catalog may replace it. + it.each([ + ['an unreachable endpoint', async () => { throw new Error('Unable to connect'); }], + ['a non-2xx response', respondWith('nope', false)], + ['a gateway error page', respondWith('502 Bad Gateway')], + ['a truncated body', respondWith('{"opencode":{"models":')], + ['valid JSON that is not a catalog', respondWith('{}')], + ])('keeps the existing catalog on %s', async (_label, fetchImpl) => { + await seedStaleCatalog(); + + const result = await primeOpencodeCatalogCache({ env, fetchImpl }); + + expect(result.primed).toBe(false); + await expect(readFile(cachePath, 'utf8')).resolves.toBe(CATALOG); + }); + + // Each of these means PortOS cannot be certain which file OpenCode reads (or + // that it should read one at all) — writing anyway would leave a file OpenCode + // ignores, or override an explicit opt-out. + it.each([ + ['OPENCODE_MODELS_PATH pins another file', { OPENCODE_MODELS_PATH: '/somewhere/models.json' }], + ['OPENCODE_DISABLE_MODELS_FETCH opts out', { OPENCODE_DISABLE_MODELS_FETCH: '1' }], + ['OPENCODE_MODELS_URL names a mirror', { OPENCODE_MODELS_URL: 'https://mirror.example.com' }], + ])('neither fetches nor writes when %s', async (_label, overrides) => { + const fetchImpl = vi.fn(respondWith(CATALOG)); + + const result = await primeOpencodeCatalogCache({ env: { ...env, ...overrides }, fetchImpl }); + + expect(result.primed).toBe(false); + expect(fetchImpl).not.toHaveBeenCalled(); + await expect(readFile(cachePath, 'utf8')).rejects.toThrow(); + }); +}); diff --git a/server/services/harnesses.js b/server/services/harnesses.js index 00359c4e28..36cc97c1c3 100644 --- a/server/services/harnesses.js +++ b/server/services/harnesses.js @@ -33,6 +33,7 @@ import { prepareCliSpawn } from '../lib/bufferedSpawn.js'; import { commandOutput } from '../lib/commandExists.js'; import { compareHarnessVersions, parseHarnessModels, parseNpmLatestVersion } from '../lib/harnessOutput.js'; import { findCommandOnPath } from '../lib/processEnv.js'; +import { primeOpencodeCatalogCache } from '../lib/opencodeCatalogCache.js'; import { getOpencodeLocalProviderNamespace, isConfiguredDefaultModel } from '../lib/providerModels.js'; import { providerRuntimeKey } from '../lib/providerPrerequisites.js'; import { createStaleWhileRevalidate } from '../lib/staleWhileRevalidate.js'; @@ -269,6 +270,17 @@ export async function refreshHarnessModels(id, { run = commandOutput, ...probeDe return { ok: false, reason: `${runtime.label} is not installed on this host.`, models: [], updated: [] }; } + // `opencode models` prints from an on-disk catalog OpenCode refreshes on its + // own — silently, and not at all on a host where its fetch fails (see + // `lib/opencodeCatalogCache.js`). Without this the button faithfully re-reads + // a catalog frozen weeks ago and reports success, while the same account on + // another machine lists models this one has never heard of. Best-effort by + // design: a refusal or a failed fetch leaves the probe below unchanged. + if (runtime.id === 'opencode') { + const catalog = await primeOpencodeCatalogCache(); + console.log(`📚 ${runtime.label} catalog: ${catalog.primed ? 'refreshed' : 'left alone'} — ${catalog.reason}`); + } + // Resolve and `prepareCliSpawn` exactly as the version probe does. An // npm-installed harness is a `.cmd` shim on Windows, which `execFile` under // `shell: false` refuses outright — the probe would answer nothing and the diff --git a/server/services/harnesses.test.js b/server/services/harnesses.test.js index 16963614aa..78d73d8702 100644 --- a/server/services/harnesses.test.js +++ b/server/services/harnesses.test.js @@ -4,6 +4,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const npmGlobalBin = vi.hoisted(() => ({ adoptNpmGlobalBinDir: vi.fn(async () => null) })); vi.mock('../lib/npmGlobalBin.js', () => npmGlobalBin); +// `opencode models` reads a cache file this module primes over the network. +// Unmocked, the suite would fetch a multi-megabyte catalog and overwrite the +// developer's real `~/.cache/opencode/models.json`. +const opencodeCatalog = vi.hoisted(() => ({ + primeOpencodeCatalogCache: vi.fn(async () => ({ primed: true, reason: 'stubbed' })), +})); +vi.mock('../lib/opencodeCatalogCache.js', () => opencodeCatalog); + const providerService = vi.hoisted(() => ({ // `listProviders()` resolves the records as an ARRAY — the envelope // (`{ activeProvider, providers: [...] }`) is `getAllProviders`'s shape, and @@ -46,6 +54,7 @@ beforeEach(() => { __resetLatestVersionCache(); providerService.listProviders.mockResolvedValue(Object.values(providers)); providerService.updateProvider.mockClear(); + opencodeCatalog.primeOpencodeCatalogCache.mockClear(); }); // `usesHarnessCatalog` keys on the ABSENCE of a backend marker, so the class it @@ -197,6 +206,26 @@ describe('refreshHarnessModels', () => { expect(run).toHaveBeenCalledWith('/example/opencode', ['models'], expect.anything()); }); + // `opencode models` prints from an on-disk catalog OpenCode refreshes on its + // own, silently and — on a host whose IPv6 default route goes nowhere — not at + // all. Without this step the button re-reads a catalog frozen weeks ago and + // still reports success, so a model another machine already lists never + // appears here. + it('primes the OpenCode catalog before probing, and only for OpenCode', async () => { + const run = vi.fn(async (command, args) => (args[0] === 'models' ? OPENCODE_MODELS : '1.18.27')); + + await refreshHarnessModels('opencode', { run, ...found }); + expect(opencodeCatalog.primeOpencodeCatalogCache).toHaveBeenCalledTimes(1); + + // Every other harness enumerates its models live; there is no file to prime, + // and reaching for OpenCode's would be writing a cache nothing here reads. + await refreshHarnessModels('grok', { + run: async (command, args) => (args[0] === 'models' ? 'Available models:\n - grok-4.5\n' : 'grok 1.0.13'), + ...found, + }); + expect(opencodeCatalog.primeOpencodeCatalogCache).toHaveBeenCalledTimes(1); + }); + it('writes the harness catalog only to providers that draw from it', async () => { const run = vi.fn(async (command, args) => (args[0] === 'models' ? OPENCODE_MODELS : '1.18.27'));