@linxiraos/pi-ai 1.2.1 → 1.3.1

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 (153) hide show
  1. package/CHANGELOG.md +98 -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/dialect/deepseek.d.ts +13 -0
  18. package/dist/types/error/classes.d.ts +1 -1
  19. package/dist/types/error/flags.d.ts +15 -0
  20. package/dist/types/error/index.d.ts +1 -0
  21. package/dist/types/error/network-cause.d.ts +40 -0
  22. package/dist/types/images/shared.d.ts +26 -2
  23. package/dist/types/index.d.ts +1 -0
  24. package/dist/types/judgment/native.d.ts +62 -3
  25. package/dist/types/judgment/systemone.d.ts +9 -2
  26. package/dist/types/judgment/text.d.ts +11 -5
  27. package/dist/types/judgment/types.d.ts +37 -6
  28. package/dist/types/judgment/wire.d.ts +18 -3
  29. package/dist/types/provider-session-state.d.ts +2 -2
  30. package/dist/types/providers/anthropic-images.d.ts +7 -0
  31. package/dist/types/providers/anthropic-signature.d.ts +12 -2
  32. package/dist/types/providers/anthropic-state.d.ts +7 -0
  33. package/dist/types/providers/anthropic.d.ts +21 -6
  34. package/dist/types/providers/connection-prewarm.d.ts +15 -0
  35. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  36. package/dist/types/providers/cursor-pi-args.d.ts +13 -0
  37. package/dist/types/providers/cursor.d.ts +7 -0
  38. package/dist/types/providers/ollama.d.ts +6 -1
  39. package/dist/types/providers/openai-codex-responses.d.ts +6 -0
  40. package/dist/types/providers/openai-responses.d.ts +9 -0
  41. package/dist/types/providers/openai-shared.d.ts +30 -0
  42. package/dist/types/providers/systemone-server.d.ts +7 -3
  43. package/dist/types/providers/transform-messages.d.ts +28 -5
  44. package/dist/types/registry/oauth/google-antigravity.d.ts +1 -1
  45. package/dist/types/stream.d.ts +0 -2
  46. package/dist/types/types.d.ts +33 -9
  47. package/dist/types/usage/muse-code.d.ts +1 -1
  48. package/dist/types/utils/dsml-leak.d.ts +17 -13
  49. package/dist/types/utils/narration.d.ts +9 -0
  50. package/dist/types/utils/openai-http.d.ts +2 -0
  51. package/dist/types/utils/request-body.d.ts +11 -0
  52. package/dist/types/utils/request-compression.d.ts +21 -0
  53. package/dist/types/utils/tool-call-arguments.d.ts +2 -1
  54. package/dist/types/utils/transport-fetch.d.ts +8 -0
  55. package/dist/types/utils.d.ts +1 -1
  56. package/package.json +7 -7
  57. package/src/auth/cascade.ts +23 -13
  58. package/src/auth/pool.ts +3 -3
  59. package/src/auth/refresh.ts +39 -12
  60. package/src/auth/select.ts +8 -2
  61. package/src/auth/types.ts +16 -5
  62. package/src/auth/usage-cache.ts +23 -7
  63. package/src/auth/usage.ts +2 -4
  64. package/src/auth-broker/remote-store.ts +31 -6
  65. package/src/auth-gateway/clients.ts +318 -0
  66. package/src/auth-gateway/dispatch.ts +66 -35
  67. package/src/auth-gateway/http.ts +11 -5
  68. package/src/auth-gateway/index.ts +1 -0
  69. package/src/auth-gateway/routes/embeddings.ts +4 -10
  70. package/src/auth-gateway/routes/images.ts +6 -10
  71. package/src/auth-gateway/routes/rerank.ts +4 -10
  72. package/src/auth-gateway/routes/speech.ts +10 -10
  73. package/src/auth-gateway/routes/systemone.ts +22 -12
  74. package/src/auth-gateway/routes/transcriptions.ts +4 -2
  75. package/src/auth-gateway/routes/video.ts +12 -15
  76. package/src/auth-gateway/server.ts +136 -53
  77. package/src/auth-gateway/session-state.ts +14 -0
  78. package/src/auth-gateway/types.ts +16 -2
  79. package/src/dialect/coercion.ts +16 -0
  80. package/src/dialect/deepseek.ts +344 -81
  81. package/src/dialect/hermes.ts +7 -11
  82. package/src/dialect/qwen3.ts +3 -11
  83. package/src/error/classes.ts +3 -2
  84. package/src/error/flags.ts +24 -3
  85. package/src/error/index.ts +1 -0
  86. package/src/error/network-cause.ts +179 -0
  87. package/src/images/codex-format-directive.md +3 -0
  88. package/src/images/google-antigravity.ts +2 -1
  89. package/src/images/google-generative-ai.ts +4 -2
  90. package/src/images/openai-hosted.ts +25 -2
  91. package/src/images/openai-images.ts +11 -6
  92. package/src/images/shared.ts +77 -3
  93. package/src/index.ts +1 -0
  94. package/src/judgment/chat.ts +17 -1
  95. package/src/judgment/native.ts +238 -12
  96. package/src/judgment/openai-decisions.ts +30 -6
  97. package/src/judgment/systemone.ts +153 -11
  98. package/src/judgment/text.ts +26 -7
  99. package/src/judgment/types.ts +46 -6
  100. package/src/judgment/wire.ts +19 -3
  101. package/src/provider-session-state.ts +2 -2
  102. package/src/providers/amazon-bedrock.ts +4 -1
  103. package/src/providers/anthropic-client.ts +1 -1
  104. package/src/providers/anthropic-images.ts +263 -0
  105. package/src/providers/anthropic-messages-server.ts +84 -1
  106. package/src/providers/anthropic-signature.ts +35 -8
  107. package/src/providers/anthropic-state.ts +13 -0
  108. package/src/providers/anthropic.ts +479 -397
  109. package/src/providers/connection-prewarm.ts +78 -0
  110. package/src/providers/cowork-fetch.ts +5 -1
  111. package/src/providers/cursor/exec-modern.ts +1 -0
  112. package/src/providers/cursor-pi-args.ts +19 -0
  113. package/src/providers/cursor.ts +53 -13
  114. package/src/providers/devin.ts +10 -4
  115. package/src/providers/factory-droid/gemini.ts +9 -3
  116. package/src/providers/google-gemini-cli.ts +11 -1
  117. package/src/providers/google-shared.ts +2 -6
  118. package/src/providers/images-server.ts +6 -1
  119. package/src/providers/ollama.ts +23 -5
  120. package/src/providers/openai-anthropic-shim.ts +1 -0
  121. package/src/providers/openai-chat-server.ts +29 -0
  122. package/src/providers/openai-codex/access-programs.ts +2 -1
  123. package/src/providers/openai-codex-responses.ts +49 -82
  124. package/src/providers/openai-completions.ts +117 -19
  125. package/src/providers/openai-responses.ts +25 -2
  126. package/src/providers/openai-shared.ts +36 -5
  127. package/src/providers/pi-native-server.ts +1 -0
  128. package/src/providers/systemone-server.ts +72 -12
  129. package/src/providers/transform-messages.ts +104 -55
  130. package/src/registry/oauth/google-antigravity.ts +1 -1
  131. package/src/stream.ts +13 -73
  132. package/src/types.ts +33 -9
  133. package/src/usage/claude.ts +2 -9
  134. package/src/usage/github-copilot.ts +26 -4
  135. package/src/usage/muse-code.ts +115 -29
  136. package/src/usage/openai-codex.ts +13 -7
  137. package/src/usage/opencode-go.ts +7 -8
  138. package/src/usage/xai-oauth.ts +2 -9
  139. package/src/utils/dsml-leak.ts +88 -33
  140. package/src/utils/leaked-thinking-stream.ts +10 -0
  141. package/src/utils/narration.ts +18 -0
  142. package/src/utils/openai-http.ts +27 -18
  143. package/src/utils/request-body.ts +27 -0
  144. package/src/utils/request-compression.ts +202 -0
  145. package/src/utils/request-debug.ts +23 -9
  146. package/src/utils/schema/dereference.ts +51 -18
  147. package/src/utils/schema/draft.ts +10 -2
  148. package/src/utils/schema/json-schema-validator.ts +4 -10
  149. package/src/utils/schema/normalize.ts +6 -9
  150. package/src/utils/tool-call-arguments.ts +2 -1
  151. package/src/utils/transport-fetch.ts +37 -0
  152. package/src/utils/validation.ts +26 -16
  153. package/src/utils.ts +38 -23
