fix(ai): run every model call under the response-timeout setting

The response-timeout setting reached the briefing, the status notes and
the Coach, which threaded it by hand. Document reads, document
extraction, lab OCR and lab staging called the provider without a
timeout, so every client fell back to its own 60 s literal and a slow
self-hosted model was cut off however high the setting stood.

The setting now rides on the provider instance. resolveProvider,
resolveProviderChain and resolveProviderForTest stamp the record's
aiResponseTimeoutSeconds on everything they return, and each wire
client reads its ceiling through callTimeoutMs: the setting, else the
surface's own timeoutMs, else 60 s. A call site no longer has to pass
it, so a new one cannot forget it. The connection test runs under the
same ceiling a read does.

The Coach nudge tick and the reaction line keep their short ceilings
(timeoutPolicy: "surface-ceiling"): the tick runs accounts one after
another under a shared budget, the reaction line's claim lease is
shorter than the largest setting, and both have deterministic copy
that stands in.

/api/auth/me publishes the effective value as
ai.provider.responseTimeoutMs, so a client can size its own request
timeout from it instead of guessing.

Refs #1090
This commit is contained in:
Marc-André Bombeck committed 2026-10-02 23:01:36 +02:00
1 parent 1a403b78f7
commit 5ee1b3e28f
21 files changed
+588 -36

No files matched your search

@@ -39,6 +39,7 @@ let providerState: AiProviderState = {
configured: true,
managedBy: "user",
canConfigure: true,
responseTimeoutMs: 60_000,
};
vi.mock("@/hooks/use-ai-capability", () => ({
useAiProviderState: () => providerState,
@@ -135,11 +136,21 @@ describe("<GettingStartedChecklist> — rows for an established account", () =>
it("leaves the AI row out when AI cannot be set up here", () => {
// The operator switched AI off, or the record belongs to somebody else:
// the list never carries a to-do nobody on this screen can finish.
providerState = { configured: false, managedBy: null, canConfigure: false };
providerState = {
configured: false,
managedBy: null,
canConfigure: false,
responseTimeoutMs: 60_000,
};
currentUser = user();
const html = render();
expect(html).toContain("4 of 5 done");
providerState = { configured: true, managedBy: "user", canConfigure: true };
providerState = {
configured: true,
managedBy: "user",
canConfigure: true,
responseTimeoutMs: 60_000,
};
});
it("keeps the supporting queries enabled while the card can render", () => {
@@ -3,12 +3,12 @@
/* ────────────────────────────────────────────────────────────────
* v1.22 (#89) — Response timeout (seconds).
*
* A per-user upstream timeout for AI generation, threaded onto the provider
* call (`CompletionParams.timeoutMs`). Surfaced here mainly for local /
* self-hosted backends: an MLX/exo server can take >60 s on the first request
* while it loads the model, which the legacy 60 s default timed out before the
* first token landed. Empty = the built-in comprehensive-briefing default
* (~120 s, `AI_BUDGETS.comprehensive.timeoutMs`).
* A per-user upstream timeout for every model call on the record: the
* resolver binds it to the provider and each client reads its ceiling through
* `callTimeoutMs` (only the nudge tick and the reaction line keep their own
* short ceilings). Surfaced here mainly for local / self-hosted backends: an
* MLX/exo server can take >60 s on the first request while it loads the model.
* Empty = each surface's own default (60 s for most, longer for the briefing).
* ──────────────────────────────────────────────────────────────── */
import { useRef, useState, type FormEvent } from "react";