@providerkit/core 0.1.0 → 0.2.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 (50) hide show
  1. package/README.md +33 -157
  2. package/dist/context.d.ts.map +1 -1
  3. package/dist/context.js +8 -0
  4. package/dist/context.js.map +1 -1
  5. package/dist/errors.d.ts +36 -0
  6. package/dist/errors.d.ts.map +1 -1
  7. package/dist/errors.js +72 -1
  8. package/dist/errors.js.map +1 -1
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +4 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/key-pool.d.ts +60 -0
  14. package/dist/key-pool.d.ts.map +1 -0
  15. package/dist/key-pool.js +235 -0
  16. package/dist/key-pool.js.map +1 -0
  17. package/dist/providers/anthropic.d.ts +9 -0
  18. package/dist/providers/anthropic.d.ts.map +1 -1
  19. package/dist/providers/anthropic.js +12 -13
  20. package/dist/providers/anthropic.js.map +1 -1
  21. package/dist/providers/gemini.d.ts +40 -0
  22. package/dist/providers/gemini.d.ts.map +1 -0
  23. package/dist/providers/gemini.js +303 -0
  24. package/dist/providers/gemini.js.map +1 -0
  25. package/dist/providers/openai.d.ts.map +1 -1
  26. package/dist/providers/openai.js +9 -0
  27. package/dist/providers/openai.js.map +1 -1
  28. package/dist/providers/responses.d.ts +38 -0
  29. package/dist/providers/responses.d.ts.map +1 -0
  30. package/dist/providers/responses.js +341 -0
  31. package/dist/providers/responses.js.map +1 -0
  32. package/dist/rate-limit.d.ts +29 -0
  33. package/dist/rate-limit.d.ts.map +1 -0
  34. package/dist/rate-limit.js +194 -0
  35. package/dist/rate-limit.js.map +1 -0
  36. package/dist/transport.d.ts +18 -5
  37. package/dist/transport.d.ts.map +1 -1
  38. package/dist/transport.js +61 -35
  39. package/dist/transport.js.map +1 -1
  40. package/package.json +2 -2
  41. package/src/context.ts +7 -0
  42. package/src/errors.ts +79 -1
  43. package/src/index.ts +4 -0
  44. package/src/key-pool.ts +272 -0
  45. package/src/providers/anthropic.ts +21 -18
  46. package/src/providers/gemini.ts +386 -0
  47. package/src/providers/openai.ts +12 -0
  48. package/src/providers/responses.ts +455 -0
  49. package/src/rate-limit.ts +217 -0
  50. package/src/transport.ts +61 -35
@@ -1,5 +1,6 @@
1
1
  // Anthropic-shape adapter — SSE from POST /v1/messages.
2
- import { ProviderError } from "../errors.ts";
2
+ import { streamError } from "../errors.ts";
3
+ import { parseToolArgs } from "../tool-args.ts";
3
4
  import { streamSse, apiUrl } from "../transport.ts";
