diff --git a/devlog/_plan/260731_client_config_export/000_plan.md b/devlog/_plan/260731_client_config_export/000_plan.md new file mode 100644 index 000000000..f6ee73c9f --- /dev/null +++ b/devlog/_plan/260731_client_config_export/000_plan.md @@ -0,0 +1,99 @@ +# 000 — Client config export for Pi and OpenCode + +Unit: `devlog/_plan/260731_client_config_export/` +Opened: 2026-07-31 · Work class: C3 · Branch target: `dev` + +## Objective + +Let a user or an agent take the opencodex model catalog out of the proxy as a +ready-to-use custom-provider config for Pi and OpenCode, through four surfaces +that all serve the same bytes: CLI text, CLI `--json`, management API, and a GUI +panel with copy + JSON download. + +## Why this unit exists + +The proxy already speaks the wire every client understands. What it does not do is +hand over the *metadata* — which models exist, what they are called, how large +their context is — in the dialect each client's config file expects. Today a user +wanting opencodex in Pi writes that JSON by hand, and gets it wrong, because model +ids are namespaced and context windows are not guessable. + +`ocx opencode` solves half of this already, in memory, at launch, for one client +(`002`). This unit turns that private capability into an artifact and adds Pi. + +## Constraints + +| Constraint | Source | +|-----------|--------| +| Never serialize a live key; emit an env reference | AGENTS.md security boundary; `001` §3 | +| Never write or rewrite a user's client config file | existing `ocx opencode` design; `002` §2 | +| Never guess model metadata; omit what we do not know | existing `limit`-drop rule; `002` §4 | +| One payload, four presentations | `003` §2 | +| OpenCode V1 schema only | `001` §2 | +| Bun-native TypeScript, `dev` branch | AGENTS.md | + +## Scope boundary + +IN +- Client-neutral export core with OpenCode + Pi serializers. +- `ocx export --client [--json] [--out]`. +- `GET /api/client-config?client=`. +- GUI panel: client switch, JSON preview, copy, download. +- Tests at each phase; docs-site update when the CLI surface lands. + +OUT +- OpenCode V2 schema migration (recorded in `001`, not built). +- Automatic merging into an existing user config file. +- Any client beyond Pi and OpenCode. Claude Desktop already has its own path. +- Changes to routing, adapters, or the catalog itself. +- Pricing metadata. We do not have it; `cost` is omitted rather than faked. + +## Work-phase map + +Dependency-ordered. Each phase is one full PABCD cycle and closes with something +independently verifiable. + +| Phase | Doc | Delivers | Verified by | +|-------|-----|----------|-------------| +| 1 | `010_export_core.md` | `src/clients/config-export.ts`; OpenCode builder relocated, Pi serializer added | unit tests + golden diff against pre-refactor output | +| 2 | `020_cli_surface.md` | `ocx export` with `--json` / `--out` | CLI tests; stdout parses clean under `--json` | +| 3 | `030_management_api.md` | `GET /api/client-config` | route test; payload identical to CLI `--json` | +| 4 | `040_gui_panel.md` | GUI panel with copy + download | GUI test + rendered check | + +The order is the build order, not a schedule: `020`, `030`, `040` each import the +core, and `040` renders what `030` returns. Nothing later can be built first. + +## Research documents + +| Doc | Contents | +|-----|----------| +| `001_client_config_survey.md` | Pi + OpenCode V1/V2 schemas, injection points, absence of a standard, V1 decision | +| `002_existing_surface_inventory.md` | What `ocx opencode` already does, the corrected gap, catalog metadata availability | +| `003_export_ux_design.md` | Design Read, dials, Lazy-User Gate, five UX states, download rules, placement | + +## Open assumptions + +1. **Pi schema is unverified against a real install.** Taken from published docs + only (`001` §2). The cycle that ships Pi must diff against a real + `~/.pi/agent/models.json` or ship the Pi client marked experimental. +2. **The API page layout rework may or may not have landed** when `040` runs. + `003` §6 gives both placements; the panel is layout-independent. +3. **32k output budget** is a schema stand-in inherited from `ocx opencode`, not a + per-model truth. If an authoritative max-output field ever reaches + `CatalogModel`, both serializers should switch to it. + +## Acceptance + +The unit is DONE when all four phases have closed with their evidence, `bun run +typecheck` and `bun run test` are green, `bun run privacy:scan` is green, and a +config exported from each of the four surfaces is byte-identical for the same +client and catalog state. + +Terminal outcomes other than DONE: `BLOCKED` if Pi's real schema contradicts the +survey badly enough to need a new design; `NEEDS_HUMAN` if a decision outside this +plan's boundary appears. + +## Status + +Roadmap written 2026-07-31 during the docs-only cycle. No implementation has +started; no production file has been modified by this unit. diff --git a/devlog/_plan/260731_client_config_export/001_client_config_survey.md b/devlog/_plan/260731_client_config_export/001_client_config_survey.md new file mode 100644 index 000000000..6f9b47da0 --- /dev/null +++ b/devlog/_plan/260731_client_config_export/001_client_config_survey.md @@ -0,0 +1,155 @@ +# 001 — External client config survey + +Research only. No diffs here; implementation designs live in the decade docs. + +Scope of the survey: where a user (or an agent) drops a custom OpenAI-compatible +provider so that Pi and OpenCode can call the opencodex proxy, what JSON those +files require, and whether any cross-vendor standard governs the shape. + +## 1. Is there a standard? + +No. There is no formal specification body governing agent-client provider +configuration. What exists instead is a stack of de-facto layers: + +| Layer | What it standardizes | Who honors it | +|-------|----------------------|---------------| +| OpenAI `/v1` wire (`/chat/completions`, `/responses`, `/models`) | request/response bytes | effectively everyone | +| Anthropic `/v1/messages` wire | request/response bytes | Claude clients, our Messages surface | +| `@ai-sdk/openai-compatible` (Vercel AI SDK) | adapter package name + option names | OpenCode, several TS agents | +| models.dev | model metadata registry (context, cost, modality) | OpenCode, Pi metadata seeds | +| per-client JSON Schema (`https://opencode.ai/config.json`) | one client's config file only | that client | + +The consequence for this unit: the transport half is already solved — the proxy +speaks `/v1` and clients speak `/v1`. Everything left is **metadata translation** +into per-client dialects, and each dialect is a moving target owned by one vendor. + +Critically, **models.dev does not populate custom providers.** A user-defined +provider block gets no inherited context window, no pricing, no modality flags. +Whatever we do not emit ourselves, the client simply does not know. + +## 2. Where custom providers are injected + +### OpenCode + +Three layers, highest priority first: + +| Layer | Location | Notes | +|-------|----------|-------| +| Inline runtime | `OPENCODE_CONFIG_CONTENT` env var | outranks every file; what `ocx opencode` uses today | +| Project | `/opencode.json` or `.jsonc` | committed per repo | +| Global | `~/.config/opencode/opencode.json`, XDG-aware via `XDG_CONFIG_HOME` | the file most users hand-edit | + +JSONC is accepted for the file layers (comments must survive a round trip, which +is why we never rewrite a user's file — see `002`). + +V1 schema (current, what we generate): + +```json +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "opencodex": { + "npm": "@ai-sdk/openai-compatible", + "name": "opencodex", + "options": { "baseURL": "http://127.0.0.1:10100/v1", "apiKey": "{env:OPENCODEX_OPENCODE_API_KEY}" }, + "models": { + "anthropic/claude-opus-5": { "name": "Claude Opus 5", "limit": { "context": 200000, "output": 32000 } } + } + } + } +} +``` + +V2 schema (documented at `opencode.ai/v2/docs/models`) renames nearly every key: + +| Concept | V1 | V2 | +|---------|----|----| +| container | `provider` | `providers` | +| adapter | `npm: "@ai-sdk/openai-compatible"` | `package: "aisdk:@ai-sdk/openai-compatible"` | +| endpoint | `options.baseURL` | `settings.baseURL` | +| upstream id | `models..id` | `models..modelID` | +| credentials | `options.apiKey` / auth store | `env[]` | +| new in V2 | — | `capabilities`, `variants`, `disabled` | + +**Decision: stay on V1.** The shipped launcher already emits V1 and that path is +exercised by tests; switching would break it in exchange for `capabilities` and +`variants` fields we have no authoritative data to fill. V2 is recorded here so a +later migration starts from a written diff rather than a re-survey. + +### Pi + +Single global file: `~/.pi/agent/models.json`. + +```json +{ + "providers": { + "opencodex": { + "baseUrl": "http://127.0.0.1:10100/v1", + "api": "openai-completions", + "apiKey": "$OPENCODEX_API_KEY", + "models": [ + { + "id": "anthropic/claude-opus-5", + "name": "Claude Opus 5", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 200000, + "maxTokens": 32000 + } + ] + } + } +} +``` + +Shape differences that matter for the serializer: + +- `models` is an **array**, not a keyed object. Model identity lives in `id`. +- `cost` demands all four fields when present. +- `apiKey` supports `$ENV_VAR` interpolation (bare `$NAME`, not `{env:NAME}`). +- `api` selects the wire dialect; `openai-completions` is ours. + +**Verification status: UNVERIFIED against a real installation.** The shape above +comes from Pi's published `models.md` and `pi.dev/docs/latest/custom-provider`. +No `~/.pi/agent/models.json` was read on this machine. The first implementation +cycle touching Pi must diff this against a real file before shipping. + +### Cross-client field map + +| Concept | Pi | OpenCode V1 | +|---------|----|-------------| +| container | `providers` | `provider` | +| adapter | `api: "openai-completions"` | `npm: "@ai-sdk/openai-compatible"` | +| endpoint | `baseUrl` | `options.baseURL` | +| model container | array | keyed object | +| context | `contextWindow` | `limit.context` | +| max output | `maxTokens` | `limit.output` | +| price | `cost{4}` | absent | +| modality | `input[]` | absent | +| key reference | `$VAR` | `{env:VAR}` | + +Only four concepts are universal: endpoint, adapter kind, model id, and the +context/output pair. Everything else is one client's luxury. + +## 3. Credential handling + +All three injection paths support an environment reference, so no exporter needs +to serialize a live `ocx_...` key. Pi takes `$VAR`, OpenCode takes `{env:VAR}`, +and OpenCode additionally has an auth store. AGENTS.md treats token serialization +as a release blocker, so emitting a reference is not merely polite — writing the +plaintext key into an exported file would be a security-review failure. + +The exported artifact therefore carries a variable name, and every surface that +hands the file over must also tell the user which variable to export. + +## 4. Sources + +- Pi: `github.com/earendil-works/pi` `packages/coding-agent/docs/models.md`; `pi.dev/docs/latest/custom-provider` +- OpenCode V1: `opencode.ai/docs/providers`, `opencode.ai/config.json` +- OpenCode V2: `opencode.ai/v2/docs/models` +- AI SDK adapter: `@ai-sdk/openai-compatible` +- Metadata registry: `models.dev` + +All external claims were read during the 2026-07-31 P phase. Client schemas drift; +re-verify before any cycle that changes serializer output. diff --git a/devlog/_plan/260731_client_config_export/002_existing_surface_inventory.md b/devlog/_plan/260731_client_config_export/002_existing_surface_inventory.md new file mode 100644 index 000000000..ea431b20d --- /dev/null +++ b/devlog/_plan/260731_client_config_export/002_existing_surface_inventory.md @@ -0,0 +1,91 @@ +# 002 — What already exists, and what the real gap is + +Research only. Written because the first pass of this unit's P phase proposed +building things that were already shipped. Recording the correction here so a +later cycle's P does not repeat the same miss. + +## 1. Correction to the initial survey + +The opening research turn concluded that OpenCode export had to be built from +scratch and listed three "design blockers" (slash-bearing model ids, credential +leakage, missing `limit.output`). All three were already solved in +`src/cli/opencode.ts`, a 701-line module that predates this unit. + +| Claimed blocker | Actual state | +|-----------------|--------------| +| `provider/model` ids produce a double slash | `opencodeModelKey()` emits `provider/id` for routed and a bare slug for native; OpenCode splits on the FIRST slash, so the nested id survives | +| Exported config would leak `ocx_...` | Only `{env:OPENCODEX_OPENCODE_API_KEY}` is serialized; the real value rides the child process env | +| `limit.output` has no authoritative source | `SCHEMA_REQUIRED_OUTPUT_BUDGET` (32k, clamped to context) fills the schema-required half, and the whole `limit` block is dropped when no authoritative context exists | + +The lesson is procedural: a survey of external formats is not a survey of our own +tree. Both halves are required before a phase map is credible. + +## 2. Inventory of the shipped surface + +### `src/cli/opencode.ts` + +| Export | Role | +|--------|------| +| `buildOpencodeProviderBlockFromCatalog` | catalog rows -> V1 provider block. The real serializer core | +| `buildOpencodeProviderBlock` | test-facing wrapper over the same builder | +| `opencodeModelKey` | `provider/id` vs bare native slug | +| `opencodeProxyBaseUrl` | `http://:/v1` | +| `opencodeCatalogFromProxyRows` | `GET /api/models` rows -> catalog entries, drops disabled + dupes | +| `mergeOpencodeRuntimeConfig` | merges inherited inline config, overrides only `provider.opencodex` | +| `buildOpencodeConfig` / `serializeOpencodeRuntimeConfig` | assemble + stringify the inline runtime layer | +| `opencodeGlobalConfigPath` / `opencodeProviderOverridePath` / `projectConfigOverridesProvider` | detect (never write) user files | + +Design properties worth preserving verbatim: + +- **Never writes user config.** File layers are read for detection only; injection + goes through `OPENCODE_CONFIG_CONTENT`. This avoids clobbering comments, + relative `{file:...}` paths, and unrelated MCP credentials. +- **Never guesses metadata.** Absent context window means no `limit` block, not a + fabricated one. +- **Never serializes a secret.** + +### Adjacent precedents + +- `src/cli/claude-desktop.ts` — writes a Claude Desktop 3P config. +- `gui/src/pages/ClaudeDesktop.tsx:313` — `exportProfile()` does Blob + + `URL.createObjectURL` + `anchor.download`, then announces to a live region. + This is the house pattern for browser-side JSON download and the 040 phase + reuses it rather than inventing a second one. +- `src/cli/access.ts` — the `ocx access ...` family establishes the CLI contract + this unit extends: `--json` on every subcommand, `printData(value, wantsJson, lines)` + emitting either raw JSON or human lines from the SAME value. +- `src/server/management/model-routes.ts:115` — `GET /api/models`, the authenticated + route whose rows already feed the OpenCode builder. + +## 3. The actual gap + +`ocx opencode` produces a provider block **only in memory, only at launch**, and +only for OpenCode. Three things are missing: + +1. **No artifact.** A user who wants the provider block in their own + `~/.config/opencode/opencode.json` cannot get it out of opencodex. There is no + command that prints it and no file to download. +2. **No Pi support at all.** No serializer, no command, no surface. +3. **No shared abstraction.** The builder is private to the launcher module and + shaped around one client's schema, so a second client cannot reuse it as-is. + +Everything this unit builds is downstream of closing those three. The transport, +the catalog, and the security posture are already correct. + +## 4. Catalog metadata availability + +Checked because every serializer depends on it: + +| Field | Source | Available? | +|-------|--------|-----------| +| context window | `CatalogModel.contextWindow`; `NATIVE_OPENAI_CONTEXT_OVERRIDES` + upstream `context_window` for native | yes, with a documented 128k fallback in `parsing.ts:287` | +| display name | `CatalogModel.displayName` | yes, optional | +| max output | none | **no** — 32k schema stand-in only | +| price | not on `CatalogModel` | **no** | +| input modality | `CatalogModel.inputModalities` | yes | +| reasoning | `CatalogModel.reasoningEfforts` | yes, as effort list | + +Consequence for Pi: `cost` cannot be filled honestly. Emitting zeros would assert +"this model is free," which is false for routed providers. The 010 phase must +decide omit-vs-zero, and the survey's field table says `cost` is optional when the +key is absent — so omission is the honest option. diff --git a/devlog/_plan/260731_client_config_export/003_export_ux_design.md b/devlog/_plan/260731_client_config_export/003_export_ux_design.md new file mode 100644 index 000000000..dd05ad220 --- /dev/null +++ b/devlog/_plan/260731_client_config_export/003_export_ux_design.md @@ -0,0 +1,190 @@ +# 003 — Export UX design (Design Read + surface contracts) + +Design judgment only. Component-level implementation belongs to `040`. +Governs every surface that hands a client config to a caller: CLI text, CLI +`--json`, management API, GUI panel, GUI file download. + +## 1. Design Read + +```yaml +--- +name: opencodex client config export +colors: + primary: "light-dark(#0d0d0d, #ececec)" # existing --accent, unchanged + accent: "light-dark(#0d0d0d, #ececec)" + background: "light-dark(#ffffff, #212121)" # existing --bg +typography: + heading: { fontFamily: OpenAI Sans, fontSize: 14px } # --text-body / --weight-semibold + body: { fontFamily: OpenAI Sans, fontSize: 13px } # --text-control + machine: { fontFamily: ui-monospace, fontSize: 12px } # --font-code / --text-label +iconography: + system: "existing gui/src/icons" + weight: "regular" + domain: "library-subset" +--- +``` + +Reading this as: a **handoff surface inside a developer console** — the screen +where a machine-readable artifact leaves the product and enters someone else's +config file. The reference is not a marketing "integrations" page; it is closer +to a CI provider's "copy this token" step or a package registry's install +snippet. The whole job is to make an exact string transfer without error. + +Do's: reuse the API page's existing copy-on-click grammar; keep the generated JSON +visible before it is taken; name the destination path explicitly. + +Don'ts: no new visual language for this feature; no illustrations, no per-client +brand marks, no gradients; never present a download as if it were applied. + +### Dial setting + +``` +DESIGN_VARIANCE: 3 +MOTION_INTENSITY: 1 +Product density profile: D6 +Reasoning: dense developer console, preserve redesign — this is an additive panel on an +existing monochrome infra surface, so variance and motion match the host and density +stays high because the payload is machine data. +``` + +Domain gate applies: this is an admin/ops surface, so the Liquid Editorial default +kit is explicitly NOT used. The host design system in `gui/src/styles.css` governs. + +### Concept generation: skipped + +UX-CONCEPT-GEN-01 exempts utility CRUD/dashboard surfaces, and a governing design +system already exists (monochrome OpenAI-console grammar, tokens in `styles.css`). +No new brand-visible composition is introduced. Skip recorded per the rule. + +## 2. The one design decision + +Export has two legitimate consumer shapes and they pull in opposite directions: + +| Consumer | Wants | Failure if served the other | +|----------|-------|------------------------------| +| Agent / script | one exact JSON on stdout, no prose | prose in stdout breaks the parse | +| Human | to know WHERE the file goes and WHICH env var to set | bare JSON leaves them guessing | + +**Resolution: one payload, four presentations.** A single pure function produces +the artifact; each surface decides only how to frame it. This is the existing +`printData(value, wantsJson, lines)` contract in `src/cli/runtime-api.ts:285` +generalized — human lines and machine JSON are two renderings of the same value, +never two code paths that can drift. + +``` + buildClientConfig(client, catalog, endpoint) + | + +---------------+---------------+ + | | | + CLI --json CLI human GET /api/client-config + (stdout JSON) (JSON + path (same JSON + metadata) + + env hint) | + +-----+-----+ + | | + GUI preview GUI download + (+ copy) (.json file) +``` + +## 3. Lazy-User Gate + +Applied to every decision point the feature introduces: + +| Decision | Gate outcome | +|----------|--------------| +| Which client? | **Keep.** Genuinely divergent formats; no correct default exists. Segmented control, not a dropdown — two options should never hide behind a click. | +| Which OpenCode schema version? | **Delete.** V1 only (see `001` §2). Not user-facing. | +| Which models to include? | **Absorb.** System emits every visible non-disabled model. No picker. | +| Where to write the file? | **Absorb + demote.** Show the canonical path as copyable text; never write it for them. | +| Copy or download? | **Keep both, unequal.** Copy is primary (the common case is paste-into-existing-file); download is secondary. | +| Which env var? | **Absorb.** Fixed name per client, shown as a copyable export line. | + +Primary action on the panel: **Copy JSON.** Everything else is secondary weight. + +## 4. UX state contract + +Per UX-STATE-01, meaning before styling. The panel has five states. + +**Empty — no keys generated yet.** The config is still valid (the proxy admits +loopback callers), but a config referencing an unset env var will fail the moment +the user leaves loopback. So this is informational, not blocking: show the JSON, +plus a line stating that the referenced variable has no key behind it yet and +pointing at Generate key. Never disable export here — an agent may legitimately +want the shape before the key exists. + +**Loading — catalog in flight.** Skeleton, because the structure is known +(panel head, code block, action row). The existing `DataSurfaceSkeleton` +in `gui/src/components/data-surface` is the house component. No spinner, and +never render a partial JSON that could be copied mid-flight. + +**Ready.** JSON visible, model count stated, copy primary, download secondary, +destination path and env line present. + +**Error — catalog fetch failed.** The client config cannot be honestly produced +without the model list, so do not emit a half-config. State that the model list +could not be read, offer retry, and keep the endpoint/base-URL portion visible +since it is known independently of the catalog. Never dead-end. + +**Degraded — catalog partial.** Some models carry no authoritative context window +and therefore ship without a `limit` block (`002` §4). This is invisible in the +JSON, so the panel states it in one line: N of M models omit context limits, and +the client will apply its own defaults. Silence here would look like data loss. + +### Progressive disclosure + +Hidden by default: the destination-path detail (XDG resolution, project vs global +precedence) and the raw curl-equivalent. Reason: correct for the 90% case without +reading. Revealed in the same disclosure the API page already uses for +Authentication and Usage examples, so the grammar is consistent. + +## 5. Download-specific design + +The download is the surface most likely to mislead, because a file appearing in +`~/Downloads` feels like an applied change. Three rules. + +**Filename encodes the client, not the product.** `opencode.json` and +`pi-models.json` — a file named `opencodex-export.json` tells the user nothing +about where it belongs. The name should match what the destination file is called +so the mapping is obvious. + +**Downloading is never presented as applying.** The success announcement says the +file was downloaded and names the path it must be moved or merged into. Reuse of +the ClaudeDesktop precedent covers the mechanics; the wording is this unit's +responsibility. + +**Merge semantics are stated, not implied.** The downloaded file contains ONLY our +provider block plus `$schema`. A user with an existing `opencode.json` must merge, +not replace — replacing would destroy their other providers and MCP config. One +line of text prevents a support issue that would otherwise be data loss. + +Accessibility: the download button is a real ` + + + + +
+ {CLIENTS.map(option => ( + + ))} +
+ + {loading ? ( + // Owns the only live region for this transition; the announcement region below is not + // rendered while cold (data-surface.tsx header rule). + + ) : error !== null || !data ? ( +
+ {/* No partial JSON here: without the model list the config cannot be produced honestly. */} +

{error ?? t("api.clientConfig.loadFailed")}

+ +
+ ) : ( + <> +
{json}
+

+ {t("api.clientConfig.modelCount", { count: data.modelCount })} +

+ {data.modelsWithoutLimits > 0 && ( +

+ {t("api.clientConfig.missingLimits", { count: data.modelsWithoutLimits, total: data.modelCount })} +

+ )} + {!hasKeys && ( + // Informational, never blocking: an agent may legitimately want the shape first, + // so both actions stay enabled (003 §3). +

+ {t("api.clientConfig.noKeyYet", { env: data.apiKeyEnv })} +

+ )} +
+ {t("api.clientConfig.destination")} + +
+
+ {t("api.clientConfig.envHint")} + +
+

{t("api.clientConfig.mergeWarning")}

+ + )} + +
+ {/* Known without the catalog, so it survives the error state. */} + {t("api.baseUrl")} + +
+ +
+ {t("api.clientConfig.whereDisclosure")} +

