diff --git a/src/__tests__/ai-reasoning-admin-guard.test.ts b/src/__tests__/ai-reasoning-admin-guard.test.ts new file mode 100644 index 000000000..f40ab3103 --- /dev/null +++ b/src/__tests__/ai-reasoning-admin-guard.test.ts @@ -0,0 +1,151 @@ +/** + * v1.41 — the operator's reasoning switch and cap cannot be bypassed. + * + * Two halves. + * + * Behaviour: `resolveReasoning` is the one decision. With the switch off it + * answers `off` for the Coach and every background job, whatever the person + * chose, whoever pays and whatever the provider can do; with it on, no answer + * is above the cap. Proven exhaustively over the whole input space. + * + * Structure: a call carries reasoning only through `CompletionParams.reasoning`. + * Every production file outside the provider clients that puts that key on a + * call must take its value from the resolver (`@/lib/ai/reasoning/resolve` or + * `@/lib/ai/reasoning/controls`). A file that hand-built `{ effort: "high" }` + * would skip the switch, and goes red here. + * + * Its limit: the structural half reads source text for the setter shapes in + * use (`reasoning: { effort`, `{ reasoning }`, `reasoning: completionReasoning(`, + * `reasoning: resolveJobReasoning(`). A value laundered through another name + * slips it; the resolver import is still required of the file that sets it. + * + * Mutation check: replace `resolveJobReasoning(...)` in `status-provider.ts` + * with `{ effort: "high", summaries: false }` and drop the import: red. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + BACKGROUND_REASONING_JOBS, + REASONING_LEVELS, + REASONING_MAX_EFFORTS, +} from "@/lib/ai/reasoning/levels"; +import { + completionReasoning, + resolveReasoning, + type ReasoningSurface, +} from "@/lib/ai/reasoning/resolve"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +const SURFACES: ReasoningSurface[] = ["coach", ...BACKGROUND_REASONING_JOBS]; + +describe("the switch and the cap win, behaviourally", () => { + const supports = [ + null, + { + effort: true, + summaries: true, + liveSummaries: true, + stateRoundTrip: true, + offIsReal: false, + }, + ]; + + it("off reaches no surface as anything but off", () => { + let cases = 0; + for (const surface of SURFACES) + for (const userPref of REASONING_LEVELS) + for (const costOwner of ["user", "operator"] as const) + for (const support of supports) { + const r = resolveReasoning({ + surface, + userPref, + admin: { enabled: false, maxEffort: "high" }, + costOwner, + support, + }); + expect(r.effort).toBe("off"); + expect(r.source).toBe("admin_off"); + const sent = completionReasoning(r, surface); + // A job sends nothing; the Coach sends an explicit off. + if (surface === "coach") { + expect(sent).toEqual({ effort: "off", summaries: false }); + } else { + expect(sent).toBeUndefined(); + } + cases++; + } + expect(cases).toBeGreaterThanOrEqual(100); + }); + + it("no surface ever resolves above the cap", () => { + for (const surface of SURFACES) + for (const userPref of REASONING_LEVELS) + for (const maxEffort of REASONING_MAX_EFFORTS) { + const r = resolveReasoning({ + surface, + userPref, + admin: { enabled: true, maxEffort }, + costOwner: "user", + }); + expect(REASONING_LEVELS.indexOf(r.effort)).toBeLessThanOrEqual( + REASONING_LEVELS.indexOf(maxEffort), + ); + } + }); +}); + +/** The provider clients map an already-resolved value onto their wire. */ +const WIRE_LAYER = (rel: string) => + /^lib\/ai\/[a-z-]+-client\.ts$/.test(rel) || + rel === "lib/ai/provider-runner.ts" || + rel.startsWith("lib/ai/reasoning/"); + +const SETTERS = [ + /\breasoning\s*:\s*\{\s*effort\b/, + /\{\s*reasoning\s*\}/, + /\breasoning\s*:\s*(?:completionReasoning|resolveJobReasoning)\s*\(/, +]; + +const RESOLVER_IMPORT = + /from\s+["']@\/lib\/ai\/reasoning\/(?:resolve|controls)["']/; + +function stripComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +describe("every call that carries reasoning took it from the resolver", () => { + const files = walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + // Client components bind a form field called `reasoning`, not a call. + .filter((p) => !p.startsWith("components/")) + .filter((p) => !WIRE_LAYER(p)); + const setters = files.filter((rel) => { + const code = stripComments(readFileSync(join(SRC, rel), "utf8")); + return SETTERS.some((re) => re.test(code)); + }); + + it("finds the setters (an empty set is a failure, not a pass)", () => { + expect(setters.length).toBeGreaterThanOrEqual(2); + expect(setters).toContain("lib/insights/status-provider.ts"); + expect(setters).toContain("lib/insights/briefing-provider.ts"); + }); + + it("each one imports the resolver", () => { + expect( + setters.filter( + (rel) => !RESOLVER_IMPORT.test(readFileSync(join(SRC, rel), "utf8")), + ), + "A call carries reasoning that did not come from resolveReasoning, so the operator's switch and cap do not reach it.", + ).toEqual([]); + }); +}); diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index 4f28958f1..847a9c442 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -183,7 +183,7 @@ function buildUserProvider(row: UserAIRow): AIProvider | null { * An unparseable value answers false and keeps the OpenAI path, which is the * behaviour that value had before this function existed. */ -function isAnthropicBaseUrl(baseUrl: string): boolean { +export function isAnthropicBaseUrl(baseUrl: string): boolean { let host: string; try { host = new URL(baseUrl).hostname.toLowerCase(); diff --git a/src/lib/ai/reasoning/__tests__/resolve.test.ts b/src/lib/ai/reasoning/__tests__/resolve.test.ts new file mode 100644 index 000000000..bfdc33372 --- /dev/null +++ b/src/lib/ai/reasoning/__tests__/resolve.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, it } from "vitest"; + +import { + BACKGROUND_REASONING_JOBS, + REASONING_LEVELS, + REASONING_MAX_EFFORTS, +} from "../levels"; +import { + coachReasoningCeiling, + completionReasoning, + parseReasoningMaxEffort, + reasoningSupportForChainEntry, + resolveReasoning, + type ReasoningSurface, +} from "../resolve"; +import type { ReasoningSupport } from "../support"; + +const ON = { enabled: true, maxEffort: "high" } as const; +const FULL: ReasoningSupport = { + effort: true, + summaries: true, + liveSummaries: true, + stateRoundTrip: true, + offIsReal: true, +}; +const NO_REASONING: ReasoningSupport = { ...FULL, effort: false }; +const SURFACES: ReasoningSurface[] = ["coach", ...BACKGROUND_REASONING_JOBS]; +const rank = (l: string) => (REASONING_LEVELS as readonly string[]).indexOf(l); + +describe("resolveReasoning — the Coach", () => { + it("runs the person's level when nothing caps it", () => { + for (const pref of REASONING_LEVELS) { + expect( + resolveReasoning({ + surface: "coach", + userPref: pref, + admin: ON, + costOwner: "user", + support: FULL, + }), + ).toEqual({ effort: pref, summaries: pref !== "off", source: "user" }); + } + }); + + it("defaults a person who never chose to medium", () => { + const r = resolveReasoning({ + surface: "coach", + admin: ON, + costOwner: "user", + }); + expect(r).toEqual({ effort: "medium", summaries: true, source: "user" }); + }); + + it("clamps the person's level to the operator's cap", () => { + const r = resolveReasoning({ + surface: "coach", + userPref: "high", + admin: { enabled: true, maxEffort: "low" }, + costOwner: "user", + support: FULL, + }); + expect(r).toEqual({ effort: "low", summaries: true, source: "admin_cap" }); + }); + + it("holds an operator-funded turn to medium", () => { + const r = resolveReasoning({ + surface: "coach", + userPref: "high", + admin: ON, + costOwner: "operator", + support: FULL, + }); + expect(r).toEqual({ + effort: "medium", + summaries: true, + source: "cost_cap", + }); + }); + + it("names the operator's cap when it is the lower of the two", () => { + const r = resolveReasoning({ + surface: "coach", + userPref: "high", + admin: { enabled: true, maxEffort: "low" }, + costOwner: "operator", + }); + expect(r.source).toBe("admin_cap"); + expect(r.effort).toBe("low"); + }); + + it("asks for no summaries where the provider gives none", () => { + const r = resolveReasoning({ + surface: "coach", + userPref: "medium", + admin: ON, + costOwner: "user", + support: { ...FULL, summaries: false }, + }); + expect(r.summaries).toBe(false); + }); + + it("is off for a provider that cannot reason", () => { + const r = resolveReasoning({ + surface: "coach", + userPref: "high", + admin: ON, + costOwner: "user", + support: NO_REASONING, + }); + expect(r).toEqual({ + effort: "off", + summaries: false, + source: "unsupported", + }); + }); +}); + +describe("resolveReasoning — background jobs", () => { + it("never reads the Coach preference", () => { + const r = resolveReasoning({ + surface: "daily_briefing", + userPref: "off", + admin: ON, + costOwner: "user", + }); + expect(r).toEqual({ effort: "medium", summaries: false, source: "job" }); + }); + + it("takes the operator level when the operator pays", () => { + expect( + resolveReasoning({ + surface: "daily_briefing", + admin: ON, + costOwner: "operator", + }).effort, + ).toBe("low"); + expect( + resolveReasoning({ + surface: "period_narrative_month", + admin: ON, + costOwner: "user", + }).effort, + ).toBe("medium"); + expect( + resolveReasoning({ + surface: "period_narrative_week", + admin: ON, + costOwner: "user", + }).effort, + ).toBe("low"); + }); + + it("never asks a job for summaries", () => { + for (const job of BACKGROUND_REASONING_JOBS) { + expect( + resolveReasoning({ + surface: job, + admin: ON, + costOwner: "user", + support: FULL, + }).summaries, + ).toBe(false); + } + }); +}); + +describe("the operator's switch and cap win everywhere", () => { + const owners = ["user", "operator"] as const; + const supports = [null, FULL, NO_REASONING]; + const prefs = [undefined, ...REASONING_LEVELS]; + + it("off is off on every surface, for every preference, payer and provider", () => { + let cases = 0; + for (const surface of SURFACES) + for (const userPref of prefs) + for (const costOwner of owners) + for (const support of supports) + for (const maxEffort of REASONING_MAX_EFFORTS) { + const r = resolveReasoning({ + surface, + userPref, + admin: { enabled: false, maxEffort }, + costOwner, + support, + }); + expect(r).toEqual({ + effort: "off", + summaries: false, + source: "admin_off", + }); + cases++; + } + expect(cases).toBeGreaterThan(500); + }); + + it("no resolution ever exceeds the operator's cap", () => { + for (const surface of SURFACES) + for (const userPref of prefs) + for (const costOwner of owners) + for (const maxEffort of REASONING_MAX_EFFORTS) { + const r = resolveReasoning({ + surface, + userPref, + admin: { enabled: true, maxEffort }, + costOwner, + support: FULL, + }); + expect(rank(r.effort)).toBeLessThanOrEqual(rank(maxEffort)); + if (costOwner === "operator") { + expect(rank(r.effort)).toBeLessThanOrEqual(rank("medium")); + } + } + }); + + it("a job resolved off under the switch sends nothing at all", () => { + for (const job of BACKGROUND_REASONING_JOBS) { + const r = resolveReasoning({ + surface: job, + admin: { enabled: false, maxEffort: "high" }, + costOwner: "user", + }); + expect(completionReasoning(r, job)).toBeUndefined(); + } + }); +}); + +describe("completionReasoning", () => { + it("sends the Coach's off explicitly, so a provider that can switch off does", () => { + const r = resolveReasoning({ + surface: "coach", + userPref: "off", + admin: ON, + costOwner: "user", + }); + expect(completionReasoning(r, "coach")).toEqual({ + effort: "off", + summaries: false, + }); + }); + + it("sends nothing for a provider that cannot reason", () => { + const r = resolveReasoning({ + surface: "coach", + userPref: "high", + admin: ON, + costOwner: "user", + support: NO_REASONING, + }); + expect(completionReasoning(r, "coach")).toBeUndefined(); + }); + + it("carries a job's level without summaries", () => { + const r = resolveReasoning({ + surface: "daily_briefing", + admin: ON, + costOwner: "user", + }); + expect(completionReasoning(r, "daily_briefing")).toEqual({ + effort: "medium", + summaries: false, + }); + }); +}); + +describe("coachReasoningCeiling", () => { + it("is off when switched off, the cap otherwise, medium at most for the operator", () => { + expect( + coachReasoningCeiling({ enabled: false, maxEffort: "high" }, "user"), + ).toBe("off"); + expect( + coachReasoningCeiling({ enabled: true, maxEffort: "high" }, "user"), + ).toBe("high"); + expect( + coachReasoningCeiling({ enabled: true, maxEffort: "high" }, "operator"), + ).toBe("medium"); + expect( + coachReasoningCeiling({ enabled: true, maxEffort: "low" }, "operator"), + ).toBe("low"); + }); +}); + +describe("parseReasoningMaxEffort", () => { + it("keeps the three caps and reads anything else as no cap", () => { + expect(parseReasoningMaxEffort("low")).toBe("low"); + expect(parseReasoningMaxEffort("medium")).toBe("medium"); + expect(parseReasoningMaxEffort("off")).toBe("high"); + expect(parseReasoningMaxEffort(null)).toBe("high"); + }); +}); + +describe("reasoningSupportForChainEntry", () => { + it("reads the client each chain tag builds", () => { + expect(reasoningSupportForChainEntry("codex", {}).effort).toBe(true); + expect(reasoningSupportForChainEntry("admin-codex", {}).effort).toBe(true); + // The OpenAI client's default model does not reason. + expect(reasoningSupportForChainEntry("openai", {}).effort).toBe(false); + expect( + reasoningSupportForChainEntry("openai", { model: "gpt-5" }).offIsReal, + ).toBe(false); + expect( + reasoningSupportForChainEntry("anthropic", { model: "claude-opus-5" }) + .offIsReal, + ).toBe(false); + expect( + reasoningSupportForChainEntry("admin-openai", { + adminIsAnthropic: true, + adminModel: "claude-opus-5", + }).offIsReal, + ).toBe(false); + expect( + reasoningSupportForChainEntry("openai-compatible", { + compatModel: "x", + compatBaseUrl: "https://openrouter.ai/api/v1", + }).stateRoundTrip, + ).toBe(true); + expect(reasoningSupportForChainEntry("local", {}).effort).toBe(true); + }); +}); diff --git a/src/lib/ai/reasoning/controls.ts b/src/lib/ai/reasoning/controls.ts new file mode 100644 index 000000000..4beea7ef7 --- /dev/null +++ b/src/lib/ai/reasoning/controls.ts @@ -0,0 +1,176 @@ +/** + * v1.41 — the reads behind a reasoning decision: the operator's two controls, + * and the resolved Coach state the account payload publishes. + * + * Server-only. The decision itself is the pure `resolveReasoning` in + * `./resolve.ts`; this file only gathers its inputs. + */ +import { prisma } from "@/lib/db"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { memoizePerRequest } from "@/lib/request-cache"; +import { isAnthropicBaseUrl, probeProviderChain } from "@/lib/ai/provider"; +import { isOperatorFundedProvider } from "@/lib/ai/coach/budget"; +import type { ProviderChainType } from "@/lib/ai/provider-chain"; +import type { ProviderWorkAuthority } from "@/lib/sharing/provider-work-authority"; +import type { CompletionParams } from "@/lib/ai/types"; +import { + coachReasoningLevel, + type CoachPrefs, +} from "@/lib/validations/coach-prefs"; + +import type { BackgroundReasoningJob } from "./levels"; +import { + coachReasoningCeiling, + completionReasoning, + parseReasoningMaxEffort, + reasoningSupportForChainEntry, + resolveReasoning, + type CoachReasoningState, + type ReasoningAdminControls, + type ReasoningCostOwner, +} from "./resolve"; + +/** + * Fails closed: an unreadable settings row reads as "reasoning off", so a + * storage blip can never hand out reasoning the operator switched off. The + * calls it affects still run, without reasoning. + */ +const UNREADABLE: ReasoningAdminControls = Object.freeze({ + enabled: false, + maxEffort: "low", +}); + +/** + * The operator's reasoning switch and cap. Memoised per request; read afresh + * inside a background run, so switching reasoning off at 02:05 stops the + * nightly pass that began at 02:00 for every account after the switch. + */ +export function loadReasoningControls(): Promise { + return memoizePerRequest( + "ai-reasoning-controls", + async () => { + try { + const row = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { aiReasoningEnabled: true, aiReasoningMaxEffort: true }, + }); + return { + enabled: row?.aiReasoningEnabled ?? true, + maxEffort: parseReasoningMaxEffort(row?.aiReasoningMaxEffort), + }; + } catch { + getEvent()?.addWarning( + "Failed to load the reasoning controls; reasoning is off for this request", + ); + return UNREADABLE; + } + }, + { freshInBackground: true }, + ); +} + +/** Who pays for a chain whose primary is `providerType` (`null`: none). */ +function costOwnerOf(providerType: string | undefined): ReasoningCostOwner { + return providerType === undefined || + isOperatorFundedProvider(providerType as ProviderChainType) + ? "operator" + : "user"; +} + +/** + * Resolve the Coach reasoning state for the account payload. The preference is + * the caller's own (Coach preferences belong to the person); the provider is + * the record's, read through the memoised presence probe the `ai` block + * already ran, plus one point read of the model columns. + */ +export async function loadCoachReasoningState(args: { + prefs: CoachPrefs; + recordId: string; + authority: ProviderWorkAuthority | null; +}): Promise { + const [admin, presence] = await Promise.all([ + loadReasoningControls(), + probeProviderChain(args.recordId, args.authority), + ]); + const primary = presence.entries[0]?.providerType as + ProviderChainType | undefined; + const costOwner = costOwnerOf(primary); + + let support = null; + if (primary !== undefined) { + const [row, settings] = await Promise.all([ + prisma.user.findUnique({ + where: { id: args.recordId }, + select: { aiModel: true, aiCompatModel: true, aiCompatBaseUrl: true }, + }), + primary === "admin-openai" + ? prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { adminAiModel: true, adminAiBaseUrl: true }, + }) + : Promise.resolve(null), + ]); + support = reasoningSupportForChainEntry(primary, { + model: row?.aiModel, + compatModel: row?.aiCompatModel, + compatBaseUrl: row?.aiCompatBaseUrl, + adminModel: settings?.adminAiModel, + adminIsAnthropic: settings?.adminAiBaseUrl + ? isAnthropicBaseUrl(settings.adminAiBaseUrl) + : false, + }); + } + + const preference = coachReasoningLevel(args.prefs); + const resolved = resolveReasoning({ + surface: "coach", + userPref: preference, + admin, + costOwner, + support, + }); + return { + level: resolved.effort, + preference, + maxLevel: coachReasoningCeiling(admin, costOwner), + available: admin.enabled && support !== null && support.effort, + offIsReal: support?.offIsReal ?? true, + source: resolved.source, + }; +} + +/** + * The reasoning one background call carries, or `undefined` for none. + * + * Only the jobs in `BACKGROUND_REASONING_JOBS` can ask; the operator's switch + * and cap apply like everywhere else, the payer comes from the chain's + * primary, and the provider's ability is left to the client (the model is not + * known here, and every client sends nothing it cannot honour and learns a + * refusal per endpoint). The decision is stamped on the wide event as meta, + * never as an action: the job owns the action name. + */ +export async function resolveJobReasoning( + job: BackgroundReasoningJob, + chain: ReadonlyArray<{ providerType: ProviderChainType }>, +): Promise { + // Reasoning is an improvement to a job, never a precondition for it: any + // failure here sends the call exactly as it went before reasoning existed. + let resolved; + try { + resolved = resolveReasoning({ + surface: job, + admin: await loadReasoningControls(), + costOwner: costOwnerOf(chain[0]?.providerType), + }); + } catch { + return undefined; + } + annotate({ + meta: { + ai_reasoning_job: job, + ai_reasoning_effort: resolved.effort, + ai_reasoning_source: resolved.source, + }, + }); + return completionReasoning(resolved, job); +} diff --git a/src/lib/ai/reasoning/resolve.ts b/src/lib/ai/reasoning/resolve.ts new file mode 100644 index 000000000..952ef52b4 --- /dev/null +++ b/src/lib/ai/reasoning/resolve.ts @@ -0,0 +1,260 @@ +/** + * v1.41 — how hard one call may think, decided once per Coach turn or job. + * + * ## The one entry point + * + * resolveReasoning({ surface, userPref, admin, costOwner, support }) + * → { effort, summaries, source } + * + * - `surface`: `"coach"` or one of the background jobs in + * `BACKGROUND_REASONING_JOBS`. Nothing else is representable, which is + * the point: a status card, a batch note, an archetype, a nudge or a + * document read has no surface to resolve against, so it cannot ask for + * reasoning by accident. + * - `userPref`: the person's Coach preference (`coachReasoningLevel(prefs)`). + * Read for the Coach only; a job's level comes from + * `BACKGROUND_REASONING_LEVEL`, never from the Coach preference. + * - `admin`: the operator's two controls, `loadReasoningControls()` in + * `./controls.ts` (one memoised read of the settings row). + * - `costOwner`: who pays the call, `resolveCostOwner(chain)` from the Coach + * budget module. An operator-funded chain is held to `medium` at most. + * - `support`: what the chain's primary can do (`reasoningSupport(...)`), or + * `null` when the caller does not know the model; unknown is optimistic, + * because the clients learn a refusal per endpoint (`dialect-cache.ts`). + * + * The order is fixed and every arm wins over the ones after it: the + * operator's switch, the provider's ability, the requested level, the + * operator's cap, the cost cap. `source` names the arm that decided, so a + * dashboard and the settings both say why a level is what it is. + * + * `completionReasoning(resolved, surface)` turns the answer into the + * `CompletionParams.reasoning` a call carries, or `undefined` when the call + * should go out exactly as it did before reasoning existed. Pass the answer + * budget as `maxTokens`; each client adds the thinking budget itself. + * + * A leaf on purpose: no Prisma, no provider client, no `provider-chain.ts` + * value import, so a worker, a route and a test can all call it. The loaders + * live in `./controls.ts`. + */ +import type { ProviderChainType } from "../provider-chain"; +import type { CompletionParams } from "../types"; +import { + BACKGROUND_REASONING_LEVEL, + DEFAULT_REASONING_LEVEL, + DEFAULT_REASONING_MAX_EFFORT, + REASONING_LEVELS, + REASONING_MAX_EFFORTS, + capReasoningLevel, + type BackgroundReasoningJob, + type ReasoningLevel, + type ReasoningMaxEffort, +} from "./levels"; +import { reasoningSupport, type ReasoningSupport } from "./support"; + +/** Where a resolution is asked for: the Coach, or one admitted job. */ +export type ReasoningSurface = "coach" | BackgroundReasoningJob; + +/** The operator's two controls on the settings row (migration 0376). */ +export interface ReasoningAdminControls { + enabled: boolean; + maxEffort: ReasoningMaxEffort; +} + +/** The controls an instance that never touched them has. */ +export const DEFAULT_REASONING_ADMIN_CONTROLS: ReasoningAdminControls = + Object.freeze({ + enabled: true, + maxEffort: DEFAULT_REASONING_MAX_EFFORT, + }); + +/** + * Why the level is what it is: + * + * - `user`: the person's Coach preference, unchanged. + * - `job`: the job's own level from the background table, unchanged. + * - `admin_off`: the operator switched reasoning off for the instance. + * - `admin_cap`: lowered to the operator's highest level. + * - `cost_cap`: lowered because the operator pays for the call. + * - `unsupported`: the provider cannot reason at all. + */ +export const REASONING_SOURCES = [ + "user", + "job", + "admin_off", + "admin_cap", + "cost_cap", + "unsupported", +] as const; + +export type ReasoningSource = (typeof REASONING_SOURCES)[number]; + +export interface ResolvedReasoning { + effort: ReasoningLevel; + /** Ask the provider for readable summaries (the Coach's live trail only). */ + summaries: boolean; + source: ReasoningSource; +} + +export type ReasoningCostOwner = "operator" | "user"; + +export interface ResolveReasoningInput { + surface: ReasoningSurface; + userPref?: ReasoningLevel | null; + admin: ReasoningAdminControls; + costOwner: ReasoningCostOwner; + support?: ReasoningSupport | null; +} + +/** + * The Coach reasoning block on `GET /api/auth/me`, resolved on the server + * (`loadCoachReasoningState`) and read by the web settings and the native + * client alike. Client-safe, which is why it lives here. + */ +export interface CoachReasoningState { + /** The level a Coach turn runs at now, every cap applied. */ + level: ReasoningLevel; + /** What the person picked (`coachPrefsJson.reasoning`), `medium` when never. */ + preference: ReasoningLevel; + /** The highest level the settings may offer; `off` when the operator switched it off. */ + maxLevel: ReasoningLevel; + /** False when the operator switched reasoning off or the provider cannot reason. */ + available: boolean; + /** False when the provider cannot switch reasoning off (the lowest option reads "Minimal"). */ + offIsReal: boolean; + /** Why `level` is what it is. */ + source: ReasoningSource; +} + +/** The highest level an operator-funded call may take (plan 7.2). */ +export const OPERATOR_FUNDED_REASONING_CAP: ReasoningMaxEffort = "medium"; + +/** Narrow an untrusted settings value onto the cap vocabulary. */ +export function parseReasoningMaxEffort(value: unknown): ReasoningMaxEffort { + return typeof value === "string" && + (REASONING_MAX_EFFORTS as readonly string[]).includes(value) + ? (value as ReasoningMaxEffort) + : DEFAULT_REASONING_MAX_EFFORT; +} + +function lower(a: ReasoningLevel, b: ReasoningLevel): boolean { + return REASONING_LEVELS.indexOf(a) < REASONING_LEVELS.indexOf(b); +} + +export function resolveReasoning( + input: ResolveReasoningInput, +): ResolvedReasoning { + const coach = input.surface === "coach"; + if (!input.admin.enabled) { + return { effort: "off", summaries: false, source: "admin_off" }; + } + if (input.support && !input.support.effort) { + return { effort: "off", summaries: false, source: "unsupported" }; + } + + let effort: ReasoningLevel; + let source: ReasoningSource; + if (input.surface === "coach") { + effort = input.userPref ?? DEFAULT_REASONING_LEVEL; + source = "user"; + } else { + const levels = BACKGROUND_REASONING_LEVEL[input.surface]; + effort = + input.costOwner === "operator" ? levels.operatorLevel : levels.level; + source = "job"; + } + + const adminCapped = capReasoningLevel(effort, input.admin.maxEffort); + if (lower(adminCapped, effort)) { + effort = adminCapped; + source = "admin_cap"; + } + if (input.costOwner === "operator") { + const costCapped = capReasoningLevel(effort, OPERATOR_FUNDED_REASONING_CAP); + if (lower(costCapped, effort)) { + effort = costCapped; + source = "cost_cap"; + } + } + + return { + effort, + summaries: coach && effort !== "off" && (input.support?.summaries ?? true), + source, + }; +} + +/** + * The `CompletionParams.reasoning` one call carries for `resolved`. + * + * `undefined` means "send what the call sent before reasoning existed": the + * provider cannot reason, or a background job resolved to `off` (the + * operator's switch included), which a job expresses by not asking. The Coach + * sends an explicit `off` when the person or the operator chose it, so a + * provider that can switch reasoning off really does (`offIsReal`). + */ +export function completionReasoning( + resolved: ResolvedReasoning, + surface: ReasoningSurface, +): CompletionParams["reasoning"] { + if (resolved.source === "unsupported") return undefined; + if (resolved.effort === "off" && surface !== "coach") return undefined; + return { effort: resolved.effort, summaries: resolved.summaries }; +} + +/** + * The highest level the Coach may take for this person right now: what the + * settings dropdown offers. `off` when the operator switched reasoning off. + */ +export function coachReasoningCeiling( + admin: ReasoningAdminControls, + costOwner: ReasoningCostOwner, +): ReasoningLevel { + if (!admin.enabled) return "off"; + return costOwner === "operator" + ? capReasoningLevel(admin.maxEffort, OPERATOR_FUNDED_REASONING_CAP) + : admin.maxEffort; +} + +/** + * What a chain entry can do with reasoning, from the configuration columns a + * presence read already holds (no client built, no key decrypted). The chain + * tag decides the client: the Codex paths are the Codex client, the person's + * OpenAI key and the operator's slot are the OpenAI client (or Anthropic, + * when the operator's base URL is Anthropic's), and so on. Model names fall + * back to the defaults the resolvers use. + */ +export function reasoningSupportForChainEntry( + providerType: ProviderChainType, + config: { + model?: string | null; + compatModel?: string | null; + compatBaseUrl?: string | null; + adminModel?: string | null; + adminIsAnthropic?: boolean; + }, +): ReasoningSupport { + switch (providerType) { + case "codex": + case "admin-codex": + return reasoningSupport("codex", ""); + case "openai": + return reasoningSupport("admin-key", config.model ?? "gpt-4o"); + case "anthropic": + return reasoningSupport("anthropic", config.model ?? "claude-sonnet-4-6"); + case "local": + return reasoningSupport("local", config.model ?? ""); + case "openai-compatible": + return reasoningSupport( + "openai-compatible", + config.compatModel ?? config.model ?? "", + config.compatBaseUrl ? { baseUrl: config.compatBaseUrl } : {}, + ); + case "admin-openai": + return config.adminIsAnthropic + ? reasoningSupport( + "anthropic", + config.adminModel ?? "claude-sonnet-4-6", + ) + : reasoningSupport("admin-key", config.adminModel ?? "gpt-4o"); + } +}