@providerkit/core 0.23.0 → 0.25.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 (96) hide show
  1. package/README.md +43 -7
  2. package/dist/auth/device.d.ts +3 -2
  3. package/dist/auth/device.d.ts.map +1 -1
  4. package/dist/auth/device.js +5 -2
  5. package/dist/auth/device.js.map +1 -1
  6. package/dist/auth/flows.d.ts +5 -1
  7. package/dist/auth/flows.d.ts.map +1 -1
  8. package/dist/auth/flows.js +237 -0
  9. package/dist/auth/flows.js.map +1 -1
  10. package/dist/auth/oauth.d.ts +4 -2
  11. package/dist/auth/oauth.d.ts.map +1 -1
  12. package/dist/auth/oauth.js +4 -3
  13. package/dist/auth/oauth.js.map +1 -1
  14. package/dist/auth/with-token.d.ts +4 -1
  15. package/dist/auth/with-token.d.ts.map +1 -1
  16. package/dist/auth/with-token.js +9 -4
  17. package/dist/auth/with-token.js.map +1 -1
  18. package/dist/errors.d.ts.map +1 -1
  19. package/dist/errors.js +3 -0
  20. package/dist/errors.js.map +1 -1
  21. package/dist/gate.d.ts +29 -0
  22. package/dist/gate.d.ts.map +1 -0
  23. package/dist/gate.js +96 -0
  24. package/dist/gate.js.map +1 -0
  25. package/dist/index.d.ts +3 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +3 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/key-pool.d.ts +11 -0
  30. package/dist/key-pool.d.ts.map +1 -1
  31. package/dist/key-pool.js +14 -0
  32. package/dist/key-pool.js.map +1 -1
  33. package/dist/presets.d.ts +53 -0
  34. package/dist/presets.d.ts.map +1 -1
  35. package/dist/presets.js +100 -0
  36. package/dist/presets.js.map +1 -1
  37. package/dist/providers/anthropic.d.ts +7 -0
  38. package/dist/providers/anthropic.d.ts.map +1 -1
  39. package/dist/providers/anthropic.js +3 -1
  40. package/dist/providers/anthropic.js.map +1 -1
  41. package/dist/providers/factory.d.ts +36 -0
  42. package/dist/providers/factory.d.ts.map +1 -1
  43. package/dist/providers/factory.js +208 -22
  44. package/dist/providers/factory.js.map +1 -1
  45. package/dist/providers/gemini.d.ts +17 -5
  46. package/dist/providers/gemini.d.ts.map +1 -1
  47. package/dist/providers/gemini.js +241 -119
  48. package/dist/providers/gemini.js.map +1 -1
  49. package/dist/providers/openai.d.ts +7 -0
  50. package/dist/providers/openai.d.ts.map +1 -1
  51. package/dist/providers/openai.js +7 -4
  52. package/dist/providers/openai.js.map +1 -1
  53. package/dist/providers/responses.d.ts +11 -0
  54. package/dist/providers/responses.d.ts.map +1 -1
  55. package/dist/providers/responses.js +3 -1
  56. package/dist/providers/responses.js.map +1 -1
  57. package/dist/registry.d.ts +105 -0
  58. package/dist/registry.d.ts.map +1 -0
  59. package/dist/registry.js +291 -0
  60. package/dist/registry.js.map +1 -0
  61. package/dist/schema.d.ts +27 -9
  62. package/dist/schema.d.ts.map +1 -1
  63. package/dist/schema.js +197 -62
  64. package/dist/schema.js.map +1 -1
  65. package/dist/tools.d.ts +8 -0
  66. package/dist/tools.d.ts.map +1 -1
  67. package/dist/tools.js +2 -2
  68. package/dist/tools.js.map +1 -1
  69. package/dist/turn.d.ts +55 -0
  70. package/dist/turn.d.ts.map +1 -0
  71. package/dist/turn.js +125 -0
  72. package/dist/turn.js.map +1 -0
  73. package/dist/types.d.ts +8 -22
  74. package/dist/types.d.ts.map +1 -1
  75. package/dist/types.js +0 -45
  76. package/dist/types.js.map +1 -1
  77. package/package.json +1 -1
  78. package/src/auth/device.ts +8 -4
  79. package/src/auth/flows.ts +288 -1
  80. package/src/auth/oauth.ts +8 -3
  81. package/src/auth/with-token.ts +9 -4
  82. package/src/errors.ts +3 -0
  83. package/src/gate.ts +152 -0
  84. package/src/index.ts +3 -0
  85. package/src/key-pool.ts +15 -0
  86. package/src/presets.ts +100 -0
  87. package/src/providers/anthropic.ts +10 -1
  88. package/src/providers/factory.ts +288 -34
  89. package/src/providers/gemini.ts +273 -124
  90. package/src/providers/openai.ts +15 -4
  91. package/src/providers/responses.ts +14 -1
  92. package/src/registry.ts +420 -0
  93. package/src/schema.ts +200 -72
  94. package/src/tools.ts +10 -2
  95. package/src/turn.ts +166 -0
  96. package/src/types.ts +8 -62
