@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.
Files changed (148) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +4 -2
  3. package/THIRD-PARTY-NOTICES.txt +55 -5
  4. package/dist/types/auth/cascade.d.ts +5 -3
  5. package/dist/types/auth/pool.d.ts +3 -3
  6. package/dist/types/auth/select.d.ts +5 -1
  7. package/dist/types/auth/types.d.ts +15 -5
  8. package/dist/types/auth/usage-cache.d.ts +3 -1
  9. package/dist/types/auth-gateway/clients.d.ts +67 -0
  10. package/dist/types/auth-gateway/dispatch.d.ts +28 -10
  11. package/dist/types/auth-gateway/http.d.ts +2 -0
  12. package/dist/types/auth-gateway/index.d.ts +1 -0
  13. package/dist/types/auth-gateway/server.d.ts +9 -4
  14. package/dist/types/auth-gateway/session-state.d.ts +6 -0
  15. package/dist/types/auth-gateway/types.d.ts +16 -2
  16. package/dist/types/dialect/coercion.d.ts +6 -0
  17. package/dist/types/error/classes.d.ts +1 -1
  18. package/dist/types/error/flags.d.ts +15 -0
  19. package/dist/types/error/index.d.ts +1 -0
  20. package/dist/types/error/network-cause.d.ts +40 -0
  21. package/dist/types/images/shared.d.ts +26 -2
  22. package/dist/types/index.d.ts +1 -0
  23. package/dist/types/judgment/native.d.ts +51 -3
  24. package/dist/types/judgment/systemone.d.ts +9 -2
  25. package/dist/types/judgment/text.d.ts +11 -5
  26. package/dist/types/judgment/types.d.ts +5 -1
  27. package/dist/types/judgment/wire.d.ts +16 -3
  28. package/dist/types/provider-session-state.d.ts +2 -2
  29. package/dist/types/providers/anthropic-images.d.ts +7 -0
  30. package/dist/types/providers/anthropic-signature.d.ts +12 -2
  31. package/dist/types/providers/anthropic-state.d.ts +7 -0
  32. package/dist/types/providers/anthropic.d.ts +21 -6
  33. package/dist/types/providers/connection-prewarm.d.ts +15 -0
  34. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  35. package/dist/types/providers/cursor-pi-args.d.ts +13 -0
  36. package/dist/types/providers/cursor.d.ts +7 -0
  37. package/dist/types/providers/openai-codex-responses.d.ts +6 -0
  38. package/dist/types/providers/openai-responses.d.ts +9 -0
  39. package/dist/types/providers/openai-shared.d.ts +30 -0
  40. package/dist/types/providers/systemone-server.d.ts +7 -3
  41. package/dist/types/providers/transform-messages.d.ts +26 -5
  42. package/dist/types/registry/oauth/google-antigravity.d.ts +1 -1
  43. package/dist/types/stream.d.ts +0 -2
  44. package/dist/types/types.d.ts +33 -9
  45. package/dist/types/utils/dsml-leak.d.ts +4 -2
  46. package/dist/types/utils/narration.d.ts +9 -0
  47. package/dist/types/utils/openai-http.d.ts +2 -0
  48. package/dist/types/utils/request-body.d.ts +11 -0
  49. package/dist/types/utils/request-compression.d.ts +21 -0
  50. package/dist/types/utils/tool-call-arguments.d.ts +2 -1
  51. package/dist/types/utils/transport-fetch.d.ts +8 -0
  52. package/dist/types/utils.d.ts +1 -1
  53. package/package.json +8 -8
  54. package/src/auth/cascade.ts +23 -13
  55. package/src/auth/pool.ts +3 -3
  56. package/src/auth/refresh.ts +39 -12
  57. package/src/auth/select.ts +8 -2
  58. package/src/auth/types.ts +16 -5
  59. package/src/auth/usage-cache.ts +23 -7
  60. package/src/auth/usage.ts +2 -4
  61. package/src/auth-broker/remote-store.ts +31 -6
  62. package/src/auth-gateway/clients.ts +318 -0
  63. package/src/auth-gateway/dispatch.ts +66 -35
  64. package/src/auth-gateway/http.ts +11 -5
  65. package/src/auth-gateway/index.ts +1 -0
  66. package/src/auth-gateway/routes/embeddings.ts +4 -10
  67. package/src/auth-gateway/routes/images.ts +6 -10
  68. package/src/auth-gateway/routes/rerank.ts +4 -10
  69. package/src/auth-gateway/routes/speech.ts +10 -10
  70. package/src/auth-gateway/routes/systemone.ts +22 -12
  71. package/src/auth-gateway/routes/transcriptions.ts +4 -2
  72. package/src/auth-gateway/routes/video.ts +12 -15
  73. package/src/auth-gateway/server.ts +136 -53
  74. package/src/auth-gateway/session-state.ts +14 -0
  75. package/src/auth-gateway/types.ts +16 -2
  76. package/src/dialect/coercion.ts +16 -0
  77. package/src/dialect/hermes.ts +7 -11
  78. package/src/dialect/qwen3.ts +3 -11
  79. package/src/error/classes.ts +3 -2
  80. package/src/error/flags.ts +24 -3
  81. package/src/error/index.ts +1 -0
  82. package/src/error/network-cause.ts +179 -0
  83. package/src/images/codex-format-directive.md +3 -0
  84. package/src/images/google-antigravity.ts +2 -1
  85. package/src/images/google-generative-ai.ts +4 -2
  86. package/src/images/openai-hosted.ts +25 -2
  87. package/src/images/openai-images.ts +11 -6
  88. package/src/images/shared.ts +77 -3
  89. package/src/index.ts +1 -0
  90. package/src/judgment/chat.ts +17 -1
  91. package/src/judgment/native.ts +182 -12
  92. package/src/judgment/openai-decisions.ts +27 -6
  93. package/src/judgment/systemone.ts +131 -10
  94. package/src/judgment/text.ts +20 -7
  95. package/src/judgment/types.ts +5 -1
  96. package/src/judgment/wire.ts +17 -3
  97. package/src/provider-session-state.ts +2 -2
  98. package/src/providers/amazon-bedrock.ts +4 -1
  99. package/src/providers/anthropic-client.ts +1 -1
  100. package/src/providers/anthropic-images.ts +263 -0
  101. package/src/providers/anthropic-messages-server.ts +84 -1
  102. package/src/providers/anthropic-signature.ts +35 -8
  103. package/src/providers/anthropic-state.ts +13 -0
  104. package/src/providers/anthropic.ts +479 -397
  105. package/src/providers/connection-prewarm.ts +78 -0
  106. package/src/providers/cowork-fetch.ts +5 -1
  107. package/src/providers/cursor/exec-modern.ts +1 -0
  108. package/src/providers/cursor-pi-args.ts +19 -0
  109. package/src/providers/cursor.ts +53 -13
  110. package/src/providers/devin.ts +10 -4
  111. package/src/providers/factory-droid/gemini.ts +9 -3
  112. package/src/providers/google-gemini-cli.ts +11 -1
  113. package/src/providers/google-shared.ts +2 -6
  114. package/src/providers/images-server.ts +6 -1
  115. package/src/providers/ollama.ts +15 -5
  116. package/src/providers/openai-anthropic-shim.ts +1 -0
  117. package/src/providers/openai-chat-server.ts +29 -0
  118. package/src/providers/openai-codex/access-programs.ts +2 -1
  119. package/src/providers/openai-codex-responses.ts +49 -82
  120. package/src/providers/openai-completions.ts +117 -19
  121. package/src/providers/openai-responses.ts +25 -2
  122. package/src/providers/openai-shared.ts +36 -5
  123. package/src/providers/pi-native-server.ts +1 -0
  124. package/src/providers/systemone-server.ts +72 -12
  125. package/src/providers/transform-messages.ts +95 -51
  126. package/src/registry/oauth/google-antigravity.ts +1 -1
  127. package/src/stream.ts +13 -73
  128. package/src/types.ts +33 -9
  129. package/src/usage/claude.ts +2 -9
  130. package/src/usage/github-copilot.ts +26 -4
  131. package/src/usage/openai-codex.ts +13 -7
  132. package/src/usage/opencode-go.ts +7 -8
  133. package/src/usage/xai-oauth.ts +2 -9
  134. package/src/utils/dsml-leak.ts +30 -14
  135. package/src/utils/leaked-thinking-stream.ts +10 -0
  136. package/src/utils/narration.ts +18 -0
  137. package/src/utils/openai-http.ts +27 -18
  138. package/src/utils/request-body.ts +27 -0
  139. package/src/utils/request-compression.ts +202 -0
  140. package/src/utils/request-debug.ts +23 -9
  141. package/src/utils/schema/dereference.ts +51 -18
  142. package/src/utils/schema/draft.ts +10 -2
  143. package/src/utils/schema/json-schema-validator.ts +4 -10
  144. package/src/utils/schema/normalize.ts +6 -9
  145. package/src/utils/tool-call-arguments.ts +2 -1
  146. package/src/utils/transport-fetch.ts +37 -0
  147. package/src/utils/validation.ts +26 -16
  148. 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 (sep < 0 && id.length <= 64 && /^[a-zA-Z0-9_-]+$/.test(id)) return id;
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 if (msgId.length > 64) {
2520
- msgId = `msg_${Bun.hash(msgId).toString(36)}`;
2550
+ } else {
2551
+ msgId = truncateResponseItemId(msgId, "msg");
2521
2552
  }
