@bitkyc08/opencodex 2.58.0 → 2.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (303) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-BTuCbqQd.css +1 -0
  3. package/gui/dist/assets/index-DoBVdPHP.js +134 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +4 -1
  8. package/src/adapters/anthropic-image-codec.ts +16 -2
  9. package/src/adapters/anthropic-image-normalize.ts +49 -2
  10. package/src/adapters/anthropic.ts +4 -1
  11. package/src/adapters/base.ts +34 -1
  12. package/src/adapters/coding-agent/turn.ts +22 -2
  13. package/src/adapters/command-code.ts +50 -3
  14. package/src/adapters/cursor/catalog.ts +11 -0
  15. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  16. package/src/adapters/cursor/discovery.ts +11 -8
  17. package/src/adapters/cursor/effort-map.ts +16 -2
  18. package/src/adapters/cursor/envelope-echo.ts +55 -2
  19. package/src/adapters/cursor/live-transport.ts +26 -9
  20. package/src/adapters/cursor/message-mapper.ts +3 -2
  21. package/src/adapters/cursor/protobuf-request.ts +8 -5
  22. package/src/adapters/cursor/request-builder.ts +21 -4
  23. package/src/adapters/cursor/thread-continuity.ts +105 -31
  24. package/src/adapters/cursor/tool-guidance.ts +5 -4
  25. package/src/adapters/cursor/transport.ts +19 -0
  26. package/src/adapters/cursor.ts +45 -2
  27. package/src/adapters/devin/cloud-direct/chat.ts +14 -3
  28. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  29. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
  30. package/src/adapters/devin.ts +125 -26
  31. package/src/adapters/google-antigravity-replay.ts +1 -1
  32. package/src/adapters/google-antigravity-wire.ts +55 -4
  33. package/src/adapters/google-http.ts +57 -11
  34. package/src/adapters/google-tool-schema.ts +595 -31
  35. package/src/adapters/google-wire-compiler.ts +93 -10
  36. package/src/adapters/google-wire-shape.ts +461 -0
  37. package/src/adapters/google.ts +60 -10
  38. package/src/adapters/openai-chat/response-events.ts +61 -0
  39. package/src/adapters/openai-chat-images.ts +3 -1
  40. package/src/adapters/openai-chat.ts +10 -11
  41. package/src/adapters/openai-responses/image-gen.ts +8 -6
  42. package/src/adapters/openai-responses/passthrough.ts +25 -4
  43. package/src/adapters/openai-responses/reasoning.ts +7 -0
  44. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  45. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  46. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  47. package/src/adapters/responses-tool-schema.ts +76 -46
  48. package/src/adapters/run-turn-queue.ts +17 -4
  49. package/src/bridge/response-json.ts +1 -1
  50. package/src/bridge/sse.ts +28 -35
  51. package/src/claude/context-windows.ts +22 -0
  52. package/src/claude/outbound.ts +35 -4
  53. package/src/cli/account-api.ts +4 -3
  54. package/src/cli/account-extended.ts +26 -6
  55. package/src/cli/account-orca-import.ts +63 -0
  56. package/src/cli/account.ts +32 -4
  57. package/src/cli/capabilities.ts +40 -0
  58. package/src/cli/claude.ts +29 -1
  59. package/src/cli/codex-cli-update.ts +97 -2
  60. package/src/cli/dispatch.ts +57 -3
  61. package/src/cli/doctor.ts +218 -4
  62. package/src/cli/help.ts +4 -1
  63. package/src/cli/hub.ts +3 -2
  64. package/src/cli/index.ts +95 -22
  65. package/src/cli/models-runtime.ts +33 -4
  66. package/src/cli/opencode.ts +2 -2
  67. package/src/cli/provider.ts +13 -1
  68. package/src/cli/registry.ts +11 -1
  69. package/src/cli/runtime-api.ts +44 -0
  70. package/src/cli/start-args.ts +94 -0
  71. package/src/cli/system-command.ts +2 -0
  72. package/src/client/machine-api.ts +6 -5
  73. package/src/client/machine-listener.ts +16 -3
  74. package/src/client/runtime.ts +26 -2
  75. package/src/clients/config-export/constants.ts +2 -3
  76. package/src/clients/config-export.ts +5 -5
  77. package/src/codex/account-store.ts +146 -5
  78. package/src/codex/auth-api/account-list.ts +19 -11
  79. package/src/codex/auth-api/pool-quota-probe.ts +44 -10
  80. package/src/codex/auth-api/routes.ts +17 -2
  81. package/src/codex/auth-context.ts +16 -12
  82. package/src/codex/catalog/build-entries.ts +25 -4
  83. package/src/codex/catalog/derive-entry.ts +8 -1
  84. package/src/codex/catalog/effort.ts +10 -6
  85. package/src/codex/catalog/gather-capture.ts +22 -2
  86. package/src/codex/catalog/model-hints.ts +66 -33
  87. package/src/codex/catalog/parsing.ts +90 -5
  88. package/src/codex/catalog/provider-models.ts +19 -2
  89. package/src/codex/catalog/reserve-warn.ts +96 -0
  90. package/src/codex/catalog/retained-sync.ts +41 -26
  91. package/src/codex/catalog/routed-gather.ts +61 -3
  92. package/src/codex/cli-installation-identity.ts +210 -0
  93. package/src/codex/cli-installation-targets.ts +158 -0
  94. package/src/codex/context-compat.ts +5 -2
  95. package/src/codex/convergence.ts +5 -0
  96. package/src/codex/desired-state.ts +4 -1
  97. package/src/codex/history-job.ts +6 -6
  98. package/src/codex/history-provider.ts +24 -167
  99. package/src/codex/history-rollout-read.ts +174 -0
  100. package/src/codex/history-state-open.ts +105 -0
  101. package/src/codex/inject/config-toml.ts +44 -2
  102. package/src/codex/inject.ts +3 -2
  103. package/src/codex/internal/catalog-writer.ts +33 -1
  104. package/src/codex/lineage.ts +83 -32
  105. package/src/codex/loopback-target.ts +31 -0
  106. package/src/codex/main-account-hard-lock.ts +2 -1
  107. package/src/codex/main-account.ts +10 -3
  108. package/src/codex/main-device-reauth.ts +17 -9
  109. package/src/codex/model-cache.ts +47 -0
  110. package/src/codex/model-entitlements.ts +80 -2
  111. package/src/codex/observed-model-denials.ts +230 -0
  112. package/src/codex/orca-auth-source.ts +94 -0
  113. package/src/codex/orca-import.ts +219 -0
  114. package/src/codex/prompt-text-probe.ts +289 -16
  115. package/src/codex/quota-401-recovery.ts +12 -0
  116. package/src/codex/quota-types.ts +65 -0
  117. package/src/codex/quota.ts +24 -19
  118. package/src/codex/routing/cooldown-math.ts +8 -47
  119. package/src/codex/routing/pin-drain.ts +57 -0
  120. package/src/codex/routing.ts +20 -16
  121. package/src/codex/shim.ts +1 -1
  122. package/src/codex/subagent-model-fallback.ts +114 -2
  123. package/src/codex/windows-installation-files.ts +224 -0
  124. package/src/combos/failover.ts +125 -5
  125. package/src/config/admitted-identity.ts +222 -0
  126. package/src/config/diagnostics.ts +43 -1
  127. package/src/config/feature-flags.ts +5 -0
  128. package/src/config/load-degrade.ts +33 -0
  129. package/src/config/pending-teardown.ts +8 -0
  130. package/src/config/process-state.ts +36 -3
  131. package/src/config/provider-relative-send-path.ts +16 -0
  132. package/src/config/proxy-env.ts +31 -7
  133. package/src/config/schema/compaction-triggers.ts +11 -0
  134. package/src/config/schema/config-schema.ts +25 -0
  135. package/src/config/schema/leaf-validators.ts +76 -17
  136. package/src/config.ts +2 -2
  137. package/src/generated/compatibility-version.json +410 -258
  138. package/src/generated/model-metadata.ts +1 -1
  139. package/src/grok/reset-coupons.ts +38 -19
  140. package/src/images/loop.ts +6 -1
  141. package/src/integrations/aside-profile-context.ts +37 -3
  142. package/src/integrations/aside-profile-journal.ts +68 -3
  143. package/src/integrations/aside-profiles.ts +128 -3
  144. package/src/integrations/mutation-plan.ts +815 -0
  145. package/src/integrations/writer.ts +85 -99
  146. package/src/lab/live/transport.ts +4 -0
  147. package/src/lab/live/types.ts +5 -0
  148. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  149. package/src/lib/admin-secrets.ts +9 -1
  150. package/src/lib/bounded-body.ts +4 -2
  151. package/src/lib/debug-log-buffer.ts +6 -1
  152. package/src/lib/debug.ts +23 -0
  153. package/src/lib/destination-policy.ts +48 -6
  154. package/src/lib/errors.ts +82 -15
  155. package/src/lib/http-response-semantics.ts +57 -0
  156. package/src/lib/lab-live-pinned-sender.ts +26 -12
  157. package/src/lib/local-destinations.ts +32 -5
  158. package/src/lib/pinned-http.ts +142 -2
  159. package/src/lib/plain-data.ts +103 -0
  160. package/src/lib/process-control.ts +13 -5
  161. package/src/lib/provider-outbound.ts +53 -5
  162. package/src/lib/proxy-env.ts +70 -3
  163. package/src/lib/request-execution-budget.ts +11 -3
  164. package/src/lib/response-body-inactivity.ts +193 -0
  165. package/src/lib/retry-delay.ts +69 -0
  166. package/src/lib/socks5-fetch.ts +741 -0
  167. package/src/lib/spend-ledger-owner.ts +364 -0
  168. package/src/lib/spend-reservation-ledger.ts +332 -35
  169. package/src/lib/windows-system-proxy.ts +16 -11
  170. package/src/lib/workflow-budget.ts +145 -8
  171. package/src/oauth/account-quota-rank.ts +72 -15
  172. package/src/oauth/callback-server.ts +4 -3
  173. package/src/oauth/generic-account-failover.ts +41 -27
  174. package/src/oauth/health.ts +12 -1
  175. package/src/oauth/index.ts +3 -107
  176. package/src/oauth/login-flow-state.ts +127 -0
  177. package/src/oauth/orcarouter.ts +15 -2
  178. package/src/oauth/store.ts +8 -0
  179. package/src/providers/codex-capacity.ts +9 -0
  180. package/src/providers/derive.ts +34 -17
  181. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  182. package/src/providers/devin-provider-merge-migration.ts +33 -12
  183. package/src/providers/free-directory.ts +20 -2
  184. package/src/providers/key-failover.ts +327 -25
  185. package/src/providers/model-rename-migration.ts +56 -1
  186. package/src/providers/model-rename-startup.ts +7 -5
  187. package/src/providers/openai-sidecar.ts +4 -0
  188. package/src/providers/openai-virtual-models.ts +42 -2
  189. package/src/providers/opencode-go-transport.ts +14 -5
  190. package/src/providers/quota/antigravity.ts +22 -2
  191. package/src/providers/quota/report-cache.ts +3 -0
  192. package/src/providers/quota/vendor-probes-key.ts +1 -1
  193. package/src/providers/registry/entries-core.ts +39 -17
  194. package/src/providers/registry/entries-extended.ts +131 -1
  195. package/src/providers/registry/model-ids.ts +168 -0
  196. package/src/providers/registry/model-seeds.ts +87 -21
  197. package/src/providers/registry/types.ts +2 -0
  198. package/src/providers/resolved-model-policy-merge.ts +167 -0
  199. package/src/providers/resolved-model-policy.ts +406 -0
  200. package/src/providers/stale-vision-classification-migration.ts +137 -0
  201. package/src/responses/apply-patch-envelope.ts +32 -11
  202. package/src/responses/bridge-search-replay-cache.ts +152 -0
  203. package/src/responses/code-mode-helper-compat.ts +26 -16
  204. package/src/responses/custom-tool-compat.ts +1 -1
  205. package/src/responses/freeform-wrapper-scan.ts +279 -0
  206. package/src/responses/hosted-tool-policy.ts +85 -2
  207. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  208. package/src/responses/progressive-freeform-input.ts +130 -0
  209. package/src/responses/reasoning-envelope.ts +30 -0
  210. package/src/responses/schema.ts +9 -2
  211. package/src/responses/state.ts +5 -12
  212. package/src/responses/tool-name-aliases.ts +15 -1
  213. package/src/router.ts +91 -115
  214. package/src/routing/compatibility/behavior.ts +9 -0
  215. package/src/routing/compatibility/subject.ts +16 -1
  216. package/src/server/adapter-resolve.ts +9 -0
  217. package/src/server/auth-cors.ts +29 -0
  218. package/src/server/chat-completions.ts +13 -5
  219. package/src/server/chat-native-sse.ts +26 -9
  220. package/src/server/chat-native.ts +10 -4
  221. package/src/server/claude-messages.ts +30 -5
  222. package/src/server/effort-row.ts +11 -3
  223. package/src/server/grok-responses-control-frame.ts +160 -1
  224. package/src/server/gui-static.ts +36 -2
  225. package/src/server/inbound-body-admission.ts +187 -0
  226. package/src/server/index/serve-options.ts +86 -29
  227. package/src/server/index/spend-ledger-lifecycle.ts +66 -0
  228. package/src/server/index/websocket-handler.ts +6 -1
  229. package/src/server/index.ts +24 -25
  230. package/src/server/management/api-access.ts +3 -4
  231. package/src/server/management/aside-profile-routes.ts +266 -7
  232. package/src/server/management/config-routes.ts +48 -7
  233. package/src/server/management/context.ts +3 -0
  234. package/src/server/management/integration-routes.ts +287 -5
  235. package/src/server/management/metrics-routes.ts +20 -0
  236. package/src/server/management/model-rows.ts +224 -12
  237. package/src/server/management/provider-capability-config.ts +35 -7
  238. package/src/server/management/provider-routes.ts +70 -18
  239. package/src/server/management/route-registry.ts +14 -0
  240. package/src/server/management/shared.ts +10 -3
  241. package/src/server/management/system-restart.ts +7 -2
  242. package/src/server/management/system-routes.ts +2 -0
  243. package/src/server/management/usage-aggregate-cache.ts +4 -0
  244. package/src/server/management-api.ts +2 -0
  245. package/src/server/management-auth.ts +15 -1
  246. package/src/server/proxy-liveness.ts +97 -2
  247. package/src/server/readiness.ts +29 -10
  248. package/src/server/relay-eager.ts +24 -2
  249. package/src/server/relay.ts +136 -34
  250. package/src/server/request-log.ts +80 -3
  251. package/src/server/request-metrics.ts +236 -0
  252. package/src/server/responses/adapter-continuation.ts +74 -30
  253. package/src/server/responses/adapter-delivery.ts +39 -8
  254. package/src/server/responses/adapter-dispatch.ts +63 -30
  255. package/src/server/responses/compact.ts +89 -18
  256. package/src/server/responses/compaction-routing.ts +111 -0
  257. package/src/server/responses/core-codex-account.ts +90 -24
  258. package/src/server/responses/core-combo.ts +7 -7
  259. package/src/server/responses/core-normalize.ts +16 -15
  260. package/src/server/responses/core-opaque-recovery.ts +1 -0
  261. package/src/server/responses/core-options.ts +4 -0
  262. package/src/server/responses/encrypted-payload.ts +20 -2
  263. package/src/server/responses/fetch-helpers.ts +68 -2
  264. package/src/server/responses/passthrough-delivery.ts +41 -15
  265. package/src/server/responses/passthrough-dispatch.ts +145 -53
  266. package/src/server/responses/passthrough-execution.ts +11 -1
  267. package/src/server/responses/policy-fallback.ts +5 -13
  268. package/src/server/responses/request-prepare.ts +96 -17
  269. package/src/server/responses/request-send-budget.ts +89 -8
  270. package/src/server/responses/request-sidecar-auth.ts +17 -9
  271. package/src/server/responses/request-spend.ts +38 -9
  272. package/src/server/responses/request-transport.ts +15 -12
  273. package/src/server/responses/run-turn-execution.ts +45 -8
  274. package/src/server/responses/sidecar-execution.ts +19 -2
  275. package/src/server/responses/ws-upstream.ts +16 -28
  276. package/src/server/responses-custom-tool-repair.ts +29 -56
  277. package/src/server/responses-undeclared-tool-guard.ts +31 -1
  278. package/src/server/sse-frame-buffer.ts +12 -10
  279. package/src/server/sse-payload-rewrite.ts +37 -10
  280. package/src/server/system-env-shell.ts +5 -1
  281. package/src/server/system-env.ts +7 -1
  282. package/src/server/workflow-refusal.ts +56 -2
  283. package/src/service/cli.ts +16 -6
  284. package/src/service/guards.ts +10 -0
  285. package/src/service/health.ts +43 -0
  286. package/src/service/state.ts +7 -2
  287. package/src/tray/windows-tray.ps1 +155 -3
  288. package/src/types/accounts.ts +4 -0
  289. package/src/types/config.ts +111 -6
  290. package/src/types/provider.ts +37 -0
  291. package/src/types/request.ts +9 -1
  292. package/src/types/tools.ts +14 -0
  293. package/src/types/wire.ts +9 -1
  294. package/src/types.ts +1 -0
  295. package/src/usage/expected-prices.ts +28 -0
  296. package/src/usage/log.ts +87 -4
  297. package/src/vision/eligibility.ts +88 -9
  298. package/src/vision/plan.ts +34 -10
  299. package/src/web-search/executor.ts +41 -2
  300. package/src/web-search/loop.ts +6 -1
  301. package/src/web-search/passthrough-bridge.ts +39 -5
  302. package/gui/dist/assets/index-BbrHOIY0.js +0 -128
  303. package/gui/dist/assets/index-C5-RdDmD.css +0 -1
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Same-target retry of an explicit, pre-output 429 refusal with a stated
3
+ * recovery delay. No event-producing attempt is ever automatically replayed.
4
+ * This is a refusal-specific policy, not a claim that every eventless POST
5
+ * is idempotent; ambiguous transport failures still propagate unchanged.
6
+ */
7
+ import { parseRetryAfterFromMessage } from '../../../lib/retry-delay.js';
8
+ import type { AdapterFetchContext } from '../../base.js';
9
+ import { createAdapterPhysicalSend } from '../../physical-send.js';
10
+ import { abortError, SendBudgetExhaustedError, sleepWithAbort } from '../../../lib/upstream-retry.js';
11
+ import { CloudChatError, streamChatEvents, type CloudChatEvent, type CloudChatRequest } from './chat.js';
12
+
13
+ /** 1 initial attempt plus at most 2 replays. */
14
+ export const STATED_RESET_MAX_REPLAYS = 2;
15
+ /** Default cumulative wait allowance for one invocation (30 minutes). */
16
+ export const STATED_RESET_MAX_WAIT_MS = 1_800_000;
17
+ /** Absolute maximum cumulative allowance, including explicit overrides. */
18
+ export const STATED_RESET_WAIT_CEILING_MS = 3_600_000;
19
+
20
+ function statedResetMaxWaitMs(): number {
21
+ const raw = process.env.OPENCODEX_DEVIN_STATED_RESET_WAIT_MS?.trim();
22
+ if (!raw) return STATED_RESET_MAX_WAIT_MS;
23
+ const parsed = Number(raw);
24
+ if (!Number.isFinite(parsed) || parsed < 0) return STATED_RESET_MAX_WAIT_MS;
25
+ // Zero explicitly disables local waiting. Values above one hour are capped.
26
+ return Math.min(Math.floor(parsed), STATED_RESET_WAIT_CEILING_MS);
27
+ }
28
+ export const statedResetMaxWaitMsForTests = statedResetMaxWaitMs;
29
+
30
+ export interface StatedResetRetryOptions {
31
+ /** Test seam: defaults to the real cloud stream. */
32
+ stream?: (req: CloudChatRequest) => AsyncGenerator<CloudChatEvent>;
33
+ /** Test seam: must either honour the whole delay or reject on cancellation. */
34
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
35
+ maxReplays?: number;
36
+ /** CUMULATIVE wait allowance, not a fresh allowance on every failure. */
37
+ maxWaitMs?: number;
38
+ /** Request-wide execution authority. Absent preserves context-free callers' unlimited fetch. */
39
+ execution?: Pick<
40
+ AdapterFetchContext,
41
+ "executor" | "sendBudget" | "onPhysicalSend" | "onRecoveryWithheld"
42
+ >;
43
+ }
44
+
45
+ function replayLimit(value: number | undefined): number {
46
+ if (value === undefined) return STATED_RESET_MAX_REPLAYS;
47
+ if (!Number.isInteger(value) || value < 0 || value > STATED_RESET_MAX_REPLAYS) {
48
+ throw new RangeError('maxReplays must be an integer from 0 to 2');
49
+ }
50
+ return value;
51
+ }
52
+
53
+ function waitLimit(value: number | undefined): number {
54
+ if (value === undefined) return statedResetMaxWaitMs();
55
+ if (!Number.isFinite(value) || value < 0) {
56
+ throw new RangeError('maxWaitMs must be a finite non-negative number');
57
+ }
58
+ return Math.min(Math.floor(value), STATED_RESET_WAIT_CEILING_MS);
59
+ }
60
+
61
+ export async function* streamChatEventsWithResetRetry(
62
+ req: CloudChatRequest,
63
+ options?: StatedResetRetryOptions,
64
+ ): AsyncGenerator<CloudChatEvent> {
65
+ const stream = options?.stream ?? streamChatEvents;
66
+ const sleep = options?.sleep ?? sleepWithAbort;
67
+ const maxReplays = replayLimit(options?.maxReplays);
68
+ const maxWaitMs = waitLimit(options?.maxWaitMs);
69
+ const execution = options?.execution;
70
+ // One sender owns the whole invocation so ordinals span the initial POST and both replays.
71
+ // Its executor remains lazy: replay admission happens after the provider-stated wait, never
72
+ // while a reservation could be held for up to an hour.
73
+ const send = execution
74
+ ? createAdapterPhysicalSend({ ...execution, abortSignal: req.signal })
75
+ : undefined;
76
+ let replays = 0;
77
+ let waitedMs = 0;
78
+ let replaySourceError: CloudChatError | undefined;
79
+ while (true) {
80
+ // Check again after sleeping: cancellation can race with timer completion.
81
+ // A pre-aborted request must not even enter a custom transport.
82
+ if (req.signal?.aborted) throw abortError(req.signal);
83
+ let yielded = false;
84
+ try {
85
+ const recovery = replays > 0 ? "rate-limit-429" as const : undefined;
86
+ const attemptRequest = send
87
+ ? {
88
+ ...req,
89
+ executor: ((input, init) => send({
90
+ url: typeof input === "string" ? input : input instanceof URL ? input.href : input.url,
91
+ ...(recovery ? { sendClass: "auth-recovery" as const, recovery } : {}),
92
+ dispatch: executor => executor(input, init),
93
+ })) as typeof globalThis.fetch,
94
+ }
95
+ : req;
96
+ for await (const event of stream(attemptRequest)) {
97
+ // Latch before yielding, so a consumer-injected error is post-output.
98
+ yielded = true;
99
+ yield event;
100
+ }
101
+ return;
102
+ } catch (error) {
103
+ if (req.signal?.aborted) throw abortError(req.signal);
104
+ if (!yielded && error instanceof SendBudgetExhaustedError && replaySourceError) {
105
+ // The provider's refusal is the real upstream answer. A local cap can withhold its
106
+ // recovery, but replacing the 429 would erase the status and stated reset metadata.
107
+ execution?.onRecoveryWithheld?.({ reason: "retry-send-budget" });
108
+ throw replaySourceError;
109
+ }
110
+ const retryableError = !yielded
111
+ && error instanceof CloudChatError
112
+ && error.status === 429
113
+ ? error
114
+ : undefined;
115
+ const waitSec = retryableError
116
+ ? parseRetryAfterFromMessage(retryableError.message)
117
+ : undefined;
118
+ const waitMs = waitSec === undefined ? undefined : waitSec * 1000;
119
+ if (
120
+ retryableError === undefined
121
+ || waitMs === undefined
122
+ || replays >= maxReplays
123
+ || waitMs > maxWaitMs - waitedMs
124
+ ) {
125
+ // Never shorten a provider's minimum delay to fit the local budget.
126
+ // Keep the original refusal so outer policy can preserve its metadata.
127
+ throw error;
128
+ }
129
+ replays += 1;
130
+ replaySourceError = retryableError;
131
+ // Charge the complete scheduled wait once, before sleeping. This is a
132
+ // sleep allowance, not a wall-clock deadline on generation or timer
133
+ // scheduling: waking a few milliseconds late must not reject an already
134
+ // approved one-hour retry. No later wait can spend this allowance again.
135
+ waitedMs += waitMs;
136
+ await sleep(waitMs, req.signal);
137
+ if (req.signal?.aborted) throw abortError(req.signal);
138
+ }
139
+ }
140
+ }
@@ -9,12 +9,14 @@
9
9
  import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTool, OcxToolCall, OcxToolResultMessage, OcxUsage } from "../types";
10
10
  import { namespacedToolName } from "../types";
11
11
  import type { IncomingMeta, ProviderAdapter } from "./base";
12
- import { streamChatEvents, allocateCascadeId, CloudChatError, type ChatHistoryItem, type ToolDef } from "./devin/cloud-direct";
12
+ import { streamChatEventsWithResetRetry, allocateCascadeId, CloudChatError, type ChatHistoryItem, type ToolDef } from "./devin/cloud-direct";
13
13
  import type { ContentPart } from "./devin/cloud-direct/chat";
14
- import { getCachedCatalog } from "./devin/cloud-direct/catalog";
14
+ import { getCachedCatalog, type CacheEntry } from "./devin/cloud-direct/catalog";
15
15
  import { collapseDevinModelUid } from "./devin/live-models";
16
16
  import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge";
17
17
  import { DEVIN_DEFAULT_API_SERVER, resolveDevinApiServer } from "../oauth/devin";
18
+ import { isProviderIssuedThinkingSignature } from "../responses/reasoning-envelope";
19
+ import { SendBudgetExhaustedError } from "../lib/upstream-retry";
18
20
 
19
21
  /**
20
22
  * Combine two usage frames from one turn by keeping the larger count per field.
@@ -155,6 +157,7 @@ async function resolveWireModelUid(
155
157
  apiKey: string,
156
158
  host: string,
157
159
  reasoningEffort?: string,
160
+ catalog?: CacheEntry | null,
158
161
  ): Promise<string> {
159
162
  const modelId = normalizeDevinModelId(rawModelId);
160
163
  // Explicit effort wins over a suffix the picker already baked into the id, so
@@ -163,15 +166,18 @@ async function resolveWireModelUid(
163
166
  const swe2 = resolveSwe2Variant(modelId, reasoningEffort);
164
167
  if (swe2) return swe2;
165
168
  if (hasEffortSuffix(modelId)) return modelId;
166
- const catalog = await getCachedCatalog(apiKey, host);
167
- if (catalog) {
168
- if (catalog.byUid.has(modelId)) return modelId;
169
+ // Callers that already read the catalog this turn pass it in; an explicit
170
+ // null records a failed lookup and must not trigger a same-turn retry —
171
+ // failures are not cached, so re-reading would only pay another timeout.
172
+ const entry = catalog !== undefined ? catalog : await getCachedCatalog(apiKey, host);
173
+ if (entry) {
174
+ if (entry.byUid.has(modelId)) return modelId;
169
175
  const effort = reasoningEffort && CALLER_EFFORT_VALUES.has(reasoningEffort) ? reasoningEffort : "medium";
170
176
  const suffixed = `${modelId}-${effort}`;
171
- if (catalog.byUid.has(suffixed)) return suffixed;
177
+ if (entry.byUid.has(suffixed)) return suffixed;
172
178
  // Fall back to any enabled variant of this base model.
173
- for (const uid of catalog.byUid.keys()) {
174
- if (uid.startsWith(modelId + "-") && !catalog.byUid.get(uid)?.disabled) return uid;
179
+ for (const uid of entry.byUid.keys()) {
180
+ if (uid.startsWith(modelId + "-") && !entry.byUid.get(uid)?.disabled) return uid;
175
181
  }
176
182
  }
177
183
  // Degraded mode: append the default effort suffix.
@@ -186,6 +192,46 @@ async function resolveWireModelUid(
186
192
  */
187
193
  export const resolveWireModelUidForTests = resolveWireModelUid;
188
194
 
195
+ /**
196
+ * Resolve the INPUT ceiling for the exact UID selected for this turn. Catalog
197
+ * ClientModelConfig #18 and CompletionConfiguration #3 both carry input tokens;
198
+ * the independent output cap is not subtracted here. Smaller operator hints
199
+ * cap live evidence, never enlarge it. No evidence leaves the encoder's 128k
200
+ * fallback intact; an unrelated or opt-in long-context variant is not evidence.
201
+ */
202
+ function resolveDevinMaxInputTokens(
203
+ provider: OcxProviderConfig,
204
+ modelUid: string,
205
+ liveWindow?: number,
206
+ ): number | undefined {
207
+ const positive = (value: unknown): number | undefined =>
208
+ typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
209
+ const baseId = collapseDevinModelUid(modelUid);
210
+ const configured = (record: Record<string, number> | undefined): number | undefined => {
211
+ if (!record) return undefined;
212
+ for (const id of [modelUid, baseId]) {
213
+ // Prefer the canonical spelling; retain dotted/case-folded saved hints,
214
+ // matching the model-id normalization used for the inference request.
215
+ const exact = Object.hasOwn(record, id) ? positive(record[id]) : undefined;
216
+ if (exact !== undefined) return exact;
217
+ const matches = Object.entries(record)
218
+ .filter(([key]) => normalizeDevinModelId(key).toLowerCase() === id.toLowerCase())
219
+ .map(([, value]) => positive(value))
220
+ .filter((value): value is number => value !== undefined);
221
+ if (matches.length > 0) return Math.min(...matches);
222
+ }
223
+ return undefined;
224
+ };
225
+ const contextHint = configured(provider.modelContextWindows) ?? positive(provider.contextWindow);
226
+ const inputHint = configured(provider.modelMaxInputTokens);
227
+ const ceilings = [positive(liveWindow), contextHint, inputHint]
228
+ .filter((value): value is number => value !== undefined);
229
+ return ceilings.length > 0 ? Math.min(...ceilings) : undefined;
230
+ }
231
+
232
+ /** Pure test seam; runtime uses the same resolver immediately before dispatch. */
233
+ export const resolveDevinMaxInputTokensForTests = resolveDevinMaxInputTokens;
234
+
189
235
  export class DevinMissingCredentialError extends Error {
190
236
  constructor() {
191
237
  super("Devin live transport requires a Devin API key. Run ocx login devin to sign in with your Cognition/Devin account.");
@@ -209,6 +255,17 @@ function textFromParts(content: string | OcxContentPart[] | undefined): string {
209
255
  return content.map((part) => (part.type === "text" ? part.text : "")).filter(Boolean).join("\n");
210
256
  }
211
257
 
258
+ const MAX_DEVIN_REMOTE_IMAGE_URL_CHARS = 8_192;
259
+
260
+ function boundedDevinRemoteImageReference(imageUrl: string): string | undefined {
261
+ if (imageUrl.length > MAX_DEVIN_REMOTE_IMAGE_URL_CHARS) return undefined;
262
+ try {
263
+ return new URL(imageUrl).protocol === "https:" ? imageUrl : undefined;
264
+ } catch {
265
+ return undefined;
266
+ }
267
+ }
268
+
212
269
  /**
213
270
  * Convert inbound content parts to the multimodal shape the wire encoder accepts.
214
271
  *
@@ -217,9 +274,10 @@ function textFromParts(content: string | OcxContentPart[] | undefined): string {
217
274
  * text-only string and a message whose only content was an image was dropped
218
275
  * entirely, which is why a pasted screenshot killed the turn and the only
219
276
  * workaround was running OCR before sending. A data: URL carries everything
220
- * field #10 needs; a remote https URL cannot be inlined without a fetch, so it
221
- * stays as an explicit text reference rather than pretending the model can see
222
- * a picture it cannot. Video has no Devin field and is skipped.
277
+ * field #10 needs; a bounded remote https URL cannot be inlined without a fetch,
278
+ * so it stays as an explicit text reference rather than pretending the model can
279
+ * see a picture it cannot. Unsupported and oversized references become a fixed
280
+ * omission marker, never attacker-sized prompt text. Video has no Devin field.
223
281
  */
224
282
  function mapOcxContentToWire(content: string | OcxContentPart[] | undefined): string | ContentPart[] {
225
283
  if (typeof content === "string" || !Array.isArray(content)) return content ?? "";
@@ -230,7 +288,13 @@ function mapOcxContentToWire(content: string | OcxContentPart[] | undefined): st
230
288
  } else if (part.type === "image") {
231
289
  const m = part.imageUrl.match(/^data:([^;]+);base64,(.+)$/);
232
290
  if (m) out.push({ type: "image", mimeType: m[1]!, base64Data: m[2]! });
233
- else out.push({ type: "text", text: `[image url: ${part.imageUrl}]` });
291
+ else {
292
+ const remoteReference = boundedDevinRemoteImageReference(part.imageUrl);
293
+ out.push({
294
+ type: "text",
295
+ text: remoteReference ? `[image url: ${remoteReference}]` : "[image omitted: unsupported or oversized URL]",
296
+ });
297
+ }
234
298
  }
235
299
  }
236
300
  return out;
@@ -266,23 +330,26 @@ function assistantText(message: OcxAssistantMessage): string {
266
330
  * clients of the same service write #11 thinking with #12 signature and #18
267
331
  * signature_type on the assistant prompt.
268
332
  *
269
- * The signature attests the thinking it was produced with, so a block without
270
- * one contributes its text and nothing else rather than borrowing a neighbour's.
333
+ * Field #12 attests the exact text at #11, and the wire has room for one pair.
334
+ * Every block that carries text is replayed, so the chain stays intact; the
335
+ * signature rides along only when the text being replayed IS the text it
336
+ * attests, which is exactly the single-block case. Several independently signed
337
+ * blocks send an unsigned prompt rather than pairing one block's attestation
338
+ * with another block's words. A signature-only block attests encrypted thinking
339
+ * that is not being replayed at all, so it is not one of these blocks and
340
+ * cannot contribute the pair.
271
341
  */
272
342
  function assistantThinking(
273
343
  message: OcxAssistantMessage,
274
344
  ): { thinking?: string; signature?: string } {
275
345
  const blocks = message.content.filter(
276
346
  (part): part is Extract<typeof part, { type: "thinking" }> => part.type === "thinking",
277
- );
347
+ ).filter(part => Boolean(part.thinking));
278
348
  if (blocks.length === 0) return {};
279
- const thinking = blocks.map(b => b.thinking).filter(Boolean).join("\n");
280
- // Only one signature can ride the prompt, so take the last block that has
281
- // one: that is the block the turn actually ended on.
282
- const signature = blocks.filter(b => b.signature).at(-1)?.signature;
349
+ const signature = blocks.length === 1 ? blocks[0]!.signature : undefined;
283
350
  return {
284
- ...(thinking ? { thinking } : {}),
285
- ...(signature ? { signature } : {}),
351
+ thinking: blocks.map(part => part.thinking).join("\n"),
352
+ ...(isProviderIssuedThinkingSignature(signature) ? { signature } : {}),
286
353
  };
287
354
  }
288
355
 
@@ -446,6 +513,10 @@ export function createDevinAdapter(
446
513
 
447
514
  return {
448
515
  name: "devin",
516
+ // Every GetChatMessage send, including the first, is admitted through the shared budget and
517
+ // reported from the executor that dispatches it. The caller therefore leaves the first
518
+ // send's accounting here rather than logging it before admission can refuse it.
519
+ reportsPhysicalSends: true,
449
520
 
450
521
  buildRequest() {
451
522
  return {
@@ -493,7 +564,16 @@ export function createDevinAdapter(
493
564
  // entry: an EU or FedStart account that used provider.baseUrl would send
494
565
  // every RPC to the US server it is not provisioned on.
495
566
  const host = resolveDevinApiServer(provider.baseUrl, credentialProviderId);
496
- const modelUid = await resolveWireModelUid(rawModelId, apiKey, host, parsed.options.reasoning);
567
+ // One catalog read per turn serves model-UID resolution, the input
568
+ // ceiling, and the chat pre-flight inside streamChatEvents. Failures are
569
+ // not cached, so a second read would only pay another fetch timeout on
570
+ // an otherwise valid turn.
571
+ const catalog = await getCachedCatalog(apiKey, host, incoming.abortSignal);
572
+ if (incoming.abortSignal?.aborted) {
573
+ emit({ type: "error", message: DEVIN_CLIENT_CLOSED_MESSAGE, status: 499, retryable: false });
574
+ return;
575
+ }
576
+ const modelUid = await resolveWireModelUid(rawModelId, apiKey, host, parsed.options.reasoning, catalog);
497
577
  const returnedToolNames = buildDevinReturnedToolNameMap(parsed.context.tools);
498
578
  let openToolId: string | undefined;
499
579
  let usage: OcxUsage | undefined;
@@ -506,22 +586,38 @@ export function createDevinAdapter(
506
586
  };
507
587
 
508
588
  try {
509
- for await (const event of streamChatEvents({
589
+ // Read the selected UID's catalog row, not the picker's collapsed base.
590
+ const maxInputTokens = resolveDevinMaxInputTokens(
591
+ provider, modelUid, catalog?.byUid.get(modelUid)?.contextWindow,
592
+ );
593
+ // The reset-retry wrapper waits out a 429 that states its own recovery
594
+ // delay ("limit will reset in 35 seconds") and replays the identical
595
+ // request — but only while zero events have been yielded, so a
596
+ // post-output failure still takes the terminal path untouched.
597
+ for await (const event of streamChatEventsWithResetRetry({
510
598
  apiKey,
511
599
  apiServerUrl: host,
512
600
  modelUid,
601
+ catalog,
513
602
  messages: mapOcxMessagesToDevin(parsed),
514
603
  tools: mapOcxToolsToDevin(parsed.context.tools),
515
604
  cascadeId,
516
- // Without these the request falls back to the encoder's defaults
517
- // (8192 output, a 128k context window, temperature 0.7), so a client
518
- // that asked for a 4k cap never got one.
605
+ // Input and output ceilings are separate wire fields. Omitting the
606
+ // input hint used to force every model through the 128k default.
519
607
  completionOpts: {
608
+ ...(maxInputTokens !== undefined ? { maxInputTokens } : {}),
520
609
  ...(typeof parsed.options.maxOutputTokens === "number" ? { maxOutputTokens: parsed.options.maxOutputTokens } : {}),
521
610
  ...(typeof parsed.options.temperature === "number" ? { temperature: parsed.options.temperature } : {}),
522
611
  ...(typeof parsed.options.topP === "number" ? { topP: parsed.options.topP } : {}),
523
612
  },
524
613
  signal: incoming.abortSignal,
614
+ }, {
615
+ execution: {
616
+ executor: incoming.providerFetch,
617
+ sendBudget: incoming.sendBudget,
618
+ onPhysicalSend: incoming.onPhysicalSend,
619
+ onRecoveryWithheld: incoming.onRecoveryWithheld,
620
+ },
525
621
  })) {
526
622
  if (incoming.abortSignal?.aborted) {
527
623
  // Emitting nothing here left the bridge to synthesize adapter_eof.
@@ -600,6 +696,9 @@ export function createDevinAdapter(
600
696
  emit({ type: "error", message: DEVIN_CLIENT_CLOSED_MESSAGE, status: 499, retryable: false, ...(usage ? { usage } : {}) });
601
697
  return;
602
698
  }
699
+ // The Responses boundary already maps this local refusal to its structured 429 code.
700
+ // Converting it to an adapter event would make it an ordinary untyped upstream error.
701
+ if (error instanceof SendBudgetExhaustedError) throw error;
603
702
  const message = error instanceof CloudChatError
604
703
  ? ("Devin cloud error" + (error.code ? " " + error.code : "") + ": " + error.message)
605
704
  : error instanceof Error ? error.message : String(error);
@@ -629,7 +629,7 @@ export function antigravityUsesReplayCache(model: string): boolean {
629
629
  * neither the wire metadata nor the replay cache can supply a real one, this is the official
630
630
  * validator-bypass token.
631
631
  */
632
- const THOUGHT_SIGNATURE_BYPASS = "skip_thought_signature_validator";
632
+ export const THOUGHT_SIGNATURE_BYPASS = "skip_thought_signature_validator";
633
633
 
634
634
  /**
635
635
  * True when the model speaks the Gemini wire dialect that requires a thought signature on the
@@ -77,7 +77,7 @@ export function antigravitySessionId(parsed: OcxParsedRequest): string {
77
77
  //
78
78
  // Clients that send no thread header keep the text anchor and its instability; this is a scoped
79
79
  // repair, not a universal one.
80
- const text = clientThreadAnchor(parsed) ?? firstUserText(parsed);
80
+ const text = codexThreadAnchor(parsed).preimage ?? firstUserText(parsed);
81
81
  if (!text) return `-${Math.floor(Math.random() * 9e18).toString()}`;
82
82
  const digest = createHash("sha256").update(text, "utf8").digest();
83
83
  const masked = digest.readBigUInt64BE(0) & 0x7fffffffffffffffn;
@@ -96,9 +96,60 @@ export function antigravitySessionId(parsed: OcxParsedRequest): string {
96
96
  * functionCall identity (name+args), so a shared id does not misattribute them. Instability, not
97
97
  * collision, is the failure mode this function exists to prevent.
98
98
  */
99
- function clientThreadAnchor(parsed: OcxParsedRequest): string | undefined {
100
- const threadId = parsed._clientThreadId?.trim();
101
- return threadId ? `codex-thread:${threadId}` : undefined;
99
+ function codexThreadAnchor(parsed: OcxParsedRequest): {
100
+ kind: "parent-and-own" | "parent-only" | "absent";
101
+ preimage?: string;
102
+ } {
103
+ // `_clientThreadId` carries `x-codex-parent-thread-id`, which every parallel child of one
104
+ // parent presents identically, so anchoring on it alone collapsed concurrent children onto a
105
+ // single upstream Cloud Code Assist session (#5033).
106
+ //
107
+ // A thread id is only unique WITHIN its parent, which is why `codexConversationIdentity` keys
108
+ // on both. #5054 anchored on the child alone and therefore only moved the collision: two
109
+ // parents can each have a child of the same id (#5058). The pair is the identity, joined by a
110
+ // NUL so the encoding is injective: no Codex id contains one, so a pair cannot be re-read as a
111
+ // different pair, nor as the parent-only anchor below.
112
+ //
113
+ // Deliberately NOT the general lane key: `codexConversationKeyFor` is an HMAC under a
114
+ // process-random secret, so it changes across a proxy restart — and instability, not sharing,
115
+ // is the failure mode this derivation has to avoid. These ids are Codex's own values and
116
+ // survive both compaction and restart.
117
+ const own = parsed._codexOwnThreadId?.trim();
118
+ const parent = parsed._clientThreadId?.trim();
119
+ if (own && parent) return { kind: "parent-and-own", preimage: `codex-thread:${parent}\u0000${own}` };
120
+ // Parent-only clients keep the anchor they already had.
121
+ if (parent) return { kind: "parent-only", preimage: `codex-thread:${parent}` };
122
+ // A parentless ROOT deliberately omits the parent header. `src/server/context-history.ts` says
123
+ // so in as many words: root model requests use (session-id=root, thread-id=root) and do not
124
+ // fabricate a parent key. It has no pair to key on, and #5054's claim that a root presents
125
+ // `thread-id` equal to its parent was simply wrong.
126
+ //
127
+ // So it keeps the pre-#5054 anchor rather than gaining an own-thread one. That is not a
128
+ // preference: durable Antigravity replay state is keyed by model plus session id, and moving a
129
+ // root's anchor on upgrade strands every signature stored under the old session — the exact
130
+ // instability this derivation exists to avoid, introduced while fixing sharing.
131
+ return { kind: "absent" };
132
+ }
133
+
134
+ /**
135
+ * Which class of anchor {@link antigravitySessionId} derives this request's session id from.
136
+ *
137
+ * The id itself identifies a live conversation and is a Google-visible wire value, so it is not
138
+ * something a user can paste into a public bug report. The class is: it distinguishes the four
139
+ * stability regimes above — a Codex parent/child pair, a parent-only client, the content-derived
140
+ * text anchor that compaction can move, and the random fallback that changes every turn — while
141
+ * carrying no identity at all.
142
+ *
143
+ * It reads the SAME decision the id derivation reads, so the two cannot disagree about which
144
+ * regime a request is in. What it does not do is change how the id itself is derived: nothing
145
+ * about a Google-visible wire value moves because a diagnostic wants to describe it.
146
+ */
147
+ export type AntigravitySessionAnchor = "parent-and-own" | "parent-only" | "text" | "none";
148
+
149
+ export function antigravitySessionAnchor(parsed: OcxParsedRequest): AntigravitySessionAnchor {
150
+ const anchor = codexThreadAnchor(parsed);
151
+ if (anchor.kind !== "absent") return anchor.kind;
152
+ return firstUserText(parsed) ? "text" : "none";
102
153
  }
103
154
 
104
155
  /** A Gemini content part as it appears in an Antigravity request body. */
@@ -2,8 +2,10 @@ import type { AdapterFetchContext, AdapterRequest } from "./base";
2
2
  import { createAdapterPhysicalSend } from "./physical-send";
3
3
  import type { SendClass } from "../lib/request-execution-budget";
4
4
  import type { AttemptRecoveryKind } from "../usage/log";
5
+ import { debugProviderDiagnosticLazy } from "../lib/debug";
5
6
  import { isQuotaExhaustedBody, retryableGoogleStatus, safeGoogleHttpErrorMessage } from "./google-errors";
6
- import { repairGoogleInvalidRequestBody } from "./google-wire-compiler";
7
+ import { repairGoogleInvalidRequestBodyWithReport } from "./google-wire-compiler";
8
+ import type { GoogleToolSchemaPolicy, GoogleToolSchemaProfile } from "./google-tool-schema";
7
9
  import { normalizeUpstreamHttpErrorResponse, readDisplaySafeErrorPayloadText } from "./upstream-http-error";
8
10
  import {
9
11
  abortError,
@@ -22,6 +24,8 @@ const GOOGLE_RETRY_MAX_MS = 2_000;
22
24
  export interface GoogleRetryOptions {
23
25
  /** Repair-and-replay structurally invalid 400 bodies (Vertex/Antigravity behavior). */
24
26
  repairInvalid400?: boolean;
27
+ toolSchemaProfile?: GoogleToolSchemaProfile;
28
+ toolSchemaPolicy?: GoogleToolSchemaPolicy;
25
29
  }
26
30
 
27
31
  async function normalizeFinalGoogleError(label: string, res: Response, signal?: AbortSignal): Promise<Response> {
@@ -45,6 +49,8 @@ export async function fetchGoogleWithRetry(
45
49
  opts: GoogleRetryOptions = {},
46
50
  ): Promise<Response> {
47
51
  const repairInvalid400 = opts.repairInvalid400 ?? true;
52
+ const toolSchemaProfile = opts.toolSchemaProfile ?? { endpointClass: "vertex" };
53
+ const toolSchemaPolicy = opts.toolSchemaPolicy ?? "compatible";
48
54
  const timeoutMs = ctx.timeoutMs ?? 200_000;
49
55
  const send = createAdapterPhysicalSend(ctx);
50
56
  let lastError: unknown;
@@ -77,10 +83,22 @@ export async function fetchGoogleWithRetry(
77
83
  } catch (error) {
78
84
  if (ctx.abortSignal?.aborted) throw error;
79
85
  }
80
- const repairedBody = repairGoogleInvalidRequestBody(activeRequest.body, payloadText);
81
- if (repairedBody !== undefined) {
86
+ const repair = repairGoogleInvalidRequestBodyWithReport(activeRequest.body, payloadText, toolSchemaProfile);
87
+ if (repair !== undefined) {
88
+ if (repair.toolSchemaLoss) {
89
+ const changedSendAllowed = toolSchemaPolicy !== "reject-lossy";
90
+ debugProviderDiagnosticLazy("google", "google-tool-schema-repair", () => ({
91
+ ...repair.toolSchemaLoss,
92
+ phase: "repair",
93
+ declarationCount: repair.toolSchemaDeclarationCount ?? 0,
94
+ changedSendAllowed,
95
+ }));
96
+ if (!changedSendAllowed) {
97
+ return ctx.returnRawErrors ? res : normalizeFinalGoogleError(label, res, ctx.abortSignal);
98
+ }
99
+ }
82
100
  compatibilityReplayUsed = true;
83
- activeRequest = { ...activeRequest, body: repairedBody };
101
+ activeRequest = { ...activeRequest, body: repair.body };
84
102
  pendingResponse = res;
85
103
  sendClass = "repair";
86
104
  attempt--; // The changed-request replay is separate from transient retry accounting.
@@ -112,7 +130,14 @@ export async function fetchGoogleWithRetry(
112
130
  } catch (err) {
113
131
  if (ctx.abortSignal?.aborted) throw err;
114
132
  if (err instanceof SendBudgetExhaustedError) {
115
- if (pendingResponse) return ctx.returnRawErrors ? pendingResponse : normalizeFinalGoogleError(label, pendingResponse, ctx.abortSignal);
133
+ if (pendingResponse) {
134
+ // The ladder had already classified this response as retryable and was about to send
135
+ // again; the budget refused. Returning the original response is right — it is a real
136
+ // upstream answer — but it used to leave the log indistinguishable from a request
137
+ // where no retry was ever eligible (#5044).
138
+ ctx.onRecoveryWithheld?.({ reason: "retry-send-budget" });
139
+ return ctx.returnRawErrors ? pendingResponse : normalizeFinalGoogleError(label, pendingResponse, ctx.abortSignal);
140
+ }
116
141
  throw err;
117
142
  }
118
143
  lastError = err;
@@ -138,16 +163,37 @@ export async function fetchGoogleWithRetry(
138
163
  * demand" spikes, plus plain rate-limit 429s, both of which previously failed immediately
139
164
  * because the default server fetch path only retries connection resets.
140
165
  */
141
- export function fetchDirectGeminiWithRetry(request: AdapterRequest, ctx: AdapterFetchContext = {}): Promise<Response> {
142
- return fetchGoogleWithRetry("Gemini", request, { ...ctx, returnRawErrors: true }, { repairInvalid400: false });
166
+ export function fetchDirectGeminiWithRetry(
167
+ request: AdapterRequest,
168
+ ctx: AdapterFetchContext = {},
169
+ schemaPolicy: Pick<GoogleRetryOptions, "toolSchemaProfile" | "toolSchemaPolicy"> = {},
170
+ ): Promise<Response> {
171
+ return fetchGoogleWithRetry("Gemini", request, { ...ctx, returnRawErrors: true }, {
172
+ ...schemaPolicy,
173
+ repairInvalid400: false,
174
+ });
143
175
  }
144
176
 
145
177
  /** Vertex AI retry wrapper. */
146
- export function fetchVertexWithRetry(request: AdapterRequest, ctx: AdapterFetchContext = {}): Promise<Response> {
147
- return fetchGoogleWithRetry("Vertex AI", request, ctx);
178
+ export function fetchVertexWithRetry(
179
+ request: AdapterRequest,
180
+ ctx: AdapterFetchContext = {},
181
+ schemaPolicy: Pick<GoogleRetryOptions, "toolSchemaProfile" | "toolSchemaPolicy"> = {},
182
+ ): Promise<Response> {
183
+ return fetchGoogleWithRetry("Vertex AI", request, ctx, {
184
+ toolSchemaProfile: { endpointClass: "vertex" },
185
+ ...schemaPolicy,
186
+ });
148
187
  }
149
188
 
150
189
  /** Antigravity (Cloud Code Assist) retry wrapper. */
151
- export function fetchAntigravityWithRetry(request: AdapterRequest, ctx: AdapterFetchContext = {}): Promise<Response> {
152
- return fetchGoogleWithRetry("Antigravity", request, ctx);
190
+ export function fetchAntigravityWithRetry(
191
+ request: AdapterRequest,
192
+ ctx: AdapterFetchContext = {},
193
+ schemaPolicy: Pick<GoogleRetryOptions, "toolSchemaProfile" | "toolSchemaPolicy"> = {},
194
+ ): Promise<Response> {
195
+ return fetchGoogleWithRetry("Antigravity", request, ctx, {
196
+ toolSchemaProfile: { endpointClass: "cloud-code-assist" },
197
+ ...schemaPolicy,
198
+ });
153
199
  }