@oh-my-pi/pi-ai 18.10.1 → 18.10.3

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.10.3] - 2026-10-12
6
+
7
+ ### Breaking Changes
8
+
9
+ - `isOfficialCodexApiUrl` moved from `@oh-my-pi/pi-ai` to `@oh-my-pi/pi-catalog/wire/codex`, next to `CODEX_BASE_URL`, so the catalog's compat resolver classifies Codex endpoints with the same check ([#15434](https://github.com/can1357/oh-my-pi/pull/15434) by [@will-bogusz](https://github.com/will-bogusz))
10
+
11
+ ### Added
12
+
13
+ - Added `withoutUnverifiedThinking` utility to filter reasoning capability from models when no effort is requested
14
+ - Added `reachesOfficialEndpoint(model)`: whether a request to the model reaches the official endpoint its compat was built for, following `ANTHROPIC_BASE_URL` and Foundry's `FOUNDRY_BASE_URL` reroutes of first-party Anthropic models ([#15434](https://github.com/can1357/oh-my-pi/pull/15434) by [@will-bogusz](https://github.com/will-bogusz))
15
+ - Added `AssistantMessage.elidedOutputTokens`, the visible output tokens a history rewrite (shake) has removed from the turn, which `usage.output` still counts ([#15446](https://github.com/can1357/oh-my-pi/pull/15446) by [@will-bogusz](https://github.com/will-bogusz))
16
+ - Exported `shouldDropTruncatedThinkingOnlyAssistant` from `providers/transform-messages`, `isColdReplayableResponsesTurn` from `providers/openai-shared` and the new `openAIResponsesReplaysNativeHistory` from `providers/openai-responses`, so local token estimates can follow which turns replay drops and whether a session replays native history ([#15447](https://github.com/can1357/oh-my-pi/pull/15447) by [@will-bogusz](https://github.com/will-bogusz))
17
+
18
+ ### Fixed
19
+
20
+ - Fixed Claude on Google Antigravity failing every turn with "image exceeds 5 MB maximum" once a large image entered the conversation; images are now downscaled to the host's limits before sending, as on the Anthropic API ([#15444](https://github.com/can1357/oh-my-pi/pull/15444) by [@will-bogusz](https://github.com/will-bogusz))
21
+
22
+ ## [18.10.2] - 2026-10-12
23
+
24
+ ### Added
25
+
26
+ - Added support for `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` on the `anthropic` provider: when set, requests omit pre-release fields (`context_management`, beta tool fields such as `strict` and `defer_loading`, task budgets, structured-output format, `thinking.block_binding`, and Anthropic server compaction) along with their beta headers, so `ANTHROPIC_BASE_URL` gateways that reject them keep working ([#15458](https://github.com/can1357/oh-my-pi/pull/15458) by [@H4vC](https://github.com/H4vC))
27
+
5
28
  ## [18.9.2] - 2026-10-11
6
29
 
7
30
  ### Breaking Changes
@@ -79,9 +79,9 @@ uutils coreutils (https://github.com/uutils/coreutils), MIT
79
79
  --------------------------------------------------------------------------------
80
80
  Covers: base32, base64, basename, cat, cksum (shared checksum machinery),
81
81
  b2sum, md5sum, sha1sum, sha224sum, sha256sum, sha384sum, sha512sum, comm, cp,
82
- cut, date, dirname, head, hostname, ln, ls, mkdir, mktemp, mv, nproc, paste,
83
- printenv, readlink, realpath, rm, seq, sort, stat, tac, tail, tee, touch, tr,
84
- truncate, uname, uniq, wc, whoami, yes.
82
+ cut, date, dirname, head, hostname, ln, ls, mkdir, mktemp, mv, nl, nproc,
83
+ paste, printenv, readlink, realpath, rm, seq, sort, stat, tac, tail, tee,
84
+ touch, tr, truncate, uname, uniq, wc, whoami, yes.
85
85
 
86
86
  Copyright (c) uutils developers
87
87
 
@@ -0,0 +1,7 @@
1
+ import type { Context, Model } from "../types.js";
2
+ /**
3
+ * Clamp every image a Claude reader would receive to the host's contract.
4
+ * Limits the model's policy leaves unset fall back to the canonical Anthropic
5
+ * API. Returns `context` itself when nothing needed resizing.
6
+ */
7
+ export declare function prepareAnthropicImageContext(context: Context, model: Model<"anthropic-messages"> | Model<"google-gemini-cli">): Promise<Context>;
@@ -7,6 +7,13 @@ export declare const ANTHROPIC_OPAQUE_CLIENT_ANCHOR_KEY = "anthropic-opaque-clie
7
7
  export declare function normalizeAnthropicBaseUrl(baseUrl?: string): string | undefined;
8
8
  /** Resolve the endpoint for a first-party Anthropic model without loading its provider implementation. */
9
9
  export declare function resolveDirectAnthropicBaseUrl(model: Model<Api>): string;
10
+ /**
11
+ * Whether a request to `model` reaches the official endpoint its compat was built for. `compat.officialEndpoint`
12
+ * classifies the configured `baseUrl`, but a first-party Anthropic model is rerouted at request time by
13
+ * `ANTHROPIC_BASE_URL` or Foundry's `FOUNDRY_BASE_URL` without rebuilding compat. Gates official-only host
14
+ * limits such as the catalog's inline image byte and image count budgets.
15
+ */
16
+ export declare function reachesOfficialEndpoint(model: Model<Api>): boolean;
10
17
  /** Build the endpoint-and-model-scoped Anthropic session-state key. */
11
18
  export declare function anthropicProviderSessionStateKey(baseUrl: string, modelId: string): string;
12
19
  /** Re-arm fast mode across materialized Anthropic session-state entries. */
@@ -114,6 +114,15 @@ interface OpenAIResponsesChainState {
114
114
  /** Set once chaining is judged unsupported for this session (circuit breaker). */
115
115
  disabled: boolean;
116
116
  }
117
+ /**
118
+ * Whether the next request for `model` replays native Responses history items
119
+ * (encrypted reasoning, item ids), as {@link buildParams} decides it. A session
120
+ * is cold, rebuilding turns from message content, from the first request on a
121
+ * connection-bound host (`compat.connectionBoundNativeHistory`, GitHub Copilot)
122
+ * and after `close()` on any host, until a response succeeds. Without session
123
+ * state every request replays.
124
+ */
125
+ export declare function openAIResponsesReplaysNativeHistory(model: Pick<Model, "api" | "provider" | "compat"> | null | undefined, providerSessionState: Map<string, ProviderSessionState> | undefined): boolean;
117
126
  /**
118
127
  * Set the process-wide storage default per provider id, used when a request
119
128
  * leaves `storeResponses` unset. Hosts call this from their settings so every
@@ -525,6 +525,20 @@ export interface BuildResponsesInputOptions<TApi extends Api> {
525
525
  * Callers gate on {@link isHarmonyDialectModel}. Items are copied, not mutated.
526
526
  */
527
527
  export declare function escapeReplayedControlTokens(items: ResponseInput): ResponseInput;
528
+ /**
529
+ * Whether a same-provider assistant turn may replay its native items while the
530
+ * provider session is still cold (#489). A cold session rebuilds turns from
531
+ * message content because some backends bind native items to one connection
532
+ * (GitHub Copilot: `401 input item does not belong to this connection`, #488).
533
+ * Binding needs server-issued state that survives replay sanitization: an
534
+ * `encrypted_content` blob or an item id. A turn with neither, whose every
535
+ * reasoning item carries plaintext `reasoning_text`, has nothing to bind and is
536
+ * exactly what the server receives once the session warms; rebuilding it would
537
+ * drop that reasoning and change the prompt prefix the server cached.
538
+ * Summary-only reasoning keeps the rebuild: it is no evidence of a server that
539
+ * returns plaintext reasoning.
540
+ */
541
+ export declare function isColdReplayableResponsesTurn(items: ResponseInput): boolean;
528
542
  export declare function buildResponsesInput<TApi extends Api>(options: BuildResponsesInputOptions<TApi>): ResponseInput;
529
543
  /**
530
544
  * Non-empty `reasoning_text` shipped for a synthesized reasoning item when no
@@ -31,6 +31,11 @@ export declare function appendDuplicateSuffix(originalId: string, suffix: string
31
31
  * name slot carries invocation text).
32
32
  */
33
33
  export declare function isMalformedToolCallName(name: unknown): boolean;
34
+ /**
35
+ * Whether {@link transformMessages} drops `msg` from every replay: a turn cut
36
+ * off by `length` / `error` / `aborted` that carries no text and no tool call.
37
+ */
38
+ export declare function shouldDropTruncatedThinkingOnlyAssistant(msg: AssistantMessage): boolean;
34
39
  /**
35
40
  * Normalize tool call ID for cross-provider compatibility.
36
41
  * OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`.
@@ -5,8 +5,6 @@ import type { GoogleGeminiCliOptions } from "./providers/google-gemini-cli.js";
5
5
  import type { GoogleVertexOptions } from "./providers/google-vertex.js";
6
6
  import type { Api, AssistantMessage, Context, Model, OptionsForApi, SimpleStreamOptions, ToolChoice } from "./types.js";
7
7
  import { AssistantMessageEventStream } from "./utils/event-stream.js";
8
- /** Strict official-Codex endpoint check; exact origin or a path boundary after {@link CODEX_BASE_URL}. */
9
- export declare function isOfficialCodexApiUrl(baseUrl: string | undefined): boolean;
10
8
  export declare function configureProviderMaxInFlightRequests(limits: Record<string, number> | undefined): void;
11
9
  export declare const __providerInFlightForTesting: {
12
10
  setRoot(root: string | undefined): void;
@@ -1031,6 +1031,12 @@ export interface AssistantMessage {
1031
1031
  */
1032
1032
  serviceTier?: ServiceTier;
1033
1033
  usage: Usage;
1034
+ /**
1035
+ * Visible output tokens a history rewrite (shake) has since removed from
1036
+ * `content`. `usage.output` still counts them, so a replay estimate subtracts
1037
+ * them to keep the turn's reasoning without recharging the removed text.
1038
+ */
1039
+ elidedOutputTokens?: number;
1034
1040
  stopReason: StopReason;
1035
1041
  stopDetails?: StopDetails | null;
1036
1042
  errorMessage?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.10.1",
3
+ "version": "18.10.3",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -155,11 +155,11 @@
155
155
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
156
156
  },
157
157
  "dependencies": {
158
- "@oh-my-pi/omptype": "18.10.1",
159
- "@oh-my-pi/pi-catalog": "18.10.1",
160
- "@oh-my-pi/pi-natives": "18.10.1",
161
- "@oh-my-pi/pi-utils": "18.10.1",
162
- "@oh-my-pi/pi-wire": "18.10.1"
158
+ "@oh-my-pi/omptype": "18.10.3",
159
+ "@oh-my-pi/pi-catalog": "18.10.3",
160
+ "@oh-my-pi/pi-natives": "18.10.3",
161
+ "@oh-my-pi/pi-utils": "18.10.3",
162
+ "@oh-my-pi/pi-wire": "18.10.3"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -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 "@oh-my-pi/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
+ }
@@ -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}`;
@@ -9,6 +9,7 @@ import { isAnthropicOAuthToken } from "@oh-my-pi/pi-catalog/utils";
9
9
  import { parseGitHubCopilotApiKey } from "@oh-my-pi/pi-catalog/wire/github-copilot";
10
10
  import {
11
11
  $env,
12
+ $flag,
12
13
  isEnoent,
13
14
  logger,
14
15
  parseJsonWithRepair,
@@ -116,10 +117,12 @@ import {
116
117
  type MessageCreateParams,
117
118
  type MessageCreateParamsStreaming,
118
119
  type MessageParam,
120
+ type OutputConfig,
119
121
  parseAnthropicInputTransformations,
120
122
  type RawMessageStreamEvent,
121
123
  THINKING_BINDING_CONTROLS_BETA,
122
124
  type TextBlockParam,
125
+ type ThinkingConfigParam,
123
126
  } from "./anthropic-wire";
124
127
  import {
125
128
  claudeCodeSdkVersion,
@@ -138,6 +141,7 @@ import {
138
141
  resolveGitHubCopilotBaseUrl,
139
142
  wrapFetchForCopilotFallback,
140
143
  } from "./github-copilot-headers";
144
+ import { prepareAnthropicImageContext } from "./anthropic-images";
141
145
  import { servedModelFromAnthropicSignature } from "./anthropic-signature";
142
146
  import { getOpenAIPromptCacheKey } from "./openai-shared";
143
147
  import { applyInferenceHeaders } from "./inference-headers";
@@ -273,24 +277,41 @@ const promptCacheBetas: Record<string, true> = {
273
277
  };
274
278
 
275
279
  /**
276
- * Remove the prompt-cache betas from every `anthropic-beta` entry in a header
280
+ * Betas withheld under the kill switch: they gate the pre-release body fields
281
+ * it removes (Claude Code's gateway contract: context management, beta tool
282
+ * fields, structured-output format, task budgets, tool search), plus the
283
+ * thinking-binding and compaction controls omp also withholds.
284
+ */
285
+ const killSwitchBetas: Record<string, true> = {
286
+ [THINKING_BINDING_CONTROLS_BETA]: true,
287
+ [contextManagementBeta]: true,
288
+ [COMPACTION_BETA]: true,
289
+ [LEGACY_COMPACTION_BETA]: true,
290
+ [structuredOutputsBeta]: true,
291
+ [taskBudgetBeta]: true,
292
+ [midConversationToolChangesBeta]: true,
293
+ };
294
+
295
+ /**
296
+ * Remove the `dropped` betas from every `anthropic-beta` entry in a header
277
297
  * record. Header names are case-insensitive and the value is a comma-separated
278
298
  * list, so each matching key keeps its own casing and its remaining tokens;
279
299
  * an entry left with no token is deleted rather than sent empty. The input is
280
- * returned untouched when it advertises no cache beta.
300
+ * returned untouched when it advertises none of them.
281
301
  *
282
302
  * `model.headers` and `options.headers` are caller-controlled channels that
283
303
  * reach the wire through `buildAnthropicHeaders` (as `modelHeaders`, which an
284
304
  * override-enabled endpoint lets replace our own beta header outright), the
285
305
  * 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.
306
+ * injected clients — each of which must go through here once breakpoints drop
307
+ * or the experimental-beta kill switch is on.
287
308
  */
288
- function stripPromptCacheBetas(headers: Record<string, string>): Record<string, string> {
309
+ function stripHeaderBetas(headers: Record<string, string>, dropped: Record<string, true>): Record<string, string> {
289
310
  let stripped: Record<string, string> | undefined;
290
311
  for (const key in headers) {
291
312
  if (key.toLowerCase() !== "anthropic-beta") continue;
292
313
  const betas = normalizeExtraBetas(headers[key]);
293
- const kept = betas.filter(beta => promptCacheBetas[beta] !== true);
314
+ const kept = betas.filter(beta => dropped[beta] !== true);
294
315
  if (kept.length === betas.length) continue;
295
316
  stripped ??= { ...headers };
296
317
  if (kept.length === 0) {
@@ -952,253 +973,6 @@ function decodeAnthropicToolName(name: string, isOAuthToken: boolean, escapeBuil
952
973
  return name;
953
974
  }
954
975
 
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
976
  type AnthropicImageSource =
1203
977
  | { type: "base64"; media_type: AnthropicImageMediaType; data: string }
1204
978
  | { type: "url"; url: string }
@@ -2406,6 +2180,9 @@ const streamAnthropicOnce = (
2406
2180
  };
2407
2181
  let dropAllThinking = droppedAllThinkingForSignature;
2408
2182
  let prefixBindingRetryAttempted = false;
2183
+ // Claude Code's `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS`; the wrapper
2184
+ // already turned the gated compat off, this covers caller/frozen inputs.
2185
+ const killSwitchActive = experimentalBetasDisabled(model);
2409
2186
  // A live fallback-credit redemption replays its frozen body.
2410
2187
  const frozenRedemption =
2411
2188
  options?.fallbackCreditRedemption &&
@@ -2413,6 +2190,24 @@ const streamAnthropicOnce = (
2413
2190
  options.fallbackCreditRedemption.params
2414
2191
  ? options.fallbackCreditRedemption
2415
2192
  : undefined;
2193
+ // A body frozen before the kill switch was set still carries the fields
2194
+ // it withholds; the credit token is bound to that body, so forfeit it
2195
+ // and build a fresh request. Server tools that already ran would re-run
2196
+ // and re-bill on a tokenless retry, so surface that case instead.
2197
+ const frozenBody = frozenRedemption?.params as MessageCreateParamsStreaming | undefined;
2198
+ // The redemption drops `fallbacks` before sending, so they cannot force a forfeit.
2199
+ const forfeitFrozenForKillSwitch =
2200
+ killSwitchActive &&
2201
+ frozenBody !== undefined &&
2202
+ carriesKillSwitchField({ ...frozenBody, fallbacks: undefined });
2203
+ if (
2204
+ forfeitFrozenForKillSwitch &&
2205
+ frozenRedemption?.refusedContent?.some(block => block.type === "anthropicServerTool")
2206
+ ) {
2207
+ throw new AIError.ConfigurationError(
2208
+ "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.",
2209
+ );
2210
+ }
2416
2211
  // Collected at most once, on first params build; a frozen redemption
2417
2212
  // body never builds params.
2418
2213
  let controlRecords: AnthropicControlRecord[] | undefined;
@@ -2431,12 +2226,16 @@ const streamAnthropicOnce = (
2431
2226
  // `anthropic-beta` value, so once breakpoints are dropped an unstripped
2432
2227
  // base would re-attach the very cache beta the replay abandoned. Both
2433
2228
  // 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;
2229
+ // strip them too. The kill switch's betas never ride, whatever the
2230
+ // caller's header says. Read through a closure because both cache
2231
+ // flags flip mid-turn.
2232
+ const callerBetaBaseHeaders = (): Record<string, string> => {
2233
+ const headers =
2234
+ dropCacheControl || dropCacheControlOptions
2235
+ ? stripHeaderBetas(mergedCallerHeaders, promptCacheBetas)
2236
+ : mergedCallerHeaders;
2237
+ return killSwitchActive ? stripHeaderBetas(headers, killSwitchBetas) : headers;
2238
+ };
2440
2239
  // Keep fallback payloads aligned with the top-level Vertex effort gate:
2441
2240
  // no nested effort field means the fallback scan cannot re-add its beta.
2442
2241
  let fallbacks = options?.fallbacks;
@@ -2565,7 +2364,9 @@ const streamAnthropicOnce = (
2565
2364
  if (!isOAuth && isOfficialAnthropicApiUrl(baseUrl) && !extraBetas.includes(fallbackCreditBeta)) {
2566
2365
  extraBetas.push(fallbackCreditBeta);
2567
2366
  }
2568
- if (options?.fallbackCreditRedemption) {
2367
+ // A forfeited frozen body is rebuilt fresh, so it keeps the betas
2368
+ // computed above instead of the saved list describing the old body.
2369
+ if (options?.fallbackCreditRedemption && !forfeitFrozenForKillSwitch) {
2569
2370
  const frozenBetas = (options.fallbackCreditRedemption.betas ?? []).filter(
2570
2371
  b => !b.startsWith("server-side-fallback-"),
2571
2372
  );
@@ -2598,6 +2399,8 @@ const streamAnthropicOnce = (
2598
2399
  }
2599
2400
  }
2600
2401
  }
2402
+ // Caller betas and a frozen redemption's beta list bypass compat.
2403
+ if (killSwitchActive) extraBetas = extraBetas.filter(beta => killSwitchBetas[beta] !== true);
2601
2404
 
2602
2405
  clientArgs = {
2603
2406
  model,
@@ -2659,6 +2462,8 @@ const streamAnthropicOnce = (
2659
2462
  if (replacementPayload !== undefined) {
2660
2463
  nextParams = replacementPayload as typeof nextParams;
2661
2464
  }
2465
+ // After `onPayload`, so a hook cannot restore a field the switch withholds.
2466
+ if (killSwitchActive) withholdKillSwitchFields(nextParams);
2662
2467
  if (nextParams.compaction) stripCompactionIncompatibleParams(nextParams);
2663
2468
  // After `onPayload`, so a hook cannot restore a field Bedrock rejects.
2664
2469
  if (model.compat.bedrockMessagesApi) fitBedrockAnthropicPayload(nextParams);
@@ -2696,7 +2501,7 @@ const streamAnthropicOnce = (
2696
2501
  let params: MessageCreateParamsStreaming;
2697
2502
  // Recheck expiry here: preparation (image resizing) is async, and an
2698
2503
  // expired token must fall back to a fresh request as before.
2699
- if (frozenRedemption && Date.now() <= frozenRedemption.expiresAt) {
2504
+ if (frozenRedemption && !forfeitFrozenForKillSwitch && Date.now() <= frozenRedemption.expiresAt) {
2700
2505
  const redemption = frozenRedemption;
2701
2506
  usingFallbackCredit = true;
2702
2507
  const frozenParams = structuredClone(redemption.params as MessageCreateParamsStreaming);
@@ -2925,7 +2730,12 @@ const streamAnthropicOnce = (
2925
2730
  const rawBetas = options.fallbackCreditRedemption.betaHeader
2926
2731
  .split(",")
2927
2732
  .map(b => b.trim())
2928
- .filter(b => b && !b.startsWith("server-side-fallback-"));
2733
+ .filter(
2734
+ b =>
2735
+ b &&
2736
+ !b.startsWith("server-side-fallback-") &&
2737
+ (!killSwitchActive || killSwitchBetas[b] !== true),
2738
+ );
2929
2739
  if (!rawBetas.includes(fallbackCreditBeta) && !rawBetas.includes("fallback-credit-2026-06-01")) {
2930
2740
  rawBetas.push(fallbackCreditBeta);
2931
2741
  }
@@ -4003,10 +3813,97 @@ const streamAnthropicOnce = (
4003
3813
  * loop. The inner attempt owns Anthropic provider-failure retries.
4004
3814
  */
4005
3815
  export const streamAnthropic: StreamFunction<"anthropic-messages"> = (model, context, options) =>
4006
- withReplaySafeStreamRetry(model, context, options, streamAnthropicOnce, {
3816
+ withReplaySafeStreamRetry(withoutDisabledExperimentalBetas(model), context, options, streamAnthropicOnce, {
4007
3817
  retryEmptyCompletion: true,
4008
3818
  });
4009
3819
 
3820
+ /** Whether Claude Code's `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` applies to this model's provider. */
3821
+ function experimentalBetasDisabled(model: Model<"anthropic-messages">): boolean {
3822
+ return model.compat.honorsClaudeCodeBetaKillSwitch === true && $flag("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS");
3823
+ }
3824
+
3825
+ /**
3826
+ * Whether a request body carries a field the kill switch withholds. A frozen
3827
+ * fallback-credit body that does cannot be redeemed under the switch. Defined
3828
+ * as "the sanitizer would change it" so detection and stripping cannot drift.
3829
+ */
3830
+ function carriesKillSwitchField(params: MessageCreateParamsStreaming): boolean {
3831
+ const sanitized = structuredClone(params);
3832
+ withholdKillSwitchFields(sanitized);
3833
+ return !Bun.deepEquals(sanitized, params);
3834
+ }
3835
+
3836
+ /**
3837
+ * Remove every field the kill switch withholds from a final request body, for
3838
+ * payloads an `onPayload` hook replaced after compat already shaped them, and
3839
+ * for the switch's fields compat has no axis for (task budgets). Compaction
3840
+ * blocks keep their readable summary as text, the same form an endpoint
3841
+ * without compaction support receives.
3842
+ */
3843
+ function withholdKillSwitchFields(params: MessageCreateParamsStreaming): void {
3844
+ withholdKillSwitchControls(params);
3845
+ delete params.context_management;
3846
+ delete params.compaction;
3847
+ // Server-side fallback entries carry their own per-attempt controls.
3848
+ for (const entry of params.fallbacks ?? []) withholdKillSwitchControls(entry);
3849
+ for (const tool of params.tools ?? []) {
3850
+ if ("strict" in tool) delete tool.strict;
3851
+ if ("defer_loading" in tool) delete tool.defer_loading;
3852
+ if ("eager_input_streaming" in tool) delete tool.eager_input_streaming;
3853
+ }
3854
+ for (const message of params.messages) {
3855
+ // Per-message `output_config` (mid-conversation effort) may carry the same controls.
3856
+ if ("output_config" in message && message.output_config) withholdKillSwitchControls(message);
3857
+ if (!Array.isArray(message.content)) continue;
3858
+ for (let i = 0; i < message.content.length; i++) {
3859
+ const block = message.content[i];
3860
+ if (block.type === "compaction") message.content[i] = { type: "text", text: block.content };
3861
+ }
3862
+ }
3863
+ if (params.anthropic_beta) {
3864
+ params.anthropic_beta = params.anthropic_beta.filter(beta => killSwitchBetas[beta] !== true);
3865
+ }
3866
+ }
3867
+
3868
+ /** Withhold the kill switch's thinking/output controls on a body, fallback entry, or message. */
3869
+ function withholdKillSwitchControls(holder: { thinking?: ThinkingConfigParam; output_config?: OutputConfig }): void {
3870
+ if (holder.thinking && "block_binding" in holder.thinking) delete holder.thinking.block_binding;
3871
+ if (!holder.output_config) return;
3872
+ delete holder.output_config.task_budget;
3873
+ delete holder.output_config.format;
3874
+ if (Object.keys(holder.output_config).length === 0) delete holder.output_config;
3875
+ }
3876
+
3877
+ /**
3878
+ * Honor Claude Code's `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` kill switch on
3879
+ * providers whose compat opts in (`honors-claude-code-beta-kill-switch`).
3880
+ * Gateways behind `ANTHROPIC_BASE_URL` often reject pre-release body fields,
3881
+ * so the switch withholds what Claude Code's gateway contract lists — context
3882
+ * management, beta tool fields (`strict`, `defer_loading`,
3883
+ * `eager_input_streaming`), tool search (mid-conversation tool changes),
3884
+ * structured-output format, task budgets — plus `thinking.block_binding` and
3885
+ * server compaction (whose legacy replay needs a `context_management` edit),
3886
+ * together with their betas. Effort, adaptive thinking, interleaved thinking
3887
+ * and extended context stay, as in Claude Code. Applied through compat so every
3888
+ * request builder and retry rung sees one decision; {@link withholdKillSwitchFields}
3889
+ * and {@link killSwitchBetas} cover the rest where bodies and betas are finalized.
3890
+ */
3891
+ function withoutDisabledExperimentalBetas(model: Model<"anthropic-messages">): Model<"anthropic-messages"> {
3892
+ if (!experimentalBetasDisabled(model)) return model;
3893
+ return {
3894
+ ...model,
3895
+ compat: {
3896
+ ...model.compat,
3897
+ supportsContextManagement: false,
3898
+ supportsThinkingBindingControls: false,
3899
+ supportsServerCompaction: false,
3900
+ disableStrictTools: true,
3901
+ supportsEagerToolInputStreaming: false,
3902
+ supportsMidConversationToolChanges: false,
3903
+ },
3904
+ };
3905
+ }
3906
+
4010
3907
  export type AnthropicSystemBlock = {
4011
3908
  type: "text";
4012
3909
  text: string;
@@ -4163,7 +4060,9 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
4163
4060
  // Unlike the generic builder below, this branch has no enforced-key
4164
4061
  // filter: a caller-supplied `anthropic-beta` lands in the default
4165
4062
  // headers verbatim, cache betas included.
4166
- const defaultHeaders = dropPromptCacheBetas ? stripPromptCacheBetas(mergedCopilotHeaders) : mergedCopilotHeaders;
4063
+ const defaultHeaders = dropPromptCacheBetas
4064
+ ? stripHeaderBetas(mergedCopilotHeaders, promptCacheBetas)
4065
+ : mergedCopilotHeaders;
4167
4066
  applyInferenceHeaders(defaultHeaders, {
4168
4067
  provider: model.provider,
4169
4068
  protocol: "anthropic",
@@ -4208,9 +4107,13 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
4208
4107
  // value outright — but `allowAnthropicHeaderOverrides` lets it replace our
4209
4108
  // built beta header wholesale, which would put a cache beta back on a
4210
4109
  // breakpoint-free request.
4211
- const requestModelHeaders = dropPromptCacheBetas
4212
- ? stripPromptCacheBetas(mergedRequestHeaders)
4110
+ // The kill switch's betas are stripped on the same channel.
4111
+ const cacheStrippedHeaders = dropPromptCacheBetas
4112
+ ? stripHeaderBetas(mergedRequestHeaders, promptCacheBetas)
4213
4113
  : mergedRequestHeaders;
4114
+ const requestModelHeaders = experimentalBetasDisabled(model)
4115
+ ? stripHeaderBetas(cacheStrippedHeaders, killSwitchBetas)
4116
+ : cacheStrippedHeaders;
4214
4117
  const defaultHeaders = buildAnthropicHeaders({
4215
4118
  apiKey,
4216
4119
  baseUrl,
@@ -36,6 +36,7 @@ import { armPreResponseTimeout, getStreamFirstEventTimeoutMs, iterateWithIdleTim
36
36
  // the stream provider trusts the access token threaded through `options.apiKey`.
37
37
  import { normalizeSchemaForCCA } from "../utils/schema";
38
38
  import { StreamMarkupHealing, type StreamMarkupHealingEvent } from "../utils/stream-markup-healing";
39
+ import { prepareAnthropicImageContext } from "./anthropic-images";
39
40
  import forcedToolDirective from "./google-antigravity-forced-tool.md" with { type: "text" };
40
41
  import type { Content, FunctionCallingConfigMode, ThinkingConfig } from "./google-shared";
41
42
  import {
@@ -573,7 +574,16 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
573
574
  endpoints = baseUrl ? [baseUrl] : [DEFAULT_ENDPOINT];
574
575
  }
575
576
 
576
- let requestBody = buildRequest(model, context, projectId, options, isAntigravity);
577
+ // A host that declares an image contract (Claude behind Antigravity)
578
+ // rejects oversized images, and a rejected image stays in context, so
579
+ // clamp before building; readers without one take images as sent. The
580
+ // cost is one pass over the context, as on `anthropic-messages`: images
581
+ // already within bounds hit the per-image cache and are not decoded.
582
+ const requestContext =
583
+ model.compat.maxImageDimension !== undefined || model.compat.maxImagePayloadBytes !== undefined
584
+ ? await prepareAnthropicImageContext(context, model)
585
+ : context;
586
+ let requestBody = buildRequest(model, requestContext, projectId, options, isAntigravity);
577
587
  const replacementPayload = await options?.onPayload?.(requestBody, model);
578
588
  if (replacementPayload !== undefined) {
579
589
  requestBody = replacementPayload as typeof requestBody;
@@ -7,6 +7,7 @@ import {
7
7
  CODEX_CLIENT_VERSION,
8
8
  codexRoutingHint,
9
9
  getCodexAccountId,
10
+ isOfficialCodexApiUrl,
10
11
  OPENAI_HEADER_VALUES,
11
12
  OPENAI_HEADERS,
12
13
  } from "@oh-my-pi/pi-catalog/wire/codex";
@@ -25,7 +26,6 @@ import {
25
26
  import * as AIError from "../error";
26
27
  import { parseToolCallArguments, replayableToolCallArguments } from "../utils/tool-call-arguments";
27
28
  import { getEnvApiKey } from "../env-api-key";
28
- import { isOfficialCodexApiUrl } from "../stream";
29
29
  import type {
30
30
  Api,
31
31
  AssistantMessage,
@@ -496,6 +496,27 @@ function getOpenAIResponsesProviderSessionState(
496
496
  return created;
497
497
  }
498
498
 
499
+ /**
500
+ * Whether the next request for `model` replays native Responses history items
501
+ * (encrypted reasoning, item ids), as {@link buildParams} decides it. A session
502
+ * is cold, rebuilding turns from message content, from the first request on a
503
+ * connection-bound host (`compat.connectionBoundNativeHistory`, GitHub Copilot)
504
+ * and after `close()` on any host, until a response succeeds. Without session
505
+ * state every request replays.
506
+ */
507
+ export function openAIResponsesReplaysNativeHistory(
508
+ model: Pick<Model, "api" | "provider" | "compat"> | null | undefined,
509
+ providerSessionState: Map<string, ProviderSessionState> | undefined,
510
+ ): boolean {
511
+ if (model?.api !== "openai-responses" || !providerSessionState) return true;
512
+ const state = providerSessionState.get(`${OPENAI_RESPONSES_PROVIDER_SESSION_STATE_PREFIX}${model.provider}`) as
513
+ | OpenAIResponsesProviderSessionState
514
+ | undefined;
515
+ if (state) return state.nativeHistoryReplayWarmed;
516
+ const compat = model.compat;
517
+ return !(compat && "connectionBoundNativeHistory" in compat && compat.connectionBoundNativeHistory);
518
+ }
519
+
499
520
  /** Host per-provider storage defaults; see {@link configureProviderStoreResponses}. */
500
521
  let configuredProviderStoreResponses: Readonly<Record<string, boolean>> = {};
501
522
 
@@ -2064,7 +2064,7 @@ export function escapeReplayedControlTokens(items: ResponseInput): ResponseInput
2064
2064
  * Summary-only reasoning keeps the rebuild: it is no evidence of a server that
2065
2065
  * returns plaintext reasoning.
2066
2066
  */
2067
- function isColdReplayableResponsesTurn(items: ResponseInput): boolean {
2067
+ export function isColdReplayableResponsesTurn(items: ResponseInput): boolean {
2068
2068
  let hasReasoning = false;
2069
2069
  for (const item of items) {
2070
2070
  if ("id" in item && typeof item.id === "string") return false;
@@ -443,7 +443,11 @@ function sanitizeMalformedToolCalls(messages: Message[]): Message[] {
443
443
  return result;
444
444
  }
445
445
 
446
- function shouldDropTruncatedThinkingOnlyAssistant(msg: AssistantMessage): boolean {
446
+ /**
447
+ * Whether {@link transformMessages} drops `msg` from every replay: a turn cut
448
+ * off by `length` / `error` / `aborted` that carries no text and no tool call.
449
+ */
450
+ export function shouldDropTruncatedThinkingOnlyAssistant(msg: AssistantMessage): boolean {
447
451
  const isTruncatedStop = msg.stopReason === "length" || msg.stopReason === "error" || msg.stopReason === "aborted";
448
452
  return isTruncatedStop && !msg.content.some(block => block.type === "toolCall" || block.type === "text");
449
453
  }
package/src/stream.ts CHANGED
@@ -12,8 +12,9 @@ import {
12
12
  mapEffortToGoogleThinkingLevel,
13
13
  requireSupportedEffort,
14
14
  resolveWireModelId,
15
+ withoutUnverifiedThinking,
15
16
  } from "@oh-my-pi/pi-catalog/model-thinking";
16
- import { CODEX_BASE_URL } from "@oh-my-pi/pi-catalog/wire/codex";
17
+ import { isOfficialCodexApiUrl } from "@oh-my-pi/pi-catalog/wire/codex";
17
18
  import { $env, getProviderInFlightRoot, isEnoent, logger, untilAborted } from "@oh-my-pi/pi-utils";
18
19
  import { getCustomApi } from "./api-registry";
19
20
  import { createAuthRetryKeyState, isApiKeyResolver, resolvedApiKeyBearer, resolveNextAuthRetryKey } from "./auth-retry";
@@ -132,23 +133,6 @@ function isOfficialOpenAIApiUrl(baseUrl: string | undefined): boolean {
132
133
  }
133
134
  }
134
135
 
135
- const OFFICIAL_CODEX_URL = new URL(CODEX_BASE_URL);
136
-
137
- /** Strict official-Codex endpoint check; exact origin or a path boundary after {@link CODEX_BASE_URL}. */
138
- export function isOfficialCodexApiUrl(baseUrl: string | undefined): boolean {
139
- if (!baseUrl) return true;
140
- try {
141
- const candidate = new URL(baseUrl);
142
- const candidatePath = candidate.pathname.replace(/\/+$/, "");
143
- return (
144
- candidate.origin === OFFICIAL_CODEX_URL.origin &&
145
- (candidatePath === OFFICIAL_CODEX_URL.pathname || candidatePath.startsWith(`${OFFICIAL_CODEX_URL.pathname}/`))
146
- );
147
- } catch {
148
- return false;
149
- }
150
- }
151
-
152
136
  /**
153
137
  * Apply live leaked-thinking healing unless {@link model} is an official
154
138
  * first-party endpoint ({@link isLeakedThinkingHealExempt}), which emits
@@ -1237,6 +1221,11 @@ export function streamSimple<TApi extends Api>(
1237
1221
  options?: SimpleStreamOptions,
1238
1222
  ): AssistantMessageEventStream {
1239
1223
  const sessionOptions = withInferenceSessionId(options);
1224
+ // Unverified thinking is opt-in: without a requested effort the request is
1225
+ // byte-identical to a non-reasoning model's.
1226
+ const effortRequested =
1227
+ sessionOptions.reasoning !== undefined && !sessionOptions.disableReasoning && !sessionOptions.forceReasoningOff;
1228
+ if (!effortRequested) model = withoutUnverifiedThinking(model);
1240
1229
  if (!model.requiresGlyphTokenization) {
1241
1230
  return streamSimpleRequest(model, context, sessionOptions);
1242
1231
  }
package/src/types.ts CHANGED
@@ -1287,6 +1287,12 @@ export interface AssistantMessage {
1287
1287
  */
1288
1288
  serviceTier?: ServiceTier;
1289
1289
  usage: Usage;
1290
+ /**
1291
+ * Visible output tokens a history rewrite (shake) has since removed from
1292
+ * `content`. `usage.output` still counts them, so a replay estimate subtracts
1293
+ * them to keep the turn's reasoning without recharging the removed text.
1294
+ */
1295
+ elidedOutputTokens?: number;
1290
1296
  stopReason: StopReason;
1291
1297
  stopDetails?: StopDetails | null;
1292
1298
  errorMessage?: string;