@providerkit/core 0.1.0 → 0.3.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 (61) hide show
  1. package/README.md +35 -158
  2. package/dist/context.d.ts.map +1 -1
  3. package/dist/context.js +8 -0
  4. package/dist/context.js.map +1 -1
  5. package/dist/errors.d.ts +36 -0
  6. package/dist/errors.d.ts.map +1 -1
  7. package/dist/errors.js +75 -2
  8. package/dist/errors.js.map +1 -1
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +4 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/key-pool.d.ts +60 -0
  14. package/dist/key-pool.d.ts.map +1 -0
  15. package/dist/key-pool.js +235 -0
  16. package/dist/key-pool.js.map +1 -0
  17. package/dist/providers/anthropic.d.ts +10 -6
  18. package/dist/providers/anthropic.d.ts.map +1 -1
  19. package/dist/providers/anthropic.js +30 -14
  20. package/dist/providers/anthropic.js.map +1 -1
  21. package/dist/providers/gemini.d.ts +40 -0
  22. package/dist/providers/gemini.d.ts.map +1 -0
  23. package/dist/providers/gemini.js +303 -0
  24. package/dist/providers/gemini.js.map +1 -0
  25. package/dist/providers/openai.d.ts +38 -1
  26. package/dist/providers/openai.d.ts.map +1 -1
  27. package/dist/providers/openai.js +122 -16
  28. package/dist/providers/openai.js.map +1 -1
  29. package/dist/providers/responses.d.ts +38 -0
  30. package/dist/providers/responses.d.ts.map +1 -0
  31. package/dist/providers/responses.js +341 -0
  32. package/dist/providers/responses.js.map +1 -0
  33. package/dist/rate-limit.d.ts +29 -0
  34. package/dist/rate-limit.d.ts.map +1 -0
  35. package/dist/rate-limit.js +194 -0
  36. package/dist/rate-limit.js.map +1 -0
  37. package/dist/transport.d.ts +18 -5
  38. package/dist/transport.d.ts.map +1 -1
  39. package/dist/transport.js +61 -35
  40. package/dist/transport.js.map +1 -1
  41. package/dist/types.d.ts +13 -0
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/types.js +7 -2
  44. package/dist/types.js.map +1 -1
  45. package/dist/zod.d.ts +8 -1
  46. package/dist/zod.d.ts.map +1 -1
  47. package/dist/zod.js +9 -2
  48. package/dist/zod.js.map +1 -1
  49. package/package.json +2 -2
  50. package/src/context.ts +7 -0
  51. package/src/errors.ts +82 -2
  52. package/src/index.ts +4 -0
  53. package/src/key-pool.ts +272 -0
  54. package/src/providers/anthropic.ts +41 -20
  55. package/src/providers/gemini.ts +386 -0
  56. package/src/providers/openai.ts +153 -16
  57. package/src/providers/responses.ts +455 -0
  58. package/src/rate-limit.ts +217 -0
  59. package/src/transport.ts +61 -35
  60. package/src/types.ts +19 -2
  61. package/src/zod.ts +12 -2