package/src/schema.ts CHANGED
@@ -162,106 +162,234 @@ const SCHEMA_MAPS = ["properties", "patternProperties", "$defs", "definitions",
162
162
  * validation the caller's tool runner repeats anyway; the 400 costs the turn.
163
163
  */
164
164
  export function withoutPatterns(node: unknown): unknown {
165
- if (!node || typeof node !== "object" || Array.isArray(node)) return node;
166
- const { pattern: _pattern, ...schema } = node as Record<string, unknown>;
167
- for (const key of SCHEMA_SLOTS) {
168
- const value = schema[key];
165
+ if (!isSchemaObject(node)) return node;
166
+ const { pattern: _pattern, ...schema } = node;
167
+ mapChildren(schema, withoutPatterns);
168
+ return schema;
169
+ }
170
+
171
+ function isSchemaObject(node: unknown): node is Record<string, unknown> {
172
+ return typeof node === "object" && node !== null && !Array.isArray(node);
173
+ }
174
+
175
+ /** Replaces every child schema of `schema`, in place, with what `visit` returns for it. `slot`
176
+ * is the keyword it sits under and `name` is its property name, for the keywords that map
177
+ * names to schemas. */
178
+ function mapChildren(
179
+ schema: Record<string, unknown>,
180
+ visit: (child: unknown, slot: string, name?: string) => unknown,
181
+ ): void {
182
+ for (const slot of SCHEMA_SLOTS) {
183
+ const value = schema[slot];
169
184
  if (value !== undefined) {
170
- schema[key] = Array.isArray(value) ? value.map(withoutPatterns) : withoutPatterns(value);
185
+ schema[slot] = Array.isArray(value)
186
+ ? value.map((child) => visit(child, slot))
187
+ : visit(value, slot);
171
188
  }
172
189
  }
173
- for (const key of SCHEMA_LISTS) {
174
- const value = schema[key];
175
- if (Array.isArray(value)) schema[key] = value.map(withoutPatterns);
190
+ for (const slot of SCHEMA_LISTS) {
191
+ const value = schema[slot];
192
+ if (Array.isArray(value)) schema[slot] = value.map((child) => visit(child, slot));
176
193
  }
177
- for (const key of SCHEMA_MAPS) {
178
- const value = schema[key];
179
- if (value && typeof value === "object" && !Array.isArray(value)) {
180
- schema[key] = Object.fromEntries(
181
- Object.entries(value).map(([name, child]) => [name, withoutPatterns(child)]),
194
+ for (const slot of SCHEMA_MAPS) {
195
+ const value = schema[slot];
196
+ if (isSchemaObject(value)) {
197
+ schema[slot] = Object.fromEntries(
198
+ Object.entries(value).map(([name, child]) => [name, visit(child, slot, name)]),
182
199
  );
183
200
  }
184
201
  }
185
- return schema;
186
202
  }
187
203
 
188
204
  /**
189
- * Sanitize a JSON Schema for Google Gemini's REST API.
205
+ * A JSON Schema for Gemini's `parametersJsonSchema` and `responseJsonSchema`, which take
206
+ * standard JSON Schema. It is sent as written except for the four shapes the API refuses.
207
+ * Measured on `gemini-3.8-flash` on 2026-10-21, in a tool and as a response schema alike;
208
+ * every other keyword tried (`$ref`, `$defs`, `oneOf`, `allOf`, `not`, `if`, `const`, numeric
209
+ * `enum`, `type: [..., "null"]`, `patternProperties`, `additionalProperties`, arrays without
210
+ * `items`) came back accepted:
211
+ *
212
+ * - `required` names a property that is not declared, at any depth: 400 "requires unspecified
213
+ * property". The names with no property are dropped.
214
+ * - Draft-04 `exclusiveMinimum: true` (or `false`): 400 "must be a number". It becomes the
215
+ * number form, `exclusiveMinimum: <minimum>`, and `minimum` goes.
216
+ * - A tuple, `items: [a, b]`: 400 "must be a boolean or an object". It becomes `prefixItems`,
217
+ * and `additionalItems` becomes `items`, as in 2020-12.
218
+ * - A recursive `$ref` that is not behind an optional property or an array that may be empty:
219
+ * 400 "ref loops are only supported if ...". The self-reference becomes `{}`.
190
220
  *
191
- * Gemini's functionDeclarations schema parser only accepts a strict OpenAPI 3.0 subset.
192
- * It rejects:
193
- * - Nullable type arrays (`type: ["string", "null"]`), requiring `{ type: "string", nullable: true }`.
194
- * - Union types with null (`anyOf: [{ type: "string" }, { type: "null" }]`), requiring `{ type: "string", nullable: true }`.
195
- * - Numeric enums (`enum: [1, 2, 3]`), requiring string enums.
196
- * - `const: "val"`, requiring `enum: ["val"]`.
197
- * - Arrays without `items`, requiring an explicit items schema.
198
- * - Unsupported keywords ($schema, definitions, $defs, patternProperties, additionalProperties: true).
221
+ * This replaced a sanitizer written for the older `parameters` field, which accepts a small
222
+ * OpenAPI subset. Kept on `parametersJsonSchema`, it turned a numeric enum into a string enum
223
+ * (the model then sent `"2"` for `2`), forced string items onto an array of anything, and
224
+ * dropped every `$ref`, while still sending the 400s above.
199
225
  */
200
226
  export function toGeminiToolSchema(node: unknown): Record<string, unknown> {
201
- if (!node || typeof node !== "object" || Array.isArray(node)) {
202
- return { type: "object", properties: {} };
227
+ if (!isSchemaObject(node)) return { type: "object", properties: {} };
228
+ const root = geminiStandardForm(node);
229
+ for (const defs of ["$defs", "definitions"]) {
230
+ const entries = root[defs];
231
+ if (isSchemaObject(entries)) {
232
+ root[defs] = Object.fromEntries(
233
+ Object.entries(entries).map(([name, def]) => [
234
+ name,
235
+ cutRefLoops(def, `#/${defs}/${name}`, false),
236
+ ]),
237
+ );
238
+ }
203
239
  }
204
- const schema = { ...(node as Record<string, unknown>) };
240
+ const cut = cutRefLoops(root, "#", false);
241
+ return isSchemaObject(cut) ? cut : root;
242
+ }
205
243
 
206
- delete schema.$schema;
207
- delete schema.$id;
208
- delete schema.$ref;
209
- delete schema.definitions;
210
- delete schema.$defs;
211
- delete schema.patternProperties;
244
+ function geminiStandardForm(node: Record<string, unknown>): Record<string, unknown> {
245
+ const schema = { ...node };
246
+ mapChildren(schema, (child) => (isSchemaObject(child) ? geminiStandardForm(child) : child));
212
247
 
213
- if (schema.additionalProperties === true) {
214
- delete schema.additionalProperties;
248
+ if (Array.isArray(schema.required)) {
249
+ const declared = isSchemaObject(schema.properties) ? schema.properties : {};
250
+ schema.required = schema.required.filter(
251
+ (name) => typeof name === "string" && Object.hasOwn(declared, name),
252
+ );
215
253
  }
216
254
 
217
- if ("const" in schema && !("enum" in schema)) {
218
- schema.enum = [String(schema.const)];
219
- delete schema.const;
255
+ for (const [flag, bound] of [
256
+ ["exclusiveMinimum", "minimum"],
257
+ ["exclusiveMaximum", "maximum"],
258
+ ] as const) {
259
+ if (typeof schema[flag] !== "boolean") continue;
260
+ if (schema[flag] && typeof schema[bound] === "number") {
261
+ schema[flag] = schema[bound];
262
+ delete schema[bound];
263
+ } else {
264
+ delete schema[flag];
265
+ }
220
266
  }
221
267
 
222
- const union = schema.anyOf ?? schema.oneOf;
223
- if (Array.isArray(union)) {
224
- const nonNullBranches = union.filter(
225
- (b) => typeof b === "object" && b !== null && (b as Record<string, unknown>).type !== "null",
226
- );
227
- const hasNull = union.some(
228
- (b) => typeof b === "object" && b !== null && (b as Record<string, unknown>).type === "null",
229
- );
230
- if (nonNullBranches.length === 1 && typeof nonNullBranches[0] === "object") {
231
- const merged = toGeminiToolSchema(nonNullBranches[0]);
232
- if (hasNull) merged.nullable = true;
233
- return merged;
268
+ if (Array.isArray(schema.items)) {
269
+ schema.prefixItems = schema.items;
270
+ delete schema.items;
271
+ if ("additionalItems" in schema) {
272
+ const extra = schema.additionalItems;
273
+ schema.items = isSchemaObject(extra) ? geminiStandardForm(extra) : extra;
274
+ delete schema.additionalItems;
234
275
  }
235
276
  }
277
+ return schema;
278
+ }
236
279
 
237
- if (Array.isArray(schema.type)) {
238
- const types = schema.type as string[];
239
- const hasNull = types.includes("null");
240
- const nonNull = types.filter((t) => t !== "null");
241
- schema.type = nonNull[0] ?? "string";
242
- if (hasNull) schema.nullable = true;
243
- }
280
+ /**
281
+ * Gemini takes a recursive `$ref` only when the loop passes through a property that is not
282
+ * required or an `items` that may be empty. Any other reference to `target` becomes `{}`.
283
+ *
284
+ * ponytail: this cuts a definition's reference to ITSELF. Two definitions that refer to each
285
+ * other through required properties still get the 400; follow the ref graph if one shows up.
286
+ */
287
+ function cutRefLoops(node: unknown, target: string, safe: boolean): unknown {
288
+ if (!isSchemaObject(node)) return node;
289
+ if (!safe && node.$ref === target) return {};
290
+ const schema = { ...node };
291
+ const required = Array.isArray(schema.required) ? schema.required : [];
292
+ const mayBeEmpty = !(typeof schema.minItems === "number" && schema.minItems > 0);
293
+ mapChildren(schema, (child, slot, name) =>
294
+ cutRefLoops(
295
+ child,
296
+ target,
297
+ safe ||
298
+ (slot === "items" && mayBeEmpty) ||
299
+ (slot === "properties" && !required.includes(name)),
300
+ ),
301
+ );
302
+ return schema;
303
+ }
244
304
 
245
- if (Array.isArray(schema.enum)) {
246
- schema.enum = schema.enum.map((val) => String(val));
247
- }
305
+ /**
306
+ * Sanitize a JSON Schema to the OpenAPI 3.0 subset Google's Cloud Code Assist
307
+ * (`antigravity`) accepts in its `parameters` field (`type`, `nullable`,
308
+ * `description`, `format`, `enum`, `properties`, `items`, `required`, `anyOf`).
309
+ * Mirrors cc-proxy's `clean_json_schema`, including the `{ type: "string" }`
310
+ * fallback when an array omits `items`.
311
+ */
312
+ export function toAntigravityToolSchema(node: unknown): Record<string, unknown> {
313
+ if (!isSchemaObject(node)) return { type: "object" };
314
+ const out: Record<string, unknown> = {};
315
+ let nullable = node.nullable === true;
248
316
 
249
- if (schema.type === "array" && !schema.items) {
250
- schema.items = { type: "string" };
251
- } else if (schema.items && typeof schema.items === "object") {
252
- schema.items = toGeminiToolSchema(schema.items);
317
+ if (typeof node.type === "string") {
318
+ if (node.type === "null") nullable = true;
319
+ else out.type = node.type;
320
+ } else if (Array.isArray(node.type)) {
321
+ let picked: string | undefined;
322
+ for (const item of node.type) {
323
+ if (typeof item !== "string") continue;
324
+ if (item === "null") nullable = true;
325
+ else if (picked === undefined) picked = item;
326
+ }
327
+ if (picked !== undefined) out.type = picked;
253
328
  }
254
-
255
- if (schema.properties && typeof schema.properties === "object") {
256
- const props = schema.properties as Record<string, unknown>;
257
- const sanitizedProps: Record<string, unknown> = {};
258
- for (const [key, val] of Object.entries(props)) {
259
- sanitizedProps[key] = toGeminiToolSchema(val);
329
+ if (nullable) out.nullable = true;
330
+ if (typeof node.description === "string" && node.description.length > 0) {
331
+ out.description = node.description;
332
+ }
333
+ if (node.format === "enum" || node.format === "date-time") {
334
+ out.format = node.format;
335
+ }
336
+ if (Array.isArray(node.enum)) {
337
+ const strings = node.enum.filter((v): v is string => typeof v === "string");
338
+ if (strings.length > 0) out.enum = strings;
339
+ } else if (typeof node.const === "string") {
340
+ out.enum = [node.const];
341
+ }
342
+ if (isSchemaObject(node.properties)) {
343
+ const cleanProps: Record<string, unknown> = {};
344
+ for (const [propName, propSchema] of Object.entries(node.properties)) {
345
+ cleanProps[propName] = toAntigravityToolSchema(propSchema);
260
346
  }
261
- schema.properties = sanitizedProps;
347
+ if (Array.isArray(node.required)) {
348
+ const validReq = node.required.filter(
349
+ (k): k is string => typeof k === "string" && Object.hasOwn(cleanProps, k),
350
+ );
351
+ if (validReq.length > 0) out.required = validReq;
352
+ }
353
+ out.properties = cleanProps;
354
+ if (!("type" in out)) out.type = "object";
262
355
  }
263
-
264
- return schema;
356
+ if ("items" in node && node.items !== undefined) {
357
+ out.items = Array.isArray(node.items)
358
+ ? node.items.length > 0
359
+ ? toAntigravityToolSchema(node.items[0])
360
+ : { type: "string" }
361
+ : toAntigravityToolSchema(node.items);
362
+ if (!("type" in out)) out.type = "array";
363
+ }
364
+ const union = Array.isArray(node.anyOf)
365
+ ? node.anyOf
366
+ : Array.isArray(node.oneOf)
367
+ ? node.oneOf
368
+ : undefined;
369
+ if (union) {
370
+ const branches: Record<string, unknown>[] = [];
371
+ for (const branch of union) {
372
+ if (isSchemaObject(branch) && branch.type === "null") {
373
+ out.nullable = true;
374
+ continue;
375
+ }
376
+ branches.push(toAntigravityToolSchema(branch));
377
+ }
378
+ if (branches.length === 1) {
379
+ for (const [k, v] of Object.entries(branches[0]!)) {
380
+ if (!(k in out)) out[k] = v;
381
+ }
382
+ } else if (branches.length > 0) {
383
+ out.anyOf = branches;
384
+ }
385
+ }
386
+ if (!("type" in out) && !("anyOf" in out)) {
387
+ out.type = "string";
388
+ }
389
+ if (out.type === "array" && !("items" in out)) {
390
+ out.items = { type: "string" };
391
+ }
392
+ return out;
265
393
  }
266
394
 
267
395
  /**
package/src/tools.ts CHANGED
@@ -51,8 +51,16 @@ export interface ToolSpec<I, O> {
51
51
  summarize?: (output: O) => string;
52
52
  /** Default 60s. A tool with no ceiling can hang a whole run. */
53
53
  timeoutMs?: number;
54
+ /**
55
+ * The tool changes nothing. Default false: a flag that promises safety is
56
+ * true only when the tool says so. `repairHistory` tells the model an
57
+ * interrupted read-only call "changed nothing, so it is safe to call again",
58
+ * and a write that left this out used to be told that too.
59
+ */
54
60
  isReadOnly?: boolean;
55
61
  needsApproval?: boolean;
62
+ /** The tool may run in parallel with others. Default false, for the same
63
+ * reason as `isReadOnly`: two writes run at once by omission can race. */
56
64
  isConcurrencySafe?: boolean;
57
65
  /** A terminal tool ends the run; its validated input is the run's output. */
58
66
  isTerminal?: boolean;
@@ -142,9 +150,9 @@ export function defineTool<I = unknown, O = unknown>(spec: ToolSpec<I, O>): Tool
142
150
  description: spec.description,
143
151
  inputSchema: spec.inputSchema,
144
152
  timeoutMs,
145
- isReadOnly: spec.isReadOnly ?? true,
153
+ isReadOnly: spec.isReadOnly ?? false,
146
154
  needsApproval: spec.needsApproval ?? false,
147
- isConcurrencySafe: spec.isConcurrencySafe ?? true,
155
+ isConcurrencySafe: spec.isConcurrencySafe ?? false,
148
156
  isTerminal: spec.isTerminal ?? false,
149
157
 
150
158
  definition() {
package/src/turn.ts ADDED
@@ -0,0 +1,166 @@
1
+ // One turn, drained. Every agent loop reads a stream the same way: text and
2
+ // reasoning joined, tool-call fragments put back together by index, usage and
3
+ // finish taken from the last chunk that states them. The apps that use this
4
+ // package each wrote that by hand, a little differently each time, and the
5
+ // one in `drainStream` dropped the tool calls altogether.
6
+ //
7
+ // It assembles and nothing else. Arguments stay the raw string the seam
8
+ // promises, because tool-args.ts heals them and tools.ts validates them. And a
9
+ // turn that said nothing is not judged here: that is `requireContent`'s call
10
+ // (invariant 13), made where the retry that can fix it still can.
11
+ import { ProviderError } from "./errors.ts";
12
+ import {
13
+ EMPTY_USAGE,
14
+ type ChatMessage,
15
+ type FinishReason,
16
+ type ProviderChunk,
17
+ type ReasoningItems,
18
+ type TokenUsage,
19
+ type ToolCall,
20
+ } from "./types.ts";
21
+
22
+ /** One drained turn: the message a loop appends, whose `toolCalls` are the
23
+ * calls it runs, and what the turn cost. */
24
+ export interface Turn {
25
+ /** Ready to append to history as it is. `toolCalls` holds the calls to run,
26
+ * in the order the provider indexed them, and is absent when there are none. */
27
+ message: Extract<ChatMessage, { role: "assistant" }>;
28
+ /** The last usage the stream reported, or zeros when it reported none. */
29
+ usage: TokenUsage;
30
+ /** As the provider stated it, and null when it stated none. */
31
+ finishReason: FinishReason | null;
32
+ /** The endpoint and model that answered, when a fallback provider says. */
33
+ source?: { provider: string; model: string };
34
+ }
35
+
36
+ /**
37
+ * Drain one turn into the assistant message it produced, plus its usage,
38
+ * finish reason and source.
39
+ *
40
+ * ```ts
41
+ * const turn = await collectTurn(provider.createStream(history, tools));
42
+ * history.push(turn.message);
43
+ * for (const call of turn.message.toolCalls ?? []) history.push(await run(call));
44
+ * ```
45
+ *
46
+ * @throws {ProviderError} `overload` when a tool call ends the stream with no
47
+ * name. Whatever the stream itself throws passes through unchanged.
48
+ */
49
+ export async function collectTurn(stream: AsyncIterable<ProviderChunk>): Promise<Turn> {
50
+ let content = "";
51
+ let reasoning = "";
52
+ // Not concatenated: this half of the record is a payload the provider owns,
53
+ // and it arrives whole on one delta rather than in fragments. Dropped here,
54
+ // a drained turn replays only half its own reasoning on the next round,
55
+ // which is the failure `reasoningDetails` exists to prevent.
56
+ let reasoningDetails: unknown[] | undefined;
57
+ let reasoningItems: ReasoningItems | undefined;
58
+ const calls = new Map<number, ToolCall>();
59
+ // Cumulative on some providers and final-only on others: the LAST one wins.
60
+ let usage: TokenUsage = EMPTY_USAGE;
61
+ let finishReason: FinishReason | null = null;
62
+ let source: Turn["source"];
63
+
64
+ // Read to the end, past the finish chunk: the OpenAI shape sends its usage
65
+ // after it, and a loop that stopped at the finish would bill every turn at zero.
66
+ for await (const chunk of stream) {
67
+ if (chunk.source) source = chunk.source;
68
+ if (chunk.content) content += chunk.content;
69
+ if (chunk.reasoning) reasoning += chunk.reasoning;
70
+ if (chunk.reasoningDetails?.length) reasoningDetails = chunk.reasoningDetails;
71
+ if (chunk.reasoningItems?.items.length) reasoningItems = chunk.reasoningItems;
72
+ for (const delta of chunk.toolCalls ?? []) {
73
+ const call = calls.get(delta.index) ?? { id: "", name: "", arguments: "" };
74
+ // Identity is the last value STATED, so an empty one never erases a real
75
+ // one: Anthropic opens a call with `""` for what it lacks, and the
76
+ // ChatGPT backend states the real call_id and name only on the call's
77
+ // closing event. A call that never states an id keeps `""`. The four
78
+ // adapters here mint one before their finish (`settleToolCalls`); for any
79
+ // other Provider, one made up here would be ours and not the wire's
80
+ // (invariant 10), and `repairHistory` already names it the same way on
81
+ // every send. Arguments are fragments, joined in arrival order.
82
+ if (delta.id) call.id = delta.id;
83
+ if (delta.name) call.name = delta.name;
84
+ if (delta.thoughtSignature) call.thoughtSignature = delta.thoughtSignature;
85
+ call.arguments += delta.arguments ?? "";
86
+ calls.set(delta.index, call);
87
+ }
88
+ if (chunk.usage) usage = chunk.usage;
89
+ if (chunk.finishReason) finishReason = chunk.finishReason;
90
+ }
91
+
92
+ // By index, not by arrival: parallel calls interleave their fragments.
93
+ const indexed = [...calls].sort(([a], [b]) => a - b);
94
+ const nameless = indexed.find(([, call]) => !call.name);
95
+ if (nameless) {
96
+ const [index, call] = nameless;
97
+ // `overload`, the kind for a provider's own anomaly inside a 200 (an empty
98
+ // body, a turn that completed with nothing): our request was fine, the
99
+ // vendor's frame was not. A retry may well get a whole call, and another
100
+ // model is a fair next try if this one keeps doing it. `invalid` would
101
+ // blame our request and stop the run over a fault that is not ours. The
102
+ // four adapters here fail such a call themselves, with the same kind
103
+ // (`settleToolCalls`); this gives any other Provider the same answer.
104
+ const provider = source?.provider ?? "turn";
105
+ const which = call.id || `at index ${index}`;
106
+ throw new ProviderError(
107
+ provider,
108
+ "overload",
109
+ `${provider}: tool call ${which} ended with no name, so there is no tool to run`,
110
+ );
111
+ }
112
+ const toolCalls = indexed.map(([, call]) => call);
113
+
114
+ return {
115
+ message: {
116
+ role: "assistant",
117
+ content,
118
+ ...(reasoning ? { reasoning } : {}),
119
+ ...(reasoningDetails ? { reasoningDetails } : {}),
120
+ ...(reasoningItems ? { reasoningItems } : {}),
121
+ ...(toolCalls.length > 0 ? { toolCalls } : {}),
122
+ },
123
+ usage,
124
+ finishReason,
125
+ ...(source ? { source } : {}),
126
+ };
127
+ }
128
+
129
+ export interface Completion {
130
+ text: string;
131
+ /** Present when the stream identifies the endpoint that answered. */
132
+ provider?: string;
133
+ reasoning: string;
134
+ /** Present only where the provider sent one. See ChatMessage.reasoningDetails. */
135
+ reasoningDetails?: unknown[];
136
+ /** Present only where the provider sent them. See ChatMessage.reasoningItems. */
137
+ reasoningItems?: ReasoningItems;
138
+ /** The calls the model asked for, in index order, and empty when it asked
139
+ * for none. Before this field a drained turn that called a tool read as a
140
+ * turn that said nothing. */
141
+ toolCalls: ToolCall[];
142
+ usage: TokenUsage;
143
+ finishReason: FinishReason | null;
144
+ model: string;
145
+ }
146
+
147
+ /** Drain a stream into a Completion: `collectTurn`, flattened for a caller
148
+ * that wants the answer rather than a message to append. `model` names the
149
+ * model unless a fallback provider stamped the one that answered. */
150
+ export async function drainStream(
151
+ stream: AsyncIterable<ProviderChunk>,
152
+ model: string,
153
+ ): Promise<Completion> {
154
+ const { message, usage, finishReason, source } = await collectTurn(stream);
155
+ return {
156
+ text: message.content,
157
+ reasoning: message.reasoning ?? "",
158
+ ...(message.reasoningDetails ? { reasoningDetails: message.reasoningDetails } : {}),
159
+ ...(message.reasoningItems ? { reasoningItems: message.reasoningItems } : {}),
160
+ toolCalls: message.toolCalls ?? [],
161
+ usage,
162
+ finishReason,
163
+ model: source?.model ?? model,
164
+ ...(source ? { provider: source.provider } : {}),
165
+ };
166
+ }
package/src/types.ts CHANGED
@@ -71,8 +71,9 @@ export type ContentPart = TextPart | ImagePart | FilePart;
71
71
 
72
72
  /**
73
73
  * A tool the model asked to run. `arguments` is the RAW JSON string, not a
74
- * parsed object: it arrives in fragments and can be truncated mid-stream, so
75
- * assembly and validation are the consumer's job (see tools/define).
74
+ * parsed object: it arrives in fragments and can be truncated mid-stream.
75
+ * `collectTurn` joins the fragments; healing and validating them is the
76
+ * consumer's job (see tools/define).
76
77
  */
77
78
  export interface ToolCall {
78
79
  id: string;
@@ -151,7 +152,7 @@ export interface ToolDefinition {
151
152
 
152
153
  export type FinishReason = "stop" | "length" | "tool_calls" | "content_filter";
153
154
 
154
- /** Incremental tool-call fragment, assembled by `index` by the consumer. */
155
+ /** Incremental tool-call fragment, assembled by `index`. `collectTurn` does it. */
155
156
  export interface ToolCallDelta {
156
157
  index: number;
157
158
  id?: string;
@@ -385,9 +386,10 @@ export interface StreamOptions {
385
386
  */
386
387
  jsonWithTools?: JsonWithTools;
387
388
  /**
388
- * Stable id for the conversation or job this call belongs to. Sent only to
389
- * endpoints whose preset names a `sessionHeader` (OpenCode Go routes and
390
- * caches by it); every other endpoint ignores it.
389
+ * Stable id for the conversation or job this call belongs to. Sent in the
390
+ * `sessionHeader` of endpoints that name one (OpenCode Go routes and caches
391
+ * by it), and as `prompt_cache_key` on the Responses shape. A call without
392
+ * one sends the provider's `instanceSessionId` in that header instead.
391
393
  */
392
394
  sessionId?: string;
393
395
  /**
@@ -440,62 +442,6 @@ export interface Provider {
440
442
  ): AsyncIterable<ProviderChunk>;
441
443
  }
442
444
 
443
- export interface Completion {
444
- text: string;
445
- /** Present when the stream identifies the endpoint that answered. */
446
- provider?: string;
447
- reasoning: string;
448
- /** Present only where the provider sent one. See ChatMessage.reasoningDetails. */
449
- reasoningDetails?: unknown[];
450
- /** Present only where the provider sent them. See ChatMessage.reasoningItems. */
451
- reasoningItems?: ReasoningItems;
452
- usage: TokenUsage;
453
- finishReason: FinishReason | null;
454
- model: string;
455
- }
456
-
457
- /** Drain a no-tools stream into a Completion. Usage chunks are cumulative on
458
- * some providers and final-only on others: the LAST one wins. */
459
- export async function drainStream(
460
- stream: AsyncIterable<ProviderChunk>,
461
- model: string,
462
- ): Promise<Completion> {
463
- let text = "";
464
- let reasoning = "";
465
- // Not concatenated: this half of the record is a payload the provider owns,
466
- // and it arrives whole on one delta rather than in fragments. Dropped here,
467
- // a drained turn replays only half its own reasoning on the next round —
468
- // which is the failure `reasoningDetails` exists to prevent.
469
- let reasoningDetails: unknown[] | undefined;
470
- let reasoningItems: ReasoningItems | undefined;
471
- let usage: TokenUsage = EMPTY_USAGE;
472
- let finishReason: FinishReason | null = null;
473
- let source: ProviderChunk["source"];
474
- for await (const chunk of stream) {
475
- if (chunk.source) source = chunk.source;
476
- if (chunk.type === "delta") {
477
- if (chunk.content) text += chunk.content;
478
- if (chunk.reasoning) reasoning += chunk.reasoning;
479
- if (chunk.reasoningDetails?.length) reasoningDetails = chunk.reasoningDetails;
480
- if (chunk.reasoningItems?.items.length) reasoningItems = chunk.reasoningItems;
481
- } else if (chunk.type === "usage" && chunk.usage) {
482
- usage = chunk.usage;
483
- } else if (chunk.type === "finish" && chunk.finishReason) {
484
- finishReason = chunk.finishReason;
485
- }
486
- }
487
- return {
488
- text,
489
- reasoning,
490
- ...(reasoningDetails ? { reasoningDetails } : {}),
491
- ...(reasoningItems ? { reasoningItems } : {}),
492
- usage,
493
- finishReason,
494
- model: source?.model ?? model,
495
- ...(source ? { provider: source.provider } : {}),
496
- };
497
- }
498
-
499
445
  /**
500
446
  * Prepare a history for a thinking-DISABLED turn: strip `reasoning` from every
501
447
  * assistant message.