@providerkit/core 0.21.1 → 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 (137) hide show
  1. package/README.md +7 -7
  2. package/dist/attribution.d.ts +2 -2
  3. package/dist/auth/flows.d.ts.map +1 -1
  4. package/dist/auth/flows.js +5 -5
  5. package/dist/auth/flows.js.map +1 -1
  6. package/dist/auth/index.d.ts +1 -0
  7. package/dist/auth/index.d.ts.map +1 -1
  8. package/dist/auth/index.js +1 -0
  9. package/dist/auth/index.js.map +1 -1
  10. package/dist/auth/oauth.d.ts +12 -0
  11. package/dist/auth/oauth.d.ts.map +1 -1
  12. package/dist/auth/oauth.js +19 -1
  13. package/dist/auth/oauth.js.map +1 -1
  14. package/dist/auth/token-source.d.ts +9 -3
  15. package/dist/auth/token-source.d.ts.map +1 -1
  16. package/dist/auth/token-source.js +24 -9
  17. package/dist/auth/token-source.js.map +1 -1
  18. package/dist/auth/with-token.d.ts +29 -0
  19. package/dist/auth/with-token.d.ts.map +1 -0
  20. package/dist/auth/with-token.js +85 -0
  21. package/dist/auth/with-token.js.map +1 -0
  22. package/dist/capability.d.ts +9 -9
  23. package/dist/capability.js +9 -9
  24. package/dist/capability.js.map +1 -1
  25. package/dist/context.d.ts +43 -5
  26. package/dist/context.d.ts.map +1 -1
  27. package/dist/context.js +123 -4
  28. package/dist/context.js.map +1 -1
  29. package/dist/errors.d.ts +26 -17
  30. package/dist/errors.d.ts.map +1 -1
  31. package/dist/errors.js +57 -18
  32. package/dist/errors.js.map +1 -1
  33. package/dist/fallback.d.ts +45 -1
  34. package/dist/fallback.d.ts.map +1 -1
  35. package/dist/fallback.js +138 -75
  36. package/dist/fallback.js.map +1 -1
  37. package/dist/image.d.ts +1 -1
  38. package/dist/image.d.ts.map +1 -1
  39. package/dist/jev.d.ts +9 -9
  40. package/dist/jev.d.ts.map +1 -1
  41. package/dist/jev.js +1 -1
  42. package/dist/key-pool.d.ts +14 -8
  43. package/dist/key-pool.d.ts.map +1 -1
  44. package/dist/key-pool.js +35 -18
  45. package/dist/key-pool.js.map +1 -1
  46. package/dist/presets.d.ts +33 -24
  47. package/dist/presets.d.ts.map +1 -1
  48. package/dist/presets.js +25 -14
  49. package/dist/presets.js.map +1 -1
  50. package/dist/providers/anthropic.d.ts +5 -5
  51. package/dist/providers/anthropic.d.ts.map +1 -1
  52. package/dist/providers/anthropic.js +89 -34
  53. package/dist/providers/anthropic.js.map +1 -1
  54. package/dist/providers/factory.d.ts +2 -2
  55. package/dist/providers/factory.d.ts.map +1 -1
  56. package/dist/providers/factory.js +2 -1
  57. package/dist/providers/factory.js.map +1 -1
  58. package/dist/providers/gemini.d.ts +8 -2
  59. package/dist/providers/gemini.d.ts.map +1 -1
  60. package/dist/providers/gemini.js +117 -41
  61. package/dist/providers/gemini.js.map +1 -1
  62. package/dist/providers/openai.d.ts +12 -9
  63. package/dist/providers/openai.d.ts.map +1 -1
  64. package/dist/providers/openai.js +72 -21
  65. package/dist/providers/openai.js.map +1 -1
  66. package/dist/providers/responses.d.ts +3 -3
  67. package/dist/providers/responses.d.ts.map +1 -1
  68. package/dist/providers/responses.js +103 -57
  69. package/dist/providers/responses.js.map +1 -1
  70. package/dist/providers/zai.d.ts +2 -2
  71. package/dist/providers/zai.js +2 -2
  72. package/dist/rate-limit.d.ts +13 -3
  73. package/dist/rate-limit.d.ts.map +1 -1
  74. package/dist/rate-limit.js +10 -4
  75. package/dist/rate-limit.js.map +1 -1
  76. package/dist/retry.d.ts +16 -5
  77. package/dist/retry.d.ts.map +1 -1
  78. package/dist/retry.js +29 -16
  79. package/dist/retry.js.map +1 -1
  80. package/dist/schema.d.ts +6 -6
  81. package/dist/schema.js +8 -8
  82. package/dist/tool-args.d.ts +23 -1
  83. package/dist/tool-args.d.ts.map +1 -1
  84. package/dist/tool-args.js +30 -2
  85. package/dist/tool-args.js.map +1 -1
  86. package/dist/tools.d.ts +3 -3
  87. package/dist/tools.js +2 -2
  88. package/dist/tools.js.map +1 -1
  89. package/dist/transport.d.ts +40 -10
  90. package/dist/transport.d.ts.map +1 -1
  91. package/dist/transport.js +82 -21
  92. package/dist/transport.js.map +1 -1
  93. package/dist/types.d.ts +37 -23
  94. package/dist/types.d.ts.map +1 -1
  95. package/dist/types.js +3 -11
  96. package/dist/types.js.map +1 -1
  97. package/dist/usage.d.ts +4 -4
  98. package/dist/usage.d.ts.map +1 -1
  99. package/dist/usage.js +3 -3
  100. package/dist/usage.js.map +1 -1
  101. package/dist/watchdog.d.ts +14 -12
  102. package/dist/watchdog.d.ts.map +1 -1
  103. package/dist/watchdog.js +35 -16
  104. package/dist/watchdog.js.map +1 -1
  105. package/dist/zod.d.ts +4 -4
  106. package/dist/zod.js +3 -3
  107. package/package.json +1 -1
  108. package/src/attribution.ts +2 -2
  109. package/src/auth/flows.ts +5 -4
  110. package/src/auth/index.ts +1 -0
  111. package/src/auth/oauth.ts +23 -1
  112. package/src/auth/token-source.ts +30 -11
  113. package/src/auth/with-token.ts +97 -0
  114. package/src/capability.ts +12 -12
  115. package/src/context.ts +141 -6
  116. package/src/errors.ts +63 -23
  117. package/src/fallback.ts +173 -75
  118. package/src/image.ts +1 -1
  119. package/src/jev.ts +11 -11
  120. package/src/key-pool.ts +37 -20
  121. package/src/presets.ts +38 -24
  122. package/src/providers/anthropic.ts +97 -38
  123. package/src/providers/factory.ts +4 -3
  124. package/src/providers/gemini.ts +121 -44
  125. package/src/providers/openai.ts +78 -25
  126. package/src/providers/responses.ts +113 -61
  127. package/src/providers/zai.ts +2 -2
  128. package/src/rate-limit.ts +35 -7
  129. package/src/retry.ts +46 -15
  130. package/src/schema.ts +8 -8
  131. package/src/tool-args.ts +46 -2
  132. package/src/tools.ts +4 -4
  133. package/src/transport.ts +103 -23
  134. package/src/types.ts +37 -23
  135. package/src/usage.ts +5 -5
  136. package/src/watchdog.ts +38 -19
  137. package/src/zod.ts +4 -4
