Skip to content

Commit eeef701

Browse files
authored
feat(export): client config export for Pi and OpenCode (#852)
* feat(export): add client-neutral config export core Phase 010 of devlog/_plan/260731_client_config_export. Lifts the OpenCode provider-block builder out of the launcher into a client-neutral module and adds a Pi serializer beside it. `ocx opencode` keeps every export it had and its launch path is untouched; the builder now has one body shared by the launcher and future export surfaces. The OpenCode output is pinned by a golden captured from the pre-refactor implementation. Pi emits a models array, always omits `cost` (we have no price data and zeros would assert "free"), and omits context/maxTokens when no authoritative context window exists. Neither serializer ever emits a live key: OpenCode carries `{env:OPENCODEX_OPENCODE_API_KEY}`, Pi carries `$OPENCODEX_API_KEY`. * feat(export): add the ocx export CLI command Phase 020 of devlog/_plan/260731_client_config_export. `ocx export --client <opencode|pi>` prints the client config. With --json stdout is the config and nothing else, so an agent can pipe it; without it, the JSON is followed by the destination path, the merge warning, the env line, and a count of models shipping without context limits. --out writes the file but refuses to clobber an existing one without --force, since the obvious target is a populated config whose other providers would be lost. Disabled rows are filtered here, at the /api/models boundary: the export core dedupes and sorts but cannot see visibility state. A non-array models payload is an error rather than an empty-model config. Also corrects two example paths in the plan docs that tripped privacy:scan. * feat(export): serve client configs from the management API Phase 030 of devlog/_plan/260731_client_config_export. GET /api/client-config?client=<opencode|pi> returns the same bytes the CLI prints, wrapped in an envelope carrying the destination path, the env var name, and the model counts the GUI needs to render its states. The /api/models row logic moved into listManagementModelRows() and both branches now call it. Copying it would have created a second definition of which models exist, free to drift from the first; disabled/dedupe precedence is ~40 lines and only correct in one place. modelsWithoutLimits is counted from the serialized document rather than re-derived from input rows, so the number cannot disagree with the bytes the caller receives. A catalog failure is 503, never a partial 200. * feat(gui): add the client config panel to the API tab Phase 040 of devlog/_plan/260731_client_config_export. Renders what GET /api/client-config returns: a segmented OpenCode/Pi switch, the JSON itself, copy as the primary action, download as the secondary one, plus the destination path and the env line the config references. Download names the file from the server envelope and announces that nothing changed yet and the file must be merged. A downloaded config that reads as "applied" is how someone loses the other providers in their opencode.json. Rendering it in a real browser caught two defects a static read missed: a bare .awi-clientconfig-panel selector ties .api-panel's overflow on specificity and loses, leaving a clipping ancestor between the JSON and the page scroller; and a max-height on the JSON made a second capped scroll region on a tab whose invariant is that the model catalog is the only one. Both are fixed and the layout invariant test covers the second. Placement is the fallback 003 §6 allows, since the connect-bar rework has not landed yet. * docs: document ocx export and the Pi client Phase 050 of devlog/_plan/260731_client_config_export. The export command shipped in the previous four commits with nothing in docs-site pointing at it, and Pi had no page at all. Adds the ocx export section to the CLI reference (English plus the four translated copies), a section in the opencode guide on getting the provider block into your own config, and a Pi guide. Three things the docs have to get right, each verified against the code while writing: the two clients use DIFFERENT env vars (OPENCODEX_OPENCODE_API_KEY vs OPENCODEX_API_KEY), a loopback bind needs no admission key at all since shouldInjectApiAuthHeader is just !isLoopbackHostname, and an exported config must be merged rather than replace an existing file. BYOK is split rather than restated: the proxy admission key is what the exported config references, provider keys stay in providers.<name>.apiKey and keep their existing home in the providers guide. The Pi schema is still unverified against a real install and the guide says so. * fix(gui): gate the client-config fetch behind an explicit cancel flag React Doctor flagged the setter after `await` in the panel's effect. The write was already safe — every result carries its request key and a superseded run's key no longer matches — but the guard was an `AbortController.signal.aborted` check, which states "the request was cancelled", not "this run may no longer write". An explicit `cancelled` flag set in the cleanup makes the invariant local to the setter, so a reader (and the linter) can see why a stale resolve cannot land. The abort stays: it stops the in-flight request, the flag stops the write. * test(cli): map /api/client-config to ocx export in the parity sweep The headless-parity sweep asserts every GUI management endpoint has a documented CLI resource. Phase 030 added GET /api/client-config without registering it, so the sweep went red — correctly: the route and the CLI command shipped in the same unit and `ocx export` is exactly its mirror.
1 parent 18352b4 commit eeef701

38 files changed

Lines changed: 3953 additions & 199 deletions
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# 000 — Client config export for Pi and OpenCode
2+
3+
Unit: `devlog/_plan/260731_client_config_export/`
4+
Opened: 2026-07-31 · Work class: C3 · Branch target: `dev`
5+
6+
## Objective
7+
8+
Let a user or an agent take the opencodex model catalog out of the proxy as a
9+
ready-to-use custom-provider config for Pi and OpenCode, through four surfaces
10+
that all serve the same bytes: CLI text, CLI `--json`, management API, and a GUI
11+
panel with copy + JSON download.
12+
13+
## Why this unit exists
14+
15+
The proxy already speaks the wire every client understands. What it does not do is
16+
hand over the *metadata* — which models exist, what they are called, how large
17+
their context is — in the dialect each client's config file expects. Today a user
18+
wanting opencodex in Pi writes that JSON by hand, and gets it wrong, because model
19+
ids are namespaced and context windows are not guessable.
20+
21+
`ocx opencode` solves half of this already, in memory, at launch, for one client
22+
(`002`). This unit turns that private capability into an artifact and adds Pi.
23+
24+
## Constraints
25+
26+
| Constraint | Source |
27+
|-----------|--------|
28+
| Never serialize a live key; emit an env reference | AGENTS.md security boundary; `001` §3 |
29+
| Never write or rewrite a user's client config file | existing `ocx opencode` design; `002` §2 |
30+
| Never guess model metadata; omit what we do not know | existing `limit`-drop rule; `002` §4 |
31+
| One payload, four presentations | `003` §2 |
32+
| OpenCode V1 schema only | `001` §2 |
33+
| Bun-native TypeScript, `dev` branch | AGENTS.md |
34+
35+
## Scope boundary
36+
37+
IN
38+
- Client-neutral export core with OpenCode + Pi serializers.
39+
- `ocx export --client <id> [--json] [--out]`.
40+
- `GET /api/client-config?client=<id>`.
41+
- GUI panel: client switch, JSON preview, copy, download.
42+
- Tests at each phase; docs-site update when the CLI surface lands.
43+
44+
OUT
45+
- OpenCode V2 schema migration (recorded in `001`, not built).
46+
- Automatic merging into an existing user config file.
47+
- Any client beyond Pi and OpenCode. Claude Desktop already has its own path.
48+
- Changes to routing, adapters, or the catalog itself.
49+
- Pricing metadata. We do not have it; `cost` is omitted rather than faked.
50+
51+
## Work-phase map
52+
53+
Dependency-ordered. Each phase is one full PABCD cycle and closes with something
54+
independently verifiable.
55+
56+
| Phase | Doc | Delivers | Verified by |
57+
|-------|-----|----------|-------------|
58+
| 1 | `010_export_core.md` | `src/clients/config-export.ts`; OpenCode builder relocated, Pi serializer added | unit tests + golden diff against pre-refactor output |
59+
| 2 | `020_cli_surface.md` | `ocx export` with `--json` / `--out` | CLI tests; stdout parses clean under `--json` |
60+
| 3 | `030_management_api.md` | `GET /api/client-config` | route test; payload identical to CLI `--json` |
61+
| 4 | `040_gui_panel.md` | GUI panel with copy + download | GUI test + rendered check |
62+
63+
The order is the build order, not a schedule: `020`, `030`, `040` each import the
64+
core, and `040` renders what `030` returns. Nothing later can be built first.
65+
66+
## Research documents
67+
68+
| Doc | Contents |
69+
|-----|----------|
70+
| `001_client_config_survey.md` | Pi + OpenCode V1/V2 schemas, injection points, absence of a standard, V1 decision |
71+
| `002_existing_surface_inventory.md` | What `ocx opencode` already does, the corrected gap, catalog metadata availability |
72+
| `003_export_ux_design.md` | Design Read, dials, Lazy-User Gate, five UX states, download rules, placement |
73+
74+
## Open assumptions
75+
76+
1. **Pi schema is unverified against a real install.** Taken from published docs
77+
only (`001` §2). The cycle that ships Pi must diff against a real
78+
`~/.pi/agent/models.json` or ship the Pi client marked experimental.
79+
2. **The API page layout rework may or may not have landed** when `040` runs.
80+
`003` §6 gives both placements; the panel is layout-independent.
81+
3. **32k output budget** is a schema stand-in inherited from `ocx opencode`, not a
82+
per-model truth. If an authoritative max-output field ever reaches
83+
`CatalogModel`, both serializers should switch to it.
84+
85+
## Acceptance
86+
87+
The unit is DONE when all four phases have closed with their evidence, `bun run
88+
typecheck` and `bun run test` are green, `bun run privacy:scan` is green, and a
89+
config exported from each of the four surfaces is byte-identical for the same
90+
client and catalog state.
91+
92+
Terminal outcomes other than DONE: `BLOCKED` if Pi's real schema contradicts the
93+
survey badly enough to need a new design; `NEEDS_HUMAN` if a decision outside this
94+
plan's boundary appears.
95+
96+
## Status
97+
98+
Roadmap written 2026-07-31 during the docs-only cycle. No implementation has
99+
started; no production file has been modified by this unit.
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# 001 — External client config survey
2+
3+
Research only. No diffs here; implementation designs live in the decade docs.
4+
5+
Scope of the survey: where a user (or an agent) drops a custom OpenAI-compatible
6+
provider so that Pi and OpenCode can call the opencodex proxy, what JSON those
7+
files require, and whether any cross-vendor standard governs the shape.
8+
9+
## 1. Is there a standard?
10+
11+
No. There is no formal specification body governing agent-client provider
12+
configuration. What exists instead is a stack of de-facto layers:
13+
14+
| Layer | What it standardizes | Who honors it |
15+
|-------|----------------------|---------------|
16+
| OpenAI `/v1` wire (`/chat/completions`, `/responses`, `/models`) | request/response bytes | effectively everyone |
17+
| Anthropic `/v1/messages` wire | request/response bytes | Claude clients, our Messages surface |
18+
| `@ai-sdk/openai-compatible` (Vercel AI SDK) | adapter package name + option names | OpenCode, several TS agents |
19+
| models.dev | model metadata registry (context, cost, modality) | OpenCode, Pi metadata seeds |
20+
| per-client JSON Schema (`https://opencode.ai/config.json`) | one client's config file only | that client |
21+
22+
The consequence for this unit: the transport half is already solved — the proxy
23+
speaks `/v1` and clients speak `/v1`. Everything left is **metadata translation**
24+
into per-client dialects, and each dialect is a moving target owned by one vendor.
25+
26+
Critically, **models.dev does not populate custom providers.** A user-defined
27+
provider block gets no inherited context window, no pricing, no modality flags.
28+
Whatever we do not emit ourselves, the client simply does not know.
29+
30+
## 2. Where custom providers are injected
31+
32+
### OpenCode
33+
34+
Three layers, highest priority first:
35+
36+
| Layer | Location | Notes |
37+
|-------|----------|-------|
38+
| Inline runtime | `OPENCODE_CONFIG_CONTENT` env var | outranks every file; what `ocx opencode` uses today |
39+
| Project | `<git root>/opencode.json` or `.jsonc` | committed per repo |
40+
| Global | `~/.config/opencode/opencode.json`, XDG-aware via `XDG_CONFIG_HOME` | the file most users hand-edit |
41+
42+
JSONC is accepted for the file layers (comments must survive a round trip, which
43+
is why we never rewrite a user's file — see `002`).
44+
45+
V1 schema (current, what we generate):
46+
47+
```json
48+
{
49+
"$schema": "https://opencode.ai/config.json",
50+
"provider": {
51+
"opencodex": {
52+
"npm": "@ai-sdk/openai-compatible",
53+
"name": "opencodex",
54+
"options": { "baseURL": "http://127.0.0.1:10100/v1", "apiKey": "{env:OPENCODEX_OPENCODE_API_KEY}" },
55+
"models": {
56+
"anthropic/claude-opus-5": { "name": "Claude Opus 5", "limit": { "context": 200000, "output": 32000 } }
57+
}
58+
}
59+
}
60+
}
61+
```
62+
63+
V2 schema (documented at `opencode.ai/v2/docs/models`) renames nearly every key:
64+
65+
| Concept | V1 | V2 |
66+
|---------|----|----|
67+
| container | `provider` | `providers` |
68+
| adapter | `npm: "@ai-sdk/openai-compatible"` | `package: "aisdk:@ai-sdk/openai-compatible"` |
69+
| endpoint | `options.baseURL` | `settings.baseURL` |
70+
| upstream id | `models.<k>.id` | `models.<k>.modelID` |
71+
| credentials | `options.apiKey` / auth store | `env[]` |
72+
| new in V2 || `capabilities`, `variants`, `disabled` |
73+
74+
**Decision: stay on V1.** The shipped launcher already emits V1 and that path is
75+
exercised by tests; switching would break it in exchange for `capabilities` and
76+
`variants` fields we have no authoritative data to fill. V2 is recorded here so a
77+
later migration starts from a written diff rather than a re-survey.
78+
79+
### Pi
80+
81+
Single global file: `~/.pi/agent/models.json`.
82+
83+
```json
84+
{
85+
"providers": {
86+
"opencodex": {
87+
"baseUrl": "http://127.0.0.1:10100/v1",
88+
"api": "openai-completions",
89+
"apiKey": "$OPENCODEX_API_KEY",
90+
"models": [
91+
{
92+
"id": "anthropic/claude-opus-5",
93+
"name": "Claude Opus 5",
94+
"reasoning": false,
95+
"input": ["text"],
96+
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
97+
"contextWindow": 200000,
98+
"maxTokens": 32000
99+
}
100+
]
101+
}
102+
}
103+
}
104+
```
105+
106+
Shape differences that matter for the serializer:
107+
108+
- `models` is an **array**, not a keyed object. Model identity lives in `id`.
109+
- `cost` demands all four fields when present.
110+
- `apiKey` supports `$ENV_VAR` interpolation (bare `$NAME`, not `{env:NAME}`).
111+
- `api` selects the wire dialect; `openai-completions` is ours.
112+
113+
**Verification status: UNVERIFIED against a real installation.** The shape above
114+
comes from Pi's published `models.md` and `pi.dev/docs/latest/custom-provider`.
115+
No `~/.pi/agent/models.json` was read on this machine. The first implementation
116+
cycle touching Pi must diff this against a real file before shipping.
117+
118+
### Cross-client field map
119+
120+
| Concept | Pi | OpenCode V1 |
121+
|---------|----|-------------|
122+
| container | `providers` | `provider` |
123+
| adapter | `api: "openai-completions"` | `npm: "@ai-sdk/openai-compatible"` |
124+
| endpoint | `baseUrl` | `options.baseURL` |
125+
| model container | array | keyed object |
126+
| context | `contextWindow` | `limit.context` |
127+
| max output | `maxTokens` | `limit.output` |
128+
| price | `cost{4}` | absent |
129+
| modality | `input[]` | absent |
130+
| key reference | `$VAR` | `{env:VAR}` |
131+
132+
Only four concepts are universal: endpoint, adapter kind, model id, and the
133+
context/output pair. Everything else is one client's luxury.
134+
135+
## 3. Credential handling
136+
137+
All three injection paths support an environment reference, so no exporter needs
138+
to serialize a live `ocx_...` key. Pi takes `$VAR`, OpenCode takes `{env:VAR}`,
139+
and OpenCode additionally has an auth store. AGENTS.md treats token serialization
140+
as a release blocker, so emitting a reference is not merely polite — writing the
141+
plaintext key into an exported file would be a security-review failure.
142+
143+
The exported artifact therefore carries a variable name, and every surface that
144+
hands the file over must also tell the user which variable to export.
145+
146+
## 4. Sources
147+
148+
- Pi: `github.com/earendil-works/pi` `packages/coding-agent/docs/models.md`; `pi.dev/docs/latest/custom-provider`
149+
- OpenCode V1: `opencode.ai/docs/providers`, `opencode.ai/config.json`
150+
- OpenCode V2: `opencode.ai/v2/docs/models`
151+
- AI SDK adapter: `@ai-sdk/openai-compatible`
152+
- Metadata registry: `models.dev`
153+
154+
All external claims were read during the 2026-07-31 P phase. Client schemas drift;
155+
re-verify before any cycle that changes serializer output.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# 002 — What already exists, and what the real gap is
2+
3+
Research only. Written because the first pass of this unit's P phase proposed
4+
building things that were already shipped. Recording the correction here so a
5+
later cycle's P does not repeat the same miss.
6+
7+
## 1. Correction to the initial survey
8+
9+
The opening research turn concluded that OpenCode export had to be built from
10+
scratch and listed three "design blockers" (slash-bearing model ids, credential
11+
leakage, missing `limit.output`). All three were already solved in
12+
`src/cli/opencode.ts`, a 701-line module that predates this unit.
13+
14+
| Claimed blocker | Actual state |
15+
|-----------------|--------------|
16+
| `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 |
17+
| Exported config would leak `ocx_...` | Only `{env:OPENCODEX_OPENCODE_API_KEY}` is serialized; the real value rides the child process env |
18+
| `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 |
19+
20+
The lesson is procedural: a survey of external formats is not a survey of our own
21+
tree. Both halves are required before a phase map is credible.
22+
23+
## 2. Inventory of the shipped surface
24+
25+
### `src/cli/opencode.ts`
26+
27+
| Export | Role |
28+
|--------|------|
29+
| `buildOpencodeProviderBlockFromCatalog` | catalog rows -> V1 provider block. The real serializer core |
30+
| `buildOpencodeProviderBlock` | test-facing wrapper over the same builder |
31+
| `opencodeModelKey` | `provider/id` vs bare native slug |
32+
| `opencodeProxyBaseUrl` | `http://<host>:<port>/v1` |
33+
| `opencodeCatalogFromProxyRows` | `GET /api/models` rows -> catalog entries, drops disabled + dupes |
34+
| `mergeOpencodeRuntimeConfig` | merges inherited inline config, overrides only `provider.opencodex` |
35+
| `buildOpencodeConfig` / `serializeOpencodeRuntimeConfig` | assemble + stringify the inline runtime layer |
36+
| `opencodeGlobalConfigPath` / `opencodeProviderOverridePath` / `projectConfigOverridesProvider` | detect (never write) user files |
37+
38+
Design properties worth preserving verbatim:
39+
40+
- **Never writes user config.** File layers are read for detection only; injection
41+
goes through `OPENCODE_CONFIG_CONTENT`. This avoids clobbering comments,
42+
relative `{file:...}` paths, and unrelated MCP credentials.
43+
- **Never guesses metadata.** Absent context window means no `limit` block, not a
44+
fabricated one.
45+
- **Never serializes a secret.**
46+
47+
### Adjacent precedents
48+
49+
- `src/cli/claude-desktop.ts` — writes a Claude Desktop 3P config.
50+
- `gui/src/pages/ClaudeDesktop.tsx:313``exportProfile()` does Blob +
51+
`URL.createObjectURL` + `anchor.download`, then announces to a live region.
52+
This is the house pattern for browser-side JSON download and the 040 phase
53+
reuses it rather than inventing a second one.
54+
- `src/cli/access.ts` — the `ocx access ...` family establishes the CLI contract
55+
this unit extends: `--json` on every subcommand, `printData(value, wantsJson, lines)`
56+
emitting either raw JSON or human lines from the SAME value.
57+
- `src/server/management/model-routes.ts:115``GET /api/models`, the authenticated
58+
route whose rows already feed the OpenCode builder.
59+
60+
## 3. The actual gap
61+
62+
`ocx opencode` produces a provider block **only in memory, only at launch**, and
63+
only for OpenCode. Three things are missing:
64+
65+
1. **No artifact.** A user who wants the provider block in their own
66+
`~/.config/opencode/opencode.json` cannot get it out of opencodex. There is no
67+
command that prints it and no file to download.
68+
2. **No Pi support at all.** No serializer, no command, no surface.
69+
3. **No shared abstraction.** The builder is private to the launcher module and
70+
shaped around one client's schema, so a second client cannot reuse it as-is.
71+
72+
Everything this unit builds is downstream of closing those three. The transport,
73+
the catalog, and the security posture are already correct.
74+
75+
## 4. Catalog metadata availability
76+
77+
Checked because every serializer depends on it:
78+
79+
| Field | Source | Available? |
80+
|-------|--------|-----------|
81+
| context window | `CatalogModel.contextWindow`; `NATIVE_OPENAI_CONTEXT_OVERRIDES` + upstream `context_window` for native | yes, with a documented 128k fallback in `parsing.ts:287` |
82+
| display name | `CatalogModel.displayName` | yes, optional |
83+
| max output | none | **no** — 32k schema stand-in only |
84+
| price | not on `CatalogModel` | **no** |
85+
| input modality | `CatalogModel.inputModalities` | yes |
86+
| reasoning | `CatalogModel.reasoningEfforts` | yes, as effort list |
87+
88+
Consequence for Pi: `cost` cannot be filled honestly. Emitting zeros would assert
89+
"this model is free," which is false for routed providers. The 010 phase must
90+
decide omit-vs-zero, and the survey's field table says `cost` is optional when the
91+
key is absent — so omission is the honest option.

0 commit comments

Comments
 (0)