@bitkyc08/opencodex 2.87.0 → 2.89.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 (246) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-BnRSujOq.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-DCd-A1az.js → Tray-7z1COnmz.js} +1 -1
  6. package/gui/dist/assets/index-uchD-ngf.js +94 -0
  7. package/gui/dist/assets/{quota-summary-ChvmKGOk.js → quota-summary-BY_10IEz.js} +1 -1
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/cache-retention.ts +117 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -4
  12. package/src/adapters/anthropic-rejection-memo.ts +71 -0
  13. package/src/adapters/anthropic-replay.ts +135 -0
  14. package/src/adapters/anthropic-tool-catalog.ts +11 -0
  15. package/src/adapters/anthropic.ts +86 -48
  16. package/src/adapters/coding-agent/protocol.ts +6 -0
  17. package/src/adapters/google-http.ts +3 -2
  18. package/src/adapters/google-progress-history.ts +53 -0
  19. package/src/adapters/google-vertex-endpoint.ts +67 -0
  20. package/src/adapters/google.ts +52 -30
  21. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  22. package/src/adapters/openai-responses/passthrough.ts +17 -12
  23. package/src/adapters/openai-responses/reasoning.ts +68 -1
  24. package/src/adapters/registry.ts +1 -1
  25. package/src/adapters/upstream-http-error.ts +3 -2
  26. package/src/adapters/zed.ts +7 -4
  27. package/src/bridge/internal.ts +17 -3
  28. package/src/bridge/response-json.ts +4 -4
  29. package/src/bridge/sse.ts +17 -10
  30. package/src/claude/inbound-content-options.ts +66 -0
  31. package/src/claude/inbound.ts +12 -26
  32. package/src/claude/outbound.ts +18 -5
  33. package/src/cli/account-api.ts +13 -1
  34. package/src/cli/account-extended.ts +17 -5
  35. package/src/cli/account-key-quota.ts +4 -1
  36. package/src/cli/account.ts +1 -1
  37. package/src/cli/capabilities-agents-routing.ts +45 -8
  38. package/src/cli/capabilities-base.ts +5 -5
  39. package/src/cli/capabilities-provider-models.ts +5 -4
  40. package/src/cli/combo-result.ts +8 -2
  41. package/src/cli/combo.ts +17 -0
  42. package/src/cli/cross-home-owner.ts +16 -2
  43. package/src/cli/help.ts +1 -1
  44. package/src/cli/loopback-family.ts +31 -0
  45. package/src/cli/message-command.ts +4 -1
  46. package/src/cli/message-remote-args.ts +69 -0
  47. package/src/cli/message-remote-command.ts +77 -0
  48. package/src/cli/provider-runtime.ts +69 -1
  49. package/src/cli/registry.ts +3 -3
  50. package/src/codex/account-pause.ts +6 -1
  51. package/src/codex/account-runtime-state.ts +5 -0
  52. package/src/codex/account-store.ts +30 -3
  53. package/src/codex/auth-api/account-list.ts +3 -0
  54. package/src/codex/auth-api/main-account-probe.ts +3 -2
  55. package/src/codex/auth-context.ts +9 -4
  56. package/src/codex/catalog/aggregation.ts +15 -0
  57. package/src/codex/catalog/auto-review.ts +2 -1
  58. package/src/codex/catalog/build-entries.ts +17 -7
  59. package/src/codex/catalog/derive-entry.ts +3 -1
  60. package/src/codex/catalog/metadata.ts +2 -0
  61. package/src/codex/catalog/model-visibility.ts +0 -2
  62. package/src/codex/catalog/ownership.ts +71 -0
  63. package/src/codex/catalog/parsing.ts +12 -3
  64. package/src/codex/catalog/retained-sync.ts +2 -1
  65. package/src/codex/catalog/routed-gather.ts +3 -1
  66. package/src/codex/catalog/routed-removal.ts +4 -4
  67. package/src/codex/convergence.ts +2 -1
  68. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  69. package/src/codex/low-quota-protection.ts +57 -31
  70. package/src/codex/main-account-cache.ts +77 -3
  71. package/src/codex/main-account.ts +6 -2
  72. package/src/codex/native-residue.ts +2 -3
  73. package/src/codex/quota.ts +26 -2
  74. package/src/codex/websocket-activity.ts +161 -0
  75. package/src/codex/websocket-registry.ts +25 -1
  76. package/src/combos/failover.ts +2 -1
  77. package/src/combos/jev-model-backend.ts +30 -7
  78. package/src/combos/jev-model-request.ts +20 -0
  79. package/src/combos/jev-quota-activation.ts +153 -0
  80. package/src/combos/jev-quota-config.ts +62 -0
  81. package/src/combos/jev-quota-route.ts +26 -0
  82. package/src/combos/jev-quota.ts +88 -0
  83. package/src/combos/jev.ts +25 -12
  84. package/src/combos/types.ts +27 -0
  85. package/src/compatibility/index.ts +3 -0
  86. package/src/compatibility/openai-responses.ts +8 -1
  87. package/src/compatibility/opengateway-responses.ts +61 -0
  88. package/src/config/account-auto-pause-revisions.ts +136 -0
  89. package/src/config/diagnostics.ts +13 -0
  90. package/src/config/live-reconcile.ts +9 -0
  91. package/src/config/opencode-zen-upgrade.ts +9 -8
  92. package/src/config/persist-unlocked.ts +10 -1
  93. package/src/config/removed-opencode-free.ts +135 -0
  94. package/src/config/schema/account-auto-pause.ts +14 -0
  95. package/src/config/schema/config-schema.ts +15 -0
  96. package/src/config/schema/leaf-validators.ts +7 -0
  97. package/src/config.ts +10 -3
  98. package/src/generated/compatibility-version.json +411 -175
  99. package/src/images/loop.ts +101 -27
  100. package/src/lib/account-auto-pause-response.ts +16 -0
  101. package/src/lib/bun-runtime.ts +18 -1
  102. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  103. package/src/lib/bun-transpiler-cache-scope.mjs +68 -0
  104. package/src/lib/decision-quota-sink.ts +76 -0
  105. package/src/lib/request-failure-model.ts +2 -0
  106. package/src/lib/state-store-registrations.ts +25 -0
  107. package/src/link/ssh-argv.ts +3 -0
  108. package/src/messaging/envelope.ts +6 -3
  109. package/src/messaging/remote-auth.ts +83 -0
  110. package/src/messaging/remote-bridge.ts +270 -0
  111. package/src/messaging/remote-contract.ts +108 -0
  112. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  113. package/src/messaging/remote-enrollment.ts +244 -0
  114. package/src/messaging/remote-envelope.ts +40 -0
  115. package/src/messaging/remote-files.ts +117 -0
  116. package/src/messaging/remote-owner.ts +169 -0
  117. package/src/messaging/remote-ports.ts +56 -0
  118. package/src/messaging/remote-process.ts +131 -0
  119. package/src/messaging/remote-replies.ts +98 -0
  120. package/src/messaging/remote-rpc-admission.ts +32 -0
  121. package/src/messaging/remote-send.ts +72 -0
  122. package/src/messaging/remote-store.ts +111 -0
  123. package/src/messaging/remote-tunnels.ts +65 -0
  124. package/src/messaging/rpc.ts +13 -1
  125. package/src/oauth/account-auto-pause-policy.ts +66 -0
  126. package/src/oauth/account-auto-pause.ts +171 -0
  127. package/src/oauth/account-quota-rank.ts +2 -1
  128. package/src/oauth/anthropic-account-refusal.ts +6 -0
  129. package/src/oauth/anthropic-model-family.ts +8 -0
  130. package/src/oauth/anthropic-model-quota.ts +2 -6
  131. package/src/oauth/api-key-pool-preserve.ts +37 -0
  132. package/src/oauth/generic-account-failover.ts +7 -4
  133. package/src/oauth/index.ts +5 -35
  134. package/src/oauth/store.ts +35 -8
  135. package/src/oauth/types.ts +2 -0
  136. package/src/plugins/upstream-hooks.ts +14 -1
  137. package/src/protocols/encoders/adapter-events.ts +5 -5
  138. package/src/protocols/encoders/messages.ts +2 -2
  139. package/src/providers/api-key-pause.ts +32 -0
  140. package/src/providers/api-key-resolve.ts +10 -4
  141. package/src/providers/api-key-selection.ts +6 -1
  142. package/src/providers/api-keys.ts +8 -4
  143. package/src/providers/claude-cli-usage.ts +5 -0
  144. package/src/providers/key-failover.ts +11 -11
  145. package/src/providers/model-rename-fields.ts +1 -0
  146. package/src/providers/opencode-zen-rate-limit.ts +12 -73
  147. package/src/providers/quota/account-cache.ts +15 -1
  148. package/src/providers/quota/anthropic-account-quota.ts +2 -1
  149. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  150. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  151. package/src/providers/quota/antigravity.ts +39 -6
  152. package/src/providers/quota/report-cache.ts +14 -0
  153. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  154. package/src/providers/quota-decision-publication.ts +77 -0
  155. package/src/providers/quota-decision-snapshot.ts +145 -0
  156. package/src/providers/quota-routing-cache.ts +3 -0
  157. package/src/providers/quota-types.ts +6 -1
  158. package/src/providers/quota.ts +3 -2
  159. package/src/providers/registry/entries-extended.ts +8 -52
  160. package/src/providers/registry/model-seeds.ts +1 -1
  161. package/src/providers/registry.ts +1 -2
  162. package/src/providers/stale-vision-classification-migration.ts +1 -1
  163. package/src/responses/parser.ts +18 -10
  164. package/src/responses/reasoning-replay-cache.ts +50 -4
  165. package/src/responses/web-search-replay.ts +138 -0
  166. package/src/routing/analytics.ts +7 -15
  167. package/src/routing/compatibility/behavior.ts +4 -1
  168. package/src/server/auth-cors.ts +7 -0
  169. package/src/server/background-lifecycle.ts +2 -1
  170. package/src/server/chat-native.ts +28 -13
  171. package/src/server/claude-messages.ts +4 -2
  172. package/src/server/index/live-sideband.ts +3 -2
  173. package/src/server/index/optional-listeners.ts +6 -0
  174. package/src/server/index/serve-options.ts +11 -5
  175. package/src/server/index/websocket-handler.ts +18 -4
  176. package/src/server/index.ts +7 -7
  177. package/src/server/lifecycle.ts +13 -1
  178. package/src/server/management/combo-routes.ts +17 -2
  179. package/src/server/management/oauth-account-routes.ts +10 -1
  180. package/src/server/management/provider-completion-test.ts +289 -0
  181. package/src/server/management/provider-routes.ts +9 -1
  182. package/src/server/management/route-registry.ts +2 -0
  183. package/src/server/messages-native.ts +34 -7
  184. package/src/server/messages-shared.ts +4 -1
  185. package/src/server/request-log.ts +7 -2
  186. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  187. package/src/server/responses/adapter-continuation.ts +34 -18
  188. package/src/server/responses/adapter-delivery.ts +53 -6
  189. package/src/server/responses/adapter-dispatch.ts +104 -29
  190. package/src/server/responses/anthropic-thinking-recovery.ts +153 -0
  191. package/src/server/responses/combo-session-recall.ts +25 -0
  192. package/src/server/responses/combo-stream-preflight.ts +6 -3
  193. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  194. package/src/server/responses/compact.ts +8 -5
  195. package/src/server/responses/compaction-recovery.ts +2 -0
  196. package/src/server/responses/context-overflow.ts +141 -2
  197. package/src/server/responses/core-codex-account.ts +9 -3
  198. package/src/server/responses/core-combo.ts +34 -7
  199. package/src/server/responses/core-lifetime.ts +3 -2
  200. package/src/server/responses/core-options.ts +2 -0
  201. package/src/server/responses/core-replay.ts +5 -1
  202. package/src/server/responses/core.ts +12 -9
  203. package/src/server/responses/empty-completion-guard.ts +2 -0
  204. package/src/server/responses/fetch-helpers.ts +35 -4
  205. package/src/server/responses/jev-model-invoke.ts +13 -14
  206. package/src/server/responses/native-steering-log.ts +2 -2
  207. package/src/server/responses/passthrough-delivery.ts +3 -2
  208. package/src/server/responses/passthrough-dispatch.ts +30 -33
  209. package/src/server/responses/passthrough-execution.ts +3 -2
  210. package/src/server/responses/passthrough-observations.ts +38 -0
  211. package/src/server/responses/request-compression.ts +98 -0
  212. package/src/server/responses/request-prepare.ts +1 -0
  213. package/src/server/responses/request-transport.ts +32 -3
  214. package/src/server/responses/response-effects.ts +1 -0
  215. package/src/server/responses/response-tool-ownership.ts +21 -0
  216. package/src/server/responses/run-turn-execution.ts +2 -0
  217. package/src/server/responses/sidecar-execution.ts +59 -10
  218. package/src/server/responses/sidecar-send-budget.ts +2 -2
  219. package/src/server/responses/terminal-guard.ts +2 -0
  220. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  221. package/src/server/startup-action-control.ts +2 -2
  222. package/src/server/startup-health-cache.ts +2 -2
  223. package/src/server/windows-tray-control.ts +2 -2
  224. package/src/server/ws-bridge.ts +2 -0
  225. package/src/service/definition.ts +9 -1
  226. package/src/types/account-auto-pause.ts +15 -0
  227. package/src/types/config.ts +23 -1
  228. package/src/types/opencode-zen.ts +9 -3
  229. package/src/types/provider.ts +9 -5
  230. package/src/types/request.ts +30 -4
  231. package/src/types/wire.ts +4 -2
  232. package/src/types.ts +2 -0
  233. package/src/usage/cost.ts +17 -1
  234. package/src/usage/expected-prices.ts +3 -1
  235. package/src/usage/jev-stats.ts +4 -0
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/summary.ts +16 -3
  238. package/src/usage/telemetry-contract.ts +1 -0
  239. package/src/web-search/anthropic-history.ts +59 -0
  240. package/src/web-search/loop.ts +212 -73
  241. package/src/web-search/progress-stream.ts +14 -1
  242. package/src/web-search/run-turn-loop.ts +9 -3
  243. package/src/web-search/sidecar-search-replay.ts +96 -0
  244. package/gui/dist/assets/App-ZfYbLHkB.js +0 -58
  245. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
  246. package/gui/dist/assets/index-CfYDMj34.js +0 -94