{t("api.clientConfig.whereBody")}

+
+ + {/* Copy/download announcements only once the surface is ready, so the skeleton and this + region never speak for the same transition. */} + {!loading && data !== null && ( +
{announcement}
+ )} + + ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index dcb621f57..d20ebeece 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -887,6 +887,26 @@ export const de: Record = { "api.confirm": "Bestätigen", "api.deleteAria": "API-Schlüssel löschen", "api.usageSampleInput": "Hallo, Welt!", + "api.clientConfig.title": "Client-Konfiguration", + "api.clientConfig.clientLabel": "Client", + "api.clientConfig.clientOpencode": "OpenCode", + "api.clientConfig.clientPi": "Pi", + "api.clientConfig.copy": "JSON kopieren", + "api.clientConfig.download": "Herunterladen", + "api.clientConfig.loading": "Client-Konfiguration wird erstellt…", + "api.clientConfig.jsonLabel": "{client}-Konfigurations-JSON", + "api.clientConfig.destination": "Zieldatei", + "api.clientConfig.envHint": "Schlüssel vor dem Start setzen", + "api.clientConfig.mergeWarning": "Führe dies in die Zieldatei ein. Ein Ersetzen würde deine anderen Provider und MCP-Einstellungen entfernen.", + "api.clientConfig.modelCount": "{count} Modell(e) exportiert", + "api.clientConfig.missingLimits": "{count} von {total} Modell(en) haben kein Kontextlimit; der Client verwendet dafür seine eigenen Vorgaben.", + "api.clientConfig.noKeyYet": "Für {env} existiert noch kein Schlüssel. Erzeuge oben einen Schlüssel, bevor du diese Konfiguration außerhalb von Loopback nutzt.", + "api.clientConfig.loadFailed": "Die Modellliste konnte nicht gelesen werden, daher wurde keine Client-Konfiguration erzeugt.", + "api.clientConfig.copiedAnnounce": "Client-Konfigurations-JSON in die Zwischenablage kopiert.", + "api.clientConfig.copyFailed": "Client-Konfigurations-JSON konnte nicht kopiert werden.", + "api.clientConfig.downloadedAnnounce": "{filename} heruntergeladen. Es hat sich noch nichts geändert — führe die Datei selbst in {destination} ein.", + "api.clientConfig.whereDisclosure": "Wohin diese Datei gehört", + "api.clientConfig.whereBody": "Der Pfad oben ist der globale Speicherort. Eine projektlokale Konfigurationsdatei im Arbeitsverzeichnis hat Vorrang, und der Schlüssel wird aus der in der Konfiguration genannten Umgebungsvariable gelesen — nie aus dieser Datei.", "api.keysLoadFailed": "API-Schlüssel konnten nicht geladen werden.", "api.createFailed": "API-Schlüssel konnte nicht erstellt werden.", "api.deleteFailed": "API-Schlüssel konnte nicht gelöscht werden.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index e130743dc..0d8a8a9b5 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1335,6 +1335,26 @@ export const en = { "api.usageResponsesTitle": "Responses example", "api.usageMessagesTitle": "Messages example", "api.usageSampleInput": "Hello, world!", + "api.clientConfig.title": "Client config", + "api.clientConfig.clientLabel": "Client", + "api.clientConfig.clientOpencode": "OpenCode", + "api.clientConfig.clientPi": "Pi", + "api.clientConfig.copy": "Copy JSON", + "api.clientConfig.download": "Download", + "api.clientConfig.loading": "Building client config…", + "api.clientConfig.jsonLabel": "{client} config JSON", + "api.clientConfig.destination": "Destination file", + "api.clientConfig.envHint": "Set the key before launching", + "api.clientConfig.mergeWarning": "Merge this into the destination file. Replacing it would drop your other providers and MCP settings.", + "api.clientConfig.modelCount": "{count} model(s) exported", + "api.clientConfig.missingLimits": "{count} of {total} model(s) ship without a context limit; the client applies its own defaults.", + "api.clientConfig.noKeyYet": "{env} has no key behind it yet. Generate a key above before using this config off loopback.", + "api.clientConfig.loadFailed": "Could not read the model list, so no client config was produced.", + "api.clientConfig.copiedAnnounce": "Client config JSON copied to the clipboard.", + "api.clientConfig.copyFailed": "Could not copy the client config JSON.", + "api.clientConfig.downloadedAnnounce": "Downloaded {filename}. Nothing changed yet — merge it into {destination} yourself.", + "api.clientConfig.whereDisclosure": "Where this file goes", + "api.clientConfig.whereBody": "The destination above is the global path. A project-local config file in the working directory takes precedence over it, and the client reads the key from the environment variable named in the config — never from this file.", "api.keysLoadFailed": "Could not load API keys.", "api.createFailed": "Could not create API key.", "api.deleteFailed": "Could not delete API key.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index ae8109ed7..45a3dc33d 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1283,6 +1283,26 @@ export const ja: Record = { "api.confirm": "確認", "api.deleteAria": "API キーを削除", "api.usageSampleInput": "こんにちは、世界!", + "api.clientConfig.title": "クライアント設定", + "api.clientConfig.clientLabel": "クライアント", + "api.clientConfig.clientOpencode": "OpenCode", + "api.clientConfig.clientPi": "Pi", + "api.clientConfig.copy": "JSON をコピー", + "api.clientConfig.download": "ダウンロード", + "api.clientConfig.loading": "クライアント設定を生成中…", + "api.clientConfig.jsonLabel": "{client} 設定 JSON", + "api.clientConfig.destination": "配置先ファイル", + "api.clientConfig.envHint": "起動前にキーを設定", + "api.clientConfig.mergeWarning": "配置先ファイルにマージしてください。置き換えると既存のプロバイダーや MCP 設定が失われます。", + "api.clientConfig.modelCount": "{count} 件のモデルを書き出しました", + "api.clientConfig.missingLimits": "{total} 件中 {count} 件のモデルにコンテキスト上限がないため、クライアント側の既定値が使われます。", + "api.clientConfig.noKeyYet": "{env} に対応するキーがまだありません。ループバック外で使う前に上でキーを発行してください。", + "api.clientConfig.loadFailed": "モデル一覧を読み取れなかったため、クライアント設定を生成できませんでした。", + "api.clientConfig.copiedAnnounce": "クライアント設定 JSON をクリップボードにコピーしました。", + "api.clientConfig.copyFailed": "クライアント設定 JSON をコピーできませんでした。", + "api.clientConfig.downloadedAnnounce": "{filename} をダウンロードしました。まだ何も変わっていません。{destination} に自分でマージしてください。", + "api.clientConfig.whereDisclosure": "このファイルの置き場所", + "api.clientConfig.whereBody": "上のパスはグローバル設定の場所です。作業ディレクトリのプロジェクト設定ファイルが優先され、キーは設定に書かれた環境変数から読み込まれ、このファイルには保存されません。", "api.keysLoadFailed": "APIキーを読み込めませんでした。", "api.createFailed": "APIキーを作成できませんでした。", "api.deleteFailed": "APIキーを削除できませんでした。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index d5f16aae2..4e2679857 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -914,6 +914,26 @@ export const ko: Record = { "api.confirm": "확인", "api.deleteAria": "API 키 삭제", "api.usageSampleInput": "안녕하세요, 세계!", + "api.clientConfig.title": "클라이언트 설정", + "api.clientConfig.clientLabel": "클라이언트", + "api.clientConfig.clientOpencode": "OpenCode", + "api.clientConfig.clientPi": "Pi", + "api.clientConfig.copy": "JSON 복사", + "api.clientConfig.download": "다운로드", + "api.clientConfig.loading": "클라이언트 설정 생성 중…", + "api.clientConfig.jsonLabel": "{client} 설정 JSON", + "api.clientConfig.destination": "대상 파일", + "api.clientConfig.envHint": "실행 전 키 설정", + "api.clientConfig.mergeWarning": "대상 파일에 병합하세요. 덮어쓰면 기존 프로바이더와 MCP 설정이 사라집니다.", + "api.clientConfig.modelCount": "모델 {count}개 내보냄", + "api.clientConfig.missingLimits": "{total}개 중 {count}개 모델에 컨텍스트 한도가 없어 클라이언트 기본값이 적용됩니다.", + "api.clientConfig.noKeyYet": "{env}에 연결된 키가 아직 없습니다. 루프백 밖에서 쓰려면 위에서 키를 발급하세요.", + "api.clientConfig.loadFailed": "모델 목록을 읽지 못해 클라이언트 설정을 만들지 못했습니다.", + "api.clientConfig.copiedAnnounce": "클라이언트 설정 JSON을 클립보드에 복사했습니다.", + "api.clientConfig.copyFailed": "클라이언트 설정 JSON을 복사하지 못했습니다.", + "api.clientConfig.downloadedAnnounce": "{filename} 파일을 다운로드했습니다. 아직 아무것도 바뀌지 않았으니 {destination}에 직접 병합하세요.", + "api.clientConfig.whereDisclosure": "이 파일이 들어갈 위치", + "api.clientConfig.whereBody": "위 경로는 전역 설정 경로입니다. 작업 디렉터리의 프로젝트 설정 파일이 우선하며, 키는 설정에 적힌 환경 변수에서 읽고 이 파일에는 저장되지 않습니다.", "api.keysLoadFailed": "API 키를 불러오지 못했습니다.", "api.createFailed": "API 키를 만들지 못했습니다.", "api.deleteFailed": "API 키를 삭제하지 못했습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index a0bf4eb01..eae71ca8f 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1325,6 +1325,26 @@ export const ru: Record = { "api.confirm": "Подтвердить", "api.deleteAria": "Удалить API-ключ", "api.usageSampleInput": "Привет, мир!", + "api.clientConfig.title": "Конфигурация клиента", + "api.clientConfig.clientLabel": "Клиент", + "api.clientConfig.clientOpencode": "OpenCode", + "api.clientConfig.clientPi": "Pi", + "api.clientConfig.copy": "Копировать JSON", + "api.clientConfig.download": "Скачать", + "api.clientConfig.loading": "Формируется конфигурация клиента…", + "api.clientConfig.jsonLabel": "JSON конфигурации {client}", + "api.clientConfig.destination": "Целевой файл", + "api.clientConfig.envHint": "Задайте ключ перед запуском", + "api.clientConfig.mergeWarning": "Объедините это с целевым файлом. Замена удалит ваши другие провайдеры и настройки MCP.", + "api.clientConfig.modelCount": "Экспортировано моделей: {count}", + "api.clientConfig.missingLimits": "У {count} из {total} моделей нет лимита контекста; клиент применит свои значения по умолчанию.", + "api.clientConfig.noKeyYet": "Для {env} пока нет ключа. Создайте ключ выше, прежде чем использовать конфигурацию вне loopback.", + "api.clientConfig.loadFailed": "Не удалось прочитать список моделей, поэтому конфигурация клиента не создана.", + "api.clientConfig.copiedAnnounce": "JSON конфигурации клиента скопирован в буфер обмена.", + "api.clientConfig.copyFailed": "Не удалось скопировать JSON конфигурации клиента.", + "api.clientConfig.downloadedAnnounce": "Файл {filename} скачан. Пока ничего не изменилось — объедините его с {destination} самостоятельно.", + "api.clientConfig.whereDisclosure": "Куда положить этот файл", + "api.clientConfig.whereBody": "Путь выше — глобальное расположение. Файл конфигурации проекта в рабочем каталоге имеет приоритет, а ключ читается из переменной окружения, указанной в конфигурации, и никогда не хранится в этом файле.", "api.keysLoadFailed": "Не удалось загрузить API-ключи.", "api.createFailed": "Не удалось создать API-ключ.", "api.deleteFailed": "Не удалось удалить API-ключ.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 03e48ddc4..f563e3415 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -907,6 +907,26 @@ export const zh: Record = { "api.confirm": "确认", "api.deleteAria": "删除 API 密钥", "api.usageSampleInput": "你好,世界!", + "api.clientConfig.title": "客户端配置", + "api.clientConfig.clientLabel": "客户端", + "api.clientConfig.clientOpencode": "OpenCode", + "api.clientConfig.clientPi": "Pi", + "api.clientConfig.copy": "复制 JSON", + "api.clientConfig.download": "下载", + "api.clientConfig.loading": "正在生成客户端配置…", + "api.clientConfig.jsonLabel": "{client} 配置 JSON", + "api.clientConfig.destination": "目标文件", + "api.clientConfig.envHint": "启动前设置密钥", + "api.clientConfig.mergeWarning": "请合并到目标文件中。直接替换会丢失你已有的其他提供商和 MCP 配置。", + "api.clientConfig.modelCount": "已导出 {count} 个模型", + "api.clientConfig.missingLimits": "{total} 个模型中有 {count} 个没有上下文上限,客户端将使用自己的默认值。", + "api.clientConfig.noKeyYet": "{env} 目前还没有对应的密钥。离开回环地址使用前,请先在上方生成密钥。", + "api.clientConfig.loadFailed": "无法读取模型列表,因此没有生成客户端配置。", + "api.clientConfig.copiedAnnounce": "客户端配置 JSON 已复制到剪贴板。", + "api.clientConfig.copyFailed": "无法复制客户端配置 JSON。", + "api.clientConfig.downloadedAnnounce": "已下载 {filename}。目前还没有任何改动,请自行将其合并到 {destination}。", + "api.clientConfig.whereDisclosure": "该文件应放在哪里", + "api.clientConfig.whereBody": "上面的路径是全局配置位置。工作目录中的项目级配置文件优先级更高;密钥由配置中指定的环境变量读取,不会写入该文件。", "api.keysLoadFailed": "无法加载 API 密钥。", "api.createFailed": "无法创建 API 密钥。", "api.deleteFailed": "无法删除 API 密钥。", diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index 9bf316697..f6990f6dd 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -415,6 +415,7 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { )} .awi-clientconfig-panel, +.awi-clientconfig-panel.api-panel { + overflow: visible; +} + +.awi-clientconfig-head { + gap: var(--space-2); + flex-wrap: wrap; +} + +.awi-clientconfig-actions { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; +} + +/* Copy is the primary action and its label must not wrap into two lines at the + narrow breakpoint, where the head is the tightest row on the tab. */ +.awi-clientconfig-actions .btn { + white-space: nowrap; +} + +.awi-clientconfig-segmented { + display: inline-flex; + align-self: flex-start; + max-width: 100%; + border-radius: var(--radius-pill); + background: var(--surface-soft, var(--raised)); + padding: var(--space-1); + gap: var(--space-1); +} + +.awi-clientconfig-segmented .btn { + border-radius: var(--radius-pill); + min-width: 84px; + padding: var(--space-1-5) var(--space-3); + border: none; +} + +.awi-clientconfig-segmented .btn.btn-ghost { + background: transparent; + color: var(--muted); +} + +/* No vertical cap and no y-scroller of its own: the catalog table is the only capped + region on this tab, and the unscoped `.api-example-pre` rules above already give this + block `overflow-y: visible` + `overscroll-behavior: auto`. Adding a `max-height` here + would make the JSON a second wheel trap. */ +.awi-clientconfig-json { + margin: 0; +} + +.awi-clientconfig-json:focus-visible { + outline: 2px solid var(--accent-ring); + outline-offset: 1px; +} + +.awi-clientconfig-count, +.awi-clientconfig-degraded, +.awi-clientconfig-nokey, +.awi-clientconfig-merge { + margin: 0; + line-height: 1.35; +} + +.awi-clientconfig-line { + display: grid; + gap: 4px; + min-width: 0; +} + +.awi-clientconfig-error { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + flex-wrap: wrap; +} + +.awi-clientconfig-error p { + margin: 0; + min-width: 0; +} + +.awi-clientconfig-where > summary { + cursor: pointer; + padding: 2px 0; +} + +.awi-clientconfig-where > p { + margin: 6px 0 0; + line-height: 1.4; +} + .awi-section { margin-bottom: 20px; } diff --git a/gui/tests/client-config-panel.test.tsx b/gui/tests/client-config-panel.test.tsx new file mode 100644 index 000000000..393795f32 --- /dev/null +++ b/gui/tests/client-config-panel.test.tsx @@ -0,0 +1,254 @@ +/** + * Client config panel (devlog 260731_client_config_export/040) — accept criteria 1-6. + * + * The panel renders what GET /api/client-config returns, so every assertion here drives the + * real component against a stubbed route rather than a locally rebuilt config. + */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import ClientConfigPanel from "../src/components/apikeys-workspace/ClientConfigPanel"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +const originalFetch = globalThis.fetch; + +const OPENCODE_ENVELOPE = { + client: "opencode", + filename: "opencode.json", + destination: "/home/dev/.config/opencode/opencode.json", + apiKeyEnv: "OPENCODEX_OPENCODE_API_KEY", + exportHint: "export OPENCODEX_OPENCODE_API_KEY=", + modelCount: 2, + modelsWithoutLimits: 0, + config: { + provider: { + opencodex: { + npm: "@ai-sdk/openai-compatible", + name: "OpenCodex", + options: { baseURL: "http://127.0.0.1:10100/v1", apiKey: "{env:OPENCODEX_OPENCODE_API_KEY}" }, + models: { "gpt-5.4": { name: "gpt-5.4 (native)" } }, + }, + }, + }, +}; + +const PI_ENVELOPE = { + client: "pi", + filename: "pi-models.json", + destination: "/home/dev/.pi/agent/models.json", + apiKeyEnv: "OPENCODEX_PI_API_KEY", + exportHint: "export OPENCODEX_PI_API_KEY=", + modelCount: 2, + modelsWithoutLimits: 1, + // Pi keys its models as an ARRAY — the shape swap is what proves a real refetch. + config: { providers: { opencodex: { models: [{ id: "gpt-5.4" }, { id: "claude-sonnet-4-6" }] } } }, +}; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +function stubRoute(handler: (client: string) => Response | Promise) { + const calls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = new URL(String(input), "http://localhost"); + const client = url.searchParams.get("client") ?? ""; + calls.push(client); + return handler(client); + }) as typeof fetch; + return calls; +} + +async function mountPanel(props: { hasKeys?: boolean } = {}): Promise<{ root: Root; container: HTMLElement }> { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render( + + + , + ); + }); + return { root, container }; +} + +function button(container: HTMLElement, label: string): HTMLButtonElement { + return [...container.querySelectorAll("button")] + .find(el => el.textContent?.trim() === label)!; +} + +test("switching client refetches and swaps the rendered payload shape", async () => { + const calls = stubRoute(client => Response.json(client === "pi" ? PI_ENVELOPE : OPENCODE_ENVELOPE)); + const { root, container } = await mountPanel(); + + const opencodeJson = container.querySelector(".awi-clientconfig-json")!.textContent!; + expect(opencodeJson).toContain("\"provider\""); + expect(JSON.parse(opencodeJson)).toEqual(OPENCODE_ENVELOPE.config); + + await act(async () => { button(container, "Pi").click(); }); + + expect(calls).toEqual(["opencode", "pi"]); + const piJson = container.querySelector(".awi-clientconfig-json")!.textContent!; + const parsed = JSON.parse(piJson) as typeof PI_ENVELOPE.config; + expect(Array.isArray(parsed.providers.opencodex.models)).toBe(true); + expect(piJson).not.toContain("\"npm\""); + + await act(async () => { root.unmount(); }); +}); + +test("download emits the fetched config under the server-provided filename and never says applied", async () => { + stubRoute(() => Response.json(OPENCODE_ENVELOPE)); + const blobs: Blob[] = []; + const createObjectURL = ((blob: Blob) => { blobs.push(blob); return "blob:stub"; }) as typeof URL.createObjectURL; + const originalCreate = URL.createObjectURL; + const originalRevoke = URL.revokeObjectURL; + URL.createObjectURL = createObjectURL; + URL.revokeObjectURL = (() => {}) as typeof URL.revokeObjectURL; + + const downloaded: string[] = []; + const originalCreateElement = document.createElement.bind(document); + document.createElement = ((tag: string) => { + const element = originalCreateElement(tag); + if (tag === "a") { + // The anchor is an internal implementation detail; happy-dom does not navigate on click. + (element as HTMLAnchorElement).click = () => { downloaded.push((element as HTMLAnchorElement).download); }; + } + return element; + }) as typeof document.createElement; + + try { + const { root, container } = await mountPanel(); + await act(async () => { button(container, "Download").click(); }); + + expect(downloaded).toEqual(["opencode.json"]); + expect(blobs).toHaveLength(1); + // happy-dom appends `;charset=utf-8` to the constructed Blob type. + expect(blobs[0]!.type).toStartWith("application/json"); + expect(JSON.parse(await blobs[0]!.text())).toEqual(OPENCODE_ENVELOPE.config); + + const announcement = container.querySelector(".sr-only[aria-live='polite']")!.textContent!; + expect(announcement).toContain("Downloaded opencode.json"); + expect(announcement).toContain(OPENCODE_ENVELOPE.destination); + for (const forbidden of ["applied", "saved", "configured"]) { + expect(announcement.toLowerCase()).not.toContain(forbidden); + } + // Merge semantics are stated on the surface, not only in the announcement (003 §5). + expect(container.textContent).toContain("Merge this into the destination file."); + + await act(async () => { root.unmount(); }); + } finally { + URL.createObjectURL = originalCreate; + URL.revokeObjectURL = originalRevoke; + document.createElement = originalCreateElement as typeof document.createElement; + } +}); + +test("route failure shows a retry and no partial JSON, and keeps the base URL visible", async () => { + let attempt = 0; + stubRoute(() => { + attempt += 1; + return attempt === 1 + ? Response.json({ error: "model catalog unavailable: upstream timeout" }, { status: 503 }) + : Response.json(OPENCODE_ENVELOPE); + }); + const { root, container } = await mountPanel(); + + expect(container.textContent).toContain("model catalog unavailable: upstream timeout"); + expect(container.querySelector(".awi-clientconfig-json")).toBeNull(); + expect(container.textContent).toContain("http://127.0.0.1:10100/v1"); + + await act(async () => { button(container, "Retry").click(); }); + + expect(attempt).toBe(2); + expect(container.querySelector(".awi-clientconfig-json")).not.toBeNull(); + expect(container.querySelector(".awi-clientconfig-error")).toBeNull(); + + await act(async () => { root.unmount(); }); +}); + +test("degraded line appears only when models ship without context limits", async () => { + stubRoute(client => Response.json(client === "pi" ? PI_ENVELOPE : OPENCODE_ENVELOPE)); + const { root, container } = await mountPanel(); + + expect(container.querySelector(".awi-clientconfig-degraded")).toBeNull(); + + await act(async () => { button(container, "Pi").click(); }); + + expect(container.querySelector(".awi-clientconfig-degraded")?.textContent) + .toBe("1 of 2 model(s) ship without a context limit; the client applies its own defaults."); + + await act(async () => { root.unmount(); }); +}); + +test("no-key state is informational and leaves copy and download enabled", async () => { + stubRoute(() => Response.json(OPENCODE_ENVELOPE)); + const { root, container } = await mountPanel({ hasKeys: false }); + + expect(container.querySelector(".awi-clientconfig-nokey")?.textContent) + .toContain("OPENCODEX_OPENCODE_API_KEY has no key behind it yet"); + expect(button(container, "Copy JSON").disabled).toBe(false); + expect(button(container, "Download").disabled).toBe(false); + + await act(async () => { root.unmount(); }); +}); + +test("client switch is a segmented radio group with both options visible, not a dropdown", async () => { + stubRoute(() => Response.json(OPENCODE_ENVELOPE)); + const { root, container } = await mountPanel(); + + const group = container.querySelector(".awi-clientconfig-segmented")!; + expect(group.getAttribute("role")).toBe("radiogroup"); + expect(container.querySelector("select")).toBeNull(); + const options = [...group.querySelectorAll("button[role='radio']")]; + expect(options.map(el => el.textContent)).toEqual(["OpenCode", "Pi"]); + expect(options.map(el => el.getAttribute("aria-checked"))).toEqual(["true", "false"]); + + await act(async () => { root.unmount(); }); +}); + +test("cold load announces through the skeleton only, with no second live region", async () => { + let release: (() => void) | null = null; + const gate = new Promise(resolve => { release = resolve; }); + stubRoute(async () => { await gate; return Response.json(OPENCODE_ENVELOPE); }); + + const { root, container } = await mountPanel(); + + const liveRegions = container.querySelectorAll("[aria-live]"); + expect(liveRegions).toHaveLength(1); + expect(container.querySelector(".data-surface-skeleton")).not.toBeNull(); + expect(container.querySelector(".awi-clientconfig-json")).toBeNull(); + + await act(async () => { release!(); await gate; }); + + expect(container.querySelector(".data-surface-skeleton")).toBeNull(); + expect(container.querySelectorAll("[aria-live]")).toHaveLength(1); + + await act(async () => { root.unmount(); }); +}); diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts new file mode 100644 index 000000000..b3a26ae77 --- /dev/null +++ b/src/cli/export-command.ts @@ -0,0 +1,187 @@ +/** + * `ocx export --client ` — print a client config for the live proxy. + * + * Two consumers, one payload (devlog 260731_client_config_export/020): + * + * - **Agent** (`--json`): stdout is exactly the client config JSON and nothing else, so + * `ocx export --client pi --json > models.json` is safe to pipe. Every diagnostic — + * including the `--out` write note — goes to stderr. + * - **Human** (no flag): the JSON leads, then the destination path, the merge warning, + * the env export line, and the model/degraded counts. + * + * The command never writes the user's real config path. `--out` is an explicit target and + * refuses to clobber an existing file without `--force`, because the common mistake + * (`--out ~/.config/opencode/opencode.json`) would silently destroy other providers. + * + * Serialization itself belongs to src/clients/config-export.ts; this module only resolves + * the base URL, filters the catalog, and renders. No secret is ever serialized: the config + * carries the client's documented env reference and the real key stays in the environment. + */ +import { writeFileSync } from "node:fs"; +import { loadConfig } from "../config"; +import { + EXPORT_CLIENTS, + EXPORT_CLIENT_IDS, + buildClientConfig, + isExportClientId, + opencodeProxyBaseUrl, + type ExportClientId, + type ExportModel, +} from "../clients/config-export"; +import { opencodeCatalogFromProxyRows, type OpencodeProxyModelRow } from "./opencode"; +import type { OcxConfig } from "../types"; +import { + CliUsageError, + RuntimeApiError, + printData, + rejectArgs, + runCliAction, + runtimeBaseUrl, + runtimeRequest, + takeFlag, + takeOption, + type RuntimeApiDeps, +} from "./runtime-api"; + +const USAGE = `Usage: + ocx export --client <${EXPORT_CLIENT_IDS.join("|")}> [--json] [--out ] [--force]`; + +export interface ExportCommandDeps extends RuntimeApiDeps { + /** Live config seam; tests inject a fixture instead of reading the user's config. */ + configImpl?: () => OcxConfig; +} + +/** + * `/api/models` row plus the modality list Pi consumes. The launcher's row type predates + * the Pi exporter and stops at the fields OpenCode needs. + */ +type ExportProxyModelRow = OpencodeProxyModelRow & { inputModalities?: string[] }; + +/** Same authoritativeness rule the serializers apply, for the degraded-count line. */ +function hasContextLimit(model: ExportModel): boolean { + return typeof model.contextWindow === "number" + && Number.isFinite(model.contextWindow) + && model.contextWindow > 0; +} + +/** + * Export rows from proxy `/api/models` rows. + * + * `opencodeCatalogFromProxyRows` owns the visibility rules (drop `disabled`, drop dupes, + * drop native under Codex Direct) — the export core does none of that, so a row filtered + * here is the only thing keeping a disabled model out of a client's picker. Modalities are + * re-joined by `namespaced` because the launcher's catalog type does not carry them. + */ +export function exportModelsFromProxyRows( + rows: readonly ExportProxyModelRow[], + config: OcxConfig, +): ExportModel[] { + const modalities = new Map(); + for (const row of rows) { + const namespaced = row.namespaced?.trim(); + if (namespaced && Array.isArray(row.inputModalities) && row.inputModalities.length > 0) { + if (!modalities.has(namespaced)) modalities.set(namespaced, [...row.inputModalities]); + } + } + return opencodeCatalogFromProxyRows(rows, config).map(entry => { + const model: ExportModel = { + namespaced: entry.namespaced, + provider: entry.provider ?? (entry.native ? "openai" : "routed"), + id: entry.id ?? entry.namespaced, + }; + if (entry.native) model.native = true; + if (entry.displayName) model.displayName = entry.displayName; + if (entry.contextWindow !== undefined) model.contextWindow = entry.contextWindow; + const input = modalities.get(entry.namespaced); + if (input) model.inputModalities = input; + return model; + }); +} + +/** + * `http://host:port/v1` for the proxy that is actually listening. + * + * `runtimeBaseUrl` is the identity-checked `findLiveProxy` probe the launcher uses, and it + * already throws the "Start it with: ocx start" error when nothing answers — so a config + * with an empty models block can never be emitted. + * + * Resolved ONCE and handed back to `runtimeRequest` as `baseUrl`, so the catalog and the + * exported endpoint can never come from two different probes. + */ +function proxyV1BaseUrl(root: string): string { + const url = new URL(root); + const port = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80; + return opencodeProxyBaseUrl(port, url.hostname); +} + +function parseClient(args: string[]): ExportClientId { + const raw = takeOption(args, "--client"); + if (raw === undefined) { + throw new CliUsageError(`--client is required (${EXPORT_CLIENT_IDS.join(", ")})`, USAGE); + } + const client = raw.trim().toLowerCase(); + if (!isExportClientId(client)) { + throw new CliUsageError(`--client must be one of: ${EXPORT_CLIENT_IDS.join(", ")}`, USAGE); + } + return client; +} + +/** + * Write the config, refusing to replace an existing file without `--force`. + * + * The `wx` flag does the refusal in the kernel rather than after an `existsSync` check, so + * a file created between the two can never be truncated. Nothing is printed before this + * runs: a refusal leaves both the target bytes and stdout untouched. + */ +function writeExport(path: string, text: string, force: boolean): void { + try { + writeFileSync(path, text, force ? { encoding: "utf8" } : { encoding: "utf8", flag: "wx" }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + throw new CliUsageError( + `${path} already exists. Re-run with --force to replace it, or print the config and merge it yourself.`, + USAGE, + ); + } + throw error; + } +} + +export async function handleExportCommand(argv: string[], deps: ExportCommandDeps = {}): Promise { + return runCliAction(async () => { + const args = [...argv]; + const client = parseClient(args); + const wantsJson = takeFlag(args, "--json"); + const force = takeFlag(args, "--force"); + const out = takeOption(args, "--out"); + rejectArgs(args, USAGE); + + const spec = EXPORT_CLIENTS[client]; + const config = (deps.configImpl ?? loadConfig)(); + const root = await runtimeBaseUrl(deps); + const rows = await runtimeRequest("/api/models", {}, { ...deps, baseUrl: root }); + if (!Array.isArray(rows)) { + throw new RuntimeApiError("Management API returned an unexpected /api/models payload.", 502, rows); + } + const models = exportModelsFromProxyRows(rows, config); + const clientConfig = buildClientConfig(client, { baseUrl: proxyV1BaseUrl(root), models, config }); + const text = JSON.stringify(clientConfig, null, 2); + + if (out !== undefined) writeExport(out, `${text}\n`, force); + // stderr, so `--json` stdout stays byte-exact for a redirect. + if (out !== undefined && wantsJson) console.error(`Wrote ${out}`); + + const degraded = models.filter(model => !hasContextLimit(model)).length; + printData(clientConfig, wantsJson, [ + text, + "", + ...(out !== undefined ? [`Wrote ${out}`] : []), + `Destination: ${spec.destination(process.env)}`, + "Merge this provider block into that file; do not replace it.", + `Before launching: ${spec.exportHint}`, + `${models.length} model${models.length === 1 ? "" : "s"}; ${degraded} omit context limits (the client applies its own defaults).`, + ]); + }); +} + +export const EXPORT_USAGE = USAGE; diff --git a/src/cli/help.ts b/src/cli/help.ts index 71850d375..6de5ec04b 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -169,6 +169,16 @@ const helpEntries: Record = { summary: "Manage OpenCodex admission API keys and inspect external endpoints.", }, "api-key": { usage: "ocx api-key ...", summary: "Alias of ocx access key." }, + export: { + usage: "ocx export --client [--json] [--out ] [--force]", + summary: "Print a client config (opencode, Pi) wired to the running proxy.", + details: [ + "--json prints only the config JSON on stdout, so it is safe to redirect to a file.", + "--out writes the config there and refuses to replace an existing file without --force.", + "The config never contains a key; it references the client's env var, which you export before launching.", + "The destination path is printed for merging by hand — ocx never writes your real client config.", + ], + }, grok: { usage: "ocx grok ...", summary: "Manage and apply the Grok Build model fence." }, integration: { usage: "ocx integration ...", summary: "Manage supported client integrations." }, system: { @@ -286,6 +296,7 @@ Usage: ocx agent Subagents, injection, effort caps, and sidecars ocx observe Logs, usage, storage, memory, and debug data ocx access External API keys and endpoint information + ocx export --client Print an opencode/Pi config wired to the running proxy ocx grok Grok Build model selection and apply ocx system Runtime settings, startup, sync, and updates ocx config Validated configuration show/get/set/import/export diff --git a/src/cli/index.ts b/src/cli/index.ts index 0cc8a45d2..6e84e47c6 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1031,6 +1031,11 @@ switch (command) { process.exitCode = await handleAccessCommand(["key", ...args.slice(1)]); break; } + case "export": { + const { handleExportCommand } = await import("./export-command"); + process.exitCode = await handleExportCommand(args.slice(1)); + break; + } case "grok": { const { handleGrokCommand } = await import("./integrations"); process.exitCode = await handleGrokCommand(args.slice(1)); diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index 966985087..60682d428 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -20,17 +20,49 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { loadConfig } from "../config"; +import { + OPENCODE_API_KEY_ENV, + OPENCODE_CONFIG_SCHEMA, + OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, + OPENCODE_PROVIDER_ID, + buildOpencodeProviderBlockFromCatalog, + opencodeGlobalConfigPath, +} from "../clients/config-export"; +import type { + OpencodeCatalogModel, + OpencodeGeneratedConfig, + OpencodeLaunchEnv, + OpencodeProviderBlock, +} from "../clients/config-export"; import { visibleNativeSlugs } from "../codex/catalog"; -import { shouldInjectApiAuthHeader } from "../codex/inject"; import { commandInvocation } from "../lib/win-exec"; import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; import { providerCodexAccountMode } from "../providers/registry"; import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; -export interface OpencodeLaunchEnv { - [key: string]: string | undefined; -} +/** + * The provider-block serializer, its constants, and the config-path helpers now live in + * `src/clients/config-export.ts`, shared with every other client-config export surface. + * They are re-exported here so the launcher's long-standing import surface is unchanged. + */ +export { + OPENCODE_API_KEY_ENV, + OPENCODE_API_KEY_ENV_REF, + OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, + OPENCODE_PROVIDER_ID, + SCHEMA_REQUIRED_OUTPUT_BUDGET, + buildOpencodeProviderBlockFromCatalog, + opencodeGlobalConfigPath, + opencodeProxyBaseUrl, +} from "../clients/config-export"; +export type { + OpencodeCatalogModel, + OpencodeGeneratedConfig, + OpencodeLaunchEnv, + OpencodeModelEntry, + OpencodeProviderBlock, +} from "../clients/config-export"; /** One proxy-routed model destined for the generated provider block. */ export interface OpencodeRoutedModel { @@ -53,42 +85,6 @@ export interface OpencodeProxyModelRow { contextWindow?: number; } -/** Visible catalog entry keyed by the proxy's canonical namespaced selector. */ -export interface OpencodeCatalogModel { - namespaced: string; - native?: boolean; - provider?: string; - id?: string; - contextWindow?: number; - displayName?: string; -} - -export interface OpencodeModelEntry { - name: string; - limit?: { context: number; output: number }; -} - -export interface OpencodeProviderBlock { - npm: string; - name: string; - options: { - baseURL: string; - apiKey?: string; - headers?: Record; - }; - models: Record; -} - -export interface OpencodeGeneratedConfig { - $schema: string; - provider: Record; -} - -/** Provider key owned by this launcher; the only key it ever injects at runtime. */ -export const OPENCODE_PROVIDER_ID = "opencodex"; - -const OPENCODE_CONFIG_SCHEMA = "https://opencode.ai/config.json"; - const PROJECT_CONFIG_FILENAMES = ["opencode.json", "opencode.jsonc"] as const; /** @@ -97,41 +93,6 @@ const PROJECT_CONFIG_FILENAMES = ["opencode.json", "opencode.jsonc"] as const; */ export const OPENCODE_CONFIG_CONTENT_ENV = "OPENCODE_CONFIG_CONTENT"; -/** - * The proxy speaks the OpenAI-compatible shape at /v1, which opencode reaches through - * the AI SDK's openai-compatible package (the same wiring users hand-write today). - */ -const OPENCODE_PROVIDER_NPM = "@ai-sdk/openai-compatible"; - -/** - * Env var carrying the proxy admission key to the child. The inline config only ever - * holds the `{env:...}` reference, so the secret never lands on disk (AGENTS.md treats - * token serialization as a release blocker). opencode substitutes it at load time. - */ -export const OPENCODE_API_KEY_ENV = "OPENCODEX_OPENCODE_API_KEY"; - -/** - * opencode's config schema rejects a `limit` block that carries `context` without - * `output`, but CatalogModel has no authoritative per-model output field. Dropping - * `limit` entirely would also throw away the authoritative context window we DO have, - * so the block is emitted with this budget standing in for the missing half. - * - * The value matches REASONING_MAX_TOKENS_CEILING in src/adapters/anthropic.ts — the - * project's existing "safe ceiling across current models" figure. It is a ceiling for - * schema validity, NOT a claim about any specific model's true maximum, and it is - * clamped to the context window so a small-context model can never be emitted with - * output > context. - */ -export const SCHEMA_REQUIRED_OUTPUT_BUDGET = 32_000; - -/** Deterministic loopback default for exported provider-block helpers in tests. */ -export const OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG: OcxConfig = { - port: 10100, - hostname: "127.0.0.1", - defaultProvider: "mock", - providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, -} as OcxConfig; - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -219,31 +180,11 @@ export function parseJsonc(text: string): unknown { } } -/** - * Resolve the user's global opencode config path. opencode uses the XDG layout on every - * platform (including Windows, where it is %USERPROFILE%\.config\opencode). - */ -export function opencodeGlobalConfigPath( - env: OpencodeLaunchEnv = process.env, - home: string = homedir(), -): string { - const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.length > 0 ? env.XDG_CONFIG_HOME : join(home, ".config"); - return join(xdg, "opencode", "opencode.json"); -} - /** Model key as the proxy routes it: `provider/id` for routed models, bare slug for native OpenAI entries. */ export function opencodeModelKey(provider: string, id: string): string { return provider === "native" ? id : `${provider}/${id}`; } -/** Compose the OpenAI-compatible proxy base URL from a live probe result. */ -export function opencodeProxyBaseUrl(port: number, hostname?: string): string { - return `http://${probeHostname(hostname)}:${port}/v1`; -} - -/** Env reference shared by apiKey and the dedicated proxy admission header. */ -export const OPENCODE_API_KEY_ENV_REF = `{env:${OPENCODE_API_KEY_ENV}}`; - /** * Native OpenAI slugs advertised to opencode. Omitted in Codex Direct mode because native * chat-completions require the caller's real ChatGPT OAuth bearer, not proxy admission. @@ -253,62 +194,6 @@ export function opencodeLaunchNativeSlugs(config: OcxConfig): string[] { return [...visibleNativeSlugs(config)]; } -function opencodeProviderOptions(baseURL: string, config: OcxConfig): OpencodeProviderBlock["options"] { - const options: OpencodeProviderBlock["options"] = { baseURL }; - // Non-loopback binds accept proxy admission only via x-opencodex-api-key so Authorization - // stays free for Codex Direct upstream credentials when applicable. - if (shouldInjectApiAuthHeader(config)) { - options.headers = { "x-opencodex-api-key": OPENCODE_API_KEY_ENV_REF }; - return options; - } - options.apiKey = OPENCODE_API_KEY_ENV_REF; - return options; -} - -function opencodeModelEntryLabel(model: OpencodeCatalogModel): string { - const providerLabel = model.native ? "native" : (model.provider ?? "routed"); - const id = model.id ?? model.namespaced; - if (model.displayName && model.displayName.length > 0) { - return `${model.displayName} (${providerLabel})`; - } - return `${id} (${providerLabel})`; -} - -/** - * Build the `opencodex` provider block from proxy catalog rows keyed by each row's - * canonical `namespaced` selector. - * - * `limit.context` is emitted ONLY from an authoritative context window — never guessed. - * When none is available the whole `limit` block is dropped and opencode keeps its own - * defaults; when one is present, `limit.output` rides along (opencode's schema requires - * the pair) clamped to the context window. - */ -export function buildOpencodeProviderBlockFromCatalog( - port: number, - catalogModels: readonly OpencodeCatalogModel[], - hostname?: string, - config: OcxConfig = OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, -): OpencodeProviderBlock { - const models: Record = {}; - for (const model of catalogModels) { - const key = model.namespaced; - if (models[key]) continue; // first entry wins; native rows lead /api/models - const entry: OpencodeModelEntry = { name: opencodeModelEntryLabel(model) }; - const { contextWindow } = model; - if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) { - const context = Math.floor(contextWindow); - entry.limit = { context, output: Math.min(SCHEMA_REQUIRED_OUTPUT_BUDGET, context) }; - } - models[key] = entry; - } - return { - npm: OPENCODE_PROVIDER_NPM, - name: "OpenCodex", - options: opencodeProviderOptions(opencodeProxyBaseUrl(port, hostname), config), - models, - }; -} - /** Back-compat helper for unit tests that assemble slugs/routed rows directly. */ export function buildOpencodeProviderBlock( port: number, diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts new file mode 100644 index 000000000..2294aa549 --- /dev/null +++ b/src/clients/config-export.ts @@ -0,0 +1,377 @@ +/** + * Client-neutral config export core. + * + * One pure function per client, one shared input type. Every export surface (CLI, + * management API, GUI) consumes this module so the bytes a user copies, downloads, or + * curls can never drift between surfaces. + * + * Two invariants carried over from `ocx opencode` (src/cli/opencode.ts), which owned the + * OpenCode serializer before it moved here: + * + * - **No secret is ever serialized.** Configs carry only the client's documented env + * reference (`{env:VAR}` for OpenCode, `$VAR` for Pi); the real admission key travels + * through the environment. AGENTS.md treats token serialization as a release blocker. + * - **No metadata is guessed.** A model with no authoritative context window ships + * without context/output fields, and the client applies its own defaults. Pi's `cost` + * is omitted entirely rather than zero-filled, because zeros would assert "free", + * which is false for routed providers. + * + * This module never writes a file. `destination` names the canonical path for a human; + * targeting it is the caller's explicit act. + */ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { shouldInjectApiAuthHeader } from "../codex/inject"; +import { probeHostname } from "../server/proxy-liveness"; +import type { OcxConfig } from "../types"; + +export interface OpencodeLaunchEnv { + [key: string]: string | undefined; +} + +/** Visible catalog entry keyed by the proxy's canonical namespaced selector. */ +export interface OpencodeCatalogModel { + namespaced: string; + native?: boolean; + provider?: string; + id?: string; + contextWindow?: number; + displayName?: string; +} + +export interface OpencodeModelEntry { + name: string; + limit?: { context: number; output: number }; +} + +export interface OpencodeProviderBlock { + npm: string; + name: string; + options: { + baseURL: string; + apiKey?: string; + headers?: Record; + }; + models: Record; +} + +export interface OpencodeGeneratedConfig { + $schema: string; + provider: Record; +} + +/** Provider key owned by this project; the only key any exporter ever emits. */ +export const OPENCODE_PROVIDER_ID = "opencodex"; + +export const OPENCODE_CONFIG_SCHEMA = "https://opencode.ai/config.json"; + +/** + * The proxy speaks the OpenAI-compatible shape at /v1, which opencode reaches through + * the AI SDK's openai-compatible package (the same wiring users hand-write today). + */ +const OPENCODE_PROVIDER_NPM = "@ai-sdk/openai-compatible"; + +/** + * Env var carrying the proxy admission key to opencode. The config only ever holds the + * `{env:...}` reference, so the secret never lands on disk. opencode substitutes it at + * load time. + */ +export const OPENCODE_API_KEY_ENV = "OPENCODEX_OPENCODE_API_KEY"; + +/** Env reference shared by apiKey and the dedicated proxy admission header. */ +export const OPENCODE_API_KEY_ENV_REF = `{env:${OPENCODE_API_KEY_ENV}}`; + +/** Env var Pi interpolates. Pi takes bare `$NAME`, not opencode's `{env:NAME}`. */ +export const PI_API_KEY_ENV = "OPENCODEX_API_KEY"; + +/** Pi's reference form for the admission key. Never the value. */ +export const PI_API_KEY_ENV_REF = `$${PI_API_KEY_ENV}`; + +/** Pi's wire-dialect selector for an OpenAI-compatible endpoint. */ +const PI_API_DIALECT = "openai-completions"; + +/** + * opencode's config schema rejects a `limit` block that carries `context` without + * `output`, but CatalogModel has no authoritative per-model output field. Dropping + * `limit` entirely would also throw away the authoritative context window we DO have, + * so the block is emitted with this budget standing in for the missing half. + * + * The value matches REASONING_MAX_TOKENS_CEILING in src/adapters/anthropic.ts — the + * project's existing "safe ceiling across current models" figure. It is a ceiling for + * schema validity, NOT a claim about any specific model's true maximum, and it is + * clamped to the context window so a small-context model can never be emitted with + * output > context. Pi's `maxTokens` uses the same stand-in and the same clamp. + */ +export const SCHEMA_REQUIRED_OUTPUT_BUDGET = 32_000; + +/** Deterministic loopback default for exported provider-block helpers in tests. */ +export const OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG: OcxConfig = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, +} as OcxConfig; + +/** + * Resolve the user's global opencode config path. opencode uses the XDG layout on every + * platform (including Windows, where it is %USERPROFILE%\.config\opencode). + */ +export function opencodeGlobalConfigPath( + env: OpencodeLaunchEnv = process.env, + home: string = homedir(), +): string { + const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.length > 0 ? env.XDG_CONFIG_HOME : join(home, ".config"); + return join(xdg, "opencode", "opencode.json"); +} + +/** Compose the OpenAI-compatible proxy base URL from a live probe result. */ +export function opencodeProxyBaseUrl(port: number, hostname?: string): string { + return `http://${probeHostname(hostname)}:${port}/v1`; +} + +/** + * One proxy-routed model destined for a client config. Deliberately narrower than + * `CatalogModel` so a serializer cannot reach for a field that does not survive the + * `/api/models` boundary. + */ +export interface ExportModel { + /** Canonical proxy selector: `provider/id`, or bare slug for native. */ + namespaced: string; + provider: string; + id: string; + /** Native OpenAI entry. Read by the shared label rule. */ + native?: boolean; + displayName?: string; + contextWindow?: number; + inputModalities?: string[]; +} + +export interface ExportContext { + /** `http://host:port/v1` — the OpenAI-compatible surface the client dials. */ + baseUrl: string; + models: readonly ExportModel[]; + /** + * Live proxy config. Only the OpenCode path reads it: a non-loopback bind moves + * admission from `apiKey` to the `x-opencodex-api-key` header. + */ + config?: OcxConfig; +} + +export type ExportClientId = "opencode" | "pi"; + +export interface ExportClientSpec { + id: ExportClientId; + /** Download filename; matches the destination file's own name (003 §5). */ + filename: string; + /** Canonical destination for humans. Never written to. */ + destination: (env: NodeJS.ProcessEnv) => string; + /** Env var the config references; the value is never serialized. */ + apiKeyEnv: string; + /** Shell line the user runs before launching the client. */ + exportHint: string; + build: (ctx: ExportContext) => unknown; +} + +/** + * Authoritative context window, or undefined. Never guesses: a missing, non-finite, or + * non-positive value means the serializer omits every context-derived field. + */ +function authoritativeContextWindow(contextWindow: number | undefined): number | undefined { + if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) { + return Math.floor(contextWindow); + } + return undefined; +} + +/** Schema-required output budget for a known context window. */ +function outputBudgetFor(context: number): number { + return Math.min(SCHEMA_REQUIRED_OUTPUT_BUDGET, context); +} + +/** + * Label shared by every client: `" ()"`. The + * provider suffix is what makes two same-named models from different upstreams + * distinguishable in a client's model picker. + */ +function exportModelLabel(model: OpencodeCatalogModel): string { + const providerLabel = model.native ? "native" : (model.provider ?? "routed"); + const id = model.id ?? model.namespaced; + if (model.displayName && model.displayName.length > 0) { + return `${model.displayName} (${providerLabel})`; + } + return `${id} (${providerLabel})`; +} + +function opencodeProviderOptions(baseURL: string, config: OcxConfig): OpencodeProviderBlock["options"] { + const options: OpencodeProviderBlock["options"] = { baseURL }; + // Non-loopback binds accept proxy admission only via x-opencodex-api-key so Authorization + // stays free for Codex Direct upstream credentials when applicable. + if (shouldInjectApiAuthHeader(config)) { + options.headers = { "x-opencodex-api-key": OPENCODE_API_KEY_ENV_REF }; + return options; + } + options.apiKey = OPENCODE_API_KEY_ENV_REF; + return options; +} + +/** + * `opencodex` provider block for a resolved base URL. + * + * `limit.context` is emitted ONLY from an authoritative context window — never guessed. + * When none is available the whole `limit` block is dropped and opencode keeps its own + * defaults; when one is present, `limit.output` rides along (opencode's schema requires + * the pair) clamped to the context window. + */ +function opencodeProviderBlock( + baseURL: string, + catalogModels: readonly OpencodeCatalogModel[], + config: OcxConfig, +): OpencodeProviderBlock { + const models: Record = {}; + for (const model of catalogModels) { + const key = model.namespaced; + if (models[key]) continue; // first entry wins; native rows lead /api/models + const entry: OpencodeModelEntry = { name: exportModelLabel(model) }; + const context = authoritativeContextWindow(model.contextWindow); + if (context !== undefined) { + entry.limit = { context, output: outputBudgetFor(context) }; + } + models[key] = entry; + } + return { + npm: OPENCODE_PROVIDER_NPM, + name: "OpenCodex", + options: opencodeProviderOptions(baseURL, config), + models, + }; +} + +/** + * Build the `opencodex` provider block from proxy catalog rows keyed by each row's + * canonical `namespaced` selector. Used by the `ocx opencode` launcher, which injects + * the block through OpenCode's inline runtime layer rather than any file. + */ +export function buildOpencodeProviderBlockFromCatalog( + port: number, + catalogModels: readonly OpencodeCatalogModel[], + hostname?: string, + config: OcxConfig = OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, +): OpencodeProviderBlock { + return opencodeProviderBlock(opencodeProxyBaseUrl(port, hostname), catalogModels, config); +} + +/** + * Shared precondition for every serializer: drop duplicate `namespaced` (first wins, + * native rows lead `/api/models`) and sort by `namespaced` so two calls with the same + * models produce identical bytes. Stability matters because the GUI shows a diffable + * preview and agents may checksum the payload. + */ +export function normalizeExportModels(models: readonly ExportModel[]): ExportModel[] { + const seen = new Set(); + const unique: ExportModel[] = []; + for (const model of models) { + if (seen.has(model.namespaced)) continue; + seen.add(model.namespaced); + unique.push(model); + } + return unique.sort((a, b) => (a.namespaced < b.namespaced ? -1 : a.namespaced > b.namespaced ? 1 : 0)); +} + +/** OpenCode V1 document: our provider block plus `$schema`, and nothing else. */ +function buildOpencodeClientConfig(ctx: ExportContext): OpencodeGeneratedConfig { + const block = opencodeProviderBlock( + ctx.baseUrl, + normalizeExportModels(ctx.models), + ctx.config ?? OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, + ); + return { $schema: OPENCODE_CONFIG_SCHEMA, provider: { [OPENCODE_PROVIDER_ID]: block } }; +} + +export interface PiModelEntry { + id: string; + name: string; + input: string[]; + contextWindow?: number; + maxTokens?: number; +} + +export interface PiProviderBlock { + baseUrl: string; + api: string; + apiKey: string; + models: PiModelEntry[]; +} + +export interface PiGeneratedConfig { + providers: Record; +} + +/** + * Pi's `~/.pi/agent/models.json` shape. `models` is an ARRAY (identity lives in `id`), + * unlike OpenCode's keyed object. + * + * Two fields are deliberately absent. `cost` requires all four price fields and we have + * no price data at all, so emitting zeros would assert every routed model is free. + * `reasoning` is a boolean in Pi while our catalog carries an effort list — mapping one + * to the other would be a guess. + * + * Pi's schema is UNVERIFIED against a real installation (001 §2); this contract is ours, + * not a claim about Pi's acceptance. + */ +function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig { + const models: PiModelEntry[] = normalizeExportModels(ctx.models).map(model => { + const entry: PiModelEntry = { + id: model.namespaced, + name: exportModelLabel(model), + // Text is the one modality every routed model supports; anything richer must come + // from the catalog rather than an assumption. + input: model.inputModalities && model.inputModalities.length > 0 ? [...model.inputModalities] : ["text"], + }; + const context = authoritativeContextWindow(model.contextWindow); + if (context !== undefined) { + entry.contextWindow = context; + entry.maxTokens = outputBudgetFor(context); + } + return entry; + }); + return { + providers: { + [OPENCODE_PROVIDER_ID]: { + baseUrl: ctx.baseUrl, + api: PI_API_DIALECT, + apiKey: PI_API_KEY_ENV_REF, + models, + }, + }, + }; +} + +export const EXPORT_CLIENTS: Record = { + opencode: { + id: "opencode", + filename: "opencode.json", + destination: env => opencodeGlobalConfigPath(env), + apiKeyEnv: OPENCODE_API_KEY_ENV, + exportHint: `export ${OPENCODE_API_KEY_ENV}=`, + build: buildOpencodeClientConfig, + }, + pi: { + id: "pi", + filename: "pi-models.json", + destination: () => join(homedir(), ".pi", "agent", "models.json"), + apiKeyEnv: PI_API_KEY_ENV, + exportHint: `export ${PI_API_KEY_ENV}=`, + build: buildPiClientConfig, + }, +}; + +export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[]; + +export function isExportClientId(value: string): value is ExportClientId { + return Object.prototype.hasOwnProperty.call(EXPORT_CLIENTS, value); +} + +/** Single entry point every export surface calls. */ +export function buildClientConfig(client: ExportClientId, ctx: ExportContext): unknown { + return EXPORT_CLIENTS[client].build(ctx); +} diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index bfdfe29b3..ccb2bc1e3 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -31,6 +31,7 @@ function readInputModalities(raw: unknown): { values?: string[]; error?: string } import type { CatalogModel } from "../../codex/catalog"; import { catalogModelSlug, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { CatalogGatherBusyError } from "../../codex/catalog/provider-fetch"; import { getProviderLiveModelCount } from "../../codex/model-cache"; import { DEFAULT_SUBAGENT_MODELS, @@ -85,12 +86,127 @@ import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerS import type { PersistedUsageAttempt } from "../../usage/log"; import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO, corsHeaders } from "../auth-cors"; import { applySystemEnvToggle } from "../system-env"; +import { + EXPORT_CLIENTS, + EXPORT_CLIENT_IDS, + OPENCODE_PROVIDER_ID, + buildClientConfig, + isExportClientId, + opencodeProxyBaseUrl, +} from "../../clients/config-export"; +import type { + ExportClientId, + ExportModel, + OpencodeGeneratedConfig, + PiGeneratedConfig, +} from "../../clients/config-export"; import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared"; import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; import type { ManagementContext } from "./context"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +/** + * One row of the `/api/models` list. Routed rows spread a `CatalogModel`, so the shape is + * that model plus the identity/visibility fields this boundary computes for every row + * regardless of source. `disabled` is always present; the rest vary by row origin. + */ +type ManagementModelRow = Partial & { + provider: string; + id: string; + namespaced: string; + disabled: boolean; + native?: boolean; + custom?: boolean; + customId?: string; +}; + +/** + * The exact row list `/api/models` returns. Extracted so `/api/client-config` exports the + * models the GUI's Models tab shows — including this function's `disabled` computation, + * which the export core (src/clients/config-export.ts) deliberately does not perform. + */ +async function listManagementModelRows(config: OcxConfig): Promise { + const models = await fetchAllModels(config); + const disabled = new Set(config.disabledModels ?? []); + // Native GPT passthrough rows lead (provider "openai", bare-slug namespaced ids): sourced + // from the static supported set so a disabled model stays listed and re-enableable. + const native: ManagementModelRow[] = nativeModelRows(config).map(row => ({ + provider: "openai", + id: row.slug, + namespaced: row.slug, + disabled: row.disabled, + native: true, + ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}), + })); + const customModels: ManagementModelRow[] = (config.customModels ?? []).map(cm => { + const namespaced = routedSlug(cm.provider, cm.modelId); + return { + provider: cm.provider, + id: cm.modelId, + namespaced, + disabled: [...disabled].some(stored => slugEquals(stored, cm.provider, cm.modelId)), + custom: true, + customId: cm.id, + displayName: cm.displayName, + ...(cm.contextWindow ? { contextWindow: cm.contextWindow } : {}), + ...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}), + }; + }); + const publicModels = uniqueCatalogModelsForPublicList(models); + const comboNamespaced = new Set( + publicModels.filter(model => model.provider === "combo").map(catalogModelSlug), + ); + const visibleCustomModels = customModels.filter(model => !comboNamespaced.has(model.namespaced)); + // Custom metadata wins when a physical live/static row resolves to the same Codex-facing + // slug, while a combo keeps the same precedence it has in routing and /v1/models. + const customNamespaced = new Set(visibleCustomModels.map(c => c.namespaced)); + const dedupedRouted = publicModels.map((m): ManagementModelRow | null => { + // Codex-facing slug (one "/", slug-codec); disabledModels compares tolerate both forms. + const namespaced = catalogModelSlug(m); + if (m.provider !== "combo" && customNamespaced.has(namespaced)) return null; + const contextCap = providerContextCap(config, m.provider); + return { + ...m, + namespaced, + disabled: [...disabled].some(stored => ( + stored === namespaced || slugEquals(stored, m.provider, m.id) + )), + ...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}), + }; + }).filter((row): row is ManagementModelRow => row !== null); + return [...native, ...dedupedRouted, ...visibleCustomModels]; +} + +/** `/api/models` row → the narrower input the client-config serializers accept. */ +function toExportModel(row: ManagementModelRow): ExportModel { + return { + namespaced: row.namespaced, + provider: row.provider, + id: row.id, + ...(row.native ? { native: true } : {}), + ...(row.displayName ? { displayName: row.displayName } : {}), + ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}), + ...(row.inputModalities ? { inputModalities: row.inputModalities } : {}), + }; +} + +/** + * Counts read back off the SERIALIZED document rather than recomputed from the input rows. + * `modelsWithoutLimits` drives a GUI line claiming "these models ship without limits", so it + * has to describe the bytes the user actually receives — a parallel reimplementation of the + * core's "authoritative context window" rule would be free to drift from it silently. + */ +function summarizeExportedModels(client: ExportClientId, document: unknown): { modelCount: number; modelsWithoutLimits: number } { + if (client === "opencode") { + const models = (document as OpencodeGeneratedConfig).provider[OPENCODE_PROVIDER_ID].models; + const entries = Object.values(models); + return { modelCount: entries.length, modelsWithoutLimits: entries.filter(entry => entry.limit === undefined).length }; + } + const models = (document as PiGeneratedConfig).providers[OPENCODE_PROVIDER_ID].models; + return { modelCount: models.length, modelsWithoutLimits: models.filter(entry => entry.contextWindow === undefined).length }; +} + export async function handleModelRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx; // A handler persists the exact config object passed in. Production defaults to @@ -114,55 +230,58 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise ({ - provider: "openai", - id: row.slug, - namespaced: row.slug, - disabled: row.disabled, - native: true, - ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}), - })); - const customModels = (config.customModels ?? []).map(cm => { - const namespaced = routedSlug(cm.provider, cm.modelId); - return { - provider: cm.provider, - id: cm.modelId, - namespaced, - disabled: [...disabled].some(stored => slugEquals(stored, cm.provider, cm.modelId)), - custom: true, - customId: cm.id, - displayName: cm.displayName, - ...(cm.contextWindow ? { contextWindow: cm.contextWindow } : {}), - ...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}), - }; + return jsonResponse(await listManagementModelRows(config)); + } + + /** + * Client config document for OpenCode / Pi, built from the SAME function `ocx export` + * calls, so the bytes a user downloads here and the bytes they pipe from the CLI cannot + * disagree. Read-only: this route never writes the user's client config. + */ + if (url.pathname === "/api/client-config" && req.method === "GET") { + const requested = url.searchParams.get("client")?.trim() ?? ""; + if (!isExportClientId(requested)) { + return jsonResponse( + { error: `client must be one of: ${EXPORT_CLIENT_IDS.join(", ")}` }, + 400, + req, + config, + ); + } + const spec = EXPORT_CLIENTS[requested]; + let rows: ManagementModelRow[]; + try { + rows = await listManagementModelRows(config); + } catch (error) { + // A partial or empty `models` block reads as a valid config while offering nothing, + // so a catalog failure is surfaced as unavailable rather than serialized. The + // catalog-busy error keeps its own 503 from handleManagementAPI. + if (error instanceof CatalogGatherBusyError) throw error; + return jsonResponse( + { error: `model catalog unavailable: ${error instanceof Error ? error.message : String(error)}` }, + 503, + req, + config, + ); + } + // The export core does not filter visibility — it serializes what it is given. A model the + // user disabled in the Models tab is absent from /v1/models, so exporting it would hand the + // client a selector the proxy refuses to route. + const models = rows.filter(row => !row.disabled).map(toExportModel); + const document = buildClientConfig(requested, { + baseUrl: opencodeProxyBaseUrl(Number(url.port) || config.port, config.hostname), + models, + config, }); - const publicModels = uniqueCatalogModelsForPublicList(models); - const comboNamespaced = new Set( - publicModels.filter(model => model.provider === "combo").map(catalogModelSlug), - ); - const visibleCustomModels = customModels.filter(model => !comboNamespaced.has(model.namespaced)); - // Custom metadata wins when a physical live/static row resolves to the same Codex-facing - // slug, while a combo keeps the same precedence it has in routing and /v1/models. - const customNamespaced = new Set(visibleCustomModels.map(c => c.namespaced)); - const dedupedRouted = publicModels.map(m => { - // Codex-facing slug (one "/", slug-codec); disabledModels compares tolerate both forms. - const namespaced = catalogModelSlug(m); - if (m.provider !== "combo" && customNamespaced.has(namespaced)) return null; - const contextCap = providerContextCap(config, m.provider); - return { - ...m, - namespaced, - disabled: [...disabled].some(stored => ( - stored === namespaced || slugEquals(stored, m.provider, m.id) - )), - ...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}), - }; - }).filter(Boolean); - return jsonResponse([...native, ...dedupedRouted, ...visibleCustomModels]); + return jsonResponse({ + client: spec.id, + filename: spec.filename, + destination: spec.destination(process.env), + apiKeyEnv: spec.apiKeyEnv, + exportHint: spec.exportHint, + ...summarizeExportedModels(requested, document), + config: document, + }, 200, req, config); } // Enable/disable models: which routed models Codex sees. PUT hides them from the catalog + diff --git a/tests/cli-export-command.test.ts b/tests/cli-export-command.test.ts new file mode 100644 index 000000000..af910fac2 --- /dev/null +++ b/tests/cli-export-command.test.ts @@ -0,0 +1,303 @@ +/** + * `ocx export` CLI surface (devlog 260731_client_config_export/020 accept criteria). + * + * The serializers themselves are covered by tests/client-config-export.test.ts; this file + * covers only what the CLI boundary owns: stdout purity under --json, the human framing, + * --out overwrite refusal, argument validation, the proxy-down path, and the standing + * no-secret rule. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { handleExportCommand, exportModelsFromProxyRows } from "../src/cli/export-command"; +import type { OcxConfig } from "../src/types"; + +const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); +const cliPath = join(repoRoot, "src", "cli", "index.ts"); + +const servers: Array> = []; +const tempDirs: string[] = []; + +function config(extra?: Partial): OcxConfig { + return { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, + ...extra, + } as OcxConfig; +} + +/** Rows in the shape GET /api/models actually returns, including a disabled one. */ +const ROWS = [ + { provider: "openai", id: "gpt-5.6-luna", namespaced: "gpt-5.6-luna", native: true, disabled: false, contextWindow: 272_000 }, + { provider: "anthropic", id: "claude-opus-5", namespaced: "anthropic/claude-opus-5", disabled: false, contextWindow: 200_000, displayName: "Claude Opus 5" }, + { provider: "custom", id: "no-context", namespaced: "custom/no-context", disabled: false }, + { provider: "banned", id: "hidden", namespaced: "banned/hidden", disabled: true, contextWindow: 100_000 }, +]; + +function fakeProxy(rows: unknown = ROWS) { + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + if (url.pathname === "/api/models") return Response.json(rows); + return new Response("not found", { status: 404 }); + }, + }); + servers.push(server); + return { port: server.port, baseUrl: `http://127.0.0.1:${server.port}` }; +} + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "ocx-export-")); + tempDirs.push(dir); + return dir; +} + +let logs: string[] = []; +let errors: string[] = []; +let originalLog: typeof console.log; +let originalError: typeof console.error; + +beforeEach(() => { + logs = []; + errors = []; + originalLog = console.log; + originalError = console.error; + console.log = (...args: unknown[]) => logs.push(args.map(String).join(" ")); + console.error = (...args: unknown[]) => errors.push(args.map(String).join(" ")); +}); + +afterEach(() => { + console.log = originalLog; + console.error = originalError; + for (const server of servers.splice(0)) server.stop(true); + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +/** console.log adds exactly one newline per call; this is the byte stream a shell sees. */ +function stdout(): string { + return logs.map(line => `${line}\n`).join(""); +} + +async function run(args: string[], extra: { baseUrl: string; config?: OcxConfig }) { + const code = await handleExportCommand(args, { + baseUrl: extra.baseUrl, + configImpl: () => extra.config ?? config(), + }); + return { code, stdout: stdout(), stderr: errors.join("\n") }; +} + +describe("ocx export --json (accept criterion 1)", () => { + test("stdout parses as JSON with zero extra bytes, for both clients", async () => { + const proxy = fakeProxy(); + for (const client of ["opencode", "pi"] as const) { + logs = []; + errors = []; + const result = await run(["--client", client, "--json"], { baseUrl: proxy.baseUrl }); + expect(result.code).toBe(0); + // The whole buffer, not a trimmed slice: a banner or path hint would throw here. + const parsed = JSON.parse(result.stdout) as Record; + expect(parsed).toBeTruthy(); + expect(result.stdout).toBe(`${JSON.stringify(parsed, null, 2)}\n`); + } + }); + + test("the exported endpoint points at the live proxy port, not config.port", async () => { + const proxy = fakeProxy(); + const result = await run(["--client", "opencode", "--json"], { baseUrl: proxy.baseUrl }); + const parsed = JSON.parse(result.stdout) as { provider: Record }; + expect(parsed.provider.opencodex!.options.baseURL).toBe(`http://127.0.0.1:${proxy.port}/v1`); + expect(parsed.provider.opencodex!.options.baseURL).not.toContain(":10100/"); + }); + + test("disabled rows never reach the exported config", async () => { + const proxy = fakeProxy(); + const result = await run(["--client", "pi", "--json"], { baseUrl: proxy.baseUrl }); + const parsed = JSON.parse(result.stdout) as { providers: Record }> }; + const ids = parsed.providers.opencodex!.models.map(model => model.id); + expect(ids).not.toContain("banned/hidden"); + expect(ids).toEqual(["anthropic/claude-opus-5", "custom/no-context", "gpt-5.6-luna"]); + }); +}); + +describe("ocx export human output (accept criterion 2)", () => { + test("leads with the JSON, then destination, merge warning, env line, and counts", async () => { + const proxy = fakeProxy(); + const result = await run(["--client", "opencode"], { baseUrl: proxy.baseUrl }); + + expect(result.code).toBe(0); + expect(result.stdout.startsWith("{\n")).toBe(true); + expect(result.stdout).toContain(join("opencode", "opencode.json")); + expect(result.stdout).toContain("Merge this provider block into that file; do not replace it."); + expect(result.stdout).toContain("export OPENCODEX_OPENCODE_API_KEY="); + // Three visible models; only `custom/no-context` lacks an authoritative window. + expect(result.stdout).toContain("3 models; 1 omit context limits"); + }); + + test("Pi names its own destination and env var", async () => { + const proxy = fakeProxy(); + const result = await run(["--client", "pi"], { baseUrl: proxy.baseUrl }); + expect(result.stdout).toContain(join(".pi", "agent", "models.json")); + expect(result.stdout).toContain("export OPENCODEX_API_KEY="); + }); +}); + +describe("ocx export --out (accept criterion 3)", () => { + test("writes the config to the given path", async () => { + const proxy = fakeProxy(); + const target = join(tempDir(), "opencode.json"); + const result = await run(["--client", "opencode", "--json", "--out", target], { baseUrl: proxy.baseUrl }); + + expect(result.code).toBe(0); + expect(JSON.parse(readFileSync(target, "utf8"))).toEqual(JSON.parse(result.stdout)); + // The write note is a diagnostic; it must not pollute the --json byte stream. + expect(result.stderr).toContain(`Wrote ${target}`); + }); + + test("refuses to overwrite an existing file and leaves the bytes untouched", async () => { + const proxy = fakeProxy(); + const target = join(tempDir(), "populated.json"); + const original = '{"provider":{"someone-elses":{"models":{}}}}'; + writeFileSync(target, original, "utf8"); + + const result = await run(["--client", "opencode", "--out", target], { baseUrl: proxy.baseUrl }); + + expect(result.code).not.toBe(0); + expect(readFileSync(target, "utf8")).toBe(original); + expect(result.stderr).toContain("--force"); + expect(result.stdout).toBe(""); + }); + + test("--force replaces it", async () => { + const proxy = fakeProxy(); + const target = join(tempDir(), "populated.json"); + writeFileSync(target, '{"provider":{"someone-elses":{"models":{}}}}', "utf8"); + + const result = await run(["--client", "opencode", "--out", target, "--force"], { baseUrl: proxy.baseUrl }); + + expect(result.code).toBe(0); + const written = JSON.parse(readFileSync(target, "utf8")) as { provider: Record }; + expect(Object.keys(written.provider)).toEqual(["opencodex"]); + }); + + test("without --out nothing is written to the real destination path", async () => { + const proxy = fakeProxy(); + const dir = tempDir(); + const result = await run(["--client", "opencode", "--json"], { + baseUrl: proxy.baseUrl, + }); + expect(result.code).toBe(0); + // No --out means no file anywhere: the destination is text the user acts on. + expect(existsSync(join(dir, "opencode.json"))).toBe(false); + }); +}); + +describe("ocx export argument validation (accept criterion 4)", () => { + test("an unknown --client names both valid values", async () => { + const proxy = fakeProxy(); + const result = await run(["--client", "cursor"], { baseUrl: proxy.baseUrl }); + expect(result.code).toBe(2); + expect(result.stderr).toContain("opencode"); + expect(result.stderr).toContain("pi"); + expect(result.stdout).toBe(""); + }); + + test("a missing --client is a usage error", async () => { + const proxy = fakeProxy(); + const result = await run([], { baseUrl: proxy.baseUrl }); + expect(result.code).toBe(2); + expect(result.stderr).toContain("--client is required"); + }); + + test("stray arguments are rejected rather than ignored", async () => { + const proxy = fakeProxy(); + const result = await run(["--client", "pi", "--wat"], { baseUrl: proxy.baseUrl }); + expect(result.code).toBe(2); + expect(result.stderr).toContain("--wat"); + }); +}); + +describe("ocx export with no live proxy (accept criterion 5)", () => { + /** + * Run through the real dispatcher in a subprocess with an isolated OPENCODEX_HOME whose + * configured port has no listener. In-process injection cannot cover this: `findLiveProxy` + * reads the pid file and config directly, so a proxy running on the developer's machine + * would be discovered and the assertion would pass for the wrong reason. + */ + test("fails through the runtime-api error naming ocx start, emitting no config", () => { + const probe = Bun.serve({ port: 0, fetch: () => new Response("") }); + const deadPort = probe.port; + probe.stop(true); + + const home = tempDir(); + // Must pass config validation: a rejected config falls back to the DEFAULT config, whose + // port 10100 may host the developer's own proxy — the test would then probe a live one. + writeFileSync( + join(home, "config.json"), + JSON.stringify({ + port: deadPort, + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1", allowPrivateNetwork: true } }, + }), + "utf8", + ); + + const result = spawnSync(process.execPath, [cliPath, "export", "--client", "opencode", "--json"], { + cwd: repoRoot, + env: { ...process.env, OPENCODEX_HOME: home }, + encoding: "utf8", + }); + + expect(result.status).not.toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("ocx start"); + // Routed by the dispatcher, not swallowed by the unknown-command branch. + expect(result.stderr).not.toContain("Unknown command"); + }, { timeout: 30_000 }); + + test("a non-array /api/models payload is an error, not an empty-model config", async () => { + const proxy = fakeProxy({ error: "unauthorized" }); + const result = await run(["--client", "opencode", "--json"], { baseUrl: proxy.baseUrl }); + expect(result.code).not.toBe(0); + expect(result.stdout).toBe(""); + }); +}); + +describe("ocx export never serializes a key (accept criterion 6)", () => { + test("no stdout path contains an ocx_ token even when config carries one", async () => { + const proxy = fakeProxy(); + const withKey = config({ apiKeys: [{ id: "k1", name: "default", key: "ocx_liveSecretValue" }] } as Partial); + for (const [args, envRef] of [ + [["--client", "opencode"], "{env:OPENCODEX_OPENCODE_API_KEY}"], + [["--client", "opencode", "--json"], "{env:OPENCODEX_OPENCODE_API_KEY}"], + [["--client", "pi"], "$OPENCODEX_API_KEY"], + [["--client", "pi", "--json"], "$OPENCODEX_API_KEY"], + ] as Array<[string[], string]>) { + logs = []; + errors = []; + const result = await run(args, { baseUrl: proxy.baseUrl, config: withKey }); + expect(result.code).toBe(0); + expect(result.stdout).not.toContain("ocx_"); + // The env REFERENCE is present; the value it stands for never is. + expect(result.stdout).toContain(envRef); + } + }); +}); + +describe("export row filtering", () => { + test("drops disabled rows, dedupes, and carries modalities through to Pi", () => { + const models = exportModelsFromProxyRows([ + { provider: "a", id: "one", namespaced: "a/one", disabled: true }, + { provider: "a", id: "two", namespaced: "a/two", inputModalities: ["text", "image"] }, + { provider: "a", id: "two", namespaced: "a/two", displayName: "duplicate" }, + ], config()); + expect(models).toHaveLength(1); + expect(models[0]!.namespaced).toBe("a/two"); + expect(models[0]!.inputModalities).toEqual(["text", "image"]); + }); +}); diff --git a/tests/cli-headless-parity.test.ts b/tests/cli-headless-parity.test.ts index fbb87a800..746efce3a 100644 --- a/tests/cli-headless-parity.test.ts +++ b/tests/cli-headless-parity.test.ts @@ -71,6 +71,7 @@ describe("headless GUI parity CLI", () => { ["/api/custom-models", "ocx models"], ["/api/model", "ocx models"], ["/api/combos", "ocx combo"], + ["/api/client-config", "ocx export"], ["/api/debug", "ocx debug/observe"], ["/api/diagnostics", "ocx system"], ["/api/effort", "ocx agent"], diff --git a/tests/client-config-export.test.ts b/tests/client-config-export.test.ts new file mode 100644 index 000000000..c15ff641d --- /dev/null +++ b/tests/client-config-export.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, test } from "bun:test"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + EXPORT_CLIENTS, + EXPORT_CLIENT_IDS, + OPENCODE_API_KEY_ENV, + OPENCODE_API_KEY_ENV_REF, + PI_API_KEY_ENV, + PI_API_KEY_ENV_REF, + SCHEMA_REQUIRED_OUTPUT_BUDGET, + buildClientConfig, + isExportClientId, + normalizeExportModels, + type ExportContext, + type ExportModel, + type OpencodeGeneratedConfig, + type PiGeneratedConfig, +} from "../src/clients/config-export"; +import { buildOpencodeProviderBlockFromCatalog, opencodeGlobalConfigPath } from "../src/cli/opencode"; +import type { OcxConfig } from "../src/types"; + +/** + * Fixture covering the four rows that exercise every emission branch: native, + * routed-with-displayName, missing context window, and a context window below the + * schema output budget (which must clamp). + */ +const FIXTURE: ExportModel[] = [ + { namespaced: "gpt-5.6-luna", native: true, provider: "openai", id: "gpt-5.6-luna", contextWindow: 272_000 }, + { namespaced: "anthropic/claude-opus-5", provider: "anthropic", id: "claude-opus-5", contextWindow: 200_000, displayName: "Claude Opus 5" }, + { namespaced: "custom/no-context", provider: "custom", id: "no-context" }, + { namespaced: "tiny/small-ctx", provider: "tiny", id: "small-ctx", contextWindow: 8_000 }, +]; + +const BASE_URL = "http://127.0.0.1:10100/v1"; + +function ctx(extra?: Partial): ExportContext { + return { baseUrl: BASE_URL, models: FIXTURE, ...extra }; +} + +function cfg(extra?: Partial): OcxConfig { + return { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, + ...extra, + } as OcxConfig; +} + +/** + * Captured from `buildOpencodeProviderBlockFromCatalog` BEFORE the serializer moved to + * src/clients/config-export.ts, for the fixture above at port 10100 / 127.0.0.1. Inlined + * rather than read from a file so the assertion survives without scratch state. + * + * The relocated builder must reproduce this byte-for-byte; the client-config path adds a + * dedupe+sort precondition, so it is compared entry-by-entry against the same truth. + */ +const GOLDEN_OPENCODE_BLOCK = JSON.parse( + '{"npm":"@ai-sdk/openai-compatible","name":"OpenCodex","options":{"baseURL":"http://127.0.0.1:10100/v1","apiKey":"{env:OPENCODEX_OPENCODE_API_KEY}"},"models":{"gpt-5.6-luna":{"name":"gpt-5.6-luna (native)","limit":{"context":272000,"output":32000}},"anthropic/claude-opus-5":{"name":"Claude Opus 5 (anthropic)","limit":{"context":200000,"output":32000}},"custom/no-context":{"name":"no-context (custom)"},"tiny/small-ctx":{"name":"small-ctx (tiny)","limit":{"context":8000,"output":8000}}}}', +) as { + npm: string; + name: string; + options: Record; + models: Record; +}; + +function opencodeConfig(context: ExportContext = ctx()): OpencodeGeneratedConfig { + return buildClientConfig("opencode", context) as OpencodeGeneratedConfig; +} + +function piConfig(context: ExportContext = ctx()): PiGeneratedConfig { + return buildClientConfig("pi", context) as PiGeneratedConfig; +} + +describe("relocated OpenCode serializer (accept criterion 1)", () => { + test("the moved builder reproduces the pre-refactor golden byte-for-byte", () => { + const block = buildOpencodeProviderBlockFromCatalog(10100, FIXTURE, "127.0.0.1"); + expect(JSON.stringify(block)).toBe(JSON.stringify(GOLDEN_OPENCODE_BLOCK)); + }); + + test("buildClientConfig('opencode') emits the same entries as the golden", () => { + const block = opencodeConfig().provider.opencodex!; + expect(block.npm).toBe(GOLDEN_OPENCODE_BLOCK.npm); + expect(block.name).toBe(GOLDEN_OPENCODE_BLOCK.name); + expect(block.options).toEqual(GOLDEN_OPENCODE_BLOCK.options); + // Sort order differs from the golden by design (dedupe+sort precondition); the + // per-entry values are the truth being preserved. + expect(Object.keys(block.models).sort()).toEqual(Object.keys(GOLDEN_OPENCODE_BLOCK.models).sort()); + for (const [key, expected] of Object.entries(GOLDEN_OPENCODE_BLOCK.models)) { + expect(block.models[key]).toEqual(expected); + } + }); + + test("carries the V1 schema and only the opencodex provider key", () => { + const config = opencodeConfig(); + expect(config.$schema).toBe("https://opencode.ai/config.json"); + expect(Object.keys(config.provider)).toEqual(["opencodex"]); + }); + + test("a non-loopback bind moves admission to the header branch", () => { + const block = opencodeConfig(ctx({ config: cfg({ hostname: "0.0.0.0" }) })).provider.opencodex!; + expect(block.options.apiKey).toBeUndefined(); + expect(block.options.headers).toEqual({ "x-opencodex-api-key": OPENCODE_API_KEY_ENV_REF }); + }); + + test("a loopback bind keeps the apiKey branch", () => { + const block = opencodeConfig(ctx({ config: cfg({ hostname: "127.0.0.1" }) })).provider.opencodex!; + expect(block.options.apiKey).toBe(OPENCODE_API_KEY_ENV_REF); + expect(block.options.headers).toBeUndefined(); + }); + + test("the label carries the provider suffix, not a bare display name", () => { + const models = opencodeConfig().provider.opencodex!.models; + expect(models["gpt-5.6-luna"]!.name).toBe("gpt-5.6-luna (native)"); + expect(models["anthropic/claude-opus-5"]!.name).toBe("Claude Opus 5 (anthropic)"); + expect(models["custom/no-context"]!.name).toBe("no-context (custom)"); + }); + + test("a catalog row with no provider falls back to the routed label", () => { + // Nullish fallback, preserved verbatim from the pre-move builder: an ABSENT provider + // becomes "routed", while an empty string is passed through unchanged. + const block = buildOpencodeProviderBlockFromCatalog(10100, [ + { namespaced: "mystery", id: "mystery" }, + { namespaced: "blank", id: "blank", provider: "" }, + ], "127.0.0.1"); + expect(block.models["mystery"]!.name).toBe("mystery (routed)"); + expect(block.models["blank"]!.name).toBe("blank ()"); + }); + + test("a row whose id is absent labels from the namespaced selector", () => { + const block = buildOpencodeProviderBlockFromCatalog(10100, [ + { namespaced: "acme/only-namespaced", provider: "acme" }, + ], "127.0.0.1"); + expect(block.models["acme/only-namespaced"]!.name).toBe("acme/only-namespaced (acme)"); + }); + + test("a non-positive or non-finite context window drops the limit block entirely", () => { + const block = buildOpencodeProviderBlockFromCatalog(10100, [ + { namespaced: "a/zero", provider: "a", id: "zero", contextWindow: 0 }, + { namespaced: "b/negative", provider: "b", id: "negative", contextWindow: -1 }, + { namespaced: "c/nan", provider: "c", id: "nan", contextWindow: Number.NaN }, + ], "127.0.0.1"); + for (const entry of Object.values(block.models)) { + expect(entry.limit).toBeUndefined(); + } + }); +}); + +describe("Pi serializer (accept criterion 2)", () => { + test("models is an array keyed by id, not a keyed object", () => { + const provider = piConfig().providers.opencodex!; + expect(Array.isArray(provider.models)).toBe(true); + expect(provider.models.map(model => model.id)).toEqual([ + "anthropic/claude-opus-5", + "custom/no-context", + "gpt-5.6-luna", + "tiny/small-ctx", + ]); + }); + + test("provider envelope names the OpenAI-compatible dialect and the env reference", () => { + const provider = piConfig().providers.opencodex!; + expect(provider.baseUrl).toBe(BASE_URL); + expect(provider.api).toBe("openai-completions"); + expect(provider.apiKey).toBe(PI_API_KEY_ENV_REF); + expect(provider.apiKey).toBe("$OPENCODEX_API_KEY"); + }); + + test("cost is omitted on every entry — zeros would assert routed models are free", () => { + for (const model of piConfig().providers.opencodex!.models) { + expect(model).not.toHaveProperty("cost"); + } + expect(JSON.stringify(piConfig())).not.toContain("cost"); + }); + + test("reasoning is omitted — an effort list is not Pi's boolean", () => { + for (const model of piConfig().providers.opencodex!.models) { + expect(model).not.toHaveProperty("reasoning"); + } + }); + + test("contextWindow and maxTokens are omitted when the context window is unknown", () => { + const entry = piConfig().providers.opencodex!.models.find(model => model.id === "custom/no-context")!; + expect(entry).not.toHaveProperty("contextWindow"); + expect(entry).not.toHaveProperty("maxTokens"); + expect(entry).toEqual({ id: "custom/no-context", name: "no-context (custom)", input: ["text"] }); + }); + + test("maxTokens uses the schema budget and clamps to a smaller context window", () => { + const models = piConfig().providers.opencodex!.models; + const large = models.find(model => model.id === "gpt-5.6-luna")!; + expect(large.contextWindow).toBe(272_000); + expect(large.maxTokens).toBe(SCHEMA_REQUIRED_OUTPUT_BUDGET); + const small = models.find(model => model.id === "tiny/small-ctx")!; + expect(small.contextWindow).toBe(8_000); + expect(small.maxTokens).toBe(8_000); + }); + + test("input defaults to text and passes through declared modalities", () => { + const config = piConfig(ctx({ + models: [ + { namespaced: "a/plain", provider: "a", id: "plain" }, + { namespaced: "b/multi", provider: "b", id: "multi", inputModalities: ["text", "image"] }, + { namespaced: "c/empty", provider: "c", id: "empty", inputModalities: [] }, + ], + })); + const models = config.providers.opencodex!.models; + expect(models.find(model => model.id === "a/plain")!.input).toEqual(["text"]); + expect(models.find(model => model.id === "b/multi")!.input).toEqual(["text", "image"]); + expect(models.find(model => model.id === "c/empty")!.input).toEqual(["text"]); + }); + + test("Pi reuses the OpenCode label rule verbatim", () => { + const piNames = piConfig().providers.opencodex!.models.map(model => model.name).sort(); + const opencodeNames = Object.values(opencodeConfig().provider.opencodex!.models) + .map(entry => entry.name) + .sort(); + expect(piNames).toEqual(opencodeNames); + }); +}); + +describe("no credential ever reaches the output (accept criterion 3)", () => { + const LIVE_KEY = "ocx_live_do_not_serialize_0123456789"; + + test("neither serializer emits an ocx_ key even when one exists in config", () => { + const withKey = cfg({ apiKeys: [{ key: LIVE_KEY }] } as Partial); + const context = ctx({ config: withKey }); + for (const client of EXPORT_CLIENT_IDS) { + const serialized = JSON.stringify(buildClientConfig(client, context)); + expect(serialized).not.toContain("ocx_"); + expect(serialized).not.toContain(LIVE_KEY); + } + }); + + test("each client emits only its own documented env reference", () => { + expect(JSON.stringify(opencodeConfig())).toContain(OPENCODE_API_KEY_ENV_REF); + expect(JSON.stringify(piConfig())).toContain(PI_API_KEY_ENV_REF); + expect(JSON.stringify(piConfig())).not.toContain("{env:"); + }); +}); + +describe("stable ordering (accept criterion 4)", () => { + const shuffled: ExportModel[] = [FIXTURE[3]!, FIXTURE[1]!, FIXTURE[0]!, FIXTURE[2]!]; + + test("shuffled input produces byte-identical output for both clients", () => { + for (const client of EXPORT_CLIENT_IDS) { + const first = JSON.stringify(buildClientConfig(client, ctx())); + const second = JSON.stringify(buildClientConfig(client, ctx({ models: shuffled }))); + expect(second).toBe(first); + } + }); + + test("repeated calls with the same input are byte-identical", () => { + for (const client of EXPORT_CLIENT_IDS) { + expect(JSON.stringify(buildClientConfig(client, ctx()))).toBe(JSON.stringify(buildClientConfig(client, ctx()))); + } + }); + + test("normalizeExportModels dedupes by namespaced with first-wins and sorts", () => { + const normalized = normalizeExportModels([ + { namespaced: "z/last", provider: "z", id: "last" }, + { namespaced: "a/dup", provider: "a", id: "dup", displayName: "First wins" }, + { namespaced: "a/dup", provider: "a", id: "dup", displayName: "Second loses" }, + ]); + expect(normalized.map(model => model.namespaced)).toEqual(["a/dup", "z/last"]); + expect(normalized[0]!.displayName).toBe("First wins"); + }); + + test("a duplicate namespaced row is emitted once by both serializers", () => { + const dupes = ctx({ + models: [ + { namespaced: "gpt-5.6-luna", native: true, provider: "openai", id: "gpt-5.6-luna", contextWindow: 272_000 }, + { namespaced: "gpt-5.6-luna", provider: "openai", id: "gpt-5.6-luna", displayName: "Shadow" }, + ], + }); + expect(Object.keys(opencodeConfig(dupes).provider.opencodex!.models)).toEqual(["gpt-5.6-luna"]); + expect(piConfig(dupes).providers.opencodex!.models).toHaveLength(1); + expect(piConfig(dupes).providers.opencodex!.models[0]!.name).toBe("gpt-5.6-luna (native)"); + }); +}); + +describe("EXPORT_CLIENTS registry", () => { + test("covers exactly the two supported clients", () => { + expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi"]); + expect(isExportClientId("opencode")).toBe(true); + expect(isExportClientId("pi")).toBe(true); + expect(isExportClientId("claude-desktop")).toBe(false); + }); + + test("each spec's id matches its registry key", () => { + for (const id of EXPORT_CLIENT_IDS) { + expect(EXPORT_CLIENTS[id].id).toBe(id); + } + }); + + test("filenames name the destination file, not the product", () => { + expect(EXPORT_CLIENTS.opencode.filename).toBe("opencode.json"); + expect(EXPORT_CLIENTS.pi.filename).toBe("pi-models.json"); + }); + + test("the opencode destination reuses the launcher's XDG resolution", () => { + const xdg = { XDG_CONFIG_HOME: "/xdg" } as NodeJS.ProcessEnv; + expect(EXPORT_CLIENTS.opencode.destination(xdg)).toBe(opencodeGlobalConfigPath(xdg)); + expect(EXPORT_CLIENTS.opencode.destination(xdg)).toBe(join("/xdg", "opencode", "opencode.json")); + const noXdg = {} as NodeJS.ProcessEnv; + expect(EXPORT_CLIENTS.opencode.destination(noXdg)).toBe(join(homedir(), ".config", "opencode", "opencode.json")); + }); + + test("the pi destination is the documented global models file", () => { + expect(EXPORT_CLIENTS.pi.destination({} as NodeJS.ProcessEnv)).toBe(join(homedir(), ".pi", "agent", "models.json")); + }); + + test("apiKeyEnv and exportHint name the variable the config references", () => { + expect(EXPORT_CLIENTS.opencode.apiKeyEnv).toBe(OPENCODE_API_KEY_ENV); + expect(EXPORT_CLIENTS.opencode.exportHint).toContain(OPENCODE_API_KEY_ENV); + expect(EXPORT_CLIENTS.pi.apiKeyEnv).toBe(PI_API_KEY_ENV); + expect(EXPORT_CLIENTS.pi.exportHint).toContain(PI_API_KEY_ENV); + for (const id of EXPORT_CLIENT_IDS) { + expect(EXPORT_CLIENTS[id].exportHint).not.toContain("ocx_"); + } + }); + + test("build() on a spec matches buildClientConfig for the same context", () => { + for (const id of EXPORT_CLIENT_IDS) { + expect(JSON.stringify(EXPORT_CLIENTS[id].build(ctx()))).toBe(JSON.stringify(buildClientConfig(id, ctx()))); + } + }); + + test("an empty catalog still yields a structurally valid document", () => { + const empty = ctx({ models: [] }); + expect(opencodeConfig(empty).provider.opencodex!.models).toEqual({}); + expect(piConfig(empty).providers.opencodex!.models).toEqual([]); + }); +}); diff --git a/tests/management-client-config-route.test.ts b/tests/management-client-config-route.test.ts new file mode 100644 index 000000000..a6d296688 --- /dev/null +++ b/tests/management-client-config-route.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, test } from "bun:test"; +import { handleManagementAPI } from "../src/server/management-api"; +import { + OPENCODE_API_KEY_ENV, + OPENCODE_CONFIG_SCHEMA, + OPENCODE_PROVIDER_ID, + PI_API_KEY_ENV, + PI_API_KEY_ENV_REF, + buildClientConfig, + normalizeExportModels, + opencodeGlobalConfigPath, + type ExportModel, + type OpencodeGeneratedConfig, + type PiGeneratedConfig, +} from "../src/clients/config-export"; +import type { OcxConfig } from "../src/types"; + +/** + * A key that looks exactly like a real one. Every assertion about `ocx_` absence is + * worthless unless the running config actually holds a serializable secret (030 §Security). + */ +const REAL_LOOKING_KEY = "ocx_live_9f3c7a2b41d84e6fa05c8e17b3d92764"; + +interface ClientConfigEnvelope { + client: string; + filename: string; + destination: string; + apiKeyEnv: string; + exportHint: string; + modelCount: number; + modelsWithoutLimits: number; + config: unknown; +} + +interface ModelRow { + provider: string; + id: string; + namespaced: string; + disabled: boolean; + native?: boolean; + displayName?: string; + contextWindow?: number; + inputModalities?: string[]; +} + +/** + * Static provider catalogs (`liveModels: false`) so the model list is deterministic and no + * test ever reaches the network. `b/no-context` carries no context window, which is what + * makes `modelsWithoutLimits` non-zero and therefore actually assertable. + */ +function baseConfig(overrides: Partial = {}): OcxConfig { + return { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "a", + apiKeys: [{ id: "key-1", name: "default", key: REAL_LOOKING_KEY, createdAt: new Date(0).toISOString() }], + providers: { + a: { + adapter: "openai-chat", + baseUrl: "https://a.example/v1", + apiKey: REAL_LOOKING_KEY, + liveModels: false, + models: ["m1", "m2"], + modelContextWindows: { m1: 128_000 }, + }, + b: { + adapter: "openai-chat", + baseUrl: "https://b.example/v1", + apiKey: REAL_LOOKING_KEY, + liveModels: false, + models: ["no-context"], + }, + }, + ...overrides, + } as OcxConfig; +} + +async function clientConfigApi(config: OcxConfig, query: string): Promise { + const url = new URL(`http://127.0.0.1:10100/api/client-config${query}`); + const response = await handleManagementAPI( + new Request(url, { headers: { Host: url.host } }), + url, + config, + { saveConfigPreservingClaudeCode: () => {}, refreshCodexCatalog: async () => {} }, + ); + expect(response).not.toBeNull(); + return response!; +} + +async function modelRows(config: OcxConfig): Promise { + const url = new URL("http://127.0.0.1:10100/api/models"); + const response = await handleManagementAPI( + new Request(url, { headers: { Host: url.host } }), + url, + config, + { saveConfigPreservingClaudeCode: () => {}, refreshCodexCatalog: async () => {} }, + ); + return await response!.json() as ModelRow[]; +} + +function toExportModel(row: ModelRow): ExportModel { + return { + namespaced: row.namespaced, + provider: row.provider, + id: row.id, + ...(row.native ? { native: true } : {}), + ...(row.displayName ? { displayName: row.displayName } : {}), + ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}), + ...(row.inputModalities ? { inputModalities: row.inputModalities } : {}), + }; +} + +describe("GET /api/client-config", () => { + test("opencode envelope carries the shared builder's exact bytes", async () => { + const config = baseConfig(); + const response = await clientConfigApi(config, "?client=opencode"); + expect(response.status).toBe(200); + const body = await response.json() as ClientConfigEnvelope; + + expect(body.client).toBe("opencode"); + expect(body.filename).toBe("opencode.json"); + expect(body.destination).toBe(opencodeGlobalConfigPath(process.env)); + expect(body.apiKeyEnv).toBe(OPENCODE_API_KEY_ENV); + expect(body.exportHint).toBe(`export ${OPENCODE_API_KEY_ENV}=`); + + // Accept criterion 1: the route's `config` must equal what the shared builder produces + // for the same input, so the GUI download and `ocx export` can never disagree. + const rows = await modelRows(config); + const expected = buildClientConfig("opencode", { + baseUrl: "http://127.0.0.1:10100/v1", + models: rows.filter(row => !row.disabled).map(toExportModel), + config, + }); + expect(body.config).toEqual(expected as Record); + expect(JSON.stringify(body.config)).toBe(JSON.stringify(expected)); + + const document = body.config as OpencodeGeneratedConfig; + expect(document.$schema).toBe(OPENCODE_CONFIG_SCHEMA); + const models = document.provider[OPENCODE_PROVIDER_ID].models; + expect(models["a/m1"]).toEqual({ name: "m1 (a)", limit: { context: 128_000, output: 32_000 } }); + expect(models["b/no-context"]).toEqual({ name: "no-context (b)" }); + }, 15_000); + + test("pi returns a models ARRAY under the same provider id", async () => { + const response = await clientConfigApi(baseConfig(), "?client=pi"); + expect(response.status).toBe(200); + const body = await response.json() as ClientConfigEnvelope; + + expect(body.client).toBe("pi"); + expect(body.filename).toBe("pi-models.json"); + expect(body.apiKeyEnv).toBe(PI_API_KEY_ENV); + + const provider = (body.config as PiGeneratedConfig).providers[OPENCODE_PROVIDER_ID]; + expect(Array.isArray(provider.models)).toBe(true); + expect(provider.apiKey).toBe(PI_API_KEY_ENV_REF); + expect(provider.baseUrl).toBe("http://127.0.0.1:10100/v1"); + expect(provider.models.map(model => model.id)).toContain("a/m1"); + }, 15_000); + + test("counts describe the emitted document, including models without limits", async () => { + const config = baseConfig(); + const opencode = await (await clientConfigApi(config, "?client=opencode")).json() as ClientConfigEnvelope; + const models = (opencode.config as OpencodeGeneratedConfig).provider[OPENCODE_PROVIDER_ID].models; + const entries = Object.values(models); + + expect(opencode.modelCount).toBe(entries.length); + expect(opencode.modelsWithoutLimits).toBe(entries.filter(entry => entry.limit === undefined).length); + // Non-zero, or the assertion above would hold vacuously for a fixture with limits everywhere. + expect(opencode.modelsWithoutLimits).toBeGreaterThan(0); + + const pi = await (await clientConfigApi(config, "?client=pi")).json() as ClientConfigEnvelope; + const piModels = (pi.config as PiGeneratedConfig).providers[OPENCODE_PROVIDER_ID].models; + expect(pi.modelCount).toBe(piModels.length); + expect(pi.modelsWithoutLimits).toBe(piModels.filter(model => model.contextWindow === undefined).length); + }, 15_000); + + test("disabled models are filtered before the config is built", async () => { + const enabled = await (await clientConfigApi(baseConfig(), "?client=opencode")).json() as ClientConfigEnvelope; + const enabledModels = (enabled.config as OpencodeGeneratedConfig).provider[OPENCODE_PROVIDER_ID].models; + expect(Object.keys(enabledModels)).toContain("a/m2"); + + // The export core (src/clients/config-export.ts) does not filter visibility; this route + // must, or a user's hidden model ships as a selector the proxy refuses to route. + const response = await clientConfigApi(baseConfig({ disabledModels: ["a/m2"] }), "?client=opencode"); + const body = await response.json() as ClientConfigEnvelope; + const models = (body.config as OpencodeGeneratedConfig).provider[OPENCODE_PROVIDER_ID].models; + expect(Object.keys(models)).not.toContain("a/m2"); + expect(Object.keys(models)).toContain("a/m1"); + expect(body.modelCount).toBe(enabled.modelCount - 1); + }, 15_000); + + test("model order and dedupe are stable across repeated calls", async () => { + const config = baseConfig(); + const first = await (await clientConfigApi(config, "?client=opencode")).json() as ClientConfigEnvelope; + const second = await (await clientConfigApi(config, "?client=opencode")).json() as ClientConfigEnvelope; + expect(JSON.stringify(first.config)).toBe(JSON.stringify(second.config)); + + const keys = Object.keys((first.config as OpencodeGeneratedConfig).provider[OPENCODE_PROVIDER_ID].models); + expect(keys).toEqual(normalizeExportModels(keys.map(key => ({ namespaced: key, provider: "x", id: key }))).map(m => m.namespaced)); + }, 15_000); + + test("no response body serializes a real key", async () => { + const config = baseConfig(); + // Precondition: the secret really is present in the config this route reads. + expect(JSON.stringify(config)).toContain("ocx_"); + + for (const client of ["opencode", "pi"]) { + const raw = await (await clientConfigApi(config, `?client=${client}`)).text(); + expect(raw).not.toContain("ocx_"); + expect(raw).not.toContain(REAL_LOOKING_KEY); + } + }, 15_000); + + test("missing or unknown client is a 400 naming both valid values", async () => { + for (const query of ["", "?client=", "?client=zed", "?client=OpenCode", "?client=%20"]) { + const response = await clientConfigApi(baseConfig(), query); + expect(response.status).toBe(400); + const body = await response.json() as { error: string }; + expect(body.error).toContain("opencode"); + expect(body.error).toContain("pi"); + } + }, 15_000); + + test("a catalog failure is 503, never a partial 200", async () => { + const config = baseConfig(); + // A provider whose enumeration throws stands in for an unavailable catalog; the route + // must refuse rather than emit a config missing that provider's models. + Object.defineProperty(config, "providers", { + get() { throw new Error("catalog offline"); }, + configurable: true, + }); + + const response = await clientConfigApi(config, "?client=opencode"); + expect(response.status).toBe(503); + const body = await response.json() as { error: string; config?: unknown }; + expect(body.error).toContain("catalog offline"); + expect(body.config).toBeUndefined(); + }, 15_000); + + test("cross-origin admission is unchanged from every other /api route", async () => { + const url = new URL("http://127.0.0.1:10100/api/client-config?client=opencode"); + const response = await handleManagementAPI( + new Request(url, { headers: { Host: url.host, Origin: "https://evil.example" } }), + url, + baseConfig(), + { saveConfigPreservingClaudeCode: () => {}, refreshCodexCatalog: async () => {} }, + ); + expect(response?.status).toBe(403); + }, 15_000); +});