@bitkyc08/opencodex 2.88.0 → 2.90.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 (291) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-DgxkE6Md.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-DbTNc_88.js} +1 -1
  6. package/gui/dist/assets/{index-B5bsvxor.js → index-TmaHa54u.js} +15 -15
  7. package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +2 -2
  10. package/src/adapters/anthropic/cache-retention.ts +117 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -4
  12. package/src/adapters/anthropic.ts +10 -3
  13. package/src/adapters/base.ts +4 -0
  14. package/src/adapters/claude-cli/adapter.ts +13 -7
  15. package/src/adapters/claude-cli/usage-admission.ts +14 -2
  16. package/src/adapters/coding-agent/protocol.ts +6 -0
  17. package/src/adapters/coding-agent/turn.ts +14 -4
  18. package/src/adapters/cursor/protobuf-request.ts +3 -0
  19. package/src/adapters/google-http.ts +3 -2
  20. package/src/adapters/google-progress-history.ts +19 -1
  21. package/src/adapters/google-vertex-endpoint.ts +67 -0
  22. package/src/adapters/google.ts +62 -37
  23. package/src/adapters/image.ts +5 -3
  24. package/src/adapters/input-media-guard.ts +5 -2
  25. package/src/adapters/kiro-refusal.ts +41 -1
  26. package/src/adapters/ollama-native.ts +1 -0
  27. package/src/adapters/openai-chat/messages.ts +34 -4
  28. package/src/adapters/openai-chat/passthrough.ts +37 -4
  29. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  30. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  31. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  32. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  33. package/src/adapters/openai-chat.ts +7 -6
  34. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  35. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  36. package/src/adapters/openai-responses/passthrough.ts +22 -13
  37. package/src/adapters/openai-responses/reasoning.ts +68 -1
  38. package/src/adapters/openai-responses/transport.ts +13 -1
  39. package/src/adapters/registry.ts +1 -1
  40. package/src/adapters/upstream-http-error.ts +3 -2
  41. package/src/adapters/zed.ts +7 -4
  42. package/src/bridge/internal.ts +11 -1
  43. package/src/bridge/response-json.ts +2 -2
  44. package/src/bridge/sse.ts +10 -4
  45. package/src/claude/desktop-3p-library.ts +43 -3
  46. package/src/claude/desktop-3p.ts +20 -10
  47. package/src/claude/desktop-picker-profile.ts +18 -9
  48. package/src/claude/desktop-remote-store-io.ts +2 -2
  49. package/src/claude/inbound.ts +12 -26
  50. package/src/claude/outbound.ts +5 -0
  51. package/src/cli/account-api.ts +13 -1
  52. package/src/cli/account-extended.ts +17 -5
  53. package/src/cli/account-key-quota.ts +4 -1
  54. package/src/cli/account.ts +1 -1
  55. package/src/cli/capabilities-agents-routing.ts +45 -8
  56. package/src/cli/capabilities-base.ts +5 -5
  57. package/src/cli/capabilities-provider-models.ts +5 -4
  58. package/src/cli/combo-result.ts +8 -2
  59. package/src/cli/combo.ts +17 -0
  60. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  61. package/src/cli/help.ts +1 -1
  62. package/src/cli/message-command.ts +4 -1
  63. package/src/cli/message-remote-args.ts +69 -0
  64. package/src/cli/message-remote-command.ts +77 -0
  65. package/src/cli/provider-runtime.ts +69 -1
  66. package/src/cli/registry.ts +3 -3
  67. package/src/codex/account-lifecycle.ts +19 -6
  68. package/src/codex/account-pause.ts +6 -1
  69. package/src/codex/account-runtime-state.ts +5 -0
  70. package/src/codex/account-store.ts +55 -9
  71. package/src/codex/account-usability.ts +4 -1
  72. package/src/codex/auth-api/account-list.ts +13 -5
  73. package/src/codex/auth-api/account-pause-group.ts +2 -22
  74. package/src/codex/auth-api/login-collision.ts +59 -0
  75. package/src/codex/auth-api/login-flow.ts +12 -7
  76. package/src/codex/auth-api/main-account-probe.ts +3 -2
  77. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  78. package/src/codex/auth-collision.ts +31 -4
  79. package/src/codex/auth-context.ts +57 -10
  80. package/src/codex/catalog/aggregation.ts +15 -0
  81. package/src/codex/catalog/auto-review.ts +2 -1
  82. package/src/codex/catalog/build-entries.ts +17 -7
  83. package/src/codex/catalog/derive-entry.ts +3 -1
  84. package/src/codex/catalog/metadata.ts +2 -0
  85. package/src/codex/catalog/model-visibility.ts +0 -2
  86. package/src/codex/catalog/ownership.ts +71 -0
  87. package/src/codex/catalog/parsing.ts +12 -3
  88. package/src/codex/catalog/retained-sync.ts +2 -1
  89. package/src/codex/catalog/routed-removal.ts +4 -4
  90. package/src/codex/cli-update-plan.ts +18 -3
  91. package/src/codex/collision-identity.ts +78 -0
  92. package/src/codex/convergence.ts +2 -1
  93. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  94. package/src/codex/low-quota-protection.ts +57 -31
  95. package/src/codex/main-account-cache.ts +77 -3
  96. package/src/codex/main-account-hard-lock.ts +14 -0
  97. package/src/codex/main-account.ts +6 -2
  98. package/src/codex/native-residue.ts +2 -3
  99. package/src/codex/quota.ts +26 -2
  100. package/src/codex/routing/cooldown-math.ts +5 -0
  101. package/src/codex/routing/failure-window.ts +61 -9
  102. package/src/codex/routing.ts +4 -3
  103. package/src/codex/shim-diagnostics.ts +2 -1
  104. package/src/codex/shim-fingerprint.ts +11 -2
  105. package/src/codex/shim-templates.ts +16 -5
  106. package/src/codex/shim-windows-refresh.ts +224 -0
  107. package/src/codex/shim.ts +45 -21
  108. package/src/combos/failover.ts +10 -2
  109. package/src/combos/jev-model-backend.ts +30 -7
  110. package/src/combos/jev-model-request.ts +20 -0
  111. package/src/combos/jev-quota-activation.ts +153 -0
  112. package/src/combos/jev-quota-config.ts +62 -0
  113. package/src/combos/jev-quota-route.ts +26 -0
  114. package/src/combos/jev-quota.ts +88 -0
  115. package/src/combos/jev.ts +25 -12
  116. package/src/combos/types.ts +27 -0
  117. package/src/compatibility/index.ts +3 -0
  118. package/src/compatibility/openai-responses.ts +8 -1
  119. package/src/compatibility/opengateway-responses.ts +61 -0
  120. package/src/config/account-auto-pause-revisions.ts +136 -0
  121. package/src/config/diagnostics.ts +13 -0
  122. package/src/config/live-reconcile.ts +9 -0
  123. package/src/config/persist-unlocked.ts +10 -1
  124. package/src/config/provider-validation.ts +7 -2
  125. package/src/config/removed-opencode-free.ts +135 -0
  126. package/src/config/schema/account-auto-pause.ts +14 -0
  127. package/src/config/schema/config-schema.ts +15 -0
  128. package/src/config/schema/leaf-validators.ts +7 -0
  129. package/src/config.ts +10 -3
  130. package/src/generated/compatibility-version.json +476 -216
  131. package/src/generated/model-metadata.ts +1 -1
  132. package/src/images/loop.ts +34 -11
  133. package/src/lib/account-auto-pause-response.ts +16 -0
  134. package/src/lib/bun-runtime.ts +18 -1
  135. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  136. package/src/lib/bun-transpiler-cache-scope.mjs +82 -0
  137. package/src/lib/decision-quota-sink.ts +76 -0
  138. package/src/lib/provider-admission-wait.ts +14 -0
  139. package/src/lib/service-secrets.ts +37 -2
  140. package/src/lib/state-store-registrations.ts +25 -0
  141. package/src/lib/translator-budget.ts +1 -1
  142. package/src/lib/upstream-retry.ts +15 -0
  143. package/src/link/ssh-argv.ts +3 -0
  144. package/src/messaging/envelope.ts +6 -3
  145. package/src/messaging/remote-auth.ts +83 -0
  146. package/src/messaging/remote-bridge.ts +270 -0
  147. package/src/messaging/remote-contract.ts +108 -0
  148. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  149. package/src/messaging/remote-enrollment.ts +244 -0
  150. package/src/messaging/remote-envelope.ts +40 -0
  151. package/src/messaging/remote-files.ts +117 -0
  152. package/src/messaging/remote-owner.ts +169 -0
  153. package/src/messaging/remote-ports.ts +56 -0
  154. package/src/messaging/remote-process.ts +131 -0
  155. package/src/messaging/remote-replies.ts +98 -0
  156. package/src/messaging/remote-rpc-admission.ts +32 -0
  157. package/src/messaging/remote-send.ts +72 -0
  158. package/src/messaging/remote-store.ts +111 -0
  159. package/src/messaging/remote-tunnels.ts +65 -0
  160. package/src/messaging/rpc.ts +13 -1
  161. package/src/oauth/account-auto-pause-policy.ts +66 -0
  162. package/src/oauth/account-auto-pause.ts +171 -0
  163. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  164. package/src/oauth/account-import/parser.ts +15 -2
  165. package/src/oauth/account-import/types.ts +7 -0
  166. package/src/oauth/account-quota-rank.ts +2 -1
  167. package/src/oauth/anthropic-account-refusal.ts +6 -0
  168. package/src/oauth/anthropic-model-family.ts +8 -0
  169. package/src/oauth/anthropic-model-quota.ts +2 -6
  170. package/src/oauth/api-key-pool-preserve.ts +37 -0
  171. package/src/oauth/chatgpt-shared.ts +10 -1
  172. package/src/oauth/dsh.ts +45 -2
  173. package/src/oauth/index.ts +5 -35
  174. package/src/oauth/kiro.ts +44 -1
  175. package/src/oauth/login-flow-state.ts +11 -2
  176. package/src/oauth/store.ts +35 -8
  177. package/src/oauth/types.ts +2 -0
  178. package/src/plugins/upstream-hooks.ts +14 -1
  179. package/src/providers/api-key-pause.ts +32 -0
  180. package/src/providers/api-key-resolve.ts +10 -4
  181. package/src/providers/api-key-selection.ts +6 -1
  182. package/src/providers/api-keys.ts +8 -4
  183. package/src/providers/claude-cli-usage.ts +25 -5
  184. package/src/providers/initial-model-selection.ts +2 -2
  185. package/src/providers/key-failover.ts +21 -13
  186. package/src/providers/key-store.ts +8 -3
  187. package/src/providers/mistral-transport.ts +207 -0
  188. package/src/providers/model-rename-fields.ts +1 -0
  189. package/src/providers/openai-sidecar.ts +5 -0
  190. package/src/providers/opencode-zen-rate-limit.ts +6 -72
  191. package/src/providers/quota/account-cache.ts +15 -1
  192. package/src/providers/quota/anthropic-account-quota.ts +20 -7
  193. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  194. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  195. package/src/providers/quota/antigravity.ts +45 -8
  196. package/src/providers/quota/failure-codes.ts +18 -0
  197. package/src/providers/quota/report-cache.ts +14 -0
  198. package/src/providers/quota/vendor-probes-oauth.ts +15 -6
  199. package/src/providers/quota-decision-publication.ts +77 -0
  200. package/src/providers/quota-decision-snapshot.ts +145 -0
  201. package/src/providers/quota-routing-cache.ts +4 -1
  202. package/src/providers/quota-types.ts +6 -1
  203. package/src/providers/quota.ts +8 -3
  204. package/src/providers/registry/entries-extended.ts +14 -53
  205. package/src/providers/registry/model-seeds.ts +1 -1
  206. package/src/providers/registry.ts +1 -2
  207. package/src/providers/routed-config.ts +1 -1
  208. package/src/providers/stale-vision-classification-migration.ts +1 -1
  209. package/src/providers/xai-transport.ts +2 -1
  210. package/src/responses/audio.ts +46 -0
  211. package/src/responses/input-media.ts +37 -15
  212. package/src/responses/parser-content.ts +22 -27
  213. package/src/responses/parser.ts +18 -10
  214. package/src/responses/reasoning-retention.ts +218 -11
  215. package/src/responses/schema.ts +3 -4
  216. package/src/responses/web-search-replay.ts +147 -0
  217. package/src/routing/compatibility/behavior.ts +4 -1
  218. package/src/server/auth-cors.ts +7 -0
  219. package/src/server/background-lifecycle.ts +2 -1
  220. package/src/server/chat-native.ts +36 -20
  221. package/src/server/claude-messages.ts +2 -1
  222. package/src/server/index/optional-listeners.ts +6 -0
  223. package/src/server/index/serve-options.ts +8 -2
  224. package/src/server/index/websocket-handler.ts +10 -0
  225. package/src/server/lifecycle.ts +17 -1
  226. package/src/server/management/combo-routes.ts +17 -2
  227. package/src/server/management/oauth-account-routes.ts +12 -2
  228. package/src/server/management/provider-completion-test.ts +289 -0
  229. package/src/server/management/provider-routes.ts +9 -1
  230. package/src/server/management/route-registry.ts +2 -0
  231. package/src/server/messages-native.ts +29 -4
  232. package/src/server/messages-shared.ts +4 -1
  233. package/src/server/request-log.ts +23 -9
  234. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  235. package/src/server/responses/adapter-continuation.ts +2 -2
  236. package/src/server/responses/adapter-dispatch.ts +79 -30
  237. package/src/server/responses/codex-ws-exchange.ts +3 -0
  238. package/src/server/responses/codex-ws-metadata.ts +44 -0
  239. package/src/server/responses/codex-ws-session.ts +21 -1
  240. package/src/server/responses/combo-session-recall.ts +25 -0
  241. package/src/server/responses/combo-stream-preflight.ts +10 -3
  242. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  243. package/src/server/responses/compact.ts +10 -5
  244. package/src/server/responses/context-overflow.ts +141 -2
  245. package/src/server/responses/core-auth.ts +20 -0
  246. package/src/server/responses/core-codex-account.ts +28 -15
  247. package/src/server/responses/core-combo.ts +34 -5
  248. package/src/server/responses/core-opaque-recovery.ts +20 -11
  249. package/src/server/responses/core.ts +12 -9
  250. package/src/server/responses/deepseek-image-files.ts +121 -0
  251. package/src/server/responses/empty-completion-guard.ts +10 -0
  252. package/src/server/responses/fetch-helpers.ts +65 -14
  253. package/src/server/responses/input-admission.ts +6 -2
  254. package/src/server/responses/jev-model-invoke.ts +13 -14
  255. package/src/server/responses/main-hard-lock-routing.ts +33 -8
  256. package/src/server/responses/native-steering-log.ts +2 -2
  257. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  258. package/src/server/responses/passthrough-completion.ts +41 -0
  259. package/src/server/responses/passthrough-delivery.ts +5 -2
  260. package/src/server/responses/passthrough-dispatch.ts +42 -45
  261. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  262. package/src/server/responses/passthrough-observations.ts +38 -0
  263. package/src/server/responses/reasoning-archive-error.ts +21 -0
  264. package/src/server/responses/request-compression.ts +98 -0
  265. package/src/server/responses/request-prepare.ts +11 -1
  266. package/src/server/responses/request-transport.ts +92 -5
  267. package/src/server/responses/run-turn-execution.ts +2 -0
  268. package/src/server/responses/sidecar-execution.ts +16 -3
  269. package/src/server/responses/terminal-guard.ts +2 -0
  270. package/src/server/startup-action-control.ts +2 -2
  271. package/src/server/startup-health-cache.ts +2 -2
  272. package/src/server/windows-tray-control.ts +2 -2
  273. package/src/service/definition.ts +9 -1
  274. package/src/service/orchestration.ts +17 -0
  275. package/src/types/account-auto-pause.ts +15 -0
  276. package/src/types/config.ts +23 -1
  277. package/src/types/provider.ts +11 -5
  278. package/src/types/request.ts +36 -3
  279. package/src/types.ts +2 -0
  280. package/src/usage/cost.ts +17 -1
  281. package/src/usage/expected-prices.ts +3 -1
  282. package/src/usage/jev-stats.ts +4 -0
  283. package/src/usage/log.ts +2 -0
  284. package/src/usage/summary.ts +7 -0
  285. package/src/web-search/loop.ts +39 -13
  286. package/src/web-search/progress-stream.ts +4 -1
  287. package/src/web-search/run-turn-loop.ts +9 -3
  288. package/src/web-search/sidecar-search-replay.ts +96 -0
  289. package/gui/dist/assets/App-CHC89EXQ.js +0 -58
  290. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
  291. package/gui/dist/assets/quota-summary-D8y2wTsB.js +0 -1
