Files
learnharness/src/agent/models.ts
T

136 lines
5.5 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";
import packageMetadata from "../../package.json" with { type: "json" };
import { createClaudeCodeProvider } from "./claude-code/provider.ts";
import { streamClaudeCode } from "./claude-code/stream.ts";
/** App identity is immutable and shared by Models and every native Agent.
* Agent's default Pi attribution otherwise overrides the Models identity. */
export const APP_IDENTITY = Object.freeze({
userAgent: Object.freeze({ custom: `learnharness/${packageMetadata.version}` }),
attribution: Object.freeze({ name: packageMetadata.name, url: packageMetadata.homepage }),
});
/**
* 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({ identity: APP_IDENTITY });
for (const provider of builtinProviders()) collection.setProvider(provider);
collection.setProvider(createClaudeCodeProvider(streamClaudeCode));
}
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)`,
);
}