diff --git a/src/__tests__/background-reasoning-inventory.test.ts b/src/__tests__/background-reasoning-inventory.test.ts new file mode 100644 index 000000000..8b1c78cd9 --- /dev/null +++ b/src/__tests__/background-reasoning-inventory.test.ts @@ -0,0 +1,124 @@ +/** + * v1.41 — which background calls may reason, frozen. + * + * Reasoning in the background is paid for every night, for every account, by + * nobody watching. So it is admitted where it changes the result a person + * reads (the daily briefing and the period narratives) and nowhere else: not + * on status cards, batch notes, derived scores, workout and reaction lines, + * nudges, document reads or OCR, which are routine, latency-bound and + * seed-reproducible. + * + * The only way a background call asks is the `reasoningJob` argument of the + * two chokepoints (`runStatusCompletion`, `runBriefingCompletion`). This test + * finds every place that sets it and holds the set, file by file and job by + * job, against the inventory below. A status card that started passing a job + * goes red here; so does a new caller nobody admitted. + * + * Its limit: it reads source text for a `reasoningJob:` property and the job + * literals beside it. A call that built the argument object elsewhere and + * spread it in would slip the matcher; the chokepoint signature is the only + * documented way in, and the wire test (`background-reasoning-wire.test.ts`) + * proves a call without the argument sends nothing. + * + * Mutation check: add `reasoningJob: "daily_briefing"` to + * `src/lib/insights/status-batch.ts`, or delete it from the narrative + * generator: either goes red. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { BACKGROUND_REASONING_JOBS } from "@/lib/ai/reasoning/levels"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** File → the jobs it may name, each with the reason it may. */ +const ADMITTED: Record = { + "lib/insights/comprehensive-generate.ts": { + jobs: ["daily_briefing"], + why: "The daily briefing's generation and its phrasing re-roll; the JSON and grounding repairs do not reason.", + }, + "lib/insights/narrative/period-narrative-generate.ts": { + jobs: ["period_narrative_month", "period_narrative_week"], + why: "The weekly and monthly narratives' first pass; the grounding repair does not reason.", + }, +}; + +/** The routine generators that must never name a job. */ +const NEVER = [ + "lib/insights/status-batch.ts", + "lib/insights/status-card-generation.ts", + "lib/insights/metric-status.ts", + "lib/insights/biomarker-status.ts", + "lib/insights/derived/derived-assessment-ai.ts", + "lib/jobs/workout-insight-generate.ts", +]; + +function stripComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +/** `reasoningJob:` as a value, not the optional declaration `reasoningJob?:`. */ +const SETTER = /\breasoningJob\s*:(?!\s*BackgroundReasoningJob)/g; + +function scan(): Map }> { + const found = new Map }>(); + 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")); + for (const rel of files) { + const code = stripComments(readFileSync(join(SRC, rel), "utf8")); + const setters = code.match(SETTER)?.length ?? 0; + if (setters === 0) continue; + const jobs = new Set(); + for (const m of code.matchAll(SETTER)) { + const window = code.slice(m.index, m.index + 160); + for (const job of BACKGROUND_REASONING_JOBS) { + if (window.includes(`"${job}"`)) jobs.add(job); + } + } + found.set(rel, { setters, jobs }); + } + return found; +} + +describe("background reasoning inventory", () => { + const found = scan(); + + it("finds the admitted callers (an empty set is a failure, not a pass)", () => { + expect(found.size).toBeGreaterThanOrEqual(2); + }); + + it("only the admitted files name a job", () => { + expect([...found.keys()].sort()).toEqual(Object.keys(ADMITTED).sort()); + }); + + it.each(Object.entries(ADMITTED))( + "%s names exactly its admitted jobs", + (rel, { jobs }) => { + expect([...(found.get(rel)?.jobs ?? [])].sort()).toEqual( + [...jobs].sort(), + ); + }, + ); + + it.each(NEVER)("%s never reasons", (rel) => { + const code = stripComments(readFileSync(join(SRC, rel), "utf8")); + expect(code).not.toMatch(/\breasoningJob\b/); + expect(code).not.toMatch(/\breasoning\s*:/); + }); + + it("every admitted job is a known background job", () => { + for (const { jobs } of Object.values(ADMITTED)) { + for (const job of jobs) { + expect(BACKGROUND_REASONING_JOBS as readonly string[]).toContain(job); + } + } + }); +}); diff --git a/src/lib/insights/__tests__/background-reasoning-wire.test.ts b/src/lib/insights/__tests__/background-reasoning-wire.test.ts new file mode 100644 index 000000000..a135cbea1 --- /dev/null +++ b/src/lib/insights/__tests__/background-reasoning-wire.test.ts @@ -0,0 +1,249 @@ +/** + * v1.41 — what the two background chokepoints put on the wire about + * reasoning. + * + * Status cards and every other routine generation call `runStatusCompletion` + * without a job and must send no `reasoning` at all. The daily briefing and + * the period narratives name their job and get the resolved level, unless the + * operator switched reasoning off, which wins over every job. The operator's + * controls are read through the real loader against a stubbed settings row, + * so the switch is exercised the way production reads it. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { settingsRow } = vi.hoisted(() => ({ + settingsRow: { + current: null as null | { + aiReasoningEnabled: boolean; + aiReasoningMaxEffort: string; + }, + fail: false, + }, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { + findUnique: vi.fn(async () => { + if (settingsRow.fail) throw new Error("db down"); + return settingsRow.current; + }), + }, + user: { + findUnique: vi.fn(async () => ({ aiResponseTimeoutSeconds: null })), + }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: () => undefined, +})); + +const { resolveProviderChain } = vi.hoisted(() => ({ + resolveProviderChain: vi.fn(), +})); +vi.mock("@/lib/ai/provider", () => ({ + resolveProviderChain, + resolveProvider: vi.fn(), + probeProviderPresence: vi.fn(async () => true), + probeProviderChain: vi.fn(), + isAnthropicBaseUrl: () => false, +})); + +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: vi.fn(async () => ({ available: true })), +})); +vi.mock("@/lib/ai/capabilities/egress", () => ({ + aiEgressRefusal: vi.fn(async () => null), +})); + +const { reserveBudget } = vi.hoisted(() => ({ reserveBudget: vi.fn() })); +vi.mock("@/lib/ai/coach/budget", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + reserveBudget, + reconcileSpend: vi.fn(async () => undefined), + }; +}); + +const { runRawCompletionWithFallback } = vi.hoisted(() => ({ + runRawCompletionWithFallback: vi.fn(), +})); +vi.mock("@/lib/ai/provider-runner", () => ({ + AllProvidersFailedError: class extends Error {}, + runRawCompletionWithFallback, +})); + +import { runStatusCompletion } from "../status-provider"; +import { runBriefingCompletion } from "../briefing-provider"; +import type { CompletionParams } from "@/lib/ai/types"; + +const BYOK = [{ providerType: "anthropic" as const, instance: {} as never }]; +const OPERATOR = [ + { providerType: "admin-openai" as const, instance: {} as never }, +]; + +function sentParams(): CompletionParams { + const call = runRawCompletionWithFallback.mock.calls.at(-1)?.[0] as { + params: CompletionParams; + }; + return call.params; +} + +function reserved(): number { + return reserveBudget.mock.calls.at(-1)?.[1] as number; +} + +beforeEach(() => { + vi.clearAllMocks(); + settingsRow.current = null; + settingsRow.fail = false; + resolveProviderChain.mockResolvedValue(BYOK); + reserveBudget.mockImplementation(async (_u: string, n: number) => ({ + allowed: true, + reserved: n, + owner: "user", + })); + runRawCompletionWithFallback.mockResolvedValue({ + result: { content: '{"summary":"ok"}', tokensUsed: 10 }, + workingProvider: BYOK[0], + }); +}); + +const statusArgs = { + userId: "u1", + cacheAction: "status:test", + systemPrompt: "system", + userPrompt: "user", + capability: "statusText" as const, +}; + +describe("runStatusCompletion", () => { + it("sends no reasoning for a call that names no job (every status card)", async () => { + await runStatusCompletion(statusArgs); + expect(sentParams().reasoning).toBeUndefined(); + expect(sentParams().timeoutMs).toBe(60_000); + }); + + it("sends the job's level for a monthly narrative and reserves its thinking", async () => { + await runStatusCompletion({ + ...statusArgs, + capability: "periodNarrative", + maxTokens: 400, + reasoningJob: "period_narrative_month", + }); + expect(sentParams().reasoning).toEqual({ + effort: "medium", + summaries: false, + }); + // The answer budget only; the client adds the thinking budget. + expect(sentParams().maxTokens).toBe(400); + expect(sentParams().timeoutMs).toBe(90_000); + expect(reserved()).toBe(400 + 4_096 + Math.ceil(10 / 4)); + }); + + it("drops to the operator level when the operator pays", async () => { + resolveProviderChain.mockResolvedValue(OPERATOR); + await runStatusCompletion({ + ...statusArgs, + capability: "periodNarrative", + reasoningJob: "period_narrative_month", + }); + expect(sentParams().reasoning?.effort).toBe("low"); + }); + + it("honours the operator's cap", async () => { + settingsRow.current = { + aiReasoningEnabled: true, + aiReasoningMaxEffort: "low", + }; + await runStatusCompletion({ + ...statusArgs, + capability: "periodNarrative", + reasoningJob: "period_narrative_month", + }); + expect(sentParams().reasoning?.effort).toBe("low"); + }); + + it("sends nothing when the operator switched reasoning off", async () => { + settingsRow.current = { + aiReasoningEnabled: false, + aiReasoningMaxEffort: "high", + }; + await runStatusCompletion({ + ...statusArgs, + capability: "periodNarrative", + maxTokens: 400, + reasoningJob: "period_narrative_month", + }); + expect(sentParams().reasoning).toBeUndefined(); + expect(sentParams().timeoutMs).toBe(60_000); + expect(reserved()).toBe(400 + Math.ceil(10 / 4)); + }); + + it("sends nothing when the controls cannot be read (fails closed)", async () => { + settingsRow.fail = true; + const outcome = await runStatusCompletion({ + ...statusArgs, + capability: "periodNarrative", + reasoningJob: "period_narrative_week", + }); + expect(outcome.kind).toBe("ok"); + expect(sentParams().reasoning).toBeUndefined(); + }); +}); + +describe("runBriefingCompletion", () => { + const briefingArgs = { + userId: "u1", + chain: BYOK, + systemPrompt: "system", + userPrompt: "user", + temperature: 0.3, + maxTokens: 3000, + timeoutMs: 180_000, + stage: "generate" as const, + }; + + it("sends no reasoning without a job (the retries, the on-demand route)", async () => { + await runBriefingCompletion(briefingArgs); + expect(sentParams().reasoning).toBeUndefined(); + expect(reserved()).toBe(3000 + Math.ceil(10 / 4)); + }); + + it("thinks at medium on the person's own provider", async () => { + await runBriefingCompletion({ + ...briefingArgs, + reasoningJob: "daily_briefing", + }); + expect(sentParams().reasoning).toEqual({ + effort: "medium", + summaries: false, + }); + expect(sentParams().maxTokens).toBe(3000); + expect(reserved()).toBe(3000 + 4_096 + Math.ceil(10 / 4)); + }); + + it("thinks at low on the operator's provider", async () => { + await runBriefingCompletion({ + ...briefingArgs, + chain: OPERATOR, + reasoningJob: "daily_briefing", + }); + expect(sentParams().reasoning?.effort).toBe("low"); + }); + + it("sends nothing when the operator switched reasoning off", async () => { + settingsRow.current = { + aiReasoningEnabled: false, + aiReasoningMaxEffort: "high", + }; + await runBriefingCompletion({ + ...briefingArgs, + reasoningJob: "daily_briefing", + }); + expect(sentParams().reasoning).toBeUndefined(); + }); +}); diff --git a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts index 4d83ec073..c9a7376e9 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts @@ -38,6 +38,8 @@ vi.mock("@/lib/db", () => ({ }, }, auditLog: { deleteMany: vi.fn() }, + // v1.41 — the operator's reasoning controls; an untouched instance. + appSettings: { findUnique: vi.fn(async () => null) }, }, })); // The `briefing` capability is available in these fixtures; the refusal @@ -163,6 +165,8 @@ describe("daily-briefing re-roll on unchanged data", () => { const params = runRawCompletionWithFallback.mock.calls[0][0].params; expect(params.temperature).toBe(0.6); expect(params.seed).toBeUndefined(); + // v1.41 — the re-roll thinks like the generation it replaces. + expect(params.reasoning?.effort).toBe("medium"); // The cache write swaps only the paragraph; findings + summary preserved. const write = userUpdate.mock.calls.find( diff --git a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts index a667ad361..4f64b9a21 100644 --- a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts +++ b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts @@ -36,6 +36,8 @@ vi.mock("@/lib/db", () => ({ }, }, auditLog: { deleteMany: vi.fn() }, + // v1.41 — the operator's reasoning controls; an untouched instance. + appSettings: { findUnique: vi.fn(async () => null) }, }, })); // The `briefing` capability is available in these fixtures; the refusal @@ -141,6 +143,14 @@ describe("comprehensive JSON-retry", () => { expect(retryParams.messages[0].content).toContain( "did not satisfy the required", ); + // v1.41 — the generation reasons as the daily-briefing job; the JSON + // repair does not. + const firstParams = runRawCompletionWithFallback.mock.calls[0][0].params; + expect(firstParams.reasoning).toEqual({ + effort: "medium", + summaries: false, + }); + expect(retryParams.reasoning).toBeUndefined(); }); it("fails with invalid-json when both attempts miss", async () => { diff --git a/src/lib/insights/briefing-provider.ts b/src/lib/insights/briefing-provider.ts index 1b8d15079..a573e3a88 100644 --- a/src/lib/insights/briefing-provider.ts +++ b/src/lib/insights/briefing-provider.ts @@ -44,6 +44,11 @@ import { } from "@/lib/ai/coach/budget"; import { singleUserTurn } from "@/lib/ai/types"; import { annotate } from "@/lib/logging/context"; +import { + REASONING_THINKING_BUDGET, + type BackgroundReasoningJob, +} from "@/lib/ai/reasoning/levels"; +import { resolveJobReasoning } from "@/lib/ai/reasoning/controls"; /** * Which call on the briefing path this was. Threaded onto the refusal @@ -90,6 +95,14 @@ export interface RunBriefingCompletionArgs { seed?: number; /** UTC day-key override; defaults to today. Tests pin it. */ dateKey?: string; + /** + * v1.41 — `daily_briefing` on the calls that may reason (the generation and + * its phrasing re-roll). Absent on the JSON-shape and grounding retries, + * which repair a reply rather than think it through again, and on every + * caller outside the background generator. `maxTokens` stays the answer + * budget; the client adds the thinking budget itself. + */ + reasoningJob?: BackgroundReasoningJob; } /** @@ -111,11 +124,23 @@ export async function runBriefingCompletion( ): Promise { const dateKey = args.dateKey ?? buildDateKey(); + // v1.41 — one resolution per call: the operator's switch and cap, the payer + // from the chain's primary. `undefined` is today's wire. + const reasoning = args.reasoningJob + ? await resolveJobReasoning(args.reasoningJob, args.chain) + : undefined; + const thinkingTokens = + reasoning && reasoning.effort !== "off" + ? REASONING_THINKING_BUDGET[reasoning.effort] + : 0; + // Reserve an ESTIMATE up front — the output ceiling plus a ~4-chars-per-token // approximation of the prompt about to be sent — and reconcile against the // provider's reported count afterwards. Same shape as the status tier. + // Thinking is billed as output, so its budget is reserved too. const estimatedTokens = args.maxTokens + + thinkingTokens + Math.ceil((args.systemPrompt.length + args.userPrompt.length) / 4); // v1.38.19 — a background surface: half the day's ceiling when the @@ -153,17 +178,21 @@ export async function runBriefingCompletion( // the job share, exactly as the reservation above was. surface: "job", providers: args.chain, - params: singleUserTurn({ - system: args.systemPrompt, - user: args.userPrompt, - temperature: args.temperature, - maxTokens: args.maxTokens, - seed: args.seed, - timeoutMs: args.timeoutMs, - // Every briefing call parses its reply with `JSON.parse`, so opt the - // chains with a native JSON mode into it. - responseFormat: "json", - }), + params: { + ...singleUserTurn({ + system: args.systemPrompt, + user: args.userPrompt, + temperature: args.temperature, + maxTokens: args.maxTokens, + seed: args.seed, + timeoutMs: args.timeoutMs, + // Every briefing call parses its reply with `JSON.parse`, so opt the + // chains with a native JSON mode into it. + responseFormat: "json", + }), + // v1.41 — only a call that names its job carries this. + ...(reasoning ? { reasoning } : {}), + }, }); } catch (err) { // No usable reply: refund the whole reservation rather than bill an diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index c6d4c3162..1ddc772cc 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -249,6 +249,9 @@ async function rerollBriefingParagraph(args: { // v1.25 — honours the per-user response-timeout setting (see caller). timeoutMs: args.effectiveTimeoutMs, stage: "reroll", + // v1.41 — the re-roll is a fresh pass at the paragraph, so it may think + // like the generation it replaces; the operator's switch still wins. + reasoningJob: "daily_briefing", }); result = fallback.result; providerType = fallback.workingProvider.providerType; @@ -1132,6 +1135,11 @@ export async function generateComprehensiveInsight( // v1.25 — honours the per-user response-timeout setting. timeoutMs: effectiveTimeoutMs, stage: "generate", + // v1.41 — the briefing is one of the few background jobs that may + // reason (medium on the person's own provider, low on the operator's, + // never above the operator's cap, nothing when switched off). The JSON + // and grounding retries below repair a reply and do not reason. + reasoningJob: "daily_briefing", }); result = fallback.result; workingProviderType = fallback.workingProvider.providerType; diff --git a/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts index e44257ee4..6e9a0c5c2 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts @@ -221,6 +221,66 @@ describe("generatePeriodNarrative — descriptive generation", () => { }); }); +describe("generatePeriodNarrative — background reasoning (v1.41)", () => { + it.each([ + ["week", "period_narrative_week"], + ["month", "period_narrative_month"], + ] as const)( + "names the %s narrative's own job on the first pass", + async (period, job) => { + const prisma = makePrisma(); + const runCompletion = vi.fn(async (_args: { reasoningJob?: string }) => ({ + kind: "ok" as const, + content: "Your weight eased down slightly.", + providerType: "openai", + model: "m", + tokensUsed: 1, + })); + await generatePeriodNarrative("u1", { + period, + locale: "en", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma: prisma as any, + buildContext: async () => + readyContext({ period }) as PeriodNarrativeResult, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + runCompletion: runCompletion as any, + }); + expect(runCompletion.mock.calls[0][0].reasoningJob).toBe(job); + }, + ); + + it("does not reason on the grounding retry", async () => { + const prisma = makePrisma(); + const runCompletion = vi + .fn() + .mockResolvedValueOnce({ + kind: "ok", + content: "Your weight fell by 9.9 kg because you slept more.", + providerType: "openai", + model: "m", + tokensUsed: 1, + }) + .mockResolvedValueOnce({ + kind: "ok", + content: "Your weight eased down slightly.", + providerType: "openai", + model: "m", + tokensUsed: 1, + }); + await generatePeriodNarrative("u1", { + period: "week", + locale: "en", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma: prisma as any, + buildContext: async () => readyContext() as PeriodNarrativeResult, + runCompletion, + }); + expect(runCompletion).toHaveBeenCalledTimes(2); + expect(runCompletion.mock.calls[1][0].reasoningJob).toBeUndefined(); + }); +}); + describe("generatePeriodNarrative — honesty floor", () => { it("writes no narrative when the context is insufficient", async () => { const prisma = makePrisma(); diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts index 22a33b2fc..f8cd50d57 100644 --- a/src/lib/insights/narrative/period-narrative-generate.ts +++ b/src/lib/insights/narrative/period-narrative-generate.ts @@ -369,6 +369,11 @@ export async function generatePeriodNarrative( // The narrative output is PLAIN TEXT (no markdown, no JSON), so suppress // the JSON-mode opt-in the status cards default into. responseFormat: "text", + // v1.41 — a period narrative is one of the few background jobs that may + // reason: medium for the month, low for the week, held to the operator's + // switch and cap. The grounding retry below repairs and does not reason. + reasoningJob: + period === "month" ? "period_narrative_month" : "period_narrative_week", }); // A provider error / timeout is non-fatal — the last good row stays as-is diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index a5b0ff31d..d098fe15c 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -26,6 +26,19 @@ import { singleUserTurn } from "@/lib/ai/types"; import { STATUS_PROVIDER_TIMEOUT_MS, withTimeout } from "./with-timeout"; import { prisma } from "@/lib/db"; import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout"; +import { + REASONING_THINKING_BUDGET, + type BackgroundReasoningJob, +} from "@/lib/ai/reasoning/levels"; +import { resolveJobReasoning } from "@/lib/ai/reasoning/controls"; + +/** + * v1.41 — the fallback ceiling for a status-path call that reasons. Thinking + * is spent before the first answer token, so the 60 s status budget would cut + * a medium-effort narrative off mid-thought. A positive per-record setting + * still wins, as everywhere. + */ +export const REASONING_JOB_TIMEOUT_MS = 90_000; /** * Shared provider plumbing for the seven `*-status.ts` generators. @@ -92,6 +105,15 @@ interface RunStatusCompletionArgs { * consent kinds an operator-held chain entry needs. */ capability: AiCapabilityKey; + /** + * v1.41 — the background job this call is, when it is one of the few that + * may reason (`BACKGROUND_REASONING_JOBS`: the period narratives and the + * Coach memory passes). Absent for every status card, batch note, derived + * score and workout line, which therefore send exactly what they sent + * before reasoning existed; `background-reasoning-inventory.test.ts` + * freezes which call sites set it. The operator's switch and cap apply. + */ + reasoningJob?: BackgroundReasoningJob; } /** @@ -190,11 +212,23 @@ export async function runStatusCompletion( where: { id: userId }, select: { aiResponseTimeoutSeconds: true }, }); + // v1.41 — resolved once per call, after the capability and egress checks + // so a refused call never reads the controls. `undefined` (the operator + // switched reasoning off, or the job resolved off) is today's wire. + const reasoning = args.reasoningJob + ? await resolveJobReasoning(args.reasoningJob, chain) + : undefined; + const thinkingTokens = + reasoning && reasoning.effort !== "off" + ? REASONING_THINKING_BUDGET[reasoning.effort] + : 0; + const effectiveTimeoutMs = resolveEffectiveTimeoutMs( settingsRow?.aiResponseTimeoutSeconds, - STATUS_PROVIDER_TIMEOUT_MS, + thinkingTokens > 0 ? REASONING_JOB_TIMEOUT_MS : STATUS_PROVIDER_TIMEOUT_MS, ); + // The answer budget. Each client adds the thinking budget on top itself. const maxTokens = args.maxTokens ?? AI_BUDGETS.status.maxTokens; // The day's token ledger. Until now this chokepoint — the provider entry for @@ -217,8 +251,12 @@ export async function runStatusCompletion( // or a local model is measured against the generous user-plan ceiling, so // their own hardware/plan is never rationed by the operator's bill. const dateKey = buildDateKey(); + // Thinking is billed as output, so a reasoning call reserves its thinking + // budget too; the reconcile below settles on the reported count. const estimatedTokens = - maxTokens + Math.ceil((systemPrompt.length + userPrompt.length) / 4); + maxTokens + + thinkingTokens + + Math.ceil((systemPrompt.length + userPrompt.length) / 4); // v1.38.19 — a background surface: half the day's ceiling when the // operator funds the chain. const jobCap = resolveDailyCapFor("job", chain); @@ -264,19 +302,23 @@ export async function runStatusCompletion( // A background generator: an operator-funded fallback hop is rationed // at the job share, exactly as the reservation above was. surface: "job", - params: singleUserTurn({ - system: systemPrompt, - user: userPrompt, - temperature: args.temperature ?? AI_BUDGETS.status.temperature, - maxTokens, - // v1.18.7 — status/reference output is reproducible: pin the - // deterministic seed unless a caller overrides it. - seed: args.seed ?? REFERENCE_AI_SEED, - // Status cards are JSON by default; the narrative opts out via - // `"text"`. - responseFormat: args.responseFormat === "text" ? undefined : "json", - timeoutMs: effectiveTimeoutMs, - }), + params: { + ...singleUserTurn({ + system: systemPrompt, + user: userPrompt, + temperature: args.temperature ?? AI_BUDGETS.status.temperature, + maxTokens, + // v1.18.7 — status/reference output is reproducible: pin the + // deterministic seed unless a caller overrides it. + seed: args.seed ?? REFERENCE_AI_SEED, + // Status cards are JSON by default; the narrative opts out via + // `"text"`. + responseFormat: args.responseFormat === "text" ? undefined : "json", + timeoutMs: effectiveTimeoutMs, + }), + // v1.41 — only a named job ever carries this; see `reasoningJob`. + ...(reasoning ? { reasoning } : {}), + }, }), effectiveTimeoutMs, null,