@providerkit/core 0.21.1 → 0.23.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 (137) hide show
  1. package/README.md +7 -7
  2. package/dist/attribution.d.ts +2 -2
  3. package/dist/auth/flows.d.ts.map +1 -1
  4. package/dist/auth/flows.js +5 -5
  5. package/dist/auth/flows.js.map +1 -1
  6. package/dist/auth/index.d.ts +1 -0
  7. package/dist/auth/index.d.ts.map +1 -1
  8. package/dist/auth/index.js +1 -0
  9. package/dist/auth/index.js.map +1 -1
  10. package/dist/auth/oauth.d.ts +12 -0
  11. package/dist/auth/oauth.d.ts.map +1 -1
  12. package/dist/auth/oauth.js +19 -1
  13. package/dist/auth/oauth.js.map +1 -1
  14. package/dist/auth/token-source.d.ts +9 -3
  15. package/dist/auth/token-source.d.ts.map +1 -1
  16. package/dist/auth/token-source.js +24 -9
  17. package/dist/auth/token-source.js.map +1 -1
  18. package/dist/auth/with-token.d.ts +29 -0
  19. package/dist/auth/with-token.d.ts.map +1 -0
  20. package/dist/auth/with-token.js +85 -0
  21. package/dist/auth/with-token.js.map +1 -0
  22. package/dist/capability.d.ts +9 -9
  23. package/dist/capability.js +9 -9
  24. package/dist/capability.js.map +1 -1
  25. package/dist/context.d.ts +43 -5
  26. package/dist/context.d.ts.map +1 -1
  27. package/dist/context.js +123 -4
  28. package/dist/context.js.map +1 -1
  29. package/dist/errors.d.ts +26 -17
  30. package/dist/errors.d.ts.map +1 -1
  31. package/dist/errors.js +57 -18
  32. package/dist/errors.js.map +1 -1
  33. package/dist/fallback.d.ts +45 -1
  34. package/dist/fallback.d.ts.map +1 -1
  35. package/dist/fallback.js +138 -75
  36. package/dist/fallback.js.map +1 -1
  37. package/dist/image.d.ts +1 -1
  38. package/dist/image.d.ts.map +1 -1
  39. package/dist/jev.d.ts +9 -9
  40. package/dist/jev.d.ts.map +1 -1
  41. package/dist/jev.js +1 -1
  42. package/dist/key-pool.d.ts +14 -8
  43. package/dist/key-pool.d.ts.map +1 -1
  44. package/dist/key-pool.js +35 -18
  45. package/dist/key-pool.js.map +1 -1
  46. package/dist/presets.d.ts +33 -24
  47. package/dist/presets.d.ts.map +1 -1
  48. package/dist/presets.js +25 -14
  49. package/dist/presets.js.map +1 -1
  50. package/dist/providers/anthropic.d.ts +5 -5
  51. package/dist/providers/anthropic.d.ts.map +1 -1
  52. package/dist/providers/anthropic.js +89 -34
  53. package/dist/providers/anthropic.js.map +1 -1
  54. package/dist/providers/factory.d.ts +2 -2
  55. package/dist/providers/factory.d.ts.map +1 -1
  56. package/dist/providers/factory.js +2 -1
  57. package/dist/providers/factory.js.map +1 -1
  58. package/dist/providers/gemini.d.ts +8 -2
  59. package/dist/providers/gemini.d.ts.map +1 -1
  60. package/dist/providers/gemini.js +117 -41
  61. package/dist/providers/gemini.js.map +1 -1
  62. package/dist/providers/openai.d.ts +12 -9
  63. package/dist/providers/openai.d.ts.map +1 -1
  64. package/dist/providers/openai.js +72 -21
  65. package/dist/providers/openai.js.map +1 -1
  66. package/dist/providers/responses.d.ts +3 -3
  67. package/dist/providers/responses.d.ts.map +1 -1
  68. package/dist/providers/responses.js +103 -57
  69. package/dist/providers/responses.js.map +1 -1
  70. package/dist/providers/zai.d.ts +2 -2
  71. package/dist/providers/zai.js +2 -2
  72. package/dist/rate-limit.d.ts +13 -3
  73. package/dist/rate-limit.d.ts.map +1 -1
  74. package/dist/rate-limit.js +10 -4
  75. package/dist/rate-limit.js.map +1 -1
  76. package/dist/retry.d.ts +16 -5
  77. package/dist/retry.d.ts.map +1 -1
  78. package/dist/retry.js +29 -16
  79. package/dist/retry.js.map +1 -1
  80. package/dist/schema.d.ts +6 -6
  81. package/dist/schema.js +8 -8
  82. package/dist/tool-args.d.ts +23 -1
  83. package/dist/tool-args.d.ts.map +1 -1
  84. package/dist/tool-args.js +30 -2
  85. package/dist/tool-args.js.map +1 -1
  86. package/dist/tools.d.ts +3 -3
  87. package/dist/tools.js +2 -2
  88. package/dist/tools.js.map +1 -1
  89. package/dist/transport.d.ts +40 -10
  90. package/dist/transport.d.ts.map +1 -1
  91. package/dist/transport.js +82 -21
  92. package/dist/transport.js.map +1 -1
  93. package/dist/types.d.ts +37 -23
  94. package/dist/types.d.ts.map +1 -1
  95. package/dist/types.js +3 -11
  96. package/dist/types.js.map +1 -1
  97. package/dist/usage.d.ts +4 -4
  98. package/dist/usage.d.ts.map +1 -1
  99. package/dist/usage.js +3 -3
  100. package/dist/usage.js.map +1 -1
  101. package/dist/watchdog.d.ts +14 -12
  102. package/dist/watchdog.d.ts.map +1 -1
  103. package/dist/watchdog.js +35 -16
  104. package/dist/watchdog.js.map +1 -1
  105. package/dist/zod.d.ts +4 -4
  106. package/dist/zod.js +3 -3
  107. package/package.json +1 -1
  108. package/src/attribution.ts +2 -2
  109. package/src/auth/flows.ts +5 -4
  110. package/src/auth/index.ts +1 -0
  111. package/src/auth/oauth.ts +23 -1
  112. package/src/auth/token-source.ts +30 -11
  113. package/src/auth/with-token.ts +97 -0
  114. package/src/capability.ts +12 -12
  115. package/src/context.ts +141 -6
  116. package/src/errors.ts +63 -23
  117. package/src/fallback.ts +173 -75
  118. package/src/image.ts +1 -1
  119. package/src/jev.ts +11 -11
  120. package/src/key-pool.ts +37 -20
  121. package/src/presets.ts +38 -24
  122. package/src/providers/anthropic.ts +97 -38
  123. package/src/providers/factory.ts +4 -3
  124. package/src/providers/gemini.ts +121 -44
  125. package/src/providers/openai.ts +78 -25
  126. package/src/providers/responses.ts +113 -61
  127. package/src/providers/zai.ts +2 -2
  128. package/src/rate-limit.ts +35 -7
  129. package/src/retry.ts +46 -15
  130. package/src/schema.ts +8 -8
  131. package/src/tool-args.ts +46 -2
  132. package/src/tools.ts +4 -4
  133. package/src/transport.ts +103 -23
  134. package/src/types.ts +37 -23
  135. package/src/usage.ts +5 -5
  136. package/src/watchdog.ts +38 -19
  137. package/src/zod.ts +4 -4
