mixdog 0.9.94 → 0.9.96

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 (259) hide show
  1. package/LICENSES/Apache-2.0.txt +201 -0
  2. package/LICENSES/MIT.txt +56 -0
  3. package/LICENSES/codex-NOTICE.txt +6 -0
  4. package/NOTICE.md +109 -0
  5. package/package.json +26 -13
  6. package/scripts/.tmp-coverage-report.mjs +96 -0
  7. package/scripts/build-tui.mjs +17 -1
  8. package/scripts/fixtures/patch-replay-corpus.json +98 -0
  9. package/scripts/lib/isolated-root-cleanup.mjs +19 -0
  10. package/scripts/run-suite.mjs +100 -0
  11. package/src/defaults/mixdog-config.template.json +5 -8
  12. package/src/lib/rules-builder.cjs +4 -3
  13. package/src/output-styles/detailed.md +13 -15
  14. package/src/output-styles/extreme-minimal.md +7 -11
  15. package/src/output-styles/minimal.md +4 -7
  16. package/src/output-styles/simple.md +11 -13
  17. package/src/rules/agent/30-explorer.md +7 -4
  18. package/src/rules/lead/01-general.md +1 -2
  19. package/src/rules/lead/lead-brief.md +4 -5
  20. package/src/rules/lead/lead-tool.md +3 -2
  21. package/src/rules/shared/01-tool.md +20 -35
  22. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  23. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  24. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +2 -2
  25. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  26. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +13 -2
  27. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -0
  28. package/src/runtime/agent/orchestrator/agent-trace.mjs +29 -0
  29. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  30. package/src/runtime/agent/orchestrator/mcp/client.mjs +29 -14
  31. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  32. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  33. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +97 -7
  34. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +11 -4
  35. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +2 -3
  36. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  37. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  38. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +4 -6
  39. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  40. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  41. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  42. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +1 -1
  43. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +8 -8
  44. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  45. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  46. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +29 -16
  47. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +2 -2
  48. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  49. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +14 -16
  50. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  51. package/src/runtime/agent/orchestrator/providers/openai-ws-headers.mjs +5 -4
  52. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +13 -14
  53. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -3
  54. package/src/runtime/agent/orchestrator/providers/registry.mjs +51 -4
  55. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +82 -22
  56. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  57. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  58. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +40 -36
  59. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +7 -0
  60. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +10 -3
  61. package/src/runtime/agent/orchestrator/session/context-utils.mjs +10 -11
  62. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +5 -33
  63. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +3 -3
  64. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +0 -14
  65. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  66. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +9 -8
  67. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  68. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  69. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -27
  70. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +6 -10
  71. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +12 -4
  72. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +1 -1
  73. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +1 -1
  74. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +168 -83
  75. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  76. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +3 -0
  77. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  78. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +8 -1
  79. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +34 -18
  80. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  81. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  82. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +89 -11
  83. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +12 -0
  84. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +6 -39
  85. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -6
  86. package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -4
  87. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  88. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +56 -0
  89. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +124 -19
  90. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  91. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  92. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  93. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +7 -16
  94. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +10 -26
  95. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +14 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +4 -4
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +2 -2
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +1 -1
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +3 -3
  100. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +2 -1
  101. package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +3 -3
  102. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +1 -23
  103. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +24 -10
  104. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  105. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +20 -43
  106. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +76 -3
  107. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +13 -1
  108. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +10 -5
  109. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +38 -13
  110. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  111. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +98 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -2
  113. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +8 -6
  114. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +138 -25
  115. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +47 -2
  116. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +6 -15
  117. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +3 -1
  118. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -2
  119. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +9 -2
  120. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  121. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +2 -2
  122. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +40 -2
  123. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +57 -2
  124. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +177 -16
  125. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +21 -1
  126. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +217 -25
  127. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +10 -10
  128. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +14 -15
  129. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +60 -8
  130. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  131. package/src/runtime/channels/backends/discord.mjs +5 -14
  132. package/src/runtime/channels/backends/telegram.mjs +0 -5
  133. package/src/runtime/channels/lib/config.mjs +2 -2
  134. package/src/runtime/channels/lib/inbound-handler.mjs +1 -2
  135. package/src/runtime/channels/lib/output-forwarder.mjs +24 -5
  136. package/src/runtime/channels/lib/scheduler.mjs +3 -3
  137. package/src/runtime/channels/lib/worker-main.mjs +1 -1
  138. package/src/runtime/media/renditions.mjs +21 -2
  139. package/src/runtime/memory/index.mjs +5 -38
  140. package/src/runtime/memory/lib/agent-ipc.mjs +145 -81
  141. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  142. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  143. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +12 -33
  144. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  145. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  146. package/src/runtime/memory/lib/http-router.mjs +8 -0
  147. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  148. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  149. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  150. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  151. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  152. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  153. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  154. package/src/runtime/memory/tool-defs.mjs +7 -11
  155. package/src/runtime/search/tool-defs.mjs +2 -2
  156. package/src/runtime/shared/agent-route-config.mjs +119 -0
  157. package/src/runtime/shared/atomic-file.mjs +133 -4
  158. package/src/runtime/shared/background-tasks.mjs +6 -3
  159. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  160. package/src/runtime/shared/child-guardian.mjs +204 -108
  161. package/src/runtime/shared/child-spawn-gate.mjs +60 -31
  162. package/src/runtime/shared/config.mjs +130 -44
  163. package/src/runtime/shared/resource-admission.mjs +146 -58
  164. package/src/runtime/shared/stream-progress.mjs +6 -0
  165. package/src/runtime/shared/tool-status.mjs +10 -1
  166. package/src/runtime/shared/tool-surface.mjs +2 -7
  167. package/src/runtime/shared/turn-snapshot.mjs +411 -21
  168. package/src/runtime/shared/turn-worktree-snapshot.mjs +552 -0
  169. package/src/session-runtime/config-helpers.mjs +39 -37
  170. package/src/session-runtime/context-status.mjs +9 -3
  171. package/src/session-runtime/lifecycle-api.mjs +35 -5
  172. package/src/session-runtime/mcp-glue.mjs +11 -6
  173. package/src/session-runtime/model-route-api.mjs +7 -5
  174. package/src/session-runtime/provider-auth-api.mjs +0 -7
  175. package/src/session-runtime/provider-models.mjs +94 -43
  176. package/src/session-runtime/runtime-core.mjs +57 -19
  177. package/src/session-runtime/runtime-tunables.mjs +4 -0
  178. package/src/session-runtime/self-update.mjs +33 -4
  179. package/src/session-runtime/session-lifecycle.mjs +11 -2
  180. package/src/session-runtime/session-text.mjs +2 -1
  181. package/src/session-runtime/session-turn-api.mjs +139 -23
  182. package/src/session-runtime/settings-api.mjs +5 -17
  183. package/src/session-runtime/tool-defs.mjs +3 -3
  184. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  185. package/src/session-runtime/workflow.mjs +25 -28
  186. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  187. package/src/standalone/agent-tool/helpers.mjs +2 -2
  188. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  189. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  190. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  191. package/src/standalone/agent-tool.mjs +4 -4
  192. package/src/standalone/backend-daemon.mjs +631 -0
  193. package/src/standalone/channel-admin.mjs +12 -12
  194. package/src/standalone/channel-daemon-client.mjs +17 -1
  195. package/src/standalone/channel-daemon-transport.mjs +231 -9
  196. package/src/standalone/channel-worker.mjs +3 -2
  197. package/src/standalone/engine-daemon-client.mjs +1069 -0
  198. package/src/standalone/engine-daemon-protocol.mjs +32 -0
  199. package/src/standalone/engine-daemon-service.mjs +1108 -0
  200. package/src/standalone/engine-daemon-transport.mjs +794 -0
  201. package/src/standalone/explore-tool.mjs +187 -70
  202. package/src/standalone/fair-call-scheduler.mjs +264 -0
  203. package/src/standalone/session-protocol.mjs +188 -0
  204. package/src/tui/App.jsx +92 -64
  205. package/src/tui/app/app-format.mjs +4 -2
  206. package/src/tui/app/app-view.jsx +10 -2
  207. package/src/tui/app/channel-pickers.mjs +7 -6
  208. package/src/tui/app/core-memory-picker.mjs +20 -20
  209. package/src/tui/app/doctor.mjs +5 -11
  210. package/src/tui/app/extension-pickers.mjs +20 -18
  211. package/src/tui/app/maintenance-pickers.mjs +27 -27
  212. package/src/tui/app/message-selector.mjs +103 -0
  213. package/src/tui/app/onboarding-steps.mjs +24 -20
  214. package/src/tui/app/project-picker.mjs +80 -56
  215. package/src/tui/app/prompt-submit.mjs +59 -43
  216. package/src/tui/app/resume-picker.mjs +2 -2
  217. package/src/tui/app/route-pickers.mjs +16 -9
  218. package/src/tui/app/settings-picker.mjs +60 -65
  219. package/src/tui/app/slash-dispatch.mjs +32 -28
  220. package/src/tui/app/transcript-window.mjs +57 -0
  221. package/src/tui/app/usage-context-panels.mjs +22 -7
  222. package/src/tui/app/use-mouse-input.mjs +25 -3
  223. package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
  224. package/src/tui/app/use-prompt-handlers.mjs +86 -21
  225. package/src/tui/app/use-prompt-hint.mjs +3 -2
  226. package/src/tui/app/use-prompt-queue-history.mjs +39 -17
  227. package/src/tui/app/use-transcript-scroll.mjs +39 -10
  228. package/src/tui/app/use-transcript-window.mjs +62 -14
  229. package/src/tui/app/use-welcome-prompt-hint.mjs +2 -2
  230. package/src/tui/components/PromptInput.jsx +81 -38
  231. package/src/tui/components/Spinner.jsx +89 -95
  232. package/src/tui/components/TextEntryPanel.jsx +14 -0
  233. package/src/tui/components/ToolExecution.jsx +2 -2
  234. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  235. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  236. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  237. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  238. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  239. package/src/tui/dist/index.mjs +1694 -10633
  240. package/src/tui/engine/agent-job-feed.mjs +2 -2
  241. package/src/tui/engine/live-share.mjs +110 -3
  242. package/src/tui/engine/session-api-ext.mjs +24 -5
  243. package/src/tui/engine/session-api.mjs +165 -55
  244. package/src/tui/engine/session-flow.mjs +50 -5
  245. package/src/tui/engine/tool-card-results.mjs +10 -2
  246. package/src/tui/engine/tool-result-text.mjs +10 -0
  247. package/src/tui/engine/turn.mjs +11 -64
  248. package/src/tui/engine-local-session.mjs +1124 -0
  249. package/src/tui/engine.mjs +16 -1065
  250. package/src/tui/index.jsx +41 -4
  251. package/src/tui/markdown/stream-fence.mjs +1 -1
  252. package/src/tui/spinner-meta.mjs +80 -0
  253. package/src/tui/spinner-verbs.mjs +83 -0
  254. package/src/ui/statusline-segments.mjs +43 -10
  255. package/src/ui/statusline.mjs +10 -1
  256. package/scripts/tmp-cdp-errors.mjs +0 -41
  257. package/scripts/tmp-cdp-inspect.mjs +0 -41
  258. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +0 -176
  259. package/src/standalone/channel-daemon.mjs +0 -226