@@ -1110,7 +1110,8 @@ async function handleClaudeMessagesWithBudget(
1110
1110
  const finalizeNativeLog = createFinalRequestLog(logIds, logCtx).finish;
1111
1111
  const upstream = await handleResponses(internalReq, {
1112
1112
  ...buildClaudeReplayConfig(config),
1113
- // Operator config wins; otherwise the caller's own 1h breakpoints keep 1h here.
1113
+ // Operator config wins; otherwise the caller's explicit TTLs (all 1h, or a pinned 5m) carry
1114
+ // over, and TTL-less breakpoints take the destination default.
1114
1115
  cacheRetention: config.cacheRetention ?? callerCacheRetention(anthropicBody),
1115
1116
  }, logCtx, {
1116
1117
  // Routing keeps Claude-only sidecar overrides; admission policy must follow the live owner.
@@ -17,6 +17,7 @@ import { createLinkSupervisor, type LinkSupervisor } from "../../link/supervisor
17
17
  import { linkManagementRouteAllowed } from "../../link/management-routes";
18
18
  import { saveConfigPreservingClaudeCode } from "../../config/live-reconcile";
19
19
  import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
20
+ import { startJevQuotaActivation } from "../../combos/jev-quota-activation";
20
21
  export { LINK_INGRESS_HOSTNAME } from "./link-listener";
21
22
  import type { ServerIngress } from "./serve-options";
22
23
 
@@ -51,6 +52,7 @@ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): O
51
52
  const claudeIntercept: ClaudeInterceptLifecycle<T> = createClaudeInterceptLifecycle<T>();
52
53
  const linkListener: LinkListenerLifecycle<T> = createLinkListenerLifecycle<T>(linkDeps);
53
54
  let activeConfig: OcxConfig | undefined;
55
+ let stopJevQuotaActivation: (() => void) | undefined;
54
56
  const supervisor = createLinkSupervisor({
55
57
  apiKeys: () => activeConfig?.apiKeys ?? [],
56
58
  revokeApiKey: id => {
@@ -114,6 +116,8 @@ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): O
114
116
  maxRequestBodySize: ctx.maxRequestBodySize,
115
117
  dispatch: ctx.dispatch,
116
118
  });
119
+ // Last, after every listener started: JEV advisory quota follows the live config while serving.
120
+ stopJevQuotaActivation = startJevQuotaActivation(ctx.config);
117
121
  },
118
122
  ensureStarted: () => linkListener.ensureStarted(),
119
123
  close: () => linkListener.close(),
@@ -132,6 +136,8 @@ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): O
132
136
  try { await claudeIntercept.stop(); } catch (error) { failure ??= error; }
