177 lines
8.1 KiB
TypeScript
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)`,
|
|
);
|
|
}
|