@@ -23,6 +23,7 @@ import {
23
23
  collectGeminiGroundingSources,
24
24
  parseGeminiTextPartMetadata,
25
25
  } from './gemini-schema.mjs';
26
+ import { parseProviderJsonBatch } from './stream-json-pool.mjs';
26
27
 
27
28
  export const GEMINI_FIRST_BYTE_TIMEOUT_MS = resolveTimeoutMs(
28
29
  'MIXDOG_GEMINI_FIRST_BYTE_TIMEOUT_MS',
@@ -131,6 +132,19 @@ function geminiChunkHasFunctionCall(chunk) {
131
132
  return parts.some((p) => p && p.functionCall);
132
133
  }
133
134
 
135
+ export function geminiChunkProgressKind(chunk) {
136
+ const parts = chunk?.candidates?.[0]?.content?.parts;
137
+ if (!Array.isArray(parts)) return 'transport';
138
+ if (parts.some((part) => part?.functionCall)) return 'tool';
139
+ if (parts.some((part) => part?.thought === true && typeof part.text === 'string' && part.text)) {
140
+ return 'reasoning';
141
+ }
142
+ if (parts.some((part) => part?.thought !== true && typeof part?.text === 'string' && part.text)) {
143
+ return 'text';
144
+ }
145
+ return 'transport';
146
+ }
147
+
134
148
  /**
135
149
  * Aggregate streamed GenerateContentResponse chunks into one response object
136
150
  * (same shape as a non-streaming generateContent JSON body).
@@ -256,7 +270,7 @@ export function createGeminiTextLeakGuard({ knownToolNames, onTextDelta, onToolC
256
270
  };
257
271
  leakedCalls.push(call);
258
272
  try { onToolCall?.(call); } catch {}
259
- try { onStreamDelta?.(); } catch {}
273
+ try { onStreamDelta?.('tool'); } catch {}
260
274
  };
261
275
 
262
276
  const pumpLeakBuffer = (final) => {
@@ -407,6 +421,7 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
407
421
  if (done) break;
408
422
  resetIdleTimer();
409
423
  buffer += decoder.decode(value, { stream: true });
424
+ const payloads = [];
410
425
  let lineEnd;
411
426
  while ((lineEnd = buffer.indexOf('\n')) >= 0) {
412
427
  let line = buffer.slice(0, lineEnd);
@@ -415,18 +430,24 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
415
430
  if (!line.startsWith('data: ')) continue;
416
431
  const data = line.slice(6).trim();
417
432
  if (!data || data === '[DONE]') continue;
418
- let parsed;
419
- try {
420
- parsed = JSON.parse(data);
421
- } catch (cause) {
422
- throw geminiStreamCorruptionError(`${label} corrupt SSE JSON`, cause);
423
- }
433
+ payloads.push(data);
434
+ }
435
+ let parsedChunks;
436
+ try {
437
+ // Preserve the established delivery boundary: once read()
438
+ // returned the bytes, parse and relay them before the next
439
+ // iteration observes cancellation.
440
+ parsedChunks = await parseProviderJsonBatch(payloads);
441
+ } catch (cause) {
442
+ throw geminiStreamCorruptionError(`${label} corrupt SSE JSON`, cause);
443
+ }
444
+ for (const parsed of parsedChunks) {
424
445
  if (!sawStreamChunk) {
425
446
  sawStreamChunk = true;
426
447
  clearFirstByteTimer();
427
448
  }
428
449
  allChunks.push(parsed);
429
- try { onStreamDelta?.(); } catch {}
450
+ try { onStreamDelta?.(geminiChunkProgressKind(parsed)); } catch {}
430
451
  if (!sawFunctionCall && geminiChunkHasFunctionCall(parsed)) sawFunctionCall = true;
431
452
  if (onTextDelta || textLeakGuard) {
432
453
  const t = geminiChunkText(parsed);
@@ -440,13 +461,13 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
440
461
  const data = line.slice(6).trim();
441
462
  if (data && data !== '[DONE]') {
442
463
  try {
443
- const parsed = JSON.parse(data);
464
+ const [parsed] = await parseProviderJsonBatch([data]);
444
465
  if (!sawStreamChunk) {
445
466
  sawStreamChunk = true;
446
467
  clearFirstByteTimer();
447
468
  }
448
469
  allChunks.push(parsed);
449
- try { onStreamDelta?.(); } catch {}
470
+ try { onStreamDelta?.(geminiChunkProgressKind(parsed)); } catch {}
450
471
  if (!sawFunctionCall && geminiChunkHasFunctionCall(parsed)) sawFunctionCall = true;
451
472
  if (onTextDelta || textLeakGuard) {
452
473
  const t = geminiChunkText(parsed);
@@ -677,7 +698,7 @@ export async function consumeGeminiSdkStream(streamResult, {
677
698
  }
678
699
  resetIdleTimer();
679
700
  if (step.value) collectedChunks.push(step.value);
680
- try { onStreamDelta?.(); } catch {}
701
+ try { onStreamDelta?.(geminiChunkProgressKind(step.value)); } catch {}
681
702
  if (!sawFunctionCall && geminiChunkHasFunctionCall(step.value)) sawFunctionCall = true;
682
703
  if (onTextDelta || textLeakGuard) {
683
704
  const t = geminiChunkText(step.value);
@@ -1,6 +1,6 @@
1
1
  import { GoogleGenerativeAI } from '@google/generative-ai';
2
2
  import { getAgentApiKey } from '../../../shared/provider-api-key.mjs';
3
- import { withRetry } from './retry-classifier.mjs';
3
+ import { canFallbackNonStreaming, withRetry } from './retry-classifier.mjs';
4
4
  import { traceAgentUsage, appendAgentTrace } from '../agent-trace.mjs';
5
5
  import {
6
6
  PROVIDER_FIRST_BYTE_TIMEOUT_MS,
@@ -158,6 +158,53 @@ export class GeminiProvider {
158
158
  return this.config?.apiKey || process.env.GEMINI_API_KEY || '';
159
159
  }
160
160
 
161
+ /**
162
+ * cc parity (claude.ts:2504): re-issue a dead stream ONCE as a
163
+ * non-streaming generateContent call instead of failing the turn.
164
+ * Narrower than cc by design — canFallbackNonStreaming() clears only a
165
+ * stream that exposed nothing, so rendered text is never duplicated and a
166
+ * dispatched tool can never run twice. Returns the aggregated response, or
167
+ * null when the failure is ineligible or the fallback itself fails.
168
+ */
169
+ async _recoverGeminiNonStreaming({ streamErr, signal, opts, model, generate }) {
170
+ if (!canFallbackNonStreaming(streamErr, { signal })) return null;
171
+ let aggregated;
172
+ try {
173
+ try { opts?.onStageChange?.('requesting'); } catch { /* heartbeat best-effort */ }
174
+ aggregated = await generate(signal || undefined);
175
+ } catch {
176
+ return null;
177
+ }
178
+ if (!aggregated || !Array.isArray(aggregated.candidates) || aggregated.candidates.length === 0) {
179
+ return null;
180
+ }
181
+ try {
182
+ process.stderr.write(
183
+ `[gemini] stream failed (${streamErr?.code || streamErr?.message || 'unknown'}); `
184
+ + `recovered via non-streaming generateContent\n`,
185
+ );
186
+ } catch { /* best-effort */ }
187
+ try {
188
+ appendAgentTrace({
189
+ sessionId: opts?.sessionId || opts?.session?.id || null,
190
+ iteration: Number.isFinite(Number(opts?.iteration)) ? Number(opts.iteration) : null,
191
+ kind: 'transport_fallback',
192
+ provider: 'gemini',
193
+ model,
194
+ transport: 'non-streaming',
195
+ payload: {
196
+ from: 'stream',
197
+ to: 'non-streaming',
198
+ reason: streamErr?.retryClassifier || streamErr?.code || streamErr?.message || 'stream_failed',
199
+ error_code: streamErr?.code || null,
200
+ error_http_status: Number(streamErr?.httpStatus || streamErr?.status || 0) || null,
201
+ error_classifier: streamErr?.retryClassifier || streamErr?.midstreamClassifier || null,
202
+ },
203
+ });
204
+ } catch { /* best-effort */ }
205
+ return aggregated;
206
+ }
207
+
161
208
  // Explicit cachedContents API. The implicit cache layer on Gemini 3.x
162
209
  // does not surface cachedContentTokenCount in usageMetadata, so the only
163
210
  // way to obtain measurable + billable cache savings is to register the
@@ -655,7 +702,30 @@ export class GeminiProvider {
655
702
  }
656
703
  return await this._doSend(messages, model, tools, opts, { skipExplicitCache: true });
657
704
  }