133
137
  unregisterSupervisorAdmission?.();
134
138
  unregisterSupervisorAdmission = undefined;
139
+ stopJevQuotaActivation?.();
140
+ stopJevQuotaActivation = undefined;
135
141
  if (failure) throw failure;
136
142
  },
137
143
  };
@@ -206,6 +206,7 @@ import type { WorkflowRefusalLog } from "../workflow-refusal";
206
206
  import { readyProtocolMetadata } from "../../remote/protocol";
207
207
  import { modelCapabilityFields } from "../models-capabilities";
208
208
  import { createWebsocketHandler } from "./websocket-handler";
209
+ import { reasoningArchiveErrorResponse } from "../responses/reasoning-archive-error";
209
210
  import { withGrokSessionIdentity } from "../../grok/session-identity";
210
211
  import { withCallerSessionIdentity } from "../caller-session-identity";
211
212
 
@@ -1347,8 +1348,9 @@ export function createServeOptions(ctx: ServeOptionsContext) {
1347
1348
  response = await handleResponsesCompact(req, config, logCtx, turnAdmissionLease, admission, {
1348
1349
  onRequestBodyRead: () => disableResponsesRequestTimeout(req, requestServer),
1349
1350
  });
1350
- } catch {
1351
- response = formatErrorResponse(500, "server_error", "Unexpected compact request failure");
1351
+ } catch (error) {
1352
+ response = reasoningArchiveErrorResponse(error)
1353
+ ?? formatErrorResponse(500, "server_error", "Unexpected compact request failure");
1352
1354
  }
