Files
HealthLog/src/lib/insights/status-provider.ts
T
Marc-André Bombeck f1163d5efb fix(insights): do not count a status run the bench sent nothing for
A status run whose whole chain was benched for background work sent no
request, yet the caller opened the note's negative window as for any
failure and raised its failure streak. The streak doubles the window up to
two hours, so a note kept waiting long after its provider recovered.

AllProvidersFailedError now says when no hop sent anything (sentNothing:
every provider benched, or refused by the operator cost cap). The status
chokepoint then holds the note for the first window without touching the
streak (holdStatusNoteWhileBenched), and the caller's own failure write
lands inside that window and neither moves nor counts it. A run that
reached a provider counts as before.

Refs #1279
2026-10-25 00:40:23 +02:00

462 lines
17 KiB
TypeScript

import {
probeProviderPresence,
resolveProvider,
resolveProviderChain,
} from "@/lib/ai/provider";
import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate";
import type {
AiCapabilityKey,
AiUnavailableReason,
} from "@/lib/ai/capabilities/types";
import {
runRawCompletionWithFallback,
type ProviderChainResolved,
} from "@/lib/ai/provider-runner";
import { spentOnFailure } from "@/lib/ai/reasoning/answerless";
import { aiEgressRefusal } from "@/lib/ai/capabilities/egress";
import { annotate } from "@/lib/logging/context";
import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveCostOwner,
resolveDailyCapFor,
type BudgetCostOwner,
} from "@/lib/ai/coach/budget";
import {
AI_BUDGETS,
REFERENCE_AI_SEED,
STATUS_MODEL_CALLS,
} from "@/lib/ai/ai-budgets";
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";
import {
holdStatusNoteWhileBenched,
statusNoteCoolingDown,
} from "./status-negative-window";
/**
* 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;
/**
* The outer bound of one status run: the per-call timeout for each of the
* `STATUS_MODEL_CALLS` calls the run may make in a row. Bounded at one call,
* a thinking model whose first answer was reasoning alone timed out in the
* middle of the repeat that would have answered, and a slow local model the
* person had given 300 s or more per call never landed its note (#1274).
* Every caller is a background job; the job expiries in `queue-runtime.ts`
* sit above this at the largest setting.
*/
export function statusRunBoundMs(effectiveTimeoutMs: number): number {
return effectiveTimeoutMs * STATUS_MODEL_CALLS;
}
/**
* Shared provider plumbing for the seven `*-status.ts` generators.
*
* Before this, each generator resolved a single provider via
* `resolveProvider()` and raced one `generateCompletion()` call against
* a 20 s cap. A degraded primary provider could not cascade, and the
* cap fired below the providers' own 60 s floor — converting healthy-
* but-slow generations into the generic fallback.
*
* This helper mirrors the Coach (`chat/route.ts`): resolve the user's
* provider chain, fall back to the legacy single provider when the chain
* is empty, and run `runRawCompletionWithFallback` so a degraded provider
* cascades to the next. The whole thing is still wrapped in
* `withTimeout` (`statusRunBoundMs`) so a total stall cannot hold the job —
* but a timeout / error is reported as a transient miss (the caller serves
* the fallback without persisting it), never as the day's cached assessment.
*/
export type StatusProviderResult =
/**
* Nothing was sent: no provider, or the capability is unavailable for this
* record (operator switch, opt-out, module, consent, …). Callers serve the
* deterministic line and persist nothing; `reason` says which layer said no.
*/
| { kind: "none"; reason: AiUnavailableReason }
| { kind: "timeout" }
| { kind: "error" }
| {
kind: "ok";
content: string;
providerType: string;
model: string;
tokensUsed: number | null;
};
interface RunStatusCompletionArgs {
userId: string;
cacheAction: string;
systemPrompt: string;
userPrompt: string;
temperature?: number;
maxTokens?: number;
/**
* v1.18.7 — optional deterministic seed override. Defaults to
* `REFERENCE_AI_SEED` for every status/reference surface (reproducible
* QA); the period narrative passes the same constant explicitly.
*/
seed?: number;
/**
* v1.18.7 — output contract of this generation. The per-metric status
* cards return a JSON `{ "summary": ... }` (the default), so they opt the
* non-OpenAI chains into their strongest JSON mode. The period narrative
* returns PLAIN TEXT and passes `"text"` to suppress that.
*/
responseFormat?: "json" | "text";
/**
* The AI capability this generation serves (`statusText`, `periodNarrative`,
* `workoutInsights`, `coach`, …). Required: the chokepoint re-checks it
* immediately before anything leaves the machine, so a job enqueued before
* an operator flipped a switch, or before the person withdrew consent or
* turned AI analysis off, still stops here. The capability also names the
* 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;
}
/**
* Resolve the provider chain for `userId`, falling back to the legacy
* single provider. Returns `null` when the user has no usable provider
* anywhere (the caller surfaces the no-key fallback with
* `hasProvider:false`).
*/
async function resolveStatusChain(
userId: string,
): Promise<ProviderChainResolved[] | null> {
const chain = await resolveProviderChain(userId);
if (chain.length > 0) return chain;
const legacy = await resolveProvider(userId);
if (legacy.type === "none") return null;
return [{ providerType: "admin-openai", instance: legacy }];
}
/**
* Whether any configured provider could serve text for `userId`: the one
* presence probe (`probeProviderPresence`). No client construction, no token
* refresh, no network.
*/
export async function hasUsableStatusProvider(
userId: string,
): Promise<boolean> {
return probeProviderPresence(userId, "text");
}
/**
* Run a status generation across the user's provider chain, bounded by the
* per-user response-timeout setting (falling back to `STATUS_PROVIDER_TIMEOUT_MS`
* when unset) for each call the run may make (`statusRunBoundMs`). The result discriminates between
* no-provider / timeout / provider-error / success so the caller can
* decide what to persist — only `ok` is ever cached as the day's
* assessment.
*/
export async function runStatusCompletion(
args: RunStatusCompletionArgs,
): Promise<StatusProviderResult> {
const { userId, cacheAction, systemPrompt, userPrompt } = args;
// The capability, re-checked at the wire. Every caller resolved it earlier
// (a worker before building its snapshot, a route before enqueueing), but
// switches, opt-outs and consent can change between the enqueue and here.
const capability = await aiCapabilityForRecord(userId, args.capability);
if (!capability.available) {
const reason = capability.reason ?? "check_failed";
annotate({
action: { name: "insights.status.capability_unavailable" },
meta: { cacheAction, capability: args.capability, reason },
});
return { kind: "none", reason };
}
const chain = await resolveStatusChain(userId);
if (chain === null) {
return { kind: "none", reason: "no_provider" };
}
// #1279 — a note whose last generations ended without one waits out its
// window here, whichever path selected it again (a data sync, a warm pass,
// a queued job). Reported as `error`, the transient miss, like a stall.
if (await statusNoteCoolingDown(userId, cacheAction)) {
annotate({
action: { name: "insights.status.cooling_down" },
meta: { cacheAction },
});
return { kind: "error" };
}
// The wire re-check for exactly this chain (`aiEgressRefusal`, the one
// helper every chokepoint uses): the capability again, and whether sending
// to these providers needs a consent receipt under the capability's rule
// (the operator's key or the shared central Codex does; a person's own key
// or a local model does not). Fails closed like the rest: a chain that
// COULD cascade to an operator-held entry needs the receipt.
const refusal = await aiEgressRefusal(
args.capability,
userId,
chain.map((entry) => entry.providerType),
);
if (refusal) {
annotate({
action: { name: "insights.status.capability_unavailable" },
meta: {
cacheAction,
capability: args.capability,
reason: refusal.reason,
at: "wire",
},
});
return { kind: "none", reason: refusal.reason };
}
// The per-user response-timeout setting (Settings → AI), threaded here onto
// every status / reference surface at once. A positive stored value wins
// (seconds → ms); unset falls back to the status-path budget. Applied to the
// call's own `timeoutMs` and, times the calls a run may make, to the outer
// `withTimeout` cap, so a raised value is not clipped by a shorter bound.
const settingsRow = await prisma.user.findUnique({
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, userId)
: undefined;
const thinkingTokens =
reasoning && reasoning.effort !== "off"
? REASONING_THINKING_BUDGET[reasoning.effort]
: 0;
const effectiveTimeoutMs = resolveEffectiveTimeoutMs(
settingsRow?.aiResponseTimeoutSeconds,
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, for EVERY status/reference family (the cards, the
// batched assessment, the derived scores, the period narrative, and the
// off-request Coach memory workers).
//
// The reservation is atomic (single upsert-increment), matching the Coach and
// document paths: a read-then-write check would let concurrent generations
// each observe a sub-cap total and all proceed. We reserve an ESTIMATE up
// front — the output ceiling plus a ~4-chars-per-token approximation of the
// prompt we are about to send — and reconcile against the provider's reported
// count afterwards, refunding in full when nothing was generated.
//
// The cap follows the COST OWNER: `resolveDailyCapFor` charges
// the operator ceiling only when the chain's primary is the operator's own
// credential (`admin-openai` / `admin-codex`). A self-hoster on their own key
// 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 = await buildDateKey(userId);
// 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 +
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);
const reservation = await reserveBudget(
userId,
estimatedTokens,
dateKey,
jobCap,
resolveCostOwner(chain),
"job",
);
if (!reservation.allowed) {
annotateBudgetRefusal(cacheAction, reservation, jobCap);
return { kind: "error" };
}
// #1279 — the run's bound aborts the call it cuts off, so a provider call
// the bound gave up on (and any repeat inside it) stops instead of running
// on detached, with its spend reported nowhere.
const abort = new AbortController();
const raced = await withTimeout(
() =>
runRawCompletionWithFallback({
userId,
providers: chain,
// 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,
}),
signal: abort.signal,
// v1.41 — only a named job ever carries this; see `reasoningJob`.
...(reasoning ? { reasoning } : {}),
},
}),
statusRunBoundMs(effectiveTimeoutMs),
null,
() => abort.abort(),
);
if (raced.timedOut || raced.errored || raced.value === null) {
return failedRun(raced, { userId, cacheAction, dateKey, reservation });
}
const { result, workingProvider, tokensSpent } = raced.value;
// What the provider reported, plus (#1279) what the hops that failed before
// it spent. The empty-content branch is charged too: those tokens were
// burned upstream. No count falls back to the reservation, never to zero.
const actualTokens =
(result.tokensUsed ?? reservation.reserved) + tokensSpent;
await reconcileSpend(userId, reservation.reserved, actualTokens, dateKey, 0, {
servedBy: workingProvider.providerType,
reservedOwner: reservation.owner,
});
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
annotate({
action: { name: "insights.status.empty_content" },
meta: { cacheAction, providerType: workingProvider.providerType },
});
return { kind: "error" };
}
// Consent withdrawn while the call was in flight. The withdrawal purged the
// stored text in its own transaction; a reply persisted after it would
// bring that text back under a consent that no longer exists. So the rule
// runs once more, against the provider that actually answered, with a fresh
// receipt read, and a refused reply is dropped as if it had never arrived.
// The tokens stay on the ledger: they were spent upstream.
const late = await aiEgressRefusal(args.capability, userId, [
workingProvider.providerType,
]);
if (late) {
annotate({
action: { name: "insights.status.capability_unavailable" },
meta: {
cacheAction,
capability: args.capability,
reason: late.reason,
at: "reply",
},
});
return { kind: "none", reason: late.reason };
}
return {
kind: "ok",
content,
providerType: workingProvider.providerType,
model: result.model ?? "unknown",
tokensUsed: result.tokensUsed ?? null,
};
}
/**
* A run that timed out or failed. It charges only what a reply reported
* spending, never an invented figure; #1279 — a failed chain's report counts,
* since a model that reasons without answering bills every call, and a full
* refund let background work spend without the day's ceiling seeing it. A
* chain that sent nothing (the bench) holds the note without counting it.
*/
async function failedRun(
raced: { timedOut: boolean; error?: unknown },
run: {
userId: string;
cacheAction: string;
dateKey: string;
reservation: { reserved: number; owner: BudgetCostOwner };
},
): Promise<StatusProviderResult> {
const spent = spentOnFailure(raced.error);
await reconcileSpend(
run.userId,
run.reservation.reserved,
spent,
run.dateKey,
0,
{
servedBy: null,
reservedOwner: run.reservation.owner,
},
);
if ((raced.error as { sentNothing?: unknown } | null)?.sentNothing === true) {
await holdStatusNoteWhileBenched({
userId: run.userId,
cacheAction: run.cacheAction,
todayKey: run.dateKey,
});
}
return { kind: raced.timedOut ? "timeout" : "error" };
}
/**
* Over the day's ceiling. Reported as `error` — a TRANSIENT miss the caller
* serves the fallback for without persisting it — deliberately NOT `none`,
* which callers cache as the settled "no provider configured" assessment.
* The distinct annotation keeps the refusal observable even though the
* result shape is shared.
*
* v1.38.19 — the shared `error` outcome cannot say WHY the generation
* stopped, so the annotation has to. `surface: "job"` plus `cap` separates
* "this job used up the background share" from "the whole day is spent", and
* `limit` says which counter tripped. The operator sees the same split on the
* admin provider-health card.
*/
function annotateBudgetRefusal(
cacheAction: string,
reservation: Awaited<ReturnType<typeof reserveBudget>>,
jobCap: number,
): void {
annotate({
action: { name: "insights.status.budget_exceeded" },
meta: {
cacheAction,
owner: reservation.owner,
surface: "job",
limit: reservation.limit,
cap: jobCap,
totalAfter: reservation.totalAfter,
operatorAfter: reservation.operatorAfter,
},
});
}