@bitkyc08/opencodex 2.42.0 → 2.43.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/AGENTS_INSTALL.md +2 -2
  2. package/README.md +31 -0
  3. package/bin/ocx.mjs +10 -1
  4. package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
  5. package/gui/dist/assets/index-Djowl68T.js +112 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic-image-codec.ts +304 -0
  9. package/src/adapters/anthropic-image-normalize.ts +8 -298
  10. package/src/adapters/anthropic.ts +30 -7
  11. package/src/adapters/command-code.ts +7 -5
  12. package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
  13. package/src/adapters/cursor/images.ts +36 -6
  14. package/src/adapters/cursor/live-transport.ts +7 -2
  15. package/src/adapters/cursor/native-exec-desktop.ts +2 -15
  16. package/src/adapters/cursor/protobuf-request.ts +13 -3
  17. package/src/adapters/cursor/tool-definitions.ts +5 -670
  18. package/src/adapters/cursor/tool-guidance.ts +236 -0
  19. package/src/adapters/cursor/tool-naming.ts +252 -0
  20. package/src/adapters/cursor/tool-schemas.ts +195 -0
  21. package/src/adapters/cursor/types.ts +6 -3
  22. package/src/adapters/exec-tool-result-normalize.ts +1 -1
  23. package/src/adapters/google-errors.ts +9 -1
  24. package/src/adapters/google.ts +1 -0
  25. package/src/adapters/kiro-calibration.ts +181 -0
  26. package/src/adapters/kiro.ts +135 -3
  27. package/src/adapters/openai-responses.ts +216 -26
  28. package/src/adapters/responses-code-mode.ts +59 -0
  29. package/src/adapters/tool-catalog-nudge.ts +1 -1
  30. package/src/adapters/xai-schema-analysis.ts +86 -0
  31. package/src/adapters/xai-tool-schema.ts +2 -87
  32. package/src/adapters/xai-web-search.ts +1 -1
  33. package/src/bridge.ts +22 -10
  34. package/src/chat/inbound.ts +11 -3
  35. package/src/claude/inbound-content-options.ts +60 -0
  36. package/src/claude/inbound-model-options.ts +142 -0
  37. package/src/claude/inbound-records.ts +7 -0
  38. package/src/claude/inbound.ts +10 -202
  39. package/src/claude/model-info.ts +45 -0
  40. package/src/cli/account-auth.ts +21 -6
  41. package/src/cli/capabilities.ts +13 -2
  42. package/src/cli/claude.ts +232 -39
  43. package/src/cli/config-command.ts +9 -1
  44. package/src/cli/dispatch.ts +5 -1
  45. package/src/cli/doctor.ts +10 -0
  46. package/src/cli/effort.ts +372 -0
  47. package/src/cli/export-command.ts +3 -9
  48. package/src/cli/help.ts +1 -0
  49. package/src/cli/index.ts +13 -0
  50. package/src/cli/init.ts +4 -0
  51. package/src/cli/model-selection-guidance.ts +30 -0
  52. package/src/cli/models-runtime.ts +3 -2
  53. package/src/cli/models.ts +8 -3
  54. package/src/cli/opencode.ts +4 -1
  55. package/src/cli/provider-runtime.ts +65 -0
  56. package/src/cli/provider.ts +8 -0
  57. package/src/cli/registry.ts +16 -2
  58. package/src/cli/runtime-api.ts +3 -1
  59. package/src/cli/star-prompt.ts +22 -6
  60. package/src/cli/status-probes.ts +168 -0
  61. package/src/cli/status.ts +5 -168
  62. package/src/clients/config-export/constants.ts +69 -0
  63. package/src/clients/config-export/contracts.ts +154 -0
  64. package/src/clients/config-export/dsh.ts +132 -0
  65. package/src/clients/config-export/fast-models.ts +29 -0
  66. package/src/clients/config-export/mcode.ts +83 -0
  67. package/src/clients/config-export/model-metadata.ts +108 -0
  68. package/src/clients/config-export/omp.ts +104 -0
  69. package/src/clients/config-export/zcode.ts +92 -0
  70. package/src/clients/config-export.ts +18 -710
  71. package/src/codex/account-lifecycle.ts +20 -3
  72. package/src/codex/account-usability.ts +2 -0
  73. package/src/codex/auth-api.ts +170 -23
  74. package/src/codex/auth-context.ts +328 -24
  75. package/src/codex/catalog/effort.ts +30 -4
  76. package/src/codex/catalog/metadata.ts +39 -9
  77. package/src/codex/catalog/native-models.ts +53 -12
  78. package/src/codex/catalog/parsing.ts +119 -5
  79. package/src/codex/catalog/provider-fetch.ts +150 -20
  80. package/src/codex/catalog/reserve.ts +52 -0
  81. package/src/codex/catalog/sync.ts +89 -16
  82. package/src/codex/convergence-types.ts +1 -0
  83. package/src/codex/convergence.ts +2 -0
  84. package/src/codex/data/upstream-models.json +1 -1
  85. package/src/codex/forward-transport-headers.ts +25 -0
  86. package/src/codex/inject.ts +3 -28
  87. package/src/codex/legacy-config-keys.ts +68 -0
  88. package/src/codex/log-guard/inspect-schema.ts +137 -0
  89. package/src/codex/log-guard/inspect.ts +2 -134
  90. package/src/codex/loopback-target.ts +54 -0
  91. package/src/codex/main-account-cache.ts +63 -1
  92. package/src/codex/main-account-hard-lock.ts +52 -0
  93. package/src/codex/main-account.ts +3 -1
  94. package/src/codex/management-convergence.ts +3 -0
  95. package/src/codex/model-entitlements.ts +54 -4
  96. package/src/codex/project-config-warnings.ts +92 -2
  97. package/src/codex/prompt-layers/encoding.ts +80 -0
  98. package/src/codex/prompt-layers/paths.ts +54 -0
  99. package/src/codex/prompt-layers/revision.ts +55 -0
  100. package/src/codex/prompt-layers/toml-edit.ts +163 -0
  101. package/src/codex/prompt-layers/toml-read.ts +181 -0
  102. package/src/codex/prompt-layers.ts +14 -520
  103. package/src/codex/quota-auto-refresh-state.ts +16 -0
  104. package/src/codex/quota-auto-refresh.ts +219 -0
  105. package/src/codex/quota-types.ts +51 -0
  106. package/src/codex/quota.ts +252 -93
  107. package/src/codex/reserve-availability.ts +177 -0
  108. package/src/codex/routing.ts +28 -9
  109. package/src/codex/shim.ts +53 -11
  110. package/src/codex/subagent-model-fallback.ts +23 -3
  111. package/src/combos/failover.ts +125 -7
  112. package/src/combos/identifiers.ts +89 -0
  113. package/src/combos/index.ts +4 -0
  114. package/src/combos/resolve.ts +80 -9
  115. package/src/combos/types.ts +20 -93
  116. package/src/config/subagent-models.ts +24 -0
  117. package/src/config.ts +156 -13
  118. package/src/generated/compatibility-version.json +454 -166
  119. package/src/generated/model-metadata.ts +1 -1
  120. package/src/integrations/journal.ts +65 -4
  121. package/src/integrations/store.ts +5 -0
  122. package/src/lab/events/limits.ts +4 -0
  123. package/src/lib/destination-policy.ts +31 -2
  124. package/src/lib/errors.ts +39 -0
  125. package/src/lib/provider-outbound.ts +69 -3
  126. package/src/lib/proxy-env.ts +22 -0
  127. package/src/lib/redact-folding.ts +176 -0
  128. package/src/lib/redact.ts +2 -175
  129. package/src/lib/state-store-sweeper.ts +20 -6
  130. package/src/lib/token-estimate.ts +94 -27
  131. package/src/lib/windows-user-principal.ts +53 -5
  132. package/src/oauth/anthropic-routing.ts +99 -3
  133. package/src/oauth/generic-account-failover.ts +36 -13
  134. package/src/oauth/index.ts +127 -26
  135. package/src/oauth/login-cli.ts +5 -0
  136. package/src/oauth/meta-muse.ts +117 -15
  137. package/src/oauth/pool-settings-capability.ts +15 -4
  138. package/src/providers/api-keys.ts +8 -10
  139. package/src/providers/default-aliases.ts +39 -0
  140. package/src/providers/derive.ts +10 -2
  141. package/src/providers/fastwire.ts +36 -7
  142. package/src/providers/initial-model-selection-runtime.ts +90 -0
  143. package/src/providers/initial-model-selection.ts +120 -0
  144. package/src/providers/key-failover.ts +134 -54
  145. package/src/providers/key-store.ts +11 -1
  146. package/src/providers/label.ts +1 -1
  147. package/src/providers/model-discovery.ts +76 -0
  148. package/src/providers/model-rename-startup.ts +72 -8
  149. package/src/providers/openai-sidecar.ts +17 -5
  150. package/src/providers/openai-tiers-destination.ts +102 -0
  151. package/src/providers/openai-tiers.ts +2 -99
  152. package/src/providers/opencode-go-transport.ts +41 -0
  153. package/src/providers/quota-key-accounts.ts +141 -0
  154. package/src/providers/quota-types.ts +9 -0
  155. package/src/providers/quota.ts +535 -104
  156. package/src/providers/registry.ts +60 -17
  157. package/src/providers/xai-responses-opt-in.ts +31 -5
  158. package/src/quota/reset-activation.ts +81 -0
  159. package/src/quota/reset-detector.ts +305 -0
  160. package/src/quota/reset-notify-config.ts +162 -0
  161. package/src/quota/reset-observer.ts +125 -0
  162. package/src/quota/reset-poller.ts +160 -0
  163. package/src/quota/reset-seen-store.ts +385 -0
  164. package/src/quota/reset-sinks.ts +199 -0
  165. package/src/quota/window-mapping.ts +106 -0
  166. package/src/responses/apply-patch-envelope.ts +46 -0
  167. package/src/responses/code-mode-helper-compat.ts +39 -1
  168. package/src/responses/custom-tool-compat.ts +10 -4
  169. package/src/responses/hosted-tool-policy.ts +12 -4
  170. package/src/responses/parser-content.ts +133 -0
  171. package/src/responses/parser-text-format.ts +24 -0
  172. package/src/responses/parser-tools.ts +188 -0
  173. package/src/responses/parser.ts +3 -326
  174. package/src/responses/state.ts +124 -28
  175. package/src/router.ts +48 -13
  176. package/src/routing/analytics.ts +1 -0
  177. package/src/routing/capability.ts +17 -4
  178. package/src/server/auth-cors.ts +7 -1
  179. package/src/server/background-lifecycle.ts +23 -1
  180. package/src/server/chat-completions.ts +25 -3
  181. package/src/server/claude-messages.ts +62 -5
  182. package/src/server/effort-row.ts +1 -1
  183. package/src/server/fast-row.ts +295 -0
  184. package/src/server/gui-static.ts +30 -4
  185. package/src/server/index.ts +107 -21
  186. package/src/server/management/agent-settings-routes.ts +2 -2
  187. package/src/server/management/combo-routes.ts +37 -9
  188. package/src/server/management/config-routes.ts +93 -2
  189. package/src/server/management/integration-routes.ts +108 -0
  190. package/src/server/management/model-routes.ts +13 -3
  191. package/src/server/management/model-rows.ts +20 -1
  192. package/src/server/management/native-integration-routes.ts +4 -1
  193. package/src/server/management/oauth-account-routes.ts +38 -10
  194. package/src/server/management/provider-routes.ts +34 -3
  195. package/src/server/management/quota-reset-routes.ts +57 -0
  196. package/src/server/management/route-registry.ts +7 -4
  197. package/src/server/management/shared.ts +19 -5
  198. package/src/server/management/system-routes.ts +3 -2
  199. package/src/server/management-api.ts +14 -2
  200. package/src/server/ports.ts +12 -2
  201. package/src/server/relay-eager.ts +38 -23
  202. package/src/server/relay.ts +4 -0
  203. package/src/server/request-log.ts +6 -0
  204. package/src/server/responses/codex-ws-correlation.ts +65 -0
  205. package/src/server/responses/codex-ws-exchange.ts +261 -0
  206. package/src/server/responses/codex-ws-metadata.ts +134 -0
  207. package/src/server/responses/codex-ws-pool.ts +162 -0
  208. package/src/server/responses/codex-ws-request.ts +87 -0
  209. package/src/server/responses/codex-ws-session.ts +93 -0
  210. package/src/server/responses/codex-ws-wire.ts +144 -0
  211. package/src/server/responses/collaboration.ts +41 -1
  212. package/src/server/responses/compact.ts +105 -12
  213. package/src/server/responses/core.ts +476 -57
  214. package/src/server/responses/fetch-helpers.ts +10 -3
  215. package/src/server/responses/input-admission.ts +16 -9
  216. package/src/server/responses/responses-field-backfill.ts +1 -1
  217. package/src/server/responses/ws-upstream.ts +34 -318
  218. package/src/server/responses-custom-tool-repair.ts +20 -4
  219. package/src/server/responses-undeclared-tool-guard.ts +100 -8
  220. package/src/server/safe-response-headers.ts +23 -0
  221. package/src/server/search.ts +9 -0
  222. package/src/server/subagent-models-startup.ts +27 -0
  223. package/src/server/system-env-shell.ts +238 -0
  224. package/src/server/system-env.ts +7 -234
  225. package/src/server/ws-bridge.ts +3 -25
  226. package/src/server/xai-responses-startup.ts +21 -0
  227. package/src/service-manager-probe.ts +1 -1
  228. package/src/service.ts +55 -16
  229. package/src/types/config.ts +108 -12
  230. package/src/types/provider.ts +36 -7
  231. package/src/types/tools.ts +26 -1
  232. package/src/types.ts +2 -0
  233. package/src/update/notify.ts +8 -2
  234. package/src/usage/cost.ts +38 -28
  235. package/src/usage/expected-prices.ts +34 -15
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/model-identity.ts +26 -0
  238. package/src/usage/summary.ts +15 -1
  239. package/src/vision/describe.ts +6 -0
  240. package/src/vision/image-rewrite.ts +108 -0
  241. package/src/vision/index.ts +19 -306
  242. package/src/vision/plan.ts +205 -0
  243. package/src/web-search/executor.ts +6 -0
  244. package/src/web-search/index.ts +8 -1
  245. package/gui/dist/assets/index-BU1tE0sr.js +0 -112
  246. package/gui/dist/assets/index-DL9-iS6J.css +0 -1