1353
1355
  addFinalRequestLog(requestId, start, logCtx, response.status,
1354
1356
  response.status === 499 ? { closeReason: "client_cancel" } : undefined);
@@ -1519,6 +1521,10 @@ export function createServeOptions(ctx: ServeOptionsContext) {
1519
1521
  },
1520
1522
  });
1521
1523
  } catch (error) {
1524
+ const archiveError = reasoningArchiveErrorResponse(error);
1525
+ if (archiveError) return withRequestLogId(
1526
+ withCors(responseWithDeferredRequestLog(archiveError, requestId, start, logCtx), req, policy), requestId,
1527
+ );
1522
1528
  // A bounded upstream body can fail before handleResponses returns a client response.
1523
1529
  // Finalize before rethrow so accounting observes the physical send while the caller
1524
1530
  // retains the existing reset/rejection instead of receiving a synthesized response.
@@ -2,6 +2,7 @@ import { nativeSteeringUnavailableReason, nativeResponseControlMode, type Native
2
2
  import { NativeInjectionChannel } from "../responses/native-injection";
3
3
  import { NativeSteeringChannel, NativeSteeringError } from "../responses/native-steering";
4
4
  import { createNativeSteeringLogObserver } from "../responses/native-steering-log";
5
+ import { reasoningArchiveErrorResponse } from "../responses/reasoning-archive-error";
5
6
  import type { Server, ServerWebSocket } from "bun";
6
7
  import {
7
8
  LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS,
@@ -396,6 +397,15 @@ export function createWebsocketHandler(
396
397
  }));
397
398
  return;
398
399
  }
