From cc1e3193f4a8391098a708068fddc52149792957 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 19 Sep 2026 22:28:11 +0300 Subject: [PATCH] =?UTF-8?q?feat(models):=20rolePins=20on=20GET=20/api/mode?= =?UTF-8?q?ls=20=E2=80=94=20the=20background=20roles'=20resolved=20pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings interlude (owner request, 2026-09-19): the route now also returns rolePins: Record — the six background roles' RESOLVED models (the user's agent-models override falling back to the config.ts MODELS pin), so the settings UI shows each role row's 'Default: ' instead of a generic config caption. Same resolution the agent factories use (effectiveSettings), so the caption can never drift from what runs. Pure projection (rolePinsFor) + the fs wrapper (catalogRolePins); pins tested against config defaults and model/thinkingLevel-only overrides. BACKGROUND_MODEL_PINS exported from stores.ts for the test (was local). --- src/core/contracts/models.ts | 13 +++++++++++++ src/server/model-catalog.ts | 34 +++++++++++++++++++++++++++++++-- src/server/stores.ts | 1 + tests/model-catalog.test.ts | 37 +++++++++++++++++++++++++++++++++++- 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/core/contracts/models.ts b/src/core/contracts/models.ts index 60906a6..22c2287 100644 --- a/src/core/contracts/models.ts +++ b/src/core/contracts/models.ts @@ -6,6 +6,7 @@ * components never import from the server band). */ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import type { BackgroundModelRole } from "../settings.ts"; /** One picker row: everything the UI shows about a model, projected from * pi-ai's catalog. Costs are USD per 1M tokens (the same catalog, the same @@ -32,8 +33,20 @@ export interface CatalogDefaults { thinkingLevel: ThinkingLevel; } +/** One background role's RESOLVED pin (settings interlude, 2026-09-19): + * the user's agent-models override falling back to the config.ts MODELS + * pin — what that role actually runs today. The settings UI shows each + * agent-role row's "Default: " caption from it. */ +export type RolePins = Record< + BackgroundModelRole, + { model: { provider: string; id: string }; thinkingLevel?: ThinkingLevel } +>; + /** GET /api/models response. */ export interface ModelCatalogResponse { models: ModelCatalogEntry[]; defaults: CatalogDefaults; + /** The six background roles' resolved pins (same resolution the agent + * factories use — src/server/stores.ts effectiveSettings). */ + rolePins: RolePins; } \ No newline at end of file diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index a09084b..76cb27f 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -19,12 +19,16 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { MODELS } from "../../config.ts"; import { piModels } from "../agent/models.ts"; import { settingsStore } from "../store/settings.ts"; +import { effectiveSettings } from "./stores.ts"; import type { ChatSettings } from "../core/contracts/chat.ts"; import type { CatalogDefaults, ModelCatalogEntry, ModelCatalogResponse, + RolePins, } from "../core/contracts/models.ts"; +import type { EffectiveModelSpec } from "../core/settings-resolution.ts"; +import { BACKGROUND_MODEL_ROLES, type BackgroundModelRole } from "../core/settings.ts"; /** The picker's client-safe model entry (contract: core/contracts/models.ts). */ export type ModelDto = ModelCatalogEntry; @@ -48,6 +52,31 @@ export function catalogDefaults(): CatalogDefaults { return catalogDefaultsFor(settingsStore.readSettings()?.chat); } +/** Project the resolved per-role specs to the client DTO (pure — the + * settings UI's "Default: " caption reads this). + * thinkingLevel is always concrete in the resolved view; the contract + * keeps it optional so the UI tolerates a future role without one. */ +export function rolePinsFor(models: Record): RolePins { + const pins = {} as RolePins; + for (const role of BACKGROUND_MODEL_ROLES) { + const spec = models[role]; + pins[role] = { + model: { provider: spec.provider, id: spec.id }, + thinkingLevel: spec.thinkingLevel, + }; + } + return pins; +} + +/** The six background roles' RESOLVED pins (settings interlude, + * 2026-09-19): the user's agent-models override falling back to the + * config.ts MODELS pin — the same resolution the agent factories use + * (src/server/stores.ts effectiveSettings), so the caption can never + * drift from what actually runs. */ +export function catalogRolePins(): RolePins { + return rolePinsFor(effectiveSettings().agent.models); +} + /** Project one catalog model to the client DTO. */ export function toModelDto(model: Model): ModelDto { return { @@ -70,14 +99,15 @@ export function toModelDto(model: Model): ModelDto { /** The full picker payload: every model of every configured provider * (sorted by provider then id — stable, diff-friendly) + the config - * defaults a settings-less chat runs. */ + * defaults a settings-less chat runs + the background roles' resolved + * pins (the settings UI's per-role default captions). */ export function modelCatalog(): ModelCatalogResponse { const models = piModels() .getProviders() .flatMap((p) => piModels().getModels(p.id)) .map(toModelDto) .sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider))); - return { models, defaults: catalogDefaults() }; + return { models, defaults: catalogDefaults(), rolePins: catalogRolePins() }; } /** One validated outcome for a settings write. */ diff --git a/src/server/stores.ts b/src/server/stores.ts index f584b11..5335e8a 100644 --- a/src/server/stores.ts +++ b/src/server/stores.ts @@ -35,6 +35,7 @@ const BACKGROUND_MODEL_PINS: ModelRoleSpecs = { auditor: MODELS.auditor, labeler: MODELS.labeler, }; +export { BACKGROUND_MODEL_PINS }; /** * The resolved view of the settings record (core/settings-resolution.ts): diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index f30788b..8114bb9 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -1,9 +1,12 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { modelCatalog, validateChatSettings, catalogDefaultsFor, toModelDto } from "../src/server/model-catalog.ts"; +import { modelCatalog, validateChatSettings, catalogDefaultsFor, toModelDto, rolePinsFor, catalogRolePins } from "../src/server/model-catalog.ts"; import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai"; import { MODELS } from "../config.ts"; import { piEngineFor } from "../src/agent/models.ts"; +import { BACKGROUND_MODEL_ROLES, type BackgroundModelRole } from "../src/core/settings.ts"; +import { resolveSettings } from "../src/core/settings-resolution.ts"; +import { BACKGROUND_MODEL_PINS } from "../src/server/stores.ts"; // The model-control wave (2026-09-12): pi-ai owns the registry — these pins // hold our projection + validation honest against the shipped catalog, and @@ -62,6 +65,38 @@ describe("the model catalog projection", () => { }); }); +describe("the background roles' resolved pins (rolePins)", () => { + it("no overrides → every role pins its config MODELS spec, nested DTO shape, all six roles", () => { + const pins = rolePinsFor(resolveSettings(null, BACKGROUND_MODEL_PINS).agent.models); + assert.deepEqual(Object.keys(pins), [...BACKGROUND_MODEL_ROLES]); + for (const role of BACKGROUND_MODEL_ROLES) { + assert.deepEqual(pins[role].model, { provider: "openrouter", id: MODELS[role].model }); + assert.equal(pins[role].thinkingLevel, MODELS[role].effort); + } + // the vision pin rides along like any other + assert.equal(pins.visualMaker.model.id, "qwen/qwen3.8-flash"); + }); + + it("a model override wins for that role only; others stay pinned", () => { + const record = { + version: 1, + agent: { models: { researcher: { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } } } }, + }; + const pins = rolePinsFor(resolveSettings(record, BACKGROUND_MODEL_PINS).agent.models); + assert.equal(pins.researcher.model.id, "qwen/qwen3.8-flash"); + assert.equal(pins.researcher.thinkingLevel, MODELS.researcher.effort, "level stays the pin unless overridden too"); + assert.equal(pins.visualMaker.model.id, MODELS.visualMaker.model); + assert.equal(pins.labeler.model.id, MODELS.labeler.model); + }); + + it("a thinkingLevel-only override keeps the model, changes the level", () => { + const record = { version: 1, agent: { models: { extractor: { thinkingLevel: "high" as const } } } }; + const pins = rolePinsFor(resolveSettings(record, BACKGROUND_MODEL_PINS).agent.models); + assert.equal(pins.extractor.model.id, MODELS.extractor.model); + assert.equal(pins.extractor.thinkingLevel, "high"); + }); +}); + describe("the settings validation gate", () => { it("accepts a supported level and rejects an unsupported one with the list", () => { const ok = validateChatSettings({ model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "max" });