@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 +27 -0
- package/dist/types/providers/anthropic-images.d.ts +7 -0
- package/dist/types/providers/anthropic-state.d.ts +7 -0
- package/dist/types/providers/openai-responses.d.ts +9 -0
- package/dist/types/providers/openai-shared.d.ts +14 -0
- package/dist/types/providers/transform-messages.d.ts +5 -0
- package/dist/types/stream.d.ts +0 -2
- package/dist/types/types.d.ts +6 -2
- package/package.json +6 -6
- package/src/providers/anthropic-images.ts +263 -0
- package/src/providers/anthropic-state.ts +13 -0
- package/src/providers/anthropic.ts +1 -247
- package/src/providers/google-gemini-cli.ts +11 -1
- package/src/providers/openai-codex/access-programs.ts +2 -1
- package/src/providers/openai-codex-responses.ts +5 -26
- package/src/providers/openai-responses.ts +21 -0
- package/src/providers/openai-shared.ts +1 -1
- package/src/providers/transform-messages.ts +5 -1
- package/src/stream.ts +7 -19
- package/src/types.ts +6 -2
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 `|`.
|
package/dist/types/stream.d.ts
CHANGED
|
@@ -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;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
159
|
-
"@oh-my-pi/pi-catalog": "18.10.
|
|
160
|
-
"@oh-my-pi/pi-natives": "18.10.
|
|
161
|
-
"@oh-my-pi/pi-utils": "18.10.
|
|
162
|
-
"@oh-my-pi/pi-wire": "18.10.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
714
|
-
headers.set(OPENAI_HEADERS.TURN_METADATA, metadata.
|
|
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
|
-
|
|
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 {
|
|
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;
|