@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/gate.ts ADDED
@@ -0,0 +1,152 @@
1
+ // How many calls run at once on one connection.
2
+ //
3
+ // A flat-rate plan caps concurrent requests, and says so only by failing: six
4
+ // calls at once on one Z.ai Coding Plan key came back with some 429s (measured
5
+ // 2026-09-25, `capability.ts`). Behind a fallback chain the overflow is
6
+ // answered by the NEXT model, so the cap shows up as a quietly different
7
+ // answer, not as an error. The capability probe runs one call at a time for
8
+ // that reason, and one adopter runs its batches one item at a time on purpose.
9
+ // A gate holds the overflow in a queue instead, and lets each call in as a
10
+ // slot frees.
11
+ //
12
+ // The hard part is giving the slot back. A slot leaked by an abandoned stream
13
+ // is gone for the life of the process, with no error anywhere: the connection
14
+ // just serves one call fewer, until it serves none. So the slot is taken on the
15
+ // first read (a stream nobody reads holds nothing) and released exactly once,
16
+ // on whichever comes first: the stream ends, throws or is returned, or its
17
+ // signal aborts. That last one matters when the consumer holds a stream and
18
+ // stops reading it. Nothing else would ever free that slot.
19
+ //
20
+ // ponytail: three known ceilings.
21
+ // - It counts one process only. Two replicas each let `max` calls through. The
22
+ // upgrade path is a shared counter (one adopter already keeps one in Redis).
23
+ // - A full gate makes callers wait instead of moving on to the next candidate
24
+ // in a fallback chain. The upgrade is a bounded wait that fails as transient,
25
+ // so the pool tries the next one.
26
+ // - A fallback probe stuck waiting here has no first chunk, so once it goes
27
+ // stale (`probeStaleMs`) the pool can let one extra probe in beside it.
28
+ import type {
29
+ ChatMessage,
30
+ Provider,
31
+ ProviderChunk,
32
+ StreamOptions,
33
+ ToolDefinition,
34
+ } from "./types.ts";
35
+
36
+ export interface Gate {
37
+ /** How many calls may hold a slot at once. */
38
+ readonly max: number;
39
+ /** How many slots are held right now. */
40
+ readonly held: number;
41
+ /** How many calls are queued for a slot. */
42
+ readonly waiting: number;
43
+ /**
44
+ * Wait for a slot, first in, first out. Resolves to its release, which is
45
+ * safe to call more than once. The signal covers the whole life of the slot:
46
+ * an abort while waiting leaves the queue and rejects with `signal.reason`,
47
+ * and an abort while held releases it.
48
+ */
49
+ acquire(signal?: AbortSignal): Promise<() => void>;
50
+ }
51
+
52
+ /** A gate with `max` slots. Keep one alive per connection, so every call on
53
+ * it shares the count. */
54
+ export function createGate(max: number): Gate {
55
+ if (!Number.isInteger(max) || max < 1) {
56
+ throw new Error(`[providerkit] A gate needs a whole number of slots, 1 or more. Got ${max}.`);
57
+ }
58
+
59
+ let held = 0;
60
+ const queue: Array<() => void> = [];
61
+
62
+ function acquire(signal?: AbortSignal): Promise<() => void> {
63
+ // Rejected before it queues: a caller already gone must not take a place
64
+ // in line, or a slot, even for a moment.
65
+ if (signal?.aborted) return Promise.reject(signal.reason);
66
+
67
+ return new Promise((resolve, reject) => {
68
+ let state: "waiting" | "held" | "released" = "waiting";
69
+
70
+ const release = () => {
71
+ if (state !== "held") return;
72
+ state = "released";
73
+ signal?.removeEventListener("abort", onAbort);
74
+ // Handed straight to the next waiter, so `held` never dips and a
75
+ // newcomer arriving now finds the gate full and queues behind them.
76
+ const next = queue.shift();
77
+ if (next) next();
78
+ else held--;
79
+ };
80
+
81
+ const grant = () => {
82
+ state = "held";
83
+ resolve(release);
84
+ };
85
+
86
+ function onAbort() {
87
+ if (state === "held") {
88
+ release();
89
+ } else if (state === "waiting") {
90
+ state = "released";
91
+ queue.splice(queue.indexOf(grant), 1);
92
+ reject(signal?.reason);
93
+ }
94
+ }
95
+
96
+ if (held < max) {
97
+ held++;
98
+ grant();
99
+ } else {
100
+ queue.push(grant);
101
+ }
102
+ signal?.addEventListener("abort", onAbort, { once: true });
103
+ });
104
+ }
105
+
106
+ return {
107
+ max,
108
+ get held() {
109
+ return held;
110
+ },
111
+ get waiting() {
112
+ return queue.length;
113
+ },
114
+ acquire,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Let a provider run at most `gate.max` calls at once.
120
+ *
121
+ * Put it outside the watchdog. The watchdog arms on its first read, and this
122
+ * reads it only once a slot is held, so a call queued here never spends its
123
+ * deadlines waiting. Inside it, a call that stood in line for five minutes
124
+ * would time out as a silent provider before it ever sent a byte.
125
+ */
126
+ export function withGate(provider: Provider, gate: Gate): Provider {
127
+ return {
128
+ ...provider,
129
+ createStream(
130
+ messages: ChatMessage[],
131
+ tools: ToolDefinition[],
132
+ opts: StreamOptions = {},
133
+ ): AsyncIterable<ProviderChunk> {
134
+ // A generator, so nothing runs (the wait included) until the first read.
135
+ async function* gated(): AsyncGenerator<ProviderChunk> {
136
+ const release = await gate.acquire(opts.signal);
137
+ try {
138
+ // The slot can be granted and released by the same abort before this
139
+ // line runs (two calls sharing one signal). Such a call is over, and
140
+ // must not reach the wire without a slot.
141
+ opts.signal?.throwIfAborted();
142
+ yield* provider.createStream(messages, tools, opts);
143
+ } finally {
144
+ // Covers the end, a throw and a consumer's `break`. An abort may
145
+ // have released it already, and a second call is a no-op.
146
+ release();
147
+ }
148
+ }
149
+ return gated();
150
+ },
151
+ };
152
+ }
package/src/index.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  export * from "./types.ts";
2
+ export * from "./turn.ts";
2
3
  export * from "./errors.ts";
3
4
  export * from "./retry.ts";
4
5
  export * from "./fallback.ts";
5
6
  export * from "./key-pool.ts";
6
7
  export * from "./watchdog.ts";
8
+ export * from "./gate.ts";
7
9
  export * from "./usage.ts";
8
10
  export * from "./transport.ts";
9
11
  export * from "./rate-limit.ts";
@@ -21,3 +23,4 @@ export * from "./providers/responses.ts";
21
23
  export * from "./providers/gemini.ts";
22
24
  export * from "./providers/zai.ts";
23
25
  export * from "./providers/factory.ts";
26
+ export * from "./registry.ts";
package/src/key-pool.ts CHANGED
@@ -55,12 +55,20 @@ export interface KeyPoolOptions {
55
55
  now?: () => number;
56
56
  }
57
57
 
58
- /** No key can serve right now. `retryAtMs` = when the soonest one is back. */
59
- export class NoAvailableKeyError extends Error {
58
+ /** No key can serve right now. `retryAtMs` = when the soonest one is back.
59
+ *
60
+ * A `ProviderError` of kind `rate` that carries that moment as `resetAtMs` and
61
+ * `retryAfterMs`. As a plain `Error` it classified as `rate` only by its
62
+ * wording and carried no deadline anyone read, so a FallbackPool around the
63
+ * key pool benched it for its default minute, whatever the keys had said. */
64
+ export class NoAvailableKeyError extends ProviderError {
60
65
  readonly retryAtMs: number;
61
66
 
62
- constructor(label: string, retryAtMs: number) {
63
- super(`No ${label} API key available (all rate-limited or denied)`);
67
+ constructor(label: string, retryAtMs: number, now: number) {
68
+ super(label, "rate", `No ${label} API key available (all rate-limited or denied)`, {
69
+ resetAtMs: retryAtMs,
70
+ retryAfterMs: Math.max(0, retryAtMs - now),
71
+ });
64
72
  this.name = "NoAvailableKeyError";
65
73
  this.retryAtMs = retryAtMs;
66
74
  }
@@ -93,6 +101,17 @@ export class KeyPool {
93
101
  return this.free.length + (this.paid ? 1 : 0);
94
102
  }
95
103
 
104
+ /** Each key's tier and when it is back (0 = serving now): free keys in
105
+ * config order, then the paid one. Never the key itself, so a status page
106
+ * can show it as is. */
107
+ status(): Array<{ tier: KeyTier; evictedUntil: number }> {
108
+ const now = this.now();
109
+ return [...this.free, ...(this.paid ? [this.paid] : [])].map(({ tier, evictedUntil }) => ({
110
+ tier,
111
+ evictedUntil: evictedUntil > now ? evictedUntil : 0,
112
+ }));
113
+ }
114
+
96
115
  /**
97
116
  * Run `fn` with the next available key, rotating on key-specific and
98
117
  * transient failures. Everything else (a bad request, a content block) throws
@@ -101,7 +120,7 @@ export class KeyPool {
101
120
  */
102
121
  async with<T>(fn: (apiKey: string, tier: KeyTier) => Promise<T>): Promise<T> {
103
122
  const candidates = this.candidates(this.now());
104
- if (candidates.length === 0) throw new NoAvailableKeyError(this.label, this.nextAvailableAt());
123
+ if (candidates.length === 0) throw this.noneAvailable();
105
124
 
106
125
  let last: unknown;
107
126
  for (const key of candidates) {
@@ -112,7 +131,7 @@ export class KeyPool {
112
131
  // The single-key rule: with nothing to rotate to, benching the only key
113
132
  // answers NoAvailableKeyError to every call for the next minute — for a
114
133
  // failure the caller's own retry would have absorbed.
115
- const cooldown = this.size > 1 ? cooldownFor(err) : null;
134
+ const cooldown = this.size > 1 ? cooldownFor(err, this.now()) : null;
116
135
  if (cooldown === null) throw err;
117
136
  key.evictedUntil = this.now() + cooldown.forMs;
118
137
  this.onEvict?.({ tier: key.tier, kind: cooldown.kind, forMs: cooldown.forMs });
@@ -120,8 +139,7 @@ export class KeyPool {
120
139
  }
121
140
 
122
141
  // Every candidate was evicted during this call.
123
- if (this.candidates(this.now()).length === 0)
124
- throw new NoAvailableKeyError(this.label, this.nextAvailableAt());
142
+ if (this.candidates(this.now()).length === 0) throw this.noneAvailable();
125
143
  throw last;
126
144
  }
127
145
 
@@ -138,9 +156,10 @@ export class KeyPool {
138
156
  return out;
139
157
  }
140
158
 
141
- private nextAvailableAt(): number {
159
+ private noneAvailable(): NoAvailableKeyError {
142
160
  const all = this.paid ? [...this.free, this.paid] : this.free;
143
- return Math.min(...all.map((key) => key.evictedUntil));
161
+ const retryAtMs = Math.min(...all.map((key) => key.evictedUntil));
162
+ return new NoAvailableKeyError(this.label, retryAtMs, this.now());
144
163
  }
145
164
  }
146
165
 
@@ -149,15 +168,24 @@ export class KeyPool {
149
168
  * key's fault. Nothing is gained by rotating, and benching would spend the
150
169
  * pool on a request that fails the same way everywhere.
151
170
  */
152
- function cooldownFor(err: unknown): { kind: ErrorKind; forMs: number } | null {
171
+ function cooldownFor(err: unknown, now: number): { kind: ErrorKind; forMs: number } | null {
153
172
  if (!(err instanceof ProviderError)) return null;
154
173
  const clamp = (ms: number) => Math.min(Math.max(ms, MIN_EVICTION_MS), MAX_EVICTION_MS);
155
174
  switch (err.kind) {
156
175
  // The three kinds that are about THIS key.
157
176
  case "rate":
158
177
  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) };
178
+ case "quota": {
179
+ // A spent window names its reset, and that outranks Retry-After: Anthropic
180
+ // sends `retry-after: 60` beside a weekly window, and a key benched for
181
+ // the minute is dialled again into the same wall.
182
+ const reset = err.resetAtMs;
183
+ const forMs =
184
+ reset !== undefined && Number.isFinite(reset)
185
+ ? reset - now
186
+ : (err.retryAfterMs ?? QUOTA_COOLDOWN_MS);
187
+ return { kind: err.kind, forMs: clamp(forMs) };
188
+ }
161
189
  case "auth":
162
190
  // 12 h, not forever: a key is also refused while a billing account is
163
191
  // reinstated, and a pool that drops keys permanently ends up empty.
@@ -189,6 +217,10 @@ function cooldownFor(err: unknown): { kind: ErrorKind; forMs: number } | null {
189
217
  * committed to one key, exactly as a retry is committed past its first chunk
190
218
  * (retry.ts, rule 2): a mid-stream 429 evicts nothing and rotates nothing,
191
219
  * because there is no way to resume a half-rendered answer on another key.
220
+ *
221
+ * The factory runs once per attempt. An adapter with a session header mints
222
+ * its id when it is built, so give the factory an `instanceSessionId`, or each
223
+ * attempt opens a new session on a gateway that pins sessions to one host.
192
224
  */
193
225
  export function withKeyPool(pool: KeyPool, factory: (apiKey: string) => Provider): Provider {
194
226
  // The identity every key shares. Building an adapter performs no I/O — it
package/src/presets.ts CHANGED
@@ -373,8 +373,9 @@ export const PROVIDER_PRESETS = {
373
373
  * Go serves three wires, and `routes` picks one from the model id (cc-proxy's
374
374
  * table, from Go's own docs): `minimax-` and `qwen` on Anthropic /messages
375
375
  * (key in `x-api-key`), `gpt-`, `grok-` and `muse-spark-` on /responses, and
376
- * everything else on chat completions. Each model in a chain resolves its
377
- * own route, so one preset serves a chain that crosses wires.
376
+ * everything else on chat completions, `grok-4.5` included. Each model in a
377
+ * chain resolves its own route, so one preset serves a chain that crosses
378
+ * wires.
378
379
  *
379
380
  * Muse Contributor, measured 2026-09-28 on /responses: text, images, strict
380
381
  * JSON Schema, parallel tools and tool-result round trips all passed. It
@@ -408,6 +409,15 @@ export const PROVIDER_PRESETS = {
408
409
  baseUrl: "https://opencode.ai/zen/go",
409
410
  auth: "key",
410
411
  },
412
+ {
413
+ // The one Grok Go serves on chat completions, while 4.6 and 4.7 are on
414
+ // /responses: cc-proxy's catalog pins it there as an override of the
415
+ // family rule. It sits above the `grok-` route because the first match
416
+ // wins.
417
+ prefixes: ["grok-4.5"],
418
+ shape: "openai",
419
+ baseUrl: "https://opencode.ai/zen/go/v1",
420
+ },
411
421
  {
412
422
  prefixes: ["gpt-", "grok-", "muse-spark-"],
413
423
  shape: "responses",
@@ -1,8 +1,9 @@
1
1
  // Anthropic-shape adapter — SSE from POST /v1/messages.
2
+ import { repairHistory } from "../context.ts";
2
3
  import { fileRefused, ProviderError, streamCut, streamError } from "../errors.ts";
3
4
  import { schemaPrompt, toAnthropicToolSchema } from "../schema.ts";
4
- import { parseToolArgs } from "../tool-args.ts";
5
- import { streamSse, apiUrl } from "../transport.ts";
5
+ import { parseToolArgs, settleToolCalls, type ToolCallIdentity } from "../tool-args.ts";
6
+ import { streamSse, apiUrl, parseFrame } from "../transport.ts";
6
7
  import { attributionHeaders } from "../attribution.ts";
7
8
  import { withConfiguredFallbacks, type ProviderFallbackConfig } from "../fallback.ts";
8
9
  import type {
@@ -54,6 +55,13 @@ export interface AnthropicConfig extends ProviderFallbackConfig {
54
55
  /** Header that carries `StreamOptions.sessionId`. When set and a call has no
55
56
  * sessionId, one stable id for this provider instance rides instead. */
56
57
  sessionHeader?: string;
58
+ /** The id `sessionHeader` carries when a call has no `sessionId`. Absent, the
59
+ * adapter mints one when it is built. Pass one when something rebuilds the
60
+ * adapter (`withKeyPool` and `withToken` build one per attempt): each build
61
+ * mints a new id, and a gateway that pins a session to one upstream host
62
+ * then treats every call as a new session, re-routes it and misses its
63
+ * prompt cache. Ignored without `sessionHeader`. */
64
+ instanceSessionId?: string;
57
65
  /** Say "no thinking" with an explicit `thinking: { type: "disabled" }` when
58
66
  * effort resolves to none, instead of omitting the field. For endpoints
59
67
  * where an absent field means the model's default, not off. Z.ai's coding
@@ -191,19 +199,32 @@ function continuesToolLoop(messages: readonly ChatMessage[]): boolean {
191
199
  return false;
192
200
  }
193
201
 
194
- function mapStopReason(reason: string | undefined): FinishReason | undefined {
202
+ /**
203
+ * A stated stop, or a throw for one this adapter does not know. An unknown
204
+ * reason used to end the stream as a clean success with no finish at all.
205
+ * Nothing says the answer ended on purpose, so it is `overload`: theirs, and
206
+ * worth a retry (cc-proxy answers it the same way). `pause_turn` lands there
207
+ * too: it is a server-tool turn waiting to be sent back, which this adapter
208
+ * never asks for, and a half answer is not a finished one.
209
+ */
210
+ function mapStopReason(provider: string, reason: string): FinishReason {
195
211
  switch (reason) {
196
212
  case "end_turn":
197
213
  case "stop_sequence":
198
214
  return "stop";
199
215
  case "tool_use":
200
216
  return "tool_calls";
217
+ // `model_context_window_exceeded`: the context window filled before the
218
+ // answer did. Cut short, and a retry fills it the same way.
201
219
  case "max_tokens":
220
+ case "model_context_window_exceeded":
202
221
  return "length";
203
222
  case "refusal":
204
223
  return "content_filter";
205
224
  default:
206
- return undefined;
225
+ throw new ProviderError(provider, "overload", `${provider} stopped the turn on "${reason}"`, {
226
+ code: reason,
227
+ });
207
228
  }
208
229
  }
209
230
 
@@ -270,6 +291,34 @@ function jsonBlock(json: JsonOutput): unknown {
270
291
  return { type: "text", text: schemaPrompt(json.schema) };
271
292
  }
272
293
 
294
+ /**
295
+ * Tool ids as this wire accepts them: `[A-Za-z0-9_-]` only. Kimi's
296
+ * `functions.search:0` is a 400 here, and an opencode-go rotation hands a
297
+ * history from chat completions to Messages mid-conversation, so a Kimi id
298
+ * does arrive. Each other character becomes `_`, and a result follows its call
299
+ * because both go through the same map.
300
+ *
301
+ * Collision-safe in one forward pass: an id whose cleaned form is already
302
+ * taken gets `_2`, `_3`…, judged only against ids seen before it, so an earlier
303
+ * id never changes because of a later one and the prompt cache keeps its
304
+ * prefix. A history whose ids are already clean comes out byte for byte.
305
+ */
306
+ function anthropicIds(): (id: string) => string {
307
+ const sent = new Map<string, string>();
308
+ const taken = new Set<string>();
309
+ return (id) => {
310
+ let wire = sent.get(id);
311
+ if (wire === undefined) {
312
+ const clean = id.replace(/[^A-Za-z0-9_-]/g, "_");
313
+ wire = clean;
314
+ for (let k = 2; taken.has(wire); k++) wire = `${clean}_${k}`;
315
+ sent.set(id, wire);
316
+ taken.add(wire);
317
+ }
318
+ return wire;
319
+ };
320
+ }
321
+
273
322
  export function toAnthropicMessages(
274
323
  messages: readonly ChatMessage[],
275
324
  provider = "anthropic",
@@ -277,6 +326,7 @@ export function toAnthropicMessages(
277
326
  system?: unknown[];
278
327
  messages: unknown[];
279
328
  } {
329
+ const wireId = anthropicIds();
280
330
  const system = messages
281
331
  .filter((m) => m.role === "system")
282
332
  .map((m) => m.content)
@@ -299,7 +349,7 @@ export function toAnthropicMessages(
299
349
  pushBlocks("user", [
300
350
  {
301
351
  type: "tool_result",
302
- tool_use_id: message.toolCallId,
352
+ tool_use_id: wireId(message.toolCallId),
303
353
  content: [
304
354
  { type: "text", text: message.content },
305
355
  ...(message.images ?? []).map((image) => ({
@@ -319,7 +369,7 @@ export function toAnthropicMessages(
319
369
  for (const call of message.toolCalls ?? []) {
320
370
  blocks.push({
321
371
  type: "tool_use",
322
- id: call.id,
372
+ id: wireId(call.id),
323
373
  name: call.name,
324
374
  input: parseToolArgs(call.arguments),
325
375
  });
@@ -359,7 +409,9 @@ interface AnthropicEvent {
359
409
  export function createAnthropicProvider(config: AnthropicConfig): Provider {
360
410
  const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
361
411
  const id = config.id ?? "anthropic";
362
- const instanceSessionId = config.sessionHeader ? crypto.randomUUID() : undefined;
412
+ const instanceSessionId = config.sessionHeader
413
+ ? (config.instanceSessionId ?? crypto.randomUUID())
414
+ : undefined;
363
415
 
364
416
  const provider: Provider = {
365
417
  id,
@@ -373,7 +425,10 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
373
425
  const model = opts.model ?? config.model;
374
426
  const maxTokens = opts.maxTokens ?? config.maxTokens ?? DEFAULT_MAX_TOKENS;
375
427
  const effort = opts.effort ?? config.effort;
376
- const { system, messages: body } = toAnthropicMessages(messages, id);
428
+ // Repaired first: a result with no call, a call with no result, or an id
429
+ // used twice is a 400 that loses the turn. See repairHistory.
430
+ const sent = repairHistory(messages).messages;
431
+ const { system, messages: body } = toAnthropicMessages(sent, id);
377
432
 
378
433
  // Anthropic's own `service_tier` means auto or standard_only, not this.
379
434
  if (opts.serviceTier) {
@@ -438,7 +493,7 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
438
493
  // ponytail: every loop step after the first goes unthought on these
439
494
  // models. The upgrade is replaying the signed thinking blocks.
440
495
  const unthought =
441
- !claude && model.startsWith("claude-") && (forcesTool || continuesToolLoop(messages));
496
+ !claude && model.startsWith("claude-") && (forcesTool || continuesToolLoop(sent));
442
497
  if (claude) {
443
498
  // An effort the caller never set sends nothing: the model keeps its own
444
499
  // default, which on most of these is thinking ON.
@@ -476,12 +531,12 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
476
531
  let cachedInputTokens = 0;
477
532
  let cacheWriteTokens = 0;
478
533
  let outputTokens = 0;
479
- let toolCall: { index: number; id: string; name: string } | null = null;
534
+ let toolCall: ToolCallIdentity | null = null;
535
+ let sawToolCall = false;
480
536
  let blockIndex = -1;
481
537
  // What proves the turn ended rather than the socket: a stated stop
482
538
  // reason, or `message_stop`. See the check after the loop.
483
539
  let stopped = false;
484
- let closed = false;
485
540
  const usageChunk = (): ProviderChunk => ({
486
541
  type: "usage",
487
542
  usage: {
@@ -510,13 +565,12 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
510
565
  ...(opts.signal ? { signal: opts.signal } : {}),
511
566
  ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
512
567
  ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
568
+ ...(opts.onRateLimit ? { onRateLimit: opts.onRateLimit } : {}),
569
+ // The stop reason rides in `message_delta` with the final usage, so a
570
+ // read that dies after it loses nothing but `message_stop`.
571
+ finished: () => stopped,
513
572
  })) {
514
- let event: AnthropicEvent;
515
- try {
516
- event = JSON.parse(data) as AnthropicEvent;
517
- } catch {
518
- continue; // a keep-alive or a frame we do not model
519
- }
573
+ const event = parseFrame<AnthropicEvent>(data, id);
520
574
 
521
575
  switch (event.type) {
522
576
  // A failure the backend reports after its headers went out. Classified
@@ -542,6 +596,10 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
542
596
  id: event.content_block.id ?? "",
543
597
  name: event.content_block.name ?? "",
544
598
  };
599
+ // Checked here, because this block is the only place this shape
600
+ // states a call's id and name.
601
+ settleToolCalls(id, [toolCall]);
602
+ sawToolCall = true;
545
603
  yield {
546
604
  type: "delta",
547
605
  toolCalls: [{ index: toolCall.index, id: toolCall.id, name: toolCall.name }],
@@ -577,21 +635,31 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
577
635
  cachedInputTokens = event.usage?.cache_read_input_tokens ?? cachedInputTokens;
578
636
  cacheWriteTokens = event.usage?.cache_creation_input_tokens ?? cacheWriteTokens;
579
637
  outputTokens = event.usage?.output_tokens ?? outputTokens;
580
- if (event.delta?.stop_reason) stopped = true;
581
- const finishReason = mapStopReason(event.delta?.stop_reason);
582
- if (finishReason) yield { type: "finish", finishReason };
638
+ if (event.delta?.stop_reason) {
639
+ const stated = mapStopReason(id, event.delta.stop_reason);
640
+ stopped = true;
641
+ // Anthropic-compatible gateways can end a tool turn on
642
+ // `end_turn`, and a caller reading that as done never runs the
643
+ // tool. Only `stop` is promoted: a length cut may be what broke
644
+ // the call.
645
+ const finishReason = stated === "stop" && sawToolCall ? "tool_calls" : stated;
646
+ yield { type: "finish", finishReason };
647
+ }
583
648
  break;
584
649
  }
585
650
 
651
+ // The turn's last event, so the reader stops here. Some gateways
652
+ // append a `[DONE]` or other bytes after it, and a read that died on
653
+ // those failed an answer that was already whole (Kimi, GLM and
654
+ // OpenCode Go, measured by cc-proxy).
586
655
  case "message_stop":
587
- closed = true;
588
656
  yield usageChunk();
589
- break;
657
+ return;
590
658
  }
591
659
  }
592
- if (closed) return;
593
- // A gateway that states its stop reason and then skips `message_stop`
594
- // still finished the turn — and still billed it.
660
+ // A gateway that states its stop reason and then skips `message_stop`, or
661
+ // whose socket dies after the reason, still finished the turn, and still
662
+ // billed it.
595
663
  if (stopped) {
596
664
  yield usageChunk();
597
665
  return;
@@ -46,6 +46,17 @@ export interface PresetProviderConfig extends ProviderFallbackConfig {
46
46
  /** Per-request headers (request ids, `ChatGPT-Account-Id`) on top of the
47
47
  * preset's static protocol headers. */
48
48
  headers?: Record<string, string>;
49
+ /**
50
+ * The session id for a call without `StreamOptions.sessionId`, on a preset
51
+ * that sends one (`opencode-go`, `chatgpt`). Absent, the provider mints its
52
+ * own when it is built. Pass one when you build the provider per attempt
53
+ * (`withKeyPool`, `withToken`): each build would mint a new id, and a gateway
54
+ * that pins a session to one upstream host then treats every call as a new
55
+ * session, re-routes it and misses its prompt cache. Every model in a chain
56
+ * sends the same one. A preset without a session header has nowhere to put
57
+ * it and ignores it.
58
+ */
59
+ instanceSessionId?: string;
49
60
  /**
50
61
  * App attribution for OpenRouter's rankings. The site URL rides as
51
62
  * `HTTP-Referer`, the app name as `X-Title`. Public, not secret. Sent on
@@ -69,15 +80,15 @@ export interface PresetProviderConfig extends ProviderFallbackConfig {
69
80
  fetchImpl?: typeof fetch;
70
81
  }
71
82
 
83
+ /** What one adapter for one model takes: a `PresetProviderConfig` without the
84
+ * chain (`models`) and without the layers that go around an adapter. */
85
+ export type PresetAdapterConfig = Omit<
86
+ PresetProviderConfig,
87
+ "models" | keyof ProviderFallbackConfig
88
+ >;
89
+
72
90
  export function createPresetProvider(id: ProviderPresetId, config: PresetProviderConfig): Provider {
73
- // The table is closed (`satisfies`), but a caller may hold an id as a plain
74
- // string — validate rather than index into undefined.
75
- if (!(id in PROVIDER_PRESETS)) {
76
- throw new Error(
77
- `[providerkit] Unknown provider preset "${id}". Valid ids: ${PRESET_IDS.join(", ")}`,
78
- );
79
- }
80
- const preset: ProviderPreset = PROVIDER_PRESETS[id];
91
+ const preset = presetFor(id);
81
92
  const chain = config.models?.length ? config.models : config.model ? undefined : preset.rotation;
82
93
  if (chain && chain.length > 1) {
83
94
  const { models: _models, fallbacks = [], ...single } = config;
@@ -90,7 +101,27 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
90
101
  ],
91
102
  });
92
103
  }
93
- const model = chain?.[0] ?? config.model ?? preset.defaultModel;
104
+ const { models: _models, ...single } = config;
105
+ return withConfiguredFallbacks(
106
+ presetAdapter(id, { ...single, model: chain?.[0] ?? config.model }),
107
+ config,
108
+ );
109
+ }
110
+
111
+ /**
112
+ * The bare adapter for one preset and one model: the wire that serves the
113
+ * model, its auth style and the preset's quirks, and nothing around them. No
114
+ * fallbacks, no watchdog, no `models` chain. A registry builds one per
115
+ * (connection, model) and puts those layers around all of them once; calling
116
+ * `createPresetProvider` per model would nest a second pool and a second
117
+ * watchdog inside its own.
118
+ *
119
+ * The adapter's `id` is the preset id, because the OpenAI shape reads its
120
+ * effort dialect from it (`opencode-go`, `openrouter`, `deepseek`).
121
+ */
122
+ export function presetAdapter(id: ProviderPresetId, config: PresetAdapterConfig): Provider {
123
+ const preset = presetFor(id);
124
+ const model = config.model ?? preset.defaultModel;
94
125
  if (!model) {
95
126
  throw new Error(`[providerkit] Preset "${id}" has no defaultModel. Pass a model.`);
96
127
  }
@@ -104,14 +135,16 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
104
135
  const headers = { ...preset.headers, ...config.headers };
105
136
  const baseUrl = config.baseUrl ?? wire.baseUrl;
106
137
  const maxTokens = config.maxTokens ?? preset.maxTokens;
107
-
108
- const {
109
- fallbacks: _fallbacks,
110
- fallbackOptions: _fallbackOptions,
111
- watchdog: _watchdog,
112
- models: _models,
113
- ...baseConfig
114
- } = config;
138
+ // Every adapter builds its own pool and watchdog from these. The type leaves
139
+ // them out, but a wider object (a whole `PresetProviderConfig` in a variable)
140
+ // can still carry them, and the adapter would nest a second pool and a
141
+ // second watchdog inside the caller's. Cleared, it stays bare.
142
+ const baseConfig = {
143
+ ...config,
144
+ fallbacks: undefined,
145
+ fallbackOptions: undefined,
146
+ watchdog: undefined,
147
+ };
115
148
 
116
149
  let provider: Provider;
117
150
  switch (wire.shape) {
@@ -184,10 +217,22 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
184
217
  break;
185
218
  }
186
219
 
187
- return withConfiguredFallbacks(
188
- preset.routes ? refuseOtherWires(provider, preset, wire) : provider,
189
- config,
190
- );
220
+ // Here rather than in `createPresetProvider`: the wire is resolved here, and
221
+ // the bare adapter is exported, so it must refuse on its own a per-call model
222
+ // it would post to the wrong endpoint. That 404 reads as an unknown model,
223
+ // and a pool benches the model for an hour.
224
+ return preset.routes ? refuseOtherWires(provider, preset, wire) : provider;
225
+ }
226
+
227
+ /** The table is closed (`satisfies`), but a caller may hold an id as a plain
228
+ * string. Validate rather than index into undefined. */
229
+ function presetFor(id: ProviderPresetId): ProviderPreset {
230
+ if (!(id in PROVIDER_PRESETS)) {
231
+ throw new Error(
232
+ `[providerkit] Unknown provider preset "${id}". Valid ids: ${PRESET_IDS.join(", ")}`,
233
+ );
234
+ }
235
+ return PROVIDER_PRESETS[id];
191
236
  }
192
237
 
193
238
  /** The endpoint a model is served from: its route when the preset has one for