feat(models): rolePins on GET /api/models — the background roles' resolved pins
Settings interlude (owner request, 2026-09-19): the route now also returns
rolePins: Record<BackgroundModelRole, {model: {provider, id},
thinkingLevel}> — 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: <actual pinned model>' 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).
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
* components never import from the server band).
|
* components never import from the server band).
|
||||||
*/
|
*/
|
||||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
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
|
/** 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
|
* pi-ai's catalog. Costs are USD per 1M tokens (the same catalog, the same
|
||||||
@@ -32,8 +33,20 @@ export interface CatalogDefaults {
|
|||||||
thinkingLevel: ThinkingLevel;
|
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: <this>" caption from it. */
|
||||||
|
export type RolePins = Record<
|
||||||
|
BackgroundModelRole,
|
||||||
|
{ model: { provider: string; id: string }; thinkingLevel?: ThinkingLevel }
|
||||||
|
>;
|
||||||
|
|
||||||
/** GET /api/models response. */
|
/** GET /api/models response. */
|
||||||
export interface ModelCatalogResponse {
|
export interface ModelCatalogResponse {
|
||||||
models: ModelCatalogEntry[];
|
models: ModelCatalogEntry[];
|
||||||
defaults: CatalogDefaults;
|
defaults: CatalogDefaults;
|
||||||
|
/** The six background roles' resolved pins (same resolution the agent
|
||||||
|
* factories use — src/server/stores.ts effectiveSettings). */
|
||||||
|
rolePins: RolePins;
|
||||||
}
|
}
|
||||||
@@ -19,12 +19,16 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|||||||
import { MODELS } from "../../config.ts";
|
import { MODELS } from "../../config.ts";
|
||||||
import { piModels } from "../agent/models.ts";
|
import { piModels } from "../agent/models.ts";
|
||||||
import { settingsStore } from "../store/settings.ts";
|
import { settingsStore } from "../store/settings.ts";
|
||||||
|
import { effectiveSettings } from "./stores.ts";
|
||||||
import type { ChatSettings } from "../core/contracts/chat.ts";
|
import type { ChatSettings } from "../core/contracts/chat.ts";
|
||||||
import type {
|
import type {
|
||||||
CatalogDefaults,
|
CatalogDefaults,
|
||||||
ModelCatalogEntry,
|
ModelCatalogEntry,
|
||||||
ModelCatalogResponse,
|
ModelCatalogResponse,
|
||||||
|
RolePins,
|
||||||
} from "../core/contracts/models.ts";
|
} 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). */
|
/** The picker's client-safe model entry (contract: core/contracts/models.ts). */
|
||||||
export type ModelDto = ModelCatalogEntry;
|
export type ModelDto = ModelCatalogEntry;
|
||||||
@@ -48,6 +52,31 @@ export function catalogDefaults(): CatalogDefaults {
|
|||||||
return catalogDefaultsFor(settingsStore.readSettings()?.chat);
|
return catalogDefaultsFor(settingsStore.readSettings()?.chat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Project the resolved per-role specs to the client DTO (pure — the
|
||||||
|
* settings UI's "Default: <actual pinned model>" 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<BackgroundModelRole, EffectiveModelSpec>): 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. */
|
/** Project one catalog model to the client DTO. */
|
||||||
export function toModelDto(model: Model<Api>): ModelDto {
|
export function toModelDto(model: Model<Api>): ModelDto {
|
||||||
return {
|
return {
|
||||||
@@ -70,14 +99,15 @@ export function toModelDto(model: Model<Api>): ModelDto {
|
|||||||
|
|
||||||
/** The full picker payload: every model of every configured provider
|
/** The full picker payload: every model of every configured provider
|
||||||
* (sorted by provider then id — stable, diff-friendly) + the config
|
* (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 {
|
export function modelCatalog(): ModelCatalogResponse {
|
||||||
const models = piModels()
|
const models = piModels()
|
||||||
.getProviders()
|
.getProviders()
|
||||||
.flatMap((p) => piModels().getModels(p.id))
|
.flatMap((p) => piModels().getModels(p.id))
|
||||||
.map(toModelDto)
|
.map(toModelDto)
|
||||||
.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
|
.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. */
|
/** One validated outcome for a settings write. */
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const BACKGROUND_MODEL_PINS: ModelRoleSpecs = {
|
|||||||
auditor: MODELS.auditor,
|
auditor: MODELS.auditor,
|
||||||
labeler: MODELS.labeler,
|
labeler: MODELS.labeler,
|
||||||
};
|
};
|
||||||
|
export { BACKGROUND_MODEL_PINS };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The resolved view of the settings record (core/settings-resolution.ts):
|
* The resolved view of the settings record (core/settings-resolution.ts):
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
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 { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
||||||
import { MODELS } from "../config.ts";
|
import { MODELS } from "../config.ts";
|
||||||
import { piEngineFor } from "../src/agent/models.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
|
// The model-control wave (2026-09-12): pi-ai owns the registry — these pins
|
||||||
// hold our projection + validation honest against the shipped catalog, and
|
// 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", () => {
|
describe("the settings validation gate", () => {
|
||||||
it("accepts a supported level and rejects an unsupported one with the list", () => {
|
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" });
|
const ok = validateChatSettings({ model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "max" });
|
||||||
|
|||||||
Reference in New Issue
Block a user