4
5
  import type {
5
6
  ChatMessage,
@@ -15,13 +16,22 @@ import type {
15
16
  export interface AnthropicConfig {
16
17
  apiKey: string;
17
18
  model: string;
19
+ /** Any endpoint speaking the Anthropic Messages dialect — a proxy or gateway.
20
+ * Defaults to Anthropic itself. */
18
21
  baseUrl?: string;
22
+ /** Names the provider in errors and logs. The subscription backend is the
23
+ * reason this is not hardcoded: a token failure there is a re-login, not a
24
+ * bad API key, and the two must not read the same in a ledger. */
25
+ id?: string;
19
26
  /** Bound default; a per-call `effort` overrides it. */
20
27
  effort?: Effort;
21
28
  /** Anthropic requires an output ceiling on every request. */
22
29
  maxTokens?: number;
23
30
  version?: string;
24
31
  fetchImpl?: typeof fetch;
32
+ /** Merged into every request. The subscription backend needs its own beta
33
+ * headers, and a gateway in front usually wants one of its own. */
34
+ headers?: Record<string, string>;
25
35
  /** Send the key as a Bearer instead of `x-api-key` — what a subscription
26
36
  * access token needs. */
27
37
  bearer?: boolean;
@@ -125,7 +135,7 @@ export function toAnthropicMessages(messages: readonly ChatMessage[]): {
125
135
  type: "tool_use",
126
136
  id: call.id,
127
137
  name: call.name,
128
- input: safeParse(call.arguments),
138
+ input: parseToolArgs(call.arguments),
129
139
  });
130
140
  }
131
141
  if (blocks.length > 0) pushBlocks("assistant", blocks);
@@ -134,14 +144,6 @@ export function toAnthropicMessages(messages: readonly ChatMessage[]): {
134
144
  return { ...(system ? { system } : {}), messages: out };
135
145
  }
136
146
 
137
- function safeParse(raw: string): unknown {
138
- try {
139
- return raw ? JSON.parse(raw) : {};
140
- } catch {
141
- return {};
142
- }
143
- }
144
-
145
147
  interface AnthropicEvent {
146
148
  type?: string;
147
149
  message?: {
@@ -167,9 +169,10 @@ interface AnthropicEvent {
167
169
 
168
170
  export function createAnthropicProvider(config: AnthropicConfig): Provider {
169
171
  const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
172
+ const id = config.id ?? "anthropic";
170
173
 
171
174
  return {
172
- id: "anthropic",
175
+ id,
173
176
  model: config.model,
174
177
 
175
178
  async *createStream(
@@ -230,9 +233,10 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
230
233
  ...(config.bearer
231
234
  ? { authorization: `Bearer ${config.apiKey}` }
232
235
  : { "x-api-key": config.apiKey }),
236
+ ...config.headers,
233
237
  },
234
238
  body: request,
235
- provider: "anthropic",
239
+ provider: id,
236
240
  ...(opts.signal ? { signal: opts.signal } : {}),
237
241
  ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
238
242
  })) {
@@ -244,13 +248,12 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
244
248
  }
245
249
 
246
250
  switch (event.type) {
251
+ // A failure the backend reports after its headers went out. Classified
252
+ // rather than assumed transient: this shape carries `overloaded_error`
253
+ // most of the time, but a prompt found too long mid-stream arrives the
254
+ // same way, and retrying that one only fails it again more slowly.
247
255
  case "error":
248
- throw new ProviderError(
249
- "anthropic",
250
- "overload",
251
- event.error?.message ?? "anthropic stream error",
252
- { code: event.error?.type },
253
- );
256
+ throw streamError(id, event.error);
254
257
 
255
258
  case "message_start": {
256
259
  const usage = event.message?.usage;
@@ -0,0 +1,386 @@
1
+ // Gemini adapter — SSE from POST /v1beta/models/{model}:streamGenerateContent.
2
+ //
3
+ // Native Gemini, not its OpenAI-compatible shim: only this endpoint carries
4
+ // thought signatures, and a signature dropped on the way back costs the model
5
+ // its own chain of thought on the next turn. Written straight against the REST
6
+ // wire rather than @google/genai, because this package ships zero dependencies
7
+ // and the SDK is a Node-shaped one.
8
+ import { streamError } from "../errors.ts";
9
+ import { parseToolArgs } from "../tool-args.ts";
10
+ import { streamSse, apiUrl } from "../transport.ts";
11
+ import type {
12
+ ChatMessage,
13
+ Effort,
14
+ FinishReason,
15
+ Provider,
16
+ ProviderChunk,
17
+ StreamOptions,
18
+ ToolCallDelta,
19
+ ToolChoice,
20
+ ToolDefinition,
21
+ } from "../types.ts";
22
+
23
+ export interface GeminiConfig {
24
+ apiKey: string;
25
+ model: string;
26
+ /** Any endpoint speaking the Generative Language REST dialect — a proxy or
27
+ * gateway. Defaults to the Generative Language API.
28
+ *
29
+ * Not Vertex: it serves `/v1/projects/…/locations/…/publishers/google/models`
30
+ * and authenticates with a Bearer token, and both the path and the
31
+ * `x-goog-api-key` header are fixed below. Vertex would be its own adapter. */
32
+ baseUrl?: string;
33
+ /** Names the provider in errors and logs. */
34
+ id?: string;
35
+ /** Bound default; a per-call `effort` overrides it. */
36
+ effort?: Effort;
37
+ maxTokens?: number;
38
+ fetchImpl?: typeof fetch;
39
+ headers?: Record<string, string>;
40
+ }
41
+
42
+ const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com";
43
+
44
+ /**
45
+ * Gemini 3's thinking dial. `none` is MINIMAL rather than a 0 budget: the Pro
46
+ * models reject a hard 0, so the only way to say "think as little as possible"
47
+ * without a 400 is the lowest level.
48
+ *
49
+ * Sent as the REST enum NAMES. The SDK's `ThinkingLevel` members serialize to
50
+ * exactly these strings, so nothing is lost by writing them out.
51
+ */
52
+ const THINKING_LEVEL: Record<Effort, string> = {
53
+ none: "MINIMAL",
54
+ low: "LOW",
55
+ medium: "MEDIUM",
56
+ high: "HIGH",
57
+ max: "HIGH",
58
+ };
59
+
60
+ /** A turn in Gemini's history. `model` is its word for the assistant. */
61
+ export interface GeminiContent {
62
+ role: "user" | "model";
63
+ parts: unknown[];
64
+ }
65
+
66
+ function isJsonObject(value: unknown): value is Record<string, unknown> {
67
+ return typeof value === "object" && value !== null && !Array.isArray(value);
68
+ }
69
+
70
+ /**
71
+ * Tool-call arguments as the OBJECT Gemini's `functionCall.args` requires.
72
+ *
73
+ * A bare JSON scalar or array is wrapped rather than rejected — the shared
74
+ * `parseToolArgs` drops a non-object as a protocol violation, which is right
75
+ * for reading a fresh tool call and wrong here, where replaying `{}` deletes an
76
+ * argument the model did send. Everything else defers to it, so a truncated or
77
+ * double-escaped argument string is salvaged on replay rather than degraded to
78
+ * `{}`.
79
+ */
80
+ function parseArgs(json: string): Record<string, unknown> {
81
+ try {
82
+ const parsed: unknown = JSON.parse(json);
83
+ if (!isJsonObject(parsed)) return { value: parsed };
84
+ } catch {
85
+ // Unparseable — the salvage below is the whole point.
86
+ }
87
+ return parseToolArgs(json);
88
+ }
89
+
90
+ /** Same rule for a tool RESULT, which `functionResponse.response` also requires
91
+ * as an object. Plain text (the common case) rides under `output`. */
92
+ function toResponseObject(content: string): Record<string, unknown> {
93
+ try {
94
+ const parsed: unknown = JSON.parse(content);
95
+ return isJsonObject(parsed) ? parsed : { output: parsed };
96
+ } catch {
97
+ return { output: content };
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Our messages → Gemini contents.
103
+ *
104
+ * Three shape differences the rest of the adapter must not have to know about:
105
+ * system text is lifted out and merged into ONE instruction (Gemini takes a
106
+ * single one, not a role in the turn list); assistant turns are role `model`
107
+ * and carry tool calls as `functionCall` parts with the thought signature
108
+ * beside them; tool results are role `user` with a `functionResponse` part
109
+ * naming the FUNCTION, since Gemini pairs a result to its call by name.
110
+ */
111
+ export function toGeminiContents(messages: readonly ChatMessage[]): {
112
+ system?: string;
113
+ contents: GeminiContent[];
114
+ } {
115
+ let system = "";
116
+ const contents: GeminiContent[] = [];
117
+
118
+ for (const message of messages) {
119
+ switch (message.role) {
120
+ case "system":
121
+ system = system ? `${system}\n\n${message.content}` : message.content;
122
+ break;
123
+
124
+ case "user":
125
+ contents.push({
126
+ role: "user",
127
+ parts:
128
+ typeof message.content === "string"
129
+ ? [{ text: message.content }]
130
+ : message.content.map((part) =>
131
+ part.type === "text"
132
+ ? { text: part.text }
133
+ : { inlineData: { mimeType: part.mimeType, data: part.data } },
134
+ ),
135
+ });
136
+ break;
137
+
138
+ case "assistant": {
139
+ const parts: unknown[] = [];
140
+ if (message.content) parts.push({ text: message.content });
141
+ for (const call of message.toolCalls ?? []) {
142
+ parts.push({
143
+ functionCall: { id: call.id, name: call.name, args: parseArgs(call.arguments) },
144
+ // Replayed verbatim: this is the model's own reasoning token, and
145
+ // without it the next turn starts from a chain of thought that
146
+ // no longer includes the call it just made.
147
+ ...(call.thoughtSignature ? { thoughtSignature: call.thoughtSignature } : {}),
148
+ });
149
+ }
150
+ // An assistant turn with neither text nor calls has no representation
151
+ // here, and an empty `parts` is a 400.
152
+ if (parts.length > 0) contents.push({ role: "model", parts });
153
+ break;
154
+ }
155
+
156
+ case "tool":
157
+ contents.push({
158
+ role: "user",
159
+ parts: [
160
+ {
161
+ functionResponse: {
162
+ id: message.toolCallId,
163
+ name: message.name,
164
+ response: toResponseObject(message.content),
165
+ },
166
+ },
167
+ ...(message.images ?? []).map((image) => ({
168
+ inlineData: { mimeType: image.mimeType, data: image.data },
169
+ })),
170
+ ],
171
+ });
172
+ break;
173
+ }
174
+ }
175
+
176
+ return { ...(system ? { system } : {}), contents };
177
+ }
178
+
179
+ /**
180
+ * `auto` and an absent choice are Gemini's own default, so the field is omitted
181
+ * rather than sent as AUTO. `required` and a pinned tool are both mode ANY —
182
+ * the pin is the allow-list, not the mode.
183
+ */
184
+ function toToolConfig(choice: ToolChoice | undefined): unknown {
185
+ if (choice === undefined || choice === "auto") return undefined;
186
+ if (choice === "none") return { functionCallingConfig: { mode: "NONE" } };
187
+ if (choice === "required") return { functionCallingConfig: { mode: "ANY" } };
188
+ return { functionCallingConfig: { mode: "ANY", allowedFunctionNames: [choice.name] } };
189
+ }
190
+
191
+ function mapFinishReason(reason: string): FinishReason {
192
+ switch (reason) {
193
+ case "MAX_TOKENS":
194
+ return "length";
195
+ case "SAFETY":
196
+ case "PROHIBITED_CONTENT":
197
+ return "content_filter";
198
+ default:
199
+ return "stop";
200
+ }
201
+ }
202
+
203
+ interface GeminiPart {
204
+ text?: string;
205
+ /** Marks the parts that are the model's reasoning. Absent on the answer. */
206
+ thought?: boolean;
207
+ thoughtSignature?: string;
208
+ functionCall?: { id?: string; name?: string; args?: Record<string, unknown> };
209
+ }
210
+
211
+ /** Google's `google.rpc.Status`: an HTTP `code`, the canonical `status` name,
212
+ * and `details` — which is where RetryInfo's `retryDelay` rides. */
213
+ interface GeminiStatus {
214
+ code?: number;
215
+ message?: string;
216
+ status?: string;
217
+ details?: unknown;
218
+ }
219
+
220
+ interface GeminiResponse {
221
+ candidates?: {
222
+ content?: { parts?: GeminiPart[] };
223
+ finishReason?: string;
224
+ }[];
225
+ usageMetadata?: {
226
+ promptTokenCount?: number;
227
+ candidatesTokenCount?: number;
228
+ thoughtsTokenCount?: number;
229
+ cachedContentTokenCount?: number;
230
+ };
231
+ /** Present only on the in-band failure below — never on a real candidate. */
232
+ error?: GeminiStatus;
233
+ }
234
+
235
+ export function createGeminiProvider(config: GeminiConfig): Provider {
236
+ const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
237
+ const id = config.id ?? "gemini";
238
+
239
+ return {
240
+ id,
241
+ model: config.model,
242
+
243
+ async *createStream(
244
+ messages: ChatMessage[],
245
+ tools: ToolDefinition[],
246
+ opts: StreamOptions = {},
247
+ ): AsyncIterable<ProviderChunk> {
248
+ const effort = opts.effort ?? config.effort;
249
+ const maxTokens = opts.maxTokens ?? config.maxTokens;
250
+ const { system, contents } = toGeminiContents(messages);
251
+
252
+ const generationConfig: Record<string, unknown> = {};
253
+ if (maxTokens !== undefined) generationConfig.maxOutputTokens = maxTokens;
254
+ if (opts.temperature !== undefined) generationConfig.temperature = opts.temperature;
255
+ // No effort means the model's own dynamic thinking. Sending MINIMAL here
256
+ // would switch that off for a caller who never asked, which is the whole
257
+ // reason the seam treats an absent effort as "never sent".
258
+ if (effort) generationConfig.thinkingConfig = { thinkingLevel: THINKING_LEVEL[effort] };
259
+ if (opts.json) {
260
+ generationConfig.responseMimeType = "application/json";
261
+ // `responseJsonSchema` takes JSON Schema as written; `responseSchema`
262
+ // is Gemini's own trimmed dialect and rejects most of what a real
263
+ // schema carries.
264
+ generationConfig.responseJsonSchema = opts.json.schema;
265
+ }
266
+
267
+ const request: Record<string, unknown> = { contents, generationConfig };
268
+ // A Content, not the bare string the SDK accepts — REST rejects a string.
269
+ if (system) request.systemInstruction = { parts: [{ text: system }] };
270
+ if (tools.length > 0) {
271
+ request.tools = [
272
+ {
273
+ functionDeclarations: tools.map((tool) => ({
274
+ name: tool.name,
275
+ description: tool.description,
276
+ // Same rule as the response schema: `parameters` is the trimmed
277
+ // dialect, `parametersJsonSchema` is the schema we actually wrote.
278
+ parametersJsonSchema: tool.inputSchema,
279
+ })),
280
+ },
281
+ ];
282
+ // Pointless without declarations, and Gemini 400s on a tool config that
283
+ // names a function it was never given.
284
+ const toolConfig = toToolConfig(opts.toolChoice);
285
+ if (toolConfig) request.toolConfig = toolConfig;
286
+ }
287
+
288
+ // A model id copied out of Gemini's docs is often already `models/…`, and
289
+ // the doubled segment 404s as "model not found" — a confusing way to
290
+ // learn about a prefix.
291
+ const model = (opts.model ?? config.model).replace(/^models\//, "");
292
+
293
+ // Gemini reports the finish reason on a candidate that can arrive AFTER
294
+ // the chunk carrying the function calls, so a turn's tool use has to be
295
+ // remembered rather than read off the final chunk.
296
+ let sawFunctionCalls = false;
297
+ let callIndex = 0;
298
+
299
+ for await (const data of streamSse({
300
+ // Without `?alt=sse` the response is one long JSON array that only
301
+ // parses once complete, which is not a stream.
302
+ url: apiUrl(baseUrl, `/v1beta/models/${model}:streamGenerateContent?alt=sse`),
303
+ headers: { "x-goog-api-key": config.apiKey, ...config.headers },
304
+ body: request,
305
+ provider: id,
306
+ ...(opts.signal ? { signal: opts.signal } : {}),
307
+ ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
308
+ })) {
309
+ let chunk: GeminiResponse;
310
+ try {
311
+ chunk = JSON.parse(data) as GeminiResponse;
312
+ } catch {
313
+ continue;
314
+ }
315
+
316
+ // `?alt=sse` commits to 200 the moment the headers go out, so a
317
+ // throttle or an overload landing after that arrives here as a
318
+ // google.rpc.Status in the body rather than as a status line.
319
+ if (chunk.error) throw streamError(id, chunk.error);
320
+
321
+ if (chunk.usageMetadata) {
322
+ const usage = chunk.usageMetadata;
323
+ yield {
324
+ type: "usage",
325
+ usage: {
326
+ inputTokens: usage.promptTokenCount ?? 0,
327
+ // Thoughts bill as OUTPUT, and Gemini reports them OUTSIDE
328
+ // candidatesTokenCount — leaving them out undercounts a thinking
329
+ // turn by most of what it cost.
330
+ outputTokens: (usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0),
331
+ cachedInputTokens: usage.cachedContentTokenCount ?? 0,
332
+ },
333
+ };
334
+ }
335
+
336
+ const candidate = chunk.candidates?.[0];
337
+ if (!candidate) continue;
338
+
339
+ // One chunk can carry several parts of each kind. They are coalesced
340
+ // per kind so the seam sees one reasoning delta and one content delta
341
+ // per chunk, in that order, rather than interleaved fragments.
342
+ let reasoning = "";
343
+ let content = "";
344
+ const toolCalls: ToolCallDelta[] = [];
345
+ for (const part of candidate.content?.parts ?? []) {
346
+ if (part.text) {
347
+ if (part.thought) reasoning += part.text;
348
+ else content += part.text;
349
+ continue;
350
+ }
351
+ const call = part.functionCall;
352
+ if (!call) continue;
353
+ const index = callIndex++;
354
+ toolCalls.push({
355
+ index,
356
+ // Gemini omits the id on a single-call turn, and the seam's
357
+ // consumers pair a result back to its call by id.
358
+ id: call.id ?? `call_${index}`,
359
+ ...(call.name ? { name: call.name } : {}),
360
+ // Whole and already assembled — unlike the OpenAI shape, Gemini
361
+ // never fragments an argument object across chunks.
362
+ arguments: JSON.stringify(call.args ?? {}),
363
+ ...(part.thoughtSignature ? { thoughtSignature: part.thoughtSignature } : {}),
364
+ });
365
+ }
366
+
367
+ if (reasoning) yield { type: "delta", reasoning };
368
+ if (content) yield { type: "delta", content };
369
+ if (toolCalls.length > 0) {
370
+ sawFunctionCalls = true;
371
+ yield { type: "delta", toolCalls };
372
+ }
373
+
374
+ if (candidate.finishReason) {
375
+ yield {
376
+ type: "finish",
377
+ // A turn that called tools finishes as tool_calls whatever the
378
+ // candidate says — Gemini routinely reports STOP there, and a
379
+ // caller reading that as "done" drops the tool round entirely.
380
+ finishReason: sawFunctionCalls ? "tool_calls" : mapFinishReason(candidate.finishReason),
381
+ };
382
+ }
383
+ }
384
+ },
385
+ };
386
+ }
@@ -3,6 +3,7 @@
3
3
  // This is the dialect most gateways speak, so one adapter serves OpenAI,
4
4
  // OpenRouter, DeepSeek, GLM, Kimi, Groq, Together, vLLM, Ollama and LM Studio.
5
5
  // Their divergences are small and named where they appear.
6
+ import { streamError } from "../errors.ts";
6
7
  import { streamSse, apiUrl } from "../transport.ts";
7
8
  import type {
8
9
  ChatMessage,
@@ -119,6 +120,9 @@ interface OpenAIChunk {
119
120
  completion_tokens?: number;
120
121
  prompt_tokens_details?: { cached_tokens?: number };
121
122
  } | null;
123
+ /** Present only on the in-band failure below — never beside a choice.
124
+ * `code` is the numeric HTTP status on the gateways, a slug on OpenAI. */
125
+ error?: { message?: string; code?: string | number; type?: string };
122
126
  }
123
127
 
124
128
  export function createOpenAIProvider(config: OpenAIConfig): Provider {
@@ -189,6 +193,14 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
189
193
  continue;
190
194
  }
191
195
 
196
+ // A failure the backend reports after its headers went out. The
197
+ // gateways speaking this dialect — OpenRouter above all — report a
198
+ // throttle or an upstream outage this way rather than as a status
199
+ // line, and a frame carrying `error` carries no choices: unread, it
200
+ // falls through both branches below and the turn ends as a successful
201
+ // zero-token completion nobody retries.
202
+ if (chunk.error) throw streamError(id, chunk.error);
203
+
192
204
  // A usage-only frame carries no choices — this shape sends it last.
193
205
  if (chunk.usage) {
194
206
  const input = chunk.usage.prompt_tokens ?? 0;