@linxiraos/pi-ai 1.2.1 → 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 +7 -7
  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
@@ -3,7 +3,8 @@
3
3
  * encodes, and decodes one request for each API it serves. Internal to the
4
4
  * judgment module; `native.ts` maps every judgment API to its dialect.
5
5
  */
6
- import type { Usage } from "../types";
6
+ import type { ImageContent, Usage } from "../types";
7
+ import type { JudgmentImageInput } from "@linxiraos/pi-catalog/compat/judgment-images";
7
8
  import type { JudgmentRequest } from "./types";
8
9
 
9
10
  /** Where one attempt posts and which bearer token it sends. */
@@ -23,6 +24,9 @@ export interface DecodedJudgment {
23
24
  usage: Usage;
24
25
  }
25
26
 
27
+ /** Image assets kept separately from judgment state for asset-aware native wires. */
28
+ export type JudgmentImageAssets = Readonly<Record<string, ImageContent>>;
29
+
26
30
  /** How one judgment API addresses, encodes, and decodes a request. */
27
31
  export interface JudgmentWire {
28
32
  /**
@@ -30,8 +34,18 @@ export interface JudgmentWire {
30
34
  * Throws when the credential cannot address the endpoint (e.g. no account id).
31
35
  */
32
36
  target(baseUrl: string, model: string, key: string): JudgmentTarget;
33
- /** Request body for `model`. */
34
- encode(request: JudgmentRequest, model: string): unknown;
37
+ /** Request body for `model`, with optional asset context for dialects that preserve references. */
38
+ encode(request: JudgmentRequest, model: string, imageAssets?: JudgmentImageAssets): unknown;
39
+ /** Headers this dialect adds to requests that carry images. */
40
+ imageHeaders?: Readonly<Record<string, string>>;
41
+ /** Image input this dialect accepts; absent means text-only. */
42
+ images?: JudgmentImageInput;
43
+ /** Whether raw asset maps can be transported without loss by this dialect. */
44
+ supportsImageAssets?: boolean;
45
+ /** Whether image support must be enabled explicitly for direct NativeJudge construction. */
46
+ imagesRequireOptIn?: boolean;
47
+ /** Whether this wire drops per-image detail metadata. */
48
+ ignoresImageDetail?: boolean;
35
49
  /** The response's answers and usage; `undefined` when the payload is not this API's response shape. */
36
50
  decode(payload: unknown): DecodedJudgment | undefined;
37
51
  }
@@ -18,8 +18,8 @@
18
18
  * siblings are endpoint-scoped and stay: `strictToolsDisabled`
19
19
  * (grammar-too-large 400 for the model's tool schema),
20
20
  * `replayUnsignedThinkingDisabled` / `thinkingReplayDisabled` (the endpoint
21
- * is a signing proxy), `prefixDroppedThinkingBlocks` (blocks a prefix-binding
22
- * 400 made the client strip).
21
+ * is a signing proxy), `prefixDroppedThinkingBlocks` (blocks a rejected
22
+ * replay made the client strip).
23
23
  * - **OpenAI Responses** — the `previous_response_id` chain baselines are
24
24
  * account-scoped: a stored response belongs to the account that created it.
25
25
  * Strict-tools / reasoning-effort fallbacks, replay warmup and the chaining
@@ -50,6 +50,7 @@ import { invalidateAwsCredentialCache, resolveAwsCredentials } from "./aws-crede
50
50
  import { decodeEventStream } from "./aws-eventstream";
51
51
  import { signRequest } from "./aws-sigv4";
52
52
  import { isThinkingPrefixBindingError } from "./anthropic";
53
+ import { isAnthropicNarrationSignature } from "./anthropic-signature";
53
54
  import { parseAnthropicInputTransformations, THINKING_BINDING_CONTROLS_BETA } from "./anthropic-wire";
54
55
  import { isBedrockRequestMetadataValue } from "./bedrock-request-metadata";
55
56
  import { transformMessages } from "./transform-messages";
