mixdog 1.2.1 → 1.2.2

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 (233) hide show
  1. package/package.json +1 -1
  2. package/scripts/gate-local.mjs +19 -3
  3. package/scripts/model-catalog-audit.mjs +1 -15
  4. package/src/agents/reviewer/AGENT.md +3 -3
  5. package/src/agents/reviewer/agent.json +1 -1
  6. package/src/defaults/shipped-history.json +1 -0
  7. package/src/repl/slash-commands.mjs +16 -1
  8. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +29 -4
  9. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +10 -0
  10. package/src/runtime/agent/orchestrator/mcp/client-config.mjs +36 -7
  11. package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +49 -10
  12. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +40 -3
  13. package/src/runtime/agent/orchestrator/mcp/client.mjs +230 -96
  14. package/src/runtime/agent/orchestrator/mcp/oauth-callback.mjs +103 -0
  15. package/src/runtime/agent/orchestrator/mcp/oauth-credentials.mjs +150 -0
  16. package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +111 -0
  17. package/src/runtime/agent/orchestrator/mcp/oauth-probe.mjs +87 -0
  18. package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +95 -0
  19. package/src/runtime/agent/orchestrator/mcp/oauth-request.mjs +122 -0
  20. package/src/runtime/agent/orchestrator/mcp/oauth-shared.mjs +9 -0
  21. package/src/runtime/agent/orchestrator/mcp/oauth.mjs +229 -0
  22. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +188 -29
  23. package/src/runtime/agent/orchestrator/providers/anthropic-api-recovery.mjs +2 -1
  24. package/src/runtime/agent/orchestrator/providers/anthropic-api-transport.mjs +94 -31
  25. package/src/runtime/agent/orchestrator/providers/anthropic-error-contract.mjs +96 -0
  26. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +39 -18
  27. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens-recovery.mjs +55 -0
  28. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +25 -21
  29. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +3 -1
  30. package/src/runtime/agent/orchestrator/providers/anthropic-midstream-recovery.mjs +18 -4
  31. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +13 -1
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-midstream.mjs +10 -1
  34. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-recovery.mjs +23 -9
  35. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request/initial-status.mjs +46 -20
  36. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request.mjs +14 -9
  37. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +73 -35
  38. package/src/runtime/agent/orchestrator/providers/anthropic-overload-fallback.mjs +69 -0
  39. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +4 -0
  40. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -1
  41. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -7
  42. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +30 -9
  43. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
  44. package/src/runtime/agent/orchestrator/providers/antigravity-request.mjs +30 -14
  45. package/src/runtime/agent/orchestrator/providers/antigravity-response.mjs +2 -1
  46. package/src/runtime/agent/orchestrator/providers/antigravity-retry-policy.mjs +93 -0
  47. package/src/runtime/agent/orchestrator/providers/antigravity-transport.mjs +82 -9
  48. package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +3 -0
  49. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +38 -5
  50. package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
  51. package/src/runtime/agent/orchestrator/providers/cursor-wire-guards.mjs +97 -2
  52. package/src/runtime/agent/orchestrator/providers/cursor-wire-stream-bridge.mjs +102 -23
  53. package/src/runtime/agent/orchestrator/providers/cursor-wire-transport.mjs +103 -33
  54. package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +29 -6
  55. package/src/runtime/agent/orchestrator/providers/cursor.mjs +18 -1
  56. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +77 -17
  57. package/src/runtime/agent/orchestrator/providers/gemini-cache-policy.mjs +1 -1
  58. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +29 -8
  59. package/src/runtime/agent/orchestrator/providers/gemini-error-contract.mjs +191 -0
  60. package/src/runtime/agent/orchestrator/providers/gemini-family.mjs +9 -0
  61. package/src/runtime/agent/orchestrator/providers/gemini-history-signatures.mjs +9 -4
  62. package/src/runtime/agent/orchestrator/providers/gemini-response.mjs +3 -1
  63. package/src/runtime/agent/orchestrator/providers/gemini-retry-policy.mjs +180 -0
  64. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +21 -82
  65. package/src/runtime/agent/orchestrator/providers/gemini-sdk-request.mjs +4 -1
  66. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +71 -12
  67. package/src/runtime/agent/orchestrator/providers/gemini-thinking.mjs +21 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +56 -23
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +3 -0
  70. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +68 -19
  71. package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +3 -0
  72. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +2 -1
  73. package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +4 -9
  74. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +4 -2
  75. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-result.mjs +22 -0
  76. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +1 -0
  77. package/src/runtime/agent/orchestrator/providers/openai-compat-error-contract.mjs +50 -0
  78. package/src/runtime/agent/orchestrator/providers/openai-compat-response-normalization.mjs +23 -0
  79. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-events.mjs +16 -8
  80. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-state.mjs +3 -2
  81. package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +94 -13
  82. package/src/runtime/agent/orchestrator/providers/openai-compat-retry-profile.mjs +193 -0
  83. package/src/runtime/agent/orchestrator/providers/openai-compat-stream-common.mjs +66 -2
  84. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +2 -1
  85. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +6 -0
  86. package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +4 -2
  87. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +23 -14
  88. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +17 -3
  89. package/src/runtime/agent/orchestrator/providers/openai-direct-request.mjs +42 -25
  90. package/src/runtime/agent/orchestrator/providers/openai-error-contract.mjs +274 -0
  91. package/src/runtime/agent/orchestrator/providers/openai-http-sse-request.mjs +72 -32
  92. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/terminal-frames.mjs +18 -11
  93. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-outcome.mjs +1 -2
  94. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-stamps.mjs +2 -2
  95. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-state.mjs +2 -3
  96. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state.mjs +2 -2
  97. package/src/runtime/agent/orchestrator/providers/openai-http-sse-watchdogs.mjs +1 -3
  98. package/src/runtime/agent/orchestrator/providers/openai-oauth-dispatch.mjs +34 -3
  99. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +5 -1
  100. package/src/runtime/agent/orchestrator/providers/openai-oauth-tokens.mjs +22 -2
  101. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +5 -0
  102. package/src/runtime/agent/orchestrator/providers/openai-responses-input.mjs +5 -9
  103. package/src/runtime/agent/orchestrator/providers/openai-retry-policy.mjs +9 -0
  104. package/src/runtime/agent/orchestrator/providers/openai-ws-handshake.mjs +16 -8
  105. package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/attempt-context.mjs +1 -2
  106. package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/policy.mjs +25 -14
  107. package/src/runtime/agent/orchestrator/providers/openai-ws-terminal.mjs +18 -11
  108. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +31 -20
  109. package/src/runtime/agent/orchestrator/providers/provider-error-kind.mjs +581 -0
  110. package/src/runtime/agent/orchestrator/providers/retry-backoff.mjs +46 -7
  111. package/src/runtime/agent/orchestrator/providers/retry-classification.mjs +118 -219
  112. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +2 -0
  113. package/src/runtime/agent/orchestrator/providers/retry-executor.mjs +148 -45
  114. package/src/runtime/agent/orchestrator/providers/retry-midstream.mjs +81 -16
  115. package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +3 -0
  116. package/src/runtime/agent/orchestrator/runtime-core/goal-reminder.mjs +0 -18
  117. package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +57 -28
  118. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +19 -16
  119. package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +1 -97
  120. package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +3 -1
  121. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +4 -0
  122. package/src/runtime/agent/orchestrator/session/loop/step-auto-effort.mjs +102 -18
  123. package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +3 -1
  124. package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +30 -2
  125. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +11 -2
  126. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +41 -21
  127. package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +5 -10
  128. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  129. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +18 -7
  130. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +155 -29
  131. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +6 -3
  132. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +16 -0
  133. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +22 -10
  134. package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +2 -0
  135. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +5 -0
  136. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +3 -0
  137. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +20 -1
  138. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +2 -2
  139. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/deadline-plan.mjs +12 -15
  140. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  141. package/src/runtime/memory/lib/pg/process.mjs +34 -24
  142. package/src/runtime/memory/lib/pg/supervisor.mjs +16 -13
  143. package/src/runtime/office/README.md +1 -1
  144. package/src/runtime/office/shared/values.mjs +0 -5
  145. package/src/runtime/shared/background-tasks.mjs +92 -14
  146. package/src/runtime/shared/err-text.mjs +25 -0
  147. package/src/runtime/shared/error-presentation.mjs +159 -19
  148. package/src/runtime/shared/llm/anthropic-betas.mjs +4 -3
  149. package/src/runtime/shared/llm/anthropic-effort.mjs +123 -134
  150. package/src/runtime/shared/llm/anthropic-thinking-contract.mjs +50 -14
  151. package/src/runtime/shared/llm/model-catalog.mjs +53 -40
  152. package/src/runtime/shared/llm/openai-daybreak.mjs +4 -16
  153. package/src/runtime/shared/llm/provider-catalog-registry.mjs +11 -0
  154. package/src/runtime/shared/llm/provider-model-identities.mjs +13 -4
  155. package/src/runtime/shared/llm/usage-rollup.mjs +0 -1
  156. package/src/runtime/shared/model-display.mjs +13 -0
  157. package/src/runtime/shared/quota-resume-store.mjs +11 -0
  158. package/src/runtime/shared/session-baseline-store.mjs +960 -0
  159. package/src/runtime/shared/statusline/statusline-format.mjs +4 -4
  160. package/src/runtime/shared/task-notification-envelope.mjs +2 -2
  161. package/src/runtime/shared/turn-changes.mjs +447 -219
  162. package/src/runtime/shared/turn-snapshot-store.mjs +182 -1
  163. package/src/runtime/shared/turn-snapshot.mjs +170 -48
  164. package/src/runtime/shared/turn-worktree-snapshot.mjs +135 -46
  165. package/src/runtime/shared/worktrees/contract.mjs +0 -24
  166. package/src/runtime/shared/worktrees/test-support.mjs +19 -4
  167. package/src/session-runtime/boot/apis.mjs +6 -0
  168. package/src/session-runtime/boot/infrastructure.mjs +6 -0
  169. package/src/session-runtime/lifecycle/session-catalog.mjs +2 -0
  170. package/src/session-runtime/lifecycle/teardown.mjs +1 -1
  171. package/src/session-runtime/mcp-glue/connect.mjs +29 -9
  172. package/src/session-runtime/mcp-glue/server-catalog.mjs +20 -3
  173. package/src/session-runtime/mcp-glue.mjs +3 -0
  174. package/src/session-runtime/mcp-oauth-login.mjs +174 -0
  175. package/src/session-runtime/model-route/model-switch-target.mjs +34 -0
  176. package/src/session-runtime/model-route/set-route.mjs +83 -15
  177. package/src/session-runtime/model-route/web-search-route.mjs +1 -1
  178. package/src/session-runtime/native-web-search/routes.mjs +22 -3
  179. package/src/session-runtime/provider-models/model-meta.mjs +12 -3
  180. package/src/session-runtime/quick-model-rows/web-search-rows.mjs +13 -8
  181. package/src/session-runtime/quick-web-search-models.mjs +13 -38
  182. package/src/session-runtime/resource-extension-sync.mjs +4 -0
  183. package/src/session-runtime/resource-mcp-api.mjs +98 -2
  184. package/src/session-runtime/runtime-review-api.mjs +13 -0
  185. package/src/session-runtime/services/agent-tool/job-task-reconcile.mjs +13 -1
  186. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +7 -2
  187. package/src/session-runtime/services/agent-tool/send-flow.mjs +22 -4
  188. package/src/session-runtime/services/agent-tool/spawn-flow/job-start.mjs +2 -0
  189. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +22 -5
  190. package/src/session-runtime/services/agent-tool/worker-index/row-liveness.mjs +8 -1
  191. package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +2 -2
  192. package/src/session-runtime/services/agent-tool/worker-rows.mjs +17 -0
  193. package/src/session-runtime/settings-compaction-api.mjs +1 -0
  194. package/src/standalone/daemon-drain-policy.mjs +8 -0
  195. package/src/standalone/daemon-telemetry.mjs +55 -0
  196. package/src/standalone/daemon.mjs +3 -7
  197. package/src/standalone/session-protocol.mjs +7 -0
  198. package/src/standalone/session-service/agent-tree/agent-cancel.mjs +33 -2
  199. package/src/standalone/session-service/agent-tree/agent-turns.mjs +26 -10
  200. package/src/standalone/session-service/agent-tree.mjs +12 -0
  201. package/src/standalone/session-service/quota-resume.mjs +8 -0
  202. package/src/standalone/session-service/retention/busy.mjs +15 -1
  203. package/src/standalone/session-service-api.mjs +5 -0
  204. package/src/standalone/session-service.mjs +1 -0
  205. package/src/tui/app/app-view.jsx +1 -0
  206. package/src/tui/app/create-app-pickers.mjs +4 -0
  207. package/src/tui/app/extension-pickers/mcp-login.mjs +107 -0
  208. package/src/tui/app/extension-pickers/mcp-servers-picker.mjs +2 -1
  209. package/src/tui/app/extension-pickers.mjs +2 -1
  210. package/src/tui/app/model-options.mjs +11 -16
  211. package/src/tui/app/slash-commands.mjs +7 -2
  212. package/src/tui/app/slash-dispatch/panel-commands.mjs +16 -1
  213. package/src/tui/components/StatusLine.jsx +2 -0
  214. package/src/tui/components/status-line/local-line.mjs +5 -2
  215. package/src/tui/components/status-line/use-statusline-text.mjs +7 -1
  216. package/src/tui/dist/index.mjs +816 -43
  217. package/src/tui/session/quota-resume.mjs +46 -14
  218. package/src/tui/session/session-api/commands.mjs +33 -24
  219. package/src/tui/session/session-api/extensions.mjs +22 -0
  220. package/src/tui/session/session-api/integrations.mjs +7 -0
  221. package/src/tui/session/session-api/lifecycle.mjs +5 -1
  222. package/src/tui/session/session-api/routes.mjs +71 -7
  223. package/src/tui/session/session-api/settings.mjs +5 -14
  224. package/src/tui/session/session-flow/drain.mjs +7 -1
  225. package/src/tui/session/session-flow/failed-turn-rewind.mjs +10 -4
  226. package/src/tui/session/turn.mjs +38 -15
  227. package/src/tui/statusline-ansi-bridge.mjs +1 -1
  228. package/src/ui/statusline-agents.mjs +15 -4
  229. package/src/ui/statusline.mjs +7 -3
  230. package/src/ui/streaming-markdown-heal.mjs +34 -3
  231. package/src/vendor/statusline/src/gateway/claude-current.mjs +14 -4
  232. package/src/runtime/memory/lib/memory-session-merge.mjs +0 -32
  233. package/src/runtime/shared/llm/usage-session-history.mjs +0 -266