@@ -0,0 +1,272 @@
1
+ // A rotating pool of API keys for one provider.
2
+ //
3
+ // Rate limits are scoped per project or per account, so several keys multiply
4
+ // throughput. Free-tier keys go first, round-robin, and the paid key last —
5
+ // free quota burns before money. A key that answers 429 is benched for the
6
+ // delay the provider named (Retry-After, or Gemini's RetryInfo; else a minute,
7
+ // and an hour when the body says the balance or the daily window is gone), a
8
+ // denied key for 12 h (durable, but key-specific — one dead key must not take
9
+ // the pool down with it), an overload only briefly, because a sibling project's
10
+ // key routes to a different backend.
11
+ //
12
+ // With ONE key there is nothing to rotate to, so NOTHING is evicted and the
13
+ // error propagates untouched: a transient 503 must not switch a single-key
14
+ // deployment off for a minute, where the caller's own retry is the whole
15
+ // recovery there is.
16
+ import { isTransient, ProviderError } from "./errors.ts";
17
+ import type { ErrorKind } from "./errors.ts";
18
+ import type {
19
+ ChatMessage,
20
+ Provider,
21
+ ProviderChunk,
22
+ StreamOptions,
23
+ ToolDefinition,
24
+ } from "./types.ts";
25
+
26
+ /** A per-minute throttle: the window it names is the next one. */
27
+ const RATE_COOLDOWN_MS = 60_000;
28
+ /** A balance or a daily window — minutes will not bring it back. */
29
+ const QUOTA_COOLDOWN_MS = 60 * 60_000;
30
+ const AUTH_COOLDOWN_MS = 12 * 60 * 60_000;
31
+ /** The vendor's bad time, not the key's — benched only long enough for the
32
+ * next call to land somewhere else. */
33
+ const TRANSIENT_COOLDOWN_MS = 60_000;
34
+ const MIN_EVICTION_MS = 1_000;
35
+ const MAX_EVICTION_MS = 12 * 60 * 60_000;
36
+
37
+ export type KeyTier = "free" | "paid";
38
+
39
+ interface PoolKey {
40
+ apiKey: string;
41
+ tier: KeyTier;
42
+ /** Epoch ms until which the key is out; 0 = available. */
43
+ evictedUntil: number;
44
+ }
45
+
46
+ export interface KeyPoolOptions {
47
+ /** Free-tier keys, walked round-robin before the paid one. */
48
+ keys: readonly string[];
49
+ paidKey?: string | null;
50
+ /** Fires whenever a key is benched — the pool's only report, in place of a
51
+ * logger a zero-dependency package has no business owning. */
52
+ onEvict?: (info: { tier: KeyTier; kind: ErrorKind; forMs: number }) => void;
53
+ /** Injected in tests, so an expiry can be exercised without waiting out a
54
+ * 12-hour cooldown. */
55
+ now?: () => number;
56
+ }
57
+
58
+ /** No key can serve right now. `retryAtMs` = when the soonest one is back. */
59
+ export class NoAvailableKeyError extends Error {
60
+ readonly retryAtMs: number;
61
+
62
+ constructor(label: string, retryAtMs: number) {
63
+ super(`No ${label} API key available (all rate-limited or denied)`);
64
+ this.name = "NoAvailableKeyError";
65
+ this.retryAtMs = retryAtMs;
66
+ }
67
+ }
68
+
69
+ export class KeyPool {
70
+ private readonly label: string;
71
+ private readonly free: PoolKey[];
72
+ private readonly paid: PoolKey | null;
73
+ private readonly onEvict: KeyPoolOptions["onEvict"];
74
+ private readonly now: () => number;
75
+ private cursor = 0;
76
+
77
+ constructor(label: string, opts: KeyPoolOptions) {
78
+ this.label = label;
79
+ // A blank slot in the caller's config is not a key — an absent env var
80
+ // reads as "" and would otherwise be dialled once per rotation.
81
+ this.free = opts.keys
82
+ .filter(Boolean)
83
+ .map((apiKey): PoolKey => ({ apiKey, tier: "free", evictedUntil: 0 }));
84
+ this.paid = opts.paidKey ? { apiKey: opts.paidKey, tier: "paid", evictedUntil: 0 } : null;
85
+ this.onEvict = opts.onEvict;
86
+ this.now = opts.now ?? Date.now;
87
+ if (this.free.length === 0 && !this.paid) {
88
+ throw new Error(`KeyPool(${this.label}) needs at least one key`);
89
+ }
90
+ }
91
+
92
+ get size(): number {
93
+ return this.free.length + (this.paid ? 1 : 0);
94
+ }
95
+
96
+ /**
97
+ * Run `fn` with the next available key, rotating on key-specific and
98
+ * transient failures. Everything else (a bad request, a content block) throws
99
+ * straight through — it would fail identically on every key, and spending the
100
+ * pool on it only turns one bad request into an outage.
101
+ */
102
+ async with<T>(fn: (apiKey: string, tier: KeyTier) => Promise<T>): Promise<T> {
103
+ const candidates = this.candidates(this.now());
104
+ if (candidates.length === 0) throw new NoAvailableKeyError(this.label, this.nextAvailableAt());
105
+
106
+ let last: unknown;
107
+ for (const key of candidates) {
108
+ try {
109
+ return await fn(key.apiKey, key.tier);
110
+ } catch (err) {
111
+ last = err;
112
+ // The single-key rule: with nothing to rotate to, benching the only key
113
+ // answers NoAvailableKeyError to every call for the next minute — for a
114
+ // failure the caller's own retry would have absorbed.
115
+ const cooldown = this.size > 1 ? cooldownFor(err) : null;
116
+ if (cooldown === null) throw err;
117
+ key.evictedUntil = this.now() + cooldown.forMs;
118
+ this.onEvict?.({ tier: key.tier, kind: cooldown.kind, forMs: cooldown.forMs });
119
+ }
120
+ }
121
+
122
+ // Every candidate was evicted during this call.
123
+ if (this.candidates(this.now()).length === 0)
124
+ throw new NoAvailableKeyError(this.label, this.nextAvailableAt());
125
+ throw last;
126
+ }
127
+
128
+ /** Free keys round-robin from a moving cursor, then the paid key. */
129
+ private candidates(now: number): PoolKey[] {
130
+ const out: PoolKey[] = [];
131
+ const n = this.free.length;
132
+ for (let i = 0; i < n; i++) {
133
+ const key = this.free[(this.cursor + i) % n]!;
134
+ if (key.evictedUntil <= now) out.push(key);
135
+ }
136
+ if (n > 0) this.cursor = (this.cursor + 1) % n;
137
+ if (this.paid && this.paid.evictedUntil <= now) out.push(this.paid);
138
+ return out;
139
+ }
140
+
141
+ private nextAvailableAt(): number {
142
+ const all = this.paid ? [...this.free, this.paid] : this.free;
143
+ return Math.min(...all.map((key) => key.evictedUntil));
144
+ }
145
+ }
146
+
147
+ /**
148
+ * How long to bench a key for this failure, or null when the failure is not the
149
+ * key's fault — nothing is gained by rotating, and benching would spend the
150
+ * pool on a request that fails the same way everywhere.
151
+ */
152
+ function cooldownFor(err: unknown): { kind: ErrorKind; forMs: number } | null {
153
+ if (!(err instanceof ProviderError)) return null;
154
+ const clamp = (ms: number) => Math.min(Math.max(ms, MIN_EVICTION_MS), MAX_EVICTION_MS);
155
+ switch (err.kind) {
156
+ // The three kinds that are about THIS key.
157
+ case "rate":
158
+ return { kind: err.kind, forMs: clamp(err.retryAfterMs ?? RATE_COOLDOWN_MS) };
159
+ case "quota":
160
+ return { kind: err.kind, forMs: clamp(err.retryAfterMs ?? QUOTA_COOLDOWN_MS) };
161
+ case "auth":
162
+ // 12 h, not forever: a key is also refused while a billing account is
163
+ // reinstated, and a pool that drops keys permanently ends up empty.
164
+ return { kind: err.kind, forMs: AUTH_COOLDOWN_MS };
165
+ default:
166
+ // Not the key's fault, but a sibling key is a different project on a
167
+ // different backend, so one more call is the cheapest way to find out —
168
+ // which is what an overload or a stalled request is worth. `network` is
169
+ // excluded deliberately: the socket died on our side and dies identically
170
+ // on every key, so benching for it would answer NoAvailableKeyError to
171
+ // what is really "no internet".
172
+ return isTransient(err.kind) && err.kind !== "network"
173
+ ? { kind: err.kind, forMs: TRANSIENT_COOLDOWN_MS }
174
+ : null;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Give any provider a rotating pool of keys.
180
+ *
181
+ * The subtlety is the seam's shape. `createStream` is an async generator, so
182
+ * CALLING it performs no I/O: the POST — and the 429 that should rotate the key
183
+ * — happens on the first `next()`, long after a `pool.with` wrapped around the
184
+ * call itself would have returned, with nothing left to rotate. So the first
185
+ * chunk is pulled INSIDE the pool and only the rest of the stream is consumed
186
+ * outside it.
187
+ *
188
+ * That line is also the honest one. Past the first chunk the answer is
189
+ * committed to one key, exactly as a retry is committed past its first chunk
190
+ * (retry.ts, rule 2): a mid-stream 429 evicts nothing and rotates nothing,
191
+ * because there is no way to resume a half-rendered answer on another key.
192
+ */
193
+ export function withKeyPool(pool: KeyPool, factory: (apiKey: string) => Provider): Provider {
194
+ // The identity every key shares. Building an adapter performs no I/O — it
195
+ // closes over its config and nothing else — so a throwaway instance is the
196
+ // cheapest way to read `id` and `model` without holding a key outside the
197
+ // pool.
198
+ const identity = factory("");
199
+
200
+ return {
201
+ id: identity.id,
202
+ model: identity.model,
203
+
204
+ async *createStream(
205
+ messages: ChatMessage[],
206
+ tools: ToolDefinition[],
207
+ opts: StreamOptions = {},
208
+ ): AsyncIterable<ProviderChunk> {
209
+ const opened = await pool.with(async (apiKey) => {
210
+ // One AbortController per attempt, chained to the caller's own signal
211
+ // (retry.ts's rule). Finalizing a generator is not cancellation:
212
+ // `return()` unwinds the adapter down to `streamSse`'s finalizer, which
213
+ // only releases the reader's lock — the response body stays live and
214
+ // the request is never aborted. Without this controller an abandoned
215
+ // attempt keeps the provider generating the rest of the answer, holding
216
+ // a connection and a concurrency slot on the very key the pool is
217
+ // rotating away from.
218
+ const controller = new AbortController();
219
+ const onAbort = () => controller.abort(opts.signal?.reason);
220
+ if (opts.signal?.aborted) onAbort();
221
+ else opts.signal?.addEventListener("abort", onAbort, { once: true });
222
+
223
+ const stream = factory(apiKey).createStream(messages, tools, {
224
+ ...opts,
225
+ signal: controller.signal,
226
+ });
227
+ const iterator = stream[Symbol.asyncIterator]();
228
+ const release = async () => {
229
+ opts.signal?.removeEventListener("abort", onAbort);
230
+ await close(iterator);
231
+ controller.abort();
232
+ };
233
+
234
+ try {
235
+ return { iterator, first: await iterator.next(), release };
236
+ } catch (err) {
237
+ // The request failed under the pool, which is about to try the next
238
+ // key: release this attempt before a second one opens against the
239
+ // same rate limit.
240
+ await release();
241
+ throw err;
242
+ }
243
+ });
244
+
245
+ const { iterator, first, release } = opened;
246
+ try {
247
+ if (first.done) return;
248
+ yield first.value;
249
+ for (;;) {
250
+ const next = await iterator.next();
251
+ if (next.done) return;
252
+ yield next.value;
253
+ }
254
+ } finally {
255
+ // A consumer that breaks out of its loop never reaches the end of ours,
256
+ // and an attempt left unfinalized and un-aborted streams for the whole
257
+ // rest of the answer into a body nobody reads.
258
+ await release();
259
+ }
260
+ },
261
+ };
262
+ }
263
+
264
+ /** Finalize an abandoned stream. `return()` can reject on its own (an aborted
265
+ * body), and that must never replace the failure being handled. */
266
+ async function close(iterator: AsyncIterator<ProviderChunk>): Promise<void> {
267
+ try {
268
+ await iterator.return?.();
269
+ } catch {
270
+ // The request is being abandoned either way.
271
+ }
272
+ }
@@ -1,5 +1,6 @@
1
1
  // Anthropic-shape adapter — SSE from POST /v1/messages.
2
- import { ProviderError } from "../errors.ts";
2
+ import { streamError } from "../errors.ts";
3
+ import { parseToolArgs } from "../tool-args.ts";
3
4
  import { streamSse, apiUrl } from "../transport.ts";
4
5
  import type {
5
6
  ChatMessage,
@@ -15,13 +16,22 @@ import type {
15
16
  export interface AnthropicConfig {
16
17
  apiKey: string;
17
18
  model: string;
19
+ /** Any endpoint speaking the Anthropic Messages dialect — a proxy or gateway.
20
+ * Defaults to Anthropic itself. */
18
21
  baseUrl?: string;
22
+ /** Names the provider in errors and logs. The subscription backend is the
23
+ * reason this is not hardcoded: a token failure there is a re-login, not a
24
+ * bad API key, and the two must not read the same in a ledger. */
25
+ id?: string;
19
26
  /** Bound default; a per-call `effort` overrides it. */
20
27
  effort?: Effort;
21
28
  /** Anthropic requires an output ceiling on every request. */
22
29
  maxTokens?: number;
23
30
  version?: string;
24
31
  fetchImpl?: typeof fetch;
32
+ /** Merged into every request. The subscription backend needs its own beta
33
+ * headers, and a gateway in front usually wants one of its own. */
34
+ headers?: Record<string, string>;
25
35
  /** Send the key as a Bearer instead of `x-api-key` — what a subscription
26
36
  * access token needs. */
27
37
  bearer?: boolean;
@@ -77,8 +87,25 @@ function partsToAnthropic(content: string | ContentPart[]): unknown[] {
77
87
  * turns carrying `tool_result` blocks — not as a role of their own. Consecutive
78
88
  * tool results are merged into one user turn, which the API requires.
79
89
  */
90
+ /**
91
+ * The system prompt as ONE cached block.
92
+ *
93
+ * Anthropic's prompt caching is opt-in PER BLOCK — a plain string system prompt
94
+ * is never cached, however many times it is re-sent. An agent loop re-sends this
95
+ * every single turn, and it is the largest stable prefix in the request, so
96
+ * without the breakpoint the whole thing bills at the full input rate on every
97
+ * round instead of a tenth of it on all but the first.
98
+ *
99
+ * Unconditional. Below the model's minimum cacheable length the field is
100
+ * ignored rather than rejected, and above it the one-time 1.25× write is repaid
101
+ * by the second turn — which, in the loop this package sits under, always comes.
102
+ */
103
+ function systemBlocks(text: string): unknown[] | undefined {
104
+ return text ? [{ type: "text", text, cache_control: { type: "ephemeral" } }] : undefined;
105
+ }
106
+
80
107
  export function toAnthropicMessages(messages: readonly ChatMessage[]): {
81
- system?: string;
108
+ system?: unknown[];
82
109
  messages: unknown[];
83
110
  } {
84
111
  const system = messages
@@ -125,21 +152,14 @@ export function toAnthropicMessages(messages: readonly ChatMessage[]): {
125
152
  type: "tool_use",
126
153
  id: call.id,
127
154
  name: call.name,
128
- input: safeParse(call.arguments),
155
+ input: parseToolArgs(call.arguments),
129
156
  });
130
157
  }
131
158
  if (blocks.length > 0) pushBlocks("assistant", blocks);
132
159
  }
133
160
 
134
- return { ...(system ? { system } : {}), messages: out };
135
- }
136
-
137
- function safeParse(raw: string): unknown {
138
- try {
139
- return raw ? JSON.parse(raw) : {};
140
- } catch {
141
- return {};
142
- }
161
+ const blocks = systemBlocks(system);
162
+ return { ...(blocks ? { system: blocks } : {}), messages: out };
143
163
  }
144
164
 
145
165
  interface AnthropicEvent {
@@ -167,9 +187,10 @@ interface AnthropicEvent {
167
187
 
168
188
  export function createAnthropicProvider(config: AnthropicConfig): Provider {
169
189
  const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
190
+ const id = config.id ?? "anthropic";
170
191
 
171
192
  return {
172
- id: "anthropic",
193
+ id,
173
194
  model: config.model,
174
195
 
175
196
  async *createStream(
@@ -230,9 +251,10 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
230
251
  ...(config.bearer
231
252
  ? { authorization: `Bearer ${config.apiKey}` }
232
253
  : { "x-api-key": config.apiKey }),
254
+ ...config.headers,
233
255
  },
234
256
  body: request,
235
- provider: "anthropic",
257
+ provider: id,
236
258
  ...(opts.signal ? { signal: opts.signal } : {}),
237
259
  ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
238
260
  })) {
@@ -244,13 +266,12 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
244
266
  }
245
267
 
246
268
  switch (event.type) {
269
+ // A failure the backend reports after its headers went out. Classified
270
+ // rather than assumed transient: this shape carries `overloaded_error`
271
+ // most of the time, but a prompt found too long mid-stream arrives the
272
+ // same way, and retrying that one only fails it again more slowly.
247
273
  case "error":
248
- throw new ProviderError(
249
- "anthropic",
250
- "overload",
251
- event.error?.message ?? "anthropic stream error",
252
- { code: event.error?.type },
253
- );
274
+ throw streamError(id, event.error);
254
275
 
255
276
  case "message_start": {
256
277
  const usage = event.message?.usage;