@@ -217,8 +217,17 @@ export function isCodexSteerRejection(message: { api?: Api; errorMessage?: strin
217
217
  );
218
218
  }
219
219
 
220
+ /**
221
+ * Transport-failure wording that marks an error transient. Covers
222
+ * diagnostics forwarded verbatim by gateways/proxies in other runtimes, e.g.
223
+ * undici's "other side closed" and Go's `net.ErrClosed` ("use of closed
224
+ * network connection", #15385). Also covers OMP's own network-failure
225
+ * sentences from `networkFailureMessage` (offline / unreachable / refused /
226
+ * TLS), which must stay at least as retryable as the bare `Connection error.`
227
+ * text they replaced.
228
+ */
220
229
  export const TRANSIENT_TRANSPORT_PATTERN =
221
- /\b(?:no[_ -]?capacity|(?:high|peak)[ _-]?demand|(?:at|over|insufficient)[ _-]?capacity|capacity[ _-]?(?:exceeded|exhausted)|peak[ _-]?load)\b|overloaded|provider.?returned.?error|rate.?limit|too many requests|auth-gateway\s+5\d{2}(?=[:\s]|$)|\b(?:429|500|502|503|504)\b|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|unable.?to.?connect\.\s*is the computer able to access the url\?|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|nghttp2_(?:internal_error|refused_stream)|stream closed with error code nghttp2_(?:internal_error|refused_stream)|malformed.?function.?call/i;
230
+ /\b(?:no[_ -]?capacity|(?:high|peak)[ _-]?demand|(?:at|over|insufficient)[ _-]?capacity|capacity[ _-]?(?:exceeded|exhausted)|peak[ _-]?load)\b|overloaded|provider.?returned.?error|rate.?limit|too many requests|auth-gateway\s+5\d{2}(?=[:\s]|$)|\b(?:429|500|502|503|504)\b|service.?unavailable|temporar(?:y|ily)[ _-]?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|unable.?to.?connect\.\s*is the computer able to access the url\?|other side closed|use of closed network connection|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|nghttp2_(?:internal_error|refused_stream)|stream closed with error code nghttp2_(?:internal_error|refused_stream)|\bE(?:TIMEOUT|NOTFOUND|NETUNREACH|HOSTUNREACH)\b|\bFailedToOpenSocket\b|no internet connection|couldn.?t reach|couldn.?t connect to|tls certificate verification failed|malformed.?function.?call/i;
222
231
  const AUTH_FAILURE_PATTERN =