658
- throw err;
705
+ const recovered = await this._recoverGeminiNonStreaming({
706
+ streamErr: err,
707
+ signal: totalSignal,
708
+ opts,
709
+ model: useModel,
710
+ generate: async (fallbackSignal) => {
711
+ const nonStreamUrl = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(useModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;
712
+ const res = await this._fetch(nonStreamUrl, {
713
+ method: 'POST',
714
+ headers: { 'Content-Type': 'application/json' },
715
+ body: JSON.stringify(body),
716
+ signal: fallbackSignal,
717
+ dispatcher: getLlmDispatcher(),
718
+ });
719
+ if (!res.ok) {
720
+ const text = await res.text().catch(() => '');
721
+ throw geminiRestError(res, text, 'Gemini REST generateContent');
722
+ }
723
+ return await res.json();
724
+ },
725
+ });
726
+ if (!recovered) throw err;
727
+ textLeakGuard = null;
728
+ response = recovered;
659
729
  } finally {
660
730
  restPassthrough.cleanup();
661
731
  }
@@ -749,6 +819,23 @@ export class GeminiProvider {
749
819
  },
750
820
  },
751
821
  );
822
+ } catch (err) {
823
+ const recovered = await this._recoverGeminiNonStreaming({
824
+ streamErr: err,
825
+ signal: totalSignal,
826
+ opts,
827
+ model: useModel,
828
+ generate: async (fallbackSignal) => {
829
+ const result = await genModel.generateContent(
830
+ { contents },
831
+ { signal: fallbackSignal },
832
+ );
833
+ return result?.response ?? null;
834
+ },
835
+ });
836
+ if (!recovered) throw err;
837
+ textLeakGuard = null;
838
+ response = recovered;
752
839
  } finally {
753
840
  sdkPassthrough.cleanup();
754
841
  }
