feat(models): BYOK all the way down — factory pins deleted, per-need refusal gates
Owner decision 2026-09-22: 'it doesn't make sense to have any default
model configured anywhere; the application is bring your own keys.'
config.ts: MODELS deleted; ModelRole becomes an explicit 9-role union;
DEFAULT_THINKING_ORDER (medium, low, high, minimal, xhigh, max) bakes
the unset-level default — first entry the model supports; explicitly
set levels keep pi's native nearest-clamp.
core/settings-resolution: the factory layer dissolves — the chain is
role override → slot → NOTHING. resolveSettings(record) takes no
factory. Empty chains resolve to null/undefined; a thinkingLevel-only
slot is inert (stored, never run). resolveConversationSpec: chat.model
override over the strong slot, null when both empty. resolveRoleSpec
spans all nine roles for the job/extraction engines.
Refusal envelope (core/settings.ts): modelsNotConfigured(missing) —
{error, code: 'models_not_configured', missing: [slot tiers]} +
ModelsNotConfiguredError with .envelope. Per-need gates, each honest:
- chat-handler: universal turn gate (new + continuing turns) returns
503 before beginTurn — no liveness, no write-ahead persist, no graph
trigger before the gate fires.
- runner.runJob: unresolvable engine lands the job as error with the
envelope wording; no phantom loop, no result steer.
- extraction: refuseIfStagesUnconfigured throws before any stage
(turn-service info-skips so coverage retries next turn; the import
route surfaces per-file code+missing); labeler-less runs skip
cluster naming with a console line and still merge.
Write side: slot entries must carry a model — level-only slot writes
are 400s (absent slot key = clear). piEngineForSpec resolves unset
thinking levels via DEFAULT_THINKING_ORDER; piEngineFor now throws
honestly; thinkingLevelFor/modelIdFor deleted; teacher-agent, pi-run,
visual-maker, pdf-reader all read engine.level.
model-catalog: defaults/slots/rolePins null when the chain is empty
(contracts nullable) — no phantom ids in payloads. Stores drop
MODEL_PINS entirely.
Tests: pin-leaners converted to explicit injection (per-chat model
pins on scripted chats — exactly what the setup flow writes; faux
engines for agent-construction tests). New coverage: the 503 gate
envelope, the runner refusal, the extraction refusal + labeler skip.
Gates: typecheck, lint, 815/815, build.
This commit is contained in:
+51
-14
@@ -5,8 +5,10 @@
|
||||
*
|
||||
* DELTA SEMANTICS (owner decision, 2026-09-19): the record stores EXPLICIT
|
||||
* user choices only — ABSENT keys resolve to the factory defaults compiled
|
||||
* into this module (FACTORY_SETTINGS) and config.ts's MODELS (the compiled
|
||||
* factory layer for model roles). A software update that ADDS a knob (new
|
||||
* into this module (FACTORY_SETTINGS). MODELS are the exception (owner,
|
||||
* 2026-09-22 — BYOK all the way down): no factory pins exist, so an absent
|
||||
* model key resolves to EMPTY and the per-need gates refuse honestly. A
|
||||
* software update that ADDS a knob (new
|
||||
* tool, new role, new appearance field) therefore needs no migration: old
|
||||
* records simply lack the new keys. `version` is a PRIVATE schema counter
|
||||
* for BREAKING shape changes only (field renamed / meaning changed) —
|
||||
@@ -26,6 +28,7 @@
|
||||
*/
|
||||
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import type { ModelRole } from "../../config.ts";
|
||||
import type { ChatSettings } from "./contracts/chat.ts";
|
||||
import type { SearchEngineId } from "./search.ts";
|
||||
|
||||
@@ -34,9 +37,8 @@ import type { SearchEngineId } from "./search.ts";
|
||||
export const CURRENT_SETTINGS_VERSION = 1;
|
||||
|
||||
/** The background-agent roles whose models the Agent settings expose.
|
||||
* Derived from config.ts's MODELS minus the conversation pair — prober and
|
||||
* teacher stay chat-settings-controlled (one controller, the model picker).
|
||||
* Keep in sync with config.ts's MODELS (tests pin the set). */
|
||||
* The conversation pair (prober/teacher) stays out — chat-controlled via
|
||||
* the strong slot (one controller, the model picker). Tests pin the set. */
|
||||
export const BACKGROUND_MODEL_ROLES = [
|
||||
"researcher",
|
||||
"demoBuilder",
|
||||
@@ -67,9 +69,44 @@ export interface RoleModelOverride {
|
||||
export const MODEL_SLOTS = ["strong", "cheap", "vision"] as const;
|
||||
export type ModelSlotName = (typeof MODEL_SLOTS)[number];
|
||||
|
||||
/** One slot's override — same per-field independence as a role override:
|
||||
* absent model keeps the tier's current model, absent thinkingLevel keeps
|
||||
* its thinking budget. */
|
||||
/** The machine-readable NOT-CONFIGURED refusal envelope (owner, 2026-09-22):
|
||||
* every per-need gate — the turn gate in chat-handler, a job spawn, a
|
||||
* notes import — refuses with this exact shape. HTTP callers get 503 +
|
||||
* this JSON; in-band consumers (the job record's error field) carry the
|
||||
* human `error` line. `missing` names the slot TIER(S) the refused
|
||||
* action needs and found unset. */
|
||||
export const MODELS_NOT_CONFIGURED_CODE = "models_not_configured" as const;
|
||||
export interface ModelsNotConfigured {
|
||||
/** Human-readable — safe to render as-is. */
|
||||
error: string;
|
||||
/** The machine discriminator the client switches on. */
|
||||
code: typeof MODELS_NOT_CONFIGURED_CODE;
|
||||
/** Which slot tiers the refused action needs ("strong" | "cheap" | "vision"). */
|
||||
missing: ModelSlotName[];
|
||||
}
|
||||
export function modelsNotConfigured(missing: ModelSlotName[]): ModelsNotConfigured {
|
||||
return {
|
||||
error: "No model configured — set up your models first",
|
||||
code: MODELS_NOT_CONFIGURED_CODE,
|
||||
missing,
|
||||
};
|
||||
}
|
||||
/** The typed throw for in-process seams (extraction pipeline): callers
|
||||
* catch it to skip honestly (trigger) or surface the 503 envelope
|
||||
* (routes) instead of logging a failure. */
|
||||
export class ModelsNotConfiguredError extends Error {
|
||||
readonly envelope: ModelsNotConfigured;
|
||||
constructor(missing: ModelSlotName[]) {
|
||||
super(modelsNotConfigured(missing).error);
|
||||
this.name = "ModelsNotConfiguredError";
|
||||
this.envelope = modelsNotConfigured(missing);
|
||||
}
|
||||
}
|
||||
|
||||
/** One slot's stored state. BYOK (owner, 2026-09-22): the WRITE side
|
||||
* requires model (a level alone can never run — no factory floor); the
|
||||
* sanitizer keeps whatever is on disk, but RESOLUTION treats a slot
|
||||
* without a model as unset (inert level, no phantom default). */
|
||||
export interface ModelSlot {
|
||||
/** The model swap: both coordinates or neither. */
|
||||
model?: { provider: string; id: string };
|
||||
@@ -99,7 +136,7 @@ export function slotsConfigured(models: ModelsSettings | undefined): boolean {
|
||||
/** Which slot a role draws from (model slots wave, 2026-09-20). Includes the
|
||||
* conversation pair and pdfReader (not BackgroundModelRoles — the slot
|
||||
* tier spans every role; only background roles surface per-role overrides). */
|
||||
export const ROLE_SLOT: Readonly<Record<string, ModelSlotName>> = {
|
||||
export const ROLE_SLOT: Readonly<Record<ModelRole, ModelSlotName>> = {
|
||||
prober: "strong",
|
||||
teacher: "strong",
|
||||
researcher: "cheap",
|
||||
@@ -201,11 +238,11 @@ export interface UserSettings {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Factory defaults for everything core can pin (owner decisions 2026-09-19).
|
||||
* Model-role defaults live in config.ts's MODELS (the compiled factory
|
||||
* layer); the chat section has NO factory model (BYOK first boot doctrine
|
||||
* — "no model" until the user picks one); tools default on; prompts
|
||||
* default off — hence their absence here. */
|
||||
/** Factory defaults for everything core can pin (owner decisions
|
||||
* 2026-09-19). NO factory models exist anywhere (owner, 2026-09-22 — BYOK
|
||||
* all the way down): the chat section and every slot stay empty until the
|
||||
* user picks, and the per-need gates refuse honestly; tools default on;
|
||||
* prompts default off — hence their absence here. */
|
||||
export interface FactorySettings {
|
||||
version: number;
|
||||
general: Required<GeneralSettings>;
|
||||
|
||||
Reference in New Issue
Block a user