diff --git a/devlog/_plan/260905_now_split_train/000_plan.md b/devlog/_plan/260905_now_split_train/000_plan.md new file mode 100644 index 0000000000..180a85abb2 --- /dev/null +++ b/devlog/_plan/260905_now_split_train/000_plan.md @@ -0,0 +1,72 @@ +# 260905 — RESOLVABLE_NOW split train (stacked PRs) + +Date: 2026-09-05. Worktree a2c0, docs branch `codex/260905-modular-debt-ledger-docs` +at 4cc219549 (source basis 980a9fbed; origin/dev tip at unit open 583d6a91b, +6 commits ahead, only one of which touches a NOW file — see 001). Session +01a06e97-b9d8-7250-8204-bb788338c288, goalplan +`.codexclaw/goalplans/reduce-the-68-resolvable-now-modularization-debt/`. +Input ledger: `devlog/_plan/260905_modular_debt_ledger/021_ledger.md` (68 rows +with `RESOLVABLE_NOW`); lane evidence in that unit's 011–016. + +## Objective + +Bring each of the 68 files under the cxc-dev §1 400-line limit by pure-move +splits (leaf modules + barrel re-exports), published as stacked PRs against +`dev`, each layer independently reviewable and mergeable. Zero behavior +change; every existing export stays importable from its original path. +Per-file success is `RESOLVED` or `RESIDUAL-FN` (003 RESIDUAL-ACCOUNTING-01); +the closeout tallies both and only the first counts as resolved. + +## Constraints (binding on every layer) + +- Pure move only. No renames of exported identifiers, no signature changes, + no deletion of exports, no "while I'm here" fixes. A behavior defect found + during a move is recorded in the decade doc and left alone. +- New leaf files ≤400 lines; the residual original file ≤400 lines or the + layer states why a second layer (`#b`) follows (003 INTERMEDIATE-RESIDUAL-01, + RESIDUAL-FN-01). +- The ≤500-line PR cap is measured on the non-move diff for pure-move layers + (003 PURE-MOVE-SIZE-01); non-move diff ≤150 lines. +- Re-export binds nothing locally (260818 WP1 lesson): internal call sites in + the residual file import from the leaf explicitly. +- Text-oracle tests that read a split file as source (001 column + `textoracle`) are retargeted to the leaf **without weakening**; the + decade doc names each and the C phase drives the retargeted guard red once + when it is a guard. +- `tests/lab/core-lab-boundary.test.ts` PROTECTED roots are never edited; + a new leaf imported from a protected root must not reach `src/lab`. +- Verification from WP400 onward: typecheck, focused tests, privacy scan and + full suite run in an isolated checkout on `ssh lidge`; no local suites. +- Git: layer branches `codex/split-`; bottom layer base `dev`, each + upper layer base = the branch below; push + PR creation pre-authorized by + the user for this loop; **merge never** (DEV-STACK-04 ESCALATE). Cascade + with `git rebase --update-refs` + `--force-with-lease` when a lower layer + changes (DEV-STACK-02). +- Open-stack depth cap: 5 dependent PRs. S04 contains six total layers, + including prerequisite layer 105, but STACK-INDEPENDENCE-01 replaced the initial + six-deep linear proposal: its longest current base chain is 3. Across the + 77-layer map, the longest planned chain is 4. The former S04 depth-six + exception is historical, not permission to create a six-deep stack now. +- From WP400 onward, code and receipts use the existing a2c0 worktree in + place (003 WORKTREE-EVIDENCE-01). Preserve each previous branch before + selecting the next layer branch. Never relocate or recreate a2c0. + +## Work-phase map (dependency-ordered) + +| WP | Deliverable | Depends on | Verifier | +|---|---|---|---| +| wp1 | 000–002 + every layer's decade doc (010…750) at diff level | — | docs checks (numbered only, every layer has a doc, every NOW file appears in exactly one stack); privacy scan | +| wp2… | one layer per work-phase, dependency-ordered by the base edges in 002; independent groups may be interleaved | its declared base layer, if any | the current decade document's Verification and Accept criteria sections | + +Total: 77 implementation layers across 21 stacks (002_layer_map.md; 105 and +625 appended per 003). + +## Out of scope + +The 151 `RESOLVABLE_AFTER` and 19 `ACCEPTED` rows; core.ts / config.ts / +service.ts / auth-api.ts; merges; releases. + +## Terminal outcome expected + +DONE when every layer in 002 has an open PR with a green exact-head CI rollup +recorded in its decade doc. diff --git a/devlog/_plan/260905_now_split_train/003_parent_decisions.md b/devlog/_plan/260905_now_split_train/003_parent_decisions.md new file mode 100644 index 0000000000..20ea08717e --- /dev/null +++ b/devlog/_plan/260905_now_split_train/003_parent_decisions.md @@ -0,0 +1,201 @@ +# 003 — Parent decisions on drafter escalations (binding amendments to 000/002) + +Twenty-one drafters (one per stack) returned 75 decade docs. Fourteen of them +escalated the same conflict and six raised stack-specific questions. Each +decision below is an amendment to 000_plan.md constraints and is what the +A-phase audits and every executor obey. + +## PURE-MOVE-SIZE-01 — the ≤500-line changeset cap for pure-move layers + +Conflict: cxc-dev §1 says "PR changeset >500 lines → split" (DEFAULT class: +exceed only with a stated reason). A pure move of a file that must lose +≥1,000 original lines produces ≥2,000 raw diff lines however it is layered; +adding layers only multiplies fully-gated PRs and leaves intermediate residuals +over 400 with no review benefit (S02, S03, S05, S07, S08, S10, S11, S13, S15, +S16, S19, S20, S21 all showed this arithmetic). + +Decision (stated reason for exceeding): for a layer whose decade doc classes +it as pure-move, the 500-line cap is measured on the **non-move diff** — the +lines that are not a verbatim relocation: re-export blocks, import edits in +the residual and in consumers, test retargets, route-registry metadata. That +non-move diff must stay ≤150 lines per layer. Moved lines are reviewed as +moves: the PR body links `git diff --color-moved=dimmed-zebra` guidance and the +executor's C phase records `git diff -M --stat` plus a symbol-inventory check +(every symbol in the doc's inventory appears exactly once in the tree after +the move). + +Permitted transformations of a moved line (still pure-move): + +1. Adding or removing the `export` modifier on a moved declaration (a leaf must + export what the residual re-exports; a symbol that was module-private and + is now consumed only inside its leaf may stay private). +2. Changing the import specifier path of a moved symbol's own imports. +3. Object-literal method → factory-produced function when an adapter's + returned object literal is split across leaves (S03 Anthropic #b, + `createAnthropicAdapter` returns `{ ...methods }` capturing lexical + `provider`/`toolNames`): the method body is moved verbatim into a + leaf function `makeX(captured…)` whose parameters are exactly the + lexical bindings the body captures, called once inside the original + factory so the returned property becomes `x: makeX(provider, toolNames)`. + Capture identity and invocation lifetime are preserved because the + factory is invoked in the same closure scope the literal was built in. + Evidence: the C phase pastes `git diff --color-moved=dimmed-zebra + --color-moved-ws=allow-indentation-change` for each converted method and + shows the body as a move block; the layer's focused tests cover every + converted method (listed in the doc's Tests section). The same rule + covers a class method split by `this`-fields, should one occur. +4. JSX block → sibling component with verbatim props (GUI-SEAM-01). + +Anything else (reordering statements inside a moved body, renaming, changing +a literal, altering control flow) is not pure-move; the layer falls back to +the literal 500-line cap or is re-sliced. + +The layer count in 002 stands as drafted; no stack is re-sliced for size. + +## RESIDUAL-FN-01 — residual >400 caused by a single function + +S07 L1: `parseRequest` is 464 lines by itself, so `src/responses/parser.ts` +cannot reach ≤400 by moving other symbols. Splitting the function is a +behavior-preserving extraction, not a move, and is out of this train's scope. +Decision: the layer moves everything movable, the residual stays over 400, +and the doc records the function as `RESOLVABLE_AFTER(design:L1-parse-request-extraction)` +for the 021 ledger's next revision. Same rule applies to any other layer that +finds a single >350-line function (none other reported). + +## INTERMEDIATE-RESIDUAL-01 — over-400 residuals inside a multi-part file + +S13 (config-export #a), S18 (IntegrationsOverview), S21 (release-notes #a), +S02 (registry #a/#b): an intermediate residual over 400 is acceptable when a +**bounded successor chain inside the same stack** brings it under 400 and +each doc states the number it hands to the next layer (registry: +3250 → 2429 → 1267 → 219 across #a/#b/#c). S18 had no next layer: **layer +625 (IntegrationsOverview #b)** is appended to 002 and drafted by the same +agent. + +## RESIDUAL-ACCOUNTING-01 — what "done" means for a file + +000's objective is amended: the train's success measure is per file, one of +`RESOLVED` (residual ≤400 and all leaves ≤400), or `RESIDUAL-FN` (residual +>400 solely because of one unsplittable function, recorded per +RESIDUAL-FN-01 with the `design:` id for the ledger). The closeout doc +tallies both; a file in the second bucket is *not* counted as resolved. At +draft time exactly one file is expected there: `src/responses/parser.ts` +(561, `parseRequest`). + +## TYPE-CYCLE-01 — pre-existing type-only cycles + +S04 L1 reports `src/types.ts → src/types/provider.ts → native-exec-desktop.ts +→ native-exec-tools.ts → tool-definitions.ts → src/types.ts`; S02 reports an +Antigravity type cycle. Both pre-exist on `dev` and are erased at runtime. +Decision: a layer must not add a **runtime** cycle and must not add a new +type-only cycle; it may leave existing ones untouched. The audit checks the +delta, not the whole graph. + +S04 is the exception: its new leaves would each join the existing type cycle +(`tool-naming → ../../types → provider → native-exec-desktop → +native-exec-tools → tool-definitions → tool-naming`), which is a *new* cycle +through new files. Decision: the prerequisite the S04 drafter named is +approved and becomes **layer 105 (`codex/split-cursor-desktop-executor-contract`, +base `dev`, new bottom of S04)**: move `DesktopExecutorConfig` +(`src/adapters/cursor/native-exec-desktop.ts:28–37`) to a new dependency-free +`src/adapters/cursor/desktop-executor-contract.ts`, keep it exported from +`native-exec-desktop.ts` via `export type { DesktopExecutorConfig } from +"./desktop-executor-contract"` plus a local `import type`, and retarget the +inline `import("../adapters/cursor/native-exec-desktop").DesktopExecutorConfig` +at `src/types/provider.ts:701` to the contract file. Type-only, zero runtime +effect; breaks the provider → desktop-implementation edge for good. 110's +base becomes `codex/split-cursor-desktop-executor-contract`. S04 has six +members including 105. The original linear proposal called that depth 6 and +made an exception; STACK-INDEPENDENCE-01 below superseded that topology. +Current planned parents are 105→dev, 110/120/130→105, 140→130, 150→110. +Thus S04's maximum dependent depth is 3, and the five-layer cap still applies. + +## COMPANION-EDIT-01 — allowed edits outside the split file + +- S09 L2/L3: `src/server/management/route-registry.ts` module-path metadata + for routes whose handler moves to a leaf — allowed (it is the route table's + pointer to the owning file; the registry test enumerates siblings). +- S02 L3: one `import type` path change for FastWire types — allowed + (type-only, no runtime effect). +- Consumer import edits are only allowed when the doc lists them; default is + that consumers keep importing from the original path via re-export. + +## GUI-SEAM-01 — React component extraction as the seam + +S17 (Storage policy panel) and other gui layers: extracting a JSX block into a +sibling component file with its props passed through verbatim counts as a +pure move for this train when the rendered tree is unchanged. Verification for +such layers adds the GUI checks: `bun run lint:gui`, `bun run build:gui`, and +a before/after screenshot of the affected page attached to the PR (the +`enforce-target` gate requires a screenshot for gui PRs anyway). + +## STACK-INDEPENDENCE-01 — stacks whose layers do not depend on each other + +DEV-STACK-01 says independent parts go as parallel PRs off trunk. The +original 002 chained every stack by directory. Decision, applied **per +layer** to every stack: a layer's base is the nearest lower layer in its +stack that it imports from (001's 47 edges) or that is a `#`-part of the +same file; S04 layers additionally base on the 105 type-contract layer; +otherwise the base is `dev`. 002 is regenerated with this rule (29 chained +layers, 48 `dev`-based). The stack id still groups execution order and PR +stack-map navigation; a `dev`-based layer's PR body still shows its stack's +map but states "base: dev — no dependency on the layers below". Each decade +doc's PR section is the authority for its own base and must match 002. + +## S06-ORACLE-01 — correcting 002 + +002's S06 thesis said "47 text oracles retargeted". The drafter showed the +count came from a broad `index.ts` basename match; no test reads +`src/vision/index.ts` as text. 002 is corrected to "no text oracle; three +recursive source-walk guards must include the new leaves". + +## S10-SIZE-01 — resolved by PURE-MOVE-SIZE-01 + +prompt-layers stays two layers (518 + 913 moved lines) under the pure-move +measure. + +## WORKTREE-EVIDENCE-01 — real implementation and receipt identity + +Current WP400 authority: branch `codex/split-clients-config-export-a`, PR #3611, +base `dev` at `be81013fab6d83ff630ca5f38e7881678a303871`. This is the base +commit, not the tested layer head; the verifier derives the latter from the +clean current branch and matches it to the fetched remote branch. #3610 has +landed as `5ab8aa9a2d9d2a3926469f9d8c82387b43c6d0e9`; it is no longer an +open prerequisite or a retargeting destination. Scoped CI reruns and repair +work are authorized; no local suite or merge is requested. + +Historical only: WP400 temporarily used #3610 at +`afdd38ff43c64696153372fc2e27a38aff208c73` to separate a verification fix +from the split. That older basis and its open-parent workflow are retired. +The historical evidence remains in400; do not execute it as the current plan. + +The original dedicated-worktree execution choice conflicts with the FSM's +checkout-local source identity. Operational audit by Wegener found no +separate supported execution-root binding: `--cwd` selects both state and +source. The main agent amends its own topology choice, not the user's scope. + +From WP400, preserve the docs branch and every completed layer branch, then +create the current layer branch in the same a2c0 directory from its pinned +base. Carry 000, 003 and the current decade doc as tracked layer documentation; +the complete roadmap remains on `codex/260905-modular-debt-ledger-docs` and +can be read by immutable commit/ref. The ignored `.codexclaw` state stays +in a2c0; do not copy, hand-edit or relocate session state. Actual source edits +must occur there during B. Commit the layer before C and preserve that HEAD +through its receipt and C→D. Source changes from another checkout cannot be +represented by a documentation-only delta. + +All tests from WP400 run remotely. Each run uses its own mktemp checkout, +fetches the layer branch, and requires the fetched SHA to equal a2c0 HEAD. +Never switch or reset the shared remote seed checkout. Install root and GUI +dependencies with frozen lockfiles, then typecheck, focused checks, privacy +scan and full suite. Preserve full output and propagate each actual exit +code, including SSH transport failures. Failed or incomplete gates keep the +layer unverified; do not synthesize a passing receipt. Retain temporary +checkouts/evidence until scoped cleanup is authorized. + +Use the complete isolated Bash recipe in400's Verification section from C. +It checks the clean local layer HEAD, fetched remote HEAD and final remote +state, while preserving output and failures inside the receipt command. +No local Bun test command is allowed. Older shared-checkout recipes must not +be reused; each current plan must supply its isolated verifier. Availability +and success require real execution evidence. diff --git a/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md b/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md new file mode 100644 index 0000000000..cec6014089 --- /dev/null +++ b/devlog/_plan/260905_now_split_train/400_clients_config_export_a.md @@ -0,0 +1,500 @@ +# 400 — S13 L1/5: extract low-fanout client formats and dependency foundations + +## Loop spec + +- Archetype: `pure-move`, C3 implementation with explicit security regression review for the relocated admission/credential helpers. Main owns orchestration and goal state. +- Goal: extract low-fanout client formats and dependency foundations, preserving the original public import path and behavior. +- Non-goals: behavior fixes, exported renames, signature changes, new validation, changed credentials/admission policy, changed config paths, new framework, caller migration, merges or releases. Preserve function bodies verbatim, including >50-line functions; function redesign is not this pure-move train. +- Verifier: this document's authoritative Verification section; every layer must pass independently at its actual tip. All tests run on `ssh lidge`, never locally. +- Stop: exact-tip acceptance evidence and CI recorded, then close the work phase; do not merge. The earlier docs-only drafting pass is historical. +- Size gate: the binding `003_parent_decisions.md` PURE-MOVE-SIZE-01 resolves the original 500-line churn conflict. Non-move changes must stay **≤150 lines**, with move-aware diff review and unique-owner evidence for every inventory symbol. Raw added+deleted churn is not claimed to meet 500. Stale source, a leaf >400, any new cycle, any behavioral difference, or non-move changes above the bound stop implementation. + +Inventory basis (historical): task docs HEAD `4cc219549`; code `origin/dev=1362b1a3841b4de20177e5d65865a513dd7936c4`. The drafting pass read 000, 001, S13 rows/Per-layer gate of 002, and the relevant records in `devlog/_plan/260905_modular_debt_ledger/016_lane_cli_storage_usage_update_lab_scripts.md`. Source was read with `git show origin/dev:`; `git diff origin/dev -- src/clients/config-export.ts src/cli/opencode.ts src/cli/minimax.ts src/integrations/state.ts` was empty. Current execution uses the dev base in the PR section; the inventoried source bytes were checked unchanged. + +Structural decision (cxc-dev §1/§5, architecture ARCH-MAP-01/ARCH-DECISION-01): 1990 lines mix distinct concerns. Reject deleting/configuring the feature (does not preserve behavior), and generic helpers/index barrels (do not establish ownership). Reuse every existing algorithm and lower-level dependency; only relocate declarations. Inspected conventions: `src/config/paths.ts`, `src/config/process-state.ts`, `src/cli/launcher-context.ts`, `src/cli/account-extended.ts`, `src/integrations/ownership-policy.ts`. Use the domain subfolder `src/clients/config-export/` without an index barrel. The original remains an existing compatibility boundary, not an internal import shortcut. + +Structural map: 33 direct source/test/fixture consumer files. Production dependents: `src/integrations/state.ts`, `src/integrations/ownership.ts`, `src/integrations/merge.ts`, `src/integrations/registry.ts`, `src/integrations/owned-refresh.ts`, `src/integrations/config-io.ts`, `src/integrations/ownership-policy.ts`, `src/integrations/writer.ts`, `src/server/management/model-routes.ts`, `src/server/management/model-rows.ts`, `src/cli/export-command.ts`, `src/cli/minimax.ts`, `src/cli/opencode.ts`. Current direction is dependents → original → existing imported owners; intended direction is dependents → original → concern leaves → existing owners. Leaf imports are fully enumerated below; no leaf → original edge. Blast radius: client/CLI integration feature, with public consumers unchanged. `structure/09_client-integrations.md:11` identifies builders and classification as single authorities; no parallel implementation is introduced. + +## Symbol inventory + +Exact syntax spans at `origin/dev:src/clients/config-export.ts` (leading comments excluded). Reproduce: `sg run --lang ts --kind 'function_declaration,interface_declaration,type_alias_declaration,lexical_declaration,variable_declaration,class_declaration' --json=compact src/clients/config-export.ts`, filtering declarations enclosed by another declaration. Consumers = distinct direct importer/re-exporter files per symbol, resolved by literal module path then counted with `rg -l -w '' `. Dynamic dispatch destructuring counts too. Private declarations have 0 external consumers, not 0 local calls. Imported bindings are covered by the leaf imports; export-only declarations are noted below. L2 repeats the complete basis inventory and marks L1-owned rows already moved. + +| symbol | kind | lines start–end | exported? | consumers (count from rg) | target leaf | +|---|---|---|---|---:|---| +| `ManagedFragment` | interface | 43–46 | yes | 2 | `src/clients/config-export/contracts.ts` (L1) | +| `ManagedContribution` | interface | 49–52 | yes | 5 | `src/clients/config-export/contracts.ts` (L1) | +| `BuildContribution` | type | 54–54 | yes | 0 | `src/clients/config-export/contracts.ts` (L1) | +| `OpencodeLaunchEnv` | interface | 56–58 | yes | 1 | `src/clients/config-export/contracts.ts` (L1) | +| `OpencodeCatalogModel` | interface | 61–76 | yes | 1 | `src/clients/config-export/contracts.ts` (L1) | +| `OpencodeModelEntry` | interface | 78–81 | yes | 1 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `OpencodeModelVariant` | interface | 90–93 | yes | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `OpencodeV2ModelEntry` | interface | 95–97 | yes | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `OpencodeProviderConnection` | interface | 100–104 | yes | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `OpencodeProviderBlock` | interface | 107–112 | yes | 1 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `OpencodeV2ProviderBlock` | interface | 115–120 | yes | 1 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `OpencodeProviderBlocks` | interface | 127–130 | yes | 1 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `OpencodeGeneratedConfig` | interface | 132–138 | yes | 4 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `OPENCODE_PROVIDER_ID` | const | 141–141 | yes | 11 | `src/clients/config-export/constants.ts` (L1) | +| `OPENCODE_CONFIG_SCHEMA` | const | 143–143 | yes | 2 | `src/clients/config-export/constants.ts` (L1) | +| `OPENCODE_PROVIDER_NPM` | const | 149–149 | no | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `OPENCODE_V2_PROVIDER_PACKAGE` | const | 161–161 | no | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `OPENCODE_PROVIDER_NAME` | const | 164–164 | no | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `OPENCODE_API_KEY_ENV` | const | 171–171 | yes | 3 | `src/clients/config-export/constants.ts` (L1) | +| `OPENCODE_API_KEY_ENV_REF` | const | 174–174 | yes | 2 | `src/clients/config-export/constants.ts` (L1) | +| `HERMES_API_KEY_ENV` | const | 180–180 | yes | 0 | `src/clients/config-export/constants.ts` (L1) | +| `HERMES_API_KEY_ENV_REF` | const | 181–181 | yes | 2 | `src/clients/config-export/constants.ts` (L1) | +| `OPENCLAW_API_KEY_ENV` | const | 184–184 | yes | 0 | `src/clients/config-export/constants.ts` (L1) | +| `OPENCLAW_API_KEY_ENV_REF` | const | 185–185 | yes | 2 | `src/clients/config-export/constants.ts` (L1) | +| `LOOPBACK_API_KEY_PLACEHOLDER` | const | 193–193 | yes | 9 | `src/clients/config-export/constants.ts` (L1) | +| `GAJAE_API_KEY_ENV` | const | 200–200 | yes | 2 | `src/clients/config-export/constants.ts` (L1) | +| `PI_API_DIALECT` | const | 203–203 | no | 0 | `src/clients/config-export/constants.ts` (L1) | +| `SCHEMA_REQUIRED_OUTPUT_BUDGET` | const | 217–217 | yes | 2 | `src/clients/config-export/constants.ts` (L1) | +| `OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG` | const | 220–225 | yes | 1 | `src/clients/config-export/constants.ts` (L1) | +| `opencodeGlobalConfigPath` | function | 231–237 | yes | 3 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `OMP_PROFILE_NAME_RE` | const | 239–239 | no | 0 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `OMP_WINDOWS_RESERVED_PROFILE_RE` | const | 240–240 | no | 0 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `ompProfileName` | function | 242–258 | no | 0 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `piAgentDir` | function | 270–274 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `piConfigPath` | function | 277–279 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `ompAgentDir` | function | 282–293 | yes | 1 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `ompModelsConfigPath` | function | 296–301 | yes | 4 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `opencodeProxyBaseUrl` | function | 304–316 | yes | 4 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `hermesHomeDir` | function | 322–330 | yes | 1 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `hermesConfigPath` | function | 332–334 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `ClientPathError` | class | 350–350 | yes | 12 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `absoluteClientPath` | function | 352–363 | no | 0 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `openclawEffectiveHome` | function | 372–375 | no | 0 | `src/clients/config-export/openclaw-paths.ts` (L2; deferred) | +| `openclawHomeDir` | function | 393–413 | yes | 2 | `src/clients/config-export/openclaw-paths.ts` (L2; deferred) | +| `openclawConfigPath` | function | 427–457 | yes | 2 | `src/clients/config-export/openclaw-paths.ts` (L2; deferred) | +| `kimiHomeDir` | function | 459–462 | yes | 1 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `kimiConfigPath` | function | 464–466 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `gajaeHomeDir` | function | 468–470 | yes | 1 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `gajaeConfigPath` | function | 472–474 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `dshHomeDir` | function | 477–492 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `dshConfigPath` | function | 494–496 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `mcodeHomeDir` | function | 503–509 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `mcodeConfigPath` | function | 511–513 | yes | 3 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `zcodeHomeDir` | function | 521–525 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `zcodeConfigPath` | function | 527–529 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `primeAgentDir` | function | 540–544 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `primeConfigPath` | function | 547–549 | yes | 2 | `src/clients/config-export/paths.ts` (L2; deferred) | +| `asideHomeDir` | function | 558–560 | yes | 1 | `src/clients/config-export/aside-paths.ts` (L2; deferred) | +| `asideCurrentAccountId` | function | 584–612 | no | 0 | `src/clients/config-export/aside-paths.ts` (L2; deferred) | +| `asideAccountDir` | function | 619–622 | yes | 2 | `src/clients/config-export/aside-paths.ts` (L2; deferred) | +| `asideConfigPath` | function | 625–627 | yes | 2 | `src/clients/config-export/aside-paths.ts` (L2; deferred) | +| `ExportModel` | interface | 634–647 | yes | 18 | `src/clients/config-export/contracts.ts` (L1) | +| `ExportContext` | interface | 649–658 | yes | 8 | `src/clients/config-export/contracts.ts` (L1) | +| `ExportClientId` | type | 660–672 | yes | 3 | `src/clients/config-export/contracts.ts` (L1) | +| `ExportClientSpec` | interface | 674–713 | yes | 0 | `src/clients/config-export/contracts.ts` (L1) | +| `authoritativeContextWindow` | function | 719–725 | no | 0 | `src/clients/config-export/model-metadata.ts` (L1) | +| `outputBudgetFor` | function | 728–730 | no | 0 | `src/clients/config-export/model-metadata.ts` (L1) | +| `CLIENT_INPUT_MODALITIES` | const | 761–764 | no | 0 | `src/clients/config-export/model-metadata.ts` (L1) | +| `inputModalitiesForClient` | function | 767–779 | no | 0 | `src/clients/config-export/model-metadata.ts` (L1) | +| `dshInputModalities` | function | 782–791 | no | 0 | `src/clients/config-export/dsh.ts` (L1) | +| `exportModelLabel` | function | 798–805 | no | 0 | `src/clients/config-export/model-metadata.ts` (L1) | +| `opencodeProviderConnection` | function | 808–818 | no | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `opencodeEffortVariants` | function | 833–840 | no | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `opencodeProviderBlocks` | function | 855–894 | yes | 1 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `opencodeProviderBlock` | function | 897–903 | no | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `opencodeV2ProviderBlock` | function | 906–912 | yes | 1 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `buildOpencodeProviderBlockFromCatalog` | function | 919–926 | yes | 1 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `normalizeExportModels` | function | 934–943 | yes | 2 | `src/clients/config-export/model-metadata.ts` (L1) | +| `buildOpencodeClientConfig` | function | 953–962 | no | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `PiModelEntry` | interface | 964–979 | yes | 0 | `src/clients/config-export/contracts.ts` (L1) | +| `PiProviderBlock` | interface | 981–986 | yes | 0 | `src/clients/config-export/pi.ts` (L2; deferred) | +| `PiGeneratedConfig` | interface | 988–990 | yes | 6 | `src/clients/config-export/pi.ts` (L2; deferred) | +| `OmpModelEntry` | interface | 997–1006 | yes | 0 | `src/clients/config-export/omp.ts` (L1) | +| `OmpProviderBlock` | interface | 1008–1013 | yes | 0 | `src/clients/config-export/omp.ts` (L1) | +| `OmpGeneratedConfig` | interface | 1015–1017 | yes | 0 | `src/clients/config-export/omp.ts` (L1) | +| `OMP_EFFORT_VOCABULARY` | const | 1023–1023 | no | 0 | `src/clients/config-export/omp.ts` (L1) | +| `ompEfforts` | function | 1025–1034 | no | 0 | `src/clients/config-export/omp.ts` (L1) | +| `HermesProviderBlock` | interface | 1041–1049 | yes | 0 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `HermesModelEntry` | interface | 1052–1054 | yes | 0 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `HermesGeneratedConfig` | interface | 1056–1058 | yes | 4 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `OpenclawModelEntry` | interface | 1060–1064 | yes | 0 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `OpenclawProviderBlock` | interface | 1066–1072 | yes | 0 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `OpenclawGeneratedConfig` | interface | 1075–1080 | yes | 2 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `KimiProviderBlock` | interface | 1082–1086 | yes | 0 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `KimiModelBlock` | interface | 1095–1100 | yes | 0 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `KimiGeneratedConfig` | interface | 1102–1105 | yes | 2 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `GajaeModelEntry` | interface | 1107–1113 | yes | 0 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `GajaeProviderBlock` | interface | 1116–1121 | yes | 0 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `GajaeGeneratedConfig` | interface | 1123–1125 | yes | 3 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `DshReasoningEffort` | type | 1127–1127 | yes | 0 | `src/clients/config-export/dsh.ts` (L1) | +| `DshWireReasoningEffort` | type | 1128–1128 | yes | 0 | `src/clients/config-export/dsh.ts` (L1) | +| `DshModelEntry` | interface | 1130–1136 | yes | 0 | `src/clients/config-export/dsh.ts` (L1) | +| `DshProviderBlock` | interface | 1138–1144 | yes | 0 | `src/clients/config-export/dsh.ts` (L1) | +| `DshGeneratedConfig` | interface | 1146–1150 | yes | 2 | `src/clients/config-export/dsh.ts` (L1) | +| `McodeProviderBlock` | interface | 1152–1163 | yes | 0 | `src/clients/config-export/mcode.ts` (L1) | +| `McodeModelEntry` | interface | 1165–1170 | yes | 0 | `src/clients/config-export/mcode.ts` (L1) | +| `McodeGeneratedConfig` | interface | 1172–1174 | yes | 2 | `src/clients/config-export/mcode.ts` (L1) | +| `ZcodeModelEntry` | interface | 1183–1187 | yes | 0 | `src/clients/config-export/zcode.ts` (L1) | +| `ZcodeProviderBlock` | interface | 1189–1200 | yes | 0 | `src/clients/config-export/zcode.ts` (L1) | +| `ZcodeGeneratedConfig` | interface | 1202–1204 | yes | 1 | `src/clients/config-export/zcode.ts` (L1) | +| `buildPiClientConfig` | function | 1229–1276 | no | 0 | `src/clients/config-export/pi.ts` (L2; deferred) | +| `buildOmpClientConfig` | function | 1283–1321 | no | 0 | `src/clients/config-export/omp.ts` (L1) | +| `proxyAdmissionHeaders` | function | 1324–1326 | no | 0 | `src/clients/config-export/model-metadata.ts` (L1) | +| `buildHermesClientConfig` | function | 1328–1349 | no | 0 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `buildOpenclawClientConfig` | function | 1351–1375 | no | 0 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `kimiModelAlias` | function | 1378–1380 | yes | 1 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `buildKimiClientConfig` | function | 1382–1407 | no | 0 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `buildGajaeClientConfig` | function | 1409–1438 | no | 0 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `DSH_EFFORT_ORDER` | const | 1440–1440 | no | 0 | `src/clients/config-export/dsh.ts` (L1) | +| `dshReasoningEfforts` | function | 1442–1462 | no | 0 | `src/clients/config-export/dsh.ts` (L1) | +| `isKnownSafeDshCombo` | function | 1464–1483 | no | 0 | `src/clients/config-export/dsh.ts` (L1) | +| `buildDshClientConfig` | function | 1485–1516 | no | 0 | `src/clients/config-export/dsh.ts` (L1) | +| `buildMcodeClientConfig` | function | 1527–1559 | no | 0 | `src/clients/config-export/mcode.ts` (L1) | +| `buildZcodeClientConfig` | function | 1570–1606 | no | 0 | `src/clients/config-export/zcode.ts` (L1) | +| `summarizeOpencode` | function | 1614–1617 | no | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `summarizePi` | function | 1619–1622 | no | 0 | `src/clients/config-export/pi.ts` (L2; deferred) | +| `summarizeOmp` | function | 1624–1627 | no | 0 | `src/clients/config-export/omp.ts` (L1) | +| `summarizeHermes` | function | 1629–1633 | no | 0 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `summarizeOpenclaw` | function | 1635–1638 | no | 0 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `summarizeKimi` | function | 1640–1645 | no | 0 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `summarizeGajae` | function | 1647–1650 | no | 0 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `summarizeDsh` | function | 1652–1655 | no | 0 | `src/clients/config-export/dsh.ts` (L1) | +| `summarizeMcode` | function | 1657–1660 | no | 0 | `src/clients/config-export/mcode.ts` (L1) | +| `summarizeZcode` | function | 1662–1665 | no | 0 | `src/clients/config-export/zcode.ts` (L1) | +| `singleFragment` | function | 1668–1670 | no | 0 | `src/clients/config-export/model-metadata.ts` (L1) | +| `buildOpencodeContribution` | function | 1672–1684 | no | 0 | `src/clients/config-export/opencode.ts` (L2; deferred) | +| `buildPiContribution` | function | 1686–1689 | no | 0 | `src/clients/config-export/pi.ts` (L2; deferred) | +| `buildOmpContribution` | function | 1691–1694 | no | 0 | `src/clients/config-export/omp.ts` (L1) | +| `buildHermesContribution` | function | 1696–1699 | no | 0 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `buildOpenclawContribution` | function | 1701–1704 | no | 0 | `src/clients/config-export/hermes-openclaw.ts` (L2; deferred) | +| `buildKimiContribution` | function | 1711–1720 | no | 0 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `buildGajaeContribution` | function | 1722–1725 | no | 0 | `src/clients/config-export/kimi-gajae.ts` (L2; deferred) | +| `buildDshContribution` | function | 1727–1730 | no | 0 | `src/clients/config-export/dsh.ts` (L1) | +| `buildMcodeContribution` | function | 1732–1735 | no | 0 | `src/clients/config-export/mcode.ts` (L1) | +| `buildZcodeContribution` | function | 1737–1740 | no | 0 | `src/clients/config-export/zcode.ts` (L1) | +| `buildPrimeContribution` | function | 1755–1758 | no | 0 | `src/clients/config-export/pi.ts` (L2; deferred) | +| `buildAsideContribution` | function | 1778–1781 | no | 0 | `src/clients/config-export/pi.ts` (L2; deferred) | +| `EXPORT_CLIENTS` | const | 1783–1954 | yes | 15 | `src/clients/config-export.ts` (residual) | +| `EXPORT_CLIENT_IDS` | const | 1956–1956 | yes | 7 | `src/clients/config-export.ts` (residual) | +| `isExportClientId` | function | 1958–1960 | yes | 3 | `src/clients/config-export.ts` (residual) | +| `buildClientConfig` | function | 1963–1965 | yes | 9 | `src/clients/config-export.ts` (residual) | +| `buildClientConfigText` | function | 1973–1985 | yes | 8 | `src/clients/config-export.ts` (residual) | +| `buildClientContribution` | function | 1988–1990 | yes | 5 | `src/clients/config-export.ts` (residual) | + +Export-only declaration: `ConfigFormat` at `src/clients/config-export.ts:32` remains forwarded from `../integrations/serialize`, not redefined. + +## Leaf partition + +Part a moves the lowest-fanout format leaves first: `omp` (sum of external symbol consumers 0), `zcode` (1), `dsh` (2), `mcode` (2). Part b takes the higher-fanout families and paths. The three shared foundations move with part a because even its lowest-fanout clients need them: leaving types/constants/model rules in the original would create facade back-imports. No external caller changes paths. PiModelEntry (0 consumers) moves with shared contracts because OmpModelEntry extends it. The larger Pi document type/builders remain for part b. + +Line-budget convention: each declaration carries immediately preceding comments/whitespace, from previous declaration end+1. One explicit exception: the blank separator at original line 33, immediately after the import/export header, stays in the facade; the first moved block starts at line 34. This gives 707 moved original lines and the contracts projection below. Moving line 33 as well would instead give 708 moved lines and a 151-line contracts leaf. Counts include those blocks, the exact one-line imports shown, one header line and one separator. These are projected implementation counts, not measurements of files already written. Do not discard comments to meet limits. Adding an export keyword does not add a line. All new files are ≤400. + +### `src/clients/config-export/contracts.ts` — expected 150 lines + +Symbols: `ManagedFragment`, `ManagedContribution`, `BuildContribution`, `OpencodeLaunchEnv`, `OpencodeCatalogModel`, `ExportModel`, `ExportContext`, `ExportClientId`, `ExportClientSpec`, `PiModelEntry`. + +Own imports: + +```ts +import type { OcxConfig } from "../../types"; +import type { ConfigFormat } from "../../integrations/serialize"; +``` + +Leaf exports: `ManagedFragment`, `ManagedContribution`, `BuildContribution`, `OpencodeLaunchEnv`, `OpencodeCatalogModel`, `ExportModel`, `ExportContext`, `ExportClientId`, `ExportClientSpec`, `PiModelEntry`. Other listed declarations remain private. Only previously public symbols are forwarded from the original path; newly exposed internal symbols serve production registry/sibling calls, not tests. + +### `src/clients/config-export/constants.ts` — expected 69 lines + +Symbols: `OPENCODE_PROVIDER_ID`, `OPENCODE_CONFIG_SCHEMA`, `OPENCODE_API_KEY_ENV`, `OPENCODE_API_KEY_ENV_REF`, `HERMES_API_KEY_ENV`, `HERMES_API_KEY_ENV_REF`, `OPENCLAW_API_KEY_ENV`, `OPENCLAW_API_KEY_ENV_REF`, `LOOPBACK_API_KEY_PLACEHOLDER`, `GAJAE_API_KEY_ENV`, `PI_API_DIALECT`, `SCHEMA_REQUIRED_OUTPUT_BUDGET`, `OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG`. + +Own imports: + +```ts +import type { OcxConfig } from "../../types"; +``` + +Leaf exports: `OPENCODE_PROVIDER_ID`, `OPENCODE_CONFIG_SCHEMA`, `OPENCODE_API_KEY_ENV`, `OPENCODE_API_KEY_ENV_REF`, `HERMES_API_KEY_ENV`, `HERMES_API_KEY_ENV_REF`, `OPENCLAW_API_KEY_ENV`, `OPENCLAW_API_KEY_ENV_REF`, `LOOPBACK_API_KEY_PLACEHOLDER`, `GAJAE_API_KEY_ENV`, `PI_API_DIALECT`, `SCHEMA_REQUIRED_OUTPUT_BUDGET`, `OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG`. Other listed declarations remain private. Only previously public symbols are forwarded from the original path; newly exposed internal symbols serve production registry/sibling calls, not tests. + +### `src/clients/config-export/model-metadata.ts` — expected 113 lines + +Symbols: `authoritativeContextWindow`, `outputBudgetFor`, `CLIENT_INPUT_MODALITIES`, `inputModalitiesForClient`, `exportModelLabel`, `normalizeExportModels`, `proxyAdmissionHeaders`, `singleFragment`. + +Own imports: + +```ts +import { SCHEMA_REQUIRED_OUTPUT_BUDGET } from "./constants"; +import type { OpencodeCatalogModel, ExportModel, ExportClientId, ManagedContribution } from "./contracts"; +import type { OcxConfig } from "../../types"; +import { shouldInjectApiAuthHeader } from "../../codex/inject"; +``` + +Leaf exports: `authoritativeContextWindow`, `outputBudgetFor`, `inputModalitiesForClient`, `exportModelLabel`, `normalizeExportModels`, `proxyAdmissionHeaders`, `singleFragment`. Other listed declarations remain private. Only previously public symbols are forwarded from the original path; newly exposed internal symbols serve production registry/sibling calls, not tests. + +### `src/clients/config-export/omp.ts` — expected 104 lines + +Symbols: `OmpModelEntry`, `OmpProviderBlock`, `OmpGeneratedConfig`, `OMP_EFFORT_VOCABULARY`, `ompEfforts`, `buildOmpClientConfig`, `summarizeOmp`, `buildOmpContribution`. + +Own imports: + +```ts +import type { PiModelEntry, ExportModel, ExportContext, ManagedContribution } from "./contracts"; +import { PI_API_DIALECT, OPENCODE_PROVIDER_ID, LOOPBACK_API_KEY_PLACEHOLDER } from "./constants"; +import { normalizeExportModels, inputModalitiesForClient, exportModelLabel, authoritativeContextWindow, outputBudgetFor, singleFragment } from "./model-metadata"; +``` + +Leaf exports: `OmpModelEntry`, `OmpProviderBlock`, `OmpGeneratedConfig`, `buildOmpClientConfig`, `summarizeOmp`, `buildOmpContribution`. Other listed declarations remain private. Only previously public symbols are forwarded from the original path; newly exposed internal symbols serve production registry/sibling calls, not tests. + +### `src/clients/config-export/zcode.ts` — expected 92 lines + +Symbols: `ZcodeModelEntry`, `ZcodeProviderBlock`, `ZcodeGeneratedConfig`, `buildZcodeClientConfig`, `summarizeZcode`, `buildZcodeContribution`. + +Own imports: + +```ts +import type { ExportContext, ManagedContribution } from "./contracts"; +import { normalizeExportModels, inputModalitiesForClient, exportModelLabel, authoritativeContextWindow, singleFragment } from "./model-metadata"; +import { OPENCODE_PROVIDER_ID, LOOPBACK_API_KEY_PLACEHOLDER } from "./constants"; +``` + +Leaf exports: `ZcodeModelEntry`, `ZcodeProviderBlock`, `ZcodeGeneratedConfig`, `buildZcodeClientConfig`, `summarizeZcode`, `buildZcodeContribution`. Other listed declarations remain private. Only previously public symbols are forwarded from the original path; newly exposed internal symbols serve production registry/sibling calls, not tests. + +### `src/clients/config-export/dsh.ts` — expected 132 lines + +Symbols: `dshInputModalities`, `DshReasoningEffort`, `DshWireReasoningEffort`, `DshModelEntry`, `DshProviderBlock`, `DshGeneratedConfig`, `DSH_EFFORT_ORDER`, `dshReasoningEfforts`, `isKnownSafeDshCombo`, `buildDshClientConfig`, `summarizeDsh`, `buildDshContribution`. + +Own imports: + +```ts +import type { ExportModel, ExportContext, ManagedContribution } from "./contracts"; +import type { OcxConfig } from "../../types"; +import { providerCodexAccountMode } from "../../providers/registry"; +import { normalizeExportModels, authoritativeContextWindow, exportModelLabel, singleFragment } from "./model-metadata"; +import { OPENCODE_PROVIDER_ID } from "./constants"; +``` + +Leaf exports: `DshReasoningEffort`, `DshWireReasoningEffort`, `DshModelEntry`, `DshProviderBlock`, `DshGeneratedConfig`, `buildDshClientConfig`, `summarizeDsh`, `buildDshContribution`. Other listed declarations remain private. Only previously public symbols are forwarded from the original path; newly exposed internal symbols serve production registry/sibling calls, not tests. + +### `src/clients/config-export/mcode.ts` — expected 83 lines + +Symbols: `McodeProviderBlock`, `McodeModelEntry`, `McodeGeneratedConfig`, `buildMcodeClientConfig`, `summarizeMcode`, `buildMcodeContribution`. + +Own imports: + +```ts +import type { ExportContext, ManagedContribution } from "./contracts"; +import { normalizeExportModels, authoritativeContextWindow, singleFragment } from "./model-metadata"; +import { sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; +import { OPENCODE_PROVIDER_ID, LOOPBACK_API_KEY_PLACEHOLDER } from "./constants"; +``` + +Leaf exports: `McodeProviderBlock`, `McodeModelEntry`, `McodeGeneratedConfig`, `buildMcodeClientConfig`, `summarizeMcode`, `buildMcodeContribution`. Other listed declarations remain private. Only previously public symbols are forwarded from the original path; newly exposed internal symbols serve production registry/sibling calls, not tests. + +Residual `src/clients/config-export.ts`: expected **1299 lines**. It remains >400 intentionally; **410 / S13 L2 / #b** takes all deferred inventory rows. + +Retained declarations after this layer: `OpencodeModelEntry`, `OpencodeModelVariant`, `OpencodeV2ModelEntry`, `OpencodeProviderConnection`, `OpencodeProviderBlock`, `OpencodeV2ProviderBlock`, `OpencodeProviderBlocks`, `OpencodeGeneratedConfig`, `OPENCODE_PROVIDER_NPM`, `OPENCODE_V2_PROVIDER_PACKAGE`, `OPENCODE_PROVIDER_NAME`, `opencodeGlobalConfigPath`, `OMP_PROFILE_NAME_RE`, `OMP_WINDOWS_RESERVED_PROFILE_RE`, `ompProfileName`, `piAgentDir`, `piConfigPath`, `ompAgentDir`, `ompModelsConfigPath`, `opencodeProxyBaseUrl`, `hermesHomeDir`, `hermesConfigPath`, `ClientPathError`, `absoluteClientPath`, `openclawEffectiveHome`, `openclawHomeDir`, `openclawConfigPath`, `kimiHomeDir`, `kimiConfigPath`, `gajaeHomeDir`, `gajaeConfigPath`, `dshHomeDir`, `dshConfigPath`, `mcodeHomeDir`, `mcodeConfigPath`, `zcodeHomeDir`, `zcodeConfigPath`, `primeAgentDir`, `primeConfigPath`, `asideHomeDir`, `asideCurrentAccountId`, `asideAccountDir`, `asideConfigPath`, `opencodeProviderConnection`, `opencodeEffortVariants`, `opencodeProviderBlocks`, `opencodeProviderBlock`, `opencodeV2ProviderBlock`, `buildOpencodeProviderBlockFromCatalog`, `buildOpencodeClientConfig`, `PiProviderBlock`, `PiGeneratedConfig`, `HermesProviderBlock`, `HermesModelEntry`, `HermesGeneratedConfig`, `OpenclawModelEntry`, `OpenclawProviderBlock`, `OpenclawGeneratedConfig`, `KimiProviderBlock`, `KimiModelBlock`, `KimiGeneratedConfig`, `GajaeModelEntry`, `GajaeProviderBlock`, `GajaeGeneratedConfig`, `buildPiClientConfig`, `buildHermesClientConfig`, `buildOpenclawClientConfig`, `kimiModelAlias`, `buildKimiClientConfig`, `buildGajaeClientConfig`, `summarizeOpencode`, `summarizePi`, `summarizeHermes`, `summarizeOpenclaw`, `summarizeKimi`, `summarizeGajae`, `buildOpencodeContribution`, `buildPiContribution`, `buildHermesContribution`, `buildOpenclawContribution`, `buildKimiContribution`, `buildGajaeContribution`, `buildPrimeContribution`, `buildAsideContribution`, `EXPORT_CLIENTS`, `EXPORT_CLIENT_IDS`, `isExportClientId`, `buildClientConfig`, `buildClientConfigText`, `buildClientContribution`. + +Projection before unused-import pruning: 1990 original − 707 cumulative moved original lines + 16 facade glue = 1299. Across a/b: 707 + 1,041 = 1,748 moved body/trivia lines; 242 retained original lines; 1,748 + 242 = 1,990. The projected final glue is 31 lines, giving 273; L1's 16 glue lines are replaced by L2's 31, not both counted. These are estimates, not acceptance measurements: remove the now-unused provider import and measure actual import/forward/separator lines in B/C, recording the reconciled residual and leaf counts before advancing. + +## Re-export block + +Exact forwards in the original path follow. Other public declarations remain exported in place. No wildcard, alias, wrapper, signature change or duplicate definition. + +```ts +export type { ConfigFormat } from "../integrations/serialize"; +export type { ManagedFragment, ManagedContribution, BuildContribution, OpencodeLaunchEnv, OpencodeCatalogModel, ExportModel, ExportContext, ExportClientId, ExportClientSpec, PiModelEntry } from "./config-export/contracts"; +export { OPENCODE_PROVIDER_ID, OPENCODE_CONFIG_SCHEMA, OPENCODE_API_KEY_ENV, OPENCODE_API_KEY_ENV_REF, HERMES_API_KEY_ENV, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV, OPENCLAW_API_KEY_ENV_REF, LOOPBACK_API_KEY_PLACEHOLDER, GAJAE_API_KEY_ENV, SCHEMA_REQUIRED_OUTPUT_BUDGET, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG } from "./config-export/constants"; +export { normalizeExportModels } from "./config-export/model-metadata"; +export type { OmpModelEntry, OmpProviderBlock, OmpGeneratedConfig } from "./config-export/omp"; +export type { ZcodeModelEntry, ZcodeProviderBlock, ZcodeGeneratedConfig } from "./config-export/zcode"; +export type { DshReasoningEffort, DshWireReasoningEffort, DshModelEntry, DshProviderBlock, DshGeneratedConfig } from "./config-export/dsh"; +export type { McodeProviderBlock, McodeModelEntry, McodeGeneratedConfig } from "./config-export/mcode"; +``` + +Explicit residual local imports (re-export binds nothing locally): + +```ts +import type { OpencodeLaunchEnv, OpencodeCatalogModel, ExportContext, PiModelEntry, ManagedContribution, ManagedFragment, ExportClientId, ExportClientSpec } from "./config-export/contracts"; +import { OPENCODE_API_KEY_ENV_REF, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, OPENCODE_CONFIG_SCHEMA, OPENCODE_PROVIDER_ID, PI_API_DIALECT, LOOPBACK_API_KEY_PLACEHOLDER, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV_REF, GAJAE_API_KEY_ENV, OPENCODE_API_KEY_ENV, HERMES_API_KEY_ENV, OPENCLAW_API_KEY_ENV } from "./config-export/constants"; +import { exportModelLabel, authoritativeContextWindow, outputBudgetFor, normalizeExportModels, inputModalitiesForClient, proxyAdmissionHeaders, singleFragment } from "./config-export/model-metadata"; +import { buildOmpClientConfig, summarizeOmp, buildOmpContribution } from "./config-export/omp"; +import { buildDshClientConfig, summarizeDsh, buildDshContribution } from "./config-export/dsh"; +import { buildMcodeClientConfig, summarizeMcode, buildMcodeContribution } from "./config-export/mcode"; +import { buildZcodeClientConfig, summarizeZcode, buildZcodeContribution } from "./config-export/zcode"; +``` + +Retain original external imports still used by the residual; prune only proven-unused bindings. Specifically remove the `providerCodexAccountMode` import and remove only `sanitizeCodexReasoningEfforts` from the reasoning-effort import, retaining `canonicalizeReasoningEfforts`. Keep both existing imports from `../codex/inject`: the residual still uses `shouldInjectApiAuthHeader` and `standaloneCodexRoutingTarget`. New leaves import one another directly. + +## Module-level state and cycles + +`CLIENT_INPUT_MODALITIES` at `src/clients/config-export.ts:761–764` owns two allowlist Sets in `config-export/model-metadata.ts`; never copy them into each client. `OMP_EFFORT_VOCABULARY` at `:1023` belongs only to `config-export/omp.ts`. No top-level let, Map, WeakMap, timer or lock exists. Function-local seen/offered Sets remain per-call. The exported default-config object at `:220–225` moves once to constants.ts; preserve object identity. `EXPORT_CLIENTS` at `:1783–1954` and derived `EXPORT_CLIENT_IDS` at `:1956` remain initialized once in the residual; preserve order. + +Lane 016's AST import BFS found no return path through the original. The partition avoids new return imports, including type-only ones. Risk: original → client leaf → original. Shared contracts/constants/model rules therefore move down in L1. `contracts.ts → ../../integrations/serialize` preserves ConfigFormat's actual owner; do not substitute config-io (which imports the original facade). OpenClaw/Aside paths import paths.ts for the single constructor/absolute-path rule; paths.ts imports no path sibling. Only the residual registry composes all client builders. Private builders/summarizers become explicit leaf exports for that production registry; no duplicated closures. + +Coupling classification: existing config-schema coupling stays with format owners; sequential/functional coupling is explicit through parameters. No new common mutable state or temporal startup constraint. Existing auth/ownership checks are moved verbatim. Before execution rerun lane 016 method G against the actual layer base (relative static imports, re-exports, type-only edges and literal dynamic imports); any new return path is escalation, not permission for a lazy-import workaround. + +## Tests + +Discovery: `rg -l 'src/clients/config-export' tests --glob '*.ts'`, followed by import/source-read inspection. Every direct test/fixture importer is listed below, with disposition **unchanged** (old public path): + +- `tests/ci-workflows/dsh-path-contract.test.ts` — unchanged. +- `tests/ci-workflows/dsh-writer-lock.test.ts` — unchanged. +- `tests/cli/cli-help.test.ts` — unchanged. +- `tests/clients/client-export-modality-enum.test.ts` — unchanged. +- `tests/clients/integrations-state.test.ts` — unchanged. +- `tests/clients/integrations-writer.test.ts` — unchanged. +- `tests/clients/omp-path-contract.test.ts` — unchanged. +- `tests/clients/pi-path-contract.test.ts` — unchanged. +- `tests/clients/prime-client.test.ts` — unchanged. +- `tests/clients/sync-client-integrations.test.ts` — unchanged. +- `tests/config/client-config-export-new-clients.test.ts` — unchanged. +- `tests/config/client-config-export.test.ts` — original import/assertions retained; facade identity and fixed-byte regressions added. +- `tests/config/client-config-new-clients.test.ts` — unchanged. +- `tests/gui/integrations-invariants.test.ts` — unchanged. +- `tests/providers/aside-client.test.ts` — unchanged. +- `tests/providers/minimax-clients.test.ts` — unchanged. +- `tests/providers/zcode-client.test.ts` — unchanged. +- `tests/server/management-client-config-route.test.ts` — unchanged. +- `tests/server/management-integration-journal-delete.test.ts` — unchanged. +- `tests/server/management-integration-routes.test.ts` — unchanged. + +No source-text reader of src/clients/config-export.ts was found. `tests/config/client-config-export.test.ts:58` and `tests/server/management-client-config-route.test.ts:416` mention it in comments, not source reads. No retarget-to-leaf or add-leaf-to-scan-list action. Preserve baked serialized fixtures unchanged. + +C-phase red proof: temporarily treat incompatible audio-only input as text in the moved metadata function and observe `tests/clients/client-export-modality-enum.test.ts:96` fail; restore. Temporarily retain none in the moved MCode effort list and observe `tests/providers/minimax-clients.test.ts:117` fail; restore. + +The implementation uses the existing focused test file; no new test file was needed. Original assertions remain intact. The recorded red/restored-green proof applies to the unchanged tested source and test blobs; repeat it if those change. Never commit mutation probes. A future new test file requires both layout registry entries. + +## Verification + +Run the following Bash recipe from this session's bound checkout while its FSM is at C, after committing and publishing the layer head. The session identifier below belongs to this task; another task must use its own latest SessionStart binding. All Bun commands run on `lidge`, inside a fresh temporary clone. No shared seed checkout is switched. The local commands only validate identity, transport the verifier and retain output. + +```bash +set -euo pipefail +wp400_root=$(git rev-parse --show-toplevel) +wp400_expected=$(git rev-parse HEAD) +wp400_status=$(git status --porcelain) +test -z "$wp400_status" +wp400_log="$wp400_root/.codexclaw/evidence/01a06e97-b9d8-7250-8204-bb788338c288/wp400-remote-check-$wp400_expected.log" +mkdir -p "$(dirname "$wp400_log")" +cxc receipt test --cwd "$wp400_root" --session 01a06e97-b9d8-7250-8204-bb788338c288 -- bash -c ' +set -euo pipefail +test "$(git rev-parse HEAD)" = "$1" +local_status=$(git status --porcelain) +test -z "$local_status" +ssh lidge bash -s -- "$1" 2>&1 | tee "$2" +test "$(git rev-parse HEAD)" = "$1" +local_status=$(git status --porcelain) +test -z "$local_status" +' -- "$wp400_expected" "$wp400_log" <<'REMOTE' +set -euo pipefail +expected=${1:?expected SHA required} +[[ "$expected" =~ ^[0-9a-f]{40}$ ]] +run_dir=$(mktemp -d /tmp/ocx-wp400.XXXXXX) +printf 'RETAINED_RUN_DIR=%s\n' "$run_dir" +git clone --no-checkout https://github.com/lidge-jun/opencodex.git "$run_dir/repo" +cd "$run_dir/repo" +git fetch origin refs/heads/codex/split-clients-config-export-a +test "$(git rev-parse FETCH_HEAD)" = "$expected" +git checkout --detach "$expected" +bun --version +bun install --frozen-lockfile +(cd gui && bun install --frozen-lockfile) +tree_status=$(git status --porcelain) +test -z "$tree_status" +printf 'CHECKOUT=%s\nHEAD=%s\n' "$PWD" "$(git rev-parse HEAD)" +unset OCX_TEST_NO_QUEUE +bun run typecheck +bun test tests/ci-workflows/dsh-path-contract.test.ts tests/ci-workflows/dsh-writer-lock.test.ts tests/cli/cli-help.test.ts tests/clients/client-export-modality-enum.test.ts tests/clients/integrations-state.test.ts tests/clients/integrations-writer.test.ts tests/clients/omp-path-contract.test.ts tests/clients/pi-path-contract.test.ts tests/clients/prime-client.test.ts tests/clients/sync-client-integrations.test.ts tests/config/client-config-export-new-clients.test.ts tests/config/client-config-export.test.ts tests/config/client-config-new-clients.test.ts tests/gui/integrations-invariants.test.ts tests/providers/aside-client.test.ts tests/providers/minimax-clients.test.ts tests/providers/zcode-client.test.ts tests/server/management-client-config-route.test.ts tests/server/management-integration-journal-delete.test.ts tests/server/management-integration-routes.test.ts tests/cli/cli-export-command.test.ts +bun run privacy:scan +if bun run test; then + test_rc=0 +else + test_rc=$? +fi +printf 'SUITE_EXIT=%s\n' "$test_rc" +if [ "$test_rc" -ne 0 ]; then exit "$test_rc"; fi +test "$(git rev-parse HEAD)" = "$expected" +tree_status=$(git status --porcelain) +test -z "$tree_status" +printf 'VERIFIED_HEAD=%s\n' "$expected" +REMOTE +``` + +The local pipeline propagates SSH and log-write failure to the receipt producer. Remote commands stop on failure; full-suite status is printed and returned. Final remote HEAD and Git status must still match the clean layer head. Keep the temporary clone and full output as evidence. A receipt proves the command actually run, not this prose; require fresh current-head CI and independent review before closure. + +Local read-only structural checks are `git diff --check`, `wc -l` for the eight source files, and importer discovery with `rg -n 'clients/config-export' src gui/src scripts tests`. Resolve actual import edges when comparing consumers; a grep count alone is insufficient. New leaves must have no facade return edge, including type-only and literal dynamic imports. The full suite includes the core/Lab guard; do not weaken its protected roots. This layer's own source delta does not touch those protected files. + +## Accept criteria + +1. Apply PURE-MOVE-SIZE-01: ≤150 non-move changed lines, move-aware diff evidence, and exactly one implementation owner for each inventory symbol. No claim that literal added+deleted churn meets 500. +2. Every inventory declaration has exactly one implementation owner. Preserve all original export names/signatures and value/type importability; do not extract L1 declarations a second time. +3. Every new leaf is ≤400 lines. Residual target is 1299, with the sole >400 carry explicitly assigned to 410 / #b. Measure actual files and explain drift before proceeding. +4. Preserve function bodies, branch order, literals, serialized bytes/key order, class/object identity and state initialization. Only moves, explicit imports and named forwards change source structure. +5. Old-path consumers and assertions remain intact. Record the exact red/restored-green evidence named under Tests; no guard deletion, skipping, weakened assertions or empty-facade source scans. +6. Singleton state/allowlists each have one owner; no leaf imports the original even for types; resolved static/re-export/type/dynamic-literal graph has no new cycles. +7. Typecheck, focused checks, privacy, remote full suite and exact-head CI pass at this layer tip independently of later layers. No full local suite and no merge. +8. Diff stays within the original/new leaves and genuinely required existing focused tests. New tests, SoT edits, new topology or unrelated code require parent scope approval. + +## PR + +Title: `refactor(clients): extract low-fanout client formats and dependency foundations (split S13 L1/5)` + +Branch: `codex/split-clients-config-export-a`. Current replanned base: `dev`, pinned `be81013fab6d83ff630ca5f38e7881678a303871` after prerequisite #3610 landed. Closes: none. + +Use all sections of `.github/PULL_REQUEST_TEMPLATE.md` (Summary, Verification, Checklist), including the size-gate disposition and DEV-STACK-03 map below. This layer is PR #3611; placeholder rows refer only to future layers. + +| # | PR | Layer | Branch | Base | Review focus | +|---|---|---|---|---|---| +| 1 | #3611 | 400 — this layer | `codex/split-clients-config-export-a` | `dev` | extract low-fanout client formats and dependency foundations | +| 2 | #TBD-S13-L2 | 410 | `codex/split-clients-config-export-b` | `codex/split-clients-config-export-a` | finish client path and format partitions | +| 3 | #TBD-S13-L3 | 420 | `codex/split-cli-opencode` | `codex/split-clients-config-export-b` | separate OpenCode config and catalog from launch | +| 4 | #TBD-S13-L4 | 430 | `codex/split-cli-minimax` | `codex/split-cli-opencode` | isolate MMX protocol and termination owners | +| 5 | #TBD-S13-L5 | 440 | `codex/split-integrations-state` | `codex/split-clients-config-export-b` | separate classification from state reads | + +Bottom S13 layer against `dev`. The former prerequisite #3610 has landed and the retarget/restack is complete. Review this layer's diff only. No S13 child had been published at this checkpoint. Future base changes require normal scoped restack and fresh verification; no open-prerequisite retargeting action remains. Merging stays out of scope. + +## P stale-check (2026-09-05, wp400) + +Historical stale check at origin/dev 3191fe1aa: config-export.ts unchanged since 445742966 (1990 lines). Base `dev` (S13 bottom; 410 #b, 420, 430, 440 chain on it). The planned subdirectory mirrors the src/codex/prompt-layers/ precedent from L300. 003 INTERMEDIATE-RESIDUAL-01 applies. Known upstream failures were management-route-registry ×3 and quota-reset-notify ×1. The earlier OCX_TEST_NO_QUEUE=1 instruction is withdrawn: it contaminates lock tests and must be unset for remote verification. No local suites; CI hygiene requires a test change. + +## A audit synthesis (2026-09-05, wp400) + +### Current execution authority + +The current branch is `codex/split-clients-config-export-a`, PR #3611, base `dev` at `be81013fab6d83ff630ca5f38e7881678a303871`. That SHA is the integration base, not the layer head. The restack has already been performed. For current verification, use the clean layer HEAD and the isolated recipe above; do not repeat the retired parent rebase below. #3610 has landed as `5ab8aa9a2d9d2a3926469f9d8c82387b43c6d0e9`. + +### Historical replan record — not executable + +The original `244663568` split on the `850afb2e9` foundation passed focused checks but failed the full suite on four inherited quota/route cases. A temporary stack used the then-open #3610 head `afdd38ff43c64696153372fc2e27a38aff208c73`; its older foundation omitted four intervening dev commits, an explicitly recorded and audited tradeoff. The historical review anchors `7953e6d4` and `7d4a37544` belong to that retired parent arrangement. + +After #3610 landed, the user authorized continued scoped verification and repair. Main replayed only its own commits onto the current dev base, preserving old refs and using `--no-update-refs` plus an exact-old-head lease. The actual `412dcba4` tree was identity-checked against the audited dev-base overlay. Its remote gates passed. Subsequent documentation repairs require a new current-head receipt; the historical hashes here are evidence anchors, never checkout or retargeting instructions. + +The complete initial roadmap remains in the preserved documentation branch, at immutable local commit `dc44b08cafbbd45da81f940f1e8c00a9e5f61ce1`. Only this layer's governing documents are carried in its PR; other roadmap documents are read from that preserved ref. Implementation and receipts remain in the existing a2c0 directory, never in a replacement managed worktree. + +Hooke (`01a06f9f-f57f-7fc3-9261-b07f291929be`, requested gpt-6-astra high) returned GO-WITH-FIXES with zero blockers, then PASS after the two documentation corrections above. The read-only audit matched all 153 inventory ranges, assigned all 63 moved declarations uniquely, checked seven leaf and seven facade import lists, and preserved 96 public exports (47 types, 49 values). Its dependency traversal reported no return path from the external owners to the facade at base `3191fe1aa`. These are plan-audit results, not implementation or test results. + +Accepted findings: replace the stale raw-churn escalation with the binding ≤150 non-move gate; explicitly retain original blank line 33 and mark projected line counts as pre-pruning estimates. No blocker was rebutted. Re-review confirmed both closures at docs HEAD `38ad3cf5a` plus the working diff. `git diff --check` exited 0 after those edits; no local test suite was run. + +Operational audit by Wegener (`01a06fa6-5e3c-7840-8172-8587e853dcc7`, explicitly `model=gpt-6-astra`, `reasoning_effort=high`) found two blockers: checkout-local source identity was incompatible with the prior separate execution tree, and the remote recipe switched a shared checkout. Both were accepted and folded into 003 WORKTREE-EVIDENCE-01 and 000. Re-audit returned PASS, with no blocker to entering B. A documentation-only delta must not stand in for implementation evidence from another checkout. The unsafe shared-checkout command has been removed; Verification now contains the isolated recipe. The actual runner received independent exact-SHA, clean-tree and failure-propagation review; changes to that recipe require the same checks. Approval of the plan is not proof that remote verification passed. + +## B implementation record (2026-09-05) + +### Historical temporary-stack checkpoint + +At historical review anchor `7953e6d4e18b0e7c90c0c5cdb0a4256c22a25dd0`, the integration base was `afdd38ff43c64696153372fc2e27a38aff208c73` and PR #3611 temporarily targeted the then-open #3610 branch. That arrangement is retired. Only our branch was rebased/pushed; the exact-old-head lease protected publication and `--no-update-refs` preserved the old snapshots. + +Independent reviewer Heisenberg confirmed identical blob IDs for all nine source/test paths versus244, exactly those nine paths plus three documents in the parent-relative diff, and an actual-tree traversal of4979edges/349facade-reachable files with no new return cycle or unresolved reachable import. Static verdict PASS; not a runtime-pass claim. This documentation checkpoint adds no source/test changes after that review anchor. + +The prerequisite itself was separately verified in remote `/tmp/ocx-wp400.T036h8/repo` at exactafdd38ff: typecheck0,440focusedpass/0fail, privacy0, full suite SUITE_EXIT=0 and final HEAD/clean-tree checks passed. Full output: `wp400-prerequisite-check.log` in the session evidence directory. No local suite ran. Fresh resulting-head verification and GitHub CI for #3611 are still required. + +Franklin (`01a06fac-95ee-77a0-8916-f7546c2b8996`, explicitly gpt-6-astra high) implemented only the approved source/test paths in a2c0 and handed them back without Git mutations or local tests. Main inspected the diff and measured all leaves. Source owner search and the A inventory were reused; no new algorithm or parallel implementation was introduced. + +| File | Change and impact | Measured lines | +|---|---|---:| +| `src/clients/config-export.ts` | Retains dispatch/compatibility exports, imports moved owners; caller paths unchanged | 1298 | +| `src/clients/config-export/contracts.ts` | Canonical shared types, no runtime behavior | 150 | +| `src/clients/config-export/constants.ts` | Single constant/default-object owner | 69 | +| `src/clients/config-export/model-metadata.ts` | Existing normalization/modality/admission helpers moved intact | 113 | +| `src/clients/config-export/omp.ts` | Existing OMP builder, summary and owned fragment | 104 | +| `src/clients/config-export/zcode.ts` | Existing ZCode builder, summary and owned fragment | 92 | +| `src/clients/config-export/dsh.ts` | Existing DSH builder, summary and owned fragment | 132 | +| `src/clients/config-export/mcode.ts` | Existing MCode builder, summary and owned fragment | 83 | +| `tests/config/client-config-export.test.ts` | Adds identity and independent fixed-byte/fragment assertions; original assertions/fixtures unchanged | 919 | + +The worker's AST inventory reports 153 unique owners (63 moved, 90 retained), 96 public exports (47 types, 49 values), 707 moved original lines and 109 non-move lines (36 leaf glue + 18 facade additions + 3 removals + 52 test additions). Actual facade size is one below the estimate because the unused provider import was removed. `git diff --check` passed. The existing test file was already over 400; its scoped extension is not a claim to resolve test-file debt. Final independent graph/syntax and runtime gates remain pending; the worker's combined graph/syntax command hit an AST no-match exit and did not establish a pass. + +Verification runner: `.codexclaw/evidence/01a06e97-b9d8-7250-8204-bb788338c288/wp400-check.sh` invokes the reviewed `wp400-remote-check.sh` only over SSH. Wegener closed the pre-C hold after four clean-tree substitutions were changed to standalone Git-status assignments and the no-queue override was removed. Both scripts pass `bash -n`; runtime success is not implied. + +Baseline evidence: isolated remote `/tmp/ocx-wp400.4dKWtB/repo`, exact base `850afb2e9f84979c87e914b248de482f44b34cd6`; typecheck, 440 focused tests across 21 files and privacy scan passed. Full suite exited 1. The initial runner mistakenly exported OCX_TEST_NO_QUEUE=1, inducing four lock-test failures in addition to the known upstream route-registry/rollover failures. That run is contaminated and cannot certify all gates. The variable is now explicitly unset; corrected verification is required. Full baseline output is retained as `wp400-base-check.log` in the same evidence directory. No local suite was run. diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index ea1b623f31..7aaf556f2b 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -24,56 +24,28 @@ import { existsSync, readFileSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; import { shouldInjectApiAuthHeader, standaloneCodexRoutingTarget } from "../codex/inject"; import { FORMAT_MEDIA_TYPE, serializeDocument, type ConfigFormat } from "../integrations/serialize"; -import { providerCodexAccountMode } from "../providers/registry"; -import { canonicalizeReasoningEfforts, sanitizeCodexReasoningEfforts } from "../reasoning-effort"; +import { canonicalizeReasoningEfforts } from "../reasoning-effort"; import { probeHostname } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; -export type { ConfigFormat }; +export type { ConfigFormat } from "../integrations/serialize"; +export type { ManagedFragment, ManagedContribution, BuildContribution, OpencodeLaunchEnv, OpencodeCatalogModel, ExportModel, ExportContext, ExportClientId, ExportClientSpec, PiModelEntry } from "./config-export/contracts"; +export { OPENCODE_PROVIDER_ID, OPENCODE_CONFIG_SCHEMA, OPENCODE_API_KEY_ENV, OPENCODE_API_KEY_ENV_REF, HERMES_API_KEY_ENV, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV, OPENCLAW_API_KEY_ENV_REF, LOOPBACK_API_KEY_PLACEHOLDER, GAJAE_API_KEY_ENV, SCHEMA_REQUIRED_OUTPUT_BUDGET, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG } from "./config-export/constants"; +export { normalizeExportModels } from "./config-export/model-metadata"; +export type { OmpModelEntry, OmpProviderBlock, OmpGeneratedConfig } from "./config-export/omp"; +export type { ZcodeModelEntry, ZcodeProviderBlock, ZcodeGeneratedConfig } from "./config-export/zcode"; +export type { DshReasoningEffort, DshWireReasoningEffort, DshModelEntry, DshProviderBlock, DshGeneratedConfig } from "./config-export/dsh"; +export type { McodeProviderBlock, McodeModelEntry, McodeGeneratedConfig } from "./config-export/mcode"; -/** - * One entry opencodex owns inside a client's config: the JSON path to it and - * the value we put there. - * - * A path list rather than a single provider key because ownership is not - * always one entry — Kimi owns its provider block AND one model entry per - * model, and a writer that only knew about the provider would strand the rest - * (devlog 260802 006 §2). - */ -export interface ManagedFragment { - path: readonly string[]; - value: unknown; -} +import type { OpencodeLaunchEnv, OpencodeCatalogModel, ExportContext, PiModelEntry, ManagedContribution, ManagedFragment, ExportClientId, ExportClientSpec } from "./config-export/contracts"; +import { OPENCODE_API_KEY_ENV_REF, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, OPENCODE_CONFIG_SCHEMA, OPENCODE_PROVIDER_ID, PI_API_DIALECT, LOOPBACK_API_KEY_PLACEHOLDER, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV_REF, GAJAE_API_KEY_ENV, OPENCODE_API_KEY_ENV, HERMES_API_KEY_ENV, OPENCLAW_API_KEY_ENV } from "./config-export/constants"; +import { exportModelLabel, authoritativeContextWindow, outputBudgetFor, normalizeExportModels, inputModalitiesForClient, proxyAdmissionHeaders, singleFragment } from "./config-export/model-metadata"; +import { buildOmpClientConfig, summarizeOmp, buildOmpContribution } from "./config-export/omp"; +import { buildDshClientConfig, summarizeDsh, buildDshContribution } from "./config-export/dsh"; +import { buildMcodeClientConfig, summarizeMcode, buildMcodeContribution } from "./config-export/mcode"; +import { buildZcodeClientConfig, summarizeZcode, buildZcodeContribution } from "./config-export/zcode"; -/** Everything opencodex contributes to one client's config, as one unit. */ -export interface ManagedContribution { - clientId: ExportClientId; - fragments: readonly ManagedFragment[]; -} - -export type BuildContribution = (ctx: ExportContext) => ManagedContribution; - -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; - /** Declared effort ladder. Exported as opencode model variants where the client reads them. */ - reasoningEfforts?: readonly string[]; - /** - * Declared default effort. Carried so every client export reads one deduped, visibility- - * filtered ladder per model. The opencode serializer deliberately does NOT turn it into a - * model-level setting — see {@link opencodeEffortVariants} for why. - */ - defaultReasoningEffort?: string; -} export interface OpencodeModelEntry { name: string; @@ -137,11 +109,6 @@ export interface OpencodeGeneratedConfig { providers: 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). @@ -163,67 +130,6 @@ const OPENCODE_V2_PROVIDER_PACKAGE = "@opencode-ai/ai/providers/openai-compatibl /** Display name for the provider block, identical in both generations. */ const OPENCODE_PROVIDER_NAME = "OpenCodex"; -/** - * 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}}`; - -/** - * Hermes interpolates `${VAR}` anywhere in config.yaml, so the credential stays - * in the environment exactly as it does for OpenCode. - */ -export const HERMES_API_KEY_ENV = "OPENCODEX_HERMES_API_KEY"; -export const HERMES_API_KEY_ENV_REF = `\${${HERMES_API_KEY_ENV}}`; - -/** OpenClaw interpolates `${UPPERCASE_VAR}` and fails closed when it is unset. */ -export const OPENCLAW_API_KEY_ENV = "OPENCODEX_OPENCLAW_API_KEY"; -export const OPENCLAW_API_KEY_ENV_REF = `\${${OPENCLAW_API_KEY_ENV}}`; - -/** - * Placeholder credential for loopback-only clients (Kimi, Pi). A loopback - * bind needs no real admission key, so we emit the same placeholder the Grok - * managed block uses rather than a user secret. Pi resolves `apiKey` before - * building its model list and hides the provider when an env reference is unset. - */ -export const LOOPBACK_API_KEY_PLACEHOLDER = "opencodex-loopback"; - -/** - * Gajae's `apiKeyEnv` is env-name-only and fail-closed. Its sibling `apiKey` - * falls back to treating the literal text as the token when the variable is - * unset, which would silently ship a bogus credential — so we never emit it. - */ -export const GAJAE_API_KEY_ENV = "OPENCODEX_GAJAE_API_KEY"; - -/** 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). @@ -626,184 +532,6 @@ export function asideConfigPath(env: OpencodeLaunchEnv = process.env, home: stri return join(asideAccountDir(env, home), "models.json"); } -/** - * 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[]; - /** Optional effort ladder exported only to clients that support it. */ - reasoningEfforts?: string[]; - defaultReasoningEffort?: 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" - | "omp" - | "hermes" - | "openclaw" - | "kimi" - | "gajae" - | "dsh" - | "mcode" - | "zcode" - | "prime" - | "aside"; - -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; - /** - * Text format of the client's config file. `filename` already carries the - * extension; this drives serialization and the download media type so no - * consumer has to infer either from the name. - */ - format: ConfigFormat; - /** - * Count models in THIS client's document shape. Required so a new client - * cannot be added without teaching the summarizer about it — the old - * "anything that is not OpenCode must be Pi" branch was a latent bug. - */ - summarize: (document: unknown) => { modelCount: number; modelsWithoutLimits: number }; - /** - * The fragments opencodex owns inside this client's config. Only the builder - * knows where a client keeps our entries, so ownership paths originate here - * rather than being re-derived by the writer. - */ - buildContribution: BuildContribution; - /** - * True when the generated integration deliberately supports loopback only. - * - * `/v1/chat/completions` rejects bearer credentials and requires the - * dedicated `x-opencodex-api-key` header (AUTH_MATRIX in - * src/server/auth-cors.ts). If this exporter cannot safely emit that header, - * it refuses a remote bind rather than generating a config that 401s. Same - * reasoning as the Grok managed block's non-loopback refusal. - */ - loopbackOnly: boolean; -} - -/** - * 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) { - const integer = Math.floor(contextWindow); - return integer > 0 ? integer : undefined; - } - return undefined; -} - -/** Schema-required output budget for a known context window. */ -function outputBudgetFor(context: number): number { - return Math.min(SCHEMA_REQUIRED_OUTPUT_BUDGET, context); -} - -/** - * Modalities a given client's schema will actually accept. - * - * Our internal vocabulary is `text | image | audio` (ALLOWED_INPUT_MODALITIES in - * src/server/management/model-routes.ts). Pi and Gajae accept only - * `text | image`, and both reject the WHOLE config file over one out-of-enum - * value — Gajae reports `/providers/opencodex/models/N/input/2: Invalid option` - * and falls back to its built-in list, Pi returns an empty model config. So a - * single `audio` model takes every routed model down with it. That is not - * hypothetical: zenmux/meta-muse-spark-1.1 advertises audio and did exactly - * this. It is also the same defect the Codex catalog had with `video`, where - * the app showed zero apps (tests/codex-integration/catalog-input-modality-enum.test.ts). - * - * UNKNOWN and INCOMPATIBLE are different inputs, and the Codex fix could - * conflate them safely only because its enum is wider. A model with nothing - * declared is unknown, and `text` is the honest floor — every routed model takes - * prompts. A model declaring `["audio"]` and nothing else is incompatible with a - * text|image client, and rewriting it to `["text"]` would advertise a capability - * it does not have. That input is reachable three ways: `ocx models add - * --modalities audio`, `/api/custom-models`, and provider discovery. - * - * So unknown falls back to text and incompatible returns null, which drops the - * row. Omitting a model costs the user a line in a picker; fabricating `text` - * costs them a model that fails at call time with no explanation. - * - * Deliberately NOT applied in `ExportModel` construction: the management and CLI - * boundaries carry catalog modalities verbatim on purpose, and stripping `audio` - * globally would destroy valid metadata before the destination is known. - */ -const CLIENT_INPUT_MODALITIES: Record<"pi" | "gajae", ReadonlySet> = { - pi: new Set(["text", "image"]), - gajae: new Set(["text", "image"]), -}; - -/** `null` means the model cannot be represented for this client — drop the row. */ -function inputModalitiesForClient( - client: "pi" | "gajae", - modalities: readonly string[] | undefined, -): string[] | null { - const declared = modalities ?? []; - if (declared.length === 0) return ["text"]; - const accepted = CLIENT_INPUT_MODALITIES[client]; - const kept: string[] = []; - for (const value of declared) { - if (accepted.has(value) && !kept.includes(value)) kept.push(value); - } - return kept.length > 0 ? kept : null; -} - -/** DSH rc.6 accepts text/image; unknown values degrade to text, while audio-only cannot be represented. */ -function dshInputModalities(modalities: readonly string[] | undefined): string[] | null { - const declared = modalities ?? []; - if (declared.length === 0) return ["text"]; - const kept: string[] = []; - for (const value of declared) { - if ((value === "text" || value === "image") && !kept.includes(value)) kept.push(value); - } - if (kept.length > 0) return kept; - return declared.every(value => value === "audio") ? null : ["text"]; -} - -/** - * 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})`; -} - /** Endpoint plus admission, identical for the V1 `options` and V2 `settings` field. */ function opencodeProviderConnection(baseURL: string, config: OcxConfig): OpencodeProviderConnection { const options: OpencodeProviderConnection = { baseURL }; @@ -925,23 +653,6 @@ export function buildOpencodeProviderBlockFromCatalog( 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 document: both provider generations plus `$schema`, and nothing else. * @@ -961,23 +672,6 @@ function buildOpencodeClientConfig(ctx: ExportContext): OpencodeGeneratedConfig }; } -export interface PiModelEntry { - id: string; - name: string; - input: string[]; - contextWindow?: number; - maxTokens?: number; - /** Advertised when the catalog row carries a non-empty effort ladder. */ - reasoning?: true; - /** - * Constrains pi's own level scale (minimal..max) to the declared ladder: members map to - * themselves, everything else is hidden (`null`). Without it pi would offer levels the - * ladder does not contain — harmless for provider-config ladders (the proxy clamps those - * at the wire) but a real 400 risk for custom-row ladders, which are advertisement-only. - */ - thinkingLevelMap?: Record; -} - export interface PiProviderBlock { baseUrl: string; api: string; @@ -989,50 +683,6 @@ export interface PiGeneratedConfig { providers: Record; } -/** - * omp accepts a model-level API override. Keep the provider on Chat - * Completions so routed providers retain their established wire format, while - * native OpenAI models can use the lossless Responses surface. - */ -export interface OmpModelEntry extends PiModelEntry { - api?: "openai-responses"; - /** omp requires this flag before it honors a thinking block. */ - reasoning?: true; - thinking?: { - mode: "effort"; - efforts: string[]; - defaultLevel?: string; - }; -} - -export interface OmpProviderBlock { - baseUrl: string; - api: typeof PI_API_DIALECT; - apiKey: string; - models: OmpModelEntry[]; -} - -export interface OmpGeneratedConfig { - providers: Record; -} - -/** - * omp validates model entries strictly. These are its documented effort - * values; omit an unknown value rather than invalidating the whole provider. - */ -const OMP_EFFORT_VOCABULARY = new Set(["minimal", "low", "medium", "high", "xhigh", "max"]); - -function ompEfforts(model: ExportModel): string[] { - const efforts: string[] = []; - for (const effort of model.reasoningEfforts ?? []) { - const normalized = effort.trim().toLowerCase(); - if (OMP_EFFORT_VOCABULARY.has(normalized) && !efforts.includes(normalized)) { - efforts.push(normalized); - } - } - return efforts; -} - /** * Hermes `~/.hermes/config.yaml`. We emit ONLY the provider entry — never * `model.default` — because hijacking the user's main model is not what a @@ -1124,85 +774,6 @@ export interface GajaeGeneratedConfig { providers: Record; } -export type DshReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max"; -export type DshWireReasoningEffort = DshReasoningEffort | "ultra"; - -export interface DshModelEntry { - id: string; - name: string; - input: string[]; - contextWindow?: number; - reasoningEfforts?: Partial>; -} - -export interface DshProviderBlock { - displayName: "OpenCodex"; - api: "openai-responses"; - baseURL: string; - headers: { Authorization: "Bearer ocx_data_dsh" }; - models: DshModelEntry[]; -} - -export interface DshGeneratedConfig { - "llm-pi-ai": { - providers: Record; - }; -} - -export interface McodeProviderBlock { - name: "OpenCodex"; - kind: "custom"; - enabled: true; - api: "anthropic-messages"; - options: { - apiKey: string; - baseURL: string; - authMode: "api-key"; - }; - models: Record; -} - -export interface McodeModelEntry { - /** MCode uses this value for context accounting and compaction. */ - limit?: { context: number }; - /** MCode exposes these exact levels in `/model` and sends the selected effort. */ - thinking?: { effortOptions: string[] }; -} - -export interface McodeGeneratedConfig { - custom_provider: Record; -} - -/** - * ZCode's `~/.zcode/v2/config.json` provider entry (observed schema, validated - * live against ZCode 3.7.7 / 3.8.1). `kind: "openai-compatible"` selects the - * OpenAI Chat Completions protocol, which the proxy serves at `/v1/chat/completions`. - * `apiKeyRequired` keeps ZCode's UI from prompting for a key it does not need on - * loopback; the serialized key is always the non-secret loopback placeholder. - */ -export interface ZcodeModelEntry { - name?: string; - limit?: { context: number; output?: number }; - modalities: { input: string[]; output: string[] }; -} - -export interface ZcodeProviderBlock { - name: "OpenCodex"; - kind: "openai-compatible"; - enabled: true; - source: "custom"; - options: { - apiKey: string; - baseURL: string; - apiKeyRequired: true; - }; - models: Record; -} - -export interface ZcodeGeneratedConfig { - provider: Record; -} - /** * Pi's `~/.pi/agent/models.json` shape. `models` is an ARRAY (identity lives in `id`), * unlike OpenCode's keyed object. @@ -1275,56 +846,6 @@ function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig { }; } -/** - * omp's models.yml is Pi-like, but it supports effort metadata and a per-model - * API dialect. Native OpenAI models use Responses; all routed models inherit - * the provider's existing Chat Completions dialect. - */ -function buildOmpClientConfig(ctx: ExportContext): OmpGeneratedConfig { - const models: OmpModelEntry[] = []; - for (const model of normalizeExportModels(ctx.models)) { - const input = inputModalitiesForClient("pi", model.inputModalities); - if (input === null) continue; - const entry: OmpModelEntry = { - id: model.namespaced, - name: exportModelLabel(model), - input, - ...(model.native && model.provider === "openai" ? { api: "openai-responses" } : {}), - }; - const context = authoritativeContextWindow(model.contextWindow); - if (context !== undefined) { - entry.contextWindow = context; - entry.maxTokens = outputBudgetFor(context); - } - const efforts = ompEfforts(model); - if (efforts.length > 0) { - const defaultLevel = model.defaultReasoningEffort?.trim().toLowerCase(); - entry.reasoning = true; - entry.thinking = { - mode: "effort", - efforts, - ...(defaultLevel && efforts.includes(defaultLevel) ? { defaultLevel } : {}), - }; - } - models.push(entry); - } - return { - providers: { - [OPENCODE_PROVIDER_ID]: { - baseUrl: ctx.baseUrl, - api: PI_API_DIALECT, - apiKey: LOOPBACK_API_KEY_PLACEHOLDER, - models, - }, - }, - }; -} - -/** Extra headers a non-loopback bind needs, or nothing on loopback. */ -function proxyAdmissionHeaders(config: OcxConfig | undefined, envRef: string): Record | undefined { - return shouldInjectApiAuthHeader(config) ? { "x-opencodex-api-key": envRef } : undefined; -} - function buildHermesClientConfig(ctx: ExportContext): HermesGeneratedConfig { const models: Record = {}; for (const model of normalizeExportModels(ctx.models)) { @@ -1437,174 +958,6 @@ function buildGajaeClientConfig(ctx: ExportContext): GajaeGeneratedConfig { }; } -const DSH_EFFORT_ORDER: readonly DshReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"]; - -function dshReasoningEfforts(model: ExportModel): DshModelEntry["reasoningEfforts"] { - const offered = new Set(); - for (const raw of model.reasoningEfforts ?? []) { - const effort = raw.trim().toLowerCase(); - if (effort === "ultra" || DSH_EFFORT_ORDER.includes(effort as DshReasoningEffort)) offered.add(effort); - } - if (offered.size === 0) return undefined; - const entries: Array<[DshReasoningEffort, DshWireReasoningEffort]> = []; - for (const effort of DSH_EFFORT_ORDER) { - if (effort !== "max") { - if (offered.has(effort)) entries.push([effort, effort]); - continue; - } - // DSH's key is the selectable level; the value is what it sends on the - // wire. Preserve OpenCodex's `ultra` spelling when that is the only - // highest effort, exactly like the rc.6 `max: ultra` contract. - if (offered.has("max")) entries.push(["max", "max"]); - else if (offered.has("ultra")) entries.push(["max", "ultra"]); - } - return Object.fromEntries(entries); -} - -function isKnownSafeDshCombo(model: ExportModel, config: OcxConfig): boolean { - const combos = (config as { combos?: unknown }).combos; - if (typeof combos !== "object" || combos === null || Array.isArray(combos)) return false; - const combo = (combos as Record)[model.id]; - if (typeof combo !== "object" || combo === null || Array.isArray(combo)) return false; - const targets = (combo as { targets?: unknown }).targets; - if (!Array.isArray(targets) || targets.length === 0) return false; - return targets.every(target => { - if (typeof target !== "object" || target === null || Array.isArray(target)) return false; - const provider = (target as { provider?: unknown }).provider; - const modelId = (target as { model?: unknown }).model; - return typeof provider === "string" - && provider.length > 0 - && provider === provider.trim() - && provider !== "openai" - && typeof modelId === "string" - && modelId.length > 0 - && modelId === modelId.trim(); - }); -} - -function buildDshClientConfig(ctx: ExportContext): DshGeneratedConfig { - const direct = providerCodexAccountMode("openai", ctx.config?.providers?.openai) === "direct"; - const models: DshModelEntry[] = []; - for (const model of normalizeExportModels(ctx.models)) { - if (direct && (model.native === true || model.provider === "openai")) continue; - if (direct && model.provider === "combo" && (!ctx.config || !isKnownSafeDshCombo(model, ctx.config))) continue; - const input = dshInputModalities(model.inputModalities); - if (input === null) continue; - const contextWindow = authoritativeContextWindow(model.contextWindow); - const reasoningEfforts = dshReasoningEfforts(model); - models.push({ - id: model.namespaced, - name: exportModelLabel(model), - input, - ...(contextWindow !== undefined ? { contextWindow } : {}), - ...(reasoningEfforts ? { reasoningEfforts } : {}), - }); - } - return { - "llm-pi-ai": { - providers: { - [OPENCODE_PROVIDER_ID]: { - displayName: "OpenCodex", - api: "openai-responses", - baseURL: ctx.baseUrl, - headers: { Authorization: "Bearer ocx_data_dsh" }, - models, - }, - }, - }, - }; -} - -/** - * MiniMax Code's `provider add` command persists custom providers under - * `custom_provider.`. Its current model schema reads `limit.context` for - * context accounting and `thinking.effortOptions` for the `/model` effort - * control. Do not emit the removed `thinking.effort` / `defaultEffort` fields: - * MCode 0.1.6 migrates those into options and keeps the selected effort in the - * session. Do not emit `defaultModel` either: connecting a client must not - * silently replace the user's current model selection. - */ -function buildMcodeClientConfig(ctx: ExportContext): McodeGeneratedConfig { - const models: Record = {}; - for (const model of normalizeExportModels(ctx.models)) { - const entry: McodeModelEntry = {}; - const context = authoritativeContextWindow(model.contextWindow); - if (context !== undefined) entry.limit = { context }; - // `none` is an internal Codex catalog sentinel, not an MCode effort. MCode - // forwards every option as `output_config.effort` while keeping adaptive - // thinking enabled, and the Anthropic ingress deliberately accepts only - // minimal..ultra. Advertising `none` would therefore create a selectable - // value that cannot disable reasoning and is not forwarded as an effort. - const efforts = sanitizeCodexReasoningEfforts(model.reasoningEfforts) - ?.filter(effort => effort !== "none"); - if (efforts && efforts.length > 0) entry.thinking = { effortOptions: efforts }; - models[model.namespaced] = entry; - } - return { - custom_provider: { - [OPENCODE_PROVIDER_ID]: { - name: "OpenCodex", - kind: "custom", - enabled: true, - api: "anthropic-messages", - options: { - apiKey: LOOPBACK_API_KEY_PLACEHOLDER, - baseURL: ctx.baseUrl.replace(/\/v1\/?$/, ""), - authMode: "api-key", - }, - models, - }, - }, - }; -} - -/** - * ZCode dials the OpenAI Chat Completions surface (`openai-compatible`), which - * appends `/chat/completions` to `baseURL`. We supply `baseURL` with the `/v1` - * suffix so requests land on `/v1/chat/completions`. Model ids are the proxy's canonical - * `provider/id` selectors, which `/v1/chat/completions` resolves directly. Context - * limits follow the authoritative-window rule: a model without one ships - * without `limit` rather than guessing. Modalities are ZCode's observed - * `text`-floor vocabulary; image-capable rows advertise image input. - */ -function buildZcodeClientConfig(ctx: ExportContext): ZcodeGeneratedConfig { - const models: Record = {}; - for (const model of normalizeExportModels(ctx.models)) { - const input = inputModalitiesForClient("pi", model.inputModalities); - if (input === null) continue; - const entry: ZcodeModelEntry = { - name: exportModelLabel(model), - modalities: { input, output: ["text"] }, - }; - // `limit.context` follows the authoritative-window rule. `output` is - // deliberately absent: ZCode's schema makes it optional and we have no - // authoritative output budget to assert (reviewer finding: an emitted - // stand-in would be a guessed capability, exactly what "no metadata is - // guessed" forbids). - const context = authoritativeContextWindow(model.contextWindow); - if (context !== undefined) { - entry.limit = { context }; - } - models[model.namespaced] = entry; - } - return { - provider: { - [OPENCODE_PROVIDER_ID]: { - name: "OpenCodex", - kind: "openai-compatible", - enabled: true, - source: "custom", - options: { - apiKey: LOOPBACK_API_KEY_PLACEHOLDER, - baseURL: ctx.baseUrl.replace(/\/v1\/?$/, "") + "/v1", - apiKeyRequired: true, - }, - models, - }, - }, - }; -} - /** * Per-client model counts, read back off the SERIALIZED document rather than * recomputed from the input rows: `modelsWithoutLimits` drives a GUI line about @@ -1621,11 +974,6 @@ function summarizePi(document: unknown): { modelCount: number; modelsWithoutLimi return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length }; } -function summarizeOmp(document: unknown): { modelCount: number; modelsWithoutLimits: number } { - const models = (document as OmpGeneratedConfig | undefined)?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? []; - return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length }; -} - function summarizeHermes(document: unknown): { modelCount: number; modelsWithoutLimits: number } { const models = (document as HermesGeneratedConfig | undefined)?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? {}; // Hermes carries capability metadata but no per-model limit to be missing. @@ -1649,26 +997,6 @@ function summarizeGajae(document: unknown): { modelCount: number; modelsWithoutL return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length }; } -function summarizeDsh(document: unknown): { modelCount: number; modelsWithoutLimits: number } { - const models = (document as DshGeneratedConfig | undefined)?.["llm-pi-ai"]?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? []; - return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length }; -} - -function summarizeMcode(document: unknown): { modelCount: number; modelsWithoutLimits: number } { - const models = Object.values((document as McodeGeneratedConfig | undefined)?.custom_provider?.[OPENCODE_PROVIDER_ID]?.models ?? {}); - return { modelCount: models.length, modelsWithoutLimits: models.filter(model => !model.limit).length }; -} - -function summarizeZcode(document: unknown): { modelCount: number; modelsWithoutLimits: number } { - const models = Object.values((document as ZcodeGeneratedConfig | undefined)?.provider?.[OPENCODE_PROVIDER_ID]?.models ?? {}); - return { modelCount: models.length, modelsWithoutLimits: models.filter(model => !model.limit).length }; -} - -/** One fragment at `path`, built from this client's own document. */ -function singleFragment(clientId: ExportClientId, path: readonly string[], value: unknown): ManagedContribution { - return { clientId, fragments: [{ path, value }] }; -} - function buildOpencodeContribution(ctx: ExportContext): ManagedContribution { const doc = buildOpencodeClientConfig(ctx); return { @@ -1688,11 +1016,6 @@ function buildPiContribution(ctx: ExportContext): ManagedContribution { return singleFragment("pi", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); } -function buildOmpContribution(ctx: ExportContext): ManagedContribution { - const doc = buildOmpClientConfig(ctx); - return singleFragment("omp", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); -} - function buildHermesContribution(ctx: ExportContext): ManagedContribution { const doc = buildHermesClientConfig(ctx); return singleFragment("hermes", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); @@ -1724,21 +1047,6 @@ function buildGajaeContribution(ctx: ExportContext): ManagedContribution { return singleFragment("gajae", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); } -function buildDshContribution(ctx: ExportContext): ManagedContribution { - const doc = buildDshClientConfig(ctx); - return singleFragment("dsh", ["llm-pi-ai", "providers", OPENCODE_PROVIDER_ID], doc["llm-pi-ai"].providers[OPENCODE_PROVIDER_ID]); -} - -function buildMcodeContribution(ctx: ExportContext): ManagedContribution { - const doc = buildMcodeClientConfig(ctx); - return singleFragment("mcode", ["custom_provider", OPENCODE_PROVIDER_ID], doc.custom_provider[OPENCODE_PROVIDER_ID]); -} - -function buildZcodeContribution(ctx: ExportContext): ManagedContribution { - const doc = buildZcodeClientConfig(ctx); - return singleFragment("zcode", ["provider", OPENCODE_PROVIDER_ID], doc.provider[OPENCODE_PROVIDER_ID]); -} - /** * Prime Agent (PrimeIntellect) is the pi coding agent shipped under a different * brand rather than a lookalike: its package declares a `piConfig` block, and diff --git a/src/clients/config-export/constants.ts b/src/clients/config-export/constants.ts new file mode 100644 index 0000000000..a37f87f0d5 --- /dev/null +++ b/src/clients/config-export/constants.ts @@ -0,0 +1,69 @@ +// Shared client export constants. +import type { OcxConfig } from "../../types"; + + +/** 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"; + +/** + * 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}}`; + +/** + * Hermes interpolates `${VAR}` anywhere in config.yaml, so the credential stays + * in the environment exactly as it does for OpenCode. + */ +export const HERMES_API_KEY_ENV = "OPENCODEX_HERMES_API_KEY"; +export const HERMES_API_KEY_ENV_REF = `\${${HERMES_API_KEY_ENV}}`; + +/** OpenClaw interpolates `${UPPERCASE_VAR}` and fails closed when it is unset. */ +export const OPENCLAW_API_KEY_ENV = "OPENCODEX_OPENCLAW_API_KEY"; +export const OPENCLAW_API_KEY_ENV_REF = `\${${OPENCLAW_API_KEY_ENV}}`; + +/** + * Placeholder credential for loopback-only clients (Kimi, Pi). A loopback + * bind needs no real admission key, so we emit the same placeholder the Grok + * managed block uses rather than a user secret. Pi resolves `apiKey` before + * building its model list and hides the provider when an env reference is unset. + */ +export const LOOPBACK_API_KEY_PLACEHOLDER = "opencodex-loopback"; + +/** + * Gajae's `apiKeyEnv` is env-name-only and fail-closed. Its sibling `apiKey` + * falls back to treating the literal text as the token when the variable is + * unset, which would silently ship a bogus credential — so we never emit it. + */ +export const GAJAE_API_KEY_ENV = "OPENCODEX_GAJAE_API_KEY"; + +/** Pi's wire-dialect selector for an OpenAI-compatible endpoint. */ +export 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; diff --git a/src/clients/config-export/contracts.ts b/src/clients/config-export/contracts.ts new file mode 100644 index 0000000000..3edd91eb5e --- /dev/null +++ b/src/clients/config-export/contracts.ts @@ -0,0 +1,150 @@ +// Shared client export contracts. +import type { OcxConfig } from "../../types"; +import type { ConfigFormat } from "../../integrations/serialize"; + +/** + * One entry opencodex owns inside a client's config: the JSON path to it and + * the value we put there. + * + * A path list rather than a single provider key because ownership is not + * always one entry — Kimi owns its provider block AND one model entry per + * model, and a writer that only knew about the provider would strand the rest + * (devlog 260802 006 §2). + */ +export interface ManagedFragment { + path: readonly string[]; + value: unknown; +} + +/** Everything opencodex contributes to one client's config, as one unit. */ +export interface ManagedContribution { + clientId: ExportClientId; + fragments: readonly ManagedFragment[]; +} + +export type BuildContribution = (ctx: ExportContext) => ManagedContribution; + +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; + /** Declared effort ladder. Exported as opencode model variants where the client reads them. */ + reasoningEfforts?: readonly string[]; + /** + * Declared default effort. Carried so every client export reads one deduped, visibility- + * filtered ladder per model. The opencode serializer deliberately does NOT turn it into a + * model-level setting — see {@link opencodeEffortVariants} for why. + */ + defaultReasoningEffort?: string; +} + +/** + * 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[]; + /** Optional effort ladder exported only to clients that support it. */ + reasoningEfforts?: string[]; + defaultReasoningEffort?: 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" + | "omp" + | "hermes" + | "openclaw" + | "kimi" + | "gajae" + | "dsh" + | "mcode" + | "zcode" + | "prime" + | "aside"; + +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; + /** + * Text format of the client's config file. `filename` already carries the + * extension; this drives serialization and the download media type so no + * consumer has to infer either from the name. + */ + format: ConfigFormat; + /** + * Count models in THIS client's document shape. Required so a new client + * cannot be added without teaching the summarizer about it — the old + * "anything that is not OpenCode must be Pi" branch was a latent bug. + */ + summarize: (document: unknown) => { modelCount: number; modelsWithoutLimits: number }; + /** + * The fragments opencodex owns inside this client's config. Only the builder + * knows where a client keeps our entries, so ownership paths originate here + * rather than being re-derived by the writer. + */ + buildContribution: BuildContribution; + /** + * True when the generated integration deliberately supports loopback only. + * + * `/v1/chat/completions` rejects bearer credentials and requires the + * dedicated `x-opencodex-api-key` header (AUTH_MATRIX in + * src/server/auth-cors.ts). If this exporter cannot safely emit that header, + * it refuses a remote bind rather than generating a config that 401s. Same + * reasoning as the Grok managed block's non-loopback refusal. + */ + loopbackOnly: boolean; +} + +export interface PiModelEntry { + id: string; + name: string; + input: string[]; + contextWindow?: number; + maxTokens?: number; + /** Advertised when the catalog row carries a non-empty effort ladder. */ + reasoning?: true; + /** + * Constrains pi's own level scale (minimal..max) to the declared ladder: members map to + * themselves, everything else is hidden (`null`). Without it pi would offer levels the + * ladder does not contain — harmless for provider-config ladders (the proxy clamps those + * at the wire) but a real 400 risk for custom-row ladders, which are advertisement-only. + */ + thinkingLevelMap?: Record; +} diff --git a/src/clients/config-export/dsh.ts b/src/clients/config-export/dsh.ts new file mode 100644 index 0000000000..e140d543ef --- /dev/null +++ b/src/clients/config-export/dsh.ts @@ -0,0 +1,132 @@ +// DSH config export. +import type { ExportModel, ExportContext, ManagedContribution } from "./contracts"; +import type { OcxConfig } from "../../types"; +import { providerCodexAccountMode } from "../../providers/registry"; +import { normalizeExportModels, authoritativeContextWindow, exportModelLabel, singleFragment } from "./model-metadata"; +import { OPENCODE_PROVIDER_ID } from "./constants"; + + +/** DSH rc.6 accepts text/image; unknown values degrade to text, while audio-only cannot be represented. */ +function dshInputModalities(modalities: readonly string[] | undefined): string[] | null { + const declared = modalities ?? []; + if (declared.length === 0) return ["text"]; + const kept: string[] = []; + for (const value of declared) { + if ((value === "text" || value === "image") && !kept.includes(value)) kept.push(value); + } + if (kept.length > 0) return kept; + return declared.every(value => value === "audio") ? null : ["text"]; +} + +export type DshReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max"; +export type DshWireReasoningEffort = DshReasoningEffort | "ultra"; + +export interface DshModelEntry { + id: string; + name: string; + input: string[]; + contextWindow?: number; + reasoningEfforts?: Partial>; +} + +export interface DshProviderBlock { + displayName: "OpenCodex"; + api: "openai-responses"; + baseURL: string; + headers: { Authorization: "Bearer ocx_data_dsh" }; + models: DshModelEntry[]; +} + +export interface DshGeneratedConfig { + "llm-pi-ai": { + providers: Record; + }; +} + +const DSH_EFFORT_ORDER: readonly DshReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"]; + +function dshReasoningEfforts(model: ExportModel): DshModelEntry["reasoningEfforts"] { + const offered = new Set(); + for (const raw of model.reasoningEfforts ?? []) { + const effort = raw.trim().toLowerCase(); + if (effort === "ultra" || DSH_EFFORT_ORDER.includes(effort as DshReasoningEffort)) offered.add(effort); + } + if (offered.size === 0) return undefined; + const entries: Array<[DshReasoningEffort, DshWireReasoningEffort]> = []; + for (const effort of DSH_EFFORT_ORDER) { + if (effort !== "max") { + if (offered.has(effort)) entries.push([effort, effort]); + continue; + } + // DSH's key is the selectable level; the value is what it sends on the + // wire. Preserve OpenCodex's `ultra` spelling when that is the only + // highest effort, exactly like the rc.6 `max: ultra` contract. + if (offered.has("max")) entries.push(["max", "max"]); + else if (offered.has("ultra")) entries.push(["max", "ultra"]); + } + return Object.fromEntries(entries); +} + +function isKnownSafeDshCombo(model: ExportModel, config: OcxConfig): boolean { + const combos = (config as { combos?: unknown }).combos; + if (typeof combos !== "object" || combos === null || Array.isArray(combos)) return false; + const combo = (combos as Record)[model.id]; + if (typeof combo !== "object" || combo === null || Array.isArray(combo)) return false; + const targets = (combo as { targets?: unknown }).targets; + if (!Array.isArray(targets) || targets.length === 0) return false; + return targets.every(target => { + if (typeof target !== "object" || target === null || Array.isArray(target)) return false; + const provider = (target as { provider?: unknown }).provider; + const modelId = (target as { model?: unknown }).model; + return typeof provider === "string" + && provider.length > 0 + && provider === provider.trim() + && provider !== "openai" + && typeof modelId === "string" + && modelId.length > 0 + && modelId === modelId.trim(); + }); +} + +export function buildDshClientConfig(ctx: ExportContext): DshGeneratedConfig { + const direct = providerCodexAccountMode("openai", ctx.config?.providers?.openai) === "direct"; + const models: DshModelEntry[] = []; + for (const model of normalizeExportModels(ctx.models)) { + if (direct && (model.native === true || model.provider === "openai")) continue; + if (direct && model.provider === "combo" && (!ctx.config || !isKnownSafeDshCombo(model, ctx.config))) continue; + const input = dshInputModalities(model.inputModalities); + if (input === null) continue; + const contextWindow = authoritativeContextWindow(model.contextWindow); + const reasoningEfforts = dshReasoningEfforts(model); + models.push({ + id: model.namespaced, + name: exportModelLabel(model), + input, + ...(contextWindow !== undefined ? { contextWindow } : {}), + ...(reasoningEfforts ? { reasoningEfforts } : {}), + }); + } + return { + "llm-pi-ai": { + providers: { + [OPENCODE_PROVIDER_ID]: { + displayName: "OpenCodex", + api: "openai-responses", + baseURL: ctx.baseUrl, + headers: { Authorization: "Bearer ocx_data_dsh" }, + models, + }, + }, + }, + }; +} + +export function summarizeDsh(document: unknown): { modelCount: number; modelsWithoutLimits: number } { + const models = (document as DshGeneratedConfig | undefined)?.["llm-pi-ai"]?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? []; + return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length }; +} + +export function buildDshContribution(ctx: ExportContext): ManagedContribution { + const doc = buildDshClientConfig(ctx); + return singleFragment("dsh", ["llm-pi-ai", "providers", OPENCODE_PROVIDER_ID], doc["llm-pi-ai"].providers[OPENCODE_PROVIDER_ID]); +} diff --git a/src/clients/config-export/mcode.ts b/src/clients/config-export/mcode.ts new file mode 100644 index 0000000000..7cd004982d --- /dev/null +++ b/src/clients/config-export/mcode.ts @@ -0,0 +1,83 @@ +// MiniMax Code config export. +import type { ExportContext, ManagedContribution } from "./contracts"; +import { normalizeExportModels, authoritativeContextWindow, singleFragment } from "./model-metadata"; +import { sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; +import { OPENCODE_PROVIDER_ID, LOOPBACK_API_KEY_PLACEHOLDER } from "./constants"; + + +export interface McodeProviderBlock { + name: "OpenCodex"; + kind: "custom"; + enabled: true; + api: "anthropic-messages"; + options: { + apiKey: string; + baseURL: string; + authMode: "api-key"; + }; + models: Record; +} + +export interface McodeModelEntry { + /** MCode uses this value for context accounting and compaction. */ + limit?: { context: number }; + /** MCode exposes these exact levels in `/model` and sends the selected effort. */ + thinking?: { effortOptions: string[] }; +} + +export interface McodeGeneratedConfig { + custom_provider: Record; +} + +/** + * MiniMax Code's `provider add` command persists custom providers under + * `custom_provider.`. Its current model schema reads `limit.context` for + * context accounting and `thinking.effortOptions` for the `/model` effort + * control. Do not emit the removed `thinking.effort` / `defaultEffort` fields: + * MCode 0.1.6 migrates those into options and keeps the selected effort in the + * session. Do not emit `defaultModel` either: connecting a client must not + * silently replace the user's current model selection. + */ +export function buildMcodeClientConfig(ctx: ExportContext): McodeGeneratedConfig { + const models: Record = {}; + for (const model of normalizeExportModels(ctx.models)) { + const entry: McodeModelEntry = {}; + const context = authoritativeContextWindow(model.contextWindow); + if (context !== undefined) entry.limit = { context }; + // `none` is an internal Codex catalog sentinel, not an MCode effort. MCode + // forwards every option as `output_config.effort` while keeping adaptive + // thinking enabled, and the Anthropic ingress deliberately accepts only + // minimal..ultra. Advertising `none` would therefore create a selectable + // value that cannot disable reasoning and is not forwarded as an effort. + const efforts = sanitizeCodexReasoningEfforts(model.reasoningEfforts) + ?.filter(effort => effort !== "none"); + if (efforts && efforts.length > 0) entry.thinking = { effortOptions: efforts }; + models[model.namespaced] = entry; + } + return { + custom_provider: { + [OPENCODE_PROVIDER_ID]: { + name: "OpenCodex", + kind: "custom", + enabled: true, + api: "anthropic-messages", + options: { + apiKey: LOOPBACK_API_KEY_PLACEHOLDER, + baseURL: ctx.baseUrl.replace(/\/v1\/?$/, ""), + authMode: "api-key", + }, + models, + }, + }, + }; +} + +export function summarizeMcode(document: unknown): { modelCount: number; modelsWithoutLimits: number } { + const models = Object.values((document as McodeGeneratedConfig | undefined)?.custom_provider?.[OPENCODE_PROVIDER_ID]?.models ?? {}); + return { modelCount: models.length, modelsWithoutLimits: models.filter(model => !model.limit).length }; +} + +export function buildMcodeContribution(ctx: ExportContext): ManagedContribution { + const doc = buildMcodeClientConfig(ctx); + return singleFragment("mcode", ["custom_provider", OPENCODE_PROVIDER_ID], doc.custom_provider[OPENCODE_PROVIDER_ID]); +} diff --git a/src/clients/config-export/model-metadata.ts b/src/clients/config-export/model-metadata.ts new file mode 100644 index 0000000000..4f3038efac --- /dev/null +++ b/src/clients/config-export/model-metadata.ts @@ -0,0 +1,113 @@ +// Shared client export model metadata. +import { SCHEMA_REQUIRED_OUTPUT_BUDGET } from "./constants"; +import type { OpencodeCatalogModel, ExportModel, ExportClientId, ManagedContribution } from "./contracts"; +import type { OcxConfig } from "../../types"; +import { shouldInjectApiAuthHeader } from "../../codex/inject"; + + +/** + * Authoritative context window, or undefined. Never guesses: a missing, non-finite, or + * non-positive value means the serializer omits every context-derived field. + */ +export function authoritativeContextWindow(contextWindow: number | undefined): number | undefined { + if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) { + const integer = Math.floor(contextWindow); + return integer > 0 ? integer : undefined; + } + return undefined; +} + +/** Schema-required output budget for a known context window. */ +export function outputBudgetFor(context: number): number { + return Math.min(SCHEMA_REQUIRED_OUTPUT_BUDGET, context); +} + +/** + * Modalities a given client's schema will actually accept. + * + * Our internal vocabulary is `text | image | audio` (ALLOWED_INPUT_MODALITIES in + * src/server/management/model-routes.ts). Pi and Gajae accept only + * `text | image`, and both reject the WHOLE config file over one out-of-enum + * value — Gajae reports `/providers/opencodex/models/N/input/2: Invalid option` + * and falls back to its built-in list, Pi returns an empty model config. So a + * single `audio` model takes every routed model down with it. That is not + * hypothetical: zenmux/meta-muse-spark-1.1 advertises audio and did exactly + * this. It is also the same defect the Codex catalog had with `video`, where + * the app showed zero apps (tests/codex-integration/catalog-input-modality-enum.test.ts). + * + * UNKNOWN and INCOMPATIBLE are different inputs, and the Codex fix could + * conflate them safely only because its enum is wider. A model with nothing + * declared is unknown, and `text` is the honest floor — every routed model takes + * prompts. A model declaring `["audio"]` and nothing else is incompatible with a + * text|image client, and rewriting it to `["text"]` would advertise a capability + * it does not have. That input is reachable three ways: `ocx models add + * --modalities audio`, `/api/custom-models`, and provider discovery. + * + * So unknown falls back to text and incompatible returns null, which drops the + * row. Omitting a model costs the user a line in a picker; fabricating `text` + * costs them a model that fails at call time with no explanation. + * + * Deliberately NOT applied in `ExportModel` construction: the management and CLI + * boundaries carry catalog modalities verbatim on purpose, and stripping `audio` + * globally would destroy valid metadata before the destination is known. + */ +const CLIENT_INPUT_MODALITIES: Record<"pi" | "gajae", ReadonlySet> = { + pi: new Set(["text", "image"]), + gajae: new Set(["text", "image"]), +}; + +/** `null` means the model cannot be represented for this client — drop the row. */ +export function inputModalitiesForClient( + client: "pi" | "gajae", + modalities: readonly string[] | undefined, +): string[] | null { + const declared = modalities ?? []; + if (declared.length === 0) return ["text"]; + const accepted = CLIENT_INPUT_MODALITIES[client]; + const kept: string[] = []; + for (const value of declared) { + if (accepted.has(value) && !kept.includes(value)) kept.push(value); + } + return kept.length > 0 ? kept : null; +} + +/** + * 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. + */ +export 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})`; +} + +/** + * 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)); +} + +/** Extra headers a non-loopback bind needs, or nothing on loopback. */ +export function proxyAdmissionHeaders(config: OcxConfig | undefined, envRef: string): Record | undefined { + return shouldInjectApiAuthHeader(config) ? { "x-opencodex-api-key": envRef } : undefined; +} + +/** One fragment at `path`, built from this client's own document. */ +export function singleFragment(clientId: ExportClientId, path: readonly string[], value: unknown): ManagedContribution { + return { clientId, fragments: [{ path, value }] }; +} diff --git a/src/clients/config-export/omp.ts b/src/clients/config-export/omp.ts new file mode 100644 index 0000000000..e31d9bc59f --- /dev/null +++ b/src/clients/config-export/omp.ts @@ -0,0 +1,104 @@ +// Oh My Pi config export. +import type { PiModelEntry, ExportModel, ExportContext, ManagedContribution } from "./contracts"; +import { PI_API_DIALECT, OPENCODE_PROVIDER_ID, LOOPBACK_API_KEY_PLACEHOLDER } from "./constants"; +import { normalizeExportModels, inputModalitiesForClient, exportModelLabel, authoritativeContextWindow, outputBudgetFor, singleFragment } from "./model-metadata"; + + +/** + * omp accepts a model-level API override. Keep the provider on Chat + * Completions so routed providers retain their established wire format, while + * native OpenAI models can use the lossless Responses surface. + */ +export interface OmpModelEntry extends PiModelEntry { + api?: "openai-responses"; + /** omp requires this flag before it honors a thinking block. */ + reasoning?: true; + thinking?: { + mode: "effort"; + efforts: string[]; + defaultLevel?: string; + }; +} + +export interface OmpProviderBlock { + baseUrl: string; + api: typeof PI_API_DIALECT; + apiKey: string; + models: OmpModelEntry[]; +} + +export interface OmpGeneratedConfig { + providers: Record; +} + +/** + * omp validates model entries strictly. These are its documented effort + * values; omit an unknown value rather than invalidating the whole provider. + */ +const OMP_EFFORT_VOCABULARY = new Set(["minimal", "low", "medium", "high", "xhigh", "max"]); + +function ompEfforts(model: ExportModel): string[] { + const efforts: string[] = []; + for (const effort of model.reasoningEfforts ?? []) { + const normalized = effort.trim().toLowerCase(); + if (OMP_EFFORT_VOCABULARY.has(normalized) && !efforts.includes(normalized)) { + efforts.push(normalized); + } + } + return efforts; +} + +/** + * omp's models.yml is Pi-like, but it supports effort metadata and a per-model + * API dialect. Native OpenAI models use Responses; all routed models inherit + * the provider's existing Chat Completions dialect. + */ +export function buildOmpClientConfig(ctx: ExportContext): OmpGeneratedConfig { + const models: OmpModelEntry[] = []; + for (const model of normalizeExportModels(ctx.models)) { + const input = inputModalitiesForClient("pi", model.inputModalities); + if (input === null) continue; + const entry: OmpModelEntry = { + id: model.namespaced, + name: exportModelLabel(model), + input, + ...(model.native && model.provider === "openai" ? { api: "openai-responses" } : {}), + }; + const context = authoritativeContextWindow(model.contextWindow); + if (context !== undefined) { + entry.contextWindow = context; + entry.maxTokens = outputBudgetFor(context); + } + const efforts = ompEfforts(model); + if (efforts.length > 0) { + const defaultLevel = model.defaultReasoningEffort?.trim().toLowerCase(); + entry.reasoning = true; + entry.thinking = { + mode: "effort", + efforts, + ...(defaultLevel && efforts.includes(defaultLevel) ? { defaultLevel } : {}), + }; + } + models.push(entry); + } + return { + providers: { + [OPENCODE_PROVIDER_ID]: { + baseUrl: ctx.baseUrl, + api: PI_API_DIALECT, + apiKey: LOOPBACK_API_KEY_PLACEHOLDER, + models, + }, + }, + }; +} + +export function summarizeOmp(document: unknown): { modelCount: number; modelsWithoutLimits: number } { + const models = (document as OmpGeneratedConfig | undefined)?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? []; + return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length }; +} + +export function buildOmpContribution(ctx: ExportContext): ManagedContribution { + const doc = buildOmpClientConfig(ctx); + return singleFragment("omp", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); +} diff --git a/src/clients/config-export/zcode.ts b/src/clients/config-export/zcode.ts new file mode 100644 index 0000000000..bcd522a389 --- /dev/null +++ b/src/clients/config-export/zcode.ts @@ -0,0 +1,92 @@ +// ZCode config export. +import type { ExportContext, ManagedContribution } from "./contracts"; +import { normalizeExportModels, inputModalitiesForClient, exportModelLabel, authoritativeContextWindow, singleFragment } from "./model-metadata"; +import { OPENCODE_PROVIDER_ID, LOOPBACK_API_KEY_PLACEHOLDER } from "./constants"; + + +/** + * ZCode's `~/.zcode/v2/config.json` provider entry (observed schema, validated + * live against ZCode 3.7.7 / 3.8.1). `kind: "openai-compatible"` selects the + * OpenAI Chat Completions protocol, which the proxy serves at `/v1/chat/completions`. + * `apiKeyRequired` keeps ZCode's UI from prompting for a key it does not need on + * loopback; the serialized key is always the non-secret loopback placeholder. + */ +export interface ZcodeModelEntry { + name?: string; + limit?: { context: number; output?: number }; + modalities: { input: string[]; output: string[] }; +} + +export interface ZcodeProviderBlock { + name: "OpenCodex"; + kind: "openai-compatible"; + enabled: true; + source: "custom"; + options: { + apiKey: string; + baseURL: string; + apiKeyRequired: true; + }; + models: Record; +} + +export interface ZcodeGeneratedConfig { + provider: Record; +} + +/** + * ZCode dials the OpenAI Chat Completions surface (`openai-compatible`), which + * appends `/chat/completions` to `baseURL`. We supply `baseURL` with the `/v1` + * suffix so requests land on `/v1/chat/completions`. Model ids are the proxy's canonical + * `provider/id` selectors, which `/v1/chat/completions` resolves directly. Context + * limits follow the authoritative-window rule: a model without one ships + * without `limit` rather than guessing. Modalities are ZCode's observed + * `text`-floor vocabulary; image-capable rows advertise image input. + */ +export function buildZcodeClientConfig(ctx: ExportContext): ZcodeGeneratedConfig { + const models: Record = {}; + for (const model of normalizeExportModels(ctx.models)) { + const input = inputModalitiesForClient("pi", model.inputModalities); + if (input === null) continue; + const entry: ZcodeModelEntry = { + name: exportModelLabel(model), + modalities: { input, output: ["text"] }, + }; + // `limit.context` follows the authoritative-window rule. `output` is + // deliberately absent: ZCode's schema makes it optional and we have no + // authoritative output budget to assert (reviewer finding: an emitted + // stand-in would be a guessed capability, exactly what "no metadata is + // guessed" forbids). + const context = authoritativeContextWindow(model.contextWindow); + if (context !== undefined) { + entry.limit = { context }; + } + models[model.namespaced] = entry; + } + return { + provider: { + [OPENCODE_PROVIDER_ID]: { + name: "OpenCodex", + kind: "openai-compatible", + enabled: true, + source: "custom", + options: { + apiKey: LOOPBACK_API_KEY_PLACEHOLDER, + baseURL: ctx.baseUrl.replace(/\/v1\/?$/, "") + "/v1", + apiKeyRequired: true, + }, + models, + }, + }, + }; +} + +export function summarizeZcode(document: unknown): { modelCount: number; modelsWithoutLimits: number } { + const models = Object.values((document as ZcodeGeneratedConfig | undefined)?.provider?.[OPENCODE_PROVIDER_ID]?.models ?? {}); + return { modelCount: models.length, modelsWithoutLimits: models.filter(model => !model.limit).length }; +} + +export function buildZcodeContribution(ctx: ExportContext): ManagedContribution { + const doc = buildZcodeClientConfig(ctx); + return singleFragment("zcode", ["provider", OPENCODE_PROVIDER_ID], doc.provider[OPENCODE_PROVIDER_ID]); +} diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index 19f1edc8da..ec969a8455 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -24,6 +24,13 @@ import { import { buildOpencodeProviderBlockFromCatalog, opencodeGlobalConfigPath } from "../../src/cli/opencode"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import * as facade from "../../src/clients/config-export"; +import { OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG as leafDefaultConfig } from "../../src/clients/config-export/constants"; +import { normalizeExportModels as leafNormalizeExportModels } from "../../src/clients/config-export/model-metadata"; +import * as omp from "../../src/clients/config-export/omp"; +import * as dsh from "../../src/clients/config-export/dsh"; +import * as mcode from "../../src/clients/config-export/mcode"; +import * as zcode from "../../src/clients/config-export/zcode"; /** * Fixture covering the four rows that exercise every emission branch: native, @@ -82,6 +89,51 @@ function dshConfig(context: ExportContext = ctx()): DshGeneratedConfig { return buildClientConfig("dsh", context) as DshGeneratedConfig; } +describe("split config-export public facade", () => { + test("keeps canonical singleton and registry function identities", () => { + expect(facade.OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG).toBe(leafDefaultConfig); + expect(facade.normalizeExportModels).toBe(leafNormalizeExportModels); + const leaves = [ + ["omp", omp.buildOmpClientConfig, omp.summarizeOmp, omp.buildOmpContribution], + ["dsh", dsh.buildDshClientConfig, dsh.summarizeDsh, dsh.buildDshContribution], + ["mcode", mcode.buildMcodeClientConfig, mcode.summarizeMcode, mcode.buildMcodeContribution], + ["zcode", zcode.buildZcodeClientConfig, zcode.summarizeZcode, zcode.buildZcodeContribution], + ] as const; + for (const [id, build, summarize, contribute] of leaves) { + expect(EXPORT_CLIENTS[id].build).toBe(build); + expect(EXPORT_CLIENTS[id].summarize).toBe(summarize); + expect(EXPORT_CLIENTS[id].buildContribution).toBe(contribute); + } + }); + + test("preserves each moved format's serialized fields, order and owned fragment", () => { + const context = ctx({ models: [{ + namespaced: "test/known", provider: "test", id: "known", contextWindow: 8192, + inputModalities: ["text", "image"], reasoningEfforts: ["none", "high"], + }] }); + const cases = [ + ["omp", ["providers", "opencodex"], '{"providers":{"opencodex":{"baseUrl":"http://127.0.0.1:10100/v1","api":"openai-completions","apiKey":"opencodex-loopback","models":[{"id":"test/known","name":"known (test)","input":["text","image"],"contextWindow":8192,"maxTokens":8192,"reasoning":true,"thinking":{"mode":"effort","efforts":["high"]}}]}}}'], + ["dsh", ["llm-pi-ai", "providers", "opencodex"], '{"llm-pi-ai":{"providers":{"opencodex":{"displayName":"OpenCodex","api":"openai-responses","baseURL":"http://127.0.0.1:10100/v1","headers":{"Authorization":"Bearer ocx_data_dsh"},"models":[{"id":"test/known","name":"known (test)","input":["text","image"],"contextWindow":8192,"reasoningEfforts":{"high":"high"}}]}}}}'], + ["mcode", ["custom_provider", "opencodex"], '{"custom_provider":{"opencodex":{"name":"OpenCodex","kind":"custom","enabled":true,"api":"anthropic-messages","options":{"apiKey":"opencodex-loopback","baseURL":"http://127.0.0.1:10100","authMode":"api-key"},"models":{"test/known":{"limit":{"context":8192},"thinking":{"effortOptions":["high"]}}}}}}'], + ["zcode", ["provider", "opencodex"], '{"provider":{"opencodex":{"name":"OpenCodex","kind":"openai-compatible","enabled":true,"source":"custom","options":{"apiKey":"opencodex-loopback","baseURL":"http://127.0.0.1:10100/v1","apiKeyRequired":true},"models":{"test/known":{"name":"known (test)","modalities":{"input":["text","image"],"output":["text"]},"limit":{"context":8192}}}}}}'], + ] as const; + for (const [id, path, expectedBytes] of cases) { + const built = buildClientConfigText(id, context); + expect(JSON.stringify(built.document)).toBe(expectedBytes); + expect(EXPORT_CLIENTS[id].summarize(built.document)).toEqual({ modelCount: 1, modelsWithoutLimits: 0 }); + const expectedDocument = JSON.parse(expectedBytes); + const expectedValue = path.reduce((value, key) => value[key], expectedDocument); + expect(facade.buildClientContribution(id, context)).toEqual({ + clientId: id, fragments: [{ path, value: expectedValue }], + }); + if (id === "zcode") { + expect(built.format).toBe("json"); + expect(built.text).toBe(JSON.stringify(expectedDocument, null, 2) + "\n"); + } + } + }); +}); + describe("relocated OpenCode serializer (accept criterion 1)", () => { test("the moved builder reproduces the pre-refactor golden byte-for-byte", () => {