@@ -23,7 +23,7 @@ import { boundProviderAuthPath } from '../../../shared/provider-auth-binding.mjs
23
23
  import { enrichModels, getModelMetadataSync } from './model-catalog.mjs';
24
24
  import { sanitizeModelList } from './model-list-sanitize.mjs';
25
25
  import { makeModelCache } from './model-cache.mjs';
26
- import { OpenAICompatProvider } from './openai-compat.mjs';
26
+ import { OpenAICompatProvider, preloadOpenAICompatRuntime } from './openai-compat.mjs';
27
27
  import { createTimeoutSignal } from '../stall-policy.mjs';
28
28
  import { getLlmDispatcher, preconnect } from '../../../shared/llm/http-agent.mjs';
29
29
  import { normalizeGrokToolSchemas } from './lib/grok-tool-schema.mjs';
@@ -205,6 +205,17 @@ export class GrokOAuthProvider {
205
205
  constructor(config) {
206
206
  this.config = config || {};
207
207
  this.tokens = loadTokens();
208
+ // The credential/header-bound inner provider is created lazily, but its
209
+ // heavy SDK + undici graph and proxy socket are not request-specific.
210
+ // Pay both during the daemon's process-wide provider warmup so first
211
+ // Grok send only builds the cheap inner client.
212
+ try { preloadOpenAICompatRuntime(); } catch { /* authoritative send surfaces load failures */ }
213
+ if (this.config?.preconnect !== false) {
214
+ const warm = typeof this.config?.preconnectFn === 'function'
215
+ ? this.config.preconnectFn
216
+ : preconnect;
217
+ try { warm(PROXY_BASE_URL); } catch { /* best-effort */ }
218
+ }
208
219
  }
209
220
 
210
221
  async ensureAuth({ forceRefresh = false } = {}) {
@@ -50,7 +50,7 @@
50
50
  * || toolCallsComplete > 0 || toolCallsDispatched > 0
51
51
  * sideEffectDispatched = toolCallsDispatched > 0
52
52
  * replayUnsafe = the ONE architecture-specific deny MixDog adds on top
53
- * of the Codex retry rules, because it streams UI text
53
+ * of the standard retry rules, because it streams UI text
54
54
  * and dispatches tools eagerly:
55
55
  * visibleOutput || sideEffectDispatched
56
56
  * || dispatchAmbiguous || unsafe marker
@@ -35,8 +35,8 @@ function _codexInstallationId(sendOpts) {
35
35
  || `mixdog-${_hashText(`${process.env.USERPROFILE || process.env.HOME || ''}:${process.cwd()}`, 32)}`;
36
36
  }
37
37
 
38
- // The identity block codex rebuilds per request (responses_metadata.rs
39
- // client_metadata()): never cached on the pooled socket, or a later turn would
38
+ // The identity block is rebuilt per request: never cached on the pooled
39
+ // socket, or a later turn would
40
40
  // replay the first turn's identity.
41
41
  function _codexMetadataBase(entry, { poolKey, cacheKey, sendOpts, handshake = false } = {}) {
42
42
  const sessionId = _cleanMetaString(sendOpts?.codexSessionId || sendOpts?.session?.codexSessionId || poolKey || cacheKey)
@@ -49,8 +49,8 @@ function _codexMetadataBase(entry, { poolKey, cacheKey, sendOpts, handshake = fa
49
49
  : _sessionStartedAtUnixMs(sessionId);
50
50
  const requestKind = _codexRequestKind(sendOpts, sessionId);
51
51
  const wireParity = process.env.MIXDOG_OAI_CODEX_WIRE_PARITY === '1';
52
- // codex opens the WS with a prewarm (empty turn_id) BEFORE the real turn
53
- // (client.rs). Under wire parity the handshake IS that prewarm, so its
52
+ // The reference client opens the WS with a prewarm (empty turn_id) BEFORE
53
+ // the real turn. Under wire parity the handshake IS that prewarm, so its
54
54
  // turn_id empties and its request_kind becomes 'prewarm' instead of
55
55
  // presenting the handshake as a live turn. Parity off is unchanged.
56
56
  const isPrewarm = requestKind === 'prewarm' || handshake === true;
@@ -66,7 +66,7 @@ function _codexMetadataBase(entry, { poolKey, cacheKey, sendOpts, handshake = fa
66
66
  turn_id: turnId,
67
67
  window_id: windowId,
68
68
  request_kind: effectiveRequestKind,
69
- // Richer codex turn-metadata (responses_metadata.rs:264-280). A/B
69
+ // Richer turn-metadata. A/B
70
70
  // 2026-07-04 showed no effect, so it stays behind a knob for future
71
71
  // probes; wire parity implies it.
72
72
  ...((process.env.MIXDOG_OAI_TURN_METADATA_RICH === '1' || wireParity) ? {
@@ -98,8 +98,8 @@ export function _metadataTrace(metadata) {
98
98
  };
99
99
  }
100
100
 
101
- // Handshake projection of the same identity. codex attaches these on every
102
- // request (client.rs:582-584, responses_metadata.rs:227-252); A/B 2026-07-04
101
+ // Handshake projection of the same identity. These ride on every
102
+ // request; A/B 2026-07-04
103
103
  // showed the turn-metadata blob alone lifts prefix-cache hits, so it is ON by
104
104
  // default. MIXDOG_OAI_TURN_METADATA overrides:
105
105
  // unset|1|turn-metadata : window-id + turn-metadata + installation-id
@@ -141,7 +141,7 @@ export function _withCodexWsClientMetadata(frame, entry, enabled, context = {})
141
141
  'x-codex-ws-stream-request-start-ms': String(Date.now()),
142
142
  };
143
143
  if (entry && typeof entry === 'object') {
144
- // codex scopes x-codex-turn-state to ONE turn (client.rs:263-279) while
144
+ // x-codex-turn-state is scoped to ONE turn while
145
145
  // pooled sockets span turns: attribute a captured token to the FIRST
146
146
  // turn that observes it, then drop it once turn_id moves on. An empty
147
147
  // turn_id (parity prewarm) is a valid owner, so the check is against
@@ -59,6 +59,22 @@ export function xaiResponsesCacheRouting(opts, params, rawTools, model) {
59
59
  const scope = String(opts?.xaiResponsesCacheScope || process.env.MIXDOG_XAI_RESPONSES_CACHE_SCOPE || 'prefix')
60
60
  .trim()
61
61
  .toLowerCase();
62
+ // 'none' omits prompt_cache_key entirely, matching xAI's own reference
63
+ // sampler (grok-build sends prompt_cache_key: None) and zed's xAI provider
64
+ // (supports_prompt_cache_key() == false): the service falls back to
65
+ // automatic prompt-prefix caching with no client-supplied lane. Callers
66
+ // must treat a null key as "do not send the field".
67
+ if (scope === 'none' || scope === 'off' || scope === 'omit') {
68
+ const sessionId = String(opts?.sessionId || opts?.session?.id || '').trim();
69
+ const prefixHash = traceHash(xaiPrefixSeed({ opts, params, rawTools, model }));
70
+ return {
71
+ key: null,
72
+ mode: 'none',
73
+ seedHash: null,
74
+ prefixHash,
75
+ ownerSessionHash: sessionId ? traceHash(sessionId) : null,
76
+ };
77
+ }
62
78
  if (scope !== 'prefix') {
63
79
  return xaiCacheRouting(opts, params, rawTools, model);
64
80
  }
@@ -183,7 +199,9 @@ export function useXaiResponsesWebSocketWarmup(opts, config, { previousResponseI
183
199
  if (['0', 'false', 'off', 'none', 'disabled'].includes(mode)) return false;
184
200
  if (['1', 'true', 'on', 'always', 'force'].includes(mode)) return true;
185
201
  }
186
- return String(instructions || '').length >= 2048 || (Array.isArray(rawTools) && rawTools.length >= 10);
202
+ // A generate:false response can still carry provider usage/quota. Keep it
203
+ // strictly opt-in; transport-only HTTP/WS preconnect remains cost-free.
204
+ return false;
187
205
  }
188
206
 
189
207
  // Codex-aligned default: no compat cache lane/serialization. Keep the override
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import { getAgentApiKey } from '../../../shared/provider-api-key.mjs';
3
- import { withRetry } from './retry-classifier.mjs';
3
+ import { canFallbackNonStreaming, withRetry } from './retry-classifier.mjs';
4
4
  import { getLlmDispatcher, preconnect } from '../../../shared/llm/http-agent.mjs';
5
5
  import { sendViaWebSocket } from './openai-oauth-ws.mjs';
6
6
  import { _combineUsageWithWarmup } from './openai-ws-events.mjs';
@@ -69,6 +69,16 @@ function loadOpenAI() {
69
69
  return _OpenAI;
70
70
  }
71
71
 
72
+ // Grok OAuth creates its credential-bound inner provider only when send() has
73
+ // the final token and request headers. Let the process-wide provider warmup pay
74
+ // the OpenAI SDK + undici pool load before that first request reaches the hot
75
+ // path, without constructing a fake credential-bound client.
76
+ export function preloadOpenAICompatRuntime() {
77
+ const OpenAI = loadOpenAI();
78
+ getLlmDispatcher();
79
+ return OpenAI;
80
+ }
81
+
72
82
  function attachCompletedWarmup(err, warmup) {
73
83
  if (!err || !warmup?.usage) return err;
74
84
  try {
@@ -238,7 +248,8 @@ export class OpenAICompatProvider {
238
248
  // extraHeaders behave exactly as before.
239
249
  this.defaultHeaders = { ...(preset?.extraHeaders || {}), ...(config.extraHeaders || {}) };
240
250
  this.defaultModel = preset?.defaultModel || 'default';
241
- this.client = new (loadOpenAI())({
251
+ const OpenAI = preloadOpenAICompatRuntime();
252
+ this.client = new OpenAI({
242
253
  baseURL,
243
254
  apiKey,
244
255
  defaultHeaders: this.defaultHeaders,
@@ -253,6 +264,12 @@ export class OpenAICompatProvider {
253
264
  // a harmless no-op then.
254
265
  fetchOptions: { dispatcher: getLlmDispatcher() },
255
266
  });
267
+ // Provider registry initialization normally runs during daemon warmup.
268
+ // Start the origin handshake there; the per-send call below remains as
269
+ // the TTL-gated rewarm after long idle gaps.
270
+ if (this.config?.preconnect !== false) {
271
+ try { this._preconnectFn(this.baseURL); } catch { /* best-effort */ }
272
+ }
256
273
  }
257
274
  get _preconnectFn() {
258
275
  return typeof this.config?.preconnectFn === 'function'
@@ -420,44 +437,52 @@ export class OpenAICompatProvider {
420
437
  params.stream_options = { include_usage: true };
421
438
  let assembled;
422
439
  try {
423
- assembled = await withRetry(
424
- async ({ signal: attemptSignal }) => {
425
- try { opts.onStageChange?.('requesting'); } catch { /* heartbeat best-effort */ }
426
- const stream = await withRetry(
427
- ({ signal: openSignal }) => this.client.chat.completions.create(params, { signal: openSignal }),
428
- {
440
+ try {
441
+ assembled = await withRetry(
442
+ async ({ signal: attemptSignal }) => {
443
+ try { opts.onStageChange?.('requesting'); } catch { /* heartbeat best-effort */ }
444
+ const stream = await withRetry(
445
+ ({ signal: openSignal }) => this.client.chat.completions.create(params, { signal: openSignal }),
446
+ {
447
+ signal: attemptSignal,
448
+ // Single attempt: this inner wrapper exists only to
449
+ // apply the first-byte per-attempt timeout. Retry is
450
+ // owned by the outer withRetry — nesting retry loops
451
+ // here multiplied tail latency (5x5).
452
+ maxAttempts: 1,
453
+ perAttemptTimeoutMs: PROVIDER_FIRST_BYTE_TIMEOUT_MS,
454
+ perAttemptLabel: `${this.name} first byte`,
455
+ },
456
+ );
457
+ try { opts.onStageChange?.('streaming'); } catch { /* heartbeat best-effort */ }
458
+ return consumeCompatChatCompletionStream(stream, {
429
459
  signal: attemptSignal,
430
- // Single attempt: this inner wrapper exists only to
431
- // apply the first-byte per-attempt timeout. Retry is
432
- // owned by the outer withRetry — nesting retry loops
433
- // here multiplied tail latency (5x5).
434
- maxAttempts: 1,
435
- perAttemptTimeoutMs: PROVIDER_FIRST_BYTE_TIMEOUT_MS,
436
- perAttemptLabel: `${this.name} first byte`,
460
+ label: this.name,
461
+ onStreamDelta: opts.onStreamDelta,
462
+ onToolCall: opts.onToolCall,
463
+ onTextDelta: opts.onTextDelta,
464
+ parseToolCalls,
465
+ // Known tool names for the leaked-tool-call guard:
466
+ // recovered leaked calls only synthesize when they name
467
+ // a tool actually offered to this request.
468
+ knownToolNames: knownToolNamesFromOpenAITools(params.tools),
469
+ });
470
+ },
471
+ {
472
+ signal: totalSignal.signal,
473
+ onRetry: ({ attempt, lastErr, delayMs, delayReason }) => {
474
+ const delayLabel = Number.isFinite(Number(delayMs)) ? `, delay ${delayMs}ms${delayReason ? ` (${delayReason})` : ''}` : '';
475
+ process.stderr.write(`[${this.name}] retry attempt ${attempt + 1} after ${lastErr?.message || lastErr?.code || 'transient error'}${delayLabel}\n`);
437
476
  },
438
- );
439
- try { opts.onStageChange?.('streaming'); } catch { /* heartbeat best-effort */ }
440
- return consumeCompatChatCompletionStream(stream, {
441
- signal: attemptSignal,
442
- label: this.name,
443
- onStreamDelta: opts.onStreamDelta,
444
- onToolCall: opts.onToolCall,
445
- onTextDelta: opts.onTextDelta,
446
- parseToolCalls,
447
- // Known tool names for the leaked-tool-call guard:
448
- // recovered leaked calls only synthesize when they name
449
- // a tool actually offered to this request.
450
- knownToolNames: knownToolNamesFromOpenAITools(params.tools),
451
- });
452
- },
453
- {
454
- signal: totalSignal.signal,
455
- onRetry: ({ attempt, lastErr, delayMs, delayReason }) => {
456
- const delayLabel = Number.isFinite(Number(delayMs)) ? `, delay ${delayMs}ms${delayReason ? ` (${delayReason})` : ''}` : '';
457
- process.stderr.write(`[${this.name}] retry attempt ${attempt + 1} after ${lastErr?.message || lastErr?.code || 'transient error'}${delayLabel}\n`);
458
477
  },
459
- },
460
- );
478
+ );
479
+ } catch (streamErr) {
480
+ const recovered = await this._recoverCompatNonStreaming({
481
+ streamErr, params, opts, signal: totalSignal.signal, useModel,
482
+ });
483
+ if (!recovered) throw streamErr;
484
+ assembled = recovered;
485
+ }
461
486
  } finally {
462
487
  totalSignal.cleanup();
463
488
  }
@@ -567,6 +592,83 @@ export class OpenAICompatProvider {
567
592
  })() : undefined,
568
593
  };
569
594
  }
595
+ /**
596
+ * cc parity (claude.ts:2504): when a stream dies, re-issue the SAME request
597
+ * with `stream:false` rather than failing the turn. Narrower than cc on
598
+ * purpose — canFallbackNonStreaming() clears only a stream that exposed
599
+ * NOTHING, so an eagerly dispatched tool can never run twice and rendered
600
+ * text is never duplicated (that case belongs to the loop's retraction
601
+ * replay). Returns the same assembled shape the stream consumer produces,
602
+ * or null when the failure is ineligible or the fallback itself fails.
603
+ */
604
+ async _recoverCompatNonStreaming({ streamErr, params, opts, signal, useModel }) {
605
+ if (!canFallbackNonStreaming(streamErr, { signal })) return null;
606
+ const nonStreamParams = { ...params, stream: false };
607
+ delete nonStreamParams.stream_options;
608
+ let response;
609
+ try {
610
+ try { opts.onStageChange?.('requesting'); } catch { /* heartbeat best-effort */ }
611
+ response = await withRetry(
612
+ ({ signal: attemptSignal }) => this.client.chat.completions.create(
613
+ nonStreamParams,
614
+ { signal: attemptSignal },
615
+ ),
616
+ {
617
+ signal,
618
+ maxAttempts: 1,
619
+ // A non-streaming call returns only when generation is done,
620
+ // so the first-byte bound would false-abort it.
621
+ perAttemptTimeoutMs: PROVIDER_GENERATE_TOTAL_TIMEOUT_MS,
622
+ perAttemptLabel: `${this.name} non-streaming fallback`,
623
+ },
624
+ );
625
+ } catch {
626
+ return null;
627
+ }
628
+ const choice = response?.choices?.[0] || null;
629
+ if (!choice) return null;
630
+ const message = choice.message || {};
631
+ let toolCalls;
632
+ try {
633
+ toolCalls = parseToolCalls(choice, this.name);
634
+ } catch {
635
+ return null;
636
+ }
637
+ try {
638
+ process.stderr.write(
639
+ `[${this.name}] stream failed (${streamErr?.code || streamErr?.message || 'unknown'}); `
640
+ + `recovered via non-streaming request\n`,
641
+ );
642
+ } catch { /* best-effort */ }
643
+ try {
644
+ appendAgentTrace({
645
+ sessionId: opts?.sessionId || opts?.session?.id || null,
646
+ iteration: Number.isFinite(Number(opts?.iteration)) ? Number(opts.iteration) : null,
647
+ kind: 'transport_fallback',
648
+ provider: this.name,
649
+ model: useModel,
650
+ transport: 'non-streaming',
651
+ payload: {
652
+ from: 'stream',
653
+ to: 'non-streaming',
654
+ reason: streamErr?.retryClassifier || streamErr?.code || streamErr?.message || 'stream_failed',
655
+ error_code: streamErr?.code || null,
656
+ error_http_status: Number(streamErr?.httpStatus || streamErr?.status || 0) || null,
657
+ error_classifier: streamErr?.retryClassifier || streamErr?.midstreamClassifier || null,
658
+ },
659
+ });
660
+ } catch { /* best-effort */ }
661
+ return {
662
+ response,
663
+ model: response.model || useModel,
664
+ content: typeof message.content === 'string' ? message.content : '',
665
+ toolCalls,
666
+ stopReason: choice.finish_reason || null,
667
+ reasoningContent: typeof message.reasoning_content === 'string' ? message.reasoning_content : null,
668
+ rawUsage: response.usage || null,
669
+ };
670
+ }
671
+
570
672
  async _doSendXaiResponses(messages, useModel, tools, opts) {
571
673
  const signal = opts.signal || null;
572
674
  if (signal?.aborted) {
@@ -589,8 +691,11 @@ export class OpenAICompatProvider {
589
691
  // existing request bodies.
590
692
  store: false,
591
693
  include: ['reasoning.encrypted_content'],
592
- prompt_cache_key: cacheRouting.key,
593
694
  };
695
+ // A null routing key means "omit the field" (cache scope 'none'):
696
+ // xAI's own client sends no prompt_cache_key and relies on the
697
+ // service's automatic prefix caching.
698
+ if (cacheRouting.key) params.prompt_cache_key = cacheRouting.key;
594
699
  if (previousResponseId) params.previous_response_id = previousResponseId;
595
700
  const nativeTools = nativeResponsesTools(opts);
596
701
  if (tools?.length || nativeTools.length) params.tools = [...nativeTools, ...toResponsesTools(tools || [], { provider: 'xai' })];
@@ -797,8 +902,8 @@ export class OpenAICompatProvider {
797
902
  input,
798
903
  store: false,
799
904
  include: ['reasoning.encrypted_content'],
800
- prompt_cache_key: cacheRouting.key,
801
905
  };
906
+ if (cacheRouting.key) params.prompt_cache_key = cacheRouting.key;
802
907
  const instructions = xaiSystemInstructions(messages);
803
908
  if (previousResponseId) params.previous_response_id = previousResponseId;
804
909
  // xAI rejects instructions together with previous_response_id; the