From 7746abe4325f1340e4b189df81648dca366088bd Mon Sep 17 00:00:00 2001 From: Daniil Koryto Date: Mon, 6 Jul 2026 18:13:43 +0300 Subject: [PATCH] feat: fetch GonkaGate models dynamically --- AGENTS.md | 56 ++-- CHANGELOG.md | 6 + README.md | 68 ++-- docs/README.md | 2 +- docs/architecture-decisions.md | 53 +--- docs/how-it-works.md | 93 +----- docs/model-validation.md | 77 ++--- docs/specs/opencode-setup-prd/spec.md | 108 +++---- docs/troubleshooting.md | 167 +--------- src/cli/parse.ts | 6 +- src/constants/contract.ts | 2 +- src/constants/gateway.ts | 1 + src/constants/models.ts | 238 +------------- src/install/README.md | 2 +- src/install/contracts/effective-config.ts | 2 - src/install/contracts/managed-config.ts | 6 +- src/install/deps.ts | 34 ++ src/install/effective-config-policy.ts | 77 ++--- src/install/errors.ts | 28 +- src/install/index.ts | 61 +++- src/install/managed-provider-config.ts | 182 +---------- src/install/model-catalog.ts | 119 +++++++ src/install/scope.ts | 17 +- src/install/selection.ts | 40 +-- src/install/session.ts | 3 + src/install/verify-effective.ts | 16 +- test/cli.test.ts | 151 +++++---- test/docs-contract.test.ts | 308 ++++--------------- test/install/harness.ts | 3 + test/install/managed-provider-config.test.ts | 94 ++---- test/install/model-fixtures.ts | 70 +++++ test/install/models.test.ts | 205 +++++------- test/install/rerun.test.ts | 32 +- test/install/scope.test.ts | 20 +- test/install/test-deps.ts | 53 ++++ test/install/verify-effective.test.ts | 67 ++-- test/package-contract.test.ts | 19 +- 37 files changed, 925 insertions(+), 1561 deletions(-) create mode 100644 src/install/model-catalog.ts create mode 100644 test/install/model-fixtures.ts diff --git a/AGENTS.md b/AGENTS.md index c92c75e..ca367b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,15 +16,12 @@ npx @gonkagate/opencode-setup Current honest state: - the real end-to-end public installer flow is implemented -- the public CLI validates local `opencode`, resolves a curated model and - scope, collects a GonkaGate API key through the supported safe inputs before - managed writes, verifies both the durable and current-session effective +- the public CLI validates local `opencode`, collects a GonkaGate API key through the supported safe inputs, fetches live models, resolves model and scope before managed writes, verifies both the durable and current-session effective OpenCode config, and ends with plain `opencode` - the product docs, CI, package contract, and PRD are implemented and now describe the shipped runtime -- the public curated model picker is shipped and currently exposes three - validated models -- the installer writes every validated curated model into +- the live GonkaGate model picker is shipped and uses the `/v1/models` response +- the installer writes every fetched GonkaGate model id into `provider.gonkagate.models` so OpenCode's `/models` command can switch between managed GonkaGate models after setup - native Windows support is part of the current contract and is backed by @@ -43,16 +40,16 @@ The intended happy path is: 1. user runs `npx @gonkagate/opencode-setup` 2. installer validates local `opencode` -3. installer offers the public curated model picker in interactive mode and - accepts recommended defaults through `--yes` or safe non-interactive - selection rules -4. installer asks for `user` or `project` scope -5. installer collects a GonkaGate `gp-...` key through a hidden prompt, +3. installer collects a GonkaGate `gp-...` key through a hidden prompt, `GONKAGATE_API_KEY`, or `--api-key-stdin` -6. installer writes the minimum safe OpenCode config layers -7. installer verifies the durable OpenCode config and the current session's +4. installer fetches `GET https://api.gonkagate.com/v1/models` with Bearer auth +5. installer offers the live GonkaGate model picker in interactive mode and + accepts defaults through `--yes` or safe non-interactive selection rules +6. installer asks for `user` or `project` scope +7. installer writes the minimum safe OpenCode config layers +8. installer verifies the durable OpenCode config and the current session's effective OpenCode config -8. user returns to plain `opencode` +9. user returns to plain `opencode` For `project` scope, the user-level config owns the provider definition and secret binding, while the repository-local `opencode.json` contains only @@ -75,14 +72,14 @@ refactor; it is a product change. replacement for the global config target - `OPENCODE_CONFIG_CONTENT` is a runtime-only higher-precedence override layer, not a durable install target -- interactive mode keeps the public curated model picker visible even when only - one validated model is currently available -- `--yes` and safe non-interactive flows may accept the recommended validated - model without showing the picker +- interactive mode keeps the live GonkaGate model picker visible even when only + one GonkaGate model is currently available +- `--yes` and safe non-interactive flows may accept the first fetched model + without showing the picker - project config target is `opencode.json` - the managed user-level provider key is `provider.gonkagate` - the managed user-level provider catalog under `provider.gonkagate.models` - includes every public validated curated model + includes every model id returned by `/v1/models` - `project` scope writes only activation settings - repo-local `opencode.json` rewrites must keep rollback backups under `~/.gonkagate/opencode/backups/project-config` instead of beside the @@ -103,8 +100,8 @@ refactor; it is a product change. --pure` as the final truth source instead of reimplementing the full upstream merge engine - resolved effective-config verification must stay responsible for `model`, - `small_model`, `provider.gonkagate`, validated transport and base URL shape, - curated model-catalog shape, and provider allow/deny gating + `small_model`, `provider.gonkagate`, transport and base URL shape, + live model-catalog shape, and provider allow/deny gating - secret-binding provenance verification must separately enforce `provider.gonkagate.options.apiKey` ownership instead of inferring it from redacted resolved-config output @@ -145,13 +142,12 @@ refactor; it is a product change. - shell profile mutation is out of scope - `.env` file generation is out of scope - arbitrary custom base URLs are out of scope for v1 -- arbitrary custom model ids are out of scope for v1 +- arbitrary model ids not returned by `/v1/models` are out of scope for v1 - the installer must not depend on `gonkagate doctor` - `small_model` is explicitly set by the runtime - `model` and `small_model` select the setup default; they are not the full GonkaGate model catalog -- the curated model registry contract must be able to carry compatibility - metadata required for validated OpenCode flows, not only model ids +- the live `/v1/models` response is the runtime source of truth for model availability ## Security Invariants @@ -199,11 +195,7 @@ These are implementation facts today, not future plans: installer-owned scope normalization, separate resolved-config versus secret-binding provenance verification, redacted effective-config diagnostics, and the end-to-end installer flow -- the curated model registry under `src/constants/models.ts` now includes - pinned public validated entries for - `qwen/qwen3-235b-a22b-instruct-2507-fp8`, - `moonshotai/Kimi-K2.6`, and `minimaxai/minimax-m2.7`, with Kimi K2.6 marked - as the recommended default +- the installer fetches `GET https://api.gonkagate.com/v1/models` after API-key intake and uses the live response for model selection, `--model` validation, provider catalog writes, and verification - a mirrored skill pack is present under `.agents/skills/` and `.claude/skills/`, imported from `codex-setup` as an initial shared engineering baseline @@ -221,8 +213,8 @@ This repo currently does: - define the product contract for the OpenCode setup tool - define security, scope, and transport constraints - provide npm packaging, CI, release-please, and publish scaffolding -- provide a working public CLI entrypoint with a curated public model picker -- provide end-to-end managed config writes, a validated GonkaGate model catalog +- provide a working public CLI entrypoint with a live GonkaGate model picker +- provide end-to-end managed config writes, a fetched GonkaGate model catalog for OpenCode `/models` switching, scope-aware ownership, rerun-safe rollback, and redacted effective-config verification under `src/install/` - provide docs and tests that protect the current contract @@ -327,7 +319,7 @@ When behavior changes: The installer is now real: - remove or revise any wording that drifts back toward scaffold-only language -- update the curated model registry truth when the public picker changes +- no repository update is needed when GonkaGate adds or removes a model from `/v1/models` - add runtime behavior tests before claiming any new end-user capability ## Validation diff --git a/CHANGELOG.md b/CHANGELOG.md index 59dc4a5..04d112f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +* fetch the GonkaGate model catalog from `/v1/models` during setup + ## [0.5.1](https://github.com/GonkaGate/opencode-setup/compare/v0.5.0...v0.5.1) (2026-06-23) diff --git a/README.md b/README.md index a1195bc..82916b1 100644 --- a/README.md +++ b/README.md @@ -57,13 +57,13 @@ npx @gonkagate/opencode-setup The happy path is: -1. The CLI checks that `opencode` is installed and supported. -2. It shows the public curated model picker. -3. It asks whether GonkaGate should be activated for `user` or `project` - scope. -4. It asks for your GonkaGate API key in a hidden prompt. -5. It writes the managed config, verifies the result, and tells you to go back - to plain `opencode`. +1. CLI checks `opencode` is installed and supported. +2. It asks for your GonkaGate API key through a hidden prompt. +3. It fetches `GET https://api.gonkagate.com/v1/models` with Bearer auth. +4. It shows the live GonkaGate model picker. +5. It asks whether GonkaGate should be activated in `user` or `project` scope. +6. It writes managed config, verifies the result, and tells you to go back to + plain `opencode`. ### Non-interactive setup @@ -115,19 +115,19 @@ npx @gonkagate/opencode-setup Under the hood, the shipped runtime: - validates local `opencode` -- keeps the public curated model picker visible in interactive mode, even - as the curated validated model list grows -- resolves the curated validated model and activation scope - accepts the secret only through a hidden prompt, `GONKAGATE_API_KEY`, or `--api-key-stdin` +- fetches `GET https://api.gonkagate.com/v1/models` with Bearer auth after + API-key intake +- uses the live response for the model picker, `--model` fetched model id + validation, defaults, config writes, and verification - writes only the minimum safe OpenCode config layers -- verifies both the durable plain-`opencode` outcome and the current session's +- verifies the durable plain-`opencode` outcome and the current session effective OpenCode outcome - finishes by returning the user to plain `opencode` -For `project` scope, the installer keeps the provider definition and secret -binding in user scope and writes only activation settings to repo-local -`opencode.json`. +For `project` scope, installer keeps the provider definition and secret binding +in user scope and writes only activation settings to repo-local `opencode.json`. ## Where Files Go @@ -176,31 +176,29 @@ rely on inherited per-user ACLs instead of portable `chmod`-style enforcement. ## Current Product Truth -The current public curated model picker is shipped and currently exposes three -validated models: +The live GonkaGate model picker is backed by: -- `qwen/qwen3-235b-a22b-instruct-2507-fp8` -- `moonshotai/Kimi-K2.6` -- `minimaxai/minimax-m2.7` +```http +GET https://api.gonkagate.com/v1/models +Authorization: Bearer +``` -Interactive mode keeps the public curated model picker visible. `--yes` and -safe non-interactive flows may auto-select the recommended validated model -without showing the picker. +That response is the source of truth for picker choices, `--model` fetched +model id validation, defaults, config writes, and verification. Interactive mode +keeps the live picker visible. `--yes` safe non-interactive flows may +auto-select the first fetched model without showing the picker. -The runtime is curated-model-first: +The runtime live-model-first contract: -- the stable provider id is `gonkagate` -- the managed user-level provider key is `provider.gonkagate` -- the canonical base URL is `https://api.gonkagate.com/v1` -- the current transport target is `chat/completions` +- stable provider id is `gonkagate` +- managed user-level provider key is `provider.gonkagate` +- canonical base URL is `https://api.gonkagate.com/v1` +- current transport target is `chat/completions` - future migration should add `responses` support without renaming the product -- the installer writes every validated curated GonkaGate model into - `provider.gonkagate.models` so OpenCode's `/models` command can switch - between managed GonkaGate models -- the selected setup model remains the activation default through `model` and +- installer writes every fetched model id into `provider.gonkagate.models` so + OpenCode's `/models` command can switch between managed GonkaGate models +- selected setup model remains the activation default through `model` and `small_model` -- the curated registry can carry compatibility metadata, provider options, - model options, and headers when a validated OpenCode flow needs them ## Verification And Config Precedence @@ -209,8 +207,8 @@ Success is based on effective OpenCode config. For durable verification, `opencode debug config --pure` stays the final truth source. The installer uses that resolved result to verify `model`, -`small_model`, `provider.gonkagate`, the validated transport and base URL -shape, the curated model-catalog shape, and provider allow/deny gating. +`small_model`, `provider.gonkagate`, the transport and base URL +shape, the live model-catalog shape, and provider allow/deny gating. OpenCode precedence matters here: diff --git a/docs/README.md b/docs/README.md index 308ead5..1acb9b1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,7 +5,7 @@ This repository currently contains: - the shipped installer runtime for `@gonkagate/opencode-setup` -- the public CLI, curated model picker UI, and rerun-safe rollback behavior +- the public CLI, live GonkaGate model picker UI, and rerun-safe rollback behavior - Phase 1 through Phase 5 runtime implementation under `src/install/` - CI and release tooling - the final product contract diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index a48b932..b0e3824 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -49,54 +49,26 @@ Verified upstream facts used here: - `opencode debug config` outputs resolved config values, including substituted secret material, so raw output is secret-bearing. -## Decision 1: Expose only validated curated GonkaGate models +## Decision 1: Use the live GonkaGate model catalog **Decision** -The runtime must expose only curated model entries marked `validated`, with one -explicit recommended default for automation-safe flows. +After API-key intake, the runtime fetches `GET https://api.gonkagate.com/v1/models` with Bearer auth and uses only that response for model availability. -- the installer keeps the public curated picker visible in interactive mode -- `--yes` and safe non-interactive flows may auto-select the recommended - validated model -- `model` and `small_model` are both set to the selected validated model -- every validated curated model is written to `provider.gonkagate.models` so - OpenCode's `/models` command can switch between managed GonkaGate models -- additional public model choices may land after they pass the same validation - gate +- installer keeps the live GonkaGate model picker visible in interactive mode +- `--yes` safe non-interactive flows may auto-select the first fetched model +- `--model` must match a fetched model id +- `model` and `small_model` are both set to the selected fetched model id +- every fetched model id is written to `provider.gonkagate.models` so OpenCode.s `/models` command can switch managed GonkaGate models +- adding or removing a GonkaGate model does not require a repository update **Rationale** -The safest public shape is to keep the picker visible in interactive mode while -still allowing automatic recommended-default behavior when prompts are -intentionally bypassed: - -- bounded compatibility surface -- smallest rollback surface -- no ambiguity about the recommended default in automation-safe paths -- stable public UX even as more validated models are added later -- no hidden dependency on unproven model variants - -What matters architecturally is not guessing a model name early. What matters -is forcing the runtime to ship only behind end-to-end proof. +GonkaGate owns model availability. Keeping the catalog in the API avoids stale CLI releases and removes repository-owned allowlists from user-facing setup behavior. **Implementation consequence** -- The runtime must refuse to expose or auto-fallback to any model that is not - marked `validated`. -- The curated registry should add explicit default-selection metadata such as - `recommended: true` rather than relying on array order once multiple - validated models exist. -- The managed provider catalog should be derived from validated registry - entries, while `model` and `small_model` remain only the setup defaults. -- Each public GonkaGate model ID must be pinned in a model validation record - before it is exposed through the picker or provider catalog. - -**What this means for v1** - -- Every public picker entry must pass the validation matrix from the PRD. -- Interactive users still see the public picker, while `--yes` and safe - non-interactive flows may accept the recommended default. +The runtime rejects empty or invalid model responses and rejects `--model` values not returned by `/v1/models`. ## Decision 2: Do not reconcile or mutate `OPENCODE_CONFIG` in v1 @@ -220,8 +192,7 @@ At minimum, the verification gate should confirm: - `small_model` resolves to the same v1 model - `provider.gonkagate` is present with the expected transport package and base URL -- required compatibility fragments and model catalog entries from the curated - registry are present +- every fetched model catalog entry is present - no higher-precedence layer excludes `gonkagate` ## Decision 4: Prefer the plugin-safe verification path when available @@ -343,7 +314,7 @@ These decisions fix the v1 production shape as follows: - only validated GonkaGate models are exposed, with later choices gated by the same validation bar -- interactive public picker remains visible while the curated model list is +- interactive public picker remains visible while the live model list is small - durable writes only to the documented user config target, project activation file, and GonkaGate-managed user storage diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 94253e0..3314c6b 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -16,9 +16,8 @@ The runtime is implemented and shipped. Today the repository ships: - the public CLI -- the public curated model picker UI, which currently exposes three validated - models -- a managed GonkaGate provider catalog that writes every validated curated +- the live GonkaGate model picker UI backed by `GET https://api.gonkagate.com/v1/models` +- a managed GonkaGate provider catalog that writes every fetched GonkaGate model into `provider.gonkagate.models` so OpenCode's `/models` command can switch between GonkaGate models after setup - end-to-end secret intake, managed secret persistence, managed OpenCode config @@ -29,73 +28,18 @@ Today the repository ships: - CI and release tooling, including native Windows runner coverage - docs and contract tests -Current validated picker entries: - -- `qwen/qwen3-235b-a22b-instruct-2507-fp8` -- `moonshotai/Kimi-K2.6` -- `minimaxai/minimax-m2.7` - ## Install Flow -1. Check that `opencode` is available and that the installed version is at - least `1.4.0`. -2. Show the public curated model picker in interactive mode. The current picker - contains three validated models and one recommended default. -3. Recommend `project` scope inside a git repository and `user` scope - otherwise. -4. Accept a GonkaGate API key through: - - a hidden interactive prompt - - `GONKAGATE_API_KEY` - - `--api-key-stdin` -5. Save the secret only under `~/.gonkagate/opencode/...`. On macOS, Linux, and - WSL, reruns also repair drifted owner-only modes on the managed secret file - and directory without rewriting unchanged secret contents or creating a - backup. -6. Write or update the durable global user-level provider definition and the - full validated GonkaGate model catalog. -7. When `project` scope is chosen, write only activation settings to - `opencode.json`. -8. On rerun, normalize both managed targets to the selected final scope by - removing only installer-owned GonkaGate activation from the old location. - Unrelated `model` and `small_model` values are preserved and later surfaced - by verification if they still override the selected scope. -9. Create backups before replacing managed user files. If an existing - repo-local `opencode.json` must be rewritten, store its rollback backup - under `~/.gonkagate/opencode/backups/project-config` instead of beside the - repository file, then roll back changed managed files automatically if a - later verification step fails. -10. Verify the durable plain-`opencode` outcome with - `opencode debug config --pure` on the verified baseline without treating - `OPENCODE_CONFIG_CONTENT` as a durable install target. This resolved check - covers `model`, `small_model`, `provider.gonkagate`, validated transport - and base URL shape, curated model-catalog shape, and provider allow/deny - gating. -11. Verify secret-binding provenance separately instead of inferring it from - redacted resolved-config output: `user_config` must own - `provider.gonkagate.options.apiKey` with the canonical - `{file:~/.gonkagate/opencode/api-key}` binding, and project config, - `OPENCODE_CONFIG`, and inspectable file-based system managed config must - not define that key. -12. If the durable resolved config does not match the intended GonkaGate - outcome, inspect locally inspectable layers such as `OPENCODE_CONFIG`, user - config, project config, and file-based system managed config to attribute - the block without reimplementing the full upstream merge engine. - When more than one inspectable layer conflicts on the same managed key, use - the real OpenCode precedence order instead of file traversal order. -13. If the resolved config proves `enabled_providers` or - `disabled_providers` blocks `gonkagate` but no locally inspectable layer - explains it, surface an inferred higher-precedence or managed blocker. -14. If `OPENCODE_CONFIG_CONTENT` is active, verify the current session again - with that runtime-only layer still present. Resolved current-session - mismatches are blocked when the inline layer changes the effective result - away from the intended GonkaGate outcome, and inline - `provider.gonkagate.options.apiKey` overrides are always blocked in v1 - because current upstream docs do not clearly prove equivalent inline - `{file:...}` substitution parity for this secret-binding contract. -15. Tell the user to run plain `opencode`, and show the safe rerun command for - replacing the managed API key later. OpenCode Desktop users should restart - the desktop app after rerunning setup so its sidecar reloads - `~/.gonkagate/opencode/api-key`. +1. Check `opencode` is available and at least `1.4.0`. +2. Accept the GonkaGate API key through a hidden prompt, `GONKAGATE_API_KEY`, or `--api-key-stdin`. +3. Fetch `GET https://api.gonkagate.com/v1/models` with Bearer auth. +4. Show the live GonkaGate model picker in interactive mode, defaulting to the first fetched model. +5. Recommend `project` scope inside a git repository and `user` scope otherwise. +6. Save the secret only under `~/.gonkagate/opencode/...`. +7. Write or update the user-level provider definition with every fetched model id in `provider.gonkagate.models`. +8. When `project` scope is chosen, write only activation settings to repo-local `opencode.json`. +9. Verify durable and current-session effective OpenCode config without printing raw resolved config. +10. Tell the user to run plain `opencode` and show the safe key-rotation rerun command. ## Why User-Level Provider Ownership @@ -236,14 +180,7 @@ user. Resolved-config output can already contain expanded `{file:...}` or `{env:...}` secrets, so it is parsed internally and surfaced only through redacted diagnostics. -The curated model registry also carries enough metadata to reproduce the exact -validated config shape. The installer writes all validated registry entries to -`provider.gonkagate.models`; the selected setup model only controls the root -`model` and `small_model` defaults. That lets users switch between managed -GonkaGate models through OpenCode's `/models` command without exposing arbitrary -custom model ids. If a GonkaGate model requires extra provider options, model -options, or headers for stable OpenCode behavior, those requirements live in -the registry and are written by the installer. +The live `GET https://api.gonkagate.com/v1/models` response is the source of truth for model availability. The installer writes every fetched id to `provider.gonkagate.models`; the selected setup model only controls the root `model` and `small_model` defaults. That lets users switch between managed GonkaGate models through OpenCode.s `/models` command without requiring repository updates for model additions or removals. ## Windows Support @@ -285,6 +222,6 @@ If non-interactive setup does not pass `--scope`, the CLI requires either an explicit `--scope` or `--yes` so it can accept the recommended scope safely. If non-interactive setup does not pass `--model`, the CLI may accept the single -recommended validated model automatically. Interactive mode still keeps the -public curated picker visible so the public UX remains stable as the registry +default fetched model automatically. Interactive mode still keeps the +live GonkaGate model picker visible so the public UX remains stable as the registry grows. diff --git a/docs/model-validation.md b/docs/model-validation.md index 3d0903b..a137c6f 100644 --- a/docs/model-validation.md +++ b/docs/model-validation.md @@ -1,63 +1,28 @@ -# Model Validation +# Model Catalog Source -This document records the minimum validation gate for curated GonkaGate models -and the approved public validated models in the shipped runtime. +`@gonkagate/opencode-setup` does not publish a checked-in GonkaGate model +catalog. -## Minimum Validation Gate +During setup, after collecting the GonkaGate API key, the installer calls: -A model may be marked `validated` only after end-to-end verification against -the current verified OpenCode baseline: +```http +GET https://api.gonkagate.com/v1/models +Authorization: Bearer +``` -- interactive `opencode` -- `opencode run` -- streaming text responses -- tool-calling and file-edit loops -- multi-turn continuation -- the `small_model` path used by lightweight OpenCode tasks -- effective-config resolution in both `user` and `project` scope -- any required provider options, model options, or model headers needed for - stable validated behavior +The OpenAI-compatible response is the source of truth for: -## Approved Public Records +- interactive model picker choices +- `--model` validation +- `--yes` default model selection +- `provider.gonkagate.models` config writes +- effective-config verification -The current shipped runtime exposes these validated models through the public -picker. +The installer accepts `{ data: [{ id: string, name?: string }] }`, dedupes +model ids, uses the first returned model as the default, and rejects empty or +invalid responses. Adding or removing a GonkaGate model should not require a +repository update. -### Qwen3 235B A22B Instruct 2507 FP8 - -- display name: `Qwen3 235B A22B Instruct 2507 FP8` -- key: `qwen3-235b-a22b-instruct-2507-fp8` -- model id: `qwen/qwen3-235b-a22b-instruct-2507-fp8` -- transport: `chat_completions` -- adapter package: `@ai-sdk/openai-compatible` -- recommended: `false` - -### Kimi K2.6 - -- display name: `Kimi K2.6` -- key: `kimi-k2.6` -- model id: `moonshotai/Kimi-K2.6` -- transport: `chat_completions` -- adapter package: `@ai-sdk/openai-compatible` -- recommended: `true` - -### MiniMax M2.7 - -- display name: `MiniMax M2.7` -- key: `minimax-m2.7` -- model id: `minimaxai/minimax-m2.7` -- transport: `chat_completions` -- adapter package: `@ai-sdk/openai-compatible` -- recommended: `false` - -## Notes - -- v1 explicitly writes both `model` and `small_model` to the same selected - validated GonkaGate model -- v1 writes every validated curated model into `provider.gonkagate.models` so - OpenCode's `/models` command can switch between managed GonkaGate models -- interactive setup keeps the public curated picker visible as broader public - model choice lands without changing the UX contract -- future `/v1/responses` support requires a separate migration and revalidation -- broader public model choice stays gated on entries passing the same - validation gate +The runtime still targets `chat/completions` with +`https://api.gonkagate.com/v1` today. Future `/v1/responses` support should be +added as a migration without changing the provider id. diff --git a/docs/specs/opencode-setup-prd/spec.md b/docs/specs/opencode-setup-prd/spec.md index 6713f7e..f866c07 100644 --- a/docs/specs/opencode-setup-prd/spec.md +++ b/docs/specs/opencode-setup-prd/spec.md @@ -26,16 +26,17 @@ npx @gonkagate/opencode-setup The tool: 1. validates local `opencode` -2. offers only curated GonkaGate model choices -3. lets the user choose `user` or `project` scope -4. accepts a GonkaGate API key through a hidden prompt, `GONKAGATE_API_KEY`, +2. accepts GonkaGate API key through hidden prompt, `GONKAGATE_API_KEY`, or `--api-key-stdin` -5. writes the minimum safe configuration automatically -6. verifies both the durable plain-`opencode` outcome and the current +3. fetches `GET https://api.gonkagate.com/v1/models` with Bearer auth +4. offers only models returned by that live response +5. lets user choose `user` or `project` scope +6. writes minimum safe configuration automatically +7. verifies both durable plain-`opencode` outcome and the current session's effective OpenCode outcome -7. never requires manual edits to `opencode.json` -8. never requires `.env` files or shell exports -9. sends the user back to normal `opencode` +8. never requires manual edits to `opencode.json` +9. never requires `.env` files or shell exports +10. sends user back to normal `opencode` ## Users @@ -55,7 +56,7 @@ Secondary user: - one public repository - configuration of already installed local `opencode` - hidden or automation-safe secret input -- curated model picker +- live GonkaGate model picker backed by `/v1/models` - `user` and `project` scope - managed user secret storage - managed config writes with backups @@ -67,9 +68,8 @@ Secondary user: - shell profile mutation - `.env` file generation - plain `--api-key` -- runtime `/v1/models` discovery as the main onboarding UX - arbitrary custom base URLs -- arbitrary custom model ids +- arbitrary model ids not returned by `/v1/models` - integration with `gonkagate doctor` - claiming `/v1/responses` support today @@ -244,15 +244,15 @@ rewrite. ### Default UX -- interactive mode keeps the public curated model picker visible even when - exactly one validated GonkaGate model is currently available +- interactive mode keeps the live GonkaGate model picker visible even when + exactly one model is returned - if exactly one validated GonkaGate model is available, safe non-interactive setup may select it automatically -- if multiple validated models are available, preselect one recommended default - and allow the user to change it +- if multiple GonkaGate models are returned, preselect the first fetched + model and allow the user to change it - recommend `project` scope when the installer is running inside a git repository and recommend `user` scope otherwise -- `--yes` accepts the recommended defaults +- `--yes` accepts the model and scope defaults - prompts should explain scope in plain user language, not only in OpenCode config terms @@ -310,31 +310,19 @@ root. ### Model Strategy -The onboarding flow must not depend on runtime `/v1/models` discovery. +The onboarding flow depends on live GonkaGate model discovery after API-key +intake. The installer calls `GET https://api.gonkagate.com/v1/models` with +Bearer auth and accepts OpenAI-compatible `{ data: [{ id: string, name?: +string }] }`. -Instead it ships a curated model registry that records, per model: +The fetched response is the only runtime source of truth for picker choices, +`--model` validation, default model selection, `provider.gonkagate.models` +writes, and effective-config verification. The installer dedupes ids, rejects +empty or invalid responses, and uses the first fetched model as the default when +the API does not return a dynamic default. -- stable GonkaGate setup key -- upstream model id -- display name -- transport kind -- default adapter package -- validation status -- optional context and output limits -- optional compatibility metadata and managed config fragments required for the - validated OpenCode flow, such as provider options, model options, or model - headers -- optional migration metadata for future per-model adapter changes - -Registry keys must map cleanly to OpenCode's `provider_id/model_id` format so -validated entries can be written under `provider.gonkagate.models` and the -selected setup default can be written as `gonkagate/`. - -Registry data must be sufficient for the installer to reproduce the exact -provider and model config shape used during validation, not only the visible -picker label or adapter package. - -Only validated models should be shown to end users. +Fetched ids are written directly under `provider.gonkagate.models`; the selected +setup default is written as `gonkagate/`. ### Model Validation Gate @@ -342,7 +330,7 @@ A model may be marked `validated` only after end-to-end verification against the current verified OpenCode baseline for the workflows the product claims to support. -Minimum validation proof for a curated GonkaGate model includes: +Minimum validation proof for a GonkaGate model exposed by `/v1/models` includes: - an interactive `opencode` session - `opencode run` @@ -358,7 +346,7 @@ If GonkaGate later claims task, delegate, or subagent compatibility, the model must be re-validated for those flows before the product advertises that support. A model must not be marked `validated` if its working setup depends on -undocumented manual tweaks that are not representable in the curated registry +undocumented manual tweaks that are not representable in the live model response contract. ### `small_model` Policy @@ -371,7 +359,7 @@ The installer must explicitly set both: In v1 they should be set to the same selected GonkaGate model. The selected model is only the default activation target. The installer must -also write every validated curated model into `provider.gonkagate.models` so +also write every fetched GonkaGate model into `provider.gonkagate.models` so OpenCode's `/models` command can switch between managed GonkaGate models after setup. @@ -402,7 +390,7 @@ Migration contract: - package identity remains `@gonkagate/opencode-setup` - secret location remains stable - rerunning the installer is the official migration path -- curated registry and install-state metadata decide whether migration happens +- live model response and install-state metadata decide whether migration happens through: - a whole-provider adapter change - or a per-model provider override from `@ai-sdk/openai-compatible` to @@ -415,14 +403,14 @@ The installer owns only the GonkaGate-managed subset of config. User-level managed keys: - `provider.gonkagate` in the durable global config -- the full validated GonkaGate catalog under `provider.gonkagate.models` +- the full fetched GonkaGate catalog under `provider.gonkagate.models` - validated GonkaGate compatibility settings under `provider.gonkagate` and its - model entries when the curated registry requires them + model entries when the live model response requires them - GonkaGate-managed activation settings when scope is `user` - GonkaGate-managed `small_model` when scope is `user` - stale activation cleanup in the old target only when the installer can prove - ownership through the currently selected curated GonkaGate ref or the - previously recorded managed curated ref from `install-state.json` + ownership through the currently selected fetched GonkaGate ref or the + previously recorded managed fetched-model ref from `install-state.json` Additional precedence-aware ownership: @@ -490,7 +478,7 @@ Before claiming success, the installer must: - use `opencode debug config --pure` as the final durable success gate on the verified baseline instead of reimplementing the full upstream merge engine - use resolved-config verification for `model`, `small_model`, - `provider.gonkagate`, validated transport and base URL shape, curated + `provider.gonkagate`, transport and base URL shape, live model-catalog shape, and provider allow/deny gating - verify `provider.gonkagate.options.apiKey` provenance separately instead of inferring secret ownership from redacted resolved output @@ -545,7 +533,7 @@ It must not depend on `gonkagate doctor`. 4. the installer must store the secret only outside the repository 5. the installer must configure GonkaGate using the current `chat_completions` contract -6. the installer must use curated validated models +6. the installer must use fetched GonkaGate models 7. the installer must preserve unrelated OpenCode config 8. the installer must set `small_model` explicitly 9. the installer must support rerun as the official update path @@ -559,9 +547,9 @@ It must not depend on `gonkagate doctor`. current session's effective config before reporting success 15. the installer must not print raw resolved-config output that may contain substituted secrets -16. the curated model registry must be able to encode any validated - compatibility settings required beyond model id and adapter choice -17. the installer must write every validated curated model into +16. installer must fetch `/v1/models` after API-key intake and use only + that response as the runtime model source +17. the installer must write every fetched GonkaGate model into `provider.gonkagate.models` so OpenCode's `/models` command can switch between managed GonkaGate models 18. the installer must treat `enabled_providers` and `disabled_providers` that @@ -601,8 +589,8 @@ It must not depend on `gonkagate doctor`. 8. future `responses` migration must not require a new package identity 9. setup should minimize prompts by accepting recommended defaults when it is safe to do so -10. interactive setup should keep the public curated picker visible even when - the curated validated list is small so the public UX stays stable +10. interactive setup should keep the live GonkaGate model picker visible even when + the fetched GonkaGate list is small so the public UX stays stable 11. on macOS, Linux, and WSL, reruns must repair drifted managed-secret file and directory modes without rewriting unchanged secret contents or creating backups @@ -611,7 +599,7 @@ It must not depend on `gonkagate doctor`. - uninstall or repair commands - richer post-setup verification -- broader curated model registry +- richer live model metadata when the API exposes it - validated cheaper small-model strategy - future migration to `responses` @@ -619,7 +607,6 @@ It must not depend on `gonkagate doctor`. - if secrets enter project config, users will leak keys into git - if the tool overclaims `responses`, product truth will drift -- if runtime discovery becomes the onboarding truth, stability will degrade - if unrelated config is overwritten, users will stop trusting the tool - if `small_model` is left implicit, OpenCode may take an unvalidated cheaper model path or a fallback path we did not configure explicitly @@ -644,9 +631,6 @@ It must not depend on `gonkagate doctor`. - if native Windows profile-directory ACL inheritance is mistaken for installer-enforced ACL repair, the product will overclaim its real security guarantee -- if validated compatibility settings are not captured in the curated registry, - a model may look installable on paper but still fail in real OpenCode agent - loops - if raw resolved-config output is echoed during verification, secret material from `{file:...}` or `{env:...}` substitution may leak into terminals, logs, CI output, or support tickets @@ -657,8 +641,8 @@ The correct v1 product shape is: - a small onboarding CLI - one stable provider id: `gonkagate` -- one curated model picker and a validated GonkaGate model catalog for - OpenCode `/models` switching +- one live GonkaGate model picker and fetched model catalog for OpenCode + `/models` switching - one safe secret flow - zero manual config editing - zero `.env` @@ -666,7 +650,7 @@ The correct v1 product shape is: - minimal prompting with recommended defaults - one durable global config target plus precedence-aware conflict handling, including provider allow and deny blockers -- a curated registry that can carry the compatibility settings required for +- a live model response that can carry the compatibility settings required for validated OpenCode behavior - honest support for `chat_completions` today - a deliberate migration path for future `responses` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e8f23f9..96fe7c9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,169 +1,10 @@ # Troubleshooting -## Why does the CLI stop with a blocked status? +## What happens when GonkaGate changes available models? -Because the installer verifies the effective OpenCode config, not only the file -writes it just performed. - -Common blockers include: - -- `OPENCODE_CONFIG` -- `OPENCODE_CONFIG_CONTENT` -- user config -- file-based system managed config -- `provider.gonkagate.options.apiKey` ownership or higher-precedence secret- - binding overrides -- `enabled_providers` -- `disabled_providers` -- higher-precedence project activation that overrides the selected scope - -When those layers prevent GonkaGate from becoming effective, the installer -returns a redacted blocked result instead of claiming success. - -The shipped runtime proves two things separately: - -- the resolved durable plain-`opencode` result after setup -- the current session resolved result with runtime-only overrides like - `OPENCODE_CONFIG_CONTENT` still active -- the secret-binding provenance for `provider.gonkagate.options.apiKey` - -That means a current shell can still be reported as blocked even when the -durable install itself is correct. - -For the durable plain-`opencode` check, exact blocker attribution is only -promised for locally inspectable `OPENCODE_CONFIG`, user config, project -config, and file-based system managed config. If the resolved config proves -that `enabled_providers` or `disabled_providers` blocks `gonkagate` but none -of those layers explains it, the installer reports an inferred -higher-precedence or managed blocker instead of a generic mismatch. -When more than one inspectable layer conflicts on the same managed key, the -installer attributes the block according to real OpenCode precedence, so -project config wins over `OPENCODE_CONFIG` and file-based system managed config -wins above both. - -Secret-binding provenance is stricter than ordinary resolved-key comparison: - -- `user_config` must own - `provider.gonkagate.options.apiKey = {file:~/.gonkagate/opencode/api-key}` -- project config, `OPENCODE_CONFIG`, and inspectable file-based system managed - config must not define that key -- `OPENCODE_CONFIG_CONTENT` must not define that key in v1 - -In that case the managed `install-state.json` timestamp still reflects the last -durably verified setup, because `lastDurableSetupAt` is about the durable -plain-`opencode` result, not universal success for every current shell -override. - -## Why is plain `--api-key` not supported? - -Because a plain CLI flag can leak into shell history, logs, and process -inspection. - -The safe options are: - -- hidden interactive prompt -- `GONKAGATE_API_KEY` -- `--api-key-stdin` - -## Why does OpenCode Desktop still say "Invalid credentials" after I pasted a new key? - -If setup was installed by `@gonkagate/opencode-setup`, GonkaGate auth is owned -by the managed secret file at `~/.gonkagate/opencode/api-key`. The OpenCode -config points at that file with -`provider.gonkagate.options.apiKey = {file:~/.gonkagate/opencode/api-key}`. - -The OpenCode Desktop custom-provider form can show a new key while the running -sidecar still resolves the installer-managed file. To replace the key, rerun -setup instead of relying on the Desktop form: - -```bash -printf '%s' "$GONKAGATE_API_KEY" | npx @gonkagate/opencode-setup --api-key-stdin --scope project --yes -``` - -Then restart OpenCode Desktop so it reloads the managed secret file. - -## Why does non-interactive setup require `--scope` or `--yes`? - -Because the installer needs an explicit safe way to choose between `user` and -`project` activation. - -If you do not pass `--scope`, then: - -- interactive mode will show the scope prompt -- `--yes` will accept the recommended scope -- non-interactive mode without `--yes` will stop instead of guessing - -## Why does the public model picker only show curated options? - -Because the shipped picker exposes validated models only. - -The current public validated entries are: - -- `qwen/qwen3-235b-a22b-instruct-2507-fp8` -- `moonshotai/Kimi-K2.6` -- `minimaxai/minimax-m2.7` - -Interactive mode still keeps that public picker visible, so more validated -models can be added later without changing the CLI contract. - -## How can I switch GonkaGate models inside OpenCode? - -Run `/models` in OpenCode after setup. - -The installer writes every validated curated GonkaGate model into -`provider.gonkagate.models`, while `model` and `small_model` only set the -initial default selected during setup. If a new validated model ships later, -rerun `npx @gonkagate/opencode-setup` to refresh the managed provider catalog. - -## Why is `/v1/responses` mentioned if it is not supported today? - -Because the product needs a planned migration path. - -Current truth: - -- today: `chat/completions` -- later: `responses` - -The repository contract is designed so the setup tool can migrate forward -without renaming the product or changing provider identity. - -## Why does project scope only write activation settings? - -Because the repository-local `opencode.json` should be safe to commit. - -Provider definition and secret binding stay in user scope so the project file -does not contain machine-specific or secret-bearing details. - -When you rerun and switch scopes, the installer only removes activation values -it owns. If an unrelated or manual `model` / `small_model` value remains in -the old target and still wins by precedence, verification surfaces that as a -blocker instead of silently deleting it. - -## Why is `OPENCODE_CONFIG` treated specially? - -Because in OpenCode it is an extra higher-precedence config layer, not a -replacement for `~/.config/opencode/opencode.json`. - -That means the installer cannot safely treat it as the only user config target. -It has to account for the resolved config OpenCode will actually use. - -## Why can setup still fail even if `provider.gonkagate` was written? - -Because OpenCode can still block a configured provider through -`enabled_providers` or `disabled_providers`. - -Those keys can live in higher-precedence config layers, so the installer treats -them as explicit blockers instead of claiming success just because the provider -block, `model`, and `small_model` were written. - -## Why would a validated model need more than a model id? - -Because in stable OpenCode, some custom-provider behavior can depend on extra -compatibility metadata and managed config fragments such as provider options, -model options, or model headers. - -If GonkaGate needs those settings for a validated flow, they belong in the -curated model registry and installer output, not as undocumented manual tweaks. +Rerun setup. The installer fetches the current `/v1/models` response and +rewrites the managed provider catalog from that response while preserving +unrelated OpenCode config. ## Is it safe to paste raw `opencode debug config` output into logs or tickets? diff --git a/src/cli/parse.ts b/src/cli/parse.ts index b0f6f79..fdc8708 100644 --- a/src/cli/parse.ts +++ b/src/cli/parse.ts @@ -45,7 +45,7 @@ function createProgram(output?: ProgramOutput): Command { .addOption( new Option( "--model ", - "Choose a curated validated GonkaGate model without using the picker.", + "Choose a GonkaGate model id returned by /v1/models without using the picker.", ), ) .addOption( @@ -69,7 +69,7 @@ function createProgram(output?: ProgramOutput): Command { .addOption( new Option( "--yes", - "Accept the recommended model and scope defaults without prompting.", + "Accept the model and scope defaults without prompting.", ).default(false), ) .addOption( @@ -95,7 +95,7 @@ Runtime contract: - Provider id: ${GONKAGATE_PROVIDER_ID} - Base URL: ${GONKAGATE_BASE_URL} - Current transport target: ${CURRENT_TRANSPORT} - - Curated model picker: public and validated-model-only + - Model source: ${GONKAGATE_BASE_URL}/models - Verified OpenCode baseline: ${CONTRACT_METADATA.verifiedOpencode.minVersion}+ Safe secret inputs: diff --git a/src/constants/contract.ts b/src/constants/contract.ts index 76f26b4..ac9fab3 100644 --- a/src/constants/contract.ts +++ b/src/constants/contract.ts @@ -3,7 +3,7 @@ export const CONTRACT_METADATA = { legacyBinName: "gonkagate-opencode", binPath: "bin/gonkagate-opencode.js", cliVersion: "0.5.1", // x-release-please-version - curatedRegistryPublished: true, + liveModelCatalogPublished: true, packageName: "@gonkagate/opencode-setup", publicEntrypoint: "npx @gonkagate/opencode-setup", publicState: diff --git a/src/constants/gateway.ts b/src/constants/gateway.ts index 3e849eb..c63554b 100644 --- a/src/constants/gateway.ts +++ b/src/constants/gateway.ts @@ -1,4 +1,5 @@ export const GONKAGATE_BASE_URL = "https://api.gonkagate.com/v1"; +export const GONKAGATE_MODELS_URL = `${GONKAGATE_BASE_URL}/models`; export const GONKAGATE_PROVIDER_ID = "gonkagate"; export const GONKAGATE_PROVIDER_NAME = "GonkaGate"; export const CURRENT_TRANSPORT = "chat_completions"; diff --git a/src/constants/models.ts b/src/constants/models.ts index e48c5b2..db1685e 100644 --- a/src/constants/models.ts +++ b/src/constants/models.ts @@ -1,217 +1,39 @@ -import type { JsonObject, JsonValue } from "../json.js"; +import { CURRENT_TRANSPORT } from "./gateway.js"; -export const CURATED_MODEL_TRANSPORTS = Object.freeze([ +export const GONKAGATE_MODEL_TRANSPORTS = Object.freeze([ "chat_completions", "responses", ] as const); -export type CuratedModelTransport = (typeof CURATED_MODEL_TRANSPORTS)[number]; -export type CuratedModelValidationStatus = "planned" | "validated"; +export type CuratedModelTransport = (typeof GONKAGATE_MODEL_TRANSPORTS)[number]; +export type CuratedModelKey = string; -export type CuratedModelJsonValue = JsonValue; - -export interface CuratedModelProviderOverride { - api?: CuratedModelTransport; - npm?: string; -} - -export interface CuratedModelCompatibility { - modelHeaders?: Readonly>; - modelOptions?: Readonly; - modelProvider?: Readonly; - notes?: readonly string[]; - providerOptions?: Readonly; -} - -export interface CuratedModelLimits { - context?: number; - output?: number; -} - -export interface CuratedModelMigrationMetadata { - adapterPackage?: string; - transport?: CuratedModelTransport; -} - -export interface CuratedModelDefinition { - adapterPackage: string; +export interface GonkagateModel { displayName: string; - limits?: CuratedModelLimits; - migrationMetadata?: CuratedModelMigrationMetadata; + key: CuratedModelKey; modelId: string; - recommended: boolean; - runtimeCompatibility?: CuratedModelCompatibility; transport: CuratedModelTransport; - validationStatus: CuratedModelValidationStatus; } -export interface CuratedModelRegistry { - readonly [key: string]: CuratedModelDefinition; -} - -export type CuratedModelRecord = - CuratedModelDefinition & { - key: TKey; - }; - -type CuratedModelKeyOf = Extract< - keyof TRegistry, - string ->; -type CuratedModelRecordFor< - TRegistry extends CuratedModelRegistry, - TKey extends CuratedModelKeyOf = CuratedModelKeyOf, -> = TRegistry[TKey] & { - key: TKey; -}; -type ValidatedCuratedModelRecordFor< - TRegistry extends CuratedModelRegistry, - TKey extends CuratedModelKeyOf = CuratedModelKeyOf, -> = Extract< - CuratedModelRecordFor, - { validationStatus: "validated" } ->; -type RecommendedValidatedCuratedModelRecordFor< - TRegistry extends CuratedModelRegistry, - TKey extends CuratedModelKeyOf = CuratedModelKeyOf, -> = Extract< - ValidatedCuratedModelRecordFor, - { recommended: true } ->; - -export interface CuratedModelIndex< - TRegistry extends CuratedModelRegistry = CuratedModelRegistry, -> { - modelKeys: readonly CuratedModelKeyOf[]; - models: readonly CuratedModelRecordFor[]; - recommendedValidatedModel: - | RecommendedValidatedCuratedModelRecordFor - | undefined; - validatedModelKeys: readonly ValidatedCuratedModelRecordFor["key"][]; - validatedModels: readonly ValidatedCuratedModelRecordFor[]; -} +export type ValidatedCuratedModel = GonkagateModel; export type OpencodeModelRef = `gonkagate/${TKey}`; -export const CURATED_MODEL_REGISTRY = Object.freeze({ - "qwen3-235b-a22b-instruct-2507-fp8": { - adapterPackage: "@ai-sdk/openai-compatible", - displayName: "Qwen3 235B A22B Instruct 2507 FP8", - modelId: "qwen/qwen3-235b-a22b-instruct-2507-fp8", - recommended: false, - transport: "chat_completions", - validationStatus: "validated", - }, - "kimi-k2.6": { - adapterPackage: "@ai-sdk/openai-compatible", - displayName: "Kimi K2.6", - modelId: "moonshotai/Kimi-K2.6", - recommended: true, - transport: "chat_completions", - validationStatus: "validated", - }, - "minimax-m2.7": { - adapterPackage: "@ai-sdk/openai-compatible", - displayName: "MiniMax M2.7", - modelId: "minimaxai/minimax-m2.7", - recommended: false, - transport: "chat_completions", - validationStatus: "validated", - }, -} as const satisfies CuratedModelRegistry); - -function toCuratedModelRecord< - TKey extends string, - TDefinition extends CuratedModelDefinition, ->(key: TKey, definition: TDefinition): TDefinition & { key: TKey } { +export function createGonkagateModel( + id: string, + name: string | undefined, +): GonkagateModel { return { - ...definition, - key, + displayName: name ?? id, + key: id, + modelId: id, + transport: CURRENT_TRANSPORT, }; } -export function isValidatedModel< - TModel extends { validationStatus: CuratedModelValidationStatus }, ->(model: TModel): model is Extract { - return model.validationStatus === "validated"; -} - -export function isRecommendedCuratedModel< - TModel extends { recommended: boolean }, ->(model: TModel): model is Extract { - return model.recommended; -} - -export function createCuratedModelIndex( - registry: TRegistry, -): CuratedModelIndex { - type RegistryKey = CuratedModelKeyOf; - type RegistryModel = CuratedModelRecordFor; - type ValidatedRegistryModel = ValidatedCuratedModelRecordFor; - type RecommendedValidatedRegistryModel = - RecommendedValidatedCuratedModelRecordFor; - - const modelKeys = Object.keys(registry) as RegistryKey[]; - const models: RegistryModel[] = []; - const validatedModels: ValidatedRegistryModel[] = []; - const validatedModelKeys: ValidatedRegistryModel["key"][] = []; - const recommendedValidatedModels: RecommendedValidatedRegistryModel[] = []; - - for (const key of modelKeys) { - const model = toCuratedModelRecord(key, registry[key]); - models.push(model); - - if (!isValidatedModel(model)) { - continue; - } - - validatedModels.push(model); - validatedModelKeys.push(model.key); - - if (isRecommendedCuratedModel(model)) { - recommendedValidatedModels.push(model); - } - } - - if (recommendedValidatedModels.length > 1) { - throw new Error( - "Curated model registry must not expose more than one recommended validated model.", - ); - } - - return { - modelKeys: Object.freeze(modelKeys), - models: Object.freeze(models), - recommendedValidatedModel: recommendedValidatedModels[0], - validatedModelKeys: Object.freeze(validatedModelKeys), - validatedModels: Object.freeze(validatedModels), - }; -} - -type DefaultCuratedModelRegistry = typeof CURATED_MODEL_REGISTRY; - -export type CuratedModelKey = CuratedModelKeyOf; -export type CuratedModel = CuratedModelRecordFor; -export type CuratedModelByKey = - CuratedModelRecordFor; -export type ValidatedCuratedModel = - ValidatedCuratedModelRecordFor; -export type RecommendedValidatedCuratedModel = - RecommendedValidatedCuratedModelRecordFor; - -const DEFAULT_CURATED_MODEL_INDEX = createCuratedModelIndex( - CURATED_MODEL_REGISTRY, -); - -export const SUPPORTED_MODELS: readonly CuratedModel[] = - DEFAULT_CURATED_MODEL_INDEX.models; - -export const SUPPORTED_MODEL_KEYS: readonly CuratedModelKey[] = - DEFAULT_CURATED_MODEL_INDEX.modelKeys; - export function isCuratedModelKey(key: string): key is CuratedModelKey { - return key in CURATED_MODEL_REGISTRY; + return key.trim().length > 0; } export function isCuratedModelTransport( @@ -219,36 +41,10 @@ export function isCuratedModelTransport( ): value is CuratedModelTransport { return ( typeof value === "string" && - CURATED_MODEL_TRANSPORTS.includes(value as CuratedModelTransport) + GONKAGATE_MODEL_TRANSPORTS.includes(value as CuratedModelTransport) ); } -export function getCuratedModelByKey( - key: TKey, -): CuratedModelByKey; -export function getCuratedModelByKey(key: string): CuratedModel | undefined; -export function getCuratedModelByKey(key: string): CuratedModel | undefined { - if (!isCuratedModelKey(key)) { - return undefined; - } - - return toCuratedModelRecord(key, CURATED_MODEL_REGISTRY[key]); -} - -export function getValidatedModels(): readonly ValidatedCuratedModel[] { - return DEFAULT_CURATED_MODEL_INDEX.validatedModels; -} - -export function getValidatedModelKeys(): readonly ValidatedCuratedModel["key"][] { - return DEFAULT_CURATED_MODEL_INDEX.validatedModelKeys; -} - -export function getRecommendedValidatedModel(): - | RecommendedValidatedCuratedModel - | undefined { - return DEFAULT_CURATED_MODEL_INDEX.recommendedValidatedModel; -} - export function formatOpencodeModelRef( model: TKey | { key: TKey }, ): OpencodeModelRef { diff --git a/src/install/README.md b/src/install/README.md index 2fb5a79..f83fd40 100644 --- a/src/install/README.md +++ b/src/install/README.md @@ -21,7 +21,7 @@ Start here: - `storage.ts` and `state.ts` handle managed secret and install-state persistence with backups, POSIX owner-only permissions where supported, and explicit native Windows profile-scoped storage semantics -- `managed-provider-config.ts` translates validated curated models into the +- `managed-provider-config.ts` translates fetched GonkaGate models into the canonical GonkaGate-owned provider shape - `config.ts` parses JSON and JSONC OpenCode config safely and applies GonkaGate-owned mutations without replacing unrelated config diff --git a/src/install/contracts/effective-config.ts b/src/install/contracts/effective-config.ts index 5c9ee40..19cacfc 100644 --- a/src/install/contracts/effective-config.ts +++ b/src/install/contracts/effective-config.ts @@ -1,5 +1,4 @@ import type { - CuratedModelCompatibility, CuratedModelKey, CuratedModelTransport, OpencodeModelRef, @@ -53,7 +52,6 @@ export interface EffectiveConfigVerificationTarget { modelKey: CuratedModelKey; modelRef: OpencodeModelRef; providerId: string; - runtimeCompatibility?: CuratedModelCompatibility; transport: CuratedModelTransport; } diff --git a/src/install/contracts/managed-config.ts b/src/install/contracts/managed-config.ts index c164cfd..d8151e7 100644 --- a/src/install/contracts/managed-config.ts +++ b/src/install/contracts/managed-config.ts @@ -1,4 +1,7 @@ -import type { CuratedModelKey } from "../../constants/models.js"; +import type { + CuratedModelKey, + ValidatedCuratedModel, +} from "../../constants/models.js"; import type { JsonObject, JsonValue } from "../../json.js"; import type { ManagedPaths } from "../paths.js"; import type { ManagedArtifactWriteResult } from "./managed-artifact.js"; @@ -86,6 +89,7 @@ export type ManagedConfigPlan = export interface ScopeWriteRequest { managedPaths: ManagedPaths; model: CuratedModelKey; + models: readonly ValidatedCuratedModel[]; previousManagedModelKey?: CuratedModelKey; scope: ManagedConfigScope; } diff --git a/src/install/deps.ts b/src/install/deps.ts index 92c9bf6..3544482 100644 --- a/src/install/deps.ts +++ b/src/install/deps.ts @@ -64,6 +64,23 @@ export interface InstallInput { readStdin(): Promise; } +export interface InstallHttpResponse { + ok: boolean; + status: number; + statusText: string; + text(): Promise; +} + +export interface InstallHttp { + fetch( + url: string, + options?: { + headers?: Record; + method?: string; + }, + ): Promise; +} + export interface InstallPrompts { readSecret(message: string): Promise; selectOption( @@ -95,6 +112,7 @@ export interface InstallDependencies { clock: InstallClock; commands: InstallCommandRunner; fs: InstallFs; + http: InstallHttp; input: InstallInput; prompts: InstallPrompts; runtime: InstallRuntimeEnvironment; @@ -117,6 +135,7 @@ export interface CreateNodeInstallDependenciesOverrides { clock?: Partial; commands?: Partial; fs?: Partial; + http?: Partial; input?: Partial; prompts?: Partial; runtime?: InstallRuntimeOverrides; @@ -484,6 +503,16 @@ async function readStdin(): Promise { return contents; } +async function fetchHttp( + url: string, + options?: { + headers?: Record; + method?: string; + }, +): Promise { + return await fetch(url, options); +} + async function readSecret(message: string): Promise { return await password({ mask: "*", message }); } @@ -524,6 +553,10 @@ export function createNodeInstallDependencies( readStdin, }; + const defaultHttp: InstallHttp = { + fetch: fetchHttp, + }; + const defaultPrompts: InstallPrompts = { readSecret, selectOption, @@ -547,6 +580,7 @@ export function createNodeInstallDependencies( clock: { ...defaultClock, ...overrides.clock }, commands: { ...defaultCommands, ...overrides.commands }, fs: { ...defaultFs, ...overrides.fs }, + http: { ...defaultHttp, ...overrides.http }, input: { ...defaultInput, ...overrides.input }, prompts: { ...defaultPrompts, ...overrides.prompts }, runtime: { ...defaultRuntime, ...overrides.runtime }, diff --git a/src/install/effective-config-policy.ts b/src/install/effective-config-policy.ts index c4e8381..6aa3f21 100644 --- a/src/install/effective-config-policy.ts +++ b/src/install/effective-config-policy.ts @@ -1,7 +1,7 @@ import { formatOpencodeModelRef, - getValidatedModelKeys, type CuratedModelKey, + type ValidatedCuratedModel, } from "../constants/models.js"; import { GONKAGATE_PROVIDER_ID } from "../constants/gateway.js"; import type { EffectiveConfigVerificationTarget } from "./contracts/effective-config.js"; @@ -9,8 +9,8 @@ import type { EffectiveConfigValueCheck } from "./verification-mismatches.js"; import { buildManagedProviderCatalogConfig, GONKAGATE_SECRET_FILE_REFERENCE, - resolveValidatedModel, } from "./managed-provider-config.js"; +import { createInstallError } from "./errors.js"; interface VerificationSummaryCheck { expected: Record; @@ -54,7 +54,7 @@ export interface SecretBindingVerificationPolicy { } const PROVIDER_MISMATCH_REASON = - "Resolved GonkaGate provider settings do not match the curated validated contract."; + "Resolved GonkaGate provider settings do not match the fetched model catalog."; const PROVIDER_REASON_RULES = Object.freeze([ { @@ -76,53 +76,56 @@ const PROVIDER_REASON_RULES = Object.freeze([ function createEffectiveConfigVerificationTarget( modelKey: CuratedModelKey, + models: readonly ValidatedCuratedModel[], ): EffectiveConfigVerificationTarget { - const model = resolveValidatedModel(modelKey); + const model = models.find((candidate) => candidate.key === modelKey); + + if (model === undefined) { + throw createInstallError("unsupported_model_key", { + modelKey, + }); + } return { modelKey, modelRef: formatOpencodeModelRef(modelKey), providerId: GONKAGATE_PROVIDER_ID, - runtimeCompatibility: model.runtimeCompatibility, transport: model.transport, }; } export function createResolvedConfigVerificationPolicy( modelKey: CuratedModelKey, + models: readonly ValidatedCuratedModel[], ): ResolvedConfigVerificationPolicy { - const target = createEffectiveConfigVerificationTarget(modelKey); - const expectedProviderConfig = buildManagedProviderCatalogConfig(); + const target = createEffectiveConfigVerificationTarget(modelKey, models); + const expectedProviderConfig = buildManagedProviderCatalogConfig(models); const providerPath = ["provider", target.providerId] as const; - const requiredModelEntries = getValidatedModelKeys().map( - (catalogModelKey) => { - const modelEntryPath = [ - ...providerPath, - "models", - catalogModelKey, - ] as const; - const expectedModelEntry = expectedProviderConfig.models[catalogModelKey]; - - if (expectedModelEntry === undefined) { - throw new Error( - `Managed GonkaGate provider catalog is missing ${catalogModelKey}.`, - ); - } - - return { - object: { - expected: expectedModelEntry, - path: modelEntryPath, - reason: - "Resolved config is missing a curated GonkaGate model catalog entry.", - }, - valueChecks: createObjectValueChecks( - modelEntryPath, - expectedModelEntry, - ), - }; - }, - ); + const requiredModelEntries = models.map((catalogModel) => { + const catalogModelKey = catalogModel.key; + const modelEntryPath = [ + ...providerPath, + "models", + catalogModelKey, + ] as const; + const expectedModelEntry = expectedProviderConfig.models[catalogModelKey]; + + if (expectedModelEntry === undefined) { + throw new Error( + `Managed GonkaGate provider catalog is missing ${catalogModelKey}.`, + ); + } + + return { + object: { + expected: expectedModelEntry, + path: modelEntryPath, + reason: + "Resolved config is missing a fetched GonkaGate model catalog entry.", + }, + valueChecks: createObjectValueChecks(modelEntryPath, expectedModelEntry), + }; + }); const modelEntryPaths = requiredModelEntries.map( (entry) => entry.object.path, ); @@ -264,7 +267,7 @@ function resolveProviderMismatchReason(path: readonly string[]): string { const formattedPath = path.join("."); if (formattedPath.startsWith(`provider.${GONKAGATE_PROVIDER_ID}.models.`)) { - return "Resolved GonkaGate model catalog settings do not match the curated validated contract."; + return "Resolved GonkaGate model catalog settings do not match the fetched model catalog."; } for (const rule of PROVIDER_REASON_RULES) { diff --git a/src/install/errors.ts b/src/install/errors.ts index 8875fa9..270e879 100644 --- a/src/install/errors.ts +++ b/src/install/errors.ts @@ -46,6 +46,20 @@ export interface InstallErrorDetailsByCode { path: string; target: ManagedConfigTarget; }; + model_catalog_fetch_failed: + | { + cause: unknown; + url: string; + } + | { + status: number; + statusText: string; + url: string; + }; + model_catalog_invalid: { + reason: string; + url: string; + }; managed_secret_backup_failed: { cause: unknown; source: AllowedSecretInput; @@ -148,6 +162,12 @@ const INSTALL_ERROR_MESSAGE_FACTORIES: { `Managed config plan for ${details.scope} scope is missing the required ${formatManagedConfigTarget(details.missingTarget)} step.`, managed_config_write_failed: (details) => `Failed to write the ${formatManagedConfigTarget(details.target)} at ${details.path}.`, + model_catalog_fetch_failed: (details) => + "status" in details + ? `Could not fetch the GonkaGate model catalog from ${details.url} (HTTP ${details.status}).` + : `Could not fetch the GonkaGate model catalog from ${details.url}.`, + model_catalog_invalid: (details) => + `GonkaGate returned an invalid model catalog from ${details.url} (${details.reason}).`, managed_secret_backup_failed: (details) => formatManagedArtifactFailureMessage({ operation: "backup", @@ -204,12 +224,12 @@ const INSTALL_ERROR_MESSAGE_FACTORIES: { : "Non-interactive setup requires --scope or --yes so the installer can confirm the activation scope safely.", model_selection_required: (details) => details.validatedModelCount <= 1 - ? "A validated GonkaGate model could not be selected automatically. Pass --model or rerun interactively." - : "Multiple validated GonkaGate models are available. Pass --model, use --yes for the recommended default, or rerun interactively.", + ? "A GonkaGate model could not be selected automatically. Pass --model or rerun interactively." + : "Multiple GonkaGate models are available. Pass --model, use --yes for the default, or rerun interactively.", unsupported_model_key: (details) => - `The requested GonkaGate model key "${details.modelKey}" is not in the current validated public picker.`, + `The requested GonkaGate model "${details.modelKey}" was not returned by /v1/models.`, validated_models_unavailable: () => - "No validated GonkaGate models are currently available for setup.", + "No GonkaGate models are currently available for setup.", secret_prompt_unavailable: () => "A hidden GonkaGate API key prompt requires an interactive terminal. Use GONKAGATE_API_KEY or --api-key-stdin for non-interactive setup.", secret_source_unavailable: (details) => diff --git a/src/install/index.ts b/src/install/index.ts index 95199e1..5d41b2a 100644 --- a/src/install/index.ts +++ b/src/install/index.ts @@ -6,10 +6,11 @@ import type { ManagedConfigScope } from "./contracts/managed-config.js"; import type { InstallFlowResult } from "./contracts/install-flow.js"; import type { InstallDependencies } from "./deps.js"; import { isInstallError, isInstallErrorCode } from "./errors.js"; +import { fetchGonkagateModels } from "./model-catalog.js"; import { redactSecretBearingText } from "./redact.js"; import { rollbackManagedWrites } from "./rollback.js"; import { resolveInstallModel, resolveInstallScope } from "./selection.js"; -import { resolveSecretInput } from "./secrets.js"; +import { resolveSecretInput, type ResolvedSecretInput } from "./secrets.js"; import { createInstallProgressStateForModel, createSuccessfulInstallResult, @@ -37,21 +38,34 @@ export interface InstallFlowRequest { yes: boolean; } +interface PreparedInstallFlow { + installFlow: PreparedInstallSession; + secretInput: ResolvedSecretInput; +} + export async function runInstallFlow( request: InstallFlowRequest, dependencies: InstallDependencies, ): Promise { let progressState: InstallProgressState = {}; const managedWrites = createManagedWriteTransaction(); - let installFlow: PreparedInstallSession; + let preparedInstall: PreparedInstallFlow; try { const context = await resolveInstallContext(dependencies, { cwd: request.cwd, }); + const secretInput = await resolveSecretInput( + { + apiKeyStdin: request.apiKeyStdin, + }, + dependencies, + ); + const models = await fetchGonkagateModels(secretInput.secret, dependencies); const model = await resolveInstallModel( { modelKey: request.modelKey, + models, yes: request.yes, }, dependencies, @@ -67,7 +81,11 @@ export async function runInstallFlow( }, dependencies, ); - installFlow = prepareInstallSession(context, model, scope); + const installFlow = prepareInstallSession(context, model, models, scope); + preparedInstall = { + installFlow, + secretInput, + }; progressState = installFlow.summary; } catch (error) { @@ -75,9 +93,18 @@ export async function runInstallFlow( } try { - await applyManagedWrites(request, installFlow, managedWrites, dependencies); - await verifyPreparedInstall(installFlow, dependencies); - await persistInstallState(installFlow, managedWrites, dependencies); + await applyManagedWrites( + preparedInstall.secretInput, + preparedInstall.installFlow, + managedWrites, + dependencies, + ); + await verifyPreparedInstall(preparedInstall.installFlow, dependencies); + await persistInstallState( + preparedInstall.installFlow, + managedWrites, + dependencies, + ); } catch (error) { return await buildInstallFailureResult( error, @@ -88,16 +115,19 @@ export async function runInstallFlow( } const currentSessionResult = await verifyCurrentSessionInstall( - installFlow, + preparedInstall.installFlow, progressState, dependencies, ); - return currentSessionResult ?? createSuccessfulInstallResult(installFlow); + return ( + currentSessionResult ?? + createSuccessfulInstallResult(preparedInstall.installFlow) + ); } async function applyManagedWrites( - request: InstallFlowRequest, + secretInput: ResolvedSecretInput, installFlow: PreparedInstallSession, managedWrites: ManagedWriteTransaction, dependencies: InstallDependencies, @@ -108,7 +138,7 @@ async function applyManagedWrites( ); await writeManagedSecretForInstall( - request, + secretInput, installFlow, managedWrites, dependencies, @@ -122,17 +152,11 @@ async function applyManagedWrites( } async function writeManagedSecretForInstall( - request: InstallFlowRequest, + secretInput: ResolvedSecretInput, installFlow: PreparedInstallSession, managedWrites: ManagedWriteTransaction, dependencies: InstallDependencies, ): Promise { - const secretInput = await resolveSecretInput( - { - apiKeyStdin: request.apiKeyStdin, - }, - dependencies, - ); await managedWrites.run( writeManagedSecret( secretInput, @@ -153,6 +177,7 @@ async function writeManagedConfigsForInstall( { managedPaths: installFlow.context.workspace.managedPaths, model: installFlow.model.key, + models: installFlow.models, previousManagedModelKey, scope: installFlow.summary.scope, }, @@ -171,6 +196,7 @@ async function verifyPreparedInstall( { context: installFlow.context, model: installFlow.model.key, + models: installFlow.models, scope: installFlow.summary.scope, }, dependencies, @@ -191,6 +217,7 @@ async function verifyCurrentSessionInstall( { context: installFlow.context, model: installFlow.model.key, + models: installFlow.models, scope: installFlow.summary.scope, }, dependencies, diff --git a/src/install/managed-provider-config.ts b/src/install/managed-provider-config.ts index 5a2e7f7..aa078e6 100644 --- a/src/install/managed-provider-config.ts +++ b/src/install/managed-provider-config.ts @@ -1,16 +1,11 @@ import type { JsonObject } from "../json.js"; -import { - getCuratedModelByKey, - getValidatedModels, - type CuratedModelKey, - type CuratedModelProviderOverride, - type CuratedModelRecord, - type CuratedModelTransport, +import type { + CuratedModelTransport, + ValidatedCuratedModel, } from "../constants/models.js"; import { CURRENT_TRANSPORT, GONKAGATE_BASE_URL, - GONKAGATE_PROVIDER_ID, GONKAGATE_PROVIDER_NAME, } from "../constants/gateway.js"; @@ -18,22 +13,11 @@ export const GONKAGATE_SECRET_FILE_REFERENCE = "{file:~/.gonkagate/opencode/api-key}"; const OPENAI_COMPATIBLE_ADAPTER = "@ai-sdk/openai-compatible"; -export type ManagedProviderSourceModel = CuratedModelRecord & { - validationStatus: "validated"; -}; - -type ManagedProviderLimit = JsonObject & { - context: number; - output: number; -}; +export type ManagedProviderSourceModel = ValidatedCuratedModel; type ManagedProviderModelConfig = JsonObject & { - headers?: Record; id: string; - limit?: ManagedProviderLimit; name: string; - options?: JsonObject; - provider?: CuratedModelProviderOverride; }; type ManagedProviderModelMap = JsonObject & @@ -47,29 +31,13 @@ export type ManagedProviderConfig = JsonObject & { options: JsonObject; }; -export function resolveValidatedModel( - modelKey: CuratedModelKey, -): ManagedProviderSourceModel { - const model = getCuratedModelByKey(modelKey); - - if (model === undefined || model.validationStatus !== "validated") { - throw new Error(`Unknown validated GonkaGate model key: ${modelKey}`); - } - - return model; -} - export function buildManagedProviderConfig( model: ManagedProviderSourceModel, ): ManagedProviderConfig { - return buildManagedProviderConfigForModels([model]); -} - -export function buildManagedProviderCatalogConfig(): ManagedProviderConfig { - return buildManagedProviderConfigForModels(getValidatedModels()); + return buildManagedProviderCatalogConfig([model]); } -function buildManagedProviderConfigForModels( +export function buildManagedProviderCatalogConfig( models: readonly ManagedProviderSourceModel[], ): ManagedProviderConfig { if (models.length === 0) { @@ -81,8 +49,10 @@ function buildManagedProviderConfigForModels( const providerModels: ManagedProviderModelMap = {}; for (const model of models) { - assertCanonicalModelContract(model); - providerModels[model.key] = buildManagedProviderModelConfig(model); + providerModels[model.key] = { + id: model.modelId, + name: model.displayName, + }; } return { @@ -90,133 +60,9 @@ function buildManagedProviderConfigForModels( models: providerModels, name: GONKAGATE_PROVIDER_NAME, npm: OPENAI_COMPATIBLE_ADAPTER, - options: buildManagedProviderOptions(models), - }; -} - -function buildManagedProviderOptions( - models: readonly ManagedProviderSourceModel[], -): JsonObject { - const providerOptions: JsonObject = { - apiKey: GONKAGATE_SECRET_FILE_REFERENCE, - baseURL: GONKAGATE_BASE_URL, - }; - - for (const model of models) { - mergeManagedProviderOptions(providerOptions, model); - } - - return providerOptions; -} - -function mergeManagedProviderOptions( - providerOptions: JsonObject, - model: ManagedProviderSourceModel, -): void { - for (const [key, value] of Object.entries( - model.runtimeCompatibility?.providerOptions ?? {}, - )) { - if (key === "apiKey" && value !== GONKAGATE_SECRET_FILE_REFERENCE) { - throw new Error( - `Validated model compatibility for ${GONKAGATE_PROVIDER_ID} must not override options.apiKey.`, - ); - } - - if (key === "baseURL" && value !== GONKAGATE_BASE_URL) { - throw new Error( - `Validated model compatibility for ${GONKAGATE_PROVIDER_ID} must not override options.baseURL.`, - ); - } - - if (key === "apiKey" || key === "baseURL") { - continue; - } - - if ( - Object.hasOwn(providerOptions, key) && - !areJsonValuesEqual(providerOptions[key], value) - ) { - throw new Error( - `Validated model compatibility for ${GONKAGATE_PROVIDER_ID} must not define conflicting provider option "${key}" across the managed catalog.`, - ); - } - - providerOptions[key] = value; - } -} - -function areJsonValuesEqual(left: unknown, right: unknown): boolean { - return JSON.stringify(left) === JSON.stringify(right); -} - -function buildManagedProviderModelConfig( - model: ManagedProviderSourceModel, -): ManagedProviderModelConfig { - const managedModel: ManagedProviderModelConfig = { - id: model.modelId, - name: model.displayName, + options: { + apiKey: GONKAGATE_SECRET_FILE_REFERENCE, + baseURL: GONKAGATE_BASE_URL, + }, }; - const compatibility = model.runtimeCompatibility; - - if (model.limits !== undefined) { - if ( - model.limits.context === undefined || - model.limits.output === undefined - ) { - throw new Error( - `Validated model ${model.key} must define both limit.context and limit.output together.`, - ); - } - - managedModel.limit = { - context: model.limits.context, - output: model.limits.output, - }; - } - - if (compatibility?.modelOptions !== undefined) { - managedModel.options = { ...compatibility.modelOptions }; - } - - if (compatibility?.modelHeaders !== undefined) { - managedModel.headers = { ...compatibility.modelHeaders }; - } - - if (compatibility?.modelProvider !== undefined) { - assertModelProviderOverride(model, compatibility.modelProvider); - managedModel.provider = { ...compatibility.modelProvider }; - } - - return managedModel; -} - -function assertCanonicalModelContract(model: ManagedProviderSourceModel): void { - if (model.adapterPackage !== OPENAI_COMPATIBLE_ADAPTER) { - throw new Error( - `Validated model ${model.key} must use ${OPENAI_COMPATIBLE_ADAPTER} during Phase 3.`, - ); - } - - if (model.transport !== CURRENT_TRANSPORT) { - throw new Error( - `Validated model ${model.key} must use ${CURRENT_TRANSPORT} during Phase 3.`, - ); - } -} - -function assertModelProviderOverride( - model: ManagedProviderSourceModel, - override: Readonly, -): void { - if (override.npm !== undefined && override.npm !== model.adapterPackage) { - throw new Error( - `Validated model ${model.key} must not override provider.npm during Phase 3.`, - ); - } - - if (override.api !== undefined && override.api !== model.transport) { - throw new Error( - `Validated model ${model.key} must not override provider.api during Phase 3.`, - ); - } } diff --git a/src/install/model-catalog.ts b/src/install/model-catalog.ts new file mode 100644 index 0000000..42dafed --- /dev/null +++ b/src/install/model-catalog.ts @@ -0,0 +1,119 @@ +import { GONKAGATE_MODELS_URL } from "../constants/gateway.js"; +import { + createGonkagateModel, + type ValidatedCuratedModel, +} from "../constants/models.js"; +import type { InstallDependencies } from "./deps.js"; +import { createInstallError } from "./errors.js"; + +export async function fetchGonkagateModels( + apiKey: string, + dependencies: Pick, +): Promise { + let response; + + try { + response = await dependencies.http.fetch(GONKAGATE_MODELS_URL, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${apiKey}`, + }, + method: "GET", + }); + } catch (cause) { + throw createInstallError("model_catalog_fetch_failed", { + cause, + url: GONKAGATE_MODELS_URL, + }); + } + + if (!response.ok) { + throw createInstallError("model_catalog_fetch_failed", { + status: response.status, + statusText: response.statusText, + url: GONKAGATE_MODELS_URL, + }); + } + + return parseGonkagateModelsResponse( + await response.text(), + GONKAGATE_MODELS_URL, + ); +} + +export function parseGonkagateModelsResponse( + body: string, + url = GONKAGATE_MODELS_URL, +): readonly ValidatedCuratedModel[] { + let parsedBody: unknown; + + try { + parsedBody = JSON.parse(body) as unknown; + } catch { + throw createInstallError("model_catalog_invalid", { + reason: "response body is not valid JSON", + url, + }); + } + + if (!isObjectRecord(parsedBody) || !Array.isArray(parsedBody.data)) { + throw createInstallError("model_catalog_invalid", { + reason: "expected an object with a data array", + url, + }); + } + + const models = new Map(); + + for (const entry of parsedBody.data) { + const model = parseModelEntry(entry, url); + + if (!models.has(model.key)) { + models.set(model.key, model); + } + } + + if (models.size === 0) { + throw createInstallError("model_catalog_invalid", { + reason: "data must include at least one model id", + url, + }); + } + + return Object.freeze([...models.values()]); +} + +function parseModelEntry(entry: unknown, url: string): ValidatedCuratedModel { + if (!isObjectRecord(entry)) { + throw createInstallError("model_catalog_invalid", { + reason: "each data entry must be an object", + url, + }); + } + + const rawId = entry.id; + const rawName = entry.name; + + if (typeof rawId !== "string" || rawId.trim().length === 0) { + throw createInstallError("model_catalog_invalid", { + reason: "each data entry must include a non-empty string id", + url, + }); + } + + if (rawName !== undefined && typeof rawName !== "string") { + throw createInstallError("model_catalog_invalid", { + reason: "model name must be a string when present", + url, + }); + } + + const id = rawId.trim(); + const name = rawName?.trim(); + + return createGonkagateModel(id, name === "" ? undefined : name); +} + +function isObjectRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/install/scope.ts b/src/install/scope.ts index f18c39c..83e1d94 100644 --- a/src/install/scope.ts +++ b/src/install/scope.ts @@ -11,10 +11,7 @@ import { } from "./contracts/managed-config.js"; import type { InstallDependencies } from "./deps.js"; import { createInstallError } from "./errors.js"; -import { - buildManagedProviderCatalogConfig, - resolveValidatedModel, -} from "./managed-provider-config.js"; +import { buildManagedProviderCatalogConfig } from "./managed-provider-config.js"; import { writeManagedConfigTarget } from "./write-target-config.js"; export async function writeScopeManagedConfigs( @@ -37,13 +34,21 @@ export async function writeScopeManagedConfigs( function createManagedConfigWriteContext( request: ScopeWriteRequest, ): ManagedConfigWriteContext { - const model = resolveValidatedModel(request.model); + const model = request.models.find( + (candidate) => candidate.key === request.model, + ); + + if (model === undefined) { + throw createInstallError("unsupported_model_key", { + modelKey: request.model, + }); + } return { activationModelRef: formatOpencodeModelRef(model), managedPaths: request.managedPaths, ownedActivationModelRefs: createOwnedActivationModelRefs(request), - providerConfig: buildManagedProviderCatalogConfig(), + providerConfig: buildManagedProviderCatalogConfig(request.models), }; } diff --git a/src/install/selection.ts b/src/install/selection.ts index a01ef99..3f96a88 100644 --- a/src/install/selection.ts +++ b/src/install/selection.ts @@ -1,9 +1,5 @@ import { formatOpencodeModelRef, - getCuratedModelByKey, - getRecommendedValidatedModel, - getValidatedModels, - type CuratedModelKey, type ValidatedCuratedModel, } from "../constants/models.js"; import type { InstallDependencies, InstallSelectChoice } from "./deps.js"; @@ -12,6 +8,7 @@ import type { ManagedConfigScope } from "./contracts/managed-config.js"; export interface ModelSelectionRequest { modelKey?: string; + models: readonly ValidatedCuratedModel[]; yes: boolean; } @@ -37,33 +34,23 @@ export async function resolveInstallModel( request: ModelSelectionRequest, dependencies: InstallDependencies, ): Promise { - const validatedModels = getValidatedModels(); + const validatedModels = request.models; if (validatedModels.length === 0) { throw createInstallError("validated_models_unavailable", {}); } if (request.modelKey !== undefined) { - return requireValidatedModel(request.modelKey); + return requireFetchedModel(request.modelKey, validatedModels); } const isInteractive = canUseInteractiveInstallPrompts(dependencies); - const recommendedModel = getRecommendedValidatedModel(); const singleValidatedModel = validatedModels.length === 1 ? validatedModels[0] : undefined; const defaultPromptModel = - recommendedModel ?? - singleValidatedModel ?? - validatedModels[0] ?? - raiseValidatedModelsUnavailable(); + validatedModels[0] ?? raiseValidatedModelsUnavailable(); if (request.yes) { - if (singleValidatedModel !== undefined) { - return singleValidatedModel; - } - - if (recommendedModel !== undefined) { - return recommendedModel; - } + return defaultPromptModel; } if (!isInteractive) { @@ -85,7 +72,7 @@ export async function resolveInstallModel( pageSize: Math.min(8, validatedModels.length), }); - return requireValidatedModel(selectedModelKey); + return requireFetchedModel(selectedModelKey, validatedModels); } export async function resolveInstallScope( @@ -121,12 +108,12 @@ export async function resolveInstallScope( function createModelChoice( model: ValidatedCuratedModel, defaultModel: ValidatedCuratedModel, -): InstallSelectChoice { +): InstallSelectChoice { return { - description: `${formatOpencodeModelRef(model)} · validated`, + description: formatOpencodeModelRef(model), label: model.key === defaultModel.key - ? `${model.displayName} (Recommended)` + ? `${model.displayName} (Default)` : model.displayName, value: model.key, }; @@ -159,10 +146,13 @@ function createScopeChoices( ]; } -function requireValidatedModel(modelKey: string): ValidatedCuratedModel { - const model = getCuratedModelByKey(modelKey); +function requireFetchedModel( + modelKey: string, + models: readonly ValidatedCuratedModel[], +): ValidatedCuratedModel { + const model = models.find((candidate) => candidate.key === modelKey); - if (model === undefined || model.validationStatus !== "validated") { + if (model === undefined) { throw createInstallError("unsupported_model_key", { modelKey, }); diff --git a/src/install/session.ts b/src/install/session.ts index 8b5ea14..dbd3b40 100644 --- a/src/install/session.ts +++ b/src/install/session.ts @@ -18,6 +18,7 @@ export type InstallProgressState = InstallFlowProgress; export interface PreparedInstallSession { context: ResolvedInstallContext; model: ValidatedCuratedModel; + models: readonly ValidatedCuratedModel[]; summary: InstallFlowSelectionSummary; } @@ -34,11 +35,13 @@ export function createInstallProgressStateForModel( export function prepareInstallSession( context: ResolvedInstallContext, model: ValidatedCuratedModel, + models: readonly ValidatedCuratedModel[], scope: ManagedConfigScope, ): PreparedInstallSession { return { context, model, + models, summary: { ...createInstallProgressStateForModel(model), scope, diff --git a/src/install/verify-effective.ts b/src/install/verify-effective.ts index 5ab314c..6bf8a93 100644 --- a/src/install/verify-effective.ts +++ b/src/install/verify-effective.ts @@ -1,4 +1,7 @@ -import type { CuratedModelKey } from "../constants/models.js"; +import type { + CuratedModelKey, + ValidatedCuratedModel, +} from "../constants/models.js"; import { isJsonObjectRecord } from "../json.js"; import type { EffectiveConfigVerificationBlocker, @@ -37,6 +40,7 @@ import { export interface EffectiveConfigVerificationRequest { context: Pick; model: CuratedModelKey; + models: readonly ValidatedCuratedModel[]; scope: ManagedConfigScope; } @@ -55,6 +59,7 @@ export async function verifyEffectiveConfig( ): Promise { const verificationPolicy = createResolvedConfigVerificationPolicy( request.model, + request.models, ); const { target } = verificationPolicy; const durableSecretBindingBlockers = @@ -115,11 +120,15 @@ export async function verifyCurrentSessionEffectiveConfig( const inlineLayer = readInlineVerificationLayer(dependencies.runtime.env); if (inlineLayer === undefined) { - return createEffectiveConfigVerificationSuccess(request.model); + return createEffectiveConfigVerificationSuccess( + request.model, + request.models, + ); } const verificationPolicy = createResolvedConfigVerificationPolicy( request.model, + request.models, ); const { target } = verificationPolicy; const inlineSecretBindingBlockers = collectSecretBindingProvenanceBlockers( @@ -238,12 +247,13 @@ function createDurableVerificationEnv( function createEffectiveConfigVerificationSuccess( model: CuratedModelKey, + models: readonly ValidatedCuratedModel[], ): EffectiveConfigVerificationSuccess { return { blockers: [], ok: true, resolvedMatch: true, - target: createResolvedConfigVerificationPolicy(model).target, + target: createResolvedConfigVerificationPolicy(model, models).target, }; } diff --git a/test/cli.test.ts b/test/cli.test.ts index 2c1515c..c5416b5 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -5,25 +5,25 @@ import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { pathToFileURL } from "node:url"; import test from "node:test"; -import { - formatOpencodeModelRef, - type CuratedModelKey, -} from "../src/constants/models.js"; import { CONTRACT_METADATA } from "../src/constants/contract.js"; import { GONKAGATE_BASE_URL } from "../src/constants/gateway.js"; import { parseCliOptions, renderCliEntrypointError, run } from "../src/cli.js"; import type { InstallSelectOptions } from "../src/install/deps.js"; -import { - buildManagedProviderCatalogConfig, - resolveValidatedModel, -} from "../src/install/managed-provider-config.js"; +import type { ValidatedCuratedModel } from "../src/constants/models.js"; import { escapeRegExp, repoRoot } from "./contract-helpers.js"; import { createInstallIntegrationHarness } from "./install/harness.js"; - -const MODEL_KEY = "qwen3-235b-a22b-instruct-2507-fp8" as const; -const KIMI_MODEL_KEY = "kimi-k2.6" as const; -const MINIMAX_MODEL_KEY = "minimax-m2.7" as const; -const RECOMMENDED_MODEL_KEY = KIMI_MODEL_KEY; +import { + createModelsHttp, + createResolvedConfigFixture, + EXTRA_LIVE_MODEL, + EXTRA_LIVE_MODEL_ID, + LIVE_MODELS, + LIVE_MODEL_ID, + SECOND_LIVE_MODEL_ID, +} from "./install/model-fixtures.js"; + +const MODEL_KEY = LIVE_MODEL_ID; +const SECOND_MODEL_KEY = SECOND_LIVE_MODEL_ID; type TestSelectOption = ( options: InstallSelectOptions, @@ -43,39 +43,13 @@ function createBufferWriter(): BufferWriter { }; } -function createResolvedConfigFixture( - options: - | { - modelKey?: CuratedModelKey; - mutate?: (config: Record) => void; - } - | ((config: Record) => void) = {}, -): string { - const modelKey = - typeof options === "function" ? RECOMMENDED_MODEL_KEY : options.modelKey; - const mutate = typeof options === "function" ? options : options.mutate; - const model = resolveValidatedModel(modelKey ?? RECOMMENDED_MODEL_KEY); - const providerConfig = buildManagedProviderCatalogConfig(); - const resolvedConfig = { - model: formatOpencodeModelRef(model), - provider: { - gonkagate: providerConfig, - }, - small_model: formatOpencodeModelRef(model), - } satisfies Record; - const nextConfig = structuredClone(resolvedConfig); - - mutate?.(nextConfig); - - return `${JSON.stringify(nextConfig, null, 2)}\n`; -} - async function createCliFixture( options: { debugConfigPureOutput?: string; debugConfigPureOutputWhenInlineConfigPresent?: string; env?: NodeJS.ProcessEnv; interactive?: boolean; + models?: readonly ValidatedCuratedModel[]; selectOption?: TestSelectOption; } = {}, ) { @@ -86,7 +60,8 @@ async function createCliFixture( await harness.installFakeOpenCodeOnPath({ debugConfigPureOutput: - options.debugConfigPureOutput ?? createResolvedConfigFixture(), + options.debugConfigPureOutput ?? + createResolvedConfigFixture({ models: options.models }), debugConfigPureOutputWhenInlineConfigPresent: options.debugConfigPureOutputWhenInlineConfigPresent, output: "opencode-ai 1.4.0", @@ -97,6 +72,7 @@ async function createCliFixture( return { dependencies: harness.createDependencies({ + http: createModelsHttp(options.models), prompts: options.selectOption === undefined ? undefined @@ -166,7 +142,7 @@ test("CLI wrapper exposes the shipped help surface", () => { assert.equal(helpResult.status, 0); assert.match(helpResult.stdout, /Usage: opencode-setup/i); assert.match(helpResult.stdout, /Configure OpenCode to use GonkaGate/i); - assert.match(helpResult.stdout, /validated-model-only/i); + assert.match(helpResult.stdout, /Model source/i); assert.match(helpResult.stdout, /--scope /); assert.match(helpResult.stdout, /--api-key-stdin/); assert.match(helpResult.stdout, /GONKAGATE_API_KEY/); @@ -211,7 +187,7 @@ test("CLI wrapper still runs when invoked through a symlinked bin path", (t) => assert.match(helpResult.stdout, /Configure OpenCode to use GonkaGate/i); }); -test("interactive runs show the public model picker with the validated models", async () => { +test("interactive runs show the public model picker with the fetched models", async () => { const promptMessages: string[] = []; const promptChoiceSnapshots: string[][] = []; const fixture = await createCliFixture({ @@ -238,9 +214,8 @@ test("interactive runs show the public model picker with the validated models", /Choose the GonkaGate model to configure for OpenCode/i, ); assert.deepEqual(promptChoiceSnapshots[0], [ - "Qwen3 235B A22B Instruct 2507 FP8", - "Kimi K2.6 (Recommended)", - "MiniMax M2.7", + "Dynamic Alpha (Default)", + "Dynamic Beta", ]); assert.match( promptMessages[1] ?? "", @@ -251,7 +226,49 @@ test("interactive runs show the public model picker with the validated models", } }); -test("--yes auto-selects the recommended model and scope without prompting", async () => { +test("installer writes every fetched model id into provider.gonkagate.models", async () => { + const models = [...LIVE_MODELS, EXTRA_LIVE_MODEL]; + const fixture = await createCliFixture({ + debugConfigPureOutput: createResolvedConfigFixture({ models }), + models, + }); + + try { + const result = await run(["--json", "--yes"], { + dependencies: fixture.dependencies, + stderr: fixture.stderr, + stdout: fixture.stdout, + }); + + const userConfig = JSON.parse( + await fixture.dependencies.fs.readFile( + resolve( + fixture.harness.homeDir, + ".config", + "opencode", + "opencode.json", + ), + "utf8", + ), + ) as { + provider?: { + gonkagate?: { + models?: Record; + }; + }; + }; + + assert.equal(result.exitCode, 0); + assert.ok( + userConfig.provider?.gonkagate?.models?.[EXTRA_LIVE_MODEL_ID] !== + undefined, + ); + } finally { + await fixture.harness.cleanup(); + } +}); + +test("--yes auto-selects the default model and scope without prompting", async () => { let selectCallCount = 0; const fixture = await createCliFixture({ interactive: true, @@ -274,14 +291,14 @@ test("--yes auto-selects the recommended model and scope without prompting", asy assert.match(fixture.stdout.contents, /"scope": "project"/); assert.match( fixture.stdout.contents, - new RegExp(escapeRegExp('"modelRef": "gonkagate/kimi-k2.6"')), + new RegExp(escapeRegExp(`"modelRef": "gonkagate/${MODEL_KEY}"`)), ); } finally { await fixture.harness.cleanup(); } }); -test("non-interactive runs require model selection when multiple validated models are available", async () => { +test("non-interactive runs require model selection when multiple models are available", async () => { const fixture = await createCliFixture(); try { @@ -323,15 +340,15 @@ test("non-interactive runs still require scope after an explicit model selection } }); -test("CLI accepts Kimi K2.6 as an explicit curated model selection", async () => { +test("CLI accepts an explicit fetched model id", async () => { const fixture = await createCliFixture({ debugConfigPureOutput: createResolvedConfigFixture({ - modelKey: KIMI_MODEL_KEY, + modelKey: SECOND_MODEL_KEY, }), }); try { - const result = await run(["--json", "--yes", "--model", KIMI_MODEL_KEY], { + const result = await run(["--json", "--yes", "--model", SECOND_MODEL_KEY], { dependencies: fixture.dependencies, stderr: fixture.stderr, stdout: fixture.stdout, @@ -341,35 +358,7 @@ test("CLI accepts Kimi K2.6 as an explicit curated model selection", async () => assert.match(fixture.stdout.contents, /"status": "success"/); assert.match( fixture.stdout.contents, - new RegExp(escapeRegExp('"modelRef": "gonkagate/kimi-k2.6"')), - ); - } finally { - await fixture.harness.cleanup(); - } -}); - -test("CLI accepts MiniMax M2.7 as an explicit curated model selection", async () => { - const fixture = await createCliFixture({ - debugConfigPureOutput: createResolvedConfigFixture({ - modelKey: MINIMAX_MODEL_KEY, - }), - }); - - try { - const result = await run( - ["--json", "--yes", "--model", MINIMAX_MODEL_KEY], - { - dependencies: fixture.dependencies, - stderr: fixture.stderr, - stdout: fixture.stdout, - }, - ); - - assert.equal(result.exitCode, 0); - assert.match(fixture.stdout.contents, /"status": "success"/); - assert.match( - fixture.stdout.contents, - new RegExp(escapeRegExp('"modelRef": "gonkagate/minimax-m2.7"')), + new RegExp(escapeRegExp(`"modelRef": "gonkagate/${SECOND_MODEL_KEY}"`)), ); } finally { await fixture.harness.cleanup(); diff --git a/test/docs-contract.test.ts b/test/docs-contract.test.ts index 2961327..f51b00a 100644 --- a/test/docs-contract.test.ts +++ b/test/docs-contract.test.ts @@ -2,9 +2,9 @@ import assert from "node:assert/strict"; import test from "node:test"; import { CONTRACT_METADATA } from "../src/constants/contract.js"; import { - CURRENT_TRANSPORT, FUTURE_TRANSPORT, GONKAGATE_BASE_URL, + GONKAGATE_MODELS_URL, GONKAGATE_PROVIDER_ID, } from "../src/constants/gateway.js"; import { @@ -13,289 +13,95 @@ import { readText, } from "./contract-helpers.js"; -test("README captures the shipped runtime truth and current opencode contract", () => { +const OLD_MODEL_ID_PATTERNS = [ + /qwen\/qwen3-235b-a22b-instruct-2507-fp8/i, + /moonshotai\/Kimi-K2\.6/i, + /minimaxai\/minimax-m2\.7/i, +] as const; + +function assertNoHardcodedPublicCatalog(text: string): void { + for (const pattern of OLD_MODEL_ID_PATTERNS) { + assert.doesNotMatch(text, pattern); + } + + assert.doesNotMatch(text, /curated model registry/i); + assert.doesNotMatch(text, /public curated model picker/i); +} + +test("README captures shipped live model catalog contract", () => { const readme = readText("README.md"); assertMatchesAll(readme, [ new RegExp(escapeRegExp(CONTRACT_METADATA.publicEntrypoint)), new RegExp(escapeRegExp(GONKAGATE_BASE_URL)), - /~\/\.config\/opencode\/opencode\.json/, - /OPENCODE_CONFIG.*override layer/i, - /OPENCODE_CONFIG_CONTENT/, - /enabled_providers|disabled_providers/, - /opencode\.json/, - new RegExp(escapeRegExp(`provider.${GONKAGATE_PROVIDER_ID}`)), - new RegExp(escapeRegExp(CONTRACT_METADATA.verifiedOpencode.minVersion)), - /chat\/completions/, - /public curated model picker/i, - /qwen\/qwen3-235b-a22b-instruct-2507-fp8/, - /moonshotai\/Kimi-K2\.6/, - /minimaxai\/minimax-m2\.7/, - /rerunning the installer is the official safe update path/i, - /lastDurableSetupAt/, - /durably verified setup/i, - /compatibility metadata|provider options|model options|headers/i, + new RegExp(escapeRegExp(GONKAGATE_MODELS_URL)), + /Bearer auth/i, + /live GonkaGate model picker/i, + /--model.*fetched model id|fetched model id.*--model/i, + /provider\.gonkagate\.models/i, /opencode debug config\s+--pure/i, /provider\.gonkagate\.options\.apiKey/i, /\{file:~\/\.gonkagate\/opencode\/api-key\}/i, - /locally\s+inspectable/i, - /inferred\s+higher-precedence|inferred[\s\S]*managed blocker/i, - /repair.*owner-only|reruns?.*repair.*secret/i, - /user config[\s\S]*OPENCODE_CONFIG[\s\S]*project config[\s\S]*system managed/i, - /redacted.*resolved-config|resolved-config.*raw/i, - /durable plain-`opencode` outcome|current session's effective OpenCode outcome/i, - /interactive mode keeps the public curated model picker visible/i, - /provider\.gonkagate\.models[\s\S]*\/models|\/models[\s\S]*provider\.gonkagate\.models/i, - /fallback entrypoint error handling/i, - /native Windows/i, - /Windows CI|native Windows CI|integration coverage/i, - /WSL/i, - /--api-key-stdin/, - /GONKAGATE_API_KEY/, - /OpenCode Desktop[\s\S]*restart[\s\S]*managed secret file/i, - /~\/\.gonkagate\/opencode\/backups\/project-config/, - ]); - - assert.doesNotMatch(readme, /not implemented yet/i); - assert.doesNotMatch( - readme, - /v1 support target is macOS, Linux, and WSL-based OpenCode usage on Windows/i, - ); -}); - -test("AGENTS captures the repository contract anchors for the shipped runtime", () => { - const agents = readText("AGENTS.md"); - - assertMatchesAll(agents, [ - new RegExp(escapeRegExp(CONTRACT_METADATA.packageName)), - /src\/cli\.ts/, - /docs\/specs\/opencode-setup-prd\/spec\.md/, - /~\/\.config\/opencode\/opencode\.json/, - /OPENCODE_CONFIG.*override layer/i, /OPENCODE_CONFIG_CONTENT/, /enabled_providers|disabled_providers/, - /opencode\.json/, - new RegExp(escapeRegExp(`provider.${GONKAGATE_PROVIDER_ID}`)), - /compatibility metadata|required for validated OpenCode flows/i, - /end-to-end public installer flow is implemented/i, - /public curated model picker is shipped/i, - /moonshotai\/Kimi-K2\.6/, - /minimaxai\/minimax-m2\.7/, - /effective OpenCode config/i, - /current session's runtime-resolved outcome/i, /lastDurableSetupAt/, - /last durably verified setup/i, - /opencode debug config\s+--pure/i, - /provider\.gonkagate\.options\.apiKey/i, - /\{file:~\/\.gonkagate\/opencode\/api-key\}/i, - /locally\s+inspectable/i, - /user config[\s\S]*OPENCODE_CONFIG[\s\S]*project config[\s\S]*system managed/i, - /repair drifted.*secret|without rewriting the secret/i, - /inferred\s+higher-precedence|inferred[\s\S]*managed blocker/i, - /interactive mode keeps the public curated model picker visible/i, - /provider\.gonkagate\.models[\s\S]*\/models|\/models[\s\S]*provider\.gonkagate\.models/i, - /raw resolved config/i, - /native Windows/i, - /native Windows CI|Windows runners|runner-backed proof/i, + /native Windows|run directly on Windows/i, /WSL/i, - /--api-key-stdin/, - /GONKAGATE_API_KEY/, - /no plain CLI flag/i, - /~\/\.gonkagate\/opencode\/backups\/project-config/, ]); - assert.doesNotMatch(agents, /should not be claimed until validated/i); + assertNoHardcodedPublicCatalog(readme); }); -test("implementation docs capture the shipped setup architecture and boundaries", () => { - const howItWorks = readText("docs/how-it-works.md"); - const troubleshooting = readText("docs/troubleshooting.md"); - const prd = readText("docs/specs/opencode-setup-prd/spec.md"); - - assertMatchesAll(howItWorks, [ - new RegExp(escapeRegExp(CONTRACT_METADATA.publicEntrypoint)), - new RegExp(escapeRegExp(CONTRACT_METADATA.verifiedOpencode.minVersion)), - new RegExp(escapeRegExp(CURRENT_TRANSPORT)), - /OPENCODE_CONFIG.*higher-precedence/i, - /OPENCODE_CONFIG_CONTENT/, - /enabled_providers|disabled_providers/, - /opencode debug config\s+--pure/i, - /provider options|model options|headers|compatibility metadata/i, - /provider\.gonkagate\.models[\s\S]*\/models|\/models[\s\S]*provider\.gonkagate\.models/i, - /locally\s+inspectable/i, - /provider\.gonkagate\.options\.apiKey/i, - /\{file:~\/\.gonkagate\/opencode\/api-key\}/i, - /user config[\s\S]*OPENCODE_CONFIG[\s\S]*project[\s\S]*system managed/i, - /repair drifted.*owner-only|without rewriting unchanged secret/i, - /inferred\s+higher-precedence|inferred[\s\S]*managed blocker/i, - /must not print raw.*debug config|parsed internally.*redacted/i, - /durable verification|current-session verification/i, - /lastDurableSetupAt/, - /runtime is implemented and shipped/i, - /public curated model picker/i, - /moonshotai\/Kimi-K2\.6/, - /minimaxai\/minimax-m2\.7/, - /rollback/i, - /run directly on Windows|native Windows/i, - /Windows CI|native Windows CI|integration tests/i, - /WSL/i, - /inherited user-profile ACLs|does not attempt to rewrite Windows ACLs/i, - /project scope writes only activation settings/i, - /OpenCode Desktop[\s\S]*restart[\s\S]*~\/\.gonkagate\/opencode\/api-key/i, - /~\/\.gonkagate\/opencode\/backups\/project-config/, - ]); - - assert.doesNotMatch( - howItWorks, - /native Windows writes[\s\S]*repair drifted secret file and directory modes/i, - ); - - assertMatchesAll(troubleshooting, [ - /--api-key-stdin/, - /GONKAGATE_API_KEY/, +test("current docs describe /v1/models as source of truth", () => { + const docs = [ + readText("AGENTS.md"), + readText("docs/how-it-works.md"), + readText("docs/model-validation.md"), + readText("docs/troubleshooting.md"), + readText("docs/specs/opencode-setup-prd/spec.md"), + readText("docs/architecture-decisions.md"), + ].join("\n\n"); + + assertMatchesAll(docs, [ + new RegExp(escapeRegExp(GONKAGATE_MODELS_URL)), + /source of truth/i, + /first fetched model/i, + /--model[\s\S]*fetched model id|fetched model id[\s\S]*--model/i, + /provider\.gonkagate\.models/i, + /adding or removing|new or removed|adds or removes/i, /chat\/completions/, new RegExp(escapeRegExp(FUTURE_TRANSPORT)), - /public model picker only show curated options/i, - /moonshotai\/Kimi-K2\.6/, - /minimaxai\/minimax-m2\.7/, - /durable plain-`opencode` result/i, - /lastDurableSetupAt/, - /scope.*--yes/i, - /enabled_providers|disabled_providers/, - /locally\s+inspectable/i, - /inferred\s+higher-precedence|inferred[\s\S]*managed blocker/i, - /provider options|model options|headers|compatibility metadata/i, - /provider\.gonkagate\.models[\s\S]*\/models|\/models[\s\S]*provider\.gonkagate\.models/i, - /provider\.gonkagate\.options\.apiKey/i, - /OpenCode Desktop[\s\S]*Invalid credentials[\s\S]*--api-key-stdin/i, - /raw `opencode debug config` output/i, - ]); - - assertMatchesAll(prd, [ - new RegExp(escapeRegExp(CONTRACT_METADATA.packageName)), - new RegExp(escapeRegExp(GONKAGATE_BASE_URL)), - new RegExp(escapeRegExp(CONTRACT_METADATA.verifiedOpencode.minVersion)), - /small_model/, - /GONKAGATE_API_KEY/, - /--api-key-stdin/, - /OPENCODE_CONFIG.*not a replacement/i, - /OPENCODE_CONFIG_CONTENT/, - /enabled_providers/, - /disabled_providers/, - /effective OpenCode config/i, - /interactive mode keeps the public curated model picker visible/i, - /durable plain-`opencode` outcome/i, - /current-session blockers/i, - /locally\s+inspectable/i, - /user config[\s\S]*OPENCODE_CONFIG[\s\S]*project config[\s\S]*system managed/i, - /without rewriting the secret contents|without rewriting unchanged secret/i, - /inferred\s+higher-precedence|inferred[\s\S]*managed blocker/i, - /lastDurableSetupAt/, - /providers login/i, - /provider options|model options|model headers|compatibility metadata/i, - /provider\.gonkagate\.models[\s\S]*\/models|\/models[\s\S]*provider\.gonkagate\.models/i, - /redact secret-bearing fields|printing raw.*stdout or stderr/i, - /native Windows/i, - /native Windows CI|integration proof/i, - /WSL/i, - /project scope/i, - /chat_completions/, - new RegExp(escapeRegExp(FUTURE_TRANSPORT)), - /does not write directly to `auth\.json`/i, - /does not depend on `gonkagate doctor`/i, - /~\/\.gonkagate\/opencode\/backups\/project-config/, + new RegExp(escapeRegExp(`provider.${GONKAGATE_PROVIDER_ID}`)), ]); - - assert.doesNotMatch(prd, /opencode auth login/i); + assertNoHardcodedPublicCatalog(docs); }); -test("security docs capture the shipped secret-handling constraints", () => { +test("security docs preserve secret-handling and verification contracts", () => { const security = readText("docs/security.md"); assertMatchesAll(security, [ - /GONKAGATE_API_KEY/, /--api-key-stdin/, - /never accept `--api-key`/i, - /owner-only permissions/i, - /inherited ACLs|profile-directory ACL/i, - /~\/\.gonkagate\/opencode\/api-key/, - /OPENCODE_CONFIG.*not a replacement/i, - /OPENCODE_CONFIG_CONTENT/, - /resolved-config inspection output/i, + /GONKAGATE_API_KEY/, + /plain `--api-key`|--api-key/i, + /auth\.json/i, /provider\.gonkagate\.options\.apiKey/i, /\{file:~\/\.gonkagate\/opencode\/api-key\}/i, - /redact secret-bearing fields/i, - /fallback entrypoint error handling/i, - /durable verification for plain `opencode`/i, - /locally\s+inspectable/i, - /user config[\s\S]*OPENCODE_CONFIG[\s\S]*project config[\s\S]*system managed/i, - /repair drifted modes|without rewriting unchanged secret/i, - /inferred\s+higher-precedence|inferred[\s\S]*managed blocker/i, - /lastDurableSetupAt/, - /roll back changed managed files automatically/i, - /native Windows/i, - /native Windows CI|integration coverage/i, - /WSL/i, - /auth\.json/, - /~\/\.gonkagate\/opencode\/backups\/project-config/, - ]); - assert.doesNotMatch(security, /not part of the v1 verified contract yet/i); -}); - -test("docs index separates current contract docs from historical planning docs", () => { - const docsIndex = readText("docs/README.md"); - - assertMatchesAll(docsIndex, [ - /Current Contract Documents/i, - /Architecture Decisions/i, - /Model Validation/i, - /Historical Context/i, - /Implementation Plan.*historical execution record/i, - /historical documents must be labeled explicitly/i, - ]); -}); - -test("architecture decisions capture the shipped verification and picker strategy", () => { - const decisions = readText("docs/architecture-decisions.md"); - - assertMatchesAll(decisions, [ - /public curated picker visible in interactive mode/i, - /--yes.*safe non-interactive flows may auto-select/i, - /Separate durable verification from current-session verification/i, - /identical inline\s+override/i, - /inline[\s\S]*provider\.gonkagate\.options\.apiKey[\s\S]*block/i, - /Centralize user-facing error redaction/i, + /redacted/i, /opencode debug config --pure/i, - /auth\.json/i, - ]); -}); - -test("model validation doc matches the shipped small_model and picker contract", () => { - const modelValidation = readText("docs/model-validation.md"); - - assertMatchesAll(modelValidation, [ - /qwen3-235b-a22b-instruct-2507-fp8/, - /Qwen3 235B A22B Instruct 2507 FP8[\s\S]*recommended: `false`/i, - /kimi-k2\.6/, - /moonshotai\/Kimi-K2\.6/, - /Kimi K2\.6[\s\S]*recommended: `true`/i, - /minimax-m2\.7/, - /minimaxai\/minimax-m2\.7/, - /MiniMax M2\.7[\s\S]*recommended: `false`/i, - /@ai-sdk\/openai-compatible/, - /writes both `model` and `small_model`/i, - /provider\.gonkagate\.models[\s\S]*\/models|\/models[\s\S]*provider\.gonkagate\.models/i, - /public curated picker visible/i, + /OPENCODE_CONFIG_CONTENT/, ]); - - assert.doesNotMatch(modelValidation, /once write behavior exists/i); }); -test("implementation plan is explicitly marked historical", () => { +test("implementation plan explicitly marked historical", () => { const implementationPlan = readText("docs/implementation-plan.md"); assertMatchesAll(implementationPlan, [ /^# Historical Implementation Plan/m, - /historical context, not the current product contract/i, - /scaffold-era wording below is preserved as an execution record/i, + /historical context/i, ]); }); + +test("transport constants stay aligned with docs", () => { + const readme = readText("README.md"); + + assert.match(readme, /chat\/completions/); +}); diff --git a/test/install/harness.ts b/test/install/harness.ts index 7f73f33..8c3cc08 100644 --- a/test/install/harness.ts +++ b/test/install/harness.ts @@ -13,6 +13,7 @@ import { fileURLToPath } from "node:url"; import type { InstallClock, InstallDependencies, + InstallHttp, InstallInput, InstallPrompts, InstallRuntimeOverrides, @@ -37,6 +38,7 @@ interface FakeOpenCodeOptions { type HarnessRuntimeOverrides = { clock?: Partial; + http?: Partial; input?: Partial; prompts?: Partial; runtime?: InstallRuntimeOverrides; @@ -126,6 +128,7 @@ export async function createInstallIntegrationHarness(): Promise { - const providerConfig = buildManagedProviderConfig({ - ...BASE_VALIDATED_MODEL, - limits: { - context: 128000, - output: 32768, - }, - runtimeCompatibility: { - modelHeaders: { - "x-gonkagate-mode": "validated", - }, - modelOptions: { - reasoningEffort: "high", - }, - modelProvider: { - api: "chat_completions", - npm: "@ai-sdk/openai-compatible", - }, - providerOptions: { - timeout: 60_000, - }, - }, - }); - const modelConfig = providerConfig.models[BASE_VALIDATED_MODEL.key]; +test("buildManagedProviderConfig maps a fetched model into the provider model entry", () => { + const providerConfig = buildManagedProviderConfig(LIVE_MODELS[0]); + const modelConfig = providerConfig.models[LIVE_MODEL_ID]; assert.equal( providerConfig.options.apiKey, "{file:~/.gonkagate/opencode/api-key}", ); assert.equal(providerConfig.options.baseURL, "https://api.gonkagate.com/v1"); - assert.equal(providerConfig.options.timeout, 60_000); - assert.equal(modelConfig?.limit?.context, 128000); - assert.equal(modelConfig?.limit?.output, 32768); - assert.equal(modelConfig?.options?.reasoningEffort, "high"); - assert.equal(modelConfig?.headers?.["x-gonkagate-mode"], "validated"); - assert.equal(modelConfig?.provider?.api, "chat_completions"); - assert.equal(modelConfig?.provider?.npm, "@ai-sdk/openai-compatible"); + assert.equal(modelConfig?.id, LIVE_MODEL_ID); + assert.equal(modelConfig?.name, "Dynamic Alpha"); }); -test("buildManagedProviderCatalogConfig exposes every validated model for OpenCode model selection", () => { - const providerConfig = buildManagedProviderCatalogConfig(); +test("buildManagedProviderCatalogConfig exposes every fetched model for OpenCode model selection", () => { + const providerConfig = buildManagedProviderCatalogConfig([ + ...LIVE_MODELS, + EXTRA_LIVE_MODEL, + ]); - assert.ok( - providerConfig.models["qwen3-235b-a22b-instruct-2507-fp8"] !== undefined, - ); + assert.equal(providerConfig.models[LIVE_MODEL_ID]?.id, LIVE_MODEL_ID); assert.equal( - providerConfig.models["qwen3-235b-a22b-instruct-2507-fp8"]?.id, - "qwen/qwen3-235b-a22b-instruct-2507-fp8", + providerConfig.models[SECOND_LIVE_MODEL_ID]?.id, + SECOND_LIVE_MODEL_ID, ); - assert.ok(providerConfig.models["kimi-k2.6"] !== undefined); - assert.equal(providerConfig.models["kimi-k2.6"]?.id, "moonshotai/Kimi-K2.6"); - assert.ok(providerConfig.models["minimax-m2.7"] !== undefined); assert.equal( - providerConfig.models["minimax-m2.7"]?.id, - "minimaxai/minimax-m2.7", - ); -}); - -test("buildManagedProviderConfig rejects compatibility metadata that conflicts with canonical provider keys", () => { - assert.throws( - () => - buildManagedProviderConfig({ - ...BASE_VALIDATED_MODEL, - displayName: "Conflicting Provider Options", - key: "conflict-test-model", - modelId: "vendor/conflict-test-model", - runtimeCompatibility: { - providerOptions: { - baseURL: "https://example.invalid/v1", - }, - }, - }), - /must not override options\.baseURL/i, + providerConfig.models[EXTRA_LIVE_MODEL_ID]?.id, + EXTRA_LIVE_MODEL_ID, ); }); diff --git a/test/install/model-fixtures.ts b/test/install/model-fixtures.ts new file mode 100644 index 0000000..01fa319 --- /dev/null +++ b/test/install/model-fixtures.ts @@ -0,0 +1,70 @@ +import { + createGonkagateModel, + formatOpencodeModelRef, + type ValidatedCuratedModel, +} from "../../src/constants/models.js"; +import type { InstallHttp } from "../../src/install/deps.js"; +import { buildManagedProviderCatalogConfig } from "../../src/install/managed-provider-config.js"; + +export const LIVE_MODEL_ID = "vendor/dynamic-alpha"; +export const SECOND_LIVE_MODEL_ID = "vendor/dynamic-beta"; +export const EXTRA_LIVE_MODEL_ID = "vendor/dynamic-extra"; + +export const LIVE_MODELS = Object.freeze([ + createGonkagateModel(LIVE_MODEL_ID, "Dynamic Alpha"), + createGonkagateModel(SECOND_LIVE_MODEL_ID, "Dynamic Beta"), +] as const); +export const EXTRA_LIVE_MODEL = createGonkagateModel( + EXTRA_LIVE_MODEL_ID, + "Dynamic Extra", +); + +export function createModelsHttp( + models: readonly ValidatedCuratedModel[] = LIVE_MODELS, +): Partial { + return { + async fetch() { + return { + ok: true, + status: 200, + statusText: "OK", + async text() { + return JSON.stringify({ + data: models.map((model) => ({ + id: model.modelId, + name: model.displayName, + })), + }); + }, + }; + }, + }; +} + +export function createResolvedConfigFixture( + options: + | { + modelKey?: string; + models?: readonly ValidatedCuratedModel[]; + mutate?: (config: Record) => void; + } + | ((config: Record) => void) = {}, +): string { + const models = typeof options === "function" ? LIVE_MODELS : options.models; + const modelKey = + typeof options === "function" ? LIVE_MODEL_ID : options.modelKey; + const mutate = typeof options === "function" ? options : options.mutate; + const selectedModelKey = modelKey ?? LIVE_MODEL_ID; + const resolvedConfig = { + model: formatOpencodeModelRef(selectedModelKey), + provider: { + gonkagate: buildManagedProviderCatalogConfig(models ?? LIVE_MODELS), + }, + small_model: formatOpencodeModelRef(selectedModelKey), + } satisfies Record; + const nextConfig = structuredClone(resolvedConfig); + + mutate?.(nextConfig); + + return `${JSON.stringify(nextConfig, null, 2)}\n`; +} diff --git a/test/install/models.test.ts b/test/install/models.test.ts index fa7696f..bf05eef 100644 --- a/test/install/models.test.ts +++ b/test/install/models.test.ts @@ -1,149 +1,96 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { GONKAGATE_MODELS_URL } from "../../src/constants/gateway.js"; +import { formatOpencodeModelRef } from "../../src/constants/models.js"; import { - CURATED_MODEL_REGISTRY, - createCuratedModelIndex, - formatOpencodeModelRef, - getRecommendedValidatedModel, - getValidatedModels, - type CuratedModelRecord, - type CuratedModelRegistry, -} from "../../src/constants/models.js"; + fetchGonkagateModels, + parseGonkagateModelsResponse, +} from "../../src/install/model-catalog.js"; +import { isInstallErrorCode } from "../../src/install/errors.js"; -test("the default curated registry exposes validated entries through the validated-model helpers", () => { - const validatedModels = getValidatedModels(); - - assert.equal(validatedModels.length, 3); - assert.equal(validatedModels[0]?.key, "qwen3-235b-a22b-instruct-2507-fp8"); - assert.equal(validatedModels[0]?.validationStatus, "validated"); - assert.equal(validatedModels[1]?.key, "kimi-k2.6"); - assert.equal(validatedModels[1]?.modelId, "moonshotai/Kimi-K2.6"); - assert.equal(validatedModels[1]?.validationStatus, "validated"); - assert.equal(validatedModels[2]?.key, "minimax-m2.7"); - assert.equal(validatedModels[2]?.modelId, "minimaxai/minimax-m2.7"); - assert.equal(validatedModels[2]?.validationStatus, "validated"); -}); - -test("createCuratedModelIndex derives keyed records and picks the recommended validated model by metadata", () => { - const testRegistry = { - alpha: { - adapterPackage: "@ai-sdk/openai-compatible", - displayName: "Alpha", - modelId: "gonkagate/alpha", - recommended: false, - transport: "chat_completions", - validationStatus: "validated", - }, - beta: { - adapterPackage: "@ai-sdk/openai-compatible", - displayName: "Beta", - modelId: "gonkagate/beta", - recommended: true, - transport: "chat_completions", - validationStatus: "validated", - }, - gamma: { - adapterPackage: "@ai-sdk/openai-compatible", - displayName: "Gamma", - modelId: "gonkagate/gamma", - recommended: true, - transport: "responses", - validationStatus: "planned", - }, - } as const satisfies CuratedModelRegistry; - - const index = createCuratedModelIndex(testRegistry); +test("parseGonkagateModelsResponse accepts OpenAI-compatible models and dedupes ids", () => { + const models = parseGonkagateModelsResponse( + JSON.stringify({ + data: [ + { + id: "vendor/dynamic-alpha", + name: "Dynamic Alpha", + }, + { + id: "vendor/dynamic-alpha", + name: "Duplicate ignored", + }, + { + id: "vendor/dynamic-beta", + }, + ], + }), + ); - assert.deepEqual(index.modelKeys, ["alpha", "beta", "gamma"]); - assert.equal(index.models[0]?.key, "alpha"); - assert.equal(index.recommendedValidatedModel?.key, "beta"); - assert.deepEqual(index.validatedModelKeys, ["alpha", "beta"]); + assert.equal(models.length, 2); + assert.equal(models[0]?.key, "vendor/dynamic-alpha"); + assert.equal(models[0]?.displayName, "Dynamic Alpha"); + assert.equal(models[1]?.key, "vendor/dynamic-beta"); + assert.equal(models[1]?.displayName, "vendor/dynamic-beta"); }); -test("createCuratedModelIndex rejects more than one recommended validated model", () => { - const invalidRegistry = { - alpha: { - adapterPackage: "@ai-sdk/openai-compatible", - displayName: "Alpha", - modelId: "gonkagate/alpha", - recommended: true, - transport: "chat_completions", - validationStatus: "validated", - }, - beta: { - adapterPackage: "@ai-sdk/openai-compatible", - displayName: "Beta", - modelId: "gonkagate/beta", - recommended: true, - transport: "responses", - validationStatus: "validated", - }, - } as const satisfies CuratedModelRegistry; - +test("parseGonkagateModelsResponse rejects empty and invalid model responses", () => { assert.throws( - () => createCuratedModelIndex(invalidRegistry), - /must not expose more than one recommended validated model/i, + () => parseGonkagateModelsResponse('{"data":[]}'), + (error) => isInstallErrorCode(error, "model_catalog_invalid"), ); -}); - -test("the recommended validated model is selected by explicit metadata, not array order", () => { - const recommendedModel = getRecommendedValidatedModel(); - - assert.equal(recommendedModel?.key, "kimi-k2.6"); - assert.equal(recommendedModel?.recommended, true); -}); -test("the formatted OpenCode model reference uses the stable provider/model key shape", () => { - assert.equal( - formatOpencodeModelRef("qwen3-235b-a22b-instruct-2507-fp8"), - "gonkagate/qwen3-235b-a22b-instruct-2507-fp8", - ); - assert.equal(formatOpencodeModelRef("kimi-k2.6"), "gonkagate/kimi-k2.6"); - assert.equal( - formatOpencodeModelRef("minimax-m2.7"), - "gonkagate/minimax-m2.7", + assert.throws( + () => parseGonkagateModelsResponse('{"data":[{"name":"No id"}]}'), + (error) => isInstallErrorCode(error, "model_catalog_invalid"), ); }); -test("the curated model contract can carry compatibility and migration metadata", () => { - const metadataRichRecord: CuratedModelRecord = { - adapterPackage: "@ai-sdk/openai-compatible", - runtimeCompatibility: { - modelHeaders: { - "x-gonkagate-mode": "validated", - }, - modelOptions: { - reasoningEffort: "high", - }, - notes: ["Validated against the OpenCode 1.4.0 baseline."], - providerOptions: { - baseURL: "https://api.gonkagate.com/v1", +test("fetchGonkagateModels calls /v1/models with Bearer auth", async () => { + const seenRequests: Array<{ + options: + | { + headers?: Record; + method?: string; + } + | undefined; + url: string; + }> = []; + const models = await fetchGonkagateModels("gp-test-secret", { + http: { + async fetch(url, options) { + seenRequests.push({ options, url }); + + return { + ok: true, + status: 200, + statusText: "OK", + async text() { + return '{"data":[{"id":"vendor/live-from-http","name":"HTTP Live"}]}'; + }, + }; }, }, - displayName: "Metadata Rich Test Record", - key: "metadata-rich-test-record", - migrationMetadata: { - adapterPackage: "@ai-sdk/openai", - transport: "responses", + }); + + assert.deepEqual(seenRequests, [ + { + options: { + headers: { + Accept: "application/json", + Authorization: "Bearer gp-test-secret", + }, + method: "GET", + }, + url: GONKAGATE_MODELS_URL, }, - modelId: "gonkagate/metadata-rich-test-record", - recommended: false, - transport: "chat_completions", - validationStatus: "planned", - }; + ]); + assert.equal(models[0]?.key, "vendor/live-from-http"); +}); +test("the formatted OpenCode model reference uses the stable provider/model shape", () => { assert.equal( - metadataRichRecord.runtimeCompatibility?.providerOptions?.baseURL, - "https://api.gonkagate.com/v1", + formatOpencodeModelRef("vendor/dynamic-alpha"), + "gonkagate/vendor/dynamic-alpha", ); - assert.equal(metadataRichRecord.migrationMetadata?.transport, "responses"); -}); - -test("the shipped registry stays wired through the default derived index", () => { - const index = createCuratedModelIndex(CURATED_MODEL_REGISTRY); - - assert.equal(index.models.length, 3); - assert.equal(index.models[0]?.key, "qwen3-235b-a22b-instruct-2507-fp8"); - assert.equal(index.models[1]?.key, "kimi-k2.6"); - assert.equal(index.models[2]?.key, "minimax-m2.7"); }); diff --git a/test/install/rerun.test.ts b/test/install/rerun.test.ts index 952a3eb..0930719 100644 --- a/test/install/rerun.test.ts +++ b/test/install/rerun.test.ts @@ -11,12 +11,13 @@ import { resolveProjectConfigBackupDirectory, } from "../../src/install/paths.js"; import { - buildManagedProviderCatalogConfig, - resolveValidatedModel, -} from "../../src/install/managed-provider-config.js"; + createModelsHttp, + createResolvedConfigFixture, + LIVE_MODEL_ID, +} from "./model-fixtures.js"; import { createInstallIntegrationHarness } from "./harness.js"; -const MODEL_KEY = "qwen3-235b-a22b-instruct-2507-fp8" as const; +const MODEL_KEY = LIVE_MODEL_ID; const MODEL_REF = formatOpencodeModelRef(MODEL_KEY); const SECOND_RUN_BACKUP_TIMESTAMP = "20260409T110000Z"; const SKIP_POSIX_HOST_INTEGRATION = process.platform === "win32"; @@ -32,25 +33,6 @@ interface ManagedStateSnapshot { userConfig?: string; } -function createResolvedConfigFixture( - mutate?: (config: Record) => void, -): string { - const model = resolveValidatedModel(MODEL_KEY); - const providerConfig = buildManagedProviderCatalogConfig(); - const resolvedConfig = { - model: MODEL_REF, - provider: { - gonkagate: providerConfig, - }, - small_model: MODEL_REF, - } satisfies Record; - const nextConfig = structuredClone(resolvedConfig); - - mutate?.(nextConfig); - - return `${JSON.stringify(nextConfig, null, 2)}\n`; -} - async function createInstallerFixture( options: { debugConfigPureOutput?: string; @@ -89,6 +71,7 @@ function createRunDependencies( clock: { now: () => new Date(options.clockIso), }, + http: createModelsHttp(), runtime: { cwd: fixture.repositoryRoot, env: { @@ -604,8 +587,7 @@ test("install-state write failures after successful verification roll earlier wr test("late failures after rewriting repo-local config restore the old file without leaving a repo-local backup", async () => { const fixture = await createInstallerFixture(); - const secretBearingProjectConfig = - '{\n "provider": {\n "gonkagate": {\n "options": {\n "apiKey": "{file:~/.gonkagate/opencode/api-key}"\n }\n }\n },\n "model": "gonkagate/qwen3-235b-a22b-instruct-2507-fp8"\n}\n'; + const secretBearingProjectConfig = `{\n "provider": {\n "gonkagate": {\n "options": {\n "apiKey": "{file:~/.gonkagate/opencode/api-key}"\n }\n }\n },\n "model": "${MODEL_REF}"\n}\n`; try { await writeFile( diff --git a/test/install/scope.test.ts b/test/install/scope.test.ts index 252eff1..fdc347f 100644 --- a/test/install/scope.test.ts +++ b/test/install/scope.test.ts @@ -6,6 +6,11 @@ import { resolveProjectConfigBackupDirectory, } from "../../src/install/paths.js"; import { writeScopeManagedConfigs } from "../../src/install/scope.js"; +import { + LIVE_MODELS, + LIVE_MODEL_ID, + SECOND_LIVE_MODEL_ID, +} from "./model-fixtures.js"; import { createTestInstallDependencies, type StubInstallFs, @@ -18,15 +23,14 @@ import { const TEST_HOME_DIR = "/home/test-user"; const TEST_WORKSPACE_DIR = "/workspace/repo"; -const VALIDATED_MODEL_KEY = "qwen3-235b-a22b-instruct-2507-fp8"; -const KIMI_MODEL_KEY = "kimi-k2.6"; +const VALIDATED_MODEL_KEY = LIVE_MODEL_ID; +const SECOND_MODEL_KEY = SECOND_LIVE_MODEL_ID; const VALIDATED_MODEL_REF = formatOpencodeModelRef(VALIDATED_MODEL_KEY); const EXISTING_USER_CONFIG = '{\n // keep this comment\n "provider": {\n "anthropic": {\n "name": "Anthropic"\n }\n },\n "command": {\n "review": {\n "template": "Review this"\n }\n }\n}\n'; const EXISTING_PROJECT_PROVIDER_CONFIG = '{\n "provider": {\n "gonkagate": {\n "name": "Old GonkaGate"\n },\n "openai": {\n "name": "OpenAI"\n }\n }\n}\n'; -const SECRET_BEARING_PROJECT_CONFIG = - '{\n "provider": {\n "gonkagate": {\n "options": {\n "apiKey": "{file:~/.gonkagate/opencode/api-key}"\n }\n },\n "openai": {\n "name": "OpenAI"\n }\n },\n "model": "gonkagate/qwen3-235b-a22b-instruct-2507-fp8"\n}\n'; +const SECRET_BEARING_PROJECT_CONFIG = `{\n "provider": {\n "gonkagate": {\n "options": {\n "apiKey": "{file:~/.gonkagate/opencode/api-key}"\n }\n },\n "openai": {\n "name": "OpenAI"\n }\n },\n "model": "${VALIDATED_MODEL_REF}"\n}\n`; interface ConfigSnapshot { config: Record; @@ -155,6 +159,7 @@ async function runScopeWrite( { managedPaths: context.managedPaths, model: VALIDATED_MODEL_KEY, + models: LIVE_MODELS, scope, }, context.dependencies, @@ -188,15 +193,14 @@ test("user scope writes provider and activation settings to the user config whil assert.equal(anthropicProvider.name, "Anthropic"); assert.equal(gonkagateOptions.apiKey, "{file:~/.gonkagate/opencode/api-key}"); assert.ok(gonkagateModels[VALIDATED_MODEL_KEY] !== undefined); - assert.ok(gonkagateModels[KIMI_MODEL_KEY] !== undefined); + assert.ok(gonkagateModels[SECOND_MODEL_KEY] !== undefined); assert.equal(reviewCommand.template, "Review this"); assert.match(userConfigText, /keep this comment/u); }); test("user scope removes GonkaGate activation and provider keys from an existing project config", async () => { const { readProjectConfig } = await runScopeWrite("user", { - projectConfigContents: - '{\n "provider": {\n "gonkagate": {\n "name": "Old GonkaGate"\n },\n "openai": {\n "name": "OpenAI"\n }\n },\n "model": "gonkagate/qwen3-235b-a22b-instruct-2507-fp8",\n "small_model": "gonkagate/qwen3-235b-a22b-instruct-2507-fp8"\n}\n', + projectConfigContents: `{\n "provider": {\n "gonkagate": {\n "name": "Old GonkaGate"\n },\n "openai": {\n "name": "OpenAI"\n }\n },\n "model": "${VALIDATED_MODEL_REF}",\n "small_model": "${VALIDATED_MODEL_REF}"\n}\n`, }); const { config: projectConfig } = readProjectConfig(); const providerConfig = expectObject( @@ -251,7 +255,7 @@ test("project scope writes provider only to the user config and activation only ); assert.ok( expectManagedGonkagateProvider(userConfig, "user config").models[ - KIMI_MODEL_KEY + SECOND_MODEL_KEY ] !== undefined, ); assert.equal(projectConfig.model, VALIDATED_MODEL_REF); diff --git a/test/install/test-deps.ts b/test/install/test-deps.ts index c7699f1..c6c8dec 100644 --- a/test/install/test-deps.ts +++ b/test/install/test-deps.ts @@ -6,6 +6,8 @@ import { type InstallCommandRunner, type InstallDependencies, type InstallFs, + type InstallHttp, + type InstallHttpResponse, type InstallInput, type InstallPrompts, type InstallRuntimeEnvironment, @@ -41,6 +43,13 @@ type TestInstallCommandConfig = TestDependencyConfig< result?: InstallCommandResult; } >; +type TestInstallHttpConfig = TestDependencyConfig< + InstallHttp, + { + error?: unknown; + response?: InstallHttpResponse; + } +>; type TestInstallInputConfig = TestDependencyConfig< InstallInput, { stdinText?: string } @@ -61,6 +70,7 @@ export interface TestInstallDependencyOverrides { seedDirectories?: readonly TestInstallFsDirectorySeed[]; seedFiles?: readonly TestInstallFsFileSeed[]; fs?: Partial; + http?: TestInstallHttpConfig; input?: TestInstallInputConfig; prompts?: TestInstallPromptsConfig; runtime?: InstallRuntimeOverrides; @@ -364,6 +374,32 @@ export function createStubInstallCommandRunner( }; } +export function createStubInstallHttp( + options: { + error?: unknown; + response?: InstallHttpResponse; + } = {}, +): InstallHttp { + return { + async fetch() { + if (options.error !== undefined) { + throw options.error; + } + + return ( + options.response ?? { + ok: true, + status: 200, + statusText: "OK", + async text() { + return '{"data":[{"id":"test/live-model","name":"Test Live Model"}]}'; + }, + } + ); + }, + }; +} + export function createTestInstallRuntime( overrides: InstallRuntimeOverrides = {}, ): InstallRuntimeEnvironment { @@ -488,6 +524,21 @@ function createStubbedTestInput( ); } +function createStubbedTestHttp( + overrides: TestInstallDependencyOverrides, +): Partial { + return resolveTestDependency(overrides.http, (stubConfig) => + createStubInstallHttp( + stubConfig === undefined + ? undefined + : { + error: stubConfig.error, + response: stubConfig.response, + }, + ), + ); +} + function createStubbedTestPrompts( overrides: TestInstallDependencyOverrides, ): Partial { @@ -511,6 +562,7 @@ export function createStubbedTestInstallDependencies( clock: createStubbedTestClock(overrides), commands: createStubbedTestCommands(overrides), fs: createStubbedTestFs(overrides), + http: createStubbedTestHttp(overrides), input: createStubbedTestInput(overrides), prompts: createStubbedTestPrompts(overrides), runtime: createTestInstallRuntime(overrides.runtime), @@ -530,6 +582,7 @@ export function createNodeBackedTestInstallDependencies( clock: overrides.clock, commands: overrides.commands, fs: overrides.fs, + http: overrides.http, input: overrides.input, prompts: overrides.prompts, runtime: runtimeOverrides, diff --git a/test/install/verify-effective.test.ts b/test/install/verify-effective.test.ts index 06d440c..4ac276b 100644 --- a/test/install/verify-effective.test.ts +++ b/test/install/verify-effective.test.ts @@ -9,11 +9,7 @@ import { type InstallError, type InstallErrorCode, } from "../../src/install/errors.js"; -import { - buildManagedProviderCatalogConfig, - GONKAGATE_SECRET_FILE_REFERENCE, - resolveValidatedModel, -} from "../../src/install/managed-provider-config.js"; +import { GONKAGATE_SECRET_FILE_REFERENCE } from "../../src/install/managed-provider-config.js"; import { formatRedactedDiagnosticValue } from "../../src/install/redact.js"; import { resolveManagedPaths } from "../../src/install/paths.js"; import { @@ -21,9 +17,15 @@ import { verifyEffectiveConfig, } from "../../src/install/verify-effective.js"; import { createInstallIntegrationHarness } from "./harness.js"; +import { + createResolvedConfigFixture, + LIVE_MODELS, + LIVE_MODEL_ID, + SECOND_LIVE_MODEL_ID, +} from "./model-fixtures.js"; import { createTestInstallDependencies } from "./test-deps.js"; -const MODEL_KEY = "qwen3-235b-a22b-instruct-2507-fp8" as const; +const MODEL_KEY = LIVE_MODEL_ID; const TEST_HOME_DIR = "/home/test"; const TEST_PROJECT_ROOT = "/workspace/repo"; @@ -81,25 +83,6 @@ function expectInstallErrorCode( }; } -function createResolvedConfigFixture( - mutate?: (config: Record) => void, -): string { - const model = resolveValidatedModel(MODEL_KEY); - const providerConfig = buildManagedProviderCatalogConfig(); - const resolvedConfig = { - model: formatOpencodeModelRef(model), - provider: { - gonkagate: providerConfig, - }, - small_model: formatOpencodeModelRef(model), - } satisfies Record; - const nextConfig = structuredClone(resolvedConfig); - - mutate?.(nextConfig); - - return `${JSON.stringify(nextConfig, null, 2)}\n`; -} - async function createVerificationFixture( options: { debugConfigPureOutput?: string; @@ -232,6 +215,7 @@ test("verifyEffectiveConfig succeeds against the resolved config and uses the -- { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -283,6 +267,7 @@ test("verifyEffectiveConfig ignores OPENCODE_CONFIG_CONTENT during durable verif { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -323,6 +308,7 @@ test("verifyEffectiveConfig keeps opencode debug config --pure as the durable su { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -352,6 +338,7 @@ test("verifyEffectiveConfig attributes enabled_providers durable blockers to use { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -390,6 +377,7 @@ test("verifyEffectiveConfig attributes overlapping managed keys to project_confi { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -421,6 +409,7 @@ test("verifyEffectiveConfig blocks when OPENCODE_CONFIG overrides only provider. { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -460,6 +449,7 @@ test("verifyEffectiveConfig attributes disabled_providers durable blockers to fi { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -494,6 +484,7 @@ test("verifyEffectiveConfig blocks when a file-based system managed config overr { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -539,6 +530,7 @@ test("verifyEffectiveConfig attributes overlapping managed keys to file-based sy { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -563,6 +555,7 @@ test("verifyEffectiveConfig returns an inferred higher-precedence blocker when r { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -592,6 +585,7 @@ test("verifyEffectiveConfig blocks when user_config does not own the canonical s { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -624,6 +618,7 @@ test("verifyEffectiveConfig blocks when project scope finds a repo-local GonkaGa { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "project", }, fixture.dependencies, @@ -650,6 +645,7 @@ test("verifyCurrentSessionEffectiveConfig allows an identical inline override", { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -676,6 +672,7 @@ test("verifyCurrentSessionEffectiveConfig blocks when OPENCODE_CONFIG_CONTENT de { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -715,6 +712,7 @@ test("verifyCurrentSessionEffectiveConfig blocks when an inline model override c { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -752,6 +750,7 @@ test("verifyCurrentSessionEffectiveConfig preserves enabled_providers blockers f { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -784,6 +783,7 @@ test("verifyCurrentSessionEffectiveConfig preserves disabled_providers blockers { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -811,6 +811,7 @@ test("verifyCurrentSessionEffectiveConfig fails when the inline layer is invalid { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -838,6 +839,7 @@ test("verifyEffectiveConfig detects a resolved model mismatch", async () => { { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -868,6 +870,7 @@ test("verifyEffectiveConfig detects a resolved small_model mismatch", async () = { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -900,6 +903,7 @@ test("verifyEffectiveConfig detects a missing provider.gonkagate block", async ( { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -929,6 +933,7 @@ test( { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -970,6 +975,7 @@ test("verifyEffectiveConfig detects a wrong resolved adapter package", async () { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -1004,6 +1010,7 @@ test("verifyEffectiveConfig detects a wrong resolved transport", async () => { { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -1039,6 +1046,7 @@ test("verifyEffectiveConfig detects a wrong resolved base URL", async () => { { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -1073,6 +1081,7 @@ test("verifyEffectiveConfig detects a missing curated model entry under provider { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -1098,7 +1107,7 @@ test("verifyEffectiveConfig requires the full GonkaGate model catalog", async () const provider = (config.provider as Record) .gonkagate as Record; const models = provider.models as Record; - delete models["minimax-m2.7"]; + delete models[SECOND_LIVE_MODEL_ID]; }), }); @@ -1109,6 +1118,7 @@ test("verifyEffectiveConfig requires the full GonkaGate model catalog", async () { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -1117,7 +1127,8 @@ test("verifyEffectiveConfig requires the full GonkaGate model catalog", async () assert.equal( error.details.mismatches.some( (mismatch) => - mismatch.key === "provider.gonkagate.models.minimax-m2.7", + mismatch.key === + `provider.gonkagate.models.${SECOND_LIVE_MODEL_ID}`, ), true, ); @@ -1140,6 +1151,7 @@ test("verifyEffectiveConfig rejects invalid resolved-config payloads without ech { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, @@ -1170,6 +1182,7 @@ test("verifyEffectiveConfig redacts expanded secrets in mismatch diagnostics", a { context: fixture.context, model: MODEL_KEY, + models: LIVE_MODELS, scope: "user", }, fixture.dependencies, diff --git a/test/package-contract.test.ts b/test/package-contract.test.ts index abfda0a..d7e867c 100644 --- a/test/package-contract.test.ts +++ b/test/package-contract.test.ts @@ -13,7 +13,7 @@ interface PackageJson { version?: string; } -test("package metadata matches the scaffold contract", () => { +test("package metadata matches scaffold contract", () => { const packageJson = JSON.parse(readText("package.json")) as PackageJson; assert.equal(packageJson.name, CONTRACT_METADATA.packageName); @@ -38,16 +38,13 @@ test("package metadata matches the scaffold contract", () => { assert.match(packageJson.scripts?.ci ?? "", /npm run package:check/); }); -test("curated model contract can encode compatibility and migration metadata", () => { +test("model contract uses the live GonkaGate model catalog", () => { const modelsContract = readText("src/constants/models.ts"); + const modelCatalog = readText("src/install/model-catalog.ts"); - assert.match(modelsContract, /CuratedModelCompatibility/); - assert.match(modelsContract, /CuratedModelRecord/); - assert.match(modelsContract, /providerOptions/); - assert.match(modelsContract, /modelOptions/); - assert.match(modelsContract, /modelHeaders/); - assert.match(modelsContract, /migrationMetadata/); - assert.match(modelsContract, /recommended/); - assert.match(modelsContract, /CURATED_MODEL_REGISTRY/); - assert.match(modelsContract, /getRecommendedValidatedModel/); + assert.doesNotMatch(modelsContract, /CURATED_MODEL_REGISTRY/); + assert.doesNotMatch(modelsContract, /SUPPORTED_MODELS/); + assert.match(modelCatalog, /GONKAGATE_MODELS_URL/); + assert.match(modelCatalog, /Authorization: `Bearer/); + assert.match(modelCatalog, /parseGonkagateModelsResponse/); });