@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
@@ -0,0 +1,78 @@
1
+ import { isCatalogEndpointUrl } from "@linxiraos/pi-catalog/compat/catalog-endpoint";
2
+ import { logger } from "@linxiraos/pi-utils";
3
+ import type { Api, FetchImpl, Model } from "../types";
4
+ import { lastDispatchOrigin, transportFetch } from "../utils/transport-fetch";
5
+ import { resolveAnthropicPrewarmTarget } from "./anthropic";
6
+
7
+ /**
8
+ * Minimum gap between warm-ups of one origin. Idle pooled sockets outlive it
9
+ * by minutes (Copilot drops them after ~2.5–5 min, most hosts later), so a
10
+ * tighter cadence would only add requests.
11
+ */
12
+ const PREWARM_INTERVAL_MS = 30_000;
13
+ const PREWARM_TIMEOUT_MS = 10_000;
14
+
15
+ const prewarmedAt = new Map<string, number>();
16
+
17
+ /**
18
+ * Where a warm-up for {@link model} goes, or `undefined` when it must not be
19
+ * probed. First-party Anthropic resolves its effective endpoint (following
20
+ * `ANTHROPIC_BASE_URL`/Foundry reroutes) and TLS options up front. Every
21
+ * other model warms only the origin its last inference request actually
22
+ * reached, so credential endpoints (Copilot plans), env overrides
23
+ * (`MOONSHOT_BASE_URL`, `XAI_BASE_URL`), and regional hosts are never guessed
24
+ * from `baseUrl`; before its first request there is nothing to warm. That
25
+ * origin must also be the provider's own endpoint: a custom provider, or a
26
+ * catalog provider id routed to a gateway, proxy, or local server, is never
27
+ * probed.
28
+ */
29
+ function resolvePrewarmTarget(model: Model<Api>): { origin: string; init: RequestInit } | undefined {
30
+ if (model.provider === "anthropic") {
31
+ const target = resolveAnthropicPrewarmTarget(model);
32
+ return target && { origin: new URL(target.url).origin, init: target.init };
33
+ }
34
+ const origin = lastDispatchOrigin(model);
35
+ if (!origin || !isCatalogEndpointUrl(model, origin)) return undefined;
36
+ return { origin, init: {} };
37
+ }
38
+
39
+ /**
40
+ * Opens, or keeps alive, a pooled connection to {@link model}'s API host so
41
+ * the next turn skips DNS + TCP + TLS. Sends a credential-less `HEAD` (nothing
42
+ * is billed; hosts answer 401/404/405) through the same transport — proxy,
43
+ * extra CA, Cowork profile, TLS options — as real requests, because sockets
44
+ * pool per transport configuration and a warm-up made any other way (e.g.
45
+ * `fetch.preconnect`, which also throws on Windows) is never reused.
46
+ * Throttled per origin; best effort, never throws.
47
+ */
48
+ export async function prewarmModelConnection(model: Model<Api>, options?: { fetch?: FetchImpl }): Promise<void> {
49
+ let origin: string | undefined;
50
+ try {
51
+ // Inside the boundary: resolving Anthropic's TLS options throws on an
52
+ // unusable CA or client-certificate setup, which the real request reports.
53
+ const target = resolvePrewarmTarget(model);
54
+ if (!target) return;
55
+ origin = target.origin;
56
+ const now = Date.now();
57
+ const last = prewarmedAt.get(origin);
58
+ if (last !== undefined && now - last < PREWARM_INTERVAL_MS) return;
59
+ prewarmedAt.set(origin, now);
60
+ const response = await transportFetch(model, options?.fetch)(origin, {
61
+ ...target.init,
62
+ method: "HEAD",
63
+ headers: {},
64
+ signal: AbortSignal.timeout(PREWARM_TIMEOUT_MS),
65
+ });
66
+ await response.body?.cancel();
67
+ } catch (error) {
68
+ logger.debug("Model connection prewarm failed", {
69
+ origin,
70
+ error: error instanceof Error ? error.message : String(error),
71
+ });
72
+ }
73
+ }
74
+
75
+ /** Test hook: forgets every warm-up so the next call is not throttled. */
76
+ export function resetConnectionPrewarmForTesting(): void {
77
+ prewarmedAt.clear();
78
+ }
@@ -198,7 +198,11 @@ async function sendCoworkRequest(
198
198
  };