package/src/tool-args.ts CHANGED
@@ -12,6 +12,8 @@
12
12
  // `\uXXXX`, which JSON.parse decodes correctly. Some escape it twice and
13
13
  // emit `\\u00e7`, so even a clean parse leaves six literal characters
14
14
  // standing and a Portuguese answer reaches the user as `atenção`.
15
+ import { ProviderError } from "./errors.ts";
16
+ import type { ToolCallDelta } from "./types.ts";
15
17
 
16
18
  function isRecord(value: unknown): value is Record<string, unknown> {
17
19
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -58,7 +60,7 @@ function unescapeJson(text: string): string {
58
60
  /**
59
61
  * Best-effort recovery of `"key": "value"` string fields from truncated JSON.
60
62
  *
61
- * Only strings: they are what a summary field — the one worth rescuing — is
63
+ * Only strings: they are what a summary field (the one worth rescuing) is
62
64
  * made of, and a closing quote is the single reliable boundary in a partial
63
65
  * stream. Numbers, booleans and objects are dropped, because a salvaged
64
66
  * half-value is worse than none.
@@ -129,6 +131,48 @@ export function normalizeToolId(id: string | undefined | null, prefix = "call_pk
129
131
  return clean;
130
132
  }
131
133
 
134
+ /** A tool call's identity as a stream stated it: the last id and name it gave. */
135
+ export interface ToolCallIdentity {
136
+ index: number;
137
+ id: string;
138
+ name: string;
139
+ }
140
+
141
+ /**
142
+ * Make a turn's tool calls runnable before its finish goes out, or fail the turn.
143
+ *
144
+ * A call with no name can't run: nothing says which tool to dispatch. It used to
145
+ * pass through (the Messages reader wrote `""` for both fields), and the loop
146
+ * failed one step later with nothing to say why. It throws `overload` now, a
147
+ * broken turn the same request usually does not repeat; cc-proxy answers it
148
+ * with a 502.
149
+ *
150
+ * A call with no id still can run. It gets one from `normalizeToolId`, written
151
+ * back into `calls` and returned as the deltas to send, so the result has an id
152
+ * to quote. An id the vendor did send is never rewritten: the four wires must
153
+ * agree on it (invariant 10), and repeated ids are history repair's job.
154
+ */
155
+ export function settleToolCalls(
156
+ provider: string,
157
+ calls: Iterable<ToolCallIdentity>,
158
+ ): ToolCallDelta[] {
159
+ const minted: ToolCallDelta[] = [];
160
+ for (const call of calls) {
161
+ if (!call.name.trim()) {
162
+ throw new ProviderError(
163
+ provider,
164
+ "overload",
165
+ `${provider}: tool call ${call.index} came with no name, so no tool can run it`,
166
+ );
167
+ }
168
+ if (!call.id.trim()) {
169
+ call.id = normalizeToolId(call.id);
170
+ minted.push({ index: call.index, id: call.id });
171
+ }
172
+ }
173
+ return minted;
174
+ }
175
+
132
176
  /**
133
177
  * Parse a tool call's raw argument string into an object, salvaging what a
134
178
  * truncated stream left behind, resolving concatenated JSON, and healing
@@ -151,7 +195,7 @@ export function parseToolArgs(raw: string): Record<string, unknown> {
151
195
  return healArgs(isRecord(parsed) ? parsed : {});
152
196
  }
153
197
 
154
- /** Whether `raw` parses at all — how a caller tells a truncated tool call from
198
+ /** Whether `raw` parses at all. Lets a caller tell a truncated tool call from
155
199
  * an intact one, since `parseToolArgs` deliberately never throws. */
156
200
  export function isCompleteJson(raw: string): boolean {
157
201
  if (!raw.trim()) return false;
package/src/tools.ts CHANGED
@@ -13,7 +13,7 @@ import type { JsonObjectSchema, ToolDefinition } from "./types.ts";
13
13
  import { messageOf } from "./errors.ts";
14
14
 
15
15
  export interface ToolContext {
16
- /** The model's own call id when there is one — the event, any approval row
16
+ /** The model's own call id when there is one. The event, any approval row
17
17
  * and the tool message must all key on what the provider expects back. */
18
18
  callId?: string;
19
19
  signal?: AbortSignal;
@@ -29,7 +29,7 @@ export type ToolOutcome<O> =
29
29
  ok: false;
30
30
  callId: string;
31
31
  kind: ToolFailure;
32
- /** Fed back to the model verbatim — so it must read as an instruction to
32
+ /** Fed back to the model verbatim, so it must read as an instruction to
33
33
  * a reader who cannot see our stack trace. */
34
34
  error: string;
35
35
  durationMs: number;
@@ -83,7 +83,7 @@ export class ToolTimeoutError extends Error {
83
83
  }
84
84
  }
85
85
 
86
- /** Rejects when `signal` aborts — races a `run` that ignores its own signal. */
86
+ /** Rejects when `signal` aborts. Races a `run` that ignores its own signal. */
87
87
  function abortion(signal: AbortSignal): Promise<never> {
88
88
  return new Promise((_resolve, reject) => {
89
89
  if (signal.aborted) reject(signal.reason);
@@ -220,7 +220,7 @@ export class ToolRegistry {
220
220
  }
221
221
 
222
222
  /**
223
- * Definitions for an allow-list, in the order given — which is the order the
223
+ * Definitions for an allow-list, in the order given. This is the order the
224
224
  * model reads them in, and it is part of the cached prompt prefix. Reordering
225
225
  * or appending mid-conversation invalidates that prefix, so a caller that
226
226
  * cares should freeze the list when the session opens.
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;
@@ -16,11 +20,16 @@ export interface RequestInit_ {
16
20
  signal?: AbortSignal;
17
21
  /** Swapped in tests, or to route through a proxy. Defaults to global fetch. */
18
22
  fetchImpl?: typeof fetch;
19
- /** Called when the response headers arrive and on every body read after —
23
+ /** Called when the response headers arrive and on every body read after,
20
24
  * keep-alives included. What the watchdog's idle clock runs on. */
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. */
@@ -41,8 +62,8 @@ export interface SseHooks {
41
62
 
42
63
  /**
43
64
  * 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.
65
+ * runaway upstream grows the buffer until the runtime dies, starting with an
66
+ * MV3 worker. cc-proxy caps the ChatGPT backend at the same 8 MiB.
46
67
  */
47
68
  export const MAX_SSE_FRAME_CHARS = 8 * 1024 * 1024;
48
69
 
@@ -53,7 +74,7 @@ export function apiUrl(baseUrl: string, path: string): string {
53
74
 
54
75
  /**
55
76
  * How long the provider says to wait, read from the RESPONSE rather than from
56
- * a thrown error. Headers are authoritative — `Retry-After` first, then the
77
+ * a thrown error. Headers are authoritative: `Retry-After` first, then the
57
78
  * vendor reset headers that name a subscription window rather than a
58
79
  * per-minute throttle, because "try again in a moment" is a lie for those.
59
80
  */
@@ -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
  });
@@ -168,21 +196,22 @@ function payloadOf(frame: string): string | null {
168
196
  }
169
197
 
170
198
  /**
171
- * Yield each `data:` payload of an SSE body, trimmed — plus the bare JSON
199
+ * Yield each `data:` payload of an SSE body, trimmed, plus the bare JSON
172
200
  * object a vendor appends outside the framing, which is only ever an error
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
183
212
  * copy, its own log levels, or its own auth refresh has to keep building the
184
213
  * request and reading the failure itself. Framing is the half nobody should
185
- * write twice — hand it a `res.body` and keep your own envelope.
214
+ * write twice. Hand it a `res.body` and keep your own envelope.
186
215
  */
187
216
  export async function* parseSseStream(
188
217
  body: ReadableStream<Uint8Array>,
@@ -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
@@ -14,7 +15,7 @@
14
15
  * `output_config.effort` for current Claude models, where `none` is the lowest
15
16
  * effort on a model that cannot stop thinking; and to thinking budgets for
16
17
  * Claude 4.5 and older and for every other vendor on that wire.
17
- * Support varies per model — an unsupported level comes back as a clean 400.
18
+ * Support varies per model. An unsupported level comes back as a clean 400.
18
19
  *
19
20
  * Ordered least → most; the type derives from the array so the runtime guard
20
21
  * and the union can never drift apart.
@@ -34,7 +35,7 @@ export interface TextPart {
34
35
  text: string;
35
36
  }
36
37
 
37
- /** An image the model looks at (vision) — bytes as base64, never a URL the
38
+ /** An image the model looks at (vision), with bytes as base64, never a URL the
38
39
  * provider would have to fetch on our behalf. */
39
40
  export interface ImagePart {
40
41
  type: "image";
@@ -48,7 +49,7 @@ export type FileMimeType =
48
49
  `application/${string}` | `audio/${string}` | `video/${string}` | `text/${string}`;
49
50
 
50
51
  /**
51
- * A file the model reads — a PDF, a recording, a video — as base64 bytes,
52
+ * A file the model reads (a PDF, a recording, a video) as base64 bytes,
52
53
  * never a URL. Only the Gemini adapter sends one, as `inlineData`, the way it
53
54
  * sends an image. Every other adapter refuses it with an `invalid`
54
55
  * ProviderError before any request goes out, rather than drop it: a dropped
@@ -94,7 +95,7 @@ export type ChatMessage =
94
95
  * OpenAI-shape serializes it as `reasoning_content`; Anthropic-shape
95
96
  * drops it, since its thinking blocks carry signatures we never capture.
96
97
  *
97
- * A turn that DISABLES thinking must not carry it — mixing the two is
98
+ * A turn that disables thinking must not carry it. Mixing the two is
98
99
  * unsupported. `stripReasoning` below is that rule, once.
99
100
  */
100
101
  reasoning?: string;
@@ -102,7 +103,7 @@ export type ChatMessage =
102
103
  * OpenRouter's normalized reasoning payload, arriving on the stream and
103
104
  * riding back UNMODIFIED on the next turn's assistant message.
104
105
  *
105
- * Opaque on purpose — the same contract as Gemini's `thoughtSignature`,
106
+ * Opaque on purpose, with the same contract as Gemini's `thoughtSignature`,
106
107
  * and for the same reason: it is the provider's own record of how it got
107
108
  * here, and reading, reshaping or dropping it costs the model its
108
109
  * continuity across a tool round. Absent on every other dialect.
@@ -133,7 +134,7 @@ export interface ReasoningItems {
133
134
  items: unknown[];
134
135
  }
135
136
 
136
- /** JSON Schema for an object — what every provider's tool contract wants. */
137
+ /** JSON Schema for an object, as every provider's tool contract requires. */
137
138
  export interface JsonObjectSchema {
138
139
  type: "object";
139
140
  properties?: Record<string, unknown>;
@@ -161,19 +162,19 @@ export interface ToolCallDelta {
161
162
 
162
163
  export interface TokenUsage {
163
164
  inputTokens: number;
164
- /** Cache-HIT subset of `inputTokens` — providers auto-cache repeated
165
+ /** Cache-hit subset of `inputTokens`. Providers auto-cache repeated
165
166
  * prefixes and bill the hit portion far cheaper, so it must be tracked
166
167
  * separately to cost a turn correctly. 0 when the provider reports none. */
167
168
  cachedInputTokens: number;
168
- /** Tokens WRITTEN to cache — also a subset of `inputTokens`, so the total
169
+ /** Tokens written to cache, also a subset of `inputTokens`, so the total
169
170
  * stays the whole prompt the window has to hold. Anthropic bills these
170
171
  * above the input rate; the OpenAI-shape auto-cachers bill them at it.
171
172
  * 0 when not reported. */
172
173
  cacheWriteTokens?: number;
173
174
  outputTokens: number;
174
175
  /**
175
- * The part of `outputTokens` spent thinking, when the provider reports it —
176
- * already INSIDE `outputTokens`, so never add it again. It is the number
176
+ * The part of `outputTokens` spent thinking, when the provider reports it.
177
+ * Already inside `outputTokens`, so never add it again. It is the number
177
178
  * that explains a turn that came back empty with a length finish: the
178
179
  * reasoning ate the budget. Anthropic does not report it; absent there.
179
180
  */
@@ -195,7 +196,7 @@ export const EMPTY_USAGE: TokenUsage = {
195
196
  outputTokens: 0,
196
197
  };
197
198
 
198
- /** Normalized streaming chunk — every provider's shape collapses into this. */
199
+ /** Normalized streaming chunk. Every provider's shape maps to this. */
199
200
  export interface ProviderChunk {
200
201
  type: "delta" | "usage" | "finish";
201
202
  /** The selected endpoint and model when a fallback provider emits this chunk.
@@ -203,7 +204,7 @@ export interface ProviderChunk {
203
204
  source?: { provider: string; model: string };
204
205
  content?: string;
205
206
  reasoning?: string;
206
- /** OpenRouter's normalized reasoning payload — hand it back on the next
207
+ /** OpenRouter's normalized reasoning payload. Hand it back on the next
207
208
  * turn's assistant message verbatim. See ChatMessage.reasoningDetails. */
208
209
  reasoningDetails?: unknown[];
209
210
  /** Once per turn, where a provider replays its reasoning. See ChatMessage.reasoningItems. */
@@ -213,22 +214,22 @@ export interface ProviderChunk {
213
214
  finishReason?: FinishReason;
214
215
  }
215
216
 
216
- /** Pin or deny tool use. `{ name }` forces one specific tool — how a run is
217
+ /** Pin or deny tool use. `{ name }` forces one specific tool, so a run is
217
218
  * made to commit an answer at its step budget's edge. */
218
219
  export type ToolChoice = "auto" | "none" | "required" | { name: string };
219
220
 
220
221
  /**
221
222
  * Ask for a JSON object matching `schema`. Providers that enforce schemas get
222
223
  * it verbatim; the rest get JSON mode plus the schema in the prompt. Either
223
- * way the CALLER validates — a provider's "guaranteed" JSON is not one.
224
+ * way the caller validates: a provider's "guaranteed" JSON is not one.
224
225
  */
225
226
  export interface JsonOutput {
226
227
  name: string;
227
228
  schema: JsonObjectSchema;
228
229
  /**
229
230
  * Force OpenAI's strict schema mode on or off. Left unset, the adapters ask
230
- * `isStrictSchema` and enforce whenever the schema actually qualifies —
231
- * which is what keeps an optional field from turning a working call into a
231
+ * `isStrictSchema` and enforce whenever the schema actually qualifies.
232
+ * This keeps an optional field from turning a working call into a
232
233
  * 400. Set it only to overrule that reading.
233
234
  */
234
235
  strict?: boolean;
@@ -339,13 +340,13 @@ function bracketed(text: string): string {
339
340
  /**
340
341
  * How the schema rides on a call that ALSO carries tools.
341
342
  *
342
- * `"response_format"` sends both — what the shapes document, what most models
343
+ * `"response_format"` sends both, as the shapes document and most models
343
344
  * honour. `"prompt"` leaves the format off that call and sends the schema as
344
345
  * prompt instead, which is what the Anthropic shape has always done.
345
346
  *
346
347
  * It is a setting because a model that cannot serve both never says so: its
347
348
  * decoder is pinned to the schema, the tool call has nowhere to go, and it
348
- * writes the announcement instead — "let me look that up" — and stops. Nothing
349
+ * writes the announcement instead ("let me look that up") and stops. Nothing
349
350
  * is logged, because nothing failed. `probeJsonWithTools` answers it in one
350
351
  * call; there is no default that is right for every model.
351
352
  */
@@ -359,9 +360,10 @@ export interface StreamOptions {
359
360
  * task emitting a large artifact must raise it or the tool-call JSON is
360
361
  * silently truncated mid-argument. */
361
362
  maxTokens?: number;
363
+ /** Not sent to Gemini, which fixes its own sampling. Same for `topP`. */
362
364
  temperature?: number;
363
365
  /**
364
- * Nucleus sampling. Set this OR `temperature`, not both — the vendors all
366
+ * Nucleus sampling. Set this or `temperature`, not both. The vendors all
365
367
  * document them as alternatives and some reject the pair outright.
366
368
  */
367
369
  topP?: number;
@@ -378,7 +380,7 @@ export interface StreamOptions {
378
380
  toolChoice?: ToolChoice;
379
381
  json?: JsonOutput;
380
382
  /**
381
- * Overrides the provider's own setting for this call — how `probeJsonWithTools`
383
+ * Overrides the provider's own setting for this call, so `probeJsonWithTools`
382
384
  * asks the same model both ways. See {@link JsonWithTools}.
383
385
  */
384
386
  jsonWithTools?: JsonWithTools;
@@ -397,7 +399,19 @@ export interface StreamOptions {
397
399
  */
398
400
  onActivity?: () => void;
399
401
  /**
400
- * `false` asks for at most one tool call per turn — for tools that must run
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;
413
+ /**
414
+ * `false` asks for at most one tool call per turn, for tools that must run
401
415
  * in order, or a backend lane that refuses parallel calls. Absent or `true`
402
416
  * is every vendor's default and sends nothing. Gemini has no such switch,
403
417
  * so it refuses `false` rather than silently ignore it.
@@ -488,7 +502,7 @@ export async function drainStream(
488
502
  *
489
503
  * The chain-of-thought belongs only to thinking turns. A provider that
490
504
  * requires it replayed while thinking is ON (DeepSeek) rejects it when
491
- * thinking is OFF — which is exactly the shape of a forced-submit salvage
505
+ * thinking is off. This is exactly the shape of a forced-submit salvage
492
506
  * turn, where a run reasons through its whole investigation and then drops
493
507
  * thinking to serialize what it already found.
494
508
  *
@@ -518,7 +532,7 @@ export function stripReasoning(messages: readonly ChatMessage[]): ChatMessage[]
518
532
  });
519
533
  }
520
534
 
521
- /** `data:` URI for an image part — what the OpenAI dialect wants inline. */
535
+ /** `data:` URI for an image part, as the OpenAI dialect requires inline. */
522
536
  export function toDataUri(part: ImagePart): string {
523
537
  return `data:${part.mimeType};base64,${part.data}`;
524
538
  }
package/src/usage.ts CHANGED
@@ -15,14 +15,14 @@
15
15
  import type { TokenUsage } from "./types.ts";
16
16
  import { EMPTY_USAGE } from "./types.ts";
17
17
 
18
- /** USD per MILLION tokens — how every vendor prints its price sheet, so a row
18
+ /** USD per million tokens, as every vendor prints its price sheet, so a row
19
19
  * can be checked against one without arithmetic. */
20
20
  export interface ModelRate {
21
21
  /** Cache-MISS input: fresh tokens the provider had to read. */
22
22
  input: number;
23
23
  output: number;
24
24
  /** Cache-HIT input. Providers auto-cache repeated prefixes and bill the hit
25
- * portion far cheaper — 0.1× on Anthropic, ~0.1× on Gemini, 0.5× on some
25
+ * portion far cheaper: 0.1× on Anthropic, ~0.1× on Gemini, 0.5× on some
26
26
  * OpenAI models. In an agent loop the re-sent context is overwhelmingly
27
27
  * hits, so billing it at the miss rate overcounts by up to 10×. */
28
28
  cacheRead: number;
@@ -95,14 +95,14 @@ export function costUsd(usage: TokenUsage, rate: ModelRate): number {
95
95
  }
96
96
 
97
97
  /** What one call adds to a run: its reported cost, else its rate, else
98
- * nothing — an unpriced call still counts its tokens. */
98
+ * nothing. An unpriced call still counts its tokens. */
99
99
  function callCost(usage: TokenUsage, rate: ModelRate | undefined): number {
100
100
  return rate ? costUsd(usage, rate) : (usage.reportedCostUsd ?? 0);
101
101
  }
102
102
 
103
103
  /**
104
104
  * Accumulates usage and cost across the calls of one run, pricing each with
105
- * the rate in effect for the model that served it — so a run that switches to
105
+ * the rate in effect for the model that served it, so a run that switches to
106
106
  * a backup model, or spans a time-of-day price boundary, still bills correctly.
107
107
  * A call that carries a reported cost is billed that, with or without a rate.
108
108
  */
@@ -110,7 +110,7 @@ export class UsageTracker {
110
110
  private usage: TokenUsage = { ...EMPTY_USAGE, cacheWriteTokens: 0 };
111
111
  private cost = 0;
112
112
  /** USD the cache hits saved, versus billing them all as misses.
113
- * Observability only — never billed. */
113
+ * Observability only. Never billed. */
114
114
  private saved = 0;
115
115
 
116
116
  add(usage: TokenUsage, rate?: ModelRate): void {