@@ -0,0 +1,180 @@
1
+ /**
2
+ * gemini-retry-policy.mjs — Gemini API retry constants and the two-phase
3
+ * attempt loop (request backoff and stream retry):
4
+ *
5
+ * connection phase up to 10 attempts, 5s initial delay doubling to a 30s
6
+ * cap, ±30% jitter (a server-advised wait replaces it);
7
+ * stream phase a failure after the response opened (a stream that ends
8
+ * with no finish reason is one: the transport raises it as
9
+ * a truncated stream), or a response the model left
10
+ * unusable (malformed / unexpected tool call, empty text),
11
+ * re-issues the whole request up to 4 attempts total at
12
+ * 1s / 2s / 4s. A response that finished with no finish
13
+ * reason is never judged here, and right after a tool
14
+ * result an empty response (no finish reason or no text)
15
+ * is accepted as the model's answer. An empty answer that
16
+ * survives all 4 attempts throws a typed, provider-terminal
17
+ * invalid-stream error instead of returning nothing.
18
+ * long advice a server wait over 300s ends recovery on both phases
19
+ * (provider-terminal, no quota scope; the wait stays on
20
+ * the error for display).
21
+ *
22
+ * Replay stays gated by the stream-outcome contract: a stream that exposed
23
+ * text or dispatched a tool is never re-issued.
24
+ */
25
+ import { readStreamOutcome } from './lib/stream-outcome.mjs';
26
+ import { DETERMINISTIC_KINDS, providerErrorKind, typedCodeKind } from './provider-error-kind.mjs';
27
+ import { midstreamRetryDelayMs } from './retry-backoff.mjs';
28
+ import { classifyError, markProviderRecoveryExhausted, sleepWithAbort, withRetry } from './retry-classifier.mjs';
29
+
30
+ export const GEMINI_REQUEST_RETRY = Object.freeze({
31
+ maxAttempts: 10,
32
+ // Wait before retry N reads index N (the leading 5s covers index 0 and 1):
33
+ // 5s, 10s, 20s, then the 30s cap.
34
+ backoffMs: Object.freeze([5000, 5000, 10_000, 20_000, 30_000]),
35
+ retryJitterRatio: 0.3,
36
+ // Server advice gets positive-only jitter and is never below the step.
37
+ retryAfterJitterRatio: 0.2,
38
+ adviceAtLeastBackoff: true,
39
+ retry499: true,
40
+ });
41
+
42
+ // A server wait longer than this is a lock-out, not a retry.
43
+ export const GEMINI_MAX_RETRYABLE_ADVICE_MS = 300_000;
44
+
45
+ function hasLongAdvice(err) {
46
+ const { kind, retryAfterMs } = providerErrorKind(err);
47
+ return retryAfterMs > GEMINI_MAX_RETRYABLE_ADVICE_MS && !DETERMINISTIC_KINDS.has(kind);
48
+ }
49
+
50
+ /** True when the request's last turn is a tool result (functionResponse). */
51
+ export function geminiEndsWithFunctionResponse(contents) {
52
+ const last = Array.isArray(contents) ? contents[contents.length - 1] : null;
53
+ return Array.isArray(last?.parts) && last.parts.some((part) => part?.functionResponse);
54
+ }
55
+
56
+ export const GEMINI_MIDSTREAM_MAX_ATTEMPTS = 4;
57
+ export const GEMINI_MIDSTREAM_INITIAL_DELAY_MS = 1000;
58
+
59
+ const midstreamStepMs = (streamAttempt) => GEMINI_MIDSTREAM_INITIAL_DELAY_MS * 2 ** streamAttempt;
60
+
61
+ /**
62
+ * Wait before re-issuing after a stream-phase failure, or null when it must
63
+ * surface. A server-advised wait on a retryable kind replaces the step.
64
+ */
65
+ export function geminiMidstreamDelayMs(err, streamAttempt, signal = null) {
66
+ if (streamAttempt + 1 >= GEMINI_MIDSTREAM_MAX_ATTEMPTS || signal?.aborted) return null;
67
+ if (readStreamOutcome(err).replaySafe !== true) return null;
68
+ const { kind } = providerErrorKind(err);
69
+ if (hasLongAdvice(err)) return null;
70
+ // A status-only 499 (client closed request) is retried, as on the request
71
+ // ladder; a typed code on the 499 keeps it final.
72
+ const closed499 =
73
+ GEMINI_REQUEST_RETRY.retry499 && kind === 'invalid_request' && statusOf(err) === 499 && !typedCodeKind(err);
74
+ if (DETERMINISTIC_KINDS.has(kind) && !closed499) return null;
75
+ // 429 is request-local here: classifyError calls it permanent, but a rate
76
+ // limit that is not a spent quota is retried like any other stream fault.
77
+ if (!closed499 && classifyError(err) !== 'transient' && kind !== 'rate_limited') return null;
78
+ return midstreamRetryDelayMs(err, midstreamStepMs(streamAttempt));
79
+ }
80
+
81
+ const statusOf = (err) => Number(err?.httpStatus || err?.status || 0);
82
+
83
+ const INVALID_TOOL_CALL_FINISH = new Set(['MALFORMED_FUNCTION_CALL', 'UNEXPECTED_TOOL_CALL']);
84
+ // Empty-text finishes that are deterministic refusals or output-limit stops,
85
+ // owned by the policy / incomplete handling instead of a re-ask.
86
+ const EMPTY_TEXT_TERMINAL_FINISH = new Set(['MAX_TOKENS', 'SAFETY', 'RECITATION']);
87
+
88
+ /**
89
+ * Invalid stream: the reason a finished response is unusable
90
+ * ('MALFORMED_FUNCTION_CALL' | 'UNEXPECTED_TOOL_CALL' | 'NO_RESPONSE_TEXT'),
91
+ * or null. A response that carried a tool call, or whose text was already
92
+ * relayed, is never invalid (and never replayed).
93
+ */
94
+ export function geminiInvalidStreamReason(response, textLeakGuard = null, { afterToolResult = false } = {}) {
95
+ const candidate = response?.candidates?.[0];
96
+ const parts = Array.isArray(candidate?.content?.parts) ? candidate.content.parts : [];
97
+ if (parts.some((part) => part?.functionCall)) return null;
98
+ if ((textLeakGuard?.getLeakedToolCalls?.() || []).length > 0) return null;
99
+ if ((textLeakGuard?.getRelayedText?.() || '').length > 0) return null;
100
+ const finish = String(candidate?.finishReason || '').replace(/^FINISH_REASON_/, '');
101
+ if (INVALID_TOOL_CALL_FINISH.has(finish)) return finish;
102
+ if (!finish || EMPTY_TEXT_TERMINAL_FINISH.has(finish)) return null;
103
+ // Right after a tool result the model may legitimately answer with nothing.
104
+ if (afterToolResult) return null;
105
+ const text = parts
106
+ .filter((part) => part?.thought !== true && typeof part?.text === 'string')
107
+ .map((part) => part.text)
108
+ .join('')
109
+ .replace(/[\u200B-\u200D\uFEFF\u200E\u200F]/g, '')
110
+ .trim();
111
+ return text ? null : 'NO_RESPONSE_TEXT';
112
+ }
113
+
114
+ /**
115
+ * Run `run` under the Gemini ladder. `run({ signal, connected })` performs one
116
+ * open + consume and calls `connected()` once the response opened; it resolves
117
+ * `{ response, textLeakGuard }`.
118
+ */
119
+ export async function runGeminiStreamAttempts({ run, signal, onRetry, sleepFn, afterToolResult = false }) {
120
+ for (let streamAttempt = 0; ; streamAttempt++) {
121
+ let connected = false;
122
+ let result;
123
+ try {
124
+ result = await withRetry(
125
+ async ({ signal: attemptSignal }) => {
126
+ try {
127
+ return await run({
128
+ signal: attemptSignal,
129
+ connected: () => {
130
+ connected = true;
131
+ },
132
+ });
133
+ } catch (err) {
134
+ // A server wait over the limit ends recovery on either phase.
135
+ if (hasLongAdvice(err)) {
136
+ throw markProviderRecoveryExhausted(err, {
137
+ owner: connected ? 'gemini-stream' : 'gemini-connection',
138
+ terminal: true,
139
+ });
140
+ }
141
+ // The connection ladder is spent on opening; a stream-phase
142
+ // failure belongs to the stream ladder below.
143
+ throw connected ? markProviderRecoveryExhausted(err, { owner: 'gemini-connection' }) : err;
144
+ }
145
+ },
146
+ { ...GEMINI_REQUEST_RETRY, finalOnExhaust: true, signal, sleepFn, onRetry }
147
+ );
148
+ } catch (err) {
149
+ const waitMs = connected ? geminiMidstreamDelayMs(err, streamAttempt, signal) : null;
150
+ if (waitMs == null) throw err;
151
+ onRetry?.({ attempt: streamAttempt + 1, maxAttempts: GEMINI_MIDSTREAM_MAX_ATTEMPTS, lastErr: err, delayMs: waitMs });
152
+ await sleepWithAbort(waitMs, signal, sleepFn, 'Gemini stream retry wait aborted');
153
+ continue;
154
+ }
155
+ const invalid = geminiInvalidStreamReason(result.response, result.textLeakGuard, { afterToolResult });
156
+ if (!invalid || signal?.aborted) return result;
157
+ const unusable = Object.assign(new Error(`Gemini stream unusable: ${invalid}`), {
158
+ name: 'InvalidStreamError',
159
+ code: invalid.toLowerCase(),
160
+ });
161
+ if (streamAttempt + 1 >= GEMINI_MIDSTREAM_MAX_ATTEMPTS) {
162
+ // A spent ladder on an empty answer is a failure, not an answer; a
163
+ // malformed / unexpected tool call keeps its finish-reason error.
164
+ if (invalid !== 'NO_RESPONSE_TEXT') return result;
165
+ throw markProviderRecoveryExhausted(unusable, {
166
+ owner: 'gemini-stream',
167
+ attempts: GEMINI_MIDSTREAM_MAX_ATTEMPTS,
168
+ terminal: true,
169
+ });
170
+ }
171
+ const waitMs = midstreamStepMs(streamAttempt);
172
+ onRetry?.({
173
+ attempt: streamAttempt + 1,
174
+ maxAttempts: GEMINI_MIDSTREAM_MAX_ATTEMPTS,
175
+ lastErr: unusable,
176
+ delayMs: waitMs,
177
+ });
178
+ await sleepWithAbort(waitMs, signal, sleepFn, 'Gemini stream retry wait aborted');
179
+ }
180
+ }
@@ -66,49 +66,6 @@ function splitGeminiToolContent(content) {
66
66
  };