199
199
  const abort = (): void => {
200
200
  const reason = signal?.reason;
201
- request?.destroy(reason instanceof Error ? reason : new DOMException("The operation was aborted.", "AbortError"));
201
+ const error = reason instanceof Error ? reason : new DOMException("The operation was aborted.", "AbortError");
202
+ request?.destroy(error);
203
+ // Bun before 1.4 emits 'close' but not 'error' for destroy(error), so the
204
+ // 'error' listener below never settles a request still waiting for headers.
205
+ result.reject(error);
202
206
  };
203
207
  if (signal?.aborted) {
204
208
  release();
@@ -86,6 +86,7 @@ export {
86
86
  piReadPath,
87
87
  piReadPathHasRange,
88
88
  piTimeout,
89
+ shellDeadlineSeconds,
89
90
  shellTimeoutSeconds,
90
91
  } from "../cursor-pi-args";
91
92
 
@@ -209,6 +209,25 @@ export function shellTimeoutSeconds(timeoutMs: number | undefined): number | und
209
209
  return Math.max(1, Math.round(timeoutMs / 1000));
210
210
  }
211
211
 
212
+ /** The bash tool's deadline when its `timeout` is omitted. */
213
+ const BASH_DEFAULT_DEADLINE_SECONDS = 300;
214
+
215
+ /**
216
+ * The bash-tool kill deadline, in seconds, for a Cursor `ShellArgs`/`ShellStreamArgs` frame.
217
+ *
218
+ * Cursor's `timeout` carries the model's `block_until_ms`, which Cursor's Shell tool documents as
219
+ * how long to block before moving the command to the background, not as a kill deadline; the kill
220
+ * deadline is `hard_timeout`. Without one, a short wait (`block_until_ms: 5000` on a build) must not
221
+ * kill the command: the deadline stays the bash default (`undefined`), raised to the wait only when
222
+ * the wait is longer.
223
+ */
224
+ export function shellDeadlineSeconds(args: { timeout: number; hardTimeout?: number }): number | undefined {
225
+ const hardSeconds = shellTimeoutSeconds(args.hardTimeout);
226
+ if (hardSeconds !== undefined) return hardSeconds;
227
+ const waitSeconds = shellTimeoutSeconds(args.timeout);
228
+ return waitSeconds !== undefined && waitSeconds > BASH_DEFAULT_DEADLINE_SECONDS ? waitSeconds : undefined;
229
+ }
230
+
212
231
  /**
213
232
  * Drop keys whose value is `undefined` so optional local-tool kwargs stay
214
233
  * absent rather than present-as-undefined.
@@ -261,7 +261,7 @@ import {
261
261
  piReadDisplayPath,
262
262
  piReadPathHasRange,
263
263
  piTimeout,
264
- shellTimeoutSeconds,
264
+ shellDeadlineSeconds,
265
265
  } from "./cursor/exec-modern";
266
266
  import { handleInteractionQuery, protoUnknownFields } from "./cursor/interaction-query";
267
267
 
@@ -348,6 +348,18 @@ function isCursorModelNotFound(error: unknown): boolean {
348
348
  );
349
349
  }
350
350
 
351
+ /**
352
+ * Failures that, before any token, mean the wire conversation is poisoned
353
+ * rather than the account: a bare gRPC `resource_exhausted` (#8345), or the
354
+ * structured PROVIDER_ERROR every resume of a turn cut mid-stream draws (#15830).
355
+ */
356
+ function isCursorConversationRejected(error: unknown, message: string): boolean {
357
+ return (
358
+ (error instanceof AIError.ProviderHttpError && error.code === "PROVIDER_ERROR") ||
359
+ RESOURCE_EXHAUSTED_PATTERN.test(message)
360
+ );
361
+ }
362
+
351
363
  const CURSOR_MAX_STREAM_RETRIES = 5;
