Files
learnharness/src/agent/models.ts
T

177 lines
8.1 KiB
TypeScript

import {
clampThinkingLevel,
createModels,
getSupportedThinkingLevels,
type Api,
type Model,
type Models,
type MutableModels,
type StreamFunction,
} from "@earendil-works/pi-ai";
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
import { lazyStream } from "@earendil-works/pi-ai/api/lazy";
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 { chatGPTModel } from "./chatgpt-bridge.ts";
import { chatGPTStream } from "./chatgpt-stream.ts";
import type { ChatGPTDispatchLifecycle } from "./chatgpt-request-cycle.ts";
import { nativeSubscriptionId, nativeSubscriptionModel } from "./subscription-bridge.ts";
import { nativeSubscriptionStream } from "./subscription-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 initially resolved (config-or-settings, CLAMPED) thinking level.
* Custom streams re-resolve the original preference per operation. */
level?: ThinkingLevel;
}
export type { ThinkingLevel };
let collection: ReturnType<typeof createModels> | undefined;
interface ProviderSource {
sync(models: MutableModels): void;
isCustom(id: string): boolean;
}
let providerSource: ProviderSource | undefined;
/** Configuration IO is injected by the server; the agent never imports stores. */
export function configureProviderSource(source: ProviderSource): void {
// Server module re-evaluation can replace the source while this collection
// survives. Retire only the previous source's routes, not built-ins.
if (collection && providerSource) {
for (const provider of collection.getProviders()) {
if (providerSource.isCustom(provider.id)) collection.deleteProvider(provider.id);
}
}
providerSource = source;
}
/** 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);
}
providerSource?.sync(collection);
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, background: boolean | (() => boolean) = true, lifecycle?: ChatGPTDispatchLifecycle): PiEngine {
const models: Models = piModels();
const nativeId = nativeSubscriptionId(spec.provider);
const model = spec.provider === "chatgpt" ? chatGPTModel(spec.id) : nativeId ? nativeSubscriptionModel(spec.provider, spec.id) : 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 thinkingPreference = spec.thinkingLevel;
const level = thinkingPreference
? clampThinkingLevel(model, thinkingPreference)
: defaultThinkingLevel(model);
// A lesson's agent loop can stay alive through many tool calls. Remember
// only that this is a custom route, never its configuration for that run.
const custom = providerSource?.isCustom(spec.provider) ?? false;
return {
model,
level,
// The agent loop forwards state.thinkingLevel as `reasoning` (undefined
// = off on pi-ai's scale). Built-ins keep the factory clamp; custom
// requests re-resolve thinking against their current metadata below.
streamFn: (requestModel, context, options) => {
if (custom) {
// Revalidate before EVERY upstream model operation, including later
// tool-loop steps. Resolve thinking from the ORIGINAL preference (or
// existing default order), not a previous metadata snapshot's clamp.
// Pi retains the setup of an operation already in flight.
const current = piModels();
const currentModel = current.getModel(spec.provider, spec.id);
if (!currentModel) return lazyStream(requestModel, async () => { throw new Error("The selected custom model is unavailable."); });
const currentLevel = thinkingPreference
? clampThinkingLevel(currentModel, thinkingPreference)
: defaultThinkingLevel(currentModel);
return current.streamSimple(currentModel, context, { ...options, reasoning: currentLevel === "off" ? undefined : currentLevel });
}
const requestOptions = { ...options, reasoning: level === "off" ? undefined : level };
const purpose = typeof background === "function" ? background : () => background;
const stream = spec.provider === "chatgpt" ? chatGPTStream(purpose, lifecycle) : nativeId ? nativeSubscriptionStream(models.getProvider(nativeId)!, purpose, lifecycle) : models.streamSimple.bind(models);
return stream(requestModel, context, requestOptions);
},
};
}
/** 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)`,
);
}