@oh-my-pi/pi-ai 18.10.1 → 18.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/THIRD-PARTY-NOTICES.txt +3 -3
- 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 +168 -265
- 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,29 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.10.3] - 2026-10-12
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- `isOfficialCodexApiUrl` moved from `@oh-my-pi/pi-ai` to `@oh-my-pi/pi-catalog/wire/codex`, next to `CODEX_BASE_URL`, so the catalog's compat resolver classifies Codex endpoints with the same check ([#15434](https://github.com/can1357/oh-my-pi/pull/15434) by [@will-bogusz](https://github.com/will-bogusz))
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Added `withoutUnverifiedThinking` utility to filter reasoning capability from models when no effort is requested
|
|
14
|
+
- Added `reachesOfficialEndpoint(model)`: whether a request to the model reaches the official endpoint its compat was built for, following `ANTHROPIC_BASE_URL` and Foundry's `FOUNDRY_BASE_URL` reroutes of first-party Anthropic models ([#15434](https://github.com/can1357/oh-my-pi/pull/15434) by [@will-bogusz](https://github.com/will-bogusz))
|
|
15
|
+
- Added `AssistantMessage.elidedOutputTokens`, the visible output tokens a history rewrite (shake) has removed from the turn, which `usage.output` still counts ([#15446](https://github.com/can1357/oh-my-pi/pull/15446) by [@will-bogusz](https://github.com/will-bogusz))
|
|
16
|
+
- Exported `shouldDropTruncatedThinkingOnlyAssistant` from `providers/transform-messages`, `isColdReplayableResponsesTurn` from `providers/openai-shared` and the new `openAIResponsesReplaysNativeHistory` from `providers/openai-responses`, so local token estimates can follow which turns replay drops and whether a session replays native history ([#15447](https://github.com/can1357/oh-my-pi/pull/15447) by [@will-bogusz](https://github.com/will-bogusz))
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Fixed Claude on Google Antigravity failing every turn with "image exceeds 5 MB maximum" once a large image entered the conversation; images are now downscaled to the host's limits before sending, as on the Anthropic API ([#15444](https://github.com/can1357/oh-my-pi/pull/15444) by [@will-bogusz](https://github.com/will-bogusz))
|
|
21
|
+
|
|
22
|
+
## [18.10.2] - 2026-10-12
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- Added support for `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` on the `anthropic` provider: when set, requests omit pre-release fields (`context_management`, beta tool fields such as `strict` and `defer_loading`, task budgets, structured-output format, `thinking.block_binding`, and Anthropic server compaction) along with their beta headers, so `ANTHROPIC_BASE_URL` gateways that reject them keep working ([#15458](https://github.com/can1357/oh-my-pi/pull/15458) by [@H4vC](https://github.com/H4vC))
|
|
27
|
+
|
|
5
28
|
## [18.9.2] - 2026-10-11
|
|
6
29
|
|
|
7
30
|
### Breaking Changes
|
package/THIRD-PARTY-NOTICES.txt
CHANGED
|
@@ -79,9 +79,9 @@ uutils coreutils (https://github.com/uutils/coreutils), MIT
|
|
|
79
79
|
--------------------------------------------------------------------------------
|
|
80
80
|
Covers: base32, base64, basename, cat, cksum (shared checksum machinery),
|
|
81
81
|
b2sum, md5sum, sha1sum, sha224sum, sha256sum, sha384sum, sha512sum, comm, cp,
|
|
82
|
-
cut, date, dirname, head, hostname, ln, ls, mkdir, mktemp, mv,
|
|
83
|
-
printenv, readlink, realpath, rm, seq, sort, stat, tac, tail, tee,
|
|
84
|
-
truncate, uname, uniq, wc, whoami, yes.
|
|
82
|
+
cut, date, dirname, head, hostname, ln, ls, mkdir, mktemp, mv, nl, nproc,
|
|
83
|
+
paste, printenv, readlink, realpath, rm, seq, sort, stat, tac, tail, tee,
|
|
84
|
+
touch, tr, truncate, uname, uniq, wc, whoami, yes.
|
|
85
85
|
|
|
86
86
|
Copyright (c) uutils developers
|
|
87
87
|
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Context, Model } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Clamp every image a Claude reader would receive to the host's contract.
|
|
4
|
+
* Limits the model's policy leaves unset fall back to the canonical Anthropic
|
|
5
|
+
* API. Returns `context` itself when nothing needed resizing.
|
|
6
|
+
*/
|
|
7
|
+
export declare function prepareAnthropicImageContext(context: Context, model: Model<"anthropic-messages"> | Model<"google-gemini-cli">): Promise<Context>;
|
|
@@ -7,6 +7,13 @@ export declare const ANTHROPIC_OPAQUE_CLIENT_ANCHOR_KEY = "anthropic-opaque-clie
|
|
|
7
7
|
export declare function normalizeAnthropicBaseUrl(baseUrl?: string): string | undefined;
|
|
8
8
|
/** Resolve the endpoint for a first-party Anthropic model without loading its provider implementation. */
|
|
9
9
|
export declare function resolveDirectAnthropicBaseUrl(model: Model<Api>): string;
|
|
10
|
+
/**
|
|
11
|
+
* Whether a request to `model` reaches the official endpoint its compat was built for. `compat.officialEndpoint`
|
|
12
|
+
* classifies the configured `baseUrl`, but a first-party Anthropic model is rerouted at request time by
|
|
13
|
+
* `ANTHROPIC_BASE_URL` or Foundry's `FOUNDRY_BASE_URL` without rebuilding compat. Gates official-only host
|
|
14
|
+
* limits such as the catalog's inline image byte and image count budgets.
|
|
15
|
+
*/
|
|
16
|
+
export declare function reachesOfficialEndpoint(model: Model<Api>): boolean;
|
|
10
17
|
/** Build the endpoint-and-model-scoped Anthropic session-state key. */
|
|
11
18
|
export declare function anthropicProviderSessionStateKey(baseUrl: string, modelId: string): string;
|
|
12
19
|
/** Re-arm fast mode across materialized Anthropic session-state entries. */
|
|
@@ -114,6 +114,15 @@ interface OpenAIResponsesChainState {
|
|
|
114
114
|
/** Set once chaining is judged unsupported for this session (circuit breaker). */
|
|
115
115
|
disabled: boolean;
|
|
116
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Whether the next request for `model` replays native Responses history items
|
|
119
|
+
* (encrypted reasoning, item ids), as {@link buildParams} decides it. A session
|
|
120
|
+
* is cold, rebuilding turns from message content, from the first request on a
|
|
121
|
+
* connection-bound host (`compat.connectionBoundNativeHistory`, GitHub Copilot)
|
|
122
|
+
* and after `close()` on any host, until a response succeeds. Without session
|
|
123
|
+
* state every request replays.
|
|
124
|
+
*/
|
|
125
|
+
export declare function openAIResponsesReplaysNativeHistory(model: Pick<Model, "api" | "provider" | "compat"> | null | undefined, providerSessionState: Map<string, ProviderSessionState> | undefined): boolean;
|
|
117
126
|
/**
|
|
118
127
|
* Set the process-wide storage default per provider id, used when a request
|
|
119
128
|
* leaves `storeResponses` unset. Hosts call this from their settings so every
|
|
@@ -525,6 +525,20 @@ export interface BuildResponsesInputOptions<TApi extends Api> {
|
|
|
525
525
|
* Callers gate on {@link isHarmonyDialectModel}. Items are copied, not mutated.
|
|
526
526
|
*/
|
|
527
527
|
export declare function escapeReplayedControlTokens(items: ResponseInput): ResponseInput;
|
|
528
|
+
/**
|
|
529
|
+
* Whether a same-provider assistant turn may replay its native items while the
|
|
530
|
+
* provider session is still cold (#489). A cold session rebuilds turns from
|
|
531
|
+
* message content because some backends bind native items to one connection
|
|
532
|
+
* (GitHub Copilot: `401 input item does not belong to this connection`, #488).
|
|
533
|
+
* Binding needs server-issued state that survives replay sanitization: an
|
|
534
|
+
* `encrypted_content` blob or an item id. A turn with neither, whose every
|
|
535
|
+
* reasoning item carries plaintext `reasoning_text`, has nothing to bind and is
|
|
536
|
+
* exactly what the server receives once the session warms; rebuilding it would
|
|
537
|
+
* drop that reasoning and change the prompt prefix the server cached.
|
|
538
|
+
* Summary-only reasoning keeps the rebuild: it is no evidence of a server that
|
|
539
|
+
* returns plaintext reasoning.
|
|
540
|
+
*/
|
|
541
|
+
export declare function isColdReplayableResponsesTurn(items: ResponseInput): boolean;
|
|
528
542
|
export declare function buildResponsesInput<TApi extends Api>(options: BuildResponsesInputOptions<TApi>): ResponseInput;
|
|
529
543
|
/**
|
|
530
544
|
* Non-empty `reasoning_text` shipped for a synthesized reasoning item when no
|
|
@@ -31,6 +31,11 @@ export declare function appendDuplicateSuffix(originalId: string, suffix: string
|
|
|
31
31
|
* name slot carries invocation text).
|
|
32
32
|
*/
|
|
33
33
|
export declare function isMalformedToolCallName(name: unknown): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Whether {@link transformMessages} drops `msg` from every replay: a turn cut
|
|
36
|
+
* off by `length` / `error` / `aborted` that carries no text and no tool call.
|
|
37
|
+
*/
|
|
38
|
+
export declare function shouldDropTruncatedThinkingOnlyAssistant(msg: AssistantMessage): boolean;
|
|
34
39
|
/**
|
|
35
40
|
* Normalize tool call ID for cross-provider compatibility.
|
|
36
41
|
* OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`.
|
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}`;
|
|
@@ -9,6 +9,7 @@ import { isAnthropicOAuthToken } from "@oh-my-pi/pi-catalog/utils";
|
|
|
9
9
|
import { parseGitHubCopilotApiKey } from "@oh-my-pi/pi-catalog/wire/github-copilot";
|
|
10
10
|
import {
|
|
11
11
|
$env,
|
|
12
|
+
$flag,
|
|
12
13
|
isEnoent,
|
|
13
14
|
logger,
|
|
14
15
|
parseJsonWithRepair,
|
|
@@ -116,10 +117,12 @@ import {
|
|
|
116
117
|
type MessageCreateParams,
|
|
117
118
|
type MessageCreateParamsStreaming,
|
|
118
119
|
type MessageParam,
|
|
120
|
+
type OutputConfig,
|
|
119
121
|
parseAnthropicInputTransformations,
|
|
120
122
|
type RawMessageStreamEvent,
|
|
121
123
|
THINKING_BINDING_CONTROLS_BETA,
|
|
122
124
|
type TextBlockParam,
|
|
125
|
+
type ThinkingConfigParam,
|
|
123
126
|
} from "./anthropic-wire";
|
|
124
127
|
import {
|
|
125
128
|
claudeCodeSdkVersion,
|
|
@@ -138,6 +141,7 @@ import {
|
|
|
138
141
|
resolveGitHubCopilotBaseUrl,
|
|
139
142
|
wrapFetchForCopilotFallback,
|
|
140
143
|
} from "./github-copilot-headers";
|
|
144
|
+
import { prepareAnthropicImageContext } from "./anthropic-images";
|
|
141
145
|
import { servedModelFromAnthropicSignature } from "./anthropic-signature";
|
|
142
146
|
import { getOpenAIPromptCacheKey } from "./openai-shared";
|
|
143
147
|
import { applyInferenceHeaders } from "./inference-headers";
|
|
@@ -273,24 +277,41 @@ const promptCacheBetas: Record<string, true> = {
|
|
|
273
277
|
};
|
|
274
278
|
|
|
275
279
|
/**
|
|
276
|
-
*
|
|
280
|
+
* Betas withheld under the kill switch: they gate the pre-release body fields
|
|
281
|
+
* it removes (Claude Code's gateway contract: context management, beta tool
|
|
282
|
+
* fields, structured-output format, task budgets, tool search), plus the
|
|
283
|
+
* thinking-binding and compaction controls omp also withholds.
|
|
284
|
+
*/
|
|
285
|
+
const killSwitchBetas: Record<string, true> = {
|
|
286
|
+
[THINKING_BINDING_CONTROLS_BETA]: true,
|
|
287
|
+
[contextManagementBeta]: true,
|
|
288
|
+
[COMPACTION_BETA]: true,
|
|
289
|
+
[LEGACY_COMPACTION_BETA]: true,
|
|
290
|
+
[structuredOutputsBeta]: true,
|
|
291
|
+
[taskBudgetBeta]: true,
|
|
292
|
+
[midConversationToolChangesBeta]: true,
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Remove the `dropped` betas from every `anthropic-beta` entry in a header
|
|
277
297
|
* record. Header names are case-insensitive and the value is a comma-separated
|
|
278
298
|
* list, so each matching key keeps its own casing and its remaining tokens;
|
|
279
299
|
* an entry left with no token is deleted rather than sent empty. The input is
|
|
280
|
-
* returned untouched when it advertises
|
|
300
|
+
* returned untouched when it advertises none of them.
|
|
281
301
|
*
|
|
282
302
|
* `model.headers` and `options.headers` are caller-controlled channels that
|
|
283
303
|
* reach the wire through `buildAnthropicHeaders` (as `modelHeaders`, which an
|
|
284
304
|
* override-enabled endpoint lets replace our own beta header outright), the
|
|
285
305
|
* GitHub Copilot default headers, and the per-request beta override built for
|
|
286
|
-
* injected clients — each of which must go through here once breakpoints drop
|
|
306
|
+
* injected clients — each of which must go through here once breakpoints drop
|
|
307
|
+
* or the experimental-beta kill switch is on.
|
|
287
308
|
*/
|
|
288
|
-
function
|
|
309
|
+
function stripHeaderBetas(headers: Record<string, string>, dropped: Record<string, true>): Record<string, string> {
|
|
289
310
|
let stripped: Record<string, string> | undefined;
|
|
290
311
|
for (const key in headers) {
|
|
291
312
|
if (key.toLowerCase() !== "anthropic-beta") continue;
|
|
292
313
|
const betas = normalizeExtraBetas(headers[key]);
|
|
293
|
-
const kept = betas.filter(beta =>
|
|
314
|
+
const kept = betas.filter(beta => dropped[beta] !== true);
|
|
294
315
|
if (kept.length === betas.length) continue;
|
|
295
316
|
stripped ??= { ...headers };
|
|
296
317
|
if (kept.length === 0) {
|
|
@@ -952,253 +973,6 @@ function decodeAnthropicToolName(name: string, isOAuthToken: boolean, escapeBuil
|
|
|
952
973
|
return name;
|
|
953
974
|
}
|
|
954
975
|
|
|
955
|
-
const ANTHROPIC_MANY_IMAGE_THRESHOLD = 20;
|
|
956
|
-
const ANTHROPIC_MANY_IMAGE_MAX_DIMENSION = 2000;
|
|
957
|
-
/**
|
|
958
|
-
* Image contract of the canonical Anthropic Messages API, used when resolved
|
|
959
|
-
* model policy names no other: 8000px per side ("At least one of the image
|
|
960
|
-
* dimensions exceed max allowed size: 8000 pixels") and a 10 MB base64
|
|
961
|
-
* payload per block ("image exceeds 10 MB maximum: 14012300 bytes >
|
|
962
|
-
* 10485760 bytes"), measured on the encoded string rather than the decoded
|
|
963
|
-
* bytes. Either rejection poisons every retry and model fallback because the
|
|
964
|
-
* block stays in context, so both are clamped before the request goes out.
|
|
965
|
-
* A host with a different image contract sets `max-image-dimension` /
|
|
966
|
-
* `max-image-payload-bytes` in the KDL compatibility rules.
|
|
967
|
-
*/
|
|
968
|
-
const ANTHROPIC_MAX_IMAGE_DIMENSION = 8000;
|
|
969
|
-
const ANTHROPIC_MAX_IMAGE_PAYLOAD_BYTES = 10 * 1024 * 1024;
|
|
970
|
-
/** Headroom for the surrounding JSON and any provider-side accounting. */
|
|
971
|
-
const ANTHROPIC_IMAGE_PAYLOAD_HEADROOM = 0.9;
|
|
972
|
-
/** Re-encode passes allowed to bring one image under the byte budget. */
|
|
973
|
-
const ANTHROPIC_IMAGE_RESIZE_ATTEMPTS = 4;
|
|
974
|
-
|
|
975
|
-
/**
|
|
976
|
-
* Image bounds for one request. `maxDimension` and `payloadBudget` come from
|
|
977
|
-
* resolved model policy (`max-image-dimension`, `max-image-payload-bytes`) so
|
|
978
|
-
* a deployment whose image contract differs from the canonical API can
|
|
979
|
-
* override them, and the many-image path tightens `maxDimension` further
|
|
980
|
-
* because many images share one request budget.
|
|
981
|
-
*/
|
|
982
|
-
interface AnthropicImageCaps {
|
|
983
|
-
maxDimension: number;
|
|
984
|
-
payloadBudget: number;
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
function countAnthropicImageBlocks(messages: Message[]): number {
|
|
988
|
-
let count = 0;
|
|
989
|
-
for (const message of messages) {
|
|
990
|
-
if (message.role !== "user" && message.role !== "developer" && message.role !== "toolResult") continue;
|
|
991
|
-
if (!Array.isArray(message.content)) continue;
|
|
992
|
-
for (const block of message.content) {
|
|
993
|
-
if (block.type === "image") count++;
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
return count;
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
/**
|
|
1000
|
-
* Ceiling on encoder output pixels in flight. One 8000x8000 RGBA surface is
|
|
1001
|
-
* roughly 256 MiB, so admitting four of them would spike the CLI by a
|
|
1002
|
-
* gigabyte; at the full-resolution cap this yields a single pipeline, while
|
|
1003
|
-
* the many-image cap encodes at most 2000px and keeps the four-way fan-out.
|
|
1004
|
-
*/
|
|
1005
|
-
const ANTHROPIC_IMAGE_RESIZE_PIXEL_BUDGET = 16_000_000;
|
|
1006
|
-
const ANTHROPIC_IMAGE_RESIZE_MAX_CONCURRENCY = 4;
|
|
1007
|
-
|
|
1008
|
-
function anthropicImageResizeConcurrency(maxDimension: number): number {
|
|
1009
|
-
const perPipeline = maxDimension * maxDimension;
|
|
1010
|
-
if (perPipeline <= 0) return ANTHROPIC_IMAGE_RESIZE_MAX_CONCURRENCY;
|
|
1011
|
-
return Math.max(
|
|
1012
|
-
1,
|
|
1013
|
-
Math.min(ANTHROPIC_IMAGE_RESIZE_MAX_CONCURRENCY, Math.floor(ANTHROPIC_IMAGE_RESIZE_PIXEL_BUDGET / perPipeline)),
|
|
1014
|
-
);
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
/**
|
|
1018
|
-
* Memoized resize results keyed on ImageContent identity, per cap pair.
|
|
1019
|
-
* Callers keep message objects stable across turns, so without this every
|
|
1020
|
-
* request (and every in-provider retry of a fresh turn) re-decodes and
|
|
1021
|
-
* re-encodes the same oversized screenshots. A cached value identical to the
|
|
1022
|
-
* key means "already within bounds / unresizable — skip the decode". The caps
|
|
1023
|
-
* depend on image count and on resolved policy, so each pair keeps its own
|
|
1024
|
-
* cache: a value cached at 8000px is not reusable at 2000px.
|
|
1025
|
-
*/
|
|
1026
|
-
const anthropicImageResizeCaches = new Map<string, WeakMap<ImageContent, ImageContent>>();
|
|
1027
|
-
|
|
1028
|
-
function anthropicImageResizeCache(caps: AnthropicImageCaps): WeakMap<ImageContent, ImageContent> {
|
|
1029
|
-
const key = `${caps.maxDimension}:${caps.payloadBudget}`;
|
|
1030
|
-
const existing = anthropicImageResizeCaches.get(key);
|
|
1031
|
-
if (existing) return existing;
|
|
1032
|
-
const created = new WeakMap<ImageContent, ImageContent>();
|
|
1033
|
-
anthropicImageResizeCaches.set(key, created);
|
|
1034
|
-
return created;
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
type ResizeLimiter = <R>(fn: () => Promise<R>) => Promise<R>;
|
|
1038
|
-
|
|
1039
|
-
/**
|
|
1040
|
-
* Bounded-concurrency gate for image decode/encode work. The many-image path
|
|
1041
|
-
* fans out over every block of every message; unbounded, 100+ oversized images
|
|
1042
|
-
* would decode concurrently and spike memory by gigabytes. Slots are handed
|
|
1043
|
-
* off directly to the next waiter on release.
|
|
1044
|
-
*/
|
|
1045
|
-
function createResizeLimiter(limit: number): ResizeLimiter {
|
|
1046
|
-
let active = 0;
|
|
1047
|
-
const queue: (() => void)[] = [];
|
|
1048
|
-
return async fn => {
|
|
1049
|
-
if (active >= limit) {
|
|
1050
|
-
const { promise, resolve } = Promise.withResolvers<void>();
|
|
1051
|
-
queue.push(resolve);
|
|
1052
|
-
await promise;
|
|
1053
|
-
} else {
|
|
1054
|
-
active++;
|
|
1055
|
-
}
|
|
1056
|
-
try {
|
|
1057
|
-
return await fn();
|
|
1058
|
-
} finally {
|
|
1059
|
-
const next = queue.shift();
|
|
1060
|
-
if (next) next();
|
|
1061
|
-
else active--;
|
|
1062
|
-
}
|
|
1063
|
-
};
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
async function resizeAnthropicImageBlock(block: ImageContent, caps: AnthropicImageCaps): Promise<ImageContent> {
|
|
1067
|
-
const { maxDimension, payloadBudget } = caps;
|
|
1068
|
-
try {
|
|
1069
|
-
const inputBuffer = Buffer.from(block.data, "base64");
|
|
1070
|
-
const { width, height } = await new Bun.Image(inputBuffer).metadata();
|
|
1071
|
-
if (!width || !height) return block;
|
|
1072
|
-
const overDimension = width > maxDimension || height > maxDimension;
|
|
1073
|
-
const overBudget = block.data.length > payloadBudget;
|
|
1074
|
-
if (!overDimension && !overBudget) return block;
|
|
1075
|
-
|
|
1076
|
-
// Fitting the pixel cap does not imply fitting the byte cap: a dense
|
|
1077
|
-
// screenshot re-encodes large even at the cap. Shrink by the overshoot in
|
|
1078
|
-
// area terms until the payload fits or the passes run out.
|
|
1079
|
-
let scale = Math.min(1, maxDimension / width, maxDimension / height);
|
|
1080
|
-
let best: { buffer: Uint8Array; mimeType: string } | undefined;
|
|
1081
|
-
for (let attempt = 0; attempt < ANTHROPIC_IMAGE_RESIZE_ATTEMPTS; attempt++) {
|
|
1082
|
-
const targetWidth = Math.max(1, Math.min(maxDimension, Math.round(width * scale)));
|
|
1083
|
-
const targetHeight = Math.max(1, Math.min(maxDimension, Math.round(height * scale)));
|
|
1084
|
-
// One encoder at a time: a full-resolution surface is hundreds of
|
|
1085
|
-
// megabytes, so the two candidates never coexist.
|
|
1086
|
-
const png = await new Bun.Image(inputBuffer).resize(targetWidth, targetHeight).png().bytes();
|
|
1087
|
-
const jpeg = await new Bun.Image(inputBuffer).resize(targetWidth, targetHeight).jpeg({ quality: 85 }).bytes();
|
|
1088
|
-
best =
|
|
1089
|
-
png.length <= jpeg.length
|
|
1090
|
-
? { buffer: png, mimeType: "image/png" }
|
|
1091
|
-
: { buffer: jpeg, mimeType: "image/jpeg" };
|
|
1092
|
-
|
|
1093
|
-
// Base64 expands three bytes into four characters; the encoded length
|
|
1094
|
-
// is what Anthropic measures, so derive it without encoding.
|
|
1095
|
-
const encoded = Math.ceil(best.buffer.length / 3) * 4;
|
|
1096
|
-
if (encoded <= payloadBudget) break;
|
|
1097
|
-
if (targetWidth === 1 && targetHeight === 1) break;
|
|
1098
|
-
scale *= Math.max(0.25, Math.sqrt(payloadBudget / encoded) * 0.9);
|
|
1099
|
-
}
|
|
1100
|
-
if (!best) return block;
|
|
1101
|
-
|
|
1102
|
-
const data = Buffer.from(best.buffer).toString("base64");
|
|
1103
|
-
// An image inside the pixel cap is only worth replacing if it shrank.
|
|
1104
|
-
if (!overDimension && data.length >= block.data.length) return block;
|
|
1105
|
-
// The rebuilt block carries no `url` or `providerFile`: those reference
|
|
1106
|
-
// the original bytes, and Anthropic validates a referenced image after
|
|
1107
|
-
// fetching it, so keeping either would resend the oversized copy.
|
|
1108
|
-
return {
|
|
1109
|
-
type: "image",
|
|
1110
|
-
data,
|
|
1111
|
-
mimeType: best.mimeType,
|
|
1112
|
-
};
|
|
1113
|
-
} catch (error) {
|
|
1114
|
-
logger.warn("anthropic: failed to resize oversized image", {
|
|
1115
|
-
mimeType: block.mimeType,
|
|
1116
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1117
|
-
});
|
|
1118
|
-
return block;
|
|
1119
|
-
}
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
async function resizeAnthropicImageContent(
|
|
1123
|
-
content: (TextContent | ImageContent)[],
|
|
1124
|
-
state: { resized: number },
|
|
1125
|
-
limit: ResizeLimiter,
|
|
1126
|
-
caps: AnthropicImageCaps,
|
|
1127
|
-
): Promise<(TextContent | ImageContent)[]> {
|
|
1128
|
-
let changed = false;
|
|
1129
|
-
const cache = anthropicImageResizeCache(caps);
|
|
1130
|
-
const next = await Promise.all(
|
|
1131
|
-
content.map(async block => {
|
|
1132
|
-
if (block.type !== "image") return block;
|
|
1133
|
-
let resized = cache.get(block);
|
|
1134
|
-
if (resized === undefined) {
|
|
1135
|
-
resized = await limit(() => resizeAnthropicImageBlock(block, caps));
|
|
1136
|
-
cache.set(block, resized);
|
|
1137
|
-
}
|
|
1138
|
-
if (resized !== block) {
|
|
1139
|
-
changed = true;
|
|
1140
|
-
state.resized++;
|
|
1141
|
-
}
|
|
1142
|
-
return resized;
|
|
1143
|
-
}),
|
|
1144
|
-
);
|
|
1145
|
-
return changed ? next : content;
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
async function resizeAnthropicImageMessage(
|
|
1149
|
-
message: Message,
|
|
1150
|
-
state: { resized: number },
|
|
1151
|
-
limit: ResizeLimiter,
|
|
1152
|
-
caps: AnthropicImageCaps,
|
|
1153
|
-
): Promise<Message> {
|
|
1154
|
-
if (message.role === "user" || message.role === "developer") {
|
|
1155
|
-
if (!Array.isArray(message.content)) return message;
|
|
1156
|
-
const content = await resizeAnthropicImageContent(message.content, state, limit, caps);
|
|
1157
|
-
return content === message.content ? message : { ...message, content };
|
|
1158
|
-
}
|
|
1159
|
-
if (message.role === "toolResult") {
|
|
1160
|
-
const content = await resizeAnthropicImageContent(message.content, state, limit, caps);
|
|
1161
|
-
return content === message.content ? message : { ...message, content };
|
|
1162
|
-
}
|
|
1163
|
-
return message;
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
async function prepareAnthropicImageContext(context: Context, model: Model<"anthropic-messages">): Promise<Context> {
|
|
1167
|
-
if (!model.input.includes("image")) return context;
|
|
1168
|
-
const imageCount = countAnthropicImageBlocks(context.messages);
|
|
1169
|
-
if (imageCount === 0) return context;
|
|
1170
|
-
// Many images share one request size budget, so they get the tighter cap.
|
|
1171
|
-
// A smaller set only has to clear the host's per-image limit.
|
|
1172
|
-
const hostMaxDimension = model.compat.maxImageDimension ?? ANTHROPIC_MAX_IMAGE_DIMENSION;
|
|
1173
|
-
const maxDimension =
|
|
1174
|
-
imageCount > ANTHROPIC_MANY_IMAGE_THRESHOLD
|
|
1175
|
-
? Math.min(ANTHROPIC_MANY_IMAGE_MAX_DIMENSION, hostMaxDimension)
|
|
1176
|
-
: hostMaxDimension;
|
|
1177
|
-
const maxPayloadBytes = model.compat.maxImagePayloadBytes ?? ANTHROPIC_MAX_IMAGE_PAYLOAD_BYTES;
|
|
1178
|
-
const caps: AnthropicImageCaps = {
|
|
1179
|
-
maxDimension,
|
|
1180
|
-
payloadBudget: Math.floor(maxPayloadBytes * ANTHROPIC_IMAGE_PAYLOAD_HEADROOM),
|
|
1181
|
-
};
|
|
1182
|
-
|
|
1183
|
-
let changed = false;
|
|
1184
|
-
const state = { resized: 0 };
|
|
1185
|
-
const limit = createResizeLimiter(anthropicImageResizeConcurrency(maxDimension));
|
|
1186
|
-
const messages = await Promise.all(
|
|
1187
|
-
context.messages.map(async message => {
|
|
1188
|
-
const next = await resizeAnthropicImageMessage(message, state, limit, caps);
|
|
1189
|
-
if (next !== message) changed = true;
|
|
1190
|
-
return next;
|
|
1191
|
-
}),
|
|
1192
|
-
);
|
|
1193
|
-
if (!changed) return context;
|
|
1194
|
-
logger.debug("anthropic: resized oversized images", {
|
|
1195
|
-
imageCount,
|
|
1196
|
-
resized: state.resized,
|
|
1197
|
-
maxDimension,
|
|
1198
|
-
});
|
|
1199
|
-
return { ...context, messages };
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
976
|
type AnthropicImageSource =
|
|
1203
977
|
| { type: "base64"; media_type: AnthropicImageMediaType; data: string }
|
|
1204
978
|
| { type: "url"; url: string }
|
|
@@ -2406,6 +2180,9 @@ const streamAnthropicOnce = (
|
|
|
2406
2180
|
};
|
|
2407
2181
|
let dropAllThinking = droppedAllThinkingForSignature;
|
|
2408
2182
|
let prefixBindingRetryAttempted = false;
|
|
2183
|
+
// Claude Code's `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS`; the wrapper
|
|
2184
|
+
// already turned the gated compat off, this covers caller/frozen inputs.
|
|
2185
|
+
const killSwitchActive = experimentalBetasDisabled(model);
|
|
2409
2186
|
// A live fallback-credit redemption replays its frozen body.
|
|
2410
2187
|
const frozenRedemption =
|
|
2411
2188
|
options?.fallbackCreditRedemption &&
|
|
@@ -2413,6 +2190,24 @@ const streamAnthropicOnce = (
|
|
|
2413
2190
|
options.fallbackCreditRedemption.params
|
|
2414
2191
|
? options.fallbackCreditRedemption
|
|
2415
2192
|
: undefined;
|
|
2193
|
+
// A body frozen before the kill switch was set still carries the fields
|
|
2194
|
+
// it withholds; the credit token is bound to that body, so forfeit it
|
|
2195
|
+
// and build a fresh request. Server tools that already ran would re-run
|
|
2196
|
+
// and re-bill on a tokenless retry, so surface that case instead.
|
|
2197
|
+
const frozenBody = frozenRedemption?.params as MessageCreateParamsStreaming | undefined;
|
|
2198
|
+
// The redemption drops `fallbacks` before sending, so they cannot force a forfeit.
|
|
2199
|
+
const forfeitFrozenForKillSwitch =
|
|
2200
|
+
killSwitchActive &&
|
|
2201
|
+
frozenBody !== undefined &&
|
|
2202
|
+
carriesKillSwitchField({ ...frozenBody, fallbacks: undefined });
|
|
2203
|
+
if (
|
|
2204
|
+
forfeitFrozenForKillSwitch &&
|
|
2205
|
+
frozenRedemption?.refusedContent?.some(block => block.type === "anthropicServerTool")
|
|
2206
|
+
) {
|
|
2207
|
+
throw new AIError.ConfigurationError(
|
|
2208
|
+
"CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS was set after a refused turn that ran server tools; its fallback credit cannot be redeemed without the experimental fields the switch withholds.",
|
|
2209
|
+
);
|
|
2210
|
+
}
|
|
2416
2211
|
// Collected at most once, on first params build; a frozen redemption
|
|
2417
2212
|
// body never builds params.
|
|
2418
2213
|
let controlRecords: AnthropicControlRecord[] | undefined;
|
|
@@ -2431,12 +2226,16 @@ const streamAnthropicOnce = (
|
|
|
2431
2226
|
// `anthropic-beta` value, so once breakpoints are dropped an unstripped
|
|
2432
2227
|
// base would re-attach the very cache beta the replay abandoned. Both
|
|
2433
2228
|
// prompt-cache betas govern `ttl`/`scope`, so the options rung has to
|
|
2434
|
-
// strip them too.
|
|
2435
|
-
//
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2229
|
+
// strip them too. The kill switch's betas never ride, whatever the
|
|
2230
|
+
// caller's header says. Read through a closure because both cache
|
|
2231
|
+
// flags flip mid-turn.
|
|
2232
|
+
const callerBetaBaseHeaders = (): Record<string, string> => {
|
|
2233
|
+
const headers =
|
|
2234
|
+
dropCacheControl || dropCacheControlOptions
|
|
2235
|
+
? stripHeaderBetas(mergedCallerHeaders, promptCacheBetas)
|
|
2236
|
+
: mergedCallerHeaders;
|
|
2237
|
+
return killSwitchActive ? stripHeaderBetas(headers, killSwitchBetas) : headers;
|
|
2238
|
+
};
|
|
2440
2239
|
// Keep fallback payloads aligned with the top-level Vertex effort gate:
|
|
2441
2240
|
// no nested effort field means the fallback scan cannot re-add its beta.
|
|
2442
2241
|
let fallbacks = options?.fallbacks;
|
|
@@ -2565,7 +2364,9 @@ const streamAnthropicOnce = (
|
|
|
2565
2364
|
if (!isOAuth && isOfficialAnthropicApiUrl(baseUrl) && !extraBetas.includes(fallbackCreditBeta)) {
|
|
2566
2365
|
extraBetas.push(fallbackCreditBeta);
|
|
2567
2366
|
}
|
|
2568
|
-
|
|
2367
|
+
// A forfeited frozen body is rebuilt fresh, so it keeps the betas
|
|
2368
|
+
// computed above instead of the saved list describing the old body.
|
|
2369
|
+
if (options?.fallbackCreditRedemption && !forfeitFrozenForKillSwitch) {
|
|
2569
2370
|
const frozenBetas = (options.fallbackCreditRedemption.betas ?? []).filter(
|
|
2570
2371
|
b => !b.startsWith("server-side-fallback-"),
|
|
2571
2372
|
);
|
|
@@ -2598,6 +2399,8 @@ const streamAnthropicOnce = (
|
|
|
2598
2399
|
}
|
|
2599
2400
|
}
|
|
2600
2401
|
}
|
|
2402
|
+
// Caller betas and a frozen redemption's beta list bypass compat.
|
|
2403
|
+
if (killSwitchActive) extraBetas = extraBetas.filter(beta => killSwitchBetas[beta] !== true);
|
|
2601
2404
|
|
|
2602
2405
|
clientArgs = {
|
|
2603
2406
|
model,
|
|
@@ -2659,6 +2462,8 @@ const streamAnthropicOnce = (
|
|
|
2659
2462
|
if (replacementPayload !== undefined) {
|
|
2660
2463
|
nextParams = replacementPayload as typeof nextParams;
|
|
2661
2464
|
}
|
|
2465
|
+
// After `onPayload`, so a hook cannot restore a field the switch withholds.
|
|
2466
|
+
if (killSwitchActive) withholdKillSwitchFields(nextParams);
|
|
2662
2467
|
if (nextParams.compaction) stripCompactionIncompatibleParams(nextParams);
|
|
2663
2468
|
// After `onPayload`, so a hook cannot restore a field Bedrock rejects.
|
|
2664
2469
|
if (model.compat.bedrockMessagesApi) fitBedrockAnthropicPayload(nextParams);
|
|
@@ -2696,7 +2501,7 @@ const streamAnthropicOnce = (
|
|
|
2696
2501
|
let params: MessageCreateParamsStreaming;
|
|
2697
2502
|
// Recheck expiry here: preparation (image resizing) is async, and an
|
|
2698
2503
|
// expired token must fall back to a fresh request as before.
|
|
2699
|
-
if (frozenRedemption && Date.now() <= frozenRedemption.expiresAt) {
|
|
2504
|
+
if (frozenRedemption && !forfeitFrozenForKillSwitch && Date.now() <= frozenRedemption.expiresAt) {
|
|
2700
2505
|
const redemption = frozenRedemption;
|
|
2701
2506
|
usingFallbackCredit = true;
|
|
2702
2507
|
const frozenParams = structuredClone(redemption.params as MessageCreateParamsStreaming);
|
|
@@ -2925,7 +2730,12 @@ const streamAnthropicOnce = (
|
|
|
2925
2730
|
const rawBetas = options.fallbackCreditRedemption.betaHeader
|
|
2926
2731
|
.split(",")
|
|
2927
2732
|
.map(b => b.trim())
|
|
2928
|
-
.filter(
|
|
2733
|
+
.filter(
|
|
2734
|
+
b =>
|
|
2735
|
+
b &&
|
|
2736
|
+
!b.startsWith("server-side-fallback-") &&
|
|
2737
|
+
(!killSwitchActive || killSwitchBetas[b] !== true),
|
|
2738
|
+
);
|
|
2929
2739
|
if (!rawBetas.includes(fallbackCreditBeta) && !rawBetas.includes("fallback-credit-2026-06-01")) {
|
|
2930
2740
|
rawBetas.push(fallbackCreditBeta);
|
|
2931
2741
|
}
|
|
@@ -4003,10 +3813,97 @@ const streamAnthropicOnce = (
|
|
|
4003
3813
|
* loop. The inner attempt owns Anthropic provider-failure retries.
|
|
4004
3814
|
*/
|
|
4005
3815
|
export const streamAnthropic: StreamFunction<"anthropic-messages"> = (model, context, options) =>
|
|
4006
|
-
withReplaySafeStreamRetry(model, context, options, streamAnthropicOnce, {
|
|
3816
|
+
withReplaySafeStreamRetry(withoutDisabledExperimentalBetas(model), context, options, streamAnthropicOnce, {
|
|
4007
3817
|
retryEmptyCompletion: true,
|
|
4008
3818
|
});
|
|
4009
3819
|
|
|
3820
|
+
/** Whether Claude Code's `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` applies to this model's provider. */
|
|
3821
|
+
function experimentalBetasDisabled(model: Model<"anthropic-messages">): boolean {
|
|
3822
|
+
return model.compat.honorsClaudeCodeBetaKillSwitch === true && $flag("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS");
|
|
3823
|
+
}
|
|
3824
|
+
|
|
3825
|
+
/**
|
|
3826
|
+
* Whether a request body carries a field the kill switch withholds. A frozen
|
|
3827
|
+
* fallback-credit body that does cannot be redeemed under the switch. Defined
|
|
3828
|
+
* as "the sanitizer would change it" so detection and stripping cannot drift.
|
|
3829
|
+
*/
|
|
3830
|
+
function carriesKillSwitchField(params: MessageCreateParamsStreaming): boolean {
|
|
3831
|
+
const sanitized = structuredClone(params);
|
|
3832
|
+
withholdKillSwitchFields(sanitized);
|
|
3833
|
+
return !Bun.deepEquals(sanitized, params);
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3836
|
+
/**
|
|
3837
|
+
* Remove every field the kill switch withholds from a final request body, for
|
|
3838
|
+
* payloads an `onPayload` hook replaced after compat already shaped them, and
|
|
3839
|
+
* for the switch's fields compat has no axis for (task budgets). Compaction
|
|
3840
|
+
* blocks keep their readable summary as text, the same form an endpoint
|
|
3841
|
+
* without compaction support receives.
|
|
3842
|
+
*/
|
|
3843
|
+
function withholdKillSwitchFields(params: MessageCreateParamsStreaming): void {
|
|
3844
|
+
withholdKillSwitchControls(params);
|
|
3845
|
+
delete params.context_management;
|
|
3846
|
+
delete params.compaction;
|
|
3847
|
+
// Server-side fallback entries carry their own per-attempt controls.
|
|
3848
|
+
for (const entry of params.fallbacks ?? []) withholdKillSwitchControls(entry);
|
|
3849
|
+
for (const tool of params.tools ?? []) {
|
|
3850
|
+
if ("strict" in tool) delete tool.strict;
|
|
3851
|
+
if ("defer_loading" in tool) delete tool.defer_loading;
|
|
3852
|
+
if ("eager_input_streaming" in tool) delete tool.eager_input_streaming;
|
|
3853
|
+
}
|
|
3854
|
+
for (const message of params.messages) {
|
|
3855
|
+
// Per-message `output_config` (mid-conversation effort) may carry the same controls.
|
|
3856
|
+
if ("output_config" in message && message.output_config) withholdKillSwitchControls(message);
|
|
3857
|
+
if (!Array.isArray(message.content)) continue;
|
|
3858
|
+
for (let i = 0; i < message.content.length; i++) {
|
|
3859
|
+
const block = message.content[i];
|
|
3860
|
+
if (block.type === "compaction") message.content[i] = { type: "text", text: block.content };
|
|
3861
|
+
}
|
|
3862
|
+
}
|
|
3863
|
+
if (params.anthropic_beta) {
|
|
3864
|
+
params.anthropic_beta = params.anthropic_beta.filter(beta => killSwitchBetas[beta] !== true);
|
|
3865
|
+
}
|
|
3866
|
+
}
|
|
3867
|
+
|
|
3868
|
+
/** Withhold the kill switch's thinking/output controls on a body, fallback entry, or message. */
|
|
3869
|
+
function withholdKillSwitchControls(holder: { thinking?: ThinkingConfigParam; output_config?: OutputConfig }): void {
|
|
3870
|
+
if (holder.thinking && "block_binding" in holder.thinking) delete holder.thinking.block_binding;
|
|
3871
|
+
if (!holder.output_config) return;
|
|
3872
|
+
delete holder.output_config.task_budget;
|
|
3873
|
+
delete holder.output_config.format;
|
|
3874
|
+
if (Object.keys(holder.output_config).length === 0) delete holder.output_config;
|
|
3875
|
+
}
|
|
3876
|
+
|
|
3877
|
+
/**
|
|
3878
|
+
* Honor Claude Code's `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` kill switch on
|
|
3879
|
+
* providers whose compat opts in (`honors-claude-code-beta-kill-switch`).
|
|
3880
|
+
* Gateways behind `ANTHROPIC_BASE_URL` often reject pre-release body fields,
|
|
3881
|
+
* so the switch withholds what Claude Code's gateway contract lists — context
|
|
3882
|
+
* management, beta tool fields (`strict`, `defer_loading`,
|
|
3883
|
+
* `eager_input_streaming`), tool search (mid-conversation tool changes),
|
|
3884
|
+
* structured-output format, task budgets — plus `thinking.block_binding` and
|
|
3885
|
+
* server compaction (whose legacy replay needs a `context_management` edit),
|
|
3886
|
+
* together with their betas. Effort, adaptive thinking, interleaved thinking
|
|
3887
|
+
* and extended context stay, as in Claude Code. Applied through compat so every
|
|
3888
|
+
* request builder and retry rung sees one decision; {@link withholdKillSwitchFields}
|
|
3889
|
+
* and {@link killSwitchBetas} cover the rest where bodies and betas are finalized.
|
|
3890
|
+
*/
|
|
3891
|
+
function withoutDisabledExperimentalBetas(model: Model<"anthropic-messages">): Model<"anthropic-messages"> {
|
|
3892
|
+
if (!experimentalBetasDisabled(model)) return model;
|
|
3893
|
+
return {
|
|
3894
|
+
...model,
|
|
3895
|
+
compat: {
|
|
3896
|
+
...model.compat,
|
|
3897
|
+
supportsContextManagement: false,
|
|
3898
|
+
supportsThinkingBindingControls: false,
|
|
3899
|
+
supportsServerCompaction: false,
|
|
3900
|
+
disableStrictTools: true,
|
|
3901
|
+
supportsEagerToolInputStreaming: false,
|
|
3902
|
+
supportsMidConversationToolChanges: false,
|
|
3903
|
+
},
|
|
3904
|
+
};
|
|
3905
|
+
}
|
|
3906
|
+
|
|
4010
3907
|
export type AnthropicSystemBlock = {
|
|
4011
3908
|
type: "text";
|
|
4012
3909
|
text: string;
|
|
@@ -4163,7 +4060,9 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
|
|
4163
4060
|
// Unlike the generic builder below, this branch has no enforced-key
|
|
4164
4061
|
// filter: a caller-supplied `anthropic-beta` lands in the default
|
|
4165
4062
|
// headers verbatim, cache betas included.
|
|
4166
|
-
const defaultHeaders = dropPromptCacheBetas
|
|
4063
|
+
const defaultHeaders = dropPromptCacheBetas
|
|
4064
|
+
? stripHeaderBetas(mergedCopilotHeaders, promptCacheBetas)
|
|
4065
|
+
: mergedCopilotHeaders;
|
|
4167
4066
|
applyInferenceHeaders(defaultHeaders, {
|
|
4168
4067
|
provider: model.provider,
|
|
4169
4068
|
protocol: "anthropic",
|
|
@@ -4208,9 +4107,13 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
|
|
4208
4107
|
// value outright — but `allowAnthropicHeaderOverrides` lets it replace our
|
|
4209
4108
|
// built beta header wholesale, which would put a cache beta back on a
|
|
4210
4109
|
// breakpoint-free request.
|
|
4211
|
-
|
|
4212
|
-
|
|
4110
|
+
// The kill switch's betas are stripped on the same channel.
|
|
4111
|
+
const cacheStrippedHeaders = dropPromptCacheBetas
|
|
4112
|
+
? stripHeaderBetas(mergedRequestHeaders, promptCacheBetas)
|
|
4213
4113
|
: mergedRequestHeaders;
|
|
4114
|
+
const requestModelHeaders = experimentalBetasDisabled(model)
|
|
4115
|
+
? stripHeaderBetas(cacheStrippedHeaders, killSwitchBetas)
|
|
4116
|
+
: cacheStrippedHeaders;
|
|
4214
4117
|
const defaultHeaders = buildAnthropicHeaders({
|
|
4215
4118
|
apiKey,
|
|
4216
4119
|
baseUrl,
|
|
@@ -36,6 +36,7 @@ import { armPreResponseTimeout, getStreamFirstEventTimeoutMs, iterateWithIdleTim
|
|
|
36
36
|
// the stream provider trusts the access token threaded through `options.apiKey`.
|
|
37
37
|
import { normalizeSchemaForCCA } from "../utils/schema";
|
|
38
38
|
import { StreamMarkupHealing, type StreamMarkupHealingEvent } from "../utils/stream-markup-healing";
|
|
39
|
+
import { prepareAnthropicImageContext } from "./anthropic-images";
|
|
39
40
|
import forcedToolDirective from "./google-antigravity-forced-tool.md" with { type: "text" };
|
|
40
41
|
import type { Content, FunctionCallingConfigMode, ThinkingConfig } from "./google-shared";
|
|
41
42
|
import {
|
|
@@ -573,7 +574,16 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|
|
573
574
|
endpoints = baseUrl ? [baseUrl] : [DEFAULT_ENDPOINT];
|
|
574
575
|
}
|
|
575
576
|
|
|
576
|
-
|
|
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;
|