352
364
  const CURSOR_RETRY_BASE_DELAY_MS = 500;
353
365
  const NOT_IMPLEMENTED = `Not implemented by this client`;
@@ -1814,13 +1826,14 @@ function streamCursorWithWireMode(
1814
1826
  flushOpenToolCalls(output, stream, openBlockState);
1815
1827
  }
1816
1828
  const result = await AIError.finalize(error, { api: model.api, signal: options?.signal });
1817
- // #8345: a server-side per-conversation rejection surfaces as a bare
1818
- // resource_exhausted with zero tokens — the conversation is poisoned,
1819
- // not the account. Rotate the wire id and rebuild from `context` on
1820
- // the next attempt (the caller's retry loop). Do not migrate cached
1821
- // side-state: pendingToolCalls from a mid-turn checkpoint re-poison
1822
- // the new id. One rotation per failure streak; a new rotation is
1823
- // allowed only after the current rotated id completed a turn.
1829
+ // #8345/#15830: a server-side per-conversation rejection (a bare
1830
+ // resource_exhausted, or PROVIDER_ERROR after a mid-turn cut) with zero
1831
+ // tokens means the conversation is poisoned, not the account. Rotate
1832
+ // the wire id and rebuild from `context` on the next attempt (the
1833
+ // caller's retry loop). Do not migrate cached side-state:
1834
+ // pendingToolCalls from a mid-turn checkpoint re-poison the new id.
1835
+ // One rotation per failure streak; a new rotation is allowed only
1836
+ // after the current rotated id completed a turn.
1824
1837
  const currentRotated =
1825
1838
  baseConversationId === undefined ? undefined : rotatedConversationIds.get(baseConversationId);
1826
1839
  const canRotate = currentRotated === undefined || successfulRotatedConversationIds.has(currentRotated);
@@ -1829,7 +1842,7 @@ function streamCursorWithWireMode(
1829
1842
  baseConversationId !== undefined &&
1830
1843
  usageState !== undefined &&
1831
1844
  !usageState.sawTokenDelta &&
1832
- RESOURCE_EXHAUSTED_PATTERN.test(result.message) &&
1845
+ isCursorConversationRejected(error, result.message) &&
1833
1846
  canRotate
1834
1847
  ) {
1835
1848
  const rotated = crypto.randomUUID();
@@ -2668,7 +2681,7 @@ async function handleExecServerMessage(
2668
2681
  synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", {
2669
2682
  command: args.command,
2670
2683
  cwd: args.workingDirectory || undefined,
2671
- timeout: shellTimeoutSeconds(args.timeout),
2684
+ timeout: shellDeadlineSeconds(args),
2672
2685
  });
2673
2686
  const { execResult } = await resolveExecHandler(
2674
2687
  args,
@@ -2689,7 +2702,7 @@ async function handleExecServerMessage(
2689
2702
  synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", {
2690
2703
  command: args.command,
2691
2704
  cwd: args.workingDirectory || undefined,
2692
- timeout: shellTimeoutSeconds(args.timeout),
2705
+ timeout: shellDeadlineSeconds(args),
2693
2706
  });
2694
2707
  await handleShellStreamArgs(args, execMsg, runTransport, execHandlers, onToolResult);
2695
2708
  return;
@@ -3167,7 +3180,7 @@ async function handleExecServerMessage(
3167
3180
  synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", {
3168
3181
  command: args.command,
3169
3182
  cwd: args.workingDirectory || undefined,
3170
- timeout: shellTimeoutSeconds(args.timeout),
3183
+ timeout: shellDeadlineSeconds(args),
3171
3184
  });