223
232
  /\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i;
224
233
  const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i;
@@ -237,6 +246,18 @@ export function isAnthropicAccountPolicyText(text: string, provider?: string, st
237
246
  return ANTHROPIC_ACCOUNT_POLICY_PATTERN.test(text);
238
247
  }
239
248
 
249
+ const IMMUTABLE_LATEST_THINKING_PATTERN =
250
+ /messages\.\d+\.content\.\d+.*\b(?:thinking|redacted_thinking)\b.*\blatest assistant message cannot be modified\b/is;
251
+
252
+ /**
253
+ * Anthropic's rejection of a replayed latest assistant turn whose `thinking` or
254
+ * `redacted_thinking` blocks no longer verify. Resending the same turn fails the
255
+ * same way; only a request that changes that turn's reasoning can succeed.
256
+ */
257
+ export function isImmutableLatestThinkingText(text: string): boolean {
258
+ return IMMUTABLE_LATEST_THINKING_PATTERN.test(text);
259
+ }
260
+
240
261
  const CODEX_CHATGPT_ACCOUNT_MODEL_POLICY_PATTERN =
241
262
  /\bThe ['"]([^'"\r\n]+)['"] model is not supported when using Codex with a ChatGPT account\./i;
242
263
  const CODEX_CHATGPT_ACCOUNT_MODEL_MAX_LENGTH = 256;
@@ -383,7 +404,7 @@ const CACHE_CONTROL_EMPTY_TEXT_PATTERN =
383
404
  const OAUTH_DEFINITIVE_FAILURE_PATTERN =
384
405
  /invalid_grant|invalid_token|unauthorized_client|\brevoked\b|refresh[\s_]?token.*expired/i;
385
406
  const OAUTH_TRANSIENT_FAILURE_PATTERN =
386
- /timeout|network|fetch failed|ECONN(?:REFUSED|RESET)|ETIMEDOUT|EAI_AGAIN|socket hang up|\b(?:408|425|429|5\d{2})\b|rate.?limit|too many requests|temporar|unavailable|forbidden|permission_denied|cloudflare|captcha/i;
407
+ /timeout|network|fetch failed|ECONN(?:REFUSED|RESET)|ETIMEDOUT|ETIMEOUT|ENOTFOUND|ENETUNREACH|EHOSTUNREACH|FailedToOpenSocket|EAI_AGAIN|socket hang up|\b(?:408|425|429|5\d{2})\b|rate.?limit|too many requests|temporar|unavailable|forbidden|permission_denied|cloudflare|captcha/i;
387
408
  const OAUTH_HTTP_AUTH_PATTERN = /\b401\b/;
388
409
 