@@ -267,17 +267,17 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
267
267
  startupOwnershipHomes = homes;
268
268
  startupOwnershipStatePaths = statePaths;
269
269
  } catch { /* inspection below stays unknown */ }
270
- const startupCacheOwnership = inspectStartupOwnership(
270
+ const startupCacheOwnership = shouldSyncCodexOnStart(config) ? inspectStartupOwnership(
271
271
  deps,
272
272
  startupOwnershipHomes,
273
273
  startupOwnershipStatePaths,
274
274
  startupWindowsTaskListingCache,
275
- );
275
+ ) : null;
276
276
  // Startup cache invalidation is best-effort and applies only when startup sync is enabled.
277
277
  // Check OFF before taking K: on Windows K resolves SID + LocalAppData through PowerShell,
278
278
  // and run 35093667426 exceeded healthy controls by 33.8 s against that 30 s child budget.
279
279
  // The permit callback still re-reads intent under K to close a concurrent disable race.
280
- if (shouldSyncCodexOnStart(config) && startupCacheOwnership.ownership === "owned" && startupOwnershipHomes !== null) {
280
+ if (startupCacheOwnership?.ownership === "owned" && startupOwnershipHomes !== null) {
281
281
  try {
282
282
  const startupCodexHome = startupOwnershipHomes.codexHome;
283
283
  // #1046: record whether this actually rewrote the cache. `handleStart` ORs this
@@ -549,12 +549,12 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
549
549
  // clients; no Codex request can use this lifecycle in that state.
550
550
  // Re-probe here instead of trusting the earlier cache decision: startup work
551
551
  // between the two sites must not widen the service-install race.
552
- const nativeOwnership = inspectStartupOwnership(
552
+ const nativeOwnership = shouldSyncCodexOnStart(config) ? inspectStartupOwnership(
553
553
  deps,
554
554
  startupOwnershipHomes,
555
555
  startupOwnershipStatePaths,
556
- );
557
- const preparedNativeMainLifecycle = nativeOwnership.ownership !== "foreign"
556
+ ) : null;
557
+ const preparedNativeMainLifecycle = nativeOwnership !== null && nativeOwnership.ownership !== "foreign"
558
558
  && startupOwnershipHomes !== null
559
559
  ? prepareNativeMainStartupLifecycle(
560
560
  deps.nativeMainStartup,
@@ -600,7 +600,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
600
600
  return retryPreparedNativeMainLifecycle.start();
601
601
  },
602
602
  };
603
- const nativeMainLifecycle: NativeMainStartupLifecycle = shouldSyncCodexOnStart(config)
603
+ const nativeMainLifecycle: NativeMainStartupLifecycle = nativeOwnership !== null
604
604
  ? nativeOwnership.ownership === "owned"
605
605
  ? preparedNativeMainLifecycle
606
606
  ? preparedNativeMainLifecycle.start()
@@ -38,6 +38,7 @@ export interface ActiveTurnLease extends AdmissionLease {
38
38
  attach(lease: AdmissionLease): void;
39
39
  bindAbortSignal(signal: AbortSignal): () => void;
40
40
  bindAbortController(ac: AbortController): void;
41
+ unbindAbortController(ac: AbortController): void;
41
42
  beginCodexAccountSelection(): CodexAccountSelectionAdmission;
42
43
  isTransferred(): boolean;
43
44
  }
@@ -231,6 +232,17 @@ export function tryAdmitTurn(sessionLaneId?: string, clientSignal?: AbortSignal)
231
232
  activeTurns.set(ac, lease);
232
233
  lease.bindAbortSignal(ac.signal);
233
234
  },
235
+ unbindAbortController(ac) {
236
+ if (!controllers.delete(ac)) return;
237
+ if (activeTurns.get(ac) === lease) activeTurns.delete(ac);
238
+ const listener = abortListeners.get(ac.signal);
239
+ if (listener) {
240
+ ac.signal.removeEventListener("abort", listener);
241
+ abortListeners.delete(ac.signal);
242
+ }
243
+ // A completed child relay cannot retire its still-running parent turn.
244
+ if (controllers.size === 0) lease.release();
245
+ },
234
246
  beginCodexAccountSelection() {
235
247
  const mainProfileDraining = nativeMainDrainOwners.size > 0;
236
248
  let selectionActive = active && !mainProfileDraining;
@@ -327,7 +339,7 @@ export function unregisterTurn(ac: AbortController): void {
327
339
  turnReleaseMisses += 1;
328
340
  return;
329
341
  }
330
- lease.release();
342
+ lease.unbindAbortController(ac);
331
343
  }
332
344
  export function isDraining(): boolean { return shutdownDraining || temporaryDrainOwners.size > 0; }
333
345
  export function getActiveTurnCount(): number { return turnGate.metrics().active; }
@@ -63,7 +63,7 @@ import {
63
63
  } from "../../vision/eligibility";
64
64
  import type { OcxClaudeCodeConfig, OcxComboConfig, OcxConfig, OcxCustomModel, OcxProviderConfig, OcxComboCooldownWaitPolicy } from "../../types";
65
65
  import { drainAndShutdown } from "../lifecycle";
66
- import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
66
+ import { notifyLiveConfigObservers, reconcileLiveStateStores } from "../../lib/state-store-registrations";
67
67
  import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
68
68
  import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
69
69
  import type { PersistedUsageAttempt } from "../../usage/log";
@@ -162,6 +162,7 @@ function parseVisionSidecarTargets(
162
162
  return { targets };
163
163
  }
164
164
 
165
+ /** Handle the `/api/combos` management routes (list, save, delete and related combo endpoints), validating and preserving the opt-in quota signal fields; null when no route matches. */
165
166
  export async function handleComboRoutes(ctx: ManagementContext): Promise<Response | null> {
166
167
  const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx;
167
168
 
@@ -222,6 +223,8 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
222
223
  const requestedCombo: Record<string, unknown> = body.combo;
223
224
  const effectiveCombo = {
224
225
  ...requestedCombo,
226
+ ...(!Object.hasOwn(requestedCombo, "decisionQuotaSignals") && requestedCombo.strategy === "jev" && previous?.decisionQuotaSignals !== undefined ? { decisionQuotaSignals: previous.decisionQuotaSignals } : {}),
227
+ ...(!Object.hasOwn(requestedCombo, "decisionQuotaTiers") && requestedCombo.strategy === "jev" && previous?.decisionQuotaTiers !== undefined ? { decisionQuotaTiers: previous.decisionQuotaTiers } : {}),
225
228
  ...(!Object.hasOwn(requestedCombo, "allowMeteredTargets") && previous?.allowMeteredTargets === true
226
229
  ? { allowMeteredTargets: true }
227
230
  : {}),
@@ -244,6 +247,11 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
244
247
  ...(!Object.hasOwn(requestedCombo, "imageInput") && previous?.imageInput !== undefined
245
248
  ? { imageInput: previous.imageInput }
246
249
  : {}),
250
+ // #7229: the dashboard does not edit the row description, so a round trip that omits it
251
+ // (including a rename) keeps it. An explicit empty string still clears it.
252
+ ...(!Object.hasOwn(requestedCombo, "description") && previous?.description !== undefined
253
+ ? { description: previous.description }
254
+ : {}),
247
255
  // `lastResort` rides on each target, so a GUI that re-sends the target list without the
248
256
  // flag would strip it. Carry it over per target, matched on provider+model.
249
257
  ...(Array.isArray(requestedCombo.targets) && Array.isArray(previous?.targets)
@@ -516,6 +524,8 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
516
524
  if (!(error instanceof ConfigWritePublishedError)) {
517
525
  return jsonResponse({ error: "combo could not be saved" }, 500);
518
526
  }
527
+ // Published but bookkeeping failed: live observers (JEV quota activation) still follow the saved combo.
528
+ notifyLiveConfigObservers(config);
519
529
  throw error;
520
530
  }
521
531
  reconcileLiveStateStores();
@@ -548,7 +558,12 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
548
558
  const { clearComboSelectionState, clearComboTargetCooldowns } = await import("../../combos");
549
559
  delete config.combos![id];
550
560
  if (Object.keys(config.combos!).length === 0) deleteConfigTopLevelKey(config, "combos");
551
- saveConfigPreservingClaudeCode(config);
561
+ try {
562
+ saveConfigPreservingClaudeCode(config);
563
+ } catch (error) {
564
+ if (error instanceof ConfigWritePublishedError) notifyLiveConfigObservers(config);
565
+ throw error;
566
+ }
552
567
  reconcileLiveStateStores();
553
568
  clearComboSelectionState(id);
554
569
  clearComboTargetCooldowns(id);
@@ -510,7 +510,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
510
510
  reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined,
511
511
  });
512
512
  return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health), quotaMode,
