@providerkit/core 0.22.0 → 0.23.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 (97) hide show
  1. package/dist/auth/flows.d.ts.map +1 -1
  2. package/dist/auth/flows.js +5 -5
  3. package/dist/auth/flows.js.map +1 -1
  4. package/dist/auth/index.d.ts +1 -0
  5. package/dist/auth/index.d.ts.map +1 -1
  6. package/dist/auth/index.js +1 -0
  7. package/dist/auth/index.js.map +1 -1
  8. package/dist/auth/oauth.d.ts +12 -0
  9. package/dist/auth/oauth.d.ts.map +1 -1
  10. package/dist/auth/oauth.js +19 -1
  11. package/dist/auth/oauth.js.map +1 -1
  12. package/dist/auth/token-source.d.ts +9 -3
  13. package/dist/auth/token-source.d.ts.map +1 -1
  14. package/dist/auth/token-source.js +24 -9
  15. package/dist/auth/token-source.js.map +1 -1
  16. package/dist/auth/with-token.d.ts +29 -0
  17. package/dist/auth/with-token.d.ts.map +1 -0
  18. package/dist/auth/with-token.js +85 -0
  19. package/dist/auth/with-token.js.map +1 -0
  20. package/dist/context.d.ts +39 -1
  21. package/dist/context.d.ts.map +1 -1
  22. package/dist/context.js +119 -0
  23. package/dist/context.js.map +1 -1
  24. package/dist/errors.d.ts +12 -3
  25. package/dist/errors.d.ts.map +1 -1
  26. package/dist/errors.js +44 -5
  27. package/dist/errors.js.map +1 -1
  28. package/dist/fallback.d.ts +45 -1
  29. package/dist/fallback.d.ts.map +1 -1
  30. package/dist/fallback.js +138 -75
  31. package/dist/fallback.js.map +1 -1
  32. package/dist/key-pool.d.ts +10 -4
  33. package/dist/key-pool.d.ts.map +1 -1
  34. package/dist/key-pool.js +29 -12
  35. package/dist/key-pool.js.map +1 -1
  36. package/dist/presets.d.ts +7 -2
  37. package/dist/presets.d.ts.map +1 -1
  38. package/dist/presets.js +12 -2
  39. package/dist/presets.js.map +1 -1
  40. package/dist/providers/anthropic.d.ts.map +1 -1
  41. package/dist/providers/anthropic.js +80 -25
  42. package/dist/providers/anthropic.js.map +1 -1
  43. package/dist/providers/gemini.d.ts +6 -0
  44. package/dist/providers/gemini.d.ts.map +1 -1
  45. package/dist/providers/gemini.js +102 -27
  46. package/dist/providers/gemini.js.map +1 -1
  47. package/dist/providers/openai.d.ts.map +1 -1
  48. package/dist/providers/openai.js +63 -14
  49. package/dist/providers/openai.js.map +1 -1
  50. package/dist/providers/responses.d.ts.map +1 -1
  51. package/dist/providers/responses.js +98 -52
  52. package/dist/providers/responses.js.map +1 -1
  53. package/dist/rate-limit.d.ts +10 -0
  54. package/dist/rate-limit.d.ts.map +1 -1
  55. package/dist/rate-limit.js +7 -1
  56. package/dist/rate-limit.js.map +1 -1
  57. package/dist/retry.d.ts +11 -0
  58. package/dist/retry.d.ts.map +1 -1
  59. package/dist/retry.js +25 -12
  60. package/dist/retry.js.map +1 -1
  61. package/dist/tool-args.d.ts +22 -0
  62. package/dist/tool-args.d.ts.map +1 -1
  63. package/dist/tool-args.js +28 -0
  64. package/dist/tool-args.js.map +1 -1
  65. package/dist/transport.d.ts +34 -4
  66. package/dist/transport.d.ts.map +1 -1
  67. package/dist/transport.js +77 -16
  68. package/dist/transport.js.map +1 -1
  69. package/dist/types.d.ts +13 -0
  70. package/dist/types.d.ts.map +1 -1
  71. package/dist/types.js +0 -8
  72. package/dist/types.js.map +1 -1
  73. package/dist/watchdog.d.ts +2 -0
  74. package/dist/watchdog.d.ts.map +1 -1
  75. package/dist/watchdog.js +25 -6
  76. package/dist/watchdog.js.map +1 -1
  77. package/package.json +1 -1
  78. package/src/auth/flows.ts +5 -4
  79. package/src/auth/index.ts +1 -0
  80. package/src/auth/oauth.ts +23 -1
  81. package/src/auth/token-source.ts +30 -11
  82. package/src/auth/with-token.ts +97 -0
  83. package/src/context.ts +137 -2
  84. package/src/errors.ts +46 -6
  85. package/src/fallback.ts +173 -75
  86. package/src/key-pool.ts +30 -13
  87. package/src/presets.ts +12 -2
  88. package/src/providers/anthropic.ts +83 -24
  89. package/src/providers/gemini.ts +102 -28
  90. package/src/providers/openai.ts +61 -13
  91. package/src/providers/responses.ts +102 -50
  92. package/src/rate-limit.ts +31 -3
  93. package/src/retry.ts +41 -10
  94. package/src/tool-args.ts +44 -0
  95. package/src/transport.ts +97 -17
  96. package/src/types.ts +13 -0
  97. package/src/watchdog.ts +25 -6