389
410
  function matchesStrictToolsRejection(message: string, errorStatus: number | undefined): boolean {
@@ -1082,7 +1103,7 @@ export function isTransientStreamParseError(error: unknown): boolean {
1082
1103
  * non-terminal status treat it as transient (#11805).
1083
1104
  */
1084
1105
  const STREAM_DROP_PATTERN =
1085
- /stream disconnected before completion|stream closed before response\.completed|stream was interrupted|stream ended before terminal (?:chunk|completion event)|socket disconnected before secure tls connection/i;
1106
+ /stream disconnected before completion|(?:stream|websocket) closed before response\.completed|stream was interrupted|stream ended before terminal (?:chunk|completion event)|socket disconnected before secure tls connection/i;
1086
1107
 
1087
1108
  /**
1088
1109
  * Transport stream-drop diagnostic (see {@link STREAM_DROP_PATTERN}). Unlike
@@ -8,6 +8,7 @@ export * from "./finalize";
8
8
  export * from "./flags";
9
9
  export * from "./format";
10
10
  export * from "./gateway";
11
+ export * from "./network-cause";
11
12
  export * from "./oauth";
12
13
  export * from "./provider";
13
14
  export * from "./rate-limit";
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Local transport diagnosis for a rejected `fetch`.
3
+ *
4
+ * Bun and Node describe the same outage with different shapes — Bun throws a
5
+ * `TypeError` carrying `code: "ETIMEOUT" | "FailedToOpenSocket" |
6
+ * "ConnectionRefused"`, undici nests an `ENOTFOUND`/`ECONNREFUSED` errno under
7
+ * `TypeError: fetch failed` (sometimes through an `AggregateError`), and
8
+ * `fetchWithRetry` may already have flattened the chain into a message-only
9
+ * `Error`. Both the code and the text are inspected so every one of those
10
+ * shapes lands in the right bucket.
11
+ */
12
+
13
+ /** Transport-level failure families that get their own user-visible wording. */
14
+ export type NetworkFailureKind = "offline" | "unreachable" | "refused" | "tls";
15
+
16
+ /** What {@link classifyNetworkCause} reports about a rejected `fetch`: the failure family plus the bits its sentence needs. */
17
+ export interface NetworkFailure {
18
+ kind: NetworkFailureKind;
19
+ /** Runtime error code when the chain exposed one (`ETIMEOUT`, `FailedToOpenSocket`, …). */
20
+ code: string | undefined;
21
+ /** Short parenthetical detail for the user-visible message. */
22
+ detail: string | undefined;
23
+ /** Host named by the error text, when it named one. */
24
+ host: string | undefined;
25
+ }
26
+
27
+ const MAX_CAUSE_DEPTH = 8;
28
+ const MAX_AGGREGATE_BRANCH = 4;
29
+ const MAX_LINKS = 16;
30
+ const MAX_DETAIL_LENGTH = 120;
31
+ const UNKNOWN_HOST = "the API host";
32
+
33
+ const OFFLINE_CODE_PATTERN = /^(?:ETIMEOUT|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH)$/i;
34
+ const OFFLINE_TEXT_PATTERN = /\b(?:ETIMEOUT|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH)\b/i;
35
+ const UNREACHABLE_CODE_PATTERN = /^(?:ENOTFOUND|FailedToOpenSocket)$/i;
36
+ const UNREACHABLE_TEXT_PATTERN = /\b(?:ENOTFOUND|FailedToOpenSocket|getaddrinfo)\b/i;
37
+ const REFUSED_CODE_PATTERN = /^(?:ECONNREFUSED|ConnectionRefused)$/i;
38
+ const REFUSED_TEXT_PATTERN = /\bECONNREFUSED\b|\bConnectionRefused\b|\bUnable to connect\b/i;
39
+ const TLS_CODE_PATTERN =
40
+ /^(?:CERT_|SELF_SIGNED|UNABLE_TO_GET_ISSUER_CERT|UNABLE_TO_VERIFY|DEPTH_ZERO_SELF_SIGNED_CERT|ERR_TLS_CERT)/i;
41
+ const TLS_TEXT_PATTERN =
42
+ /unable to verify|certificate verif(?:y|ication)|self[- ]signed certificate|\bCERT_[A-Z_]+\b|\bSELF_SIGNED[A-Z_]*\b|\bUNABLE_TO_GET_ISSUER_CERT[A-Z_]*\b|\bUNABLE_TO_VERIFY_LEAF_SIGNATURE\b/i;
43
+ const GETADDRINFO_HOST_PATTERN = /\bgetaddrinfo\s+[A-Z_]+\s+([^\s,)]+)/i;
44
+ const CONNECT_HOST_PATTERN = /\b(?:connect|connection to)\s+[A-Z_]+\s+([^\s,)]+?)(?::\d+)?(?=[\s,)]|$)/i;
45
+ const BARE_HOST_PATTERN = /\b((?:[a-z0-9-]+\.)+[a-z]{2,})\b/i;
46
+ const GETADDRINFO_DETAIL_PATTERN = /\bgetaddrinfo\s+[A-Z_]+/i;
47
+ const ERRNO_DETAIL_PATTERN = /\b(?:ETIMEOUT|ENOTFOUND|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|FailedToOpenSocket)\b/i;
48
+ const HOST_PATTERNS = [GETADDRINFO_HOST_PATTERN, CONNECT_HOST_PATTERN, BARE_HOST_PATTERN];
49
+
50
+ interface Link {
51
+ value: unknown;
52
+ depth: number;
53
+ }
54
+
55
+ function kindOf(code: string | undefined, message: string | undefined): NetworkFailureKind | undefined {
56
+ // TLS first: a certificate failure usually also reads as a failed connect.
57
+ if (code !== undefined && TLS_CODE_PATTERN.test(code)) return "tls";
58
+ if (message !== undefined && TLS_TEXT_PATTERN.test(message)) return "tls";
59
+ if (code !== undefined && REFUSED_CODE_PATTERN.test(code)) return "refused";
60
+ if (message !== undefined && REFUSED_TEXT_PATTERN.test(message)) return "refused";
61
+ // Offline before unreachable: `getaddrinfo ETIMEOUT` matches both text patterns.
62
+ if (code !== undefined && OFFLINE_CODE_PATTERN.test(code)) return "offline";
63
+ if (message !== undefined && OFFLINE_TEXT_PATTERN.test(message)) return "offline";
64
+ if (code !== undefined && UNREACHABLE_CODE_PATTERN.test(code)) return "unreachable";
65
+ if (message !== undefined && UNREACHABLE_TEXT_PATTERN.test(message)) return "unreachable";
66
+ return undefined;
67
+ }
68
+
69
+ /** First host named by any message seen so far, most specific pattern first. */
70
+ function hostFromTexts(texts: readonly string[]): string | undefined {
71
+ for (const pattern of HOST_PATTERNS) {
72
+ for (const text of texts) {
73
+ const host = pattern.exec(text)?.[1];
74
+ if (host) return host;
75
+ }
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ function detailOf(kind: NetworkFailureKind, code: string | undefined, message: string | undefined): string | undefined {
81
+ // The refused template already carries its own wording.
82
+ if (kind === "refused") return undefined;
83
+ if (kind === "tls" && code !== undefined) return code;
84
+ const pattern = kind === "tls" ? TLS_TEXT_PATTERN : GETADDRINFO_DETAIL_PATTERN;
85
+ const matched = message === undefined ? undefined : pattern.exec(message)?.[0];
86
+ if (matched !== undefined) return matched.replace(/\s+/g, " ").trim().slice(0, MAX_DETAIL_LENGTH);
87
+ if (kind === "tls") return undefined;
88
+ // A DNS/route failure without a `getaddrinfo …` fragment still names its errno; keeping it in
89
+ // the sentence is what keeps the rendered message matching TRANSIENT_TRANSPORT_PATTERN.
90
+ return code ?? (message === undefined ? undefined : ERRNO_DETAIL_PATTERN.exec(message)?.[0]);
91
+ }
92
+
93
+ /**
94
+ * Walk an error's `cause`/`errors` graph (bounded depth, cycle-safe) and report
95
+ * the outermost link that describes a local transport failure. Returns
96
+ * `undefined` for anything else, including HTTP-level provider errors.
97
+ */
98
+ export function classifyNetworkCause(err: unknown): NetworkFailure | undefined {
99
+ const seen = new Set<object>();
100
+ const queue: Link[] = [{ value: err, depth: 0 }];
101
+ const texts: string[] = [];
102
+ let links = 0;
103
+
104
+ while (links < MAX_LINKS) {
105
+ const link = queue.shift();
106
+ if (link === undefined) break;
107
+ links++;
108
+ const { value, depth } = link;
109
+
110
+ let code: string | undefined;
111
+ let message: string | undefined;
112
+ const isObject = typeof value === "object" && value !== null;
113
+ if (typeof value === "string") {
114
+ message = value;
115
+ } else if (isObject) {
116
+ if (seen.has(value)) continue;
117
+ seen.add(value);
118
+ if ("code" in value && typeof value.code === "string") code = value.code;
119
+ if ("message" in value && typeof value.message === "string") message = value.message;
120
+ } else {
121
+ continue;
122
+ }
123
+ if (message !== undefined) texts.push(message);
124
+
125
+ const kind = kindOf(code, message);
126
+ if (kind !== undefined) {
127
+ return { kind, code, detail: detailOf(kind, code, message), host: hostFromTexts(texts) };
128
+ }
129
+
130
+ if (!isObject || depth >= MAX_CAUSE_DEPTH) continue;
131
+ if ("cause" in value) queue.push({ value: value.cause, depth: depth + 1 });
132
+ if ("errors" in value && Array.isArray(value.errors)) {
133
+ for (const nested of value.errors.slice(0, MAX_AGGREGATE_BRANCH)) {
134
+ queue.push({ value: nested, depth: depth + 1 });
135
+ }
136
+ }
137
+ }
138
+ return undefined;
139
+ }
140
+
141
+ /**
142
+ * User-visible sentence for a network failure, or `undefined` when the error is
143
+ * not one. `requestUrl` wins over any host parsed out of the error text.
144
+ */
145
+ export function networkFailureMessage(err: unknown, requestUrl?: string): string | undefined {
146
+ const failure = classifyNetworkCause(err);
147
+ if (failure === undefined) return undefined;
148
+ let urlHost: string | undefined;
149
+ if (typeof requestUrl === "string" && requestUrl.length > 0) {
150
+ try {
151
+ const { host } = new URL(requestUrl);
152
+ if (host.length > 0) urlHost = host;
153
+ } catch {
154
+ urlHost = undefined;
155
+ }
156
+ }
157
+ const host = urlHost ?? failure.host ?? UNKNOWN_HOST;
158
+ const detail = failure.detail === undefined ? "" : ` (${failure.detail})`;
159
+ switch (failure.kind) {
160
+ case "offline":
161
+ return `No internet connection: couldn't reach ${host}${detail}. Check your network or proxy and try again.`;
162
+ case "unreachable":
163
+ return `Couldn't reach ${host}${detail}. Check the URL, your network, or proxy.`;
164
+ case "refused":
165
+ return `Couldn't connect to ${host} (connection refused). Check that the server is running and reachable.`;
166
+ case "tls":
167
+ return `TLS certificate verification failed for ${host}${detail}. If you're behind a TLS-inspecting proxy, its CA may need to be trusted.`;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Re-describe a rejected `fetch` with {@link networkFailureMessage}, keeping the
173
+ * original error as `cause`. Anything that is not a local transport failure is
174
+ * returned untouched, so provider/HTTP errors keep their identity.
175
+ */
176
+ export function networkFailureError(err: unknown, requestUrl?: string): unknown {
177
+ const message = networkFailureMessage(err, requestUrl);
178
+ return message === undefined ? err : new Error(message, { cause: err });
179
+ }
@@ -0,0 +1,3 @@
1
+ {{{prompt}}}
2
+
3
+ Output format: {{shape}} image, {{#if pixels}}{{width}}x{{height}} pixels{{else}}{{width}}:{{height}} aspect ratio{{/if}}.
@@ -8,7 +8,7 @@ import { getAntigravityUserAgent } from "@linxiraos/pi-catalog/wire/gemini-heade
8
8
  import { readSseJson } from "@linxiraos/pi-utils";
9
9
  import { withAuth } from "../auth-retry";
10
10
  import * as AIError from "../error";
11
- import { errorMessage, ImageApiError, usageFromWire } from "./shared";
11
+ import { errorMessage, ImageApiError, usageFromWire, validateImageInput } from "./shared";
12
12
  import type { GeneratedImage, ImageGenerationOptions, ImageGenerationRequest, ImageGenerationResult } from "./types";
13
13
 
14
14
  const IMAGE_SYSTEM_INSTRUCTION =
@@ -138,6 +138,7 @@ export async function generateAntigravityImage(
138
138
  request: ImageGenerationRequest,
139
139
  options: ImageGenerationOptions,
140
140
  ): Promise<ImageGenerationResult> {
141
+ for (const image of request.inputImages ?? []) validateImageInput(image);
141
142
  const fetchImpl = options.fetch ?? fetch;
142
143
  const response = await withAuth(
143
144
  options.apiKey,
@@ -1,7 +1,7 @@
1
1
  import type { Model } from "@linxiraos/pi-catalog/types";
2
2
  import { withAuth } from "../auth-retry";
3
3
  import * as AIError from "../error";
4
- import { errorMessage, ImageApiError, imageBaseUrl, modelHeaders, usageFromWire } from "./shared";
4
+ import { errorMessage, ImageApiError, imageBaseUrl, modelHeaders, usageFromWire, validateImageInput } from "./shared";
5
5
  import type { GeneratedImage, ImageGenerationOptions, ImageGenerationRequest, ImageGenerationResult } from "./types";
6
6
 
7
7
  interface GeminiPart {
@@ -20,7 +20,9 @@ export async function generateGoogleImage(
20
20
  options: ImageGenerationOptions,
21
21
  ): Promise<ImageGenerationResult> {
22
22
  const fetchImpl = options.fetch ?? fetch;
23
- const parts: Array<{ text?: string; inlineData?: GeneratedImage }> = (request.inputImages ?? []).map(image => ({
23
+ const inputImages = request.inputImages ?? [];
24
+ for (const image of inputImages) validateImageInput(image);
25
+ const parts: Array<{ text?: string; inlineData?: GeneratedImage }> = inputImages.map(image => ({
24
26
  inlineData: image,
25
27
  }));
26
28
  parts.push({ text: request.prompt });
@@ -7,7 +7,7 @@ import {
7
7
  OPENAI_HEADERS,
8
8
  URL_PATHS,
9
9
  } from "@linxiraos/pi-catalog/wire/codex";
10
- import { readSseJson, USER_AGENT } from "@linxiraos/pi-utils";
10
+ import { prompt, readSseJson, USER_AGENT } from "@linxiraos/pi-utils";
11
11
  import { withAuth } from "../auth-retry";
12
12
  import * as AIError from "../error";
13
13
  import { applySessionHeader } from "../providers/inference-headers";
@@ -20,6 +20,7 @@ import {
20
20
  toDataUrl,
21
21
  usageFromWire,
22
22
  } from "./shared";
23
+ import codexFormatDirective from "./codex-format-directive.md" with { type: "text" };
23
24
  import type { GeneratedImage, ImageGenerationOptions, ImageGenerationRequest, ImageGenerationResult } from "./types";
24
25
 
25
26
  const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
@@ -143,6 +144,27 @@ async function parseSse(response: Response, signal?: AbortSignal): Promise<Image
143
144
  return collectResponse(completed?.output?.length ? completed : { ...completed, output: fallbackOutput });
144
145
  }
145
146
 
147
+ /**
148
+ * The Codex backend runs its image tool with `size: "auto"`, ignoring the requested `size`, and
149
+ * shapes the canvas from the prompt alone. Spell the requested shape out so it is honored.
150
+ */
151
+ function withCodexFormatDirective(request: ImageGenerationRequest): string {
152
+ const dimensions = request.imageSize?.match(/^(\d+)x(\d+)$/);
153
+ const ratio = dimensions ? undefined : request.aspectRatio?.match(/^(\d+):(\d+)$/);
154
+ const match = dimensions ?? ratio;
155
+ if (!match) return request.prompt;
156
+ const width = Number(match[1]);
157
+ const height = Number(match[2]);
158
+ const shape = width === height ? "square" : width > height ? "landscape" : "portrait";
159
+ return prompt.render(codexFormatDirective, {
160
+ prompt: request.prompt,
161
+ shape,
162
+ width,
163
+ height,
164
+ pixels: dimensions !== undefined,
165
+ });
166
+ }
167
+
146
168
  export async function generateHostedImage(
147
169
  model: Model,
148
170
  request: ImageGenerationRequest,
@@ -154,7 +176,8 @@ export async function generateHostedImage(
154
176
  throw new AIError.ValidationError(`Hosted image carrier API ${carrier.api} is unsupported`);
155
177
  }
156
178
  const stream = carrier.api === "openai-codex-responses";
157
- const content: Array<Record<string, unknown>> = [{ type: "input_text", text: request.prompt }];
179
+ const inputText = stream ? withCodexFormatDirective(request) : request.prompt;
180
+ const content: Array<Record<string, unknown>> = [{ type: "input_text", text: inputText }];
158
181
  for (const image of request.inputImages ?? []) {
159
182
  content.push({ type: "input_image", detail: "auto", image_url: toDataUrl(image) });
160
183
  }
@@ -8,6 +8,7 @@ import {
8
8
  postMultipart,
9
9
  resolveOpenAIImageSize,
10
10
  toDataUrl,
11
+ validateImageInput,
11
12
  } from "./shared";
12
13
  import type { ImageGenerationOptions, ImageGenerationRequest, ImageGenerationResult } from "./types";
13
14
 
@@ -25,21 +26,24 @@ export async function generateOpenAIImage(
25
26
  const fetchImpl = options.fetch ?? fetch;
26
27
  const size = resolveOpenAIImageSize(request.aspectRatio, request.imageSize);
27
28
  const count = request.count ?? 1;
29
+ const wireModelId = model.requestModelId ?? model.id;
30
+ // Hand-built image models (SDK callers, tests) may skip `buildModel` and carry no identity.
31
+ const isGptImage = model.identity?.family === "gpt-image";
28
32
  const isXAI = model.provider === "xai" || model.provider === "xai-oauth";
29
33
  const generationBody = isXAI
30
34
  ? {
31
- model: model.requestModelId ?? model.id,
35
+ model: wireModelId,
32
36
  prompt: request.prompt,
33
37
  aspect_ratio: request.aspectRatio ?? "1:1",
34
38
  resolution: resolveXAIResolution(request.imageSize),
35
39
  n: count,
36
- response_format: "b64_json",
40
+ ...(isGptImage ? {} : { response_format: "b64_json" }),
37
41
  }
38
42
  : {
39
- model: model.requestModelId ?? model.id,
43
+ model: wireModelId,
40
44
  prompt: request.prompt,
41
45
  n: count,
42
- response_format: "b64_json",
46
+ ...(isGptImage ? {} : { response_format: "b64_json" }),
43
47
  ...(size ? { size } : {}),
44
48
  };
45
49
  const inputImages = request.inputImages ?? [];
@@ -79,12 +83,13 @@ export async function generateOpenAIImage(
79
83
  try {
80
84
  if (model.provider === "openai") {
81
85
  const form = new FormData();
82
- form.set("model", model.requestModelId ?? model.id);
86
+ form.set("model", wireModelId);
83
87
  form.set("prompt", request.prompt);
84
88
  form.set("n", String(count));
85
- form.set("response_format", "b64_json");
89
+ if (!isGptImage) form.set("response_format", "b64_json");
86
90
  if (size) form.set("size", size);
87
91
  for (const image of inputImages) {
92
+ validateImageInput(image);
88
93
  form.append("image", new File([Buffer.from(image.data, "base64")], "image", { type: image.mimeType }));
89
94
  }
90
95
  response = await postMultipart({
@@ -1,9 +1,10 @@
1
1
  import type { FetchImpl, Model, Usage } from "@linxiraos/pi-catalog/types";
2
- import { parseImageMetadata, USER_AGENT } from "@linxiraos/pi-utils";
2
+ import { base64ImageDataFailureReason, isRecord, parseImageMetadata, USER_AGENT } from "@linxiraos/pi-utils";
3
3
  import type { ApiKey } from "../auth-retry";
4
4
  import { withAuth } from "../auth-retry";
5
5
  import * as AIError from "../error";
6
- import type { GeneratedImage } from "./types";
6
+ import type { ImageContent } from "../types";
7
+ import type { GeneratedImage, ImageInput } from "./types";
7
8
 
8
9
  export class ImageApiError extends AIError.ProviderHttpError {
9
10
  override readonly name = "ImageApiError";
@@ -185,18 +186,91 @@ export function sniffBase64ImageMimeType(base64: string): string | undefined {
185
186
  return parseImageMetadata(Buffer.from(base64.slice(0, 64), "base64"))?.mimeType;
186
187
  }
187
188
 
188
- export function toDataUrl(image: GeneratedImage): string {
189
+ /**
190
+ * Validate provider-bound image input without changing it. Accepts non-empty standard base64
191
+ * with canonical trailing bits and optional padding, and case-insensitive image MIME syntax
192
+ * (RFC 6838 restricted names). Does not sniff the MIME type, decode image pixels, or enforce
193
+ * provider-specific format support; data-URL transports additionally go through `toDataUrl`.
194
+ *
195
+ * @throws {AIError.ValidationError} If the base64 data or image MIME type is invalid.
196
+ */
197
+ export function validateImageInput(image: ImageInput): void {
198
+ if (!isRecord(image) || typeof image.data !== "string") {
199
+ throw new AIError.ValidationError("invalid base64 image data");
200
+ }
201
+ const reason = base64ImageDataFailureReason(image.data);
202
+ if (reason) throw new AIError.ValidationError(reason);
203
+ if (!isImageMimeType(image.mimeType)) throw new AIError.ValidationError("invalid image MIME type");
204
+ }
205
+
206
+ /**
207
+ * Encode validated image input as a base64 data URL.
208
+ *
209
+ * @throws {AIError.ValidationError} If the input is invalid or its MIME type cannot survive a data URL.
210
+ */
211
+ export function toDataUrl(image: ImageInput): string {
212
+ validateImageInput(image);
213
+ if (!isDataUrlMimeType(image.mimeType)) {
214
+ throw new AIError.ValidationError("image MIME type containing '#' cannot be sent as a data URL");
215
+ }
189
216
  return `data:${image.mimeType};base64,${image.data}`;
190
217
  }
191
218
 
219
+ // RFC 6838 restricted-name.
220
+ const IMAGE_MIME_TYPE = /^image\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}$/i;
221
+ const IMAGE_DETAILS: Record<string, true> = {
222
+ auto: true,
223
+ low: true,
224
+ high: true,
225
+ original: true,
226
+ };
227
+
228
+ function isImageMimeType(value: unknown): value is string {
229
+ return typeof value === "string" && IMAGE_MIME_TYPE.test(value);
230
+ }
231
+
232
+ /**
233
+ * Whether a MIME type survives a data URL intact: a raw `#` starts a URL fragment and
234
+ * data-URL consumers leave a `%23` escape undecoded, so no data-URL transport preserves it.
235
+ */
236
+ export function isDataUrlMimeType(mimeType: string): boolean {
237
+ return !mimeType.includes("#");
238
+ }
239
+
240
+ function isEmbeddedImageShape(image: unknown): image is ImageContent {
241
+ return (
242
+ isRecord(image) &&
243
+ image.type === "image" &&
244
+ typeof image.data === "string" &&
245
+ isImageMimeType(image.mimeType) &&
246
+ isDataUrlMimeType(image.mimeType) &&
247
+ (image.detail === undefined || (typeof image.detail === "string" && Object.hasOwn(IMAGE_DETAILS, image.detail)))
248
+ );
249
+ }
250
+
251
+ /** Whether a value is an embedded image with valid base64 data, MIME type, and detail. */
252
+ export function isEmbeddedImageContent(image: unknown): image is ImageContent {
253
+ return isEmbeddedImageShape(image) && base64ImageDataFailureReason(image.data) === undefined;
254
+ }
255
+
256
+ /** Convert one validated embedded image to a lowercase-MIME inline data URL. */
257
+ export function embeddedImageDataUrl(image: unknown): string {
258
+ if (!isEmbeddedImageShape(image)) {
259
+ throw new AIError.ValidationError("Judgment images must contain non-empty base64 data and an image MIME type");
260
+ }
261
+ return toDataUrl({ data: image.data, mimeType: image.mimeType.toLowerCase() });
262
+ }
263
+
192
264
  export function resolveOpenAIImageSize(aspectRatio?: string, imageSize?: string): string | undefined {
193
265
  if (imageSize) return imageSize;
194
266
  switch (aspectRatio) {
195
267
  case "1:1":
196
268
  return "1024x1024";
269
+ case "2:3":
197
270
  case "3:4":
198
271
  case "9:16":
199
272
  return "1024x1536";
273
+ case "3:2":
200
274
  case "4:3":
201
275
  case "16:9":
202
276
  return "1536x1024";
package/src/index.ts CHANGED
@@ -65,6 +65,7 @@ export * from "./utils/anthropic-auth";
65
65
  export * from "./utils/event-stream";
66
66
  export * from "./utils/glyph-codec";
67
67
  export * from "./utils/openrouter-headers";
68
+ export * from "./utils/narration";
68
69
  export * from "./utils/schema";
69
70
  export * from "./utils/thinking-loop";
70
71
  export * from "./utils/tool-call-loop-guard";
@@ -5,6 +5,7 @@
5
5
  import { type } from "@linxiraos/pi-omptype";
6
6
  import * as AIError from "../error";
7
7
  import { retryTransientCompletion } from "../oneshot-retry";
8
+ import { sendsImageInputOnWire } from "../providers/vision-guard";
8
9
  import { completeSimple } from "../stream";
9
10
  import type { Api, AssistantMessage, Model, SimpleStreamOptions, Tool } from "../types";
10
11
  import type { TextBackend, TextCompletion, TextPrompt } from "./text";
@@ -40,19 +41,34 @@ export type ChatTextBackendOptions = Pick<SimpleStreamOptions, "apiKey" | "sessi
40
41
 
41
42
  /** Chat completions with reasoning disabled, temperature 0, and transient-failure retry. */
42
43
  export function chatTextBackend(model: Model<Api>, options: ChatTextBackendOptions): TextBackend {
44
+ const supportsImages = sendsImageInputOnWire(model);
43
45
  return {
44
46
  api: model.api,
45
47
  provider: model.provider,
46
48
  model: model.id,
49
+ ...(supportsImages ? { supportsImages: true } : {}),
47
50
  parseRetries: 2,
48
51
  async complete(prompt: TextPrompt, judge: JudgeOptions): Promise<TextCompletion> {
52
+ if (prompt.images?.length && !supportsImages) {
53
+ throw new AIError.ValidationError(
54
+ `Chat judgment backend ${model.provider}/${model.id} does not support image inputs`,
55
+ );
56
+ }
49
57
  const response = await retryTransientCompletion(
50
58
  () =>
51
59
  completeSimple(
52
60
  model,
53
61
  {
54
62
  systemPrompt: [prompt.system],
55
- messages: [{ role: "user", content: prompt.user, timestamp: Date.now() }],
63
+ messages: [
64
+ {
65
+ role: "user",
66
+ content: prompt.images?.length
67
+ ? [{ type: "text" as const, text: prompt.user }, ...prompt.images]
68
+ : prompt.user,
69
+ timestamp: Date.now(),
70
+ },
71
+ ],
56
72
  tools: prompt.retry ? [SUBMIT_JUDGMENT] : undefined,
57
73
  },
58
74
  {