@@ -977,6 +978,8 @@ function handleContentBlockStop(
977
978
  stream.push({ type: "text_end", contentIndex: index, content: block.text, partial: output });
978
979
  break;
979
980
  case "thinking":
981
+ // Claude on Bedrock carries Anthropic's signature format, narration kind included.
982
+ if (block.thinkingSignature && isAnthropicNarrationSignature(block.thinkingSignature)) block.narration = true;
980
983
  stream.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: output });
981
984
  break;
982
985
  case "toolCall":
@@ -1087,7 +1090,7 @@ function convertMessages(
1087
1090
  promptCachePolicy: BedrockPromptCachePolicy,
1088
1091
  ): WireMessage[] {
1089
1092
  const result: WireMessage[] = [];
1090
- const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
1093
+ const transformedMessages = transformMessages(context.messages, model, id => normalizeToolCallId(id));
1091
1094
 
1092
1095
  for (let i = 0; i < transformedMessages.length; i++) {
1093
1096
  const m = transformedMessages[i];
@@ -278,7 +278,7 @@ export class AnthropicHttpClient {
278
278
  continue;
279
279
  }
280
280
  if (error instanceof AIError.AnthropicConnectionTimeoutError) throw error;
281
- throw new AIError.AnthropicConnectionError(error);
281
+ throw new AIError.AnthropicConnectionError(error, url);
282
282
  }
283
283
 
284
284
  if (response.ok) return response;
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Claude's image contract, enforced before a request leaves the client. Every
3
+ * wire that delivers images to a Claude reader shares it: `anthropic-messages`
4
+ * always, and Google's Cloud Code Assist wire when resolved policy declares
5
+ * the host's limits (`max-image-dimension` / `max-image-payload-bytes`).
6
+ */
7
+ import { logger } from "@linxiraos/pi-utils";
8
+ import type { Context, ImageContent, Message, Model, TextContent } from "../types";
9
+
10
+ const ANTHROPIC_MANY_IMAGE_THRESHOLD = 20;
11
+ const ANTHROPIC_MANY_IMAGE_MAX_DIMENSION = 2000;
12
+ /**
13
+ * Image contract of the canonical Anthropic Messages API, used when resolved
14
+ * model policy names no other: 8000px per side ("At least one of the image
15
+ * dimensions exceed max allowed size: 8000 pixels") and a 10 MB base64
16
+ * payload per block ("image exceeds 10 MB maximum: 14012300 bytes >
17
+ * 10485760 bytes"), measured on the encoded string rather than the decoded
18
+ * bytes. Either rejection poisons every retry and model fallback because the
19
+ * block stays in context, so both are clamped before the request goes out.
20
+ * A host with a different image contract sets `max-image-dimension` /
21
+ * `max-image-payload-bytes` in the KDL compatibility rules.
22
+ */
23
+ const ANTHROPIC_MAX_IMAGE_DIMENSION = 8000;
24
+ const ANTHROPIC_MAX_IMAGE_PAYLOAD_BYTES = 10 * 1024 * 1024;
25
+ /** Headroom for the surrounding JSON and any provider-side accounting. */
26
+ const ANTHROPIC_IMAGE_PAYLOAD_HEADROOM = 0.9;
27
+ /** Re-encode passes allowed to bring one image under the byte budget. */
28
+ const ANTHROPIC_IMAGE_RESIZE_ATTEMPTS = 4;
29
+
30
+ /**
31
+ * Image bounds for one request. `maxDimension` and `payloadBudget` come from
32
+ * resolved model policy (`max-image-dimension`, `max-image-payload-bytes`) so
33
+ * a deployment whose image contract differs from the canonical API can
34
+ * override them, and the many-image path tightens `maxDimension` further
35
+ * because many images share one request budget.
36
+ */
37
+ interface AnthropicImageCaps {
38
+ maxDimension: number;
39
+ payloadBudget: number;
40
+ }
41
+
42
+ function countAnthropicImageBlocks(messages: Message[]): number {
43
+ let count = 0;
44
+ for (const message of messages) {
45
+ if (message.role !== "user" && message.role !== "developer" && message.role !== "toolResult") continue;
46
+ if (!Array.isArray(message.content)) continue;
47
+ for (const block of message.content) {
48
+ if (block.type === "image") count++;
49
+ }
50
+ }
51
+ return count;
52
+ }
53
+
54
+ /**
55
+ * Ceiling on encoder output pixels in flight. One 8000x8000 RGBA surface is
56
+ * roughly 256 MiB, so admitting four of them would spike the CLI by a
57
+ * gigabyte; at the full-resolution cap this yields a single pipeline, while
58
+ * the many-image cap encodes at most 2000px and keeps the four-way fan-out.
59
+ */
60
+ const ANTHROPIC_IMAGE_RESIZE_PIXEL_BUDGET = 16_000_000;
61
+ const ANTHROPIC_IMAGE_RESIZE_MAX_CONCURRENCY = 4;
62
+
63
+ function anthropicImageResizeConcurrency(maxDimension: number): number {
64
+ const perPipeline = maxDimension * maxDimension;
65
+ if (perPipeline <= 0) return ANTHROPIC_IMAGE_RESIZE_MAX_CONCURRENCY;
66
+ return Math.max(
67
+ 1,
68
+ Math.min(ANTHROPIC_IMAGE_RESIZE_MAX_CONCURRENCY, Math.floor(ANTHROPIC_IMAGE_RESIZE_PIXEL_BUDGET / perPipeline)),
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Memoized resize results keyed on ImageContent identity, per cap pair.
74
+ * Callers keep message objects stable across turns, so without this every
75
+ * request (and every in-provider retry of a fresh turn) re-decodes and
76
+ * re-encodes the same oversized screenshots. A cached value identical to the
77
+ * key means "already within bounds / unresizable — skip the decode". The caps
78
+ * depend on image count and on resolved policy, so each pair keeps its own
79
+ * cache: a value cached at 8000px is not reusable at 2000px.
80
+ */
81
+ const anthropicImageResizeCaches = new Map<string, WeakMap<ImageContent, ImageContent>>();
82
+
83
+ function anthropicImageResizeCache(caps: AnthropicImageCaps): WeakMap<ImageContent, ImageContent> {
84
+ const key = `${caps.maxDimension}:${caps.payloadBudget}`;
85
+ const existing = anthropicImageResizeCaches.get(key);
86
+ if (existing) return existing;
87
+ const created = new WeakMap<ImageContent, ImageContent>();
88
+ anthropicImageResizeCaches.set(key, created);
89
+ return created;
90
+ }
91
+
92
+ type ResizeLimiter = <R>(fn: () => Promise<R>) => Promise<R>;
93
+
94
+ /**
95
+ * Bounded-concurrency gate for image decode/encode work. The many-image path
96
+ * fans out over every block of every message; unbounded, 100+ oversized images
97
+ * would decode concurrently and spike memory by gigabytes. Slots are handed
98
+ * off directly to the next waiter on release.
99
+ */
100
+ function createResizeLimiter(limit: number): ResizeLimiter {
101
+ let active = 0;
102
+ const queue: (() => void)[] = [];
103
+ return async fn => {
104
+ if (active >= limit) {
105
+ const { promise, resolve } = Promise.withResolvers<void>();
106
+ queue.push(resolve);
107
+ await promise;
108
+ } else {
109
+ active++;
110
+ }
111
+ try {
112
+ return await fn();
113
+ } finally {
114
+ const next = queue.shift();
115
+ if (next) next();
116
+ else active--;
117
+ }
118
+ };
119
+ }
120
+
121
+ async function resizeAnthropicImageBlock(block: ImageContent, caps: AnthropicImageCaps): Promise<ImageContent> {
122
+ const { maxDimension, payloadBudget } = caps;
123
+ try {
124
+ const inputBuffer = Buffer.from(block.data, "base64");
125
+ const { width, height } = await new Bun.Image(inputBuffer).metadata();
126
+ if (!width || !height) return block;
127
+ const overDimension = width > maxDimension || height > maxDimension;
128
+ const overBudget = block.data.length > payloadBudget;
129
+ if (!overDimension && !overBudget) return block;
130
+
131
+ // Fitting the pixel cap does not imply fitting the byte cap: a dense
132
+ // screenshot re-encodes large even at the cap. Shrink by the overshoot in
133
+ // area terms until the payload fits or the passes run out.
134
+ let scale = Math.min(1, maxDimension / width, maxDimension / height);
135
+ let best: { buffer: Uint8Array; mimeType: string } | undefined;
136
+ for (let attempt = 0; attempt < ANTHROPIC_IMAGE_RESIZE_ATTEMPTS; attempt++) {
137
+ const targetWidth = Math.max(1, Math.min(maxDimension, Math.round(width * scale)));
138
+ const targetHeight = Math.max(1, Math.min(maxDimension, Math.round(height * scale)));
139
+ // One encoder at a time: a full-resolution surface is hundreds of
140
+ // megabytes, so the two candidates never coexist.
141
+ const png = await new Bun.Image(inputBuffer).resize(targetWidth, targetHeight).png().bytes();
142
+ const jpeg = await new Bun.Image(inputBuffer).resize(targetWidth, targetHeight).jpeg({ quality: 85 }).bytes();
143
+ best =
144
+ png.length <= jpeg.length
145
+ ? { buffer: png, mimeType: "image/png" }
146
+ : { buffer: jpeg, mimeType: "image/jpeg" };
147
+
148
+ // Base64 expands three bytes into four characters; the encoded length
149
+ // is what Anthropic measures, so derive it without encoding.
150
+ const encoded = Math.ceil(best.buffer.length / 3) * 4;
151
+ if (encoded <= payloadBudget) break;
152
+ if (targetWidth === 1 && targetHeight === 1) break;
153
+ scale *= Math.max(0.25, Math.sqrt(payloadBudget / encoded) * 0.9);
154
+ }
155
+ if (!best) return block;
156
+
157
+ const data = Buffer.from(best.buffer).toString("base64");
158
+ // An image inside the pixel cap is only worth replacing if it shrank.
159
+ if (!overDimension && data.length >= block.data.length) return block;
160
+ // The rebuilt block carries no `url` or `providerFile`: those reference
161
+ // the original bytes, and Anthropic validates a referenced image after
162
+ // fetching it, so keeping either would resend the oversized copy.
163
+ return {
164
+ type: "image",
165
+ data,
166
+ mimeType: best.mimeType,
167
+ };
168
+ } catch (error) {
169
+ logger.warn("anthropic: failed to resize oversized image", {
170
+ mimeType: block.mimeType,
171
+ error: error instanceof Error ? error.message : String(error),
172
+ });
173
+ return block;
174
+ }
175
+ }
176
+
177
+ async function resizeAnthropicImageContent(
178
+ content: (TextContent | ImageContent)[],
179
+ state: { resized: number },
180
+ limit: ResizeLimiter,
181
+ caps: AnthropicImageCaps,
182
+ ): Promise<(TextContent | ImageContent)[]> {
183
+ let changed = false;
184
+ const cache = anthropicImageResizeCache(caps);
185
+ const next = await Promise.all(
186
+ content.map(async block => {
187
+ if (block.type !== "image") return block;
188
+ let resized = cache.get(block);
189
+ if (resized === undefined) {
190
+ resized = await limit(() => resizeAnthropicImageBlock(block, caps));
191
+ cache.set(block, resized);
192
+ }
193
+ if (resized !== block) {
194
+ changed = true;
195
+ state.resized++;
196
+ }
197
+ return resized;
198
+ }),
199
+ );
200
+ return changed ? next : content;
201
+ }
202
+
203
+ async function resizeAnthropicImageMessage(
204
+ message: Message,
205
+ state: { resized: number },
206
+ limit: ResizeLimiter,
207
+ caps: AnthropicImageCaps,
208
+ ): Promise<Message> {
209
+ if (message.role === "user" || message.role === "developer") {
210
+ if (!Array.isArray(message.content)) return message;
211
+ const content = await resizeAnthropicImageContent(message.content, state, limit, caps);
212
+ return content === message.content ? message : { ...message, content };
213
+ }
214
+ if (message.role === "toolResult") {
215
+ const content = await resizeAnthropicImageContent(message.content, state, limit, caps);
216
+ return content === message.content ? message : { ...message, content };
217
+ }
218
+ return message;
219
+ }
220
+
221
+ /**
222
+ * Clamp every image a Claude reader would receive to the host's contract.
223
+ * Limits the model's policy leaves unset fall back to the canonical Anthropic
224
+ * API. Returns `context` itself when nothing needed resizing.
225
+ */
226
+ export async function prepareAnthropicImageContext(
227
+ context: Context,
228
+ model: Model<"anthropic-messages"> | Model<"google-gemini-cli">,
229
+ ): Promise<Context> {
230
+ if (!model.input.includes("image")) return context;
231
+ const imageCount = countAnthropicImageBlocks(context.messages);
232
+ if (imageCount === 0) return context;
233
+ // Many images share one request size budget, so they get the tighter cap.
234
+ // A smaller set only has to clear the host's per-image limit.
235
+ const hostMaxDimension = model.compat.maxImageDimension ?? ANTHROPIC_MAX_IMAGE_DIMENSION;
236
+ const maxDimension =
237
+ imageCount > ANTHROPIC_MANY_IMAGE_THRESHOLD
238
+ ? Math.min(ANTHROPIC_MANY_IMAGE_MAX_DIMENSION, hostMaxDimension)
239
+ : hostMaxDimension;
240
+ const maxPayloadBytes = model.compat.maxImagePayloadBytes ?? ANTHROPIC_MAX_IMAGE_PAYLOAD_BYTES;
241
+ const caps: AnthropicImageCaps = {
242
+ maxDimension,
243
+ payloadBudget: Math.floor(maxPayloadBytes * ANTHROPIC_IMAGE_PAYLOAD_HEADROOM),
244
+ };
245
+
246
+ let changed = false;
247
+ const state = { resized: 0 };
248
+ const limit = createResizeLimiter(anthropicImageResizeConcurrency(maxDimension));
249
+ const messages = await Promise.all(
250
+ context.messages.map(async message => {
251
+ const next = await resizeAnthropicImageMessage(message, state, limit, caps);
252
+ if (next !== message) changed = true;
253
+ return next;
254
+ }),
255
+ );
256
+ if (!changed) return context;
257
+ logger.debug("anthropic: resized oversized images", {
258
+ imageCount,
259
+ resized: state.resized,
260
+ maxDimension,
261
+ });
262
+ return { ...context, messages };
263
+ }
@@ -538,9 +538,53 @@ function mapStopReasonOut(reason: StopReason, hasToolUse: boolean): "end_turn" |
538
538
  }
539
539
  }
540
540
 
541
+ /**
542
+ * Collapse a server-side-fallback turn for a bridge client that never opted
543
+ * into the beta: drop the `fallback` markers and keep reasoning from one
544
+ * model only. The downstream client never accepted a `fallbacks` request
545
+ * param and the bridge does not forward one, so the marker cannot go on the
546
+ * wire; without it, two models' signed reasoning replayed back-to-back reads
547
+ * as one modified chain and is the shape Anthropic rejects.
548
+ *
549
+ * The segment kept is the first that has any reasoning, not the last. The
550
+ * streaming encoder has already sent the first segment's blocks when the
551
+ * boundary arrives and cannot retract them, so first-segment is the only
552
+ * rule it can decide online; this path uses the same rule so streamed and
553
+ * non-streamed turns present the same reasoning. The offline converter
554
+ * (`collapseFallbackSegments` in `anthropic.ts`) can see the whole turn and
555
+ * keeps the last segment instead — both guarantee a single chain survives.
556
+ */
557
+ function collapseFallbackSegmentsKeepFirst(content: AssistantMessage["content"]): AssistantMessage["content"] {
558
+ let firstReasoningSegment = -1;
559
+ let segment = 0;
560
+ for (const block of content) {
561
+ if (block.type === "fallback") segment++;
562
+ else if (block.type === "thinking" || block.type === "redactedThinking") {
563
+ firstReasoningSegment = segment;
564
+ break;
565
+ }
566
+ }
567
+ if (firstReasoningSegment === -1) {
568
+ return content.filter(block => block.type !== "fallback");
569
+ }
570
+ const collapsed: AssistantMessage["content"] = [];
571
+ segment = 0;
572
+ for (const block of content) {
573
+ if (block.type === "fallback") {
574
+ segment++;
575
+ continue;
576
+ }
577
+ if ((block.type === "thinking" || block.type === "redactedThinking") && segment !== firstReasoningSegment) {
578
+ continue;
579
+ }
580
+ collapsed.push(block);
581
+ }
582
+ return collapsed;
583
+ }
584
+
541
585
  function encodeContentBlocks(message: AssistantMessage): Record<string, unknown>[] {
542
586
  const blocks: Record<string, unknown>[] = [];
543
- for (const c of message.content) {
587
+ for (const c of collapseFallbackSegmentsKeepFirst(message.content)) {
544
588
  switch (c.type) {
545
589
  case "text":
546
590
  blocks.push({ type: "text", text: c.text });
@@ -668,6 +712,20 @@ export function encodeStream(
668
712
  // preceding text/thinking block before it opens, so no block that is
669
713
  // still open is ever renumbered.
670
714
  const suppressed = new Set<number>();
715
+ // A persisted `fallback` marker occupies a content index but is
716
+ // never emitted — the bridge client never opted into the beta, so
717
+ // the marker cannot go on the wire. The walk below records each
718
+ // marker (and every other block this encoder cannot emit) in
719
+ // `suppressed` so `wireIndex` stays dense. Once a boundary has been
720
+ // passed with reasoning already sent, every later segment's
721
+ // reasoning is a second signed chain the client must not receive;
722
+ // the first chain is already on the wire and cannot be retracted,
723
+ // so it is the one that survives (see
724
+ // `collapseFallbackSegmentsKeepFirst`). `reasoningSeen` tracks what
725
+ // the client actually received, which is why redacted reasoning —
726
+ // which this encoder has no event for — does not set it.
727
+ let reasoningSeen = false;
728
+ let suppressLaterReasoning = false;
671
729
  const wireIndex = (contentIndex: number): number => {
672
730
  let shift = 0;
673
731
  for (const index of suppressed) {
@@ -707,6 +765,20 @@ export function encodeStream(
707
765
  while (nextContentIndexToInspect < limit) {
708
766
  const contentIndex = nextContentIndexToInspect++;
709
767
  const content = message.content[contentIndex];
768
+ if (content?.type === "fallback") {
769
+ // Never on the wire, so keep the numbering dense. Latch the
770
+ // collapse only when the client already has this turn's first
771
+ // chain: what follows would be a second one.
772
+ suppressed.add(contentIndex);
773
+ if (reasoningSeen) suppressLaterReasoning = true;
774
+ continue;
775
+ }
776
+ if (content?.type === "redactedThinking") {
777
+ // Redacted reasoning has no stream event, so it never reaches
778
+ // the client; account for its index like any other hole.
779
+ suppressed.add(contentIndex);
780
+ continue;
781
+ }
710
782
  if (content?.type !== "anthropicServerTool") continue;
711
783
  ensureStart(message);
712
784
  const index = wireIndex(contentIndex);
@@ -781,6 +853,15 @@ export function encodeStream(
781
853
  case "thinking_start": {
782
854
  emitServerToolBlocksBefore(ev.partial, ev.contentIndex);
783
855
  ensureStart(ev.partial);
856
+ if (suppressLaterReasoning) {
857
+ // A later segment's reasoning is a second signed
858
+ // chain; drop the whole block — start, deltas,
859
+ // signature and stop — and account for its index
860
+ // so later blocks stay densely numbered.
861
+ suppressed.add(ev.contentIndex);
862
+ break;
863
+ }
864
+ reasoningSeen = true;
784
865
  const index = wireIndex(ev.contentIndex);
785
866
  open.set(ev.contentIndex, { index, kind: "thinking" });
786
867
  controller.enqueue(
@@ -793,6 +874,7 @@ export function encodeStream(
793
874
  break;
794
875
  }
795
876
  case "thinking_delta":
877
+ if (suppressed.has(ev.contentIndex)) break;
796
878
  controller.enqueue(
797
879
  sseFrame("content_block_delta", {
798
880
  type: "content_block_delta",
@@ -802,6 +884,7 @@ export function encodeStream(
802
884
  );
803
885
  break;
804
886
  case "thinking_end": {
887
+ if (suppressed.has(ev.contentIndex)) break;
805
888
  const c = ev.partial.content[ev.contentIndex];
806
889
  if (c?.type === "thinking" && c.thinkingSignature) {
807
890
  controller.enqueue(
@@ -1,21 +1,26 @@
1
1
  /**
2
- * Recovers the serving model id Anthropic embeds in thinking-block signatures.
2
+ * Reads the cleartext header Anthropic embeds in thinking-block signatures.
3
3
  *
4
4
  * A signature is a base64 protobuf: outer `{1: version, 2: envelope}`, envelope
5
5
  * `{1: header, 2..5: nonce/ciphertext/tag}`, header
6
- * `{1: schema, 5: hash, 6: model, 8: "thinking", 11: org uuid, 15: profile, 21: unix ts}`.
6
+ * `{1: schema, 5: hash, 6: model, 8: kind, 11: org uuid, 15: profile, 21: unix ts}`.
7
7
  * Field 6 names the model that actually produced the block — a concrete
8
8
  * snapshot (`claude-haiku-4-5-20251001`) or an internal codename
9
9
  * (`numbat-v6-…`) — which lets a caller notice a gateway serving something other
10
10
  * than the requested model. The v4 outer format (`CAQS…`) ships a header
11
11
  * without field 6; the decoder then yields nothing.
12
+ * Field 8 names the block kind: `thinking` for reasoning, `narration` for the
13
+ * user-facing prose the API converts into a thinking block when the model
14
+ * writes it between tool calls (connector-text summarization).
12
15
  */
13
16
 
14
17
  const OUTER_ENVELOPE_FIELD = 2;
15
18
  const ENVELOPE_HEADER_FIELD = 1;
16
19
  const HEADER_MODEL_FIELD = 6;
20
+ const HEADER_KIND_FIELD = 8;
17
21
  const MAX_MODEL_ID_LENGTH = 128;
18
22
  const MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
23
+ const NARRATION_KIND = Buffer.from("narration");
19
24
 
20
25
  /** Finds a length-delimited field, rejecting tags and lengths that exceed uint32. */
21
26
  function lengthDelimitedField(message: Uint8Array, field: number): Uint8Array | undefined {
@@ -71,12 +76,12 @@ function lengthDelimitedField(message: Uint8Array, field: number): Uint8Array |
71
76
  }
72
77
 
73
78
  /**
74
- * Model id embedded in an Anthropic thinking signature, or undefined when the
75
- * signature is malformed, uses a format without a cleartext header, or names
76
- * nothing resembling a model id.
79
+ * Cleartext header of an Anthropic thinking signature, or undefined when malformed or absent.
80
+ * No upper length bound: the signature grows with its block, and a long reply's
81
+ * narration signature runs past 13k characters.
77
82
  */
78
- export function servedModelFromAnthropicSignature(signature: string): string | undefined {
79
- if (signature.length < 8 || signature.length > 8192) return undefined;
83
+ function anthropicSignatureHeader(signature: string): Uint8Array | undefined {
84
+ if (signature.length < 8) return undefined;
80
85
  let raw: Uint8Array;
81
86
  try {
82
87
  raw = Buffer.from(signature, "base64");
@@ -85,7 +90,16 @@ export function servedModelFromAnthropicSignature(signature: string): string | u
85
90
  }
86
91
  const envelope = lengthDelimitedField(raw, OUTER_ENVELOPE_FIELD);
87
92
  if (!envelope) return undefined;
88
- const header = lengthDelimitedField(envelope, ENVELOPE_HEADER_FIELD);
93
+ return lengthDelimitedField(envelope, ENVELOPE_HEADER_FIELD);
94
+ }
95
+
96
+ /**
97
+ * Model id embedded in an Anthropic thinking signature, or undefined when the
98
+ * signature is malformed, uses a format without a cleartext header, or names
99
+ * nothing resembling a model id.
100
+ */
101
+ export function servedModelFromAnthropicSignature(signature: string): string | undefined {
102
+ const header = anthropicSignatureHeader(signature);
89
103
  if (!header) return undefined;
90
104
  const modelBytes = lengthDelimitedField(header, HEADER_MODEL_FIELD);
91
105
  if (!modelBytes || modelBytes.length === 0 || modelBytes.length > MAX_MODEL_ID_LENGTH) return undefined;
@@ -93,6 +107,19 @@ export function servedModelFromAnthropicSignature(signature: string): string | u
93
107
  return MODEL_ID_PATTERN.test(model) ? model : undefined;
94
108
  }
95
109
 
110
+ /**
111
+ * Whether an Anthropic thinking signature marks its block as narration: prose
112
+ * the model addressed to the user between tool calls, which the API returns as
113
+ * a thinking block. Only an exact `narration` kind matches; any other or
114
+ * unreadable header stays reasoning.
115
+ */
116
+ export function isAnthropicNarrationSignature(signature: string): boolean {
117
+ const header = anthropicSignatureHeader(signature);
118
+ if (!header) return false;
119
+ const kind = lengthDelimitedField(header, HEADER_KIND_FIELD);
120
+ return kind !== undefined && Buffer.from(kind).equals(NARRATION_KIND);
121
+ }
122
+
96
123
  /**
97
124
  * Model id from an OpenRouter reasoning item — a `reasoning_details` entry on
98
125
  * chat completions or a Responses `reasoning` output item — which forwards the
@@ -27,6 +27,19 @@ export function resolveDirectAnthropicBaseUrl(model: Model<Api>): string {
27
27
  return normalizeAnthropicBaseUrl($env.ANTHROPIC_BASE_URL) ?? configured ?? "https://api.anthropic.com";
28
28
  }
29
29
 
30
+ /**
31
+ * Whether a request to `model` reaches the official endpoint its compat was built for. `compat.officialEndpoint`
32
+ * classifies the configured `baseUrl`, but a first-party Anthropic model is rerouted at request time by
33
+ * `ANTHROPIC_BASE_URL` or Foundry's `FOUNDRY_BASE_URL` without rebuilding compat. Gates official-only host
34
+ * limits such as the catalog's inline image byte and image count budgets.
35
+ */
36
+ export function reachesOfficialEndpoint(model: Model<Api>): boolean {
37
+ const compat = model.compat;
38
+ if (!compat || !("officialEndpoint" in compat) || !compat.officialEndpoint) return false;
39
+ if (model.provider !== "anthropic" || model.api !== "anthropic-messages") return true;
40
+ return isOfficialAnthropicApiUrl(resolveDirectAnthropicBaseUrl(model));
41
+ }
42
+
30
43
  /** Build the endpoint-and-model-scoped Anthropic session-state key. */
31
44
  export function anthropicProviderSessionStateKey(baseUrl: string, modelId: string): string {
32
45
  return `${ANTHROPIC_PROVIDER_SESSION_STATE_KEY}:${baseUrl}\u0000${modelId}`;