Fix 1 — clamp, never reject (owner ruling, interlude 2026-09-22): a model swap with a pinned thinking level must JUST WORK. The owner hit the write-side 400 live (Claude Fable 5.1/xhigh → GLM 5.3 flash: "z-ai/glm-5.3-flash does not support thinking level xhigh"). The supported-level verdicts in validateChatSettings and validateModelsSettings are DEAD — the WRITE clamps the pin to the new model's nearest supported level (pi's clampThinkingLevel, the same translation piEngineForSpec applies at runtime) and stores the clamped result; the server is the truth — the record shows the clamped level after write. New pure helpers in model-catalog.ts, applied by ALL THREE write routes (section, defaults, per-chat — one verdict seam, so every surface that used the old 400 gets the clamp uniformly): - clampModelsLevels: every slot's level clamps against the SLOT'S OWN model (the parser already requires a model on each slot write). - clampChatLevel: the body's own model, else the record's resolved strong slot (the chain's fallback); no model anywhere → rides unchecked (runtime clamps at engine build; the turn gate refuses the model-less chat anyway). Fix 2 — DEFAULT_THINKING_ORDER moves from config.ts to core/settings (core barrel, AUTH_KEYS precedent — one source of truth, no drift). The UI imports it to auto-populate a thinking level on FRESH model picks; the ordering has no other client role. Legacy unset records keep the runtime defaultThinkingLevel fallback at engine build untouched. Import sites updated: agent/models.ts, tests. Verified live on 3004: the owner's exact case stores max (not 400); a bare chat-level write clamps to low against the strong slot's glm; unknown provider/model still 400s with the provider list. Tests: the level-verdict suites became clamp suites (the owner's case pinned verbatim: xhigh→max, minimal→low, off→low, supported pass-through, level-less slots ride); the machine-aware expectBareLevelVerdict chain is retired — clampChatLevel takes the strong slot as an explicit param, so the tests are pure again. Gates: typecheck, lint, 839/839, build.
125 lines
4.9 KiB
TypeScript
125 lines
4.9 KiB
TypeScript
import {
|
|
clampThinkingLevel,
|
|
createModels,
|
|
getSupportedThinkingLevels,
|
|
type Api,
|
|
type Model,
|
|
type Models,
|
|
type StreamFunction,
|
|
} from "@earendil-works/pi-ai";
|
|
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
|
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
import { type ModelRole } from "../../config.ts";
|
|
import { DEFAULT_THINKING_ORDER } from "../core/settings.ts";
|
|
|
|
/**
|
|
* The one engine factory every agent's model comes from (the pi-ai swap,
|
|
* 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 catalogs and stream through the same Models
|
|
* collection.
|
|
*/
|
|
|
|
export interface PiEngine {
|
|
model: Model<Api>;
|
|
streamFn: StreamFunction;
|
|
/** The resolved (config-or-settings, CLAMPED) thinking level —
|
|
* observability + tests; the streamFn pins it anyway. */
|
|
level?: ThinkingLevel;
|
|
}
|
|
|
|
export type { ThinkingLevel };
|
|
|
|
let collection: ReturnType<typeof createModels> | undefined;
|
|
|
|
/** The process-wide pi-ai Models collection — EVERY builtin provider's
|
|
* catalog (model slots wave, 2026-09-20): engines and the picker resolve
|
|
* against all of pi-ai's per-provider catalogs, so a settings slot can pin
|
|
* any native model id. Auth is resolved per request by pi-ai itself from
|
|
* the environment (the keys seam reflects file-saved keys into env), and
|
|
* a provider with no key simply fails its stream with "not configured" —
|
|
* honest, late, and unreachable through the picker, which gates on
|
|
* keyPresent. */
|
|
export function piModels() {
|
|
if (!collection) {
|
|
collection = createModels();
|
|
for (const provider of builtinProviders()) collection.setProvider(provider);
|
|
}
|
|
return collection;
|
|
}
|
|
|
|
/** OpenRouter API key (BYOK); tests never read this — they inject engines. */
|
|
export function openRouterApiKey(): string | undefined {
|
|
return process.env.OPENROUTER_API_KEY || undefined;
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/** 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,
|
|
* 2026-09-20): provider-aware catalog lookup + thinking clamp. The API
|
|
* key is resolved by pi-ai itself from the environment per request (the
|
|
* keys seam reflects file-saved keys into env), so the engine never
|
|
* hardcodes OpenRouter's. */
|
|
export function piEngineForSpec(spec: ResolvedModelSpec): PiEngine {
|
|
const models: Models = piModels();
|
|
const model = models.getModel(spec.provider, spec.id);
|
|
if (!model) {
|
|
throw new Error(`${spec.provider} catalog has no model ${spec.id}`);
|
|
}
|
|
// 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,
|
|
// bound streamSimple: the agent loop forwards state.thinkingLevel as
|
|
// `reasoning` (undefined = off on pi-ai's scale); the clamp above keeps
|
|
// the pin honest even when config or settings drift off the map.
|
|
streamFn: (requestModel, context, options) =>
|
|
models.streamSimple(requestModel, context, {
|
|
...options,
|
|
reasoning: level === "off" ? undefined : level,
|
|
}),
|
|
};
|
|
}
|
|
|
|
/** 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)`,
|
|
);
|
|
}
|