-
Notifications
You must be signed in to change notification settings - Fork 513
feat(export): client config export for Pi and OpenCode #852
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
3275bf5
569dc4e
f54666b
4d0b9af
be4e1ed
f4f7774
4521f1d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 🤖 Prompt for AI Agents |
||
| 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. |
There was a problem hiding this comment.
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:
Repository: lidge-jun/opencodex
Length of output: 50377
🏁 Script executed:
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:
Repository: lidge-jun/opencodex
Length of output: 24371
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 49191
Make Pi schema validation a release gate.
The caveat in
000_plan.md#L74-L78is not part of theDONEcriteria at#L85-L90.010_export_core.md#L121-L124defers the decision to a later cycle, while050_docs_and_hardening.md#L1-L4ships 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
DONEcriteria 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