513
- ...(supportsPause ? { paused: full?.paused === true } : {}),
513
+ ...(supportsPause ? { paused: full?.paused === true, ...(full?.pauseDetails ? { pauseDetails: full.pauseDetails } : {}) } : {}),
514
514
  ...(isAnthropicOAuthInstance(provider) && supportsPause ? { autoSwitchThresholdOverride: full?.autoSwitchThresholdOverride ?? null,
515
515
  effectiveAutoSwitchThreshold: effectiveAnthropicAccountThresholdForInstance(provider as AnthropicInstanceId, config, full),
516
516
  autoSwitchThreshold: effectiveAnthropicAccountThresholdForInstance(provider as AnthropicInstanceId, config) } : {}),
@@ -1116,6 +1116,15 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
1116
1116
  clearProviderQuotaCache();
1117
1117
  return jsonResponse({ ...result, name, store: providerKeyStoreKind(config.providers[name]) });
1118
1118
  }
1119
+ if (url.pathname === "/api/providers/keys/pause" && req.method === "PUT") {
1120
+ const body = await readManagementJsonBodyOr(req, {});
1121
+ if (!isPlainRecord(body)) return jsonResponse({ error: "body must be an object" }, 400);
1122
+ if (typeof body.name !== "string" || typeof body.id !== "string" || typeof body.paused !== "boolean") return jsonResponse({ error: "name, id and paused required" }, 400);
1123
+ if (!isValidProviderName(body.name) || !hasOwnProvider(config.providers, body.name)) return jsonResponse({ error: "unknown provider" }, 404);
1124
+ const { setProviderApiKeyPaused } = await import("../../providers/api-key-pause");
1125
+ return setProviderApiKeyPaused(config, body.name, body.id, body.paused)
1126
+ ? jsonResponse({ ok: true }) : jsonResponse({ error: "key not found" }, 404);
1127
+ }
1119
1128
  if (url.pathname === "/api/providers/keys/active" && req.method === "PUT") {
1120
1129
  const body = await readManagementJsonBodyOr(req, {}) as { name?: string; id?: string };
1121
1130
  const name = (body.name ?? "").trim();
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Explicit single-model diagnostic, isolated from discovery, routing health and request history.
3
+ * Unsupported transports: TLS profiles, HTTP version pins, enabled pacing, custom fetch + proxy.
4
+ * Adding an exclusion requires a regression test. No retries, failover, or output preview.
5
+ */
6
+ import type { OcxConfig, OcxProviderConfig } from "../../types";
7
+ import { routedProviderConfig } from "../../providers/routed-config";
8
+ import { PROVIDER_REGISTRY } from "../../providers/registry";
9
+ import { resolveModelAlias } from "../../providers/default-aliases";
10
+ import { providerApiKeySelectionIsCurrent } from "../../providers/api-key-selection";
11
+ import { resolveWireProtocolOverride } from "../adapter-resolve";
12
+ import { openAIChatTransport, stripBracketedModelSuffix } from "../../adapters/openai-chat/wire";
13
+ import { buildResponsesTransport } from "../../adapters/openai-responses/transport";
14
+ import { openRouterProviderPayload, resolveOpenRouterRouting } from "../../providers/openrouter-routing";
15
+ import { resolveVercelGatewayRouting, vercelGatewayProviderPayload } from "../../providers/vercel-gateway-routing";
16
+ import { assertProviderDestinationAllowed, providerAllowsPrivateNetwork } from "../../lib/destination-policy";
17
+ import { providerOutboundPost, ProviderOutboundPolicyError, ProviderOutboundSendCancelledError,
18
+ type ProviderOutboundDependencies } from "../../lib/provider-outbound";
19
+ import { redactSecretString } from "../../lib/redact";
20
+ import { decodeServerSentEvents } from "../../lib/sse-decoder";
21
+ import { createTranslatorBudget } from "../../lib/translator-budget";
22
+ import { jsonResponse } from "../auth-cors";
23
+ import type { ManagementContext } from "./context";
24
+ import { managementBodyTooLargeResponse, readManagementJsonBody } from "./body";
25
+
26
+ export const COMPLETION_TEST_PROMPT = "Reply exactly OK.";
27
+ export const COMPLETION_TEST_OUTPUT_TOKEN_CAP = 32;
28
+ export const COMPLETION_TEST_TIMEOUT_MS = 20_000;
29
+ export const COMPLETION_TEST_MAX_BYTES = 64 * 1024;
30
+
31
+ type Wire = "openai-chat" | "openai-responses";
32
+ type Code = "destination_refused" | "credential_missing" | "credential_changed" | "http_status" | "redirect"
33
+ | "network" | "stream_error" | "premature_eof" | "no_content" | "timeout" | "aborted" | "too_large";
34
+ const MESSAGES: Record<Code, string> = {
35
+ destination_refused: "Provider destination refused by policy.",
36
+ credential_missing: "Provider credential is missing.",
37
+ credential_changed: "Provider request authority changed before send.",
38
+ http_status: "Upstream returned an unsuccessful HTTP status.",
39
+ redirect: "Upstream redirect refused.",
40
+ network: "Upstream network request failed.",
41
+ stream_error: "Upstream stream reported an error.",
42
+ premature_eof: "Upstream stream ended without a terminal event.",
43
+ no_content: "Upstream completed without a text delta.",
44
+ timeout: "Completion test deadline exceeded.",
45
+ aborted: "Completion test was cancelled.",
46
+ too_large: "Upstream stream exceeded the byte limit.",
47
+ };
48
+ export type ProviderCompletionTestResult = {
49
+ applicable: false;
50
+ reason: "disabled" | "forward_auth" | "oauth_auth" | "unsupported_adapter" | "unsupported_transport";
51
+ adapter?: string;
52
+ detail?: "tls_profile" | "upstream_http_version" | "request_pacing" | "custom_executor_egress";
53
+ } | {
54
+ applicable: true; ok: boolean; provider: string; requestedModel: string; wireModel: string; wire: Wire;
55
+ upstreamModel: string | null; modelComparison: "match" | "mismatch" | "unknown";
56
+ comparisonNote?: "missing_metadata" | "conflicting_metadata";
57
+ identityVerified: false; ttftMs: number | null; totalLatencyMs: number; outputTokenCap: 32;
58
+ termination: "completed" | "failed" | "timeout" | "aborted" | "too_large";
59
+ error: null | { code: Code; status?: number; message: string };
60
+ };
61
+ export interface ProviderCompletionTestInput { config: OcxConfig; name: string; model: string; signal?: AbortSignal }
62
+ export interface ProviderCompletionTestDependencies {
63
+ now?: () => number;
64
+ timeoutMs?: number;
65
+ outboundDependencies?: ProviderOutboundDependencies;
66
+ }
67
+
68
+ function transportExclusion(row: OcxProviderConfig): Extract<ProviderCompletionTestResult, { applicable: false }>["detail"] {
69
+ if (row.tlsProfile !== undefined) return "tls_profile";
70
+ if (row.upstreamHttpVersion !== undefined) return "upstream_http_version";
71
+ if (row.requestPacing?.enabled) return "request_pacing";
72
+ if (typeof (row as { fetch?: unknown }).fetch === "function" && "proxy" in row) return "custom_executor_egress";
73
+ return undefined;
74
+ }
75
+ function nativeModel(config: OcxConfig, row: OcxProviderConfig, model: string): string {
76
+ const known = new Set([...(row.models ?? []), ...Object.keys(row.modelAliases ?? {})]);
77
+ return known.has(model) ? model : resolveModelAlias(config, row, known, model) ?? model;
78
+ }
79
+ function derive(config: OcxConfig, name: string, row: OcxProviderConfig, model: string) {
80
+ const resolved = routedProviderConfig(name, row);
81
+ const native = nativeModel(config, resolved, model);
82
+ const routed = resolveWireProtocolOverride(name, native, resolved);
83
+ const wire = routed.adapter;
84
+ if (wire !== "openai-chat" && wire !== "openai-responses") throw new Error("unsupported adapter");
85
+ const transport = wire === "openai-chat" ? openAIChatTransport(routed)
86
+ : buildResponsesTransport(routed, { headers: new Headers() });
87
+ const headers = new Headers(transport.headers);
88
+ headers.set("Accept", "text/event-stream");
89
+ const wireModel = routed.modelSuffixBracketStrip ? stripBracketedModelSuffix(native) : native;
90
+ const openRouterRouting = wire === "openai-chat" ? resolveOpenRouterRouting(routed, native) : undefined;
91
+ const vercelRouting = wire === "openai-chat" ? resolveVercelGatewayRouting(routed, native) : undefined;
92
+ const providerRouting = vercelRouting ? vercelGatewayProviderPayload(vercelRouting)
93
+ : openRouterRouting ? openRouterProviderPayload(openRouterRouting) : undefined;
94
+ // Normal inference admits registry-local defaults as well as the operator's explicit opt-in.
95
+ // Materialize that effective policy for the outbound wrapper's narrower local HTTP safeguard.
96
+ const allowLocalCleartextPost = providerAllowsPrivateNetwork(name, routed);
97
+ // Capture primitive values now: rows and their nested header objects may mutate in place.
98
+ const selection = routed._apiKeyAttempt;
99
+ const fingerprint = JSON.stringify({ wire, providerRouting, allowLocalCleartextPost, url: transport.url, wireModel, headers: [...headers].sort(([a], [b]) => a.localeCompare(b)),
100
+ authMode: routed.authMode, apiKey: routed.apiKey,
101
+ selection: selection && { entryId: selection.entryId, reference: selection.reference, revision: selection.revision },
102
+ allowPrivateNetwork: routed.allowPrivateNetwork, proxy: routed.proxy, noProxy: routed.noProxy, disabled: row.disabled });
103
+ return { routed, wire: wire as Wire, wireModel, providerRouting, allowLocalCleartextPost, headers, url: transport.url, fingerprint };
104
+ }
105
+ function maskedModel(raw: string, secrets: Array<string | undefined>): string {
106
+ if (secrets.some(secret => typeof secret === "string" && secret.length > 0 && raw.includes(secret))) return "[redacted]";
107
+ return redactSecretString(raw.replace(/[\x00-\x1f\x7f-\x9f]/g, "").slice(0, 256));
108
+ }
109
+ function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
110
+ return new Promise((resolve, reject) => {
111
+ const abort = () => reject(signal.reason);
112
+ signal.addEventListener("abort", abort, { once: true });
113
+ if (signal.aborted) abort();
114
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
115
+ });
116
+ }
117
+ class DiagnosticFailure extends Error { constructor(readonly code: Code) { super(MESSAGES[code]); } }
118
+
119
+ /** Direct diagnostic; callers supply an existing provider name and an already validated model. */
120
+ export async function runProviderCompletionTest(
121
+ { config, name, model, signal: requestSignal }: ProviderCompletionTestInput,
122
+ deps: ProviderCompletionTestDependencies = {},
123
+ ): Promise<ProviderCompletionTestResult> {
124
+ const row = config.providers[name];
125
+ if (!Object.hasOwn(config.providers, name) || !row) throw new Error("unknown provider");
126
+ if (row.disabled) return { applicable: false, reason: "disabled" };
127
+ const authKind = PROVIDER_REGISTRY.find(entry => entry.id === name)?.authKind;
128
+ if (row.authMode === "forward" || authKind === "forward") return { applicable: false, reason: "forward_auth" };
129
+ if (row.authMode === "oauth" || authKind === "oauth") return { applicable: false, reason: "oauth_auth" };
130
+ const detail = transportExclusion(row);
131
+ if (detail) return { applicable: false, reason: "unsupported_transport", detail };
132
+ // Metadata resolution must never dereference keychain/env/pool credentials.
133
+ const { apiKey: _key, apiKeyPool: _pool, _apiKeyAttempt: _attempt, ...metadata } = row;
134
+ let snapshot: OcxProviderConfig = metadata;
135
+ let destinationRefused = false;
136
+ try { snapshot = routedProviderConfig(name, metadata); } catch { destinationRefused = true; }
137
+ const native = nativeModel(config, snapshot, model);
138
+ const wire = resolveWireProtocolOverride(name, native, snapshot).adapter;
139
+ if (wire !== "openai-chat" && wire !== "openai-responses") return { applicable: false, reason: "unsupported_adapter", adapter: wire };
140
+ const now = deps.now ?? (() => performance.now());
141
+ const result: Extract<ProviderCompletionTestResult, { applicable: true }> = {
142
+ applicable: true, ok: false, provider: name, requestedModel: model,
143
+ wireModel: snapshot.modelSuffixBracketStrip ? stripBracketedModelSuffix(native) : native, wire,
144
+ upstreamModel: null, modelComparison: "unknown", comparisonNote: "missing_metadata", identityVerified: false,
145
+ ttftMs: null, totalLatencyMs: 0, outputTokenCap: COMPLETION_TEST_OUTPUT_TOKEN_CAP, termination: "failed", error: null,
146
+ };
147
+ const fail = (code: Code, status?: number) => {
148
+ result.error = { code, message: MESSAGES[code], ...(status !== undefined ? { status } : {}) };
149
+ result.termination = code === "timeout" || code === "aborted" || code === "too_large" ? code : "failed";
150
+ return result;
151
+ };
152
+ if (destinationRefused) return fail("destination_refused");
153
+ try { assertProviderDestinationAllowed(name, snapshot); } catch { return fail("destination_refused"); }
154
+ let resolved: OcxProviderConfig;
155
+ try { resolved = routedProviderConfig(name, row); } catch { return fail("credential_missing"); }
156
+ if (resolved.authMode === "key" && !resolved.keyOptional && !resolved.apiKey?.trim()) return fail("credential_missing");
157
+ let authority: ReturnType<typeof derive>;
158
+ try { authority = derive(config, name, row, model); } catch { return fail("network"); }
159
+ const secrets = [authority.routed.apiKey, ...Object.values(authority.routed.headers ?? {}),
160
+ ...[...authority.headers].filter(([name]) => name === "authorization" || name === "x-api-key").map(([, value]) => value)];
161
+ result.wire = authority.wire;
162
+ result.wireModel = authority.wireModel;
163
+ const controller = new AbortController();
164
+ const deadline = AbortSignal.timeout(deps.timeoutMs ?? COMPLETION_TEST_TIMEOUT_MS);
165
+ const signal = AbortSignal.any([...(requestSignal ? [requestSignal] : []), deadline, controller.signal]);
166
+ const started = now();
167
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
168
+ let response: Response | undefined;
169
+ const translatorBudget = createTranslatorBudget();
170
+ let observed: string | undefined;
171
+ let conflicting = false, terminal = false, content = false, finishReason = false;
172
+ const observe = (value: unknown) => {
173
+ if (typeof value !== "string") return;
174
+ if (observed === undefined) observed = value;
175
+ else if (value !== observed) conflicting = true;
176
+ };
177
+ try {
178
+ signal.throwIfAborted();
179
+ const body = JSON.stringify(authority.wire === "openai-chat"
180
+ ? { model: authority.wireModel, messages: [{ role: "user", content: COMPLETION_TEST_PROMPT }], stream: true, max_tokens: 32,
181
+ ...(authority.providerRouting ? { provider: authority.providerRouting } : {}) }
182
+ : { model: authority.wireModel, input: COMPLETION_TEST_PROMPT, stream: true, max_output_tokens: 32, store: false });
183
+ const pending = providerOutboundPost(name, { ...authority.routed, allowPrivateNetwork: authority.allowLocalCleartextPost }, authority.url, { headers: authority.headers, body, signal }, {
184
+ ...deps.outboundDependencies,
185
+ allowLocalCleartextPost: authority.allowLocalCleartextPost,
186
+ beforeSend: () => {
187
+ try {
188
+ const current = config.providers[name];
189
+ return current === row && !current.disabled && !transportExclusion(current)
190
+ && derive(config, name, current, model).fingerprint === authority.fingerprint
191
+ && (!authority.routed.apiKey || providerApiKeySelectionIsCurrent(config, name, authority.routed));
192
+ } catch { return false; }
193
+ },
194
+ });
195
+ // Even an executor ignoring AbortSignal cannot retain a late response body.
196
+ void pending.then(late => { if (signal.aborted) void late.body?.cancel().catch(() => {}); }, () => {});
197
+ response = await abortable(pending, signal);
198
+ if (response.status >= 300 && response.status < 400) return fail("redirect");
199
+ if (!response.ok) return fail("http_status", response.status);
200
+ if (!response.body) return fail("premature_eof");
201
+ reader = response.body.getReader();
202
+ let bytes = 0;
203
+ const counted = new ReadableStream<Uint8Array>({
204
+ async pull(target) {
205
+ try {
206
+ const next = await abortable(reader!.read(), signal);
207
+ if (next.done) { target.close(); return; }
208
+ bytes += next.value.byteLength;
209
+ if (bytes > COMPLETION_TEST_MAX_BYTES) throw new DiagnosticFailure("too_large");
210
+ target.enqueue(next.value);
211
+ } catch (error) { target.error(error); }
212
+ },
213
+ cancel() { void reader!.cancel().catch(() => {}); },
214
+ }, { highWaterMark: 0 });
215
+ for await (const event of decodeServerSentEvents(counted, { signal, translatorBudget })) {
216
+ if (authority.wire === "openai-chat" && event.data.trim() === "[DONE]") { terminal = true; break; }
217
+ let chunk: Record<string, any>;
218
+ try { chunk = JSON.parse(event.data); } catch { throw new DiagnosticFailure("stream_error"); }
219
+ if (!chunk || typeof chunk !== "object" || chunk.error || event.event === "error" || chunk.type === "error" || chunk.type === "response.failed") {
220
+ throw new DiagnosticFailure("stream_error");
221
+ }
222
+ let text = false;
223
+ if (authority.wire === "openai-chat") {
224
+ observe(chunk.model);
225
+ if (Array.isArray(chunk.choices)) for (const choice of chunk.choices) {
226
+ if (typeof choice?.delta?.content === "string" && choice.delta.content.length > 0) text = true;
227
+ if (choice?.finish_reason != null) finishReason = true;
228
+ }
229
+ } else {
230
+ const type = chunk.type ?? event.event;
231
+ if (["response.created", "response.completed", "response.incomplete"].includes(type)) observe(chunk.response?.model);
232
+ text = type === "response.output_text.delta" && typeof chunk.delta === "string" && chunk.delta.length > 0;
233
+ terminal = type === "response.completed" || type === "response.incomplete";
234
+ if (type === "response.failed") throw new DiagnosticFailure("stream_error");
235
+ }
236
+ if (text) { content = true; result.ttftMs ??= Math.max(0, Math.round(now() - started)); }
237
+ if (terminal) break;
238
+ }
239
+ signal.throwIfAborted();
240
+ if (!terminal && !(authority.wire === "openai-chat" && finishReason)) return fail("premature_eof");
241
+ if (!content) return fail("no_content");
242
+ result.ok = true;
243
+ result.termination = "completed";
244
+ return result;
245
+ } catch (error) {
246
+ return fail(requestSignal?.aborted ? "aborted" : deadline.aborted ? "timeout"
247
+ : error instanceof DiagnosticFailure ? error.code
248
+ : error instanceof ProviderOutboundSendCancelledError ? "credential_changed"
249
+ : error instanceof ProviderOutboundPolicyError ? "destination_refused" : "network");
250
+ } finally {
251
+ translatorBudget.dispose();
252
+ result.totalLatencyMs = Math.max(0, Math.round(now() - started));
253
+ if (observed !== undefined && !conflicting) {
254
+ result.modelComparison = observed === authority.wireModel ? "match" : "mismatch";
255
+ delete result.comparisonNote;
256
+ result.upstreamModel = maskedModel(observed, secrets);
257
+ } else if (conflicting) result.comparisonNote = "conflicting_metadata";
258
+ controller.abort();
259
+ // Cancellation is initiated on every exit; do not wait on a misbehaving executor.
260
+ if (reader) { void reader.cancel().catch(() => {}); try { reader.releaseLock(); } catch { /* read pending */ } }
261
+ else void response?.body?.cancel().catch(() => {});
262
+ }
263
+ }
264
+
265
+ const inFlight = new Set<string>();
266
+ export async function handleProviderCompletionTestRoute(
267
+ { req, url, config }: Pick<ManagementContext, "req" | "url" | "config">,
268
+ deps: ProviderCompletionTestDependencies = {},
269
+ ): Promise<Response> {
270
+ let body: unknown;
271
+ try { body = await readManagementJsonBody(req); } catch (error) {
272
+ return managementBodyTooLargeResponse(error, req, config) ?? jsonResponse({ error: "invalid completion test request" }, 400);
273
+ }
274
+ if (!body || typeof body !== "object" || Array.isArray(body)
275
+ || Object.keys(body).some(key => key !== "model" && key !== "confirm")
276
+ || (body as { confirm?: unknown }).confirm !== true || typeof (body as { model?: unknown }).model !== "string") {
277
+ return jsonResponse({ error: "invalid completion test request" }, 400);
278
+ }
279
+ const model = (body as { model: string }).model.trim();
280
+ if (!model || model.length > 256 || /[\x00-\x1f\x7f-\x9f]/.test((body as { model: string }).model)) {
281
+ return jsonResponse({ error: "invalid completion test request" }, 400);
282
+ }
283
+ const name = url.searchParams.get("name") ?? "";
284
+ if (!Object.hasOwn(config.providers, name) || !config.providers[name]) return jsonResponse({ error: "unknown provider" }, 404);
285
+ if (inFlight.has(name)) return jsonResponse({ error: "completion test already running for this provider" }, 409);
286
+ inFlight.add(name);
287
+ try { return jsonResponse(await runProviderCompletionTest({ config, name, model, signal: req.signal }, deps)); }
288
+ finally { inFlight.delete(name); }
289
+ }
@@ -764,7 +764,7 @@ function applyProviderPatchFields(
764
764
  if (Object.hasOwn(rawBody, "headers")) {
765
765
  const headersValue = rawBody.headers;
766
766
  if (headersValue === null || (isPlainRecord(headersValue) && Object.keys(headersValue).length === 0)) {
767
- // Registry-owned static metadata (e.g. opencode-free's x-opencode-client marker)
767
+ // Registry-owned static metadata (e.g. meta-muse's x-api-version marker)
768
768
  // is not user-managed: restoring it keeps the upstream transport intact after a
769
769
  // clear instead of deleting the whole block.
770
770
  const entry = getProviderRegistryEntry(name);
@@ -1357,6 +1357,12 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1357
1357
  if (submittedModelContextTiers && liveTiers) candidate.modelContextTiers = Object.assign(Object.create(null), liveTiers, candidate.modelContextTiers ?? {});
1358
1358
  else if (!submittedModelContextTiers && liveTiers) candidate.modelContextTiers = { ...liveTiers };
1359
1359
  else if (!submittedModelContextTiers) delete candidate.modelContextTiers;
1360
+ // Read omitted policy at publication time, after any concurrent operator edit.
1361
+ if (!Object.hasOwn(rawProvider, "accountAutoPause")) {
1362
+ const livePolicy = config.providers[name]?.accountAutoPause;
1363
+ if (livePolicy === undefined) delete candidate.accountAutoPause;
1364
+ else candidate.accountAutoPause = structuredClone(livePolicy);
1365
+ }
1360
1366
  // Full forms omit the opt-in; preserve the latest value, including a concurrent clear.
1361
1367
  if (!Object.hasOwn(rawProvider, "inferContextWindow")) {
1362
1368
  const liveInference = config.providers[name]?.inferContextWindow;
@@ -1602,6 +1608,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1602
1608
  });
1603
1609
  }
1604
1610
 
1611
+ if (url.pathname === "/api/providers/test-completion" && req.method === "POST") return (await import("./provider-completion-test")).handleProviderCompletionTestRoute(ctx);
1612
+
1605
1613
  // Lightweight connectivity probe: perform the provider's live /models fetch DIRECTLY and
1606
1614
  // report only real upstream evidence. The catalog aggregate (fetchAllModels) deliberately
1607
1615
  // hides fetch failures behind stale/static fallbacks, so a catalog-presence check would
@@ -364,6 +364,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
364
364
  { method: "PUT", path: "/api/oauth/accounts/pool", module: "server/management/oauth-account-routes", mutates: true },
365
365
  { method: "PUT", path: "/api/providers/keys/active", module: "server/management/oauth-account-routes", mutates: true },
366
366
  { method: "PUT", path: "/api/providers/keys/alias", module: "server/management/oauth-account-routes", mutates: true },
367
+ { method: "PUT", path: "/api/providers/keys/pause", module: "server/management/oauth-account-routes", mutates: true },
367
368
  // server/management/provider-routes
368
369
  { method: "DELETE", path: "/api/providers", module: "server/management/provider-routes", mutates: true },
369
370
  { method: "GET", path: "/api/provider-context-caps", module: "server/management/provider-routes", mutates: false },
@@ -373,6 +374,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
373
374
  { method: "GET", path: "/api/providers", module: "server/management/provider-routes", mutates: false },
374
375
  { method: "PATCH", path: "/api/providers", module: "server/management/provider-routes", mutates: true },
375
376
  { method: "POST", path: "/api/providers", module: "server/management/provider-routes", mutates: true },
377
+ { method: "POST", path: "/api/providers/test-completion", module: "server/management/provider-routes", mutates: true },
376
378
  { method: "POST", path: "/api/providers/test", module: "server/management/provider-routes", mutates: true },
377
379
  { method: "PUT", path: "/api/providers", module: "server/management/provider-routes", mutates: true },
378
380
  { method: "PUT", path: "/api/provider-context-caps", module: "server/management/provider-routes", mutates: true },
@@ -1,3 +1,4 @@
1
+ import { captureAccountAutoPauseAttempt, wasAccountAutoPaused } from "../oauth/account-auto-pause";
1
2
  import { createPhysicalSendReporter } from "../lib/request-execution-budget";
2
3
  import { createInferenceSendBudget } from "./inference/context";
3
4
  /**
@@ -44,6 +45,7 @@ import { classifyError } from "../lib/errors";
44
45
  import { relaySseWithPayloadRewrite } from "./sse-payload-rewrite";
45
46
  import { redactSecretString } from "../lib/redact";
46
47
  import { resolveClientRetryAfter } from "../lib/retry-after";
48
+ import { enrichOpenCodeZenUpstreamMessage } from "../providers/opencode-zen-rate-limit";
47
49
  import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../lib/translator-budget";
48
50
  import {
49
51
  applyReplayRefusalClientHeaders,
@@ -119,7 +121,7 @@ import {
119
121
  recordKeyWireAttemptUsage,
120
122
  type RequestLogContext,
121
123
  } from "./request-log";
122
- import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, sendWithConnectionPolicy } from "./responses/fetch-helpers";
124
+ import { fetchWithHeaderTimeout, physicalSendObserverInit, providerFetch, safeHostLabel, sendWithConnectionPolicy } from "./responses/fetch-helpers";
123
125
  import { anthropicBillingPhysicalFetch } from "../providers/anthropic-billing-ownership";
124
126
  import { rotateAnthropicBillingKeyOnResponse } from "../providers/anthropic-billing-recovery";
125
127
  import { linkAbortSignal } from "./responses/core-lifetime";
@@ -544,6 +546,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
544
546
  pendingDistributedFailure = undefined;
545
547
  record?.();
546
548
  };
549
+ /** Send the native Messages request under the transient retry policy, re-validating the selected OAuth account or API key at the dispatch boundary. */
547
550
  const send = async (recovery?: "rate-limit-429" | "oauth-401" | "oauth-account-403" | "key-429" | "key-401"): Promise<Response> => {
548
551
  const remaining = remainingTransientSends();
549
552
  if (requestTransientPolicy && remaining <= 0) {
@@ -615,6 +618,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
615
618
  }
616
619
  const releaseFamily = snapshot && oauthBinding ? anthropicModelQuotaFor(oauthBinding.instance).claimAnthropicFamilyRevalidation(snapshot.accountId, route.modelId) : () => {};
617
620
  if (!releaseFamily) throw new AnthropicAccountCooldownError(1);
621
+ const observeAutoPause = captureAccountAutoPauseAttempt(route.providerName, activeProvider, { config, oauth: sendingBinding?.snapshot }, headers);
618
622
  const selection = activeProvider._apiKeyAttempt;
619
623
  let dispatched: Response;
620
624
  const spendReport = createPhysicalSendReporter(physicalBudget, () => ({
@@ -640,7 +644,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
640
644
  anthropicBillingPhysicalFetch((activeProvider as OcxProviderTransport).fetch ?? execute, route.providerName, activeProvider),
641
645
  route.providerName, activeProvider, wire.url, activeProvider.apiKeyTransport === "bearer" ? "authorization" : "x-api-key"),
642
646
  wire.url,
643
- applyUpstreamRecoveryInit({ ...init, method: "POST", headers, body: wire.body }, transportRecovery),
647
+ applyUpstreamRecoveryInit({ ...init, method: "POST", headers, body: wire.body, ...physicalSendObserverInit(observeAutoPause, wire.url) }, transportRecovery),
644
648
  { providerName: route.providerName, provider: activeProvider },
645
649
  );
646
650
  } finally {
@@ -658,7 +662,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
658
662
  && credentialGeneration(current.credential) === snapshot.generation
659
663
  && current.credential.accountId === providerAccountUuid) {
660
664
  bindAnthropicRefusalCredentialForSend(dispatched, sendOwner, providerAccountUuid);
661
- recordAnthropicAccountQuotaFromHeadersForInstance(nativeInstance!, snapshot.accountId, dispatched.headers, writerGeneration, dispatched.status, route.modelId);
665
+ recordAnthropicAccountQuotaFromHeadersForInstance(nativeInstance!, snapshot.accountId, dispatched.headers, writerGeneration, dispatched.status, route.modelId, snapshot.generation);
662
666
  }
663
667
  } catch { /* Passive observation must not fail the response. */ }
664
668
  }
