/** * Model routing configuration for workflow subagents. * * This module is the single source of truth for the on-disk * `~/.pi/workflows/model-tiers.json` profile. It carries TWO layers: * * 1. **Semantic roles** (issue #142): `worker`, `conductor`, `advisor`, * `security`. A role expresses WHAT JOB an agent is doing; the machine-local * config decides the concrete provider/model. Each role has a `default` * model and a small set of named `routes` (e.g. `escalation`, * `long-context`, `independent`) that are explicit, journaled specialist * selections — never an automatic fallback ladder. * * 2. **Deprecated size tiers** (small/medium/big): kept ONLY as migration * inputs for existing saved workflows and journals. They map * small→worker, medium→conductor, big→advisor (security has no size-tier * ancestor). New authoring guidance uses roles only. * * The on-disk file is read non-destructively: a profile that still uses only * the legacy `tiers` block keeps working, with roles derived from it via the * deprecation alias. A profile that adds a `roles` block wins. Migration never * destroys machine-local config. * * Augments the phase-pattern routing in model-routing.ts (phase routing maps * workflow phases → models via the script's meta); roles give scripts a * coarse, portable, responsibility-oriented knob independent of any concrete * provider/model id. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { listAvailableModelSpecs } from "./agent.js"; import { MODEL_TIERS_FILE } from "./config.js"; // --------------------------------------------------------------------------- // Roles // --------------------------------------------------------------------------- /** * Semantic routing role. A role expresses WHAT JOB an agent is doing * (worker / conductor / advisor / security), not how big or expensive the * model is. The machine-local config decides the concrete provider/model. */ export type ModelRole = "worker" | "conductor" | "advisor" | "security"; /** Canonical ordered list of roles (used for display + validation). */ export const MODEL_ROLES: readonly ModelRole[] = ["worker", "conductor", "advisor", "security"]; /** * Deprecation alias map: legacy size tier → semantic role. `security` has no * size-tier ancestor. Used only to keep existing saved workflows/journals * working; new authoring guidance uses roles. */ export const TIER_TO_ROLE: Readonly> = { small: "worker", medium: "conductor", big: "advisor", }; /** * A single role definition: a default model plus named, explicit specialist * routes. Routes are NEVER an automatic fallback ladder — each named route is * a deliberately journaled specialist selection carrying the evidence that * justified it (failed host checks, a verifier rejection, long-context fit, * model-family diversity, etc.). */ export interface RoleRoute { /** Concrete model spec for this named route (e.g. "openai-codex/gpt-5.6-luna"). */ model: string; /** * Optional operator note carried into the authoring prompt to explain when * this route should be chosen (the evidence/trigger rule). */ note?: string; } export interface RoleDefinition { /** Default model spec for this role. */ default: string; /** Named, explicit specialist routes (e.g. escalation, long-context, independent). */ routes?: Record; } export type RoleMap = Record; // --------------------------------------------------------------------------- // Security / billing policy constants // --------------------------------------------------------------------------- /** * The provider prefix whose models are API-billed (not subscription- or * self-hosted). These models must NEVER be role defaults/routes/fallbacks; an * exact use requires a visible operator opt-in (`googleBillingOptIn: true` on * the agent() call) and is recorded as an explicit model selection. */ export const API_BILLED_PROVIDER_PREFIX = "google-ai-studio/"; /** * The security-only model. It is rejected outside the `security` role — after * EVERY selection source (explicit `model`, agentType model pin, role default, * role route, and deprecated tier). A quota/availability failure must surface, * never silently downgrade a required security gate. */ export const SECURITY_MODEL = "meridian/claude-fable-5"; /** * Role whose default model is the security-only model. Fable cannot run * outside this role. */ export const SECURITY_ROLE: ModelRole = "security"; // --------------------------------------------------------------------------- // Config types // --------------------------------------------------------------------------- /** * Full model routing configuration. Carries the semantic `roles` layer and * the deprecated `tiers` compatibility layer (kept for migration only). * * `roles` is optional on disk: when absent, roles are derived from `tiers` via * the deprecation alias. When present, `roles` is authoritative. */ export interface ModelRoleConfig { /** * Semantic role → model mapping. Authoritative when present. When absent, * roles are derived from `tiers` via {@link TIER_TO_ROLE}. May be partial on * disk; {@link resolveRoleMap} falls back to derivation when a canonical * role is missing. */ roles?: Partial; /** * Deprecated size-tier → model map (small/medium/big). Kept ONLY as migration * input. Resolved via {@link resolveTierModel}; new code should resolve * roles instead. */ tiers?: Record; /** * Optional operator guidance injected into the model-facing workflow * authoring prompt. Carries machine-specific specialization (context-window * facts, escalation evidence rules, provider availability policy). */ routingNotes?: string[]; } /** * Legacy alias for {@link ModelRoleConfig}. The on-disk shape is unchanged; * this name is retained for compatibility with existing imports/tests. * @deprecated Use {@link ModelRoleConfig}. */ export type ModelTierConfig = ModelRoleConfig; // --------------------------------------------------------------------------- // Configuration path // --------------------------------------------------------------------------- /** Path to the model routing config file (~/.pi/workflows/model-tiers.json). */ export function getModelTierConfigPath(): string { return join(homedir(), MODEL_TIERS_FILE); } // --------------------------------------------------------------------------- // Defaults // --------------------------------------------------------------------------- /** * Build a default routing config where every role AND every legacy tier points * at a single model — the user's currently active Pi model when known, else * the first available model. New users get consistent behaviour (every route * == the model they're already chatting with) and can refine via * `/workflows-models`. Both layers are populated so legacy code paths and new * role paths agree until the user customizes one. */ export function buildDefaultTierConfig(currentModelSpec?: string): ModelRoleConfig { const model = currentModelSpec ?? listAvailableModelSpecs()[0] ?? ""; return { roles: { worker: { default: model }, conductor: { default: model }, advisor: { default: model }, security: { default: model }, }, tiers: { small: model, medium: model, big: model, }, }; } // --------------------------------------------------------------------------- // Normalization: roles from tiers (deprecation alias) // --------------------------------------------------------------------------- /** * Return the authoritative role map for a config: the explicit `roles` block * when present, else a role map derived from the legacy `tiers` block via the * deprecation alias (small→worker, medium→conductor, big→advisor). Derivation is * STRICT — each role maps ONLY to its size-tier ancestor, with no * cross-fallback, so a tier-only profile preserves the exact legacy semantics * (e.g. a profile lacking a `medium` tier still yields an absent conductor so * an untagged agent falls through to the session default, as before). `security` * has no size-tier ancestor; on a tier-only profile it derives from `big` (the * closest frontier tier) so a security agent still resolves a model. Returns * null when neither block is usable. */ export function resolveRoleMap(config: ModelRoleConfig | null | undefined): RoleMap | null { if (!config) return null; if (config.roles) { // Validate the explicit role map has the four canonical roles with defaults. if (MODEL_ROLES.every((r) => config.roles?.[r]?.default)) { return config.roles as RoleMap; } // Fall through to derivation if the explicit map is incomplete. } const tiers = config.tiers; if (!tiers) return null; const worker = tiers.small?.trim() || undefined; const conductor = tiers.medium?.trim() || undefined; const advisor = tiers.big?.trim() || undefined; if (!worker && !conductor && !advisor) return null; return { worker: { default: worker || "" }, conductor: { default: conductor || "" }, advisor: { default: advisor || "" }, // security has no size-tier ancestor; on a tier-only profile derive from // `big` (the closest frontier tier) so a security agent still resolves. security: { default: advisor || "" }, }; } // --------------------------------------------------------------------------- // Load / Save // --------------------------------------------------------------------------- function isStringRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function isValidRoleMap(value: unknown): value is RoleMap { if (!isStringRecord(value)) return false; for (const role of MODEL_ROLES) { const def = (value as Record)[role]; if (!def || typeof def !== "object") return false; const d = def as Record; if (typeof d.default !== "string") return false; if (d.routes !== undefined) { if (!isStringRecord(d.routes)) return false; for (const route of Object.values(d.routes)) { if (!route || typeof route !== "object") return false; const r = route as Record; if (typeof r.model !== "string") return false; if (r.note !== undefined && typeof r.note !== "string") return false; } } } return true; } /** * Load the model routing config from disk. Returns null if the file does not * exist or is unparseable (callers fall back to a default). The on-disk file is * read non-destructively — a profile with only a `tiers` block keeps working. */ export function loadModelTierConfig(configPath?: string): ModelRoleConfig | null { const path = configPath ?? getModelTierConfigPath(); if (!existsSync(path)) return null; try { const raw = readFileSync(path, "utf-8"); const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object") return null; const hasTiers = parsed.tiers && typeof parsed.tiers === "object"; const hasRoles = parsed.roles && typeof parsed.roles === "object"; if (!hasTiers && !hasRoles) return null; if (hasTiers) { for (const val of Object.values(parsed.tiers)) { if (typeof val !== "string") return null; } } if (hasRoles && !isValidRoleMap(parsed.roles)) return null; if ( parsed.routingNotes !== undefined && (!Array.isArray(parsed.routingNotes) || parsed.routingNotes.some((note: unknown) => typeof note !== "string")) ) { return null; } return parsed as ModelRoleConfig; } catch { return null; } } /** * Save a model routing config to disk. Creates parent directories if needed. */ export function saveModelTierConfig(config: ModelRoleConfig, configPath?: string): void { const path = configPath ?? getModelTierConfigPath(); const dir = dirname(path); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } writeFileSync(path, JSON.stringify(config, null, 2), "utf-8"); } // --------------------------------------------------------------------------- // Resolve: tiers (deprecated) // --------------------------------------------------------------------------- /** * Resolve a deprecated size-tier name to its configured model spec, or * undefined if the tier is not configured. New code should use * {@link resolveRoleModel} instead. */ export function resolveTierModel(tier: string, config: ModelRoleConfig): string | undefined { const model = config.tiers?.[tier]?.trim(); return model || undefined; } // --------------------------------------------------------------------------- // Resolve: roles // --------------------------------------------------------------------------- /** * Resolve a role (with an optional named route) to its concrete model spec. * Returns undefined when the role/route is unknown or the route is absent — * the caller decides whether to fail closed or fall back to the session model. * * Route resolution is exact, never laddered: an unknown `route` does NOT fall * back to the role default; it returns undefined so the caller can surface an * actionable error rather than silently dropping to a weaker selection. */ export function resolveRoleModel( role: string, route: string | undefined, config: ModelRoleConfig | null | undefined, ): string | undefined { const roles = resolveRoleMap(config); if (!roles) return undefined; const def = roles[role as ModelRole]; if (!def) return undefined; if (route) { const r = def.routes?.[route]; return r?.model?.trim() || undefined; } return def.default?.trim() || undefined; } // --------------------------------------------------------------------------- // Policy guards // --------------------------------------------------------------------------- /** True if the model spec belongs to the API-billed provider (never a default/route/fallback). */ export function isApiBilledModel(modelSpec: string | undefined): boolean { return Boolean(modelSpec?.startsWith(API_BILLED_PROVIDER_PREFIX)); } /** True if the model spec is the security-only model (Fable). */ export function isSecurityModel(modelSpec: string | undefined): boolean { return Boolean(modelSpec && modelSpec === SECURITY_MODEL); } // --------------------------------------------------------------------------- // Display helpers // --------------------------------------------------------------------------- /** Return all tier names sorted: small < medium < big, then alphabetically. */ export function sortedTierNames(config: ModelRoleConfig): string[] { const names = Object.keys(config.tiers ?? {}); const rank: Record = { small: 0, medium: 1, big: 2 }; return names.sort((a, b) => (rank[a] ?? 99) - (rank[b] ?? 99) || a.localeCompare(b)); } /** Return the canonical role names in stable order (only roles that resolve in the config). */ export function sortedRoleNames(config: ModelRoleConfig | null | undefined): string[] { const roles = resolveRoleMap(config); if (!roles) return []; return MODEL_ROLES.filter((r) => roles[r]?.default); } /** * Surface configuration shapes that make a small → medium → big escalation * misleading. These are warnings rather than validation errors because a fresh * install intentionally starts with all three tiers on the current Pi model. * * Also surfaces role-layer warnings: the security-only model used outside the * security role, and API-billed models used as role defaults/routes. */ export function modelTierConfigWarnings(config: ModelRoleConfig): string[] { const warnings: string[] = []; const standardTiers = ["small", "medium", "big"] as const; const tiersByModel = new Map(); for (const tier of standardTiers) { const model = config.tiers?.[tier]?.trim(); if (!model) { warnings.push(`The ${tier} tier has no model; it will fall back to the session model.`); continue; } const names = tiersByModel.get(model) ?? []; names.push(tier); tiersByModel.set(model, names); } for (const [model, tiers] of tiersByModel) { if (tiers.length < 2) continue; warnings.push( `${tiers.join("/")} tiers all resolve to "${model}"; escalation between them will retry the same model.`, ); } // Role-layer policy warnings. const roles = resolveRoleMap(config); if (roles) { for (const role of MODEL_ROLES) { const def = roles[role]; const allModels = [def.default, ...Object.values(def.routes ?? {}).map((r) => r.model)]; for (const model of allModels) { if (isSecurityModel(model) && role !== SECURITY_ROLE) { warnings.push( `Role "${role}" routes to the security-only model "${model}"; it must be used only with the security role.`, ); } if (isApiBilledModel(model)) { warnings.push( `Role "${role}" routes to the API-billed model "${model}"; API-billed models must never be role defaults/routes and require explicit opt-in.`, ); } } } } return warnings; }