package/src/key-pool.ts CHANGED
@@ -25,10 +25,10 @@ import type {
25
25
 
26
26
  /** A per-minute throttle: the window it names is the next one. */
27
27
  const RATE_COOLDOWN_MS = 60_000;
28
- /** A balance or a daily window — minutes will not bring it back. */
28
+ /** A balance or a daily window. Minutes will not bring it back. */
29
29
  const QUOTA_COOLDOWN_MS = 60 * 60_000;
30
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
31
+ /** The vendor's bad time, not the key's. Benched only long enough for the
32
32
  * next call to land somewhere else. */
33
33
  const TRANSIENT_COOLDOWN_MS = 60_000;
34
34
  const MIN_EVICTION_MS = 1_000;
@@ -47,7 +47,7 @@ export interface KeyPoolOptions {
47
47
  /** Free-tier keys, walked round-robin before the paid one. */
48
48
  keys: readonly string[];
49
49
  paidKey?: string | null;
50
- /** Fires whenever a key is benched — the pool's only report, in place of a
50
+ /** Fires whenever a key is benched. The pool's only report, in place of a
51
51
  * logger a zero-dependency package has no business owning. */
52
52
  onEvict?: (info: { tier: KeyTier; kind: ErrorKind; forMs: number }) => void;
53
53
  /** Injected in tests, so an expiry can be exercised without waiting out a
@@ -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
  }
@@ -96,12 +104,12 @@ export class KeyPool {
96
104
  /**
97
105
  * Run `fn` with the next available key, rotating on key-specific and
98
106
  * transient failures. Everything else (a bad request, a content block) throws
99
- * straight through — it would fail identically on every key, and spending the
107
+ * straight through: it would fail identically on every key, and spending the
100
108
  * pool on it only turns one bad request into an outage.
101
109
  */
102
110
  async with<T>(fn: (apiKey: string, tier: KeyTier) => Promise<T>): Promise<T> {
103
111
  const candidates = this.candidates(this.now());
104
- if (candidates.length === 0) throw new NoAvailableKeyError(this.label, this.nextAvailableAt());
112
+ if (candidates.length === 0) throw this.noneAvailable();
105
113
 
106
114
  let last: unknown;
107
115
  for (const key of candidates) {
@@ -112,7 +120,7 @@ export class KeyPool {
112
120
  // The single-key rule: with nothing to rotate to, benching the only key
113
121
  // answers NoAvailableKeyError to every call for the next minute — for a
114
122
  // failure the caller's own retry would have absorbed.
115
- const cooldown = this.size > 1 ? cooldownFor(err) : null;
123
+ const cooldown = this.size > 1 ? cooldownFor(err, this.now()) : null;
116
124
  if (cooldown === null) throw err;
117
125
  key.evictedUntil = this.now() + cooldown.forMs;
118
126
  this.onEvict?.({ tier: key.tier, kind: cooldown.kind, forMs: cooldown.forMs });
@@ -120,8 +128,7 @@ export class KeyPool {
120
128
  }
121
129
 
122
130
  // Every candidate was evicted during this call.
123
- if (this.candidates(this.now()).length === 0)
124
- throw new NoAvailableKeyError(this.label, this.nextAvailableAt());
131
+ if (this.candidates(this.now()).length === 0) throw this.noneAvailable();
125
132
  throw last;
126
133
  }
127
134
 
@@ -138,26 +145,36 @@ export class KeyPool {
138
145
  return out;
139
146
  }
140
147
 
141
- private nextAvailableAt(): number {
148
+ private noneAvailable(): NoAvailableKeyError {
142
149
  const all = this.paid ? [...this.free, this.paid] : this.free;
143
- return Math.min(...all.map((key) => key.evictedUntil));
150
+ const retryAtMs = Math.min(...all.map((key) => key.evictedUntil));
151
+ return new NoAvailableKeyError(this.label, retryAtMs, this.now());
144
152
  }
145
153
  }
146
154
 
147
155
  /**
148
156
  * 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
157
+ * key's fault. Nothing is gained by rotating, and benching would spend the
150
158
  * pool on a request that fails the same way everywhere.
151
159
  */
152
- function cooldownFor(err: unknown): { kind: ErrorKind; forMs: number } | null {
160
+ function cooldownFor(err: unknown, now: number): { kind: ErrorKind; forMs: number } | null {
153
161
  if (!(err instanceof ProviderError)) return null;
154
162
  const clamp = (ms: number) => Math.min(Math.max(ms, MIN_EVICTION_MS), MAX_EVICTION_MS);
155
163
  switch (err.kind) {
156
164
  // The three kinds that are about THIS key.
157
165
  case "rate":
158
166
  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) };
167
+ case "quota": {
168
+ // A spent window names its reset, and that outranks Retry-After: Anthropic
169
+ // sends `retry-after: 60` beside a weekly window, and a key benched for
170
+ // the minute is dialled again into the same wall.
171
+ const reset = err.resetAtMs;
172
+ const forMs =
173
+ reset !== undefined && Number.isFinite(reset)
174
+ ? reset - now
175
+ : (err.retryAfterMs ?? QUOTA_COOLDOWN_MS);
176
+ return { kind: err.kind, forMs: clamp(forMs) };
177
+ }
161
178
  case "auth":
162
179
  // 12 h, not forever: a key is also refused while a billing account is
163
180
  // reinstated, and a pool that drops keys permanently ends up empty.
@@ -179,8 +196,8 @@ function cooldownFor(err: unknown): { kind: ErrorKind; forMs: number } | null {
179
196
  * Give any provider a rotating pool of keys.
180
197
  *
181
198
  * 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
199
+ * calling it performs no I/O. The POST and the 429 that should rotate the key
200
+ * happen on the first `next()`, long after a `pool.with` wrapped around the
184
201
  * call itself would have returned, with nothing left to rotate. So the first
185
202
  * chunk is pulled INSIDE the pool and only the rest of the stream is consumed
186
203
  * outside it.
package/src/presets.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Provider presets — hand-curated, wire-level facts only.
2
+ * Provider presets: hand-curated, wire-level facts only.
3
3
  *
4
4
  * A preset is everything `createPresetProvider` needs to speak to one
5
5
  * endpoint: which adapter shape it speaks, its API root, how the credential
6
- * rides, and what it is called. Deliberately absent: prices (invariant 9 —
6
+ * rides, and what it is called. Deliberately absent: prices (invariant 9;
7
7
  * they drift on a vendor's schedule and a wrong number in a library is a
8
8
  * wrong number in everyone's ledger), icons, colors, marketing names, and
9
9
  * sign-in flows (an `oauth` preset expects the CALLER to hand over an access
@@ -11,12 +11,12 @@
11
11
  *
12
12
  * Sources: the coding-plan endpoints and auth quirks are measured
13
13
  * (tabrunner's extension + this repo's adopters, 2026-09); the model lists
14
- * are representative, not exhaustive — every adapter takes a per-call model,
14
+ * are representative, not exhaustive. Every adapter takes a per-call model,
15
15
  * so a missing id costs nothing.
16
16
  *
17
17
  * Azure is deliberately ABSENT: its base URL is per-deployment
18
- * (`https://<resource>.openai.azure.com`), so no static preset can carry it —
19
- * point createOpenAIProvider's baseUrl at your resource instead.
18
+ * (`https://<resource>.openai.azure.com`), so no static preset can carry it.
19
+ * Point createOpenAIProvider's baseUrl at your resource instead.
20
20
  */
21
21
  import type { ImageLimits } from "./image.ts";
22
22
 
@@ -27,7 +27,7 @@ export type PresetShape = "anthropic" | "openai" | "responses" | "gemini";
27
27
  * - `key`: the vendor's native key header (Anthropic reads `x-api-key`,
28
28
  * Gemini `x-goog-api-key`); the OpenAI shapes have only Bearer, so they
29
29
  * never use this.
30
- * - `bearer`: `Authorization: Bearer` — the OpenAI shapes' only mode, and the
30
+ * - `bearer`: `Authorization: Bearer`, the OpenAI shapes' only mode, and the
31
31
  * coding-plan gateways' mode on the Anthropic wire (measured: Z.ai's coding
32
32
  * endpoint ignores `x-api-key` and reads Bearer).
33
33
  * - `oauth`: same wire as `bearer`, but the token is a short-lived access
@@ -54,7 +54,7 @@ export interface PresetRoute {
54
54
  export interface ProviderPreset {
55
55
  /** Adapter wire format. */
56
56
  shape: PresetShape;
57
- /** API root — if it ends with /v1, /v4 or another version segment, the
57
+ /** API root. If it ends with /v1, /v4 or another version segment, the
58
58
  * adapter automatically routes to /chat/completions instead of /v1/chat/completions. */
59
59
  baseUrl: string;
60
60
  /** Explicit path override when a gateway has custom routing. */
@@ -71,8 +71,8 @@ export interface ProviderPreset {
71
71
  textOnly?: boolean;
72
72
  /** Anthropic-shape dialect: say "no thinking" with an explicit
73
73
  * `thinking: { type: "disabled" }` marker when effort is none. For
74
- * endpoints where an ABSENT field means the model's default — thinking ON
75
- * for reasoning-mandatory models (measured on Z.ai's coding endpoint:
74
+ * endpoints where an absent field means the model's default, with thinking
75
+ * on for reasoning-mandatory models (measured on Z.ai's coding endpoint:
76
76
  * omit → thinking block; disabled → none). Leave it unset for native
77
77
  * Anthropic: the adapter spells none per Claude model, and ignores this
78
78
  * flag for every Claude from 4.6 on. */
@@ -82,7 +82,7 @@ export interface ProviderPreset {
82
82
  * neither text nor a tool call. */
83
83
  maxTokens?: number;
84
84
  /** Header that carries `StreamOptions.sessionId`, for gateways that route
85
- * and cache per conversation — and refuse a call without it (OpenCode Go:
85
+ * and cache per conversation and refuse a call without it (OpenCode Go:
86
86
  * `MissingSessionID`, measured 2026-09-27). */
87
87
  sessionHeader?: string;
88
88
  /** Wires for model ids that don't use the preset's own `shape`. The first
@@ -103,6 +103,9 @@ export interface ProviderPreset {
103
103
  /** OpenAI shape: send `X-Initiator` (`user` or `agent`) on every call. GitHub
104
104
  * Copilot bills a premium request per `user` turn. */
105
105
  initiatorHeader?: boolean;
106
+ /** OpenAI shape: never send `temperature` or `top_p`, for an endpoint that
107
+ * fixes its own sampling and answers the fields with an error. */
108
+ noSampling?: boolean;
106
109
  }
107
110
 
108
111
  export const PROVIDER_PRESETS = {
@@ -130,12 +133,13 @@ export const PROVIDER_PRESETS = {
130
133
  defaultModel: "gemini-3.1-pro-preview",
131
134
  models: ["gemini-3.1-pro-preview", "gemini-3.8-flash", "gemini-flash-latest"],
132
135
  },
133
- /** Gemini behind its OpenAI-compatible route — the drop-in for codebases
136
+ /** Gemini behind its OpenAI-compatible route, for codebases
134
137
  * that only speak the openai shape. */
135
138
  "google-openai": {
136
139
  shape: "openai",
137
140
  baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
138
141
  auth: "key",
142
+ noSampling: true,
139
143
  defaultModel: "gemini-3.1-pro-preview",
140
144
  models: ["gemini-3.1-pro-preview", "gemini-3.8-flash", "gemini-flash-latest"],
141
145
  },
@@ -357,7 +361,7 @@ export const PROVIDER_PRESETS = {
357
361
  models: ["glm-5.3-flash", "glm-5.3", "glm-5.2"],
358
362
  },
359
363
  /**
360
- * OpenCode Go — one $10/month key across many open models, each with its own
364
+ * OpenCode Go: one $10/month key across many open models, each with its own
361
365
  * monthly dollar limit (5h = 20%, week = 50%). Chain several models as
362
366
  * `fallbacks` so a spent one hands over to the next.
363
367
  *
@@ -369,8 +373,9 @@ export const PROVIDER_PRESETS = {
369
373
  * Go serves three wires, and `routes` picks one from the model id (cc-proxy's
370
374
  * table, from Go's own docs): `minimax-` and `qwen` on Anthropic /messages
371
375
  * (key in `x-api-key`), `gpt-`, `grok-` and `muse-spark-` on /responses, and
372
- * everything else on chat completions. Each model in a chain resolves its
373
- * 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.
374
379
  *
375
380
  * Muse Contributor, measured 2026-09-28 on /responses: text, images, strict
376
381
  * JSON Schema, parallel tools and tool-result round trips all passed. It
@@ -404,6 +409,15 @@ export const PROVIDER_PRESETS = {
404
409
  baseUrl: "https://opencode.ai/zen/go",
405
410
  auth: "key",
406
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
+ },
407
421
  {
408
422
  prefixes: ["gpt-", "grok-", "muse-spark-"],
409
423
  shape: "responses",
@@ -431,7 +445,7 @@ export const PROVIDER_PRESETS = {
431
445
  defaultModel: "kimi-k2.7-code",
432
446
  models: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k3"],
433
447
  },
434
- /** Alibaba's dashscope coding plan — OpenAI-compatible per its registry. */
448
+ /** Alibaba's DashScope coding plan, OpenAI-compatible per its registry. */
435
449
  "alibaba-coding-plan": {
436
450
  shape: "openai",
437
451
  baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
@@ -439,7 +453,7 @@ export const PROVIDER_PRESETS = {
439
453
  defaultModel: "qwen3.5-plus",
440
454
  models: ["qwen3.5-plus", "qwen3-coder-next", "glm-5"],
441
455
  },
442
- /** QwenCloud's token plan — Anthropic wire (measured by tabrunner). */
456
+ /** QwenCloud's token plan on the Anthropic wire (measured by tabrunner). */
443
457
  qwen: {
444
458
  shape: "anthropic",
445
459
  baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic",
@@ -447,7 +461,7 @@ export const PROVIDER_PRESETS = {
447
461
  defaultModel: "qwen3.8-max",
448
462
  models: ["qwen3.8-max", "qwen3.8-flash", "qwen3.6-flash"],
449
463
  },
450
- /** QwenCloud's token plan — OpenAI-compatible endpoint. */
464
+ /** QwenCloud's token plan on an OpenAI-compatible endpoint. */
451
465
  "qwen-token-plan-openai": {
452
466
  shape: "openai",
453
467
  baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
@@ -455,7 +469,7 @@ export const PROVIDER_PRESETS = {
455
469
  defaultModel: "qwen3.8-max",
456
470
  models: ["qwen3.8-max", "qwen3.8-flash", "qwen3.6-flash"],
457
471
  },
458
- /** Alibaba DashScope Platform API — standard pay-as-you-go API key. */
472
+ /** Alibaba DashScope Platform API, with a standard pay-as-you-go API key. */
459
473
  alibaba: {
460
474
  shape: "openai",
461
475
  baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
@@ -501,8 +515,8 @@ export const PROVIDER_PRESETS = {
501
515
  headers: { "anthropic-beta": "claude-code-20250219,oauth-2025-04-20" },
502
516
  },
503
517
  /**
504
- * The Codex backend behind a ChatGPT sign-in — Responses wire, no public
505
- * model list, so these ids are the list.
518
+ * The Codex backend behind a ChatGPT sign-in, on the Responses wire. It has no
519
+ * public model list, so these ids are the list.
506
520
  *
507
521
  * The endpoint is `/backend-api/codex/responses`, with no version segment.
508
522
  * Without the `path` the adapter appends `/v1/responses`, the POST 404s, and
@@ -540,8 +554,8 @@ export const PROVIDER_PRESETS = {
540
554
  replayReasoning: true,
541
555
  },
542
556
  /**
543
- * A Grok subscription (SuperGrok, X Premium) — the backend the Grok CLI
544
- * talks to, Responses wire. Distinct from `xai`, which is the keyed API and
557
+ * A Grok subscription (SuperGrok, X Premium), on the backend the Grok CLI
558
+ * talks to, using the Responses wire. Distinct from `xai`, which is the keyed API and
545
559
  * bills per token.
546
560
  *
547
561
  * The two `x-…` headers are what switch the backend into CLI-token mode;
@@ -568,7 +582,7 @@ export const PROVIDER_PRESETS = {
568
582
  imageLimits: { minSide: 8, minArea: 512, maxDecodedBytes: 5 * 1024 * 1024, maxImages: 4 },
569
583
  },
570
584
  /** Kimi's coding endpoint reached with a subscription token instead of a
571
- * key — Kimi bills the two separately. */
585
+ * key. Kimi bills the two separately. */
572
586
  "kimi-plan": {
573
587
  shape: "anthropic",
574
588
  baseUrl: "https://api.kimi.ai/coding",
@@ -607,5 +621,5 @@ export const PROVIDER_PRESETS = {
607
621
 
608
622
  export type ProviderPresetId = keyof typeof PROVIDER_PRESETS;
609
623
 
610
- /** Every valid preset id — the factory's unknown-id error lists these. */
624
+ /** Every valid preset id. The factory's unknown-id error lists these. */
611
625
  export const PRESET_IDS = Object.keys(PROVIDER_PRESETS) as ProviderPresetId[];
@@ -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 {
@@ -21,7 +22,7 @@ import type {
21
22
  export interface AnthropicConfig extends ProviderFallbackConfig {
22
23
  apiKey: string;
23
24
  model: string;
24
- /** Any endpoint speaking the Anthropic Messages dialect — a proxy or gateway.
25
+ /** Any endpoint speaking the Anthropic Messages dialect, including a proxy or gateway.
25
26
  * Defaults to Anthropic itself. */
26
27
  baseUrl?: string;
27
28
  /** Names the provider in errors and logs. The subscription backend is the
@@ -38,7 +39,7 @@ export interface AnthropicConfig extends ProviderFallbackConfig {
38
39
  * headers, and a gateway in front usually wants one of its own. */
39
40
  headers?: Record<string, string>;
40
41
  /**
41
- * App attribution for OpenRouter's rankings — the site URL rides as
42
+ * App attribution for OpenRouter's rankings. The site URL rides as
42
43
  * `HTTP-Referer`, the app name as `X-Title`. Public, not secret. Sent
43
44
  * whenever set (other vendors ignore unknown headers); an explicit entry
44
45
  * in `headers` always wins. Covers OpenRouter's Anthropic-dialect endpoint.
@@ -48,15 +49,15 @@ export interface AnthropicConfig extends ProviderFallbackConfig {
48
49
  * App name for OpenRouter's rankings, sent as `X-Title`. See `siteUrl`.
49
50
  */
50
51
  siteName?: string;
51
- /** Send the key as a Bearer instead of `x-api-key` — what a subscription
52
- * access token needs. */
52
+ /** Send the key as a Bearer instead of `x-api-key`, as a subscription
53
+ * access token requires. */
53
54
  bearer?: boolean;
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;
57
58
  /** Say "no thinking" with an explicit `thinking: { type: "disabled" }` when
58
59
  * effort resolves to none, instead of omitting the field. For endpoints
59
- * where an absent field means the MODEL's default, not off — Z.ai's coding
60
+ * where an absent field means the model's default, not off. Z.ai's coding
60
61
  * endpoint reads silence as thinking ON for reasoning-mandatory models like
61
62
  * GLM 5.3 Flash (measured 2026-09-13: omit → thinking block; disabled →
62
63
  * none), and it accepts the marker natively. Ignored for a Claude id that
@@ -72,9 +73,9 @@ const DEFAULT_VERSION = "2023-06-01";
72
73
  const DEFAULT_MAX_TOKENS = 8_192;
73
74
 
74
75
  /**
75
- * Thinking budgets, in output tokens, for extended thinking — Claude 4.5 and
76
+ * Thinking budgets, in output tokens, for extended thinking on Claude 4.5 and
76
77
  * older, and every non-Claude endpoint on this wire. Thinking and the answer
77
- * SHARE `max_tokens`, so a budget is always left below the ceiling — a budget
78
+ * share `max_tokens`, so a budget is always left below the ceiling. A budget
78
79
  * at or above it leaves no room to answer, and the turn ends mid-thought.
79
80
  */
80
81
  const THINKING_BUDGET: Record<Exclude<Effort, "none">, number> = {
@@ -102,8 +103,8 @@ const LATEST: ClaudeThinking = { ...ALWAYS_ON, forcedTool: false };
102
103
 
103
104
  /**
104
105
  * Claude models that take adaptive thinking, keyed by id. A graded effort is
105
- * said the same way on all of them — `thinking: { type: "adaptive" }` plus
106
- * `output_config.effort` — which every one accepts, and which the docs say to
106
+ * said the same way on all of them: `thinking: { type: "adaptive" }` plus
107
+ * `output_config.effort`, which every one accepts, and which the docs say to
107
108
  * use wherever a model also takes `budget_tokens`. What differs is `none`,
108
109
  * because what each model does when told nothing differs. Documented (Anthropic per-model table, read
109
110
  * 2026-09-27: platform.claude.com/docs/en/build-with-claude/thinking-troubleshooting);
@@ -191,19 +192,32 @@ function continuesToolLoop(messages: readonly ChatMessage[]): boolean {
191
192
  return false;
192
193
  }
193
194
 
194
- function mapStopReason(reason: string | undefined): FinishReason | undefined {
195
+ /**
196
+ * A stated stop, or a throw for one this adapter does not know. An unknown
197
+ * reason used to end the stream as a clean success with no finish at all.
198
+ * Nothing says the answer ended on purpose, so it is `overload`: theirs, and
199
+ * worth a retry (cc-proxy answers it the same way). `pause_turn` lands there
200
+ * too: it is a server-tool turn waiting to be sent back, which this adapter
201
+ * never asks for, and a half answer is not a finished one.
202
+ */
203
+ function mapStopReason(provider: string, reason: string): FinishReason {
195
204
  switch (reason) {
196
205
  case "end_turn":
197
206
  case "stop_sequence":
198
207
  return "stop";
199
208
  case "tool_use":
200
209
  return "tool_calls";
210
+ // `model_context_window_exceeded`: the context window filled before the
211
+ // answer did. Cut short, and a retry fills it the same way.
201
212
  case "max_tokens":
213
+ case "model_context_window_exceeded":
202
214
  return "length";
203
215
  case "refusal":
204
216
  return "content_filter";
205
217
  default:
206
- return undefined;
218
+ throw new ProviderError(provider, "overload", `${provider} stopped the turn on "${reason}"`, {
219
+ code: reason,
220
+ });
207
221
  }
208
222
  }
209
223
 
@@ -233,13 +247,13 @@ function partsToAnthropic(content: string | ContentPart[], provider: string): un
233
247
 
234
248
  /**
235
249
  * Anthropic takes `system` at the top level and expects tool RESULTS as user
236
- * turns carrying `tool_result` blocks — not as a role of their own. Consecutive
250
+ * turns carrying `tool_result` blocks, not as a role of their own. Consecutive
237
251
  * tool results are merged into one user turn, which the API requires.
238
252
  */
239
253
  /**
240
254
  * The system prompt as ONE cached block.
241
255
  *
242
- * Anthropic's prompt caching is opt-in PER BLOCK — a plain string system prompt
256
+ * Anthropic's prompt caching is opt-in per block. A plain string system prompt
243
257
  * is never cached, however many times it is re-sent. An agent loop re-sends this
244
258
  * every single turn, and it is the largest stable prefix in the request, so
245
259
  * without the breakpoint the whole thing bills at the full input rate on every
@@ -247,14 +261,14 @@ function partsToAnthropic(content: string | ContentPart[], provider: string): un
247
261
  *
248
262
  * Unconditional. Below the model's minimum cacheable length the field is
249
263
  * ignored rather than rejected, and above it the one-time 1.25× write is repaid
250
- * by the second turn — which, in the loop this package sits under, always comes.
264
+ * by the second turn, which always comes in the loop this package sits under.
251
265
  */
252
266
  function systemBlocks(text: string): unknown[] | undefined {
253
267
  return text ? [{ type: "text", text, cache_control: { type: "ephemeral" } }] : undefined;
254
268
  }
255
269
 
256
270
  /**
257
- * The schema, as an extra system block — Anthropic has no native schema mode,
271
+ * The schema, as an extra system block. Anthropic has no native schema mode,
258
272
  * and the seam promises that a provider without one gets the schema in the
259
273
  * prompt instead. Without this an `opts.json` request went out carrying
260
274
  * nothing at all: the model answered in prose, the caller's `JSON.parse` threw,
@@ -263,13 +277,41 @@ function systemBlocks(text: string): unknown[] | undefined {
263
277
  * It rides AFTER the cached block, and that order is load-bearing. A cache
264
278
  * breakpoint caches everything before it, so folding a per-call schema into the
265
279
  * cached block would change the cached prefix on every turn whose schema
266
- * differs and throw the whole system prompt's cache away — paying for the
280
+ * differs and throw the whole system prompt's cache away, paying for the
267
281
  * schema with the most expensive thing in an agent loop.
268
282
  */
269
283
  function jsonBlock(json: JsonOutput): unknown {
270
284
  return { type: "text", text: schemaPrompt(json.schema) };
271
285
  }
272
286
 
287
+ /**
288
+ * Tool ids as this wire accepts them: `[A-Za-z0-9_-]` only. Kimi's
289
+ * `functions.search:0` is a 400 here, and an opencode-go rotation hands a
290
+ * history from chat completions to Messages mid-conversation, so a Kimi id
291
+ * does arrive. Each other character becomes `_`, and a result follows its call
292
+ * because both go through the same map.
293
+ *
294
+ * Collision-safe in one forward pass: an id whose cleaned form is already
295
+ * taken gets `_2`, `_3`…, judged only against ids seen before it, so an earlier
296
+ * id never changes because of a later one and the prompt cache keeps its
297
+ * prefix. A history whose ids are already clean comes out byte for byte.
298
+ */
299
+ function anthropicIds(): (id: string) => string {
300
+ const sent = new Map<string, string>();
301
+ const taken = new Set<string>();
302
+ return (id) => {
303
+ let wire = sent.get(id);
304
+ if (wire === undefined) {
305
+ const clean = id.replace(/[^A-Za-z0-9_-]/g, "_");
306
+ wire = clean;
307
+ for (let k = 2; taken.has(wire); k++) wire = `${clean}_${k}`;
308
+ sent.set(id, wire);
309
+ taken.add(wire);
310
+ }
311
+ return wire;
312
+ };
313
+ }
314
+
273
315
  export function toAnthropicMessages(
274
316
  messages: readonly ChatMessage[],
275
317
  provider = "anthropic",
@@ -277,6 +319,7 @@ export function toAnthropicMessages(
277
319
  system?: unknown[];
278
320
  messages: unknown[];
279
321
  } {
322
+ const wireId = anthropicIds();
280
323
  const system = messages
281
324
  .filter((m) => m.role === "system")
282
325
  .map((m) => m.content)
@@ -299,7 +342,7 @@ export function toAnthropicMessages(
299
342
  pushBlocks("user", [
300
343
  {
301
344
  type: "tool_result",
302
- tool_use_id: message.toolCallId,
345
+ tool_use_id: wireId(message.toolCallId),
303
346
  content: [
304
347
  { type: "text", text: message.content },
305
348
  ...(message.images ?? []).map((image) => ({
@@ -319,7 +362,7 @@ export function toAnthropicMessages(
319
362
  for (const call of message.toolCalls ?? []) {
320
363
  blocks.push({
321
364
  type: "tool_use",
322
- id: call.id,
365
+ id: wireId(call.id),
323
366
  name: call.name,
324
367
  input: parseToolArgs(call.arguments),
325
368
  });
@@ -373,7 +416,10 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
373
416
  const model = opts.model ?? config.model;
374
417
  const maxTokens = opts.maxTokens ?? config.maxTokens ?? DEFAULT_MAX_TOKENS;
375
418
  const effort = opts.effort ?? config.effort;
376
- const { system, messages: body } = toAnthropicMessages(messages, id);
419
+ // Repaired first: a result with no call, a call with no result, or an id
420
+ // used twice is a 400 that loses the turn. See repairHistory.
421
+ const sent = repairHistory(messages).messages;
422
+ const { system, messages: body } = toAnthropicMessages(sent, id);
377
423
 
378
424
  // Anthropic's own `service_tier` means auto or standard_only, not this.
379
425
  if (opts.serviceTier) {
@@ -438,7 +484,7 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
438
484
  // ponytail: every loop step after the first goes unthought on these
439
485
  // models. The upgrade is replaying the signed thinking blocks.
440
486
  const unthought =
441
- !claude && model.startsWith("claude-") && (forcesTool || continuesToolLoop(messages));
487
+ !claude && model.startsWith("claude-") && (forcesTool || continuesToolLoop(sent));
442
488
  if (claude) {
443
489
  // An effort the caller never set sends nothing: the model keeps its own
444
490
  // default, which on most of these is thinking ON.
@@ -476,12 +522,12 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
476
522
  let cachedInputTokens = 0;
477
523
  let cacheWriteTokens = 0;
478
524
  let outputTokens = 0;
479
- let toolCall: { index: number; id: string; name: string } | null = null;
525
+ let toolCall: ToolCallIdentity | null = null;
526
+ let sawToolCall = false;
480
527
  let blockIndex = -1;
481
528
  // What proves the turn ended rather than the socket: a stated stop
482
529
  // reason, or `message_stop`. See the check after the loop.
483
530
  let stopped = false;
484
- let closed = false;
485
531
  const usageChunk = (): ProviderChunk => ({
486
532
  type: "usage",
487
533
  usage: {
@@ -510,13 +556,12 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
510
556
  ...(opts.signal ? { signal: opts.signal } : {}),
511
557
  ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
512
558
  ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
559
+ ...(opts.onRateLimit ? { onRateLimit: opts.onRateLimit } : {}),
560
+ // The stop reason rides in `message_delta` with the final usage, so a
561
+ // read that dies after it loses nothing but `message_stop`.
562
+ finished: () => stopped,
513
563
  })) {
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
- }
564
+ const event = parseFrame<AnthropicEvent>(data, id);
520
565
 
521
566
  switch (event.type) {
522
567
  // A failure the backend reports after its headers went out. Classified
@@ -542,6 +587,10 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
542
587
  id: event.content_block.id ?? "",
543
588
  name: event.content_block.name ?? "",
544
589
  };
590
+ // Checked here, because this block is the only place this shape
591
+ // states a call's id and name.
592
+ settleToolCalls(id, [toolCall]);
593
+ sawToolCall = true;
545
594
  yield {
546
595
  type: "delta",
547
596
  toolCalls: [{ index: toolCall.index, id: toolCall.id, name: toolCall.name }],
@@ -577,21 +626,31 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
577
626
  cachedInputTokens = event.usage?.cache_read_input_tokens ?? cachedInputTokens;
578
627
  cacheWriteTokens = event.usage?.cache_creation_input_tokens ?? cacheWriteTokens;
579
628
  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 };
629
+ if (event.delta?.stop_reason) {
630
+ const stated = mapStopReason(id, event.delta.stop_reason);
631
+ stopped = true;
632
+ // Anthropic-compatible gateways can end a tool turn on
633
+ // `end_turn`, and a caller reading that as done never runs the
634
+ // tool. Only `stop` is promoted: a length cut may be what broke
635
+ // the call.
636
+ const finishReason = stated === "stop" && sawToolCall ? "tool_calls" : stated;
637
+ yield { type: "finish", finishReason };
638
+ }
583
639
  break;
584
640
  }
585
641
 
642
+ // The turn's last event, so the reader stops here. Some gateways
643
+ // append a `[DONE]` or other bytes after it, and a read that died on
644
+ // those failed an answer that was already whole (Kimi, GLM and
645
+ // OpenCode Go, measured by cc-proxy).
586
646
  case "message_stop":
587
- closed = true;
588
647
  yield usageChunk();
589
- break;
648
+ return;
590
649
  }
591
650
  }
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.
651
+ // A gateway that states its stop reason and then skips `message_stop`, or
652
+ // whose socket dies after the reason, still finished the turn, and still
653
+ // billed it.
595
654
  if (stopped) {
596
655
  yield usageChunk();
597
656
  return;