@@ -30,7 +30,7 @@ import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/ke
30
30
  import { deriveProviderPresets } from "../../providers/derive";
31
31
  import { providerCodexAccountMode } from "../../providers/registry";
32
32
  import { routedSlug, slugEquals } from "../../providers/slug-codec";
33
- import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderAccountQuotas, fetchProviderQuotaReports, hasPassiveAccountQuota, readPassiveProviderAccountQuotas, supportsPerAccountQuota } from "../../providers/quota";
33
+ import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderAccountQuotas, fetchProviderApiKeyQuotas, fetchProviderQuotaReports, providerOAuthAccountQuotaMode, providerApiKeyQuotaMode, readPassiveProviderAccountQuotas } from "../../providers/quota";
34
34
  import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
35
35
  import { clearThreadAccountMap } from "../../codex/routing";
36
36
  import {
@@ -254,7 +254,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
254
254
  if (url.pathname === "/api/oauth/accounts" && req.method === "GET") {
255
255
  const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
256
256
  if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
257
- const status = getLoginStatus(provider);
257
+ const quotaMode = providerOAuthAccountQuotaMode(provider);
258
+ const quotaProvider = config.providers[provider];
258
259
  const { getAccountSet } = await import("../../oauth/store");
259
260
  const {
260
261
  oauthAccountHealthFields,
@@ -274,26 +275,26 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
274
275
  needsReauth: summary.needsReauth === true,
275
276
  reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined,
276
277
  });
277
- return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health) };
278
+ return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health), quotaMode };
278
279
  }),
