@linxiraos/pi-ai 1.2.1 → 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 +7 -7
- 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
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
sanitizeOpenAIResponsesAssistantHistoryItemsForReplay,
|
|
85
85
|
sanitizeOpenAIResponsesHistoryItemsForReplay,
|
|
86
86
|
stripUnpairedOpenAIResponsesComputerReasoningIdsForReplay,
|
|
87
|
+
truncateResponseItemId,
|
|
87
88
|
} from "../utils";
|
|
88
89
|
import {
|
|
89
90
|
clearStreamingPartialJson,
|
|
@@ -124,7 +125,7 @@ import type {
|
|
|
124
125
|
ResponseStatus,
|
|
125
126
|
ResponseStreamEvent,
|
|
126
127
|
} from "./openai-responses-wire";
|
|
127
|
-
import { transformMessages } from "./transform-messages";
|
|
128
|
+
import { isValidToolCallId, transformMessages } from "./transform-messages";
|
|
128
129
|
import { joinTextWithImagePlaceholder, NON_VISION_IMAGE_PLACEHOLDER, partitionVisionContent } from "./vision-guard";
|
|
129
130
|
|
|
130
131
|
export interface OpenAIModelIdentity {
|
|
@@ -1379,6 +1380,36 @@ interface StrictToolsRetryContext {
|
|
|
1379
1380
|
tools: Tool[] | undefined;
|
|
1380
1381
|
}
|
|
1381
1382
|
|
|
1383
|
+
/**
|
|
1384
|
+
* Read OpenRouter's `error.metadata.raw` from a captured non-2xx body.
|
|
1385
|
+
*
|
|
1386
|
+
* OpenRouter routes to a different upstream per request, so its top-level
|
|
1387
|
+
* `error.message` is a generic policy sentence ("Access denied by security
|
|
1388
|
+
* policy") and the only place the routed provider's own words appear is
|
|
1389
|
+
* `error.metadata.raw`. Providers that proxy through OpenRouter also hang the
|
|
1390
|
+
* upstream provider name there.
|
|
1391
|
+
*
|
|
1392
|
+
* The detail lives in the decoded body captured on the thrown
|
|
1393
|
+
* {@link AIError.OpenAIHttpError}, not on the error object itself: no error
|
|
1394
|
+
* class in this package carries an `error` property, so reading
|
|
1395
|
+
* `error.error.metadata.raw` unconditionally yielded `undefined` and the
|
|
1396
|
+
* upstream detail was dropped from every surfaced message.
|
|
1397
|
+
*/
|
|
1398
|
+
export function readOpenRouterRawMetadata(
|
|
1399
|
+
capturedErrorResponse: CapturedHttpErrorResponse | undefined,
|
|
1400
|
+
): string | undefined {
|
|
1401
|
+
const body = capturedErrorResponse?.bodyJson;
|
|
1402
|
+
if (!body || typeof body !== "object" || !("error" in body)) return undefined;
|
|
1403
|
+
const errorBody = body.error;
|
|
1404
|
+
if (!errorBody || typeof errorBody !== "object" || !("metadata" in errorBody)) return undefined;
|
|
1405
|
+
const metadata = errorBody.metadata;
|
|
1406
|
+
if (!metadata || typeof metadata !== "object" || !("raw" in metadata)) return undefined;
|
|
1407
|
+
const raw = metadata.raw;
|
|
1408
|
+
if (typeof raw !== "string") return undefined;
|
|
1409
|
+
const trimmed = raw.trim();
|
|
1410
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1382
1413
|
/** Decide whether an OpenAI-family request should retry once with non-strict tools. */
|
|
1383
1414
|
export function shouldRetryWithoutStrictTools(
|
|
1384
1415
|
error: unknown,
|
|
@@ -1487,7 +1518,7 @@ export function normalizeResponsesToolCallIdForTransform(
|
|
|
1487
1518
|
source?: AssistantMessage,
|
|
1488
1519
|
): string {
|
|
1489
1520
|
const sep = id.search(/[\n|]/);
|
|
1490
|
-
if (
|
|
1521
|
+
if (isValidToolCallId(id)) return id;
|
|
1491
1522
|
const isForeignToolCall =
|
|
1492
1523
|
source != null && model != null && (source.provider !== model.provider || source.api !== model.api);
|
|
1493
1524
|
if (isForeignToolCall || sep >= 0 || id.length > 64) {
|
|
@@ -2064,7 +2095,7 @@ export function escapeReplayedControlTokens(items: ResponseInput): ResponseInput
|
|
|
2064
2095
|
* Summary-only reasoning keeps the rebuild: it is no evidence of a server that
|
|
2065
2096
|
* returns plaintext reasoning.
|
|
2066
2097
|
*/
|
|
2067
|
-
function isColdReplayableResponsesTurn(items: ResponseInput): boolean {
|
|
2098
|
+
export function isColdReplayableResponsesTurn(items: ResponseInput): boolean {
|
|
2068
2099
|
let hasReasoning = false;
|
|
2069
2100
|
for (const item of items) {
|
|
2070
2101
|
if ("id" in item && typeof item.id === "string") return false;
|
|
@@ -2516,8 +2547,8 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
|
|
|
2516
2547
|
// legacy plain-string signatures that would otherwise fall into
|
|
2517
2548
|
// the >64-char hash branch and fabricate a bogus msg_ id.
|
|
2518
2549
|
msgId = undefined;
|
|
2519
|
-
} else
|
|
2520
|
-
msgId =
|
|
2550
|
+
} else {
|
|
2551
|
+
msgId = truncateResponseItemId(msgId, "msg");
|
|
2521
2552
|
}
|
|
2522
2553
|
const messageItem: ResponsesReplayAssistantMessage = {
|
|
2523
2554
|
type: "message",
|
|
@@ -3,14 +3,37 @@
|
|
|
3
3
|
* served by the auth-gateway so TypeSafe SDKs and omp's own `NativeJudge`
|
|
4
4
|
* can point `TYPESAFE_BASE_URL` at the gateway and never hold the real key.
|
|
5
5
|
*
|
|
6
|
-
* Requests are validated only as far as routing needs — the `model` id
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Requests are validated only as far as routing needs — the `model` id, the
|
|
7
|
+
* shape of the `questions` map, top-level embedded images, and inline asset
|
|
8
|
+
* maps — and otherwise forwarded to the selected native judgment wire.
|
|
9
|
+
* Validation failures answer `422` like TypeSafe.
|
|
10
10
|
*/
|
|
11
11
|
import { type } from "@linxiraos/pi-omptype";
|
|
12
12
|
import * as AIError from "../error";
|
|
13
|
+
import { isEmbeddedImageContent } from "../images/shared";
|
|
13
14
|
import type { JudgmentRequest, JudgmentResult } from "../judgment/types";
|
|
15
|
+
import type { JudgmentImageAssets } from "../judgment/wire";
|
|
16
|
+
import type { ImageContent } from "../types";
|
|
17
|
+
|
|
18
|
+
/** Image shape only; the shared embedded-image guard validates data, MIME type, and detail. */
|
|
19
|
+
const systemOneImageSchema = type({
|
|
20
|
+
type: "'image'",
|
|
21
|
+
data: "string > 0",
|
|
22
|
+
mimeType: "string > 0",
|
|
23
|
+
"detail?": "unknown",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** Inline JSON assets only: multipart `part` assets need a multipart body this ingress does not parse. */
|
|
27
|
+
const systemOneAssetSchema = type({
|
|
28
|
+
"type?": "'image'",
|
|
29
|
+
format: "'png' | 'jpg' | 'jpeg'",
|
|
30
|
+
data_base64: "string > 0",
|
|
31
|
+
"part?": "never",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const systemOneAssetsSchema = type({ "[string]": systemOneAssetSchema }).narrow(
|
|
35
|
+
(assets, context) => !Array.isArray(assets) || context.mustBe("an object map"),
|
|
36
|
+
);
|
|
14
37
|
|
|
15
38
|
/**
|
|
16
39
|
* TypeSafe accepts `instructions` and `criteria` as strings or structured
|
|
@@ -21,11 +44,14 @@ const systemOneRequestSchema = type({
|
|
|
21
44
|
model: "string > 0",
|
|
22
45
|
state: "string | object",
|
|
23
46
|
questions: type({ "[string]": { type: "'noul' | 'choice' | 'score'", instructions: "string | object" } }),
|
|
47
|
+
"images?": systemOneImageSchema.array(),
|
|
48
|
+
"assets?": systemOneAssetsSchema,
|
|
24
49
|
});
|
|
25
50
|
|
|
26
51
|
export interface SystemOneParsedRequest {
|
|
27
52
|
modelId: string;
|
|
28
53
|
request: JudgmentRequest;
|
|
54
|
+
imageAssets?: JudgmentImageAssets;
|
|
29
55
|
}
|
|
30
56
|
|
|
31
57
|
/** `POST /v1/systemone` response body; `cost` (USD) appears only when the route billed one. */
|
|
@@ -36,19 +62,53 @@ export interface SystemOneResponseBody {
|
|
|
36
62
|
}
|
|
37
63
|
|
|
38
64
|
/**
|
|
39
|
-
* Validate the routing-relevant parts of a System One request body
|
|
65
|
+
* Validate the routing-relevant parts of a System One request body, including
|
|
66
|
+
* top-level embedded images and inline TypeSafe image assets.
|
|
40
67
|
*
|
|
41
|
-
* @throws {AIError.ValidationError}
|
|
42
|
-
*
|
|
68
|
+
* @throws {AIError.ValidationError} when required routing fields are missing
|
|
69
|
+
* or malformed, supplied image data is invalid, or an asset uses multipart
|
|
70
|
+
* `part`, which this JSON ingress cannot read.
|
|
43
71
|
*/
|
|
44
72
|
export function parseRequest(body: unknown): SystemOneParsedRequest {
|
|
45
73
|
const parsed = systemOneRequestSchema(body);
|
|
46
74
|
if (parsed instanceof type.errors) throw new AIError.ValidationError(`systemone: ${parsed.summary}`);
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
75
|
+
|
|
76
|
+
const images: ImageContent[] | undefined = parsed.images?.map((image, index) => {
|
|
77
|
+
if (!isEmbeddedImageContent(image)) {
|
|
78
|
+
throw new AIError.ValidationError(
|
|
79
|
+
`systemone: images[${index}] must contain non-empty base64 data and an image MIME type`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return image;
|
|
83
|
+
});
|
|
84
|
+
const imageAssets: JudgmentImageAssets | undefined =
|
|
85
|
+
parsed.assets === undefined
|
|
86
|
+
? undefined
|
|
87
|
+
: Object.fromEntries(
|
|
88
|
+
Object.entries(parsed.assets).map(([reference, asset]) => {
|
|
89
|
+
const image = {
|
|
90
|
+
type: "image",
|
|
91
|
+
data: asset.data_base64,
|
|
92
|
+
mimeType: asset.format === "png" ? "image/png" : "image/jpeg",
|
|
93
|
+
};
|
|
94
|
+
if (!isEmbeddedImageContent(image)) {
|
|
95
|
+
throw new AIError.ValidationError(
|
|
96
|
+
`systemone: assets[${JSON.stringify(reference)}] must contain non-empty base64 data`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return [reference, image];
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
const request = {
|
|
103
|
+
state: parsed.state,
|
|
104
|
+
questions: parsed.questions,
|
|
105
|
+
...(images === undefined ? {} : { images }),
|
|
106
|
+
} as JudgmentRequest;
|
|
107
|
+
return {
|
|
108
|
+
modelId: parsed.model,
|
|
109
|
+
request,
|
|
110
|
+
...(imageAssets === undefined ? {} : { imageAssets }),
|
|
111
|
+
};
|
|
52
112
|
}
|
|
53
113
|
|
|
54
114
|
/** Re-encode a judgment result in TypeSafe's response shape. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { resolveCatalogPolicy } from "@linxiraos/pi-catalog/compat/catalog-policy";
|
|
2
3
|
import { carriesReasoning } from "@linxiraos/pi-catalog/compat/reasoning-carry";
|
|
3
4
|
import { renderDemotedThinking } from "../dialect/demotion";
|
|
4
5
|
import type {
|
|
@@ -37,6 +38,18 @@ const enum ToolCallStatus {
|
|
|
37
38
|
*/
|
|
38
39
|
export const MAX_TOOL_CALL_ID_LENGTH = 64;
|
|
39
40
|
|
|
41
|
+
/** Replace unsupported tool-call ID characters and cap the result for strict replay providers. */
|
|
42
|
+
export function normalizeToolCallId(id: string, maxLength = MAX_TOOL_CALL_ID_LENGTH): string {
|
|
43
|
+
return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, maxLength);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const TOOL_CALL_ID_PATTERN = new RegExp(`^[a-zA-Z0-9_-]{1,${MAX_TOOL_CALL_ID_LENGTH}}$`);
|
|
47
|
+
|
|
48
|
+
/** Whether `id` already satisfies the strict 1–64 `[a-zA-Z0-9_-]` tool-call ID contract. */
|
|
49
|
+
export function isValidToolCallId(id: string): boolean {
|
|
50
|
+
return TOOL_CALL_ID_PATTERN.test(id);
|
|
51
|
+
}
|
|
52
|
+
|
|
40
53
|
/**
|
|
41
54
|
* OpenAI Responses-family APIs mint composite tool ids (`call_id|item_id`);
|
|
42
55
|
* opaque Chat Completions ids do not (openai-completions preserves same-model
|
|
@@ -333,35 +346,54 @@ function deduplicateToolCallIds(
|
|
|
333
346
|
return { messages: transformed, realToolResultsById, validToolUseIds };
|
|
334
347
|
}
|
|
335
348
|
|
|
349
|
+
/** Default for providers without an authored tool-name policy and native OpenAI Responses history. */
|
|
336
350
|
const MAX_TOOL_CALL_NAME_LENGTH = 128;
|
|
337
|
-
const
|
|
351
|
+
const STRICT_TOOL_CALL_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
352
|
+
const GOOGLE_TOOL_CALL_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_.:-]*$/;
|
|
353
|
+
/** Any character some provider accepts in a declared tool name, for checks with no target. */
|
|
354
|
+
const ANY_PROVIDER_TOOL_CALL_NAME_RE = /^[a-zA-Z0-9_.:-]+$/;
|
|
338
355
|
|
|
339
356
|
/**
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
* control characters (tool schemas never allow them; their presence means the
|
|
343
|
-
* name slot carries invocation text).
|
|
357
|
+
* Charset and length a tool-call name must meet to replay to `model`. Without a
|
|
358
|
+
* target, a name passes when any provider could declare it.
|
|
344
359
|
*/
|
|
345
|
-
|
|
346
|
-
return
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
360
|
+
function toolCallNameLimits(model: Model | undefined): { allowedName: RegExp; maxLength: number } {
|
|
361
|
+
if (!model) return { allowedName: ANY_PROVIDER_TOOL_CALL_NAME_RE, maxLength: MAX_TOOL_CALL_NAME_LENGTH };
|
|
362
|
+
const policy = resolveCatalogPolicy(model);
|
|
363
|
+
return {
|
|
364
|
+
allowedName: policy.toolCallNameCharset === "google" ? GOOGLE_TOOL_CALL_NAME_RE : STRICT_TOOL_CALL_NAME_RE,
|
|
365
|
+
maxLength:
|
|
366
|
+
typeof policy.toolCallNameMaxLength === "number" ? policy.toolCallNameMaxLength : MAX_TOOL_CALL_NAME_LENGTH,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Whether a tool-call name cannot be replayed to the target: missing, empty,
|
|
372
|
+
* longer than its declaration limit, or containing a character outside its
|
|
373
|
+
* declaration charset. Models emit such names when the name slot carries
|
|
374
|
+
* invocation text (whitespace, NULs) or an invented internal URL (`proc://*`).
|
|
375
|
+
*
|
|
376
|
+
* Without a target, use the OpenAI Responses native-history constraints.
|
|
377
|
+
*/
|
|
378
|
+
export function isMalformedToolCallName(
|
|
379
|
+
name: unknown,
|
|
380
|
+
allowedName = STRICT_TOOL_CALL_NAME_RE,
|
|
381
|
+
maxLength = MAX_TOOL_CALL_NAME_LENGTH,
|
|
382
|
+
): boolean {
|
|
383
|
+
return typeof name !== "string" || name.length > maxLength || !allowedName.test(name);
|
|
352
384
|
}
|
|
353
385
|
|
|
354
386
|
function isMalformedToolCallId(id: string | undefined): boolean {
|
|
355
387
|
return !id || id.trim().length === 0;
|
|
356
388
|
}
|
|
357
389
|
|
|
358
|
-
function isMalformedToolCall(block: { id: string; name: string }): boolean {
|
|
359
|
-
return isMalformedToolCallId(block.id) || isMalformedToolCallName(block.name);
|
|
390
|
+
function isMalformedToolCall(block: { id: string; name: string }, allowedName: RegExp, maxLength: number): boolean {
|
|
391
|
+
return isMalformedToolCallId(block.id) || isMalformedToolCallName(block.name, allowedName, maxLength);
|
|
360
392
|
}
|
|
361
393
|
|
|
362
394
|
/**
|
|
363
395
|
* Drop assistant `toolCall` blocks with an empty / whitespace-only `id` or a `name`
|
|
364
|
-
*
|
|
396
|
+
* the target provider cannot declare (see {@link isMalformedToolCallName}),
|
|
365
397
|
* the `toolResult` messages they point at, and any assistant turn that has no
|
|
366
398
|
* replayable content left.
|
|
367
399
|
*
|
|
@@ -380,16 +412,17 @@ function isMalformedToolCall(block: { id: string; name: string }): boolean {
|
|
|
380
412
|
* 128 characters with `string_above_max_length`.
|
|
381
413
|
*
|
|
382
414
|
* Run before any other transform so the rest of the pipeline never sees a
|
|
383
|
-
* malformed call. Idempotent: a re-run on an already-sanitized
|
|
384
|
-
* the input untouched.
|
|
415
|
+
* malformed call. Idempotent for the same target: a re-run on an already-sanitized
|
|
416
|
+
* list returns the input untouched.
|
|
385
417
|
*/
|
|
386
|
-
function sanitizeMalformedToolCalls(messages: Message[]): Message[] {
|
|
418
|
+
function sanitizeMalformedToolCalls(messages: Message[], model: Model): Message[] {
|
|
419
|
+
const { allowedName, maxLength } = toolCallNameLimits(model);
|
|
387
420
|
// Fast path: skip the rewrite entirely when nothing is malformed.
|
|
388
421
|
let hasMalformed = false;
|
|
389
422
|
outer: for (const msg of messages) {
|
|
390
423
|
if (msg.role !== "assistant") continue;
|
|
391
424
|
for (const block of msg.content) {
|
|
392
|
-
if (block.type === "toolCall" && isMalformedToolCall(block)) {
|
|
425
|
+
if (block.type === "toolCall" && isMalformedToolCall(block, allowedName, maxLength)) {
|
|
393
426
|
hasMalformed = true;
|
|
394
427
|
break outer;
|
|
395
428
|
}
|
|
@@ -415,7 +448,7 @@ function sanitizeMalformedToolCalls(messages: Message[]): Message[] {
|
|
|
415
448
|
const filtered: AssistantMessage["content"] = [];
|
|
416
449
|
for (const block of msg.content) {
|
|
417
450
|
if (block.type === "toolCall") {
|
|
418
|
-
const malformed = isMalformedToolCall(block);
|
|
451
|
+
const malformed = isMalformedToolCall(block, allowedName, maxLength);
|
|
419
452
|
const queue = dropQueues.get(block.id);
|
|
420
453
|
if (queue) queue.push(malformed);
|
|
421
454
|
else dropQueues.set(block.id, [malformed]);
|
|
@@ -443,22 +476,45 @@ function sanitizeMalformedToolCalls(messages: Message[]): Message[] {
|
|
|
443
476
|
return result;
|
|
444
477
|
}
|
|
445
478
|
|
|
446
|
-
|
|
479
|
+
/**
|
|
480
|
+
* Whether {@link transformMessages} drops `msg` from every replay: a turn cut
|
|
481
|
+
* off by `length` / `error` / `aborted` that carries no text and no tool call.
|
|
482
|
+
*/
|
|
483
|
+
export function shouldDropTruncatedThinkingOnlyAssistant(msg: AssistantMessage): boolean {
|
|
447
484
|
const isTruncatedStop = msg.stopReason === "length" || msg.stopReason === "error" || msg.stopReason === "aborted";
|
|
448
485
|
return isTruncatedStop && !msg.content.some(block => block.type === "toolCall" || block.type === "text");
|
|
449
486
|
}
|
|
450
487
|
|
|
451
|
-
|
|
488
|
+
/**
|
|
489
|
+
* Whether `msg` reaches a replay as an assistant turn, so it can be the latest
|
|
490
|
+
* surviving one: {@link sanitizeMalformedToolCalls} drops its malformed tool
|
|
491
|
+
* calls, then {@link shouldDropTruncatedThinkingOnlyAssistant} a truncated
|
|
492
|
+
* turn left with no text and no tool call. On an already sanitized list this
|
|
493
|
+
* is `!shouldDropTruncatedThinkingOnlyAssistant(msg)`. Pass the target `model`
|
|
494
|
+
* to apply its tool-name limits; without one, any provider-declarable name survives.
|
|
495
|
+
*/
|
|
496
|
+
export function isSurvivingAssistant(msg: AssistantMessage, model?: Model): boolean {
|
|
497
|
+
const isTruncatedStop = msg.stopReason === "length" || msg.stopReason === "error" || msg.stopReason === "aborted";
|
|
498
|
+
if (msg.content.length === 0) return !isTruncatedStop;
|
|
499
|
+
const { allowedName, maxLength } = toolCallNameLimits(model);
|
|
500
|
+
return msg.content.some(block =>
|
|
501
|
+
block.type === "toolCall"
|
|
502
|
+
? !isMalformedToolCall(block, allowedName, maxLength)
|
|
503
|
+
: !isTruncatedStop || block.type === "text",
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function getLatestSurvivingAssistantIndex(messages: readonly Message[], model: Model): number {
|
|
452
508
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
453
509
|
const msg = messages[index]!;
|
|
454
|
-
if (msg.role === "assistant" &&
|
|
510
|
+
if (msg.role === "assistant" && isSurvivingAssistant(msg, model)) {
|
|
455
511
|
return index;
|
|
456
512
|
}
|
|
457
513
|
}
|
|
458
514
|
return -1;
|
|
459
515
|
}
|
|
460
516
|
|
|
461
|
-
function isAnthropicMessagesModel(model: Model): model is Model<"anthropic-messages"> {
|
|
517
|
+
export function isAnthropicMessagesModel(model: Model): model is Model<"anthropic-messages"> {
|
|
462
518
|
return model.api === "anthropic-messages";
|
|
463
519
|
}
|
|
464
520
|
|
|
@@ -520,12 +576,6 @@ function targetReplaysPlaintextReasoning(model: Model, compat: Model["compat"]):
|
|
|
520
576
|
}
|
|
521
577
|
}
|
|
522
578
|
|
|
523
|
-
const ANTHROPIC_TOOL_CALL_ID_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
524
|
-
|
|
525
|
-
function isValidAnthropicToolCallId(id: string): boolean {
|
|
526
|
-
return ANTHROPIC_TOOL_CALL_ID_PATTERN.test(id);
|
|
527
|
-
}
|
|
528
|
-
|
|
529
579
|
function fallbackAnthropicToolCallId(originalId: string): string {
|
|
530
580
|
return `toolu_${Bun.hash(originalId).toString(36)}`;
|
|
531
581
|
}
|
|
@@ -534,12 +584,11 @@ function normalizeAnthropicTargetToolCallId<TApi extends Api>(
|
|
|
534
584
|
id: string,
|
|
535
585
|
model: Model<TApi>,
|
|
536
586
|
source: AssistantMessage,
|
|
537
|
-
|
|
587
|
+
normalizeId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string,
|
|
538
588
|
): string {
|
|
539
|
-
if (
|
|
540
|
-
const normalized =
|
|
541
|
-
|
|
542
|
-
if (isValidAnthropicToolCallId(normalized)) return normalized;
|
|
589
|
+
if (isValidToolCallId(id)) return id;
|
|
590
|
+
const normalized = normalizeId?.(id, model, source) ?? normalizeToolCallId(id);
|
|
591
|
+
if (isValidToolCallId(normalized)) return normalized;
|
|
543
592
|
return fallbackAnthropicToolCallId(id);
|
|
544
593
|
}
|
|
545
594
|
|
|
@@ -767,11 +816,10 @@ export function transformMessages<TApi extends Api>(
|
|
|
767
816
|
// block errors from LLM providers (e.g. invalid_prompt).
|
|
768
817
|
messages = redactSensitiveCredentialsInMessages(messages);
|
|
769
818
|
|
|
770
|
-
// Drop assistant `toolCall` blocks
|
|
819
|
+
// Drop assistant `toolCall` blocks whose id or name the target cannot replay
|
|
771
820
|
// (and their matched `toolResult` messages) before anything else looks at
|
|
772
|
-
// the history.
|
|
773
|
-
|
|
774
|
-
messages = sanitizeMalformedToolCalls(messages);
|
|
821
|
+
// the history. Replaying them would 400 the target provider.
|
|
822
|
+
messages = sanitizeMalformedToolCalls(messages, model);
|
|
775
823
|
|
|
776
824
|
// Build a map of original tool call IDs to normalized IDs
|
|
777
825
|
const toolCallIdMap = new Map<string, string>();
|
|
@@ -782,7 +830,7 @@ export function transformMessages<TApi extends Api>(
|
|
|
782
830
|
// Completions ids pair by raw equality and must never be canonicalized).
|
|
783
831
|
const responsesCompositeIdMap = new Map<string, string>();
|
|
784
832
|
|
|
785
|
-
const latestSurvivingAssistantIndex = getLatestSurvivingAssistantIndex(messages);
|
|
833
|
+
const latestSurvivingAssistantIndex = getLatestSurvivingAssistantIndex(messages, model);
|
|
786
834
|
const invalidBoundThinkingAssistantIndexes = new Set<number>();
|
|
787
835
|
if (model.thinking?.prefixBinding) {
|
|
788
836
|
let latestRewriteAt: number | undefined;
|
|
@@ -1117,17 +1165,13 @@ export function transformMessages<TApi extends Api>(
|
|
|
1117
1165
|
|
|
1118
1166
|
if (block.type === "fallback") {
|
|
1119
1167
|
// Server-side-fallback boundary marker (Anthropic beta
|
|
1120
|
-
// `server-side-fallback-2026-06-01`).
|
|
1121
|
-
// Anthropic
|
|
1122
|
-
//
|
|
1123
|
-
//
|
|
1124
|
-
//
|
|
1125
|
-
// or throws in its converter
|
|
1126
|
-
|
|
1127
|
-
// itself opts into the beta — but we don't know that here, so
|
|
1128
|
-
// keep it and let `convertAnthropicMessages` re-check the
|
|
1129
|
-
// per-request opt-in before serializing.
|
|
1130
|
-
if (isAnthropicTarget && model.compat.officialEndpoint) return block;
|
|
1168
|
+
// `server-side-fallback-2026-06-01`). Keep it for every
|
|
1169
|
+
// Anthropic-messages target: `convertAnthropicMessages` decides
|
|
1170
|
+
// per request whether the marker can go on the wire (official
|
|
1171
|
+
// endpoint + beta opt-in), and when it cannot, the boundary is
|
|
1172
|
+
// what tells it which model's signed reasoning to keep. Every
|
|
1173
|
+
// other API rejects the block or throws in its converter.
|
|
1174
|
+
if (isAnthropicTarget) return block;
|
|
1131
1175
|
return [];
|
|
1132
1176
|
}
|
|
1133
1177
|
|
package/src/stream.ts
CHANGED
|
@@ -3,7 +3,6 @@ import * as fsSync from "node:fs";
|
|
|
3
3
|
import * as fs from "node:fs/promises";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { scheduler } from "node:timers/promises";
|
|
6
|
-
import { isOfficialAnthropicApiUrl } from "@linxiraos/pi-catalog/compat/anthropic";
|
|
7
6
|
import type { Effort } from "@linxiraos/pi-catalog/effort";
|
|
8
7
|
import {
|
|
9
8
|
isVertexExpressOpenAIUrl,
|
|
@@ -16,8 +15,8 @@ import {
|
|
|
16
15
|
mapEffortToGoogleThinkingLevel,
|
|
17
16
|
requireSupportedEffort,
|
|
18
17
|
resolveWireModelId,
|
|
18
|
+
withoutUnverifiedThinking,
|
|
19
19
|
} from "@linxiraos/pi-catalog/model-thinking";
|
|
20
|
-
import { CODEX_BASE_URL } from "@linxiraos/pi-catalog/wire/codex";
|
|
21
20
|
import { $env, getProviderInFlightRoot, isEnoent, logger, untilAborted } from "@linxiraos/pi-utils";
|
|
22
21
|
import { getCustomApi } from "./api-registry";
|
|
23
22
|
import { createAuthRetryKeyState, isApiKeyResolver, resolvedApiKeyBearer, resolveNextAuthRetryKey } from "./auth-retry";
|
|
@@ -27,6 +26,7 @@ import * as AIError from "./error";
|
|
|
27
26
|
import { ProviderHttpError } from "./error";
|
|
28
27
|
import { isConcurrencyCapExclusion, isUsageLimitOutcome } from "./error/rate-limit";
|
|
29
28
|
import type { BedrockOptions } from "./providers/amazon-bedrock";
|
|
29
|
+
import { reachesOfficialEndpoint } from "./providers/anthropic-state";
|
|
30
30
|
import type { AnthropicOptions } from "./providers/anthropic";
|
|
31
31
|
import type { AppleFoundationModelsOptions } from "./providers/apple-foundation-models";
|
|
32
32
|
import type { CursorOptions } from "./providers/cursor";
|
|
@@ -74,7 +74,6 @@ import type {
|
|
|
74
74
|
} from "./types";
|
|
75
75
|
import { getHeaderCaseInsensitive, resolveCacheRetention } from "./utils";
|
|
76
76
|
import { AssistantMessageEventStream } from "./utils/event-stream";
|
|
77
|
-
import { isFoundryEnabled } from "./utils/foundry";
|
|
78
77
|
import { applyGlyphCodec } from "./utils/glyph-codec";
|
|
79
78
|
import { wrapLeakedThinkingStream } from "./utils/leaked-thinking-stream";
|
|
80
79
|
import { withThinkingLoopGuard } from "./utils/thinking-loop";
|
|
@@ -89,77 +88,12 @@ function isGoogleVertexAuthenticatedModel(model: Model<Api>): boolean {
|
|
|
89
88
|
}
|
|
90
89
|
|
|
91
90
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* leak reasoning idioms into the visible text channel.
|
|
96
|
-
*
|
|
97
|
-
* The gate is provider id **and** official endpoint URL: pointing
|
|
98
|
-
* `provider: "anthropic"` (or `openai`) at a custom proxy via `models.yml`
|
|
99
|
-
* still routes through {@link wrapLeakedThinkingStream}, since a third-party
|
|
100
|
-
* gateway may well leak. URL checks are strict (exact origin / path boundary
|
|
101
|
-
* or parsed hostname) — a substring match would accept lookalikes like
|
|
102
|
-
* `https://api.openai.com.evil/`. Anthropic Foundry (`CLAUDE_CODE_USE_FOUNDRY`)
|
|
103
|
-
* redirects an empty `baseUrl` to `FOUNDRY_BASE_URL`, so the check runs against
|
|
104
|
-
* that effective endpoint — exempt only when it resolves to the official host.
|
|
105
|
-
*/
|
|
106
|
-
function isLeakedThinkingHealExempt(model: Model<Api>): boolean {
|
|
107
|
-
switch (model.provider) {
|
|
108
|
-
case "anthropic": {
|
|
109
|
-
// Mirror resolveAnthropicBaseUrl's effective endpoint: Foundry redirects
|
|
110
|
-
// an empty baseUrl to FOUNDRY_BASE_URL; otherwise an explicit non-official
|
|
111
|
-
// model.baseUrl wins, then the ANTHROPIC_BASE_URL gateway fallback, then
|
|
112
|
-
// the official default. Exempt only when the effective endpoint is official.
|
|
113
|
-
if (isFoundryEnabled()) {
|
|
114
|
-
const foundry = $env.FOUNDRY_BASE_URL?.trim();
|
|
115
|
-
if (foundry) return isOfficialAnthropicApiUrl(foundry);
|
|
116
|
-
}
|
|
117
|
-
if (model.baseUrl && !isOfficialAnthropicApiUrl(model.baseUrl)) return false;
|
|
118
|
-
return isOfficialAnthropicApiUrl($env.ANTHROPIC_BASE_URL?.trim() || model.baseUrl);
|
|
119
|
-
}
|
|
120
|
-
case "openai":
|
|
121
|
-
return isOfficialOpenAIApiUrl(model.baseUrl);
|
|
122
|
-
case "openai-codex":
|
|
123
|
-
return isOfficialCodexApiUrl(model.baseUrl);
|
|
124
|
-
default:
|
|
125
|
-
return false;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Strict official-OpenAI endpoint check; missing baseUrl defaults to `api.openai.com`. */
|
|
130
|
-
function isOfficialOpenAIApiUrl(baseUrl: string | undefined): boolean {
|
|
131
|
-
if (!baseUrl) return true;
|
|
132
|
-
try {
|
|
133
|
-
return new URL(baseUrl).hostname === "api.openai.com";
|
|
134
|
-
} catch {
|
|
135
|
-
return false;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const OFFICIAL_CODEX_URL = new URL(CODEX_BASE_URL);
|
|
140
|
-
|
|
141
|
-
/** Strict official-Codex endpoint check; exact origin or a path boundary after {@link CODEX_BASE_URL}. */
|
|
142
|
-
export function isOfficialCodexApiUrl(baseUrl: string | undefined): boolean {
|
|
143
|
-
if (!baseUrl) return true;
|
|
144
|
-
try {
|
|
145
|
-
const candidate = new URL(baseUrl);
|
|
146
|
-
const candidatePath = candidate.pathname.replace(/\/+$/, "");
|
|
147
|
-
return (
|
|
148
|
-
candidate.origin === OFFICIAL_CODEX_URL.origin &&
|
|
149
|
-
(candidatePath === OFFICIAL_CODEX_URL.pathname || candidatePath.startsWith(`${OFFICIAL_CODEX_URL.pathname}/`))
|
|
150
|
-
);
|
|
151
|
-
} catch {
|
|
152
|
-
return false;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Apply live leaked-thinking healing unless {@link model} is an official
|
|
158
|
-
* first-party endpoint ({@link isLeakedThinkingHealExempt}), which emits
|
|
159
|
-
* structured thinking and needs no healing.
|
|
91
|
+
* Apply live leaked-thinking healing unless the request reaches the model's official endpoint
|
|
92
|
+
* ({@link reachesOfficialEndpoint}): official Anthropic / OpenAI / Codex endpoints return structured
|
|
93
|
+
* thinking blocks and never leak reasoning idioms into the visible text channel.
|
|
160
94
|
*/
|
|
161
95
|
function healLeakedThinking(model: Model<Api>, inner: AssistantMessageEventStream): AssistantMessageEventStream {
|
|
162
|
-
return
|
|
96
|
+
return reachesOfficialEndpoint(model) ? inner : wrapLeakedThinkingStream(inner);
|
|
163
97
|
}
|
|
164
98
|
|
|
165
99
|
type ProviderInFlightLease = {
|
|
@@ -1241,6 +1175,11 @@ export function streamSimple<TApi extends Api>(
|
|
|
1241
1175
|
options?: SimpleStreamOptions,
|
|
1242
1176
|
): AssistantMessageEventStream {
|
|
1243
1177
|
const sessionOptions = withInferenceSessionId(options);
|
|
1178
|
+
// Unverified thinking is opt-in: without a requested effort the request is
|
|
1179
|
+
// byte-identical to a non-reasoning model's.
|
|
1180
|
+
const effortRequested =
|
|
1181
|
+
sessionOptions.reasoning !== undefined && !sessionOptions.disableReasoning && !sessionOptions.forceReasoningOff;
|
|
1182
|
+
if (!effortRequested) model = withoutUnverifiedThinking(model);
|
|
1244
1183
|
if (!model.requiresGlyphTokenization) {
|
|
1245
1184
|
return streamSimpleRequest(model, context, sessionOptions);
|
|
1246
1185
|
}
|
|
@@ -1831,6 +1770,7 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
1831
1770
|
repetitionPenalty: options?.repetitionPenalty,
|
|
1832
1771
|
maxTokens: options?.maxTokens ?? model.maxTokens ?? undefined,
|
|
1833
1772
|
signal: options?.signal,
|
|
1773
|
+
maxHttpAttempts: options?.maxHttpAttempts,
|
|
1834
1774
|
apiKey: apiKey ?? (typeof options?.apiKey === "string" ? options.apiKey : undefined),
|
|
1835
1775
|
credentialId: options?.credentialId,
|
|
1836
1776
|
oauthIdentity: options?.oauthIdentity,
|
|
@@ -1850,7 +1790,6 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
1850
1790
|
minimizeEffort: options?.minimizeEffort,
|
|
1851
1791
|
liveSteering: options?.liveSteering,
|
|
1852
1792
|
maxInFlightRequests: options?.maxInFlightRequests,
|
|
1853
|
-
toolNamespacesInfo: options?.toolNamespacesInfo,
|
|
1854
1793
|
onPayload: options?.onPayload,
|
|
1855
1794
|
onResponse: options?.onResponse,
|
|
1856
1795
|
onSseEvent: options?.onSseEvent,
|
|
@@ -2126,6 +2065,7 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
2126
2065
|
codexCompaction: options?.codexCompaction,
|
|
2127
2066
|
reasoningSummary: options?.hideThinkingSummary ? null : undefined,
|
|
2128
2067
|
textVerbosity: options?.textVerbosity,
|
|
2068
|
+
disableReasoning: options?.disableReasoning,
|
|
2129
2069
|
forceReasoningOff: options?.forceReasoningOff,
|
|
2130
2070
|
});
|
|
2131
2071
|
|