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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions devlog/_plan/260731_client_config_export/000_plan.md
Original file line number Diff line number Diff line change
@@ -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 <id> [--json] [--out]`.
- `GET /api/client-config?client=<id>`.
- 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.
Comment on lines +74 to +90

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- plan files ---'
cat -n devlog/_plan/260731_client_config_export/000_plan.md | sed -n '1,130p'
printf '%s\n' '--- core phase ---'
cat -n devlog/_plan/260731_client_config_export/010_export_core.md | sed -n '1,155p'
printf '%s\n' '--- related Pi references ---'
rg -n -i -C 3 'Pi|models\.json|experimental|byte-identical|acceptance|serializer' devlog/_plan/260731_client_config_export

Repository: lidge-jun/opencodex

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- phase map and acceptance references ---'
rg -n -C 4 'Phase|Accept criteria|Acceptance|DONE|BLOCKED|NEEDS_HUMAN|real .*install|experimental|diff against' \
  devlog/_plan/260731_client_config_export/{000_plan.md,010_export_core.md,020_cli_surface.md,030_management_api.md,040_gui_panel.md,050_docs_and_hardening.md}

printf '%s\n' '--- all repository references to Pi export status ---'
rg -n -i -C 2 'Pi.*(schema|experimental|install|models\.json)|models\.json.*Pi|experimental.*Pi|Pi.*stable|stable.*Pi' \
  --glob '!devlog/_plan/260731_client_config_export/001_client_config_survey.md' \
  .

Repository: lidge-jun/opencodex

Length of output: 50378


🌐 Web query:

Pi coding agent custom provider models.json schema providers models contextWindow maxTokens apiKey current documentation

💡 Result:

The Pi coding agent allows you to define custom providers and models using a models.json file located at ~/.pi/agent/models.json [1][2]. Models.json Schema Structure The schema is defined as an object containing a providers record, where each key represents a provider name [3][4]. providers (Record): A dictionary where keys are provider identifiers [3]. - baseUrl (string, optional): The API endpoint URL [1][5]. - api (string, optional): The API type (e.g., openai-completions, anthropic-messages) [1][6]. - apiKey (string, optional): API key for the provider [1][2]. It is often better to omit this in the file if using auth.json or CLI flags for security [1][7]. - headers (Record, optional): Custom headers [1][5]. - models (Array, optional): A list of model configurations [1][2]. - modelOverrides (Record, optional): Per-model configuration overrides [1][5]. Model Configuration Fields Each entry in the models array uses the following fields [1][5]: - id (string, required): The internal model identifier [1][2]. - name (string, optional): A human-readable label [1][2]. - contextWindow (number, optional): Maximum context window in tokens (default: 128000) [1][2]. - maxTokens (number, optional): Maximum output tokens (default: 16384) [1][2]. - reasoning (boolean, optional): Whether the model supports extended thinking [1][5]. - input (array, optional): Supported input types, e.g., ["text", "image"] [1][6]. - cost (object, optional): Token pricing for usage tracking (input, output, cacheRead, cacheWrite) [6][8]. - compat (object, optional): Provider compatibility overrides [1][6]. Credential Resolution Order When the agent resolves an API key or configuration for a provider, it follows this priority order [7][9]: 1. CLI --api-key flag [7][9] 2. auth.json entry [7][9] 3. Environment variables [7][9] 4. Custom provider keys defined directly in models.json [7][9] If no authentication is configured via the first three methods, models defined in models.json will load but remain unavailable for use in the CLI [1][2]. For complex requirements like custom OAuth or custom API implementations, the documentation recommends creating an extension instead of using models.json [6][9].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- exact plan acceptance and Pi gate ---'
sed -n '74,100p' devlog/_plan/260731_client_config_export/000_plan.md
sed -n '109,126p' devlog/_plan/260731_client_config_export/010_export_core.md
sed -n '1,115p' devlog/_plan/260731_client_config_export/050_docs_and_hardening.md

printf '%s\n' '--- phase-specific acceptance criteria ---'
for f in devlog/_plan/260731_client_config_export/020_cli_surface.md \
         devlog/_plan/260731_client_config_export/030_management_api.md \
         devlog/_plan/260731_client_config_export/040_gui_panel.md; do
  echo "### $f"
  rg -n -A 18 '^## Accept criteria|^## Acceptance|^## Status' "$f"
done

printf '%s\n' '--- implemented Pi serializer and tests ---'
sed -n '300,375p' src/clients/config-export.ts
sed -n '120,185p' tests/client-config-export.test.ts
sed -n '1,180p' tests/management-client-config-route.test.ts | tail -n 50

printf '%s\n' '--- repository state and Pi runtime dependencies ---'
git status --short
rg -n -i 'pi-mono|earendil-works/pi|badlogic/pi|models\.json' package.json bun.lockb bun.lock docs-site src tests gui 2>/dev/null || true

Repository: lidge-jun/opencodex

Length of output: 24371


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- Pi guide status ---'
cat -n docs-site/src/content/docs/guides/pi.md | sed -n '100,122p'

printf '%s\n' '--- exact source and test evidence ---'
cat -n src/clients/config-export.ts | sed -n '304,325p'
cat -n tests/client-config-export.test.ts | sed -n '205,285p'

printf '%s\n' '--- repository evidence of a real-install fixture or validator ---'
rg -n -i 'real (pi )?install|pi (schema|config).*(validate|accept|parse|load)|models\.json.*(fixture|golden|snapshot)|pi.*(fixture|golden|snapshot)' \
  devlog docs-site src tests gui package.json bun.lock bun.lockb 2>/dev/null || true

Repository: lidge-jun/opencodex

Length of output: 49191


Make Pi schema validation a release gate.

The caveat in 000_plan.md#L74-L78 is not part of the DONE criteria at #L85-L90. 010_export_core.md#L121-L124 defers the decision to a later cycle, while 050_docs_and_hardening.md#L1-L4 ships after the four implementation phases and only documents the unverified status. Project tests validate our JSON shape, not Pi's ability to load ~/.pi/agent/models.json; a schema mismatch can make every exported Pi config unusable.

Add real-install Pi validation to the unit DONE criteria and block Pi from stable CLI, API, and GUI surfaces until it passes. Otherwise, mark Pi export experimental in the plan, all surfaces, and documentation.

📍 Affects 2 files
  • devlog/_plan/260731_client_config_export/000_plan.md#L74-L90 (this comment)
  • devlog/_plan/260731_client_config_export/010_export_core.md#L121-L124
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@devlog/_plan/260731_client_config_export/000_plan.md` around lines 74 - 90,
The plan must make real-install Pi schema validation a release gate: update the
DONE criteria in 000_plan.md to require successful validation against an actual
~/.pi/agent/models.json and prevent Pi from stable CLI, API, and GUI surfaces
until it passes; update 010_export_core.md at its deferred decision to reflect
this requirement. If validation cannot be made a gate, consistently mark Pi
export experimental across the plan, all surfaces, and documentation.


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.
Comment on lines +96 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the roadmap status.

The status says that implementation has not started, but devlog/_plan/260731_client_config_export/010_export_core.md contains August 1, 2026 implementation amendments and the PR objectives describe completed implementation phases. Record the current phase and verification evidence instead of leaving this historical status active.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@devlog/_plan/260731_client_config_export/000_plan.md` around lines 96 - 99,
Update the Status section of the roadmap to reflect the current implementation
phase rather than the historical docs-only state. Summarize the completed
implementation phases and include verification evidence based on the amendments
and PR objectives documented in 010_export_core.md; remove the claim that no
implementation has started or no production files were modified.

155 changes: 155 additions & 0 deletions devlog/_plan/260731_client_config_export/001_client_config_survey.md
Original file line number Diff line number Diff line change
@@ -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 | `<git root>/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.<k>.id` | `models.<k>.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.
Original file line number Diff line number Diff line change
@@ -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://<host>:<port>/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.
Loading
Loading