@oh-my-pi/pi-ai 18.10.2 → 18.10.4

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,33 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.10.4] - 2026-10-13
6
+
7
+ ### Fixed
8
+
9
+ - Fixed Codex cybersecurity policy errors incorrectly retrying without Daybreak and disabling it for subsequent requests, while preserving retries without Daybreak when access is rejected ([#15472](https://github.com/can1357/oh-my-pi/pull/15472) by [@taibenvenuti](https://github.com/taibenvenuti))
10
+
11
+ ### Removed
12
+
13
+ - Removed the `toolNamespacesInfo` stream option; Codex requests no longer send `tool_namespaces_info` turn metadata, and `code_mode_tool_names`/`tool_namespaces_info` are no longer reserved client metadata keys.
14
+
15
+ ## [18.10.3] - 2026-10-12
16
+
17
+ ### Breaking Changes
18
+
19
+ - `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))
20
+
21
+ ### Added
22
+
23
+ - Added `withoutUnverifiedThinking` utility to filter reasoning capability from models when no effort is requested
24
+ - 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))
25
+ - 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))
26
+ - 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))
27
+
28
+ ### Fixed
29
+
30
+ - 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))
31
+
5
32
  ## [18.10.2] - 2026-10-12
6
33
 
7
34
  ### Added
@@ -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;
@@ -398,8 +398,6 @@ export interface StreamOptions {
398
398
  liveSteering?: LiveSteering;
399
399
  /** Canonical Codex compaction classification; ignored by other providers. */
400
400
  codexCompaction?: CodexCompactionRequestContext;
401
- /** Codex Code Mode tool exposure snapshot emitted as `tool_namespaces_info` turn metadata; ignored by other providers. */
402
- toolNamespacesInfo?: unknown;
403
401
  /**
404
402
  * Optional per-provider concurrent request cap for LLM stream calls. Keys are
405
403
  * provider ids (`model.provider`); positive numeric values cap in-flight
@@ -1031,6 +1029,12 @@ export interface AssistantMessage {
1031
1029
  */
1032
1030
  serviceTier?: ServiceTier;
1033
1031
  usage: Usage;
1032
+ /**
1033
+ * Visible output tokens a history rewrite (shake) has since removed from
1034
+ * `content`. `usage.output` still counts them, so a replay estimate subtracts
1035
+ * them to keep the turn's reasoning without recharging the removed text.
1036
+ */
1037
+ elidedOutputTokens?: number;
1034
1038
  stopReason: StopReason;
1035
1039
  stopDetails?: StopDetails | null;
1036
1040
  errorMessage?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.10.2",
3
+ "version": "18.10.4",
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.2",
159
- "@oh-my-pi/pi-catalog": "18.10.2",
160
- "@oh-my-pi/pi-natives": "18.10.2",
161
- "@oh-my-pi/pi-utils": "18.10.2",
162
- "@oh-my-pi/pi-wire": "18.10.2"
158
+ "@oh-my-pi/omptype": "18.10.4",
159
+ "@oh-my-pi/pi-catalog": "18.10.4",
160
+ "@oh-my-pi/pi-natives": "18.10.4",
161
+ "@oh-my-pi/pi-utils": "18.10.4",
162
+ "@oh-my-pi/pi-wire": "18.10.4"
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}`;
@@ -141,6 +141,7 @@ import {
141
141
  resolveGitHubCopilotBaseUrl,
142
142
  wrapFetchForCopilotFallback,
143
143
  } from "./github-copilot-headers";
144
+ import { prepareAnthropicImageContext } from "./anthropic-images";
144
145
  import { servedModelFromAnthropicSignature } from "./anthropic-signature";
145
146
  import { getOpenAIPromptCacheKey } from "./openai-shared";
146
147
  import { applyInferenceHeaders } from "./inference-headers";
@@ -972,253 +973,6 @@ function decodeAnthropicToolName(name: string, isOAuthToken: boolean, escapeBuil
972
973
  return name;
973
974
  }
974
975
 
975
- const ANTHROPIC_MANY_IMAGE_THRESHOLD = 20;
976
- const ANTHROPIC_MANY_IMAGE_MAX_DIMENSION = 2000;
977
- /**
978
- * Image contract of the canonical Anthropic Messages API, used when resolved
979
- * model policy names no other: 8000px per side ("At least one of the image
980
- * dimensions exceed max allowed size: 8000 pixels") and a 10 MB base64
981
- * payload per block ("image exceeds 10 MB maximum: 14012300 bytes >
982
- * 10485760 bytes"), measured on the encoded string rather than the decoded
983
- * bytes. Either rejection poisons every retry and model fallback because the
984
- * block stays in context, so both are clamped before the request goes out.
985
- * A host with a different image contract sets `max-image-dimension` /
986
- * `max-image-payload-bytes` in the KDL compatibility rules.
987
- */
988
- const ANTHROPIC_MAX_IMAGE_DIMENSION = 8000;
989
- const ANTHROPIC_MAX_IMAGE_PAYLOAD_BYTES = 10 * 1024 * 1024;
990
- /** Headroom for the surrounding JSON and any provider-side accounting. */
991
- const ANTHROPIC_IMAGE_PAYLOAD_HEADROOM = 0.9;
992
- /** Re-encode passes allowed to bring one image under the byte budget. */
993
- const ANTHROPIC_IMAGE_RESIZE_ATTEMPTS = 4;
994
-
995
- /**
996
- * Image bounds for one request. `maxDimension` and `payloadBudget` come from
997
- * resolved model policy (`max-image-dimension`, `max-image-payload-bytes`) so
998
- * a deployment whose image contract differs from the canonical API can
999
- * override them, and the many-image path tightens `maxDimension` further
1000
- * because many images share one request budget.
1001
- */
1002
- interface AnthropicImageCaps {
1003
- maxDimension: number;
1004
- payloadBudget: number;
1005
- }
1006
-
1007
- function countAnthropicImageBlocks(messages: Message[]): number {
1008
- let count = 0;
1009
- for (const message of messages) {
1010
- if (message.role !== "user" && message.role !== "developer" && message.role !== "toolResult") continue;
1011
- if (!Array.isArray(message.content)) continue;
1012
- for (const block of message.content) {
1013
- if (block.type === "image") count++;
1014
- }
1015
- }
1016
- return count;
1017
- }
1018
-
1019
- /**
1020
- * Ceiling on encoder output pixels in flight. One 8000x8000 RGBA surface is
1021
- * roughly 256 MiB, so admitting four of them would spike the CLI by a
1022
- * gigabyte; at the full-resolution cap this yields a single pipeline, while
1023
- * the many-image cap encodes at most 2000px and keeps the four-way fan-out.
1024
- */
1025
- const ANTHROPIC_IMAGE_RESIZE_PIXEL_BUDGET = 16_000_000;
1026
- const ANTHROPIC_IMAGE_RESIZE_MAX_CONCURRENCY = 4;
1027
-
1028
- function anthropicImageResizeConcurrency(maxDimension: number): number {
1029
- const perPipeline = maxDimension * maxDimension;
1030
- if (perPipeline <= 0) return ANTHROPIC_IMAGE_RESIZE_MAX_CONCURRENCY;
1031
- return Math.max(
1032
- 1,
1033
- Math.min(ANTHROPIC_IMAGE_RESIZE_MAX_CONCURRENCY, Math.floor(ANTHROPIC_IMAGE_RESIZE_PIXEL_BUDGET / perPipeline)),
1034
- );
1035
- }
1036
-
1037
- /**
1038
- * Memoized resize results keyed on ImageContent identity, per cap pair.
1039
- * Callers keep message objects stable across turns, so without this every
1040
- * request (and every in-provider retry of a fresh turn) re-decodes and
1041
- * re-encodes the same oversized screenshots. A cached value identical to the
1042
- * key means "already within bounds / unresizable — skip the decode". The caps
1043
- * depend on image count and on resolved policy, so each pair keeps its own
1044
- * cache: a value cached at 8000px is not reusable at 2000px.
1045
- */
1046
- const anthropicImageResizeCaches = new Map<string, WeakMap<ImageContent, ImageContent>>();
1047
-
1048
- function anthropicImageResizeCache(caps: AnthropicImageCaps): WeakMap<ImageContent, ImageContent> {
1049
- const key = `${caps.maxDimension}:${caps.payloadBudget}`;
1050
- const existing = anthropicImageResizeCaches.get(key);
1051
- if (existing) return existing;
1052
- const created = new WeakMap<ImageContent, ImageContent>();
1053
- anthropicImageResizeCaches.set(key, created);
1054
- return created;
1055
- }
1056
-
1057
- type ResizeLimiter = <R>(fn: () => Promise<R>) => Promise<R>;
1058
-
1059
- /**
1060
- * Bounded-concurrency gate for image decode/encode work. The many-image path
1061
- * fans out over every block of every message; unbounded, 100+ oversized images
1062
- * would decode concurrently and spike memory by gigabytes. Slots are handed
1063
- * off directly to the next waiter on release.
1064
- */
1065
- function createResizeLimiter(limit: number): ResizeLimiter {
1066
- let active = 0;
1067
- const queue: (() => void)[] = [];
1068
- return async fn => {
1069
- if (active >= limit) {
1070
- const { promise, resolve } = Promise.withResolvers<void>();
1071
- queue.push(resolve);
1072
- await promise;
1073
- } else {
1074
- active++;
1075
- }
1076
- try {
1077
- return await fn();
1078
- } finally {
1079
- const next = queue.shift();
1080
- if (next) next();
1081
- else active--;
1082
- }
1083
- };
1084
- }
1085
-
1086
- async function resizeAnthropicImageBlock(block: ImageContent, caps: AnthropicImageCaps): Promise<ImageContent> {
1087
- const { maxDimension, payloadBudget } = caps;
1088
- try {
1089
- const inputBuffer = Buffer.from(block.data, "base64");
1090
- const { width, height } = await new Bun.Image(inputBuffer).metadata();
1091
- if (!width || !height) return block;
1092
- const overDimension = width > maxDimension || height > maxDimension;
1093
- const overBudget = block.data.length > payloadBudget;
1094
- if (!overDimension && !overBudget) return block;
1095
-
1096
- // Fitting the pixel cap does not imply fitting the byte cap: a dense
1097
- // screenshot re-encodes large even at the cap. Shrink by the overshoot in
1098
- // area terms until the payload fits or the passes run out.
1099
- let scale = Math.min(1, maxDimension / width, maxDimension / height);
1100
- let best: { buffer: Uint8Array; mimeType: string } | undefined;
1101
- for (let attempt = 0; attempt < ANTHROPIC_IMAGE_RESIZE_ATTEMPTS; attempt++) {
1102
- const targetWidth = Math.max(1, Math.min(maxDimension, Math.round(width * scale)));
1103
- const targetHeight = Math.max(1, Math.min(maxDimension, Math.round(height * scale)));
1104
- // One encoder at a time: a full-resolution surface is hundreds of
1105
- // megabytes, so the two candidates never coexist.
1106
- const png = await new Bun.Image(inputBuffer).resize(targetWidth, targetHeight).png().bytes();
1107
- const jpeg = await new Bun.Image(inputBuffer).resize(targetWidth, targetHeight).jpeg({ quality: 85 }).bytes();
1108
- best =
1109
- png.length <= jpeg.length
1110
- ? { buffer: png, mimeType: "image/png" }
1111
- : { buffer: jpeg, mimeType: "image/jpeg" };
1112
-
1113
- // Base64 expands three bytes into four characters; the encoded length
1114
- // is what Anthropic measures, so derive it without encoding.
1115
- const encoded = Math.ceil(best.buffer.length / 3) * 4;
1116
- if (encoded <= payloadBudget) break;
1117
- if (targetWidth === 1 && targetHeight === 1) break;
1118
- scale *= Math.max(0.25, Math.sqrt(payloadBudget / encoded) * 0.9);
1119
- }
1120
- if (!best) return block;
1121
-
1122
- const data = Buffer.from(best.buffer).toString("base64");
1123
- // An image inside the pixel cap is only worth replacing if it shrank.
1124
- if (!overDimension && data.length >= block.data.length) return block;
1125
- // The rebuilt block carries no `url` or `providerFile`: those reference
1126
- // the original bytes, and Anthropic validates a referenced image after
1127
- // fetching it, so keeping either would resend the oversized copy.
1128
- return {
1129
- type: "image",
1130
- data,
1131
- mimeType: best.mimeType,
1132
- };
1133
- } catch (error) {
1134
- logger.warn("anthropic: failed to resize oversized image", {
1135
- mimeType: block.mimeType,
1136
- error: error instanceof Error ? error.message : String(error),
1137
- });
1138
- return block;
1139
- }
1140
- }
1141
-
1142
- async function resizeAnthropicImageContent(
1143
- content: (TextContent | ImageContent)[],
1144
- state: { resized: number },
1145
- limit: ResizeLimiter,
1146
- caps: AnthropicImageCaps,
1147
- ): Promise<(TextContent | ImageContent)[]> {
1148
- let changed = false;
1149
- const cache = anthropicImageResizeCache(caps);
1150
- const next = await Promise.all(
1151
- content.map(async block => {
1152
- if (block.type !== "image") return block;
1153
- let resized = cache.get(block);
1154
- if (resized === undefined) {
1155
- resized = await limit(() => resizeAnthropicImageBlock(block, caps));
1156
- cache.set(block, resized);
1157
- }
1158
- if (resized !== block) {
1159
- changed = true;
1160
- state.resized++;
1161
- }
1162
- return resized;
1163
- }),
1164
- );
1165
- return changed ? next : content;
1166
- }
1167
-
1168
- async function resizeAnthropicImageMessage(
1169
- message: Message,
1170
- state: { resized: number },
1171
- limit: ResizeLimiter,
1172
- caps: AnthropicImageCaps,
1173
- ): Promise<Message> {
1174
- if (message.role === "user" || message.role === "developer") {
1175
- if (!Array.isArray(message.content)) return message;
1176
- const content = await resizeAnthropicImageContent(message.content, state, limit, caps);
1177
- return content === message.content ? message : { ...message, content };
1178
- }
1179
- if (message.role === "toolResult") {
1180
- const content = await resizeAnthropicImageContent(message.content, state, limit, caps);
1181
- return content === message.content ? message : { ...message, content };
1182
- }
1183
- return message;
1184
- }
1185
-
1186
- async function prepareAnthropicImageContext(context: Context, model: Model<"anthropic-messages">): Promise<Context> {
1187
- if (!model.input.includes("image")) return context;
1188
- const imageCount = countAnthropicImageBlocks(context.messages);
1189
- if (imageCount === 0) return context;
1190
- // Many images share one request size budget, so they get the tighter cap.
1191
- // A smaller set only has to clear the host's per-image limit.
1192
- const hostMaxDimension = model.compat.maxImageDimension ?? ANTHROPIC_MAX_IMAGE_DIMENSION;
1193
- const maxDimension =
1194
- imageCount > ANTHROPIC_MANY_IMAGE_THRESHOLD
1195
- ? Math.min(ANTHROPIC_MANY_IMAGE_MAX_DIMENSION, hostMaxDimension)
1196
- : hostMaxDimension;
1197
- const maxPayloadBytes = model.compat.maxImagePayloadBytes ?? ANTHROPIC_MAX_IMAGE_PAYLOAD_BYTES;
1198
- const caps: AnthropicImageCaps = {
1199
- maxDimension,
1200
- payloadBudget: Math.floor(maxPayloadBytes * ANTHROPIC_IMAGE_PAYLOAD_HEADROOM),
1201
- };
1202
-
1203
- let changed = false;
1204
- const state = { resized: 0 };
1205
- const limit = createResizeLimiter(anthropicImageResizeConcurrency(maxDimension));
1206
- const messages = await Promise.all(
1207
- context.messages.map(async message => {
1208
- const next = await resizeAnthropicImageMessage(message, state, limit, caps);
1209
- if (next !== message) changed = true;
1210
- return next;
1211
- }),
1212
- );
1213
- if (!changed) return context;
1214
- logger.debug("anthropic: resized oversized images", {
1215
- imageCount,
1216
- resized: state.resized,
1217
- maxDimension,
1218
- });
1219
- return { ...context, messages };
1220
- }
1221
-
1222
976
  type AnthropicImageSource =
1223
977
  | { type: "base64"; media_type: AnthropicImageMediaType; data: string }
1224
978
  | { type: "url"; url: string }
@@ -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;
@@ -65,6 +65,7 @@ export function dropRejectedCodexAccessPrograms(
65
65
  function isAccessProgramRejection(error: unknown): boolean {
66
66
  if (!(error instanceof Error)) return false;
67
67
  const code = "code" in error && typeof error.code === "string" ? error.code : undefined;
68
- if (code !== undefined && Object.hasOwn(REJECTION_CODES, code)) return true;
68
+ // HTTP parsing promotes generic error.type to code; only specific codes rule out message fallback.
69
+ if (code !== undefined && code !== "invalid_request_error") return Object.hasOwn(REJECTION_CODES, code);
69
70
  return REJECTION_PATTERN.test(error.message);
70
71
  }
@@ -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,
@@ -498,15 +498,12 @@ interface CodexCompatibilityIdentity {
498
498
  affinitySessionId?: string;
499
499
  threadId: string;
500
500
  windowId: string;
501
- /** Header projection: identity fields only, never the Code Mode snapshot. */
502
- turnMetadataHeaderJson?: string;
501
+ turnMetadataJson?: string;
503
502
  }
504
503
 
505
504
  interface CodexRequestMetadata extends CodexCompatibilityIdentity {
506
505
  turnId: string;
507
- /** Canonical body projection, including `tool_namespaces_info` when present. */
508
506
  turnMetadataJson: string;
509
- turnMetadataHeaderJson: string;
510
507
  clientMetadata: Record<string, string>;
511
508
  }
512
509
 
@@ -523,11 +520,6 @@ const CODEX_RESERVED_METADATA_KEYS: Record<string, true> = {
523
520
  [OPENAI_HEADERS.SUBAGENT]: true,
524
521
  request_kind: true,
525
522
  compaction: true,
526
- // codex-rs retired `code_mode_tool_names` in favor of `tool_namespaces_info`
527
- // for its Responses Lite Code Mode mapping; both stay reserved so callers
528
- // cannot smuggle them in as extras.
529
- code_mode_tool_names: true,
530
- tool_namespaces_info: true,
531
523
  turn_started_at_unix_ms: true,
532
524
  forked_from_thread_id: true,
533
525
  parent_thread_id: true,
@@ -633,7 +625,6 @@ function createCodexRequestMetadata(
633
625
  clientMetadata?: Readonly<Record<string, string>>;
634
626
  parentTurnId?: string;
635
627
  compaction?: CodexCompactionRequestContext;
636
- toolNamespacesInfo?: unknown;
637
628
  affinitySessionId?: string;
638
629
  },
639
630
  ): CodexRequestMetadata {
@@ -674,17 +665,7 @@ function createCodexRequestMetadata(
674
665
  turnMetadata.turn_started_at_unix_ms = session.turnStartedAtUnixMs;
675
666
  }
676
667
  for (const key in extra) turnMetadata[key] = extra[key];
677
- // The `x-codex-turn-metadata` header is capped at 100KB by the Codex
678
- // backend, while `tool_namespaces_info` grows with the session's tool count
679
- // (409 tools ~ 100KB on its own). The body's `client_metadata` is the
680
- // canonical envelope, so the snapshot rides there only and the header keeps
681
- // the fixed-size identity projection.
682
- const turnMetadataHeaderJson = toAsciiJsonString(turnMetadata);
683
- let turnMetadataJson = turnMetadataHeaderJson;
684
- if (options.toolNamespacesInfo !== undefined) {
685
- turnMetadata.tool_namespaces_info = options.toolNamespacesInfo;
686
- turnMetadataJson = toAsciiJsonString(turnMetadata);
687
- }
668
+ const turnMetadataJson = toAsciiJsonString(turnMetadata);
688
669
  const clientMetadata: Record<string, string> = {
689
670
  [OPENAI_HEADERS.INSTALLATION_ID]: identity.installationId,
690
671
  session_id: identity.sessionId,
@@ -701,7 +682,6 @@ function createCodexRequestMetadata(
701
682
  affinitySessionId: options.affinitySessionId,
702
683
  turnId: session.turnId,
703
684
  turnMetadataJson,
704
- turnMetadataHeaderJson,
705
685
  clientMetadata,
706
686
  };
707
687
  }
@@ -710,8 +690,8 @@ function applyCodexCompatibilityHeaders(headers: Headers, metadata: CodexCompati
710
690
  headers.set(OPENAI_HEADERS.SCOPED_SESSION_ID, metadata.affinitySessionId ?? metadata.sessionId);
711
691
  headers.set(OPENAI_HEADERS.THREAD_ID, metadata.threadId);
712
692
  headers.set(OPENAI_HEADERS.WINDOW_ID, metadata.windowId);
713
- if (metadata.turnMetadataHeaderJson) {
714
- headers.set(OPENAI_HEADERS.TURN_METADATA, metadata.turnMetadataHeaderJson);
693
+ if (metadata.turnMetadataJson) {
694
+ headers.set(OPENAI_HEADERS.TURN_METADATA, metadata.turnMetadataJson);
715
695
  } else {
716
696
  headers.delete(OPENAI_HEADERS.TURN_METADATA);
717
697
  }
@@ -1531,7 +1511,6 @@ function createCodexRequestContext(
1531
1511
  clientMetadata: transformedBody.client_metadata,
1532
1512
  parentTurnId: options?.parentTurnId,
1533
1513
  compaction,
1534
- toolNamespacesInfo: options?.toolNamespacesInfo,
1535
1514
  affinitySessionId: normalizeOpenAIPromptCacheKey(options?.parentSessionId),
1536
1515
  });
1537
1516
  transformedBody.client_metadata = requestMetadata.clientMetadata;
@@ -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
  }
@@ -1846,7 +1835,6 @@ function mapOptionsForApi<TApi extends Api>(
1846
1835
  minimizeEffort: options?.minimizeEffort,
1847
1836
  liveSteering: options?.liveSteering,
1848
1837
  maxInFlightRequests: options?.maxInFlightRequests,
1849
- toolNamespacesInfo: options?.toolNamespacesInfo,
1850
1838
  onPayload: options?.onPayload,
1851
1839
  onResponse: options?.onResponse,
1852
1840
  onSseEvent: options?.onSseEvent,
package/src/types.ts CHANGED
@@ -628,8 +628,6 @@ export interface StreamOptions {
628
628
  liveSteering?: LiveSteering;
629
629
  /** Canonical Codex compaction classification; ignored by other providers. */
630
630
  codexCompaction?: CodexCompactionRequestContext;
631
- /** Codex Code Mode tool exposure snapshot emitted as `tool_namespaces_info` turn metadata; ignored by other providers. */
632
- toolNamespacesInfo?: unknown;
633
631
  /**
634
632
  * Optional per-provider concurrent request cap for LLM stream calls. Keys are
635
633
  * provider ids (`model.provider`); positive numeric values cap in-flight
@@ -1287,6 +1285,12 @@ export interface AssistantMessage {
1287
1285
  */
1288
1286
  serviceTier?: ServiceTier;
1289
1287
  usage: Usage;
1288
+ /**
1289
+ * Visible output tokens a history rewrite (shake) has since removed from
1290
+ * `content`. `usage.output` still counts them, so a replay estimate subtracts
1291
+ * them to keep the turn's reasoning without recharging the removed text.
1292
+ */
1293
+ elidedOutputTokens?: number;
1290
1294
  stopReason: StopReason;
1291
1295
  stopDetails?: StopDetails | null;
1292
1296
  errorMessage?: string;