/** @file Tools model-adapter module. */ import type { ModelAdapter, ModelRequest, ModelResponse } from "../../extract/adhoc/model.ts"; import { isUnknownRecord, type UnknownRecord } from "../../types.ts"; import { modelRegistry, type ModelCapability, type ResolvePreference } from "./model-registry.ts"; type Runner = (payload: unknown, signal?: AbortSignal) => Promise | unknown; /** * Resolves the optional model adapter from the Pi host context. * * @remarks * Pi has evolved its model APIs over time, so this boundary intentionally uses narrow duck * typing. When a host exposes a selected-model runner on the handler context, the tools can call * it; otherwise model-backed `web_scrape`, `web_extract`, and `web_summarize` paths fall through * to the event-bus registry or return the stable `MODEL_ADAPTER_MISSING` error. */ export function resolveModelAdapterFromContext(source: unknown): ModelAdapter | undefined { if (isUnknownRecord(source)) { const configured = source.modelAdapter; if (isModelAdapter(configured)) return configured; } const runner = findRunner(source); if (!runner) return; return { async run(request: ModelRequest, signal?: AbortSignal) { const raw = await runner(buildModelPayload(request), signal); return normalizeModelResponse(request, raw); }, }; } function findRunner(source: unknown): Runner | undefined { if (!isUnknownRecord(source)) return; for (const key of ["runModel", "generate", "chat", "complete"] as const) { if (typeof source[key] === "function") return source[key].bind(source) as Runner; } for (const key of ["model", "selectedModel", "models"] as const) { const candidate = source[key]; if (!isUnknownRecord(candidate)) continue; for (const method of ["run", "generate", "chat", "complete"] as const) { if (typeof candidate[method] === "function") { return candidate[method].bind(candidate) as Runner; } } } } function buildModelPayload(request: ModelRequest): UnknownRecord { const prompt = modelPrompt(request); return { ...request, prompt, messages: [{ role: "user", content: [{ type: "text", text: prompt }] }], }; } function modelPrompt(request: ModelRequest): string { if (request.task === "summarize") { return `${request.prompt ?? "Summarize this page."}\n\n${request.input}`; } const schema = request.schema ? `\nJSON schema or shape:\n${JSON.stringify(request.schema)}` : ""; return [ "Extract structured JSON from this page content.", request.prompt ? `Instructions: ${request.prompt}` : undefined, schema || undefined, "Return only JSON.", "", request.input, ] .filter(Boolean) .join("\n"); } function normalizeModelResponse(request: ModelRequest, raw: unknown): ModelResponse { if (isUnknownRecord(raw) && "data" in raw) { return raw as unknown as ModelResponse; } const text = extractText(raw); const data = request.task === "extract" ? (parseJsonOrText(text) as T) : (text as T); return { data, text, raw }; } function extractText(value: unknown): string { if (typeof value === "string") return value; if (!isUnknownRecord(value)) return value === null || value === undefined ? "" : JSON.stringify(value); if (typeof value.text === "string") return value.text; if (typeof value.output === "string") return value.output; if (typeof value.message === "string") return value.message; if (Array.isArray(value.content)) { return value.content .map((item) => { if (typeof item === "string") return item; if (isUnknownRecord(item) && typeof item.text === "string") return item.text; return ""; }) .filter(Boolean) .join("\n"); } return JSON.stringify(value); } function parseJsonOrText(text: string): unknown { try { return JSON.parse(text); } catch { return text; } } function isModelAdapter(value: unknown): value is ModelAdapter { return isUnknownRecord(value) && typeof value.run === "function"; } /** Resolve a model adapter from the cross-extension registry. */ export function resolveAdapterFromRegistry( preference: ResolvePreference, capability: ModelCapability, ): ModelAdapter | undefined { return modelRegistry.resolve(preference, capability); } /** * Resolve provider preference from the precedence chain: per-call param → Pi flag → env var → * config → "auto". */ export function resolveProviderPreference(opts: { paramProvider?: string; flagProvider?: string; envProvider?: string; configProvider?: | string | { summarize?: string; extract?: string; analyze?: string; chat?: string }; capability: ModelCapability; }): ResolvePreference { const layers = [ opts.paramProvider, opts.flagProvider, opts.envProvider, // oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime values may be undefined despite TS inference typeof opts.configProvider === "object" && opts.configProvider !== null ? opts.configProvider[opts.capability] : opts.configProvider, ] as const; for (const layer of layers) { if (layer && typeof layer === "string") { const trimmed = layer.trim(); if (trimmed) return trimmed; } } return "auto"; }