@linxiraos/pi-ai 1.2.0 → 1.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 (148) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +4 -2
  3. package/THIRD-PARTY-NOTICES.txt +55 -5
  4. package/dist/types/auth/cascade.d.ts +5 -3
  5. package/dist/types/auth/pool.d.ts +3 -3
  6. package/dist/types/auth/select.d.ts +5 -1
  7. package/dist/types/auth/types.d.ts +15 -5
  8. package/dist/types/auth/usage-cache.d.ts +3 -1
  9. package/dist/types/auth-gateway/clients.d.ts +67 -0
  10. package/dist/types/auth-gateway/dispatch.d.ts +28 -10
  11. package/dist/types/auth-gateway/http.d.ts +2 -0
  12. package/dist/types/auth-gateway/index.d.ts +1 -0
  13. package/dist/types/auth-gateway/server.d.ts +9 -4
  14. package/dist/types/auth-gateway/session-state.d.ts +6 -0
  15. package/dist/types/auth-gateway/types.d.ts +16 -2
  16. package/dist/types/dialect/coercion.d.ts +6 -0
  17. package/dist/types/error/classes.d.ts +1 -1
  18. package/dist/types/error/flags.d.ts +15 -0
  19. package/dist/types/error/index.d.ts +1 -0
  20. package/dist/types/error/network-cause.d.ts +40 -0
  21. package/dist/types/images/shared.d.ts +26 -2
  22. package/dist/types/index.d.ts +1 -0
  23. package/dist/types/judgment/native.d.ts +51 -3
  24. package/dist/types/judgment/systemone.d.ts +9 -2
  25. package/dist/types/judgment/text.d.ts +11 -5
  26. package/dist/types/judgment/types.d.ts +5 -1
  27. package/dist/types/judgment/wire.d.ts +16 -3
  28. package/dist/types/provider-session-state.d.ts +2 -2
  29. package/dist/types/providers/anthropic-images.d.ts +7 -0
  30. package/dist/types/providers/anthropic-signature.d.ts +12 -2
  31. package/dist/types/providers/anthropic-state.d.ts +7 -0
  32. package/dist/types/providers/anthropic.d.ts +21 -6
  33. package/dist/types/providers/connection-prewarm.d.ts +15 -0
  34. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  35. package/dist/types/providers/cursor-pi-args.d.ts +13 -0
  36. package/dist/types/providers/cursor.d.ts +7 -0
  37. package/dist/types/providers/openai-codex-responses.d.ts +6 -0
  38. package/dist/types/providers/openai-responses.d.ts +9 -0
  39. package/dist/types/providers/openai-shared.d.ts +30 -0
  40. package/dist/types/providers/systemone-server.d.ts +7 -3
  41. package/dist/types/providers/transform-messages.d.ts +26 -5
  42. package/dist/types/registry/oauth/google-antigravity.d.ts +1 -1
  43. package/dist/types/stream.d.ts +0 -2
  44. package/dist/types/types.d.ts +33 -9
  45. package/dist/types/utils/dsml-leak.d.ts +4 -2
  46. package/dist/types/utils/narration.d.ts +9 -0
  47. package/dist/types/utils/openai-http.d.ts +2 -0
  48. package/dist/types/utils/request-body.d.ts +11 -0
  49. package/dist/types/utils/request-compression.d.ts +21 -0
  50. package/dist/types/utils/tool-call-arguments.d.ts +2 -1
  51. package/dist/types/utils/transport-fetch.d.ts +8 -0
  52. package/dist/types/utils.d.ts +1 -1
  53. package/package.json +8 -8
  54. package/src/auth/cascade.ts +23 -13
  55. package/src/auth/pool.ts +3 -3
  56. package/src/auth/refresh.ts +39 -12
  57. package/src/auth/select.ts +8 -2
  58. package/src/auth/types.ts +16 -5
  59. package/src/auth/usage-cache.ts +23 -7
  60. package/src/auth/usage.ts +2 -4
  61. package/src/auth-broker/remote-store.ts +31 -6
  62. package/src/auth-gateway/clients.ts +318 -0
  63. package/src/auth-gateway/dispatch.ts +66 -35
  64. package/src/auth-gateway/http.ts +11 -5
  65. package/src/auth-gateway/index.ts +1 -0
  66. package/src/auth-gateway/routes/embeddings.ts +4 -10
  67. package/src/auth-gateway/routes/images.ts +6 -10
  68. package/src/auth-gateway/routes/rerank.ts +4 -10
  69. package/src/auth-gateway/routes/speech.ts +10 -10
  70. package/src/auth-gateway/routes/systemone.ts +22 -12
  71. package/src/auth-gateway/routes/transcriptions.ts +4 -2
  72. package/src/auth-gateway/routes/video.ts +12 -15
  73. package/src/auth-gateway/server.ts +136 -53
  74. package/src/auth-gateway/session-state.ts +14 -0
  75. package/src/auth-gateway/types.ts +16 -2
  76. package/src/dialect/coercion.ts +16 -0
  77. package/src/dialect/hermes.ts +7 -11
  78. package/src/dialect/qwen3.ts +3 -11
  79. package/src/error/classes.ts +3 -2
  80. package/src/error/flags.ts +24 -3
  81. package/src/error/index.ts +1 -0
  82. package/src/error/network-cause.ts +179 -0
  83. package/src/images/codex-format-directive.md +3 -0
  84. package/src/images/google-antigravity.ts +2 -1
  85. package/src/images/google-generative-ai.ts +4 -2
  86. package/src/images/openai-hosted.ts +25 -2
  87. package/src/images/openai-images.ts +11 -6
  88. package/src/images/shared.ts +77 -3
  89. package/src/index.ts +1 -0
  90. package/src/judgment/chat.ts +17 -1
  91. package/src/judgment/native.ts +182 -12
  92. package/src/judgment/openai-decisions.ts +27 -6
  93. package/src/judgment/systemone.ts +131 -10
  94. package/src/judgment/text.ts +20 -7
  95. package/src/judgment/types.ts +5 -1
  96. package/src/judgment/wire.ts +17 -3
  97. package/src/provider-session-state.ts +2 -2
  98. package/src/providers/amazon-bedrock.ts +4 -1
  99. package/src/providers/anthropic-client.ts +1 -1
  100. package/src/providers/anthropic-images.ts +263 -0
  101. package/src/providers/anthropic-messages-server.ts +84 -1
  102. package/src/providers/anthropic-signature.ts +35 -8
  103. package/src/providers/anthropic-state.ts +13 -0
  104. package/src/providers/anthropic.ts +479 -397
  105. package/src/providers/connection-prewarm.ts +78 -0
  106. package/src/providers/cowork-fetch.ts +5 -1
  107. package/src/providers/cursor/exec-modern.ts +1 -0
  108. package/src/providers/cursor-pi-args.ts +19 -0
  109. package/src/providers/cursor.ts +53 -13
  110. package/src/providers/devin.ts +10 -4
  111. package/src/providers/factory-droid/gemini.ts +9 -3
  112. package/src/providers/google-gemini-cli.ts +11 -1
  113. package/src/providers/google-shared.ts +2 -6
  114. package/src/providers/images-server.ts +6 -1
  115. package/src/providers/ollama.ts +15 -5
  116. package/src/providers/openai-anthropic-shim.ts +1 -0
  117. package/src/providers/openai-chat-server.ts +29 -0
  118. package/src/providers/openai-codex/access-programs.ts +2 -1
  119. package/src/providers/openai-codex-responses.ts +49 -82
  120. package/src/providers/openai-completions.ts +117 -19
  121. package/src/providers/openai-responses.ts +25 -2
  122. package/src/providers/openai-shared.ts +36 -5
  123. package/src/providers/pi-native-server.ts +1 -0
  124. package/src/providers/systemone-server.ts +72 -12
  125. package/src/providers/transform-messages.ts +95 -51
  126. package/src/registry/oauth/google-antigravity.ts +1 -1
  127. package/src/stream.ts +13 -73
  128. package/src/types.ts +33 -9
  129. package/src/usage/claude.ts +2 -9
  130. package/src/usage/github-copilot.ts +26 -4
  131. package/src/usage/openai-codex.ts +13 -7
  132. package/src/usage/opencode-go.ts +7 -8
  133. package/src/usage/xai-oauth.ts +2 -9
  134. package/src/utils/dsml-leak.ts +30 -14
  135. package/src/utils/leaked-thinking-stream.ts +10 -0
  136. package/src/utils/narration.ts +18 -0
  137. package/src/utils/openai-http.ts +27 -18
  138. package/src/utils/request-body.ts +27 -0
  139. package/src/utils/request-compression.ts +202 -0
  140. package/src/utils/request-debug.ts +23 -9
  141. package/src/utils/schema/dereference.ts +51 -18
  142. package/src/utils/schema/draft.ts +10 -2
  143. package/src/utils/schema/json-schema-validator.ts +4 -10
  144. package/src/utils/schema/normalize.ts +6 -9
  145. package/src/utils/tool-call-arguments.ts +2 -1
  146. package/src/utils/transport-fetch.ts +37 -0
  147. package/src/utils/validation.ts +26 -16
  148. package/src/utils.ts +38 -23
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as tls from "node:tls";
3
3
  import { isAnthropicSigningProxyUrl, isOfficialAnthropicApiUrl } from "@linxiraos/pi-catalog/compat/anthropic";
4
- import { hostMatchesUrl, isVertexRawPredictUrl } from "@linxiraos/pi-catalog/hosts";
4
+ import { hostMatchesUrl, isVertexRawPredictUrl, modelMatchesHost } from "@linxiraos/pi-catalog/hosts";
5
5
  import type { Effort } from "@linxiraos/pi-catalog/effort";
6
6
  import { mapEffortToAnthropicAdaptiveEffort } from "@linxiraos/pi-catalog/model-thinking";
7
7
  import { calculateCost, getBundledModel } from "@linxiraos/pi-catalog/models";
@@ -9,6 +9,7 @@ import { isAnthropicOAuthToken } from "@linxiraos/pi-catalog/utils";
9
9
  import { parseGitHubCopilotApiKey } from "@linxiraos/pi-catalog/wire/github-copilot";
