diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 366ea747c..b5aeaf86a 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -72249,10 +72249,19 @@ components:
type: boolean
description: "Whether the person in front of the screen may set up a provider for this record: false inside somebody
else's record and while the operator's master switch is off. A setup hint is shown only when this is true."
+ responseTimeoutMs:
+ type: integer
+ exclusiveMinimum: 0
+ maximum: 9007199254740991
+ description: "How long one model call on this record may take before the server gives up, in milliseconds: the record's
+ response-timeout setting (Settings → AI), else 60000. A client that waits on a model call (a document read,
+ lab OCR) sizes its own request timeout from this plus a margin, so it never gives up before the server
+ does."
required:
- configured
- managedBy
- canConfigure
+ - responseTimeoutMs
additionalProperties: false
description: The account-level companion to the capability map.
RecordSessionState:
diff --git a/messages/de.json b/messages/de.json
index 50d14c443..822a5d825 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5927,9 +5927,9 @@
"questionsOpenCoach": "Im Coach beantworten"
},
"responseTimeoutHeading": "Antwort-Timeout",
- "responseTimeoutBody": "Wie lange auf die Antwort der KI gewartet wird, bevor abgebrochen wird. Erhöhe den Wert für ein lokales oder selbst gehostetes Modell, das beim ersten Aufruf Zeit zum Laden braucht.",
+ "responseTimeoutBody": "Wie lange auf die Antwort der KI gewartet wird, bevor abgebrochen wird. Erhöhe den Wert für ein lokales oder selbst gehostetes Modell, das beim ersten Aufruf Zeit zum Laden braucht. Ohne Wert darf eine Antwort {seconds} s dauern, das Tagesbriefing bis zu {briefingSeconds} s.",
"responseTimeoutLabel": "Antwort-Timeout (Sekunden)",
- "responseTimeoutPlaceholder": "Standard (~120)",
+ "responseTimeoutPlaceholder": "Standard ({seconds} s)",
"responseTimeoutInvalid": "Gib eine Zahl zwischen 10 und 600 Sekunden ein.",
"autoRead": {
"title": "Dokumente automatisch mit KI lesen",
diff --git a/messages/en.json b/messages/en.json
index b23ee1634..41ffba7d9 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5927,9 +5927,9 @@
"questionsOpenCoach": "Answer them in the Coach"
},
"responseTimeoutHeading": "Response timeout",
- "responseTimeoutBody": "How long to wait for the AI to respond before giving up. Raise it for a local or self-hosted model that needs time to load on the first request.",
+ "responseTimeoutBody": "How long to wait for the AI to respond before giving up. Raise it for a local or self-hosted model that needs time to load on the first request. Left empty, a reply may take {seconds} s; the daily briefing allows up to {briefingSeconds} s.",
"responseTimeoutLabel": "Response timeout (seconds)",
- "responseTimeoutPlaceholder": "Default (~120)",
+ "responseTimeoutPlaceholder": "Default ({seconds} s)",
"responseTimeoutInvalid": "Enter a number between 10 and 600 seconds.",
"autoRead": {
"title": "Read documents automatically with AI",
diff --git a/messages/es.json b/messages/es.json
index cbde8a5a7..e6c2d55df 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5927,9 +5927,9 @@
"questionsOpenCoach": "Responderlas en el Coach"
},
"responseTimeoutHeading": "Tiempo de espera de la respuesta",
- "responseTimeoutBody": "Cuánto esperar a que responda la IA antes de cancelar. Aumenta el valor para un modelo local o autoalojado que necesita tiempo para cargarse en la primera solicitud.",
+ "responseTimeoutBody": "Cuánto esperar a que responda la IA antes de cancelar. Aumenta el valor para un modelo local o autoalojado que necesita tiempo para cargarse en la primera solicitud. Si lo dejas vacío, una respuesta puede tardar {seconds} s; el resumen diario admite hasta {briefingSeconds} s.",
"responseTimeoutLabel": "Tiempo de espera de la respuesta (segundos)",
- "responseTimeoutPlaceholder": "Predeterminado (~120)",
+ "responseTimeoutPlaceholder": "Predeterminado ({seconds} s)",
"responseTimeoutInvalid": "Introduce un número entre 10 y 600 segundos.",
"autoRead": {
"title": "Leer documentos automáticamente con IA",
diff --git a/messages/fr.json b/messages/fr.json
index 4aaac4602..a559cbce2 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5927,9 +5927,9 @@
"questionsOpenCoach": "Y répondre dans le Coach"
},
"responseTimeoutHeading": "Délai de réponse",
- "responseTimeoutBody": "Durée d'attente de la réponse de l'IA avant d'abandonner. Augmentez-la pour un modèle local ou auto-hébergé qui a besoin de temps pour se charger à la première requête.",
+ "responseTimeoutBody": "Durée d'attente de la réponse de l'IA avant d'abandonner. Augmentez-la pour un modèle local ou auto-hébergé qui a besoin de temps pour se charger à la première requête. Sans valeur, une réponse peut prendre {seconds} s ; le briefing quotidien jusqu'à {briefingSeconds} s.",
"responseTimeoutLabel": "Délai de réponse (secondes)",
- "responseTimeoutPlaceholder": "Par défaut (~120)",
+ "responseTimeoutPlaceholder": "Par défaut ({seconds} s)",
"responseTimeoutInvalid": "Saisissez un nombre entre 10 et 600 secondes.",
"autoRead": {
"title": "Lire les documents automatiquement avec l'IA",
diff --git a/messages/it.json b/messages/it.json
index f35614d3a..d4210d9be 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5927,9 +5927,9 @@
"questionsOpenCoach": "Rispondi nel Coach"
},
"responseTimeoutHeading": "Timeout della risposta",
- "responseTimeoutBody": "Quanto attendere la risposta dell'IA prima di annullare. Aumentalo per un modello locale o self-hosted che ha bisogno di tempo per caricarsi alla prima richiesta.",
+ "responseTimeoutBody": "Quanto attendere la risposta dell'IA prima di annullare. Aumentalo per un modello locale o self-hosted che ha bisogno di tempo per caricarsi alla prima richiesta. Se lasci vuoto, una risposta può richiedere {seconds} s; il briefing giornaliero fino a {briefingSeconds} s.",
"responseTimeoutLabel": "Timeout della risposta (secondi)",
- "responseTimeoutPlaceholder": "Predefinito (~120)",
+ "responseTimeoutPlaceholder": "Predefinito ({seconds} s)",
"responseTimeoutInvalid": "Inserisci un numero tra 10 e 600 secondi.",
"autoRead": {
"title": "Leggi i documenti automaticamente con l'IA",
diff --git a/messages/ko.json b/messages/ko.json
index 1f82dceb1..83eefb037 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -5927,9 +5927,9 @@
"questionsOpenCoach": "코치에서 답하기"
},
"responseTimeoutHeading": "응답 제한 시간",
- "responseTimeoutBody": "AI 응답을 얼마나 기다린 뒤 포기할지 정해요. 첫 요청에서 모델을 불러오는 데 시간이 걸리는 로컬·셀프 호스팅 모델이라면 늘리세요.",
+ "responseTimeoutBody": "AI 응답을 얼마나 기다린 뒤 포기할지 정해요. 첫 요청에서 모델을 불러오는 데 시간이 걸리는 로컬·셀프 호스팅 모델이라면 늘리세요. 비워 두면 응답은 {seconds}초까지 기다리고, 일일 브리핑은 최대 {briefingSeconds}초까지 기다려요.",
"responseTimeoutLabel": "응답 제한 시간 (초)",
- "responseTimeoutPlaceholder": "기본값 (약 120)",
+ "responseTimeoutPlaceholder": "기본값 ({seconds}초)",
"responseTimeoutInvalid": "10에서 600초 사이의 숫자를 입력해 주세요.",
"autoRead": {
"title": "AI로 문서 자동 읽기",
diff --git a/messages/pl.json b/messages/pl.json
index 2c185809c..9b520a948 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5927,9 +5927,9 @@
"questionsOpenCoach": "Odpowiedz w Coachu"
},
"responseTimeoutHeading": "Limit czasu odpowiedzi",
- "responseTimeoutBody": "Jak długo czekać na odpowiedź AI, zanim nastąpi przerwanie. Zwiększ wartość dla lokalnego lub samodzielnie hostowanego modelu, który potrzebuje czasu na załadowanie przy pierwszym żądaniu.",
+ "responseTimeoutBody": "Jak długo czekać na odpowiedź AI, zanim nastąpi przerwanie. Zwiększ wartość dla lokalnego lub samodzielnie hostowanego modelu, który potrzebuje czasu na załadowanie przy pierwszym żądaniu. Bez wartości odpowiedź może trwać {seconds} s, a dzienne podsumowanie do {briefingSeconds} s.",
"responseTimeoutLabel": "Limit czasu odpowiedzi (sekundy)",
- "responseTimeoutPlaceholder": "Domyślnie (~120)",
+ "responseTimeoutPlaceholder": "Domyślnie ({seconds} s)",
"responseTimeoutInvalid": "Podaj liczbę od 10 do 600 sekund.",
"autoRead": {
"title": "Automatyczne czytanie dokumentów przez AI",
diff --git a/src/__tests__/ai-call-timeout-guard.test.ts b/src/__tests__/ai-call-timeout-guard.test.ts
new file mode 100644
index 000000000..686b8a225
--- /dev/null
+++ b/src/__tests__/ai-call-timeout-guard.test.ts
@@ -0,0 +1,195 @@
+/**
+ * Structural guard: every model call runs under the record owner's
+ * response-timeout setting.
+ *
+ * The setting (Settings → AI → response timeout) used to be threaded by hand,
+ * and only the briefing, the status notes and the Coach did it. Document
+ * reads, document extraction, lab OCR and lab staging called the provider
+ * without a `timeoutMs`, so each 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 is now bound to the provider instance by the resolver that hands
+ * it out, and each client reads its ceiling through `callTimeoutMs`. A call
+ * site therefore cannot forget it; what can still go wrong is structural, and
+ * each way is pinned here:
+ *
+ * 1. a wire client reads `params.timeoutMs` (or a literal) itself instead of
+ * `callTimeoutMs(params, this.responseTimeoutSeconds)`;
+ * 2. an exported resolver in `provider.ts` returns a provider it did not bind;
+ * 3. a client is constructed outside `provider.ts`, where no resolver binds
+ * it (the offline evaluation harness is the one named exception);
+ * 4. a surface opts out with `timeoutPolicy: "surface-ceiling"` without
+ * being named below with its reason.
+ *
+ * Its limit: it reads source text. A provider object built by hand in
+ * production code that dials the network itself would slip all four matchers,
+ * and so would a client class imported under an alias.
+ * `response-timeout-binding.test.ts` and `provider-response-timeout.test.ts`
+ * are what prove the behaviour end to end.
+ *
+ * Mutation check: put `params.timeoutMs ?? 60_000` back into any client, drop
+ * `bindResponseTimeout` from `resolveProviderChain`, construct a
+ * `LocalOpenAICompatibleClient` in a route, or add
+ * `timeoutPolicy: "surface-ceiling"` to the document summary: each one goes
+ * red here.
+ */
+import { readFileSync, readdirSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+import { walkSourceFiles } from "./helpers/source-files";
+
+const SRC = join(process.cwd(), "src");
+const AI_DIR = join(SRC, "lib", "ai");
+
+function stripComments(source: string): string {
+ return source
+ .replace(/\/\*[\s\S]*?\*\//g, "")
+ .replace(/(^|[^:])\/\/.*$/gm, "$1");
+}
+
+function code(rel: string): string {
+ return stripComments(readFileSync(join(SRC, rel), "utf8"));
+}
+
+/** The wire clients: every `*-client.ts` that dials through `safeFetch`. */
+function wireClients(): string[] {
+ return readdirSync(AI_DIR)
+ .filter((f) => f.endsWith("-client.ts"))
+ .map((f) => `lib/ai/${f}`)
+ .filter((rel) => /\bsafeFetch\s*\(/.test(code(rel)))
+ .sort();
+}
+
+const CLIENT_CLASSES = [
+ "AnthropicClient",
+ "OpenAIClient",
+ "LocalOpenAICompatibleClient",
+ "CodexClient",
+];
+
+// Built fresh per use: a shared `g` regex carries `lastIndex` between
+// `.test()` calls and silently skips files.
+function construction(): RegExp {
+ return new RegExp(`\\bnew\\s+(?:${CLIENT_CLASSES.join("|")})\\s*\\(`, "g");
+}
+
+/** Where a client may be constructed, and why no resolver needs to bind it. */
+const CONSTRUCTION_SITES: Record = {
+ "lib/ai/provider.ts":
+ "The resolvers. Every exported one binds the record's setting on the way out.",
+ "lib/ai/coach/eval/judge.ts":
+ "The offline evaluation harness, run by the maintainer against a pinned key; no record and no person's setting.",
+};
+
+/**
+ * The surfaces whose `timeoutMs` is a hard ceiling the setting does not lift.
+ * Each is unattended, latency-bounded by design, and has deterministic copy
+ * that stands in when the model does not answer in time.
+ */
+const SURFACE_CEILING_SITES: Record = {
+ "lib/jobs/coach-nudge-ai.ts":
+ "The 05:15 nudge tick runs accounts one after another under a shared wall-clock budget; one raised setting would starve everyone after it. The template is always ready.",
+ "lib/jobs/reaction-line.ts":
+ "The claim lease is two minutes, below the largest setting, and the deterministic lead stands in for a line that does not arrive.",
+};
+
+describe("wire clients read the bound setting", () => {
+ const clients = wireClients();
+
+ it("finds the clients (an empty set is a failure, not a pass)", () => {
+ expect(clients.length).toBeGreaterThanOrEqual(4);
+ });
+
+ it.each(clients)("%s derives every ceiling through callTimeoutMs", (rel) => {
+ const source = code(rel);
+ expect(
+ source.match(
+ /\bcallTimeoutMs\s*\(\s*params\s*,\s*this\s*\.\s*responseTimeoutSeconds\s*\)/g,
+ )?.length ?? 0,
+ ).toBeGreaterThan(0);
+ expect(
+ source.match(/\bparams\s*\.\s*timeoutMs\b/g) ?? [],
+ "A client read the surface timeout itself and skipped the person's setting. Use callTimeoutMs(params, this.responseTimeoutSeconds).",
+ ).toEqual([]);
+ });
+});
+
+describe("every exported resolver binds", () => {
+ const source = code("lib/ai/provider.ts");
+ // One chunk per export; the header is everything up to the brace that
+ // opens the body, so a default-object parameter (`= {}`) does not end it.
+ const resolvers = source
+ .split(/\nexport\s+/)
+ .map((chunk) => {
+ const m = /^async\s+function\s+(\w+)\s*\(/.exec(chunk);
+ if (!m) return null;
+ const header = chunk.slice(0, chunk.indexOf("{\n"));
+ if (
+ !/Promise<\s*(?:AIProvider|ProviderChainResolved\[\])\s*>/.test(header)
+ ) {
+ return null;
+ }
+ const end = chunk.indexOf("\n}\n");
+ return { name: m[1], body: chunk.slice(header.length, end) };
+ })
+ .filter((r): r is { name: string; body: string } => r !== null);
+
+ it("finds the resolvers (an empty set is a failure, not a pass)", () => {
+ expect(resolvers.map((r) => r.name).sort()).toEqual([
+ "resolveProvider",
+ "resolveProviderChain",
+ "resolveProviderForTest",
+ ]);
+ });
+
+ it.each([
+ "resolveProvider",
+ "resolveProviderChain",
+ "resolveProviderForTest",
+ ])("%s calls bindResponseTimeout", (name) => {
+ const resolver = resolvers.find((r) => r.name === name);
+ expect(resolver?.body ?? "").toMatch(/\bbindResponseTimeout\s*\(/);
+ });
+});
+
+describe("clients are built only where a resolver binds them", () => {
+ 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"));
+ const sites = files.filter((rel) => construction().test(code(rel)));
+
+ it("finds constructions at all", () => {
+ expect(sites.length).toBeGreaterThan(0);
+ expect(
+ code("lib/ai/provider.ts").match(construction())?.length ?? 0,
+ ).toBeGreaterThan(10);
+ });
+
+ it("every construction site is named, with a reason", () => {
+ expect(
+ sites.filter((rel) => !(rel in CONSTRUCTION_SITES)),
+ "A client is constructed outside provider.ts, so no resolver binds the person's response-timeout setting to it. Resolve it through provider.ts instead.",
+ ).toEqual([]);
+ expect(
+ Object.keys(CONSTRUCTION_SITES).filter((r) => !sites.includes(r)),
+ ).toEqual([]);
+ });
+});
+
+describe("surface ceilings are frozen", () => {
+ 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"));
+ const sites = files.filter((rel) =>
+ /\btimeoutPolicy\s*:\s*["']surface-ceiling["']/.test(code(rel)),
+ );
+
+ it("names every surface that keeps its own ceiling", () => {
+ expect(sites.length).toBeGreaterThan(0);
+ expect(sites.sort()).toEqual(Object.keys(SURFACE_CEILING_SITES).sort());
+ });
+});
diff --git a/src/components/documents/__tests__/document-ai-timeout.test.ts b/src/components/documents/__tests__/document-ai-timeout.test.ts
new file mode 100644
index 000000000..00ca00ae3
--- /dev/null
+++ b/src/components/documents/__tests__/document-ai-timeout.test.ts
@@ -0,0 +1,94 @@
+/**
+ * The browser waits as long as the server may.
+ *
+ * Document reads and lab OCR used to abort on fixed windows (120 s, 90 s, and
+ * the 15 s default on the lab text path) while the server, for a slow local
+ * model, was allowed far longer. The windows now derive from
+ * `ai.provider.responseTimeoutMs` on the account payload: the per-call value
+ * times the model calls one request can make, plus a 30 s margin.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const apiPost = vi.fn();
+const apiFetch = vi.fn();
+vi.mock("@/lib/api/api-fetch", () => ({
+ apiPost: (...a: unknown[]) => apiPost(...a),
+ apiFetch: (...a: unknown[]) => apiFetch(...a),
+ apiFetchRaw: vi.fn(),
+ apiGet: vi.fn(),
+ apiPatch: vi.fn(),
+}));
+vi.mock("@/lib/labs/local-ocr", () => ({
+ ocrImageToText: vi.fn(),
+ LocalOcrError: class extends Error {},
+}));
+
+import {
+ runDocumentAi,
+ runDocumentIndex,
+} from "@/components/documents/document-ai-transport";
+import { postOcrExtract } from "@/components/labs/use-ocr-extract";
+
+const target = {
+ documentId: "d1",
+ mimeType: "application/pdf",
+ filename: "report.pdf",
+ servingClass: "attachment" as const,
+};
+
+let timeout: ReturnType;
+
+beforeEach(() => {
+ apiPost.mockReset().mockResolvedValue({});
+ apiFetch.mockReset().mockResolvedValue({});
+ timeout = vi.spyOn(AbortSignal, "timeout");
+});
+
+afterEach(() => {
+ timeout.mockRestore();
+});
+
+describe("document AI requests", () => {
+ it("give Read with AI three model calls under the published setting", async () => {
+ await runDocumentIndex({
+ mode: "vision",
+ target,
+ responseTimeoutMs: 300_000,
+ });
+ expect(timeout).toHaveBeenCalledWith(3 * 300_000 + 30_000);
+ });
+
+ it("give a one-call route one call plus the margin", async () => {
+ await runDocumentAi({
+ path: "/api/documents/inbound/d1/summary?mode=summary",
+ mode: "vision",
+ target,
+ responseTimeoutMs: 300_000,
+ modelCalls: 1,
+ });
+ expect(timeout).toHaveBeenCalledWith(330_000);
+ });
+
+ it("follow the server default when nothing is set", async () => {
+ await runDocumentIndex({
+ mode: "vision",
+ target,
+ responseTimeoutMs: 60_000,
+ });
+ expect(timeout).toHaveBeenCalledWith(210_000);
+ });
+});
+
+describe("lab OCR requests", () => {
+ it("vision upload waits for the read and its retry", async () => {
+ await postOcrExtract({ file: new File(["x"], "r.png") }, 300_000);
+ expect(timeout).toHaveBeenCalledWith(630_000);
+ expect(apiFetch.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
+ });
+
+ it("text mode no longer rides the 15 s default", async () => {
+ await postOcrExtract({ text: "Hb 14" }, 300_000);
+ expect(timeout).toHaveBeenCalledWith(630_000);
+ expect(apiPost.mock.calls[0][2].signal).toBeInstanceOf(AbortSignal);
+ });
+});
diff --git a/src/components/documents/document-ai-transport.ts b/src/components/documents/document-ai-transport.ts
index a698e0c2a..ae3a909af 100644
--- a/src/components/documents/document-ai-transport.ts
+++ b/src/components/documents/document-ai-transport.ts
@@ -10,10 +10,14 @@
* loaded) and posts ONLY the text. Images only — a PDF/attachment in text
* mode is refused client-side before any download/OCR work.
*
- * A provider call is slow, so requests opt out of the default 15 s fetch window
- * in favour of a generous ceiling.
+ * A provider call is slow, so requests opt out of the default 15 s fetch window.
+ * The ceiling is derived from the `ai.provider.responseTimeoutMs` the account
+ * payload publishes (the person's response-timeout setting, resolved on the
+ * server) times the model calls the route can make, plus a margin, so the
+ * browser never abandons a read the server is still allowed to finish.
*/
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
+import { clientAbortMs } from "@/lib/ai/effective-timeout";
import { ocrImageToText, LocalOcrError } from "@/lib/labs/local-ocr";
/** The transport an AI call uses, resolved from the OCR capability probe. */
@@ -68,7 +72,16 @@ export async function runDocumentAi(opts: {
path: string;
mode: DocumentAiMode;
target: DocumentAiTarget;
+ /** `ai.provider.responseTimeoutMs` from the account payload. */
+ responseTimeoutMs: number;
+ /**
+ * The most sequential model calls the route makes on one request: one for
+ * suggest and summary; three for index (the transcription, then the lab
+ * staging read with its one corrective retry).
+ */
+ modelCalls: number;
}): Promise {
+ const timeoutMs = clientAbortMs(opts.responseTimeoutMs, opts.modelCalls);
if (opts.mode === "text") {
if (
opts.target.servingClass !== "inline" ||
@@ -89,12 +102,12 @@ export async function runDocumentAi(opts: {
return apiPost(
opts.path,
{ mode: "text", text },
- { signal: AbortSignal.timeout(120_000) },
+ { signal: AbortSignal.timeout(timeoutMs) },
);
}
// Vision: empty body → the route dispatches to its vision handler.
return apiPost(opts.path, undefined, {
- signal: AbortSignal.timeout(120_000),
+ signal: AbortSignal.timeout(timeoutMs),
});
}
@@ -114,10 +127,13 @@ export interface DocumentIndexResult {
export function runDocumentIndex(opts: {
mode: DocumentAiMode;
target: DocumentAiTarget;
+ responseTimeoutMs: number;
}): Promise {
return runDocumentAi({
path: `/api/documents/inbound/${opts.target.documentId}/index`,
mode: opts.mode,
target: opts.target,
+ responseTimeoutMs: opts.responseTimeoutMs,
+ modelCalls: 3,
});
}
diff --git a/src/components/documents/use-content-index.ts b/src/components/documents/use-content-index.ts
index 28f164ba8..146bdf697 100644
--- a/src/components/documents/use-content-index.ts
+++ b/src/components/documents/use-content-index.ts
@@ -16,6 +16,7 @@
*/
import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useAiProviderState } from "@/hooks/use-ai-capability";
import { apiPost } from "@/lib/api/api-fetch";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
@@ -29,12 +30,14 @@ import {
/** Index / re-index one document for content search. */
export function useIndexDocument() {
const queryClient = useQueryClient();
+ const { responseTimeoutMs } = useAiProviderState();
return useMutation<
DocumentIndexResult,
Error,
{ mode: DocumentAiMode; target: DocumentAiTarget }
>({
- mutationFn: ({ mode, target }) => runDocumentIndex({ mode, target }),
+ mutationFn: ({ mode, target }) =>
+ runDocumentIndex({ mode, target, responseTimeoutMs }),
onSuccess: () => {
void invalidateKeys(queryClient, [queryKeys.documents()]);
},
diff --git a/src/components/documents/use-document-assist.ts b/src/components/documents/use-document-assist.ts
index 8ca83e7fe..a2e75fc15 100644
--- a/src/components/documents/use-document-assist.ts
+++ b/src/components/documents/use-document-assist.ts
@@ -17,6 +17,7 @@ import { useCallback } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
+import { useAiProviderState } from "@/hooks/use-ai-capability";
import { apiGet, ApiError } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -86,6 +87,7 @@ export function useDocumentsAutoAiRead(enabled: boolean) {
* nothing.
*/
export function useSuggestDetails() {
+ const { responseTimeoutMs } = useAiProviderState();
return useMutation<
DocumentSuggestionDto,
Error,
@@ -96,6 +98,8 @@ export function useSuggestDetails() {
path: `/api/documents/inbound/${target.documentId}/suggest`,
mode,
target,
+ responseTimeoutMs,
+ modelCalls: 1,
});
return data.suggestions;
},
@@ -107,6 +111,7 @@ export function useSuggestDetails() {
* summary block explicitly requests persistence or replacement.
*/
export function useDocumentSummary() {
+ const { responseTimeoutMs } = useAiProviderState();
return useMutation<
DocumentDescribeResult,
Error,
@@ -123,6 +128,8 @@ export function useDocumentSummary() {
path: `/api/documents/inbound/${target.documentId}/summary?mode=${output}${persist ? "&persist=true" : ""}${replace ? "&replace=true" : ""}`,
mode,
target,
+ responseTimeoutMs,
+ modelCalls: 1,
}),
});
}
diff --git a/src/components/labs/use-ocr-extract.ts b/src/components/labs/use-ocr-extract.ts
index 999e00f9a..e68e07eb0 100644
--- a/src/components/labs/use-ocr-extract.ts
+++ b/src/components/labs/use-ocr-extract.ts
@@ -14,6 +14,8 @@
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useAiProviderState } from "@/hooks/use-ai-capability";
+import { clientAbortMs } from "@/lib/ai/effective-timeout";
import { apiFetch, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { ocrImageToText } from "@/lib/labs/local-ocr";
import {
@@ -92,19 +94,48 @@ export function useOcrCapability(enabled: boolean) {
});
}
+/**
+ * The most sequential model calls one extract request makes: the read, and one
+ * corrective retry when the reply does not parse.
+ */
+const OCR_EXTRACT_MODEL_CALLS = 2;
+
+/**
+ * POST one lab-report extraction. Vision mode uploads the file; text mode posts
+ * only the text the browser OCR'd. Either way a model reads it, so the request
+ * waits as long as the server may: the person's response-timeout setting as
+ * the account payload publishes it (`ai.provider.responseTimeoutMs`), times
+ * the calls one request can make, plus a margin. The 15 s default used to cut
+ * the text path off long before any model answered.
+ */
+export function postOcrExtract(
+ input: { file: File } | { text: string },
+ responseTimeoutMs: number,
+): Promise {
+ const signal = AbortSignal.timeout(
+ clientAbortMs(responseTimeoutMs, OCR_EXTRACT_MODEL_CALLS),
+ );
+ if ("text" in input) {
+ return apiPost(
+ "/api/labs/ocr/extract",
+ { mode: "text", text: input.text },
+ { signal },
+ );
+ }
+ const form = new FormData();
+ form.append("file", input.file);
+ return apiFetch("/api/labs/ocr/extract", {
+ method: "POST",
+ body: form,
+ signal,
+ });
+}
+
/** Upload + extract (VISION mode). Resolves with the proposed review rows. */
export function useOcrExtract() {
+ const { responseTimeoutMs } = useAiProviderState();
return useMutation({
- mutationFn: (file: File) => {
- const form = new FormData();
- form.append("file", file);
- return apiFetch("/api/labs/ocr/extract", {
- method: "POST",
- body: form,
- // Vision extraction is slow; a generous ceiling beats the 15 s default.
- signal: AbortSignal.timeout(90_000),
- });
- },
+ mutationFn: (file: File) => postOcrExtract({ file }, responseTimeoutMs),
});
}
@@ -115,14 +146,10 @@ export function useOcrExtract() {
* is shared verbatim.
*/
export function useOcrTextExtract() {
+ const { responseTimeoutMs } = useAiProviderState();
return useMutation({
- mutationFn: async (file: File) => {
- const text = await ocrImageToText(file);
- return apiPost("/api/labs/ocr/extract", {
- mode: "text",
- text,
- });
- },
+ mutationFn: async (file: File) =>
+ postOcrExtract({ text: await ocrImageToText(file) }, responseTimeoutMs),
});
}
diff --git a/src/components/onboarding/__tests__/getting-started-checklist.test.tsx b/src/components/onboarding/__tests__/getting-started-checklist.test.tsx
index 5d6f65c2f..13d7733dc 100644
--- a/src/components/onboarding/__tests__/getting-started-checklist.test.tsx
+++ b/src/components/onboarding/__tests__/getting-started-checklist.test.tsx
@@ -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(" — 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", () => {
diff --git a/src/components/settings/ai/__tests__/response-timeout-copy.test.ts b/src/components/settings/ai/__tests__/response-timeout-copy.test.ts
new file mode 100644
index 000000000..da2c87b54
--- /dev/null
+++ b/src/components/settings/ai/__tests__/response-timeout-copy.test.ts
@@ -0,0 +1,70 @@
+/**
+ * The response-timeout copy names the defaults the server applies.
+ *
+ * The placeholder said "Default (~120)" while most surfaces fell back to 60 s
+ * and the briefing to 180 s. The numbers now come from the server constants
+ * through interpolation; this pins that every locale interpolates them instead
+ * of writing a number of its own, and that the rendered text carries them.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
+import { PROVIDER_DEFAULT_TIMEOUT_MS } from "@/lib/ai/effective-timeout";
+
+import { RESPONSE_TIMEOUT_COPY_PARAMS } from "../response-timeout-card";
+
+const LOCALES = ["de", "en", "es", "fr", "it", "ko", "pl"] as const;
+
+function aiCopy(locale: string): Record {
+ const bundle = JSON.parse(
+ readFileSync(join(process.cwd(), "messages", `${locale}.json`), "utf8"),
+ ) as { settings: { ai: Record } };
+ return bundle.settings.ai;
+}
+
+function render(template: string): string {
+ return template.replace(/\{(\w+)\}/g, (_, k: string) =>
+ String(
+ RESPONSE_TIMEOUT_COPY_PARAMS[
+ k as keyof typeof RESPONSE_TIMEOUT_COPY_PARAMS
+ ],
+ ),
+ );
+}
+
+describe("response-timeout copy", () => {
+ it("reads its numbers from the server constants", () => {
+ expect(RESPONSE_TIMEOUT_COPY_PARAMS.seconds).toBe(
+ PROVIDER_DEFAULT_TIMEOUT_MS / 1000,
+ );
+ expect(RESPONSE_TIMEOUT_COPY_PARAMS.briefingSeconds).toBe(
+ AI_BUDGETS.comprehensive.timeoutMs! / 1000,
+ );
+ expect(RESPONSE_TIMEOUT_COPY_PARAMS.briefingSeconds).toBeGreaterThan(0);
+ });
+
+ it.each(LOCALES)(
+ "%s interpolates the defaults and writes none itself",
+ (locale) => {
+ const copy = aiCopy(locale);
+ const placeholder = copy.responseTimeoutPlaceholder;
+ const body = copy.responseTimeoutBody;
+ expect(placeholder).toContain("{seconds}");
+ expect(body).toContain("{seconds}");
+ expect(body).toContain("{briefingSeconds}");
+ // No literal number outside the placeholders: a hardcoded one would drift.
+ expect(placeholder.replace(/\{\w+\}/g, "")).not.toMatch(/\d/);
+ expect(body.replace(/\{\w+\}/g, "")).not.toMatch(/\d/);
+
+ expect(render(placeholder)).toContain(
+ String(PROVIDER_DEFAULT_TIMEOUT_MS / 1000),
+ );
+ expect(render(body)).toContain(
+ String(AI_BUDGETS.comprehensive.timeoutMs! / 1000),
+ );
+ },
+ );
+});
diff --git a/src/components/settings/ai/response-timeout-card.tsx b/src/components/settings/ai/response-timeout-card.tsx
index c471694e3..628e9c0e4 100644
--- a/src/components/settings/ai/response-timeout-card.tsx
+++ b/src/components/settings/ai/response-timeout-card.tsx
@@ -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";
@@ -18,12 +18,24 @@ import { Loader2, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
+import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
+import { PROVIDER_DEFAULT_TIMEOUT_MS } from "@/lib/ai/effective-timeout";
import { apiPatch } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
import type { UserAIProvider } from "./shared";
+/**
+ * The defaults the copy names, read from the constants the server applies, so
+ * the placeholder cannot drift from the real fallback again (it said "~120"
+ * while most surfaces waited 60 s).
+ */
+export const RESPONSE_TIMEOUT_COPY_PARAMS = {
+ seconds: PROVIDER_DEFAULT_TIMEOUT_MS / 1000,
+ briefingSeconds: (AI_BUDGETS.comprehensive.timeoutMs ?? 0) / 1000,
+} as const;
+
export function ResponseTimeoutCard({
userProvider,
}: {
@@ -98,7 +110,7 @@ export function ResponseTimeoutCard({
{t("settings.ai.responseTimeoutHeading")}
- {t("settings.ai.responseTimeoutBody")}
+ {t("settings.ai.responseTimeoutBody", RESPONSE_TIMEOUT_COPY_PARAMS)}
@@ -113,7 +125,10 @@ export function ResponseTimeoutCard({
max={600}
value={value}
onChange={(e) => setValue(e.target.value)}
- placeholder={t("settings.ai.responseTimeoutPlaceholder")}
+ placeholder={t(
+ "settings.ai.responseTimeoutPlaceholder",
+ RESPONSE_TIMEOUT_COPY_PARAMS,
+ )}
className="mt-1 sm:max-w-xs"
/>
diff --git a/src/hooks/__tests__/use-ai-capability.test.tsx b/src/hooks/__tests__/use-ai-capability.test.tsx
index fbc7e9bf8..38a57a787 100644
--- a/src/hooks/__tests__/use-ai-capability.test.tsx
+++ b/src/hooks/__tests__/use-ai-capability.test.tsx
@@ -76,6 +76,7 @@ describe("useAiCapability", () => {
configured: false,
managedBy: null,
canConfigure: false,
+ responseTimeoutMs: 60_000,
});
});
diff --git a/src/hooks/use-ai-capability.ts b/src/hooks/use-ai-capability.ts
index fa8721c30..6a8abd503 100644
--- a/src/hooks/use-ai-capability.ts
+++ b/src/hooks/use-ai-capability.ts
@@ -7,6 +7,7 @@ import type {
AiCapabilityState,
AiProviderState,
} from "@/lib/ai/capabilities/types";
+import { PROVIDER_DEFAULT_TIMEOUT_MS } from "@/lib/ai/effective-timeout";
/**
* The web reader for the `ai` block on `GET /api/auth/me`.
@@ -34,6 +35,7 @@ const NO_PROVIDER_STATE: AiProviderState = Object.freeze({
configured: false,
managedBy: null,
canConfigure: false,
+ responseTimeoutMs: PROVIDER_DEFAULT_TIMEOUT_MS,
});
/** One capability, for the record this browser is inside. */
diff --git a/src/lib/ai/__tests__/provider-response-timeout.test.ts b/src/lib/ai/__tests__/provider-response-timeout.test.ts
new file mode 100644
index 000000000..006585c72
--- /dev/null
+++ b/src/lib/ai/__tests__/provider-response-timeout.test.ts
@@ -0,0 +1,85 @@
+/**
+ * Every exported resolver hands out providers carrying the record owner's
+ * response-timeout setting. This is the end of the pipe that makes the
+ * setting unforgettable at the call site: the surface functions never see it,
+ * the provider they are given already does.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ user: { findUnique: vi.fn(), update: vi.fn() },
+ appSettings: { findUnique: vi.fn() },
+ },
+}));
+vi.mock("@/lib/crypto", () => ({
+ decrypt: vi.fn((v: string) => `decrypted:${v}`),
+ encrypt: vi.fn((v: string) => `encrypted:${v}`),
+}));
+
+import { prisma } from "@/lib/db";
+import {
+ resolveProvider,
+ resolveProviderChain,
+ resolveProviderForTest,
+} from "../provider";
+
+const LOCAL_ROW = {
+ aiProvider: "LOCAL",
+ aiModel: "llama",
+ aiBaseUrl: "https://llm.example.org/v1",
+ aiAnthropicKeyEncrypted: null,
+ aiLocalKeyEncrypted: null,
+ aiOpenaiKeyEncrypted: null,
+ aiCompatBaseUrl: null,
+ aiCompatKeyEncrypted: null,
+ aiCompatModel: null,
+ role: "USER",
+ aiProviderChain: [{ providerType: "local", priority: 1, enabled: true }],
+ useCentralCodex: false,
+ managedProfileAt: null,
+};
+
+function withSetting(seconds: number | null) {
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ ...LOCAL_ROW,
+ aiResponseTimeoutSeconds: seconds,
+ } as never);
+ vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
+}
+
+beforeEach(() => {
+ vi.resetAllMocks();
+});
+
+describe("the resolvers bind the record's response-timeout setting", () => {
+ it("resolveProvider", async () => {
+ withSetting(300);
+ const provider = await resolveProvider("user-1");
+ expect(provider.type).toBe("local");
+ expect(provider.responseTimeoutSeconds).toBe(300);
+ });
+
+ it("resolveProviderChain, every entry", async () => {
+ withSetting(300);
+ const chain = await resolveProviderChain("user-1");
+ expect(chain.length).toBeGreaterThan(0);
+ for (const entry of chain) {
+ expect(entry.instance.responseTimeoutSeconds).toBe(300);
+ }
+ });
+
+ it("resolveProviderForTest, so the connection test waits as long as a read", async () => {
+ withSetting(300);
+ const provider = await resolveProviderForTest("user-1", {
+ provider: "LOCAL",
+ });
+ expect(provider.responseTimeoutSeconds).toBe(300);
+ });
+
+ it("binds nothing when the setting is unset", async () => {
+ withSetting(null);
+ const provider = await resolveProvider("user-1");
+ expect(provider.responseTimeoutSeconds).toBeNull();
+ });
+});
diff --git a/src/lib/ai/__tests__/response-timeout-binding.test.ts b/src/lib/ai/__tests__/response-timeout-binding.test.ts
new file mode 100644
index 000000000..5da896b28
--- /dev/null
+++ b/src/lib/ai/__tests__/response-timeout-binding.test.ts
@@ -0,0 +1,223 @@
+/**
+ * The person's response-timeout setting reaches every model call.
+ *
+ * A slow self-hosted model could need minutes for a five-page lab report, and
+ * raising Settings → AI → response timeout did nothing for it: the document
+ * read, the document extraction and the lab OCR called the provider without a
+ * `timeoutMs`, so every client fell back to its own 60 s. The setting now rides
+ * on the provider instance (stamped by the resolver) and every client reads its
+ * ceiling through `callTimeoutMs`, so these surfaces get it without passing it.
+ *
+ * These cases drive the real clients and the real surface functions down to
+ * the `safeFetch` call and read the timeout off it, so they hold the pipe and
+ * not just the helper.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const safeFetch = vi.fn();
+vi.mock("@/lib/safe-fetch", () => ({
+ safeFetch: (...a: unknown[]) => safeFetch(...a),
+}));
+vi.mock("../local-host-allowlist", () => ({
+ aiEgressPolicyFor: () => ({}),
+ isLocalAiHostAllowed: () => true,
+}));
+vi.mock("@/lib/db", () => ({ prisma: {} }));
+
+import { AnthropicClient } from "../anthropic-client";
+import { CodexClient } from "../codex-client";
+import {
+ bindResponseTimeout,
+ callTimeoutMs,
+ clientAbortMs,
+ PROVIDER_DEFAULT_TIMEOUT_MS,
+} from "../effective-timeout";
+import { LocalOpenAICompatibleClient } from "../local-client";
+import { OpenAIClient } from "../openai-client";
+import { singleUserTurn, type AIProvider } from "../types";
+import { runDocumentAssist } from "@/lib/documents/assist";
+import {
+ runDocumentSummary,
+ transcribeDocument,
+} from "@/lib/documents/describe";
+import { runInboundExtraction } from "@/lib/documents/extract";
+import { runOcrExtraction } from "@/lib/labs/ocr-extract";
+
+const STOP = new Error("stop after the fetch was dialled");
+
+beforeEach(() => {
+ safeFetch.mockReset();
+ safeFetch.mockRejectedValue(STOP);
+});
+
+/** The `timeoutMs` the most recent `safeFetch` call was dialled with. */
+function dialledTimeout(): number {
+ const call = safeFetch.mock.calls.at(-1);
+ expect(call, "safeFetch was never called").toBeDefined();
+ return (call![2] as { timeoutMs: number }).timeoutMs;
+}
+
+function local(): LocalOpenAICompatibleClient {
+ return new LocalOpenAICompatibleClient({
+ apiKey: null,
+ model: "llama",
+ baseUrl: "https://llm.example.org/v1",
+ });
+}
+
+const CLIENTS: Record AIProvider> = {
+ local,
+ openai: () =>
+ new OpenAIClient({
+ apiKey: "k",
+ model: "gpt-4o",
+ baseUrl: "https://api.openai.com/v1",
+ }),
+ anthropic: () =>
+ new AnthropicClient({ apiKey: "k", model: "claude-sonnet-4-6" }),
+ codex: () =>
+ new CodexClient({
+ accessToken: "t",
+ accountId: "a",
+ onTokenRefresh: vi.fn(),
+ slugChain: ["gpt-5.5"],
+ }),
+};
+
+describe("callTimeoutMs", () => {
+ it("lets the setting win over the surface value and the default", () => {
+ expect(callTimeoutMs({}, null)).toBe(PROVIDER_DEFAULT_TIMEOUT_MS);
+ expect(callTimeoutMs({ timeoutMs: 180_000 }, null)).toBe(180_000);
+ expect(callTimeoutMs({}, 300)).toBe(300_000);
+ expect(callTimeoutMs({ timeoutMs: 180_000 }, 30)).toBe(30_000);
+ });
+
+ it("keeps a surface ceiling where the surface says so", () => {
+ expect(
+ callTimeoutMs(
+ { timeoutMs: 9_000, timeoutPolicy: "surface-ceiling" },
+ 600,
+ ),
+ ).toBe(9_000);
+ });
+});
+
+describe("bindResponseTimeout", () => {
+ it("stamps a positive setting and clears an unset one", () => {
+ const p = local();
+ expect(bindResponseTimeout(p, 240).responseTimeoutSeconds).toBe(240);
+ expect(bindResponseTimeout(p, null).responseTimeoutSeconds).toBeNull();
+ expect(bindResponseTimeout(p, 0).responseTimeoutSeconds).toBeNull();
+ });
+});
+
+describe("clientAbortMs", () => {
+ it("waits for every model call plus the margin", () => {
+ expect(clientAbortMs(300_000, 1)).toBe(330_000);
+ expect(clientAbortMs(300_000, 3)).toBe(930_000);
+ });
+
+ it("reads a missing or malformed value as the server default", () => {
+ expect(clientAbortMs(undefined, 2)).toBe(2 * 60_000 + 30_000);
+ expect(clientAbortMs(0, 1)).toBe(90_000);
+ });
+});
+
+describe.each(Object.entries(CLIENTS))("the %s client", (_name, make) => {
+ it("dials with the bound setting, over the surface value", async () => {
+ const provider = bindResponseTimeout(make(), 420);
+ await expect(
+ provider.generateCompletion(
+ singleUserTurn({ system: "s", user: "u", timeoutMs: 120_000 }),
+ ),
+ ).rejects.toBeDefined();
+ expect(dialledTimeout()).toBe(420_000);
+ });
+
+ it("dials with the default when nothing is set", async () => {
+ await expect(
+ make().generateCompletion(singleUserTurn({ system: "s", user: "u" })),
+ ).rejects.toBeDefined();
+ expect(dialledTimeout()).toBe(60_000);
+ });
+
+ it("keeps a surface ceiling", async () => {
+ const provider = bindResponseTimeout(make(), 420);
+ await expect(
+ provider.generateCompletion(
+ singleUserTurn({
+ system: "s",
+ user: "u",
+ timeoutMs: 9_000,
+ timeoutPolicy: "surface-ceiling",
+ }),
+ ),
+ ).rejects.toBeDefined();
+ expect(dialledTimeout()).toBe(9_000);
+ });
+});
+
+describe("the surfaces that used to fall back to 60 s", () => {
+ const image = [{ mediaType: "image/png" as const, dataBase64: "AAAA" }];
+
+ function bound(): AIProvider {
+ return bindResponseTimeout(local(), 300);
+ }
+
+ it("document summary", async () => {
+ await expect(
+ runDocumentSummary({
+ provider: bound(),
+ providerType: "local",
+ images: image,
+ locale: "en",
+ }),
+ ).rejects.toBe(STOP);
+ expect(dialledTimeout()).toBe(300_000);
+ });
+
+ it("document transcription (Read with AI)", async () => {
+ await expect(
+ transcribeDocument({
+ provider: bound(),
+ providerType: "local",
+ images: image,
+ }),
+ ).rejects.toBe(STOP);
+ expect(dialledTimeout()).toBe(300_000);
+ });
+
+ it("document filing suggestion", async () => {
+ await expect(
+ runDocumentAssist({
+ provider: bound(),
+ providerType: "local",
+ images: image,
+ }),
+ ).rejects.toBe(STOP);
+ expect(dialledTimeout()).toBe(300_000);
+ });
+
+ it("document extraction, which lab staging runs", async () => {
+ await expect(
+ runInboundExtraction({
+ provider: bound(),
+ providerType: "local",
+ images: image,
+ }),
+ ).rejects.toBe(STOP);
+ expect(dialledTimeout()).toBe(300_000);
+ });
+
+ it("lab OCR", async () => {
+ await expect(
+ runOcrExtraction({
+ userId: "u1",
+ provider: bound(),
+ providerType: "local",
+ images: image,
+ }),
+ ).rejects.toBe(STOP);
+ expect(dialledTimeout()).toBe(300_000);
+ });
+});
diff --git a/src/lib/ai/anthropic-client.ts b/src/lib/ai/anthropic-client.ts
index c26903f90..6f4b460fe 100644
--- a/src/lib/ai/anthropic-client.ts
+++ b/src/lib/ai/anthropic-client.ts
@@ -1,4 +1,5 @@
import { safeFetch } from "@/lib/safe-fetch";
+import { callTimeoutMs } from "./effective-timeout";
import { extractJsonObject } from "./json-extract";
import type {
AIProvider,
@@ -157,6 +158,8 @@ function buildAnthropicTools(tools: AiToolDef[]) {
export class AnthropicClient implements AIProvider {
readonly type = "anthropic" as const;
+ /** See `AIProvider.responseTimeoutSeconds`; stamped by the resolver. */
+ responseTimeoutSeconds: number | null = null;
private config: AnthropicClientConfig;
constructor(config: AnthropicClientConfig) {
@@ -259,9 +262,9 @@ export class AnthropicClient implements AIProvider {
// check so a private/metadata address is rejected (SSRF/rebinding).
// v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so
// a mid-generation abort tears the upstream call down early.
- // v1.21.5 — honour the caller's per-request timeout override; default 60 s.
+ // The record owner's response-timeout setting, else the surface value, else 60 s.
{
- timeoutMs: params.timeoutMs ?? 60_000,
+ timeoutMs: callTimeoutMs(params, this.responseTimeoutSeconds),
requirePublicHost: true,
signal: params.signal,
},
diff --git a/src/lib/ai/capabilities/__tests__/resolve.test.ts b/src/lib/ai/capabilities/__tests__/resolve.test.ts
index d6f1dba90..e631014bd 100644
--- a/src/lib/ai/capabilities/__tests__/resolve.test.ts
+++ b/src/lib/ai/capabilities/__tests__/resolve.test.ts
@@ -452,9 +452,18 @@ describe("the provider block", () => {
configured: true,
managedBy: "local",
canConfigure: true,
+ responseTimeoutMs: 60_000,
});
});
+ it("publishes the record's response-timeout setting as the effective timeout", () => {
+ const inputs = baseline();
+ inputs.provider = { ...inputs.provider, responseTimeoutSeconds: 420 };
+ expect(resolveAiProviderState(inputs).responseTimeoutMs).toBe(420_000);
+ inputs.provider = { ...inputs.provider, responseTimeoutSeconds: null };
+ expect(resolveAiProviderState(inputs).responseTimeoutMs).toBe(60_000);
+ });
+
it("offers setup only on one's own record, and only with the master on", () => {
for (const recordKind of ["shared", "managed"] as const) {
const inputs = baseline();
@@ -473,6 +482,7 @@ describe("the provider block", () => {
configured: false,
managedBy: null,
canConfigure: false,
+ responseTimeoutMs: 60_000,
});
});
});
diff --git a/src/lib/ai/capabilities/resolve.ts b/src/lib/ai/capabilities/resolve.ts
index 60c48f119..5b97aee38 100644
--- a/src/lib/ai/capabilities/resolve.ts
+++ b/src/lib/ai/capabilities/resolve.ts
@@ -13,6 +13,10 @@
* a client acted on it. They stay a per-call refusal with their own codes.
*/
import { documentProviderRank } from "@/lib/documents/provider-rank";
+import {
+ PROVIDER_DEFAULT_TIMEOUT_MS,
+ resolveEffectiveTimeoutMs,
+} from "@/lib/ai/effective-timeout";
import {
isExternalProvider,
isOperatorHeldProvider,
@@ -56,6 +60,12 @@ export interface ProviderPresence {
/** The person's in-browser OCR opt-in, which lets a text-only provider read a document. */
localOcrEnabled: boolean;
managedBy: AiProviderManagedBy | null;
+ /**
+ * The record's `aiResponseTimeoutSeconds`, read off the same row. Not a
+ * presence fact, but the account payload publishes the effective timeout
+ * beside the provider and this row is already in hand. Absent → unset.
+ */
+ responseTimeoutSeconds?: number | null;
}
/** Whose record this is, as far as configuring a provider for it goes. */
@@ -332,12 +342,21 @@ export function resolveAiProviderState(
inputs: AiCapabilityInputs | null,
): AiProviderState {
if (inputs === null) {
- return { configured: false, managedBy: null, canConfigure: false };
+ return {
+ configured: false,
+ managedBy: null,
+ canConfigure: false,
+ responseTimeoutMs: PROVIDER_DEFAULT_TIMEOUT_MS,
+ };
}
return {
configured: inputs.provider.entries.length > 0,
managedBy: inputs.provider.managedBy,
canConfigure: inputs.recordKind === "self" && inputs.switches.enabled,
+ responseTimeoutMs: resolveEffectiveTimeoutMs(
+ inputs.provider.responseTimeoutSeconds,
+ PROVIDER_DEFAULT_TIMEOUT_MS,
+ ),
};
}
diff --git a/src/lib/ai/capabilities/types.ts b/src/lib/ai/capabilities/types.ts
index 13adfceca..1dc6274df 100644
--- a/src/lib/ai/capabilities/types.ts
+++ b/src/lib/ai/capabilities/types.ts
@@ -313,6 +313,15 @@ export interface AiProviderState {
* and when the operator's master switch is off.
*/
canConfigure: boolean;
+ /**
+ * How long one model call on this record may take before the server gives
+ * up, in milliseconds: the record's response-timeout setting, else the 60 s
+ * default. A surface that waits on a model call (document reads, lab OCR)
+ * sizes its own request abort from this plus a margin, so the browser never
+ * gives up before the server does. Surfaces with a larger budget of their
+ * own (the briefing) may run longer when the setting is unset.
+ */
+ responseTimeoutMs: number;
}
/** The `ai` block the account payload publishes. */
diff --git a/src/lib/ai/codex-client.ts b/src/lib/ai/codex-client.ts
index 9a2232e2b..5cba79686 100644
--- a/src/lib/ai/codex-client.ts
+++ b/src/lib/ai/codex-client.ts
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
+import { callTimeoutMs } from "./effective-timeout";
import { safeFetch } from "@/lib/safe-fetch";
import type {
AIProvider,
@@ -253,6 +254,8 @@ export interface CodexAttemptDiagnostics {
export class CodexClient implements AIProvider {
readonly type = "codex" as const;
+ /** See `AIProvider.responseTimeoutSeconds`; stamped by the resolver. */
+ responseTimeoutSeconds: number | null = null;
private accessToken: string;
private accountId: string;
private onTokenRefresh: () => Promise<{
@@ -509,9 +512,12 @@ export class CodexClient implements AIProvider {
},
// v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so
// a mid-generation abort tears the upstream call down early.
- // v1.21.5 — honour the caller's per-request timeout override (the
- // comprehensive briefing needs >60 s); default unchanged at 60 s.
- { timeoutMs: params.timeoutMs ?? 60_000, signal: params.signal },
+ // The record owner's response-timeout setting, else the surface value
+ // (the comprehensive briefing needs >60 s), else 60 s.
+ {
+ timeoutMs: callTimeoutMs(params, this.responseTimeoutSeconds),
+ signal: params.signal,
+ },
);
}
diff --git a/src/lib/ai/effective-timeout.ts b/src/lib/ai/effective-timeout.ts
index 1055c558e..9f97754bd 100644
--- a/src/lib/ai/effective-timeout.ts
+++ b/src/lib/ai/effective-timeout.ts
@@ -1,19 +1,23 @@
+import type { AIProvider, CompletionParams } from "./types";
+
+/**
+ * The provider timeout a call gets when neither the surface nor the person set
+ * one. Every client used to carry its own copy of this literal.
+ */
+export const PROVIDER_DEFAULT_TIMEOUT_MS = 60_000;
+
/**
* Resolve the upstream provider timeout (ms) for a non-chat generation surface.
*
* The per-user `User.aiResponseTimeoutSeconds` setting (Settings → AI) is the
* operator's lever for slow self-hosted / local backends whose first request
- * loads the model. Coach already threads it onto its provider call; the
- * generated-narrative surfaces (daily briefing, status cards, period
- * narratives, …) historically hardcoded their per-surface `AI_BUDGETS.*`
- * default and ignored the setting, so raising the timeout had no effect on
- * them. This helper is the one place that converts the stored seconds into the
- * milliseconds a `CompletionParams.timeoutMs` expects, falling back to the
- * surface's budget default when the user has not set a value.
+ * loads the model. This helper is the one place that converts the stored
+ * seconds into milliseconds, falling back to the surface's budget default when
+ * the user has not set a value.
*
- * Semantics mirror Coach: a positive stored value wins (seconds → ms), and any
- * unset / non-positive value yields the surface default. The setting is bounded
- * to 10–600 s at write-time, so no read-time clamp is needed here.
+ * Semantics: a positive stored value wins (seconds → ms), and any unset /
+ * non-positive value yields the surface default. The setting is bounded to
+ * 10–600 s at write-time, so no read-time clamp is needed here.
*/
export function resolveEffectiveTimeoutMs(
aiResponseTimeoutSeconds: number | null | undefined,
@@ -23,3 +27,65 @@ export function resolveEffectiveTimeoutMs(
? aiResponseTimeoutSeconds * 1000
: budgetDefaultMs;
}
+
+/**
+ * The timeout one provider call actually runs under. Every client reads its
+ * `safeFetch` ceiling from here and nowhere else.
+ *
+ * The person's setting rides on the provider instance (`responseTimeoutSeconds`,
+ * stamped by `bindResponseTimeout` when the provider is resolved for a record),
+ * so a call site cannot forget it: whatever a surface passes as `timeoutMs` is
+ * only the default the setting overrides. A surface that is deliberately
+ * latency-bounded and has a deterministic fallback opts out with
+ * `timeoutPolicy: "surface-ceiling"`, and the structural guard keeps that set
+ * frozen.
+ */
+export function callTimeoutMs(
+ params: Pick,
+ responseTimeoutSeconds: number | null | undefined,
+): number {
+ const surfaceMs = params.timeoutMs ?? PROVIDER_DEFAULT_TIMEOUT_MS;
+ if (params.timeoutPolicy === "surface-ceiling") return surfaceMs;
+ return resolveEffectiveTimeoutMs(responseTimeoutSeconds, surfaceMs);
+}
+
+/**
+ * Stamp the record owner's response-timeout setting onto a resolved provider.
+ * Called by every exported resolver in `provider.ts` on its way out, which is
+ * the only way a provider reaches a call site.
+ */
+export function bindResponseTimeout(
+ provider: P,
+ aiResponseTimeoutSeconds: number | null | undefined,
+): P {
+ provider.responseTimeoutSeconds =
+ aiResponseTimeoutSeconds != null && aiResponseTimeoutSeconds > 0
+ ? aiResponseTimeoutSeconds
+ : null;
+ return provider;
+}
+
+/**
+ * Room a browser leaves on top of the server's model-call budget for what
+ * surrounds the calls: the upload, decrypting and rasterising the original,
+ * writing the result, the response itself.
+ */
+export const CLIENT_ABORT_MARGIN_MS = 30_000;
+
+/**
+ * The request timeout a browser uses for a route that waits on `modelCalls`
+ * sequential model calls, given the `ai.provider.responseTimeoutMs` the
+ * account payload published. Never recomputes the setting: an absent or
+ * malformed value (a payload from before the field existed) reads as the
+ * server's own default.
+ */
+export function clientAbortMs(
+ responseTimeoutMs: number | null | undefined,
+ modelCalls: number,
+): number {
+ const perCall =
+ typeof responseTimeoutMs === "number" && responseTimeoutMs > 0
+ ? responseTimeoutMs
+ : PROVIDER_DEFAULT_TIMEOUT_MS;
+ return perCall * modelCalls + CLIENT_ABORT_MARGIN_MS;
+}
diff --git a/src/lib/ai/local-client.ts b/src/lib/ai/local-client.ts
index aec80be1e..27c96df89 100644
--- a/src/lib/ai/local-client.ts
+++ b/src/lib/ai/local-client.ts
@@ -1,4 +1,5 @@
import { safeFetch } from "@/lib/safe-fetch";
+import { callTimeoutMs } from "./effective-timeout";
import { annotate } from "@/lib/logging/context";
import { aiEgressPolicyFor } from "./local-host-allowlist";
import type {
@@ -105,6 +106,8 @@ function withStrictJsonPrefix(messages: AiMessage[]): AiMessage[] {
*/
export class LocalOpenAICompatibleClient implements AIProvider {
readonly type = "local" as const;
+ /** See `AIProvider.responseTimeoutSeconds`; stamped by the resolver. */
+ responseTimeoutSeconds: number | null = null;
/** Local servers commonly reject an unknown `tools` field — never send it. */
readonly supportsTools = false;
private config: LocalClientConfig;
@@ -218,8 +221,8 @@ export class LocalOpenAICompatibleClient implements AIProvider {
// v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so
// a mid-generation abort tears the upstream call down early.
{
- // v1.22 (#89) — honour the caller's per-request timeout override; default 60 s.
- timeoutMs: params.timeoutMs ?? 60_000,
+ // The record owner's response-timeout setting, else the surface value, else 60 s.
+ timeoutMs: callTimeoutMs(params, this.responseTimeoutSeconds),
...egress,
signal: params.signal,
},
@@ -353,7 +356,7 @@ export class LocalOpenAICompatibleClient implements AIProvider {
// per-idle window. Composed with the caller's cancel signal so a client
// disconnect still tears the call down immediately.
const idle = new AbortController();
- const idleMs = params.timeoutMs ?? 60_000;
+ const idleMs = callTimeoutMs(params, this.responseTimeoutSeconds);
const signal = params.signal
? AbortSignal.any([params.signal, idle.signal])
: idle.signal;
diff --git a/src/lib/ai/openai-client.ts b/src/lib/ai/openai-client.ts
index 0bf8b7c42..63464563c 100644
--- a/src/lib/ai/openai-client.ts
+++ b/src/lib/ai/openai-client.ts
@@ -1,4 +1,5 @@
import { safeFetch } from "@/lib/safe-fetch";
+import { callTimeoutMs } from "./effective-timeout";
import { annotate } from "@/lib/logging/context";
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
import { selectOpenAIChatCompletionsCapabilities } from "./openai-capabilities";
@@ -59,6 +60,8 @@ type OpenAIProviderType = "admin-key" | "codex" | "openai-compatible";
export class OpenAIClient implements AIProvider {
readonly type: OpenAIProviderType;
+ /** See `AIProvider.responseTimeoutSeconds`; stamped by the resolver. */
+ responseTimeoutSeconds: number | null = null;
private config: OpenAIClientConfig;
constructor(config: OpenAIClientConfig) {
@@ -156,7 +159,7 @@ export class OpenAIClient implements AIProvider {
// address is rejected, closing the SSRF/rebinding surface.
// v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so
// a mid-generation abort tears the upstream call down early.
- // v1.21.5 — honour the caller's per-request timeout override; default 60 s.
+ // The record owner's response-timeout setting, else the surface value, else 60 s.
// v1.33.1 (#470) — the gateway tag reuses the Local provider's host
// policy verbatim: public hosts always; a private host only when the
// operator granted its origin (`AI_PRIVATE_ORIGINS` or the legacy host
@@ -174,7 +177,7 @@ export class OpenAIClient implements AIProvider {
// pin: its base URL is a repository constant, there is nothing an
// operator legitimately redirects.
{
- timeoutMs: params.timeoutMs ?? 60_000,
+ timeoutMs: callTimeoutMs(params, this.responseTimeoutSeconds),
...(this.isGateway || this.type === "admin-key"
? aiEgressPolicyFor(url, {
operatorTrusted: this.config.operatorTrusted,
diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts
index 885ee43de..89bc856a0 100644
--- a/src/lib/ai/provider.ts
+++ b/src/lib/ai/provider.ts
@@ -1,4 +1,5 @@
import type { AIProvider, CompletionResult } from "./types";
+import { bindResponseTimeout } from "./effective-timeout";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { CodexClient, resolveCodexVisionSlug } from "./codex-client";
@@ -499,18 +500,24 @@ export async function resolveProvider(userId: string): Promise {
aiCompatModel: true,
role: true,
managedProfileAt: true,
+ aiResponseTimeoutSeconds: true,
},
});
+ // The record's response-timeout setting rides on whatever this returns, on
+ // every branch, the operator-key fallback for a managed profile included:
+ // the person waiting on the record is who the call serves.
+ const bind = (provider: AIProvider): AIProvider =>
+ bindResponseTimeout(provider, userRow?.aiResponseTimeoutSeconds);
const policy = providerCredentialPolicy(
authority,
userRow?.managedProfileAt ?? null,
);
- if (policy === "operator-default") return resolveAdminProvider();
+ if (policy === "operator-default") return bind(await resolveAdminProvider());
// 1. Per-user Anthropic / Local
if (userRow) {
const userProvider = buildUserProvider(userRow);
- if (userProvider) return userProvider;
+ if (userProvider) return bind(userProvider);
}
// 2. Codex OAuth (either explicitly selected or implicit fallback)
@@ -518,11 +525,26 @@ export async function resolveProvider(userId: string): Promise {
const tryCodex = explicitChoice === "CHATGPT_OAUTH" || !explicitChoice;
if (tryCodex) {
const codex = await resolveCodexProvider(userId);
- if (codex) return codex;
+ if (codex) return bind(codex);
}
// 3. Admin OpenAI key (also acts as fallback for user-OPENAI selection)
- return resolveAdminProvider();
+ return bind(await resolveAdminProvider());
+}
+
+/**
+ * The record owner's `aiResponseTimeoutSeconds`, for `resolveProviderForTest`,
+ * whose many branches (saved config, unsaved override, the chain) are bound in
+ * one place on the way out. The other two resolvers read it with their row.
+ */
+async function readResponseTimeoutSeconds(
+ userId: string,
+): Promise {
+ const row = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { aiResponseTimeoutSeconds: true },
+ });
+ return row?.aiResponseTimeoutSeconds ?? null;
}
/**
@@ -570,8 +592,12 @@ export async function resolveProviderChain(
aiProviderChain: true,
useCentralCodex: true,
managedProfileAt: true,
+ aiResponseTimeoutSeconds: true,
},
});
+ // See `resolveProvider`: every instance carries the record's setting.
+ const bind = (provider: AIProvider): AIProvider =>
+ bindResponseTimeout(provider, userRow?.aiResponseTimeoutSeconds);
const policy = providerCredentialPolicy(
authority,
userRow?.managedProfileAt ?? null,
@@ -580,7 +606,7 @@ export async function resolveProviderChain(
const provider = await resolveAdminProvider();
return provider.type === "none"
? []
- : [{ providerType: "admin-openai", instance: provider }];
+ : [{ providerType: "admin-openai", instance: bind(provider) }];
}
const rawChain = userRow?.aiProviderChain ?? null;
@@ -594,7 +620,10 @@ export async function resolveProviderChain(
policy,
});
if (instance) {
- resolved.push({ providerType: entry.providerType, instance });
+ resolved.push({
+ providerType: entry.providerType,
+ instance: bind(instance),
+ });
}
}
@@ -607,7 +636,10 @@ export async function resolveProviderChain(
if (userRow?.useCentralCodex) {
const centralCodex = await resolveAdminCodexProvider();
if (centralCodex) {
- resolved.push({ providerType: "admin-codex", instance: centralCodex });
+ resolved.push({
+ providerType: "admin-codex",
+ instance: bind(centralCodex),
+ });
}
}
@@ -854,6 +886,7 @@ const PRESENCE_USER_SELECT = {
useCentralCodex: true,
managedProfileAt: true,
labsLocalOcrEnabled: true,
+ aiResponseTimeoutSeconds: true,
} as const;
const PRESENCE_SETTINGS_SELECT = {
@@ -947,6 +980,7 @@ export function probeProviderChain(
// A guardian's in-browser OCR is not the record's opt-in to use.
localOcrEnabled: false,
managedBy: adminKey ? "server" : null,
+ responseTimeoutSeconds: row.aiResponseTimeoutSeconds,
};
}
@@ -1002,6 +1036,7 @@ export function probeProviderChain(
entries,
localOcrEnabled: row.labsLocalOcrEnabled,
managedBy: entries.length > 0 ? managedBy : null,
+ responseTimeoutSeconds: row.aiResponseTimeoutSeconds,
};
},
{ freshInBackground: true },
@@ -1146,6 +1181,19 @@ export class AITestConfigError extends Error {
export async function resolveProviderForTest(
userId: string,
override: AITestOverride = {},
+): Promise {
+ // The test runs under the same timeout generation would, so a slow local
+ // model that needs a raised setting is not reported as dead by the test.
+ const provider = await resolveProviderForTestUnbound(userId, override);
+ return bindResponseTimeout(
+ provider,
+ await readResponseTimeoutSeconds(userId),
+ );
+}
+
+async function resolveProviderForTestUnbound(
+ userId: string,
+ override: AITestOverride,
): Promise {
const stored = await prisma.user.findUnique({
where: { id: userId },
diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts
index 898edb62c..541dcbb24 100644
--- a/src/lib/ai/types.ts
+++ b/src/lib/ai/types.ts
@@ -115,6 +115,14 @@ export interface AIProvider {
* available" and answer from base context.
*/
supportsTools?: boolean;
+ /**
+ * The record owner's `aiResponseTimeoutSeconds`, stamped by the resolver
+ * that handed this provider out (`bindResponseTimeout`). Each client reads
+ * its upstream ceiling through `callTimeoutMs(params, this.responseTimeoutSeconds)`,
+ * so the setting reaches every call without the call site passing it.
+ * `null` / absent → the surface's `timeoutMs`, else the 60 s default.
+ */
+ responseTimeoutSeconds?: number | null;
generateCompletion(params: CompletionParams): Promise;
/**
* v1.22 (#89) — optional true-streaming variant. A provider that can emit
@@ -303,8 +311,21 @@ export interface CompletionParams {
* aborted mid-stream ("operation aborted due to timeout"), which left the
* briefing — and the insights trend narrative that reads the same cached
* block — permanently blank. Sized in `AI_BUDGETS.comprehensive.timeoutMs`.
+ *
+ * This is the SURFACE default. The record owner's response-timeout setting,
+ * bound to the provider instance at resolution, overrides it (see
+ * `callTimeoutMs` in `effective-timeout.ts`) unless `timeoutPolicy` says the
+ * value is a hard ceiling.
*/
timeoutMs?: number;
+ /**
+ * `"surface-ceiling"` makes `timeoutMs` a hard ceiling the person's
+ * response-timeout setting does not lift. Reserved for unattended surfaces
+ * that are latency-bounded by design and fall back to deterministic copy
+ * (the Coach nudge tick, the reaction line). The set of callers is frozen by
+ * `ai-call-timeout-guard.test.ts`.
+ */
+ timeoutPolicy?: "surface-ceiling";
}
export interface CompletionResult {
@@ -351,6 +372,8 @@ export function singleUserTurn(p: {
signal?: AbortSignal;
/** v1.22 (#89) — per-request upstream timeout override (ms); see CompletionParams. */
timeoutMs?: number;
+ /** See `CompletionParams.timeoutPolicy`. */
+ timeoutPolicy?: "surface-ceiling";
}): CompletionParams {
const images = p.images ?? [];
const documents = p.documents ?? [];
@@ -385,6 +408,7 @@ export function singleUserTurn(p: {
toolChoice: p.toolChoice,
signal: p.signal,
timeoutMs: p.timeoutMs,
+ ...(p.timeoutPolicy ? { timeoutPolicy: p.timeoutPolicy } : {}),
};
}
diff --git a/src/lib/jobs/coach-nudge-ai.ts b/src/lib/jobs/coach-nudge-ai.ts
index ed3c3a374..a7056d7d4 100644
--- a/src/lib/jobs/coach-nudge-ai.ts
+++ b/src/lib/jobs/coach-nudge-ai.ts
@@ -239,6 +239,10 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => {
temperature: budget.temperature,
maxTokens,
timeoutMs: COACH_NUDGE_AI_CALL_TIMEOUT_MS,
+ // The tick is sequential across every account and bounded by
+ // `COACH_NUDGE_AI_TICK_BUDGET_MS`; the template is always ready. A
+ // raised response-timeout setting must not hold the tick for others.
+ timeoutPolicy: "surface-ceiling",
signal: AbortSignal.timeout(COACH_NUDGE_AI_CALL_TIMEOUT_MS + 1_000),
}),
);
diff --git a/src/lib/jobs/reaction-line.ts b/src/lib/jobs/reaction-line.ts
index 3c32cf7d6..e5b646c1a 100644
--- a/src/lib/jobs/reaction-line.ts
+++ b/src/lib/jobs/reaction-line.ts
@@ -673,6 +673,10 @@ export async function runReactionLine(
temperature: budget.temperature,
maxTokens,
timeoutMs: REACTION_LINE_TIMEOUT_MS,
+ // The claim lease (`REACTION_LINE_CLAIM_LEASE_MS`) is shorter than the
+ // largest response-timeout setting, and the deterministic lead stands
+ // in when the line does not arrive, so this ceiling is not lifted.
+ timeoutPolicy: "surface-ceiling",
signal: AbortSignal.timeout(REACTION_LINE_TIMEOUT_MS + 1_000),
}),
);
diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts
index 56ab7a1ad..257f01428 100644
--- a/src/lib/openapi/routes/profile.ts
+++ b/src/lib/openapi/routes/profile.ts
@@ -181,6 +181,13 @@ export const aiProviderState = z
.describe(
"Whether the person in front of the screen may set up a provider for this record: false inside somebody else's record and while the operator's master switch is off. A setup hint is shown only when this is true.",
),
+ responseTimeoutMs: z
+ .number()
+ .int()
+ .positive()
+ .describe(
+ "How long one model call on this record may take before the server gives up, in milliseconds: the record's response-timeout setting (Settings → AI), else 60000. A client that waits on a model call (a document read, lab OCR) sizes its own request timeout from this plus a margin, so it never gives up before the server does.",
+ ),
})
.meta({
id: "AiProviderState",
diff --git a/tests/integration/auth-me-ai-capabilities.test.ts b/tests/integration/auth-me-ai-capabilities.test.ts
index 63bd98a91..fdabc33a0 100644
--- a/tests/integration/auth-me-ai-capabilities.test.ts
+++ b/tests/integration/auth-me-ai-capabilities.test.ts
@@ -73,6 +73,7 @@ interface MePayload {
configured: boolean;
managedBy: string | null;
canConfigure: boolean;
+ responseTimeoutMs: number;
};
};
}
@@ -171,9 +172,20 @@ describe("GET /api/auth/me — ai", () => {
configured: true,
managedBy: "user",
canConfigure: true,
+ responseTimeoutMs: 60_000,
});
});
+ it("publishes the record's response-timeout setting as the effective timeout", async () => {
+ const user = await makeUser("slow-local", {
+ aiResponseTimeoutSeconds: 420,
+ });
+ await grantConsent(user.id, "ai_full");
+ await signIn(user.id);
+ const me = await readMe();
+ expect(me.ai.provider.responseTimeoutMs).toBe(420_000);
+ });
+
it("closes everything when the operator's master switch is off", async () => {
const user = await makeUser("master-off");
await grantConsent(user.id, "ai_full");
@@ -216,6 +228,7 @@ describe("GET /api/auth/me — ai", () => {
configured: false,
managedBy: null,
canConfigure: true,
+ responseTimeoutMs: 60_000,
});
});
@@ -318,6 +331,7 @@ describe("GET /api/auth/me — ai", () => {
configured: false,
managedBy: null,
canConfigure: false,
+ responseTimeoutMs: 60_000,
});
});
@@ -352,6 +366,7 @@ describe("GET /api/auth/me — ai", () => {
configured: true,
managedBy: "server",
canConfigure: false,
+ responseTimeoutMs: 60_000,
});
});
});