From a3eb94b117eb929db3931d692001445eb1d32451 Mon Sep 17 00:00:00 2001 From: Daniil Koryto Date: Mon, 6 Jul 2026 16:58:48 +0300 Subject: [PATCH] feat: fetch GonkaGate models dynamically --- AGENTS.md | 11 +- CHANGELOG.md | 7 +- README.md | 13 ++- docs/architecture-decisions.md | 30 ++---- docs/how-it-works.md | 9 +- docs/model-validation.md | 79 ++++++-------- docs/security.md | 42 +++----- docs/specs/qwen-code-setup-prd/spec.md | 54 +++++----- docs/troubleshooting.md | 49 +++------ src/cli/contracts.ts | 5 +- src/cli/parse.ts | 63 +++++------ src/cli/render.ts | 2 - src/constants/contract.ts | 3 +- src/constants/models.ts | 132 ------------------------ src/install/contracts/blockers.ts | 1 - src/install/contracts/install-flow.ts | 5 +- src/install/gonkagate-client.ts | 43 ++++++-- src/install/index.ts | 23 ++--- src/install/managed-config-mutations.ts | 4 +- src/install/managed-provider-config.ts | 12 +-- src/install/model-discovery.ts | 72 ------------- src/install/prewrite-flow.ts | 11 +- src/install/qwen-compatibility.ts | 6 +- src/install/selection.ts | 123 +++++++++------------- src/install/state.ts | 13 +-- src/install/verify-effective.ts | 17 ++- src/install/write-target-config.ts | 11 +- src/install/write.ts | 8 +- test/cli.test.ts | 39 +++---- test/docs-contract.test.ts | 18 ++-- test/install/config.test.ts | 17 +-- test/install/dry-run.test.ts | 7 +- test/install/errors.test.ts | 7 +- test/install/gonkagate-client.test.ts | 26 ++++- test/install/install-flow.test.ts | 36 +++---- test/install/json-output.test.ts | 10 +- test/install/model-discovery.test.ts | 41 -------- test/install/model-fixtures.ts | 23 +++++ test/install/models.test.ts | 53 ---------- test/install/prewrite-flow.test.ts | 22 ++-- test/install/qwen-compatibility.test.ts | 6 +- test/install/rerun.test.ts | 6 +- test/install/result-contract.test.ts | 13 ++- test/install/selection.test.ts | 76 +++++++------- test/install/state.test.ts | 16 ++- test/install/verify-effective.test.ts | 20 ++-- test/install/write.test.ts | 17 ++- test/package-contract.test.ts | 74 +++++-------- 48 files changed, 490 insertions(+), 885 deletions(-) delete mode 100644 src/constants/models.ts delete mode 100644 src/install/model-discovery.ts delete mode 100644 test/install/model-discovery.test.ts create mode 100644 test/install/model-fixtures.ts delete mode 100644 test/install/models.test.ts diff --git a/AGENTS.md b/AGENTS.md index ecdaa0e..87a7155 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,12 +72,11 @@ These audited assumptions are the current implementation contract: - the v1 durable secret target is user-level `settings.env.GONKAGATE_API_KEY` - after API-key collection, the installer must make a separate authenticated - `GET https://api.gonkagate.com/v1/models` request and confirm all three - supported models before rendering the picker or writing config -- the managed Qwen provider catalog must include all three supported models: - - `qwen/qwen3-235b-a22b-instruct-2507-fp8` - - `moonshotai/Kimi-K2.6` - - `minimaxai/minimax-m2.7` + `GET https://api.gonkagate.com/v1/models` request before rendering the + picker or writing config +- authenticated `/v1/models` is the source of truth for user-visible model ids, + names, `--model` validation, default selection, and managed provider writes +- no checked-in GonkaGate model id list may become the runtime source of truth - `qwen auth` is removed in the audited baseline; status is an interactive `/doctor` concern rather than a standalone `qwen auth status` command - `modelProviders` is currently marked with `replace` merge semantics, so diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c1558b..8380020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,10 @@ ## [0.2.1](https://github.com/GonkaGate/qwen-code-setup/compare/v0.2.0...v0.2.1) (2026-06-23) - ### Bug Fixes -* allow future Qwen Code versions ([676d44d](https://github.com/GonkaGate/qwen-code-setup/commit/676d44d7a0181f82c22b3c584652cd642879eca0)) -* allow future Qwen Code versions ([e084d4e](https://github.com/GonkaGate/qwen-code-setup/commit/e084d4e1a2f48f6434e6c5f84311bf2cc74b8794)) +- allow future Qwen Code versions ([676d44d](https://github.com/GonkaGate/qwen-code-setup/commit/676d44d7a0181f82c22b3c584652cd642879eca0)) +- allow future Qwen Code versions ([e084d4e](https://github.com/GonkaGate/qwen-code-setup/commit/e084d4e1a2f48f6434e6c5f84311bf2cc74b8794)) ## [0.2.0](https://github.com/GonkaGate/qwen-code-setup/compare/v0.1.0...v0.2.0) (2026-06-16) @@ -22,5 +21,5 @@ - Add TypeScript, CI, release, package, docs, contract-test, and mirrored skill infrastructure. - Record the initial Qwen Code compatibility assumptions without claiming the installer runtime is implemented. - Expand the Qwen Code setup PRD with concrete v1 requirements for Qwen settings, secret storage, scope behavior, verification, and diagnostics. -- Require support for all three current GonkaGate models and authenticated `/v1/models` discovery after API-key intake. +- Require authenticated `/v1/models` discovery after API-key intake. - Implement the Qwen Code setup runtime with safe secret intake, authenticated model discovery, managed user/project settings writes, backups, rollback, dry-run, durable verification, current-session shadowing warnings, redacted human/JSON output, and fake-tested optional live verification. diff --git a/README.md b/README.md index 7407c47..1f51e4f 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ The happy path is: 1. Detects the local `qwen` binary and records its version. 2. Collects or reuses `GONKAGATE_API_KEY` without printing the secret. 3. Calls authenticated `GET https://api.gonkagate.com/v1/models`. -4. Requires all supported GonkaGate models before writing config. +4. Uses that live model response for defaulting, selection, and validation. 5. Writes the GonkaGate provider into `modelProviders.openai[]`. 6. Sets `security.auth.selectedType = "openai"` and `model.name`. 7. Stores the durable key reference at `settings.env.GONKAGATE_API_KEY`. @@ -56,12 +56,11 @@ settings. Use `--json` for machine-readable, redacted output. ## Supported Models -The installer only offers models that are present in GonkaGate's authenticated -model catalog: - -- `qwen/qwen3-235b-a22b-instruct-2507-fp8` -- `moonshotai/Kimi-K2.6` -- `minimaxai/minimax-m2.7` +The installer has no checked-in user-facing model catalog. After collecting the +GonkaGate API key, it calls authenticated +`GET https://api.gonkagate.com/v1/models` and uses the returned model ids and +names as the source of truth for the picker, `--model` validation, default +selection, and Qwen Code provider writes. ## Known Qwen Code Baseline diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index 202b4b3..9fcd8d2 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -32,7 +32,7 @@ The initial expected fields are: Activation is expected through: - `security.auth.selectedType = "openai"` -- `model.name = ` +- `model.name = ` This decision was revalidated against `@qwen-code/qwen-code` `0.18.0` on June 12, 2026. The provider surface remains valid, but `modelProviders` is @@ -40,29 +40,19 @@ marked with `replace` merge semantics, so project-scope writes must stay gated until later executable compatibility proof confirms they cannot hide user-managed providers. -## Decision 3: Combine Curated Support With Authenticated Model Discovery +## Decision 3: Use Authenticated Model Discovery As Runtime Catalog -The setup flow should expose the three supported GonkaGate models, not an -arbitrary model id box, and it must confirm availability through GonkaGate after -API-key intake. +The setup flow exposes the live GonkaGate model catalog returned for the user's +API key, not a checked-in model id list. Reasons: -- predictable public UX -- support and troubleshooting clarity -- compatibility metadata can be attached to each model -- validation proof can gate end-user exposure -- the authenticated `/v1/models` response proves the supplied API key can - actually use the required models - -The v1 required model set is: - -- `qwen/qwen3-235b-a22b-instruct-2507-fp8` -- `moonshotai/Kimi-K2.6` -- `minimaxai/minimax-m2.7` - -All three must be written into `modelProviders.openai[]`; `model.name` only -selects the default. +- adding or removing a GonkaGate network model should not require a repository + change +- authenticated `/v1/models` response proves the supplied API key can see the + model before writes +- the same live ids drive picker, `--model` validation, default selection, and + `modelProviders.openai[]` ## Decision 4: Use User Settings `env` For The Durable Secret diff --git a/docs/how-it-works.md b/docs/how-it-works.md index ff73911..6c74fb7 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -31,10 +31,11 @@ The runtime: 3. Parse and record the local Qwen Code version. 4. Collect the GonkaGate API key through a safe input. 5. Fetch `https://api.gonkagate.com/v1/models` with that key. -6. Confirm all three supported GonkaGate models are available. -7. Choose the setup default from the supported model picker. -8. Merges managed GonkaGate settings into Qwen Code user settings, including all - three models in `modelProviders.openai[]`. +6. Parse and dedupe the live GonkaGate model catalog. +7. Choose the setup default from the first fetched model or the live model + picker. +8. Merges managed GonkaGate settings into Qwen Code user settings, including + the fetched models in `modelProviders.openai[]`. 9. Persist the secret in user-level `settings.env.GONKAGATE_API_KEY`. 10. Verifies the durable Qwen Code outcome from locally inspectable evidence. 11. Reports current-session shadowing when process or project env overrides the diff --git a/docs/model-validation.md b/docs/model-validation.md index b553d05..59af0a7 100644 --- a/docs/model-validation.md +++ b/docs/model-validation.md @@ -1,62 +1,41 @@ # Model Validation -This document records the validation gate for curated GonkaGate models exposed -through `@gonkagate/qwen-code-setup`. +This document records the live model discovery rule for +`@gonkagate/qwen-code-setup`. -## Current Supported Models +## Source Of Truth -The v1 installer must support all three GonkaGate models currently available -for this setup flow: +The installer has no checked-in user-facing GonkaGate model id list. After +collecting the GonkaGate API key, it must make authenticated +`GET https://api.gonkagate.com/v1/models` with Bearer auth. -- `qwen/qwen3-235b-a22b-instruct-2507-fp8` -- `moonshotai/Kimi-K2.6` -- `minimaxai/minimax-m2.7` +The OpenAI-compatible response shape is expected to contain at least: -The static registry records each model with: +```json +{ + "data": [{ "id": "provider/model", "name": "Optional display name" }] +} +``` -- a stable curated key -- the GonkaGate model id -- a display label -- `status: "validated"` -- validation evidence date -- Qwen Code compatibility notes for the audited `0.18.0` baseline -- optional Qwen `generationConfig` fragments +The installer deduplicates returned ids, rejects malformed or empty responses +with `validated_models_unavailable`, and uses the remaining live models for: -Exactly one registry entry is marked as the recommended default for `--yes`. -For the current set, that default is -`qwen/qwen3-235b-a22b-instruct-2507-fp8`. +- the interactive picker +- `--model` validation +- the `--yes` default, which is the first fetched model +- `modelProviders.openai[]` writes +- `model.name` +- install-state managed model metadata -After collecting the GonkaGate API key, the installer must make a separate -authenticated request to `https://api.gonkagate.com/v1/models` and confirm that -all three ids are available before showing the picker or writing Qwen Code -settings. +## Runtime Rule -If any required id is missing, setup must fail with -`required_models_unavailable` instead of writing a partial provider catalog. +Only models returned by authenticated `/v1/models` for the user's key should +appear in the picker or be accepted by `--model`. -## Minimum Validation Proof +The Qwen Code provider catalog under `modelProviders.openai[]` must include the +live fetched model ids for that run, not a static subset. The selected +model controls `model.name`. -Before a model can be added to or removed from the supported set, record proof -for: - -- GonkaGate `/v1/models` availability for an authenticated `gp-...` key -- Qwen Code model provider selection -- non-streaming chat completion -- streaming chat completion if Qwen Code uses streaming for normal operation -- tool/function calling behavior if Qwen Code requires it for agentic tasks -- context-window assumptions -- timeout/retry behavior -- any provider-specific generation settings - -## Registry And Runtime Rule - -Only models with `status: "validated"` and present in the authenticated -`/v1/models` response should appear in the end-user picker. - -The Qwen Code provider catalog under `modelProviders.openai[]` must include all -three supported models, not only the selected default. The selected default -controls `model.name`. - -Unsupported curated model keys and arbitrary raw model ids must fail before any -runtime work. Extra GonkaGate `/v1/models` entries remain ignored until this -registry and the public docs are explicitly updated. +Adding or removing a model in the GonkaGate network must not require a +repository change. No checked-in GonkaGate model id list may become the runtime +source of truth. diff --git a/docs/security.md b/docs/security.md index ca6940e..c4c6d88 100644 --- a/docs/security.md +++ b/docs/security.md @@ -25,45 +25,33 @@ Allowed secret inputs: ## Qwen Code Secret Storage -Qwen Code provider entries refer to an `envKey`. For v1, the durable secret -target is user-level `settings.env.GONKAGATE_API_KEY` inside the active Qwen -settings file: +Qwen Code provider entries refer to `envKey`. + +For v1, the durable secret target is user-level +`settings.env.GONKAGATE_API_KEY` inside the active Qwen settings file: - `~/.qwen/settings.json`, or - `/settings.json` when Qwen Code is configured to use `QWEN_HOME` -The installer uses this target because Qwen Code always treats `settings.env` -as a fallback environment source, while a separate user `.env` can be skipped -when a trusted project `.env` exists. - The secret must never be written to project `.qwen/settings.json`, project `.env`, or shell profiles. ## Model Discovery Request -After collecting the key, the installer must make an authenticated -`GET https://api.gonkagate.com/v1/models` request to confirm the three supported -models are available. +After collecting the key, the installer must make authenticated +`GET https://api.gonkagate.com/v1/models` and use that live response as the +user-visible model catalog. -Security rules for this request: +Security rules for the request: -- pass the key only through an `Authorization: Bearer ...` header -- never print the header or raw key +- pass the key only through the `Authorization: Bearer ...` header +- never print the raw header key - do not include the raw response in user-facing errors -- report missing model ids through redacted diagnostics such as - `required_models_unavailable` +- report invalid, empty, or unavailable live model catalogs through redacted + diagnostics such as `validated_models_unavailable` ## Diagnostics -Do not print raw config or environment dumps if they can contain substituted -secrets. - -If a verification command exposes secret-bearing output, capture it internally -only and render a redacted summary. - -## Backups And Rollback - -Existing managed targets are backed up under -`~/.gonkagate/qwen-code/backups/` before replacement. Project settings backups -also stay under that user-level backup root. Failed writes or failed durable -verification roll back prior managed writes where the filesystem permits it. +Do not print raw config or environment dumps that contain substituted secrets. +If verification output may contain secrets, capture it internally and render +only a redacted summary. diff --git a/docs/specs/qwen-code-setup-prd/spec.md b/docs/specs/qwen-code-setup-prd/spec.md index 8bdf996..121bbc8 100644 --- a/docs/specs/qwen-code-setup-prd/spec.md +++ b/docs/specs/qwen-code-setup-prd/spec.md @@ -45,7 +45,7 @@ Expected outcome: - `qwen` runs with `security.auth.selectedType = "openai"` - the active `model.name` is a supported GonkaGate model - Qwen Code can resolve `GONKAGATE_API_KEY` -- Qwen Code can switch among all three currently supported GonkaGate models +- Qwen Code can switch among the live models returned for the user's key - GonkaGate settings are durable across terminal sessions - unrelated Qwen Code settings are preserved @@ -70,9 +70,9 @@ Interactive happy path: 4. installer asks for setup scope 5. installer collects the GonkaGate API key through a hidden prompt 6. installer calls GonkaGate `/v1/models` with that key -7. installer verifies that all three required GonkaGate models are available +7. installer verifies that all current live GonkaGate models are available 8. installer shows the supported GonkaGate model picker -9. installer writes managed Qwen Code user settings with all three models in +9. installer writes managed Qwen Code user settings with the fetched models in the provider catalog 10. if project scope is selected, installer writes only project activation settings @@ -110,7 +110,7 @@ printf '%s' "$GONKAGATE_API_KEY" | - Qwen Code active model selection: `model.name` - Qwen Code API-key lookup key: `GONKAGATE_API_KEY` - transport target: OpenAI-compatible chat completions -- v1 supports all three current GonkaGate models +- v1 supports the live GonkaGate model catalog returned for the user's key - v1 fetches GonkaGate models through a separate authenticated `/v1/models` request after API-key collection and before rendering the picker - v1 writes every supported GonkaGate model into `modelProviders.openai[]` @@ -197,7 +197,7 @@ block if project `modelProviders` would hide the user-managed provider catalog. The installer must represent GonkaGate through Qwen Code's `openai` auth type. It must not invent a separate Qwen provider family. -The installer must not rely only on a static model registry. After collecting a +The installer must not rely only on a checked-in model registry. After collecting a safe API key, it must call: ```http @@ -205,7 +205,7 @@ GET https://api.gonkagate.com/v1/models Authorization: Bearer gp-... ``` -This is a separate model-discovery request. It must run before model selection +This is a separate model-catalog request. It must run before model selection and before managed writes. The v1 required GonkaGate models are: @@ -214,12 +214,12 @@ The v1 required GonkaGate models are: - `moonshotai/Kimi-K2.6` - `minimaxai/minimax-m2.7` -If the authenticated `/v1/models` response does not include all three required -ids, setup must block with `required_models_unavailable`. It must not silently +If the authenticated `/v1/models` response does not include all current live +ids, setup must block with `validated_models_unavailable`. It must not silently write a partial catalog. If GonkaGate returns additional models, v1 must ignore -them until the curated registry and validation docs are explicitly updated. +them until the live GonkaGate catalog changes. -User-level managed settings must include all required models: +User-level managed settings must include fetched models: ```json { @@ -262,7 +262,7 @@ User-level managed settings must include all required models: } ``` -The actual `modelProviders.openai[]` catalog must include all three required +The actual `modelProviders.openai[]` catalog must include all current live GonkaGate models, not only the selected default. The selected model controls `model.name`. @@ -354,7 +354,7 @@ User scope writes: - managed `modelProviders.openai[]` entries - `security.auth.selectedType = "openai"` -- `model.name = ` +- `model.name = ` - `env.GONKAGATE_API_KEY` - install state under `~/.gonkagate/qwen-code/install-state.json` @@ -372,7 +372,7 @@ Project scope writes: - `env.GONKAGATE_API_KEY` - project settings: - `security.auth.selectedType = "openai"` - - `model.name = ` + - `model.name = ` Project scope must not write: @@ -399,10 +399,10 @@ not beside repository files. The public picker must show only models that are both: -- present in the local curated registry with `status: "validated"` +- returned by authenticated GonkaGate `/v1/models` - present in the authenticated GonkaGate `/v1/models` response -The static registry defines what the installer knows how to support. The +The authenticated response defines what the installer exposes for that run. The authenticated `/v1/models` response proves what this API key can actually use at setup time. @@ -412,7 +412,7 @@ The registry must store: - GonkaGate model id - display label - validation status -- recommended default marker +- live response ordering for the `--yes` default - Qwen Code compatibility notes - optional Qwen generation config fragments - validation evidence date @@ -427,8 +427,7 @@ All three must be written into Qwen Code's managed `modelProviders.openai[]` catalog. One model may be selected as the setup default through `model.name`, but model support is not limited to that default. -Runtime `/v1/models` discovery is not a replacement for the curated registry; -it is an authenticated availability check after API-key intake. +Runtime `/v1/models` discovery is the runtime catalog source of truth. ## CLI Contract @@ -436,7 +435,7 @@ Supported commands/options: - `npx @gonkagate/qwen-code-setup` - `--scope user|project` -- `--model ` +- `--model ` - `--yes` - `--json` - `--api-key-stdin` @@ -451,7 +450,7 @@ Forbidden: Interactive behavior: -- show curated picker even when the validated list is small +- show live picker even when the list is small - hide API-key input - explain whether setup is user or project scoped - display only redacted paths and blocker summaries @@ -461,8 +460,7 @@ Non-interactive behavior: - require `--scope` or `--yes` - require a safe secret source unless a valid managed key already exists and the user explicitly allows reuse through `--yes` -- if multiple validated models exist, require `--model` or use the recommended - default only with `--yes` +- if multiple live models exist, accept `--model` by id or use the first fetched model with `--yes` - `--json` output must not contain raw secrets `--dry-run` must show planned writes and blockers without writing files. @@ -497,7 +495,7 @@ Write requirements: - installer version - audited Qwen Code version used for compatibility metadata - selected scope -- selected curated model key +- selected live model id - managed model ids written - active Qwen user settings path - project settings path when applicable @@ -516,10 +514,10 @@ Durable verification must prove, from locally inspectable files and audited Qwen Code merge rules, that: - active Qwen user settings contain all managed GonkaGate model entries -- the last authenticated `/v1/models` check confirmed all three required model +- the last authenticated `/v1/models` check confirmed all current live model ids before writes - `env.GONKAGATE_API_KEY` exists in user settings -- selected scope activation points at the intended curated model +- selected scope activation points at the intended live model - `security.auth.selectedType` resolves to `openai` - `model.name` resolves to the selected GonkaGate model - no locally inspectable higher-precedence settings layer disables or hides the @@ -583,7 +581,7 @@ Important blocker classes: - `managed_write_failed` - `model_conflict` - `validated_models_unavailable` -- `required_models_unavailable` +- `validated_models_unavailable` - `secret_missing` - `secret_shadowed_by_process_env` - `secret_shadowed_by_project_env` @@ -647,9 +645,9 @@ The first runtime implementation is complete only when: - project-scope behavior is implemented or explicitly disabled in CLI/docs - installer fetches GonkaGate `/v1/models` after API-key intake and before the model picker -- curated model picker exposes all three required Qwen-supported GonkaGate +- live model picker exposes the authenticated GonkaGate response models when the authenticated response confirms availability -- managed provider catalog writes all three required GonkaGate models +- managed provider catalog writes all current live GonkaGate models - duplicate model/provider conflicts block safely - durable verification is implemented - current-session shadowing diagnostics are implemented diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index fc7dc77..473b2b6 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,28 +1,16 @@ # Troubleshooting -## Why did setup fail? +## Why Did Setup Fail? -The CLI reports a blocker code, layer, and next action. JSON mode uses the same -stable codes without printing secrets. +The CLI reports a blocker or error code, layer, and next action. JSON mode uses +the same stable codes without printing secrets. -## Can I use `qwen auth status` for verification? +## Can I Use `qwen auth status` Verification? -No. In the audited Qwen Code baseline, `qwen auth` is removed. The command -itself points users toward interactive `/doctor`. - -The installer verifies locally inspectable Qwen settings and current-session +No. In the audited Qwen Code baseline, `qwen auth` has been removed. The +installer verifies locally inspectable Qwen settings and current-session shadowing instead. -## Why not copy the OpenCode installer directly? - -OpenCode and Qwen Code have different config and secret semantics. - -OpenCode supports a provider secret binding under its config. Qwen Code's -audited contract uses `envKey` and environment loading. For v1 this installer -stores the durable key in user-level `settings.env.GONKAGATE_API_KEY` and keeps -project files secret-free. Copying OpenCode's runtime would create false safety -claims. - ## Common Blockers - `qwen_not_found`: install `@qwen-code/qwen-code` and ensure `qwen` is on @@ -35,23 +23,18 @@ claims. - `model_conflict`: remove or rename an unmanaged provider entry with the same model id. - `validated_models_unavailable`: retry after GonkaGate `/v1/models` is - reachable and returns valid JSON. -- `required_models_unavailable`: use a key with access to all three required - GonkaGate models. + reachable and returns at least one model with a string `id`. - `secret_missing`: provide `GONKAGATE_API_KEY`, `--api-key-stdin`, or run - interactively for a hidden prompt. + interactively for the hidden prompt. - `secret_shadowed_by_process_env`: unset or align the current shell `GONKAGATE_API_KEY`. -- `secret_shadowed_by_project_env`: remove or align trusted project - `.qwen/.env` or `.env`. +- `secret_shadowed_by_project_env`: remove or align trusted project `.qwen/.env` + or `.env`. - `project_modelproviders_override`: remove project `modelProviders` or use user scope. -- `system_settings_override`: inspect system Qwen settings that override managed - user/project state. -- `verification_incomplete`: make higher-precedence evidence readable and - verify permissions. -- `live_verify_failed`: local setup is separate; inspect live Qwen/GonkaGate - connectivity manually. - -`--verify-live` is optional, can spend quota, and is not part of default setup -success. +- `system_settings_override`: remove conflicting system Qwen settings or verify + the override manually. +- `verification_incomplete`: inspect the reported path and rerun after fixing + local evidence. +- `live_verify_failed`: rerun without `--verify-live` or inspect Qwen/GonkaGate + network availability. diff --git a/src/cli/contracts.ts b/src/cli/contracts.ts index 3aaf446..af22590 100644 --- a/src/cli/contracts.ts +++ b/src/cli/contracts.ts @@ -1,4 +1,3 @@ -import type { CuratedModelKey } from "../constants/models.js"; import type { InstallFlowRequest, InstallScope, @@ -19,9 +18,7 @@ export interface CliResult { readonly exitCode: number; } -export interface CliOptions extends InstallFlowRequest { - readonly modelKey?: CuratedModelKey; -} +export interface CliOptions extends InstallFlowRequest {} export type CliParseErrorCode = | "forbidden_option" diff --git a/src/cli/parse.ts b/src/cli/parse.ts index 5d9a95f..8bc9c20 100644 --- a/src/cli/parse.ts +++ b/src/cli/parse.ts @@ -1,10 +1,5 @@ import { Command, CommanderError } from "commander"; import { QWEN_CODE_SETUP_CONTRACT } from "../constants/contract.js"; -import { - UnsupportedCuratedModelError, - getCuratedModelByKey, - getRecommendedDefaultModel, -} from "../constants/models.js"; import type { CliOptions, CliParseError, CliParseResult } from "./contracts.js"; const FORBIDDEN_OPTIONS = ["--api-key", "--base-url", "--model-id"] as const; @@ -29,7 +24,10 @@ export function createCli(): Command { ) .version(QWEN_CODE_SETUP_CONTRACT.packageVersion, "-v, --version") .option("--scope ", "Planned setup scope: user or project.") - .option("--model ", "Planned curated GonkaGate model key.") + .option( + "--model ", + "GonkaGate model id from authenticated /v1/models.", + ) .option("--yes", "Accept safe defaults once the runtime is implemented.") .option("--json", "Render machine-readable installer output.") .option("--api-key-stdin", "Read the GonkaGate API key from stdin.") @@ -117,42 +115,29 @@ function normalizeCliOptions( }; } - try { - const explicitModel = - rawOptions.model === undefined - ? undefined - : getCuratedModelByKey(rawOptions.model).key; - const defaultModel = - rawOptions.yes === true && explicitModel === undefined - ? getRecommendedDefaultModel().key - : undefined; - - const options: CliOptions = { - scope: rawOptions.scope, - modelKey: explicitModel ?? defaultModel, - yes: rawOptions.yes === true, - json: rawOptions.json === true, - apiKeyStdin: rawOptions.apiKeyStdin === true, - dryRun: rawOptions.dryRun === true, - verifyLive: rawOptions.verifyLive === true, + if (rawOptions.model !== undefined && rawOptions.model.trim() === "") { + return { + kind: "parse-error", + error: { + code: "invalid_model", + option: "--model", + json: wantsJson, + message: "Model id cannot be empty.", + }, }; + } - return { kind: "parsed", options }; - } catch (error) { - if (error instanceof UnsupportedCuratedModelError) { - return { - kind: "parse-error", - error: { - code: "invalid_model", - option: "--model", - json: wantsJson, - message: `Unsupported curated model key "${error.modelKey}".`, - }, - }; - } + const options: CliOptions = { + scope: rawOptions.scope, + modelKey: rawOptions.model, + yes: rawOptions.yes === true, + json: rawOptions.json === true, + apiKeyStdin: rawOptions.apiKeyStdin === true, + dryRun: rawOptions.dryRun === true, + verifyLive: rawOptions.verifyLive === true, + }; - throw error; - } + return { kind: "parsed", options }; } function findForbiddenOption(args: readonly string[]): string | undefined { diff --git a/src/cli/render.ts b/src/cli/render.ts index 0d37c1e..9fb4fcc 100644 --- a/src/cli/render.ts +++ b/src/cli/render.ts @@ -1,5 +1,4 @@ import { QWEN_CODE_SETUP_CONTRACT } from "../constants/contract.js"; -import { getValidatedModels } from "../constants/models.js"; import type { InstallFlowResult } from "../install/contracts/install-flow.js"; import { redactSecrets, redactedJsonStringify } from "../install/redact.js"; import type { @@ -144,6 +143,5 @@ function toJsonPayload(result: InstallFlowResult): Record { changed: result.changed, blockers: result.blockers, warnings: result.warnings, - supportedModels: getValidatedModels().map((model) => model.key), }; } diff --git a/src/constants/contract.ts b/src/constants/contract.ts index ab1caaf..bd6d38b 100644 --- a/src/constants/contract.ts +++ b/src/constants/contract.ts @@ -16,8 +16,7 @@ export const QWEN_CODE_SETUP_CONTRACT = { latestAuditedQwenCodeTag: "v0.18.0", latestAuditedQwenCodeTagCommit: "a7b8a3655c73c14dde99ab7138a566885e31c68f", runtimeImplemented: true, - curatedRegistryPublished: true, - requiredGonkagateModelCount: 3, + modelCatalogSource: "authenticated /v1/models", qwenUserSettingsPath: "~/.qwen/settings.json", qwenHomeSettingsPath: "/settings.json", qwenWorkspaceSettingsPath: ".qwen/settings.json", diff --git a/src/constants/models.ts b/src/constants/models.ts deleted file mode 100644 index 58e7754..0000000 --- a/src/constants/models.ts +++ /dev/null @@ -1,132 +0,0 @@ -export type CuratedModelStatus = "candidate" | "validated"; - -export interface CuratedModelGenerationConfig { - readonly contextWindowSize?: number; - readonly modalities?: Readonly>; - readonly samplingParams?: Readonly>; -} - -export interface CuratedModelRecord { - readonly key: string; - readonly id: string; - readonly label: string; - readonly status: CuratedModelStatus; - readonly recommendedDefault: boolean; - readonly validationEvidenceDate: string; - readonly qwenCompatibilityNotes: readonly string[]; - readonly generationConfig?: CuratedModelGenerationConfig; - readonly notes: readonly string[]; -} - -export const CURATED_MODEL_REGISTRY = [ - { - key: "qwen3-235b-a22b-instruct-2507-fp8", - id: "qwen/qwen3-235b-a22b-instruct-2507-fp8", - label: "Qwen3 235B A22B Instruct FP8", - status: "validated", - recommendedDefault: true, - validationEvidenceDate: "2026-06-12", - qwenCompatibilityNotes: [ - "Audited against @qwen-code/qwen-code 0.18.0 modelProviders.openai[] using id, name, baseUrl, envKey, and optional generationConfig.", - "Recommended default for --yes because it is the Qwen-native model in the curated GonkaGate set.", - ], - generationConfig: { - modalities: {}, - }, - notes: [ - "Required GonkaGate model for v1; installer must confirm it is present in the authenticated /v1/models response before setup.", - ], - }, - { - key: "kimi-k2.6", - id: "moonshotai/Kimi-K2.6", - label: "Kimi K2.6", - status: "validated", - recommendedDefault: false, - validationEvidenceDate: "2026-06-12", - qwenCompatibilityNotes: [ - "Audited against @qwen-code/qwen-code 0.18.0 modelProviders.openai[] using id, name, baseUrl, envKey, and optional generationConfig.", - "Qwen Code's model defaults classify Kimi-family ids as text-only unless a provider generationConfig overrides modalities.", - ], - generationConfig: { - modalities: {}, - }, - notes: [ - "Required GonkaGate model for v1; installer must confirm it is present in the authenticated /v1/models response before setup.", - ], - }, - { - key: "minimax-m2.7", - id: "minimaxai/minimax-m2.7", - label: "MiniMax M2.7", - status: "validated", - recommendedDefault: false, - validationEvidenceDate: "2026-06-12", - qwenCompatibilityNotes: [ - "Audited against @qwen-code/qwen-code 0.18.0 modelProviders.openai[] using id, name, baseUrl, envKey, and optional generationConfig.", - "Qwen Code's model defaults classify MiniMax M2-series ids as text-only unless a provider generationConfig overrides modalities.", - ], - generationConfig: { - modalities: {}, - }, - notes: [ - "Required GonkaGate model for v1; installer must confirm it is present in the authenticated /v1/models response before setup.", - ], - }, -] as const satisfies readonly CuratedModelRecord[]; - -export type CuratedModelRegistryRecord = - (typeof CURATED_MODEL_REGISTRY)[number]; - -export type CuratedModelKey = (typeof CURATED_MODEL_REGISTRY)[number]["key"]; - -export class UnsupportedCuratedModelError extends Error { - readonly code = "unsupported_curated_model"; - - constructor(readonly modelKey: string) { - super(`Unsupported curated model key: ${modelKey}`); - this.name = "UnsupportedCuratedModelError"; - } -} - -export function getValidatedModels(): CuratedModelRegistryRecord[] { - return CURATED_MODEL_REGISTRY.filter((model) => model.status === "validated"); -} - -export function getCandidateModels(): CuratedModelRegistryRecord[] { - return [...CURATED_MODEL_REGISTRY]; -} - -export function getRequiredGonkagateModelIds(): string[] { - return CURATED_MODEL_REGISTRY.map((model) => model.id); -} - -export function getRecommendedDefaultModel(): CuratedModelRegistryRecord { - const recommended = CURATED_MODEL_REGISTRY.filter( - (model) => model.recommendedDefault, - ); - - if (recommended.length !== 1) { - throw new Error( - `Expected exactly one recommended default model, found ${recommended.length}.`, - ); - } - - return recommended[0]; -} - -export function getCuratedModelByKey(key: string): CuratedModelRegistryRecord { - const model = CURATED_MODEL_REGISTRY.find( - (candidate) => candidate.key === key, - ); - - if (model === undefined) { - throw new UnsupportedCuratedModelError(key); - } - - return model; -} - -export function isCuratedModelKey(key: string): key is CuratedModelKey { - return CURATED_MODEL_REGISTRY.some((model) => model.key === key); -} diff --git a/src/install/contracts/blockers.ts b/src/install/contracts/blockers.ts index e627e11..bd7234c 100644 --- a/src/install/contracts/blockers.ts +++ b/src/install/contracts/blockers.ts @@ -5,7 +5,6 @@ export const PRD_INSTALL_BLOCKER_CODES = [ "managed_write_failed", "model_conflict", "validated_models_unavailable", - "required_models_unavailable", "secret_missing", "secret_shadowed_by_process_env", "secret_shadowed_by_project_env", diff --git a/src/install/contracts/install-flow.ts b/src/install/contracts/install-flow.ts index 7a2f42f..e79b4ff 100644 --- a/src/install/contracts/install-flow.ts +++ b/src/install/contracts/install-flow.ts @@ -1,11 +1,10 @@ -import type { CuratedModelKey } from "../../constants/models.js"; import type { InstallBlocker } from "./blockers.js"; export type InstallScope = "user" | "project"; export interface InstallFlowRequest { readonly scope?: InstallScope; - readonly modelKey?: CuratedModelKey; + readonly modelKey?: string; readonly yes: boolean; readonly json: boolean; readonly apiKeyStdin: boolean; @@ -39,7 +38,7 @@ export interface BaseInstallFlowResult { | "verification-warning"; readonly runtimeImplemented: boolean; readonly scope?: InstallScope; - readonly selectedModel?: CuratedModelKey; + readonly selectedModel?: string; readonly managedPaths: readonly ManagedPathSummary[]; readonly changed: boolean; readonly blockers: readonly InstallBlocker[]; diff --git a/src/install/gonkagate-client.ts b/src/install/gonkagate-client.ts index b1daf58..47b6b2b 100644 --- a/src/install/gonkagate-client.ts +++ b/src/install/gonkagate-client.ts @@ -3,7 +3,13 @@ import { InstallerError } from "./errors.js"; import type { InstallDependencies } from "./deps.js"; import { redactSecrets, redactUnknown } from "./redact.js"; +export interface GonkagateModel { + readonly id: string; + readonly name?: string; +} + export interface GonkagateModelList { + readonly models: readonly GonkagateModel[]; readonly modelIds: readonly string[]; } @@ -52,10 +58,13 @@ export async function fetchGonkagateModels( }; } + const models = extractModelsFromModelsResponse(response.body); + return { ok: true, models: { - modelIds: extractModelIdsFromModelsResponse(response.body), + models, + modelIds: models.map((model) => model.id), }, }; } catch (error) { @@ -72,7 +81,9 @@ export async function fetchGonkagateModels( } } -export function extractModelIdsFromModelsResponse(body: string): string[] { +export function extractModelsFromModelsResponse( + body: string, +): GonkagateModel[] { let parsed: unknown; try { @@ -93,10 +104,11 @@ export function extractModelIdsFromModelsResponse(body: string): string[] { ); } - const modelIds: string[] = []; + const modelsById = new Map(); for (const entry of data) { - const id = toRecord(entry).id; + const record = toRecord(entry); + const id = record.id; if (typeof id !== "string" || id.trim() === "") { throw modelAvailabilityError( @@ -104,10 +116,29 @@ export function extractModelIdsFromModelsResponse(body: string): string[] { ); } - modelIds.push(id); + const modelId = id.trim(); + if (!modelsById.has(modelId)) { + const name = record.name; + modelsById.set(modelId, { + id: modelId, + ...(typeof name === "string" && name.trim() !== "" + ? { name: name.trim() } + : {}), + }); + } + } + + if (modelsById.size === 0) { + throw modelAvailabilityError( + "GonkaGate /v1/models response did not return any models.", + ); } - return modelIds; + return [...modelsById.values()]; +} + +export function extractModelIdsFromModelsResponse(body: string): string[] { + return extractModelsFromModelsResponse(body).map((model) => model.id); } function modelAvailabilityError(message: string): InstallerError { diff --git a/src/install/index.ts b/src/install/index.ts index b0fbc81..a7189bd 100644 --- a/src/install/index.ts +++ b/src/install/index.ts @@ -1,5 +1,4 @@ import { QWEN_CODE_SETUP_CONTRACT } from "../constants/contract.js"; -import type { CuratedModelKey } from "../constants/models.js"; import type { InstallBlocker } from "./contracts/blockers.js"; import type { InstallBlockedResult, @@ -20,7 +19,6 @@ import { applyManagedWriteTransaction, rollbackManagedWrites, } from "./managed-write-transaction.js"; -import { enforceRequiredModelAvailability } from "./model-discovery.js"; import { readQwenSettings } from "./qwen-settings.js"; import { resolveInstallContext } from "./context.js"; import { readManagedSecretFromSettings } from "./secret-storage.js"; @@ -68,10 +66,7 @@ export async function runInstallFlow( ); } - const availability = enforceRequiredModelAvailability( - remoteModels.models.modelIds, - ); - const selection = await selectSetupModel(request, deps, availability); + const selection = await selectSetupModel(request, deps, remoteModels.models); if (!selection.ok) { return blockedResult(request, scope.scope, [selection.blocker]); @@ -81,10 +76,9 @@ export async function runInstallFlow( deps, paths: context.context.paths, scope: scope.scope, - selectedModelKey: selection.selectedModelKey, - selectedModelId: selection.selectedModel.id, + selectedModelId: selection.selectedModelId, secretValue: secret.secret.value, - models: availability.ok ? availability.catalog.requiredModels : [], + models: remoteModels.models.models, }); if (!writePlans.ok) { @@ -104,7 +98,7 @@ export async function runInstallFlow( status: "dry-run", runtimeImplemented: QWEN_CODE_SETUP_CONTRACT.runtimeImplemented, scope: scope.scope, - selectedModel: selection.selectedModelKey, + selectedModel: selection.selectedModelId, managedPaths: summary.managedPaths, changed: false, blockers: [], @@ -124,7 +118,8 @@ export async function runInstallFlow( deps, paths: context.context.paths, scope: scope.scope, - selectedModelId: selection.selectedModel.id, + selectedModelId: selection.selectedModelId, + managedModelIds: remoteModels.models.modelIds, }); if (!durableVerification.ok) { @@ -144,7 +139,7 @@ export async function runInstallFlow( const live = await verifyLiveIfRequested({ deps, verifyLive: request.verifyLive, - selectedModelId: selection.selectedModel.id, + selectedModelId: selection.selectedModelId, }); if (!live.ok) { @@ -154,7 +149,7 @@ export async function runInstallFlow( const base = { runtimeImplemented: QWEN_CODE_SETUP_CONTRACT.runtimeImplemented, scope: scope.scope, - selectedModel: selection.selectedModelKey, + selectedModel: selection.selectedModelId, managedPaths: writes.results.map((result) => ({ kind: result.kind, path: result.path, @@ -229,7 +224,7 @@ function blockedResult( status: "blocked", runtimeImplemented: QWEN_CODE_SETUP_CONTRACT.runtimeImplemented, scope, - selectedModel: request.modelKey as CuratedModelKey | undefined, + selectedModel: request.modelKey, managedPaths: [], changed: false, blockers, diff --git a/src/install/managed-config-mutations.ts b/src/install/managed-config-mutations.ts index 18168c3..e511d79 100644 --- a/src/install/managed-config-mutations.ts +++ b/src/install/managed-config-mutations.ts @@ -1,7 +1,7 @@ import { QWEN_CODE_SETUP_CONTRACT } from "../constants/contract.js"; -import type { CuratedModelRegistryRecord } from "../constants/models.js"; import type { InstallBlocker } from "./contracts/blockers.js"; import { createBlocker } from "./contracts/blockers.js"; +import type { GonkagateModel } from "./gonkagate-client.js"; import { createManagedProviderEntries, hasProviderConflict, @@ -28,7 +28,7 @@ export function mutateUserSettings( options: { readonly selectedModelId: string; readonly secretValue: string; - readonly models: readonly CuratedModelRegistryRecord[]; + readonly models: readonly GonkagateModel[]; }, ): ManagedConfigMutationResult { const root = toRecord(settings); diff --git a/src/install/managed-provider-config.ts b/src/install/managed-provider-config.ts index f205c44..c2e3c81 100644 --- a/src/install/managed-provider-config.ts +++ b/src/install/managed-provider-config.ts @@ -1,8 +1,5 @@ import { QWEN_CODE_SETUP_CONTRACT } from "../constants/contract.js"; -import { - getValidatedModels, - type CuratedModelRegistryRecord, -} from "../constants/models.js"; +import type { GonkagateModel } from "./gonkagate-client.js"; export const MANAGED_PROVIDER_DESCRIPTION = "Managed by @gonkagate/qwen-code-setup"; @@ -17,17 +14,14 @@ export interface ManagedOpenAiProviderEntry { } export function createManagedProviderEntries( - models: readonly CuratedModelRegistryRecord[] = getValidatedModels(), + models: readonly GonkagateModel[], ): ManagedOpenAiProviderEntry[] { return models.map((model) => ({ id: model.id, - name: `${model.label} via GonkaGate`, + name: `${model.name ?? model.id} via GonkaGate`, baseUrl: QWEN_CODE_SETUP_CONTRACT.canonicalBaseUrl, description: MANAGED_PROVIDER_DESCRIPTION, envKey: QWEN_CODE_SETUP_CONTRACT.qwenEnvKey, - ...(model.generationConfig === undefined - ? {} - : { generationConfig: model.generationConfig }), })); } diff --git a/src/install/model-discovery.ts b/src/install/model-discovery.ts deleted file mode 100644 index ddf82b1..0000000 --- a/src/install/model-discovery.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - getRequiredGonkagateModelIds, - getValidatedModels, - type CuratedModelRegistryRecord, -} from "../constants/models.js"; -import type { InstallBlocker } from "./contracts/blockers.js"; -import { createBlocker } from "./contracts/blockers.js"; - -export interface AvailableModelCatalog { - readonly requiredModels: readonly CuratedModelRegistryRecord[]; - readonly returnedModelIds: readonly string[]; - readonly ignoredModelIds: readonly string[]; - readonly pickerAllowed: true; - readonly mayWrite: true; -} - -export type ModelAvailabilityResult = - | { - readonly ok: true; - readonly catalog: AvailableModelCatalog; - } - | { - readonly ok: false; - readonly blocker: InstallBlocker; - readonly missingModelIds: readonly string[]; - readonly pickerAllowed: false; - readonly mayWrite: false; - }; - -export function enforceRequiredModelAvailability( - returnedModelIds: readonly string[], -): ModelAvailabilityResult { - const uniqueReturnedIds = [...new Set(returnedModelIds)]; - const requiredIds = getRequiredGonkagateModelIds(); - const missingModelIds = requiredIds.filter( - (id) => !uniqueReturnedIds.includes(id), - ); - - if (missingModelIds.length > 0) { - return { - ok: false, - blocker: createBlocker({ - code: "required_models_unavailable", - layer: "model-discovery", - message: `GonkaGate /v1/models did not return ${missingModelIds.length} required model id(s).`, - nextAction: - "Retry with a GonkaGate API key that has access to all required Qwen Code setup models.", - }), - missingModelIds, - pickerAllowed: false, - mayWrite: false, - }; - } - - const validatedModels = getValidatedModels(); - const requiredModels = validatedModels.filter((model) => - requiredIds.includes(model.id), - ); - - return { - ok: true, - catalog: { - requiredModels, - returnedModelIds: uniqueReturnedIds, - ignoredModelIds: uniqueReturnedIds.filter( - (id) => !requiredIds.includes(id), - ), - pickerAllowed: true, - mayWrite: true, - }, - }; -} diff --git a/src/install/prewrite-flow.ts b/src/install/prewrite-flow.ts index d729863..ade8507 100644 --- a/src/install/prewrite-flow.ts +++ b/src/install/prewrite-flow.ts @@ -1,5 +1,4 @@ import { QWEN_CODE_SETUP_CONTRACT } from "../constants/contract.js"; -import type { CuratedModelKey } from "../constants/models.js"; import type { InstallBlocker } from "./contracts/blockers.js"; import type { InstallBlockedResult, @@ -9,7 +8,6 @@ import type { } from "./contracts/install-flow.js"; import type { InstallDependencies } from "./deps.js"; import { fetchGonkagateModels } from "./gonkagate-client.js"; -import { enforceRequiredModelAvailability } from "./model-discovery.js"; import { createSecretStoragePlan } from "./secret-storage.js"; import { resolveGonkagateApiKey } from "./secrets.js"; import { selectSetupModel } from "./selection.js"; @@ -49,10 +47,7 @@ export async function runPrewriteInstallFlow( } satisfies InstallFailedResult; } - const availability = enforceRequiredModelAvailability( - remoteModels.models.modelIds, - ); - const selection = await selectSetupModel(request, deps, availability); + const selection = await selectSetupModel(request, deps, remoteModels.models); if (!selection.ok) { return blockedResult(request, [selection.blocker]); @@ -69,7 +64,7 @@ export async function runPrewriteInstallFlow( status: "blocked", runtimeImplemented: QWEN_CODE_SETUP_CONTRACT.runtimeImplemented, scope: request.scope, - selectedModel: selection.selectedModelKey, + selectedModel: selection.selectedModelId, managedPaths: [ { kind: "user-settings", @@ -101,7 +96,7 @@ function blockedResult( status: "blocked", runtimeImplemented: QWEN_CODE_SETUP_CONTRACT.runtimeImplemented, scope: request.scope, - selectedModel: request.modelKey as CuratedModelKey | undefined, + selectedModel: request.modelKey, managedPaths: [], changed: false, blockers, diff --git a/src/install/qwen-compatibility.ts b/src/install/qwen-compatibility.ts index 5581915..7195955 100644 --- a/src/install/qwen-compatibility.ts +++ b/src/install/qwen-compatibility.ts @@ -1,5 +1,4 @@ import { QWEN_CODE_SETUP_CONTRACT } from "../constants/contract.js"; -import { getRequiredGonkagateModelIds } from "../constants/models.js"; import type { InstallBlocker } from "./contracts/blockers.js"; import { createBlocker } from "./contracts/blockers.js"; @@ -94,11 +93,10 @@ export function projectModelProvidersCanHideManagedCatalog( projectSettings, trustedProject, ); + const userManagedIds = getOpenAiProviderIds(userSettings); const effectiveIds = getOpenAiProviderIds(effective); - return getRequiredGonkagateModelIds().some( - (id) => !effectiveIds.includes(id), - ); + return userManagedIds.some((id) => !effectiveIds.includes(id)); } export function createProjectModelProvidersOverrideBlocker(): InstallBlocker { diff --git a/src/install/selection.ts b/src/install/selection.ts index 47e20c6..59e6625 100644 --- a/src/install/selection.ts +++ b/src/install/selection.ts @@ -1,17 +1,7 @@ -import { - UnsupportedCuratedModelError, - getCuratedModelByKey, - getRecommendedDefaultModel, - type CuratedModelKey, - type CuratedModelRegistryRecord, -} from "../constants/models.js"; import type { InstallBlocker } from "./contracts/blockers.js"; import { createBlocker } from "./contracts/blockers.js"; import type { InstallDependencies } from "./deps.js"; -import type { - AvailableModelCatalog, - ModelAvailabilityResult, -} from "./model-discovery.js"; +import type { GonkagateModel, GonkagateModelList } from "./gonkagate-client.js"; export interface ModelSelectionRequest { readonly modelKey?: string; @@ -21,8 +11,8 @@ export interface ModelSelectionRequest { export type ModelSelectionResult = | { readonly ok: true; - readonly selectedModel: CuratedModelRegistryRecord; - readonly selectedModelKey: CuratedModelKey; + readonly selectedModel: GonkagateModel; + readonly selectedModelId: string; readonly pickerRendered: boolean; readonly summary: Record; } @@ -35,96 +25,79 @@ export type ModelSelectionResult = export async function selectSetupModel( request: ModelSelectionRequest, deps: InstallDependencies, - availability: ModelAvailabilityResult, + catalog: GonkagateModelList, ): Promise { - if (!availability.ok) { - return { - ok: false, - blocker: availability.blocker, - pickerRendered: false, - }; - } - - const catalog = availability.catalog; - if (request.modelKey !== undefined) { - return selectExplicitModel(request.modelKey, catalog); + return selectExplicitModel(request.modelKey, catalog, false); } if (request.yes) { - return selectAvailableModel(getRecommendedDefaultModel(), catalog, false); + return selectAvailableModel(catalog.models[0], catalog, false); } - const selectedKey = await deps.prompts.select( + const selectedChoice = await deps.prompts.select( "GonkaGate model", - catalog.requiredModels.map((model) => model.key), + catalog.models.map((model) => formatModelChoice(model)), + ); + const selectedModel = catalog.models.find( + (model) => formatModelChoice(model) === selectedChoice, ); - return selectExplicitModel(selectedKey, catalog, true); + if (selectedModel === undefined) { + return unavailableSelection(selectedChoice); + } + + return selectAvailableModel(selectedModel, catalog, true); } function selectExplicitModel( - modelKey: string, - catalog: AvailableModelCatalog, + modelId: string, + catalog: GonkagateModelList, pickerRendered = false, ): ModelSelectionResult { - try { - return selectAvailableModel( - getCuratedModelByKey(modelKey), - catalog, - pickerRendered, - ); - } catch (error) { - if (error instanceof UnsupportedCuratedModelError) { - return { - ok: false, - blocker: createBlocker({ - code: "validated_models_unavailable", - layer: "model-selection", - message: `Unsupported curated model key "${error.modelKey}".`, - nextAction: - "Choose one of the curated model keys exposed by the setup registry.", - }), - pickerRendered: false, - }; - } + const model = catalog.models.find((candidate) => candidate.id === modelId); - throw error; + if (model === undefined) { + return unavailableSelection(modelId); } + + return selectAvailableModel(model, catalog, pickerRendered); } function selectAvailableModel( - model: CuratedModelRegistryRecord, - catalog: AvailableModelCatalog, + model: GonkagateModel, + catalog: GonkagateModelList, pickerRendered: boolean, ): ModelSelectionResult { - if ( - !catalog.requiredModels.some((candidate) => candidate.key === model.key) - ) { - return { - ok: false, - blocker: createBlocker({ - code: "required_models_unavailable", - layer: "model-selection", - message: - "The selected curated model was not present in the authenticated model availability catalog.", - nextAction: - "Retry after GonkaGate /v1/models returns the full required model set.", - }), - pickerRendered: false, - }; - } - return { ok: true, selectedModel: model, - selectedModelKey: model.key, + selectedModelId: model.id, pickerRendered, summary: { - selectedModel: model.key, + selectedModel: model.id, selectedModelId: model.id, - supportedModels: catalog.requiredModels.map((candidate) => candidate.key), - ignoredModelIds: catalog.ignoredModelIds, + availableModels: catalog.modelIds, }, }; } + +function unavailableSelection(modelId: string): ModelSelectionResult { + return { + ok: false, + blocker: createBlocker({ + code: "validated_models_unavailable", + layer: "model-selection", + message: `Requested model "${modelId}" was not returned by authenticated GonkaGate /v1/models.`, + nextAction: + "Choose one of the model ids returned by GonkaGate /v1/models.", + }), + pickerRendered: false, + }; +} + +function formatModelChoice(model: GonkagateModel): string { + return model.name === undefined || model.name === model.id + ? model.id + : `${model.name} (${model.id})`; +} diff --git a/src/install/state.ts b/src/install/state.ts index 250f551..8e77374 100644 --- a/src/install/state.ts +++ b/src/install/state.ts @@ -1,8 +1,4 @@ import { QWEN_CODE_SETUP_CONTRACT } from "../constants/contract.js"; -import { - getRequiredGonkagateModelIds, - type CuratedModelKey, -} from "../constants/models.js"; import type { InstallScope } from "./contracts/install-flow.js"; import { stringifyJsonObject } from "./jsonc.js"; @@ -12,7 +8,7 @@ export interface InstallState { readonly installerVersion: string; readonly auditedQwenVersion: string; readonly scope: InstallScope; - readonly selectedModelKey: CuratedModelKey; + readonly selectedModelId: string; readonly managedModelIds: readonly string[]; readonly userSettingsPath: string; readonly projectSettingsPath?: string; @@ -22,7 +18,8 @@ export interface InstallState { export function createInstallState(input: { readonly scope: InstallScope; - readonly selectedModelKey: CuratedModelKey; + readonly selectedModelId: string; + readonly managedModelIds: readonly string[]; readonly userSettingsPath: string; readonly projectSettingsPath?: string; readonly verifiedAt: string; @@ -31,8 +28,8 @@ export function createInstallState(input: { installerVersion: QWEN_CODE_SETUP_CONTRACT.packageVersion, auditedQwenVersion: QWEN_CODE_SETUP_CONTRACT.latestAuditedQwenCodeVersion, scope: input.scope, - selectedModelKey: input.selectedModelKey, - managedModelIds: getRequiredGonkagateModelIds(), + selectedModelId: input.selectedModelId, + managedModelIds: input.managedModelIds, userSettingsPath: input.userSettingsPath, ...(input.projectSettingsPath === undefined ? {} diff --git a/src/install/verify-effective.ts b/src/install/verify-effective.ts index 8bddee3..3552d79 100644 --- a/src/install/verify-effective.ts +++ b/src/install/verify-effective.ts @@ -1,5 +1,4 @@ import { QWEN_CODE_SETUP_CONTRACT } from "../constants/contract.js"; -import { getRequiredGonkagateModelIds } from "../constants/models.js"; import type { InstallBlocker } from "./contracts/blockers.js"; import { createBlocker } from "./contracts/blockers.js"; import type { InstallScope } from "./contracts/install-flow.js"; @@ -21,6 +20,7 @@ export async function verifyDurableInstall(input: { readonly paths: ResolvedQwenPaths; readonly scope: InstallScope; readonly selectedModelId: string; + readonly managedModelIds: readonly string[]; }): Promise { const blockers: InstallBlocker[] = []; const userSettings = await readQwenSettings( @@ -28,7 +28,13 @@ export async function verifyDurableInstall(input: { input.paths.userSettingsPath, ); - blockers.push(...verifyUserSettings(userSettings, input.selectedModelId)); + blockers.push( + ...verifyUserSettings( + userSettings, + input.selectedModelId, + input.managedModelIds, + ), + ); blockers.push( ...(await verifyPosixPermissions( input.deps, @@ -117,15 +123,16 @@ async function verifyPosixPermissions( function verifyUserSettings( settings: Record, selectedModelId: string, + managedModelIds: readonly string[], ): InstallBlocker[] { const blockers: InstallBlocker[] = []; const providerIds = getOpenAiProviderIds(settings); - for (const requiredId of getRequiredGonkagateModelIds()) { - if (!providerIds.includes(requiredId)) { + for (const managedModelId of managedModelIds) { + if (!providerIds.includes(managedModelId)) { blockers.push( verificationBlocker( - `User settings are missing managed provider ${requiredId}.`, + `User settings are missing managed provider ${managedModelId}.`, ), ); } diff --git a/src/install/write-target-config.ts b/src/install/write-target-config.ts index c63b5a6..fc57b43 100644 --- a/src/install/write-target-config.ts +++ b/src/install/write-target-config.ts @@ -1,10 +1,7 @@ -import type { - CuratedModelRegistryRecord, - CuratedModelKey, -} from "../constants/models.js"; import type { InstallBlocker } from "./contracts/blockers.js"; import type { InstallScope } from "./contracts/install-flow.js"; import type { InstallDependencies } from "./deps.js"; +import type { GonkagateModel } from "./gonkagate-client.js"; import type { ResolvedQwenPaths } from "./paths.js"; import { readQwenSettings, serializeQwenSettings } from "./qwen-settings.js"; import { @@ -28,10 +25,9 @@ export async function createWriteTargetConfigPlans(input: { readonly deps: InstallDependencies; readonly paths: ResolvedQwenPaths; readonly scope: InstallScope; - readonly selectedModelKey: CuratedModelKey; readonly selectedModelId: string; readonly secretValue: string; - readonly models: readonly CuratedModelRegistryRecord[]; + readonly models: readonly GonkagateModel[]; }): Promise { const userSettings = await readQwenSettings( input.deps.fs, @@ -88,7 +84,8 @@ export async function createWriteTargetConfigPlans(input: { contents: serializeInstallState( createInstallState({ scope: input.scope, - selectedModelKey: input.selectedModelKey, + selectedModelId: input.selectedModelId, + managedModelIds: input.models.map((model) => model.id), userSettingsPath: input.paths.userSettingsPath, ...(input.scope === "project" ? { projectSettingsPath: input.paths.projectSettingsPath } diff --git a/src/install/write.ts b/src/install/write.ts index 9673aac..c0e45b3 100644 --- a/src/install/write.ts +++ b/src/install/write.ts @@ -1,9 +1,6 @@ -import type { - CuratedModelRegistryRecord, - CuratedModelKey, -} from "../constants/models.js"; import type { InstallScope } from "./contracts/install-flow.js"; import type { InstallDependencies } from "./deps.js"; +import type { GonkagateModel } from "./gonkagate-client.js"; import type { ResolvedQwenPaths } from "./paths.js"; import { applyManagedWriteTransaction } from "./managed-write-transaction.js"; import { createWriteTargetConfigPlans } from "./write-target-config.js"; @@ -12,10 +9,9 @@ export async function writeManagedQwenSettings(input: { readonly deps: InstallDependencies; readonly paths: ResolvedQwenPaths; readonly scope: InstallScope; - readonly selectedModelKey: CuratedModelKey; readonly selectedModelId: string; readonly secretValue: string; - readonly models: readonly CuratedModelRegistryRecord[]; + readonly models: readonly GonkagateModel[]; }) { const plans = await createWriteTargetConfigPlans(input); diff --git a/test/cli.test.ts b/test/cli.test.ts index 4fb1020..d5ed9b3 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { runCli } from "../src/cli.js"; import { QWEN_CODE_SETUP_CONTRACT } from "../src/constants/contract.js"; -import { getRequiredGonkagateModelIds } from "../src/constants/models.js"; +import { LIVE_MODELS, modelsResponse } from "./install/model-fixtures.js"; import { createFakeInstallDependencies } from "./install/test-deps.js"; function createOutput() { @@ -42,9 +42,7 @@ function cliDeps( request: async () => ({ status: 200, headers: {}, - body: JSON.stringify({ - data: getRequiredGonkagateModelIds().map((id) => ({ id })), - }), + body: modelsResponse(), }), }, ...overrides, @@ -90,18 +88,14 @@ test("CLI renders machine-readable install state", async () => { const payload = JSON.parse(stdout.text()) as { packageName?: string; runtimeImplemented?: boolean; - supportedModels?: string[]; status?: string; + selectedModel?: string; }; assert.equal(payload.packageName, "@gonkagate/qwen-code-setup"); assert.equal(payload.runtimeImplemented, true); assert.equal(payload.status, "success"); - assert.deepEqual(payload.supportedModels, [ - "qwen3-235b-a22b-instruct-2507-fp8", - "kimi-k2.6", - "minimax-m2.7", - ]); + assert.equal(payload.selectedModel, LIVE_MODELS[0].id); assert.doesNotMatch(stdout.text(), /gp-cli-secret/); }); @@ -117,7 +111,7 @@ test("CLI supports JSON dry-run with project scope and stdin secret", async () = "--scope", "project", "--model", - "minimax-m2.7", + "minimaxai/minimax-m2.7", "--yes", "--dry-run", "--verify-live", @@ -147,7 +141,7 @@ test("CLI supports JSON dry-run with project scope and stdin secret", async () = assert.equal(payload.status, "dry-run"); assert.equal(payload.scope, "project"); - assert.equal(payload.selectedModel, "minimax-m2.7"); + assert.equal(payload.selectedModel, "minimaxai/minimax-m2.7"); assert.deepEqual( payload.managedPaths?.map((path) => path.kind), ["user-settings", "project-settings", "install-state"], @@ -155,7 +149,7 @@ test("CLI supports JSON dry-run with project scope and stdin secret", async () = assert.doesNotMatch(stdout.text(), /gp-stdin-cli-secret/); }); -test("CLI uses the recommended model default with --yes", async () => { +test("CLI uses the first live model default with --yes", async () => { const stdout = createOutput(); const stderr = createOutput(); @@ -172,7 +166,7 @@ test("CLI uses the recommended model default with --yes", async () => { assert.equal(stderr.text(), ""); const payload = JSON.parse(stdout.text()) as { selectedModel?: string }; - assert.equal(payload.selectedModel, "qwen3-235b-a22b-instruct-2507-fp8"); + assert.equal(payload.selectedModel, LIVE_MODELS[0].id); }); test("CLI rejects forbidden secret and custom-provider options before execution", async () => { @@ -192,7 +186,7 @@ test("CLI rejects forbidden secret and custom-provider options before execution" } }); -test("CLI rejects invalid scope and unsupported curated model keys", async () => { +test("CLI rejects invalid scope before execution and unavailable live model after fetch", async () => { const invalidScopeStdout = createOutput(); const invalidScopeStderr = createOutput(); @@ -211,15 +205,16 @@ test("CLI rejects invalid scope and unsupported curated model keys", async () => const invalidModelStderr = createOutput(); const invalidModel = await runCli( - ["node", "gonkagate-qwen-code", "--model", "raw/custom-model"], + ["node", "gonkagate-qwen-code", "--model", "raw/custom-model", "--yes"], { stdout: invalidModelStdout.stream, stderr: invalidModelStderr.stream, }, + cliDeps(), ); - assert.equal(invalidModel.exitCode, 2); - assert.match(invalidModelStderr.text(), /Unsupported curated model key/); + assert.equal(invalidModel.exitCode, 1); + assert.match(invalidModelStderr.text(), /validated_models_unavailable/); }); test("CLI renders parse errors as JSON on stdout in JSON mode", async () => { @@ -247,7 +242,7 @@ test("CLI renders parse errors as JSON on stdout in JSON mode", async () => { assert.match(payload.message ?? "", /gp-\*\*\*/); }); -test("CLI renders common runtime blockers without leaking secrets", async () => { +test("CLI renders live model fetch failures without leaking secrets", async () => { const stdout = createOutput(); const stderr = createOutput(); @@ -262,9 +257,7 @@ test("CLI renders common runtime blockers without leaking secrets", async () => request: async () => ({ status: 200, headers: {}, - body: JSON.stringify({ - data: [{ id: "qwen/qwen3-235b-a22b-instruct-2507-fp8" }], - }), + body: JSON.stringify({ data: [] }), }), }, }), @@ -272,7 +265,7 @@ test("CLI renders common runtime blockers without leaking secrets", async () => assert.equal(result.exitCode, 1); assert.equal(stdout.text(), ""); - assert.match(stderr.text(), /required_models_unavailable/); + assert.match(stderr.text(), /validated_models_unavailable/); assert.doesNotMatch(stderr.text(), /gp-cli-secret/); }); diff --git a/test/docs-contract.test.ts b/test/docs-contract.test.ts index ca1090c..20b0a10 100644 --- a/test/docs-contract.test.ts +++ b/test/docs-contract.test.ts @@ -52,8 +52,8 @@ test("product spec preserves Qwen-specific runtime contract", () => { /security\.auth\.selectedType = "openai"/, /envKey/, /`qwen auth` has been removed/, - /all three required GonkaGate models/, - /required_models_unavailable/, + /live GonkaGate model catalog/, + /validated_models_unavailable/, /stable version `0\.18\.0`/, /QWEN_CODE_SYSTEM_DEFAULTS_PATH/, /project `modelProviders` would hide/, @@ -88,19 +88,17 @@ test("security docs forbid unsafe secret paths", () => { ]); }); -test("model validation docs require authenticated availability for all supported models", () => { +test("model validation docs require authenticated live model discovery", () => { const modelValidation = readText("docs/model-validation.md"); assertMatchesAll(modelValidation, [ - /all three GonkaGate models currently available/, + /authenticated\s+`GET https:\/\/api\.gonkagate\.com\/v1\/models`/, /https:\/\/api\.gonkagate\.com\/v1\/models/, - /required_models_unavailable/, + /validated_models_unavailable/, /modelProviders\.openai\[\]/, - /qwen\/qwen3-235b-a22b-instruct-2507-fp8/, - /moonshotai\/Kimi-K2\.6/, - /minimaxai\/minimax-m2\.7/, - /recommended default/, - /Unsupported curated model keys/, + /first fetched model/, + /No checked-in GonkaGate model id list/, + /`--model`/, ]); }); diff --git a/test/install/config.test.ts b/test/install/config.test.ts index fdbd72e..890d6c7 100644 --- a/test/install/config.test.ts +++ b/test/install/config.test.ts @@ -1,6 +1,5 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { getValidatedModels } from "../../src/constants/models.js"; import { InstallerError } from "../../src/install/errors.js"; import { parseJsoncObject } from "../../src/install/jsonc.js"; import { @@ -9,6 +8,7 @@ import { } from "../../src/install/managed-config-mutations.js"; import { createManagedProviderEntries } from "../../src/install/managed-provider-config.js"; import { readQwenSettings } from "../../src/install/qwen-settings.js"; +import { LIVE_MODELS, UNKNOWN_LIVE_MODEL } from "./model-fixtures.js"; import { createMemoryFileSystem } from "./test-deps.js"; test("Qwen settings parser covers missing, JSONC, malformed, and non-object input", async () => { @@ -47,7 +47,7 @@ test("managed user mutation preserves unrelated settings and writes all managed { selectedModelId: "minimaxai/minimax-m2.7", secretValue: "gp-user-secret", - models: getValidatedModels(), + models: [...LIVE_MODELS, UNKNOWN_LIVE_MODEL], }, ); @@ -68,8 +68,11 @@ test("managed user mutation preserves unrelated settings and writes all managed "gp-user-secret", ); const providers = getOpenAiProviders(result.settings); - assert.equal(providers.length, 4); - for (const managed of createManagedProviderEntries()) { + assert.equal(providers.length, 5); + for (const managed of createManagedProviderEntries([ + ...LIVE_MODELS, + UNKNOWN_LIVE_MODEL, + ])) { assert.ok(providers.some((provider) => provider.id === managed.id)); } } @@ -81,7 +84,7 @@ test("managed user mutation replaces prior managed entries without duplicates", { selectedModelId: "qwen/qwen3-235b-a22b-instruct-2507-fp8", secretValue: "gp-old", - models: getValidatedModels(), + models: LIVE_MODELS, }, ); @@ -91,7 +94,7 @@ test("managed user mutation replaces prior managed entries without duplicates", const rerun = mutateUserSettings(previous.settings, { selectedModelId: "moonshotai/Kimi-K2.6", secretValue: "gp-new", - models: getValidatedModels(), + models: LIVE_MODELS, }); assert.equal(rerun.ok, true); @@ -126,7 +129,7 @@ test("same-id unmanaged provider conflict blocks mutation", () => { { selectedModelId: "minimaxai/minimax-m2.7", secretValue: "gp-secret", - models: getValidatedModels(), + models: LIVE_MODELS, }, ); diff --git a/test/install/dry-run.test.ts b/test/install/dry-run.test.ts index 930b3a0..e55ea03 100644 --- a/test/install/dry-run.test.ts +++ b/test/install/dry-run.test.ts @@ -1,13 +1,13 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { getValidatedModels } from "../../src/constants/models.js"; import { createDryRunPlanSummary } from "../../src/install/dry-run.js"; import { createWriteTargetConfigPlans } from "../../src/install/write-target-config.js"; +import type { ResolvedQwenPaths } from "../../src/install/paths.js"; +import { LIVE_MODELS } from "./model-fixtures.js"; import { createFakeInstallDependencies, createMemoryFileSystem, } from "./test-deps.js"; -import type { ResolvedQwenPaths } from "../../src/install/paths.js"; function paths(): ResolvedQwenPaths { return { @@ -42,10 +42,9 @@ test("dry-run summarizes planned writes without touching filesystem or leaking s }), paths: paths(), scope: "project", - selectedModelKey: "kimi-k2.6", selectedModelId: "moonshotai/Kimi-K2.6", secretValue: "gp-dry-run-secret", - models: getValidatedModels(), + models: LIVE_MODELS, }); assert.equal(result.ok, true); diff --git a/test/install/errors.test.ts b/test/install/errors.test.ts index 639f4d8..6da2a9e 100644 --- a/test/install/errors.test.ts +++ b/test/install/errors.test.ts @@ -15,7 +15,6 @@ test("PRD blocker codes are stable typed values", () => { "managed_write_failed", "model_conflict", "validated_models_unavailable", - "required_models_unavailable", "secret_missing", "secret_shadowed_by_process_env", "secret_shadowed_by_project_env", @@ -29,16 +28,16 @@ test("PRD blocker codes are stable typed values", () => { test("blockers and installer errors preserve stable codes and causes", () => { const blocker = createBlocker({ - code: "required_models_unavailable", + code: "validated_models_unavailable", layer: "models", - message: "Missing required models.", + message: "Model catalog unavailable.", }); const cause = new Error("low-level"); const error = new InstallerError("managed_write_failed", "write failed", { cause, }); - assert.equal(blocker.code, "required_models_unavailable"); + assert.equal(blocker.code, "validated_models_unavailable"); assert.equal(error.code, "managed_write_failed"); assert.equal(error.cause, cause); assert.equal(toInstallerError(error), error); diff --git a/test/install/gonkagate-client.test.ts b/test/install/gonkagate-client.test.ts index 6d988be..863084a 100644 --- a/test/install/gonkagate-client.test.ts +++ b/test/install/gonkagate-client.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { HttpRequest } from "../../src/install/deps.js"; import { - extractModelIdsFromModelsResponse, + extractModelsFromModelsResponse, fetchGonkagateModels, } from "../../src/install/gonkagate-client.js"; import { createFakeInstallDependencies } from "./test-deps.js"; @@ -91,6 +91,18 @@ test("GonkaGate models client rejects invalid, oversized, and malformed response }), "gp-client-secret", ); + const empty = await fetchGonkagateModels( + createFakeInstallDependencies({ + http: { + request: async () => ({ + status: 200, + headers: {}, + body: JSON.stringify({ data: [] }), + }), + }, + }), + "gp-client-secret", + ); const oversized = await fetchGonkagateModels( createFakeInstallDependencies({ http: { @@ -106,14 +118,20 @@ test("GonkaGate models client rejects invalid, oversized, and malformed response assert.equal(invalidJson.ok, false); assert.equal(invalidSchema.ok, false); + assert.equal(empty.ok, false); assert.equal(oversized.ok, false); }); test("models response parser extracts ids and ignores extra object fields", () => { assert.deepEqual( - extractModelIdsFromModelsResponse( - JSON.stringify({ data: [{ id: "model-a", owned_by: "gonkagate" }] }), + extractModelsFromModelsResponse( + JSON.stringify({ + data: [ + { id: "model-a", name: "Model A", owned_by: "gonkagate" }, + { id: "model-a", name: "Duplicate Model A" }, + ], + }), ), - ["model-a"], + [{ id: "model-a", name: "Model A" }], ); }); diff --git a/test/install/install-flow.test.ts b/test/install/install-flow.test.ts index 77f5fff..a10a480 100644 --- a/test/install/install-flow.test.ts +++ b/test/install/install-flow.test.ts @@ -1,8 +1,12 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { getRequiredGonkagateModelIds } from "../../src/constants/models.js"; import type { FileSystemDeps, HttpRequest } from "../../src/install/deps.js"; import { runInstallFlow } from "../../src/install/index.js"; +import { + LIVE_MODELS_WITH_UNKNOWN, + UNKNOWN_LIVE_MODEL, + modelsResponse, +} from "./model-fixtures.js"; import { createFakeInstallDependencies, createMemoryFileSystem, @@ -41,9 +45,7 @@ function depsForFlow( request: async () => ({ status: 200, headers: {}, - body: JSON.stringify({ - data: getRequiredGonkagateModelIds().map((id) => ({ id })), - }), + body: modelsResponse(LIVE_MODELS_WITH_UNKNOWN), }), }, ...overrides, @@ -53,29 +55,29 @@ function depsForFlow( test("install flow writes user settings and returns success", async () => { const fs = createMemoryFileSystem(); const result = await runInstallFlow( - request({ modelKey: "kimi-k2.6" }), + request({ modelKey: UNKNOWN_LIVE_MODEL.id }), depsForFlow({ fs }), ); assert.equal(result.ok, true); assert.equal(result.status, "success"); - assert.equal(result.selectedModel, "kimi-k2.6"); + assert.equal(result.selectedModel, UNKNOWN_LIVE_MODEL.id); assert.equal(result.changed, true); assert.match( fs.files.get("/tmp/qwen-home/.qwen/settings.json") ?? "", - /moonshotai\/Kimi-K2\.6/, + /future\/network-model/, ); assert.match( fs.files.get("/tmp/qwen-home/.gonkagate/qwen-code/install-state.json") ?? "", - /kimi-k2\.6/, + /future\/network-model/, ); }); test("install flow writes project activation only and no project secret", async () => { const fs = createMemoryFileSystem(); const result = await runInstallFlow( - request({ scope: "project", modelKey: "minimax-m2.7" }), + request({ scope: "project", modelKey: "minimaxai/minimax-m2.7" }), depsForFlow({ fs }), ); @@ -147,7 +149,7 @@ test("install flow dry-run reports current-session shadowing without writing", a ); }); -test("install flow missing required models blocks before writes and picker", async () => { +test("install flow empty live model catalog fails before writes and picker", async () => { let writes = 0; let pickerCalls = 0; const memory = createMemoryFileSystem(); @@ -173,17 +175,17 @@ test("install flow missing required models blocks before writes and picker", asy request: async () => ({ status: 200, headers: {}, - body: JSON.stringify({ - data: [{ id: "qwen/qwen3-235b-a22b-instruct-2507-fp8" }], - }), + body: JSON.stringify({ data: [] }), }), }, }), ); assert.equal(result.ok, false); - assert.equal(result.status, "blocked"); - assert.equal(result.blockers[0]?.code, "required_models_unavailable"); + assert.equal(result.status, "failed"); + if (!result.ok && result.status === "failed") { + assert.equal(result.errorCode, "validated_models_unavailable"); + } assert.equal(writes, 0); assert.equal(pickerCalls, 0); }); @@ -209,9 +211,7 @@ test("install flow can reuse existing managed key on --yes rerun", async () => { return { status: 200, headers: {}, - body: JSON.stringify({ - data: getRequiredGonkagateModelIds().map((id) => ({ id })), - }), + body: modelsResponse(), }; }, }, diff --git a/test/install/json-output.test.ts b/test/install/json-output.test.ts index 327f1f9..ea245d5 100644 --- a/test/install/json-output.test.ts +++ b/test/install/json-output.test.ts @@ -6,7 +6,7 @@ import type { InstallFlowResult } from "../../src/install/contracts/install-flow const base = { runtimeImplemented: true, scope: "user" as const, - selectedModel: "kimi-k2.6" as const, + selectedModel: "moonshotai/Kimi-K2.6", managedPaths: [ { kind: "user-settings" as const, @@ -20,7 +20,7 @@ const base = { const options = { scope: "user" as const, - modelKey: "kimi-k2.6" as const, + modelKey: "moonshotai/Kimi-K2.6", yes: true, json: true, apiKeyStdin: false, @@ -62,9 +62,9 @@ test("JSON renderer emits stable fields for all installer result variants", () = status: "blocked", blockers: [ { - code: "required_models_unavailable", + code: "validated_models_unavailable", message: "missing", - layer: "model-discovery", + layer: "model-selection", }, ], }, @@ -86,7 +86,7 @@ test("JSON renderer emits stable fields for all installer result variants", () = assert.equal(payload.ok, result.ok); assert.equal(payload.status, result.status); assert.equal(payload.scope, "user"); - assert.equal(payload.selectedModel, "kimi-k2.6"); + assert.equal(payload.selectedModel, "moonshotai/Kimi-K2.6"); assert.ok(Array.isArray(payload.managedPaths)); assert.ok(Array.isArray(payload.blockers)); assert.ok(Array.isArray(payload.warnings)); diff --git a/test/install/model-discovery.test.ts b/test/install/model-discovery.test.ts deleted file mode 100644 index 42b6602..0000000 --- a/test/install/model-discovery.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { getRequiredGonkagateModelIds } from "../../src/constants/models.js"; -import { enforceRequiredModelAvailability } from "../../src/install/model-discovery.js"; - -test("model discovery requires all curated model ids and ignores extras", () => { - const result = enforceRequiredModelAvailability([ - ...getRequiredGonkagateModelIds(), - "future/model", - ]); - - assert.equal(result.ok, true); - if (result.ok) { - assert.deepEqual( - result.catalog.requiredModels.map((model) => model.id), - getRequiredGonkagateModelIds(), - ); - assert.deepEqual(result.catalog.ignoredModelIds, ["future/model"]); - assert.equal(result.catalog.pickerAllowed, true); - assert.equal(result.catalog.mayWrite, true); - } -}); - -test("model discovery blocks missing required models and disables picker/writes", () => { - const result = enforceRequiredModelAvailability([ - "qwen/qwen3-235b-a22b-instruct-2507-fp8", - "qwen/qwen3-235b-a22b-instruct-2507-fp8", - "future/model", - ]); - - assert.equal(result.ok, false); - if (!result.ok) { - assert.equal(result.blocker.code, "required_models_unavailable"); - assert.deepEqual(result.missingModelIds, [ - "moonshotai/Kimi-K2.6", - "minimaxai/minimax-m2.7", - ]); - assert.equal(result.pickerAllowed, false); - assert.equal(result.mayWrite, false); - } -}); diff --git a/test/install/model-fixtures.ts b/test/install/model-fixtures.ts new file mode 100644 index 0000000..28519ab --- /dev/null +++ b/test/install/model-fixtures.ts @@ -0,0 +1,23 @@ +import type { GonkagateModel } from "../../src/install/gonkagate-client.js"; + +export const LIVE_MODELS = [ + { id: "qwen/qwen3-235b-a22b-instruct-2507-fp8", name: "Qwen3 235B" }, + { id: "moonshotai/Kimi-K2.6", name: "Kimi K2.6" }, + { id: "minimaxai/minimax-m2.7", name: "MiniMax M2.7" }, +] satisfies readonly GonkagateModel[]; + +export const UNKNOWN_LIVE_MODEL = { + id: "future/network-model", + name: "Future Network Model", +} satisfies GonkagateModel; + +export const LIVE_MODELS_WITH_UNKNOWN = [ + ...LIVE_MODELS, + UNKNOWN_LIVE_MODEL, +] satisfies readonly GonkagateModel[]; + +export function modelsResponse( + models: readonly GonkagateModel[] = LIVE_MODELS, +): string { + return JSON.stringify({ data: models }); +} diff --git a/test/install/models.test.ts b/test/install/models.test.ts deleted file mode 100644 index a3275ca..0000000 --- a/test/install/models.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { - CURATED_MODEL_REGISTRY, - UnsupportedCuratedModelError, - getCuratedModelByKey, - getRecommendedDefaultModel, - getRequiredGonkagateModelIds, - getValidatedModels, - isCuratedModelKey, -} from "../../src/constants/models.js"; - -test("curated registry contains exactly the v1 required validated models", () => { - assert.equal(CURATED_MODEL_REGISTRY.length, 3); - assert.deepEqual(getRequiredGonkagateModelIds(), [ - "qwen/qwen3-235b-a22b-instruct-2507-fp8", - "moonshotai/Kimi-K2.6", - "minimaxai/minimax-m2.7", - ]); - assert.deepEqual( - getValidatedModels().map((model) => model.id), - getRequiredGonkagateModelIds(), - ); -}); - -test("curated registry records audit metadata for runtime selection", () => { - const recommended = getRecommendedDefaultModel(); - - assert.equal(recommended.key, "qwen3-235b-a22b-instruct-2507-fp8"); - assert.equal( - CURATED_MODEL_REGISTRY.filter((model) => model.recommendedDefault).length, - 1, - ); - - for (const model of CURATED_MODEL_REGISTRY) { - assert.equal(model.status, "validated"); - assert.equal(model.validationEvidenceDate, "2026-06-12"); - assert.ok(model.qwenCompatibilityNotes.length > 0); - assert.ok(model.generationConfig); - } -}); - -test("curated model key validation rejects arbitrary model ids", () => { - assert.equal(isCuratedModelKey("minimax-m2.7"), true); - assert.equal(isCuratedModelKey("minimaxai/minimax-m2.7"), false); - - assert.throws( - () => getCuratedModelByKey("minimaxai/minimax-m2.7"), - (error) => - error instanceof UnsupportedCuratedModelError && - error.code === "unsupported_curated_model", - ); -}); diff --git a/test/install/prewrite-flow.test.ts b/test/install/prewrite-flow.test.ts index 29c5259..37db3d2 100644 --- a/test/install/prewrite-flow.test.ts +++ b/test/install/prewrite-flow.test.ts @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { getRequiredGonkagateModelIds } from "../../src/constants/models.js"; import type { FileSystemDeps, HttpRequest } from "../../src/install/deps.js"; import { runPrewriteInstallFlow } from "../../src/install/prewrite-flow.js"; +import { modelsResponse } from "./model-fixtures.js"; import { createFakeInstallDependencies, createMemoryFileSystem, @@ -19,7 +19,7 @@ function request() { }; } -test("prewrite flow blocks missing required models before picker or writes", async () => { +test("prewrite flow fails empty live model catalog before picker or writes", async () => { let writes = 0; let pickerCalls = 0; const memoryFs = createMemoryFileSystem(); @@ -51,9 +51,7 @@ test("prewrite flow blocks missing required models before picker or writes", asy request: async () => ({ status: 200, headers: {}, - body: JSON.stringify({ - data: [{ id: "qwen/qwen3-235b-a22b-instruct-2507-fp8" }], - }), + body: JSON.stringify({ data: [] }), }), }, prompts: { @@ -67,8 +65,10 @@ test("prewrite flow blocks missing required models before picker or writes", asy ); assert.equal(result.ok, false); - assert.equal(result.status, "blocked"); - assert.equal(result.blockers[0]?.code, "required_models_unavailable"); + assert.equal(result.status, "failed"); + if (!result.ok && result.status === "failed") { + assert.equal(result.errorCode, "validated_models_unavailable"); + } assert.equal(writes, 0); assert.equal(pickerCalls, 0); }); @@ -76,7 +76,7 @@ test("prewrite flow blocks missing required models before picker or writes", asy test("prewrite flow reaches runtime-not-implemented only after availability and selection", async () => { const requests: HttpRequest[] = []; const result = await runPrewriteInstallFlow( - { ...request(), modelKey: "kimi-k2.6" }, + { ...request(), modelKey: "moonshotai/Kimi-K2.6" }, createFakeInstallDependencies({ commands: { run: async () => ({ @@ -97,9 +97,7 @@ test("prewrite flow reaches runtime-not-implemented only after availability and return { status: 200, headers: {}, - body: JSON.stringify({ - data: getRequiredGonkagateModelIds().map((id) => ({ id })), - }), + body: modelsResponse(), }; }, }, @@ -108,7 +106,7 @@ test("prewrite flow reaches runtime-not-implemented only after availability and assert.equal(result.ok, false); assert.equal(result.status, "blocked"); - assert.equal(result.selectedModel, "kimi-k2.6"); + assert.equal(result.selectedModel, "moonshotai/Kimi-K2.6"); assert.equal(result.blockers[0]?.code, "runtime_not_implemented"); assert.equal(result.changed, false); assert.equal(result.managedPaths[0]?.kind, "user-settings"); diff --git a/test/install/qwen-compatibility.test.ts b/test/install/qwen-compatibility.test.ts index 43202c6..ee0a39c 100644 --- a/test/install/qwen-compatibility.test.ts +++ b/test/install/qwen-compatibility.test.ts @@ -1,17 +1,19 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { getRequiredGonkagateModelIds } from "../../src/constants/models.js"; import { evaluateQwenCompatibilityLayers, getQwenCompatibilityEvidence, mergeSettingsForCompatibilityProof, projectModelProvidersCanHideManagedCatalog, } from "../../src/install/qwen-compatibility.js"; +import { LIVE_MODELS } from "./model-fixtures.js"; + +const LIVE_MODEL_IDS = LIVE_MODELS.map((model) => model.id); function userSettingsWithManagedModels(): Record { return { modelProviders: { - openai: getRequiredGonkagateModelIds().map((id) => ({ + openai: LIVE_MODEL_IDS.map((id) => ({ id, baseUrl: "https://api.gonkagate.com/v1", envKey: "GONKAGATE_API_KEY", diff --git a/test/install/rerun.test.ts b/test/install/rerun.test.ts index feda71f..1b49eb0 100644 --- a/test/install/rerun.test.ts +++ b/test/install/rerun.test.ts @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { getValidatedModels } from "../../src/constants/models.js"; import { writeManagedQwenSettings } from "../../src/install/write.js"; import type { ResolvedQwenPaths } from "../../src/install/paths.js"; +import { LIVE_MODELS } from "./model-fixtures.js"; import { createFakeInstallDependencies, createMemoryFileSystem, @@ -34,12 +34,11 @@ test("rerun updates managed model without duplicating catalog or deleting unrela paths: paths(), scope: "user" as const, secretValue: "gp-rerun-secret", - models: getValidatedModels(), + models: LIVE_MODELS, }; const first = await writeManagedQwenSettings({ ...base, - selectedModelKey: "qwen3-235b-a22b-instruct-2507-fp8", selectedModelId: "qwen/qwen3-235b-a22b-instruct-2507-fp8", }); const firstSettings = JSON.parse( @@ -62,7 +61,6 @@ test("rerun updates managed model without duplicating catalog or deleting unrela const second = await writeManagedQwenSettings({ ...base, - selectedModelKey: "kimi-k2.6", selectedModelId: "moonshotai/Kimi-K2.6", }); diff --git a/test/install/result-contract.test.ts b/test/install/result-contract.test.ts index b3cc126..4c0b03c 100644 --- a/test/install/result-contract.test.ts +++ b/test/install/result-contract.test.ts @@ -11,13 +11,13 @@ const jsonOptions: CliOptions = { dryRun: false, verifyLive: false, scope: "user", - modelKey: "qwen3-235b-a22b-instruct-2507-fp8", + modelKey: "qwen/qwen3-235b-a22b-instruct-2507-fp8", }; const baseResult = { runtimeImplemented: true, scope: "user", - selectedModel: "qwen3-235b-a22b-instruct-2507-fp8", + selectedModel: "qwen/qwen3-235b-a22b-instruct-2507-fp8", managedPaths: [], changed: false, warnings: [], @@ -55,8 +55,8 @@ test("JSON result contract covers all installer status variants", () => { status: "blocked", blockers: [ { - code: "required_models_unavailable", - message: "Missing required model ids.", + code: "validated_models_unavailable", + message: "Requested model was unavailable.", }, ], }, @@ -86,7 +86,10 @@ test("JSON result contract covers all installer status variants", () => { assert.equal(payload.ok, result.ok); assert.equal(payload.status, result.status); assert.equal(payload.scope, "user"); - assert.equal(payload.selectedModel, "qwen3-235b-a22b-instruct-2507-fp8"); + assert.equal( + payload.selectedModel, + "qwen/qwen3-235b-a22b-instruct-2507-fp8", + ); assert.deepEqual(payload.managedPaths, []); assert.equal(payload.changed, false); assert.ok(Array.isArray(payload.blockers)); diff --git a/test/install/selection.test.ts b/test/install/selection.test.ts index ee24c3b..0f70ad1 100644 --- a/test/install/selection.test.ts +++ b/test/install/selection.test.ts @@ -1,40 +1,51 @@ import assert from "node:assert/strict"; import test from "node:test"; import { redactedJsonStringify } from "../../src/install/redact.js"; -import { enforceRequiredModelAvailability } from "../../src/install/model-discovery.js"; import { selectSetupModel } from "../../src/install/selection.js"; -import { getRequiredGonkagateModelIds } from "../../src/constants/models.js"; +import { + LIVE_MODELS_WITH_UNKNOWN, + UNKNOWN_LIVE_MODEL, +} from "./model-fixtures.js"; import { createFakeInstallDependencies } from "./test-deps.js"; -test("--yes selects the recommended validated default after availability", async () => { +const LIVE_MODEL_CATALOG = { + models: LIVE_MODELS_WITH_UNKNOWN, + modelIds: LIVE_MODELS_WITH_UNKNOWN.map((model) => model.id), +}; + +test("--yes selects the first live model after availability", async () => { const result = await selectSetupModel( { yes: true }, createFakeInstallDependencies(), - enforceRequiredModelAvailability(getRequiredGonkagateModelIds()), + LIVE_MODEL_CATALOG, ); assert.equal(result.ok, true); if (result.ok) { - assert.equal(result.selectedModelKey, "qwen3-235b-a22b-instruct-2507-fp8"); + assert.equal( + result.selectedModelId, + "qwen/qwen3-235b-a22b-instruct-2507-fp8", + ); assert.equal(result.pickerRendered, false); } }); -test("explicit curated model key selects that model after availability", async () => { +test("explicit live model id selects that model after availability", async () => { const result = await selectSetupModel( - { yes: false, modelKey: "minimax-m2.7" }, + { yes: false, modelKey: UNKNOWN_LIVE_MODEL.id }, createFakeInstallDependencies(), - enforceRequiredModelAvailability(getRequiredGonkagateModelIds()), + LIVE_MODEL_CATALOG, ); assert.equal(result.ok, true); if (result.ok) { - assert.equal(result.selectedModelKey, "minimax-m2.7"); + assert.equal(result.selectedModelId, UNKNOWN_LIVE_MODEL.id); } }); -test("interactive picker runs only after authenticated availability succeeds", async () => { +test("interactive picker shows live names and ids", async () => { let pickerCalls = 0; + let renderedChoices: readonly string[] = []; const available = await selectSetupModel( { yes: false }, createFakeInstallDependencies({ @@ -42,51 +53,35 @@ test("interactive picker runs only after authenticated availability succeeds", a secret: async () => "gp-secret", select: async (_message, choices) => { pickerCalls += 1; - return choices[1]; + renderedChoices = choices; + return choices[choices.length - 1]; }, }, }), - enforceRequiredModelAvailability(getRequiredGonkagateModelIds()), - ); - const unavailable = await selectSetupModel( - { yes: false }, - createFakeInstallDependencies({ - prompts: { - secret: async () => "gp-secret", - select: async (_message, choices) => { - pickerCalls += 1; - return choices[0]; - }, - }, - }), - enforceRequiredModelAvailability([ - "qwen/qwen3-235b-a22b-instruct-2507-fp8", - ]), + LIVE_MODEL_CATALOG, ); assert.equal(available.ok, true); - assert.equal(unavailable.ok, false); assert.equal(pickerCalls, 1); - if (available.ok && !unavailable.ok) { + assert.ok( + renderedChoices.includes("Future Network Model (future/network-model)"), + ); + if (available.ok) { assert.equal(available.pickerRendered, true); - assert.equal(unavailable.pickerRendered, false); - assert.equal(unavailable.blocker.code, "required_models_unavailable"); + assert.equal(available.selectedModelId, UNKNOWN_LIVE_MODEL.id); } }); -test("selection rejects invalid curated keys and redacts JSON summaries", async () => { +test("selection rejects unavailable live ids and redacts JSON summaries", async () => { const invalid = await selectSetupModel( { yes: false, modelKey: "raw/model-id" }, createFakeInstallDependencies(), - enforceRequiredModelAvailability(getRequiredGonkagateModelIds()), + LIVE_MODEL_CATALOG, ); const selected = await selectSetupModel( - { yes: true }, + { yes: false, modelKey: UNKNOWN_LIVE_MODEL.id }, createFakeInstallDependencies(), - enforceRequiredModelAvailability([ - ...getRequiredGonkagateModelIds(), - "future/model", - ]), + LIVE_MODEL_CATALOG, ); assert.equal(invalid.ok, false); @@ -95,6 +90,9 @@ test("selection rejects invalid curated keys and redacts JSON summaries", async if (!invalid.ok && selected.ok) { assert.equal(invalid.blocker.code, "validated_models_unavailable"); assert.doesNotMatch(redactedJsonStringify(selected.summary), /gp-/); - assert.deepEqual(selected.summary.ignoredModelIds, ["future/model"]); + assert.deepEqual( + selected.summary.availableModels, + LIVE_MODEL_CATALOG.modelIds, + ); } }); diff --git a/test/install/state.test.ts b/test/install/state.test.ts index 9991d14..72c0b7f 100644 --- a/test/install/state.test.ts +++ b/test/install/state.test.ts @@ -1,16 +1,19 @@ import assert from "node:assert/strict"; import test from "node:test"; import { QWEN_CODE_SETUP_CONTRACT } from "../../src/constants/contract.js"; -import { getRequiredGonkagateModelIds } from "../../src/constants/models.js"; import { createInstallState, serializeInstallState, } from "../../src/install/state.js"; +import { LIVE_MODELS } from "./model-fixtures.js"; + +const LIVE_MODEL_IDS = LIVE_MODELS.map((model) => model.id); test("install state records runtime ownership metadata", () => { const state = createInstallState({ scope: "project", - selectedModelKey: "kimi-k2.6", + selectedModelId: "moonshotai/Kimi-K2.6", + managedModelIds: LIVE_MODEL_IDS, userSettingsPath: "/home/alice/.qwen/settings.json", projectSettingsPath: "/repo/.qwen/settings.json", verifiedAt: "2026-06-12T00:00:00.000Z", @@ -19,8 +22,11 @@ test("install state records runtime ownership metadata", () => { assert.equal(state.installerVersion, QWEN_CODE_SETUP_CONTRACT.packageVersion); assert.equal(state.auditedQwenVersion, "0.18.0"); assert.equal(state.scope, "project"); - assert.equal(state.selectedModelKey, "kimi-k2.6"); - assert.deepEqual(state.managedModelIds, getRequiredGonkagateModelIds()); + assert.equal(state.selectedModelId, "moonshotai/Kimi-K2.6"); + assert.deepEqual(state.managedModelIds, LIVE_MODEL_IDS); assert.equal(state.secretStoragePolicyVersion, 1); - assert.match(serializeInstallState(state), /"selectedModelKey": "kimi-k2.6"/); + assert.match( + serializeInstallState(state), + /"selectedModelId": "moonshotai\/Kimi-K2.6"/, + ); }); diff --git a/test/install/verify-effective.test.ts b/test/install/verify-effective.test.ts index d5f2135..63bb1f0 100644 --- a/test/install/verify-effective.test.ts +++ b/test/install/verify-effective.test.ts @@ -1,6 +1,5 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { getValidatedModels } from "../../src/constants/models.js"; import type { FileSystemDeps } from "../../src/install/deps.js"; import type { ResolvedQwenPaths } from "../../src/install/paths.js"; import { verifyDurableInstall } from "../../src/install/verify-effective.js"; @@ -9,6 +8,9 @@ import { createFakeInstallDependencies, createMemoryFileSystem, } from "./test-deps.js"; +import { LIVE_MODELS } from "./model-fixtures.js"; + +const LIVE_MODEL_IDS = LIVE_MODELS.map((model) => model.id); function paths(): ResolvedQwenPaths { return { @@ -35,16 +37,16 @@ test("durable verifier proves user-scope managed settings", async () => { deps, paths: paths(), scope: "user", - selectedModelKey: "kimi-k2.6", selectedModelId: "moonshotai/Kimi-K2.6", secretValue: "gp-verify-secret", - models: getValidatedModels(), + models: LIVE_MODELS, }); const verification = await verifyDurableInstall({ deps, paths: paths(), scope: "user", selectedModelId: "moonshotai/Kimi-K2.6", + managedModelIds: LIVE_MODEL_IDS, }); assert.equal(write.ok, true); @@ -66,6 +68,7 @@ test("durable verifier blocks missing provider, secret, auth, and model evidence paths: paths(), scope: "user", selectedModelId: "moonshotai/Kimi-K2.6", + managedModelIds: LIVE_MODEL_IDS, }); assert.equal(verification.ok, false); @@ -86,10 +89,9 @@ test("durable verifier blocks project and system overrides", async () => { deps, paths: paths(), scope: "project", - selectedModelKey: "qwen3-235b-a22b-instruct-2507-fp8", selectedModelId: "qwen/qwen3-235b-a22b-instruct-2507-fp8", secretValue: "gp-verify-secret", - models: getValidatedModels(), + models: LIVE_MODELS, }); fs.files.set( paths().projectSettingsPath, @@ -109,6 +111,7 @@ test("durable verifier blocks project and system overrides", async () => { paths: paths(), scope: "project", selectedModelId: "qwen/qwen3-235b-a22b-instruct-2507-fp8", + managedModelIds: LIVE_MODEL_IDS, }); assert.equal(verification.ok, false); @@ -130,11 +133,7 @@ test("durable verifier fails closed on unreadable system settings and permission const memory = createMemoryFileSystem({ [paths().userSettingsPath]: JSON.stringify({ modelProviders: { - openai: [ - "qwen/qwen3-235b-a22b-instruct-2507-fp8", - "moonshotai/Kimi-K2.6", - "minimaxai/minimax-m2.7", - ].map((id) => ({ id })), + openai: LIVE_MODEL_IDS.map((id) => ({ id })), }, security: { auth: { selectedType: "openai" } }, model: { name: "moonshotai/Kimi-K2.6" }, @@ -161,6 +160,7 @@ test("durable verifier fails closed on unreadable system settings and permission paths: paths(), scope: "user", selectedModelId: "moonshotai/Kimi-K2.6", + managedModelIds: LIVE_MODEL_IDS, }); assert.equal(verification.ok, false); diff --git a/test/install/write.test.ts b/test/install/write.test.ts index a730b56..38ad495 100644 --- a/test/install/write.test.ts +++ b/test/install/write.test.ts @@ -1,6 +1,5 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { getValidatedModels } from "../../src/constants/models.js"; import type { ResolvedQwenPaths } from "../../src/install/paths.js"; import { writeManagedQwenSettings } from "../../src/install/write.js"; import { createWriteTargetConfigPlans } from "../../src/install/write-target-config.js"; @@ -8,6 +7,7 @@ import { createFakeInstallDependencies, createMemoryFileSystem, } from "./test-deps.js"; +import { LIVE_MODELS, UNKNOWN_LIVE_MODEL } from "./model-fixtures.js"; function paths(): ResolvedQwenPaths { return { @@ -32,10 +32,9 @@ test("write target plans create user settings and install state for user scope", deps: createFakeInstallDependencies(), paths: paths(), scope: "user", - selectedModelKey: "qwen3-235b-a22b-instruct-2507-fp8", - selectedModelId: "qwen/qwen3-235b-a22b-instruct-2507-fp8", + selectedModelId: UNKNOWN_LIVE_MODEL.id, secretValue: "gp-plan-secret", - models: getValidatedModels(), + models: [...LIVE_MODELS, UNKNOWN_LIVE_MODEL], }); assert.equal(result.ok, true); @@ -45,6 +44,7 @@ test("write target plans create user settings and install state for user scope", ["user-settings", "install-state"], ); assert.match(result.plans[0].contents, /modelProviders/); + assert.match(result.plans[0].contents, /future\/network-model/); assert.match(result.plans[0].contents, /GONKAGATE_API_KEY/); assert.match(result.plans[1].contents, /secretStoragePolicyVersion/); } @@ -55,10 +55,9 @@ test("project scope writes activation only to project settings and stores projec deps: createFakeInstallDependencies(), paths: paths(), scope: "project", - selectedModelKey: "kimi-k2.6", selectedModelId: "moonshotai/Kimi-K2.6", secretValue: "gp-project-secret", - models: getValidatedModels(), + models: LIVE_MODELS, }); assert.equal(result.ok, true); @@ -83,10 +82,9 @@ test("temp-home style managed write creates settings and skips unchanged rerun", deps, paths: paths(), scope: "user" as const, - selectedModelKey: "minimax-m2.7" as const, selectedModelId: "minimaxai/minimax-m2.7", secretValue: "gp-write-secret", - models: getValidatedModels(), + models: LIVE_MODELS, }; const first = await writeManagedQwenSettings(baseInput); @@ -115,10 +113,9 @@ test("project modelProviders block write planning before project writes", async deps: createFakeInstallDependencies({ fs }), paths: paths(), scope: "project", - selectedModelKey: "kimi-k2.6", selectedModelId: "moonshotai/Kimi-K2.6", secretValue: "gp-project-secret", - models: getValidatedModels(), + models: LIVE_MODELS, }); assert.equal(result.ok, false); diff --git a/test/package-contract.test.ts b/test/package-contract.test.ts index 8b1cf52..f65ef20 100644 --- a/test/package-contract.test.ts +++ b/test/package-contract.test.ts @@ -1,15 +1,14 @@ import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; import test from "node:test"; import { QWEN_CODE_SETUP_CONTRACT } from "../src/constants/contract.js"; import { - CURATED_MODEL_REGISTRY, - UnsupportedCuratedModelError, - getCuratedModelByKey, - getRecommendedDefaultModel, - getRequiredGonkagateModelIds, - getValidatedModels, -} from "../src/constants/models.js"; -import { assertMatchesAll, readText } from "./contract-helpers.js"; + assertMatchesAll, + listRelativeFiles, + readText, + repoRoot, +} from "./contract-helpers.js"; test("package metadata follows the public qwen-code-setup contract", () => { const packageJson = JSON.parse(readText("package.json")) as { @@ -63,8 +62,10 @@ test("contract constants record shipped runtime state and Qwen Code baseline", ( assert.equal(QWEN_CODE_SETUP_CONTRACT.qwenBinaryName, "qwen"); assert.equal(QWEN_CODE_SETUP_CONTRACT.packageVersion, packageJson.version); assert.equal(QWEN_CODE_SETUP_CONTRACT.runtimeImplemented, true); - assert.equal(QWEN_CODE_SETUP_CONTRACT.curatedRegistryPublished, true); - assert.equal(QWEN_CODE_SETUP_CONTRACT.requiredGonkagateModelCount, 3); + assert.equal( + QWEN_CODE_SETUP_CONTRACT.modelCatalogSource, + "authenticated /v1/models", + ); assert.equal(QWEN_CODE_SETUP_CONTRACT.latestAuditedQwenCodeVersion, "0.18.0"); assert.equal(QWEN_CODE_SETUP_CONTRACT.qwenPackageNodeEngine, ">=22.0.0"); assert.equal( @@ -85,49 +86,20 @@ test("contract constants record shipped runtime state and Qwen Code baseline", ( ]); }); -test("supported model registry publishes all three required GonkaGate models", () => { - assert.equal(CURATED_MODEL_REGISTRY.length, 3); - assert.ok( - CURATED_MODEL_REGISTRY.every((model) => model.status === "validated"), - ); - assert.deepEqual( - CURATED_MODEL_REGISTRY.map((model) => model.id), - [ - "qwen/qwen3-235b-a22b-instruct-2507-fp8", - "moonshotai/Kimi-K2.6", - "minimaxai/minimax-m2.7", - ], - ); - assert.deepEqual( - getValidatedModels().map((model) => model.id), - getRequiredGonkagateModelIds(), - ); - assert.ok( - CURATED_MODEL_REGISTRY.every( - (model) => - model.validationEvidenceDate === "2026-06-12" && - model.qwenCompatibilityNotes.length > 0, - ), - ); - assert.equal(getRecommendedDefaultModel().id, CURATED_MODEL_REGISTRY[0].id); - assert.equal( - CURATED_MODEL_REGISTRY.filter((model) => model.recommendedDefault).length, - 1, - ); -}); +test("runtime source cannot depend on a checked-in model registry", () => { + const sourceRoot = resolve(repoRoot, "src"); + const runtimeSource = listRelativeFiles(sourceRoot) + .map((path) => readFileSync(resolve(sourceRoot, path), "utf8")) + .join("\n"); -test("curated model lookup rejects unsupported model keys", () => { - assert.equal( - getCuratedModelByKey("qwen3-235b-a22b-instruct-2507-fp8").id, - "qwen/qwen3-235b-a22b-instruct-2507-fp8", + assert.equal(existsSync(resolve(sourceRoot, "constants/models.ts")), false); + assert.doesNotMatch( + runtimeSource, + /CURATED_MODEL_REGISTRY|getRequiredGonkagateModelIds|getValidatedModels|requiredGonkagateModelCount/, ); - - assert.throws( - () => getCuratedModelByKey("custom/raw-model"), - (error) => - error instanceof UnsupportedCuratedModelError && - error.code === "unsupported_curated_model" && - error.modelKey === "custom/raw-model", + assert.match( + runtimeSource, + /modelCatalogSource: "authenticated \/v1\/models"/, ); });