@providerkit/core 0.23.0 → 0.24.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 (62) hide show
  1. package/README.md +29 -7
  2. package/dist/auth/with-token.d.ts +4 -1
  3. package/dist/auth/with-token.d.ts.map +1 -1
  4. package/dist/auth/with-token.js +4 -1
  5. package/dist/auth/with-token.js.map +1 -1
  6. package/dist/gate.d.ts +29 -0
  7. package/dist/gate.d.ts.map +1 -0
  8. package/dist/gate.js +96 -0
  9. package/dist/gate.js.map +1 -0
  10. package/dist/index.d.ts +3 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +3 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/key-pool.d.ts +11 -0
  15. package/dist/key-pool.d.ts.map +1 -1
  16. package/dist/key-pool.js +14 -0
  17. package/dist/key-pool.js.map +1 -1
  18. package/dist/providers/anthropic.d.ts +7 -0
  19. package/dist/providers/anthropic.d.ts.map +1 -1
  20. package/dist/providers/anthropic.js +3 -1
  21. package/dist/providers/anthropic.js.map +1 -1
  22. package/dist/providers/factory.d.ts +26 -0
  23. package/dist/providers/factory.d.ts.map +1 -1
  24. package/dist/providers/factory.js +41 -9
  25. package/dist/providers/factory.js.map +1 -1
  26. package/dist/providers/openai.d.ts +7 -0
  27. package/dist/providers/openai.d.ts.map +1 -1
  28. package/dist/providers/openai.js +3 -1
  29. package/dist/providers/openai.js.map +1 -1
  30. package/dist/providers/responses.d.ts +11 -0
  31. package/dist/providers/responses.d.ts.map +1 -1
  32. package/dist/providers/responses.js +3 -1
  33. package/dist/providers/responses.js.map +1 -1
  34. package/dist/registry.d.ts +105 -0
  35. package/dist/registry.d.ts.map +1 -0
  36. package/dist/registry.js +291 -0
  37. package/dist/registry.js.map +1 -0
  38. package/dist/tools.d.ts +8 -0
  39. package/dist/tools.d.ts.map +1 -1
  40. package/dist/tools.js +2 -2
  41. package/dist/tools.js.map +1 -1
  42. package/dist/turn.d.ts +55 -0
  43. package/dist/turn.d.ts.map +1 -0
  44. package/dist/turn.js +125 -0
  45. package/dist/turn.js.map +1 -0
  46. package/dist/types.d.ts +8 -22
  47. package/dist/types.d.ts.map +1 -1
  48. package/dist/types.js +0 -45
  49. package/dist/types.js.map +1 -1
  50. package/package.json +1 -1
  51. package/src/auth/with-token.ts +4 -1
  52. package/src/gate.ts +152 -0
  53. package/src/index.ts +3 -0
  54. package/src/key-pool.ts +15 -0
  55. package/src/providers/anthropic.ts +10 -1
  56. package/src/providers/factory.ts +66 -21
  57. package/src/providers/openai.ts +10 -1
  58. package/src/providers/responses.ts +14 -1
  59. package/src/registry.ts +420 -0
  60. package/src/tools.ts +10 -2
  61. package/src/turn.ts +166 -0
  62. package/src/types.ts +8 -62
