@providerkit/core 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +3 -0
  2. package/dist/errors.d.ts +17 -0
  3. package/dist/errors.d.ts.map +1 -1
  4. package/dist/errors.js +55 -6
  5. package/dist/errors.js.map +1 -1
  6. package/dist/presets.d.ts +37 -2
  7. package/dist/presets.d.ts.map +1 -1
  8. package/dist/presets.js +37 -2
  9. package/dist/presets.js.map +1 -1
  10. package/dist/providers/anthropic.d.ts.map +1 -1
  11. package/dist/providers/anthropic.js +84 -33
  12. package/dist/providers/anthropic.js.map +1 -1
  13. package/dist/providers/gemini.d.ts.map +1 -1
  14. package/dist/providers/gemini.js +25 -1
  15. package/dist/providers/gemini.js.map +1 -1
  16. package/dist/providers/openai.d.ts.map +1 -1
  17. package/dist/providers/openai.js +44 -16
  18. package/dist/providers/openai.js.map +1 -1
  19. package/dist/providers/responses.d.ts.map +1 -1
  20. package/dist/providers/responses.js +131 -24
  21. package/dist/providers/responses.js.map +1 -1
  22. package/dist/rate-limit.d.ts.map +1 -1
  23. package/dist/rate-limit.js +53 -22
  24. package/dist/rate-limit.js.map +1 -1
  25. package/dist/schema.d.ts +10 -0
  26. package/dist/schema.d.ts.map +1 -1
  27. package/dist/schema.js +49 -0
  28. package/dist/schema.js.map +1 -1
  29. package/dist/transport.d.ts +30 -3
  30. package/dist/transport.d.ts.map +1 -1
  31. package/dist/transport.js +67 -28
  32. package/dist/transport.js.map +1 -1
  33. package/dist/types.d.ts +26 -2
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/types.js.map +1 -1
  36. package/dist/usage.d.ts +5 -4
  37. package/dist/usage.d.ts.map +1 -1
  38. package/dist/usage.js +14 -5
  39. package/dist/usage.js.map +1 -1
  40. package/dist/watchdog.d.ts +22 -8
  41. package/dist/watchdog.d.ts.map +1 -1
  42. package/dist/watchdog.js +77 -27
  43. package/dist/watchdog.js.map +1 -1
  44. package/package.json +3 -2
  45. package/src/errors.ts +60 -5
  46. package/src/presets.ts +37 -2
  47. package/src/providers/anthropic.ts +94 -36
  48. package/src/providers/gemini.ts +30 -1
  49. package/src/providers/openai.ts +42 -16
  50. package/src/providers/responses.ts +158 -26
  51. package/src/rate-limit.ts +55 -24
  52. package/src/schema.ts +51 -0
  53. package/src/transport.ts +95 -24
  54. package/src/types.ts +26 -2
  55. package/src/usage.ts +14 -5
  56. package/src/watchdog.ts +89 -32
package/src/transport.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // Adapters keep only their per-event mapping; everything about being an HTTP
6
6
  // client lives here once.
7
7
  import { classifyHttp, isTransportFailure, parseRetryAfterMs, ProviderError } from "./errors.ts";
8
- import { parseRateLimitResponse } from "./rate-limit.ts";
8
+ import { parseRateLimitReset, parseRateLimitResponse } from "./rate-limit.ts";
9
9
 