67
67
  }
68
68
 
69
- const GEMINI_FUNCTION_RESPONSE_MIME = /^(?:image\/(?:png|jpeg|webp)|application\/pdf|text\/plain)$/i;
70
-
71
- function geminiFunctionCapabilities(model) {
72
- const match = String(model || '')
73
- .toLowerCase()
74
- .match(/(?:^|\/)gemini(?:-live)?-(\d+)/);
75
- const major = match ? Number.parseInt(match[1], 10) : null;
76
- const modern = major == null || major >= 3;
77
- return {
78
- functionPartIds: modern,
79
- multimodalFunctionResponse: modern,
80
- };
81
- }
82
-
83
- function toGeminiFunctionResponseMedia(mediaParts) {
84
- const parts = [];
85
- const refs = [];
86
- const external = [];
87
- const omitted = [];
88
- for (const media of mediaParts || []) {
89
- const payload = media?.inlineData || media?.fileData;
90
- const mimeType = String(payload?.mimeType || '').trim();
91
- if (!GEMINI_FUNCTION_RESPONSE_MIME.test(mimeType)) {
92
- if (mimeType) omitted.push(mimeType);
93
- continue;
94
- }
95
- const displayName = `tool_media_${parts.length + 1}`;
96
- if (media.inlineData) {
97
- parts.push({ inlineData: { ...media.inlineData, mimeType, displayName } });
98
- refs.push({ $ref: displayName });
99
- } else {
100
- // FunctionResponse.parts currently documents inlineData only.
101
- // fileData is valid in ordinary Content parts but can 400 when
102
- // nested here, so retain file-backed media as plain JSON metadata.
103
- external.push({
104
- mimeType,
105
- fileUri: String(media.fileData?.fileUri || ''),
106
- });
107
- }
108
- }
109
- return { parts, refs, external, omitted };
110
- }
111
-
112
69
  /**
113
70
  * Convert JSON Schema type string to Gemini SchemaType.
114
71
  * Gemini SDK uses its own enum instead of plain strings.
@@ -647,27 +604,18 @@ export function toGeminiToolConfig(toolChoice) {
647
604
  return undefined;
648
605
  }
649
606
 
650
- function toGeminiToolContent(message, toolNameByCallId, capabilities) {
607
+ // One tool result: its functionResponse part, plus any media it carried as
608
+ // ordinary content parts. The ids pair a call with its response (the Claude
609
+ // models behind Antigravity require them) and are harmless for Gemini.
610
+ function toGeminiToolResult(message, toolNameByCallId) {
651
611
  const functionName = toolNameByCallId?.get(message.toolCallId) || message.toolCallId || '';
652
612
  const { response, mediaParts } = splitGeminiToolContent(message.content);
653
- const media = toGeminiFunctionResponseMedia(mediaParts);
654
- if (media.refs.length) response.media = media.refs;
655
- if (media.external.length) response.externalMedia = media.external;
656
- if (media.omitted.length) response.omittedMediaTypes = media.omitted;
657
613
  const functionResponse = { name: functionName, response };
658
- if (capabilities.functionPartIds && typeof message.toolCallId === 'string' && message.toolCallId) {
659
- functionResponse.id = message.toolCallId;
660
- }
661
- if (capabilities.multimodalFunctionResponse && media.parts.length) {
662
- functionResponse.parts = media.parts;
663
- }
664
- return {
665
- content: { role: 'user', parts: [{ functionResponse }] },
666
- trailingMediaParts: capabilities.multimodalFunctionResponse ? [] : media.parts,
667
- };
614
+ if (typeof message.toolCallId === 'string' && message.toolCallId) functionResponse.id = message.toolCallId;
615
+ return { functionResponsePart: { functionResponse }, mediaParts };
668
616
  }
669
617
 
670
- function toGeminiContent(message, capabilities) {
618
+ function toGeminiContent(message) {
671
619
  if (!message || message.role === 'system') return null;
672
620
  const orderedReplay =
673
621
  message.role === 'assistant' ? providerReplayItems(message, ['gemini', 'antigravity']) : undefined;
@@ -689,7 +637,7 @@ function toGeminiContent(message, capabilities) {
689
637
  functionCall: {
690
638
  name: tc.name,
691
639
  args: tc.arguments,
692
- ...(capabilities.functionPartIds && typeof tc.id === 'string' && tc.id ? { id: tc.id } : {}),
640
+ ...(typeof tc.id === 'string' && tc.id ? { id: tc.id } : {}),
693
641
  },
694
642
  };
695
643
  if (
@@ -765,7 +713,6 @@ function geminiThoughtPartsFromMetadata(message) {
765
713
 
766
714
  export function toGeminiContents(messages, model = '', { repairToolSignatures = true } = {}) {
767
715
  const contents = [];
768
- const capabilities = geminiFunctionCapabilities(model);
769
716
  // Map synthetic toolCallId -> function name from prior assistant
770
717
  // tool_calls so each functionResponse part carries the real function name.
771
718
  const toolNameByCallId = new Map();
@@ -776,34 +723,26 @@ export function toGeminiContents(messages, model = '', { repairToolSignatures =
776
723
  }
777
724
  }
778
725
  }
779
- let pendingToolMediaParts = [];
780
- const flushToolMedia = () => {
781
- if (!pendingToolMediaParts.length) return;
782
- contents.push({ role: 'user', parts: pendingToolMediaParts });
783
- pendingToolMediaParts = [];
784
- };
726
+ // Consecutive tool results share one user turn: every functionResponse part
727
+ // first (one per call, in call order), then the media the results carried as
728
+ // sibling inlineData/fileData parts.
729
+ let toolTurn = null;
785
730
  for (const message of messages) {
786
731
  if (message?.role === 'tool') {
787
- const { content, trailingMediaParts } = toGeminiToolContent(message, toolNameByCallId, capabilities);
788
- const last = contents[contents.length - 1];
789
- if (
790
- last?.role === 'user' &&
791
- Array.isArray(last.parts) &&
792
- last.parts.length &&
793
- last.parts.every((part) => part?.functionResponse)
794
- ) {
795
- last.parts.push(...content.parts);
796
- } else {
797
- contents.push(content);
732
+ const { functionResponsePart, mediaParts } = toGeminiToolResult(message, toolNameByCallId);
733
+ if (!toolTurn) {
734
+ toolTurn = { content: { role: 'user', parts: [] }, responseCount: 0 };
735
+ contents.push(toolTurn.content);
798
736
  }
799
- pendingToolMediaParts.push(...trailingMediaParts);
737
+ toolTurn.content.parts.splice(toolTurn.responseCount, 0, functionResponsePart);
738
+ toolTurn.responseCount += 1;
739
+ toolTurn.content.parts.push(...mediaParts);
800
740
  continue;
801
741
  }
802
- flushToolMedia();
803
- const content = toGeminiContent(message, capabilities);
742
+ toolTurn = null;
743
+ const content = toGeminiContent(message);
804
744
  if (content) contents.push(content);
805
745
  }
806
- flushToolMedia();
807
746
  return repairToolSignatures ? ensureGeminiToolCallSignatures(contents, model) : contents;
808
747
  }
809
748
 
@@ -13,6 +13,7 @@ import {
13
13
  normalizeGeminiSdkError,
14
14
  } from './gemini-stream.mjs';
15
15
  import { geminiFailureUsage } from './gemini-response.mjs';
16
+ import { geminiEndsWithFunctionResponse } from './gemini-retry-policy.mjs';
16
17
 
17
18
  export function geminiTextLeakGuardFor({ tools, callbacks }) {
18
19
  return createGeminiTextLeakGuard({
@@ -71,7 +72,7 @@ function linkSdkRequestController(attemptSignal) {
71
72
  return { controller, detachParent, clearConnectTimer };
72
73
  }
73
74
 
74
- export async function streamGeminiSdkAttempt(genModel, stream, attemptSignal) {
75
+ export async function streamGeminiSdkAttempt(genModel, stream, attemptSignal, onConnected = null) {
75
76
  const { contents, callbacks } = stream;
76
77
  const link = linkSdkRequestController(attemptSignal);
77
78
  const { controller } = link;
@@ -89,6 +90,7 @@ export async function streamGeminiSdkAttempt(genModel, stream, attemptSignal) {
89
90
  // the parent link attached so a later abort during streaming still
90
91
  // reaches the request.
91
92
  link.clearConnectTimer();
93
+ onConnected?.();
92
94
  const textLeakGuard = geminiTextLeakGuardFor(stream);
93
95
  const response = await consumeGeminiSdkStream(streamResult, {
94
96
  signal: attemptSignal,
@@ -96,6 +98,7 @@ export async function streamGeminiSdkAttempt(genModel, stream, attemptSignal) {
96
98
  onTextDelta: callbacks.onTextDelta,
97
99
  textLeakGuard,
98
100
  label: 'Gemini SDK streamGenerateContent',
101
+ acceptEmptyNoFinish: geminiEndsWithFunctionResponse(contents),
99
102
  failureUsage: geminiFailureUsage(stream.opts, stream.cachedContent, stream.useModel),
100
103
  cancelGeneration: (reason) => {
101
104
  if (!controller.signal.aborted) controller.abort(reason);
@@ -26,6 +26,7 @@ import { createSdkStreamReader } from './gemini-sdk-stream/reader.mjs';
26
26
  import { createRestStreamWatchdogs } from './gemini-rest-stream/watchdogs.mjs';
27
27
  import { drainSseDataLines, sseDataPayload } from './gemini-rest-stream/sse-lines.mjs';
28
28
  import { noteAbandonedUsage } from '../../../shared/llm/usage-context.mjs';
29
+ import { applyGeminiErrorContract, assertNoGeminiInBandError } from './gemini-error-contract.mjs';
29
30
 
30
31
  export const GEMINI_FIRST_BYTE_TIMEOUT_MS = resolveTimeoutMs(
31
32
  'MIXDOG_GEMINI_FIRST_BYTE_TIMEOUT_MS',
@@ -132,9 +133,16 @@ export function normalizeGeminiSdkError(err, label) {
132
133
  const transport = geminiSdkTransportError(err, label);
133
134
  if (transport) return transport;
134
135
  const stamped = stampGeminiRpcError(err);
135
- return isGeminiSdkStreamParseError(stamped)
136
- ? geminiStreamCorruptionError(`${label} corrupt SDK SSE JSON`, stamped)
137
- : stamped;
136
+ if (isGeminiSdkStreamParseError(stamped)) {
137
+ return geminiStreamCorruptionError(`${label} corrupt SDK SSE JSON`, stamped);
138
+ }
139
+ if (!stamped || typeof stamped !== 'object') return stamped;
140
+ return applyGeminiErrorContract(stamped, {
141
+ details: stamped.details,
142
+ errorDetails: stamped.errorDetails,
143
+ googleStatus: stamped.geminiStatus,
144
+ message: stamped.message,
145
+ });
138
146
  }
139
147
 
140
148
  // CC-rule safety stamp for Gemini stream failures (provider-stall audit):
@@ -225,7 +233,10 @@ export function geminiChunkProgressKind(chunk) {
225
233
  * (same shape as a non-streaming generateContent JSON body).
226
234
  * Mirrors @google/generative-ai aggregateResponses().
227
235
  */