279
280
  };
280
281
  };
281
282
  // Per-account rate limits: Anthropic reports usage per credential, so every logged-in
282
283
  // account can show its own 5h/weekly bars (not just the active one). Opt-in via ?quota=1
283
284
  // so the plain account list stays a cheap local read; ?refresh=1 bypasses the TTL.
284
- const wantQuota = url.searchParams.get("quota") === "1" && supportsPerAccountQuota(provider);
285
+ const wantQuota = url.searchParams.get("quota") === "1" && quotaMode === "probe";
285
286
  // Meta publishes no quota endpoint: its usage is observed in-band on streaming turns
286
287
  // and read back from the cache here. `?refresh=1` is accepted and ignored on this
287
288
  // path rather than rejected -- the GUI sends it for every provider on a manual
288
289
  // refresh, and a 400 would report an error for what is simply a no-op.
289
- const passiveQuota = url.searchParams.get("quota") === "1" && hasPassiveAccountQuota(provider);
290
+ const passiveQuota = url.searchParams.get("quota") === "1" && quotaMode === "passive";
290
291
  if (!wantQuota && !passiveQuota) return jsonResponse(projectAccounts());
291
292
  const forceRefresh = url.searchParams.get("refresh") === "1";
292
293
  // Probing may refresh the active credential and mark needsReauth — project health
