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).
This commit is contained in:
Marc-André Bombeck committed 2026-06-26 09:27:29 +02:00
1 parent e1e1b0e985
commit 4c571b0e04
6 files changed
+32 -4

No files matched your search

+4
View File
@@ -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;
+3 -1
View File
@@ -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) {
+3 -1
View File
@@ -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 },
);
}
+7 -1
View File
@@ -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) {
+3 -1
View File
@@ -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) {
+12
View File
@@ -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,
};
}