@@ -0,0 +1,420 @@
1
+ // Providers and credentials, set up once per app.
2
+ //
3
+ // Every app on this package wrote the same block: build each provider from
4
+ // env, wrap it in a watchdog, chain a few into a fallback pool, keep that pool
5
+ // alive so its cooldowns survive, and cache it somewhere. The OpenCode Go
6
+ // chain alone was copied seven times. Where an app put the watchdog or the
7
+ // error envelope together by hand, it got it wrong (AGENTS.md, "A bump is an
8
+ // audit too"). A registry names each connection once, and `get` hands back
9
+ // the composition already assembled.
10
+ //
11
+ // The layers, outside in: the view (per-call effort and `maxTokens`), the
12
+ // fallback pool when there is more than one candidate, then per candidate the
13
+ // gate, the watchdog and the adapter.
14
+ //
15
+ // ponytail: one registry per process. Per-tenant keys would need one registry
16
+ // per tenant, and no app needs that yet.
17
+ import { ProviderError, type ErrorKind } from "./errors.ts";
18
+ import { withFallbackProviders, type Cooldown, type FallbackOptions } from "./fallback.ts";
19
+ import { createGate, withGate, type Gate } from "./gate.ts";
20
+ import { KeyPool, withKeyPool, type KeyPoolOptions, type KeyTier } from "./key-pool.ts";
21
+ import { PROVIDER_PRESETS, type ProviderPreset, type ProviderPresetId } from "./presets.ts";
22
+ import { presetAdapter, type PresetAdapterConfig } from "./providers/factory.ts";
23
+ import type { RateLimitWindow } from "./rate-limit.ts";
24
+ import type { Effort, Provider, ProviderChunk } from "./types.ts";
25
+ import { withWatchdog, type WatchdogOptions } from "./watchdog.ts";
26
+ // A value import from the `auth/` subpath, and a cheap one: with-token.ts
27
+ // imports only errors.ts at runtime and the sign-in flows as types, so the
28
+ // main entry gains these few lines and none of oauth, device or flows.
29
+ import { withToken } from "./auth/with-token.ts";
30
+ import type { TokenSource } from "./auth/token-source.ts";
31
+
32
+ /** How a connection signs its calls: exactly one of the three. */
33
+ export type ConnectionCredential =
34
+ | { apiKey: string; keys?: never; token?: never }
35
+ /** Several keys for one account, rotated by one `KeyPool` per connection. */
36
+ | { keys: KeyPoolOptions; apiKey?: never; token?: never }
37
+ /** A signed-in user's token (`@providerkit/core/auth`), renewed on a 401. */
38
+ | { token: TokenSource; apiKey?: never; keys?: never };
39
+
40
+ export type ConnectionConfig = Omit<PresetAdapterConfig, "apiKey" | "instanceSessionId"> &
41
+ ConnectionCredential & {
42
+ preset: ProviderPresetId;
43
+ /** The models a bare ref (`"go"`) runs, in order. Absent: `model`, then the
44
+ * preset's `rotation`, then its `defaultModel`. */
45
+ models?: readonly string[];
46
+ /** At most this many calls at once on this connection, in every chain and
47
+ * view that uses it. The rest wait in line. Absent: no limit. */
48
+ maxConcurrent?: number;
49
+ /** Replaces the registry's `watchdog` for this connection; `false` turns
50
+ * it off. */
51
+ watchdog?: WatchdogOptions | false;
52
+ };
53
+
54
+ type PoolCooldownInfo = Parameters<NonNullable<FallbackOptions<Provider>["onCooldown"]>>[0];
55
+
56
+ /** What a chain's pool reports when it benches a candidate, plus the
57
+ * candidate's ref (`"go/glm-5.3"`). The candidate's own `id` is its preset
58
+ * id, which two connections on one preset share. */
59
+ export type RegistryCooldownInfo = PoolCooldownInfo & { ref: string };
60
+
61
+ export interface RegistryOptions {
62
+ connections: Record<string, ConnectionConfig>;
63
+ /** Named lists of refs, tried in order: `"conn/model"`, or a bare `"conn"`
64
+ * for its models. A chain can't name another chain. */
65
+ chains?: Record<string, readonly string[]>;
66
+ /** Every candidate's watchdog. On by default with the package's clocks;
67
+ * `false` turns it off. A connection's own `watchdog` replaces it. */
68
+ watchdog?: WatchdogOptions | false;
69
+ /** One set for every chain, because every chain shares one cooldown store:
70
+ * a bench set by one is honoured by the others. */
71
+ fallback?: Omit<FallbackOptions<Provider>, "cooldowns" | "onCooldown"> & {
72
+ onCooldown?: (info: RegistryCooldownInfo) => void;
73
+ };
74
+ }
75
+
76
+ export interface ViewOptions {
77
+ /** Every call's effort, unless the call names its own. */
78
+ effort?: Effort;
79
+ /** Every call's output ceiling, unless the call names its own. */
80
+ maxTokens?: number;
81
+ }
82
+
83
+ export interface CandidateStatus {
84
+ ref: string;
85
+ /** When its bench ends; 0 when it has none. A time already past means its
86
+ * next call is the probe that lifts the bench. */
87
+ retryAtMs: number;
88
+ probing: boolean;
89
+ kind?: ErrorKind;
90
+ window?: RateLimitWindow;
91
+ }
92
+
93
+ export interface ConnectionStatus {
94
+ gate?: { max: number; held: number; waiting: number };
95
+ keys?: Array<{ tier: KeyTier; evictedUntil: number }>;
96
+ /** The candidates built so far: one is built the first time a ref needs it. */
97
+ candidates: CandidateStatus[];
98
+ }
99
+
100
+ export interface Registry {
101
+ /**
102
+ * A provider for a chain name, a `"conn/model"` ref or a bare `"conn"`.
103
+ * Keep it as long as you like: it looks its chain up on every call, so
104
+ * `update` reaches it. Throws on a name it doesn't know.
105
+ */
106
+ get(ref: string, opts?: ViewOptions): Provider;
107
+ /** Add a connection, or replace one. An equal config changes nothing, so
108
+ * calling this on every request is cheap. */
109
+ update(name: string, config: ConnectionConfig): void;
110
+ /** Delete a connection. A chain that names it fails on its next call. */
111
+ remove(name: string): void;
112
+ status(): Record<string, ConnectionStatus>;
113
+ }
114
+
115
+ interface Connection {
116
+ config: ConnectionConfig;
117
+ gate?: Gate;
118
+ /** ponytail: one pool per connection, so an evicted key is benched for
119
+ * every model on it. Right for a denied key or a spent balance; too broad
120
+ * where a vendor counts rate limits per model (Gemini does). The upgrade
121
+ * is one pool per candidate. */
122
+ keyPool?: KeyPool;
123
+ /** Built on first use, by model. */
124
+ candidates: Map<string, Provider>;
125
+ }
126
+
127
+ interface Member {
128
+ name: string;
129
+ model: string;
130
+ }
131
+
132
+ export function createRegistry(options: RegistryOptions): Registry {
133
+ const connections = new Map<string, Connection>();
134
+ const chainRefs = new Map(Object.entries(options.chains ?? {}));
135
+ /** Built chains by ref, with the connections each one uses. */
136
+ const built = new Map<string, { provider: Provider; uses: Set<string> }>();
137
+ // One store for every chain, so a candidate benched in one is benched in
138
+ // all. Weak, so a candidate dropped by `update` takes its bench with it.
139
+ const cooldowns = new WeakMap<Provider, Cooldown>();
140
+ const refOf = new WeakMap<Provider, string>();
141
+ const { onCooldown, ...fallback } = options.fallback ?? {};
142
+ const poolOptions: FallbackOptions<Provider> = {
143
+ ...fallback,
144
+ cooldowns,
145
+ ...(onCooldown
146
+ ? {
147
+ onCooldown: (info) =>
148
+ onCooldown({ ...info, ref: refOf.get(info.candidate) ?? info.candidate.id }),
149
+ }
150
+ : {}),
151
+ };
152
+
153
+ for (const [name, refs] of chainRefs) {
154
+ if (!name || name.includes("/")) {
155
+ throw new Error(`[providerkit] Chain name "${name}" can't be empty or contain "/".`);
156
+ }
157
+ if (refs.length === 0) throw new Error(`[providerkit] Chain "${name}" lists no refs.`);
158
+ }
159
+ // `open` refuses a connection named like a chain.
160
+ for (const [name, config] of Object.entries(options.connections)) {
161
+ connections.set(name, open(name, config));
162
+ }
163
+ // Checked once every connection exists, so a chain may name any of them.
164
+ for (const name of chainRefs.keys()) members(name);
165
+
166
+ function open(name: string, config: ConnectionConfig, current?: Connection): Connection {
167
+ if (!name || name.includes("/")) {
168
+ throw new Error(`[providerkit] Connection name "${name}" can't be empty or contain "/".`);
169
+ }
170
+ if (chainRefs.has(name)) {
171
+ throw new Error(
172
+ `[providerkit] "${name}" names both a chain and a connection. Rename one of them.`,
173
+ );
174
+ }
175
+ if (!(config.preset in PROVIDER_PRESETS)) {
176
+ throw new Error(
177
+ `[providerkit] Connection "${name}" names an unknown preset "${config.preset}".`,
178
+ );
179
+ }
180
+ const credentials = [config.apiKey, config.keys, config.token].filter((c) => c !== undefined);
181
+ if (credentials.length !== 1) {
182
+ throw new Error(
183
+ `[providerkit] Connection "${name}" needs exactly one of apiKey, keys or token. ` +
184
+ `It has ${credentials.length}.`,
185
+ );
186
+ }
187
+ return {
188
+ config,
189
+ gate: gateFor(config, current),
190
+ keyPool: config.keys ? new KeyPool(name, config.keys) : undefined,
191
+ candidates: new Map(),
192
+ };
193
+ }
194
+
195
+ /** The (connection, model) pairs a ref names, in order, duplicates dropped. */
196
+ function members(ref: string): Member[] {
197
+ const inChain = chainRefs.get(ref);
198
+ const out = new Map<string, Member>();
199
+ for (const member of inChain ?? [ref]) {
200
+ const slash = member.indexOf("/");
201
+ const name = slash < 0 ? member : member.slice(0, slash);
202
+ if (inChain && chainRefs.has(member)) {
203
+ throw new Error(
204
+ `[providerkit] Chain "${ref}" names chain "${member}". List its refs instead.`,
205
+ );
206
+ }
207
+ const connection = connections.get(name);
208
+ if (!connection) {
209
+ throw new Error(
210
+ inChain
211
+ ? `[providerkit] Chain "${ref}" names connection "${name}", which doesn't exist.`
212
+ : `[providerkit] "${ref}" is not a chain or a connection. ` +
213
+ `Known: ${[...chainRefs.keys(), ...connections.keys()].join(", ")}.`,
214
+ );
215
+ }
216
+ const models = slash < 0 ? modelsOf(name, connection.config) : [member.slice(slash + 1)];
217
+ for (const model of models) {
218
+ if (!model) throw new Error(`[providerkit] "${member}" has an empty model after "/".`);
219
+ const key = `${name}/${model}`;
220
+ if (!out.has(key)) out.set(key, { name, model });
221
+ }
222
+ }
223
+ return [...out.values()];
224
+ }
225
+
226
+ /** Get the provider for a ref, building it on first use. */
227
+ function resolve(ref: string): Provider {
228
+ const cached = built.get(ref);
229
+ if (cached) return cached.provider;
230
+ const list = members(ref);
231
+ const candidates = list.map(({ name, model }) => candidate(name, model));
232
+ // A single candidate gets no pool. A pool of one benches the only option:
233
+ // a 503 a retry would absorb in a second becomes 30 s of instant
234
+ // NoAvailableProviderError, and the real kind hides behind `rate`. KeyPool
235
+ // refuses to bench its last key for the same reason.
236
+ const provider =
237
+ candidates.length === 1 ? candidates[0]! : withFallbackProviders(candidates, poolOptions);
238
+ built.set(ref, { provider, uses: new Set(list.map(({ name }) => name)) });
239
+ return provider;
240
+ }
241
+
242
+ function candidate(name: string, model: string): Provider {
243
+ const connection = connections.get(name)!;
244
+ const cached = connection.candidates.get(model);
245
+ if (cached) return cached;
246
+ const { config, keyPool } = connection;
247
+ const {
248
+ preset,
249
+ apiKey: _apiKey,
250
+ keys: _keys,
251
+ token: _token,
252
+ models: _models,
253
+ maxConcurrent: _maxConcurrent,
254
+ watchdog: _watchdog,
255
+ headers,
256
+ ...rest
257
+ } = config;
258
+ // One session id per candidate, kept by every adapter a key pool or a
259
+ // token builds for it. A new id per attempt made a sticky gateway treat
260
+ // every call as a new session and miss its prompt cache.
261
+ const base = { ...rest, model, instanceSessionId: crypto.randomUUID() };
262
+ let provider: Provider;
263
+ if (config.token) {
264
+ // The credential's host and headers win: Copilot gives each plan its
265
+ // own host, and the Codex backend refuses a call without its account id.
266
+ provider = withToken(config.token, (auth) =>
267
+ presetAdapter(preset, { ...base, ...auth, headers: { ...headers, ...auth.headers } }),
268
+ );
269
+ } else if (config.keys) {
270
+ // `open` builds the pool whenever `keys` is set.
271
+ provider = withKeyPool(keyPool!, (key) =>
272
+ presetAdapter(preset, { ...base, headers, apiKey: key }),
273
+ );
274
+ } else {
275
+ provider = presetAdapter(preset, { ...base, headers, apiKey: config.apiKey });
276
+ }
277
+ // On unless turned off, where `createPresetProvider` adds one only when
278
+ // asked. Handing apps the whole composition is the point of a registry:
279
+ // two apps that wired the watchdog themselves left keep-alives uncounted,
280
+ // so the idle clock would cut a long tool call at 60 s (AGENTS.md, "A bump
281
+ // is an audit too").
282
+ const watchdog = config.watchdog === undefined ? options.watchdog : config.watchdog;
283
+ if (watchdog !== false) provider = withWatchdog(provider, watchdog);
284
+ // Outside the watchdog, so a call waiting for a slot spends no deadline.
285
+ if (connection.gate) provider = withGate(provider, connection.gate);
286
+ connection.candidates.set(model, provider);
287
+ refOf.set(provider, `${name}/${model}`);
288
+ return provider;
289
+ }
290
+
291
+ /** Drop every built chain that uses this connection. */
292
+ function forget(name: string): void {
293
+ for (const [ref, entry] of built) if (entry.uses.has(name)) built.delete(ref);
294
+ }
295
+
296
+ function view(ref: string, viewOpts: ViewOptions): Provider {
297
+ return {
298
+ get id() {
299
+ return resolve(ref).id;
300
+ },
301
+ get model() {
302
+ return resolve(ref).model;
303
+ },
304
+ createStream(messages, tools, opts = {}) {
305
+ async function* viewed(): AsyncGenerator<ProviderChunk> {
306
+ // A pool applies a per-call model to its primary only, and a single
307
+ // candidate would post it to a connection picked for another model.
308
+ if (opts.model) {
309
+ throw new ProviderError(
310
+ ref,
311
+ "invalid",
312
+ `${ref}: a registry provider can't take a per-call model. ` +
313
+ `Ask the registry for it instead: get("conn/${opts.model}").`,
314
+ );
315
+ }
316
+ // Looked up per call, so `update` reaches a provider taken at boot.
317
+ const provider = resolve(ref);
318
+ for await (const chunk of provider.createStream(messages, tools, {
319
+ ...opts,
320
+ effort: opts.effort ?? viewOpts.effort,
321
+ maxTokens: opts.maxTokens ?? viewOpts.maxTokens,
322
+ })) {
323
+ // A pool names the endpoint that answered; a single candidate is
324
+ // the endpoint, so its chunks get the same field.
325
+ yield chunk.source
326
+ ? chunk
327
+ : { ...chunk, source: { provider: provider.id, model: provider.model } };
328
+ }
329
+ }
330
+ return viewed();
331
+ },
332
+ };
333
+ }
334
+
335
+ return {
336
+ get(ref, opts = {}) {
337
+ members(ref);
338
+ return view(ref, opts);
339
+ },
340
+
341
+ update(name, config) {
342
+ const current = connections.get(name);
343
+ if (current && sameValue(current.config, config)) return;
344
+ connections.set(name, open(name, config, current));
345
+ forget(name);
346
+ },
347
+
348
+ remove(name) {
349
+ connections.delete(name);
350
+ forget(name);
351
+ },
352
+
353
+ status() {
354
+ const out: Record<string, ConnectionStatus> = {};
355
+ for (const [name, { gate, keyPool, candidates }] of connections) {
356
+ out[name] = {
357
+ ...(gate ? { gate: { max: gate.max, held: gate.held, waiting: gate.waiting } } : {}),
358
+ ...(keyPool ? { keys: keyPool.status() } : {}),
359
+ candidates: [...candidates].map(([model, provider]) => {
360
+ const { probe, ...bench } = cooldowns.get(provider) ?? { retryAtMs: 0 };
361
+ return { ref: `${name}/${model}`, ...bench, probing: probe !== undefined };
362
+ }),
363
+ };
364
+ }
365
+ return out;
366
+ },
367
+ };
368
+ }
369
+
370
+ function gateFor(config: ConnectionConfig, current: Connection | undefined): Gate | undefined {
371
+ if (config.maxConcurrent === undefined) return undefined;
372
+ // Calls in flight hold slots on the current gate. A fresh one with the same
373
+ // limit would let `max` more through beside them.
374
+ if (current?.gate && current.config.maxConcurrent === config.maxConcurrent) return current.gate;
375
+ return createGate(config.maxConcurrent);
376
+ }
377
+
378
+ /** A bare connection's models: `models`, then `model`, then the preset's
379
+ * rotation, then its default. The order `createPresetProvider` uses. */
380
+ function modelsOf(name: string, config: ConnectionConfig): readonly string[] {
381
+ const preset: ProviderPreset = PROVIDER_PRESETS[config.preset];
382
+ if (config.models?.length) return config.models;
383
+ if (config.model) return [config.model];
384
+ if (preset.rotation?.length) return preset.rotation;
385
+ if (preset.defaultModel) return [preset.defaultModel];
386
+ throw new Error(
387
+ `[providerkit] Connection "${name}" names no model, and preset "${config.preset}" has no ` +
388
+ "default. Set model or models.",
389
+ );
390
+ }
391
+
392
+ /**
393
+ * Equality for configs: plain objects and arrays by their contents, functions
394
+ * and every other object by identity. A key set to `undefined` equals a key
395
+ * left out. So an app that re-reads its config on every request rebuilds
396
+ * nothing, as long as it hands back the same `TokenSource`, `fetchImpl` and
397
+ * `onEvict` each time: a new one is a change, even when it does the same.
398
+ */
399
+ function sameValue(a: unknown, b: unknown): boolean {
400
+ if (Object.is(a, b)) return true;
401
+ if (Array.isArray(a) || Array.isArray(b)) {
402
+ return (
403
+ Array.isArray(a) &&
404
+ Array.isArray(b) &&
405
+ a.length === b.length &&
406
+ a.every((value, i) => sameValue(value, b[i]))
407
+ );
408
+ }
409
+ if (!isPlainObject(a) || !isPlainObject(b)) return false;
410
+ for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
411
+ if (!sameValue(a[key], b[key])) return false;
412
+ }
413
+ return true;
414
+ }
415
+
416
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
417
+ if (typeof value !== "object" || value === null) return false;
418
+ const proto: unknown = Object.getPrototypeOf(value);
419
+ return proto === Object.prototype || proto === null;
420
+ }
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
+ }