From 4c571b0e0419b05a4ac9059680449adad2d38fa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 09:27:29 +0200 Subject: [PATCH] fix(insights): cancel the Coach upstream provider call on client disconnect The Coach SSE turn ran the provider call to completion before the stream existed and never wired it to the request's abort signal, so a mid-generation client disconnect still paid the full upstream token cost. Thread an optional `signal` through CompletionParams and singleUserTurn into each client's safeFetch (composed with the per-call timeout via AbortSignal.any), and pass `request.signal` from the chat route's no-tools path. An early disconnect now tears the upstream request down instead of generating into a closed connection. Omitting the signal leaves behaviour unchanged (timeout-only). --- src/app/api/insights/chat/route.ts | 4 ++++ src/lib/ai/anthropic-client.ts | 4 +++- src/lib/ai/codex-client.ts | 4 +++- src/lib/ai/local-client.ts | 8 +++++++- src/lib/ai/openai-client.ts | 4 +++- src/lib/ai/types.ts | 12 ++++++++++++ 6 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index aedc94be7..f418a1c7e 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -567,6 +567,10 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch user: userPrompt, temperature: AI_BUDGETS.coach.temperature, maxTokens: AI_BUDGETS.coach.maxTokens, + // v1.20.1 — thread the request's abort signal so a mid-generation + // client disconnect tears the upstream provider call down instead of + // paying the full token cost into a closed connection. + signal: request.signal, }), }); result = fallback.result; diff --git a/src/lib/ai/anthropic-client.ts b/src/lib/ai/anthropic-client.ts index 97162de03..00c704701 100644 --- a/src/lib/ai/anthropic-client.ts +++ b/src/lib/ai/anthropic-client.ts @@ -259,7 +259,9 @@ export class AnthropicClient implements AIProvider { // 60 s ceiling — see openai-client.ts for the rationale. // v1.11.2 — base URL is user/admin-overridable; pin the connect-time DNS // check so a private/metadata address is rejected (SSRF/rebinding). - { timeoutMs: 60_000, requirePublicHost: true }, + // v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so + // a mid-generation abort tears the upstream call down early. + { timeoutMs: 60_000, requirePublicHost: true, signal: params.signal }, ); if (!res.ok) { diff --git a/src/lib/ai/codex-client.ts b/src/lib/ai/codex-client.ts index 2be9fcf8c..dcae2020a 100644 --- a/src/lib/ai/codex-client.ts +++ b/src/lib/ai/codex-client.ts @@ -467,7 +467,9 @@ export class CodexClient implements AIProvider { // clients use so a long generation is not clipped by the 15 s // default while still bounding a tar-pit upstream. }, - { timeoutMs: 60_000 }, + // v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so + // a mid-generation abort tears the upstream call down early. + { timeoutMs: 60_000, signal: params.signal }, ); } diff --git a/src/lib/ai/local-client.ts b/src/lib/ai/local-client.ts index 477d9bebc..fa41db7d7 100644 --- a/src/lib/ai/local-client.ts +++ b/src/lib/ai/local-client.ts @@ -125,7 +125,13 @@ export class LocalOpenAICompatibleClient implements AIProvider { ...(params.responseFormat === "json" ? { format: "json" } : {}), }), }, - { timeoutMs: 60_000, requirePublicHost: !allowPrivate }, + // v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so + // a mid-generation abort tears the upstream call down early. + { + timeoutMs: 60_000, + requirePublicHost: !allowPrivate, + signal: params.signal, + }, ); if (!res.ok) { diff --git a/src/lib/ai/openai-client.ts b/src/lib/ai/openai-client.ts index e4a8c808d..f84cda5f4 100644 --- a/src/lib/ai/openai-client.ts +++ b/src/lib/ai/openai-client.ts @@ -90,7 +90,9 @@ export class OpenAIClient implements AIProvider { // v1.11.2 — the base URL is user/admin-overridable (BYO gateway), so pin // the connect-time DNS check: a base URL resolving to a private/metadata // address is rejected, closing the SSRF/rebinding surface. - { timeoutMs: 60_000, requirePublicHost: true }, + // v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so + // a mid-generation abort tears the upstream call down early. + { timeoutMs: 60_000, requirePublicHost: true, signal: params.signal }, ); if (!res.ok) { diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts index 0e1cc8028..318becc18 100644 --- a/src/lib/ai/types.ts +++ b/src/lib/ai/types.ts @@ -271,6 +271,15 @@ export interface CompletionParams { * Mapped per provider; omitted from the wire when unset. */ toolChoice?: "auto" | "none"; + /** + * v1.20.1 — optional caller-owned cancellation signal. Threaded onto the + * client's `safeFetch` so a mid-generation client disconnect (the Coach SSE + * route wires `request.signal` here) tears the upstream provider request + * down instead of running it to completion and paying the full token cost + * into a closed connection. Composed with the per-client timeout inside + * `safeFetch`. Omitted → behaviour is unchanged (timeout-only). + */ + signal?: AbortSignal; } export interface CompletionResult { @@ -313,6 +322,8 @@ export function singleUserTurn(p: { documents?: CompletionDocument[]; tools?: AiToolDef[]; toolChoice?: "auto" | "none"; + /** v1.20.1 — caller-owned cancel signal threaded to the client fetch. */ + signal?: AbortSignal; }): CompletionParams { const images = p.images ?? []; const documents = p.documents ?? []; @@ -349,6 +360,7 @@ export function singleUserTurn(p: { responseFormat: p.responseFormat, tools: p.tools, toolChoice: p.toolChoice, + signal: p.signal, }; }