Skip to content

Commit 746edc7

Browse files
committed
merge(dev): sync lidge-jun#744 onto tip after lidge-jun#852 export land
2 parents 7f7ad3b + eeef701 commit 746edc7

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)