10
10
  import {
11
11
  $env,
12
+ $flag,
12
13
  isEnoent,
13
14
  logger,
14
15
  parseJsonWithRepair,
@@ -22,11 +23,13 @@ import { parseToolCallArguments } from "../utils/tool-call-arguments";
22
23
  import { waitForRetry } from "../utils/retry-wait";
23
24
  import { getEnvApiKey } from "../env-api-key";
24
25
  import { OUTPUT_FALLBACK_BUFFER } from "../stream";
26
+ import { wrapFetchForRequestCompression } from "../utils/request-compression";
25
27
  import type {
26
28
  AnthropicCompactionFiles,
27
29
  AnthropicCompactionPayload,
28
30
  AnthropicCompactionRequest,
29
31
  AnthropicFallbackContent,
32
+ AnthropicFallbackCreditHandle,
30
33
  AnthropicMessagePayload,
31
34
  AnthropicOutputEffort,
32
35
  AnthropicRequestControls,
@@ -116,10 +119,12 @@ import {
116
119
  type MessageCreateParams,
117
120
  type MessageCreateParamsStreaming,
118
121
  type MessageParam,
122
+ type OutputConfig,
119
123
  parseAnthropicInputTransformations,
120
124
  type RawMessageStreamEvent,
121
125
  THINKING_BINDING_CONTROLS_BETA,
122
126
  type TextBlockParam,
127
+ type ThinkingConfigParam,
123
128
  } from "./anthropic-wire";
124
129
  import {
125
130
  claudeCodeSdkVersion,
@@ -138,10 +143,11 @@ import {
138
143
  resolveGitHubCopilotBaseUrl,
139
144
  wrapFetchForCopilotFallback,
140
145
  } from "./github-copilot-headers";
141
- import { servedModelFromAnthropicSignature } from "./anthropic-signature";
146
+ import { prepareAnthropicImageContext } from "./anthropic-images";
147
+ import { isAnthropicNarrationSignature, servedModelFromAnthropicSignature } from "./anthropic-signature";
142
148
  import { getOpenAIPromptCacheKey } from "./openai-shared";
143
149
  import { applyInferenceHeaders } from "./inference-headers";
144
- import { redactSensitiveCredentials, transformMessages } from "./transform-messages";
150
+ import { isAnthropicMessagesModel, redactSensitiveCredentials, transformMessages } from "./transform-messages";
145
151
  import { NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard";
146
152
  import {
147
153
  injectedClientBaseUrl,
@@ -235,6 +241,12 @@ const contextManagementBeta = "context-management-2025-06-27";
235
241
  const structuredOutputsBeta = "structured-outputs-2025-12-15";
236
242
  const thinkingTokenCountBeta = "thinking-token-count-2026-05-13";
237
243
  const fallbackCreditBeta = "fallback-credit-2026-06-01";
244
+ /**
245
+ * Redemption window of a refusal's `fallback_credit_token`. `stop_details`
246
+ * reports no expiry; the documented window is five minutes from the refusal
247
+ * (https://platform.claude.com/docs/en/build-with-claude/fallback-credit).
248
+ */
249
+ const FALLBACK_CREDIT_TTL_MS = 5 * 60 * 1000;
238
250
  const promptCachingScopeBeta = "prompt-caching-scope-2026-01-05";
239
251
  const claudeCodeUtilityBetaDefaults = [
240
252
  oauthAuthBeta,
@@ -273,24 +285,41 @@ const promptCacheBetas: Record<string, true> = {
273
285
  };
274
286
 
275
287
  /**
276
- * Remove the prompt-cache betas from every `anthropic-beta` entry in a header
288
+ * Betas withheld under the kill switch: they gate the pre-release body fields
289
+ * it removes (Claude Code's gateway contract: context management, beta tool
290
+ * fields, structured-output format, task budgets, tool search), plus the
291
+ * thinking-binding and compaction controls omp also withholds.
292
+ */
293
+ const killSwitchBetas: Record<string, true> = {
294
+ [THINKING_BINDING_CONTROLS_BETA]: true,
295
+ [contextManagementBeta]: true,
296
+ [COMPACTION_BETA]: true,
297
+ [LEGACY_COMPACTION_BETA]: true,
298
+ [structuredOutputsBeta]: true,
299
+ [taskBudgetBeta]: true,
300
+ [midConversationToolChangesBeta]: true,
301
+ };
302
+
303
+ /**
304
+ * Remove the `dropped` betas from every `anthropic-beta` entry in a header
277
305
  * record. Header names are case-insensitive and the value is a comma-separated
278
306
  * list, so each matching key keeps its own casing and its remaining tokens;
279
307
  * an entry left with no token is deleted rather than sent empty. The input is
280
- * returned untouched when it advertises no cache beta.
308
+ * returned untouched when it advertises none of them.
281
309
  *
282
310
  * `model.headers` and `options.headers` are caller-controlled channels that
283
311
  * reach the wire through `buildAnthropicHeaders` (as `modelHeaders`, which an
284
312
  * override-enabled endpoint lets replace our own beta header outright), the
285
313
  * GitHub Copilot default headers, and the per-request beta override built for
286
- * injected clients — each of which must go through here once breakpoints drop.
314
+ * injected clients — each of which must go through here once breakpoints drop
315
+ * or the experimental-beta kill switch is on.
287
316
  */
288
- function stripPromptCacheBetas(headers: Record<string, string>): Record<string, string> {
317
+ function stripHeaderBetas(headers: Record<string, string>, dropped: Record<string, true>): Record<string, string> {
289
318
  let stripped: Record<string, string> | undefined;
290
319
  for (const key in headers) {
291
320
  if (key.toLowerCase() !== "anthropic-beta") continue;
292
321
  const betas = normalizeExtraBetas(headers[key]);
293
- const kept = betas.filter(beta => promptCacheBetas[beta] !== true);
322
+ const kept = betas.filter(beta => dropped[beta] !== true);
294
323
  if (kept.length === betas.length) continue;
295
324
  stripped ??= { ...headers };
296
325
  if (kept.length === 0) {
@@ -572,7 +601,11 @@ type AnthropicProviderSessionState = ProviderSessionState & {
572
601
  * Cleared on session close.
573
602
  */
574
603
  cacheControlOptionsUnsupported: boolean;
575
- /** Thinking blocks stripped after a prefix-binding 400 forced a retry without `drop_block`. */
604
+ /**
605
+ * Thinking blocks the client strips after a 400 rejected their replay: a
606
+ * prefix-binding 400 that forced a retry without `drop_block`, or a latest
607
+ * assistant turn whose reasoning the API refused as modified.
608
+ */
576
609
  prefixDroppedThinkingBlocks: Set<string>;
577
610
  /**
578
611
  * Server-reported `prefix_binding_mismatch` drops already warned about. A
@@ -919,15 +952,11 @@ function getUmansWebSearchProvider(headers: Record<string, string> | undefined):
919
952
  return normalizeUmansWebSearchProvider($env.UMANS_WEBSEARCH_PROVIDER);
920
953
  }
921
954
 
922
- function isUmansAnthropicModel(model: Model<"anthropic-messages">): boolean {
923
- return model.provider === "umans" || model.baseUrl.toLowerCase().includes("api.code.umans.ai");
924
- }
925
-
926
955
  function getUmansWebSearchHeader(
927
956
  model: Model<"anthropic-messages">,
928
957
  headers: Record<string, string> | undefined,
929
958
  ): Record<string, string> | undefined {
930
- if (!isUmansAnthropicModel(model)) return undefined;
959
+ if (!modelMatchesHost(model, "umans")) return undefined;
931
960
  const provider = getUmansWebSearchProvider(headers);
932
961
  return provider ? { [UMANS_WEBSEARCH_PROVIDER_HEADER]: provider } : undefined;
933
962
  }
@@ -952,253 +981,6 @@ function decodeAnthropicToolName(name: string, isOAuthToken: boolean, escapeBuil
952
981
  return name;
953
982
  }
954
983
 
955
- const ANTHROPIC_MANY_IMAGE_THRESHOLD = 20;
956
- const ANTHROPIC_MANY_IMAGE_MAX_DIMENSION = 2000;
957
- /**
958
- * Image contract of the canonical Anthropic Messages API, used when resolved
959
- * model policy names no other: 8000px per side ("At least one of the image
960
- * dimensions exceed max allowed size: 8000 pixels") and a 10 MB base64
961
- * payload per block ("image exceeds 10 MB maximum: 14012300 bytes >
962
- * 10485760 bytes"), measured on the encoded string rather than the decoded
963
- * bytes. Either rejection poisons every retry and model fallback because the
964
- * block stays in context, so both are clamped before the request goes out.
965
- * A host with a different image contract sets `max-image-dimension` /
966
- * `max-image-payload-bytes` in the KDL compatibility rules.
967
- */
968
- const ANTHROPIC_MAX_IMAGE_DIMENSION = 8000;
969
- const ANTHROPIC_MAX_IMAGE_PAYLOAD_BYTES = 10 * 1024 * 1024;
970
- /** Headroom for the surrounding JSON and any provider-side accounting. */
971
- const ANTHROPIC_IMAGE_PAYLOAD_HEADROOM = 0.9;
972
- /** Re-encode passes allowed to bring one image under the byte budget. */
973
- const ANTHROPIC_IMAGE_RESIZE_ATTEMPTS = 4;
974
-
975
- /**
976
- * Image bounds for one request. `maxDimension` and `payloadBudget` come from
977
- * resolved model policy (`max-image-dimension`, `max-image-payload-bytes`) so
978
- * a deployment whose image contract differs from the canonical API can
979
- * override them, and the many-image path tightens `maxDimension` further
980
- * because many images share one request budget.
981
- */
982
- interface AnthropicImageCaps {
983
- maxDimension: number;
984
- payloadBudget: number;
985
- }
986
-
987
- function countAnthropicImageBlocks(messages: Message[]): number {
988
- let count = 0;
989
- for (const message of messages) {
990
- if (message.role !== "user" && message.role !== "developer" && message.role !== "toolResult") continue;
991
- if (!Array.isArray(message.content)) continue;
992
- for (const block of message.content) {
993
- if (block.type === "image") count++;
994
- }
995
- }
996
- return count;
997
- }
998
-
999
- /**
1000
- * Ceiling on encoder output pixels in flight. One 8000x8000 RGBA surface is
1001
- * roughly 256 MiB, so admitting four of them would spike the CLI by a
1002
- * gigabyte; at the full-resolution cap this yields a single pipeline, while
1003
- * the many-image cap encodes at most 2000px and keeps the four-way fan-out.
1004
- */
1005
- const ANTHROPIC_IMAGE_RESIZE_PIXEL_BUDGET = 16_000_000;
1006
- const ANTHROPIC_IMAGE_RESIZE_MAX_CONCURRENCY = 4;
1007
-
1008
- function anthropicImageResizeConcurrency(maxDimension: number): number {
1009
- const perPipeline = maxDimension * maxDimension;
1010
- if (perPipeline <= 0) return ANTHROPIC_IMAGE_RESIZE_MAX_CONCURRENCY;
1011
- return Math.max(
1012
- 1,
1013
- Math.min(ANTHROPIC_IMAGE_RESIZE_MAX_CONCURRENCY, Math.floor(ANTHROPIC_IMAGE_RESIZE_PIXEL_BUDGET / perPipeline)),
1014
- );
1015
- }
1016
-
1017
- /**
1018
- * Memoized resize results keyed on ImageContent identity, per cap pair.
1019
- * Callers keep message objects stable across turns, so without this every
1020
- * request (and every in-provider retry of a fresh turn) re-decodes and
1021
- * re-encodes the same oversized screenshots. A cached value identical to the
1022
- * key means "already within bounds / unresizable — skip the decode". The caps
1023
- * depend on image count and on resolved policy, so each pair keeps its own
1024
- * cache: a value cached at 8000px is not reusable at 2000px.
1025
- */
1026
- const anthropicImageResizeCaches = new Map<string, WeakMap<ImageContent, ImageContent>>();
1027
-
1028
- function anthropicImageResizeCache(caps: AnthropicImageCaps): WeakMap<ImageContent, ImageContent> {
1029
- const key = `${caps.maxDimension}:${caps.payloadBudget}`;
1030
- const existing = anthropicImageResizeCaches.get(key);
1031
- if (existing) return existing;
1032
- const created = new WeakMap<ImageContent, ImageContent>();
1033
- anthropicImageResizeCaches.set(key, created);
1034
- return created;
1035
- }
1036
-
1037
- type ResizeLimiter = <R>(fn: () => Promise<R>) => Promise<R>;
1038
-
1039
- /**
1040
- * Bounded-concurrency gate for image decode/encode work. The many-image path
1041
- * fans out over every block of every message; unbounded, 100+ oversized images
1042
- * would decode concurrently and spike memory by gigabytes. Slots are handed
1043
- * off directly to the next waiter on release.
1044
- */
1045
- function createResizeLimiter(limit: number): ResizeLimiter {
1046
- let active = 0;
1047
- const queue: (() => void)[] = [];
1048
- return async fn => {
1049
- if (active >= limit) {
1050
- const { promise, resolve } = Promise.withResolvers<void>();
1051
- queue.push(resolve);
1052
- await promise;
1053
- } else {
1054
- active++;
1055
- }
1056
- try {
1057
- return await fn();
1058
- } finally {
1059
- const next = queue.shift();
1060
- if (next) next();
1061
- else active--;
1062
- }
1063
- };
1064
- }
1065
-
1066
- async function resizeAnthropicImageBlock(block: ImageContent, caps: AnthropicImageCaps): Promise<ImageContent> {
1067
- const { maxDimension, payloadBudget } = caps;
1068
- try {
1069
- const inputBuffer = Buffer.from(block.data, "base64");
1070
- const { width, height } = await new Bun.Image(inputBuffer).metadata();
1071
- if (!width || !height) return block;
1072
- const overDimension = width > maxDimension || height > maxDimension;
1073
- const overBudget = block.data.length > payloadBudget;
1074
- if (!overDimension && !overBudget) return block;
1075
-
1076
- // Fitting the pixel cap does not imply fitting the byte cap: a dense
1077
- // screenshot re-encodes large even at the cap. Shrink by the overshoot in
1078
- // area terms until the payload fits or the passes run out.
1079
- let scale = Math.min(1, maxDimension / width, maxDimension / height);
1080
- let best: { buffer: Uint8Array; mimeType: string } | undefined;
1081
- for (let attempt = 0; attempt < ANTHROPIC_IMAGE_RESIZE_ATTEMPTS; attempt++) {
1082
- const targetWidth = Math.max(1, Math.min(maxDimension, Math.round(width * scale)));
1083
- const targetHeight = Math.max(1, Math.min(maxDimension, Math.round(height * scale)));
1084
- // One encoder at a time: a full-resolution surface is hundreds of
1085
- // megabytes, so the two candidates never coexist.
1086
- const png = await new Bun.Image(inputBuffer).resize(targetWidth, targetHeight).png().bytes();
1087
- const jpeg = await new Bun.Image(inputBuffer).resize(targetWidth, targetHeight).jpeg({ quality: 85 }).bytes();
1088
- best =
1089
- png.length <= jpeg.length
1090
- ? { buffer: png, mimeType: "image/png" }
1091
- : { buffer: jpeg, mimeType: "image/jpeg" };
1092
-
1093
- // Base64 expands three bytes into four characters; the encoded length
1094
- // is what Anthropic measures, so derive it without encoding.
1095
- const encoded = Math.ceil(best.buffer.length / 3) * 4;
1096
- if (encoded <= payloadBudget) break;
1097
- if (targetWidth === 1 && targetHeight === 1) break;
1098
- scale *= Math.max(0.25, Math.sqrt(payloadBudget / encoded) * 0.9);
1099
- }
1100
- if (!best) return block;
1101
-
1102
- const data = Buffer.from(best.buffer).toString("base64");
1103
- // An image inside the pixel cap is only worth replacing if it shrank.
1104
- if (!overDimension && data.length >= block.data.length) return block;
1105
- // The rebuilt block carries no `url` or `providerFile`: those reference
1106
- // the original bytes, and Anthropic validates a referenced image after
1107
- // fetching it, so keeping either would resend the oversized copy.
1108
- return {
1109
- type: "image",
1110
- data,
1111
- mimeType: best.mimeType,
1112
- };
1113
- } catch (error) {
1114
- logger.warn("anthropic: failed to resize oversized image", {
1115
- mimeType: block.mimeType,
1116
- error: error instanceof Error ? error.message : String(error),
1117
- });
1118
- return block;
1119
- }
1120
- }
1121
-
1122
- async function resizeAnthropicImageContent(
1123
- content: (TextContent | ImageContent)[],
1124
- state: { resized: number },
1125
- limit: ResizeLimiter,
1126
- caps: AnthropicImageCaps,
1127
- ): Promise<(TextContent | ImageContent)[]> {
1128
- let changed = false;
1129
- const cache = anthropicImageResizeCache(caps);
1130
- const next = await Promise.all(
1131
- content.map(async block => {
1132
- if (block.type !== "image") return block;
1133
- let resized = cache.get(block);
1134
- if (resized === undefined) {
1135
- resized = await limit(() => resizeAnthropicImageBlock(block, caps));
1136
- cache.set(block, resized);
1137
- }
1138
- if (resized !== block) {
1139
- changed = true;
1140
- state.resized++;
1141
- }
1142
- return resized;
1143
- }),
1144
- );
1145
- return changed ? next : content;
1146
- }
1147
-
1148
- async function resizeAnthropicImageMessage(
1149
- message: Message,
1150
- state: { resized: number },
1151
- limit: ResizeLimiter,
1152
- caps: AnthropicImageCaps,
1153
- ): Promise<Message> {
1154
- if (message.role === "user" || message.role === "developer") {
1155
- if (!Array.isArray(message.content)) return message;
1156
- const content = await resizeAnthropicImageContent(message.content, state, limit, caps);
1157
- return content === message.content ? message : { ...message, content };
1158
- }
1159
- if (message.role === "toolResult") {
1160
- const content = await resizeAnthropicImageContent(message.content, state, limit, caps);
1161
- return content === message.content ? message : { ...message, content };
1162
- }
1163
- return message;
1164
- }
1165
-
1166
- async function prepareAnthropicImageContext(context: Context, model: Model<"anthropic-messages">): Promise<Context> {
1167
- if (!model.input.includes("image")) return context;
1168
- const imageCount = countAnthropicImageBlocks(context.messages);
1169
- if (imageCount === 0) return context;
1170
- // Many images share one request size budget, so they get the tighter cap.
1171
- // A smaller set only has to clear the host's per-image limit.
1172
- const hostMaxDimension = model.compat.maxImageDimension ?? ANTHROPIC_MAX_IMAGE_DIMENSION;
1173
- const maxDimension =
1174
- imageCount > ANTHROPIC_MANY_IMAGE_THRESHOLD
1175
- ? Math.min(ANTHROPIC_MANY_IMAGE_MAX_DIMENSION, hostMaxDimension)
1176
- : hostMaxDimension;
1177
- const maxPayloadBytes = model.compat.maxImagePayloadBytes ?? ANTHROPIC_MAX_IMAGE_PAYLOAD_BYTES;
1178
- const caps: AnthropicImageCaps = {
1179
- maxDimension,
1180
- payloadBudget: Math.floor(maxPayloadBytes * ANTHROPIC_IMAGE_PAYLOAD_HEADROOM),
1181
- };
1182
-
1183
- let changed = false;
1184
- const state = { resized: 0 };
1185
- const limit = createResizeLimiter(anthropicImageResizeConcurrency(maxDimension));
1186
- const messages = await Promise.all(
1187
- context.messages.map(async message => {
1188
- const next = await resizeAnthropicImageMessage(message, state, limit, caps);
1189
- if (next !== message) changed = true;
1190
- return next;
1191
- }),
1192
- );
1193
- if (!changed) return context;
1194
- logger.debug("anthropic: resized oversized images", {
1195
- imageCount,
1196
- resized: state.resized,
1197
- maxDimension,
1198
- });
1199
- return { ...context, messages };
1200
- }
1201
-
1202
984
  type AnthropicImageSource =
1203
985
  | { type: "base64"; media_type: AnthropicImageMediaType; data: string }
1204
986
  | { type: "url"; url: string }
@@ -1340,7 +1122,8 @@ export interface AnthropicOptions extends StreamOptions {
1340
1122
  * header is auto-attached; the response parser then honors mid-stream
1341
1123
  * `fallback` content blocks and `usage.iterations` for served-model
1342
1124
  * promotion and per-attempt pricing. Opt-in ONLY — leaving this
1343
- * undefined preserves the pre-fallback behavior on every code path.
1125
+ * undefined sends neither and keeps both off; a `fallback` block in the
1126
+ * response (a gateway opted in) is still persisted as a replay boundary.
1344
1127
  */
1345
1128
  fallbacks?: FallbackParam[];
1346
1129
  }
@@ -1355,6 +1138,8 @@ export type AnthropicClientOptionsArgs = {
1355
1138
  dynamicHeaders?: Record<string, string>;
1356
1139
  isOAuth?: boolean;
1357
1140
  hasTools?: boolean;
1141
+ /** Whether a tool in ANTHROPIC_BUFFERED_INPUT_TOOLS is present; suppresses the request-wide fine-grained streaming beta. */
1142
+ hasBufferedInputTools?: boolean;
1358
1143
  thinkingEnabled?: boolean;
1359
1144
  thinkingDisplay?: AnthropicThinkingDisplay;
1360
1145
  disableStrictTools?: boolean;
@@ -1428,7 +1213,7 @@ function resolveAnthropicBaseUrl(model: Model<"anthropic-messages">, apiKey?: st
1428
1213
  if (model.provider === "github-copilot") {
1429
1214
  return normalizeAnthropicBaseUrl(resolveGitHubCopilotBaseUrl(model.baseUrl, apiKey) ?? model.baseUrl);
1430
1215
  }
1431
- if (model.provider === "anthropic") return resolveDirectAnthropicBaseUrl(model);
1216
+ if (model.compat.firstPartyProvider) return resolveDirectAnthropicBaseUrl(model);
1432
1217
  return normalizeAnthropicBaseUrl(model.baseUrl);
1433
1218
  }
1434
1219
 
@@ -1491,7 +1276,7 @@ function resolveAnthropicCustomHeaders(
1491
1276
  model: Model<"anthropic-messages">,
1492
1277
  baseUrl: string | undefined,
1493
1278
  ): Record<string, string> | undefined {
1494
- if (model.provider !== "anthropic") return undefined;
1279
+ if (!model.compat.firstPartyProvider) return undefined;
1495
1280
  return resolveAnthropicCustomHeadersForBaseUrl(baseUrl);
1496
1281
  }
1497
1282
 
@@ -1523,7 +1308,7 @@ function resolvePemValue(value: string | undefined, name: string): string | unde
1523
1308
  }
1524
1309
 
1525
1310
  function resolveFoundryTlsOptions(model: Model<"anthropic-messages">): FoundryTlsOptions | undefined {
1526
- if (model.provider !== "anthropic") return undefined;
1311
+ if (!model.compat.firstPartyProvider) return undefined;
1527
1312
  if (!isFoundryEnabled()) return undefined;
1528
1313
 
1529
1314
  const cacheKey = foundryTlsOptionsCacheKey();
@@ -1552,7 +1337,7 @@ function buildClaudeCodeTlsFetchOptions(
1552
1337
  model: Model<"anthropic-messages">,
1553
1338
  baseUrl: string | undefined,
1554
1339
  ): AnthropicFetchOptions | undefined {
1555
- if (model.provider !== "anthropic") return undefined;
1340
+ if (!model.compat.firstPartyProvider) return undefined;
1556
1341
  if (!baseUrl) return undefined;
1557
1342
 
1558
1343
  let serverName: string;
@@ -1797,6 +1582,10 @@ function unwrapAnthropicThinkingEnvelope(text: string): string | undefined {
1797
1582
  * The refused response's content as the continuation prefix: client tool
1798
1583
  * calls (no matching tool_result) are omitted and a trailing text block is
1799
1584
  * right-trimmed, per the fallback-credit continuation contract.
1585
+ *
1586
+ * A `fallback` boundary stays exactly where it appeared: the API accepts it on
1587
+ * any request, beta or not, and uses its position to validate the thinking
1588
+ * blocks on either side, so the echo must not collapse or move it.
1800
1589
  */
1801
1590
  function refusalContinuationPrefix(content: AssistantMessage["content"]): AssistantMessage["content"] {
1802
1591
  const prefix = structuredClone(content.filter(block => block.type !== "toolCall"));
@@ -1805,40 +1594,54 @@ function refusalContinuationPrefix(content: AssistantMessage["content"]): Assist
1805
1594
  return prefix;
1806
1595
  }
1807
1596
 
1808
- /** Wire form of {@link refusalContinuationPrefix} for the appended assistant message. */
1809
- function formatEchoedRefusalContent(content: AssistantMessage["content"]): unknown[] {
1810
- return refusalContinuationPrefix(content).map(block => {
1597
+ /**
1598
+ * Wire form of {@link refusalContinuationPrefix} for the appended assistant
1599
+ * message. Blocks with no valid continuation wire shape (unsigned thinking,
1600
+ * anything the Anthropic stream cannot have produced) are dropped.
1601
+ */
1602
+ function formatEchoedRefusalContent(content: AssistantMessage["content"]): ContentBlockParam[] {
1603
+ const echoed: ContentBlockParam[] = [];
1604
+ for (const block of refusalContinuationPrefix(content)) {
1811
1605
  switch (block.type) {
1812
1606
  case "text":
1813
- return { type: "text", text: block.text };
1607
+ echoed.push({ type: "text", text: block.text });
1608
+ break;
1814
1609
  case "thinking":
1815
- return {
1816
- type: "thinking",
1817
- thinking: block.thinking,
1818
- ...(block.thinkingSignature ? { signature: block.thinkingSignature } : {}),
1819
- };
1610
+ if (block.thinkingSignature) {
1611
+ echoed.push({ type: "thinking", thinking: block.thinking, signature: block.thinkingSignature });
1612
+ }
1613
+ break;
1820
1614
  case "redactedThinking":
1821
- return { type: "redacted_thinking", data: block.data };
1615
+ echoed.push({ type: "redacted_thinking", data: block.data });
1616
+ break;
1822
1617
  case "fallback":
1823
- return { type: "fallback", from: block.from, to: block.to };
1618
+ echoed.push({ type: "fallback", from: block.from, to: block.to });
1619
+ break;
1824
1620
  case "anthropicServerTool":
1825
- return block.block;
1826
- default:
1827
- return block;
1621
+ echoed.push(block.block);
1622
+ break;
1828
1623
  }
1829
- });
1624
+ }
1625
+ return echoed;
1830
1626
  }
1831
1627
 
1832
- function isAnthropicBadRequest(error: unknown): boolean {
1833
- if (!error) return false;
1834
- if (typeof error === "object") {
1835
- const rec = error as Record<string, unknown>;
1836
- if (rec.status === 400 || rec.statusCode === 400) return true;
1837
- if (typeof rec.message === "string" && (/\b400\b/.test(rec.message) || rec.message.includes("BadRequestError"))) {
1838
- return true;
1839
- }
1628
+ /**
1629
+ * The refused request's frozen body retargeted at the fallback model with the
1630
+ * credit token, optionally followed by the echoed refusal prefix.
1631
+ */
1632
+ function fallbackCreditRequestBody(
1633
+ redemption: AnthropicFallbackCreditHandle,
1634
+ targetModelId: string,
1635
+ continuation: ContentBlockParam[] | undefined,
1636
+ ): MessageCreateParamsStreaming {
1637
+ const body = structuredClone(redemption.params);
1638
+ body.model = targetModelId;
1639
+ body.fallback_credit_token = redemption.token;
1640
+ delete body.fallbacks;
1641
+ if (continuation && continuation.length > 0) {
1642
+ body.messages = [...body.messages, { role: "assistant", content: continuation }];
1840
1643
  }
1841
- return false;
1644
+ return body;
1842
1645
  }
1843
1646
 
1844
1647
  function createEmptyUsage(premiumRequests?: number): Usage {
@@ -2197,6 +2000,33 @@ function rememberPrefixDroppedThinking(
2197
2000
  }
2198
2001
  }
2199
2002
 
2003
+ /**
2004
+ * Replay keys of the thinking blocks in the request's latest assistant turn.
2005
+ * Anthropic validates every assistant message after the last user message
2006
+ * carrying more than tool results as one "latest assistant message" (its error
2007
+ * indices count blocks across that whole run).
2008
+ */
2009
+ function latestAssistantTurnThinkingKeys(params: MessageCreateParamsStreaming): string[] {
2010
+ const keys: string[] = [];
2011
+ for (let index = params.messages.length - 1; index >= 0; index--) {
2012
+ const message = params.messages[index]!;
2013
+ if (message.role !== "assistant") {
2014
+ const toolResultsOnly =
2015
+ Array.isArray(message.content) &&
2016
+ message.content.length > 0 &&
2017
+ message.content.every(block => block.type === "tool_result");
2018
+ if (!toolResultsOnly) break;
2019
+ continue;
2020
+ }
2021
+ if (!Array.isArray(message.content)) continue;
2022
+ for (const block of message.content) {
2023
+ const key = thinkingReplayKey(block);
2024
+ if (key) keys.push(key);
2025
+ }
2026
+ }
2027
+ return keys;
2028
+ }
2029
+
2200
2030
  function rememberPrefixBindingFailure(
2201
2031
  params: MessageCreateParamsStreaming,
2202
2032
  message: string,
@@ -2406,13 +2236,33 @@ const streamAnthropicOnce = (
2406
2236
  };
2407
2237
  let dropAllThinking = droppedAllThinkingForSignature;
2408
2238
  let prefixBindingRetryAttempted = false;
2239
+ let latestTurnThinkingRetryAttempted = false;
2240
+ // Claude Code's `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS`; the wrapper
2241
+ // already turned the gated compat off, this covers caller/frozen inputs.
2242
+ const killSwitchActive = experimentalBetasDisabled(model);
2409
2243
  // A live fallback-credit redemption replays its frozen body.
2410
2244
  const frozenRedemption =
2411
- options?.fallbackCreditRedemption &&
2412
- Date.now() <= options.fallbackCreditRedemption.expiresAt &&
2413
- options.fallbackCreditRedemption.params
2245
+ options?.fallbackCreditRedemption && Date.now() <= options.fallbackCreditRedemption.expiresAt
2414
2246
  ? options.fallbackCreditRedemption
2415
2247
  : undefined;
2248
+ // A body frozen before the kill switch was set still carries the fields
2249
+ // it withholds; the credit token is bound to that body, so forfeit it
2250
+ // and build a fresh request. Server tools that already ran would re-run
2251
+ // and re-bill on a tokenless retry, so surface that case instead.
2252
+ const frozenBody = frozenRedemption?.params;
2253
+ // The redemption drops `fallbacks` before sending, so they cannot force a forfeit.
2254
+ const forfeitFrozenForKillSwitch =
2255
+ killSwitchActive &&
2256
+ frozenBody !== undefined &&
2257
+ carriesKillSwitchField({ ...frozenBody, fallbacks: undefined });
2258
+ if (
2259
+ forfeitFrozenForKillSwitch &&
2260
+ frozenRedemption?.refusedContent?.some(block => block.type === "anthropicServerTool")
2261
+ ) {
2262
+ throw new AIError.ConfigurationError(
2263
+ "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS was set after a refused turn that ran server tools; its fallback credit cannot be redeemed without the experimental fields the switch withholds.",
2264
+ );
2265
+ }
2416
2266
  // Collected at most once, on first params build; a frozen redemption
2417
2267
  // body never builds params.
2418
2268
  let controlRecords: AnthropicControlRecord[] | undefined;
@@ -2431,12 +2281,16 @@ const streamAnthropicOnce = (
2431
2281
  // `anthropic-beta` value, so once breakpoints are dropped an unstripped
2432
2282
  // base would re-attach the very cache beta the replay abandoned. Both
2433
2283
  // prompt-cache betas govern `ttl`/`scope`, so the options rung has to
2434
- // strip them too. Read through a closure because both flags flip
2435
- // mid-turn.
2436
- const callerBetaBaseHeaders = (): Record<string, string> =>
2437
- dropCacheControl || dropCacheControlOptions
2438
- ? stripPromptCacheBetas(mergedCallerHeaders)
2439
- : mergedCallerHeaders;
2284
+ // strip them too. The kill switch's betas never ride, whatever the
2285
+ // caller's header says. Read through a closure because both cache
2286
+ // flags flip mid-turn.
2287
+ const callerBetaBaseHeaders = (): Record<string, string> => {
2288
+ const headers =
2289
+ dropCacheControl || dropCacheControlOptions
2290
+ ? stripHeaderBetas(mergedCallerHeaders, promptCacheBetas)
2291
+ : mergedCallerHeaders;
2292
+ return killSwitchActive ? stripHeaderBetas(headers, killSwitchBetas) : headers;
2293
+ };
2440
2294
  // Keep fallback payloads aligned with the top-level Vertex effort gate:
2441
2295
  // no nested effort field means the fallback scan cannot re-add its beta.
2442
2296
  let fallbacks = options?.fallbacks;
@@ -2551,12 +2405,14 @@ const streamAnthropicOnce = (
2551
2405
  // `dropCacheControlOptions` short-circuits it because the breakpoint
2552
2406
  // it does carry has no `ttl` left to authorize. Both this gate and
2553
2407
  // buildParams resolve the breakpoint behind the same flags so the
2554
- // header and the body can never disagree.
2408
+ // header and the body can never disagree. Vertex rawPredict honors
2409
+ // `ttl: "1h"` without the beta and rejects it in either channel.
2555
2410
  const isOAuth = options?.isOAuth ?? isAnthropicOAuthToken(apiKey);
2556
2411
  if (
2557
2412
  !isOAuth &&
2558
2413
  !dropCacheControl &&
2559
2414
  !dropCacheControlOptions &&
2415
+ !isVertexRawPredictUrl(baseUrl) &&
2560
2416
  getCacheControl(model, options?.cacheRetention, isOAuth).cacheControl?.ttl === "1h" &&
2561
2417
  !extraBetas.includes(extendedCacheTtlBeta)
2562
2418
  ) {
@@ -2565,7 +2421,9 @@ const streamAnthropicOnce = (
2565
2421
  if (!isOAuth && isOfficialAnthropicApiUrl(baseUrl) && !extraBetas.includes(fallbackCreditBeta)) {
2566
2422
  extraBetas.push(fallbackCreditBeta);
2567
2423
  }
2568
- if (options?.fallbackCreditRedemption) {
2424
+ // A forfeited frozen body is rebuilt fresh, so it keeps the betas
2425
+ // computed above instead of the saved list describing the old body.
2426
+ if (options?.fallbackCreditRedemption && !forfeitFrozenForKillSwitch) {
2569
2427
  const frozenBetas = (options.fallbackCreditRedemption.betas ?? []).filter(
2570
2428
  b => !b.startsWith("server-side-fallback-"),
2571
2429
  );
@@ -2573,7 +2431,7 @@ const streamAnthropicOnce = (
2573
2431
  for (const beta of frozenBetas) {
2574
2432
  extraBetas.push(beta);
2575
2433
  }
2576
- if (!extraBetas.includes(fallbackCreditBeta) && !extraBetas.includes("fallback-credit-2026-06-01")) {
2434
+ if (!extraBetas.some(beta => beta.startsWith("fallback-credit-"))) {
2577
2435
  extraBetas.push(fallbackCreditBeta);
2578
2436
  }
2579
2437
  }
@@ -2598,6 +2456,8 @@ const streamAnthropicOnce = (
2598
2456
  }
2599
2457
  }
2600
2458
  }
2459
+ // Caller betas and a frozen redemption's beta list bypass compat.
2460
+ if (killSwitchActive) extraBetas = extraBetas.filter(beta => killSwitchBetas[beta] !== true);
2601
2461
 
2602
2462
  clientArgs = {
2603
2463
  model,
@@ -2609,6 +2469,8 @@ const streamAnthropicOnce = (
2609
2469
  dynamicHeaders: copilotDynamicHeaders?.headers,
2610
2470
  isOAuth: options?.isOAuth,
2611
2471
  hasTools: !!context.tools?.length,
2472
+ hasBufferedInputTools:
2473
+ context.tools?.some(tool => ANTHROPIC_BUFFERED_INPUT_TOOLS.has(tool.name)) ?? false,
2612
2474
  thinkingEnabled: options?.thinkingEnabled,
2613
2475
  thinkingDisplay: options?.thinkingDisplay,
2614
2476
  fetch: options?.fetch,
@@ -2659,6 +2521,8 @@ const streamAnthropicOnce = (
2659
2521
  if (replacementPayload !== undefined) {
2660
2522
  nextParams = replacementPayload as typeof nextParams;
2661
2523
  }
2524
+ // After `onPayload`, so a hook cannot restore a field the switch withholds.
2525
+ if (killSwitchActive) withholdKillSwitchFields(nextParams);
2662
2526
  if (nextParams.compaction) stripCompactionIncompatibleParams(nextParams);
2663
2527
  // After `onPayload`, so a hook cannot restore a field Bedrock rejects.
2664
2528
  if (model.compat.bedrockMessagesApi) fitBedrockAnthropicPayload(nextParams);
@@ -2696,31 +2560,18 @@ const streamAnthropicOnce = (
2696
2560
  let params: MessageCreateParamsStreaming;
2697
2561
  // Recheck expiry here: preparation (image resizing) is async, and an
2698
2562
  // expired token must fall back to a fresh request as before.
2699
- if (frozenRedemption && Date.now() <= frozenRedemption.expiresAt) {
2700
- const redemption = frozenRedemption;
2563
+ if (frozenRedemption && !forfeitFrozenForKillSwitch && Date.now() <= frozenRedemption.expiresAt) {
2701
2564
  usingFallbackCredit = true;
2702
- const frozenParams = structuredClone(redemption.params as MessageCreateParamsStreaming);
2703
- const targetModelId = options?.requestModelId ?? model.requestModelId ?? model.id;
2704
- frozenParams.model = targetModelId;
2705
- frozenParams.fallback_credit_token = redemption.token;
2706
- delete frozenParams.fallbacks;
2707
- if (
2708
- redemption.prefillClaim !== false &&
2709
- redemption.refusedContent &&
2710
- redemption.refusedContent.length > 0
2711
- ) {
2712
- fallbackCreditShape = "continuation";
2713
- const echoed = formatEchoedRefusalContent(redemption.refusedContent);
2714
- if (echoed.length > 0) {
2715
- frozenParams.messages = [
2716
- ...frozenParams.messages,
2717
- { role: "assistant", content: echoed } as unknown as (typeof frozenParams.messages)[number],
2718
- ];
2719
- }
2720
- } else {
2721
- fallbackCreditShape = "unchanged";
2722
- }
2723
- params = frozenParams;
2565
+ const echoed =
2566
+ frozenRedemption.prefillClaim !== false && frozenRedemption.refusedContent
2567
+ ? formatEchoedRefusalContent(frozenRedemption.refusedContent)
2568
+ : [];
2569
+ fallbackCreditShape = echoed.length > 0 ? "continuation" : "unchanged";
2570
+ params = fallbackCreditRequestBody(
2571
+ frozenRedemption,
2572
+ options?.requestModelId ?? model.requestModelId ?? model.id,
2573
+ echoed,
2574
+ );
2724
2575
  rawRequestDump = {
2725
2576
  provider: model.provider,
2726
2577
  api: output.api,
@@ -2760,11 +2611,14 @@ const streamAnthropicOnce = (
2760
2611
  if (unwrappedThinking !== undefined) {
2761
2612
  block.thinking = unwrappedThinking;
2762
2613
  block.thinkingSignature = undefined;
2763
- } else if (!output.upstreamModel && block.thinkingSignature) {
2614
+ } else if (block.thinkingSignature) {
2764
2615
  // The signature names the model that actually produced the
2765
2616
  // block; a gateway serving a different model than requested
2766
2617
  // cannot mint one that says otherwise.
2767
- output.upstreamModel = servedModelFromAnthropicSignature(block.thinkingSignature);
2618
+ if (!output.upstreamModel) {
2619
+ output.upstreamModel = servedModelFromAnthropicSignature(block.thinkingSignature);
2620
+ }
2621
+ if (isAnthropicNarrationSignature(block.thinkingSignature)) block.narration = true;
2768
2622
  }
2769
2623
  stream.push({ type: "thinking_end", contentIndex, content: block.thinking, partial: output });
2770
2624
  } else if (block.type === "anthropicServerTool" && block.block.type === "server_tool_use") {
@@ -2849,7 +2703,7 @@ const streamAnthropicOnce = (
2849
2703
  // max-wait budget covers the whole wait, not one attempt.
2850
2704
  const slowMode =
2851
2705
  options?.anthropicSlowMode !== undefined &&
2852
- model.provider === "anthropic" &&
2706
+ model.compat.firstPartyProvider &&
2853
2707
  isOAuthToken &&
2854
2708
  !options.client &&
2855
2709
  isOfficialAnthropicApiUrl(baseUrl)
@@ -2925,8 +2779,13 @@ const streamAnthropicOnce = (
2925
2779
  const rawBetas = options.fallbackCreditRedemption.betaHeader
2926
2780
  .split(",")
2927
2781
  .map(b => b.trim())
2928
- .filter(b => b && !b.startsWith("server-side-fallback-"));
2929
- if (!rawBetas.includes(fallbackCreditBeta) && !rawBetas.includes("fallback-credit-2026-06-01")) {
2782
+ .filter(
2783
+ b =>
2784
+ b &&
2785
+ !b.startsWith("server-side-fallback-") &&
2786
+ (!killSwitchActive || killSwitchBetas[b] !== true),
2787
+ );
2788
+ if (!rawBetas.some(beta => beta.startsWith("fallback-credit-"))) {
2930
2789
  rawBetas.push(fallbackCreditBeta);
2931
2790
  }
2932
2791
  perRequestHeaders = {
@@ -3156,15 +3015,15 @@ const streamAnthropicOnce = (
3156
3015
  }
3157
3016
  if (!firstTokenTime) firstTokenTime = performance.now();
3158
3017
  if (event.content_block.type === "fallback") {
3159
- // Fallback boundary is only meaningful when the request
3160
- // opted into the beta chain — silently drop otherwise so
3161
- // unopted-in sessions never see the block persisted or
3162
- // influence downstream converters.
3018
+ // The boundary between the primary's output and the
3019
+ // fallback model's. Persist it even when this request did
3020
+ // not opt into the beta chain: a gateway can opt in on the
3021
+ // client's behalf, and without the marker the replay
3022
+ // converter cannot tell the two models' signed reasoning
3023
+ // apart (see `collapseFallbackSegments`).
3163
3024
  const fallback = parseAnthropicFallbackWireBlock(event.content_block);
3164
- if (!serverSideFallback || !fallback) {
3165
- if (!fallback) {
3166
- reportAnthropicEnvelopeAnomaly("fallback content_block missing model refs");
3167
- }
3025
+ if (!fallback) {
3026
+ reportAnthropicEnvelopeAnomaly("fallback content_block missing model refs");
3168
3027
  openBlocks.set(event.index, { contentIndex: -1, kind: "ignored" });
3169
3028
  continue;
3170
3029
  }
@@ -3178,8 +3037,9 @@ const streamAnthropicOnce = (
3178
3037
  // classifier block on the primary was retried on the
3179
3038
  // fallback model. Adopt the served id immediately so
3180
3039
  // pricing decisions downstream (final usage.iterations may
3181
- // arrive before/after) see the right model.
3182
- output.model = fallback.to.model;
3040
+ // arrive before/after) see the right model. Served-model
3041
+ // promotion and per-iteration pricing stay opt-in.
3042
+ if (serverSideFallback) output.model = fallback.to.model;
3183
3043
  continue;
3184
3044
  }
3185
3045
  if (event.content_block.type === "text") {
@@ -3436,7 +3296,9 @@ const streamAnthropicOnce = (
3436
3296
  const label = category ? `Refusal (${category})` : "Refusal";
3437
3297
  output.errorMessage = explanation ? `${label}: ${explanation}` : label;
3438
3298
  }
3439
- if (stopDetails?.fallback_credit_token) {
3299
+ // Only a refusal carries a credit token. Refusal turns are never
3300
+ // written to the session, so the frozen body stays in memory.
3301
+ if (stopDetails?.type === "refusal" && stopDetails.fallback_credit_token) {
3440
3302
  const sentBetaHeader =
3441
3303
  getHeaderCaseInsensitive(perRequestHeaders ?? {}, "anthropic-beta") ??
3442
3304
  getHeaderCaseInsensitive(clientDefaultHeaders ?? {}, "anthropic-beta") ??
@@ -3447,7 +3309,7 @@ const streamAnthropicOnce = (
3447
3309
  params: structuredClone(params),
3448
3310
  betas: Array.from(requestExtraBetas),
3449
3311
  betaHeader: sentBetaHeader,
3450
- expiresAt: Date.now() + 5 * 60 * 1000,
3312
+ expiresAt: Date.now() + FALLBACK_CREDIT_TTL_MS,
3451
3313
  refusedContent: output.content ? structuredClone(output.content) : undefined,
3452
3314
  };
3453
3315
  }
@@ -3569,9 +3431,14 @@ const streamAnthropicOnce = (
3569
3431
  resetStreamOutputState();
3570
3432
  continue;
3571
3433
  }
3572
- if (usingFallbackCredit && firstTokenTime === undefined && isAnthropicBadRequest(streamFailure)) {
3434
+ if (
3435
+ usingFallbackCredit &&
3436
+ frozenRedemption &&
3437
+ firstTokenTime === undefined &&
3438
+ AIError.status(streamFailure) === 400
3439
+ ) {
3573
3440
  const errMessage = streamFailure instanceof Error ? streamFailure.message : String(streamFailure);
3574
- const redemption = options!.fallbackCreditRedemption!;
3441
+ const redemption = frozenRedemption;
3575
3442
  if (errMessage.includes("redemption temporarily unavailable")) {
3576
3443
  if (Date.now() < redemption.expiresAt && fallbackCreditTransientRetries < 2) {
3577
3444
  fallbackCreditTransientRetries++;
@@ -3597,12 +3464,11 @@ const streamAnthropicOnce = (
3597
3464
  },
3598
3465
  );
3599
3466
  fallbackCreditShape = "unchanged";
3600
- const frozenParams = structuredClone(redemption.params as MessageCreateParamsStreaming);
3601
- const targetModelId = options?.requestModelId ?? model.requestModelId ?? model.id;
3602
- frozenParams.model = targetModelId;
3603
- frozenParams.fallback_credit_token = redemption.token;
3604
- delete frozenParams.fallbacks;
3605
- params = frozenParams;
3467
+ params = fallbackCreditRequestBody(
3468
+ redemption,
3469
+ options?.requestModelId ?? model.requestModelId ?? model.id,
3470
+ undefined,
3471
+ );
3606
3472
  resetStreamOutputState();
3607
3473
  continue;
3608
3474
  }
@@ -3677,6 +3543,50 @@ const streamAnthropicOnce = (
3677
3543
  firstTokenTime = undefined;
3678
3544
  continue;
3679
3545
  }
3546
+ if (
3547
+ !latestTurnThinkingRetryAttempted &&
3548
+ firstTokenTime === undefined &&
3549
+ !streamedReplayUnsafeContent &&
3550
+ AIError.isImmutableLatestThinkingText(streamFailureMessage)
3551
+ ) {
3552
+ // The API refused the latest assistant turn's reasoning as modified
3553
+ // (e.g. a mid-turn fallback persisted without its boundary marker).
3554
+ // Resending the turn fails the same way forever, so drop its
3555
+ // reasoning on the wire — stored history keeps it — and retry once.
3556
+ // The session remembers the dropped blocks so later requests replay
3557
+ // the same prefix; without session state, drop every block instead.
3558
+ const keys = latestAssistantTurnThinkingKeys(params);
3559
+ if (keys.length > 0) {
3560
+ logger.warn(
3561
+ "anthropic: latest assistant turn's thinking rejected as modified, dropping it and retrying",
3562
+ {
3563
+ provider: model.provider,
3564
+ model: model.id,
3565
+ baseUrl,
3566
+ blocks: keys.length,
3567
+ },
3568
+ );
3569
+ latestTurnThinkingRetryAttempted = true;
3570
+ if (providerSessionState) {
3571
+ for (const key of keys) providerSessionState.prefixDroppedThinkingBlocks.add(key);
3572
+ } else {
3573
+ dropAllThinking = true;
3574
+ }
3575
+ params = await rebuildParams(streamFailure);
3576
+ providerRetryAttempt = 0;
3577
+ output.content.length = 0;
3578
+ output.model = model.id;
3579
+ output.responseId = undefined;
3580
+ output.upstreamModel = undefined;
3581
+ output.errorMessage = undefined;
3582
+ output.inputTransformations = undefined;
3583
+ output.providerPayload = undefined;
3584
+ output.usage = createEmptyUsage(copilotDynamicHeaders?.premiumRequests);
3585
+ output.stopReason = "stop";
3586
+ firstTokenTime = undefined;
3587
+ continue;
3588
+ }
3589
+ }
3680
3590
  if (
3681
3591
  !forceDemoteUnsignedThinking &&
3682
3592
  firstTokenTime === undefined &&
@@ -3927,18 +3837,16 @@ const streamAnthropicOnce = (
3927
3837
  firstTokenTime = undefined;
3928
3838
  }
3929
3839
  }
3930
- if (
3931
- usingFallbackCredit &&
3932
- fallbackCreditShape === "continuation" &&
3933
- options?.fallbackCreditRedemption?.refusedContent
3934
- ) {
3935
- // The response continues the echoed prefix; keep it in the stored turn in
3936
- // AssistantMessage form so later replays keep signatures and server tools.
3937
- output.content.unshift(...refusalContinuationPrefix(options.fallbackCreditRedemption.refusedContent));
3840
+ if (usingFallbackCredit && fallbackCreditShape === "continuation" && frozenRedemption?.refusedContent) {
3841
+ // The response continues the echoed prefix; keep it, signatures included, in
3842
+ // the stored turn. A tool loop requires the turn to open with its thinking
3843
+ // block, and a later rejection of the refused model's signature is
3844
+ // recovered by the invalid-signature retry.
3845
+ output.content.unshift(...refusalContinuationPrefix(frozenRedemption.refusedContent));
3938
3846
  }
3939
3847
  output.duration = performance.now() - startTime;
3940
3848
  if (firstTokenTime) output.ttft = firstTokenTime - startTime;
3941
- if (dropFastMode && model.provider === "anthropic" && options?.serviceTier === "priority") {
3849
+ if (dropFastMode && model.compat.firstPartyProvider && options?.serviceTier === "priority") {
3942
3850
  output.disabledFeatures = [...(output.disabledFeatures ?? []), "priority"];
3943
3851
  }
3944
3852
  if (forceDemoteUnsignedThinking && model.compat.replayUnsignedThinking) {
@@ -4003,10 +3911,97 @@ const streamAnthropicOnce = (
4003
3911
  * loop. The inner attempt owns Anthropic provider-failure retries.
4004
3912
  */
4005
3913
  export const streamAnthropic: StreamFunction<"anthropic-messages"> = (model, context, options) =>
4006
- withReplaySafeStreamRetry(model, context, options, streamAnthropicOnce, {
3914
+ withReplaySafeStreamRetry(withoutDisabledExperimentalBetas(model), context, options, streamAnthropicOnce, {
4007
3915
  retryEmptyCompletion: true,
4008
3916
  });
4009
3917
 
3918
+ /** Whether Claude Code's `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` applies to this model's provider. */
3919
+ function experimentalBetasDisabled(model: Model<"anthropic-messages">): boolean {
3920
+ return model.compat.honorsClaudeCodeBetaKillSwitch === true && $flag("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS");
3921
+ }
3922
+
3923
+ /**
3924
+ * Whether a request body carries a field the kill switch withholds. A frozen
3925
+ * fallback-credit body that does cannot be redeemed under the switch. Defined
3926
+ * as "the sanitizer would change it" so detection and stripping cannot drift.
3927
+ */
3928
+ function carriesKillSwitchField(params: MessageCreateParamsStreaming): boolean {
3929
+ const sanitized = structuredClone(params);
3930
+ withholdKillSwitchFields(sanitized);
3931
+ return !Bun.deepEquals(sanitized, params);
3932
+ }
3933
+
3934
+ /**
3935
+ * Remove every field the kill switch withholds from a final request body, for
3936
+ * payloads an `onPayload` hook replaced after compat already shaped them, and
3937
+ * for the switch's fields compat has no axis for (task budgets). Compaction
3938
+ * blocks keep their readable summary as text, the same form an endpoint
3939
+ * without compaction support receives.
3940
+ */
3941
+ function withholdKillSwitchFields(params: MessageCreateParamsStreaming): void {
3942
+ withholdKillSwitchControls(params);
3943
+ delete params.context_management;
3944
+ delete params.compaction;
3945
+ // Server-side fallback entries carry their own per-attempt controls.
3946
+ for (const entry of params.fallbacks ?? []) withholdKillSwitchControls(entry);
3947
+ for (const tool of params.tools ?? []) {
3948
+ if ("strict" in tool) delete tool.strict;
3949
+ if ("defer_loading" in tool) delete tool.defer_loading;
3950
+ if ("eager_input_streaming" in tool) delete tool.eager_input_streaming;
3951
+ }
3952
+ for (const message of params.messages) {
3953
+ // Per-message `output_config` (mid-conversation effort) may carry the same controls.
3954
+ if ("output_config" in message && message.output_config) withholdKillSwitchControls(message);
3955
+ if (!Array.isArray(message.content)) continue;
3956
+ for (let i = 0; i < message.content.length; i++) {
3957
+ const block = message.content[i];
3958
+ if (block.type === "compaction") message.content[i] = { type: "text", text: block.content };
3959
+ }
3960
+ }
3961
+ if (params.anthropic_beta) {
3962
+ params.anthropic_beta = params.anthropic_beta.filter(beta => killSwitchBetas[beta] !== true);
3963
+ }
3964
+ }
3965
+
3966
+ /** Withhold the kill switch's thinking/output controls on a body, fallback entry, or message. */
3967
+ function withholdKillSwitchControls(holder: { thinking?: ThinkingConfigParam; output_config?: OutputConfig }): void {
3968
+ if (holder.thinking && "block_binding" in holder.thinking) delete holder.thinking.block_binding;
3969
+ if (!holder.output_config) return;
3970
+ delete holder.output_config.task_budget;
3971
+ delete holder.output_config.format;
3972
+ if (Object.keys(holder.output_config).length === 0) delete holder.output_config;
3973
+ }
3974
+
3975
+ /**
3976
+ * Honor Claude Code's `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` kill switch on
3977
+ * providers whose compat opts in (`honors-claude-code-beta-kill-switch`).
3978
+ * Gateways behind `ANTHROPIC_BASE_URL` often reject pre-release body fields,
3979
+ * so the switch withholds what Claude Code's gateway contract lists — context
3980
+ * management, beta tool fields (`strict`, `defer_loading`,
3981
+ * `eager_input_streaming`), tool search (mid-conversation tool changes),
3982
+ * structured-output format, task budgets — plus `thinking.block_binding` and
3983
+ * server compaction (whose legacy replay needs a `context_management` edit),
3984
+ * together with their betas. Effort, adaptive thinking, interleaved thinking
3985
+ * and extended context stay, as in Claude Code. Applied through compat so every
3986
+ * request builder and retry rung sees one decision; {@link withholdKillSwitchFields}
3987
+ * and {@link killSwitchBetas} cover the rest where bodies and betas are finalized.
3988
+ */
3989
+ function withoutDisabledExperimentalBetas(model: Model<"anthropic-messages">): Model<"anthropic-messages"> {
3990
+ if (!experimentalBetasDisabled(model)) return model;
3991
+ return {
3992
+ ...model,
3993
+ compat: {
3994
+ ...model.compat,
3995
+ supportsContextManagement: false,
3996
+ supportsThinkingBindingControls: false,
3997
+ supportsServerCompaction: false,
3998
+ disableStrictTools: true,
3999
+ supportsEagerToolInputStreaming: false,
4000
+ supportsMidConversationToolChanges: false,
4001
+ },
4002
+ };
4003
+ }
4004
+
4010
4005
  export type AnthropicSystemBlock = {
4011
4006
  type: "text";
4012
4007
  text: string;
@@ -4091,6 +4086,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
4091
4086
  headers,
4092
4087
  dynamicHeaders,
4093
4088
  hasTools = false,
4089
+ hasBufferedInputTools = false,
4094
4090
  thinkingEnabled = false,
4095
4091
  isOAuth,
4096
4092
  maxRetryDelayMs,
@@ -4129,8 +4125,9 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
4129
4125
  !hostMatchesUrl(baseUrl, "githubCopilot")));
4130
4126
  const oauthToken = isOAuth ?? isAnthropicOAuthToken(apiKey);
4131
4127
  const supportsEagerToolInputStreaming = resolveEagerToolInputStreamingSupport(model, baseUrl);
4128
+ // The beta streams every tool eagerly, so it is skipped when a tool needs buffered input.
4132
4129
  const needsFineGrainedToolStreamingBeta =
4133
- hasTools && isOfficialAnthropicApiUrl(baseUrl) && !supportsEagerToolInputStreaming;
4130
+ hasTools && !hasBufferedInputTools && isOfficialAnthropicApiUrl(baseUrl) && !supportsEagerToolInputStreaming;
4134
4131
  const foundryCustomHeaders = resolveAnthropicCustomHeaders(model, baseUrl);
4135
4132
  const tlsFetchOptions = buildClaudeCodeTlsFetchOptions(model, baseUrl);
4136
4133
  // Disable Bun's native ~300s pre-response fetch timeout (issue #2422).
@@ -4138,7 +4135,10 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
4138
4135
  // per request, so the native ceiling can only short-circuit slow-prefill
4139
4136
  // streams before the configured watchdog gets to govern them.
4140
4137
  const fetchOptions: AnthropicFetchOptions = { ...tlsFetchOptions, timeout: false };
4141
- const baseFetch = args.fetch ?? fetch;
4138
+ // Compression sits beneath the cch rewriter, which must hash the uncompressed
4139
+ // body. Catalog rules scope it to verified hosts (official API, Copilot), so
4140
+ // proxies pass through.
4141
+ const baseFetch = wrapFetchForRequestCompression(args.fetch ?? fetch, model);
4142
4142
  // Only OAuth requests inject the CC billing header; no API-key request can ever
4143
4143
  // contain it, so there is no need to install the rewriter for those.
4144
4144
  const cchFetch = oauthToken ? wrapFetchForCch(baseFetch) : baseFetch;
@@ -4163,7 +4163,9 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
4163
4163
  // Unlike the generic builder below, this branch has no enforced-key
4164
4164
  // filter: a caller-supplied `anthropic-beta` lands in the default
4165
4165
  // headers verbatim, cache betas included.
4166
- const defaultHeaders = dropPromptCacheBetas ? stripPromptCacheBetas(mergedCopilotHeaders) : mergedCopilotHeaders;
4166
+ const defaultHeaders = dropPromptCacheBetas
4167
+ ? stripHeaderBetas(mergedCopilotHeaders, promptCacheBetas)
4168
+ : mergedCopilotHeaders;
4167
4169
  applyInferenceHeaders(defaultHeaders, {
4168
4170
  provider: model.provider,
4169
4171
  protocol: "anthropic",
@@ -4208,9 +4210,13 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
4208
4210
  // value outright — but `allowAnthropicHeaderOverrides` lets it replace our
4209
4211
  // built beta header wholesale, which would put a cache beta back on a
4210
4212
  // breakpoint-free request.
4211
- const requestModelHeaders = dropPromptCacheBetas
4212
- ? stripPromptCacheBetas(mergedRequestHeaders)
4213
+ // The kill switch's betas are stripped on the same channel.
4214
+ const cacheStrippedHeaders = dropPromptCacheBetas
4215
+ ? stripHeaderBetas(mergedRequestHeaders, promptCacheBetas)
4213
4216
  : mergedRequestHeaders;
4217
+ const requestModelHeaders = experimentalBetasDisabled(model)
4218
+ ? stripHeaderBetas(cacheStrippedHeaders, killSwitchBetas)
4219
+ : cacheStrippedHeaders;
4214
4220
  const defaultHeaders = buildAnthropicHeaders({
4215
4221
  apiKey,
4216
4222
  baseUrl,
@@ -4298,6 +4304,20 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
4298
4304
  };
4299
4305
  }
4300
4306
 
4307
+ /**
4308
+ * Warm-up target for a first-party `anthropic` model: the effective official
4309
+ * API URL plus the TLS options real requests use (sockets pool per TLS
4310
+ * configuration, so a warm-up made with other options is never reused).
4311
+ * `undefined` when requests resolve elsewhere — `ANTHROPIC_BASE_URL` or
4312
+ * Foundry gateways never receive unsolicited probes.
4313
+ */
4314
+ export function resolveAnthropicPrewarmTarget(model: Model<Api>): { url: string; init: RequestInit } | undefined {
4315
+ if (!isAnthropicMessagesModel(model)) return undefined;
4316
+ const baseUrl = resolveAnthropicBaseUrl(model) ?? "https://api.anthropic.com";
4317
+ if (!isOfficialAnthropicApiUrl(baseUrl)) return undefined;
4318
+ return { url: baseUrl, init: { ...buildClaudeCodeTlsFetchOptions(model, baseUrl) } };
4319
+ }
4320
+
4301
4321
  function createClient(
4302
4322
  model: Model<"anthropic-messages">,
4303
4323
  args: AnthropicClientOptionsArgs,
@@ -5144,7 +5164,24 @@ type AnthropicParamBuildOptions = {
5144
5164
  * priority-tier request to first-party Anthropic.
5145
5165
  */
5146
5166
  function wantsAnthropicFastMode(model: Model<"anthropic-messages">, options: AnthropicOptions | undefined): boolean {
5147
- return model.compat.fastMode === true || (model.provider === "anthropic" && options?.serviceTier === "priority");
5167
+ return model.compat.fastMode === true || (model.compat.firstPartyProvider && options?.serviceTier === "priority");
5168
+ }
5169
+
5170
+ /**
5171
+ * The block that decides whether an assistant turn is thinking-led. A
5172
+ * `fallback` boundary is bookkeeping, not model output: a response that fell
5173
+ * back before the primary emitted any reasoning opens with
5174
+ * `[fallback, thinking(fallbackModel), …]` and is still a thinking-led turn,
5175
+ * so treat the marker as transparent. Tolerates malformed blocks replayed off
5176
+ * disk.
5177
+ */
5178
+ function thinkingLedLeadBlock(content: readonly unknown[]): { type?: unknown } | undefined {
5179
+ for (const block of content) {
5180
+ if (block == null || typeof block !== "object") return undefined;
5181
+ const typed = block as { type?: unknown };
5182
+ if (typed.type !== "fallback") return typed;
5183
+ }
5184
+ return undefined;
5148
5185
  }
5149
5186
 
5150
5187
  /**
@@ -5158,14 +5195,8 @@ function hasThinkinglessAssistantHistory(messages: readonly Message[]): boolean
5158
5195
  if (message.role !== "assistant") continue;
5159
5196
  if (!Array.isArray(message.content) || message.content.length === 0) continue;
5160
5197
  hasAssistant = true;
5161
- const first = message.content[0];
5162
- if (
5163
- first != null &&
5164
- typeof first === "object" &&
5165
- (first.type === "thinking" || first.type === "redactedThinking")
5166
- ) {
5167
- return false;
5168
- }
5198
+ const first = thinkingLedLeadBlock(message.content);
5199
+ if (first?.type === "thinking" || first?.type === "redactedThinking") return false;
5169
5200
  }
5170
5201
  return hasAssistant;
5171
5202
  }
@@ -5188,8 +5219,8 @@ function hasNonThinkingTurnAfterLastUser(messages: readonly Message[]): boolean
5188
5219
  const next = messages[lastUser + 1];
5189
5220
  if (next.role !== "assistant") return false;
5190
5221
  if (!Array.isArray(next.content) || next.content.length === 0) return false;
5191
- const first = next.content[0];
5192
- if (first == null || typeof first !== "object") return false;
5222
+ const first = thinkingLedLeadBlock(next.content);
5223
+ if (first === undefined) return false;
5193
5224
  return first.type !== "thinking" && first.type !== "redactedThinking";
5194
5225
  }
5195
5226
 
@@ -5700,14 +5731,46 @@ function toWellFormedDeep(value: unknown): unknown {
5700
5731
  return value;
5701
5732
  }
5702
5733
 
5734
+ /**
5735
+ * Collapse a server-side-fallback turn for a request that cannot carry the
5736
+ * `fallback` boundary marker: drop the markers and keep reasoning from one
5737
+ * model only — the last segment that has any. Each model signed its reasoning
5738
+ * against the turn's original prefix; replayed back-to-back without the
5739
+ * marker, Anthropic validates them as one chain and rejects the latest turn
5740
+ * with "`thinking` or `redacted_thinking` blocks in the latest assistant
5741
+ * message cannot be modified". A turn whose reasoning sits in one segment
5742
+ * keeps all of it.
5743
+ */
5744
+ function collapseFallbackSegments(content: AssistantMessage["content"]): AssistantMessage["content"] {
5745
+ let segments = 0;
5746
+ let reasoningSegment = 0;
5747
+ for (const block of content) {
5748
+ if (block.type === "fallback") segments++;
5749
+ else if (block.type === "thinking" || block.type === "redactedThinking") reasoningSegment = segments;
5750
+ }
5751
+ if (segments === 0) return content;
5752
+ const collapsed: AssistantMessage["content"] = [];
5753
+ let segment = 0;
5754
+ for (const block of content) {
5755
+ if (block.type === "fallback") {
5756
+ segment++;
5757
+ continue;
5758
+ }
5759
+ if ((block.type === "thinking" || block.type === "redactedThinking") && segment !== reasoningSegment) continue;
5760
+ collapsed.push(block);
5761
+ }
5762
+ return collapsed;
5763
+ }
5764
+
5703
5765
  /**
5704
5766
  * Serialize zeta {@link Message}s to Anthropic wire messages.
5705
5767
  *
5706
5768
  * `opts.serverSideFallbackEnabled` — when the CURRENT request itself
5707
- * opts into the server-side-fallback beta chain. Only then may a persisted
5708
- * `fallback` content block from a prior turn be replayed on the wire;
5709
- * otherwise the block is dropped to avoid a 400 on non-fallback requests
5710
- * that don't send the beta.
5769
+ * opts into the server-side-fallback beta chain. Only then, and only on the
5770
+ * official endpoint, may a persisted `fallback` content block from a prior
5771
+ * turn be replayed on the wire; otherwise the block is dropped to avoid a 400
5772
+ * on requests that don't send the beta, and its turn keeps one model's
5773
+ * reasoning (see {@link collapseFallbackSegments}).
5711
5774
  *
5712
5775
  * `opts.replayCompaction` — replay a user-role compaction summary that
5713
5776
  * carries an {@link AnthropicCompactionPayload} from this provider as a
@@ -5731,9 +5794,9 @@ export function convertAnthropicMessages(
5731
5794
  compactionRequest?: AnthropicCompactionRequest;
5732
5795
  },
5733
5796
  ): AnthropicMessageParam[] {
5734
- // Indices of params emitted from `developer` messages. After the main pass,
5735
- // the ones whose placement satisfies Anthropic's mid-conversation rules are
5736
- // upgraded from the `user` role to the authoritative `system` role.
5797
+ // Indices of params emitted from agent-owned `developer` messages. After the
5798
+ // main pass, the ones whose placement satisfies Anthropic's mid-conversation
5799
+ // rules are upgraded from the `user` role to the authoritative `system` role.
5737
5800
  const developerParams: Array<{ index: number; payload?: AnthropicMessagePayload }> = [];
5738
5801
  const params: AnthropicMessageParam[] = [];
5739
5802
  // Controls rendered from request-control markers. The assistant repairs
@@ -5772,7 +5835,7 @@ export function convertAnthropicMessages(
5772
5835
  const transformedMessages = transformMessages(
5773
5836
  messages,
5774
5837
  model,
5775
- normalizeToolCallId,
5838
+ id => normalizeToolCallId(id),
5776
5839
  undefined,
5777
5840
  undefined,
5778
5841
  undefined,
@@ -5860,7 +5923,13 @@ export function convertAnthropicMessages(
5860
5923
  blocks.push(...anthropicToolChangeBlocks(payload.toolChanges, isOAuthToken, model));
5861
5924
  content = blocks;
5862
5925
  }
5863
- if (msg.role === "developer") developerParams.push({ index: params.length, payload });
5926
+ // User-attributed developer turns (text `@file` mentions, hidden extension
5927
+ // messages) only borrow the slot: their content is the user's, so it stays
5928
+ // on `user` instead of gaining system authority or ending the request in
5929
+ // place of the user's turn. Provider controls only exist on `system`.
5930
+ if (msg.role === "developer" && (msg.attribution !== "user" || hasProviderControls)) {
5931
+ developerParams.push({ index: params.length, payload });
5932
+ }
5864
5933
  const param: AnthropicMessageParam & ConversationalUserCarrier = { role: "user", content };
5865
5934
  // Record that this wire `user` came from a real conversational turn, so
5866
5935
  // prompt-cache decimation can tell it apart from everything else that
@@ -5876,7 +5945,14 @@ export function convertAnthropicMessages(
5876
5945
  params.push(param);
5877
5946
  } else if (msg.role === "assistant") {
5878
5947
  const blocks: ContentBlockParam[] = [];
5879
- const hasSignedThinking = msg.content.some(
5948
+ // The fallback boundary replays ONLY when both sides are aligned: the
5949
+ // current request opted into the beta chain, and the target is
5950
+ // official Anthropic (the only endpoint that accepts the block).
5951
+ const content =
5952
+ opts?.serverSideFallbackEnabled && model.compat.officialEndpoint
5953
+ ? msg.content
5954
+ : collapseFallbackSegments(msg.content);
5955
+ const hasSignedThinking = content.some(
5880
5956
  block =>
5881
5957
  block.type === "thinking" && !!block.thinkingSignature && block.thinkingSignature.trim().length > 0,
5882
5958
  );
@@ -5894,7 +5970,7 @@ export function convertAnthropicMessages(
5894
5970
  blocks.push(compactionBlockParam(msg.providerPayload));
5895
5971
  }
5896
5972
 
5897
- for (const block of msg.content) {
5973
+ for (const block of content) {
5898
5974
  if (block.type === "text") {
5899
5975
  if (block.text.trim().length === 0) continue;
5900
5976
  blocks.push({
@@ -5955,13 +6031,7 @@ export function convertAnthropicMessages(
5955
6031
  } else if (block.type === "anthropicServerTool") {
5956
6032
  blocks.push(block.block);
5957
6033
  } else if (block.type === "fallback") {
5958
- // Replay ONLY when both sides are aligned: the current
5959
- // request opted into the beta chain, and the target is
5960
- // official Anthropic (the only endpoint that accepts the
5961
- // block on the wire). `transformMessages` already drops
5962
- // the block for cross-provider / non-official replays, so
5963
- // this is defense-in-depth for direct convert calls.
5964
- if (!opts?.serverSideFallbackEnabled || !model.compat.officialEndpoint) continue;
6034
+ // Present only when this request can carry it (see `content`).
5965
6035
  blocks.push({
5966
6036
  type: "fallback",
5967
6037
  from: block.from,
@@ -6254,10 +6324,20 @@ const ANTHROPIC_TOOL_SCHEMA_STRING_FORMATS = new Set([
6254
6324
  // Not `bash`: strict decoding fixes property order, so an optional key declared
6255
6325
  // before one the model has already written can no longer be emitted, and bash's
6256
6326
  // `timeout` vanished from every `async`-first call.
6257
- const ANTHROPIC_STRICT_TOOL_ALLOWLIST = new Set(["python", "edit", "find"]);
6327
+ // `yield` carries a subagent's whole structured result. Its properties (`type`,
6328
+ // `data`, `error`) are declared in the order callers write them; schema-less and
6329
+ // workpool yields report `strict: false`. Strict only holds together with buffered
6330
+ // input streaming (see ANTHROPIC_BUFFERED_INPUT_TOOLS).
6331
+ const ANTHROPIC_STRICT_TOOL_ALLOWLIST = new Set(["python", "edit", "find", "yield"]);
6258
6332
  const MAX_ANTHROPIC_STRICT_TOOLS = 20;
6259
6333
  const MAX_ANTHROPIC_STRICT_OPTIONAL_PARAMETERS = 24;
6260
6334
  const MAX_ANTHROPIC_STRICT_UNION_PARAMETERS = 16;
6335
+ // Tools sent with `eager_input_streaming: false`, so the API buffers and validates
6336
+ // their input. Eagerly streamed `yield` input from Claude Haiku arrived as invalid
6337
+ // JSON (a large nested result closed with one brace too many) even with `strict`
6338
+ // set; buffered, it did not. Where the per-tool flag is unsupported, their presence
6339
+ // drops the request-wide fine-grained streaming beta instead.
6340
+ const ANTHROPIC_BUFFERED_INPUT_TOOLS = new Set(["yield"]);
6261
6341
 
6262
6342
  /** `minItems` / `maxItems` apply to arrays; Anthropic rejects them on `type: "object"` (including `minItems: 0`/`1`). */
6263
6343
  function isJsonSchemaArrayNode(schema: Record<string, unknown>): boolean {
@@ -6721,7 +6801,9 @@ function convertTools(
6721
6801
  };
6722
6802
  return {
6723
6803
  ...baseTool,
6724
- ...(supportsEagerToolInputStreaming ? { eager_input_streaming: true } : {}),
6804
+ ...(supportsEagerToolInputStreaming
6805
+ ? { eager_input_streaming: !ANTHROPIC_BUFFERED_INPUT_TOOLS.has(tool.name) }
6806
+ : {}),
6725
6807
  ...(plan.strict ? { strict: true } : {}),
6726
6808
  ...(tool.deferLoading ? { defer_loading: true } : {}),
6727
6809
  };