293
294
  // from the post-probe store so the response is not stale.
294
295
  const rows = passiveQuota
295
296
  ? readPassiveProviderAccountQuotas(provider)
296
- : await fetchProviderAccountQuotas(provider, forceRefresh);
297
+ : await fetchProviderAccountQuotas(provider, forceRefresh, quotaProvider);
297
298
  const byId = new Map(rows.map(row => [row.accountId, row]));
298
299
  const projected = projectAccounts();
299
300
  return jsonResponse({
@@ -301,10 +302,13 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
301
302
  accounts: projected.accounts.map(account => {
302
303
  const row = byId.get(account.id);
303
304
  if (!row) return account;
305
+ if (config.providers[provider] !== quotaProvider || row.isCurrent?.() === false) {
306
+ return { ...account, quota: null, quotaUnavailable: true };
307
+ }
304
308
  return {
305
309
  ...account,
306
310
  quota: row.quota,
307
- ...(row.unavailable ? { quotaUnavailable: true } : {}),
311
+ ...(quotaMode === "probe" ? { quotaUnavailable: row.unavailable === true } : {}),
308
312
  };
309
313
  }),
310
314
  });
@@ -333,8 +337,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
333
337
  if (url.pathname === "/api/oauth/accounts/pool" && req.method === "GET") {
334
338
  const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
335
339
  if (provider !== "anthropic") {
336
- // Generic OAuth pool-settings contract (#695 slice 1): persisted per provider, inert until
337
- // the selector consumes it. Codex keeps /api/codex-auth; api-key providers have no pool.
340
+ // Generic OAuth pool-settings contract (#695 slice 1): persisted per provider. `strategy`
341
+ // and `autoSwitchThreshold` stay inert until the selector consumes them; `enabled` already
342
+ // governs the pre-dispatch account preference. Codex keeps /api/codex-auth; api-key
343
+ // providers have no pool.
338
344
  const { poolSettingsCapability, genericPoolSettingsDto } = await import("../../oauth/pool-settings-capability");
339
345
  const prov = config.providers[provider];
340
346
  if (!provider || !prov || poolSettingsCapability(provider, prov) !== "generic") {
@@ -570,7 +576,29 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
570
576
  const name = (url.searchParams.get("name") ?? "").trim();
571
577
  if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
572
578
  const { listProviderApiKeys } = await import("../../providers/api-keys");
573
- return jsonResponse(listProviderApiKeys(config, name));
579
+ const projectKeys = () => {
580
+ const listed = listProviderApiKeys(config, name);
581
+ const provider = config.providers[name];
582
+ const quotaMode = provider ? providerApiKeyQuotaMode(name, provider) : "unsupported";
583
+ return { ...listed, keys: listed.keys.map(key => ({ ...key, quotaMode })) };
584
+ };
585
+ const initial = projectKeys();
586
+ if (url.searchParams.get("quota") !== "1" || !initial.keys.some(key => key.quotaMode === "probe")) {
587
+ return jsonResponse(initial);
588
+ }
589
+ const rows = await fetchProviderApiKeyQuotas(config, name, url.searchParams.get("refresh") === "1");
590
+ const byId = new Map(rows.map(row => [row.keyId, row]));
591
+ const current = projectKeys();
592
+ return jsonResponse({
593
+ activeId: current.activeId,
594
+ keys: current.keys.map(key => {
595
+ const row = byId.get(key.id);
596
+ if (!row || key.quotaMode !== "probe") return key;
597
+ if (!row.isCurrent()) return { ...key, quota: null, quotaUnavailable: true };
598
+ // Internal identity/epoch checks never enter the JSON DTO.
599
+ return { ...key, quota: row.quota, quotaUnavailable: row.unavailable === true };
600
+ }),
601
+ });
574
602
  }
575
603
  if (url.pathname === "/api/providers/keys" && req.method === "POST") {
576
604
  const body = await readManagementJsonBodyOr(req, {}) as { name?: string; key?: string; label?: string };
@@ -41,10 +41,12 @@ import { fetchCursorUsableModels } from "../../adapters/cursor/live-models";
41
41
  import { parseAntigravityAvailableModels } from "../../providers/antigravity-models";
42
42
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
43
43
  import { deriveProviderPresets, providerConfigSeed } from "../../providers/derive";
44
+ import { initializeProviderModelSelection } from "../../providers/initial-model-selection";
44
45
  import { effectiveGoogleMode, providerCodexAccountMode, providerMatchesRegistryTransport } from "../../providers/registry";
45
46
  import {
46
47
  extractModelEnvelopeRows,
47
48
  extractProviderModelItems,
49
+ isRegistryModelDiscoveryUrl,
48
50
  readBoundedDiscoveryJson,
49
51
  resolveProviderModelDiscovery,
50
52
  } from "../../providers/model-discovery";
@@ -102,6 +104,7 @@ import { refreshUserCostOverlays } from "../../usage/user-cost-overlays";
102
104
  import { redactSecretString } from "../../lib/redact";
103
105
  import {
104
106
  XAI_RESPONSES_OPT_IN_MODELS,
107
+ XAI_RESPONSES_DEFAULT_VERSION,
105
108
  xaiResponsesOptInState,
106
109
  } from "../../providers/xai-responses-opt-in";
107
110
  import { dropProviderCustomModels } from "../../providers/provider-id-rewrite";
@@ -286,6 +289,10 @@ function adoptProviderEditorCandidate(live: OcxConfig, persisted: OcxConfig): vo
286
289
  else live.customModels = structuredClone(persisted.customModels);
287
290
  if (persisted.providerContextCaps === undefined) delete live.providerContextCaps;
288
291
  else live.providerContextCaps = structuredClone(persisted.providerContextCaps);
292
+ if (persisted.disabledModels === undefined) delete live.disabledModels;
293
+ else live.disabledModels = [...persisted.disabledModels];
294
+ if (persisted.modelDiscovery === undefined) delete live.modelDiscovery;
295
+ else live.modelDiscovery = structuredClone(persisted.modelDiscovery);
289
296
  }
290
297
 
291
298
  /**
@@ -391,10 +398,11 @@ function applyProviderPatchFields(
391
398
  const modelAdapters = { ...(next.modelAdapters ?? {}) };
392
399
  for (const model of XAI_RESPONSES_OPT_IN_MODELS) {
393
400
  if (rawBody.xaiResponsesOptIn) modelAdapters[model] = "openai-responses";
394
- else delete modelAdapters[model];
401
+ else modelAdapters[model] = "openai-chat";
395
402
  }
396
403
  if (Object.keys(modelAdapters).length > 0) next.modelAdapters = modelAdapters;
397
404
  else delete next.modelAdapters;
405
+ next.xaiResponsesDefaultVersion = Math.max(next.xaiResponsesDefaultVersion ?? 0, XAI_RESPONSES_DEFAULT_VERSION);
398
406
  touched = true;
399
407
  }
400
408
  if (Object.hasOwn(rawBody, "requestPacing")) {
@@ -830,8 +838,15 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
830
838
  const changed = !isDeepStrictEqual(providerEditorConfigDTO(persisted), nextResult.value);
831
839
  if (!changed) return { changed: false, value: candidate };
832
840
 
841
+ for (const [name, provider] of Object.entries(candidate.config.providers)) {
842
+ if (!Object.hasOwn(persisted.providers, name)) {
843
+ initializeProviderModelSelection(name, provider, undefined, candidate.config);
844
+ }
845
+ }
833
846
  persisted.defaultProvider = candidate.config.defaultProvider;
834
847
  persisted.providers = structuredClone(candidate.config.providers);
848
+ persisted.disabledModels = candidate.config.disabledModels;
849
+ persisted.modelDiscovery = candidate.config.modelDiscovery;
835
850
  for (const name of candidate.removedProviders) {
836
851
  dropProviderCustomModels(persisted, name);
837
852
  setProviderContextCap(persisted, name, false);
@@ -993,6 +1008,18 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
993
1008
  // completed during that wait remains authoritative instead of being overwritten by the
994
1009
  // older ownership snapshot used to admit this POST.
995
1010
  restorePersistedAliasOverlays(prov, config.providers[name]);
1011
+ // The add/edit form omits wire choices. Read after DNS so a concurrent switch
1012
+ // remains authoritative, including the marker that protects it on the next boot.
1013
+ if (name === "xai") {
1014
+ const latest = config.providers[name];
1015
+ if (!Object.hasOwn(body.provider, "modelAdapters") && latest?.modelAdapters) {
1016
+ prov.modelAdapters = { ...latest.modelAdapters };
1017
+ }
1018
+ if (latest?.xaiResponsesDefaultVersion !== undefined) {
1019
+ prov.xaiResponsesDefaultVersion = latest.xaiResponsesDefaultVersion;
1020
+ }
1021
+ }
1022
+ initializeProviderModelSelection(name, prov, config.providers[name], config);
996
1023
  config.providers[name] = stripRegistryOnlyStaticHeaders(name, prov);
997
1024
  if (body.setDefault === true) config.defaultProvider = name;
998
1025
  save(config);
@@ -1236,16 +1263,20 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1236
1263
  const discovery = resolveProviderModelDiscovery(name, prov);
1237
1264
  const started = Date.now();
1238
1265
  try {
1266
+ // Same canonical-URL TUN transparency as catalog discovery: the registry's
1267
+ // own fixed discovery URL survives purely-benchmark (Clash/Surge/Mihomo
1268
+ // fake-IP) DNS without proxy env.
1269
+ const outboundDependencies = { isCanonicalUrl: isRegistryModelDiscoveryUrl };
1239
1270
  const res = method === "POST"
1240
1271
  ? await providerOutboundPost(name, prov, modelsUrl, {
1241
1272
  headers,
1242
1273
  body: JSON.stringify({ project }),
1243
1274
  signal: AbortSignal.timeout(8000),
1244
- })
1275
+ }, outboundDependencies)
1245
1276
  : await providerOutboundGet(name, prov, modelsUrl, {
1246
1277
  headers,
1247
1278
  signal: AbortSignal.timeout(8000),
1248
- });
1279
+ }, outboundDependencies);
1249
1280
  const latencyMs = Date.now() - started;
1250
1281
  const redirectError = await providerRedirectError(res, modelsUrl);
1251
1282
  if (redirectError) {
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Read-only view of recently detected quota resets.
3
+ *
4
+ * Loaded on demand from src/server/management-api.ts, which is the FOURTH entry in the protected
5
+ * set of tests/core-lab-boundary.test.ts — added precisely because eagerly importing handlers
6
+ * there put ~70 modules on every dashboard request. A static import here would make this
7
+ * subsystem the next instance of that bug.
8
+ *
9
+ * Authentication is inherited: every /api route passes through requireManagementAuth before the
10
+ * chain runs, so a read-only GET adds no auth code of its own. It spends no user identity and
11
+ * mutates nothing.
12
+ */
13
+
14
+ import { jsonResponse } from "../auth-cors";
15
+ import type { ManagementContext } from "./context";
16
+
17
+ const DEFAULT_LIMIT = 20;
18
+ const MAX_LIMIT = 100;
19
+
20
+ export async function handleQuotaResetRoutes(ctx: ManagementContext): Promise<Response | null> {
21
+ const { url, req, config } = ctx;
22
+ if (url.pathname !== "/api/quota-resets") return null;
23
+ if (req.method !== "GET") return null;
24
+
25
+ const rawLimit = url.searchParams.get("limit");
26
+ if (rawLimit !== null && !/^\d+$/.test(rawLimit)) {
27
+ return jsonResponse(
28
+ { error: { code: "invalid_limit", message: "limit must be a non-negative integer" } },
29
+ 400,
30
+ req,
31
+ config,
32
+ );
33
+ }
34
+ const limit = rawLimit === null
35
+ ? DEFAULT_LIMIT
36
+ : Math.min(MAX_LIMIT, Number.parseInt(rawLimit, 10));
37
+
38
+ // Imported here rather than at module scope so a dashboard request that never touches this
39
+ // route does not load the store or its state file.
40
+ const [{ listRecentQuotaResetEvents }, { isQuotaResetNotificationEnabled }] = await Promise.all([
41
+ import("../../quota/reset-seen-store"),
42
+ import("../../quota/reset-notify-config"),
43
+ ]);
44
+
45
+ // `enabled` is reported alongside the events because an empty list is ambiguous on its own:
46
+ // it means either "nothing has reset" or "detection was never turned on", and an operator
47
+ // debugging a missing notification needs to tell those apart.
48
+ return jsonResponse(
49
+ {
50
+ enabled: isQuotaResetNotificationEnabled(),
51
+ events: listRecentQuotaResetEvents(limit),
52
+ },
53
+ 200,
54
+ req,
55
+ config,
56
+ );
57
+ }
@@ -1,20 +1,20 @@
1
1
  /**
2
2
  * Declared inventory of every reachable management route.
3
3
  *
4
- * DECLARED, not harvested. A grep cannot see this surface: 18 routes are registered
4
+ * DECLARED, not harvested. A grep cannot see this surface: 19 routes are registered
5
5
  * through a regex, an `endsWith`, a `pathname.slice`, a prefix decode, a path constant, or a
6
6
  * negated `pathname !== "…"` guard, and two of those are live routes whose only textual
7
7
  * trace is the negated form. For `GET /api/storage` an equality scan finds solely the dead
8
8
  * shadowed copy in `logs-usage-routes.ts` and never the live one.
9
9
  *
10
10
  * This module is pure DATA and must stay that way. It is imported by
11
- * `src/server/management-api.ts`, which `tests/core-lab-boundary.test.ts` protects: a user
11
+ * `src/server/management-api.ts`, which `tests/lab/core-lab-boundary.test.ts` protects: a user
12
12
  * with one provider and no Lab must execute no Lab code. Route paths are strings, so
13
13
  * declaring `/api/lab/status` here creates no module edge. Never import a handler, and
14
14
  * never import anything from `src/lab/`. The `module` field names the owning file as text
15
15
  * for exactly this reason.
16
16
  *
17
- * Reconciliation lives in `tests/management-route-registry.test.ts`, which resolves
17
+ * Reconciliation lives in `tests/server/management-route-registry.test.ts`, which resolves
18
18
  * `(method, path)` pairs from source and fails loudly on a route whose method it cannot
19
19
  * determine. Adding a route without declaring it here fails that test.
20
20
  */
@@ -169,6 +169,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
169
169
  // server/management/integration-routes
170
170
  { method: "GET", path: "/api/client-integrations", module: "server/management/integration-routes", mutates: false },
171
171
  { method: "GET", path: "/api/client-integrations/journal", module: "server/management/integration-routes", mutates: false },
172
+ { method: "DELETE", path: "/api/client-integrations/journal", module: "server/management/integration-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Retiring one rollback row is a dashboard-local cleanup; the CLI verb that would drive it is owed by a later work-phase and is not implemented here.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_plan/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } },
172
173
  { method: "POST", path: "/api/client-integrations/restore", module: "server/management/integration-routes", mutates: true },
173
174
  // server/management/lab-automation-routes
174
175
  { method: "GET", path: "/api/lab/automation", module: "server/management/lab-automation-routes", mutates: false, exempt: { reason: "local-transport", why: "ocx lab reads the same rows from the local SQLite projection; src/cli/lab.ts imports ../lab/query directly and never fetches /api/lab." } },
@@ -275,6 +276,8 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
275
276
  { method: "POST", path: "/api/providers/test", module: "server/management/provider-routes", mutates: true },
276
277
  { method: "PUT", path: "/api/providers", module: "server/management/provider-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Issue #3280 scopes this atomic batch endpoint to the GUI JSON editor; a matching CLI verb is outside wp5 and remains owed.", owner: "wp5-followup", ownerDoc: "devlog/_plan/260903_bug_drawdown_bcda/050_phase5.md" } },
277
278
  { method: "PUT", path: "/api/provider-context-caps", module: "server/management/provider-routes", mutates: true },
279
+ // server/management/quota-reset-routes
280
+ { method: "GET", path: "/api/quota-resets", module: "server/management/quota-reset-routes", mutates: false, mechanism: "negated-guard" },
278
281
  // server/management/request-history-routes
279
282
  { method: "GET", path: "/api/request-history", module: "server/management/request-history-routes", mutates: false },
280
283
  // server/management/routing-analytics-routes
@@ -300,7 +303,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
300
303
  { method: "GET", path: "/api/system/memory", module: "server/management/system-routes", mutates: false },
301
304
  { method: "GET", path: "/api/system/windows-replace-retries", module: "server/management/system-routes", mutates: false },
302
305
  { method: "POST", path: "/api/system/restart", module: "server/management/system-routes", mutates: true },
303
- // --- Routes an equality scan of their own file cannot see (18). ---
306
+ // --- Routes an equality scan of their own file cannot see (19). ---
304
307
  // Each carries `mechanism`; the reconciliation test counts these separately.
305
308
  { method: "GET", path: "/api/storage", module: "server/management/storage-log-guard-routes", mutates: false, mechanism: "negated-guard" },
306
309
  { method: "GET", path: "/api/routing-analytics", module: "server/management/routing-analytics-routes", mutates: false, mechanism: "negated-guard" },
@@ -1,4 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { captureInitialSelectionBaseline, finalizeInitialModelSelection } from "../../providers/initial-model-selection-runtime";
3
+ import { initialModelSelectionPending } from "../../providers/initial-model-selection";
2
4
  import { readFileSync } from "node:fs";
3
5
  import type { CatalogModel } from "../../codex/catalog";
4
6
  import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
@@ -53,6 +55,7 @@ import { drainAndShutdown } from "../lifecycle";
53
55
  import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
54
56
  import { estimateComboCost, estimateRequestCost, serviceTierContext, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
55
57
  import type { PersistedUsageAttempt } from "../../usage/log";
58
+ import { usageModelPriceOptions } from "../../usage/model-identity";
56
59
  import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
57
60
  import { applySystemEnvToggle } from "../system-env";
58
61
 
@@ -93,7 +96,7 @@ export type CostResult =
93
96
  | { kind: "value"; estimate: NonNullable<ReturnType<typeof estimateRequestCost>>; estimateReasons: CostEstimateReason[] }
94
97
  | { kind: "unavailable"; reason: MetricUnavailableReason };
95
98
 
96
- export type MetricSource = Pick<RequestLogEntry, "provider" | "model" | "durationMs" | "usageStatus" | "usage" | "requestedServiceTier" | "configuredServiceTier" | "responseServiceTier" | "tierOutcome"> & {
99
+ export type MetricSource = Pick<RequestLogEntry, "provider" | "model" | "durationMs" | "usageStatus" | "usage" | "requestedServiceTier" | "configuredServiceTier" | "responseServiceTier" | "tierOutcome" | "routeDecision"> & {
97
100
  attempts?: readonly PersistedUsageAttempt[];
98
101
  };
99
102
 
@@ -132,8 +135,8 @@ export function unavailableCostReason(entry: MetricSource): MetricUnavailableRea
132
135
  export function costResult(entry: MetricSource): CostResult {
133
136
  const tier = serviceTierContext(entry);
134
137
  const estimate = entry.attempts?.length
135
- ? estimateComboCost(entry.attempts, undefined, tier)
136
- : estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier });
138
+ ? estimateComboCost(entry.attempts.map(attempt => ({ ...attempt, ...usageModelPriceOptions(entry, attempt) })), undefined, tier)
139
+ : estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier, ...usageModelPriceOptions(entry, entry) });
137
140
  if (!estimate) return { kind: "unavailable", reason: unavailableCostReason(entry) };
138
141
  const estimateReasons = [
139
142
  entry.usageStatus === "estimated" || entry.usage?.estimated ? "usage_estimated" as const : undefined,
@@ -162,7 +165,7 @@ export function requestLogDto(entry: RequestLogEntry): Record<string, unknown> {
162
165
  ...attempt,
163
166
  displayMetrics: {
164
167
  tokPerSecond: tokPerSecondResult(attempt),
165
- cost: costResult({ ...attempt, attempts: undefined, requestedServiceTier: entry.requestedServiceTier, configuredServiceTier: entry.configuredServiceTier, responseServiceTier: entry.responseServiceTier }),
168
+ cost: costResult({ ...attempt, attempts: undefined, routeDecision: entry.routeDecision, requestedServiceTier: entry.requestedServiceTier, configuredServiceTier: entry.configuredServiceTier, responseServiceTier: entry.responseServiceTier }),
166
169
  },
167
170
  })),
