@providerkit/core 0.22.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 (131) hide show
  1. package/README.md +29 -7
  2. package/dist/auth/flows.d.ts.map +1 -1
  3. package/dist/auth/flows.js +5 -5
  4. package/dist/auth/flows.js.map +1 -1
  5. package/dist/auth/index.d.ts +1 -0
  6. package/dist/auth/index.d.ts.map +1 -1
  7. package/dist/auth/index.js +1 -0
  8. package/dist/auth/index.js.map +1 -1
  9. package/dist/auth/oauth.d.ts +12 -0
  10. package/dist/auth/oauth.d.ts.map +1 -1
  11. package/dist/auth/oauth.js +19 -1
  12. package/dist/auth/oauth.js.map +1 -1
  13. package/dist/auth/token-source.d.ts +9 -3
  14. package/dist/auth/token-source.d.ts.map +1 -1
  15. package/dist/auth/token-source.js +24 -9
  16. package/dist/auth/token-source.js.map +1 -1
  17. package/dist/auth/with-token.d.ts +32 -0
  18. package/dist/auth/with-token.d.ts.map +1 -0
  19. package/dist/auth/with-token.js +88 -0
  20. package/dist/auth/with-token.js.map +1 -0
  21. package/dist/context.d.ts +39 -1
  22. package/dist/context.d.ts.map +1 -1
  23. package/dist/context.js +119 -0
  24. package/dist/context.js.map +1 -1
  25. package/dist/errors.d.ts +12 -3
  26. package/dist/errors.d.ts.map +1 -1
  27. package/dist/errors.js +44 -5
  28. package/dist/errors.js.map +1 -1
  29. package/dist/fallback.d.ts +45 -1
  30. package/dist/fallback.d.ts.map +1 -1
  31. package/dist/fallback.js +138 -75
  32. package/dist/fallback.js.map +1 -1
  33. package/dist/gate.d.ts +29 -0
  34. package/dist/gate.d.ts.map +1 -0
  35. package/dist/gate.js +96 -0
  36. package/dist/gate.js.map +1 -0
  37. package/dist/index.d.ts +3 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +3 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/key-pool.d.ts +21 -4
  42. package/dist/key-pool.d.ts.map +1 -1
  43. package/dist/key-pool.js +43 -12
  44. package/dist/key-pool.js.map +1 -1
  45. package/dist/presets.d.ts +7 -2
  46. package/dist/presets.d.ts.map +1 -1
  47. package/dist/presets.js +12 -2
  48. package/dist/presets.js.map +1 -1
  49. package/dist/providers/anthropic.d.ts +7 -0
  50. package/dist/providers/anthropic.d.ts.map +1 -1
  51. package/dist/providers/anthropic.js +83 -26
  52. package/dist/providers/anthropic.js.map +1 -1
  53. package/dist/providers/factory.d.ts +26 -0
  54. package/dist/providers/factory.d.ts.map +1 -1
  55. package/dist/providers/factory.js +41 -9
  56. package/dist/providers/factory.js.map +1 -1
  57. package/dist/providers/gemini.d.ts +6 -0
  58. package/dist/providers/gemini.d.ts.map +1 -1
  59. package/dist/providers/gemini.js +102 -27
  60. package/dist/providers/gemini.js.map +1 -1
  61. package/dist/providers/openai.d.ts +7 -0
  62. package/dist/providers/openai.d.ts.map +1 -1
  63. package/dist/providers/openai.js +66 -15
  64. package/dist/providers/openai.js.map +1 -1
  65. package/dist/providers/responses.d.ts +11 -0
  66. package/dist/providers/responses.d.ts.map +1 -1
  67. package/dist/providers/responses.js +101 -53
  68. package/dist/providers/responses.js.map +1 -1
  69. package/dist/rate-limit.d.ts +10 -0
  70. package/dist/rate-limit.d.ts.map +1 -1
  71. package/dist/rate-limit.js +7 -1
  72. package/dist/rate-limit.js.map +1 -1
  73. package/dist/registry.d.ts +105 -0
  74. package/dist/registry.d.ts.map +1 -0
  75. package/dist/registry.js +291 -0
  76. package/dist/registry.js.map +1 -0
  77. package/dist/retry.d.ts +11 -0
  78. package/dist/retry.d.ts.map +1 -1
  79. package/dist/retry.js +25 -12
  80. package/dist/retry.js.map +1 -1
  81. package/dist/tool-args.d.ts +22 -0
  82. package/dist/tool-args.d.ts.map +1 -1
  83. package/dist/tool-args.js +28 -0
  84. package/dist/tool-args.js.map +1 -1
  85. package/dist/tools.d.ts +8 -0
  86. package/dist/tools.d.ts.map +1 -1
  87. package/dist/tools.js +2 -2
  88. package/dist/tools.js.map +1 -1
  89. package/dist/transport.d.ts +34 -4
  90. package/dist/transport.d.ts.map +1 -1
  91. package/dist/transport.js +77 -16
  92. package/dist/transport.js.map +1 -1
  93. package/dist/turn.d.ts +55 -0
  94. package/dist/turn.d.ts.map +1 -0
  95. package/dist/turn.js +125 -0
  96. package/dist/turn.js.map +1 -0
  97. package/dist/types.d.ts +21 -22
  98. package/dist/types.d.ts.map +1 -1
  99. package/dist/types.js +0 -53
  100. package/dist/types.js.map +1 -1
  101. package/dist/watchdog.d.ts +2 -0
  102. package/dist/watchdog.d.ts.map +1 -1
  103. package/dist/watchdog.js +25 -6
  104. package/dist/watchdog.js.map +1 -1
  105. package/package.json +1 -1
  106. package/src/auth/flows.ts +5 -4
  107. package/src/auth/index.ts +1 -0
  108. package/src/auth/oauth.ts +23 -1
  109. package/src/auth/token-source.ts +30 -11
  110. package/src/auth/with-token.ts +100 -0
  111. package/src/context.ts +137 -2
  112. package/src/errors.ts +46 -6
  113. package/src/fallback.ts +173 -75
  114. package/src/gate.ts +152 -0
  115. package/src/index.ts +3 -0
  116. package/src/key-pool.ts +45 -13
  117. package/src/presets.ts +12 -2
  118. package/src/providers/anthropic.ts +93 -25
  119. package/src/providers/factory.ts +66 -21
  120. package/src/providers/gemini.ts +102 -28
  121. package/src/providers/openai.ts +71 -14
  122. package/src/providers/responses.ts +116 -51
  123. package/src/rate-limit.ts +31 -3
  124. package/src/registry.ts +420 -0
  125. package/src/retry.ts +41 -10
  126. package/src/tool-args.ts +44 -0
  127. package/src/tools.ts +10 -2
  128. package/src/transport.ts +97 -17
  129. package/src/turn.ts +166 -0
  130. package/src/types.ts +21 -62
  131. package/src/watchdog.ts +25 -6