@@ -671,7 +675,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
671
675
  ? () => { recordDistributedResponseFailure(dispatched, config, route.providerName, req.signal); }
672
676
  : undefined;
673
677
  if (!dispatched.ok) await recordKeyAttemptFailure(logCtx, dispatched, init.signal ?? upstream.signal);
674
- return dispatched;
678
+ return observeAutoPause ? observeAutoPause(dispatched) : dispatched;
675
679
  },
676
680
  }),
677
681
  ),
@@ -699,9 +703,19 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
699
703
  let billingRecoveryActive = false;
700
704
  const keySendAvailable = () => transientSendAvailable()
701
705
  && (!billingRecoveryActive || Math.max(physicalSendsUsed, physicalBudget.used) < physicalBudget.policy.maxTotalModelSends);
706
+ let autoPauseMoves = 0;
702
707
  const recoveryPasses = activeProvider._apiKeyAttempt?.distributed ? activeProvider.apiKeyPool?.length ?? 1 : 1;
703
708
  for (let pass = 0; ; pass++) {
704
709
  const priorResponse = response;
710
+ for (; wasAccountAutoPaused(response) && !oauthBinding && config.providers[route.providerName]?.apiKey && autoPauseMoves < 3 && keySendAvailable(); autoPauseMoves++) {
711
+ const fresh = resolveCurrentProviderApiKeyTransport(config, route.providerName, { ...activeProvider, _apiKeyAttempt: undefined });
712
+ const next = fresh && (selectProactiveApiKeyTransport(config, route.providerName, fresh) ?? fresh);
713
+ if (!next) break;
714
+ try { void response.body?.cancel().catch(() => {}); } catch { /* closed */ }
715
+ rebuildFor(next);
716
+ response = await send("key-401");
717
+ }
718
+
705
719
  // A credential-scoped 401 on a static key pool says nothing about the sibling keys.
706
720
  while (response.status === 401 && distributedResponseOwnsKey && hasKeyPoolFailover(activeProvider) && keySendAvailable()) {
707
721
  const rotated = rotateProviderTransportOn401(config, route.providerName, activeProvider, {
@@ -761,6 +775,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
761
775
  if (response === priorResponse || isNonReplayableResponse(response) || !keySendAvailable()) break;
762
776
  // Billing class transitions share the physical-send ceiling; distributed-only
763
777
  // transitions additionally retain their original bounded pool-pass allowance.
778
+ if (wasAccountAutoPaused(response) && autoPauseMoves < 3) continue;
764
779
  if (distributedResponseOwnsKey && (
765
780
  (billingRecoveryActive && [401, 429].includes(response.status))
766
781
  || (activeProvider.adapter === "anthropic" && hasKeyPoolFailover(activeProvider) && [400, 402].includes(response.status))
@@ -845,7 +860,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
845
860
  upstream.abort();
846
861
  return fail(499, "Client cancelled request", "api_error");
847
862
  }
848
- return retainUpstreamMessagesRequestId(nativeMessagesErrorResponse(response, bodyText, finishLog), response.headers);
863
+ return retainUpstreamMessagesRequestId(nativeMessagesErrorResponse(response, bodyText, finishLog, route.providerName, activeProvider), response.headers);
849
864
  }
850
865
 
851
866
  const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
@@ -999,7 +1014,7 @@ function isNativeOverflowError(error: Rec, message: string): boolean {
999
1014
  * Messages path, so a client sees the same contract on either lane: transient 5xx become 529
1000
1015
  * with a retry hint, and a proxy-side replay refusal keeps its no-retry marking.
1001
1016
  */
1002
- function nativeMessagesErrorResponse(response: Response, bodyText: string, finishLog: FinishLog): Response {
1017
+ function nativeMessagesErrorResponse(response: Response, bodyText: string, finishLog: FinishLog, providerName: string, provider: OcxProviderConfig): Response {
1003
1018
  let upstreamType: string | undefined;
1004
1019
  let upstreamCode: string | undefined;
1005
1020
  let upstreamMessage: string | undefined;
@@ -1013,8 +1028,20 @@ function nativeMessagesErrorResponse(response: Response, bodyText: string, finis
1013
1028
  }
1014
1029
  } catch { /* keep generic classification */ }
1015
1030
  const detail = formatAnthropicErrorBody(response.status, response.headers, bodyText);
1016
- const message = upstreamMessage
1031
+ const rawMessage = upstreamMessage
1017
1032
  ?? (detail ? `Provider error ${response.status}: ${detail}` : `Provider error ${response.status}`);
1033
+ const message = enrichOpenCodeZenUpstreamMessage(rawMessage, {
1034
+ status: response.status,
1035
+ providerName,
1036
+ baseUrl: provider.baseUrl,
1037
+ adapter: provider.adapter,
1038
+ authMode: provider.authMode,
1039
+ hasApiKey: Boolean(provider.apiKey?.trim()),
1040
+ upstreamRetryAfter: response.headers.get("retry-after"),
1041
+ upstreamErrorType: upstreamType,
1042
+ upstreamErrorCode: upstreamCode,
1043
+ supportsHttpSameKeyRetry: true,
1044
+ });
1018
1045
  const classified = classifyError(
1019
1046
  response.status,
1020
1047
  upstreamType ?? (response.status === 401 ? "authentication_error"