package/src/transport.ts CHANGED
@@ -4,8 +4,12 @@
4
4
  //
5
5
  // Adapters keep only their per-event mapping; everything about being an HTTP
6
6
  // client lives here once.
7
- import { classifyHttp, isTransportFailure, parseRetryAfterMs, ProviderError } from "./errors.ts";
8
- import { parseRateLimitReset, parseRateLimitResponse } from "./rate-limit.ts";
7
+ import { classifyHttp, parseRetryAfterMs, ProviderError } from "./errors.ts";
8
+ import {
9
+ parseRateLimitReset,
10
+ parseRateLimitResponse,
11
+ type RateLimitReading,
12
+ } from "./rate-limit.ts";
9
13
 
10
14
  export interface RequestInit_ {
11
15
  url: string;
@@ -21,6 +25,11 @@ export interface RequestInit_ {
21
25
  onActivity?: () => void;
22
26
  /** Called when the body states `[DONE]`. See `SseHooks.onDone`. */
23
27
  onDone?: () => void;
28
+ /** Called when the response headers report how full a subscription window
29
+ * is. See `StreamOptions.onRateLimit`. */
30
+ onRateLimit?: (reading: RateLimitReading) => void;
31
+ /** Whether the turn already stated its finish. See `SseHooks.finished`. */
32
+ finished?: () => boolean;
24
33
  }
25
34
 
26
35
  export interface SseHooks {
@@ -33,6 +42,18 @@ export interface SseHooks {
33
42
  * was not simply cut off.
34
43
  */
35
44
  onDone?: () => void;
45
+ /**
46
+ * Whether the turn already stated its finish, so only trailing frames (the
47
+ * usage record, `[DONE]`) are left. Once this says true, a read that fails
48
+ * ends the stream instead of throwing. Kimi, GLM and OpenCode Go drop the
49
+ * socket there, and the failure threw away an answer the reader had already
50
+ * been shown (cc-proxy, measured on live traffic). The usage may be lost;
51
+ * the answer is not.
52
+ */
53
+ finished?: () => boolean;
54
+ /** The request's signal. A caller's Stop is never swallowed, finished or not:
55
+ * a loop that sees a clean end goes on to run the turn's tools. */
56
+ signal?: AbortSignal;
36
57
  /** Largest partial frame held before giving up. Defaults to 8 MiB. */
37
58
  maxFrameChars?: number;
38
59
  /** Names the provider on the oversized-frame error. */
@@ -120,18 +141,25 @@ async function errorFor(provider: string, res: Response): Promise<ProviderError>
120
141
 
121
142
  async function send(opts: RequestInit_): Promise<Response> {
122
143
  const doFetch = opts.fetchImpl ?? globalThis.fetch;
144
+ // Serialized outside the catch below: a body that cannot become JSON is a
145
+ // bug of ours, and must not be dressed as a network blip and retried.
146
+ const body = typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body);
123
147
  try {
124
148
  return await doFetch(opts.url, {
125
149
  method: "POST",
126
150
  headers: { "content-type": "application/json", ...opts.headers },
127
- body: typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body),
151
+ body,
128
152
  signal: opts.signal,
129
153
  });
130
154
  } catch (err) {
131
- // A stopped run rejects here too, and that is not a failure — let it pass
132
- // through untouched. Anything that is not a recognizable transport
133
- // rejection is a bug of ours and keeps its own loud shape.
134
- if (opts.signal?.aborted || !isTransportFailure(err)) throw err;
155
+ // An aborted signal passes through untouched: the caller's Stop stays
156
+ // `aborted`, and the watchdog re-issues its own abort as `timeout`
157
+ // (invariant 2). Every other rejection means no response came back, so it
158
+ // is a transport failure whatever its wording. Matching known wordings
159
+ // missed each runtime's next one, and a missed one was never retried: a
160
+ // long run died on its first blip. cc-proxy reads every failed send the
161
+ // same way.
162
+ if (opts.signal?.aborted) throw err;
135
163
  throw new ProviderError(opts.provider, "network", `no response from ${opts.url}`, {
136
164
  cause: err,
137
165
  });
@@ -173,10 +201,11 @@ function payloadOf(frame: string): string | null {
173
201
  * (see `payloadOf`).
174
202
  *
175
203
  * Frames are split on the blank line the spec requires, so a payload containing
176
- * a bare newline survives; `[DONE]` is swallowed here rather than in every
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.
204
+ * a bare newline survives. `[DONE]` is swallowed here rather than in every
205
+ * adapter, reported through `hooks.onDone`, and ends the stream: it is the
206
+ * body's last word, and a read after it can only fail a finished turn. CRLF and
207
+ * bare CR are normalized: some gateways send them, and a `\r` left on the end
208
+ * of a JSON payload is a parse error nobody enjoys debugging.
180
209
  *
181
210
  * Exported apart from `streamSse` because the envelope above it is the half an
182
211
  * adopting app most often cannot take: an app with its own translated error
@@ -195,17 +224,36 @@ export async function* parseSseStream(
195
224
  // A CR at the very end of a read may be the first half of a CRLF; it waits
196
225
  // for the next read before it is turned into a line break.
197
226
  let heldCR = false;
227
+ // Whether the body was read to its end. Any other exit (`[DONE]`, a failed
228
+ // read, a consumer that stopped at its own terminal event) cancels it, so the
229
+ // connection is let go now rather than whenever the body is collected.
230
+ let drained = false;
198
231
 
199
- function* emit(frame: string): Generator<string> {
232
+ /** Yields the frame's payload, and returns true when the frame was `[DONE]`. */
233
+ function* emit(frame: string): Generator<string, boolean> {
200
234
  const payload = payloadOf(frame);
201
- if (payload === "[DONE]") hooks.onDone?.();
202
- else if (payload !== null) yield payload;
235
+ if (payload === "[DONE]") {
236
+ hooks.onDone?.();
237
+ return true;
238
+ }
239
+ if (payload !== null) yield payload;
240
+ return false;
203
241
  }
204
242
 
205
243
  try {
206
244
  for (;;) {
207
- const { done, value } = await reader.read();
208
- if (done) break;
245
+ let read: ReadableStreamReadResult<Uint8Array>;
246
+ try {
247
+ read = await reader.read();
248
+ } catch (err) {
249
+ if (hooks.finished?.() && !hooks.signal?.aborted) return;
250
+ throw err;
251
+ }
252
+ const { done, value } = read;
253
+ if (done) {
254
+ drained = true;
255
+ break;
256
+ }
209
257
  hooks.onActivity?.();
210
258
  let text: string = (heldCR ? "\r" : "") + decoder.decode(value, { stream: true });
211
259
  heldCR = text.endsWith("\r");
@@ -222,7 +270,7 @@ export async function* parseSseStream(
222
270
 
223
271
  let boundary = buffer.indexOf("\n\n", from);
224
272
  while (boundary !== -1) {
225
- yield* emit(buffer.slice(0, boundary));
273
+ if (yield* emit(buffer.slice(0, boundary))) return;
226
274
  buffer = buffer.slice(boundary + 2);
227
275
  from = 0;
228
276
  boundary = buffer.indexOf("\n\n");
@@ -240,10 +288,32 @@ export async function* parseSseStream(
240
288
  // hand — dropping it loses the last delta, or the usage record.
241
289
  yield* emit(buffer + decoder.decode() + (heldCR ? "\n" : ""));
242
290
  } finally {
291
+ if (!drained) reader.cancel().catch(() => {});
243
292
  reader.releaseLock();
244
293
  }
245
294
  }
246
295
 
296
+ /**
297
+ * Parse one SSE payload as the JSON event it must be.
298
+ *
299
+ * A frame that is not JSON used to be skipped as "a keep-alive or a frame we do
300
+ * not model". But `parseSseStream` already drops comments and empty frames,
301
+ * which is every keep-alive the vendors send, so what reaches here unparsable
302
+ * is a broken stream: a delta, a tool call or the finish itself, gone, with the
303
+ * turn still reading as whole. It fails as `network`, the same as a socket that
304
+ * dies mid-read (cc-proxy fails these too).
305
+ */
306
+ export function parseFrame<T>(data: string, provider: string): T {
307
+ try {
308
+ return JSON.parse(data) as T;
309
+ } catch (err) {
310
+ throw new ProviderError(provider, "network", `${provider}: a stream frame was not JSON`, {
311
+ body: data.slice(0, 2_000),
312
+ cause: err,
313
+ });
314
+ }
315
+ }
316
+
247
317
  /**
248
318
  * POST an SSE request and stream its payloads: the envelope (auth, the
249
319
  * classified error, the retry hints) plus `parseSseStream`.
@@ -254,6 +324,16 @@ export async function* streamSse(opts: RequestInit_): AsyncGenerator<string> {
254
324
  // the POST: a backend that withholds them until its first output token (the
255
325
  // ChatGPT backend does, for minutes on a large high-effort turn) is working.
256
326
  opts.onActivity?.();
327
+ // The window's fill rides every response, the ones that go through too.
328
+ // Read only off a refusal, a caller learns the window was filling once it
329
+ // is already full.
330
+ if (opts.onRateLimit) {
331
+ const reading = parseRateLimitReset(res.headers);
332
+ const { utilization, resetAtMs } = reading;
333
+ if (utilization !== undefined && resetAtMs !== undefined) {
334
+ opts.onRateLimit({ ...reading, utilization, resetAtMs });
335
+ }
336
+ }
257
337
  if (!res.ok) throw await errorFor(opts.provider, res);
258
338
  // A 2xx with no body at all is an upstream anomaly, not a request we got
259
339
  // wrong — worth the same retry a 5xx gets.
package/src/types.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  // the lingua franca forces the other adapters to round-trip through a dialect
7
7
  // that isn't theirs, and every quirk of that dialect then leaks into callers
8
8
  // who never asked for it.
9
+ import type { RateLimitReading } from "./rate-limit.ts";
9
10
 
10
11
  /**
11
12
  * How hard the model thinks before answering. Absent = the provider's own
@@ -397,6 +398,18 @@ export interface StreamOptions {
397
398
  * works, on the watchdog's progress clock alone.
398
399
  */
399
400
  onActivity?: () => void;
401
+ /**
402
+ * Called once per response whose headers report how full a subscription
403
+ * window is, whatever its status: `utilization` (1 is full), `resetAtMs`,
404
+ * and `window` when the headers name it. A caller sees the window fill
405
+ * turn by turn, instead of learning about it from the 429 that ends a run.
406
+ *
407
+ * Every adapter forwards it. Two backends send the headers today: Claude
408
+ * subscriptions (Anthropic's unified 5h/7d headers) and the ChatGPT backend
409
+ * (`x-codex-*`). A response that reports no window never calls it, which
410
+ * covers API-key accounts on every vendor and all of Gemini.
411
+ */
412
+ onRateLimit?: (reading: RateLimitReading) => void;
400
413
  /**
401
414
  * `false` asks for at most one tool call per turn, for tools that must run
402
415
  * in order, or a backend lane that refuses parallel calls. Absent or `true`
package/src/watchdog.ts CHANGED
@@ -24,10 +24,14 @@
24
24
  // scratch, which re-ran the reasoning into the same deadline.
25
25
  //
26
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
28
- // distinguishable from our timeout. One is their cancel and is never retried;
29
- // the other is ours, is transient, and fires while nothing has streamed yet,
30
- // so the retry is always safe.
27
+ // signal is only bridged in, which is what keeps a person's Stop
28
+ // distinguishable from our timeout. One is their cancel and is never retried.
29
+ // The other is ours and transient, with one exception: a progress timeout
30
+ // before the response started carries `shouldRetry: false`. That wait was a
31
+ // backend thinking with its headers held back, and a replay sends the same
32
+ // prompt into the same 300s and pays for the reasoning again, up to three
33
+ // times (cc-proxy never replays slow headers). Its kind stays `timeout`, so a
34
+ // fallback pool still moves on to its next candidate.
31
35
  import { ProviderError } from "./errors.ts";
32
36
  import type {
33
37
  ChatMessage,
@@ -91,6 +95,9 @@ export function streamWatch(opts: StreamWatchOptions = {}): StreamWatch {
91
95
  let firstChunk: number | null = null;
92
96
  let fired: ProviderError | null = null;
93
97
  let disposed = false;
98
+ // The first sign of life (the headers, a keep-alive, a chunk). The idle
99
+ // clock waits for it, and a timeout that fires before it is not replayed.
100
+ let responded = false;
94
101
 
95
102
  // The bridge is structural rather than an event listener: AbortSignal.any
96
103
  // aborts synchronously when an input is ALREADY aborted, which is the race
@@ -101,7 +108,13 @@ export function streamWatch(opts: StreamWatchOptions = {}): StreamWatch {
101
108
 
102
109
  function deadline(ms: number, what: string): ReturnType<typeof setTimeout> {
103
110
  const timer = setTimeout(() => {
104
- fired = new ProviderError(provider, "timeout", `stream went ${ms / 1000}s without ${what}`);
111
+ // Only the progress clock runs before the response starts, so this is
112
+ // the backend that thinks with its headers held back (see the header).
113
+ fired = responded
114
+ ? new ProviderError(provider, "timeout", `stream went ${ms / 1000}s without ${what}`)
115
+ : new ProviderError(provider, "timeout", `no response in ${ms / 1000}s`, {
116
+ shouldRetry: false,
117
+ });
105
118
  timeout.abort(fired);
106
119
  }, ms);
107
120
  // An orphaned watch — its consumer gone, dispose never called — must not
@@ -117,6 +130,7 @@ export function streamWatch(opts: StreamWatchOptions = {}): StreamWatch {
117
130
  const live = () => !disposed && !signal.aborted;
118
131
 
119
132
  function sawActivity() {
133
+ responded = true;
120
134
  clearTimeout(idle);
121
135
  if (live()) idle = deadline(idleMs, "a byte");
122
136
  }
@@ -134,7 +148,10 @@ export function streamWatch(opts: StreamWatchOptions = {}): StreamWatch {
134
148
  classify(err: unknown) {
135
149
  // Our deadline, not theirs — and not the caller's Stop.
136
150
  if (fired && !(callerSignal?.aborted ?? false)) {
137
- return new ProviderError(fired.provider, "timeout", fired.message, { cause: err });
151
+ return new ProviderError(fired.provider, "timeout", fired.message, {
152
+ shouldRetry: fired.shouldRetry,
153
+ cause: err,
154
+ });
138
155
  }
139
156
  return err;
140
157
  },
@@ -278,6 +295,8 @@ export interface WatchdogOptions {
278
295
  * copies. The result is still a `Provider`, so it composes unchanged with
279
296
  * `withStreamRetry` and `streamWithBackupModels`. Timeouts and empty turns
280
297
  * without a `length` finish are transient, so wrapping it in a retry is safe.
298
+ * The one timeout a replay would make worse, no response at all within the
299
+ * progress window, says so itself with `shouldRetry: false`.
281
300
  */
282
301
  export function withWatchdog(provider: Provider, opts: WatchdogOptions = {}): Provider {
283
302
  return {