diff --git a/CHANGELOG.md b/CHANGELOG.md index d933ab1..203725d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to OpenCode Model Control are recorded here. The project follows [Semantic Versioning](https://semver.org/). +## 0.2.1 - 2026-09-01 + +- Made any available, capability-compatible known-paid model selectable in a role dropdown while Paid mode is active, regardless of provider. Selecting a disabled model is now the explicit opt-in that enables only that model for routing; Automatic never enables models by itself. +- Aligned role-dropdown checks with the core routing gates for saved availability, text output, tool-call capability, access, and modality. Unknown-cost, unavailable, and incompatible models remain blocked. +- Recompute capability-derived role profiles on catalog refresh, including empty capability arrays, so provider upgrades and downgrades cannot leave dynamic models with stale roles. Restore bundled curated profiles from the packaged catalog when migrating legacy snapshots that mislabeled them as capability-derived. +- Treat zero role scores as incompatible consistently across draft validation, routing, and generated OpenCode configuration. +- Added provider-agnostic regression coverage using an xAI/Grok-shaped paid model plus blocked cost, availability, capability, and automatic-selection cases. + ## 0.2.0 - 2026-08-31 - Added attachment-aware Omc-Router media switching through the bundled local OpenCode plugin, with capability, modality, availability, enablement, and cost-policy gates that fail closed. Media-only analysis runs as a tool-free vision worker, while only explicit user-authored text classified as a code change may retain Omc-Router for the vision-to-code-to-review workflow. diff --git a/README.md b/README.md index 48693dc..8178e26 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenCode Model Control - + OpenCode Model Control is a local control panel and MCP companion for building a model team inside OpenCode. It discovers the models OpenCode currently exposes, lets the user decide which ones the router may use, assigns an orchestrator and specialist roles, and safely connects that policy to OpenCode. @@ -8,13 +8,13 @@ The control panel runs on `127.0.0.1`. OpenCode remains responsible for provider The running app is authoritative for model names, availability, pricing evidence, and role eligibility. -> **Release status:** Version `0.2.0` is published on [npm](https://www.npmjs.com/package/opencode-model-control/v/0.2.0) and as an [immutable GitHub release](https://github.com/BitL8-ByteShort/opencode-model-control/releases/tag/v0.2.0). Both channels serve the same verified tarball with SHA-256 `59c6094a9b7dd57b897ee59c41269b154861de408db7c138c22725d17a1e67df`. The [source repository](https://github.com/BitL8-ByteShort/opencode-model-control) is public. Linux fresh-install acceptance remains separately tracked and is not inferred from publication. +> **Release status:** This source tree is prepared as version `0.2.1`. A version in `package.json` is not proof that a distribution channel is live; verify the exact [npm version](https://www.npmjs.com/package/opencode-model-control/v/0.2.1) or [GitHub release](https://github.com/BitL8-ByteShort/opencode-model-control/releases/tag/v0.2.1) before installing. The [source repository](https://github.com/BitL8-ByteShort/opencode-model-control) is public. Linux fresh-install acceptance remains separately tracked and is not inferred from publication. ## What it does - Reads OpenCode's resolved, all-provider model catalog rather than relying on a fixed list. - Provides an **Update available models** action that runs a fresh OpenCode catalog refresh. -- Separates OpenCode discovery from Model Control enablement: newly discovered models are visible but disabled for routing until the user enables them. +- Separates OpenCode discovery from Model Control enablement: newly discovered models are visible but disabled for routing until the user enables one in Models or explicitly selects it for a compatible role. - Provides a **Free / Paid** preference: - **Free** permits only independently verified zero-cost models. - **Paid** permits verified free and known paid models, prioritizing paid models for compatible automatic assignments. @@ -51,16 +51,16 @@ The panel can open without OpenCode, but it cannot discover the user's current m Install the exact npm version: ```sh -npm install --global opencode-model-control@0.2.0 +npm install --global opencode-model-control@0.2.1 opencode-model-control ``` -The first command installs `0.2.0` and its runtime dependencies. The second command starts the local panel and opens it in the default browser. +The first command installs `0.2.1` and its runtime dependencies. The second command starts the local panel and opens it in the default browser. Then: 1. Click **Update available models** to read the models currently exposed by OpenCode. -2. Choose **Free** or **Paid**, enable the models Model Control is allowed to route to, choose whether Omc-Router should become the default agent, and save. +2. Choose **Free** or **Paid**, then enable models in Models or explicitly select compatible models for roles. A role selection enables only that chosen model. Choose whether Omc-Router should become the default agent, then save. 3. Click **Connect to OpenCode**. 4. Restart OpenCode so it loads the managed MCP, local routing plugin, and `omc-*` agents. @@ -71,12 +71,12 @@ No JSON editing is required. Connect creates a private backup, safely merges onl Close OpenCode and stop the running Model Control process with `Ctrl+C`, then run: ```sh -npm install --global opencode-model-control@0.2.0 +npm install --global opencode-model-control@0.2.1 opencode-model-control --version opencode-model-control ``` -The version command must print `0.2.0`. In the reopened panel, click **Update available models**, review the Free/Paid preference and enabled models, click **Save changes**, then click **Update connection**. If the panel says it is disconnected, use **Connect to OpenCode** instead. Restart OpenCode and verify the managed connection: +The version command must print `0.2.1`. In the reopened panel, click **Update available models**, review the Free/Paid preference and enabled models, click **Save changes**, then click **Update connection**. If the panel says it is disconnected, use **Connect to OpenCode** instead. Restart OpenCode and verify the managed connection: ```sh opencode-model-control status --json @@ -98,7 +98,7 @@ Then: 1. Open the local panel (normally `http://127.0.0.1:47821`). 2. Click **Update available models** to re-read all models exposed by OpenCode's resolved provider configuration. -3. Choose **Free** or **Paid**, enable the models Model Control is allowed to route to, choose whether Omc-Router should become the default agent, and save. +3. Choose **Free** or **Paid**, then enable models in Models or explicitly select compatible models for roles. A role selection enables only that chosen model. Choose whether Omc-Router should become the default agent, then save. 4. Click **Connect to OpenCode**. 5. Restart OpenCode so it loads the managed MCP, local routing plugin, and `omc-*` agents. @@ -111,11 +111,11 @@ The connector writes absolute Node and package CLI paths, so a source checkout d To install the same tested tarball directly from GitHub: ```sh -npm install --global https://github.com/BitL8-ByteShort/opencode-model-control/releases/download/v0.2.0/opencode-model-control-0.2.0.tgz +npm install --global https://github.com/BitL8-ByteShort/opencode-model-control/releases/download/v0.2.1/opencode-model-control-0.2.1.tgz opencode-model-control ``` -The release publishes `opencode-model-control-0.2.0.tgz.sha256` beside the tarball. The checksum and source tag are also recorded in the public [release package ledger](https://github.com/BitL8-ByteShort/opencode-model-control/blob/v0.2.0/packages/README.md). +The release publishes `opencode-model-control-0.2.1.tgz.sha256` beside the tarball. The checksum and source tag are also recorded in the public [release package ledger](https://github.com/BitL8-ByteShort/opencode-model-control/blob/v0.2.1/packages/README.md). ## What “Update available models” means @@ -128,7 +128,7 @@ OpenCode 1.18.x may add its standard `$schema` property when its CLI reads a pro Catalog state is deliberately split into four concepts: - **Discovered:** OpenCode reported the model. -- **Enabled in Model Control:** the user permits this router to select it. Newly discovered models start disabled here even if OpenCode exposes them. +- **Enabled in Model Control:** the user permits this router to select it. Newly discovered models start disabled even if OpenCode exposes them. Selecting an available, cost-allowed, compatible model in a role dropdown is an explicit opt-in that enables that one model; choosing Automatic never enables models. - **Available:** the refreshed metadata reports it active. - **Runtime access checked:** a manually confirmed bounded synthetic OpenCode run returned the expected sentinel. OpenCode may have retried a provider failure during that run. Refresh does not make this claim or incur a model charge, and a runtime-access pass is not benchmark evidence. @@ -142,6 +142,8 @@ The two cost choices set both priority and permission. They never override task - **Paid** means `paid-first + known-cost`. Known-paid models are preferred for compatible automatic assignments, but verified-free models remain eligible automatic candidates. It is not a paid-only mode. - **Unknown pricing** is blocked in both modes. A name ending in `-free` or a zero normalized by OpenCode is not enough evidence by itself. +Paid mode grants permission but does not silently enable every discovered paid model. A compatible disabled model is marked **enable on selection** in role dropdowns; selecting it enables that exact model in the draft, and **Save changes** remains the commit point. + Selecting **Paid** can incur charges under the active OpenCode provider account. Model Control does not set or enforce provider-side budgets. ## Seamless routing boundaries diff --git a/docs/architecture.md b/docs/architecture.md index 0bfd9ce..4344329 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,7 +58,7 @@ Pricing has three classes: OpenCode may normalize absent price fields to zero. Therefore a zero from arbitrary CLI metadata is not, by itself, proof of free access. -The UI maps **Free** to `free-first + free-only`. It maps **Paid** to `paid-first + known-cost`. Paid mode permits both verified-free and known-paid models but prefers paid candidates after capability, availability, enablement, and qualified-evidence gates. Unknown pricing is never eligible. +The UI maps **Free** to `free-first + free-only`. It maps **Paid** to `paid-first + known-cost`. Paid mode permits both verified-free and known-paid models but prefers paid candidates after capability, availability, enablement, and qualified-evidence gates. Unknown pricing is never eligible. A deliberate compatible role selection is also an explicit model opt-in: it enables only that selected model in the unsaved draft. Automatic routing never enables models. ### Route planner diff --git a/docs/opencode-integration.md b/docs/opencode-integration.md index 2e39578..e26cc85 100644 --- a/docs/opencode-integration.md +++ b/docs/opencode-integration.md @@ -147,7 +147,7 @@ A model is: A model name ending in `-free` is not sufficient evidence by itself. OpenCode may normalize missing pricing fields to zero, so arbitrary CLI zero values remain unknown unless independently verified. -Newly discovered models are visible and disabled by default. The user must explicitly allow Model Control to select them. +Newly discovered models are visible and disabled by default. The user must explicitly allow Model Control to select them. They can do that in Models or by deliberately choosing an available, cost-allowed, role-compatible model in a role dropdown; that selection atomically enables only the chosen model in the draft. Automatic selection never enables a model, and Save remains the commit point. ## Advanced developer tools diff --git a/docs/support-matrix.md b/docs/support-matrix.md index 6e58483..82b5e69 100644 --- a/docs/support-matrix.md +++ b/docs/support-matrix.md @@ -24,9 +24,9 @@ This matrix separates implemented behavior from compatibility that still needs l | Local usage accounting | Implemented and live-tested on OpenCode 1.18.22 | Fixed aggregate DB query; 7/30/90-day and all-time windows; no prompt/content projection. | | Usage cost values | Provider-reported estimate | OpenCode's recorded cost is displayed; it is not treated as a provider invoice. | | Plugin-free fallback | Implemented, explicitly incomplete | Preserves a usable snapshot when plugin-aware discovery fails; plugin models may be absent. | -| Dynamic model records | Implemented | New records are visible and Model Control-disabled by default. | +| Dynamic model records | Implemented | New records are visible and Model Control-disabled by default. A compatible explicit role selection enables only that selected model; Automatic never enables models. Capability-derived roles are refreshed from current provider metadata, including empty capability sets, while bundled curated profiles are restored from the packaged catalog during legacy snapshot migration. | | Verified-free mode | Implemented | Only independently verified exact-zero pricing is eligible. | -| Known-paid preference | Implemented | Paid mode allows verified free and known paid, preferring paid after hard gates. | +| Known-paid preference | Implemented | Paid mode allows verified free and known paid from any provider, preferring paid after hard gates. Compatible disabled models can be explicitly selected and enabled one at a time; unknown pricing remains blocked. | | Unknown pricing | Blocked | Missing or ambiguous pricing is not assumed free and cannot auto-route. | | Big Pickle primary | Configured, unbenchmarked | Text-first initial assignment; quality claims require benchmark evidence. | | Attachment-aware media routing | Implemented; release acceptance pending | A media turn entering through `omc-router` selects the compatible saved vision model. Media-only analysis becomes a hard tool-free vision-worker turn; only explicit user-authored text classified as a code change retains Omc-Router. | diff --git a/package-lock.json b/package-lock.json index d1cc933..9c7a24e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-model-control", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-model-control", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/server": "2.0.0", diff --git a/package.json b/package.json index f508c66..27dbb56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-model-control", - "version": "0.2.0", + "version": "0.2.1", "description": "A local model routing control panel and MCP companion for OpenCode.", "keywords": [ "opencode", diff --git a/packages/README.md b/packages/README.md index e8de00e..0f9d788 100644 --- a/packages/README.md +++ b/packages/README.md @@ -4,6 +4,12 @@ This directory carries checksum-recorded copies of verified public release packa Each tarball is produced with `npm pack` only after the full release gate passes. Its filename, version, and SHA-256 digest are recorded here so users can verify a direct download before installation. +## 0.2.1 + +- File: `opencode-model-control-0.2.1.tgz` +- SHA-256: `b0c0e161bec91ac384d12336d9786aa41870a65d3a291a72760a1e84fb3a489c` +- Source tag: `v0.2.1` + ## 0.2.0 - File: `opencode-model-control-0.2.0.tgz` diff --git a/packages/opencode-model-control-0.2.1.tgz b/packages/opencode-model-control-0.2.1.tgz new file mode 100644 index 0000000..c989427 Binary files /dev/null and b/packages/opencode-model-control-0.2.1.tgz differ diff --git a/packages/opencode-model-control-0.2.1.tgz.sha256 b/packages/opencode-model-control-0.2.1.tgz.sha256 new file mode 100644 index 0000000..e007653 --- /dev/null +++ b/packages/opencode-model-control-0.2.1.tgz.sha256 @@ -0,0 +1 @@ +b0c0e161bec91ac384d12336d9786aa41870a65d3a291a72760a1e84fb3a489c opencode-model-control-0.2.1.tgz diff --git a/src/core/catalog.js b/src/core/catalog.js index fe51767..3603a60 100644 --- a/src/core/catalog.js +++ b/src/core/catalog.js @@ -301,7 +301,10 @@ export function classifyModelPricing(model) { } export function modelSupports({ model, role, modalities, access }) { - if (!MODEL_ROLES.includes(role) || model?.roles?.[role] === undefined) return false; + const roleScore = model?.roles?.[role]; + if (!MODEL_ROLES.includes(role) || !Number.isInteger(roleScore) || roleScore <= 0) { + return false; + } if (role === "orchestrator" && model.canOrchestrate !== true) return false; if ( (role === "orchestrator" || role === "code-worker" || role === "vision-worker") && diff --git a/src/server/opencode-cli.js b/src/server/opencode-cli.js index 059c17b..7e6ebcf 100644 --- a/src/server/opencode-cli.js +++ b/src/server/opencode-cli.js @@ -231,23 +231,50 @@ export function toLiveAvailability(catalog, liveModels) { export function mergeDiscoveredCatalog(baseCatalog, liveModels, { snapshotDate = new Date().toISOString().slice(0, 10), + curatedCatalog, } = {}) { const previous = new Map(baseCatalog.models.map((model) => [model.id, model])); + const curatedById = new Map( + (curatedCatalog?.models ?? []).map((model) => [model.id, model]), + ); const liveById = new Map(liveModels.map((model) => [model.id, model])); const ids = new Set([...previous.keys(), ...liveById.keys()]); const models = [...ids].map((id) => { const prior = previous.get(id); + const curated = curatedById.get(id); const live = liveById.get(id); if (!live) { + const capabilityDerived = + !curated && + (prior?.profileSource === "capability" || + (prior?.profileSource == null && prior?.discovered === true)); return { ...prior, + ...(curated + ? { + label: curated.label, + status: curated.status, + provisional: curated.provisional, + access: curated.access, + canOrchestrate: curated.canOrchestrate, + roles: curated.roles, + profileSource: "curated", + } + : {}), available: false, discovered: false, runtimeVerified: false, - enabledByDefault: prior?.profileSource === "capability" ? false : prior.enabledByDefault, + enabledByDefault: curated?.enabledByDefault ?? + (capabilityDerived ? false : prior.enabledByDefault), }; } + const capabilityDerived = + !curated && + (!prior || + prior.profileSource === "capability" || + (prior.profileSource == null && prior.discovered === true)); + const reportedInputCost = verifiedCost( live.inputCost ?? (live.free === true ? 0 : null), ); @@ -261,35 +288,52 @@ export function mergeDiscoveredCatalog(baseCatalog, liveModels, { reportedOutputCost !== null; const liveHasPositivePrice = reportedPrices && (reportedInputCost > 0 || reportedOutputCost > 0); + const pricingEvidence = curated ?? prior; const priorVerifiedFree = - prior?.free?.verified === true && - prior.free.inputUsdPerMillion === 0 && - prior.free.outputUsdPerMillion === 0; + pricingEvidence?.free?.verified === true && + pricingEvidence.free.inputUsdPerMillion === 0 && + pricingEvidence.free.outputUsdPerMillion === 0; const verifiedPricing = liveHasPositivePrice || (priorVerifiedFree && reportedPrices); - const inputModalities = Array.isArray(live.inputModalities) && live.inputModalities.length > 0 + const inputModalities = Array.isArray(live.inputModalities) ? live.inputModalities - : prior?.modalities?.input ?? []; - const outputModalities = Array.isArray(live.outputModalities) && live.outputModalities.length > 0 + : capabilityDerived + ? [] + : curated?.modalities?.input ?? prior?.modalities?.input ?? []; + const outputModalities = Array.isArray(live.outputModalities) ? live.outputModalities - : prior?.modalities?.output ?? []; + : capabilityDerived + ? [] + : curated?.modalities?.output ?? prior?.modalities?.output ?? []; const capabilityProfile = capabilityRoleProfile({ inputModalities, outputModalities, toolCall: live.toolCall, }); + const curatedProfile = curated + ? { + access: curated.access.filter((mode) => capabilityProfile.access.includes(mode)), + canOrchestrate: + curated.canOrchestrate === true && capabilityProfile.canOrchestrate === true, + roles: Object.fromEntries( + Object.entries(curated.roles).filter(([role]) => + Object.hasOwn(capabilityProfile.roles, role), + ), + ), + } + : null; return { ...(prior ?? {}), id, - label: prior?.label ?? live.name, - status: prior?.status ?? "provisional", - provisional: prior?.provisional ?? true, - enabledByDefault: prior?.enabledByDefault ?? false, + label: curated?.label ?? prior?.label ?? live.name, + status: curated?.status ?? prior?.status ?? "provisional", + provisional: curated?.provisional ?? prior?.provisional ?? true, + enabledByDefault: curated?.enabledByDefault ?? prior?.enabledByDefault ?? false, available: live.status === "active", discovered: true, runtimeVerified: false, provider: live.provider ?? id.split("/", 1)[0], - profileSource: prior?.profileSource ?? "capability", + profileSource: curated ? "curated" : prior?.profileSource ?? "capability", contextWindowTokens: Number.isInteger(live.context) && live.context > 0 ? live.context @@ -302,9 +346,14 @@ export function mergeDiscoveredCatalog(baseCatalog, liveModels, { }, modalities: { input: inputModalities, output: outputModalities }, toolCall: live.toolCall === true, - access: prior?.access ?? capabilityProfile.access, - canOrchestrate: prior?.canOrchestrate ?? capabilityProfile.canOrchestrate, - roles: prior?.roles ?? capabilityProfile.roles, + access: curatedProfile?.access ?? + (capabilityDerived ? capabilityProfile.access : prior?.access ?? capabilityProfile.access), + canOrchestrate: curatedProfile?.canOrchestrate ?? + (capabilityDerived + ? capabilityProfile.canOrchestrate + : prior?.canOrchestrate ?? capabilityProfile.canOrchestrate), + roles: curatedProfile?.roles ?? + (capabilityDerived ? capabilityProfile.roles : prior?.roles ?? capabilityProfile.roles), }; }); diff --git a/src/server/service.js b/src/server/service.js index 7b6d93a..67d655e 100644 --- a/src/server/service.js +++ b/src/server/service.js @@ -254,6 +254,7 @@ export class ControlService { let merged = mergeDiscoveredCatalog( this.hasLiveSnapshot ? previousCatalog : this.baseCatalog, result.models, + { curatedCatalog: this.baseCatalog }, ); if (result.complete === false && this.hasLiveSnapshot) { const discoveredIds = new Set(result.models.map((model) => model.id)); diff --git a/src/ui/components/RoleAssignments.tsx b/src/ui/components/RoleAssignments.tsx index 869a7cf..3a57bf0 100644 --- a/src/ui/components/RoleAssignments.tsx +++ b/src/ui/components/RoleAssignments.tsx @@ -1,10 +1,10 @@ -import type { CatalogModel, RoleAssignments as RoleMap, RouterSettings } from "../types"; +import type { CatalogModel, RouterSettings } from "../types"; import { - isModelCostAllowed, - isModelAvailable, + isRoleModelAssignable, + isRoleModelEligible, modelDisplayName, ROLE_DEFINITIONS, - roleModelCompatible, + selectRoleModel, setCostMode, } from "../model-control.js"; import { Icon, Panel } from "./Primitives"; @@ -12,7 +12,7 @@ import { Icon, Panel } from "./Primitives"; function selectHint(role: string) { if (role === "vision-worker") return "Only image-input models are eligible."; if (role === "orchestrator") return "Only models reporting orchestration support are eligible."; - return "Choose from enabled, available models allowed by the cost policy."; + return "Choose any compatible, available model allowed by the cost policy."; } export function RoleAssignments({ @@ -27,8 +27,7 @@ export function RoleAssignments({ const enabledModels = catalog.filter((model) => settings.modelControls[model.id]?.enabled); const updateRole = (role: string, modelId: string) => { - const roleAssignments: RoleMap = { ...settings.roleAssignments, [role]: modelId }; - onChange({ ...settings, roleAssignments }); + onChange(selectRoleModel(settings, catalog, role, modelId) as RouterSettings); }; return ( @@ -76,17 +75,18 @@ export function RoleAssignments({ > {catalog.map((model) => { - const eligible = - isModelCostAllowed(model, settings) && - isModelAvailable(model) && - settings.modelControls[model.id]?.enabled && - roleModelCompatible(model, role.key); - const selected = settings.roleAssignments[role.key] === model.id; - return ; + const assignable = isRoleModelAssignable(model, settings, role.key); + const eligible = isRoleModelEligible(model, settings, role.key); + const suffix = !assignable + ? " — not eligible" + : eligible + ? "" + : " — enable on selection"; + return ; })} - {selectHint(role.key)} + {selectHint(role.key)} Selecting a disabled model explicitly enables it for routing. ))} @@ -114,7 +114,7 @@ export function RoleAssignments({
Enable at least one available model before saving a role assignment.
: null} + {enabledModels.length === 0 ?No models are enabled yet. Select a compatible model above or enable one in Models.
: null} ); } diff --git a/src/ui/model-control.js b/src/ui/model-control.js index 6eed745..eeb81c7 100644 --- a/src/ui/model-control.js +++ b/src/ui/model-control.js @@ -70,6 +70,19 @@ export function modelInputModalities(model) { return []; } +function modelOutputModalities(model) { + if ( + model?.modalities && + typeof model.modalities === "object" && + !Array.isArray(model.modalities) && + Array.isArray(model.modalities.output) + ) { + return model.modalities.output.map(String); + } + if (Array.isArray(model?.outputModalities)) return model.outputModalities.map(String); + return []; +} + export function modelRoles(model) { if (Array.isArray(model?.roles)) return model.roles.map(String); if (model?.roles && typeof model.roles === "object") { @@ -308,11 +321,61 @@ export function catalogSummary(catalog, settings) { export function roleModelCompatible(model, role) { if (!modelRoles(model).includes(role)) return false; if (role === "orchestrator" && model?.canOrchestrate !== true) return false; + if ( + (role === "orchestrator" || role === "code-worker" || role === "vision-worker") && + model?.toolCall === false + ) { + return false; + } + if (role === "vision-worker" && model?.toolCall !== true) return false; const requiredAccess = role === "orchestrator" || role === "code-worker" ? "write" : "read"; if (!modelAccess(model).includes(requiredAccess)) return false; const modalities = modelInputModalities(model).map((item) => item.toLowerCase()); if (!modalities.includes("text")) return false; - return role !== "vision-worker" || modalities.includes("image"); + if (role === "vision-worker" && !modalities.includes("image")) return false; + return modelOutputModalities(model).map((item) => item.toLowerCase()).includes("text"); +} + +export function isRoleModelAssignable(model, settings, role) { + const control = settings?.modelControls?.[model?.id]; + return ( + isModelCostAllowed(model, settings) && + isModelAvailable(model) && + control?.available === true && + roleModelCompatible(model, role) + ); +} + +export function isRoleModelEligible(model, settings, role) { + return ( + isRoleModelAssignable(model, settings, role) && + settings?.modelControls?.[model?.id]?.enabled === true + ); +} + +export function selectRoleModel(settings, catalog, role, modelId) { + if (!ROLE_DEFINITIONS.some((definition) => definition.key === role)) return settings; + if (modelId === "auto") { + return { + ...settings, + roleAssignments: { ...settings.roleAssignments, [role]: "auto" }, + }; + } + + const model = catalog.find((entry) => entry.id === modelId); + if (!model || !isRoleModelAssignable(model, settings, role)) return settings; + + return { + ...settings, + modelControls: { + ...settings.modelControls, + [modelId]: { + ...settings.modelControls[modelId], + enabled: true, + }, + }, + roleAssignments: { ...settings.roleAssignments, [role]: modelId }, + }; } export function configText(response) { diff --git a/test/core/catalog.test.js b/test/core/catalog.test.js index 1fedbb2..bdb0fba 100644 --- a/test/core/catalog.test.js +++ b/test/core/catalog.test.js @@ -298,3 +298,61 @@ test("unknown pricing and missing tool calls fail eligibility closed", () => { false, ); }); + +test("known-paid role eligibility is provider-agnostic and still requires explicit enablement", () => { + const providerIds = [ + "xai/grok-example", + "openrouter/vendor-example", + "custom/private-example", + ]; + const paidModels = providerIds.map((id) => derivedModel(id, { + label: id, + enabledByDefault: false, + free: { + verified: true, + inputUsdPerMillion: 1, + outputUsdPerMillion: 4, + verifiedAt: "2026-09-01", + }, + modalities: { input: ["text", "image"], output: ["text"] }, + toolCall: true, + access: ["read", "write"], + canOrchestrate: true, + roles: { + orchestrator: 25, + "code-worker": 25, + "vision-worker": 25, + reviewer: 25, + }, + })); + const catalog = validateCatalog({ + ...loadModelCatalog(), + models: [...loadModelCatalog().models, ...paidModels], + }); + const settings = createDefaultSettings(catalog); + settings.costPolicy = "known-cost"; + settings.costPreference = "paid-first"; + + const codeBeforeOptIn = eligibleModelsForRole({ + catalog, + settings, + role: "code-worker", + modalities: ["text"], + access: "write", + }); + assert.ok(providerIds.every((id) => !codeBeforeOptIn.some((model) => model.id === id))); + + for (const id of providerIds) settings.modelControls[id].enabled = true; + for (const [role, modalities, access] of [ + ["orchestrator", ["text"], "write"], + ["code-worker", ["text"], "write"], + ["vision-worker", ["text", "image"], "read"], + ["reviewer", ["text"], "read"], + ]) { + const eligible = eligibleModelsForRole({ catalog, settings, role, modalities, access }); + assert.deepEqual( + eligible.filter((model) => providerIds.includes(model.id)).map((model) => model.id), + [...providerIds].sort(), + ); + } +}); diff --git a/test/core/settings.test.js b/test/core/settings.test.js index 04ad812..bea1e8a 100644 --- a/test/core/settings.test.js +++ b/test/core/settings.test.js @@ -171,6 +171,47 @@ test("known-cost settings permit an explicit paid model from the active catalog" assert.equal(settings.costPolicy, "known-cost"); }); +test("explicit paid and free assignments reject a zero role score", () => { + const catalog = structuredClone(loadModelCatalog()); + const free = structuredClone(catalog.models[1]); + free.id = "provider/zero-score-free"; + free.label = "Zero Score Free"; + free.roles = { "code-worker": 0 }; + const paid = structuredClone(free); + paid.id = "provider/zero-score-paid"; + paid.label = "Zero Score Paid"; + paid.free.inputUsdPerMillion = 0.25; + paid.free.outputUsdPerMillion = 1; + catalog.models.push(free, paid); + + const base = createDefaultSettings(catalog); + for (const [model, costPreference, costPolicy] of [ + [free, "free-first", "free-only"], + [paid, "paid-first", "known-cost"], + ]) { + assert.throws( + () => + validateSettings( + { + ...base, + costPreference, + costPolicy, + roleAssignments: { + ...base.roleAssignments, + "code-worker": model.id, + }, + modelControls: { + ...base.modelControls, + [model.id]: { enabled: true, available: true }, + }, + }, + catalog, + ), + (error) => error.code === "INVALID_ROLE_ASSIGNMENT", + ); + } +}); + test("default settings degrade unavailable explicit defaults to automatic selection", () => { const catalog = loadModelCatalog({ liveAvailability: { diff --git a/test/server/opencode-cli.test.js b/test/server/opencode-cli.test.js index d1c32d0..08d54c4 100644 --- a/test/server/opencode-cli.test.js +++ b/test/server/opencode-cli.test.js @@ -1,6 +1,13 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { + createDefaultSettings, + loadModelCatalog, + validateCatalog, + validateSettings, +} from "../../src/core/index.js"; +import { buildOpenCodeConfig } from "../../src/opencode/index.js"; import { discoverOpenCode, parseOpenCodeModelList, @@ -213,6 +220,182 @@ test("catalog merge trusts curated free evidence but blocks arbitrary reported z assert.equal(merged.models.find((model) => model.id === "custom/reported-zero").free.verified, false); }); +test("catalog refresh recomputes dynamic capability roles in both directions", () => { + const dynamic = { + id: "xai/grok-example", + label: "Grok Example", + status: "provisional", + provisional: true, + enabledByDefault: false, + available: true, + discovered: true, + runtimeVerified: false, + provider: "xai", + profileSource: "capability", + contextWindowTokens: 100000, + free: { + verified: true, + inputUsdPerMillion: 1, + outputUsdPerMillion: 4, + verifiedAt: "2026-08-31", + }, + modalities: { input: ["text"], output: ["text"] }, + toolCall: false, + access: ["read"], + canOrchestrate: false, + roles: { reviewer: 25 }, + }; + const base = { + schemaVersion: 1, + snapshotDate: "2026-08-31", + models: [dynamic], + }; + const live = { + id: dynamic.id, + provider: "xai", + name: dynamic.label, + status: "active", + inputCost: 1, + outputCost: 4, + inputCostVerified: true, + outputCostVerified: true, + context: 200000, + toolCall: true, + inputModalities: ["text", "image", "pdf"], + outputModalities: ["text"], + }; + + const upgraded = mergeDiscoveredCatalog(base, [live], { snapshotDate: "2026-09-01" }); + const upgradedModel = upgraded.models[0]; + assert.deepEqual(upgradedModel.access, ["read", "write"]); + assert.equal(upgradedModel.canOrchestrate, true); + assert.deepEqual(upgradedModel.roles, { + reviewer: 25, + orchestrator: 25, + "code-worker": 25, + "vision-worker": 25, + }); + + const downgraded = mergeDiscoveredCatalog(upgraded, [{ ...live, toolCall: false }], { + snapshotDate: "2026-09-01", + }); + const downgradedModel = downgraded.models[0]; + assert.deepEqual(downgradedModel.access, ["read"]); + assert.equal(downgradedModel.canOrchestrate, false); + assert.deepEqual(downgradedModel.roles, { reviewer: 25 }); + + const removed = mergeDiscoveredCatalog(upgraded, [{ + ...live, + inputModalities: [], + outputModalities: [], + }], { snapshotDate: "2026-09-01" }); + const removedModel = removed.models[0]; + assert.deepEqual(removedModel.modalities, { input: [], output: [] }); + assert.equal(removedModel.canOrchestrate, false); + assert.deepEqual(removedModel.roles, {}); +}); + +test("catalog refresh restores bundled profiles from legacy capability-marked snapshots", () => { + const curatedCatalog = loadModelCatalog(); + const live = [ + { + id: "opencode/big-pickle", + provider: "opencode", + name: "Big Pickle", + status: "active", + inputCost: 0, + outputCost: 0, + inputCostVerified: true, + outputCostVerified: true, + context: 200000, + toolCall: true, + inputModalities: ["text"], + outputModalities: ["text"], + }, + { + id: "opencode/mimo-v2.5-free", + provider: "opencode", + name: "MiMo V2.5 Free", + status: "active", + inputCost: 0, + outputCost: 0, + inputCostVerified: true, + outputCostVerified: true, + context: 200000, + toolCall: true, + inputModalities: ["text", "image", "audio", "video"], + outputModalities: ["text"], + }, + ]; + + const legacySnapshot = mergeDiscoveredCatalog(curatedCatalog, live, { + snapshotDate: "2026-08-31", + }); + assert.equal( + legacySnapshot.models.find((model) => model.id === "opencode/big-pickle").profileSource, + "capability", + ); + + const restored = validateCatalog(mergeDiscoveredCatalog(legacySnapshot, live, { + snapshotDate: "2026-09-01", + curatedCatalog, + })); + const refreshed = validateCatalog(mergeDiscoveredCatalog(restored, live, { + snapshotDate: "2026-09-01", + curatedCatalog, + })); + + for (const id of ["opencode/big-pickle", "opencode/mimo-v2.5-free"]) { + const authored = curatedCatalog.models.find((model) => model.id === id); + const current = refreshed.models.find((model) => model.id === id); + assert.equal(current.profileSource, "curated"); + assert.deepEqual(current.access, authored.access); + assert.equal(current.canOrchestrate, authored.canOrchestrate); + assert.deepEqual(current.roles, authored.roles); + } +}); + +test("parsed known-paid provider metadata reaches validated settings and generated config", () => { + const parsed = parseOpenCodeVerboseCatalog(`opencode/big-pickle +{ + "name": "Big Pickle", + "status": "active", + "cost": {"input": 0, "output": 0}, + "limit": {"context": 200000}, + "capabilities": {"toolcall": true, "input": {"text": true}, "output": {"text": true}} +} +xai/grok-4.6 +{ + "name": "Grok 4.6", + "status": "active", + "cost": {"input": 2, "output": 6}, + "limit": {"context": 200000}, + "capabilities": {"toolcall": true, "input": {"text": true, "image": true, "pdf": true}, "output": {"text": true}} +}`); + const curatedCatalog = loadModelCatalog(); + const catalog = validateCatalog(mergeDiscoveredCatalog(curatedCatalog, parsed, { + snapshotDate: "2026-09-01", + curatedCatalog, + })); + const grok = catalog.models.find((model) => model.id === "xai/grok-4.6"); + assert.deepEqual(grok.free, { + verified: true, + inputUsdPerMillion: 2, + outputUsdPerMillion: 6, + verifiedAt: "2026-09-01", + }); + + const draft = createDefaultSettings(catalog); + draft.costPreference = "paid-first"; + draft.costPolicy = "known-cost"; + draft.modelControls[grok.id] = { enabled: true, available: true }; + draft.roleAssignments["code-worker"] = grok.id; + const settings = validateSettings(draft, catalog); + const config = buildOpenCodeConfig({ catalog, settings }); + + assert.equal(config.agent["omc-code-worker"].model, "xai/grok-4.6"); +}); + test("discovery reports a stable, secret-free failure", async () => { const execFile = (_file, _args, _options, callback) => { const error = Object.assign(new Error("spawn failed with secret=abc"), { code: "ENOENT" }); diff --git a/test/ui/model-control.test.js b/test/ui/model-control.test.js index a54ca5c..6ff75e3 100644 --- a/test/ui/model-control.test.js +++ b/test/ui/model-control.test.js @@ -6,6 +6,8 @@ import { catalogSummary, evidenceMeta, isModelFree, + isRoleModelAssignable, + isRoleModelEligible, modelCostClass, modelAccess, modelInputModalities, @@ -16,6 +18,7 @@ import { settingsEqual, settingsForApi, setCostMode, + selectRoleModel, toggleEnabledModel, } from "../../src/ui/model-control.js"; @@ -50,6 +53,7 @@ const catalog = liveIds.map((id) => ({ output: ["text"], }, access: id === "opencode/nemotron-3-ultra-free" ? ["read"] : ["read", "write"], + toolCall: id === "opencode/mimo-v2.5-free" ? true : undefined, canOrchestrate: id === "opencode/big-pickle", roles: id === "opencode/big-pickle" @@ -195,6 +199,74 @@ test("Free and Paid modes map to explicit cost policy and disable paid routes sa assert.equal(free.roleAssignments["code-worker"], "auto"); }); +test("explicit role selection opts a compatible known-paid provider model into routing", () => { + const grok = { + ...catalog[1], + id: "xai/grok-4.6", + label: "Grok 4.6", + enabledByDefault: false, + free: { verified: true, inputUsdPerMillion: 2, outputUsdPerMillion: 6 }, + toolCall: true, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + access: ["read", "write"], + canOrchestrate: true, + roles: { + orchestrator: 25, + "code-worker": 25, + "vision-worker": 25, + reviewer: 25, + }, + }; + const allModels = [...catalog, grok]; + const normalized = normalizeState({ catalog: allModels, settings: {} }); + const paid = setCostMode(normalized.settings, allModels, "paid"); + + assert.equal(paid.modelControls[grok.id].enabled, false); + assert.equal(isRoleModelAssignable(grok, paid, "code-worker"), true); + assert.equal(isRoleModelEligible(grok, paid, "code-worker"), false); + + const selected = selectRoleModel(paid, allModels, "code-worker", grok.id); + assert.equal(selected.roleAssignments["code-worker"], grok.id); + assert.equal(selected.modelControls[grok.id].enabled, true); + assert.equal(isRoleModelEligible(grok, selected, "code-worker"), true); +}); + +test("role selection keeps cost, availability, capability, and automatic opt-in gates closed", () => { + const paid = { + ...catalog[1], + id: "provider/paid-code", + enabledByDefault: false, + free: { verified: true, inputUsdPerMillion: 1, outputUsdPerMillion: 4 }, + toolCall: true, + modalities: { input: ["text"], output: ["text"] }, + access: ["read", "write"], + roles: { "code-worker": 25, reviewer: 25 }, + }; + const unknown = { + ...paid, + id: "provider/unknown-code", + free: { verified: false, inputUsdPerMillion: null, outputUsdPerMillion: null }, + }; + const unavailable = { ...paid, id: "provider/unavailable-code", available: false }; + const incompatible = { + ...paid, + id: "provider/text-generator", + modalities: { input: ["text"], output: ["image"] }, + }; + const allModels = [...catalog, paid, unknown, unavailable, incompatible]; + const freeSettings = normalizeState({ catalog: allModels, settings: {} }).settings; + const paidSettings = setCostMode(freeSettings, allModels, "paid"); + + assert.equal(isRoleModelAssignable(paid, freeSettings, "code-worker"), false); + assert.equal(isRoleModelAssignable(unknown, paidSettings, "code-worker"), false); + assert.equal(isRoleModelAssignable(unavailable, paidSettings, "code-worker"), false); + assert.equal(isRoleModelAssignable(incompatible, paidSettings, "code-worker"), false); + + const automatic = selectRoleModel(paidSettings, allModels, "code-worker", "auto"); + assert.equal(automatic.roleAssignments["code-worker"], "auto"); + assert.equal(automatic.modelControls[paid.id].enabled, false); +}); + test("catalog refresh notices require an OpenCode restart when the connection changed", () => { assert.equal( catalogRefreshNotice({ connectionChanged: false }), diff --git a/test/ui/ui-contract.test.js b/test/ui/ui-contract.test.js index 0655263..3899393 100644 --- a/test/ui/ui-contract.test.js +++ b/test/ui/ui-contract.test.js @@ -78,6 +78,9 @@ test("core interactions expose semantic labels and unsaved-state protection", as assert.match(roles, />Free<\/button>/); assert.match(roles, />Paid<\/button>/); assert.match(roles, /Provider charges may apply/); + assert.match(roles, /enable on selection/); + assert.match(roles, /selectRoleModel/); + assert.match(roles, /isRoleModelAssignable/); assert.match(roles, /Review repair passes/); assert.match(roles, /does not switch to another model/); assert.match(benchmarks, /Manual runtime access check/);