From 86ecc8c2417cea0145c090c946b760a5cc19c31c Mon Sep 17 00:00:00 2001 From: Johnny Chadda Date: Fri, 8 May 2026 10:38:44 +0200 Subject: [PATCH] feat(agents): centralize model picker list and surface 10 curated models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hardcoded model lists in each adapter were stale and inconsistent — some agents shipped just the Claude trio while OpenCode shipped 14 models including ones we no longer recommend (Kimi K2.5, GLM 4.7, GPT OSS 120B, Gemini 2.5 variants, etc.). This consolidates everything behind a single PICKER_MODELS array in src/config/models.ts. The curated set is the original 5 (Claude Opus/Sonnet/Haiku 4, GPT 5.5, Gemini 3.1 Pro Preview) plus 5 additions per request (Kimi K2.6, GLM 5.1, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash). All adapters now derive from the same source: Pi, OpenClaw, Claude Desktop, and Codex bake the list into their config; Hermes registers a providers.opper block so its /mode picker shows "Opper (N models)" with discovery against /v1/models (PICKER_MODELS as fallback). Claude Code remains dynamic via the gateway. Also fixes a latent bug where configureOpenCode would skip rewriting provider.opper if it already existed — meaning users on a stale template never got refreshed. Both configure() and spawn() now pass overwrite: true so the latest template lands on every launch. --- data/opencode.json | 98 ++++++++++++++---------------------- src/agents/claude-desktop.ts | 8 ++- src/agents/codex.ts | 17 +++---- src/agents/hermes.ts | 25 +++++++++ src/agents/openclaw.ts | 39 +++++--------- src/agents/opencode.ts | 10 ++-- src/agents/pi.ts | 33 ++++-------- src/config/models.ts | 54 ++++++++++++++++++++ test/agents/codex.test.ts | 11 ++++ test/agents/hermes.test.ts | 42 ++++++++++++++++ test/agents/openclaw.test.ts | 14 ++++++ test/agents/opencode.test.ts | 4 +- test/agents/pi.test.ts | 27 ++++++++++ 13 files changed, 255 insertions(+), 127 deletions(-) diff --git a/data/opencode.json b/data/opencode.json index 2c23b21..9077294 100644 --- a/data/opencode.json +++ b/data/opencode.json @@ -9,82 +9,62 @@ "apiKey": "{env:OPPER_API_KEY}" }, "models": { - "gpt-5.5": { - "name": "GPT 5.5", - "cost": { "input": 5.00, "output": 30.00 }, - "limit": { "context": 1050000, "output": 128000 }, - "reasoning": true, - "variants": { - "xhigh": { - "reasoningEffort": "xhigh", - "reasoningSummary": "auto" - } - } + "claude-opus-4-7": { + "name": "Claude Opus 4.7", + "cost": { "input": 5.00, "output": 25.00 }, + "limit": { "context": 1000000, "output": 128000 } }, "claude-sonnet-4-6": { "name": "Claude Sonnet 4.6", "cost": { "input": 3.00, "output": 15.00 }, "limit": { "context": 1000000, "output": 64000 } }, - "claude-opus-4-7": { - "name": "Claude Opus 4.7", - "cost": { "input": 5.00, "output": 25.00 }, - "limit": { "context": 1000000, "output": 128000 } - }, "claude-haiku-4-5": { "name": "Claude Haiku 4.5", "cost": { "input": 1.00, "output": 5.00 }, "limit": { "context": 200000, "output": 64000 } }, - "gpt-5.2": { - "name": "GPT 5.2", - "cost": { "input": 1.75, "output": 14.00 }, - "limit": { "context": 400000, "output": 128000 } - }, - "gpt-5-mini": { - "name": "GPT 5 Mini", - "cost": { "input": 0.25, "output": 1.00 }, - "limit": { "context": 400000, "output": 128000 } - }, - "gpt-5-nano": { - "name": "GPT 5 Nano", - "cost": { "input": 0.05, "output": 0.40 }, - "limit": { "context": 400000, "output": 128000 } - }, - "gemini-2.5-pro": { - "name": "Gemini 2.5 Pro", - "cost": { "input": 1.25, "output": 10.00 }, - "limit": { "context": 1000000, "output": 65536 } + "gpt-5.5": { + "name": "GPT 5.5", + "cost": { "input": 5.00, "output": 30.00 }, + "limit": { "context": 1050000, "output": 128000 }, + "reasoning": true, + "variants": { + "xhigh": { + "reasoningEffort": "xhigh", + "reasoningSummary": "auto" + } + } }, - "gemini-2.5-flash": { - "name": "Gemini 2.5 Flash", - "cost": { "input": 0.30, "output": 2.50 }, - "limit": { "context": 1000000, "output": 65536 } + "gemini-3.1-pro-preview": { + "name": "Gemini 3.1 Pro Preview", + "cost": { "input": 2.00, "output": 12.00 }, + "limit": { "context": 1048576, "output": 65536 } }, - "gemini-2.5-flash-lite": { - "name": "Gemini 2.5 Flash Lite", - "cost": { "input": 0.10, "output": 0.40 }, - "limit": { "context": 1000000, "output": 65536 } + "deepinfra/kimi-k2.6": { + "name": "Kimi K2.6", + "cost": { "input": 0.75, "output": 3.50 }, + "limit": { "context": 262144, "output": 65536 } }, - "gemini-3-flash-preview": { - "name": "Gemini 3 Flash Preview", - "cost": { "input": 0.50, "output": 3.00 }, - "limit": { "context": 1000000, "output": 65536 } + "deepinfra/glm-5.1": { + "name": "GLM 5.1", + "cost": { "input": 1.05, "output": 3.50 }, + "limit": { "context": 202752, "output": 65536 } }, - "kimi-k2.5": { - "name": "Kimi K2.5", - "cost": { "input": 0.60, "output": 3.00 }, - "limit": { "context": 256000, "output": 65536 } + "fireworks/minimax-m2p7": { + "name": "MiniMax M2.7", + "cost": { "input": 0.30, "output": 1.20 }, + "limit": { "context": 196608, "output": 65536 } }, - "glm-4.7": { - "name": "GLM 4.7", - "cost": { "input": 0.60, "output": 2.20 }, - "limit": { "context": 200000, "output": 128000 } + "deepinfra/deepseek-v4-pro": { + "name": "DeepSeek V4 Pro", + "cost": { "input": 1.74, "output": 3.48 }, + "limit": { "context": 1048576, "output": 65536 } }, - "gpt-oss-120b": { - "name": "GPT OSS 120B", - "cost": { "input": 0.15, "output": 0.75 }, - "limit": { "context": 131072, "output": 131072 } + "deepinfra/deepseek-v4-flash": { + "name": "DeepSeek V4 Flash", + "cost": { "input": 0.14, "output": 0.28 }, + "limit": { "context": 1048576, "output": 65536 } } } } diff --git a/src/agents/claude-desktop.ts b/src/agents/claude-desktop.ts index a782d32..054186e 100644 --- a/src/agents/claude-desktop.ts +++ b/src/agents/claude-desktop.ts @@ -4,7 +4,7 @@ import { homedir, platform } from "node:os"; import { join, dirname } from "node:path"; import { OpperError } from "../errors.js"; import { OPPER_COMPAT_URL } from "../config/endpoints.js"; -import { DEFAULT_MODELS } from "../config/models.js"; +import { DEFAULT_MODELS, PICKER_MODELS } from "../config/models.js"; import { run } from "../util/run.js"; import type { AgentAdapter, @@ -240,12 +240,10 @@ async function writeGatewayProfile( cfg.inferenceGatewayAuthScheme = "bearer"; cfg.disableDeploymentModeChooser = true; // First entry is the picker default. Dedupe in case primaryModel - // equals one of the catalog defaults. + // equals one of the catalog ids. const names = Array.from(new Set([ primaryModel, - DEFAULT_MODELS.opus, - DEFAULT_MODELS.sonnet, - DEFAULT_MODELS.haiku, + ...PICKER_MODELS.map((m) => m.id), ])); cfg.inferenceModels = names.map((name) => ({ name })); await writeJson(path, cfg); diff --git a/src/agents/codex.ts b/src/agents/codex.ts index d226c31..8038849 100644 --- a/src/agents/codex.ts +++ b/src/agents/codex.ts @@ -13,13 +13,19 @@ import type { } from "./types.js"; import { OPPER_COMPAT_URL } from "../config/endpoints.js"; -import { DEFAULT_MODELS } from "../config/models.js"; +import { PICKER_MODELS } from "../config/models.js"; const SENTINEL_OPEN = "# >>> opper-cli >>>"; const SENTINEL_CLOSE = "# <<< opper-cli <<<"; const DEFAULT_PROFILE = "opper-opus"; function buildOpperBlock(baseUrl: string): string { + const profileBlocks = PICKER_MODELS.flatMap((m) => [ + "", + `[profiles.opper-${m.codexProfile}]`, + `model = "${m.id}"`, + 'model_provider = "opper"', + ]); return [ SENTINEL_OPEN, "# Managed by `opper`. Edits between these markers will be overwritten", @@ -30,14 +36,7 @@ function buildOpperBlock(baseUrl: string): string { `base_url = "${baseUrl}"`, 'env_key = "OPPER_API_KEY"', 'wire_api = "responses"', - "", - "[profiles.opper-opus]", - `model = "${DEFAULT_MODELS.opus}"`, - 'model_provider = "opper"', - "", - "[profiles.opper-sonnet]", - `model = "${DEFAULT_MODELS.sonnet}"`, - 'model_provider = "opper"', + ...profileBlocks, SENTINEL_CLOSE, "", ].join("\n"); diff --git a/src/agents/hermes.ts b/src/agents/hermes.ts index 8bd1f7c..29029d6 100644 --- a/src/agents/hermes.ts +++ b/src/agents/hermes.ts @@ -6,6 +6,7 @@ import { which } from "../util/which.js"; import { run } from "../util/run.js"; import { opperHome } from "../auth/paths.js"; import { OpperError } from "../errors.js"; +import { PICKER_MODELS } from "../config/models.js"; import type { AgentAdapter, DetectResult, @@ -102,6 +103,30 @@ async function writeOpperConfig(routing: OpperRouting): Promise { default: routing.model, }; + // Register Opper as a named provider so Hermes' `/model` picker shows + // "Opper (N models)" alongside the built-in providers. Without this the + // picker only enumerates first-class providers (OpenRouter, Copilot, + // OpenAI…) with detected creds — our `model.provider: custom` route is + // invisible to it. + // + // We let Hermes auto-discover models from `/v1/models` (the + // default behaviour) — same pattern Claude Code uses with our compat + // endpoint. The curated `models:` dict below is the fallback Hermes + // uses when discovery fails (network error, auth, etc.). + // + // `key_env: OPENAI_API_KEY` matches the env var we already export at + // spawn time, so no api key lands on disk. + const opperModels: Record> = {}; + for (const m of PICKER_MODELS) opperModels[m.id] = {}; + const providers = (existing.providers as Record | undefined) ?? {}; + providers.opper = { + name: "Opper", + base_url: routing.baseUrl, + key_env: "OPENAI_API_KEY", + models: opperModels, + }; + existing.providers = providers; + await writeFile(path, stringify(existing), { mode: 0o600 }); } diff --git a/src/agents/openclaw.ts b/src/agents/openclaw.ts index 4031578..d979726 100644 --- a/src/agents/openclaw.ts +++ b/src/agents/openclaw.ts @@ -8,7 +8,7 @@ import { run } from "../util/run.js"; import { OpperError } from "../errors.js"; import { npmInstallGlobal } from "./npm-install.js"; import { OPPER_COMPAT_URL } from "../config/endpoints.js"; -import { DEFAULT_MODELS } from "../config/models.js"; +import { DEFAULT_MODELS, pickerModelsForLaunch } from "../config/models.js"; import type { AgentAdapter, ConfigureOptions, @@ -53,36 +53,21 @@ async function setOpperProvider( ): Promise { const cfg = await readConfig(); cfg.providers = cfg.providers ?? {}; + // The launch model is reordered to index 0 — OpenClaw has no explicit + // _launch marker, so position-0 is the only signal that picks the + // active default in its picker UI. cfg.providers[PROVIDER_KEY] = { api: "openai-completions", apiKey, baseUrl, - models: [ - { - id: launchModel, - name: launchModel, - api: "openai-completions", - reasoning: true, - input: ["text"], - contextWindow: 200000, - }, - { - id: DEFAULT_MODELS.sonnet, - name: DEFAULT_MODELS.sonnet, - api: "openai-completions", - reasoning: true, - input: ["text"], - contextWindow: 200000, - }, - { - id: DEFAULT_MODELS.haiku, - name: DEFAULT_MODELS.haiku, - api: "openai-completions", - reasoning: false, - input: ["text"], - contextWindow: 200000, - }, - ], + models: pickerModelsForLaunch(launchModel).map((m) => ({ + id: m.id, + name: m.id, + api: "openai-completions", + reasoning: m.reasoning, + input: ["text"], + contextWindow: m.contextWindow, + })), }; await writeConfig(cfg); } diff --git a/src/agents/opencode.ts b/src/agents/opencode.ts index 3f700f1..60fb3ad 100644 --- a/src/agents/opencode.ts +++ b/src/agents/opencode.ts @@ -44,7 +44,11 @@ async function isConfigured(): Promise { } async function configure(): Promise { - await configureOpenCode({ location: "global" }); + // overwrite: true so a re-run pulls in the latest template (model list, + // costs, defaults). Without it, an existing `provider.opper` block from + // an older CLI version would be left in place and the new models would + // never appear in OpenCode's picker. + await configureOpenCode({ location: "global", overwrite: true }); } async function unconfigure(): Promise { @@ -109,9 +113,9 @@ async function spawn( // Explicit opt-in to writing the cwd-local config. We never silently // mutate a project config the user didn't ask us to touch — that file // is usually checked in. - await configureOpenCode({ location: "local" }); + await configureOpenCode({ location: "local", overwrite: true }); } else { - await configureOpenCode({ location: "global" }); + await configureOpenCode({ location: "global", overwrite: true }); // OpenCode reads `./opencode.json` if present and uses it instead of // the user-level config. If one exists without an Opper provider, diff --git a/src/agents/pi.ts b/src/agents/pi.ts index 68bcf40..ae0e692 100644 --- a/src/agents/pi.ts +++ b/src/agents/pi.ts @@ -8,7 +8,7 @@ import { run } from "../util/run.js"; import { OpperError } from "../errors.js"; import { npmInstallGlobal } from "./npm-install.js"; import { OPPER_COMPAT_URL } from "../config/endpoints.js"; -import { DEFAULT_MODELS } from "../config/models.js"; +import { DEFAULT_MODELS, pickerModelsForLaunch } from "../config/models.js"; import type { AgentAdapter, ConfigureOptions, @@ -56,31 +56,20 @@ async function setOpperProvider( ): Promise { const cfg = await readConfig(); cfg.providers = cfg.providers ?? {}; + // The launch model is reordered to index 0 (Pi treats the first entry as + // the active default in its picker UI). `_launch: true` is the explicit + // marker the runtime reads. cfg.providers[PROVIDER_KEY] = { api: "openai-completions", apiKey, baseUrl, - models: [ - { - id: launchModel, - contextWindow: 200000, - input: ["text"], - reasoning: true, - _launch: true, - }, - { - id: DEFAULT_MODELS.sonnet, - contextWindow: 200000, - input: ["text"], - reasoning: true, - }, - { - id: DEFAULT_MODELS.haiku, - contextWindow: 200000, - input: ["text"], - reasoning: false, - }, - ], + models: pickerModelsForLaunch(launchModel).map((m) => ({ + id: m.id, + contextWindow: m.contextWindow, + input: ["text"], + reasoning: m.reasoning, + ...(m.id === launchModel ? { _launch: true } : {}), + })), }; await writeConfig(cfg); } diff --git a/src/config/models.ts b/src/config/models.ts index 6ff4248..47e1d3a 100644 --- a/src/config/models.ts +++ b/src/config/models.ts @@ -14,6 +14,60 @@ export const DEFAULT_MODELS = { opus: "claude-opus-4-7", sonnet: "claude-sonnet-4-6", haiku: "claude-haiku-4-5", + gpt: "gpt-5.5", + gemini: "gemini-3.1-pro-preview", /** Image generation default (Imagen via Opper). */ image: "vertexai/imagen-4.0-fast-generate-001-eu", } as const; + +/** + * The set of models we expose by default in adapter pickers (Pi, OpenClaw, + * Claude Desktop, Codex). Claude Code and OpenCode pull their lists from + * elsewhere (the gateway's /v1/models endpoint and the OpenCode template, + * respectively). + * + * TODO: replace this hardcoded list with a fetch from /v3/models filtered + * to a "featured" set once the platform exposes it. + */ +export interface PickerModel { + /** Gateway model id passed to /v3/call (and compat). */ + id: string; + contextWindow: number; + reasoning: boolean; + /** Codex profile suffix — becomes `[profiles.opper-]`. */ + codexProfile: string; +} + +export const PICKER_MODELS: ReadonlyArray = [ + { id: DEFAULT_MODELS.opus, contextWindow: 1_000_000, reasoning: true, codexProfile: "opus" }, + { id: DEFAULT_MODELS.sonnet, contextWindow: 1_000_000, reasoning: true, codexProfile: "sonnet" }, + { id: DEFAULT_MODELS.haiku, contextWindow: 200_000, reasoning: false, codexProfile: "haiku" }, + { id: DEFAULT_MODELS.gpt, contextWindow: 1_050_000, reasoning: true, codexProfile: "gpt" }, + { id: DEFAULT_MODELS.gemini, contextWindow: 1_048_576, reasoning: true, codexProfile: "gemini" }, + { id: "deepinfra/kimi-k2.6", contextWindow: 262_144, reasoning: true, codexProfile: "kimi" }, + { id: "deepinfra/glm-5.1", contextWindow: 202_752, reasoning: true, codexProfile: "glm" }, + { id: "fireworks/minimax-m2p7", contextWindow: 196_608, reasoning: false, codexProfile: "minimax" }, + { id: "deepinfra/deepseek-v4-pro", contextWindow: 1_048_576, reasoning: true, codexProfile: "deepseek-pro" }, + { id: "deepinfra/deepseek-v4-flash", contextWindow: 1_048_576, reasoning: true, codexProfile: "deepseek-flash" }, +]; + +/** + * Return PICKER_MODELS reordered so `launchModel` is at index 0. When the + * launch model isn't in the picker set (e.g. user passed `--model X` for + * a non-curated id), it's prepended as a minimal entry. + * + * Adapters that bake a list into agent config (Pi, OpenClaw) treat + * `models[0]` as the default — this keeps that contract regardless of + * where the launch model sits in PICKER_MODELS. + */ +export function pickerModelsForLaunch(launchModel: string): PickerModel[] { + const idx = PICKER_MODELS.findIndex((m) => m.id === launchModel); + if (idx === -1) { + return [ + { id: launchModel, contextWindow: 200_000, reasoning: true, codexProfile: "" }, + ...PICKER_MODELS, + ]; + } + const head = PICKER_MODELS[idx]!; + return [head, ...PICKER_MODELS.filter((_, i) => i !== idx)]; +} diff --git a/test/agents/codex.test.ts b/test/agents/codex.test.ts index 6766305..7f25d1b 100644 --- a/test/agents/codex.test.ts +++ b/test/agents/codex.test.ts @@ -86,6 +86,17 @@ describe("codex adapter", () => { expect(await codex.isConfigured()).toBe(true); }); + it("configure writes one profile block per PICKER_MODELS entry", async () => { + const { PICKER_MODELS } = await import("../../src/config/models.js"); + await codex.configure({}); + const text = readFileSync(join(sandbox, ".codex", "config.toml"), "utf8"); + for (const m of PICKER_MODELS) { + // Profile header + the corresponding model id appear in the block. + expect(text).toContain(`[profiles.opper-${m.codexProfile}]`); + expect(text).toContain(`model = "${m.id}"`); + } + }); + it("configure preserves user content outside the sentinels", async () => { const cfgDir = join(sandbox, ".codex"); const cfgPath = join(cfgDir, "config.toml"); diff --git a/test/agents/hermes.test.ts b/test/agents/hermes.test.ts index aff4909..174fe15 100644 --- a/test/agents/hermes.test.ts +++ b/test/agents/hermes.test.ts @@ -174,6 +174,48 @@ describe("hermes adapter — spawn (isolated HERMES_HOME)", () => { expect(after.toolsets).toEqual(["hermes-cli", "web"]); }); + it("writes a providers.opper block with all picker model ids on every spawn", async () => { + runMock.mockReturnValue({ code: 0, stdout: "", stderr: "" }); + const SESSION_URL = "https://api.opper.ai/v3/session/sess_test"; + await hermes.spawn!([], { ...ROUTING, baseUrl: SESSION_URL }); + + const configPath = join(sandbox, ".opper", "hermes-home", "config.yaml"); + const written = parse(readFileSync(configPath, "utf8")) as { + providers?: { opper?: { name?: string; base_url?: string; key_env?: string; models?: Record } }; + }; + expect(written.providers?.opper).toBeDefined(); + expect(written.providers?.opper?.name).toBe("Opper"); + expect(written.providers?.opper?.base_url).toBe(SESSION_URL); + expect(written.providers?.opper?.key_env).toBe("OPENAI_API_KEY"); + const ids = Object.keys(written.providers?.opper?.models ?? {}); + // Spot-check both ends: the curated 5 plus the 5 added later. + expect(ids).toContain("claude-opus-4-7"); + expect(ids).toContain("gpt-5.5"); + expect(ids).toContain("gemini-3.1-pro-preview"); + expect(ids).toContain("deepinfra/kimi-k2.6"); + expect(ids).toContain("fireworks/minimax-m2p7"); + expect(ids).toContain("deepinfra/deepseek-v4-flash"); + }); + + it("rewrites providers.opper.base_url with the per-session URL on each spawn", async () => { + runMock.mockReturnValue({ code: 0, stdout: "", stderr: "" }); + const SESSION_A = "https://api.opper.ai/v3/session/sess_a"; + const SESSION_B = "https://api.opper.ai/v3/session/sess_b"; + + await hermes.spawn!([], { ...ROUTING, baseUrl: SESSION_A }); + await hermes.spawn!([], { ...ROUTING, baseUrl: SESSION_B }); + + const configPath = join(sandbox, ".opper", "hermes-home", "config.yaml"); + const after = parse(readFileSync(configPath, "utf8")) as { + model?: { base_url?: string }; + providers?: { opper?: { base_url?: string } }; + }; + // Both routing surfaces must follow the latest session URL — otherwise + // the picker row keeps pointing at a stale session. + expect(after.model?.base_url).toBe(SESSION_B); + expect(after.providers?.opper?.base_url).toBe(SESSION_B); + }); + it("propagates non-zero exit codes from run()", async () => { runMock.mockReturnValue({ code: 2, stdout: "", stderr: "" }); const code = await hermes.spawn!([], ROUTING); diff --git a/test/agents/openclaw.test.ts b/test/agents/openclaw.test.ts index fe453f0..873f2f4 100644 --- a/test/agents/openclaw.test.ts +++ b/test/agents/openclaw.test.ts @@ -95,6 +95,20 @@ describe("openclaw adapter", () => { expect(raw).not.toContain('"baseUrl": "https://api.opper.ai/v3/compat"'); }); + it("spawn places the launch model at models[0] even when it isn't opus", async () => { + spawnSyncMock.mockReturnValue({ status: 0 }); + await openclaw.spawn!([], { ...ROUTING, model: "gpt-5.5" }); + + const models = readModels(sandbox) as { + providers?: { opper?: { models?: Array<{ id: string }> } }; + }; + const list = models.providers?.opper?.models ?? []; + // OpenClaw has no _launch marker — position 0 is the only signal. + expect(list[0]?.id).toBe("gpt-5.5"); + expect(list.length).toBeGreaterThan(1); + expect(list.some((m) => m.id === "claude-opus-4-7")).toBe(true); + }); + it("spawn defaults to `gateway start` when no args are passed", async () => { spawnSyncMock.mockReturnValue({ status: 0 }); await openclaw.spawn!([], ROUTING); diff --git a/test/agents/opencode.test.ts b/test/agents/opencode.test.ts index e2bbb6f..30a65b5 100644 --- a/test/agents/opencode.test.ts +++ b/test/agents/opencode.test.ts @@ -145,7 +145,7 @@ describe("opencode adapter", () => { const code = await opencode.spawn!(["chat"], ROUTING); expect(code).toBe(0); - expect(configureOpenCodeMock).toHaveBeenCalledWith({ location: "global" }); + expect(configureOpenCodeMock).toHaveBeenCalledWith({ location: "global", overwrite: true }); const call = spawnSyncMock.mock.calls[0]!; expect(call[0]).toBe("opencode"); @@ -202,7 +202,7 @@ describe("opencode adapter", () => { spawnSyncMock.mockReturnValue({ status: 0 }); await opencode.spawn!(["chat"], ROUTING, { configScope: "project" }); - expect(configureOpenCodeMock).toHaveBeenCalledWith({ location: "local" }); + expect(configureOpenCodeMock).toHaveBeenCalledWith({ location: "local", overwrite: true }); }); it("spawn warns when a project opencode.json exists without an Opper provider", async () => { diff --git a/test/agents/pi.test.ts b/test/agents/pi.test.ts index ca08bd7..bdc0404 100644 --- a/test/agents/pi.test.ts +++ b/test/agents/pi.test.ts @@ -93,6 +93,33 @@ describe("pi adapter", () => { expect(raw).not.toContain('"baseUrl": "https://api.opper.ai/v3/compat"'); }); + it("spawn places the launch model at models[0] even when it isn't opus", async () => { + spawnSyncMock.mockReturnValue({ status: 0 }); + await pi.spawn!([], { ...ROUTING, model: "claude-haiku-4-5" }); + + const models = readModels(sandbox) as { + providers?: { opper?: { models?: Array<{ id: string; _launch?: boolean }> } }; + }; + const list = models.providers?.opper?.models ?? []; + expect(list[0]?.id).toBe("claude-haiku-4-5"); + expect(list[0]?._launch).toBe(true); + // Other curated models still present after the launch entry. + expect(list.length).toBeGreaterThan(1); + expect(list.some((m) => m.id === "claude-opus-4-7")).toBe(true); + }); + + it("spawn prepends a non-curated --model id so it still appears in the picker", async () => { + spawnSyncMock.mockReturnValue({ status: 0 }); + await pi.spawn!([], { ...ROUTING, model: "deepinfra/some-future-model" }); + + const models = readModels(sandbox) as { + providers?: { opper?: { models?: Array<{ id: string; _launch?: boolean }> } }; + }; + const list = models.providers?.opper?.models ?? []; + expect(list[0]?.id).toBe("deepinfra/some-future-model"); + expect(list[0]?._launch).toBe(true); + }); + it("spawn auto-injects --provider opper and --model when user doesn't pass --model", async () => { spawnSyncMock.mockReturnValue({ status: 0 }); await pi.spawn!([], ROUTING);