package/src/rate-limit.ts CHANGED
@@ -36,8 +36,16 @@ export interface RateLimitReset {
36
36
  retryAfterMs?: number;
37
37
  /** Which subscription window bound, when it could be named at all. */
38
38
  window?: RateLimitWindow;
39
+ /** How full the binding window is, as the headers report it (1 is full).
40
+ * Set only when a window's fill rode the response: Anthropic's unified
41
+ * 5h/7d headers, or the ChatGPT backend's `x-codex-*` ones. */
42
+ utilization?: number;
39
43
  }
40
44
 
45
+ /** A window's fill off a response that reported one: what
46
+ * `StreamOptions.onRateLimit` receives. */
47
+ export type RateLimitReading = RateLimitReset & { utilization: number; resetAtMs: number };
48
+
41
49
  const RETRY_AFTER = "retry-after";
42
50
  const RETRY_AFTER_MS = "retry-after-ms";
43
51
  const UNIFIED_5H = "anthropic-ratelimit-unified-5h-";
@@ -66,11 +74,23 @@ const RESET_HEADERS = [
66
74
  */
67
75
  const CODEX_SLOTS = ["primary", "secondary"] as const;
68
76
 
77
+ /** One window as a response reports it: how full, and when it resets. */
78
+ interface WindowReading {
79
+ window?: RateLimitWindow;
80
+ utilization?: number;
81
+ reset?: number;
82
+ }
83
+ type Dated = WindowReading & { utilization: number; reset: number };
84
+
69
85
  function windowForMinutes(minutes: number | undefined): RateLimitWindow | undefined {
70
86
  return minutes !== undefined && minutes > 0 ? windowForMs(minutes * 60_000) : undefined;
71
87
  }
72
88
 
73
- function codexWindow(headers: Headers, slot: (typeof CODEX_SLOTS)[number], now: number) {
89
+ function codexWindow(
90
+ headers: Headers,
91
+ slot: (typeof CODEX_SLOTS)[number],
92
+ now: number,
93
+ ): WindowReading {
74
94
  const name = (suffix: string) => headers.get(`x-codex-${slot}-${suffix}`);
75
95
  const percent = numeric(name("used-percent"));
76
96
  const after = numeric(name("reset-after-seconds"));
@@ -185,10 +205,18 @@ export function parseRateLimitReset(headers: Headers, now = Date.now()): RateLim
185
205
  reset: unixSecondsToMs(headers.get(`${UNIFIED_7D}reset`)),
186
206
  },
187
207
  ...CODEX_SLOTS.map((slot) => codexWindow(headers, slot, now)),
188
- ].filter((w) => w.utilization !== undefined && w.reset !== undefined && w.reset > now);
189
- const binding = windows.sort((a, b) => (b.utilization ?? 0) - (a.utilization ?? 0))[0];
208
+ ].filter(
209
+ (w): w is Dated => w.utilization !== undefined && w.reset !== undefined && w.reset > now,
210
+ );
211
+ // The fuller window binds. Between two as full as each other (both spent,
212
+ // usually) the later reset does: the request passes only once both have
213
+ // cleared, and waiting out the earlier one walks into the other wall. On a
214
+ // dead heat the later slot wins (the sort is stable), as cc-proxy's `max_by`
215
+ // does.
216
+ const binding = windows.sort((a, b) => a.utilization - b.utilization || a.reset - b.reset).at(-1);
190
217
  if (binding) {
191
218
  if (binding.window) result.window = binding.window;
219
+ result.utilization = binding.utilization;
192
220
  // The window's own reset outranks retry-after: it names the real horizon,
193
221
  // where retry-after names the next polite attempt.
194
222
  if (binding.reset !== undefined) result.resetAtMs = binding.reset;
@@ -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/retry.ts CHANGED
@@ -28,6 +28,17 @@ export interface RetryOptions {
28
28
  /** Maximum acceptable wait. A longer server deadline ends retry so an outer
29
29
  * fallback can switch provider; it is never shortened to retry too early. */
30
30
  maxDelayMs?: number;
31
+ /**
32
+ * How long recovery may go on, counted from the FIRST failure. A retry
33
+ * starts only if it would start before then, its wait included. An attempt
34
+ * already running is never cut. Default 60s; `Infinity` turns it off.
35
+ *
36
+ * Without it the worst case is every attempt in full plus every wait: three
37
+ * stalled streams at the watchdog's 300s each is a quarter of an hour before
38
+ * the caller hears anything. The clock starts at the first failure, not at
39
+ * the call, so a long healthy run that then fails still gets its recovery.
40
+ */
41
+ budgetMs?: number;
31
42
  /** Aborts the wait as well as the work, so Stop lands promptly. */
32
43
  signal?: AbortSignal;
33
44
  /** Decide retryability. Default: transient kinds only. */
@@ -40,6 +51,7 @@ export interface RetryOptions {
40
51
  const DEFAULT_MAX_ATTEMPTS = 3;
41
52
  const DEFAULT_BASE_DELAY_MS = 1_000;
42
53
  const DEFAULT_MAX_DELAY_MS = 30_000;
54
+ const DEFAULT_BUDGET_MS = 60_000;
43
55
 
44
56
  /**
45
57
  * Full-jitter exponential backoff: a random delay in
@@ -92,6 +104,29 @@ function delayFor(err: unknown, attempt: number, opts: RetryOptions): number | n
92
104
 
93
105
  const defaultShouldRetry = (err: unknown, _attempt: number): boolean => isRetryable(err);
94
106
 
107
+ /** The moment the recovery budget runs out, fixed at the first failure. */
108
+ const budgetEnd = (opts: RetryOptions): number => Date.now() + (opts.budgetMs ?? DEFAULT_BUDGET_MS);
109
+
110
+ /**
111
+ * Wait out the delay before the next attempt, or rethrow `err` unchanged when
112
+ * there is none: the server's deadline is past `maxDelayMs`, or the next
113
+ * attempt would start past the budget.
114
+ */
115
+ async function waitToRetry(
116
+ err: unknown,
117
+ attempt: number,
118
+ opts: RetryOptions,
119
+ budgetEndsAt: number,
120
+ ): Promise<void> {
121
+ const delayMs = delayFor(err, attempt, opts);
122
+ if (delayMs === null || Date.now() + delayMs >= budgetEndsAt) throw err;
123
+ opts.onRetry?.({ error: err, attempt, delayMs });
124
+ await (opts.sleep ?? sleep)(delayMs, opts.signal);
125
+ // A timer can wake long after it was due (a suspended worker, a throttled
126
+ // tab). The budget is about when the attempt starts, not when it was meant to.
127
+ if (Date.now() >= budgetEndsAt) throw err;
128
+ }
129
+
95
130
  /**
96
131
  * Run `fn`, retrying transient failures with backoff. For one-shot calls such
97
132
  * as a title, a summary or a compaction pass.
@@ -102,7 +137,7 @@ export async function withRetry<T>(
102
137
  ): Promise<T> {
103
138
  const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
104
139
  const shouldRetry = opts.shouldRetry ?? defaultShouldRetry;
105
- const nap = opts.sleep ?? sleep;
140
+ let budgetEndsAt: number | undefined;
106
141
 
107
142
  let lastError: unknown;
108
143
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -114,10 +149,8 @@ export async function withRetry<T>(
114
149
  // A caller's Stop is never a transient failure, whatever it looks like.
115
150
  if (opts.signal?.aborted) throw err;
116
151
  if (attempt >= maxAttempts || !shouldRetry(err, attempt)) throw err;
117
- const delayMs = delayFor(err, attempt, opts);
118
- if (delayMs === null) throw err;
119
- opts.onRetry?.({ error: err, attempt, delayMs });
120
- await nap(delayMs, opts.signal);
152
+ budgetEndsAt ??= budgetEnd(opts);
153
+ await waitToRetry(err, attempt, opts, budgetEndsAt);
121
154
  }
122
155
  }
123
156
  throw lastError;
@@ -138,7 +171,7 @@ export async function* withStreamRetry<T>(
138
171
  ): AsyncGenerator<T> {
139
172
  const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
140
173
  const shouldRetry = opts.shouldRetry ?? defaultShouldRetry;
141
- const nap = opts.sleep ?? sleep;
174
+ let budgetEndsAt: number | undefined;
142
175
 
143
176
  let lastError: unknown;
144
177
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -163,10 +196,8 @@ export async function* withStreamRetry<T>(
163
196
  if (opts.signal?.aborted) throw err;
164
197
  // Rule 2: past the first chunk there is no going back.
165
198
  if (emitted || attempt >= maxAttempts || !shouldRetry(err, attempt)) throw err;
166
- const delayMs = delayFor(err, attempt, opts);
167
- if (delayMs === null) throw err;
168
- opts.onRetry?.({ error: err, attempt, delayMs });
169
- await nap(delayMs, opts.signal);
199
+ budgetEndsAt ??= budgetEnd(opts);
200
+ await waitToRetry(err, attempt, opts, budgetEndsAt);
170
201
  } finally {
171
202
  opts.signal?.removeEventListener("abort", onOuterAbort);
172
203
  // Abandoning mid-iteration (the consumer broke out, or we are retrying)
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);
@@ -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
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() {