3172
3185
  const { execResult } = await resolveExecHandler(
3173
3186
  normalizedArgs,
@@ -5076,6 +5089,13 @@ function endCurrentThinkingBlock(
5076
5089
  * call handed to an external executor is only a handoff while the marker is
5077
5090
  * absent, and the consumer that runs it needs a runnable block.
5078
5091
  *
5092
+ * A frame whose `toolCallId` already has a block in `output` is the server
5093
+ * re-dispatching that call (a new `execId`, e.g. a `read` re-sent once a
5094
+ * parallel shell created the file). It adds no block: a second block with the
5095
+ * same id makes every rebuilt history replay the call twice, which Cursor
5096
+ * rejects (issue #15749). The re-run's result supersedes the first one in the
5097
+ * host's result buffer.
5098
+ *
5079
5099
  * Exported for tests to exercise ordering with adjacent text/thinking blocks.
5080
5100
  */
5081
5101
  export function synthesizeCursorExecToolCall(
@@ -5087,6 +5107,7 @@ export function synthesizeCursorExecToolCall(
5087
5107
  args: Record<string, unknown>,
5088
5108
  { executed = true }: { executed?: boolean } = {},
5089
5109
  ): void {
5110
+ if (output.content.some(block => block.type === "toolCall" && block.id === toolCallId)) return;
5090
5111
  endCurrentTextBlock(output, stream, state);
5091
5112
  endCurrentThinkingBlock(output, stream, state);
5092
5113
  // Exec-frame translators often write `optional: value || undefined`. A
@@ -5919,12 +5940,20 @@ interface CursorAssistantStep {
5919
5940
  * the text and reasoning that followed each result; a round ends at a call
5920
5941
  * followed by anything other than another call. Consecutive calls stay in one
5921
5942
  * round — they were issued together. Hidden reasoning still marks a boundary.
5943
+ * A call id repeated within the message — a re-dispatched exec frame recorded
5944
+ * before issue #15749 was fixed — is issued once; Cursor rejects a history that
5945
+ * repeats it.
5922
5946
  */
5923
5947
  function buildCursorAssistantSteps(msg: AssistantMessage, targetModelId: string | undefined): CursorAssistantStep[] {
5924
5948
  const steps: CursorAssistantStep[] = [];
5925
5949
  let step: CursorAssistantStep = { content: [], callIds: [] };
5926
5950
  const replayThinking = canReplayCursorThinking(msg, targetModelId);
5951
+ const issuedCallIds = new Set<string>();
5927
5952
  for (const item of msg.content) {
5953
+ if (item.type === "toolCall") {
5954
+ if (issuedCallIds.has(item.id)) continue;
5955
+ issuedCallIds.add(item.id);
5956
+ }
5928
5957
  if (item.type !== "toolCall" && step.callIds.length > 0) {
5929
5958
  steps.push(step);
5930
5959
  step = { content: [], callIds: [] };
@@ -6147,7 +6176,14 @@ function buildRootPromptMessagesJson(
6147
6176
  });
6148
6177
  continue;
6149
6178
  }
6150
- pushToolResult(msg);
6179
+ // A re-dispatched call recorded two results in the same batch; the
6180
+ // re-run's answer is the one the model saw.
6181
+ let result = msg;
6182
+ for (let j = i + 1; j < historyEnd && messages[j].role === "toolResult"; j++) {
6183
+ const later = messages[j];
6184
+ if (later.role === "toolResult" && later.toolCallId === msg.toolCallId) result = later;
6185
+ }
6186
+ pushToolResult(result);
6151
6187
  }
6152
6188
  }
6153
6189
 
@@ -6299,6 +6335,8 @@ function buildConversationTurns(
6299
6335
  while (i < messages.length && messages[i].role !== "user" && messages[i].role !== "developer") {
6300
6336
  const stepMsg = messages[i];
6301
6337
  if (stepMsg.role === "assistant") {
6338
+ // Mirrors `buildCursorAssistantSteps`: a repeated call id is issued once.
6339
+ const issuedCallIds = new Set<string>();
6302
6340
  for (const item of stepMsg.content) {
6303
6341
  let step: ConversationStep;
6304
6342
  if (item.type === "text") {
@@ -6322,6 +6360,8 @@ function buildConversationTurns(
6322
6360
  },
6323
6361
  });
6324
6362
  } else if (item.type === "toolCall") {
6363
+ if (issuedCallIds.has(item.id)) continue;
6364
+ issuedCallIds.add(item.id);
6325
6365
  step = createCursorToolCallStep(item, toolResults.get(item.id));
6326
6366
  } else {
6327
6367
  continue;
@@ -61,7 +61,12 @@ import {
61
61
  ConnectFrameDecoder,
62
62
  frameConnectMessage,
63
63
  } from "./connect-frame";
64
- import { isResponsesFamilyApi, responsesCallComponent, transformMessages } from "./transform-messages";
64
+ import {
65
+ isResponsesFamilyApi,
66
+ normalizeToolCallId,
67
+ responsesCallComponent,
68
+ transformMessages,
69
+ } from "./transform-messages";
65
70
 
66
71
  /** Base host for Codeium/Windsurf's Cascade chat API (Connect protocol over HTTP/1.1). */
67
72
  export const DEVIN_API_URL = DEVIN_DEFAULT_BASE_URL;
@@ -244,9 +249,10 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
244
249
  context.messages,
245
250
  model,
246
251
  (id, _target, source) =>
247
- (isResponsesFamilyApi(source.api) ? responsesCallComponent(id) : id)
248
- .replace(/[^a-zA-Z0-9_-]/g, "_")
249
- .slice(0, DEVIN_TOOL_CALL_ID_MAX_LENGTH),
252
+ normalizeToolCallId(
253
+ isResponsesFamilyApi(source.api) ? responsesCallComponent(id) : id,
254
+ DEVIN_TOOL_CALL_ID_MAX_LENGTH,
255
+ ),
250
256
  DEVIN_TOOL_CALL_ID_MAX_LENGTH,
251
257
  ),
252
258
  };
@@ -118,6 +118,11 @@ const GOOGLE_APIS: Record<string, true> = {
118
118
  * everything else is DROPPED (never demoted to visible text). A text block
119
119
  * keeps its signature only when this model signed it.
120
120
  *
121
+ * Tool-call names are encoded to their wire form here, before
122
+ * `transformMessages` drops calls whose names no provider could declare: the
123
+ * proxy accepts any caller tool name once sanitized, so a declared
124
+ * `my.tool/name` call must survive replay.
125
+ *
121
126
  * Only Google-origin turns are stamped as the target, so `transformMessages`
122
127
  * keeps their surviving signatures verbatim instead of demoting/stripping them
123
128
  * as cross-model. Their ids are plain in either stamp, so pairing is
@@ -137,8 +142,9 @@ function adoptFactoryReplay(message: Message, model: Model<"factory-droid-agent"
137
142
  if (googleOrigin && block.thinking.trim() && signature && signature !== SKIP_THOUGHT_SIGNATURE) {
138
143
  content.push(block);
139
144
  }
140
- } else if (block.type === "toolCall" && !googleOrigin && block.thoughtSignature) {
141
- content.push({ ...block, thoughtSignature: undefined });
145
+ } else if (block.type === "toolCall") {
146
+ const thoughtSignature = googleOrigin ? block.thoughtSignature : undefined;
147
+ content.push({ ...block, name: sanitizeFactoryDroidToolName(block.name), thoughtSignature });
142
148
  } else if (block.type === "text" && !sameModel && block.textSignature) {
143
149
  content.push({ ...block, textSignature: undefined });
144
150
  } else {
@@ -204,7 +210,7 @@ function toGeminiContents(
204
210
  parts.push({ text: block.thinking, thoughtSignature: block.thinkingSignature });
205
211
  } else if (block.type === "toolCall") {
206
212
  parts.push({
207
- functionCall: { name: sanitizeFactoryDroidToolName(block.name), args: block.arguments },
213
+ functionCall: { name: block.name, args: block.arguments },
208
214
  ...(block.thoughtSignature ? { thoughtSignature: block.thoughtSignature } : {}),
209
215
  });
210
216
  }
@@ -36,6 +36,7 @@ import { armPreResponseTimeout, getStreamFirstEventTimeoutMs, iterateWithIdleTim
36
36
  // the stream provider trusts the access token threaded through `options.apiKey`.
37
37
  import { normalizeSchemaForCCA } from "../utils/schema";
38
38
  import { StreamMarkupHealing, type StreamMarkupHealingEvent } from "../utils/stream-markup-healing";
39
+ import { prepareAnthropicImageContext } from "./anthropic-images";
39
40
  import forcedToolDirective from "./google-antigravity-forced-tool.md" with { type: "text" };
40
41
  import type { Content, FunctionCallingConfigMode, ThinkingConfig } from "./google-shared";
41
42
  import {
@@ -573,7 +574,16 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
573
574
  endpoints = baseUrl ? [baseUrl] : [DEFAULT_ENDPOINT];
574
575
  }
575
576
 
576
- let requestBody = buildRequest(model, context, projectId, options, isAntigravity);
577
+ // A host that declares an image contract (Claude behind Antigravity)
578
+ // rejects oversized images, and a rejected image stays in context, so
579
+ // clamp before building; readers without one take images as sent. The
580
+ // cost is one pass over the context, as on `anthropic-messages`: images
581
+ // already within bounds hit the per-image cache and are not decoded.
582
+ const requestContext =
583
+ model.compat.maxImageDimension !== undefined || model.compat.maxImagePayloadBytes !== undefined
584
+ ? await prepareAnthropicImageContext(context, model)
585
+ : context;
586
+ let requestBody = buildRequest(model, requestContext, projectId, options, isAntigravity);
577
587
  const replacementPayload = await options?.onPayload?.(requestBody, model);
578
588
  if (replacementPayload !== undefined) {
579
589
  requestBody = replacementPayload as typeof requestBody;
@@ -25,7 +25,7 @@ import type {
25
25
  Usage,
26
26
  } from "../types";
27
27
  import { shouldSendServiceTier } from "../types";
28
- import { normalizeSystemPrompts } from "../utils";
28
+ import { normalizeSystemPrompts, normalizeToolCallId } from "../utils";
29
29
  import { AssistantMessageEventStream } from "../utils/event-stream";
30
30
  import type { RawHttpRequestDump } from "../utils/http-inspector";
31
31
  import { normalizeSchemaForCCA, normalizeSchemaForGoogle, toolWireSchema } from "../utils/schema";
@@ -162,11 +162,7 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
162
162
  const contents: Content[] = [];
163
163
  const emittedToolCallNames = new Map<string, string>();
164
164
 
165
- const normalizeToolCallId = (id: string): string => {
166
- return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64);
167
- };
168
-
169
- const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
165
+ const transformedMessages = transformMessages(context.messages, model, id => normalizeToolCallId(id));
170
166
 
171
167
  // Gemini < 3 image tool results go in a separate user turn, but parallel tool results must
172
168
  // stay a single contiguous functionResponse turn ("number of function response parts is not
@@ -1,5 +1,6 @@
1
1
  import { type } from "@linxiraos/pi-omptype";
2
2
  import * as AIError from "../error";
3
+ import { isDataUrlMimeType } from "../images/shared";
3
4
  import type { ImageGenerationRequest, ImageGenerationResult } from "../images/types";
4
5
 
5
6
  const imageJsonRequestSchema = type({
@@ -56,7 +57,11 @@ function decodeInputReference(value: unknown): { data: string; mimeType: string
56
57
  }
57
58
  if (typeof raw !== "string" || raw.length === 0) validation("input image must be base64 or a data URL");
58
59
  const match = raw.match(/^data:([^;]+);base64,(.+)$/s);
59
- if (match) return { data: match[2] ?? "", mimeType: match[1] ?? "image/png" };
60
+ if (match) {
61
+ const mimeType = match[1] ?? "image/png";
62
+ if (!isDataUrlMimeType(mimeType)) validation("data URL MIME type must not contain a '#' fragment marker");
63
+ return { data: match[2] ?? "", mimeType };
64
+ }
60
65
  if (/^[A-Za-z0-9+/]+={0,2}$/.test(raw)) return { data: raw, mimeType: "image/png" };
61
66
  validation("input image URLs are not supported; send a data URL or base64 bytes");
62
67
  }
@@ -68,6 +68,7 @@ type OllamaMessage = {
68
68
  };
69
69
 
70
70
  type OllamaChatChunk = {
71
+ error?: string;
71
72
  message?: {
72
73
  role?: string;
73
74
  content?: string;
@@ -252,7 +253,10 @@ function convertMessages(model: Model<"ollama-chat">, context: Context): OllamaM
252
253
  timestamp: Date.now(),
253
254
  }));
254
255
  const messages: Message[] = [...systemMessages, ...context.messages];
255
- const isCloud = model.provider === "ollama-cloud";
256
+ // Model rows cached or bundled before Ollama had a compat record carry none;
257
+ // stripping is the replay-safe answer for them until they are rebuilt.
258
+ const compat: Partial<typeof model.compat> | undefined = model.compat;
259
+ const stripThinkingHistory = compat?.stripThinkingHistory ?? true;
256
260
  const supportsImages = model.input.includes("image");
257
261
  const converted = transformMessages(messages, model).map((msg, index) => {
258
262
  // Real `systemPrompt` entries (always emitted first) stay on Ollama's
@@ -266,10 +270,9 @@ function convertMessages(model: Model<"ollama-chat">, context: Context): OllamaM
266
270
  const developerRole =
267
271
  msg.role === "developer" && (index < systemPrompts.length || msg.attribution !== "user") ? "system" : "user";
268
272
  const converted = convertMessage(msg, supportsImages, developerRole);
269
- // Ollama cloud rejects requests when assistant history messages contain the `thinking`
270
- // field — it's valid in model responses but not accepted as a history input. Strip it
271
- // to prevent HTTP 400 errors. Local Ollama instances are unaffected.
272
- if (isCloud && converted.role === "assistant" && converted.thinking) {
273
+ // Some Ollama Cloud models historically rejected thinking in history (#1000).
274
+ // Preserve reasoning for the verified model without weakening that guard everywhere.
275
+ if (stripThinkingHistory && converted.role === "assistant" && converted.thinking) {
273
276
  const { thinking: _t, ...rest } = converted;
274
277
  return rest;
275
278
  }
@@ -619,6 +622,13 @@ const streamOllamaOnce = (
619
622
  }
620
623
  stream.push({ type: "start", partial: output });
621
624
  for await (const chunk of readJsonl<OllamaChatChunk>(response.body)) {
625
+ // Once HTTP 200 has started, Ollama reports failures as NDJSON error records.
626
+ if (chunk.error) {
627
+ throw new AIError.ProviderResponseError(`Ollama stream error: ${chunk.error}`, {
628
+ provider: model.provider,
629
+ kind: "runtime",
630
+ });
631
+ }
622
632
  if (chunk.message?.thinking) {
623
633
  suppressHealedThinking = true;
624
634
  endActiveTextBlock();
@@ -133,6 +133,7 @@ export function streamOpenAIAnthropicShim(
133
133
  presencePenalty: options?.presencePenalty,
134
134
  repetitionPenalty: options?.repetitionPenalty,
135
135
  maxTokens: options?.maxTokens ?? model.maxTokens ?? undefined,
136
+ maxHttpAttempts: options?.maxHttpAttempts,
136
137
  signal: options?.signal,
137
138
  headers: mergedHeaders,
138
139
  cacheRetention: config.forwardCacheOptions ? options?.cacheRetention : undefined,
@@ -531,6 +531,13 @@ function makeId(): string {
531
531
  // encodeStream (SSE)
532
532
  // ---------------------------------------------------------------------------
533
533
 
534
+ // Keepalive cadence for the SSE encoder. Some upstreams go silent for minutes
535
+ // mid-stream (e.g. xAI emits a large tool call's arguments only when complete);
536
+ // without bytes on the wire the listener's idle timeout and client watchdogs
537
+ // drop a healthy stream. SSE comment lines are ignored by spec-compliant clients.
538
+ const STREAM_KEEPALIVE_INTERVAL_MS = 15_000;
539
+ const SSE_KEEPALIVE = ": keepalive\n\n";
540
+
534
541
  export function encodeStream(
535
542
  events: AssistantMessageEventStream,
536
543
  requestedModelId: string,
@@ -546,6 +553,13 @@ export function encodeStream(
546
553
  cancelled = true;
547
554
  };
548
555
  control?.signal?.addEventListener("abort", markCancelled, { once: true });
556
+ let keepaliveTimer: NodeJS.Timeout | undefined;
557
+ const stopKeepalive = () => {
558
+ if (keepaliveTimer !== undefined) {
559
+ clearInterval(keepaliveTimer);
560
+ keepaliveTimer = undefined;
561
+ }
562
+ };
549
563
 
550
564
  const baseChunk = (delta: Record<string, unknown>, finishReason: string | null) => ({
551
565
  id,
@@ -584,6 +598,19 @@ export function encodeStream(
584
598
  let hasToolCalls = false;
585
599
  let finishReason: string = "stop";
586
600
 
601
+ keepaliveTimer = setInterval(() => {
602
+ try {
603
+ if (cancelled) {
604
+ stopKeepalive();
605
+ return;
606
+ }
607
+ controller.enqueue(encoder.encode(SSE_KEEPALIVE));
608
+ } catch {
609
+ // Controller already closed/errored (client gone); stop the timer.
610
+ stopKeepalive();
611
+ }
612
+ }, STREAM_KEEPALIVE_INTERVAL_MS);
613
+
587
614
  try {
588
615
  if (cancelled) {
589
616
  controller.close();
@@ -733,11 +760,13 @@ export function encodeStream(
733
760
  controller.close();
734
761
  }
735
762
  } finally {
763
+ stopKeepalive();
736
764
  control?.signal?.removeEventListener("abort", markCancelled);
737
765
  }
738
766
  },
739
767
  cancel(reason) {
740
768
  cancelled = true;
769
+ stopKeepalive();
741
770
  control?.signal?.removeEventListener("abort", markCancelled);
742
771
  control?.onCancel?.(reason);
743
772
  },
@@ -65,6 +65,7 @@ export function dropRejectedCodexAccessPrograms(
65
65
  function isAccessProgramRejection(error: unknown): boolean {
66
66
  if (!(error instanceof Error)) return false;
67
67
  const code = "code" in error && typeof error.code === "string" ? error.code : undefined;
68
- if (code !== undefined && Object.hasOwn(REJECTION_CODES, code)) return true;
68
+ // HTTP parsing promotes generic error.type to code; only specific codes rule out message fallback.
69
+ if (code !== undefined && code !== "invalid_request_error") return Object.hasOwn(REJECTION_CODES, code);
69
70
  return REJECTION_PATTERN.test(error.message);
70
71
  }