400
+ // A local archive refusal happens before any send. Carry the same sanitized 503
401
+ // body and stable code as HTTP so no filesystem path reaches the client frame.
402
+ const archiveError = reasoningArchiveErrorResponse(err);
403
+ if (archiveError) {
404
+ finalizeLog(archiveError.status);
405
+ const body = await archiveError.json() as { error: Record<string, unknown> };
406
+ sendJsonFrame(ws, buildWsErrorFrame(archiveError.status, body.error));
407
+ return;
408
+ }
399
409
  finalizeLog(502);
400
410
  sendJsonFrame(ws, buildWsErrorFrame(502, {
401
411
  type: "proxy_error",
@@ -476,13 +476,25 @@ export function trackStreamLifetime(
476
476
  registerTurn(ac, lease);
477
477
  const reader = body.getReader();
478
478
  let closed = false;
479
+ let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;
480
+ // A drain aborts the turn's controller; many callers pass a fresh one nothing else listens to,
481
+ // so the tracked stream itself has to stop.
482
+ const onAbort = () => {
483
+ if (closed) return;
484
+ // Stop the stream before the caller's onDone runs, so a throwing callback cannot leave it open.
485
+ reader.cancel(ac.signal.reason).catch(() => {});
486
+ try { streamController?.error(ac.signal.reason); } catch { /* already closed */ }
487
+ finish();
488
+ };
479
489
  const finish = () => {
480
490
  if (closed) return;
481
491
  closed = true;
492
+ ac.signal.removeEventListener("abort", onAbort);
482
493
  unregisterTurn(ac);
483
494
  onDone?.();
484
495
  };
485
- return new ReadableStream<Uint8Array>({
496
+ const tracked = new ReadableStream<Uint8Array>({
497
+ start(controller) { streamController = controller; },
486
498
  async pull(controller) {
487
499
  try {
488
500
  const { done, value } = await reader.read();
@@ -499,6 +511,10 @@ export function trackStreamLifetime(
499
511
  reader.cancel(reason).catch(() => {});
500
512
  },
501
513
  });
514
+ // After registerTurn, so the lease's own abort listener (release the turn) runs first. A
515
+ // controller registerTurn already aborted (a settled lease) keeps its previous behaviour.
516
+ ac.signal.addEventListener("abort", onAbort, { once: true });
517
+ return tracked;
502
518
  }
503
519
 
504
520
  export async function drainAndShutdown(
@@ -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);
@@ -374,7 +374,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
374
374
  // Without a flow id this cancels the provider's newest in-flight login, which is what the
375
375
  // single-user GUI has always meant by "Cancel". A host running concurrent code logins for
376
376
  // several users passes the flowId it was given at /api/oauth/login.
377
- const cancelled = cancelLoginFlow(provider, typeof body.flowId === "string" ? body.flowId : undefined);
377
+ const cancelled = cancelLoginFlow(provider, typeof body.flowId === "string" ? body.flowId : undefined,
378
+ { dismissSettledFailure: true });
378
379
  return jsonResponse({ ok: true, cancelled });
379
380
  }
380
381
 
@@ -510,7 +511,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
510
511
  reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined,
511
512
  });
512
513
  return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health), quotaMode,
513
- ...(supportsPause ? { paused: full?.paused === true } : {}),
514
+ ...(supportsPause ? { paused: full?.paused === true, ...(full?.pauseDetails ? { pauseDetails: full.pauseDetails } : {}) } : {}),
514
515
  ...(isAnthropicOAuthInstance(provider) && supportsPause ? { autoSwitchThresholdOverride: full?.autoSwitchThresholdOverride ?? null,
515
516
  effectiveAutoSwitchThreshold: effectiveAnthropicAccountThresholdForInstance(provider as AnthropicInstanceId, config, full),
516
517
  autoSwitchThreshold: effectiveAnthropicAccountThresholdForInstance(provider as AnthropicInstanceId, config) } : {}),
@@ -1116,6 +1117,15 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
1116
1117
  clearProviderQuotaCache();
1117
1118
  return jsonResponse({ ...result, name, store: providerKeyStoreKind(config.providers[name]) });
1118
1119
  }
1120
+ if (url.pathname === "/api/providers/keys/pause" && req.method === "PUT") {
1121
+ const body = await readManagementJsonBodyOr(req, {});
1122
+ if (!isPlainRecord(body)) return jsonResponse({ error: "body must be an object" }, 400);
1123
+ if (typeof body.name !== "string" || typeof body.id !== "string" || typeof body.paused !== "boolean") return jsonResponse({ error: "name, id and paused required" }, 400);
1124
+ if (!isValidProviderName(body.name) || !hasOwnProvider(config.providers, body.name)) return jsonResponse({ error: "unknown provider" }, 404);
1125
+ const { setProviderApiKeyPaused } = await import("../../providers/api-key-pause");
1126
+ return setProviderApiKeyPaused(config, body.name, body.id, body.paused)
1127
+ ? jsonResponse({ ok: true }) : jsonResponse({ error: "key not found" }, 404);
1128
+ }
1119
1129
  if (url.pathname === "/api/providers/keys/active" && req.method === "PUT") {
1120
1130
  const body = await readManagementJsonBodyOr(req, {}) as { name?: string; id?: string };
1121
1131
  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 },