/** * Resolves the {@link Judge} that answers typed judgments through the `judge` * model role. The chain is re-resolved at most every {@link CANDIDATE_TTL_MS} * so live catalog discovery, role edits, credential changes, and session * fallback all take effect without recreating feature consumers, while bulk * callers (`judge_batch`) do not re-scan the whole catalog per item. * * Every provider attempt is attributed exactly once: native judgment * requests by {@link nativeJudge}, prompted chat attempts by the chat * backend's `onAttempt`. Each report reaches both {@link JudgeDeps.onUsage} * (the session ledger) and one `judgment` telemetry span, so a judgment backed * by a chat model is never billed again from its aggregated result. */ import { type AgentTelemetry, type AgentTelemetryConfig, recordJudgmentTelemetry, resolveTelemetry, } from "@oh-my-pi/pi-agent-core"; import { type Answer, type AssistantMessage, type BoxEdges, chatTextBackend, checkBoxQuestions, createNativeJudge, type ImageContent, isJudgmentApi, nativeJudgmentAcceptsImages, nativeJudgmentImageBounds, nativeJudgmentSupportsBoxes, nativeJudgmentSupportsImages, type Judge, type JudgeOptions, type JudgmentRequest, type JudgmentResult, type Model, type NativeJudge, type Question, type Questions, type TextBackend, type TextCompletion, type TextPrompt, TextJudge, TYPESAFE_PROVIDER, tokenUsage, type Usage, } from "@oh-my-pi/pi-ai"; import * as AIError from "@oh-my-pi/pi-ai/error"; import { isEmbeddedImageContent } from "@oh-my-pi/pi-ai/images/shared"; import { sendsImageInputOnWire } from "@oh-my-pi/pi-ai/providers/vision-guard"; import { calculateCost } from "@oh-my-pi/pi-catalog/models"; import { logger, prompt, untilAborted } from "@oh-my-pi/pi-utils"; import { normalizeModelContextImages } from "../utils/image-loading"; import { type FittedImage, fitImage } from "../utils/image-resize"; import type { ModelRegistry } from "../config/model-registry"; import { cfgJudgmentAttemptTimeoutMs } from "../config/model-settings"; import { formatModelStringWithRouting, resolveRoleChain, type RoleChainCandidate } from "../config/model-resolver"; import { roleCandidatePool } from "../config/model-roles"; import type { Settings } from "../config/settings"; import type { CostGateApi } from "../session/cost-gate"; import type { SessionManager } from "../session/session-manager"; import { getTinyLocalModelSpec } from "../tiny/models"; import localPromptTemplate from "../prompts/system/judgment-local.md" with { type: "text" }; import { tinyModelClient } from "../tiny/title-client"; import type { JudgmentCache } from "./cache"; export * from "./cache"; /** Usage of one billed judgment attempt, recorded on the session ledger by callers. */ export interface JudgmentUsage { /** Why the judgment ran; see {@link JudgeDeps.purpose}. */ purpose: string; /** Model role the call resolved through, or `typesafe` for native judgments. */ role: string; api: string; provider: string; model: string; usage: Usage; stopReason: AssistantMessage["stopReason"]; errorMessage?: string; } export interface JudgeDeps { settings: Settings; registry: ModelRegistry; /** The session's active model, appended when the judge role does not already route to it. */ sessionModel?: Model; sessionId?: string; metadataResolver?: (provider: string) => Record | undefined; /** Why the judgment runs (`find`, `ttsr`, `judge_batch`, …); labels ledger entries and telemetry spans. */ purpose: string; /** * Receives every billed attempt exactly once — failed ones included, cache * hits never. Wire {@link journalJudgmentUsage} here to bill the session. */ onUsage?: (usage: JudgmentUsage) => void; /** Host telemetry; every attempt, cache hits included, emits one `judgment` span. */ telemetry?: AgentTelemetryConfig; /** Answer cache for native judgments; omitted runs every question against the provider. */ cache?: JudgmentCache; /** The session tree's cost gate: refuses each candidate attempt at the cap and adds billed attempts' cost. */ costGate?: CostGateApi; } /** * One attempt as observed by a backend wrapper, before {@link ChainJudge} * stamps its purpose. `questions`/`cachedQuestions` are set by native * judgments, which consult the answer cache. */ interface JudgmentAttempt extends Omit { startedAt: number; responseModel?: string; questions?: number; cachedQuestions?: number; } interface PreparedImages { images: ImageContent[]; fitted?: FittedImage[]; } /** Session journal surface that records off-transcript model cost; journal-only managers omit it. */ export type JudgmentUsageLedger = Pick; function isUsageLedger(manager: Partial): manager is JudgmentUsageLedger { return ( manager.appendModelUsage !== undefined && manager.getSessionId !== undefined && manager.getLeafId !== undefined ); } /** * Build a {@link JudgeDeps.onUsage} that journals every billed judgment attempt * as a `model_usage` entry beneath the session leaf at record time, so * `getSessionStats()` counts it in session totals. Attempts that land after * the session changes are dropped by the ledger. Returns `undefined` when the * journal cannot record usage. */ export function journalJudgmentUsage(manager: Partial | undefined): JudgeDeps["onUsage"] { if (!manager || !isUsageLedger(manager)) return undefined; const sessionId = manager.getSessionId(); return usage => { manager.appendModelUsage(usage, { sessionId, parentId: manager.getLeafId() }); }; } /** One keyword per answer; OpenAI-compatible endpoints reject budgets below 16. */ const LOCAL_ANSWER_MAX_TOKENS = 16; /** On-device reasoning models need room for the keyword after their `` preamble. */ const LOCAL_REASONING_MAX_TOKENS = 1024; /** * How long a candidate stays skipped after its account rejected a judgment * outright (401/403 credential, 402 billing cap). Every judgment rebuilds the * chain, so without this each call re-pays the rejected request plus a * credential-rotation round trip before reaching the next candidate. */ const CANDIDATE_REJECTION_COOLDOWN_MS = 5 * 60 * 1000; /** A transient hang should not consume the next judgment's budget in the same session. */ const CANDIDATE_TIMEOUT_COOLDOWN_MS = 30_000; /** * How long a resolved role chain is reused. Resolution filters the full * catalog (thousands of models) synchronously — milliseconds per call, which a * concurrent fan-out turns into sustained event-loop stalls. The chain is * shared by every judge built over the same settings and registry, since * per-call consumers (auto-thinking, subagent starts) resolve a fresh judge. */ const CANDIDATE_TTL_MS = 1_000; /** Skip-until timestamps keyed by routed model identity, carried by the registry that produced the rejection. */ const kRejections = Symbol("judgment.rejections"); const kTimeouts = Symbol("judgment.timeouts"); /** Last resolved judge role chain, carried by the registry it was drawn from. */ const kRoleChain = Symbol("judgment.roleChain"); interface RegistryWithRejections extends ModelRegistry { [kRejections]?: Map; [kTimeouts]?: Map; [kRoleChain]?: { settings: Settings; revision: number; roleChain: RoleChainCandidate[]; list: RoleChainCandidate[]; expiresAt: number; }; } /** * The resolved `judge` role chain and its text {@link judgeRoleChain}, reused for {@link CANDIDATE_TTL_MS} * across judges over the same settings and registry. A settings change (e.g. a new judge role) resolves * afresh; registry or credential changes still wait out the TTL, so {@link hasNativeJudge} (uncached) can * briefly see a different chain after a login or discovery change. */ function cachedJudgeRoleChain( settings: Settings, registry: RegistryWithRejections, ): { roleChain: RoleChainCandidate[]; list: RoleChainCandidate[] } { const now = Date.now(); const revision = settings.revision; const cached = registry[kRoleChain]; if (cached && cached.settings === settings && cached.revision === revision && now < cached.expiresAt) { return cached; } const roleChain = resolveRoleChain("judge", settings, roleCandidatePool("judge", settings, registry)); const entry = { settings, revision, roleChain, list: textJudgeRoleChain(roleChain), expiresAt: now + CANDIDATE_TTL_MS, }; registry[kRoleChain] = entry; return entry; } /** Append a compatible session model when no candidate is native and the chain does not already route to it. */ function withSessionFallback( candidates: RoleChainCandidate[], sessionModel: Model | undefined, requiresImages = false, ): RoleChainCandidate[] { if ( !sessionModel || candidates.some(candidate => kindOf(candidate) === "native") || (requiresImages && !supportsImageJudgment(sessionModel)) ) { return candidates; } const sessionIdentity = formatModelStringWithRouting(sessionModel); if (candidates.some(candidate => formatModelStringWithRouting(candidate.model) === sessionIdentity)) { return candidates; } return [...candidates, { model: sessionModel, explicit: false }]; } /** Which backend a judge-role candidate routes to: native System One decisions, on-device keywords, or a chat model. */ export type JudgeKind = "native" | "local" | "online"; /** Classify a role candidate by model API, never by provider identity. */ export function kindOf(candidate: RoleChainCandidate): JudgeKind; export function kindOf(model: Model): JudgeKind; export function kindOf(value: RoleChainCandidate | Model): JudgeKind { const model = "model" in value ? value.model : value; if (isJudgmentApi(model.api)) return "native"; if (model.api === "local-inference") return "local"; return "online"; } /** Whether the model's actual judgment transport sends image input. */ function supportsImageJudgment(model: Model): boolean { switch (kindOf(model)) { case "native": return nativeJudgmentSupportsImages(model); case "local": return false; case "online": return sendsImageInputOnWire(model); } } /** * The `judge` role's candidates in attempt order, drawn from credentialed * judge-capable models. From the first native candidate on, only native * candidates remain: a prompted model never stands in for a failed native * judgment, whose calibrated probabilities it cannot reproduce. */ function textJudgeRoleChain(chain: RoleChainCandidate[]): RoleChainCandidate[] { const firstNative = chain.findIndex(candidate => kindOf(candidate) === "native"); if (firstNative < 0) return chain; return chain.filter((candidate, index) => index < firstNative || kindOf(candidate) === "native"); } /** * Filter candidates that cannot take `images` before applying the native-only tail policy. A native * judge gets each image fitted into its bounds ({@link fitNativeImages}), so only its image support and * count cap decide; prompted chat models normalize images per model, so their image support alone decides. */ function imageJudgeRoleChain(chain: RoleChainCandidate[], images: readonly ImageContent[]): RoleChainCandidate[] { return textJudgeRoleChain( chain.filter(candidate => kindOf(candidate) === "native" ? nativeTakesImages(candidate.model, images) : supportsImageJudgment(candidate.model), ), ); } /** Whether native `model` takes `images`: image support within its count cap. Size bounds never decide; {@link fitNativeImages} fits every image into them. */ function nativeTakesImages(model: Model, images: readonly ImageContent[]): boolean { const bounds = nativeJudgmentImageBounds(model); return bounds !== undefined && images.length <= (bounds.maxCount ?? images.length); } /** Whether `questions` include a box question, which only natives whose wire forwards them answer. */ function asksForBoxes(questions: Questions): boolean { for (const id in questions) if (questions[id].type === "box") return true; return false; } /** {@link FittedImage.content} of an image sent as is. */ const WHOLE_IMAGE: BoxEdges = { top: 0, bottom: 1, left: 0, right: 1 }; /** * `images` sized into `model`'s native judgment bounds, which the API enforces by rejecting rather than * resizing: an image already within them is sent as is, any other goes through {@link fitImage}. A * request-wide byte cap is split evenly so the fitted set also fits as a whole. */ async function fitNativeImages(model: Model, images: readonly ImageContent[]): Promise { const bounds = nativeJudgmentImageBounds(model); if (!bounds) return images.map(image => ({ image, content: WHOLE_IMAGE })); const maxBytes = Math.min( bounds.maxBytes ?? Infinity, Math.floor((bounds.maxTotalBytes ?? Infinity) / images.length), ); const fitted: FittedImage[] = []; // Sequential on purpose: each fit decodes and re-encodes a whole image. for (const image of images) { const within = nativeJudgmentAcceptsImages(model, [image]) && Buffer.byteLength(image.data, "base64") <= maxBytes; fitted.push(within ? { image, content: WHOLE_IMAGE } : await fitImage(image, { ...bounds, maxBytes })); } return fitted; } /** * `result` with each box answer re-measured on the source image its question names: Jev measures the * image it was sent, and padding a fit shrinks the source to {@link FittedImage.content} of it. Answers * are copied, never mutated, since the answer cache may share them. */ function sourceBoxes( questions: Q, result: JudgmentResult, fitted: readonly FittedImage[], ): JudgmentResult { let answers: Record | undefined; for (const id in questions) { const question: Question = questions[id]; const answer: Answer = result.answers[id]; if (question.type !== "box" || answer.type !== "box") continue; answers ??= { ...result.answers }; answers[id] = { ...answer, box: unfitBox(answer.box, fitted[question.image].content) }; } // Only box answers change, and they stay box answers. return answers ? { ...result, answers: answers as JudgmentResult["answers"] } : result; } /** `box`, measured on a fitted canvas, measured on the source whose edges on it are `content`; edges in the padding clamp onto the source. */ function unfitBox(box: BoxEdges, content: BoxEdges): BoxEdges { const x = (edge: number) => Math.min(1, Math.max(0, (edge - content.left) / (content.right - content.left))); const y = (edge: number) => Math.min(1, Math.max(0, (edge - content.top) / (content.bottom - content.top))); return { top: y(box.top), bottom: y(box.bottom), left: x(box.left), right: x(box.right) }; } function judgeRoleChain(settings: Settings, registry: ModelRegistry): RoleChainCandidate[] { return textJudgeRoleChain(resolveRoleChain("judge", settings, roleCandidatePool("judge", settings, registry))); } /** * Whether the `judge` role resolves first to a native judgment backend * (TypeSafe jev, OpenRouter or OpenAI Decisions, Workers AI Clef) rather than a * prompted on-device or chat model. Judge-heavy features gate on it, e.g. the `find` * tool under `find.enabled: auto`. */ export function hasNativeJudge(settings: Settings, registry: ModelRegistry): boolean { const [primary] = judgeRoleChain(settings, registry); return primary !== undefined && kindOf(primary) === "native"; } /** Resolve a live judge-role chain. Candidates resolve lazily and are reused for {@link CANDIDATE_TTL_MS}. */ export function resolveJudge(deps: JudgeDeps): ChainJudge { return new ChainJudge(deps); } /** * Judge facade that falls through the live `judge` role chain. `withCandidate` * lets a caller choose candidate-specific questions while retaining the exact * same credential, failure, timeout, and abort semantics as ordinary `judge`. */ export class ChainJudge implements Judge { readonly label = "judge role chain"; readonly #deps: JudgeDeps; readonly #telemetry: AgentTelemetry | undefined; /** Text-judgment candidates memoized per resolved chain; image candidates depend on each request's images. */ #candidates: { chain: RoleChainCandidate[]; list: RoleChainCandidate[] } | undefined; /** Without a session id, cooldowns belong to this judge, not every anonymous caller of the registry. */ #anonymousTimeouts: Map | undefined; constructor(deps: JudgeDeps) { this.#deps = deps; this.#telemetry = resolveTelemetry(deps.telemetry, deps.sessionId); } async judge( request: JudgmentRequest, options: JudgeOptions = {}, ): Promise> { const requestImages = request.images ?? []; // Malformed input is the caller's error, not a candidate failure: reject it before trying any candidate. for (const [index, image] of requestImages.entries()) { if (!isEmbeddedImageContent(image)) { throw new AIError.ValidationError( `judgment: images[${index}] must contain non-empty base64 data and an image MIME type`, ); } } checkBoxQuestions(request); return this.#withCandidate( async (candidateJudge, _kind, _model, prepared) => { if (!prepared) return candidateJudge.judge(request, options); const result = await candidateJudge.judge({ ...request, images: prepared.images }, options); return prepared.fitted ? sourceBoxes(request.questions, result, prepared.fitted) : result; }, options, request, requestImages.length === 0 ? undefined : async (model, kind) => { if (kind === "local") return undefined; const fitted = kind === "native" ? await fitNativeImages(model, requestImages) : undefined; const images = fitted ? fitted.map(fit => fit.image) : ((await normalizeModelContextImages([...requestImages], { model })) ?? []); return { images, fitted }; }, ); } /** * Model of the first judge-role candidate, i.e. the one a judgment routes to * when it is credentialed and healthy. Used to price work before running it; * undefined when no candidate resolves. */ primaryModel(): Model | undefined { return this.#resolveCandidates()[0]?.model; } async withCandidate(run: (judge: Judge, kind: JudgeKind) => Promise, options: JudgeOptions = {}): Promise { return this.#withCandidate((judge, kind) => run(judge, kind), options); } /** Run `run` against each candidate able to answer `request` (any candidate when omitted) until one succeeds. */ async #withCandidate( run: (judge: Judge, kind: JudgeKind, model: Model, prepared?: PreparedImages) => Promise, options: JudgeOptions, request?: JudgmentRequest, prepare?: (model: Model, kind: JudgeKind) => Promise, ): Promise { const requiresImages = (request?.images?.length ?? 0) > 0; const signal = options.signal; const timeoutMs = cfgJudgmentAttemptTimeoutMs.get(this.#deps.settings); let lastFailure: string | undefined; let lastUnavailable: string | undefined; let compatibleCandidate = false; let unsupportedCandidate = false; const candidates = this.#resolveCandidates(request); const rejections = this.#rejections(); const timeouts = this.#timeouts(); // A slow judge only gives way to a fallback the user configured: the per-attempt deadline and the // timed-out-route skip apply while an explicit candidate remains, and a timeout never hands the work // to an implicit (priority-default or session) candidate. const lastExplicitIndex = candidates.findLastIndex(candidate => candidate.explicit); let timedOutBefore = false; for (const [index, candidate] of candidates.entries()) { if (signal?.aborted) { throw signal.reason instanceof Error ? signal.reason : new AIError.AbortError("judgment aborted"); } if (timedOutBefore && !candidate.explicit) continue; const configuredFallbackRemains = index < lastExplicitIndex; const identity = formatModelStringWithRouting(candidate.model); const skippedUntil = rejections.get(identity); if (skippedUntil !== undefined) { if (skippedUntil > Date.now()) { lastUnavailable = `${identity} rejected the account recently`; continue; } rejections.delete(identity); } const timeoutKey = `${this.#deps.sessionId ?? ""}\0${identity}`; if (configuredFallbackRemains && (timeouts.get(timeoutKey) ?? 0) > Date.now()) { lastUnavailable = `${identity} timed out recently`; timedOutBefore = true; continue; } // Outside the candidate's failure handling: a refusal ends the judgment. this.#deps.costGate?.admit(); const kind = kindOf(candidate); let timeout: AbortSignal | undefined; try { // Image fitting is local work, not evidence that a provider route has stalled. const prepared = prepare ? await untilAborted(signal, () => prepare(candidate.model, kind)) : undefined; timeout = kind === "native" && configuredFallbackRemains ? AbortSignal.timeout(timeoutMs) : undefined; const candidateSignal = timeout ? (signal ? AbortSignal.any([signal, timeout]) : timeout) : signal; const judge = await untilAborted(candidateSignal, () => this.#createJudge(candidate, candidateSignal)); if (!judge) { lastUnavailable = `no API key for ${candidate.model.provider}/${candidate.model.id}`; continue; } if (requiresImages && judge.supportsImages !== true) { unsupportedCandidate = true; lastUnavailable = `${identity} does not support image inputs`; continue; } compatibleCandidate = true; return await untilAborted(candidateSignal, () => run(judge, kind, candidate.model, prepared)); } catch (error) { if (signal?.aborted) { throw signal.reason instanceof Error ? signal.reason : new AIError.AbortError("judgment aborted"); } const timedOut = timeout !== undefined && (timeout.aborted || (error instanceof Error && error.name === "TimeoutError")); if (!timedOut && isAbortOrTimeout(error)) throw error; const rejected = isAccountRejection(error); if (rejected) rejections.set(identity, Date.now() + CANDIDATE_REJECTION_COOLDOWN_MS); if (timedOut) { timeouts.set(timeoutKey, Date.now() + CANDIDATE_TIMEOUT_COOLDOWN_MS); timedOutBefore = true; } lastFailure = error instanceof Error ? error.message : String(error); logger.warn("judgment candidate failed", { candidate: identity, status: AIError.status(error), error: lastFailure, skippedForMs: rejected ? CANDIDATE_REJECTION_COOLDOWN_MS : timedOut ? CANDIDATE_TIMEOUT_COOLDOWN_MS : undefined, }); } } if (requiresImages && !compatibleCandidate && unsupportedCandidate) { throw new Error("judgment: no compatible judge candidate supports images"); } if (candidates.length === 0) { if (request && asksForBoxes(request.questions)) { throw new Error("judgment: no judge candidate answers box questions"); } if (requiresImages) throw new Error("judgment: no compatible judge candidate supports images"); throw new Error("judgment: no judge model available"); } throw new Error(`judgment: every judge candidate failed: ${lastFailure ?? lastUnavailable ?? "unknown error"}`); } /** Attribute one attempt: the ledger unless answered wholly from cache, and one telemetry span. */ #report(attempt: JudgmentAttempt): void { const { startedAt, responseModel, questions, cachedQuestions, ...rest } = attempt; const usage: JudgmentUsage = { purpose: this.#deps.purpose, ...rest }; if (questions === undefined || cachedQuestions !== questions) { this.#deps.costGate?.record(usage.usage.cost.total); this.#deps.onUsage?.(usage); } recordJudgmentTelemetry(this.#telemetry, { provider: usage.provider, model: usage.model, responseModel, purpose: usage.purpose, usage: usage.usage, stopReason: usage.stopReason, errorMessage: usage.errorMessage, startTime: startedAt, questions, cachedQuestions, }).catch(error => logger.warn("judgment telemetry failed", { error: String(error) })); } #rejections(): Map { const registry: RegistryWithRejections = this.#deps.registry; return (registry[kRejections] ??= new Map()); } #timeouts(): Map { const registry: RegistryWithRejections = this.#deps.registry; const timeouts = this.#deps.sessionId === undefined ? (this.#anonymousTimeouts ??= new Map()) : (registry[kTimeouts] ??= new Map()); const now = Date.now(); for (const [identity, expiresAt] of timeouts) { if (expiresAt <= now) timeouts.delete(identity); } return timeouts; } #resolveCandidates(request?: JudgmentRequest): RoleChainCandidate[] { const { settings, registry, sessionModel } = this.#deps; const { roleChain, list: chain } = cachedJudgeRoleChain(settings, registry); const images = request?.images ?? []; // A prompted model cannot locate anything, so no session fallback either. if (request && asksForBoxes(request.questions)) { return roleChain.filter( candidate => nativeJudgmentSupportsBoxes(candidate.model) && nativeTakesImages(candidate.model, images), ); } if (images.length > 0) return withSessionFallback(imageJudgeRoleChain(roleChain, images), sessionModel, true); if (chain === this.#candidates?.chain) return this.#candidates.list; const list = withSessionFallback(chain, sessionModel); this.#candidates = { chain, list }; return list; } async #createJudge(candidate: RoleChainCandidate, signal: AbortSignal | undefined): Promise { const model = candidate.model; if (model.api === "local-inference") return new TextJudge(new LocalTextBackend(model.id)); if (!(await this.#deps.registry.getApiKey(model, this.#deps.sessionId, { signal }))) return undefined; const apiKey = this.#deps.registry.resolver(model, this.#deps.sessionId); if (isJudgmentApi(model.api)) { const headers = await this.#deps.registry.resolveModelHeaders(model, signal); const judge = createNativeJudge(model, { apiKey, headers }); return withCandidateSignal( nativeJudge(judge, model, this.#deps.cache, attempt => this.#report(attempt)), signal, ); } // Resolve metadata after getApiKey so the session-sticky credential is recorded first. const metadata = this.#deps.metadataResolver?.(model.provider); const backend = chatTextBackend(model, { apiKey, sessionId: this.#deps.sessionId, metadata, // Sole attribution point for prompted judgments: TextJudge's result.usage // aggregates these same attempts and must never be billed again. onAttempt: attempt => this.#report({ role: "judge", api: attempt.api, provider: attempt.provider, model: attempt.model, usage: attempt.usage, stopReason: attempt.stopReason, errorMessage: attempt.errorMessage, startedAt: attempt.timestamp, }), }); return new TextJudge(backend); } } /** Keyword completions through the shared on-device tiny-model worker. */ class LocalTextBackend implements TextBackend { readonly api = "local-inference"; readonly provider = "local"; readonly guardState = false; readonly model: string; readonly #reasoning: boolean; constructor(modelId: string) { this.model = modelId; this.#reasoning = getTinyLocalModelSpec(modelId)?.reasoning === true; } async complete(judgment: TextPrompt, options: JudgeOptions): Promise { // Sub-2B models answer a bare user message as a question (or echo the // system prompt); one merged turn ending in `Answer:` keeps them classifying. const text = await tinyModelClient.complete( this.model, prompt.render(localPromptTemplate, { system: judgment.system, state: judgment.user }), { maxTokens: this.#reasoning ? LOCAL_REASONING_MAX_TOKENS : LOCAL_ANSWER_MAX_TOKENS, signal: options.signal, }, ); if (!text) throw new Error(`judgment: local model ${this.model} returned no output`); return { text }; } } /** Bind native transport cancellation even when a withCandidate callback omits its call options. */ function withCandidateSignal(judge: Judge, parent: AbortSignal | undefined): Judge { return { label: judge.label, ...(judge.supportsImages === undefined ? {} : { supportsImages: judge.supportsImages }), async judge( request: JudgmentRequest, options?: JudgeOptions, ): Promise> { const signal = parent ? options?.signal && options.signal !== parent ? AbortSignal.any([parent, options.signal]) : parent : options?.signal; return judge.judge(request, { ...options, signal }); }, }; } /** * Native judgment backend. Questions already answered about the same state * under this model come from `cache`; only the rest reach the provider (none * when every answer is cached). Each call reports exactly one attempt — failed * requests included so the ledger shows why a judgment errored. Most judgment * APIs report tokens only, so a response without a billed amount is priced from * the catalog model; a route that bills (OpenRouter) keeps its reported cost. * Attempts and results carry the catalog id, not the wire id the judge sends. */ function nativeJudge( judge: NativeJudge, model: Model, cache: JudgmentCache | undefined, report: (attempt: JudgmentAttempt) => void, ): Judge { const cacheModel = `${model.provider}/${model.id}`; return { label: judge.label, ...(judge.supportsImages === undefined ? {} : { supportsImages: judge.supportsImages }), async judge( request: JudgmentRequest, options?: JudgeOptions, ): Promise> { const startedAt = Date.now(); const cacheRequest = request.images === undefined ? request : { ...request, images: request.images.map(image => ({ ...image, detail: judge.effectiveImageDetail(image.detail), })), }; const cached = cache?.lookup(cacheModel, cacheRequest); const known = cached?.answers ?? {}; const pending: Questions = {}; let questions = 0; let pendingCount = 0; for (const id in request.questions) { questions++; if (known[id] !== undefined) continue; pending[id] = request.questions[id]; pendingCount++; } const attempt = { role: TYPESAFE_PROVIDER, api: judge.api, provider: judge.provider, model: model.id, startedAt, questions, cachedQuestions: questions - pendingCount, }; let fresh: JudgmentResult | undefined; if (pendingCount > 0 || questions === 0) { let partial = tokenUsage(0, 0); try { fresh = await judge.judge( { state: request.state, questions: pending, ...(request.images === undefined ? {} : { images: request.images }), }, { signal: options?.signal, onPartialUsage: usage => { partial = usage; }, }, ); } catch (error) { // A split judgment that fails still billed the chunks that succeeded. if (partial.cost.total === 0) calculateCost(model, partial); report({ ...attempt, usage: partial, stopReason: isAbortOrTimeout(error) ? "aborted" : "error", errorMessage: error instanceof Error ? error.message : String(error), }); throw error; } if (fresh.usage.cost.total === 0) calculateCost(model, fresh.usage); if (cache && cached) cache.record(cacheModel, cached, pending, fresh); } const usage = fresh?.usage ?? tokenUsage(0, 0); report({ ...attempt, usage, stopReason: "stop", responseModel: fresh?.model }); const answers: Record = {}; for (const id in request.questions) { const answer = known[id] ?? fresh?.answers[id]; if (answer) answers[id] = answer; } return { api: judge.api, provider: judge.provider, model: fresh?.model ?? model.id, // Every id was answered by the cache or by `fresh`, which NativeJudge validated per question type. answers: answers as JudgmentResult["answers"], usage, }; }, }; } /** Credential or billing rejection: the account cannot serve this candidate until something changes out of band. */ function isAccountRejection(error: unknown): boolean { const status = AIError.status(error); return status === 401 || status === 402 || status === 403; } function isAbortOrTimeout(error: unknown): boolean { if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) return true; return AIError.is(AIError.classify(error), AIError.Flag.Abort); }