Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions server/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<cwd-slug>/*.jsonl`), `parseCodexRollout` (`~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`), `parseGrokTurns`/`parseGrokChatHistory`/`decodeGrokSessionDir` (`~/.grok/sessions/<encodeURIComponent(cwd)>/<id>/`), `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. |
Expand Down
1 change: 1 addition & 0 deletions server/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
128 changes: 128 additions & 0 deletions server/lib/opencodeCatalogCache.js
Original file line number Diff line number Diff line change
@@ -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-<hash of url>.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<string,string|undefined>} [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 };
}
Loading