@oh-my-pi/pi-ai 18.10.2 → 18.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -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 -0
- 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-responses.ts +1 -1
- 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 -18
- package/src/types.ts +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.10.3] - 2026-10-12
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- `isOfficialCodexApiUrl` moved from `@oh-my-pi/pi-ai` to `@oh-my-pi/pi-catalog/wire/codex`, next to `CODEX_BASE_URL`, so the catalog's compat resolver classifies Codex endpoints with the same check ([#15434](https://github.com/can1357/oh-my-pi/pull/15434) by [@will-bogusz](https://github.com/will-bogusz))
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Added `withoutUnverifiedThinking` utility to filter reasoning capability from models when no effort is requested
|
|
14
|
+
- Added `reachesOfficialEndpoint(model)`: whether a request to the model reaches the official endpoint its compat was built for, following `ANTHROPIC_BASE_URL` and Foundry's `FOUNDRY_BASE_URL` reroutes of first-party Anthropic models ([#15434](https://github.com/can1357/oh-my-pi/pull/15434) by [@will-bogusz](https://github.com/will-bogusz))
|
|
15
|
+
- Added `AssistantMessage.elidedOutputTokens`, the visible output tokens a history rewrite (shake) has removed from the turn, which `usage.output` still counts ([#15446](https://github.com/can1357/oh-my-pi/pull/15446) by [@will-bogusz](https://github.com/will-bogusz))
|
|
16
|
+
- Exported `shouldDropTruncatedThinkingOnlyAssistant` from `providers/transform-messages`, `isColdReplayableResponsesTurn` from `providers/openai-shared` and the new `openAIResponsesReplaysNativeHistory` from `providers/openai-responses`, so local token estimates can follow which turns replay drops and whether a session replays native history ([#15447](https://github.com/can1357/oh-my-pi/pull/15447) by [@will-bogusz](https://github.com/will-bogusz))
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Fixed Claude on Google Antigravity failing every turn with "image exceeds 5 MB maximum" once a large image entered the conversation; images are now downscaled to the host's limits before sending, as on the Anthropic API ([#15444](https://github.com/can1357/oh-my-pi/pull/15444) by [@will-bogusz](https://github.com/will-bogusz))
|
|
21
|
+
|
|
5
22
|
## [18.10.2] - 2026-10-12
|
|
6
23
|
|
|
7
24
|
### 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
|
@@ -1031,6 +1031,12 @@ export interface AssistantMessage {
|
|
|
1031
1031
|
*/
|
|
1032
1032
|
serviceTier?: ServiceTier;
|
|
1033
1033
|
usage: Usage;
|
|
1034
|
+
/**
|
|
1035
|
+
* Visible output tokens a history rewrite (shake) has since removed from
|
|
1036
|
+
* `content`. `usage.output` still counts them, so a replay estimate subtracts
|
|
1037
|
+
* them to keep the turn's reasoning without recharging the removed text.
|
|
1038
|
+
*/
|
|
1039
|
+
elidedOutputTokens?: number;
|
|
1034
1040
|
stopReason: StopReason;
|
|
1035
1041
|
stopDetails?: StopDetails | null;
|
|
1036
1042
|
errorMessage?: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-ai",
|
|
3
|
-
"version": "18.10.
|
|
3
|
+
"version": "18.10.3",
|
|
4
4
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -155,11 +155,11 @@
|
|
|
155
155
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
156
156
|
},
|
|
157
157
|
"dependencies": {
|
|
158
|
-
"@oh-my-pi/omptype": "18.10.
|
|
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.3",
|
|
159
|
+
"@oh-my-pi/pi-catalog": "18.10.3",
|
|
160
|
+
"@oh-my-pi/pi-natives": "18.10.3",
|
|
161
|
+
"@oh-my-pi/pi-utils": "18.10.3",
|
|
162
|
+
"@oh-my-pi/pi-wire": "18.10.3"
|
|
163
163
|
},
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@types/bun": "^1.3.14"
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude's image contract, enforced before a request leaves the client. Every
|
|
3
|
+
* wire that delivers images to a Claude reader shares it: `anthropic-messages`
|
|
4
|
+
* always, and Google's Cloud Code Assist wire when resolved policy declares
|
|
5
|
+
* the host's limits (`max-image-dimension` / `max-image-payload-bytes`).
|
|
6
|
+
*/
|
|
7
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
8
|
+
import type { Context, ImageContent, Message, Model, TextContent } from "../types";
|
|
9
|
+
|
|
10
|
+
const ANTHROPIC_MANY_IMAGE_THRESHOLD = 20;
|
|
11
|
+
const ANTHROPIC_MANY_IMAGE_MAX_DIMENSION = 2000;
|
|
12
|
+
/**
|
|
13
|
+
* Image contract of the canonical Anthropic Messages API, used when resolved
|
|
14
|
+
* model policy names no other: 8000px per side ("At least one of the image
|
|
15
|
+
* dimensions exceed max allowed size: 8000 pixels") and a 10 MB base64
|
|
16
|
+
* payload per block ("image exceeds 10 MB maximum: 14012300 bytes >
|
|
17
|
+
* 10485760 bytes"), measured on the encoded string rather than the decoded
|
|
18
|
+
* bytes. Either rejection poisons every retry and model fallback because the
|
|
19
|
+
* block stays in context, so both are clamped before the request goes out.
|
|
20
|
+
* A host with a different image contract sets `max-image-dimension` /
|
|
21
|
+
* `max-image-payload-bytes` in the KDL compatibility rules.
|
|
22
|
+
*/
|
|
23
|
+
const ANTHROPIC_MAX_IMAGE_DIMENSION = 8000;
|
|
24
|
+
const ANTHROPIC_MAX_IMAGE_PAYLOAD_BYTES = 10 * 1024 * 1024;
|
|
25
|
+
/** Headroom for the surrounding JSON and any provider-side accounting. */
|
|
26
|
+
const ANTHROPIC_IMAGE_PAYLOAD_HEADROOM = 0.9;
|
|
27
|
+
/** Re-encode passes allowed to bring one image under the byte budget. */
|
|
28
|
+
const ANTHROPIC_IMAGE_RESIZE_ATTEMPTS = 4;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Image bounds for one request. `maxDimension` and `payloadBudget` come from
|
|
32
|
+
* resolved model policy (`max-image-dimension`, `max-image-payload-bytes`) so
|
|
33
|
+
* a deployment whose image contract differs from the canonical API can
|
|
34
|
+
* override them, and the many-image path tightens `maxDimension` further
|
|
35
|
+
* because many images share one request budget.
|
|
36
|
+
*/
|
|
37
|
+
interface AnthropicImageCaps {
|
|
38
|
+
maxDimension: number;
|
|
39
|
+
payloadBudget: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function countAnthropicImageBlocks(messages: Message[]): number {
|
|
43
|
+
let count = 0;
|
|
44
|
+
for (const message of messages) {
|
|
45
|
+
if (message.role !== "user" && message.role !== "developer" && message.role !== "toolResult") continue;
|
|
46
|
+
if (!Array.isArray(message.content)) continue;
|
|
47
|
+
for (const block of message.content) {
|
|
48
|
+
if (block.type === "image") count++;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return count;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Ceiling on encoder output pixels in flight. One 8000x8000 RGBA surface is
|
|
56
|
+
* roughly 256 MiB, so admitting four of them would spike the CLI by a
|
|
57
|
+
* gigabyte; at the full-resolution cap this yields a single pipeline, while
|
|
58
|
+
* the many-image cap encodes at most 2000px and keeps the four-way fan-out.
|
|
59
|
+
*/
|
|
60
|
+
const ANTHROPIC_IMAGE_RESIZE_PIXEL_BUDGET = 16_000_000;
|
|
61
|
+
const ANTHROPIC_IMAGE_RESIZE_MAX_CONCURRENCY = 4;
|
|
62
|
+
|
|
63
|
+
function anthropicImageResizeConcurrency(maxDimension: number): number {
|
|
64
|
+
const perPipeline = maxDimension * maxDimension;
|
|
65
|
+
if (perPipeline <= 0) return ANTHROPIC_IMAGE_RESIZE_MAX_CONCURRENCY;
|
|
66
|
+
return Math.max(
|
|
67
|
+
1,
|
|
68
|
+
Math.min(ANTHROPIC_IMAGE_RESIZE_MAX_CONCURRENCY, Math.floor(ANTHROPIC_IMAGE_RESIZE_PIXEL_BUDGET / perPipeline)),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Memoized resize results keyed on ImageContent identity, per cap pair.
|
|
74
|
+
* Callers keep message objects stable across turns, so without this every
|
|
75
|
+
* request (and every in-provider retry of a fresh turn) re-decodes and
|
|
76
|
+
* re-encodes the same oversized screenshots. A cached value identical to the
|
|
77
|
+
* key means "already within bounds / unresizable — skip the decode". The caps
|
|
78
|
+
* depend on image count and on resolved policy, so each pair keeps its own
|
|
79
|
+
* cache: a value cached at 8000px is not reusable at 2000px.
|
|
80
|
+
*/
|
|
81
|
+
const anthropicImageResizeCaches = new Map<string, WeakMap<ImageContent, ImageContent>>();
|
|
82
|
+
|
|
83
|
+
function anthropicImageResizeCache(caps: AnthropicImageCaps): WeakMap<ImageContent, ImageContent> {
|
|
84
|
+
const key = `${caps.maxDimension}:${caps.payloadBudget}`;
|
|
85
|
+
const existing = anthropicImageResizeCaches.get(key);
|
|
86
|
+
if (existing) return existing;
|
|
87
|
+
const created = new WeakMap<ImageContent, ImageContent>();
|
|
88
|
+
anthropicImageResizeCaches.set(key, created);
|
|
89
|
+
return created;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type ResizeLimiter = <R>(fn: () => Promise<R>) => Promise<R>;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Bounded-concurrency gate for image decode/encode work. The many-image path
|
|
96
|
+
* fans out over every block of every message; unbounded, 100+ oversized images
|
|
97
|
+
* would decode concurrently and spike memory by gigabytes. Slots are handed
|
|
98
|
+
* off directly to the next waiter on release.
|
|
99
|
+
*/
|
|
100
|
+
function createResizeLimiter(limit: number): ResizeLimiter {
|
|
101
|
+
let active = 0;
|
|
102
|
+
const queue: (() => void)[] = [];
|
|
103
|
+
return async fn => {
|
|
104
|
+
if (active >= limit) {
|
|
105
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
106
|
+
queue.push(resolve);
|
|
107
|
+
await promise;
|
|
108
|
+
} else {
|
|
109
|
+
active++;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
return await fn();
|
|
113
|
+
} finally {
|
|
114
|
+
const next = queue.shift();
|
|
115
|
+
if (next) next();
|
|
116
|
+
else active--;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function resizeAnthropicImageBlock(block: ImageContent, caps: AnthropicImageCaps): Promise<ImageContent> {
|
|
122
|
+
const { maxDimension, payloadBudget } = caps;
|
|
123
|
+
try {
|
|
124
|
+
const inputBuffer = Buffer.from(block.data, "base64");
|
|
125
|
+
const { width, height } = await new Bun.Image(inputBuffer).metadata();
|
|
126
|
+
if (!width || !height) return block;
|
|
127
|
+
const overDimension = width > maxDimension || height > maxDimension;
|
|
128
|
+
const overBudget = block.data.length > payloadBudget;
|
|
129
|
+
if (!overDimension && !overBudget) return block;
|
|
130
|
+
|
|
131
|
+
// Fitting the pixel cap does not imply fitting the byte cap: a dense
|
|
132
|
+
// screenshot re-encodes large even at the cap. Shrink by the overshoot in
|
|
133
|
+
// area terms until the payload fits or the passes run out.
|
|
134
|
+
let scale = Math.min(1, maxDimension / width, maxDimension / height);
|
|
135
|
+
let best: { buffer: Uint8Array; mimeType: string } | undefined;
|
|
136
|
+
for (let attempt = 0; attempt < ANTHROPIC_IMAGE_RESIZE_ATTEMPTS; attempt++) {
|
|
137
|
+
const targetWidth = Math.max(1, Math.min(maxDimension, Math.round(width * scale)));
|
|
138
|
+
const targetHeight = Math.max(1, Math.min(maxDimension, Math.round(height * scale)));
|
|
139
|
+
// One encoder at a time: a full-resolution surface is hundreds of
|
|
140
|
+
// megabytes, so the two candidates never coexist.
|
|
141
|
+
const png = await new Bun.Image(inputBuffer).resize(targetWidth, targetHeight).png().bytes();
|
|
142
|
+
const jpeg = await new Bun.Image(inputBuffer).resize(targetWidth, targetHeight).jpeg({ quality: 85 }).bytes();
|
|
143
|
+
best =
|
|
144
|
+
png.length <= jpeg.length
|
|
145
|
+
? { buffer: png, mimeType: "image/png" }
|
|
146
|
+
: { buffer: jpeg, mimeType: "image/jpeg" };
|
|
147
|
+
|
|
148
|
+
// Base64 expands three bytes into four characters; the encoded length
|
|
149
|
+
// is what Anthropic measures, so derive it without encoding.
|
|
150
|
+
const encoded = Math.ceil(best.buffer.length / 3) * 4;
|
|
151
|
+
if (encoded <= payloadBudget) break;
|
|
152
|
+
if (targetWidth === 1 && targetHeight === 1) break;
|
|
153
|
+
scale *= Math.max(0.25, Math.sqrt(payloadBudget / encoded) * 0.9);
|
|
154
|
+
}
|
|
155
|
+
if (!best) return block;
|
|
156
|
+
|
|
157
|
+
const data = Buffer.from(best.buffer).toString("base64");
|
|
158
|
+
// An image inside the pixel cap is only worth replacing if it shrank.
|
|
159
|
+
if (!overDimension && data.length >= block.data.length) return block;
|
|
160
|
+
// The rebuilt block carries no `url` or `providerFile`: those reference
|
|
161
|
+
// the original bytes, and Anthropic validates a referenced image after
|
|
162
|
+
// fetching it, so keeping either would resend the oversized copy.
|
|
163
|
+
return {
|
|
164
|
+
type: "image",
|
|
165
|
+
data,
|
|
166
|
+
mimeType: best.mimeType,
|
|
167
|
+
};
|
|
168
|
+
} catch (error) {
|
|
169
|
+
logger.warn("anthropic: failed to resize oversized image", {
|
|
170
|
+
mimeType: block.mimeType,
|
|
171
|
+
error: error instanceof Error ? error.message : String(error),
|
|
172
|
+
});
|
|
173
|
+
return block;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function resizeAnthropicImageContent(
|
|
178
|
+
content: (TextContent | ImageContent)[],
|
|
179
|
+
state: { resized: number },
|
|
180
|
+
limit: ResizeLimiter,
|
|
181
|
+
caps: AnthropicImageCaps,
|
|
182
|
+
): Promise<(TextContent | ImageContent)[]> {
|
|
183
|
+
let changed = false;
|
|
184
|
+
const cache = anthropicImageResizeCache(caps);
|
|
185
|
+
const next = await Promise.all(
|
|
186
|
+
content.map(async block => {
|
|
187
|
+
if (block.type !== "image") return block;
|
|
188
|
+
let resized = cache.get(block);
|
|
189
|
+
if (resized === undefined) {
|
|
190
|
+
resized = await limit(() => resizeAnthropicImageBlock(block, caps));
|
|
191
|
+
cache.set(block, resized);
|
|
192
|
+
}
|
|
193
|
+
if (resized !== block) {
|
|
194
|
+
changed = true;
|
|
195
|
+
state.resized++;
|
|
196
|
+
}
|
|
197
|
+
return resized;
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
return changed ? next : content;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function resizeAnthropicImageMessage(
|
|
204
|
+
message: Message,
|
|
205
|
+
state: { resized: number },
|
|
206
|
+
limit: ResizeLimiter,
|
|
207
|
+
caps: AnthropicImageCaps,
|
|
208
|
+
): Promise<Message> {
|
|
209
|
+
if (message.role === "user" || message.role === "developer") {
|
|
210
|
+
if (!Array.isArray(message.content)) return message;
|
|
211
|
+
const content = await resizeAnthropicImageContent(message.content, state, limit, caps);
|
|
212
|
+
return content === message.content ? message : { ...message, content };
|
|
213
|
+
}
|
|
214
|
+
if (message.role === "toolResult") {
|
|
215
|
+
const content = await resizeAnthropicImageContent(message.content, state, limit, caps);
|
|
216
|
+
return content === message.content ? message : { ...message, content };
|
|
217
|
+
}
|
|
218
|
+
return message;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Clamp every image a Claude reader would receive to the host's contract.
|
|
223
|
+
* Limits the model's policy leaves unset fall back to the canonical Anthropic
|
|
224
|
+
* API. Returns `context` itself when nothing needed resizing.
|
|
225
|
+
*/
|
|
226
|
+
export async function prepareAnthropicImageContext(
|
|
227
|
+
context: Context,
|
|
228
|
+
model: Model<"anthropic-messages"> | Model<"google-gemini-cli">,
|
|
229
|
+
): Promise<Context> {
|
|
230
|
+
if (!model.input.includes("image")) return context;
|
|
231
|
+
const imageCount = countAnthropicImageBlocks(context.messages);
|
|
232
|
+
if (imageCount === 0) return context;
|
|
233
|
+
// Many images share one request size budget, so they get the tighter cap.
|
|
234
|
+
// A smaller set only has to clear the host's per-image limit.
|
|
235
|
+
const hostMaxDimension = model.compat.maxImageDimension ?? ANTHROPIC_MAX_IMAGE_DIMENSION;
|
|
236
|
+
const maxDimension =
|
|
237
|
+
imageCount > ANTHROPIC_MANY_IMAGE_THRESHOLD
|
|
238
|
+
? Math.min(ANTHROPIC_MANY_IMAGE_MAX_DIMENSION, hostMaxDimension)
|
|
239
|
+
: hostMaxDimension;
|
|
240
|
+
const maxPayloadBytes = model.compat.maxImagePayloadBytes ?? ANTHROPIC_MAX_IMAGE_PAYLOAD_BYTES;
|
|
241
|
+
const caps: AnthropicImageCaps = {
|
|
242
|
+
maxDimension,
|
|
243
|
+
payloadBudget: Math.floor(maxPayloadBytes * ANTHROPIC_IMAGE_PAYLOAD_HEADROOM),
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
let changed = false;
|
|
247
|
+
const state = { resized: 0 };
|
|
248
|
+
const limit = createResizeLimiter(anthropicImageResizeConcurrency(maxDimension));
|
|
249
|
+
const messages = await Promise.all(
|
|
250
|
+
context.messages.map(async message => {
|
|
251
|
+
const next = await resizeAnthropicImageMessage(message, state, limit, caps);
|
|
252
|
+
if (next !== message) changed = true;
|
|
253
|
+
return next;
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
if (!changed) return context;
|
|
257
|
+
logger.debug("anthropic: resized oversized images", {
|
|
258
|
+
imageCount,
|
|
259
|
+
resized: state.resized,
|
|
260
|
+
maxDimension,
|
|
261
|
+
});
|
|
262
|
+
return { ...context, messages };
|
|
263
|
+
}
|
|
@@ -27,6 +27,19 @@ export function resolveDirectAnthropicBaseUrl(model: Model<Api>): string {
|
|
|
27
27
|
return normalizeAnthropicBaseUrl($env.ANTHROPIC_BASE_URL) ?? configured ?? "https://api.anthropic.com";
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Whether a request to `model` reaches the official endpoint its compat was built for. `compat.officialEndpoint`
|
|
32
|
+
* classifies the configured `baseUrl`, but a first-party Anthropic model is rerouted at request time by
|
|
33
|
+
* `ANTHROPIC_BASE_URL` or Foundry's `FOUNDRY_BASE_URL` without rebuilding compat. Gates official-only host
|
|
34
|
+
* limits such as the catalog's inline image byte and image count budgets.
|
|
35
|
+
*/
|
|
36
|
+
export function reachesOfficialEndpoint(model: Model<Api>): boolean {
|
|
37
|
+
const compat = model.compat;
|
|
38
|
+
if (!compat || !("officialEndpoint" in compat) || !compat.officialEndpoint) return false;
|
|
39
|
+
if (model.provider !== "anthropic" || model.api !== "anthropic-messages") return true;
|
|
40
|
+
return isOfficialAnthropicApiUrl(resolveDirectAnthropicBaseUrl(model));
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
/** Build the endpoint-and-model-scoped Anthropic session-state key. */
|
|
31
44
|
export function anthropicProviderSessionStateKey(baseUrl: string, modelId: string): string {
|
|
32
45
|
return `${ANTHROPIC_PROVIDER_SESSION_STATE_KEY}:${baseUrl}\u0000${modelId}`;
|
|
@@ -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;
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
CODEX_CLIENT_VERSION,
|
|
8
8
|
codexRoutingHint,
|
|
9
9
|
getCodexAccountId,
|
|
10
|
+
isOfficialCodexApiUrl,
|
|
10
11
|
OPENAI_HEADER_VALUES,
|
|
11
12
|
OPENAI_HEADERS,
|
|
12
13
|
} from "@oh-my-pi/pi-catalog/wire/codex";
|
|
@@ -25,7 +26,6 @@ import {
|
|
|
25
26
|
import * as AIError from "../error";
|
|
26
27
|
import { parseToolCallArguments, replayableToolCallArguments } from "../utils/tool-call-arguments";
|
|
27
28
|
import { getEnvApiKey } from "../env-api-key";
|
|
28
|
-
import { isOfficialCodexApiUrl } from "../stream";
|
|
29
29
|
import type {
|
|
30
30
|
Api,
|
|
31
31
|
AssistantMessage,
|
|
@@ -496,6 +496,27 @@ function getOpenAIResponsesProviderSessionState(
|
|
|
496
496
|
return created;
|
|
497
497
|
}
|
|
498
498
|
|
|
499
|
+
/**
|
|
500
|
+
* Whether the next request for `model` replays native Responses history items
|
|
501
|
+
* (encrypted reasoning, item ids), as {@link buildParams} decides it. A session
|
|
502
|
+
* is cold, rebuilding turns from message content, from the first request on a
|
|
503
|
+
* connection-bound host (`compat.connectionBoundNativeHistory`, GitHub Copilot)
|
|
504
|
+
* and after `close()` on any host, until a response succeeds. Without session
|
|
505
|
+
* state every request replays.
|
|
506
|
+
*/
|
|
507
|
+
export function openAIResponsesReplaysNativeHistory(
|
|
508
|
+
model: Pick<Model, "api" | "provider" | "compat"> | null | undefined,
|
|
509
|
+
providerSessionState: Map<string, ProviderSessionState> | undefined,
|
|
510
|
+
): boolean {
|
|
511
|
+
if (model?.api !== "openai-responses" || !providerSessionState) return true;
|
|
512
|
+
const state = providerSessionState.get(`${OPENAI_RESPONSES_PROVIDER_SESSION_STATE_PREFIX}${model.provider}`) as
|
|
513
|
+
| OpenAIResponsesProviderSessionState
|
|
514
|
+
| undefined;
|
|
515
|
+
if (state) return state.nativeHistoryReplayWarmed;
|
|
516
|
+
const compat = model.compat;
|
|
517
|
+
return !(compat && "connectionBoundNativeHistory" in compat && compat.connectionBoundNativeHistory);
|
|
518
|
+
}
|
|
519
|
+
|
|
499
520
|
/** Host per-provider storage defaults; see {@link configureProviderStoreResponses}. */
|
|
500
521
|
let configuredProviderStoreResponses: Readonly<Record<string, boolean>> = {};
|
|
501
522
|
|
|
@@ -2064,7 +2064,7 @@ export function escapeReplayedControlTokens(items: ResponseInput): ResponseInput
|
|
|
2064
2064
|
* Summary-only reasoning keeps the rebuild: it is no evidence of a server that
|
|
2065
2065
|
* returns plaintext reasoning.
|
|
2066
2066
|
*/
|
|
2067
|
-
function isColdReplayableResponsesTurn(items: ResponseInput): boolean {
|
|
2067
|
+
export function isColdReplayableResponsesTurn(items: ResponseInput): boolean {
|
|
2068
2068
|
let hasReasoning = false;
|
|
2069
2069
|
for (const item of items) {
|
|
2070
2070
|
if ("id" in item && typeof item.id === "string") return false;
|
|
@@ -443,7 +443,11 @@ function sanitizeMalformedToolCalls(messages: Message[]): Message[] {
|
|
|
443
443
|
return result;
|
|
444
444
|
}
|
|
445
445
|
|
|
446
|
-
|
|
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
|
}
|
package/src/types.ts
CHANGED
|
@@ -1287,6 +1287,12 @@ export interface AssistantMessage {
|
|
|
1287
1287
|
*/
|
|
1288
1288
|
serviceTier?: ServiceTier;
|
|
1289
1289
|
usage: Usage;
|
|
1290
|
+
/**
|
|
1291
|
+
* Visible output tokens a history rewrite (shake) has since removed from
|
|
1292
|
+
* `content`. `usage.output` still counts them, so a replay estimate subtracts
|
|
1293
|
+
* them to keep the turn's reasoning without recharging the removed text.
|
|
1294
|
+
*/
|
|
1295
|
+
elidedOutputTokens?: number;
|
|
1290
1296
|
stopReason: StopReason;
|
|
1291
1297
|
stopDetails?: StopDetails | null;
|
|
1292
1298
|
errorMessage?: string;
|