@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.
- package/README.md +3 -0
- package/dist/errors.d.ts +17 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +55 -6
- package/dist/errors.js.map +1 -1
- package/dist/presets.d.ts +37 -2
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +37 -2
- package/dist/presets.js.map +1 -1
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +84 -33
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +25 -1
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +44 -16
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/responses.d.ts.map +1 -1
- package/dist/providers/responses.js +131 -24
- package/dist/providers/responses.js.map +1 -1
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +53 -22
- package/dist/rate-limit.js.map +1 -1
- package/dist/schema.d.ts +10 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +49 -0
- package/dist/schema.js.map +1 -1
- package/dist/transport.d.ts +30 -3
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +67 -28
- package/dist/transport.js.map +1 -1
- package/dist/types.d.ts +26 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/usage.d.ts +5 -4
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +14 -5
- package/dist/usage.js.map +1 -1
- package/dist/watchdog.d.ts +22 -8
- package/dist/watchdog.d.ts.map +1 -1
- package/dist/watchdog.js +77 -27
- package/dist/watchdog.js.map +1 -1
- package/package.json +3 -2
- package/src/errors.ts +60 -5
- package/src/presets.ts +37 -2
- package/src/providers/anthropic.ts +94 -36
- package/src/providers/gemini.ts +30 -1
- package/src/providers/openai.ts +42 -16
- package/src/providers/responses.ts +158 -26
- package/src/rate-limit.ts +55 -24
- package/src/schema.ts +51 -0
- package/src/transport.ts +95 -24
- package/src/types.ts +26 -2
- package/src/usage.ts +14 -5
- 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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
140
|
-
*
|
|
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(
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
225
|
+
yield* emit(buffer.slice(0, boundary));
|
|
165
226
|
buffer = buffer.slice(boundary + 2);
|
|
166
|
-
|
|
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
|
-
|
|
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
|
|
155
|
-
* the
|
|
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
|
|
68
|
-
* bills at the full rate
|
|
69
|
-
* clamped to `input` so an over-reporting
|
|
70
|
-
* count negative nor bill for more prompt
|
|
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
|
-
|
|
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
|
|
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
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
43
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
timeout.abort(
|
|
83
|
-
},
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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 (
|
|
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(
|
|
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 (
|
|
173
|
-
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
}
|