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:
+40
-32
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
clampThinkingLevel,
|
||||
createModels,
|
||||
getSupportedThinkingLevels,
|
||||
type Api,
|
||||
type Model,
|
||||
type Models,
|
||||
@@ -8,19 +9,18 @@ import {
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { MODELS, type ModelRole } from "../../config.ts";
|
||||
import type { ChatSettings } from "../core/contracts/chat.ts";
|
||||
import { DEFAULT_THINKING_ORDER, type ModelRole } from "../../config.ts";
|
||||
|
||||
/**
|
||||
* The one engine factory every agent's model comes from (the pi-ai swap,
|
||||
* docs/pi-engine-swap.md). Model choice and thinking budget live in
|
||||
* config.ts (MODELS) — the single tuning table; this module only wires them
|
||||
* to pi-ai's OpenRouter provider.
|
||||
* docs/pi-engine-swap.md). BYOK ALL THE WAY DOWN (owner, 2026-09-22):
|
||||
* there are no factory pins — every engine resolves from a RESOLVED SPEC
|
||||
* (the settings chain's output) and the one builder here wires it to
|
||||
* pi-ai's federated catalogs.
|
||||
*
|
||||
* An "engine" is the model + the stream function the agent loop runs on —
|
||||
* the two things a test fake replaces. Production engines resolve the model
|
||||
* from pi-ai's static catalog (glm-5.3-flash and qwen3.8-max are both in
|
||||
* 0.85.1's built-ins — no overlay fetch) and stream through the same Models
|
||||
* from pi-ai's static catalogs and stream through the same Models
|
||||
* collection.
|
||||
*/
|
||||
|
||||
@@ -34,12 +34,6 @@ export interface PiEngine {
|
||||
|
||||
export type { ThinkingLevel };
|
||||
|
||||
/** The role's config thinking level (config's scale IS pi's since the
|
||||
* model-control wave — no mapping needed). */
|
||||
export function thinkingLevelFor(role: ModelRole): ThinkingLevel {
|
||||
return MODELS[role].effort;
|
||||
}
|
||||
|
||||
let collection: ReturnType<typeof createModels> | undefined;
|
||||
|
||||
/** The process-wide pi-ai Models collection — EVERY builtin provider's
|
||||
@@ -63,14 +57,26 @@ export function openRouterApiKey(): string | undefined {
|
||||
return process.env.OPENROUTER_API_KEY || undefined;
|
||||
}
|
||||
|
||||
/** A fully-resolved model pin — the shape every engine now comes from
|
||||
* (model slots wave, 2026-09-20): the settings-resolution chain's output.
|
||||
* Every field concrete; the CALLER resolved the chain (role override →
|
||||
* slot → factory pin, or the chat's conversation chain). */
|
||||
/** A fully-resolved model pin — the shape every engine now comes from:
|
||||
* the settings-resolution chain's output (role override → slot, or the
|
||||
* chat's conversation chain). thinkingLevel is OPTIONAL: unset means
|
||||
* the baked default resolves here (owner 2026-09-22) — the first entry
|
||||
* of DEFAULT_THINKING_ORDER the model supports. */
|
||||
export interface ResolvedModelSpec {
|
||||
provider: string;
|
||||
id: string;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
}
|
||||
|
||||
/** The first entry of the baked default order the model supports (owner,
|
||||
* 2026-09-22). A non-reasoning model reports exactly ["off"], so the
|
||||
* order walk lands there naturally. */
|
||||
function defaultThinkingLevel(model: Model<Api>): ThinkingLevel {
|
||||
const supported = getSupportedThinkingLevels(model);
|
||||
for (const level of DEFAULT_THINKING_ORDER) {
|
||||
if (supported.includes(level)) return level;
|
||||
}
|
||||
return supported[0] ?? "off";
|
||||
}
|
||||
|
||||
/** The production engine for a RESOLVED spec (model slots wave,
|
||||
@@ -84,7 +90,12 @@ export function piEngineForSpec(spec: ResolvedModelSpec): PiEngine {
|
||||
if (!model) {
|
||||
throw new Error(`${spec.provider} catalog has no model ${spec.id}`);
|
||||
}
|
||||
const level = clampThinkingLevel(model, spec.thinkingLevel);
|
||||
// UNSET level → the baked default order (first entry the model
|
||||
// supports); SET level → pi's native nearest-clamp (model swaps keep
|
||||
// the user's intent and adapt honestly).
|
||||
const level = spec.thinkingLevel
|
||||
? clampThinkingLevel(model, spec.thinkingLevel)
|
||||
: defaultThinkingLevel(model);
|
||||
return {
|
||||
model,
|
||||
level,
|
||||
@@ -99,17 +110,14 @@ export function piEngineForSpec(spec: ResolvedModelSpec): PiEngine {
|
||||
};
|
||||
}
|
||||
|
||||
/** The production engine for one agent role — the LEGACY fallback path:
|
||||
* the chat's model-control override over config.ts's pin. Production
|
||||
* callers (chat-handler) now resolve the full chain (chat override →
|
||||
* strong slot → factory pin) and call piEngineForSpec; this stays for
|
||||
* direct config-pin fallbacks (sub-agents without an injected engine —
|
||||
* the job runner injects one — and tests). */
|
||||
export function piEngineFor(role: ModelRole, settings?: ChatSettings): PiEngine {
|
||||
const spec = MODELS[role];
|
||||
return piEngineForSpec({
|
||||
provider: settings?.model?.provider ?? spec.provider,
|
||||
id: settings?.model?.id ?? spec.model,
|
||||
thinkingLevel: settings?.thinkingLevel ?? thinkingLevelFor(role),
|
||||
});
|
||||
/** The LEGACY role entry point — BYOK made it an honest THROWER (owner,
|
||||
* 2026-09-22): no factory pins exist to fall back to, and this module
|
||||
* cannot read settings (architecture law — the agent layer never
|
||||
* imports the server layer). Production resolves the chain in
|
||||
* chat-handler and ALWAYS injects engines; reaching this means a
|
||||
* wiring bug, so it fails loudly instead of guessing a model. */
|
||||
export function piEngineFor(role: ModelRole): PiEngine {
|
||||
throw new Error(
|
||||
`no configured model for role "${role}" — set your model slots (settings → models)`,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user