UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

382 lines • 16.9 kB
/** * OpenCode client configurator. * * Moved verbatim out of `proxy.ts` so that adding a CLI means adding a file * here rather than editing a 5,000-line command module in seven places. * * Two defects made every config this writer produced unusable, and both are * fixed here. They are recorded because each was invisible to the tests that * were supposed to cover this file. * * 1. The snapshot lived in `opencode.json` itself, under two `__proxy_*` keys * at the top level. OpenCode validates its config against a closed schema * and rejects unknown top-level keys outright: * * Error: Configuration is invalid at ~/.config/opencode/opencode.json * ↳ Unrecognized keys: "__proxy_original_neurolink", "__proxy_written_neurolink" * * Every `opencode` invocation failed at startup — not just proxied ones — * so auto-configuration bricked the CLI it was meant to onboard. The * snapshot now lives beside Codex's, in `~/.neurolink/`, which is what * `codex.ts` has always done. Claude Code and Qwen embed a snapshot the * same way and survive it only because their schemas ignore unknown keys; * that is tolerance, not permission, and new writers should not rely on it. * * 2. `models` was written as `{}`. OpenCode resolves `--model provider/id` * against that map and never calls `/v1/models`, so an empty map meant * every id was unknown: * * ProviderModelNotFoundError: providerID "neurolink", suggestions: [] * * Fixing only the keys exposed this one immediately underneath. * * Configs written by the previous version are repaired in place: both apply() * and restore() adopt a legacy in-file snapshot before deleting the keys, so * an existing broken config heals on the next `proxy start` without losing the * user's original provider block. */ import { createHash } from "crypto"; import { homedir } from "os"; import { join } from "path"; import { logger } from "../../utils/logger.js"; import { DEFAULT_PROXY_MODEL_IDS } from "../../constants/proxyModels.js"; import { cloneForSnapshot, isProxyOwnedValue, isUsableSnapshot, shouldCaptureSnapshot, writeFileAtomic, } from "./snapshot.js"; function getOpenCodeConfigDir() { // OpenCode resolves this with the unmodified `xdg-basedir` package — // `XDG_CONFIG_HOME || ~/.config` — on every platform, macOS included. There // is deliberately no darwin branch here: `~/Library/Application Support/ // opencode` is not a path OpenCode reads. (The similar-looking literal in // OpenCode's binary is `systemManagedConfigDir()`, an MDM policy directory // at the filesystem root with no $HOME prefix.) return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode"); } function getOpenCodeConfigPath() { return join(getOpenCodeConfigDir(), "opencode.json"); } /** * Where the snapshot of the user's pre-existing `provider.neurolink` lives. * * Outside `opencode.json`, for the reason in the file header. Persisting it on * disk (rather than in process memory) means restoration still works when the * proxy crashes or shutdown runs in a different process — the property the * in-file version was reaching for. */ function getOpenCodeSnapshotPath() { // Scoped to the config directory, not just HOME. `getOpenCodeConfigPath()` // resolves through XDG_CONFIG_HOME, so two XDG roots under one HOME are two // independent OpenCode installs — and a single shared snapshot file made the // second apply() overwrite the first's saved original. Clearing the first // root then restored the second root's block onto it, or deleted a real // provider entry outright. Measured before this fix: root A came back // holding root B's block. const slug = createHash("sha256") .update(getOpenCodeConfigDir()) .digest("hex") .slice(0, 12); return join(homedir(), ".neurolink", `opencode-proxy-snapshot-${slug}.json`); } /** * The unscoped path used before snapshots were scoped per config directory. * * Read-only, and only as a fallback: a real user has exactly one config dir, so * adopting their existing snapshot is correct. Writes always go to the scoped * path, so the ambiguity cannot be reintroduced. */ function getLegacyOpenCodeSnapshotPath() { return join(homedir(), ".neurolink", "opencode-proxy-snapshot.json"); } /** * Top-level keys written by the pre-fix version of this writer. Present only * in configs it already corrupted; removed on sight. */ const LEGACY_ORIGINAL_KEY = "__proxy_original_neurolink"; const LEGACY_WRITTEN_KEY = "__proxy_written_neurolink"; /** * Remove the legacy in-file snapshot keys. * * @returns the legacy snapshot if one was found, so the caller can migrate it * to the external file rather than discard the user's original provider block. */ function takeLegacySnapshot(config) { const hasOriginal = LEGACY_ORIGINAL_KEY in config; const removed = hasOriginal || LEGACY_WRITTEN_KEY in config; if (!removed) { return { removed: false, snapshot: null }; } // Both keys go, always — leaving either behind keeps OpenCode unstartable. // But only a record that actually carries `original` may be restored from. // A file with just the written key proves the proxy wrote something; it does // NOT prove the user had no provider block, and treating it as `original: // null` made restore delete a real one. const snapshot = hasOriginal ? { original: config[LEGACY_ORIGINAL_KEY], written: config[LEGACY_WRITTEN_KEY], } : null; delete config[LEGACY_ORIGINAL_KEY]; delete config[LEGACY_WRITTEN_KEY]; logger.debug("[proxy] OpenCode: migrated in-file snapshot keys out of opencode.json"); return { removed, snapshot }; } async function readSnapshotFile(filePath) { const fs = await import("fs"); let parsed; try { parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); } catch { return null; } // A snapshot missing `original` is malformed, not a record of "the user had // no provider block". Restore distinguishes those by deleting in the second // case, so returning `{}` here would destroy a real provider.neurolink. if (!isUsableSnapshot(parsed, "original")) { logger.debug("[proxy] OpenCode: ignoring a malformed snapshot rather than treating it as empty"); return null; } return parsed; } /** * Resolve the snapshot for the ACTIVE config directory, most specific first. * * The unscoped file is shared by every XDG root on the machine, so it must * never outrank a record that belongs to this config in particular. Preferring * it let one root adopt another root's `original` and restore the wrong * provider block. */ async function resolveOpenCodeSnapshot(inFileLegacy) { // An in-file legacy record outranks everything, because its presence means // more than "here is a snapshot": the config still carries `__proxy_*` keys, // so the previous migration did not complete. That record is the only one // that predates the proxy, and on a migration run it is guaranteed to // disagree with a scoped snapshot — the old writer's block has `models: {}` // while the new writer's `written` carries the full map, so valuesMatch() is // always false and shouldCaptureSnapshot() re-captures. Re-capturing there // adopts the proxy's OWN block as the user's "original" and discards the // real one, silently and permanently. // // Reachable whenever apply() wrote the scoped snapshot and then failed to // write opencode.json: applyAllClients() catches per-client errors, so the // retry runs against a config still holding the legacy keys. if (inFileLegacy !== null) { return { snapshot: inFileLegacy, source: "in-file" }; } const scoped = await readSnapshotFile(getOpenCodeSnapshotPath()); if (scoped !== null) { return { snapshot: scoped, source: "scoped" }; } const unscoped = await readSnapshotFile(getLegacyOpenCodeSnapshotPath()); return unscoped === null ? { snapshot: null, source: null } : { snapshot: unscoped, source: "unscoped" }; } async function writeOpenCodeSnapshot(snap) { const fs = await import("fs"); fs.mkdirSync(join(homedir(), ".neurolink"), { recursive: true }); // 0o600: the snapshot holds whatever the user's own provider block held, // which for a custom endpoint includes its API key. await writeFileAtomic(getOpenCodeSnapshotPath(), JSON.stringify(snap, null, 2), 0o600); } /** * The models map written into `provider.neurolink`. * * OpenCode needs every selectable id present here; see DEFAULT_PROXY_MODEL_IDS * for why an empty map is fatal rather than merely unhelpful. */ function buildModelsMap() { const models = {}; for (const id of DEFAULT_PROXY_MODEL_IDS) { models[id] = { name: id }; } return models; } export async function setOpenCodeProxySettings(baseUrl, proxyKey) { const fs = await import("fs"); const configDir = getOpenCodeConfigDir(); try { fs.accessSync(configDir); } catch { // OpenCode not installed — config directory does not exist. Report the // skip so the caller does not print a success message for work that did // not happen. return false; } let config; try { config = JSON.parse(fs.readFileSync(getOpenCodeConfigPath(), "utf8")); } catch { // file missing/invalid — create fresh config object config = { provider: {} }; } // Repair a config written by the pre-fix version before doing anything else. // The legacy snapshot is the only record of the user's original block, so it // is adopted rather than dropped when no external snapshot exists yet. const { snapshot: legacySnapshot } = takeLegacySnapshot(config); const provider = (config.provider ?? {}); // Persist a snapshot of the user's pre-existing provider.neurolink. Repeat // apply() calls must not overwrite it with the proxy's own block — but a // block the user wrote while the proxy was gone must replace it. See // shouldCaptureSnapshot. const currentBlock = "neurolink" in provider ? provider.neurolink : undefined; let { snapshot } = await resolveOpenCodeSnapshot(legacySnapshot); if (shouldCaptureSnapshot({ hasSnapshot: snapshot !== null, written: snapshot?.written, current: currentBlock, })) { snapshot = { original: currentBlock === undefined ? null : cloneForSnapshot(currentBlock), }; } const block = { id: "neurolink", name: "NeuroLink Proxy", npm: "@ai-sdk/openai-compatible", env: [], models: buildModelsMap(), options: { baseURL: baseUrl, apiKey: proxyKey || "neurolink-proxy", }, }; provider.neurolink = block; config.provider = provider; await writeOpenCodeSnapshot({ original: snapshot?.original ?? null, written: cloneForSnapshot(block), }); await writeFileAtomic(getOpenCodeConfigPath(), JSON.stringify(config, null, 2)); return true; } export async function clearOpenCodeProxySettings(expectedBaseUrl) { const fs = await import("fs"); let config; try { config = JSON.parse(fs.readFileSync(getOpenCodeConfigPath(), "utf8")); } catch { return false; } // Always strip legacy keys, even on a path that returns false below: leaving // them behind keeps OpenCode unusable, which is the whole defect. const { removed: legacyStripped, snapshot: legacySnapshot } = takeLegacySnapshot(config); const flushLegacy = async () => { if (legacyStripped) { config.provider = config.provider ?? {}; await writeFileAtomic(getOpenCodeConfigPath(), JSON.stringify(config, null, 2)); } }; const provider = config.provider; if (!provider || !("neurolink" in provider)) { await flushLegacy(); return false; } // Check if our proxy URL matches before removing const existing = provider.neurolink; if (expectedBaseUrl && existing) { const options = existing.options; if (options && typeof options.baseURL === "string") { if (options.baseURL !== expectedBaseUrl) { // User configured a different URL; do not clobber await flushLegacy(); return false; } } } const hadNeurolink = "neurolink" in provider; // Restore from the snapshot persisted at first set(), regardless of process // identity. Only delete provider.neurolink when the snapshot says the user // explicitly had no entry before — never on a missing snapshot, since that // would mean the snapshot was lost and we cannot prove the entry is ours. const { snapshot, source: snapshotSource } = await resolveOpenCodeSnapshot(legacySnapshot); if (snapshot !== null) { // Only restore what we can prove is ours. The base-URL check above lets // through a block still pointing at the proxy that the user has edited // beside the URL; reverting that discards a deliberate change. if (isProxyOwnedValue({ written: snapshot.written, current: existing, })) { if (snapshot.original === null || snapshot.original === undefined) { // User had no provider.neurolink before the proxy started — safe to remove. delete provider.neurolink; } else { provider.neurolink = snapshot.original; } } else { logger.debug("[proxy] OpenCode clear: provider.neurolink was edited after the proxy wrote it, leaving it intact"); } // Deletion is deferred until after the config write below. Removing the // recovery data first means a failed write leaves opencode.json still // pointing at the proxy with nothing left to restore from — the one // ordering that turns a recoverable error into permanent loss. } else { // No snapshot present — refuse to delete to avoid destroying a config // the proxy may not own (e.g. a user wrote their own `neurolink` block // before the snapshot existed, or this is being cleared from a process // that never ran set()). logger.debug("[proxy] OpenCode clear: no original-provider snapshot found, leaving provider.neurolink intact"); await flushLegacy(); return false; } config.provider = provider; await writeFileAtomic(getOpenCodeConfigPath(), JSON.stringify(config, null, 2)); // Only now, and only the store we actually consumed. The unscoped file is // shared by every XDG root on this machine: deleting it because *this* root // restored from its own scoped snapshot would take away another root's only // record of its original provider block. try { if (snapshotSource === "scoped") { fs.rmSync(getOpenCodeSnapshotPath(), { force: true }); } else if (snapshotSource === "unscoped") { fs.rmSync(getLegacyOpenCodeSnapshotPath(), { force: true }); } // "in-file" needs no deletion: takeLegacySnapshot already removed the keys // and the config write above persisted their absence. } catch { // A snapshot we cannot delete is harmless: the next apply() overwrites it. } return hadNeurolink; } /** * Test-only export (CLAUDE.md rule 15 determinism exception). The OpenCode * client writers resolve paths from the environment and are only reachable * from `proxy start` / `proxy setup`, neither of which can be driven against a * throwaway HOME without starting a real server. Consumed by * test/continuous-test-suite-proxy.ts. */ export const __openCodeTestHooks = { getOpenCodeConfigDir, getOpenCodeConfigPath, getOpenCodeSnapshotPath, setOpenCodeProxySettings, clearOpenCodeProxySettings, }; export const openCodeConfigurator = { id: "opencode", displayName: "OpenCode", detect: async () => { const fs = await import("fs"); try { fs.accessSync(getOpenCodeConfigDir()); return true; } catch { return false; } }, // OpenCode speaks OpenAI Chat Completions, so it points at the /v1 door // rather than the proxy root. The suffix belongs to the client, not the // caller — every call site used to have to remember it. apply: (proxyBaseUrl) => setOpenCodeProxySettings(`${proxyBaseUrl}/v1`), restore: (proxyBaseUrl) => clearOpenCodeProxySettings(`${proxyBaseUrl}/v1`), }; //# sourceMappingURL=openCode.js.map