From 3e5bf684e1c0f27f73ade39addc8481f5db8c788 Mon Sep 17 00:00:00 2001 From: Daniil Koryto Date: Sun, 3 May 2026 16:27:13 +0300 Subject: [PATCH] feat: support GonkaGate model catalog for OpenClaw switcher --- AGENTS.md | 54 ++++-- CHANGELOG.md | 4 +- README.md | 15 +- docs/how-it-works.md | 28 +-- docs/security.md | 1 + docs/troubleshooting.md | 8 + src/install/gonkagate-models.ts | 331 ++++++++++++++++++++++++++++++++ src/install/install-errors.ts | 31 +++ src/install/install-use-case.ts | 34 +++- src/install/merge-settings.ts | 118 +++++++++--- src/install/verify-settings.ts | 65 ++++++- test/gonkagate-models.test.ts | 195 +++++++++++++++++++ test/install-use-case.test.ts | 120 +++++++++++- test/merge-settings.test.ts | 32 ++- test/test-helpers.ts | 16 +- test/verify-settings.test.ts | 50 ++++- 16 files changed, 1009 insertions(+), 93 deletions(-) create mode 100644 src/install/gonkagate-models.ts create mode 100644 test/gonkagate-models.test.ts diff --git a/AGENTS.md b/AGENTS.md index ede5b06..84abb3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,8 @@ The happy-path installer interactively prompts only for: - a `gp-...` API key - a model picker from the curated registry +After the API key is entered, the installer fetches `GET /v1/models` from GonkaGate, requires the live catalog to contain every code-owned curated model, and uses that live metadata to populate the OpenClaw provider model catalog. + If the active OpenClaw config path does not exist yet, the installer runs `openclaw setup` automatically to initialize the base OpenClaw config and workspace, ensures a minimal local Gateway mode when OpenClaw setup did not already pick one, and then applies GonkaGate-managed settings. After setup, users can run a read-only verification command: @@ -31,7 +33,7 @@ After setup, users can run a read-only verification command: npx @gonkagate/openclaw verify ``` -That command checks the current active OpenClaw config path, confirms that the managed GonkaGate provider fields, curated primary model, and owner-only file permissions still match the supported setup, and then verifies that the active local OpenClaw runtime is healthy enough to load that config through read-only CLI probes. +That command checks the current active OpenClaw config path, confirms that the managed GonkaGate provider fields, curated provider model catalog, curated `/models` allowlist, curated primary model, and owner-only file permissions still match the supported setup, and then verifies that the active local OpenClaw runtime is healthy enough to load that config through read-only CLI probes. If the installer finishes but the local OpenClaw Gateway is not running yet, that is still a successful install outcome. In that case the installer prints one exact next command: @@ -51,7 +53,7 @@ These decisions are part of the repo contract. Changing them is not a small refa - `models.providers.openai.baseUrl` is always `https://api.gonkagate.com/v1` - `models.providers.openai.api` is always `openai-completions` -- `models.providers.openai.models` must always end up as a valid array for current OpenClaw releases +- `models.providers.openai.models` must always end up as a valid array containing the curated GonkaGate model catalog entries returned by `GET /v1/models`, while preserving unrelated existing entries - users do not choose the base URL and cannot override it in the public flow - the provider is always `openai`, not `anthropic` - model choice comes only from a code-owned curated registry @@ -70,7 +72,8 @@ These decisions are part of the repo contract. Changing them is not a small refa - config files must be written with owner-only permissions - backup files must be written with owner-only permissions - `agents.defaults.model.primary` must be updated to the selected curated model -- `agents.defaults.models` should only be merged when it already exists +- `agents.defaults.models` must be created or updated with the curated GonkaGate allowlist so OpenClaw `/models` can switch between supported models +- the installer must never expose arbitrary live `GET /v1/models` entries as selectable models unless they are also in the code-owned curated registry Current honest limitation: @@ -85,6 +88,7 @@ This repo does: - onboarding for local `OpenClaw` - read-only verification for the managed GonkaGate OpenClaw config - read-only runtime verification for the active local OpenClaw Gateway and resolved primary model +- live GonkaGate model catalog validation through `GET /v1/models` before prompting for a model - first-run OpenClaw config bootstrap through `openclaw setup` when needed - first-run minimal local Gateway bootstrap when `gateway.mode` is absent - persistent config writing into the active OpenClaw config path resolved from the current environment @@ -128,6 +132,7 @@ This repo does not do: │ │ ├── check-openclaw.ts │ │ ├── cli-display.ts │ │ ├── file-permissions.ts +│ │ ├── gonkagate-models.ts │ │ ├── install-use-case.ts │ │ ├── load-settings.ts │ │ ├── managed-settings-access.ts @@ -157,6 +162,7 @@ This repo does not do: └── test/ ├── bootstrap-gateway.test.ts ├── cli-run.test.ts + ├── gonkagate-models.test.ts ├── install-use-case.test.ts ├── load-settings.test.ts ├── merge-settings.test.ts @@ -199,6 +205,7 @@ It is responsible for: - applying first-run-only local Gateway bootstrap logic - validating the current config before prompting for secrets - running the hidden API key prompt +- fetching and validating the live GonkaGate model catalog before model selection - selecting the model - merging GonkaGate settings into the existing config - validating the generated candidate config before replacing the live file @@ -249,6 +256,19 @@ This file contains the interactive prompts built on top of `@inquirer/prompts`: The key rule here is: do not log secrets and do not turn the main UX into CLI args for secrets. +### `src/install/gonkagate-models.ts` + +This file owns the GonkaGate `GET /v1/models` trust boundary. + +It must: + +- call `https://api.gonkagate.com/v1/models` with the entered `gp-...` key +- reject authentication failures before config writes +- validate and normalize the external JSON response before the install use-case consumes it +- keep selectable models restricted to the code-owned curated registry +- require the live catalog to contain every curated supported model +- map live metadata into OpenClaw provider model catalog entries without trusting unrelated response fields + ### `src/install/check-openclaw.ts` Verifies that the local `openclaw` CLI exists and is callable through `openclaw --version`. @@ -319,11 +339,11 @@ It must: - preserve unrelated top-level keys - preserve unrelated provider entries - overwrite only OpenClaw-managed `openai` provider fields -- preserve an existing `models.providers.openai.models` array when present -- initialize `models.providers.openai.models` to `[]` when missing so the resulting config remains valid for current OpenClaw releases +- preserve unrelated existing `models.providers.openai.models` entries when present +- add or update curated GonkaGate provider model catalog entries under `models.providers.openai.models` - set `agents.defaults.model.primary` - preserve unrelated keys inside `agents.defaults.model` -- merge `agents.defaults.models` only when it already exists +- create or update `agents.defaults.models` with every curated GonkaGate allowlist entry needed by OpenClaw `/models` It must consume the shared managed-settings boundary instead of re-deriving managed object shapes locally. @@ -395,7 +415,8 @@ It must: - fail if GonkaGate-managed provider fields do not match the fixed product values - fail if `models.providers.openai.apiKey` is missing or malformed - fail if `agents.defaults.model.primary` does not point at a curated supported model -- fail if `agents.defaults.models` exists but the managed allowlist entry is missing or mismatched +- fail if `models.providers.openai.models` omits a curated GonkaGate model catalog entry +- fail if `agents.defaults.models` is missing or any managed curated allowlist entry is missing or mismatched - fail if the config file permissions are not owner-only - never rewrite the config during verification @@ -439,6 +460,7 @@ Baseline tests cover: - first-run minimal Gateway bootstrap behavior - merge behavior - model selection behavior +- live GonkaGate model catalog parsing and filtering behavior - read-only verification behavior - verify orchestration ownership - invalid JSON5 handling @@ -457,13 +479,14 @@ Baseline tests cover: 6. If Gateway mode is still unset after that bootstrap, the installer sets `gateway.mode` to `local` 7. The installer validates the current config through `openclaw config validate --json` 8. The installer securely prompts for a `gp-...` API key -9. The installer shows the curated model picker -10. The config is merged with GonkaGate-managed OpenAI settings -11. The generated candidate config is validated through `openclaw config validate --json` -12. A backup is created only when an existing config is being overwritten -13. JSON is written back to disk -14. The installer performs a best-effort runtime probe -15. If the local Gateway is not running yet, install still succeeds and prints the exact next command `openclaw gateway` +9. The installer fetches `GET /v1/models` and confirms every curated supported model is live +10. The installer shows the curated model picker +11. The config is merged with GonkaGate-managed OpenAI settings, provider model catalog entries, and `/models` allowlist entries +12. The generated candidate config is validated through `openclaw config validate --json` +13. A backup is created only when an existing config is being overwritten +14. JSON is written back to disk +15. The installer performs a best-effort runtime probe +16. If the local Gateway is not running yet, install still succeeds and prints the exact next command `openclaw gateway` Optional follow-up verification path: @@ -472,7 +495,7 @@ Optional follow-up verification path: 3. The CLI resolves the active OpenClaw config path from the current environment 4. The CLI loads that config file without modifying it 5. The CLI validates the current config through `openclaw config validate --json` -6. The CLI verifies the managed GonkaGate provider fields, curated primary model, and owner-only file permissions +6. The CLI verifies the managed GonkaGate provider fields, curated provider model catalog entries, curated `/models` allowlist, curated primary model, and owner-only file permissions 7. The CLI confirms that the local OpenClaw Gateway RPC is reachable and the health snapshot is healthy 8. The CLI confirms that OpenClaw resolves the expected primary model through `openclaw models status --plain` 9. The CLI reports success or exits with a clear error @@ -482,6 +505,7 @@ Optional follow-up verification path: - Do not add a base URL prompt - Do not add free-form custom model input - Do not make `--api-key` a recommended or supported path +- Do not expose arbitrary `GET /v1/models` results as selectable models outside the curated registry - Do not require `openclaw onboard` in the main public flow - Do not modify shell rc files - Do not write `.env` diff --git a/CHANGELOG.md b/CHANGELOG.md index 5962e1c..d70d01f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## [Unreleased] - Added `moonshotai/Kimi-K2.6` to the curated GonkaGate model registry under the `kimi-k2.6` model key and made it the default model. +- The installer now fetches GonkaGate `GET /v1/models` after API key entry, requires every curated model to be live, and uses the live metadata for OpenClaw provider model catalog entries. +- The installer now creates or updates `agents.defaults.models` with the curated GonkaGate allowlist so OpenClaw `/models` can switch between supported models. - Raised the minimum supported Node.js runtime for this package to Node 22.14+ so it matches current OpenClaw install support expectations. - CI and publish workflows now both run on Node 22.14.0, and runtime documentation no longer advertises Node 18 support. - Upgraded `@inquirer/prompts`, `commander`, and `write-file-atomic` to current releases that are now appropriate for a Node 22.14+ baseline. @@ -16,7 +18,7 @@ - Installer runtime probes now distinguish a not-yet-running local Gateway from real runtime mismatches; when the Gateway is simply not running yet, install succeeds and prints `openclaw gateway` as the exact next step. - Runtime verification now treats malformed or shape-drifted OpenClaw probe output as a strict compatibility failure instead of downgrading it to a benign Gateway-not-running result. - The installer now validates both the current config and the generated candidate config through `openclaw config validate --json` before writing. -- The managed OpenAI provider config now preserves an existing `models.providers.openai.models` array and initializes it to `[]` when missing so configs stay valid on current OpenClaw releases. +- The managed OpenAI provider config now preserves unrelated existing `models.providers.openai.models` entries while adding or updating curated GonkaGate catalog entries required by OpenClaw's model picker. - The CLI now resolves the active config path compatibly with stable OpenClaw 2026.4.1 by preferring existing legacy config candidates locally before falling back to canonical `openclaw.json`, so install and verify stay aligned on legacy hosts. ## [0.1.0] - 2026-04-01 diff --git a/README.md b/README.md index 62d92eb..237194e 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ npx @gonkagate/openclaw You will be asked for: - your GonkaGate API key (`gp-...`) in a hidden interactive prompt -- a model from the curated GonkaGate registry +- a model from the curated GonkaGate registry after the installer confirms the live catalog through `GET /v1/models` If the active OpenClaw config path does not exist yet, the installer will run `openclaw setup` automatically, ensure a minimal local Gateway mode, and then apply the GonkaGate-specific provider settings. @@ -53,8 +53,9 @@ This command checks: - that `models.providers.openai.baseUrl` is `https://api.gonkagate.com/v1` - that `models.providers.openai.api` is `openai-completions` - that `models.providers.openai.apiKey` exists and still looks like a `gp-...` key +- that `models.providers.openai.models` includes the curated GonkaGate model catalog entries needed by OpenClaw - that `agents.defaults.model.primary` points at a curated GonkaGate model -- that `agents.defaults.models` stays in sync when that allowlist exists +- that `agents.defaults.models` contains the curated GonkaGate switcher allowlist used by OpenClaw `/models` - that the config file still uses owner-only permissions - that the local OpenClaw Gateway RPC is reachable through `openclaw gateway status --require-rpc --json` - that `openclaw health --json` reports a healthy runtime @@ -86,13 +87,14 @@ It also: - bootstraps `gateway.mode: "local"` on true first-run installs when OpenClaw has not already set a gateway mode - refuses to overwrite an invalid JSON5 config - refuses to overwrite a config that fails current OpenClaw schema validation +- fetches `GET /v1/models` with the entered GonkaGate API key, requires every curated model to be present, and keeps model selection inside the code-owned curated registry - creates a backup before overwriting an existing config - preserves unrelated top-level config keys - preserves other provider entries under `models.providers` - overwrites only the managed `models.providers.openai` fields -- preserves an existing `models.providers.openai.models` array and initializes it to `[]` when missing so the resulting config remains valid for current OpenClaw releases +- merges live curated GonkaGate entries into `models.providers.openai.models` while preserving unrelated existing entries - sets `agents.defaults.model.primary` to the chosen curated model -- merges `agents.defaults.models` only when that allowlist already exists +- creates or updates `agents.defaults.models` with the curated GonkaGate switcher allowlist so `/models` can switch between supported models - validates the generated config with `openclaw config validate --json` before replacing the live file - writes the config atomically with owner-only permissions - writes backup files with owner-only permissions @@ -106,9 +108,9 @@ This installer manages only these OpenClaw surfaces: - `models.providers.openai.baseUrl` - `models.providers.openai.apiKey` - `models.providers.openai.api` -- `models.providers.openai.models` as a valid array, while preserving any existing entries +- `models.providers.openai.models` as a valid array containing the live curated GonkaGate model catalog, while preserving unrelated existing entries - `agents.defaults.model.primary` -- `agents.defaults.models["openai/"].alias` only when `agents.defaults.models` already exists +- `agents.defaults.models["openai/"].alias` for curated GonkaGate models Everything else is left intact. @@ -157,6 +159,7 @@ Implementation choices in this repository were aligned to these primary sources: - [OpenClaw Configuration Reference](https://docs.openclaw.ai/gateway/configuration-reference) - [GonkaGate OpenClaw Integration Guide](https://gonkagate.com/en/docs/guides/openclaw-integration) - [GonkaGate Model Selection Guide](https://gonkagate.com/en/docs/guides/overview/models) +- [GonkaGate GET /v1/models API Reference](https://gonkagate.com/en/docs/api/api-reference/models/get-models) ## Development diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 5f92433..c0e81a1 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -20,12 +20,13 @@ Install flow: 5. On true first-run installs only, ensure `gateway.mode` is set to `"local"` when OpenClaw setup did not already choose a gateway mode. 6. Stop with a clear error if the current config is invalid, the managed config surface has an unsafe shape, or `openclaw config validate --json` rejects the current file. 7. Prompt for the GonkaGate API key in a hidden interactive prompt. -8. Prompt for a model from the curated in-code registry. -9. Merge only the managed OpenAI provider fields plus `agents.defaults.model.primary`, while ensuring `models.providers.openai.models` remains a valid array. -10. Validate the generated config through `openclaw config validate --json` against a temporary candidate file next to the live config. -11. Create a timestamped backup next to the existing config file only when overwriting an existing config. -12. Write the resulting config atomically with owner-only permissions. -13. Run a best-effort runtime probe. If the local Gateway is not running yet, install still succeeds and prints the exact next command: `openclaw gateway`. +8. Fetch the live GonkaGate catalog through `GET /v1/models` with that API key and require it to contain every curated in-code model. +9. Prompt for a model from the curated in-code registry using live catalog metadata for the provider model entries. +10. Merge only the managed OpenAI provider fields, the live curated `models.providers.openai.models` catalog entries, `agents.defaults.model.primary`, and the curated `agents.defaults.models` switcher allowlist. +11. Validate the generated config through `openclaw config validate --json` against a temporary candidate file next to the live config. +12. Create a timestamped backup next to the existing config file only when overwriting an existing config. +13. Write the resulting config atomically with owner-only permissions. +14. Run a best-effort runtime probe. If the local Gateway is not running yet, install still succeeds and prints the exact next command: `openclaw gateway`. Verify flow: @@ -37,11 +38,12 @@ Verify flow: 6. Confirm that `models.providers.openai.api` is still `openai-completions`. 7. Confirm that `models.providers.openai.apiKey` exists and still looks like a GonkaGate `gp-...` key. 8. Confirm that `agents.defaults.model.primary` still points at one of the curated GonkaGate models. -9. If `agents.defaults.models` exists, confirm that the managed allowlist entry still matches the selected curated model alias. -10. Confirm that the config file still uses owner-only permissions. -11. Confirm that the local OpenClaw Gateway RPC is reachable through `openclaw gateway status --require-rpc --json`. -12. Confirm that `openclaw health --json` reports a healthy runtime snapshot. -13. Confirm that `openclaw models status --plain` resolves the same primary model that the saved config declares. +9. Confirm that `models.providers.openai.models` includes every curated GonkaGate model id needed by OpenClaw's model catalog. +10. Confirm that `agents.defaults.models` contains every curated GonkaGate allowlist entry and alias needed by `/models`. +11. Confirm that the config file still uses owner-only permissions. +12. Confirm that the local OpenClaw Gateway RPC is reachable through `openclaw gateway status --require-rpc --json`. +13. Confirm that `openclaw health --json` reports a healthy runtime snapshot. +14. Confirm that `openclaw models status --plain` resolves the same primary model that the saved config declares. Managed merge behavior: @@ -49,9 +51,9 @@ Managed merge behavior: - Other `models.providers.*` entries are preserved. - Existing `models.providers.openai` keys outside the managed surface are preserved. - Existing `models.providers.openai.models` entries are preserved when already present and valid. -- If `models.providers.openai.models` is missing, it is initialized to `[]` so the config stays valid for current OpenClaw releases. +- Live curated GonkaGate entries from `GET /v1/models` are added to `models.providers.openai.models`, or updated in place when their `id` already exists. - Existing `agents.defaults.model` keys outside `primary` are preserved. -- Existing `agents.defaults.models` entries are preserved and only extended when the allowlist already exists. +- Existing `agents.defaults.models` entries are preserved, and curated GonkaGate entries are created or updated so `/models` can switch between supported models. - Existing `gateway.*` keys are preserved. - Existing `gateway.mode` is preserved when already present. - Only true first-run installs gain a default `gateway.mode: "local"` when the base setup omitted it. diff --git a/docs/security.md b/docs/security.md index 961d1e6..060b575 100644 --- a/docs/security.md +++ b/docs/security.md @@ -6,6 +6,7 @@ Current protections: - API keys are accepted only through a hidden interactive prompt. - `--api-key` CLI arguments are rejected to avoid leaking secrets into shell history and process listings. +- The entered API key is sent only to GonkaGate `GET /v1/models` for live catalog validation before it is written into the OpenClaw config. - Generated configs are schema-validated through a temporary candidate file before the live config is replaced. - Existing configs are backed up before overwrite. - Config writes are atomic. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 6ed1289..c4f48bd 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -140,6 +140,14 @@ If the Gateway still does not come up, check the local status summary: openclaw status --deep ``` +## `GonkaGate ... /v1/models ...` errors during install + +The installer checks the live GonkaGate model catalog after you enter the `gp-...` key and before it writes config. + +- `GonkaGate rejected the API key` means the key could not call `GET /v1/models`; check the key and rerun the installer. +- `model catalog is temporarily unavailable` means GonkaGate returned `503`; rerun after a short wait. +- `did not return every curated supported model` means the live catalog did not contain every model this package supports, so the installer stopped instead of writing a partial `/models` switcher. + ## `Expected "models.providers.openai.baseUrl"...` or other GonkaGate field mismatch errors The config exists, but the managed GonkaGate fields no longer match the supported setup. diff --git a/src/install/gonkagate-models.ts b/src/install/gonkagate-models.ts new file mode 100644 index 0000000..9f78746 --- /dev/null +++ b/src/install/gonkagate-models.ts @@ -0,0 +1,331 @@ +import { setTimeout as delay } from "node:timers/promises"; +import { GONKAGATE_OPENAI_BASE_URL } from "../constants/gateway.js"; +import { + SUPPORTED_MODELS, + toManagedModelSelection, + type ManagedAllowlistEntry, + type SupportedModel, + type SupportedModelKey, + type SupportedPrimaryModelRef +} from "../constants/models.js"; +import { GonkaGateModelsError, describeValue, getErrorMessage } from "./install-errors.js"; +import { asPlainObject } from "./object-utils.js"; + +const GONKAGATE_MODELS_ENDPOINT = `${GONKAGATE_OPENAI_BASE_URL.replace(/\/$/, "")}/models`; +const DEFAULT_MAX_ATTEMPTS = 3; +const DEFAULT_RETRY_DELAY_MS = 250; +const DEFAULT_TIMEOUT_MS = 10_000; + +interface GonkaGateModelsHttpResponse { + json: () => Promise; + status: number; +} + +export interface FetchGonkaGateModelsOptions { + fetchImpl?: (url: string, init: { headers: Record; signal?: AbortSignal }) => Promise; + maxAttempts?: number; + retryDelayMs?: number; + timeoutMs?: number; +} + +export interface GonkaGateModelCatalogEntry { + contextLength?: number; + id: string; + name?: string; +} + +export interface OpenClawProviderModelCatalogEntry { + contextWindow?: number; + id: string; + name: string; +} + +export interface CuratedGonkaGateModelCatalogEntry { + allowlistEntry: ManagedAllowlistEntry; + model: SupportedModel; + primaryModelRef: SupportedPrimaryModelRef; + providerModel: OpenClawProviderModelCatalogEntry; +} + +interface GonkaGateModelCatalog { + models: readonly GonkaGateModelCatalogEntry[]; +} + +export async function fetchCuratedGonkaGateModelCatalog( + apiKey: string, + options: FetchGonkaGateModelsOptions = {} +): Promise { + const catalog = await fetchGonkaGateModelCatalog(apiKey, options); + const curatedCatalog = createCuratedGonkaGateModelCatalog(catalog.models); + const curatedModelIds = new Set(curatedCatalog.map((entry) => entry.model.modelId)); + const missingSupportedModels = SUPPORTED_MODELS.filter((model) => !curatedModelIds.has(model.modelId)); + + if (curatedCatalog.length === 0) { + throw new GonkaGateModelsError({ + expected: Array.from(SUPPORTED_MODELS, (model) => model.modelId).join(", "), + kind: "no_supported_models", + message: + `GonkaGate ${GONKAGATE_MODELS_ENDPOINT} did not return any curated supported models. ` + + `Expected at least one of: ${Array.from(SUPPORTED_MODELS, (model) => model.modelId).join(", ")}.` + }); + } + + if (missingSupportedModels.length > 0) { + throw new GonkaGateModelsError({ + actual: catalog.models.map((model) => model.id).join(", "), + expected: Array.from(SUPPORTED_MODELS, (model) => model.modelId).join(", "), + kind: "missing_supported_models", + message: + `GonkaGate ${GONKAGATE_MODELS_ENDPOINT} did not return every curated supported model. ` + + `Missing: ${missingSupportedModels.map((model) => model.modelId).join(", ")}.` + }); + } + + return curatedCatalog; +} + +export function createStaticCuratedGonkaGateModelCatalog( + models: readonly SupportedModel[] = SUPPORTED_MODELS +): CuratedGonkaGateModelCatalogEntry[] { + return Array.from(models, (model) => createCuratedCatalogEntry(model)); +} + +export function requireModelInGonkaGateCatalog( + selectedModel: SupportedModel, + catalog: readonly CuratedGonkaGateModelCatalogEntry[] +): void { + if (catalog.some((entry) => entry.model.key === selectedModel.key)) { + return; + } + + throw new GonkaGateModelsError({ + actual: selectedModel.modelId, + expected: catalog.length > 0 + ? catalog.map((entry) => entry.model.modelId).join(", ") + : Array.from(SUPPORTED_MODELS, (model) => model.modelId).join(", "), + kind: "missing_selected_model", + message: + `Selected curated model "${selectedModel.key}" (${selectedModel.modelId}) was not returned by ` + + `GonkaGate ${GONKAGATE_MODELS_ENDPOINT}. Choose a currently available curated model and rerun the installer.` + }); +} + +export function getPromptDefaultModelKey( + catalog: readonly CuratedGonkaGateModelCatalogEntry[], + preferredDefaultKey: SupportedModelKey +): SupportedModelKey { + const preferredDefault = catalog.find((entry) => entry.model.key === preferredDefaultKey); + + if (preferredDefault) { + return preferredDefault.model.key; + } + + const firstAvailable = catalog[0]; + + if (!firstAvailable) { + throw new GonkaGateModelsError({ + expected: Array.from(SUPPORTED_MODELS, (model) => model.modelId).join(", "), + kind: "no_supported_models", + message: "No curated GonkaGate models are available for the model prompt." + }); + } + + return firstAvailable.model.key; +} + +async function fetchGonkaGateModelCatalog( + apiKey: string, + options: FetchGonkaGateModelsOptions +): Promise { + const fetchImpl = options.fetchImpl ?? fetchGonkaGateModels; + const maxAttempts = Math.max(1, options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS); + const retryDelayMs = Math.max(0, options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS); + const timeoutMs = Math.max(1, options.timeoutMs ?? DEFAULT_TIMEOUT_MS); + let lastCatalogUnavailable: GonkaGateModelsError<"catalog_unavailable"> | undefined; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + let response: GonkaGateModelsHttpResponse; + + try { + response = await fetchImpl(GONKAGATE_MODELS_ENDPOINT, { + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json" + }, + signal: AbortSignal.timeout(timeoutMs) + }); + } catch (error) { + throw new GonkaGateModelsError({ + kind: "request_failed", + message: `Unable to fetch GonkaGate models from ${GONKAGATE_MODELS_ENDPOINT}: ${getErrorMessage(error) ?? "unknown error"}.`, + cause: error + }); + } + + if (response.status === 401 || response.status === 403) { + throw new GonkaGateModelsError({ + kind: "authentication_failed", + message: + `GonkaGate rejected the API key while fetching ${GONKAGATE_MODELS_ENDPOINT}. ` + + "Check the gp-... key and rerun the installer.", + status: response.status + }); + } + + if (response.status === 503) { + lastCatalogUnavailable = new GonkaGateModelsError({ + kind: "catalog_unavailable", + message: + `GonkaGate model catalog is temporarily unavailable (${GONKAGATE_MODELS_ENDPOINT} returned HTTP 503). ` + + "Rerun the installer in a moment.", + status: response.status + }); + + if (attempt < maxAttempts) { + await delay(retryDelayMs); + continue; + } + + throw lastCatalogUnavailable; + } + + if (response.status < 200 || response.status >= 300) { + throw new GonkaGateModelsError({ + kind: "request_failed", + message: `GonkaGate ${GONKAGATE_MODELS_ENDPOINT} returned unexpected HTTP ${response.status}.`, + status: response.status + }); + } + + return parseGonkaGateModelCatalog(await readJsonResponse(response)); + } + + throw lastCatalogUnavailable ?? new GonkaGateModelsError({ + kind: "request_failed", + message: `Unable to fetch GonkaGate models from ${GONKAGATE_MODELS_ENDPOINT}.` + }); +} + +async function fetchGonkaGateModels( + url: string, + init: { headers: Record; signal?: AbortSignal } +): Promise { + return fetch(url, init); +} + +async function readJsonResponse(response: GonkaGateModelsHttpResponse): Promise { + try { + return await response.json(); + } catch (error) { + throw new GonkaGateModelsError({ + kind: "invalid_response", + message: `GonkaGate ${GONKAGATE_MODELS_ENDPOINT} did not return valid JSON.`, + cause: error + }); + } +} + +function parseGonkaGateModelCatalog(value: unknown): GonkaGateModelCatalog { + const root = asPlainObject(value); + + if (!root) { + throw invalidCatalogResponse("data", "object with a data array", value); + } + + if (!Array.isArray(root.data)) { + throw invalidCatalogResponse("data", "array", root.data); + } + + return { + models: root.data.map((entry, index) => parseGonkaGateModelEntry(entry, index)) + }; +} + +function parseGonkaGateModelEntry(value: unknown, index: number): GonkaGateModelCatalogEntry { + const raw = asPlainObject(value); + const fieldPrefix = `data[${index}]`; + + if (!raw) { + throw invalidCatalogResponse(fieldPrefix, "object", value); + } + + if (typeof raw.id !== "string" || raw.id.trim().length === 0) { + throw invalidCatalogResponse(`${fieldPrefix}.id`, "non-empty string", raw.id); + } + + const name = parseOptionalNonEmptyString(raw.name, `${fieldPrefix}.name`); + const contextLength = parseOptionalPositiveInteger(raw.context_length, `${fieldPrefix}.context_length`); + + return { + id: raw.id, + ...(name ? { name } : {}), + ...(contextLength ? { contextLength } : {}) + }; +} + +function parseOptionalNonEmptyString(value: unknown, fieldPath: string): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + + if (typeof value !== "string") { + throw invalidCatalogResponse(fieldPath, "string", value); + } + + const trimmed = value.trim(); + + return trimmed.length > 0 ? trimmed : undefined; +} + +function parseOptionalPositiveInteger(value: unknown, fieldPath: string): number | undefined { + if (value === undefined || value === null) { + return undefined; + } + + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw invalidCatalogResponse(fieldPath, "positive integer", value); + } + + return value; +} + +function invalidCatalogResponse(fieldPath: string, expected: string, actualValue: unknown): GonkaGateModelsError<"invalid_response"> { + return new GonkaGateModelsError({ + actual: describeValue(actualValue), + expected, + kind: "invalid_response", + message: + `GonkaGate ${GONKAGATE_MODELS_ENDPOINT} returned an unexpected response. ` + + `Expected "${fieldPath}" to be ${expected}, found ${describeValue(actualValue)}.` + }); +} + +function createCuratedGonkaGateModelCatalog( + liveModels: readonly GonkaGateModelCatalogEntry[] +): CuratedGonkaGateModelCatalogEntry[] { + const liveById = new Map(liveModels.map((model) => [model.id, model])); + + return SUPPORTED_MODELS.flatMap((model) => { + const liveModel = liveById.get(model.modelId); + + return liveModel ? [createCuratedCatalogEntry(model, liveModel)] : []; + }); +} + +function createCuratedCatalogEntry( + model: SupportedModel, + liveModel?: GonkaGateModelCatalogEntry +): CuratedGonkaGateModelCatalogEntry { + const modelSelection = toManagedModelSelection(model); + + return { + allowlistEntry: modelSelection.allowlistEntry, + model, + primaryModelRef: modelSelection.primaryModelRef, + providerModel: { + id: model.modelId, + name: liveModel?.name ?? model.displayName, + ...(liveModel?.contextLength ? { contextWindow: liveModel.contextLength } : {}) + } + }; +} diff --git a/src/install/install-errors.ts b/src/install/install-errors.ts index adf5713..ee95eea 100644 --- a/src/install/install-errors.ts +++ b/src/install/install-errors.ts @@ -1,6 +1,7 @@ export const INSTALL_ERROR_CODE = { apiKeyInvalid: "api_key_invalid", cliUsageInvalid: "cli_usage_invalid", + gonkaGateModelsFailed: "gonkagate_models_failed", openClawCommandFailed: "openclaw_command_failed", openClawCommandExitedNonZero: "openclaw_command_exited_non_zero", openClawConfigValidationFailed: "openclaw_config_validation_failed", @@ -25,6 +26,14 @@ export const RUNTIME_VERIFICATION_STEP = { } as const; export type RuntimeVerificationStep = typeof RUNTIME_VERIFICATION_STEP[keyof typeof RUNTIME_VERIFICATION_STEP]; export type ApiKeyValidationKind = "missing" | "wrong_prefix" | "invalid_format"; +export type GonkaGateModelsFailureKind = + | "authentication_failed" + | "catalog_unavailable" + | "invalid_response" + | "missing_supported_models" + | "missing_selected_model" + | "no_supported_models" + | "request_failed"; export type OpenClawConfigValidationKind = "command_failed" | "invalid_config" | "unexpected_validated_path"; export type PromptFailureKind = "cancelled" | "missing_tty" | "model_registry_mismatch" | "no_supported_models"; export type SettingsMissingKind = "post_setup_target_missing" | "target_config_missing"; @@ -34,6 +43,7 @@ export type SettingsVerificationKind = | "invalid_permissions" | "missing_allowlist_entry" | "missing_managed_value" + | "missing_provider_model_entry" | "mismatched_allowlist_alias" | "mismatched_managed_value" | "permissions_check_failed"; @@ -97,6 +107,27 @@ export class ApiKeyValidationError extends InstallError { + readonly actual?: string; + readonly expected?: string; + readonly kind: Kind; + readonly status?: number; + + constructor(options: { + actual?: string; + expected?: string; + kind: Kind; + message: string; + status?: number; + } & ErrorOptions) { + super(INSTALL_ERROR_CODE.gonkaGateModelsFailed, options.message, options); + this.actual = options.actual; + this.expected = options.expected; + this.kind = options.kind; + this.status = options.status; + } +} + export class OpenClawNotFoundError extends InstallError { constructor() { super(INSTALL_ERROR_CODE.openClawNotFound, OPENCLAW_NOT_FOUND_MESSAGE); diff --git a/src/install/install-use-case.ts b/src/install/install-use-case.ts index d734a12..8589c9f 100644 --- a/src/install/install-use-case.ts +++ b/src/install/install-use-case.ts @@ -1,9 +1,15 @@ -import { DEFAULT_MODEL_KEY, SUPPORTED_MODELS, requireSupportedModel, toPrimaryModelRef } from "../constants/models.js"; +import { DEFAULT_MODEL_KEY, requireSupportedModel, toPrimaryModelRef } from "../constants/models.js"; import type { SupportedModel, SupportedModelKey } from "../constants/models.js"; import type { OpenClawConfig } from "../types/settings.js"; import { createBackup as createBackupImpl } from "./backup.js"; import { ensureFreshInstallLocalGateway as ensureFreshInstallLocalGatewayImpl } from "./bootstrap-gateway.js"; import { createInstallSuccessDisplay, type CliDisplay } from "./cli-display.js"; +import { + fetchCuratedGonkaGateModelCatalog as fetchCuratedGonkaGateModelCatalogImpl, + getPromptDefaultModelKey, + requireModelInGonkaGateCatalog, + type CuratedGonkaGateModelCatalogEntry +} from "./gonkagate-models.js"; import { loadSettings as loadSettingsImpl, requireLoadedSettings } from "./load-settings.js"; import { mergeSettingsWithGonkaGate } from "./merge-settings.js"; import { createOpenClawFacade, type OpenClawFacade } from "./openclaw-facade.js"; @@ -36,6 +42,7 @@ export interface InstallOutcome { export interface InstallUseCaseDependencies { createBackup: typeof createBackupImpl; ensureFreshInstallLocalGateway: typeof ensureFreshInstallLocalGatewayImpl; + fetchCuratedGonkaGateModelCatalog: typeof fetchCuratedGonkaGateModelCatalogImpl; loadSettings: typeof loadSettingsImpl; openClaw: Pick< OpenClawFacade, @@ -50,6 +57,7 @@ export interface InstallUseCaseDependencies { export const defaultInstallUseCaseDependencies = { createBackup: createBackupImpl, ensureFreshInstallLocalGateway: ensureFreshInstallLocalGatewayImpl, + fetchCuratedGonkaGateModelCatalog: fetchCuratedGonkaGateModelCatalogImpl, loadSettings: loadSettingsImpl, openClaw: createOpenClawFacade(), promptForApiKey: promptForApiKeyImpl, @@ -68,10 +76,9 @@ export async function runInstallUseCase( dependencies.openClaw.validateConfig(request.targetPath); const apiKey = dependencies.validateApiKey(await dependencies.promptForApiKey()); - const selectedModel = request.modelKey - ? requireSupportedModel(request.modelKey) - : await dependencies.promptForModel(SUPPORTED_MODELS, DEFAULT_MODEL_KEY); - const mergedSettings = mergeSettingsWithGonkaGate(configPreparation.settings, apiKey, selectedModel); + const modelCatalog = await dependencies.fetchCuratedGonkaGateModelCatalog(apiKey); + const selectedModel = await selectInstallModel(request.modelKey, modelCatalog, dependencies); + const mergedSettings = mergeSettingsWithGonkaGate(configPreparation.settings, apiKey, selectedModel, modelCatalog); await dependencies.openClaw.validateCandidateConfig(request.targetPath, mergedSettings); const backupPath = configPreparation.source === "existing" ? await dependencies.createBackup(request.targetPath) : undefined; @@ -100,6 +107,23 @@ export async function runInstallUseCase( } } +async function selectInstallModel( + modelKey: SupportedModelKey | undefined, + modelCatalog: readonly CuratedGonkaGateModelCatalogEntry[], + dependencies: Pick +): Promise { + if (modelKey) { + const selectedModel = requireSupportedModel(modelKey); + requireModelInGonkaGateCatalog(selectedModel, modelCatalog); + return selectedModel; + } + + const availableModels = modelCatalog.map((entry) => entry.model); + const defaultModelKey = getPromptDefaultModelKey(modelCatalog, DEFAULT_MODEL_KEY); + + return dependencies.promptForModel(availableModels, defaultModelKey); +} + async function prepareInstallConfig( targetPath: string, dependencies: Pick diff --git a/src/install/merge-settings.ts b/src/install/merge-settings.ts index d3257df..73d2d47 100644 --- a/src/install/merge-settings.ts +++ b/src/install/merge-settings.ts @@ -1,30 +1,38 @@ import { GONKAGATE_OPENAI_API, GONKAGATE_OPENAI_BASE_URL, OPENCLAW_PROVIDER_ID } from "../constants/gateway.js"; -import { toManagedModelSelection } from "../constants/models.js"; import type { ManagedAllowlistEntry, SupportedModel } from "../constants/models.js"; import type { OpenClawConfig } from "../types/settings.js"; +import { + createStaticCuratedGonkaGateModelCatalog, + type CuratedGonkaGateModelCatalogEntry, + type OpenClawProviderModelCatalogEntry +} from "./gonkagate-models.js"; import { readManagedAllowlistEntryWhenPresent, readManagedSettingsView } from "./managed-settings-access.js"; -import { clonePlainArray, clonePlainObject, copyPlainObject, type PlainObject, type ReadonlyPlainObject } from "./object-utils.js"; +import { + asPlainObject, + clonePlainArray, + clonePlainObject, + copyPlainObject, + type PlainObject, + type ReadonlyPlainObject +} from "./object-utils.js"; export function mergeSettingsWithGonkaGate( settings: OpenClawConfig, apiKey: string, - selectedModel: SupportedModel + selectedModel: SupportedModel, + modelCatalog: readonly CuratedGonkaGateModelCatalogEntry[] = createStaticCuratedGonkaGateModelCatalog() ): OpenClawConfig { const managedSettings = readManagedSettingsView(settings, "the loaded OpenClaw config"); - const selectedModelState = toManagedModelSelection(selectedModel); - const managedAllowlist = mergeManagedAllowlistEntry( - managedSettings.allowlist ? clonePlainObject(managedSettings.allowlist) : undefined, - selectedModelState.primaryModelRef, - readManagedAllowlistEntryWhenPresent( - managedSettings.allowlist, - selectedModelState.primaryModelRef, - "the loaded OpenClaw config" - ), - selectedModelState.allowlistEntry + const selectedModelState = requireCatalogEntryForSelectedModel(selectedModel, modelCatalog); + const managedAllowlist = mergeManagedAllowlistEntries( + managedSettings.allowlist ? clonePlainObject(managedSettings.allowlist) : {}, + managedSettings.allowlist, + modelCatalog + ); + const openAiModels = mergeOpenAiProviderModelCatalog( + managedSettings.openaiProvider?.models ? clonePlainArray(managedSettings.openaiProvider.models) : [], + modelCatalog ); - const openAiModels = managedSettings.openaiProvider?.models - ? clonePlainArray(managedSettings.openaiProvider.models) - : []; return { ...settings, @@ -45,7 +53,7 @@ export function mergeSettingsWithGonkaGate( ...copyPlainObject(managedSettings.agents), defaults: { ...copyPlainObject(managedSettings.defaults), - ...(managedAllowlist ? { models: managedAllowlist } : {}), + models: managedAllowlist, model: { ...copyPlainObject(managedSettings.defaultModel?.raw), primary: selectedModelState.primaryModelRef @@ -55,16 +63,48 @@ export function mergeSettingsWithGonkaGate( }; } +function requireCatalogEntryForSelectedModel( + selectedModel: SupportedModel, + modelCatalog: readonly CuratedGonkaGateModelCatalogEntry[] +): CuratedGonkaGateModelCatalogEntry { + const selectedModelState = modelCatalog.find((entry) => entry.model.key === selectedModel.key); + + if (!selectedModelState) { + throw new Error(`Selected model "${selectedModel.key}" is missing from the GonkaGate model catalog.`); + } + + return selectedModelState; +} + +function mergeManagedAllowlistEntries( + allowlist: PlainObject, + existingAllowlist: ReadonlyPlainObject | undefined, + modelCatalog: readonly CuratedGonkaGateModelCatalogEntry[] +): PlainObject { + let mergedAllowlist = allowlist; + + for (const catalogEntry of modelCatalog) { + mergedAllowlist = mergeManagedAllowlistEntry( + mergedAllowlist, + catalogEntry.primaryModelRef, + readManagedAllowlistEntryWhenPresent( + existingAllowlist, + catalogEntry.primaryModelRef, + "the loaded OpenClaw config" + ), + catalogEntry.allowlistEntry + ); + } + + return mergedAllowlist; +} + function mergeManagedAllowlistEntry( - allowlist: PlainObject | undefined, + allowlist: PlainObject, primaryModelRef: string, existingAllowlistEntry: ReadonlyPlainObject | undefined, allowlistEntry: ManagedAllowlistEntry -): PlainObject | undefined { - if (!allowlist) { - return undefined; - } - +): PlainObject { return { ...allowlist, [primaryModelRef]: { @@ -73,3 +113,35 @@ function mergeManagedAllowlistEntry( } }; } + +function mergeOpenAiProviderModelCatalog( + existingModels: unknown[], + modelCatalog: readonly CuratedGonkaGateModelCatalogEntry[] +): unknown[] { + const mergedModels = [...existingModels]; + + for (const catalogEntry of modelCatalog) { + upsertProviderModelCatalogEntry(mergedModels, catalogEntry.providerModel); + } + + return mergedModels; +} + +function upsertProviderModelCatalogEntry( + catalog: unknown[], + providerModel: OpenClawProviderModelCatalogEntry +): void { + const existingIndex = catalog.findIndex((entry) => asPlainObject(entry)?.id === providerModel.id); + + if (existingIndex === -1) { + catalog.push({ ...providerModel }); + return; + } + + const existingEntry = asPlainObject(catalog[existingIndex]); + + catalog[existingIndex] = { + ...copyPlainObject(existingEntry), + ...providerModel + }; +} diff --git a/src/install/verify-settings.ts b/src/install/verify-settings.ts index b86b523..b8a8d85 100644 --- a/src/install/verify-settings.ts +++ b/src/install/verify-settings.ts @@ -3,6 +3,8 @@ import { GONKAGATE_OPENAI_API, GONKAGATE_OPENAI_BASE_URL } from "../constants/ga import { getManagedModelSelectionByPrimaryRef, listSupportedPrimaryModelRefs, + SUPPORTED_MODELS, + toPrimaryModelRef, type ManagedModelSelection } from "../constants/models.js"; import type { SupportedModel } from "../constants/models.js"; @@ -30,7 +32,7 @@ export async function verifySettings(filePath: string, settings: OpenClawConfig) const baseUrl = requireNonEmptyString(provider.baseUrl, MANAGED_SETTINGS_PATHS.openaiBaseUrl, filePath); const api = requireNonEmptyString(provider.api, MANAGED_SETTINGS_PATHS.openaiApi, filePath); requireManagedApiKey(provider.apiKey, filePath); - requirePresentArray(provider.models, MANAGED_SETTINGS_PATHS.openaiModels, filePath); + const providerModels = requirePresentArray(provider.models, MANAGED_SETTINGS_PATHS.openaiModels, filePath); const primaryModelRef = getPrimaryModelRef(managed.defaultModel, filePath); if (baseUrl !== GONKAGATE_OPENAI_BASE_URL) { @@ -68,7 +70,8 @@ export async function verifySettings(filePath: string, settings: OpenClawConfig) }); } - verifyModelAllowlistWhenPresent(managed.allowlist, filePath, selectedModelState); + verifyOpenAiProviderModelCatalog(providerModels, filePath); + verifyModelAllowlist(managed.allowlist, filePath); let configMode: number; @@ -151,15 +154,30 @@ function requireManagedApiKey(value: unknown, filePath: string): string { } } -function verifyModelAllowlistWhenPresent( +function verifyModelAllowlist( allowlist: ReadonlyPlainObject | undefined, - filePath: string, - selectedModelState: ManagedModelSelection + filePath: string ): void { if (!allowlist) { - return; + throw new SettingsVerificationError({ + fieldPath: MANAGED_SETTINGS_PATHS.allowlist, + filePath, + kind: "missing_managed_value", + message: + `Expected "${MANAGED_SETTINGS_PATHS.allowlist}" in ${filePath} to exist so OpenClaw /models can list curated GonkaGate models.` + }); } + for (const model of SUPPORTED_MODELS) { + verifyModelAllowlistEntry(allowlist, filePath, getRequiredManagedModelSelection(model)); + } +} + +function verifyModelAllowlistEntry( + allowlist: ReadonlyPlainObject, + filePath: string, + selectedModelState: ManagedModelSelection +): void { const allowlistEntry = readManagedAllowlistEntryWhenPresent( allowlist, selectedModelState.primaryModelRef, @@ -192,6 +210,41 @@ function verifyModelAllowlistWhenPresent( } } +function verifyOpenAiProviderModelCatalog(providerModels: readonly unknown[], filePath: string): void { + for (const model of SUPPORTED_MODELS) { + const expectedModelId = model.modelId; + const modelEntry = providerModels.find((entry) => { + const objectEntry = typeof entry === "object" && entry !== null && !Array.isArray(entry) + ? entry as ReadonlyPlainObject + : undefined; + + return objectEntry?.id === expectedModelId; + }); + + if (!modelEntry) { + throw new SettingsVerificationError({ + expected: expectedModelId, + fieldPath: MANAGED_SETTINGS_PATHS.openaiModels, + filePath, + kind: "missing_provider_model_entry", + message: + `Expected "${MANAGED_SETTINGS_PATHS.openaiModels}" in ${filePath} to include model id "${expectedModelId}" ` + + "so OpenClaw /models can expose the curated GonkaGate catalog." + }); + } + } +} + +function getRequiredManagedModelSelection(model: SupportedModel): ManagedModelSelection { + const modelSelection = getManagedModelSelectionByPrimaryRef(toPrimaryModelRef(model)); + + if (!modelSelection) { + throw new Error(`Curated model "${model.key}" does not resolve to a managed OpenClaw model ref.`); + } + + return modelSelection; +} + function requireNonEmptyString(value: unknown, fieldPath: string, filePath: string): string { if (typeof value !== "string" || value.trim().length === 0) { throw new SettingsVerificationError({ diff --git a/test/gonkagate-models.test.ts b/test/gonkagate-models.test.ts new file mode 100644 index 0000000..0bea0b7 --- /dev/null +++ b/test/gonkagate-models.test.ts @@ -0,0 +1,195 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { DEFAULT_MODEL, requireSupportedModel } from "../src/constants/models.js"; +import { GonkaGateModelsError } from "../src/install/install-errors.js"; +import { + fetchCuratedGonkaGateModelCatalog, + getPromptDefaultModelKey, + requireModelInGonkaGateCatalog +} from "../src/install/gonkagate-models.js"; + +test("fetchCuratedGonkaGateModelCatalog fetches and maps live curated model metadata", async () => { + let capturedUrl: string | undefined; + let capturedAuthorization: string | undefined; + + const catalog = await fetchCuratedGonkaGateModelCatalog("gp-test-key", { + fetchImpl: async (url, init) => { + capturedUrl = url; + capturedAuthorization = init.headers.Authorization; + + return { + status: 200, + json: async () => ({ + object: "list", + data: [ + { + id: "moonshotai/Kimi-K2.6", + name: "Kimi K2.6 Live", + object: "model" + }, + { + id: "unsupported/provider-model", + name: "Unsupported", + object: "model" + }, + { + context_length: 262144, + id: "qwen/qwen3-235b-a22b-instruct-2507-fp8", + name: "Qwen3 235B A22B Instruct 2507 FP8", + object: "model" + } + ] + }) + }; + }, + maxAttempts: 1 + }); + + assert.equal(capturedUrl, "https://api.gonkagate.com/v1/models"); + assert.equal(capturedAuthorization, "Bearer gp-test-key"); + assert.deepEqual(catalog.map((entry) => entry.model.key), ["qwen3-235b", "kimi-k2.6"]); + assert.deepEqual(catalog[0]?.providerModel, { + contextWindow: 262144, + id: "qwen/qwen3-235b-a22b-instruct-2507-fp8", + name: "Qwen3 235B A22B Instruct 2507 FP8" + }); + assert.deepEqual(catalog[1]?.providerModel, { + id: "moonshotai/Kimi-K2.6", + name: "Kimi K2.6 Live" + }); +}); + +test("fetchCuratedGonkaGateModelCatalog retries temporary catalog unavailability", async () => { + let calls = 0; + + const catalog = await fetchCuratedGonkaGateModelCatalog("gp-test-key", { + fetchImpl: async () => { + calls += 1; + + if (calls === 1) { + return { + status: 503, + json: async () => ({}) + }; + } + + return { + status: 200, + json: async () => ({ + data: [ + { + id: DEFAULT_MODEL.modelId + }, + { + id: "qwen/qwen3-235b-a22b-instruct-2507-fp8" + } + ] + }) + }; + }, + maxAttempts: 2, + retryDelayMs: 0 + }); + + assert.equal(calls, 2); + assert.deepEqual(catalog.map((entry) => entry.model.key), ["qwen3-235b", DEFAULT_MODEL.key]); +}); + +test("fetchCuratedGonkaGateModelCatalog rejects invalid API keys before config writes", async () => { + await assert.rejects( + fetchCuratedGonkaGateModelCatalog("gp-bad-key", { + fetchImpl: async () => ({ + status: 401, + json: async () => ({ + error: { + code: "invalid_api_key" + } + }) + }), + maxAttempts: 1 + }), + (error) => { + assert.ok(error instanceof GonkaGateModelsError); + assert.equal(error.kind, "authentication_failed"); + assert.equal(error.status, 401); + return true; + } + ); +}); + +test("fetchCuratedGonkaGateModelCatalog rejects malformed model catalog responses", async () => { + await assert.rejects( + fetchCuratedGonkaGateModelCatalog("gp-test-key", { + fetchImpl: async () => ({ + status: 200, + json: async () => ({ + data: [ + { + id: 42 + } + ] + }) + }), + maxAttempts: 1 + }), + (error) => { + assert.ok(error instanceof GonkaGateModelsError); + assert.equal(error.kind, "invalid_response"); + assert.match(error.message, /data\[0\]\.id/); + return true; + } + ); +}); + +test("fetchCuratedGonkaGateModelCatalog rejects catalogs that omit a curated supported model", async () => { + await assert.rejects( + fetchCuratedGonkaGateModelCatalog("gp-test-key", { + fetchImpl: async () => ({ + status: 200, + json: async () => ({ + data: [ + { + id: "qwen/qwen3-235b-a22b-instruct-2507-fp8" + } + ] + }) + }), + maxAttempts: 1 + }), + (error) => { + assert.ok(error instanceof GonkaGateModelsError); + assert.equal(error.kind, "missing_supported_models"); + assert.match(error.message, /moonshotai\/Kimi-K2\.6/); + return true; + } + ); +}); + +test("model selection helpers keep selected models inside the live curated catalog", () => { + const qwen = requireSupportedModel("qwen3-235b"); + const kimi = requireSupportedModel("kimi-k2.6"); + const catalog = [ + { + allowlistEntry: { + alias: qwen.key + }, + model: qwen, + primaryModelRef: "openai/qwen/qwen3-235b-a22b-instruct-2507-fp8" as const, + providerModel: { + id: qwen.modelId, + name: qwen.displayName + } + } + ]; + + assert.equal(getPromptDefaultModelKey(catalog, kimi.key), qwen.key); + assert.doesNotThrow(() => requireModelInGonkaGateCatalog(qwen, catalog)); + assert.throws( + () => requireModelInGonkaGateCatalog(kimi, catalog), + (error) => { + assert.ok(error instanceof GonkaGateModelsError); + assert.equal(error.kind, "missing_selected_model"); + return true; + } + ); +}); diff --git a/test/install-use-case.test.ts b/test/install-use-case.test.ts index a9c60aa..0352832 100644 --- a/test/install-use-case.test.ts +++ b/test/install-use-case.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { DEFAULT_MODEL, DEFAULT_MODEL_KEY, toPrimaryModelRef } from "../src/constants/models.js"; -import { InstallError } from "../src/install/install-errors.js"; +import { DEFAULT_MODEL, DEFAULT_MODEL_KEY, requireSupportedModel, toPrimaryModelRef } from "../src/constants/models.js"; +import { GonkaGateModelsError, InstallError } from "../src/install/install-errors.js"; +import { createStaticCuratedGonkaGateModelCatalog } from "../src/install/gonkagate-models.js"; import type { OpenClawConfig } from "../src/types/settings.js"; import { runInstallUseCase, @@ -19,6 +20,7 @@ interface InstallHarnessState { configValidationCalls: number; configValidationPaths: string[]; ensureCalls: number; + fetchModelCatalogCalls: number; gatewayBootstrapCalls: number; loadCalls: number; loadPaths: string[]; @@ -71,6 +73,20 @@ function createGatewayUnavailableInstallResult() { }; } +function createStaticModelCatalog() { + return createStaticCuratedGonkaGateModelCatalog(); +} + +function expectedAllowlist() { + return Object.fromEntries( + createStaticModelCatalog().map((entry) => [entry.primaryModelRef, entry.allowlistEntry]) + ); +} + +function expectedProviderModels() { + return createStaticModelCatalog().map((entry) => entry.providerModel); +} + function createInstallHarness( overrides: { dependencies?: Partial>; @@ -86,6 +102,7 @@ function createInstallHarness( configValidationCalls: 0, configValidationPaths: [], ensureCalls: 0, + fetchModelCatalogCalls: 0, gatewayBootstrapCalls: 0, loadCalls: 0, loadPaths: [], @@ -145,6 +162,11 @@ function createInstallHarness( recordStep(state, "bootstrapGateway"); return createGatewayBootstrapResult(withLocalGatewayMode(settings), true); }, + fetchCuratedGonkaGateModelCatalog: async () => { + state.fetchModelCatalogCalls += 1; + recordStep(state, "fetchModelCatalog"); + return createStaticModelCatalog(); + }, loadSettings: async (filePath) => { state.loadCalls += 1; state.loadPaths.push(filePath); @@ -237,6 +259,7 @@ test("runInstallUseCase initializes missing OpenClaw config automatically and sk verifyRuntimeForInstall: () => createGatewayUnavailableInstallResult() }, promptForApiKey: async () => "gp-test-key", + fetchCuratedGonkaGateModelCatalog: async () => createStaticModelCatalog(), promptForModel: async () => DEFAULT_MODEL, validateApiKey: (apiKey) => apiKey, writeSettings: async (_filePath, settings) => { @@ -256,6 +279,7 @@ test("runInstallUseCase initializes missing OpenClaw config automatically and sk agents: { defaults: { workspace: "~/.openclaw/workspace", + models: expectedAllowlist(), model: { primary: toPrimaryModelRef(DEFAULT_MODEL) } @@ -273,7 +297,7 @@ test("runInstallUseCase initializes missing OpenClaw config automatically and sk api: "openai-completions", apiKey: "gp-test-key", baseUrl: "https://api.gonkagate.com/v1", - models: [] + models: expectedProviderModels() } } } @@ -321,6 +345,7 @@ test("runInstallUseCase preserves an existing gateway.mode from fresh OpenClaw s verifyRuntimeForInstall: () => createGatewayUnavailableInstallResult() }, promptForApiKey: async () => "gp-test-key", + fetchCuratedGonkaGateModelCatalog: async () => createStaticModelCatalog(), promptForModel: async () => DEFAULT_MODEL, validateApiKey: (apiKey) => apiKey, writeSettings: async (_filePath, settings) => { @@ -467,11 +492,13 @@ test("runInstallUseCase uses the curated --model value without prompting for a m }, dependencies); assert.equal(state.promptApiKeyCalls, 1); + assert.equal(state.fetchModelCatalogCalls, 1); assert.equal(state.promptModelCalls, 0); assert.equal(state.configValidationCalls, 1); assert.equal(state.prewriteValidationCalls, 1); assert.equal(state.writeCalls, 1); assert.deepEqual((state.writtenSettings?.agents as Record).defaults, { + models: expectedAllowlist(), model: { primary: toPrimaryModelRef(DEFAULT_MODEL) } @@ -536,6 +563,7 @@ test("runInstallUseCase surfaces a clear error when OpenClaw setup does not crea promptCalls += 1; return "gp-test-key"; }, + fetchCuratedGonkaGateModelCatalog: async () => createStaticModelCatalog(), promptForModel: async () => DEFAULT_MODEL, validateApiKey: (apiKey) => apiKey, writeSettings: async () => {} @@ -602,3 +630,89 @@ test("runInstallUseCase stops before backup or write when API key validation fai assert.equal(state.backupCalls, 0); assert.equal(state.writeCalls, 0); }); + +test("runInstallUseCase stops before the model prompt when the live GonkaGate catalog fetch fails", async () => { + const failure = new GonkaGateModelsError({ + kind: "catalog_unavailable", + message: "GonkaGate model catalog is unavailable." + }); + const { dependencies, state } = createInstallHarness({ + dependencies: { + fetchCuratedGonkaGateModelCatalog: async () => { + state.fetchModelCatalogCalls += 1; + throw failure; + } + } + }); + + await assert.rejects( + runInstallUseCase({ + targetPath: "/tmp/openclaw.json" + }, dependencies), + (error) => error === failure + ); + + assert.equal(state.promptApiKeyCalls, 1); + assert.equal(state.fetchModelCatalogCalls, 1); + assert.equal(state.promptModelCalls, 0); + assert.equal(state.backupCalls, 0); + assert.equal(state.writeCalls, 0); +}); + +test("runInstallUseCase prompts with the curated models returned by the live GonkaGate catalog", async () => { + let promptModels: string[] = []; + let promptDefaultModelKey: string | undefined; + const { dependencies, state } = createInstallHarness({ + dependencies: { + fetchCuratedGonkaGateModelCatalog: async () => { + state.fetchModelCatalogCalls += 1; + return createStaticModelCatalog(); + }, + promptForModel: async (models, defaultModelKey) => { + state.promptModelCalls += 1; + promptModels = models.map((model) => model.key); + promptDefaultModelKey = defaultModelKey; + return DEFAULT_MODEL; + } + } + }); + + await runInstallUseCase({ + targetPath: "/tmp/openclaw.json" + }, dependencies); + + assert.deepEqual(promptModels, ["qwen3-235b", "kimi-k2.6"]); + assert.equal(promptDefaultModelKey, DEFAULT_MODEL_KEY); + assert.equal(state.writeCalls, 1); + assert.deepEqual(((state.writtenSettings?.agents as Record).defaults as Record).model, { + primary: toPrimaryModelRef(DEFAULT_MODEL) + }); +}); + +test("runInstallUseCase rejects --model values that are curated but absent from the live GonkaGate catalog", async () => { + const qwen = requireSupportedModel("qwen3-235b"); + const { dependencies, state } = createInstallHarness({ + dependencies: { + fetchCuratedGonkaGateModelCatalog: async () => { + state.fetchModelCatalogCalls += 1; + return createStaticCuratedGonkaGateModelCatalog([qwen]); + } + } + }); + + await assert.rejects( + runInstallUseCase({ + modelKey: DEFAULT_MODEL_KEY, + targetPath: "/tmp/openclaw.json" + }, dependencies), + (error) => { + assert.ok(error instanceof GonkaGateModelsError); + assert.equal(error.kind, "missing_selected_model"); + return true; + } + ); + + assert.equal(state.promptModelCalls, 0); + assert.equal(state.backupCalls, 0); + assert.equal(state.writeCalls, 0); +}); diff --git a/test/merge-settings.test.ts b/test/merge-settings.test.ts index 2a4e889..c0b7921 100644 --- a/test/merge-settings.test.ts +++ b/test/merge-settings.test.ts @@ -2,9 +2,20 @@ import assert from "node:assert/strict"; import test from "node:test"; import { DEFAULT_MODEL, SUPPORTED_MODELS, toPrimaryModelRef } from "../src/constants/models.js"; import { SettingsShapeError } from "../src/install/install-errors.js"; +import { createStaticCuratedGonkaGateModelCatalog } from "../src/install/gonkagate-models.js"; import { mergeSettingsWithGonkaGate } from "../src/install/merge-settings.js"; -test("mergeSettingsWithGonkaGate adds an empty openai model catalog when the provider did not define one", () => { +function expectedAllowlist() { + return Object.fromEntries( + createStaticCuratedGonkaGateModelCatalog().map((entry) => [entry.primaryModelRef, entry.allowlistEntry]) + ); +} + +function expectedProviderModels() { + return createStaticCuratedGonkaGateModelCatalog().map((entry) => entry.providerModel); +} + +test("mergeSettingsWithGonkaGate adds the curated openai model catalog when the provider did not define one", () => { const merged = mergeSettingsWithGonkaGate({}, "gp-test-key", DEFAULT_MODEL); assert.deepEqual(merged.models, { @@ -13,13 +24,13 @@ test("mergeSettingsWithGonkaGate adds an empty openai model catalog when the pro api: "openai-completions", apiKey: "gp-test-key", baseUrl: "https://api.gonkagate.com/v1", - models: [] + models: expectedProviderModels() } } }); }); -test("mergeSettingsWithGonkaGate preserves an existing openai model catalog and unrelated provider entries", () => { +test("mergeSettingsWithGonkaGate merges the curated openai model catalog and unrelated provider entries", () => { const existingCatalog = [ { id: "existing-model", @@ -59,12 +70,15 @@ test("mergeSettingsWithGonkaGate preserves an existing openai model catalog and headers: { "x-extra-header": "keep-me" }, - models: existingCatalog + models: [ + ...existingCatalog, + ...expectedProviderModels() + ] } }); }); -test("mergeSettingsWithGonkaGate keeps agents.defaults.models behavior scoped to existing allowlists", () => { +test("mergeSettingsWithGonkaGate merges curated entries into existing agents.defaults.models allowlists", () => { for (const model of SUPPORTED_MODELS) { const merged = mergeSettingsWithGonkaGate( { @@ -90,19 +104,18 @@ test("mergeSettingsWithGonkaGate keeps agents.defaults.models behavior scoped to "openai/legacy-model": { alias: "legacy" }, - [toPrimaryModelRef(model)]: { - alias: model.key - } + ...expectedAllowlist() } }); } }); -test("mergeSettingsWithGonkaGate does not create agents.defaults.models when no allowlist existed", () => { +test("mergeSettingsWithGonkaGate creates agents.defaults.models for the curated switcher catalog", () => { for (const model of SUPPORTED_MODELS) { const merged = mergeSettingsWithGonkaGate({}, "gp-test-key", model); assert.deepEqual((merged.agents as Record).defaults, { + models: expectedAllowlist(), model: { primary: toPrimaryModelRef(model) } @@ -156,6 +169,7 @@ test("mergeSettingsWithGonkaGate preserves unrelated agents.defaults.model keys" ); assert.deepEqual((merged.agents as Record).defaults, { + models: expectedAllowlist(), model: { fallback: "openai/legacy-model", primary: toPrimaryModelRef(DEFAULT_MODEL), diff --git a/test/test-helpers.ts b/test/test-helpers.ts index 8a3fe49..ea712e6 100644 --- a/test/test-helpers.ts +++ b/test/test-helpers.ts @@ -4,6 +4,7 @@ import path from "node:path"; import process from "node:process"; import { GONKAGATE_OPENAI_API, GONKAGATE_OPENAI_BASE_URL } from "../src/constants/gateway.js"; import { DEFAULT_MODEL, toPrimaryModelRef, type SupportedModel } from "../src/constants/models.js"; +import { createStaticCuratedGonkaGateModelCatalog } from "../src/install/gonkagate-models.js"; import type { OpenClawConfig } from "../src/types/settings.js"; export async function createTempDirectory(prefix: string): Promise { @@ -77,11 +78,10 @@ export function createManagedConfigFixture(options: ManagedConfigFixtureOptions const primaryModelRef = options.primaryModelRef ?? toPrimaryModelRef(selectedModel); const defaults = asRecord(options.defaults); const defaultModel = asRecord(defaults.model); - const allowlist = options.allowlist ?? { - [primaryModelRef]: { - alias: selectedModel.key - } - }; + const modelCatalog = createStaticCuratedGonkaGateModelCatalog(); + const allowlist = options.allowlist ?? Object.fromEntries( + modelCatalog.map((entry) => [entry.primaryModelRef, entry.allowlistEntry]) + ); return { models: { @@ -90,7 +90,9 @@ export function createManagedConfigFixture(options: ManagedConfigFixtureOptions baseUrl: GONKAGATE_OPENAI_BASE_URL, api: GONKAGATE_OPENAI_API, apiKey: "gp-test-key", - ...(options.includeOpenAiModels === false ? {} : { models: [] }), + ...(options.includeOpenAiModels === false + ? {} + : { models: modelCatalog.map((entry) => entry.providerModel) }), ...options.openaiProvider } } @@ -102,7 +104,7 @@ export function createManagedConfigFixture(options: ManagedConfigFixtureOptions ...defaultModel, primary: primaryModelRef }, - ...(options.includeAllowlist ? { models: allowlist } : {}) + ...(options.includeAllowlist === false ? {} : { models: allowlist }) } } }; diff --git a/test/verify-settings.test.ts b/test/verify-settings.test.ts index c4f43ba..f15ab89 100644 --- a/test/verify-settings.test.ts +++ b/test/verify-settings.test.ts @@ -10,6 +10,12 @@ import { verifySettings } from "../src/install/verify-settings.js"; import { writeSettings } from "../src/install/write-settings.js"; import { createManagedConfigFixture, createTempFilePath } from "./test-helpers.js"; +function expectedAllowlist() { + return Object.fromEntries( + SUPPORTED_MODELS.map((model) => [toPrimaryModelRef(model), { alias: model.key }]) + ); +} + test("verifySettings accepts the managed GonkaGate provider config with owner-only permissions", async () => { for (const model of SUPPORTED_MODELS) { const filePath = await createManagedConfigFile(`openclaw-verify-success-${model.key}-`, 0o600); @@ -228,17 +234,33 @@ test("verifySettings rejects missing allowlist entries when agents.defaults.mode ); }); +test("verifySettings rejects configs without the curated model switcher allowlist", async () => { + const filePath = await createManagedConfigFile("openclaw-verify-missing-allowlist-", 0o600); + + await assert.rejects( + verifySettings(filePath, createManagedConfigFixture({ + includeAllowlist: false + })), + (error) => { + assert.ok(error instanceof SettingsVerificationError); + assert.equal(error.kind, "missing_managed_value"); + assert.equal(error.fieldPath, "agents.defaults.models"); + return true; + } + ); +}); + test("verifySettings rejects mismatched allowlist aliases when agents.defaults.models is present", async () => { for (const model of SUPPORTED_MODELS) { const filePath = await createManagedConfigFile(`openclaw-verify-alias-${model.key}-`, 0o600); + const allowlist = expectedAllowlist(); + allowlist[toPrimaryModelRef(model)] = { + alias: "wrong-alias" + }; await assert.rejects( verifySettings(filePath, createManagedConfigFixture({ - allowlist: { - [toPrimaryModelRef(model)]: { - alias: "wrong-alias" - } - }, + allowlist, includeAllowlist: true, selectedModel: model })), @@ -253,6 +275,24 @@ test("verifySettings rejects mismatched allowlist aliases when agents.defaults.m } }); +test("verifySettings rejects configs whose openai model catalog omits a curated model id", async () => { + const filePath = await createManagedConfigFile("openclaw-verify-provider-model-entry-", 0o600); + + await assert.rejects( + verifySettings(filePath, createManagedConfigFixture({ + openaiProvider: { + models: [] + } + })), + (error) => { + assert.ok(error instanceof SettingsVerificationError); + assert.equal(error.kind, "missing_provider_model_entry"); + assert.equal(error.fieldPath, "models.providers.openai.models"); + return true; + } + ); +}); + test("verifySettings rejects configs whose permissions are not owner-only", async () => { const filePath = await createManagedConfigFile("openclaw-verify-mode-", 0o644);