feat(models): strong/cheap/vision model slots + multi-provider engine federation

Model slots wave (2026-09-20), chunk B:

- settings: new `models` section {strong, cheap, vision} slots (each
  field independent, same shape as a role override); ROLE_SLOT maps all
  nine roles to tiers (strong=prober+teacher, cheap=researcher/
  demoBuilder/extractor/auditor/labeler, vision=visualMaker+pdfReader);
  resolution chain per role: override → slot → factory pin.
- config: ModelSpec gains provider (all pins openrouter, the BYOK
  default); MODEL_PINS in stores.ts now spans all nine roles (the strong
  slot's factory rep IS the prober pin).
- engine: piModels() registers EVERY builtin provider catalog;
  piEngineForSpec(resolved spec) is the new engine factory (pi-ai
  resolves per-provider keys from env itself); piEngineFor stays as the
  config-pin fallback. chat-handler resolves the conversation chain
  (chat override → strong slot → pin) and injects engineFor into the job
  runner, closing the old gap where per-role overrides never reached
  job engines.
- /api/models: models federate ONLY keyed providers (providers surface
  with keyPresent, env-wins, fresh per call); slots (resolved tier pins)
  join rolePins; catalogDefaultsFor inherits the strong slot; the models
  settings section gets the same catalog gate as chat.
- pricing: PRICES/CONTEXT_LIMITS uniformly keyed provider:id
  (regenerated from ALL pi-ai catalogs, 1354 keys); usage stamps
  (TurnUsage, JobUsage, extraction stages) switch to provider:id —
  owner decision: no legacy compat, old stamps render unpriced.
This commit is contained in:
amosblomqvist
2026-09-22 00:14:56 +03:00
parent f1373d5de5
commit f9e3a4c43d
26 changed files with 3529 additions and 912 deletions
+28 -1
View File
@@ -19,9 +19,10 @@ import type {
BackgroundModelRole,
GeneralSettings,
GraphSettings,
ModelsSettings,
RoleModelOverride,
} from "../core/settings.ts";
import { BACKGROUND_MODEL_ROLES } from "../core/settings.ts";
import { BACKGROUND_MODEL_ROLES, MODEL_SLOTS } from "../core/settings.ts";
import { TOGGLEABLE_TOOLS } from "../core/pending.ts";
/** Parsed-or-rejected — `ok: false` carries the 400's error message. */
@@ -292,6 +293,31 @@ export function parseGraphSettingsBody(body: unknown): SettingsBodyResult<GraphS
return { ok: true, settings };
}
/** models: { strong?, cheap?, vision? } — the slot tiers (model slots
* wave, 2026-09-20). Each slot is the same shape as a role override; a
* present-but-empty slot object is rejected (absent slot = unset; say
* what you mean). Catalog validity is checked by the route
* (validateChatSettings discipline). */
export function parseModelsSettingsBody(body: unknown): SettingsBodyResult<ModelsSettings> {
const raw = asRecord(body);
if (!raw) return { ok: false, error: "Invalid JSON body" };
const settings: ModelsSettings = {};
for (const key of Object.keys(raw)) {
const known = MODEL_SLOTS as readonly string[];
if (!known.includes(key)) {
return { ok: false, error: `unknown slot "${key}" (supported: ${known.join(", ")})` };
}
const parsed = parseRoleOverride(raw[key], key);
if (!parsed.ok) return { ok: false, error: parsed.error };
if (Object.keys(parsed.override).length) {
settings[key as (typeof MODEL_SLOTS)[number]] = parsed.override;
} else {
return { ok: false, error: `slot "${key}" must set model or thinkingLevel (absent slot = unset)` };
}
}
return { ok: true, settings };
}
/** The section→parser map for PUT /api/settings/[section] (the route also
* carries the special prompts sub-resource). */
export const SETTINGS_SECTIONS = {
@@ -300,6 +326,7 @@ export const SETTINGS_SECTIONS = {
agent: parseAgentSettingsBody,
appearance: parseAppearanceSettingsBody,
graph: parseGraphSettingsBody,
models: parseModelsSettingsBody,
} as const;
export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS;