@bitkyc08/opencodex 2.86.0 → 2.87.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 (378) hide show
  1. package/gui/dist/assets/App-ZfYbLHkB.js +58 -0
  2. package/gui/dist/assets/App-wT3mXqFz.css +1 -0
  3. package/gui/dist/assets/{Tray-DyR9yBee.js → Tray-DCd-A1az.js} +1 -1
  4. package/gui/dist/assets/index-CfYDMj34.js +94 -0
  5. package/gui/dist/assets/index-DPeB9HpV.css +1 -0
  6. package/gui/dist/assets/quota-summary-ChvmKGOk.js +1 -0
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +1 -1
  9. package/src/adapters/anthropic/beta-allowlist.ts +9 -8
  10. package/src/adapters/anthropic/beta-body-pairs.ts +93 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -23
  12. package/src/adapters/anthropic-progress.ts +84 -0
  13. package/src/adapters/anthropic.ts +108 -21
  14. package/src/adapters/base.ts +2 -0
  15. package/src/adapters/claude-cli/adapter.ts +111 -23
  16. package/src/adapters/claude-cli/profiles.ts +2 -1
  17. package/src/adapters/claude-cli/stable-replay.ts +108 -0
  18. package/src/adapters/claude-cli/usage-admission.ts +230 -0
  19. package/src/adapters/coding-agent/protocol.ts +1 -1
  20. package/src/adapters/coding-agent/turn.ts +18 -2
  21. package/src/adapters/dsh-account.ts +97 -0
  22. package/src/adapters/google.ts +35 -8
  23. package/src/adapters/openai-chat/messages.ts +4 -3
  24. package/src/adapters/openai-chat/passthrough.ts +8 -2
  25. package/src/adapters/openai-chat/reasoning-replay.ts +52 -0
  26. package/src/adapters/openai-responses/compaction-search-history.ts +14 -7
  27. package/src/adapters/openai-responses/passthrough.ts +57 -125
  28. package/src/adapters/openai-responses/request-strips.ts +25 -3
  29. package/src/adapters/openai-responses/transport.ts +81 -0
  30. package/src/adapters/qoder/adapter.ts +4 -2
  31. package/src/adapters/registry.ts +5 -0
  32. package/src/adapters/zed.ts +26 -1
  33. package/src/advisor/activity.ts +55 -0
  34. package/src/advisor/budget.ts +62 -0
  35. package/src/advisor/consult.ts +4 -1
  36. package/src/advisor/context.ts +16 -4
  37. package/src/advisor/evidence-tool.ts +21 -0
  38. package/src/advisor/evidence.ts +117 -0
  39. package/src/advisor/runtime.ts +79 -31
  40. package/src/advisor/settings.ts +11 -2
  41. package/src/bridge/errors.ts +19 -1
  42. package/src/bridge/internal.ts +3 -1
  43. package/src/bridge/response-json.ts +18 -3
  44. package/src/bridge/sse.ts +58 -8
  45. package/src/bridge/terminal-refusal.ts +59 -0
  46. package/src/claude/desktop-3p-library.ts +32 -7
  47. package/src/claude/desktop-3p.ts +55 -13
  48. package/src/claude/desktop-first-party.ts +1 -1
  49. package/src/claude/desktop-picker-platform.ts +4 -0
  50. package/src/claude/desktop-picker.ts +15 -8
  51. package/src/claude/desktop-system-proxy.ts +50 -4
  52. package/src/claude/inbound-content-options.ts +11 -0
  53. package/src/claude/inbound.ts +33 -0
  54. package/src/claude/intercept/picker-ca-startup.ts +2 -1
  55. package/src/claude/intercept/picker-runtime.ts +6 -5
  56. package/src/claude/intercept/picker-trust-windows.ts +338 -0
  57. package/src/claude/intercept/picker-trust.ts +19 -0
  58. package/src/claude/intercept/port-policy.ts +30 -0
  59. package/src/claude/intercept/runtime.ts +97 -94
  60. package/src/claude/outbound.ts +38 -13
  61. package/src/cli/account-api.ts +1 -0
  62. package/src/cli/account-policy-dto.ts +6 -0
  63. package/src/cli/account-policy.ts +34 -5
  64. package/src/cli/account-preservation.ts +44 -0
  65. package/src/cli/account.ts +8 -2
  66. package/src/cli/advisor.ts +17 -3
  67. package/src/cli/capabilities-accounts.ts +20 -1
  68. package/src/cli/capabilities-agents-routing.ts +5 -2
  69. package/src/cli/capabilities-base.ts +38 -10
  70. package/src/cli/capabilities-observe-system.ts +63 -3
  71. package/src/cli/capabilities-provider-models.ts +11 -0
  72. package/src/cli/codex-cli-update.ts +151 -41
  73. package/src/cli/codex-provider.ts +44 -0
  74. package/src/cli/combo-input.ts +10 -1
  75. package/src/cli/combo-result.ts +1 -1
  76. package/src/cli/combo.ts +2 -1
  77. package/src/cli/cross-home-owner.ts +17 -14
  78. package/src/cli/dispatch.ts +25 -4
  79. package/src/cli/doctor.ts +6 -1
  80. package/src/cli/ensure-desktop-cold-start.ts +60 -7
  81. package/src/cli/help.ts +3 -2
  82. package/src/cli/index.ts +1 -1
  83. package/src/cli/integration-plan-dto.ts +1 -0
  84. package/src/cli/log-history.ts +172 -0
  85. package/src/cli/models-runtime-subcommands.ts +1 -0
  86. package/src/cli/models-runtime.ts +72 -8
  87. package/src/cli/observe-stream.ts +2 -0
  88. package/src/cli/observe.ts +16 -4
  89. package/src/cli/provider.ts +27 -5
  90. package/src/cli/registry.ts +28 -8
  91. package/src/cli/runtime-api.ts +5 -3
  92. package/src/cli/system-command.ts +40 -8
  93. package/src/cli/uninstall-client-state.ts +32 -0
  94. package/src/cli/usage-compare.ts +309 -0
  95. package/src/cli/usage-model-search.ts +4 -4
  96. package/src/cli/usage-report.ts +2 -2
  97. package/src/cli/v2.ts +1 -1
  98. package/src/client/machine-listener.ts +1 -3
  99. package/src/client/runtime.ts +1 -0
  100. package/src/clients/client-path.ts +51 -0
  101. package/src/clients/config-export/contracts.ts +2 -1
  102. package/src/clients/config-export.ts +18 -44
  103. package/src/clients/gentle-shell-path.ts +87 -0
  104. package/src/codex/account-auto-switch.ts +2 -0
  105. package/src/codex/account-identity.ts +29 -0
  106. package/src/codex/account-label.ts +1 -1
  107. package/src/codex/account-lifecycle.ts +21 -5
  108. package/src/codex/account-preservation.ts +28 -0
  109. package/src/codex/account-use-remaining.ts +23 -0
  110. package/src/codex/app-server-processes.ts +97 -7
  111. package/src/codex/app-server-restart-service.ts +55 -4
  112. package/src/codex/auth-api/account-list.ts +4 -0
  113. package/src/codex/auth-api/account-pause-group.ts +6 -50
  114. package/src/codex/auth-api/account-preservation.ts +57 -0
  115. package/src/codex/auth-api/routes.ts +8 -0
  116. package/src/codex/auth-api/use-remaining-route.ts +36 -0
  117. package/src/codex/auth-api.ts +0 -1
  118. package/src/codex/auth-context.ts +2 -2
  119. package/src/codex/catalog/auto-review.ts +20 -3
  120. package/src/codex/catalog/build-entries.ts +9 -1
  121. package/src/codex/catalog/combo-member.ts +14 -5
  122. package/src/codex/catalog/configured-tiers.ts +1 -1
  123. package/src/codex/catalog/gather-capture.ts +2 -0
  124. package/src/codex/catalog/metadata.ts +11 -9
  125. package/src/codex/catalog/model-hints.ts +21 -16
  126. package/src/codex/catalog/model-visibility.ts +3 -1
  127. package/src/codex/catalog/parsing.ts +30 -86
  128. package/src/codex/catalog/provider-models.ts +36 -34
  129. package/src/codex/catalog/routed-gather.ts +12 -13
  130. package/src/codex/cli-install-provenance.ts +105 -17
  131. package/src/codex/cli-update-plan.ts +545 -0
  132. package/src/codex/config-write-lock.ts +23 -2
  133. package/src/codex/desktop-app/picker-freshness.ts +96 -0
  134. package/src/codex/desktop-app-restart.ts +21 -0
  135. package/src/codex/desktop-compatibility/installed-build.ts +24 -6
  136. package/src/codex/desktop-compatibility/routing-preflight.ts +1 -1
  137. package/src/codex/desktop-compatibility/runtime.ts +12 -3
  138. package/src/codex/desktop-compatibility/windows-key-protection.ts +115 -9
  139. package/src/codex/features.ts +85 -45
  140. package/src/codex/inject/history-failure.ts +27 -0
  141. package/src/codex/inject/restore.ts +24 -2
  142. package/src/codex/inject/routing-classify.ts +4 -3
  143. package/src/codex/inject.ts +18 -27
  144. package/src/codex/main-account-hard-lock.ts +30 -1
  145. package/src/codex/native-profile-startup.ts +1 -1
  146. package/src/codex/ocx-compaction-history.ts +12 -10
  147. package/src/codex/paths.ts +86 -1
  148. package/src/codex/provider-routing.ts +174 -0
  149. package/src/codex/refresh.ts +2 -2
  150. package/src/codex/routing/health-store.ts +1 -1
  151. package/src/codex/routing/selection.ts +4 -3
  152. package/src/codex/routing/thread-affinity.ts +1 -1
  153. package/src/codex/signed-in-identity.ts +43 -0
  154. package/src/codex/subagent-model-fallback.ts +1 -1
  155. package/src/codex/sync.ts +6 -1
  156. package/src/codex/user-identity.ts +16 -5
  157. package/src/codex/windows-local-appdata.ts +84 -0
  158. package/src/combos/failover.ts +6 -1
  159. package/src/combos/index.ts +1 -0
  160. package/src/combos/resolve.ts +46 -10
  161. package/src/combos/types.ts +15 -0
  162. package/src/config/diagnostics.ts +33 -6
  163. package/src/config/live-reconcile.ts +38 -2
  164. package/src/config/load-degrade.ts +17 -0
  165. package/src/config/opencode-wire-upgrade.ts +54 -0
  166. package/src/config/opencode-zen-upgrade.ts +52 -0
  167. package/src/config/owner-registry.ts +22 -17
  168. package/src/config/provider-validation.ts +45 -2
  169. package/src/config/schema/config-schema.ts +22 -0
  170. package/src/config/schema/leaf-validators.ts +38 -9
  171. package/src/config/schema/main-account-hard-lock-fallback.ts +26 -0
  172. package/src/config/schema/native-reasoning-retention.ts +26 -0
  173. package/src/config.ts +2 -0
  174. package/src/generated/compatibility-version.json +608 -284
  175. package/src/grok/sync.ts +1 -1
  176. package/src/images/loop.ts +46 -8
  177. package/src/integrations/catalog-refresh.ts +1 -1
  178. package/src/integrations/mutation-plan.ts +1 -0
  179. package/src/integrations/registry.ts +8 -0
  180. package/src/lib/codex-restart-contract.ts +15 -0
  181. package/src/lib/desktop-upstream-tunnel.ts +18 -53
  182. package/src/lib/empty-tool-catalog.ts +62 -0
  183. package/src/lib/errors.ts +19 -3
  184. package/src/lib/gcp-adc.ts +96 -9
  185. package/src/lib/gcp-credential-input.ts +12 -0
  186. package/src/lib/lab-live-route-production.ts +5 -0
  187. package/src/lib/opaque-proxy-tunnel.ts +217 -0
  188. package/src/lib/redact.ts +2 -2
  189. package/src/oauth/account-quota-rank.ts +69 -2
  190. package/src/oauth/anthropic-account-refusal.ts +20 -2
  191. package/src/oauth/anthropic-oauth-definitions.ts +3 -1
  192. package/src/oauth/anthropic-routing.ts +1 -21
  193. package/src/oauth/anthropic.ts +38 -7
  194. package/src/oauth/callback-server.ts +27 -1
  195. package/src/oauth/chatgpt-device.ts +1 -1
  196. package/src/oauth/chatgpt-shared.ts +83 -0
  197. package/src/oauth/chatgpt.ts +8 -72
  198. package/src/oauth/dsh.ts +203 -0
  199. package/src/oauth/generic-account-failover.ts +33 -7
  200. package/src/oauth/generic-session-affinity.ts +125 -0
  201. package/src/oauth/index.ts +184 -109
  202. package/src/oauth/local-token-detect.ts +21 -1
  203. package/src/oauth/login-cli-errors.ts +44 -0
  204. package/src/oauth/login-cli.ts +69 -7
  205. package/src/oauth/login-flow-state.ts +371 -23
  206. package/src/oauth/meta-muse-errors.ts +41 -0
  207. package/src/oauth/meta-muse-keychain.ts +52 -0
  208. package/src/oauth/meta-muse.ts +51 -84
  209. package/src/oauth/model-discovery-auth.ts +3 -0
  210. package/src/oauth/pool-settings-capability.ts +4 -2
  211. package/src/oauth/refresh-flight-retirement.ts +48 -0
  212. package/src/oauth/session-affinity-key.ts +23 -0
  213. package/src/oauth/store-anthropic-instance.ts +9 -2
  214. package/src/oauth/store.ts +12 -5
  215. package/src/protocols/encoders/adapter-events.ts +5 -4
  216. package/src/protocols/encoders/messages.ts +9 -6
  217. package/src/protocols/provider-summary.ts +2 -1
  218. package/src/providers/api-key-cooldowns.ts +49 -0
  219. package/src/providers/api-key-resolve.ts +11 -6
  220. package/src/providers/api-key-response-ownership.ts +57 -0
  221. package/src/providers/api-key-selection.ts +31 -6
  222. package/src/providers/api-keys.ts +214 -3
  223. package/src/providers/claude-cli-usage.ts +96 -0
  224. package/src/providers/dsh-account-destination.ts +31 -0
  225. package/src/providers/dsh-account-models.ts +41 -0
  226. package/src/providers/gcp-credential-cleanup.ts +59 -0
  227. package/src/providers/key-failover.ts +54 -50
  228. package/src/providers/key-store.ts +7 -2
  229. package/src/providers/model-rename-fields.ts +3 -0
  230. package/src/providers/model-rename-migration.ts +2 -2
  231. package/src/providers/openai-tiers-destination.ts +11 -2
  232. package/src/providers/openai-tiers.ts +5 -12
  233. package/src/providers/opencode-go-transport.ts +3 -1
  234. package/src/providers/quota/account-cache.ts +6 -5
  235. package/src/providers/quota/account-row.ts +34 -0
  236. package/src/providers/quota/antigravity.ts +28 -7
  237. package/src/providers/quota/dsh-account.ts +70 -0
  238. package/src/providers/quota/vendor-probes-oauth.ts +1 -93
  239. package/src/providers/quota-types.ts +2 -0
  240. package/src/providers/quota.ts +14 -20
  241. package/src/providers/registry/entries-core.ts +37 -8
  242. package/src/providers/registry/types.ts +1 -1
  243. package/src/providers/request-pacing.ts +77 -19
  244. package/src/providers/routed-config.ts +339 -0
  245. package/src/providers/service-tier.ts +2 -1
  246. package/src/quota/window-mapping.ts +5 -2
  247. package/src/responses/anthropic-progress.ts +34 -0
  248. package/src/responses/assistant-replay.ts +35 -0
  249. package/src/responses/compaction-history-framing.ts +65 -0
  250. package/src/responses/compaction.ts +6 -1
  251. package/src/responses/incomplete-reason.ts +31 -0
  252. package/src/responses/parser.ts +50 -5
  253. package/src/responses/reasoning-envelope.ts +4 -0
  254. package/src/responses/reasoning-replay-cache.ts +24 -2
  255. package/src/responses/reasoning-retention.ts +173 -0
  256. package/src/responses/retained-compaction.ts +46 -0
  257. package/src/responses/schema.ts +23 -0
  258. package/src/router.ts +8 -321
  259. package/src/routing/health.ts +37 -22
  260. package/src/routing/history/indexer.ts +6 -5
  261. package/src/routing/history/ingest.ts +6 -2
  262. package/src/routing/profile.ts +1 -1
  263. package/src/server/admission-model-scope.ts +17 -0
  264. package/src/server/auth-cors.ts +11 -0
  265. package/src/server/chat-completions.ts +7 -1
  266. package/src/server/chat-native-reasoning.ts +123 -0
  267. package/src/server/chat-native-sse.ts +110 -7
  268. package/src/server/chat-native.ts +76 -17
  269. package/src/server/claude-messages.ts +55 -414
  270. package/src/server/effort-policy.ts +1 -1
  271. package/src/server/images.ts +15 -2
  272. package/src/server/index/model-alias-id.ts +36 -0
  273. package/src/server/index/optional-listeners.ts +2 -0
  274. package/src/server/index/serve-options.ts +9 -1
  275. package/src/server/index/startup-warnings.ts +2 -0
  276. package/src/server/index.ts +1 -1
  277. package/src/server/inference/advisor-diagnostics.ts +31 -0
  278. package/src/server/management/advisor-routes.ts +15 -4
  279. package/src/server/management/claude-desktop-picker-routes.ts +1 -1
  280. package/src/server/management/combo-routes.ts +176 -50
  281. package/src/server/management/companion-routes.ts +1 -1
  282. package/src/server/management/config-routes.ts +75 -4
  283. package/src/server/management/cursor-integration-routes.ts +1 -1
  284. package/src/server/management/model-routes.ts +11 -6
  285. package/src/server/management/model-rows.ts +14 -0
  286. package/src/server/management/native-integration-routes.ts +2 -7
  287. package/src/server/management/native-reasoning-retention-routes.ts +41 -0
  288. package/src/server/management/oauth-account-routes.ts +87 -14
  289. package/src/server/management/provider-gcp-credential-routes.ts +29 -0
  290. package/src/server/management/provider-key-pacing-routes.ts +31 -0
  291. package/src/server/management/provider-overwrite-carry.ts +7 -0
  292. package/src/server/management/provider-routes.ts +25 -30
  293. package/src/server/management/route-registry.ts +10 -1
  294. package/src/server/management-api.ts +3 -1
  295. package/src/server/messages-native-tool-guard.ts +185 -0
  296. package/src/server/messages-native.ts +101 -21
  297. package/src/server/messages-shared.ts +431 -0
  298. package/src/server/relay.ts +1 -1
  299. package/src/server/request-log-terminal-status.ts +1 -1
  300. package/src/server/request-log.ts +6 -1
  301. package/src/server/responses/account-change-state.ts +8 -2
  302. package/src/server/responses/adapter-continuation.ts +13 -1
  303. package/src/server/responses/adapter-delivery.ts +19 -6
  304. package/src/server/responses/adapter-dispatch.ts +19 -2
  305. package/src/server/responses/advisor-retention.ts +117 -0
  306. package/src/server/responses/advisor-slot.ts +73 -41
  307. package/src/server/responses/codex-ws-wire.ts +15 -8
  308. package/src/server/responses/compact.ts +303 -125
  309. package/src/server/responses/compaction-recovery.ts +18 -10
  310. package/src/server/responses/compaction-retention.ts +61 -0
  311. package/src/server/responses/context-overflow.ts +11 -6
  312. package/src/server/responses/core-auth.ts +6 -0
  313. package/src/server/responses/core-codex-account.ts +11 -1
  314. package/src/server/responses/core-combo-failure.ts +2 -0
  315. package/src/server/responses/core-combo.ts +120 -36
  316. package/src/server/responses/core-errors.ts +3 -3
  317. package/src/server/responses/core-lifetime.ts +5 -2
  318. package/src/server/responses/core-opaque-recovery.ts +35 -0
  319. package/src/server/responses/core-options.ts +5 -0
  320. package/src/server/responses/core-pipeline.ts +141 -0
  321. package/src/server/responses/core-replay.ts +18 -2
  322. package/src/server/responses/core.ts +2 -115
  323. package/src/server/responses/fetch-helpers.ts +73 -19
  324. package/src/server/responses/main-hard-lock-routing.ts +109 -0
  325. package/src/server/responses/native-response-control.ts +4 -2
  326. package/src/server/responses/passthrough-delivery.ts +3 -1
  327. package/src/server/responses/passthrough-dispatch.ts +15 -13
  328. package/src/server/responses/request-prepare.ts +76 -12
  329. package/src/server/responses/request-sidecar-auth.ts +2 -2
  330. package/src/server/responses/request-transport.ts +104 -27
  331. package/src/server/responses/response-effects.ts +1 -0
  332. package/src/server/responses/run-turn-execution.ts +96 -30
  333. package/src/server/responses/sidecar-execution.ts +20 -2
  334. package/src/server/responses/terminal-guard.ts +2 -16
  335. package/src/service/auth-preflight.ts +71 -0
  336. package/src/service/cli.ts +4 -2
  337. package/src/service/definition.ts +97 -0
  338. package/src/service/guards.ts +1 -61
  339. package/src/service/health-sentinel-linux.ts +112 -0
  340. package/src/service/health-sentinel-main.ts +76 -57
  341. package/src/service/health.ts +5 -89
  342. package/src/service/launchd.ts +27 -7
  343. package/src/service/logging.ts +72 -9
  344. package/src/service/repair.ts +2 -1
  345. package/src/service/state.ts +7 -8
  346. package/src/service/systemd.ts +1 -1
  347. package/src/service/windows-guarded-manager.ts +2 -1
  348. package/src/service/windows-ops.ts +1 -1
  349. package/src/service/windows-scheduler.ts +2 -7
  350. package/src/service/windows-taskxml.ts +2 -2
  351. package/src/service/windows-wscript.ts +14 -0
  352. package/src/service.ts +4 -2
  353. package/src/sidecar/auth.ts +2 -0
  354. package/src/types/config.ts +64 -3
  355. package/src/types/opencode-go.ts +61 -0
  356. package/src/types/opencode-zen.ts +29 -0
  357. package/src/types/provider.ts +32 -6
  358. package/src/types/request.ts +23 -2
  359. package/src/types/wire.ts +29 -37
  360. package/src/types.ts +1 -0
  361. package/src/update/npm-invocation.mjs +12 -2
  362. package/src/usage/cost.ts +48 -12
  363. package/src/usage/expected-prices.ts +3 -0
  364. package/src/usage/ledger-retention.ts +2 -1
  365. package/src/usage/ledger-scanner.ts +6 -5
  366. package/src/usage/log.ts +17 -8
  367. package/src/usage/summary.ts +22 -9
  368. package/src/usage/user-cost-overlays.ts +14 -19
  369. package/src/usage/user-cost-validation.ts +38 -0
  370. package/src/vision/eligibility.ts +13 -0
  371. package/src/web-search/loop.ts +55 -5
  372. package/src/web-search/progress-stream.ts +4 -1
  373. package/src/web-search/run-turn-loop.ts +9 -1
  374. package/gui/dist/assets/App-BjlttVLy.js +0 -58
  375. package/gui/dist/assets/App-CSJvmjNs.css +0 -1
  376. package/gui/dist/assets/index-BB6uK6tU.css +0 -1
  377. package/gui/dist/assets/index-BMssP4k3.js +0 -94
  378. package/gui/dist/assets/quota-summary-D8M1bc_R.js +0 -1
package/src/grok/sync.ts CHANGED
@@ -18,7 +18,7 @@ export interface GrokSyncDeps {
18
18
  }
19
19
 
20
20
  async function defaultFetchAllModels(config: OcxConfig): Promise<CatalogModel[]> {
21
- const { fetchAllModels } = await import("../server/management-api");
21
+ const { fetchAllModels } = await import("../server/management/shared");
22
22
  return fetchAllModels(config);
23
23
  }
24
24
 
@@ -20,6 +20,7 @@ import { isAnthropicInstanceId } from "../providers/anthropic-instance-id";
20
20
  import { cloneProviderOpaqueToolCallMetadata } from "../responses/provider-opaque-metadata";
21
21
  import type { AttemptRecoveryKind } from "../usage/log";
22
22
  import { bridgeToResponsesSSE } from "../bridge";
23
+ import { isTerminalRefusalIncomplete } from "../bridge/terminal-refusal";
23
24
  import { clearableDeadline, idleDeadline } from "../lib/abort";
24
25
  import { readBoundedResponseBody } from "../lib/bounded-body";
25
26
  import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry";
@@ -39,6 +40,7 @@ import { submitVideoJob } from "./xai-video-client";
39
40
  import { downloadVideoToArtifact, createImageBudget, pruneArtifacts } from "./artifacts";
40
41
  import { IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "./synthetic-tool";
41
42
  import type { ImageBridgePlan, VideoBridgePlan } from "./types";
43
+ import { guardEmptyToolCatalogStream, type ClientToolCatalogRestriction } from "../lib/empty-tool-catalog";
42
44
 
43
45
  const SSE_HEADERS = {
44
46
  "Content-Type": "text/event-stream",
@@ -241,7 +243,11 @@ function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent[]
241
243
  };
242
244
 
243
245
  for (const e of events) {
244
- if (e.type === "thinking_delta") {
246
+ if (e.type === "text_delta" && e.anthropicThinkingSignature) {
247
+ flushVisible();
248
+ flushRaw();
249
+ parts.push({ type: "thinking", thinking: e.text, signature: e.anthropicThinkingSignature });
250
+ } else if (e.type === "thinking_delta") {
245
251
  flushRaw();
246
252
  thinking += e.thinking;
247
253
  } else if (e.type === "reasoning_raw_delta") {
@@ -286,6 +292,7 @@ class LoopError extends Error {
286
292
  */
287
293
  export interface ImageBridgeDeps {
288
294
  parsed: OcxParsedRequest;
295
+ clientToolCatalogRestriction?: ClientToolCatalogRestriction;
289
296
  adapter: ProviderAdapter;
290
297
  incomingMeta: IncomingMeta;
291
298
  plan?: ImageBridgePlan;
@@ -319,6 +326,12 @@ export interface ImageBridgeDeps {
319
326
  waitForRequestSlot?: (signal?: AbortSignal) => Promise<ProviderRequestSlot | void>;
320
327
  /** Raw adapter usage at the terminal event, pre wire-normalization (see bridgeToResponsesSSE onUsage). */
321
328
  onUsage?: (usage: OcxUsage | undefined) => void;
329
+ /**
330
+ * After an `on429` rotation, offered the usage of the hidden iterations the left credential
331
+ * served. Returning true means it was billed to that credential's attempt, so `onUsage` will not
332
+ * report it again; the client terminal still includes it.
333
+ */
334
+ onRotatedUsage?: (usage: OcxUsage) => boolean;
322
335
  /**
323
336
  * Optional 429 failover for the routed (non-xAI) model. Return a rebuilt adapter for the
324
337
  * rotated credential, or null when the pool is exhausted. Async hooks support OAuth refresh;
@@ -353,6 +366,8 @@ export interface ImageBridgeDeps {
353
366
  onCompletedResponse?: (response: Record<string, unknown>, providerState?: OcxProviderContinuationState) => void;
354
367
  /** WebSocket Responses path only — leave response id empty for protocol compatibility. */
355
368
  forceEmptyResponseId?: boolean;
369
+ /** Responses inbound only: deliver a non-retryable upstream refusal as `response.failed`. */
370
+ terminalRefusalAsFailed?: boolean;
356
371
  }
357
372
 
358
373
  /**
@@ -377,6 +392,10 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
377
392
  let paidImageCalls = 0;
378
393
  let paidVideoCalls = 0;
379
394
  let hiddenUsage: OcxUsage | undefined;
395
+ // Hidden usage already billed through onRotatedUsage: still on the client terminal, not in
396
+ // ledgerUsage, which is what onUsage reports once a rotation has moved some.
397
+ let rotatedUsage: OcxUsage | undefined;
398
+ let ledgerUsage: OcxUsage | undefined;
380
399
  let activeProducers = 0;
381
400
 
382
401
  const addUsage = (a: OcxUsage | undefined, b: OcxUsage | undefined): OcxUsage | undefined => {
@@ -569,6 +588,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
569
588
  // runTurn adapters signal errors via {type:"error"} events, not HTTP status codes.
570
589
  const errorEvent = events.find(e => e.type === "error");
571
590
  if (errorEvent && errorEvent.type === "error") {
591
+ hiddenUsage = addUsage(hiddenUsage, errorEvent.usage);
572
592
  if (errorEvent.code === "translation_buffer_limit") {
573
593
  throw new TranslatorBudgetExceededError("retained_collectors", TRANSLATOR_MAX_TURN_BYTES);
574
594
  }
@@ -709,6 +729,10 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
709
729
  const rotated = await deps.on429(prepared.response.headers.get("retry-after"), prepared.response.headers,
710
730
  iterParsed, prepared.response);
711
731
  if (!rotated) break;
732
+ if (hiddenUsage && deps.onRotatedUsage?.(hiddenUsage)) {
733
+ rotatedUsage = addUsage(rotatedUsage, hiddenUsage);
734
+ hiddenUsage = undefined;
735
+ }
712
736
  try { void prepared.response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
713
737
  adapter = rotated.adapter;
714
738
  yield { type: "heartbeat" };
@@ -772,6 +796,8 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
772
796
  signal,
773
797
  inactivityTimeoutMs: stallTimeoutMs,
774
798
  translatorBudget,
799
+ // A pass that fails was still billed; the error terminal below reports it with the hidden passes.
800
+ onErrorUsage: usage => { hiddenUsage = addUsage(hiddenUsage, usage); },
775
801
  })) {
776
802
  if (event.type === "heartbeat") yield event;
777
803
  else {
@@ -863,13 +889,17 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
863
889
  // Loop (fulfill + re-ask) ONLY when the model's actionable output is purely image_gen. A
864
890
  // real tool call means this turn is terminal for Codex — finalize so those calls reach
865
891
  // Codex. forceFinal also finalizes.
866
- const shouldLoop = split.calls.length > 0 && !split.hasRealToolCall && !forceFinal;
892
+ // A refused iteration also finalizes: fulfilling its media call would re-ask the model
893
+ // with the refused context (#7148).
894
+ const shouldLoop = split.calls.length > 0 && !split.hasRealToolCall && !forceFinal
895
+ && !split.passthrough.some(isTerminalRefusalIncomplete);
867
896
  if (!shouldLoop) {
868
- if (hiddenUsage) {
897
+ if (hiddenUsage || rotatedUsage) {
869
898
  for (let i = split.passthrough.length - 1; i >= 0; i--) {
870
899
  const e = split.passthrough[i];
871
900
  if (e?.type === "done" || e?.type === "incomplete") {
872
- split.passthrough[i] = { ...e, usage: addUsage(hiddenUsage, e.usage) };
901
+ ledgerUsage = addUsage(hiddenUsage, e.usage);
902
+ split.passthrough[i] = { ...e, usage: addUsage(rotatedUsage, ledgerUsage) };
873
903
  break;
874
904
  }
875
905
  }
@@ -881,6 +911,10 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
881
911
  // Discarded iteration still contributed tokens — accumulate for the final onUsage.
882
912
  takeUsageFrom(split.passthrough);
883
913
 
914
+ for (const event of split.passthrough) {
915
+ if (event.type === "text_delta" && event.anthropicThinkingSignature) yield event;
916
+ }
917
+
884
918
  // Fulfill each image/video call, then inject ONE assistant turn (thinking once + all tool
885
919
  // calls) so Anthropic extended-thinking continuations stay valid across parallel calls.
886
920
  const iterationThinking = extractIterationThinking(split.passthrough);
@@ -1052,6 +1086,8 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
1052
1086
  });
1053
1087
  }
1054
1088
  } catch (e) {
1089
+ ledgerUsage = hiddenUsage;
1090
+ const usage = addUsage(rotatedUsage, hiddenUsage);
1055
1091
  if (isTranslatorBudgetExceededError(e)) {
1056
1092
  yield {
1057
1093
  type: "error",
@@ -1059,14 +1095,14 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
1059
1095
  errorType: "upstream_error",
1060
1096
  code: e.code,
1061
1097
  message: "upstream translation buffer exceeded the safe limit",
1062
- ...(hiddenUsage ? { usage: hiddenUsage } : {}),
1098
+ ...(usage ? { usage } : {}),
1063
1099
  };
1064
1100
  } else {
1065
1101
  yield {
1066
1102
  type: "error",
1067
1103
  message: e instanceof LoopError ? e.message : (e instanceof Error ? e.message : String(e)),
1068
1104
  ...(e instanceof LoopError ? { status: e.status } : {}),
1069
- ...(hiddenUsage ? { usage: hiddenUsage } : {}),
1105
+ ...(usage ? { usage } : {}),
1070
1106
  };
1071
1107
  }
1072
1108
  return;
@@ -1079,13 +1115,15 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
1079
1115
  }
1080
1116
 
1081
1117
  const sse = bridgeToResponsesSSE(
1082
- produce(), parsed._responseModelId ?? parsed.modelId, toolNsMap, freeform, toolSearch, () => {
1118
+ guardEmptyToolCatalogStream(produce(), deps.clientToolCatalogRestriction, translatorBudget), parsed._responseModelId ?? parsed.modelId, toolNsMap, freeform, toolSearch, () => {
1083
1119
  internalAbort.abort("client closed responses stream");
1084
1120
  }, 2_000,
1085
1121
  {
1086
1122
  translatorBudget,
1087
1123
  replayCacheScope: parsed._reasoningReplayScope,
1124
+ ...(deps.clientToolCatalogRestriction ? { ...deps.clientToolCatalogRestriction, enforceDeclaredToolNames: false } : {}),
1088
1125
  ...(deps.forceEmptyResponseId ? { responseId: "" } : {}),
1126
+ ...(deps.terminalRefusalAsFailed ? { terminalRefusalAsFailed: true } : {}),
1089
1127
  hideThinkingSummary: parsed.options.hideThinkingSummary,
1090
1128
  hideRawReasoning: parsed.options.hideRawReasoning,
1091
1129
  stallTimeoutSec: deps.stallTimeoutSec,
@@ -1093,7 +1131,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
1093
1131
  ...(deps.onUsage ? {
1094
1132
  // Terminal done/incomplete already includes hiddenUsage (merged above). Do not
1095
1133
  // add it again here or request logs double-count multi-iteration image turns.
1096
- onUsage: (usage: OcxUsage | undefined) => deps.onUsage?.(usage),
1134
+ onUsage: (usage: OcxUsage | undefined) => deps.onUsage?.(rotatedUsage ? ledgerUsage : usage),
1097
1135
  } : {}),
1098
1136
  ...(deps.onCompletedResponse ? { onCompletedResponse: deps.onCompletedResponse } : {}),
1099
1137
  },
@@ -12,7 +12,7 @@ import {
12
12
  /** Refresh only previously connected clients; a refused file never blocks its peers. */
13
13
  export async function refreshOwnedCatalogIntegrations(
14
14
  input: Omit<OwnedIntegrationRefreshInput, "clientId">,
15
- clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder"],
15
+ clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo", "commandcode", "droid", "opencode", "kilo", "qoder", "gentle-shell"],
16
16
  options: { refreshOnly?: boolean; admit?: () => boolean } = {},
17
17
  ): Promise<OwnedIntegrationRefreshOutcome[]> {
18
18
  // Client files are shared with the live proxy a sibling instance runs beside; their entries
@@ -134,6 +134,7 @@ const CLIENT_MANAGED_PATHS = {
134
134
  qoder: [["providers", OPENCODE_PROVIDER_ID], ["modelConfigs", "customModels", DYNAMIC_SEGMENT]],
135
135
  opencode: [["provider", OPENCODE_PROVIDER_ID], ["providers", OPENCODE_PROVIDER_ID]],
136
136
  pi: [["providers", OPENCODE_PROVIDER_ID]],
137
+ "gentle-shell": [["providers", OPENCODE_PROVIDER_ID]],
137
138
  omp: [["providers", OPENCODE_PROVIDER_ID]],
138
139
  hermes: [["providers", OPENCODE_PROVIDER_ID]],
139
140
  openclaw: [["models", "providers", OPENCODE_PROVIDER_ID]],
@@ -9,6 +9,7 @@
9
9
  * Design of record: devlog/_fin/260802_client_toggle_api/021 §1.
10
10
  */
11
11
  import { homedir } from "node:os";
12
+ import { gentleShellPaths } from "../clients/gentle-shell-path";
12
13
  import { assertDroidSettingsUnambiguous } from "./droid-settings";
13
14
  import { readPath } from "./merge";
14
15
  import type { OwnershipRecord } from "./ownership";
@@ -505,6 +506,13 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
505
506
  return { configPath: droidConfigPath(env, home), detectDir };
506
507
  },
507
508
  },
509
+ "gentle-shell": {
510
+ id: "gentle-shell",
511
+ configPath: (env, home, io) => gentleShellPaths(env, home, io).configPath,
512
+ detectDir: (env, home, io) => gentleShellPaths(env, home, io).detectDir,
513
+ resolvePaths: (env, home, _context, io) => gentleShellPaths(env, home, io),
514
+ unresolvedPathHint: (_env, home = homedir()) => join(home, ".gentle-shell", "agent", "models.json"),
515
+ },
508
516
  };
509
517
 
510
518
  export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] =
@@ -36,6 +36,10 @@ export interface CodexAppServerStateResponse {
36
36
  * The desktop half of a restart. Scalar-only for the same reason as the rest of this
37
37
  * contract: pid lists and a closed-vocabulary reason, never a command line or an OS
38
38
  * error message, both of which routinely embed a path or a username.
39
+ *
40
+ * `reason: "picker_current"` means the shell was deliberately left running because it
41
+ * launched after the current catalog was written, so only the app-servers restarted and
42
+ * the operator's unsaved client state was kept (#4761).
39
43
  */
40
44
  export interface CodexDesktopRestartSummary {
41
45
  attempted: boolean;
@@ -43,8 +47,17 @@ export interface CodexDesktopRestartSummary {
43
47
  surviving: number[];
44
48
  relaunch: "started" | "skipped";
45
49
  reason?: string;
50
+ /**
51
+ * Why the desktop was or was not restarted: `stale` (the shell predates the bound
52
+ * catalog, or this request rebound it) and `unknown` (the evidence was unreadable) both
53
+ * take the full restart. Absent on a proxy older than #4761.
54
+ */
55
+ pickerState?: CodexDesktopPickerState;
46
56
  }
47
57
 
58
+ export type CodexDesktopPickerState = "current" | "stale" | "unknown" | "not_running";
59
+ const DESKTOP_PICKER_STATES: readonly string[] = ["current", "stale", "unknown", "not_running"];
60
+
48
61
  /** POST response. All four arrays are pid lists — never command lines. */
49
62
  export interface CodexRestartResponse {
50
63
  success: boolean;
@@ -130,6 +143,8 @@ export function isCodexRestartResponse(value: unknown): value is CodexRestartRes
130
143
  if (!isPidList(d.stopped) || !isPidList(d.surviving)) return false;
131
144
  if (d.relaunch !== "started" && d.relaunch !== "skipped") return false;
132
145
  if (d.reason !== undefined && typeof d.reason !== "string") return false;
146
+ if (d.pickerState !== undefined
147
+ && (typeof d.pickerState !== "string" || !DESKTOP_PICKER_STATES.includes(d.pickerState))) return false;
133
148
  // A started relaunch cannot have left anything behind: the ladder refuses to
134
149
  // relaunch beside a survivor precisely so a second shell never appears.
135
150
  if (d.relaunch === "started" && (d.surviving as number[]).length > 0) return false;
@@ -1,10 +1,10 @@
1
- import { connect as tcpConnect, isIP, type Socket } from "node:net";
2
- import { checkServerIdentity, connect as tlsConnect, type TLSSocket } from "node:tls";
1
+ import { connect as tcpConnect, type Socket } from "node:net";
2
+ import { connect as tlsConnect, type TLSSocket } from "node:tls";
3
3
  import { desktopProxyFor } from "./desktop-proxy-route";
4
- import { socks5Credentials, socks5Handshake } from "./socks5-handshake";
4
+ import { dialTunnel, type OpaqueProxyRoute } from "./opaque-proxy-tunnel";
5
5
 
6
- // Shares the SOCKS exchange extracted by lcxhh521 in PR #5947 (efdccdbfac3f).
7
- // Lifecycle here additionally bounds the entire dial and handles close/abort/header limits.
6
+ // The shared opaque dialer owns proxy framing; this caller owns fixed-target
7
+ // TLS and bounds the entire connection, including the target TLS handshake.
8
8
  export interface DesktopTunnelOptions {
9
9
  proxy?: string | false;
10
10
  signal?: AbortSignal;
@@ -15,37 +15,6 @@ export interface DesktopTunnelOptions {
15
15
  proxyCa?: string;
16
16
  }
17
17
  const fail = () => new Error("desktop_upstream_connection_failed");
18
- class HandshakeReader {
19
- private buffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
20
- private failure: Error | null = null;
21
- private waiting: { ready: () => boolean; resolve: () => void; reject: (error: Error) => void } | null = null;
22
- private onData = (chunk: Buffer) => {
23
- if (this.buffer.length + chunk.length > 65536) { this.onFailure(); return; }
24
- this.buffer = Buffer.concat([this.buffer, chunk]);
25
- if (this.waiting?.ready()) { const current = this.waiting; this.waiting = null; current.resolve(); }
26
- };
27
- private onFailure = () => { this.failure = fail(); const current = this.waiting; this.waiting = null; current?.reject(this.failure); };
28
- constructor(private socket: Socket, private signal: AbortSignal) {
29
- socket.on("data", this.onData); socket.once("error", this.onFailure); socket.once("end", this.onFailure); socket.once("close", this.onFailure);
30
- signal.addEventListener("abort", this.onFailure, { once: true }); if (signal.aborted) this.onFailure(); socket.resume();
31
- }
32
- write(bytes: Uint8Array | string): void { if (this.failure) throw this.failure; this.socket.write(bytes); }
33
- private async until(ready: () => boolean): Promise<void> {
34
- if (this.failure) throw this.failure;
35
- if (ready()) return;
36
- if (this.waiting) throw fail();
37
- await new Promise<void>((resolve, reject) => { this.waiting = { ready, resolve, reject }; });
38
- }
39
- private take(length: number): Buffer { const out = this.buffer.subarray(0, length); this.buffer = this.buffer.subarray(length); return out; }
40
- async readExact(length: number): Promise<Buffer> { await this.until(() => this.buffer.length >= length); return this.take(length); }
41
- async readHead(): Promise<string> { await this.until(() => this.buffer.indexOf("\r\n\r\n") >= 0); return this.take(this.buffer.indexOf("\r\n\r\n") + 4).toString("latin1"); }
42
- dispose(): void {
43
- this.socket.pause(); this.socket.removeListener("data", this.onData); this.socket.removeListener("error", this.onFailure);
44
- this.socket.removeListener("end", this.onFailure); this.socket.removeListener("close", this.onFailure);
45
- this.signal.removeEventListener("abort", this.onFailure);
46
- if (this.buffer.length) this.socket.unshift(this.buffer); this.buffer = Buffer.alloc(0); this.onFailure();
47
- }
48
- }
49
18
  function waitConnected(socket: Socket, event: "connect" | "secureConnect", signal: AbortSignal): Promise<void> {
50
19
  return new Promise((resolve, reject) => {
51
20
  const dispose = () => { socket.off(event, done); socket.off("error", failure); socket.off("close", failure); signal.removeEventListener("abort", failure); };
@@ -76,25 +45,21 @@ export async function dialDesktopUpstream(options: DesktopTunnelOptions = {}): P
76
45
  const url = new URL(proxy), protocol = url.protocol;
77
46
  if (!["http:", "https:", "socks5:", "socks5h:"].includes(protocol) || !url.hostname || url.search || url.hash || url.pathname !== "" && url.pathname !== "/") throw fail();
78
47
  const host = url.hostname.replace(/^\[|\]$/g, ""), port = Number(url.port) || (protocol === "https:" ? 443 : protocol.startsWith("socks") ? 1080 : 80);
79
- raw = own(tcpConnect({ host, port })); await waitConnected(raw, "connect", signal);
80
- if (protocol === "https:") {
81
- raw = own(tlsConnect({ socket: raw, servername: isIP(host) ? undefined : host, ca: options.proxyCa,
82
- rejectUnauthorized: true, checkServerIdentity: (_name, certificate) => checkServerIdentity(host, certificate), ALPNProtocols: ["http/1.1"] }));
83
- await waitConnected(raw, "secureConnect", signal);
84
- }
85
- const reader = new HandshakeReader(raw, signal);
86
- try {
87
- if (protocol.startsWith("socks")) await socks5Handshake(reader, target, socks5Credentials(url));
88
- else {
89
- const authority = `${target.host}:${target.port}`;
90
- const lines = [`CONNECT ${authority} HTTP/1.1`, `Host: ${authority}`];
91
- if (url.username || url.password) lines.push(`Proxy-Authorization: Basic ${Buffer.from(`${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`).toString("base64")}`);
92
- reader.write(lines.join("\r\n") + "\r\n\r\n");
93
- if (!/^HTTP\/1\.[01] 2\d\d(?: |\r)/.test(await reader.readHead())) throw fail();
94
- }
95
- } finally { reader.dispose(); }
48
+ const credentials = url.username || url.password
49
+ ? { username: decodeURIComponent(url.username), password: decodeURIComponent(url.password) }
50
+ : {};
51
+ const endpoint = { host, port, ...credentials };
52
+ const route: OpaqueProxyRoute = protocol === "https:"
53
+ ? { ...endpoint, kind: "https", proxyCa: options.proxyCa }
54
+ : { ...endpoint, kind: protocol.slice(0, -1) as "http" | "socks5" | "socks5h" };
55
+ const authority = `${target.host.includes(":") ? `[${target.host}]` : target.host}:${target.port}`;
56
+ // The route resolver remains authoritative; a failed proxy never becomes DIRECT.
57
+ raw = own(await dialTunnel(authority, route, { signal, timeoutMs: timeout, maxHeaderBytes: 65536 }));
96
58
  }
97
59
  if (signal.aborted || raw.destroyed) throw fail();
60
+ // This consumer historically closes its writable side after peer EOF. The
61
+ // shared opaque transport preserves half-closes for callers that need them.
62
+ raw.allowHalfOpen = false;
98
63
  const secured = own(tlsConnect({ socket: raw, servername: "chatgpt.com", ca: options.ca, rejectUnauthorized: true, ALPNProtocols: ["http/1.1"] }));
99
64
  // The CONNECT reader paused its stream before handing buffered bytes back. Bun's
100
65
  // TLS-over-TLS path needs the outer TLS stream explicitly resumed for the new handshake.
@@ -0,0 +1,62 @@
1
+ import { normalizeDeclaredToolName, type AdapterEvent } from "../types";
2
+ import { UNDECLARED_TOOL_CALL_ERROR_CODE } from "./errors";
3
+ import { releaseTranslatedEvent, type TranslatorBudget } from "./translator-budget";
4
+ import { releaseCompactionCiphertextLease } from "../responses/compaction";
5
+
6
+ /** Snapshot of current-turn declarations when an explicit empty catalog activates enforcement. */
7
+ export interface ClientToolCatalogRestriction {
8
+ declaredToolNames: ReadonlySet<string>;
9
+ bareCustomToolNames: ReadonlySet<string>;
10
+ }
11
+
12
+ export function emptyToolCatalogRefusal(
13
+ event: AdapterEvent,
14
+ catalog: ClientToolCatalogRestriction | undefined,
15
+ ): Extract<AdapterEvent, { type: "error" }> | undefined {
16
+ if (!catalog) return;
17
+ if (event.type === "tool_call_start") {
18
+ const name = normalizeDeclaredToolName(event.name, catalog.declaredToolNames, undefined, catalog.bareCustomToolNames);
19
+ if (catalog.declaredToolNames.has(name)) return;
20
+ } else if (catalog.declaredToolNames.size !== 0
21
+ || (event.type !== "tool_call_delta" && event.type !== "tool_call_end")) return;
22
+ return {
23
+ type: "error", status: 502, errorType: "upstream_error", code: UNDECLARED_TOOL_CALL_ERROR_CODE,
24
+ message: "routed provider emitted a client tool call outside the current request's explicit tool catalog",
25
+ };
26
+ }
27
+
28
+ /** Runs after hosted tool loops, before client delivery and continuation-state folding. */
29
+ export async function* guardEmptyToolCatalogStream(
30
+ events: AsyncIterable<AdapterEvent>,
31
+ catalog: ClientToolCatalogRestriction | undefined,
32
+ budget: TranslatorBudget,
33
+ ): AsyncGenerator<AdapterEvent> {
34
+ for await (const event of events) {
35
+ const refusal = emptyToolCatalogRefusal(event, catalog);
36
+ if (refusal) {
37
+ releaseTranslatedEvent(event, budget);
38
+ releaseCompactionCiphertextLease(event, budget);
39
+ }
40
+ yield refusal ?? event;
41
+ if (refusal) return;
42
+ }
43
+ }
44
+
45
+ export function guardEmptyToolCatalogEvents(
46
+ events: AdapterEvent[],
47
+ catalog: ClientToolCatalogRestriction | undefined,
48
+ budget: TranslatorBudget,
49
+ ): AdapterEvent[] {
50
+ if (!catalog) return events;
51
+ const index = events.findIndex(event => emptyToolCatalogRefusal(event, catalog) !== undefined);
52
+ if (index < 0) return events;
53
+ const refusal = emptyToolCatalogRefusal(events[index]!, catalog)!;
54
+ for (const event of events.slice(index)) {
55
+ if ((event.type === "done" || event.type === "incomplete" || event.type === "error") && event.usage) {
56
+ refusal.usage = event.usage;
57
+ }
58
+ releaseTranslatedEvent(event, budget);
59
+ releaseCompactionCiphertextLease(event, budget);
60
+ }
61
+ return [...events.slice(0, index), refusal];
62
+ }
package/src/lib/errors.ts CHANGED
@@ -650,10 +650,22 @@ export function adapterFailureFromMessage(message: string): { httpStatus: number
650
650
  return { httpStatus, error };
651
651
  }
652
652
 
653
+ /** A declared client refusal: retrying the identical request cannot change the verdict. */
654
+ const CLIENT_ERROR_STATUS: Record<string, 400 | 401 | 403 | 404> = {
655
+ invalid_request_error: 400,
656
+ authentication_error: 401,
657
+ permission_error: 403,
658
+ not_found_error: 404,
659
+ };
660
+
653
661
  /** Narrow structured classes carried through a synthesized bare-error terminal. */
654
662
  export function recognizedUpstreamError(error: { type?: unknown; code?: unknown }):
655
- { type: string; code: string; httpStatus: 429 | 503 } | undefined {
663
+ { type: string; code: string; httpStatus: 400 | 401 | 403 | 404 | 429 | 503 } | undefined {
656
664
  const code = typeof error.code === "string" ? error.code : undefined;
665
+ const clientClass = code ?? (typeof error.type === "string" ? error.type : undefined);
666
+ if (clientClass !== undefined && Object.hasOwn(CLIENT_ERROR_STATUS, clientClass)) {
667
+ return { type: clientClass, code: clientClass, httpStatus: CLIENT_ERROR_STATUS[clientClass] };
668
+ }
657
669
  // An explicit unknown code must not gain a recognized class from its type or copy.
658
670
  if (code !== undefined && !["rate_limit_exceeded", "rate_limit_error", "server_is_overloaded", "overloaded_error"].includes(code)) return undefined;
659
671
  if (code === "rate_limit_exceeded" || code === "rate_limit_error"
@@ -680,8 +692,13 @@ export function httpStatusFromTerminalError(error: {
680
692
  if (isCyberPolicyCode(error.code) || (error.message ? isCyberPolicyMessage(error.message) : false)) {
681
693
  return 400;
682
694
  }
695
+ const message = error.message ?? "";
683
696
  const recognized = recognizedUpstreamError(error);
684
- if (recognized) return recognized.httpStatus;
697
+ // Client-closed messages often arrive as invalid_request_error after classifyError, so a
698
+ // recognized 400 yields to the client-closed check below; every other class keeps its order.
699
+ if (recognized && !(recognized.httpStatus === 400 && message && isClientClosedMessage(message))) {
700
+ return recognized.httpStatus;
701
+ }
685
702
  if (error.type === "rate_limit_error" || error.code === "rate_limit_exceeded") return 429;
686
703
  if (error.type === "authentication_error" || error.code === "invalid_api_key") return 401;
687
704
  if (
@@ -693,7 +710,6 @@ export function httpStatusFromTerminalError(error: {
693
710
  if (error.type === "server_error" && error.code === "server_is_overloaded") return 503;
694
711
  // Client-closed messages often arrive as invalid_request_error after classifyError; check message
695
712
  // before treating every invalid_request_error as HTTP 400.
696
- const message = error.message ?? "";
697
713
  if (message && isClientClosedMessage(message)) return 499;
698
714
  if (error.type === "invalid_request_error") return 400;
699
715
  if (error.type === "proxy_error") return 500;
@@ -2,6 +2,10 @@
2
2
  * Application Default Credentials (ADC) resolution for Vertex AI.
3
3
  *
4
4
  * Direct WebCrypto + REST implementation (no `google-auth-library`). Sources, in priority order:
5
+ * 0. A `gcp-sa:<account>` reference (the provider's active key resolving to one) → the Service
6
+ * Account / authorized_user JSON stored in the OS credential store under that account
7
+ * (`keychain:<account>` secret, written by the key-store save path when the user pastes a
8
+ * credential JSON directly into the API-key slot). See resolveGcpCredentialMarker.
5
9
  * 1. `GOOGLE_APPLICATION_CREDENTIALS` env → file with `type: "service_account"` (RS256 JWT
6
10
  * exchange) or `type: "authorized_user"` (refresh-token exchange).
7
11
  * 2. gcloud user ADC (`CLOUDSDK_CONFIG`, else `%APPDATA%\gcloud` on Windows, else
@@ -18,6 +22,8 @@ import { Buffer } from "node:buffer";
18
22
  import * as os from "node:os";
19
23
  import * as path from "node:path";
20
24
  import { readFileSync, existsSync, statSync } from "node:fs";
25
+ import { GCP_CREDENTIAL_MARKER_PREFIX, providerKeychainEntry } from "../providers/api-key-resolve";
26
+ export { isGcpCredentialMaterial } from "./gcp-credential-input";
21
27
  import {
22
28
  captureConfigGeneration,
23
29
  sweepExpiredOnWrite,
@@ -36,6 +42,50 @@ const TOKEN_TIMEOUT_MS = 15_000;
36
42
  const TOKEN_ATTEMPTS = 3;
37
43
  const TOKEN_RETRY_BASE_MS = 300;
38
44
 
45
+ /**
46
+ * Reference prefix stored in `provider.apiKey` (and `apiKeyPool` entries) when the user pastes a
47
+ * GCP credential JSON directly into an API-key slot. The secret JSON lives in the OS credential
48
+ * store under the same service the `keychain:` references use; only the marker is persisted to
49
+ * `config.json`. The leaf reference prefix also identifies already-stored material for migration.
50
+ */
51
+ export { GCP_CREDENTIAL_MARKER_PREFIX } from "../providers/api-key-resolve";
52
+
53
+ /** Extract a marker account; malformed explicit markers never opt into host ADC. */
54
+ export function gcpCredentialMarkerAccount(value: string | undefined): string | undefined {
55
+ if (typeof value !== "string" || !value.startsWith(GCP_CREDENTIAL_MARKER_PREFIX)) return undefined;
56
+ const account = value.slice(GCP_CREDENTIAL_MARKER_PREFIX.length);
57
+ if (!account.trim()) throw new Error("GCP credential marker requires a non-empty account");
58
+ return account;
59
+ }
60
+
61
+ /**
62
+ * Whether a pasted API-key value is in fact a GCP credential JSON. Used by the save path to
63
+ * divert JSON material out of the literal-key flow before it reaches `config.json`. Deliberately
64
+ * strict: only a `type` field with a known ADC credential type counts, so a user's literal API
65
+ * key that happens to start with `{` is never eaten.
66
+ */
67
+ export function parseGcpCredentialJson(value: string): AdcFileCredentials | undefined {
68
+ const trimmed = (value ?? "").trim();
69
+ if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return undefined;
70
+ try {
71
+ const parsed = JSON.parse(trimmed) as Record<string, unknown>;
72
+ if (parsed?.type === "service_account") {
73
+ if (typeof parsed.client_email !== "string" || !parsed.client_email.trim()) return undefined;
74
+ if (typeof parsed.private_key !== "string" || !parsed.private_key.trim()) return undefined;
75
+ return parsed as unknown as AdcFileCredentials;
76
+ }
77
+ if (parsed?.type === "authorized_user") {
78
+ if (typeof parsed.client_id !== "string" || !parsed.client_id.trim()) return undefined;
79
+ if (typeof parsed.client_secret !== "string" || !parsed.client_secret.trim()) return undefined;
80
+ if (typeof parsed.refresh_token !== "string" || !parsed.refresh_token.trim()) return undefined;
81
+ return parsed as unknown as AdcFileCredentials;
82
+ }
83
+ return undefined;
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ }
88
+
39
89
  interface CachedToken {
40
90
  token: string;
41
91
  expiresAtMs: number;
@@ -81,7 +131,10 @@ export function sweepExpiredGcpAdcTokens(now = Date.now()): number {
81
131
 
82
132
  export function reconcileGcpAdcTokens(context: GenerationContext): number {
83
133
  if (context.generation <= lastReconciledGeneration) return 0;
84
- const currentSource = currentAdcSourceKey();
134
+ // The periodic sweep registration has no request context, so no marker is active here: markers
135
+ // travel per-request via getVertexAccessToken(options.markerAccount), and a marker-token cache
136
+ // entry whose account no longer resolves is simply pruned by this sweep when it expires.
137
+ const currentSource = currentAdcSourceKey(undefined);
85
138
  let removed = 0;
86
139
  for (const source of tokenCache.keys()) {
87
140
  if (source === currentSource) continue;
@@ -131,7 +184,29 @@ function readJsonFile<T>(filePath: string): T | undefined {
131
184
  return JSON.parse(readFileSync(filePath, "utf8")) as T;
132
185
  }
133
186
 
134
- function loadAdcCredentials(): { source: string; creds: AdcFileCredentials } | undefined {
187
+ /**
188
+ * Read the credential JSON for a `gcp-sa:<account>` marker from the OS credential store. Uses the
189
+ * same service name and the same injectable entry factory as the `keychain:` API-key references
190
+ * (api-key-resolve.providerKeychainEntry), so one probe surface and one test seam cover both.
191
+ * Mirrors `readKeychain`'s fail-closed policy: an unreadable entry yields no credential (the
192
+ * caller surfaces the failure instead of silently falling through to the next ADC source).
193
+ */
194
+ function readMarkerCredential(account: string): AdcFileCredentials | undefined {
195
+ try {
196
+ const raw = providerKeychainEntry(account).getPassword();
197
+ if (typeof raw !== "string" || !raw.trim()) return undefined;
198
+ return parseGcpCredentialJson(raw);
199
+ } catch {
200
+ return undefined;
201
+ }
202
+ }
203
+
204
+ function loadAdcCredentials(markerAccount: string | undefined): { source: string; creds: AdcFileCredentials } | undefined {
205
+ if (markerAccount) {
206
+ const creds = readMarkerCredential(markerAccount);
207
+ if (!creds) throw new Error("GCP credential marker is set but its credential could not be read from the OS credential store");
208
+ return { source: `marker:${markerAccount}`, creds };
209
+ }
135
210
  const gacPath = process.env.GOOGLE_APPLICATION_CREDENTIALS;
136
211
  if (gacPath) {
137
212
  const creds = readJsonFile<AdcFileCredentials>(gacPath);
@@ -148,9 +223,10 @@ function loadAdcCredentials(): { source: string; creds: AdcFileCredentials } | u
148
223
  * The cache key for the source the NEXT resolve would use, computed cheaply (no network). Lets the
149
224
  * cache return a token only when it still matches the active credential source, so an in-process
150
225
  * change to GOOGLE_APPLICATION_CREDENTIALS (or the user ADC file) does not keep serving a stale
151
- * token from a different source. Falls back to "metadata" when no file/env ADC is present.
226
+ * token from a different source. Falls back to "metadata" when no marker/file/env ADC is present.
152
227
  */
153
- function currentAdcSourceKey(): string {
228
+ function currentAdcSourceKey(markerAccount: string | undefined): string {
229
+ if (markerAccount) return `marker:${markerAccount}`;
154
230
  const gacPath = process.env.GOOGLE_APPLICATION_CREDENTIALS;
155
231
  if (gacPath) return fileSourceTag("gac", gacPath);
156
232
  const userPath = userAdcPath();
@@ -279,8 +355,8 @@ async function fetchMetadataToken(signal: AbortSignal | undefined, fetchImpl: Fe
279
355
  }
280
356
  }
281
357
 
282
- async function resolveAccessTokenUncached(signal: AbortSignal | undefined, fetchImpl: FetchImpl): Promise<{ source: string; token: TokenResponse }> {
283
- const adc = loadAdcCredentials();
358
+ async function resolveAccessTokenUncached(markerAccount: string | undefined, signal: AbortSignal | undefined, fetchImpl: FetchImpl): Promise<{ source: string; token: TokenResponse }> {
359
+ const adc = loadAdcCredentials(markerAccount);
284
360
  if (adc) {
285
361
  const token = adc.creds.type === "service_account"
286
362
  ? await exchangeJwtForToken(adc.creds, signal, fetchImpl)
@@ -295,14 +371,25 @@ async function resolveAccessTokenUncached(signal: AbortSignal | undefined, fetch
295
371
  }
296
372
 
297
373
  /** Returns a Bearer access token for the `Authorization` header on Vertex AI calls (cached + refreshed). */
298
- export async function getVertexAccessToken(options?: { signal?: AbortSignal; fetch?: FetchImpl }): Promise<string> {
374
+ export async function getVertexAccessToken(options?: { signal?: AbortSignal; fetch?: FetchImpl; markerAccount?: string }): Promise<string> {
375
+ // The marker travels as an argument, never as process-global state: a module-level "active
376
+ // marker" written by the router would race across concurrent requests for different providers
377
+ // and could resolve one provider's Vertex call with another provider's service account. The
378
+ // adapter passes `gcpCredentialMarkerAccount(provider.apiKey)` per request instead.
379
+ const markerAccount = options?.markerAccount;
299
380
  const fetchImpl = options?.fetch ?? globalThis.fetch.bind(globalThis);
300
381
  const skew = getRefreshSkewMs();
301
382
  const now = Date.now();
302
383
 
303
384
  // Only serve a cached token that matches the source the next resolve would actually use; prune
304
385
  // expired or now-stale (different-source) entries so a credential-source change is honored.
305
- const expectedSource = currentAdcSourceKey();
386
+ const expectedSource = currentAdcSourceKey(markerAccount);
387
+ // A cached bearer token is usable only while the explicitly selected store entry remains
388
+ // readable. Removal or a locked store must fail closed before the cache can return it.
389
+ if (markerAccount && !readMarkerCredential(markerAccount)) {
390
+ tokenCache.delete(expectedSource);
391
+ throw new Error("GCP credential marker is set but its credential could not be read from the OS credential store");
392
+ }
306
393
  const writerGeneration = captureConfigGeneration();
307
394
  for (const [source, cached] of tokenCache) {
308
395
  if (source === expectedSource && cached.expiresAtMs - skew > now) return cached.token;
@@ -317,7 +404,7 @@ export async function getVertexAccessToken(options?: { signal?: AbortSignal; fet
317
404
 
318
405
  const promise = (async () => {
319
406
  try {
320
- const { source, token } = await resolveAccessTokenUncached(options?.signal, fetchImpl);
407
+ const { source, token } = await resolveAccessTokenUncached(markerAccount, options?.signal, fetchImpl);
321
408
  const expiresAtMs = Date.now() + Math.max(0, token.expires_in * 1000);
322
409
  if (writerGeneration >= lastReconciledGeneration || liveSources.has(source)) {
323
410
  tokenCache.set(source, { token: token.access_token, expiresAtMs });