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:
amosblomqvist
2026-09-19 22:28:11 +03:00
parent cbaf6df454
commit a828d9b6d9
4 changed files with 82 additions and 3 deletions
+13
View File
@@ -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: <this>" 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;
}
+32 -2
View File
@@ -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: <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. */
export function toModelDto(model: Model<Api>): ModelDto {
return {
@@ -70,14 +99,15 @@ export function toModelDto(model: Model<Api>): 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. */
+1
View File
@@ -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):