diff --git a/.agents/skills/pi-setup-development/SKILL.md b/.agents/skills/pi-setup-development/SKILL.md index 335dc6e..a6f673c 100644 --- a/.agents/skills/pi-setup-development/SKILL.md +++ b/.agents/skills/pi-setup-development/SKILL.md @@ -20,6 +20,8 @@ Current runtime contract: - base URL: `https://api.gonkagate.com/v1` - Pi API type: `openai-completions` - auth binding: `apiKey: "$GONKAGATE_API_KEY"` +- model list source: setup-time `GET /v1/models` +- interactive model selection: arrow-key picker - setup success: `configured`, not live `verified` Rules: @@ -35,7 +37,7 @@ Rules: - never add a plain `--api-key` flag - do not mutate shell profiles - do not generate `.env` files -- do not add arbitrary custom base URLs or arbitrary custom model ids +- do not add arbitrary custom base URLs or model ids outside `/v1/models` - do not claim default live GonkaGate/Pi verification - keep TypeScript imports runtime-correct for compiled ESM - run `npm run ci` before calling the change ready diff --git a/.claude/skills/pi-setup-development/SKILL.md b/.claude/skills/pi-setup-development/SKILL.md index 335dc6e..a6f673c 100644 --- a/.claude/skills/pi-setup-development/SKILL.md +++ b/.claude/skills/pi-setup-development/SKILL.md @@ -20,6 +20,8 @@ Current runtime contract: - base URL: `https://api.gonkagate.com/v1` - Pi API type: `openai-completions` - auth binding: `apiKey: "$GONKAGATE_API_KEY"` +- model list source: setup-time `GET /v1/models` +- interactive model selection: arrow-key picker - setup success: `configured`, not live `verified` Rules: @@ -35,7 +37,7 @@ Rules: - never add a plain `--api-key` flag - do not mutate shell profiles - do not generate `.env` files -- do not add arbitrary custom base URLs or arbitrary custom model ids +- do not add arbitrary custom base URLs or model ids outside `/v1/models` - do not claim default live GonkaGate/Pi verification - keep TypeScript imports runtime-correct for compiled ESM - run `npm run ci` before calling the change ready diff --git a/AGENTS.md b/AGENTS.md index 482935f..c5d8e96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,9 @@ Current honest state: `~/.pi/agent/models.json` - the CLI reads a GonkaGate API key from `GONKAGATE_API_KEY`, `--api-key-stdin`, or a hidden prompt +- the CLI fetches available models from GonkaGate `/v1/models` after API-key + input +- the CLI uses an arrow-key model picker in interactive terminals - the CLI writes a Pi-compatible `gonkagate` API-key entry in `~/.pi/agent/auth.json` - the CLI sets `defaultProvider` and `defaultModel` in @@ -40,12 +43,13 @@ The intended minimal happy path is: 1. user runs `npx -y @gonkagate/pi-setup@latest` 2. installer resolves the Pi `models.json` path -3. installer lets the user choose a curated GonkaGate model or uses the - recommended model in non-interactive mode -4. installer upserts the curated GonkaGate provider config -5. installer stores the API key in Pi `auth.json` without printing it -6. installer sets Pi defaults in `settings.json` -7. user returns to plain `pi` +3. installer fetches available models from GonkaGate `/v1/models` +4. installer lets the user choose a fetched GonkaGate model or uses the first + fetched model in non-interactive mode +5. installer upserts the GonkaGate provider config with fetched models +6. installer stores the API key in Pi `auth.json` without printing it +7. installer sets Pi defaults in `settings.json` +8. user returns to plain `pi` ## Fixed Product Invariants @@ -59,7 +63,7 @@ The intended minimal happy path is: - the durable Pi settings config target is `~/.pi/agent/settings.json` - the managed provider key is `providers.gonkagate` - the managed auth key is `gonkagate` -- the managed provider catalog includes every public curated GonkaGate model +- the managed provider catalog comes from GonkaGate `/v1/models` at setup time - the CLI must preserve unrelated providers and top-level config values - the provider auth binding remains exactly `apiKey: "$GONKAGATE_API_KEY"` - the allowed secret inputs are `GONKAGATE_API_KEY`, `--api-key-stdin`, and a @@ -68,7 +72,7 @@ The intended minimal happy path is: - shell profile mutation is out of scope - `.env` file generation is out of scope - arbitrary custom base URLs are out of scope -- arbitrary custom model ids are out of scope +- arbitrary custom model ids outside fetched `/v1/models` are out of scope - concurrent-writer safety is not claimed without locking or an equivalent design - live GonkaGate/Pi session verification is out of scope for the initial @@ -92,7 +96,8 @@ The intended minimal happy path is: - `src/config.ts` owns the Pi `models.json` merge behavior - `src/install/user-config.ts` owns the Pi `auth.json` and `settings.json` merge behavior -- `src/constants.ts` owns the GonkaGate provider and curated model contract +- `src/constants.ts` owns the GonkaGate provider endpoint constants +- `src/install/deps.ts` owns setup-time GonkaGate `/v1/models` fetching - `src/paths.ts` owns default Pi path resolution - `docs/specs/pi-setup-prd/spec.md` is the current product requirements document and task-planning source diff --git a/CHANGELOG.md b/CHANGELOG.md index 226495c..1ca32da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,10 @@ ### Features -- Add v2 Pi setup flow with curated model selection, safe API-key intake via - `GONKAGATE_API_KEY`, `--api-key-stdin`, or a hidden prompt, Pi `auth.json` - storage, and Pi default model settings. +- Add v2 Pi setup flow with `/v1/models` model selection, safe API-key intake + via `GONKAGATE_API_KEY`, `--api-key-stdin`, or a hidden prompt, Pi + `auth.json` storage, and Pi default model settings. +- Add an arrow-key interactive model picker for TTY setup. ### Security diff --git a/README.md b/README.md index 9285913..8fd22ad 100644 --- a/README.md +++ b/README.md @@ -29,16 +29,19 @@ Pi's provider, auth, or default-model config. - Preserves unrelated Pi providers and top-level config. - Reads your API key from `GONKAGATE_API_KEY`, `--api-key-stdin`, or a hidden prompt. +- Fetches the current GonkaGate model list from `/v1/models` using that API key. +- Shows an arrow-key model picker in interactive terminals. - Writes a Pi-compatible `gonkagate` API-key entry to `~/.pi/agent/auth.json`. - Sets `defaultProvider` and `defaultModel` in `~/.pi/agent/settings.json`. - Creates sibling backups before replacing existing managed files. -- Installs the curated GonkaGate model catalog for Pi Coding Agent. +- Installs the fetched GonkaGate model list for Pi Coding Agent. - Does not accept a plain `--api-key` flag and never prints `gp-...` keys. Setup success means `configured`: GonkaGate config and Pi auth/default settings -were written or already matched. It does not mean `verified`; default setup does -not make a live GonkaGate API call. +were written or already matched. It does not mean `verified`; default setup only +calls `/v1/models` for model metadata and does not verify a live Pi chat +session. ## Quick Start @@ -59,6 +62,9 @@ Preview the generated config without writing: npx -y @gonkagate/pi-setup@latest --dry-run ``` +Dry runs still need an API-key source because model choices come from +`/v1/models`. + Use a custom config path during testing: ```bash @@ -71,10 +77,10 @@ pattern beside `auth.json` and `settings.json`. ## Limits -The setup is local-file-only and network-free. It does not mutate shell +The setup only makes the `/v1/models` metadata request. It does not mutate shell profiles, generate `.env` files, accept `--api-key`, support arbitrary custom -base URLs, support arbitrary custom model ids, claim concurrent-writer safety, -or run default live GonkaGate/Pi verification. +base URLs, support arbitrary custom model ids outside `/v1/models`, claim +concurrent-writer safety, or run default live GonkaGate/Pi verification. Deferred features require the evidence gates in [`docs/specs/pi-setup-prd/spec.md`](docs/specs/pi-setup-prd/spec.md) before diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 35a2e3c..d672613 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -1,7 +1,8 @@ # How It Works Pi supports custom providers through `~/.pi/agent/models.json`. This package -upserts a single managed provider: +fetches current models from GonkaGate `/v1/models`, then upserts a single +managed provider: ```json { @@ -11,7 +12,7 @@ upserts a single managed provider: "baseUrl": "https://api.gonkagate.com/v1", "api": "openai-completions", "apiKey": "$GONKAGATE_API_KEY", - "models": [{ "id": "moonshotai/Kimi-K2.6" }] + "models": [{ "id": "dynamic/model-id", "name": "dynamic/model-id" }] } } } @@ -19,7 +20,8 @@ upserts a single managed provider: The CLI reads any existing JSON object, preserves unrelated keys, replaces only `providers.gonkagate`, writes a backup when the target file already exists, and -then writes the updated JSON. +then writes the updated JSON. The managed model list comes from setup-time +`/v1/models`, not a repository registry. For v2 it also updates two Pi-native user files beside `models.json`: @@ -27,12 +29,15 @@ For v2 it also updates two Pi-native user files beside `models.json`: - `settings.json`: sets `defaultProvider` and `defaultModel`. The API key comes from `GONKAGATE_API_KEY`, `--api-key-stdin`, or a hidden -prompt. The CLI never accepts `--api-key` and never prints raw `gp-...` keys. +prompt. The CLI uses it to fetch `/v1/models`, never accepts `--api-key`, and +never prints raw `gp-...` keys. + +Interactive terminals use an arrow-key picker for the fetched model list. Every changed existing file gets a sibling `*.backup-*` file before replacement. If a later write fails, already-written files are rolled back from their backup or removed when newly created. The setup does not mutate shell profiles, generate `.env` files, accept -arbitrary base URLs or model ids, or verify a live Pi/GonkaGate session by -default. +arbitrary base URLs or model ids outside `/v1/models`, or verify a live +Pi/GonkaGate chat session by default. diff --git a/docs/security.md b/docs/security.md index 8af2807..37b83f1 100644 --- a/docs/security.md +++ b/docs/security.md @@ -18,6 +18,9 @@ models load consistently with Pi's provider schema: } ``` +The CLI also uses the supplied API key for the setup-time `/v1/models` metadata +request. It does not print or store that key outside Pi `auth.json`. + Security rules: - no `--api-key` flag @@ -25,7 +28,7 @@ Security rules: - no repository-local secret storage - no shell profile mutation - no `.env` generation -- no arbitrary custom base URLs or arbitrary custom model ids +- no arbitrary custom base URLs or model ids outside fetched `/v1/models` - no default live Pi/GonkaGate verification - backup before replacing existing `models.json`, `auth.json`, or `settings.json` diff --git a/docs/specs/pi-setup-prd/spec.md b/docs/specs/pi-setup-prd/spec.md index 8efd885..a67d088 100644 --- a/docs/specs/pi-setup-prd/spec.md +++ b/docs/specs/pi-setup-prd/spec.md @@ -19,10 +19,10 @@ Recommended public flow: npx -y @gonkagate/pi-setup@latest ``` -The v2 runtime stays intentionally small: write a curated GonkaGate provider -entry, store the GonkaGate API key in Pi's auth file, and set Pi's default -provider/model. It does not mutate shell profiles, generate `.env` files, or -run live verification by default. +The v2 runtime stays intentionally small: fetch GonkaGate models from +`/v1/models`, write a GonkaGate provider entry, store the GonkaGate API key in +Pi's auth file, and set Pi's default provider/model. It does not mutate shell +profiles, generate `.env` files, or run live chat verification by default. ## Problem @@ -45,8 +45,8 @@ Pi's own config files and model-switching behavior. - Preserve unrelated Pi custom providers and top-level config. - Use the canonical GonkaGate base URL `https://api.gonkagate.com/v1`. - Use Pi's OpenAI-compatible API type `openai-completions`. -- Install every public curated GonkaGate model into the provider catalog. -- Keep the recommended public path as one `npx` command. +- Install the setup-time `/v1/models` response into the provider catalog. +- Keep the public setup path as one `npx` command. - Read secrets only from `GONKAGATE_API_KEY`, `--api-key-stdin`, or a hidden prompt. - Store the key only in Pi's `~/.pi/agent/auth.json`. @@ -61,7 +61,7 @@ Pi's own config files and model-switching behavior. - Do not generate `.env` files. - Do not accept secrets through plain CLI flags such as `--api-key`. - Do not support arbitrary custom base URLs. -- Do not support arbitrary user-provided model ids. +- Do not support arbitrary user-provided model ids outside `/v1/models`. - Do not claim live GonkaGate/Pi session verification until implemented. - Do not claim concurrent-writer safety for simultaneous setup processes unless locking or atomic replacement is explicitly implemented. @@ -76,17 +76,18 @@ These features stay out of the current runtime until the listed evidence exists: restore behavior, cross-platform tests, and explicit user consent. - `.env` generation: needs target-file ownership, git-safety policy, restore behavior, and explicit user consent. -- Arbitrary custom base URLs or arbitrary custom model ids: need validation +- Arbitrary custom base URLs or model ids outside `/v1/models`: need validation rules, support boundaries, docs, tests, and an abuse/security review. - Concurrent-writer safety claims: need locking or an equivalent design with failure-mode tests on supported platforms. Until then, docs must not claim simultaneous setup processes are safe. -- Live Pi/GonkaGate verification: needs an explicit opt-in design that separates - local Pi provider visibility from live GonkaGate API calls, avoids printing or - storing raw `gp-...` keys, and records user consent before any network check. +- Live Pi/GonkaGate verification: needs an explicit opt-in design that + separates the setup-time `/v1/models` metadata request from live chat/Pi + verification, avoids printing or storing raw `gp-...` keys, and records user + consent before any verification check. -The default setup remains network-free and limited to local Pi config files: -`models.json`, `auth.json`, and `settings.json`. +The default setup makes only the `/v1/models` metadata request, then writes +local Pi config files: `models.json`, `auth.json`, and `settings.json`. ## Current Pi Integration Contract @@ -96,8 +97,9 @@ Current behavior is owned by these files: - `package.json`: npm package name, public bins, runtime package shape, scripts, Node engine, and publishable file list. -- `src/constants.ts`: provider id, provider name, GonkaGate base URL, Pi API - type, auth env binding, recommended model, and curated model catalog. +- `src/constants.ts`: provider id, provider name, GonkaGate base URL, models + URL, Pi API type, and auth env binding. +- `src/install/deps.ts`: setup-time `/v1/models` fetching and model picker I/O. - `src/paths.ts`: default Pi `models.json` target and custom config path resolution. - `src/install/user-config.ts`: Pi `auth.json` and `settings.json` merge @@ -135,7 +137,7 @@ Managed settings: ```json { "defaultProvider": "gonkagate", - "defaultModel": "moonshotai/Kimi-K2.6" + "defaultModel": "dynamic/model-id" } ``` @@ -149,56 +151,33 @@ Managed provider config: "apiKey": "$GONKAGATE_API_KEY", "models": [ { - "id": "qwen/qwen3-235b-a22b-instruct-2507-fp8", - "name": "Qwen3 235B A22B Instruct" - }, - { - "id": "moonshotai/Kimi-K2.6", - "name": "Kimi K2.6" - }, - { - "id": "minimaxai/minimax-m2.7", - "name": "MiniMax M2.7" + "id": "dynamic/model-id", + "name": "dynamic/model-id" } ] } ``` -Recommended model: +Default model source: ```text -moonshotai/Kimi-K2.6 +first model returned by /v1/models unless --model selects another fetched id ``` ## Model Catalog Requirements -Every curated GonkaGate model entry must have: +GonkaGate `/v1/models` is the source of truth for setup-time model ids. The +repository must not require a code or docs change when public GonkaGate models +are added or removed. + +Every fetched GonkaGate model entry used in provider config must have: - stable provider-facing `id` -- human-readable `name` -- explicit recommended-model ownership in source code -- docs coverage when the public curated list changes -- tests proving every curated model is present in the generated provider config - -Before first public release, each curated model must also have an explicit -decision for these Pi model fields: - -- `reasoning` -- `input` -- `contextWindow` -- `maxTokens` -- `cost` -- `compat` - -The decision may be "use Pi defaults for v1", but it must be intentional and -documented. Do not silently rely on defaults for model behavior that affects -tools, thinking, context limits, usage reporting, or compatibility. - -Current metadata decision: every curated model records -`use-pi-default-v1` for `reasoning`, `input`, `contextWindow`, `maxTokens`, -`cost`, and `compat` in `src/constants.ts`. The generated provider config keeps -only the currently supported `id` and `name` fields until product requirements -approve adding more Pi model metadata. +- Pi provider `name`, using the API name when present and falling back to `id` +- tests proving fetched models are present in the generated provider config + +The generated provider config keeps only the currently supported `id` and `name` +fields until product requirements approve adding more Pi model metadata. ## User Experience @@ -213,18 +192,20 @@ npx -y @gonkagate/pi-setup@latest Expected behavior: 1. CLI resolves the default Pi config directory. -2. CLI lets the user choose a curated GonkaGate model when interactive. -3. CLI reads the API key from `GONKAGATE_API_KEY`, `--api-key-stdin`, or a +2. CLI reads the API key from `GONKAGATE_API_KEY`, `--api-key-stdin`, or a hidden prompt. -4. CLI writes the managed GonkaGate provider. -5. CLI writes the `gonkagate` auth entry. -6. CLI sets Pi defaults to GonkaGate and the selected model. -7. CLI preserves unrelated Pi config and creates backups before replacing +3. CLI fetches available models from GonkaGate `/v1/models`. +4. CLI lets the user choose a fetched GonkaGate model with an arrow-key model + picker when interactive. +5. CLI writes the managed GonkaGate provider. +6. CLI writes the `gonkagate` auth entry. +7. CLI sets Pi defaults to GonkaGate and the selected model. +8. CLI preserves unrelated Pi config and creates backups before replacing existing files. -8. CLI prints the next command: +9. CLI prints the next command: ```bash -pi --provider gonkagate --model moonshotai/Kimi-K2.6 +pi --provider gonkagate --model dynamic/model-id ``` The CLI must not describe local config-write success as proof of live model @@ -246,7 +227,7 @@ npx -y @gonkagate/pi-setup@latest --yes Expected behavior: - no prompt -- uses the recommended model unless `--model ` is provided +- uses the first fetched model unless `--model ` is provided - requires `GONKAGATE_API_KEY` or `--api-key-stdin` for non-interactive writes - `--yes` is accepted for compatibility with existing docs and scripts - non-zero exit code on parse/write failure @@ -264,6 +245,7 @@ Expected behavior: - no file writes - result says whether the target config would change - no backup is created +- still requires an API-key source because model ids come from `/v1/models` ### Machine-Readable Output @@ -494,7 +476,7 @@ Possible future behavior: Possible future behavior: - run a Pi command that lists models or validates provider availability -- prove that `gonkagate` appears with the curated models +- prove that `gonkagate` appears with the fetched models - avoid sending live prompts unless the user explicitly asks for a live smoke check @@ -502,9 +484,10 @@ Possible future behavior: Current behavior: -- show curated model list interactively +- fetch `/v1/models` after API-key input +- show fetched model list with an arrow-key model picker interactively - let user choose Pi's default GonkaGate model -- keep every curated model in the provider catalog +- keep fetched models in the provider catalog ### Pi Extension @@ -523,8 +506,7 @@ Possible future behavior: `models.json` with comments? - Should first public release require atomic replacement, or is backup-first direct writing acceptable? -- Should curated model entries include explicit `reasoning`, `contextWindow`, - `maxTokens`, `cost`, and `compat` metadata before publication? +- Do we need cache/refresh behavior if `/v1/models` changes after setup? ## Acceptance Criteria For V2 @@ -556,7 +538,7 @@ Use this PRD to create implementation tasks in this rough order: 2. Protect package metadata, docs, and skill mirror with tests. 3. Implement config-path resolution. 4. Implement provider config generation. -5. Decide curated model metadata policy. +5. Implement setup-time `/v1/models` fetch and dynamic model picker. 6. Implement parse/merge/stringify behavior. 7. Implement backup and write flow. 8. Document backup restore. diff --git a/src/cli.ts b/src/cli.ts index f9160c1..4e7f488 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,12 +4,7 @@ import { createNodeInstallDependencies, type InstallDependencies, } from "./install/deps.js"; -import { - BIN_NAME, - GONKAGATE_API_KEY_ENV, - PACKAGE_NAME, - RECOMMENDED_MODEL_ID, -} from "./constants.js"; +import { BIN_NAME, GONKAGATE_API_KEY_ENV, PACKAGE_NAME } from "./constants.js"; import { isEntrypointInvocation } from "./entrypoint.js"; import { toFailureResult, @@ -37,6 +32,7 @@ interface CliIo { readonly input: NodeJS.ReadableStream & { readonly isTTY?: boolean }; readonly output: NodeJS.WritableStream & { readonly isTTY?: boolean }; readonly error: NodeJS.WritableStream; + readonly fetchModels?: InstallDependencies["fetchModels"]; readonly promptSecret?: InstallDependencies["promptSecret"]; readonly promptSelectModel?: InstallDependencies["promptSelectModel"]; } @@ -107,6 +103,7 @@ export async function run( ...nodeDeps, env, error: io.error, + fetchModels: io.fetchModels ?? nodeDeps.fetchModels, input: io.input, output: io.output, promptSecret: io.promptSecret ?? nodeDeps.promptSecret, @@ -227,8 +224,8 @@ Usage: ${BIN_NAME} [--yes] [--model ] [--api-key-stdin] [--config ] [--dry-run] [--json] Options: - --yes, -y Use the recommended model and non-interactive defaults - --model Choose a curated GonkaGate model id + --yes, -y Use the first fetched model and non-interactive defaults + --model Choose a GonkaGate model id from /v1/models --api-key-stdin Read the GonkaGate API key from stdin --config Pi models.json path (default: ~/.pi/agent/models.json) --dry-run Preview the managed config without writing @@ -271,7 +268,9 @@ function renderResult(result: InstallResult, dryRun: boolean): string { lines.push( "", "Status: configured, not live verified.", - `Next: pi --provider gonkagate --model ${result.selectedModelId ?? RECOMMENDED_MODEL_ID}`, + ...(result.selectedModelId === undefined + ? [] + : [`Next: pi --provider gonkagate --model ${result.selectedModelId}`]), "Then use plain pi after Pi reloads settings.json.", "If Pi is already running, open /model or restart Pi to reload models.json.", ); diff --git a/src/config.ts b/src/config.ts index 62132ad..f2d209b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,6 @@ import { PI_MODELS_JSON_OWNERSHIP } from "./install/ownership.js"; import { createGonkagateProviderConfig } from "./install/provider-config.js"; +import type { GonkaGateModel } from "./constants.js"; export { createGonkagateProviderConfig, type PiProviderConfig, @@ -18,7 +19,10 @@ export function parseModelsConfig(text: string): JsonObject { return parsed; } -export function mergeGonkagateProviderConfig(config: JsonObject): JsonObject { +export function mergeGonkagateProviderConfig( + config: JsonObject, + models: readonly GonkaGateModel[], +): JsonObject { const providers = isJsonObject(config.providers) ? config.providers : {}; const [, providerId] = PI_MODELS_JSON_OWNERSHIP.ownedPath; @@ -26,7 +30,7 @@ export function mergeGonkagateProviderConfig(config: JsonObject): JsonObject { ...config, providers: { ...providers, - [providerId]: createGonkagateProviderConfig(), + [providerId]: createGonkagateProviderConfig(models), }, }; } diff --git a/src/constants.ts b/src/constants.ts index 8d4e85c..dfda7fe 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -6,53 +6,12 @@ export const BIN_PATH = "bin/gonkagate-pi.js"; export const GONKAGATE_PROVIDER_ID = "gonkagate"; export const GONKAGATE_PROVIDER_NAME = "GonkaGate"; export const GONKAGATE_BASE_URL = "https://api.gonkagate.com/v1"; +export const GONKAGATE_MODELS_URL = `${GONKAGATE_BASE_URL}/models`; export const GONKAGATE_PI_API = "openai-completions"; export const GONKAGATE_API_KEY_ENV = "GONKAGATE_API_KEY"; export const GONKAGATE_API_KEY_BINDING = "$GONKAGATE_API_KEY"; -export const RECOMMENDED_MODEL_ID = "moonshotai/Kimi-K2.6"; -export interface CuratedModel { +export interface GonkaGateModel { readonly id: string; readonly name: string; - readonly piFieldDecisions: PiModelFieldDecisions; - readonly recommended: boolean; } - -export interface PiModelFieldDecisions { - readonly compat: "use-pi-default-v1"; - readonly contextWindow: "use-pi-default-v1"; - readonly cost: "use-pi-default-v1"; - readonly input: "use-pi-default-v1"; - readonly maxTokens: "use-pi-default-v1"; - readonly reasoning: "use-pi-default-v1"; -} - -const USE_PI_DEFAULT_MODEL_FIELDS = { - compat: "use-pi-default-v1", - contextWindow: "use-pi-default-v1", - cost: "use-pi-default-v1", - input: "use-pi-default-v1", - maxTokens: "use-pi-default-v1", - reasoning: "use-pi-default-v1", -} as const satisfies PiModelFieldDecisions; - -export const CURATED_MODELS = [ - { - id: "qwen/qwen3-235b-a22b-instruct-2507-fp8", - name: "Qwen3 235B A22B Instruct", - piFieldDecisions: USE_PI_DEFAULT_MODEL_FIELDS, - recommended: false, - }, - { - id: RECOMMENDED_MODEL_ID, - name: "Kimi K2.6", - piFieldDecisions: USE_PI_DEFAULT_MODEL_FIELDS, - recommended: true, - }, - { - id: "minimaxai/minimax-m2.7", - name: "MiniMax M2.7", - piFieldDecisions: USE_PI_DEFAULT_MODEL_FIELDS, - recommended: false, - }, -] as const satisfies readonly CuratedModel[]; diff --git a/src/install/config-mutations.ts b/src/install/config-mutations.ts index e7ac4df..84701db 100644 --- a/src/install/config-mutations.ts +++ b/src/install/config-mutations.ts @@ -1,3 +1,4 @@ +import type { GonkaGateModel } from "../constants.js"; import { type JsonObject, mergeGonkagateProviderConfig, @@ -13,8 +14,9 @@ export interface ModelsConfigMergePlan { export function createGonkagateProviderMergePlan( existingConfig: JsonObject, existingText: string | undefined, + models: readonly GonkaGateModel[], ): ModelsConfigMergePlan { - const config = mergeGonkagateProviderConfig(existingConfig); + const config = mergeGonkagateProviderConfig(existingConfig, models); const text = stringifyModelsConfig(config); return { diff --git a/src/install/deps.ts b/src/install/deps.ts index b87c014..e92035a 100644 --- a/src/install/deps.ts +++ b/src/install/deps.ts @@ -8,10 +8,8 @@ import { writeFile, } from "node:fs/promises"; import process from "node:process"; -import readline from "node:readline/promises"; -import type { CURATED_MODELS } from "../constants.js"; - -type CuratedModel = (typeof CURATED_MODELS)[number]; +import { clearLine, emitKeypressEvents, moveCursor } from "node:readline"; +import { GONKAGATE_MODELS_URL, type GonkaGateModel } from "../constants.js"; export interface InstallFileSystem { readonly chmod?: (path: string, mode: number) => Promise; @@ -39,6 +37,7 @@ export interface InstallDependencies { readonly cwd: string; readonly env: NodeJS.ProcessEnv; readonly error: NodeJS.WritableStream; + readonly fetchModels?: (apiKey: string) => Promise; readonly fs: InstallFileSystem; readonly homeDirectory?: string; readonly input: NodeJS.ReadableStream & { readonly isTTY?: boolean }; @@ -47,8 +46,8 @@ export interface InstallDependencies { readonly promptConfirm?: (question: string) => Promise; readonly promptSecret?: (question: string) => Promise; readonly promptSelectModel?: ( - models: readonly CuratedModel[], - recommendedModelId: string, + models: readonly GonkaGateModel[], + defaultModelId: string, ) => Promise; readonly runCommand?: ( command: string, @@ -56,12 +55,18 @@ export interface InstallDependencies { ) => Promise<{ readonly exitCode: number }>; } +interface TtyInputStream extends NodeJS.ReadableStream { + readonly isTTY?: boolean; + readonly setRawMode?: (enabled: boolean) => unknown; +} + export function createNodeInstallDependencies(): InstallDependencies { return { clock: { now: () => new Date() }, cwd: process.cwd(), env: process.env, error: process.stderr, + fetchModels: fetchGonkagateModels, fs: { chmod: (path, mode) => chmod(path, mode), copyFile: (source, destination) => copyFile(source, destination), @@ -79,40 +84,180 @@ export function createNodeInstallDependencies(): InstallDependencies { platform: process.platform, promptSecret: (question) => promptHidden(question, process.stdin, process.stdout), - promptSelectModel: (models, recommendedModelId) => - promptModel(models, recommendedModelId, process.stdin, process.stdout), + promptSelectModel: (models, defaultModelId) => + promptModel(models, defaultModelId, process.stdin, process.stdout), }; } -async function promptModel( - models: readonly CuratedModel[], - recommendedModelId: string, - input: NodeJS.ReadableStream & { readonly isTTY?: boolean }, +async function fetchGonkagateModels( + apiKey: string, +): Promise { + const response = await fetch(GONKAGATE_MODELS_URL, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + + if (!response.ok) { + throw new Error(`GonkaGate models request failed (${response.status}).`); + } + + return parseModelsResponse(await response.json()); +} + +function parseModelsResponse(value: unknown): readonly GonkaGateModel[] { + if (!isRecord(value) || !Array.isArray(value.data)) { + throw new Error("GonkaGate models response was not a model list."); + } + + const models = value.data.flatMap((entry): GonkaGateModel[] => { + if (!isRecord(entry) || typeof entry.id !== "string") { + return []; + } + + const id = entry.id.trim(); + if (id === "") { + return []; + } + + const name = + typeof entry.name === "string" && entry.name.trim() !== "" + ? entry.name.trim() + : id; + + return [{ id, name }]; + }); + + if (models.length === 0) { + throw new Error("GonkaGate models response did not include any models."); + } + + return models; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export async function promptModel( + models: readonly GonkaGateModel[], + defaultModelId: string, + input: TtyInputStream, output: NodeJS.WritableStream & { readonly isTTY?: boolean }, ): Promise { - if (!input.isTTY || !output.isTTY) { - return recommendedModelId; + if (models.length === 1) { + return models[0].id; + } + + if ( + !input.isTTY || + !output.isTTY || + input.setRawMode === undefined || + input.setRawMode === null + ) { + return defaultModelId; } + const setRawMode = input.setRawMode.bind(input); - const recommendedIndex = Math.max( + const defaultIndex = Math.max( 0, - models.findIndex((model) => model.id === recommendedModelId), + models.findIndex((model) => model.id === defaultModelId), ); - output.write("Choose the GonkaGate model for Pi:\n"); - models.forEach((model, index) => { - const suffix = model.id === recommendedModelId ? " (Recommended)" : ""; - output.write(` ${index + 1}. ${model.name}${suffix}\n ${model.id}\n`); + let selectedIndex = defaultIndex; + let renderedLineCount = 0; + + const render = () => { + if (renderedLineCount > 0) { + clearRenderedLines(output, renderedLineCount); + } + + const text = renderModelPicker(models, defaultModelId, selectedIndex); + renderedLineCount = text.split("\n").length; + output.write(text); + }; + + render(); + emitKeypressEvents(input); + setRawMode(true); + input.resume(); + + return await new Promise((resolve, reject) => { + const cleanup = () => { + input.off("keypress", onKeypress); + setRawMode(false); + output.write("\n"); + }; + + const onKeypress = (_text: string, key: KeypressEvent) => { + if (key.ctrl === true && key.name === "c") { + cleanup(); + reject(new Error("Input cancelled.")); + return; + } + + switch (key.name) { + case "escape": + cleanup(); + reject(new Error("Input cancelled.")); + return; + case "return": + cleanup(); + resolve(models[selectedIndex]?.id ?? defaultModelId); + return; + case "up": + selectedIndex = (selectedIndex + models.length - 1) % models.length; + render(); + return; + case "down": + selectedIndex = (selectedIndex + 1) % models.length; + render(); + return; + } + }; + + input.on("keypress", onKeypress); }); +} - const rl = readline.createInterface({ input, output }); - try { - const answer = await rl.question(`Model [${recommendedIndex + 1}]: `); - const selectedIndex = - answer.trim() === "" ? recommendedIndex : Number(answer.trim()) - 1; - return models[selectedIndex]?.id ?? recommendedModelId; - } finally { - rl.close(); +interface KeypressEvent { + readonly ctrl?: boolean; + readonly name?: string; +} + +function renderModelPicker( + models: readonly GonkaGateModel[], + defaultModelId: string, + selectedIndex: number, +): string { + return [ + "Choose GonkaGate model for Pi:", + ...models.flatMap((model, index) => { + const marker = index === selectedIndex ? ">" : " "; + const suffix = model.id === defaultModelId ? " (Default)" : ""; + return [ + `${marker} ${index + 1}. ${model.name}${suffix}`, + ` ${model.id}`, + ]; + }), + "Use Up/Down arrows and Enter to select.", + ].join("\n"); +} + +function clearRenderedLines( + output: NodeJS.WritableStream, + lineCount: number, +): void { + moveCursor(output, 0, -(lineCount - 1)); + output.write("\r"); + + for (let line = 0; line < lineCount; line += 1) { + clearLine(output, 0); + if (line < lineCount - 1) { + moveCursor(output, 0, 1); + output.write("\r"); + } } + + moveCursor(output, 0, -(lineCount - 1)); + output.write("\r"); } async function promptHidden( diff --git a/src/install/errors.ts b/src/install/errors.ts index 6510721..b7e0de7 100644 --- a/src/install/errors.ts +++ b/src/install/errors.ts @@ -4,6 +4,7 @@ export type InstallErrorCode = | "backup_failed" | "invalid_config" | "missing_home" + | "models_unavailable" | "rollback_failed" | "secret_required" | "unsupported_model" diff --git a/src/install/index.ts b/src/install/index.ts index aa208ce..f6d4ea1 100644 --- a/src/install/index.ts +++ b/src/install/index.ts @@ -1,9 +1,8 @@ import { dirname, join } from "node:path"; import { - CURATED_MODELS, GONKAGATE_API_KEY_ENV, GONKAGATE_PROVIDER_ID, - RECOMMENDED_MODEL_ID, + type GonkaGateModel, } from "../constants.js"; import { parseModelsConfig } from "../config.js"; import { createGonkagateProviderMergePlan } from "./config-mutations.js"; @@ -59,12 +58,15 @@ export async function installGonkagateProvider( existingText === undefined ? {} : parseExistingConfig(existingText, configPath); + const apiKey = await resolveApiKey(options, deps); + const models = await resolveAvailableModels(apiKey, deps); const mergePlan = createGonkagateProviderMergePlan( existingConfig, existingText, + models, ); - const modelIds = CURATED_MODELS.map((model) => model.id); - const selectedModelId = await resolveSelectedModelId(options, deps); + const modelIds = models.map((model) => model.id); + const selectedModelId = await resolveSelectedModelId(options, deps, models); const authPath = join(dirname(configPath), "auth.json"); const settingsPath = join(dirname(configPath), "settings.json"); const baseResult = { @@ -115,7 +117,6 @@ export async function installGonkagateProvider( }; } - const apiKey = await resolveApiKey(options, deps); const existingAuthText = await readOptionalText(authPath, deps); const authConfig = existingAuthText === undefined @@ -274,12 +275,52 @@ function parseExistingJsonObject(text: string, path: string, label: string) { } } +async function resolveAvailableModels( + apiKey: string, + deps: InstallDependencies, +): Promise { + try { + const models = await deps.fetchModels?.(apiKey); + + if (models === undefined) { + throw new Error("GonkaGate models fetcher is not configured."); + } + + if (models.length === 0) { + throw new Error("GonkaGate models endpoint returned no models."); + } + + return models; + } catch (error) { + if (error instanceof InstallError) { + throw error; + } + + const reason = + error instanceof Error ? error.message : "Unknown models endpoint error."; + throw new InstallError( + "models_unavailable", + `Could not fetch GonkaGate models: ${reason}`, + { cause: error }, + ); + } +} + async function resolveSelectedModelId( options: InstallOptions, deps: InstallDependencies, + models: readonly GonkaGateModel[], ): Promise { + const defaultModelId = models[0]?.id; + if (defaultModelId === undefined) { + throw new InstallError( + "models_unavailable", + "GonkaGate models endpoint returned no models.", + ); + } + if (options.modelId !== undefined) { - const model = CURATED_MODELS.find((entry) => entry.id === options.modelId); + const model = models.find((entry) => entry.id === options.modelId); if (model === undefined) { throw new InstallError( @@ -292,19 +333,20 @@ async function resolveSelectedModelId( } if (options.yes || deps.output.isTTY !== true || deps.input.isTTY !== true) { - return RECOMMENDED_MODEL_ID; + return defaultModelId; } - const prompted = await deps.promptSelectModel?.( - CURATED_MODELS, - RECOMMENDED_MODEL_ID, - ); + const prompted = await deps.promptSelectModel?.(models, defaultModelId); if (prompted === undefined || prompted === "") { - return RECOMMENDED_MODEL_ID; + return defaultModelId; } - return await resolveSelectedModelId({ ...options, modelId: prompted }, deps); + return await resolveSelectedModelId( + { ...options, modelId: prompted }, + deps, + models, + ); } async function resolveApiKey( diff --git a/src/install/provider-config.ts b/src/install/provider-config.ts index 6304f18..0ba659b 100644 --- a/src/install/provider-config.ts +++ b/src/install/provider-config.ts @@ -1,9 +1,9 @@ import { - CURATED_MODELS, GONKAGATE_API_KEY_BINDING, GONKAGATE_BASE_URL, GONKAGATE_PI_API, GONKAGATE_PROVIDER_NAME, + type GonkaGateModel, } from "../constants.js"; export interface PiProviderModelConfig { @@ -19,13 +19,15 @@ export interface PiProviderConfig { readonly name: string; } -export function createGonkagateProviderConfig(): PiProviderConfig { +export function createGonkagateProviderConfig( + models: readonly GonkaGateModel[], +): PiProviderConfig { return { name: GONKAGATE_PROVIDER_NAME, baseUrl: GONKAGATE_BASE_URL, api: GONKAGATE_PI_API, apiKey: GONKAGATE_API_KEY_BINDING, - models: CURATED_MODELS.map((model) => ({ + models: models.map((model) => ({ id: model.id, name: model.name, })), diff --git a/test/cli.test.ts b/test/cli.test.ts index daac191..cdefe45 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -11,12 +11,8 @@ import { join } from "node:path"; import { Readable } from "node:stream"; import test from "node:test"; import { renderCliEntrypointError, run } from "../src/cli.js"; -import { - GONKAGATE_PROVIDER_ID, - RECOMMENDED_MODEL_ID, -} from "../src/constants.js"; - -const TEST_ENV = { GONKAGATE_API_KEY: "TESTKEY" }; +import { GONKAGATE_PROVIDER_ID } from "../src/constants.js"; +import { TEST_ENV, TEST_MODELS } from "./model-fixtures.js"; test("CLI writes GonkaGate provider config", async () => { const root = await mkdtemp(join(tmpdir(), "pi-setup-")); @@ -48,7 +44,7 @@ test("CLI writes GonkaGate provider config", async () => { ) as { defaultModel: string; defaultProvider: string }; assert.deepEqual(auth.gonkagate, { type: "api_key", key: "TESTKEY" }); assert.equal(settings.defaultProvider, GONKAGATE_PROVIDER_ID); - assert.equal(settings.defaultModel, RECOMMENDED_MODEL_ID); + assert.equal(settings.defaultModel, TEST_MODELS[0].id); }); test("CLI success output stays configured-not-verified", async () => { @@ -163,7 +159,7 @@ test("CLI dry-run resolves home config paths without creating parents", async () const exitCode = await run( ["node", "cli", "--config", "~/agent/models.json", "--dry-run", "--json"], - { HOME: root }, + { HOME: root, ...TEST_ENV }, createTestIo(output, error), ); @@ -187,7 +183,7 @@ test("CLI dry-run reports changed without writing or creating backup", async () const exitCode = await run( ["node", "cli", "--config", configPath, "--dry-run", "--json"], - {}, + TEST_ENV, createTestIo(output, error), ); @@ -226,7 +222,7 @@ test("CLI dry-run reports already configured without writing", async () => { const exitCode = await run( ["node", "cli", "--config", configPath, "--dry-run", "--json"], - {}, + TEST_ENV, createTestIo(output, error), ); @@ -482,11 +478,17 @@ test("CLI can read the API key from stdin without printing it", async () => { const configPath = join(root, "models.json"); const output: string[] = []; const error: string[] = []; + let modelsFetchedWithKey = ""; const exitCode = await run( ["node", "cli", "--config", configPath, "--api-key-stdin", "--yes"], {}, - createTestIo(output, error, Readable.from(["STDINKEY\n"])), + createTestIo(output, error, Readable.from(["STDINKEY\n"]), { + fetchModels: async (apiKey) => { + modelsFetchedWithKey = apiKey; + return TEST_MODELS; + }, + }), ); assert.equal(exitCode, 0); @@ -497,9 +499,10 @@ test("CLI can read the API key from stdin without printing it", async () => { gonkagate: { key: string }; }; assert.equal(auth.gonkagate.key, "STDINKEY"); + assert.equal(modelsFetchedWithKey, "STDINKEY"); }); -test("CLI rejects non-curated model ids before writing", async () => { +test("CLI rejects model ids missing from the models endpoint before writing", async () => { const root = await mkdtemp(join(tmpdir(), "pi-setup-")); const configPath = join(root, "models.json"); const output: string[] = []; @@ -523,8 +526,12 @@ function createTestIo( input: NodeJS.ReadableStream & { readonly isTTY?: boolean } = Readable.from( [], ), + options: { + readonly fetchModels?: (apiKey: string) => Promise; + } = {}, ) { return { + fetchModels: options.fetchModels ?? (async () => TEST_MODELS), input: Object.assign(input, { isTTY: false }), output: { isTTY: false, diff --git a/test/config.test.ts b/test/config.test.ts index a7c6cea..8d6d6ef 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -1,44 +1,42 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - CURATED_MODELS, GONKAGATE_API_KEY_BINDING, GONKAGATE_BASE_URL, GONKAGATE_PI_API, GONKAGATE_PROVIDER_ID, - RECOMMENDED_MODEL_ID, } from "../src/constants.js"; import { createGonkagateProviderConfig, mergeGonkagateProviderConfig, parseModelsConfig, } from "../src/config.js"; +import { TEST_MODELS } from "./model-fixtures.js"; test("creates the managed GonkaGate Pi provider", () => { - const provider = createGonkagateProviderConfig(); + const provider = createGonkagateProviderConfig(TEST_MODELS); assert.equal(provider.baseUrl, GONKAGATE_BASE_URL); assert.equal(provider.api, GONKAGATE_PI_API); assert.equal(provider.apiKey, GONKAGATE_API_KEY_BINDING); - assert.equal( - provider.models.some((model) => model.id === RECOMMENDED_MODEL_ID), - true, - ); assert.deepEqual( provider.models.map((model) => model.id), - CURATED_MODELS.map((model) => model.id), + TEST_MODELS.map((model) => model.id), ); }); test("merge preserves unrelated Pi config and upserts GonkaGate provider", () => { - const merged = mergeGonkagateProviderConfig({ - defaultProvider: "anthropic", - providers: { - anthropic: { - apiKey: "$ANTHROPIC_API_KEY", + const merged = mergeGonkagateProviderConfig( + { + defaultProvider: "anthropic", + providers: { + anthropic: { + apiKey: "$ANTHROPIC_API_KEY", + }, }, }, - }); + TEST_MODELS, + ); assert.equal(merged.defaultProvider, "anthropic"); assert.deepEqual((merged.providers as Record).anthropic, { @@ -60,10 +58,13 @@ test("parse accepts plain object config", () => { }); test("merge replaces scalar providers while preserving top-level config", () => { - const merged = mergeGonkagateProviderConfig({ - defaultProvider: "anthropic", - providers: "stale", - }); + const merged = mergeGonkagateProviderConfig( + { + defaultProvider: "anthropic", + providers: "stale", + }, + TEST_MODELS, + ); assert.equal(merged.defaultProvider, "anthropic"); assert.equal( @@ -71,31 +72,3 @@ test("merge replaces scalar providers while preserving top-level config", () => "object", ); }); - -test("curated model registry has exactly one recommended model", () => { - assert.deepEqual( - CURATED_MODELS.filter((model) => model.recommended).map( - (model) => model.id, - ), - [RECOMMENDED_MODEL_ID], - ); -}); - -test("curated model registry records every Pi metadata decision", () => { - for (const model of CURATED_MODELS) { - assert.equal(typeof model.id, "string"); - assert.equal(typeof model.name, "string"); - assert.deepEqual(Object.keys(model.piFieldDecisions).sort(), [ - "compat", - "contextWindow", - "cost", - "input", - "maxTokens", - "reasoning", - ]); - assert.deepEqual( - Object.values(model.piFieldDecisions), - Array(6).fill("use-pi-default-v1"), - ); - } -}); diff --git a/test/docs-contract.test.ts b/test/docs-contract.test.ts index 29ce20c..d851445 100644 --- a/test/docs-contract.test.ts +++ b/test/docs-contract.test.ts @@ -84,6 +84,7 @@ test("public docs keep the fixed v2 Pi contract", () => { /~\/\.pi\/agent\/settings\.json/, /providers\.gonkagate/, /GONKAGATE_API_KEY/, + /arrow-key model picker/, /auth\.json/, /settings\.json/, /--api-key-stdin/, @@ -91,7 +92,7 @@ test("public docs keep the fixed v2 Pi contract", () => { /shell\s+profiles|shell profile mutation/, /\.env/, /arbitrary custom\s+base URLs/, - /arbitrary.*model ids/, + /arbitrary.*model ids.*\/v1\/models/, /backup/i, /concurrent-writer safety/, /configured/, @@ -109,23 +110,24 @@ test("PRD defines explicit evidence gates for deferred work", () => { /Deferred Work Gates/, /Shell profile mutation: needs supported-shell scope/, /`\.env` generation: needs target-file ownership/, - /Arbitrary custom base URLs or arbitrary custom model ids: need validation/, + /Arbitrary custom base URLs or model ids outside `\/v1\/models`: need validation/, /Concurrent-writer safety claims: need locking or an equivalent design/, /docs must not claim\s+simultaneous setup processes are safe/, /Live Pi\/GonkaGate verification: needs an explicit opt-in design/, - /separates\s+local Pi provider visibility from live GonkaGate API calls/, - /The default setup remains network-free and limited to local Pi config files/, + /separates\s+the setup-time `\/v1\/models` metadata request from live chat\/Pi/, + /The default setup makes only the `\/v1\/models` metadata request/, ]); }); -test("PRD documents curated model metadata decisions", () => { +test("PRD documents dynamic model catalog decisions", () => { const prd = readText("docs/specs/pi-setup-prd/spec.md"); assertMatchesAll(prd, [ - /Current metadata decision/, - /`use-pi-default-v1` for `reasoning`, `input`, `contextWindow`, `maxTokens`,/, - /`cost`, and `compat` in `src\/constants\.ts`/, - /generated provider config keeps\s+only the currently supported `id` and `name` fields/, + /GonkaGate `\/v1\/models` is the source of truth/, + /arrow-key model\s+picker/, + /must not require a code or docs change when public GonkaGate models/, + /using the API name when present and falling back to `id`/, + /generated provider config keeps only the currently supported `id` and `name`/, ]); }); diff --git a/test/install.test.ts b/test/install.test.ts index 1a36061..f20e644 100644 --- a/test/install.test.ts +++ b/test/install.test.ts @@ -13,6 +13,7 @@ import { import { InstallError, toFailureResult } from "../src/install/errors.js"; import { installGonkagateProvider } from "../src/install/index.js"; import { createGonkagateProviderConfig } from "../src/install/provider-config.js"; +import { TEST_ENV, TEST_MODELS } from "./model-fixtures.js"; test("install runtime writes GonkaGate provider without CLI globals", async () => { const root = await mkdtemp(join(tmpdir(), "pi-setup-install-")); @@ -22,7 +23,7 @@ test("install runtime writes GonkaGate provider without CLI globals", async () = const result = await installGonkagateProvider( configPath, false, - createNodeInstallDependencies(), + createNodeTestDependencies(), ); assert.equal(result.changed, true); @@ -47,7 +48,7 @@ test("install runtime dry-run does not create config files", async () => { const result = await installGonkagateProvider( configPath, true, - createNodeInstallDependencies(), + createNodeTestDependencies(), ); assert.equal(result.changed, true); @@ -114,7 +115,7 @@ test("install runtime uses stubbed clock for backup names", async () => { test("install runtime skips backup and writes for unchanged content", async () => { const currentText = stringifyModelsConfig({ providers: { - [GONKAGATE_PROVIDER_ID]: createGonkagateProviderConfig(), + [GONKAGATE_PROVIDER_ID]: createGonkagateProviderConfig(TEST_MODELS), }, }); let copied = false; @@ -282,8 +283,9 @@ function createStubInstallDependencies( return { clock: { now: () => new Date("2026-01-01T00:00:00.000Z") }, cwd: "/work", - env: { HOME: "/tmp/home" }, + env: { HOME: "/tmp/home", ...TEST_ENV }, error: { write: () => true } as unknown as NodeJS.WritableStream, + fetchModels: async () => TEST_MODELS, homeDirectory: "/tmp/home", input: { isTTY: true } as NodeJS.ReadableStream & { readonly isTTY?: boolean; @@ -302,6 +304,14 @@ function createStubInstallDependencies( }; } +function createNodeTestDependencies(): InstallDependencies { + return { + ...createNodeInstallDependencies(), + env: TEST_ENV, + fetchModels: async () => TEST_MODELS, + }; +} + function isInstallError(errorCode: InstallError["errorCode"]) { return (error: unknown): boolean => { assert.equal(error instanceof InstallError, true); diff --git a/test/install/config-document.test.ts b/test/install/config-document.test.ts index 7d43fd4..8df3435 100644 --- a/test/install/config-document.test.ts +++ b/test/install/config-document.test.ts @@ -3,12 +3,17 @@ import test from "node:test"; import { GONKAGATE_PROVIDER_ID } from "../../src/constants.js"; import { InstallError } from "../../src/install/errors.js"; import { installGonkagateProvider } from "../../src/install/index.js"; +import { createTestInstallDependencies } from "../model-fixtures.js"; import { createPiInstallHarness } from "./harness.js"; test("missing models config is treated as an empty object", async () => { const harness = await createPiInstallHarness(); - const result = await installGonkagateProvider(harness.configPath, true); + const result = await installGonkagateProvider( + harness.configPath, + true, + createTestInstallDependencies(harness.env), + ); assert.equal(result.changed, true); assert.equal(result.configPath, harness.configPath); @@ -49,7 +54,11 @@ test("scalar providers are replaced while top-level config is preserved", async `${JSON.stringify({ defaultProvider: "anthropic", providers: "stale" })}\n`, ); - await installGonkagateProvider(harness.configPath, false); + await installGonkagateProvider( + harness.configPath, + false, + createTestInstallDependencies(harness.env), + ); const config = await harness.readConfig(); assert.equal(config.defaultProvider, "anthropic"); diff --git a/test/install/config-mutations.test.ts b/test/install/config-mutations.test.ts index 9056e2f..bdcb51b 100644 --- a/test/install/config-mutations.test.ts +++ b/test/install/config-mutations.test.ts @@ -4,14 +4,15 @@ import { GONKAGATE_PROVIDER_ID } from "../../src/constants.js"; import { stringifyModelsConfig } from "../../src/config.js"; import { createGonkagateProviderMergePlan } from "../../src/install/config-mutations.js"; import { createGonkagateProviderConfig } from "../../src/install/provider-config.js"; +import { TEST_MODELS } from "../model-fixtures.js"; test("merge plan creates a new GonkaGate provider config", () => { - const plan = createGonkagateProviderMergePlan({}, undefined); + const plan = createGonkagateProviderMergePlan({}, undefined, TEST_MODELS); assert.equal(plan.changed, true); assert.deepEqual(plan.config, { providers: { - [GONKAGATE_PROVIDER_ID]: createGonkagateProviderConfig(), + [GONKAGATE_PROVIDER_ID]: createGonkagateProviderConfig(TEST_MODELS), }, }); }); @@ -25,6 +26,7 @@ test("merge plan preserves unrelated providers and top-level values", () => { }, }, "{}\n", + TEST_MODELS, ); assert.equal(plan.config.defaultProvider, "anthropic"); @@ -44,16 +46,17 @@ test("merge plan replaces stale managed GonkaGate provider", () => { }, }, "{}\n", + TEST_MODELS, ); assert.deepEqual( (plan.config.providers as Record)[GONKAGATE_PROVIDER_ID], - createGonkagateProviderConfig(), + createGonkagateProviderConfig(TEST_MODELS), ); }); test("merge plan emits deterministic two-space JSON with newline", () => { - const plan = createGonkagateProviderMergePlan({}, undefined); + const plan = createGonkagateProviderMergePlan({}, undefined, TEST_MODELS); assert.equal(plan.text, `${JSON.stringify(plan.config, null, 2)}\n`); assert.match(plan.text, /\n "providers":/); @@ -63,11 +66,15 @@ test("merge plan emits deterministic two-space JSON with newline", () => { test("merge plan reports idempotent no-op before filesystem work", () => { const currentConfig = { providers: { - [GONKAGATE_PROVIDER_ID]: createGonkagateProviderConfig(), + [GONKAGATE_PROVIDER_ID]: createGonkagateProviderConfig(TEST_MODELS), }, }; const currentText = stringifyModelsConfig(currentConfig); - const plan = createGonkagateProviderMergePlan(currentConfig, currentText); + const plan = createGonkagateProviderMergePlan( + currentConfig, + currentText, + TEST_MODELS, + ); assert.equal(plan.changed, false); assert.equal(plan.text, currentText); diff --git a/test/install/deps.test.ts b/test/install/deps.test.ts new file mode 100644 index 0000000..f2087b6 --- /dev/null +++ b/test/install/deps.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { PassThrough, Writable } from "node:stream"; +import test from "node:test"; +import { GONKAGATE_MODELS_URL } from "../../src/constants.js"; +import { + createNodeInstallDependencies, + promptModel, +} from "../../src/install/deps.js"; +import { TEST_MODELS } from "../model-fixtures.js"; + +test("node deps fetch GonkaGate models with bearer auth", async () => { + const originalFetch = globalThis.fetch; + let requestedUrl = ""; + let authorization = ""; + + globalThis.fetch = (async (url, init) => { + requestedUrl = String(url); + authorization = new Headers(init?.headers).get("Authorization") ?? ""; + + return new Response( + JSON.stringify({ + data: [ + { id: " dynamic/model ", name: " Dynamic Model " }, + { id: "dynamic/fallback-name" }, + ], + }), + { status: 200 }, + ); + }) as typeof fetch; + + try { + const models = + await createNodeInstallDependencies().fetchModels?.("TESTKEY"); + + assert.equal(requestedUrl, GONKAGATE_MODELS_URL); + assert.equal(authorization, "Bearer TESTKEY"); + assert.deepEqual(models, [ + { id: "dynamic/model", name: "Dynamic Model" }, + { id: "dynamic/fallback-name", name: "dynamic/fallback-name" }, + ]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("model prompt supports arrow-key selection", async () => { + const rawModeChanges: boolean[] = []; + const input = Object.assign(new PassThrough(), { + isTTY: true, + setRawMode(enabled: boolean) { + rawModeChanges.push(enabled); + return this; + }, + }) as NodeJS.ReadWriteStream & { + readonly isTTY?: boolean; + readonly setRawMode?: (enabled: boolean) => unknown; + }; + const outputChunks: string[] = []; + const output = Object.assign( + new Writable({ + write(chunk, _encoding, callback) { + outputChunks.push(String(chunk)); + callback(); + }, + }), + { isTTY: true }, + ) as NodeJS.WritableStream & { readonly isTTY?: boolean }; + + const selected = promptModel(TEST_MODELS, TEST_MODELS[0].id, input, output); + input.write("\u001B[B"); + input.write("\r"); + + assert.equal(await selected, TEST_MODELS[1].id); + assert.deepEqual(rawModeChanges, [true, false]); + assert.match(outputChunks.join(""), /> 2\. Dynamic Second Model/); +}); diff --git a/test/install/ownership.test.ts b/test/install/ownership.test.ts index 251d899..105650c 100644 --- a/test/install/ownership.test.ts +++ b/test/install/ownership.test.ts @@ -4,6 +4,7 @@ import { GONKAGATE_PROVIDER_ID } from "../../src/constants.js"; import { createGonkagateProviderMergePlan } from "../../src/install/config-mutations.js"; import { PI_MODELS_JSON_OWNERSHIP } from "../../src/install/ownership.js"; import { createGonkagateProviderConfig } from "../../src/install/provider-config.js"; +import { TEST_MODELS } from "../model-fixtures.js"; test("Pi ownership model declares only providers.gonkagate", () => { assert.deepEqual(PI_MODELS_JSON_OWNERSHIP, { @@ -26,6 +27,7 @@ test("ownership replacement changes only the managed provider", () => { }, }, "{}\n", + TEST_MODELS, ); assert.equal(plan.config.defaultProvider, "anthropic"); @@ -36,6 +38,6 @@ test("ownership replacement changes only the managed provider", () => { ); assert.deepEqual( (plan.config.providers as Record)[GONKAGATE_PROVIDER_ID], - createGonkagateProviderConfig(), + createGonkagateProviderConfig(TEST_MODELS), ); }); diff --git a/test/install/provider-config.test.ts b/test/install/provider-config.test.ts index e06aba6..831a0c2 100644 --- a/test/install/provider-config.test.ts +++ b/test/install/provider-config.test.ts @@ -1,7 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - CURATED_MODELS, GONKAGATE_API_KEY_BINDING, GONKAGATE_BASE_URL, GONKAGATE_PI_API, @@ -9,16 +8,17 @@ import { GONKAGATE_PROVIDER_NAME, } from "../../src/constants.js"; import { createGonkagateProviderConfig } from "../../src/install/provider-config.js"; +import { TEST_MODELS } from "../model-fixtures.js"; -test("provider config is generated from the curated registry", () => { - const provider = createGonkagateProviderConfig(); +test("provider config is generated from fetched models", () => { + const provider = createGonkagateProviderConfig(TEST_MODELS); assert.deepEqual(provider, { name: GONKAGATE_PROVIDER_NAME, baseUrl: GONKAGATE_BASE_URL, api: GONKAGATE_PI_API, apiKey: GONKAGATE_API_KEY_BINDING, - models: CURATED_MODELS.map((model) => ({ + models: TEST_MODELS.map((model) => ({ id: model.id, name: model.name, })), @@ -27,7 +27,7 @@ test("provider config is generated from the curated registry", () => { }); test("provider config never embeds a raw GonkaGate key", () => { - const provider = createGonkagateProviderConfig(); + const provider = createGonkagateProviderConfig(TEST_MODELS); assert.equal(provider.apiKey, "$GONKAGATE_API_KEY"); assert.doesNotMatch(JSON.stringify(provider), /gp-/); diff --git a/test/install/rerun.test.ts b/test/install/rerun.test.ts index f6bf4a0..05c5d71 100644 --- a/test/install/rerun.test.ts +++ b/test/install/rerun.test.ts @@ -1,19 +1,22 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { CURATED_MODELS, GONKAGATE_PROVIDER_ID } from "../../src/constants.js"; +import { GONKAGATE_PROVIDER_ID } from "../../src/constants.js"; +import { createNodeInstallDependencies } from "../../src/install/deps.js"; import { installGonkagateProvider } from "../../src/install/index.js"; import { createGonkagateProviderConfig } from "../../src/install/provider-config.js"; +import { TEST_ENV, TEST_MODELS } from "../model-fixtures.js"; import { createPiInstallHarness } from "./harness.js"; test("unchanged reruns are idempotent and create no backup", async () => { const harness = await createPiInstallHarness(); + const deps = createHarnessDependencies(harness.env); assert.equal( - (await installGonkagateProvider(harness.configPath, false)).changed, + (await installGonkagateProvider(harness.configPath, false, deps)).changed, true, ); assert.equal( - (await installGonkagateProvider(harness.configPath, false)).changed, + (await installGonkagateProvider(harness.configPath, false, deps)).changed, false, ); assert.deepEqual(await harness.listBackups(), []); @@ -21,6 +24,7 @@ test("unchanged reruns are idempotent and create no backup", async () => { test("changed reruns back up stale managed provider before replacement", async () => { const harness = await createPiInstallHarness(); + const deps = createHarnessDependencies(harness.env); await harness.writeConfigText( `${JSON.stringify({ providers: { @@ -30,7 +34,11 @@ test("changed reruns back up stale managed provider before replacement", async ( })}\n`, ); - const result = await installGonkagateProvider(harness.configPath, false); + const result = await installGonkagateProvider( + harness.configPath, + false, + deps, + ); const config = await harness.readConfig(); assert.equal(result.changed, true); @@ -41,12 +49,13 @@ test("changed reruns back up stale managed provider before replacement", async ( }); assert.deepEqual( (config.providers as Record)[GONKAGATE_PROVIDER_ID], - createGonkagateProviderConfig(), + createGonkagateProviderConfig(TEST_MODELS), ); }); test("rerun refreshes the managed catalog without touching unrelated config", async () => { const harness = await createPiInstallHarness(); + const deps = createHarnessDependencies(harness.env); await harness.writeConfigText( `${JSON.stringify({ customTopLevel: { keep: true }, @@ -59,7 +68,7 @@ test("rerun refreshes the managed catalog without touching unrelated config", as })}\n`, ); - await installGonkagateProvider(harness.configPath, false); + await installGonkagateProvider(harness.configPath, false, deps); const config = await harness.readConfig(); const providers = config.providers as Record; @@ -70,6 +79,14 @@ test("rerun refreshes the managed catalog without touching unrelated config", as assert.deepEqual(providers.other, { apiKey: "$OTHER" }); assert.deepEqual( managed.models.map((model) => model.id), - CURATED_MODELS.map((model) => model.id), + TEST_MODELS.map((model) => model.id), ); }); + +function createHarnessDependencies(env: NodeJS.ProcessEnv) { + return { + ...createNodeInstallDependencies(), + env: { ...env, ...TEST_ENV }, + fetchModels: async () => TEST_MODELS, + }; +} diff --git a/test/install/rollback.test.ts b/test/install/rollback.test.ts index 56a4ba2..d6c2ee7 100644 --- a/test/install/rollback.test.ts +++ b/test/install/rollback.test.ts @@ -7,6 +7,7 @@ import { runRollbackActions, type RollbackAction, } from "../../src/install/rollback.js"; +import { createTestInstallDependencies } from "../model-fixtures.js"; import { createPiInstallHarness } from "./harness.js"; test("rollback restores backup contents", async () => { @@ -108,7 +109,7 @@ test("install rollback restores original config after a late failure", async () })}\n`; await harness.writeConfigText(original); const deps = { - ...createNodeInstallDependencies(), + ...createTestInstallDependencies(harness.env), afterWrite: async () => { throw new Error("late failure"); }, diff --git a/test/install/write.test.ts b/test/install/write.test.ts index eb04372..aa30153 100644 --- a/test/install/write.test.ts +++ b/test/install/write.test.ts @@ -3,6 +3,7 @@ import { readFile, stat } from "node:fs/promises"; import test from "node:test"; import { GONKAGATE_PROVIDER_ID } from "../../src/constants.js"; import { installGonkagateProvider } from "../../src/install/index.js"; +import { createTestInstallDependencies } from "../model-fixtures.js"; import { createPiInstallHarness } from "./harness.js"; test("managed write backs up existing config before atomic replacement", async () => { @@ -12,7 +13,11 @@ test("managed write backs up existing config before atomic replacement", async ( })}\n`; await harness.writeConfigText(original); - const result = await installGonkagateProvider(harness.configPath, false); + const result = await installGonkagateProvider( + harness.configPath, + false, + createTestInstallDependencies(harness.env), + ); assert.equal(result.changed, true); assert.equal(result.backupPath !== undefined, true); @@ -32,7 +37,11 @@ test("managed write backs up existing config before atomic replacement", async ( test("managed write creates no backup for a new target file", async () => { const harness = await createPiInstallHarness(); - const result = await installGonkagateProvider(harness.configPath, false); + const result = await installGonkagateProvider( + harness.configPath, + false, + createTestInstallDependencies(harness.env), + ); assert.equal(result.changed, true); assert.equal(result.backupPath, undefined); @@ -47,7 +56,11 @@ test("managed write uses owner-only target mode where POSIX supports it", async const harness = await createPiInstallHarness(); - await installGonkagateProvider(harness.configPath, false); + await installGonkagateProvider( + harness.configPath, + false, + createTestInstallDependencies(harness.env), + ); assert.equal((await stat(harness.configPath)).mode & 0o777, 0o600); }); diff --git a/test/model-fixtures.ts b/test/model-fixtures.ts new file mode 100644 index 0000000..d6bc081 --- /dev/null +++ b/test/model-fixtures.ts @@ -0,0 +1,22 @@ +import type { GonkaGateModel } from "../src/constants.js"; +import { + createNodeInstallDependencies, + type InstallDependencies, +} from "../src/install/deps.js"; + +export const TEST_MODELS = [ + { id: "dynamic/default-model", name: "Dynamic Default Model" }, + { id: "dynamic/second-model", name: "Dynamic Second Model" }, +] as const satisfies readonly GonkaGateModel[]; + +export const TEST_ENV = { GONKAGATE_API_KEY: "TESTKEY" }; + +export function createTestInstallDependencies( + env: NodeJS.ProcessEnv = {}, +): InstallDependencies { + return { + ...createNodeInstallDependencies(), + env: { ...env, ...TEST_ENV }, + fetchModels: async () => TEST_MODELS, + }; +} diff --git a/test/package-contract.test.ts b/test/package-contract.test.ts index 13d0e8b..4001c2b 100644 --- a/test/package-contract.test.ts +++ b/test/package-contract.test.ts @@ -6,6 +6,7 @@ import { BIN_PATH, GONKAGATE_API_KEY_BINDING, GONKAGATE_BASE_URL, + GONKAGATE_MODELS_URL, GONKAGATE_PI_API, GONKAGATE_PROVIDER_ID, LEGACY_BIN_NAME, @@ -69,6 +70,7 @@ test("source constants match the fixed Pi public contract", () => { assert.equal(BIN_PATH, "bin/gonkagate-pi.js"); assert.equal(GONKAGATE_PROVIDER_ID, "gonkagate"); assert.equal(GONKAGATE_BASE_URL, "https://api.gonkagate.com/v1"); + assert.equal(GONKAGATE_MODELS_URL, "https://api.gonkagate.com/v1/models"); assert.equal(GONKAGATE_PI_API, "openai-completions"); assert.equal(GONKAGATE_API_KEY_BINDING, "$GONKAGATE_API_KEY"); assert.equal( diff --git a/test/skills-contract.test.ts b/test/skills-contract.test.ts index 628a421..0aef45a 100644 --- a/test/skills-contract.test.ts +++ b/test/skills-contract.test.ts @@ -32,6 +32,7 @@ test("repo skill documents the current Pi setup contract", () => { assert.match(skill, /--api-key-stdin/); assert.match(skill, /hidden\s+prompt/); assert.match(skill, /providers\.gonkagate/); + assert.match(skill, /arrow-key picker/); assert.match(skill, /configured/); assert.match(skill, /verified/); assert.match(skill, /backup/i); @@ -39,7 +40,7 @@ test("repo skill documents the current Pi setup contract", () => { assert.match(skill, /shell profiles/); assert.match(skill, /\.env/); assert.match(skill, /arbitrary custom base URLs/); - assert.match(skill, /arbitrary custom model ids/); + assert.match(skill, /model ids outside `\/v1\/models`/); assert.match(skill, /default live GonkaGate\/Pi verification/); assert.match(skill, /never add a plain `--api-key` flag/); assert.doesNotMatch(skill, /opencode/);