10
10
  export interface RequestInit_ {
11
11
  url: string;
@@ -16,8 +16,36 @@ export interface RequestInit_ {
16
16
  signal?: AbortSignal;
17
17
  /** Swapped in tests, or to route through a proxy. Defaults to global fetch. */
18
18
  fetchImpl?: typeof fetch;
19
+ /** Called when the response headers arrive and on every body read after —
20
+ * keep-alives included. What the watchdog's idle clock runs on. */
21
+ onActivity?: () => void;
22
+ /** Called when the body states `[DONE]`. See `SseHooks.onDone`. */
23
+ onDone?: () => void;
19
24
  }
20
25
 
26
+ export interface SseHooks {
27
+ /** Every read of the body, keep-alives and partial frames included: the
28
+ * stream is alive even when no event is complete yet. */
29
+ onActivity?: () => void;
30
+ /**
31
+ * The body said `[DONE]`. The payload itself is swallowed, but its arrival is
32
+ * the OpenAI dialect's only proof that a stream which stated no finish reason
33
+ * was not simply cut off.
34
+ */
35
+ onDone?: () => void;
36
+ /** Largest partial frame held before giving up. Defaults to 8 MiB. */
37
+ maxFrameChars?: number;
38
+ /** Names the provider on the oversized-frame error. */
39
+ provider?: string;
40
+ }
41
+
42
+ /**
43
+ * One frame larger than this is not a frame. Without a cap a malformed or
44
+ * runaway upstream grows the buffer until the runtime dies — an MV3 worker
45
+ * first. cc-proxy caps the ChatGPT backend at the same 8 MiB.
46
+ */
47
+ export const MAX_SSE_FRAME_CHARS = 8 * 1024 * 1024;
48
+
21
49
  /** Join a base URL and a path without doubling or dropping the slash. */
22
50
  export function apiUrl(baseUrl: string, path: string): string {
23
51
  return `${baseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
@@ -30,12 +58,12 @@ export function apiUrl(baseUrl: string, path: string): string {
30
58
  * per-minute throttle, because "try again in a moment" is a lie for those.
31
59
  */
32
60
  export function retryAfterFromHeaders(headers: Headers, now = Date.now()): number | undefined {
33
- const retryAfter = headers.get("retry-after");
34
- if (retryAfter) {
35
- if (/^\d+$/.test(retryAfter)) return Number(retryAfter) * 1000;
36
- const date = Date.parse(retryAfter);
37
- if (!Number.isNaN(date)) return Math.max(0, date - now);
38
- }
61
+ // `retry-after-ms`, then `Retry-After` as seconds (fractions included), a
62
+ // duration or an HTTP-date — one parser for both modules. This one used to
63
+ // test for digits and hand anything else to `Date.parse`, which reads "1.5"
64
+ // as a day in 2001: a wait of zero, and an immediate retry into the throttle.
65
+ const asked = parseRateLimitReset(headers, now).retryAfterMs;
66
+ if (asked !== undefined) return asked;
39
67
  // Anthropic and several gateways publish an epoch-seconds reset instead.
40
68
  for (const name of [
41
69
  "anthropic-ratelimit-unified-reset",
@@ -64,7 +92,17 @@ export function retryAfterFromHeaders(headers: Headers, now = Date.now()): numbe
64
92
  async function errorFor(provider: string, res: Response): Promise<ProviderError> {
65
93
  const text = await res.text().catch(() => "");
66
94
  const kind = classifyHttp(res.status, text);
67
- const reset = parseRateLimitResponse(res.headers, text);
95
+ // A window-reset header describes the ACCOUNT, not this failure, and only a
96
+ // throttle or a quota answer is about the window. Claude OAuth stamps the
97
+ // unified reset on every response, so read on any status a 529 said "lifts in
98
+ // three days": past every retry budget, and the backup walker benched the
99
+ // model until the weekly window rolled — the overload that most wants another
100
+ // attempt got none. A server's own Retry-After is honoured on any status.
101
+ const aboutWindow = kind === "rate" || kind === "quota";
102
+ const reset = aboutWindow ? parseRateLimitResponse(res.headers, text) : {};
103
+ const asked = aboutWindow
104
+ ? retryAfterFromHeaders(res.headers)
105
+ : parseRateLimitReset(res.headers).retryAfterMs;
68
106
  const message = text
69
107
  ? `${provider} ${res.status}: ${text.slice(0, 500)}`
70
108
  : `${provider} ${res.status} ${res.statusText}`;
@@ -75,7 +113,7 @@ async function errorFor(provider: string, res: Response): Promise<ProviderError>
75
113
  status: res.status,
76
114
  ...reset,
77
115
  shouldRetry,
78
- retryAfterMs: retryAfterFromHeaders(res.headers) ?? parseRetryAfterMs({}, text),
116
+ retryAfterMs: asked ?? parseRetryAfterMs({}, text),
79
117
  body: text.slice(0, 2_000) || undefined,
80
118
  });
81
119
  }
@@ -136,8 +174,9 @@ function payloadOf(frame: string): string | null {
136
174
  *
137
175
  * Frames are split on the blank line the spec requires, so a payload containing
138
176
  * a bare newline survives; `[DONE]` is swallowed here rather than in every
139
- * adapter. CRLF is normalized — some gateways send it, and a `\r` left on the
140
- * end of a JSON payload is a parse error nobody enjoys debugging.
177
+ * adapter, and reported through `hooks.onDone`. CRLF and bare CR are
178
+ * normalized — some gateways send them, and a `\r` left on the end of a JSON
179
+ * payload is a parse error nobody enjoys debugging.
141
180
  *
142
181
  * Exported apart from `streamSse` because the envelope above it is the half an
143
182
  * adopting app most often cannot take: an app with its own translated error
@@ -145,33 +184,61 @@ function payloadOf(frame: string): string | null {
145
184
  * request and reading the failure itself. Framing is the half nobody should
146
185
  * write twice — hand it a `res.body` and keep your own envelope.
147
186
  */
148
- export async function* parseSseStream(body: ReadableStream<Uint8Array>): AsyncGenerator<string> {
187
+ export async function* parseSseStream(
188
+ body: ReadableStream<Uint8Array>,
189
+ hooks: SseHooks = {},
190
+ ): AsyncGenerator<string> {
149
191
  const reader = body.getReader();
150
192
  const decoder = new TextDecoder();
193
+ const maxFrame = hooks.maxFrameChars ?? MAX_SSE_FRAME_CHARS;
151
194
  let buffer = "";
195
+ // A CR at the very end of a read may be the first half of a CRLF; it waits
196
+ // for the next read before it is turned into a line break.
197
+ let heldCR = false;
198
+
199
+ function* emit(frame: string): Generator<string> {
200
+ const payload = payloadOf(frame);
201
+ if (payload === "[DONE]") hooks.onDone?.();
202
+ else if (payload !== null) yield payload;
203
+ }
204
+
152
205
  try {
153
206
  for (;;) {
154
207
  const { done, value } = await reader.read();
155
208
  if (done) break;
156
- // Normalized on the BUFFER, not on the decoded read: a network read can
157
- // end between the CR and the LF, and normalizing each read separately
158
- // leaves that CR stranded on the end of a payload — a JSON parse error
159
- // that only ever reproduces under a particular packet split.
160
- buffer = (buffer + decoder.decode(value, { stream: true })).replace(/\r\n/g, "\n");
209
+ hooks.onActivity?.();
210
+ let text: string = (heldCR ? "\r" : "") + decoder.decode(value, { stream: true });
211
+ heldCR = text.endsWith("\r");
212
+ if (heldCR) text = text.slice(0, -1);
213
+ // Line endings are normalized on the new text only, CRLF and the bare CR
214
+ // the spec also allows. Normalizing the whole buffer on every read made a
215
+ // multi-megabyte frame quadratic; normalizing each read on its own, with
216
+ // no carry, strands the CR of a CRLF split across reads — a JSON parse
217
+ // error that only reproduces under one packet split.
218
+ text = text.replace(/\r\n?/g, "\n");
219
+ // The boundary may straddle the join, so the search starts one char back.
220
+ let from = Math.max(0, buffer.length - 1);
221
+ buffer += text;
161
222
 
162
- let boundary = buffer.indexOf("\n\n");
223
+ let boundary = buffer.indexOf("\n\n", from);
163
224
  while (boundary !== -1) {
164
- const frame = buffer.slice(0, boundary);
225
+ yield* emit(buffer.slice(0, boundary));
165
226
  buffer = buffer.slice(boundary + 2);
166
- const payload = payloadOf(frame);
167
- if (payload !== null && payload !== "[DONE]") yield payload;
227
+ from = 0;
168
228
  boundary = buffer.indexOf("\n\n");
169
229
  }
230
+ if (buffer.length > maxFrame) {
231
+ throw new ProviderError(
232
+ hooks.provider ?? "sse",
233
+ "overload",
234
+ `an SSE frame grew past ${maxFrame} characters without ending`,
235
+ { shouldRetry: false },
236
+ );
237
+ }
170
238
  }
171
239
  // A stream that ends without its final blank line still has an event in
172
240
  // hand — dropping it loses the last delta, or the usage record.
173
- const tail = payloadOf(buffer);
174
- if (tail !== null && tail !== "[DONE]") yield tail;
241
+ yield* emit(buffer + decoder.decode() + (heldCR ? "\n" : ""));
175
242
  } finally {
176
243
  reader.releaseLock();
177
244
  }
@@ -183,6 +250,10 @@ export async function* parseSseStream(body: ReadableStream<Uint8Array>): AsyncGe
183
250
  */
184
251
  export async function* streamSse(opts: RequestInit_): AsyncGenerator<string> {
185
252
  const res = await send(opts);
253
+ // Headers are the first sign of life — the idle clock starts here, not at
254
+ // the POST: a backend that withholds them until its first output token (the
255
+ // ChatGPT backend does, for minutes on a large high-effort turn) is working.
256
+ opts.onActivity?.();
186
257
  if (!res.ok) throw await errorFor(opts.provider, res);
187
258
  // A 2xx with no body at all is an upstream anomaly, not a request we got
188
259
  // wrong — worth the same retry a 5xx gets.
@@ -191,5 +262,5 @@ export async function* streamSse(opts: RequestInit_): AsyncGenerator<string> {
191
262
  status: res.status,
192
263
  });
193
264
  }
194
- yield* parseSseStream(res.body);
265
+ yield* parseSseStream(res.body, opts);
195
266
  }
package/src/types.ts CHANGED
@@ -151,10 +151,19 @@ export interface TokenUsage {
151
151
  * prefixes and bill the hit portion far cheaper, so it must be tracked
152
152
  * separately to cost a turn correctly. 0 when the provider reports none. */
153
153
  cachedInputTokens: number;
154
- /** Tokens WRITTEN to cache. Anthropic bills these above the input rate;
155
- * the OpenAI-shape auto-cachers bill them at it. 0 when not reported. */
154
+ /** Tokens WRITTEN to cache — also a subset of `inputTokens`, so the total
155
+ * stays the whole prompt the window has to hold. Anthropic bills these
156
+ * above the input rate; the OpenAI-shape auto-cachers bill them at it.
157
+ * 0 when not reported. */
156
158
  cacheWriteTokens?: number;
157
159
  outputTokens: number;
160
+ /**
161
+ * The part of `outputTokens` spent thinking, when the provider reports it —
162
+ * already INSIDE `outputTokens`, so never add it again. It is the number
163
+ * that explains a turn that came back empty with a length finish: the
164
+ * reasoning ate the budget. Anthropic does not report it; absent there.
165
+ */
166
+ reasoningTokens?: number;
158
167
  /**
159
168
  * What the provider says this call cost, in USD: its bill, not our
160
169
  * arithmetic. Only OpenRouter sends one today. It is the one number a rate
@@ -363,6 +372,21 @@ export interface StreamOptions {
363
372
  * caches by it); every other endpoint ignores it.
364
373
  */
365
374
  sessionId?: string;
375
+ /**
376
+ * Called when the response headers arrive and on every read of the body
377
+ * after, keep-alives included. `withWatchdog` sets it: a stream that is only
378
+ * sending keep-alives is alive, which no chunk an adapter yields can show.
379
+ * Every adapter here forwards it; a custom Provider that does not still
380
+ * works, on the watchdog's progress clock alone.
381
+ */
382
+ onActivity?: () => void;
383
+ /**
384
+ * `false` asks for at most one tool call per turn — for tools that must run
385
+ * in order, or a backend lane that refuses parallel calls. Absent or `true`
386
+ * is every vendor's default and sends nothing. Gemini has no such switch,
387
+ * so it refuses `false` rather than silently ignore it.
388
+ */
389
+ parallelToolCalls?: boolean;
366
390
  }
367
391
 
368
392
  export interface Provider {
package/src/usage.ts CHANGED
@@ -42,6 +42,9 @@ export function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
42
42
  cachedInputTokens: a.cachedInputTokens + b.cachedInputTokens,
43
43
  cacheWriteTokens: (a.cacheWriteTokens ?? 0) + (b.cacheWriteTokens ?? 0),
44
44
  outputTokens: a.outputTokens + b.outputTokens,
45
+ ...(a.reasoningTokens !== undefined || b.reasoningTokens !== undefined
46
+ ? { reasoningTokens: (a.reasoningTokens ?? 0) + (b.reasoningTokens ?? 0) }
47
+ : {}),
45
48
  };
46
49
  }
47
50
 
@@ -55,6 +58,9 @@ export function subtractUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
55
58
  cachedInputTokens: Math.max(0, a.cachedInputTokens - b.cachedInputTokens),
56
59
  cacheWriteTokens: Math.max(0, (a.cacheWriteTokens ?? 0) - (b.cacheWriteTokens ?? 0)),
57
60
  outputTokens: Math.max(0, a.outputTokens - b.outputTokens),
61
+ ...(a.reasoningTokens !== undefined
62
+ ? { reasoningTokens: Math.max(0, a.reasoningTokens - (b.reasoningTokens ?? 0)) }
63
+ : {}),
58
64
  };
59
65
  }
60
66
 
@@ -64,17 +70,20 @@ export function subtractUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
64
70
  * A cost the provider reported wins outright, including a reported 0: a free
65
71
  * call priced at the rate would be billed for something nobody charged.
66
72
  *
67
- * Cached tokens are a SUBSET of input, not an addition to it: the miss part
68
- * bills at the full rate and the hit part at the cache rate. `cached` is
69
- * clamped to `input` so an over-reporting provider can neither drive the miss
70
- * count negative nor bill for more prompt than it was sent.
73
+ * Cached and written tokens are SUBSETS of input, not additions to it: the
74
+ * miss part bills at the full rate, the hit part at the cache rate, the write
75
+ * part at the write rate. `cached` is clamped to `input` so an over-reporting
76
+ * provider can neither drive the miss count negative nor bill for more prompt
77
+ * than it was sent.
71
78
  */
72
79
  export function costUsd(usage: TokenUsage, rate: ModelRate): number {
73
80
  if (usage.reportedCostUsd !== undefined) return usage.reportedCostUsd;
74
81
  const input = Math.max(0, usage.inputTokens);
75
82
  const cached = Math.min(Math.max(0, usage.cachedInputTokens), input);
76
83
  const written = Math.max(0, usage.cacheWriteTokens ?? 0);
77
- const miss = input - cached;
84
+ // Writes are inside `input` too. Left in the miss count they billed twice —
85
+ // once at the input rate, again at the write rate: 2.25× on Claude.
86
+ const miss = Math.max(0, input - cached - written);
78
87
  const writeRate = rate.cacheWrite ?? rate.input;
79
88
  return (
80
89
  (miss * rate.input +
package/src/watchdog.ts CHANGED
@@ -1,17 +1,30 @@
1
1
  // The two ways a stream fails without failing: it goes silent, or it ends
2
2
  // having said nothing at all.
3
3
  //
4
- // The stream-idle watchdog.
4
+ // The stream watchdog.
5
5
  //
6
6
  // A provider that stops sending bytes is indistinguishable from a long prefill
7
7
  // — except that it never ends, and every SDK's default is to wait forever. A
8
8
  // queued route or a wedged prefill upstream hangs the caller indefinitely, and
9
9
  // the symptom is the worst kind: nothing. No error, no log, no timeout.
10
10
  //
11
- // So the seam gives itself a deadline. Any byte of any kind re-arms it
12
- // (reasoning models emit thinking deltas continuously, so silence really is
13
- // silence). When it fires, the watchdog aborts ITS OWN controller and the
14
- // caller's signal is only bridged in — which is what keeps a person's Stop
11
+ // So the seam gives itself two deadlines, because "silent" means two things:
12
+ //
13
+ // - IDLE: no byte at all, keep-alives included, for a minute after the
14
+ // response started. A dead socket. Any read re-arms it — a keep-alive is
15
+ // proof of life no chunk can carry (cc-proxy pings every 15s while it
16
+ // buffers a long tool call, and the old chunk-only clock killed those turns).
17
+ // - PROGRESS: no chunk for five minutes, counted from the POST. A stream that
18
+ // is alive and going nowhere — a route parked behind keep-alives — and the
19
+ // wait for the response to START, which is the one some backends take
20
+ // minutes over: the ChatGPT backend withholds its headers until the model's
21
+ // first output, so a high-effort turn on a large prompt sends nothing at all
22
+ // for minutes and is healthy the whole time (cc-proxy defaults that wait to
23
+ // 300s, measured). On a one-minute clock it was aborted and retried from
24
+ // scratch, which re-ran the reasoning into the same deadline.
25
+ //
26
+ // When either fires, the watchdog aborts ITS OWN controller and the caller's
27
+ // signal is only bridged in — which is what keeps a person's Stop
15
28
  // distinguishable from our timeout. One is their cancel and is never retried;
16
29
  // the other is ours, is transient, and fires while nothing has streamed yet,
17
30
  // so the retry is always safe.
@@ -24,44 +37,54 @@ import type {
24
37
  ToolDefinition,
25
38
  } from "./types.ts";
26
39
 
27
- /** No byte at all for this long and the stream is considered wedged. */
40
+ /** No byte at all for this long, once the response has started, and the
41
+ * stream is dead. */
28
42
  export const STREAM_IDLE_MS = 60_000;
29
43
 
44
+ /** No chunk for this long — the wait for the response to start included —
45
+ * and the stream is going nowhere, keep-alives or not. */
46
+ export const STREAM_PROGRESS_MS = 300_000;
47
+
30
48
  export interface StreamWatch {
31
49
  /** Hand this to the provider in place of the caller's signal. */
32
50
  readonly signal: AbortSignal;
33
- /** A byte arrived: re-arm the deadline, and mark TTFT if it was the first. */
51
+ /** A chunk arrived: re-arm both deadlines, and mark TTFT if it was the first. */
34
52
  sawByte(): void;
53
+ /** The response showed life without a chunk — its headers, a keep-alive, a
54
+ * partial frame. Starts or re-arms the idle deadline only. */
55
+ sawActivity(): void;
35
56
  /**
36
- * Milliseconds from the call opening to its first byte of any kind — the
57
+ * Milliseconds from the call opening to its first chunk of any kind — the
37
58
  * wait a person actually experiences, and the number a prompt-cache pin
38
59
  * exists to shrink. Null until something arrives.
39
60
  */
40
61
  firstChunkMs(): number | null;
41
62
  /**
42
- * Re-issue a provider failure as the idle timeout when — and only when — it
43
- * was our deadline that aborted. A caller's Stop passes through untouched.
63
+ * Re-issue a provider failure as the timeout when — and only when — it was
64
+ * one of our deadlines that aborted. A caller's Stop passes through untouched.
44
65
  */
45
66
  classify(err: unknown): unknown;
46
- /** Clear the deadline timer. Safe to call more than once. */
67
+ /** Clear the deadline timers. Safe to call more than once. */
47
68
  dispose(): void;
48
69
  }
49
70
 
50
71
  export interface StreamWatchOptions {
51
72
  provider?: string;
52
73
  idleMs?: number;
74
+ progressMs?: number;
53
75
  signal?: AbortSignal;
54
76
  }
55
77
 
56
78
  export function streamWatch(opts: StreamWatchOptions = {}): StreamWatch {
57
79
  const provider = opts.provider ?? "provider";
58
80
  const idleMs = opts.idleMs ?? STREAM_IDLE_MS;
81
+ const progressMs = opts.progressMs ?? STREAM_PROGRESS_MS;
59
82
  const callerSignal = opts.signal;
60
83
  const started = Date.now();
61
84
  const timeout = new AbortController();
62
85
 
63
86
  let firstChunk: number | null = null;
64
- let idle = false;
87
+ let fired: ProviderError | null = null;
65
88
  let disposed = false;
66
89
 
67
90
  // The bridge is structural rather than an event listener: AbortSignal.any
@@ -71,40 +94,49 @@ export function streamWatch(opts: StreamWatchOptions = {}): StreamWatch {
71
94
  // nicety: every runtime this package targets has had it for years.
72
95
  const signal = callerSignal ? AbortSignal.any([callerSignal, timeout.signal]) : timeout.signal;
73
96
 
74
- const idleError = (cause?: unknown) =>
75
- new ProviderError(provider, "timeout", `stream went ${idleMs / 1000}s without a byte`, {
76
- cause,
77
- });
78
-
79
- function arm(): ReturnType<typeof setTimeout> {
97
+ function deadline(ms: number, what: string): ReturnType<typeof setTimeout> {
80
98
  const timer = setTimeout(() => {
81
- idle = true;
82
- timeout.abort(idleError());
83
- }, idleMs);
99
+ fired = new ProviderError(provider, "timeout", `stream went ${ms / 1000}s without ${what}`);
100
+ timeout.abort(fired);
101
+ }, ms);
84
102
  // An orphaned watch — its consumer gone, dispose never called — must not
85
103
  // hold a Node event loop open for a full deadline.
86
104
  (timer as { unref?: () => void }).unref?.();
87
105
  return timer;
88
106
  }
89
107
 
90
- let timer = arm();
108
+ // The idle clock waits for the response to start; until then only the
109
+ // progress clock runs (see the header comment).
110
+ let idle: ReturnType<typeof setTimeout> | undefined;
111
+ let progress = deadline(progressMs, "a chunk");
112
+ const live = () => !disposed && !signal.aborted;
113
+
114
+ function sawActivity() {
115
+ clearTimeout(idle);
116
+ if (live()) idle = deadline(idleMs, "a byte");
117
+ }
91
118
 
92
119
  return {
93
120
  signal,
94
121
  sawByte() {
95
122
  firstChunk ??= Date.now() - started;
96
- clearTimeout(timer);
97
- if (!disposed && !signal.aborted) timer = arm();
123
+ sawActivity();
124
+ clearTimeout(progress);
125
+ if (live()) progress = deadline(progressMs, "a chunk");
98
126
  },
127
+ sawActivity,
99
128
  firstChunkMs: () => firstChunk,
100
129
  classify(err: unknown) {
101
130
  // Our deadline, not theirs — and not the caller's Stop.
102
- if (idle && !(callerSignal?.aborted ?? false)) return idleError(err);
131
+ if (fired && !(callerSignal?.aborted ?? false)) {
132
+ return new ProviderError(fired.provider, "timeout", fired.message, { cause: err });
133
+ }
103
134
  return err;
104
135
  },
105
136
  dispose() {
106
137
  disposed = true;
107
- clearTimeout(timer);
138
+ clearTimeout(idle);
139
+ clearTimeout(progress);
108
140
  },
109
141
  };
110
142
  }
@@ -148,11 +180,14 @@ export async function* watchChunks<T>(
148
180
  export async function* requireContent<T extends ProviderChunk>(
149
181
  provider: string,
150
182
  chunks: AsyncIterable<T>,
183
+ opts: { afterToolResult?: boolean } = {},
151
184
  ): AsyncGenerator<T> {
152
185
  const held: T[] = [];
153
186
  let usable = false;
187
+ let finish: ProviderChunk["finishReason"];
154
188
 
155
189
  for await (const chunk of chunks) {
190
+ if (chunk.type === "finish") finish = chunk.finishReason;
156
191
  if (!usable) {
157
192
  usable = Boolean(chunk.content || chunk.reasoning || chunk.toolCalls?.length);
158
193
  // Held rather than forwarded: once a chunk is out, the stream is
@@ -169,14 +204,26 @@ export async function* requireContent<T extends ProviderChunk>(
169
204
  yield chunk;
170
205
  }
171
206
 
172
- if (!usable) {
173
- throw new ProviderError(provider, "overload", `${provider}: completed with no content`);
207
+ if (usable) return;
208
+ // The one empty turn that is an answer: the model read a tool result and
209
+ // chose to stop. An agent whose reply IS a tool (a chat bot's send_message)
210
+ // ends every turn this way — retried, cc-proxy measured one such bot
211
+ // failing ~68% of real turns (11 retries, ~3 minutes, then an error). A
212
+ // `length` stop or no finish at all is still the failure this guard is for.
213
+ if (opts.afterToolResult && finish === "stop") {
214
+ yield* held;
215
+ return;
174
216
  }
217
+ throw new ProviderError(provider, "overload", `${provider}: completed with no content`);
175
218
  }
176
219
 
177
220
  export interface WatchdogOptions {
178
- /** Silence this long and the stream is wedged. Defaults to `STREAM_IDLE_MS`. */
221
+ /** No byte this long after the response starts and the stream is dead.
222
+ * Defaults to `STREAM_IDLE_MS`. */
179
223
  idleMs?: number;
224
+ /** No chunk this long, from the POST on, and the stream is going nowhere.
225
+ * Defaults to `STREAM_PROGRESS_MS`. */
226
+ progressMs?: number;
180
227
  /**
181
228
  * Reject a turn that ends having said nothing, as `requireContent` does. On
182
229
  * by default: an empty completion is a failure in every loop, and the one
@@ -214,20 +261,26 @@ export function withWatchdog(provider: Provider, opts: WatchdogOptions = {}): Pr
214
261
  // Armed on first read, not here: a stream built now and iterated later
215
262
  // must not spend its deadline sitting in a variable.
216
263
  async function* watched(): AsyncGenerator<ProviderChunk> {
217
- // idleMs and signal both default inside streamWatch.
264
+ // The deadlines and signal all default inside streamWatch.
218
265
  const watch = streamWatch({
219
266
  provider: provider.id,
220
267
  idleMs: opts.idleMs,
268
+ progressMs: opts.progressMs,
221
269
  signal: streamOpts.signal,
222
270
  });
271
+ const callerActivity = streamOpts.onActivity;
223
272
  const source = provider.createStream(messages, tools, {
224
273
  ...streamOpts,
225
274
  signal: watch.signal,
275
+ onActivity: () => {
276
+ watch.sawActivity();
277
+ callerActivity?.();
278
+ },
226
279
  });
227
280
  let reported = false;
228
281
  for await (const chunk of watchChunks(watch, source)) {
229
282
  // Before `requireContent` holds anything back — TTFT is the first
230
- // byte of any kind, not the first byte worth showing.
283
+ // chunk of any kind, not the first one worth showing.
231
284
  if (!reported) {
232
285
  reported = true;
233
286
  opts.onFirstChunk?.(watch.firstChunkMs() ?? 0);
@@ -236,7 +289,11 @@ export function withWatchdog(provider: Provider, opts: WatchdogOptions = {}): Pr
236
289
  }
237
290
  }
238
291
 
239
- return opts.requireContent === false ? watched() : requireContent(provider.id, watched());
292
+ return opts.requireContent === false
293
+ ? watched()
294
+ : requireContent(provider.id, watched(), {
295
+ afterToolResult: messages.at(-1)?.role === "tool",
296
+ });
240
297
  },
241
298
  };
242
299
  }