168
171
  }
@@ -178,7 +181,13 @@ export function requestLogDto(entry: RequestLogEntry): Record<string, unknown> {
178
181
  */
179
182
  export async function fetchAllModels(config: OcxConfig): Promise<CatalogModel[]> {
180
183
  const { gatherRoutedModels } = await import("../../codex/catalog");
181
- return gatherRoutedModels(config);
184
+ const baseline = captureInitialSelectionBaseline(config);
185
+ if (!baseline) return gatherRoutedModels(config);
186
+ const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = [];
187
+ const models = await gatherRoutedModels(config, { providerModelOutcomes: outcomes });
188
+ finalizeInitialModelSelection(config, baseline, uniqueCatalogModelsForPublicList(models),
189
+ outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider));
190
+ return models;
182
191
  }
183
192
 
184
193
  export interface GrokCandidateModel {
@@ -187,6 +196,11 @@ export interface GrokCandidateModel {
187
196
  native: boolean;
188
197
  }
189
198
 
199
+ /** Configuration pickers may retain disabled choices, but never offer provisional models. */
200
+ export async function fetchInitializedModels(config: OcxConfig): Promise<CatalogModel[]> {
201
+ return (await fetchAllModels(config)).filter(model => !initialModelSelectionPending(config.providers[model.provider]));
202
+ }
203
+
190
204
  /**
191
205
  * The model list `syncGrokConfig` would inject, BEFORE the user's exclusions. The Grok
192
206
  * page needs this to show a switch for a model the user has already excluded — such a
@@ -15,8 +15,9 @@
15
15
  * further: it is the proxy's previous_response_id continuation store, so a
16
16
  * growing responseState.totalBytes under rising observed memory points at
17
17
  * conversation retention rather than the runtime allocator. Spill counts,
18
- * payload-byte totals, tombstones, and failure counters remain finite scalars;
19
- * response ids, filenames, digests, paths, and payload content never leave the owner.
18
+ * payload-byte totals, tombstones, failure counters, fixed health/error enums,
19
+ * and event timestamps remain finite scalars; response ids, raw errors,
20
+ * filenames, digests, paths, and payload content never leave the owner.
20
21
  *
21
22
  * `activeTurnCount` / `isDraining` are scalar lifecycle counters for the
22
23
  * dashboard drain-and-restart confirm UX — never request bodies or IDs.
@@ -101,8 +101,8 @@ const managementConvergenceBindings = new WeakMap<object, Readonly<{
101
101
  * Namespace match for management route prefixes: exact hit or a child path, never a
102
102
  * prefix collision (`/api/labfoo` must not match `/api/lab`).
103
103
  */
104
- function pathInManagementNamespace(pathname: string, prefix: string): boolean {
105
- return pathname === prefix || pathname.startsWith(`${prefix}/`);
104
+ function pathInManagementNamespace(pathname: string, prefix: string, includeChildren = true): boolean {
105
+ return pathname === prefix || (includeChildren && pathname.startsWith(`${prefix}/`));
106
106
  }
107
107
 
108
108
  /**
@@ -132,6 +132,17 @@ async function handleLabRoutesOnDemand(ctx: ManagementContext): Promise<Response
132
132
  return handleLabRoutes(ctx);
133
133
  }
134
134
 
135
+ /**
136
+ * Lazy like the Lab and routing-profile handlers, and for the same recorded reason: this file is
137
+ * mounted for every dashboard request, so a static import would put the quota-reset store and
138
+ * its config resolution on all of them.
139
+ */
140
+ async function handleQuotaResetRoutesOnDemand(ctx: ManagementContext): Promise<Response | null> {
141
+ if (!pathInManagementNamespace(ctx.url.pathname, "/api/quota-resets", false)) return null;
142
+ const { handleQuotaResetRoutes } = await import("./management/quota-reset-routes");
143
+ return handleQuotaResetRoutes(ctx);
144
+ }
145
+
135
146
  export async function handleManagementAPI(
136
147
  req: Request,
137
148
  url: URL,
@@ -229,6 +240,7 @@ export async function handleManagementAPI(
229
240
  ?? (await handleStorageLogGuardRoutes(ctx))
230
241
  ?? (await handleLogsUsageRoutes(ctx))
231
242
  ?? (await handleRequestHistoryRoutes(ctx))
243
+ ?? (await handleQuotaResetRoutesOnDemand(ctx))
232
244
  ?? (await handleRoutingAnalyticsRoutes(ctx))
233
245
  ?? (await handleRoutingProfileRoutesOnDemand(ctx))
234
246
  ?? (await handleProviderRoutes(ctx))
@@ -1,5 +1,15 @@
1
1
  import { createServer } from "node:net";
2
2
 
3
+ /** Temporary bind probes must not let accepted peers hold server.close() open. */
4
+ function createProbeServer(): ReturnType<typeof createServer> {
5
+ const server = createServer();
6
+ server.on("connection", socket => {
7
+ socket.on("error", () => socket.destroy());
8
+ socket.destroy();
9
+ });
10
+ return server;
11
+ }
12
+
3
13
  /**
4
14
  * True when an error means "this port/address is already bound" — the only bind failure
5
15
  * that is safe to answer with a retry on another port. Bun/Node surface it as
@@ -15,7 +25,7 @@ export function isAddrInUse(err: unknown): boolean {
15
25
 
16
26
  export async function isPortAvailable(port: number, hostname = "127.0.0.1"): Promise<boolean> {
17
27
  return await new Promise(resolve => {
18
- const server = createServer();
28
+ const server = createProbeServer();
19
29
  // Fail closed: EACCES / EADDRNOTAVAIL / EPERM / unknown listen errors mean the
20
30
  // requested bind is not available. Only the listening event reports free.
21
31
  server.once("error", () => resolve(false));
@@ -133,7 +143,7 @@ export function setEphemeralPortAllocatorForTests(
133
143
  async function allocateEphemeralPort(hostname: string): Promise<number> {
134
144
  if (ephemeralAllocator) return ephemeralAllocator(hostname);
135
145
  return await new Promise<number>((resolve, reject) => {
136
- const server = createServer();
146
+ const server = createProbeServer();
137
147
  server.once("error", reject);
138
148
  server.once("listening", () => {
139
149
  const address = server.address();