228
- function aggregateGeminiStreamChunks(responses) {
236
+ function aggregateGeminiStreamChunks(responses, label = 'Gemini stream') {
237
+ // An error envelope anywhere in the stream — including after STOP — fails
238
+ // the turn instead of aggregating into a success.
239
+ for (const response of responses) assertNoGeminiInBandError(response, label);
229
240
  const lastResponse = responses[responses.length - 1];
230
241
  const aggregatedResponse = {
231
242
  promptFeedback: lastResponse?.promptFeedback,
@@ -270,11 +281,28 @@ function aggregateGeminiStreamChunks(responses) {
270
281
  return aggregatedResponse;
271
282
  }
272
283
 
273
- function assertGeminiStreamCompleted({ sawStreamChunk, finishReason, promptBlockReason, label }) {
284
+ // A response with neither text nor a tool call (thoughts alone are not text).
285
+ function hasNoAnswerContent(aggregated) {
286
+ const parts = aggregated?.candidates?.[0]?.content?.parts;
287
+ return !(Array.isArray(parts) ? parts : []).some(
288
+ (part) => part?.functionCall || (part?.thought !== true && typeof part?.text === 'string' && part.text.trim())
289
+ );
290
+ }
291
+
292
+ function assertGeminiStreamCompleted({
293
+ sawStreamChunk,
294
+ finishReason,
295
+ promptBlockReason,
296
+ label,
297
+ acceptEmptyNoFinish = false,
298
+ aggregated = null,
299
+ }) {
274
300
  if (!sawStreamChunk) {
275
301
  throw geminiTruncatedStreamError(`${label} truncated: empty stream`);
276
302
  }
277
303
  if (!finishReason && !promptBlockReason) {
304
+ // Right after a tool result the model may end with nothing at all.
305
+ if (acceptEmptyNoFinish && hasNoAnswerContent(aggregated)) return;
278
306
  throw geminiTruncatedStreamError(`${label} truncated: no finishReason`);
279
307
  }
280
308
  }
@@ -436,7 +464,17 @@ function createLeakGuardFinalizer(textLeakGuard) {
436
464
  // aggregation, leak guard, watchdogs — stays shared.
437
465
  export async function consumeGeminiRestStreamResponse(
438
466
  response,
439
- { signal, onStreamDelta, onTextDelta, textLeakGuard, label, unwrapChunk = null, onChunk = null, failureUsage = null }
467
+ {
468
+ signal,
469
+ onStreamDelta,
470
+ onTextDelta,
471
+ textLeakGuard,
472
+ label,
473
+ unwrapChunk = null,
474
+ onChunk = null,
475
+ failureUsage = null,
476
+ acceptEmptyNoFinish = false,
477
+ }
440
478
  ) {
441
479
  const unwrap =
442
480
  typeof unwrapChunk === 'function'
@@ -464,7 +502,10 @@ export async function consumeGeminiRestStreamResponse(
464
502
  });
465
503
  // One decoded chunk: collected for aggregation and relayed to every sink.
466
504
  const relayChunk = (rawChunk) => {
505
+ // In-band errors stay top level even on a transport that nests payloads.
506
+ assertNoGeminiInBandError(rawChunk, label);
467
507
  const parsed = unwrap(rawChunk);
508
+ assertNoGeminiInBandError(parsed, label);
468
509
  if (!sawStreamChunk) {
469
510
  sawStreamChunk = true;
470
511
  watchdogs.clearFirstByte();
@@ -530,12 +571,13 @@ export async function consumeGeminiRestStreamResponse(
530
571
  }
531
572
  const tail = sseDataPayload(buffer.trim());
532
573
  if (tail) {
574
+ let rawTail;
533
575
  try {
534
- const [rawTail] = await parseProviderJsonBatch([tail]);
535
- relayChunk(rawTail);
576
+ [rawTail] = await parseProviderJsonBatch([tail]);
536
577
  } catch (cause) {
537
578
  throw geminiStreamCorruptionError(`${label} corrupt SSE tail JSON`, cause);
538
579
  }
580
+ relayChunk(rawTail);
539
581
  }
540
582
  } catch (err) {
541
583
  finalizeLeakGuard();
@@ -558,7 +600,7 @@ export async function consumeGeminiRestStreamResponse(
558
600
  finalizeLeakGuard();
559
601
  }
560
602
 
561
- const aggregated = aggregateGeminiStreamChunks(allChunks);
603
+ const aggregated = aggregateGeminiStreamChunks(allChunks, label);
562
604
  const finishReason = aggregated.candidates?.[0]?.finishReason || null;
563
605
  const promptBlockReason = aggregated.promptFeedback?.blockReason || null;
564
606
  // Truncation (no finishReason) after visible output must carry the same
@@ -566,7 +608,14 @@ export async function consumeGeminiRestStreamResponse(
566
608
  // above, so stamp here too (review High: transient TRUNCATED_STREAM would
567
609
  // otherwise replay/double-render live text via withRetry).
568
610
  try {
569
- assertGeminiStreamCompleted({ sawStreamChunk, finishReason, promptBlockReason, label });
611
+ assertGeminiStreamCompleted({
612
+ sawStreamChunk,
613
+ finishReason,
614
+ promptBlockReason,
615
+ label,
616
+ acceptEmptyNoFinish,
617
+ aggregated,
618
+ });
570
619
  } catch (err) {
571
620
  throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard, chunks: allChunks, failureUsage });
572
621
  }
@@ -585,6 +634,7 @@ export async function consumeGeminiSdkStream(
585
634
  firstByteTimeoutMs = GEMINI_FIRST_BYTE_TIMEOUT_MS,
586
635
  cancellationGraceMs = 250,
587
636
  failureUsage = null,
637
+ acceptEmptyNoFinish = false,
588
638
  }
589
639
  ) {
590
640
  let relayedText = '';
@@ -636,6 +686,7 @@ export async function consumeGeminiSdkStream(
636
686
  if (signal?.aborted) throw abortError();
637
687
  if (step.done) break;
638
688
  reader.noteChunk();
689
+ assertNoGeminiInBandError(step.value, label);
639
690
  if (step.value) collectedChunks.push(step.value);
640
691
  try {
641
692
  onStreamDelta?.(geminiChunkProgressKind(step.value));
@@ -652,7 +703,7 @@ export async function consumeGeminiSdkStream(
652
703
  // locally; retain the SDK response path only for an empty collection.
653
704
  let raw;
654
705
  if (collectedChunks.length > 0) {
655
- raw = aggregateGeminiStreamChunks(collectedChunks);
706
+ raw = aggregateGeminiStreamChunks(collectedChunks, label);
656
707
  } else {
657
708
  let response;
658
709
  try {
@@ -661,10 +712,18 @@ export async function consumeGeminiSdkStream(
661
712
  throw normalizeGeminiSdkError(err, label);
662
713
  }
663
714
  raw = response?.candidates ? response : response?.response || response;
715
+ assertNoGeminiInBandError(raw, label);
664
716
  }
665
717
  const finishReason = raw?.candidates?.[0]?.finishReason || null;
666
718
  const promptBlockReason = raw?.promptFeedback?.blockReason || null;
667
- assertGeminiStreamCompleted({ sawStreamChunk: reader.sawStreamChunk, finishReason, promptBlockReason, label });
719
+ assertGeminiStreamCompleted({
720
+ sawStreamChunk: reader.sawStreamChunk,
721
+ finishReason,
722
+ promptBlockReason,
723
+ label,
724
+ acceptEmptyNoFinish,
725
+ aggregated: raw,
726
+ });
668
727
  return raw;
669
728
  } catch (err) {
670
729
  reader?.clearFirstByte();
@@ -1,9 +1,26 @@
1
+ import { getModelMetadataSync } from '../../../shared/llm/model-catalog.mjs';
2
+
3
+ // The thinkingLevel values the Gemini wire accepts.
1
4
  const LEVELS = ['minimal', 'low', 'medium', 'high'];
2
5
 
6
+ // The thinkingLevel values the catalogs declare for the model (provider live
7
+ // roster, then models.dev / LiteLLM rows, resolved by getModelMetadataSync),
8
+ // limited to what the Gemini wire accepts. null when no catalog declares a ladder.
9
+ function catalogThinkingLevels(model, provider) {
10
+ const bare = String(model || '').replace(/^models\//i, '');
11
+ const effort = getModelMetadataSync(bare, provider)?.reasoningOptions?.find((option) => option?.type === 'effort');
12
+ const declared = (Array.isArray(effort?.values) ? effort.values : [])
13
+ .map((value) => String(value).toLowerCase())
14
+ .filter((value) => LEVELS.includes(value));
15
+ return declared.length ? [...new Set(declared)] : null;
16
+ }
17
+
3
18
  // Only translate the public option names, not arbitrary effort-to-token ratios.
4
- // Omitting settings preserves the provider's model default.
5
- export function geminiThinkingConfig(model, opts = {}, { includeThoughts } = {}) {
6
- const id = String(model || '').toLowerCase();
19
+ // Omitting settings preserves the provider's model default. A level is checked
20
+ // against the catalog's ladder when one is declared; otherwise the value is sent
21
+ // as given (within the wire vocabulary) and the server decides, and an explicit
22
+ // thinkingBudget is likewise passed through unvalidated beyond its wire type.
23
+ export function geminiThinkingConfig(model, opts = {}, { includeThoughts, provider = 'gemini' } = {}) {
7
24
  const rawBudget = opts.thinkingBudget ?? opts.thinkingBudgetTokens;
8
25
  const hasBudget = rawBudget !== undefined && rawBudget !== null && rawBudget !== '';
9
26
  const rawLevel = opts.thinkingLevel ?? (hasBudget ? undefined : opts.effort);
@@ -15,14 +32,7 @@ export function geminiThinkingConfig(model, opts = {}, { includeThoughts } = {})
15
32
  throw new TypeError('Choose thinkingLevel/effort or thinkingBudget, not both.');
16
33
  }
17
34
  if (level) {
18
- if (!/^gemini-3(?:[.-]|$)/.test(id)) {
19
- throw new TypeError(`${model} uses thinkingBudget instead of thinkingLevel/effort.`);
20
- }
21
- let supported = LEVELS;
22
- if (/^gemini-3(?:\.0)?-pro(?:-|$)/.test(id)) supported = ['low', 'high'];
23
- else if (/^gemini-3\.1-pro(?:-|$)/.test(id) || /^gemini-3\.[78]-flash(?:-|$)/.test(id)) {
24
- supported = ['low', 'medium', 'high'];
25
- } else if (/^gemini-3\.1-flash-lite.*image/.test(id)) supported = ['minimal', 'high'];
35
+ const supported = catalogThinkingLevels(model, provider) ?? LEVELS;
26
36
  if (!supported.includes(level)) {
27
37
  throw new TypeError(`${model} supports thinking levels ${supported.join(', ')}; received ${level}.`);
28
38
  }
@@ -33,15 +43,6 @@ export function geminiThinkingConfig(model, opts = {}, { includeThoughts } = {})
33
43
  if (!Number.isInteger(budget) || budget < -1) {
34
44
  throw new TypeError('thinkingBudget must be -1 (dynamic), 0, or a positive integer.');
35
45
  }
36
- if (/^gemini-2\.5-pro(?:-|$)/.test(id) && budget !== -1 && (budget < 128 || budget > 32768)) {
37
- throw new TypeError(`${model} requires thinkingBudget -1 or 128..32768.`);
38
- }
39
- if (/^gemini-2\.5-flash(?:-|$)/.test(id) && budget > 24576) {
40
- throw new TypeError(`${model} requires thinkingBudget at most 24576.`);
41
- }
42
- if (/^gemini-2\.5-flash-lite(?:-|$)/.test(id) && budget > 0 && budget < 512) {
43
- throw new TypeError(`${model} requires thinkingBudget -1, 0, or 512..24576.`);
44
- }
45
46
  config.thinkingBudget = budget;
46
47
  }
47
48
  return Object.keys(config).length ? config : undefined;