@linxiraos/pi-ai 1.2.0 → 1.3.0
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 +83 -0
- package/README.md +4 -2
- package/THIRD-PARTY-NOTICES.txt +55 -5
- package/dist/types/auth/cascade.d.ts +5 -3
- package/dist/types/auth/pool.d.ts +3 -3
- package/dist/types/auth/select.d.ts +5 -1
- package/dist/types/auth/types.d.ts +15 -5
- package/dist/types/auth/usage-cache.d.ts +3 -1
- package/dist/types/auth-gateway/clients.d.ts +67 -0
- package/dist/types/auth-gateway/dispatch.d.ts +28 -10
- package/dist/types/auth-gateway/http.d.ts +2 -0
- package/dist/types/auth-gateway/index.d.ts +1 -0
- package/dist/types/auth-gateway/server.d.ts +9 -4
- package/dist/types/auth-gateway/session-state.d.ts +6 -0
- package/dist/types/auth-gateway/types.d.ts +16 -2
- package/dist/types/dialect/coercion.d.ts +6 -0
- package/dist/types/error/classes.d.ts +1 -1
- package/dist/types/error/flags.d.ts +15 -0
- package/dist/types/error/index.d.ts +1 -0
- package/dist/types/error/network-cause.d.ts +40 -0
- package/dist/types/images/shared.d.ts +26 -2
- package/dist/types/index.d.ts +1 -0
- package/dist/types/judgment/native.d.ts +51 -3
- package/dist/types/judgment/systemone.d.ts +9 -2
- package/dist/types/judgment/text.d.ts +11 -5
- package/dist/types/judgment/types.d.ts +5 -1
- package/dist/types/judgment/wire.d.ts +16 -3
- package/dist/types/provider-session-state.d.ts +2 -2
- package/dist/types/providers/anthropic-images.d.ts +7 -0
- package/dist/types/providers/anthropic-signature.d.ts +12 -2
- package/dist/types/providers/anthropic-state.d.ts +7 -0
- package/dist/types/providers/anthropic.d.ts +21 -6
- package/dist/types/providers/connection-prewarm.d.ts +15 -0
- package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
- package/dist/types/providers/cursor-pi-args.d.ts +13 -0
- package/dist/types/providers/cursor.d.ts +7 -0
- package/dist/types/providers/openai-codex-responses.d.ts +6 -0
- package/dist/types/providers/openai-responses.d.ts +9 -0
- package/dist/types/providers/openai-shared.d.ts +30 -0
- package/dist/types/providers/systemone-server.d.ts +7 -3
- package/dist/types/providers/transform-messages.d.ts +26 -5
- package/dist/types/registry/oauth/google-antigravity.d.ts +1 -1
- package/dist/types/stream.d.ts +0 -2
- package/dist/types/types.d.ts +33 -9
- package/dist/types/utils/dsml-leak.d.ts +4 -2
- package/dist/types/utils/narration.d.ts +9 -0
- package/dist/types/utils/openai-http.d.ts +2 -0
- package/dist/types/utils/request-body.d.ts +11 -0
- package/dist/types/utils/request-compression.d.ts +21 -0
- package/dist/types/utils/tool-call-arguments.d.ts +2 -1
- package/dist/types/utils/transport-fetch.d.ts +8 -0
- package/dist/types/utils.d.ts +1 -1
- package/package.json +8 -8
- package/src/auth/cascade.ts +23 -13
- package/src/auth/pool.ts +3 -3
- package/src/auth/refresh.ts +39 -12
- package/src/auth/select.ts +8 -2
- package/src/auth/types.ts +16 -5
- package/src/auth/usage-cache.ts +23 -7
- package/src/auth/usage.ts +2 -4
- package/src/auth-broker/remote-store.ts +31 -6
- package/src/auth-gateway/clients.ts +318 -0
- package/src/auth-gateway/dispatch.ts +66 -35
- package/src/auth-gateway/http.ts +11 -5
- package/src/auth-gateway/index.ts +1 -0
- package/src/auth-gateway/routes/embeddings.ts +4 -10
- package/src/auth-gateway/routes/images.ts +6 -10
- package/src/auth-gateway/routes/rerank.ts +4 -10
- package/src/auth-gateway/routes/speech.ts +10 -10
- package/src/auth-gateway/routes/systemone.ts +22 -12
- package/src/auth-gateway/routes/transcriptions.ts +4 -2
- package/src/auth-gateway/routes/video.ts +12 -15
- package/src/auth-gateway/server.ts +136 -53
- package/src/auth-gateway/session-state.ts +14 -0
- package/src/auth-gateway/types.ts +16 -2
- package/src/dialect/coercion.ts +16 -0
- package/src/dialect/hermes.ts +7 -11
- package/src/dialect/qwen3.ts +3 -11
- package/src/error/classes.ts +3 -2
- package/src/error/flags.ts +24 -3
- package/src/error/index.ts +1 -0
- package/src/error/network-cause.ts +179 -0
- package/src/images/codex-format-directive.md +3 -0
- package/src/images/google-antigravity.ts +2 -1
- package/src/images/google-generative-ai.ts +4 -2
- package/src/images/openai-hosted.ts +25 -2
- package/src/images/openai-images.ts +11 -6
- package/src/images/shared.ts +77 -3
- package/src/index.ts +1 -0
- package/src/judgment/chat.ts +17 -1
- package/src/judgment/native.ts +182 -12
- package/src/judgment/openai-decisions.ts +27 -6
- package/src/judgment/systemone.ts +131 -10
- package/src/judgment/text.ts +20 -7
- package/src/judgment/types.ts +5 -1
- package/src/judgment/wire.ts +17 -3
- package/src/provider-session-state.ts +2 -2
- package/src/providers/amazon-bedrock.ts +4 -1
- package/src/providers/anthropic-client.ts +1 -1
- package/src/providers/anthropic-images.ts +263 -0
- package/src/providers/anthropic-messages-server.ts +84 -1
- package/src/providers/anthropic-signature.ts +35 -8
- package/src/providers/anthropic-state.ts +13 -0
- package/src/providers/anthropic.ts +479 -397
- package/src/providers/connection-prewarm.ts +78 -0
- package/src/providers/cowork-fetch.ts +5 -1
- package/src/providers/cursor/exec-modern.ts +1 -0
- package/src/providers/cursor-pi-args.ts +19 -0
- package/src/providers/cursor.ts +53 -13
- package/src/providers/devin.ts +10 -4
- package/src/providers/factory-droid/gemini.ts +9 -3
- package/src/providers/google-gemini-cli.ts +11 -1
- package/src/providers/google-shared.ts +2 -6
- package/src/providers/images-server.ts +6 -1
- package/src/providers/ollama.ts +15 -5
- package/src/providers/openai-anthropic-shim.ts +1 -0
- package/src/providers/openai-chat-server.ts +29 -0
- package/src/providers/openai-codex/access-programs.ts +2 -1
- package/src/providers/openai-codex-responses.ts +49 -82
- package/src/providers/openai-completions.ts +117 -19
- package/src/providers/openai-responses.ts +25 -2
- package/src/providers/openai-shared.ts +36 -5
- package/src/providers/pi-native-server.ts +1 -0
- package/src/providers/systemone-server.ts +72 -12
- package/src/providers/transform-messages.ts +95 -51
- package/src/registry/oauth/google-antigravity.ts +1 -1
- package/src/stream.ts +13 -73
- package/src/types.ts +33 -9
- package/src/usage/claude.ts +2 -9
- package/src/usage/github-copilot.ts +26 -4
- package/src/usage/openai-codex.ts +13 -7
- package/src/usage/opencode-go.ts +7 -8
- package/src/usage/xai-oauth.ts +2 -9
- package/src/utils/dsml-leak.ts +30 -14
- package/src/utils/leaked-thinking-stream.ts +10 -0
- package/src/utils/narration.ts +18 -0
- package/src/utils/openai-http.ts +27 -18
- package/src/utils/request-body.ts +27 -0
- package/src/utils/request-compression.ts +202 -0
- package/src/utils/request-debug.ts +23 -9
- package/src/utils/schema/dereference.ts +51 -18
- package/src/utils/schema/draft.ts +10 -2
- package/src/utils/schema/json-schema-validator.ts +4 -10
- package/src/utils/schema/normalize.ts +6 -9
- package/src/utils/tool-call-arguments.ts +2 -1
- package/src/utils/transport-fetch.ts +37 -0
- package/src/utils/validation.ts +26 -16
- package/src/utils.ts +38 -23
package/src/judgment/wire.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* encodes, and decodes one request for each API it serves. Internal to the
|
|
4
4
|
* judgment module; `native.ts` maps every judgment API to its dialect.
|
|
5
5
|
*/
|
|
6
|
-
import type { Usage } from "../types";
|
|
6
|
+
import type { ImageContent, Usage } from "../types";
|
|
7
|
+
import type { JudgmentImageInput } from "@linxiraos/pi-catalog/compat/judgment-images";
|
|
7
8
|
import type { JudgmentRequest } from "./types";
|
|
8
9
|
|
|
9
10
|
/** Where one attempt posts and which bearer token it sends. */
|
|
@@ -23,6 +24,9 @@ export interface DecodedJudgment {
|
|
|
23
24
|
usage: Usage;
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
/** Image assets kept separately from judgment state for asset-aware native wires. */
|
|
28
|
+
export type JudgmentImageAssets = Readonly<Record<string, ImageContent>>;
|
|
29
|
+
|
|
26
30
|
/** How one judgment API addresses, encodes, and decodes a request. */
|
|
27
31
|
export interface JudgmentWire {
|
|
28
32
|
/**
|
|
@@ -30,8 +34,18 @@ export interface JudgmentWire {
|
|
|
30
34
|
* Throws when the credential cannot address the endpoint (e.g. no account id).
|
|
31
35
|
*/
|
|
32
36
|
target(baseUrl: string, model: string, key: string): JudgmentTarget;
|
|
33
|
-
/** Request body for `model
|
|
34
|
-
encode(request: JudgmentRequest, model: string): unknown;
|
|
37
|
+
/** Request body for `model`, with optional asset context for dialects that preserve references. */
|
|
38
|
+
encode(request: JudgmentRequest, model: string, imageAssets?: JudgmentImageAssets): unknown;
|
|
39
|
+
/** Headers this dialect adds to requests that carry images. */
|
|
40
|
+
imageHeaders?: Readonly<Record<string, string>>;
|
|
41
|
+
/** Image input this dialect accepts; absent means text-only. */
|
|
42
|
+
images?: JudgmentImageInput;
|
|
43
|
+
/** Whether raw asset maps can be transported without loss by this dialect. */
|
|
44
|
+
supportsImageAssets?: boolean;
|
|
45
|
+
/** Whether image support must be enabled explicitly for direct NativeJudge construction. */
|
|
46
|
+
imagesRequireOptIn?: boolean;
|
|
47
|
+
/** Whether this wire drops per-image detail metadata. */
|
|
48
|
+
ignoresImageDetail?: boolean;
|
|
35
49
|
/** The response's answers and usage; `undefined` when the payload is not this API's response shape. */
|
|
36
50
|
decode(payload: unknown): DecodedJudgment | undefined;
|
|
37
51
|
}
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
* siblings are endpoint-scoped and stay: `strictToolsDisabled`
|
|
19
19
|
* (grammar-too-large 400 for the model's tool schema),
|
|
20
20
|
* `replayUnsignedThinkingDisabled` / `thinkingReplayDisabled` (the endpoint
|
|
21
|
-
* is a signing proxy), `prefixDroppedThinkingBlocks` (blocks a
|
|
22
|
-
*
|
|
21
|
+
* is a signing proxy), `prefixDroppedThinkingBlocks` (blocks a rejected
|
|
22
|
+
* replay made the client strip).
|
|
23
23
|
* - **OpenAI Responses** — the `previous_response_id` chain baselines are
|
|
24
24
|
* account-scoped: a stored response belongs to the account that created it.
|
|
25
25
|
* Strict-tools / reasoning-effort fallbacks, replay warmup and the chaining
|
|
@@ -50,6 +50,7 @@ import { invalidateAwsCredentialCache, resolveAwsCredentials } from "./aws-crede
|
|
|
50
50
|
import { decodeEventStream } from "./aws-eventstream";
|
|
51
51
|
import { signRequest } from "./aws-sigv4";
|
|
52
52
|
import { isThinkingPrefixBindingError } from "./anthropic";
|
|
53
|
+
import { isAnthropicNarrationSignature } from "./anthropic-signature";
|
|
53
54
|
import { parseAnthropicInputTransformations, THINKING_BINDING_CONTROLS_BETA } from "./anthropic-wire";
|
|
54
55
|
import { isBedrockRequestMetadataValue } from "./bedrock-request-metadata";
|
|
55
56
|
import { transformMessages } from "./transform-messages";
|
|
@@ -977,6 +978,8 @@ function handleContentBlockStop(
|
|
|
977
978
|
stream.push({ type: "text_end", contentIndex: index, content: block.text, partial: output });
|
|
978
979
|
break;
|
|
979
980
|
case "thinking":
|
|
981
|
+
// Claude on Bedrock carries Anthropic's signature format, narration kind included.
|
|
982
|
+
if (block.thinkingSignature && isAnthropicNarrationSignature(block.thinkingSignature)) block.narration = true;
|
|
980
983
|
stream.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: output });
|
|
981
984
|
break;
|
|
982
985
|
case "toolCall":
|
|
@@ -1087,7 +1090,7 @@ function convertMessages(
|
|
|
1087
1090
|
promptCachePolicy: BedrockPromptCachePolicy,
|
|
1088
1091
|
): WireMessage[] {
|
|
1089
1092
|
const result: WireMessage[] = [];
|
|
1090
|
-
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
|
|
1093
|
+
const transformedMessages = transformMessages(context.messages, model, id => normalizeToolCallId(id));
|
|
1091
1094
|
|
|
1092
1095
|
for (let i = 0; i < transformedMessages.length; i++) {
|
|
1093
1096
|
const m = transformedMessages[i];
|
|
@@ -278,7 +278,7 @@ export class AnthropicHttpClient {
|
|
|
278
278
|
continue;
|
|
279
279
|
}
|
|
280
280
|
if (error instanceof AIError.AnthropicConnectionTimeoutError) throw error;
|
|
281
|
-
throw new AIError.AnthropicConnectionError(error);
|
|
281
|
+
throw new AIError.AnthropicConnectionError(error, url);
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
if (response.ok) return response;
|
|
@@ -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 "@linxiraos/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
|
+
}
|
|
@@ -538,9 +538,53 @@ function mapStopReasonOut(reason: StopReason, hasToolUse: boolean): "end_turn" |
|
|
|
538
538
|
}
|
|
539
539
|
}
|
|
540
540
|
|
|
541
|
+
/**
|
|
542
|
+
* Collapse a server-side-fallback turn for a bridge client that never opted
|
|
543
|
+
* into the beta: drop the `fallback` markers and keep reasoning from one
|
|
544
|
+
* model only. The downstream client never accepted a `fallbacks` request
|
|
545
|
+
* param and the bridge does not forward one, so the marker cannot go on the
|
|
546
|
+
* wire; without it, two models' signed reasoning replayed back-to-back reads
|
|
547
|
+
* as one modified chain and is the shape Anthropic rejects.
|
|
548
|
+
*
|
|
549
|
+
* The segment kept is the first that has any reasoning, not the last. The
|
|
550
|
+
* streaming encoder has already sent the first segment's blocks when the
|
|
551
|
+
* boundary arrives and cannot retract them, so first-segment is the only
|
|
552
|
+
* rule it can decide online; this path uses the same rule so streamed and
|
|
553
|
+
* non-streamed turns present the same reasoning. The offline converter
|
|
554
|
+
* (`collapseFallbackSegments` in `anthropic.ts`) can see the whole turn and
|
|
555
|
+
* keeps the last segment instead — both guarantee a single chain survives.
|
|
556
|
+
*/
|
|
557
|
+
function collapseFallbackSegmentsKeepFirst(content: AssistantMessage["content"]): AssistantMessage["content"] {
|
|
558
|
+
let firstReasoningSegment = -1;
|
|
559
|
+
let segment = 0;
|
|
560
|
+
for (const block of content) {
|
|
561
|
+
if (block.type === "fallback") segment++;
|
|
562
|
+
else if (block.type === "thinking" || block.type === "redactedThinking") {
|
|
563
|
+
firstReasoningSegment = segment;
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (firstReasoningSegment === -1) {
|
|
568
|
+
return content.filter(block => block.type !== "fallback");
|
|
569
|
+
}
|
|
570
|
+
const collapsed: AssistantMessage["content"] = [];
|
|
571
|
+
segment = 0;
|
|
572
|
+
for (const block of content) {
|
|
573
|
+
if (block.type === "fallback") {
|
|
574
|
+
segment++;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if ((block.type === "thinking" || block.type === "redactedThinking") && segment !== firstReasoningSegment) {
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
collapsed.push(block);
|
|
581
|
+
}
|
|
582
|
+
return collapsed;
|
|
583
|
+
}
|
|
584
|
+
|
|
541
585
|
function encodeContentBlocks(message: AssistantMessage): Record<string, unknown>[] {
|
|
542
586
|
const blocks: Record<string, unknown>[] = [];
|
|
543
|
-
for (const c of message.content) {
|
|
587
|
+
for (const c of collapseFallbackSegmentsKeepFirst(message.content)) {
|
|
544
588
|
switch (c.type) {
|
|
545
589
|
case "text":
|
|
546
590
|
blocks.push({ type: "text", text: c.text });
|
|
@@ -668,6 +712,20 @@ export function encodeStream(
|
|
|
668
712
|
// preceding text/thinking block before it opens, so no block that is
|
|
669
713
|
// still open is ever renumbered.
|
|
670
714
|
const suppressed = new Set<number>();
|
|
715
|
+
// A persisted `fallback` marker occupies a content index but is
|
|
716
|
+
// never emitted — the bridge client never opted into the beta, so
|
|
717
|
+
// the marker cannot go on the wire. The walk below records each
|
|
718
|
+
// marker (and every other block this encoder cannot emit) in
|
|
719
|
+
// `suppressed` so `wireIndex` stays dense. Once a boundary has been
|
|
720
|
+
// passed with reasoning already sent, every later segment's
|
|
721
|
+
// reasoning is a second signed chain the client must not receive;
|
|
722
|
+
// the first chain is already on the wire and cannot be retracted,
|
|
723
|
+
// so it is the one that survives (see
|
|
724
|
+
// `collapseFallbackSegmentsKeepFirst`). `reasoningSeen` tracks what
|
|
725
|
+
// the client actually received, which is why redacted reasoning —
|
|
726
|
+
// which this encoder has no event for — does not set it.
|
|
727
|
+
let reasoningSeen = false;
|
|
728
|
+
let suppressLaterReasoning = false;
|
|
671
729
|
const wireIndex = (contentIndex: number): number => {
|
|
672
730
|
let shift = 0;
|
|
673
731
|
for (const index of suppressed) {
|
|
@@ -707,6 +765,20 @@ export function encodeStream(
|
|
|
707
765
|
while (nextContentIndexToInspect < limit) {
|
|
708
766
|
const contentIndex = nextContentIndexToInspect++;
|
|
709
767
|
const content = message.content[contentIndex];
|
|
768
|
+
if (content?.type === "fallback") {
|
|
769
|
+
// Never on the wire, so keep the numbering dense. Latch the
|
|
770
|
+
// collapse only when the client already has this turn's first
|
|
771
|
+
// chain: what follows would be a second one.
|
|
772
|
+
suppressed.add(contentIndex);
|
|
773
|
+
if (reasoningSeen) suppressLaterReasoning = true;
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
if (content?.type === "redactedThinking") {
|
|
777
|
+
// Redacted reasoning has no stream event, so it never reaches
|
|
778
|
+
// the client; account for its index like any other hole.
|
|
779
|
+
suppressed.add(contentIndex);
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
710
782
|
if (content?.type !== "anthropicServerTool") continue;
|
|
711
783
|
ensureStart(message);
|
|
712
784
|
const index = wireIndex(contentIndex);
|
|
@@ -781,6 +853,15 @@ export function encodeStream(
|
|
|
781
853
|
case "thinking_start": {
|
|
782
854
|
emitServerToolBlocksBefore(ev.partial, ev.contentIndex);
|
|
783
855
|
ensureStart(ev.partial);
|
|
856
|
+
if (suppressLaterReasoning) {
|
|
857
|
+
// A later segment's reasoning is a second signed
|
|
858
|
+
// chain; drop the whole block — start, deltas,
|
|
859
|
+
// signature and stop — and account for its index
|
|
860
|
+
// so later blocks stay densely numbered.
|
|
861
|
+
suppressed.add(ev.contentIndex);
|
|
862
|
+
break;
|
|
863
|
+
}
|
|
864
|
+
reasoningSeen = true;
|
|
784
865
|
const index = wireIndex(ev.contentIndex);
|
|
785
866
|
open.set(ev.contentIndex, { index, kind: "thinking" });
|
|
786
867
|
controller.enqueue(
|
|
@@ -793,6 +874,7 @@ export function encodeStream(
|
|
|
793
874
|
break;
|
|
794
875
|
}
|
|
795
876
|
case "thinking_delta":
|
|
877
|
+
if (suppressed.has(ev.contentIndex)) break;
|
|
796
878
|
controller.enqueue(
|
|
797
879
|
sseFrame("content_block_delta", {
|
|
798
880
|
type: "content_block_delta",
|
|
@@ -802,6 +884,7 @@ export function encodeStream(
|
|
|
802
884
|
);
|
|
803
885
|
break;
|
|
804
886
|
case "thinking_end": {
|
|
887
|
+
if (suppressed.has(ev.contentIndex)) break;
|
|
805
888
|
const c = ev.partial.content[ev.contentIndex];
|
|
806
889
|
if (c?.type === "thinking" && c.thinkingSignature) {
|
|
807
890
|
controller.enqueue(
|
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Reads the cleartext header Anthropic embeds in thinking-block signatures.
|
|
3
3
|
*
|
|
4
4
|
* A signature is a base64 protobuf: outer `{1: version, 2: envelope}`, envelope
|
|
5
5
|
* `{1: header, 2..5: nonce/ciphertext/tag}`, header
|
|
6
|
-
* `{1: schema, 5: hash, 6: model, 8:
|
|
6
|
+
* `{1: schema, 5: hash, 6: model, 8: kind, 11: org uuid, 15: profile, 21: unix ts}`.
|
|
7
7
|
* Field 6 names the model that actually produced the block — a concrete
|
|
8
8
|
* snapshot (`claude-haiku-4-5-20251001`) or an internal codename
|
|
9
9
|
* (`numbat-v6-…`) — which lets a caller notice a gateway serving something other
|
|
10
10
|
* than the requested model. The v4 outer format (`CAQS…`) ships a header
|
|
11
11
|
* without field 6; the decoder then yields nothing.
|
|
12
|
+
* Field 8 names the block kind: `thinking` for reasoning, `narration` for the
|
|
13
|
+
* user-facing prose the API converts into a thinking block when the model
|
|
14
|
+
* writes it between tool calls (connector-text summarization).
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
const OUTER_ENVELOPE_FIELD = 2;
|
|
15
18
|
const ENVELOPE_HEADER_FIELD = 1;
|
|
16
19
|
const HEADER_MODEL_FIELD = 6;
|
|
20
|
+
const HEADER_KIND_FIELD = 8;
|
|
17
21
|
const MAX_MODEL_ID_LENGTH = 128;
|
|
18
22
|
const MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
|
|
23
|
+
const NARRATION_KIND = Buffer.from("narration");
|
|
19
24
|
|
|
20
25
|
/** Finds a length-delimited field, rejecting tags and lengths that exceed uint32. */
|
|
21
26
|
function lengthDelimitedField(message: Uint8Array, field: number): Uint8Array | undefined {
|
|
@@ -71,12 +76,12 @@ function lengthDelimitedField(message: Uint8Array, field: number): Uint8Array |
|
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
79
|
+
* Cleartext header of an Anthropic thinking signature, or undefined when malformed or absent.
|
|
80
|
+
* No upper length bound: the signature grows with its block, and a long reply's
|
|
81
|
+
* narration signature runs past 13k characters.
|
|
77
82
|
*/
|
|
78
|
-
|
|
79
|
-
if (signature.length < 8
|
|
83
|
+
function anthropicSignatureHeader(signature: string): Uint8Array | undefined {
|
|
84
|
+
if (signature.length < 8) return undefined;
|
|
80
85
|
let raw: Uint8Array;
|
|
81
86
|
try {
|
|
82
87
|
raw = Buffer.from(signature, "base64");
|
|
@@ -85,7 +90,16 @@ export function servedModelFromAnthropicSignature(signature: string): string | u
|
|
|
85
90
|
}
|
|
86
91
|
const envelope = lengthDelimitedField(raw, OUTER_ENVELOPE_FIELD);
|
|
87
92
|
if (!envelope) return undefined;
|
|
88
|
-
|
|
93
|
+
return lengthDelimitedField(envelope, ENVELOPE_HEADER_FIELD);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Model id embedded in an Anthropic thinking signature, or undefined when the
|
|
98
|
+
* signature is malformed, uses a format without a cleartext header, or names
|
|
99
|
+
* nothing resembling a model id.
|
|
100
|
+
*/
|
|
101
|
+
export function servedModelFromAnthropicSignature(signature: string): string | undefined {
|
|
102
|
+
const header = anthropicSignatureHeader(signature);
|
|
89
103
|
if (!header) return undefined;
|
|
90
104
|
const modelBytes = lengthDelimitedField(header, HEADER_MODEL_FIELD);
|
|
91
105
|
if (!modelBytes || modelBytes.length === 0 || modelBytes.length > MAX_MODEL_ID_LENGTH) return undefined;
|
|
@@ -93,6 +107,19 @@ export function servedModelFromAnthropicSignature(signature: string): string | u
|
|
|
93
107
|
return MODEL_ID_PATTERN.test(model) ? model : undefined;
|
|
94
108
|
}
|
|
95
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Whether an Anthropic thinking signature marks its block as narration: prose
|
|
112
|
+
* the model addressed to the user between tool calls, which the API returns as
|
|
113
|
+
* a thinking block. Only an exact `narration` kind matches; any other or
|
|
114
|
+
* unreadable header stays reasoning.
|
|
115
|
+
*/
|
|
116
|
+
export function isAnthropicNarrationSignature(signature: string): boolean {
|
|
117
|
+
const header = anthropicSignatureHeader(signature);
|
|
118
|
+
if (!header) return false;
|
|
119
|
+
const kind = lengthDelimitedField(header, HEADER_KIND_FIELD);
|
|
120
|
+
return kind !== undefined && Buffer.from(kind).equals(NARRATION_KIND);
|
|
121
|
+
}
|
|
122
|
+
|
|
96
123
|
/**
|
|
97
124
|
* Model id from an OpenRouter reasoning item — a `reasoning_details` entry on
|
|
98
125
|
* chat completions or a Responses `reasoning` output item — which forwards the
|
|
@@ -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}`;
|