2522
2553
  const messageItem: ResponsesReplayAssistantMessage = {
2523
2554
  type: "message",
@@ -55,6 +55,7 @@ const ALLOWED_OPTION_KEYS: ReadonlySet<keyof SimpleStreamOptions> = new Set([
55
55
  "repetitionPenalty",
56
56
  "stopSequences",
57
57
  "maxTokens",
58
+ "maxHttpAttempts",
58
59
  "cacheRetention",
59
60
  "cachedContent",
60
61
  "userProfileId",
@@ -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 and
7
- * the shape of the `questions` map — and otherwise forwarded verbatim, so
8
- * structured `instructions`/`criteria` and forward-compatible fields reach
9
- * the upstream untouched. Validation failures answer `422` like TypeSafe.
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} naming the offending field when `model`,
42
- * `state`, or `questions` is missing or malformed.
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
- // `req.json()` output is JSON by construction, and the typed `Questions`
48
- // contract models string instructions only; the judge forwards the request
49
- // verbatim and reads back nothing but each question's `type`.
50
- const request = { state: parsed.state, questions: parsed.questions } as JudgmentRequest;
51
- return { modelId: parsed.model, request };
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 TOOL_CALL_NAME_SEPARATOR_RE = /[\s\p{Cc}]/u;
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
- * Whether a tool-call name cannot belong to any declared tool: missing, empty,
341
- * longer than OpenAI's 128-character replay limit, or containing whitespace or
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
- export function isMalformedToolCallName(name: unknown): boolean {
346
- return (
347
- typeof name !== "string" ||
348
- name.length === 0 ||
349
- name.length > MAX_TOOL_CALL_NAME_LENGTH ||
350
- TOOL_CALL_NAME_SEPARATOR_RE.test(name)
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
- * no provider could have declared (see {@link isMalformedToolCallName}),
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 list returns
384
- * the input untouched. Provider-agnostic — any wire model could surface this.
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
- function shouldDropTruncatedThinkingOnlyAssistant(msg: AssistantMessage): boolean {
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
- function getLatestSurvivingAssistantIndex(messages: readonly Message[]): number {
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" && !shouldDropTruncatedThinkingOnlyAssistant(msg)) {
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
- normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string,
587
+ normalizeId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string,
538
588
  ): string {
539
- if (isValidAnthropicToolCallId(id)) return id;
540
- const normalized =
541
- normalizeToolCallId?.(id, model, source) ?? id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, MAX_TOOL_CALL_ID_LENGTH);
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 with empty/whitespace `id` or `name`
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. Replays of these would 400 every provider — see
773
- // `sanitizeMalformedToolCalls`.
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`). Only the official
1121
- // Anthropic endpoint accepts this block on replay: every
1122
- // other target either rejects unknown content blocks with a
1123
- // 400 (anthropic-compatible endpoints like Umans/Z.AI/MiniMax,
1124
- // and older zeta gateways whose schema pre-dates this feature)
1125
- // or throws in its converter (Bedrock). Even the official
1126
- // replay path only accepts the block when the current request
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
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Antigravity OAuth flow (Gemini 3, Claude, GPT-OSS via Google Cloud)
2
+ * Google Antigravity OAuth flow (Gemini, Claude, GPT-OSS via Google Cloud)
3
3
  * Uses different OAuth credentials than google-gemini-cli for access to additional models.
4
4
  */
5
5
 
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
- * Whether {@link model} is an official first-party endpoint whose stream needs
93
- * no leaked-thinking healing — the official Anthropic API and the official
94
- * OpenAI / OpenAI-Codex endpoints return structured thinking blocks and never
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 isLeakedThinkingHealExempt(model) ? inner : wrapLeakedThinkingStream(inner);
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