@bitkyc08/opencodex 2.8.0 → 2.9.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 (175) hide show
  1. package/README.md +24 -0
  2. package/bin/ocx.mjs +32 -4
  3. package/gui/dist/assets/index-CHwf3tTD.css +1 -0
  4. package/gui/dist/assets/index-u5eFOv2y.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-image-normalize.ts +114 -20
  8. package/src/adapters/anthropic.ts +126 -10
  9. package/src/adapters/azure.ts +3 -3
  10. package/src/adapters/base.ts +7 -3
  11. package/src/adapters/cursor/discovery.ts +14 -4
  12. package/src/adapters/cursor/effort-map.ts +18 -6
  13. package/src/adapters/cursor/framing.ts +102 -27
  14. package/src/adapters/cursor/kv-store.ts +30 -3
  15. package/src/adapters/cursor/live-models.ts +22 -2
  16. package/src/adapters/cursor/live-transport.ts +245 -49
  17. package/src/adapters/cursor/mcp-manager.ts +105 -8
  18. package/src/adapters/cursor/native-exec-mcp.ts +5 -3
  19. package/src/adapters/cursor/native-exec-shell.ts +296 -14
  20. package/src/adapters/cursor/native-exec.ts +381 -33
  21. package/src/adapters/cursor/protobuf-events.ts +28 -1
  22. package/src/adapters/cursor/protobuf-request.ts +71 -39
  23. package/src/adapters/cursor/request-builder.ts +2 -2
  24. package/src/adapters/cursor/transport.ts +2 -0
  25. package/src/adapters/cursor.ts +13 -2
  26. package/src/adapters/google-antigravity-replay.ts +184 -17
  27. package/src/adapters/google.ts +58 -8
  28. package/src/adapters/kiro-thinking.ts +23 -9
  29. package/src/adapters/kiro-tools.ts +49 -18
  30. package/src/adapters/kiro.ts +377 -133
  31. package/src/adapters/mimo-free.ts +36 -4
  32. package/src/adapters/openai-chat.ts +143 -17
  33. package/src/adapters/openai-responses.ts +130 -14
  34. package/src/adapters/run-turn-queue.ts +7 -1
  35. package/src/bridge.ts +466 -69
  36. package/src/chat/outbound.ts +144 -38
  37. package/src/claude/inbound-debug.ts +53 -8
  38. package/src/claude/outbound.ts +224 -38
  39. package/src/cli/agent-driven.ts +34 -1
  40. package/src/cli/catalog-prewarm.ts +5 -2
  41. package/src/cli/claude-desktop.ts +2 -2
  42. package/src/cli/doctor.ts +12 -0
  43. package/src/cli/export-command.ts +187 -0
  44. package/src/cli/help.ts +11 -0
  45. package/src/cli/index.ts +13 -3
  46. package/src/cli/init.ts +129 -102
  47. package/src/cli/opencode.ts +36 -151
  48. package/src/cli/star-prompt.ts +13 -4
  49. package/src/cli/status-oauth.ts +12 -2
  50. package/src/clients/config-export.ts +377 -0
  51. package/src/codex/account-runtime-state.ts +19 -1
  52. package/src/codex/account-store.ts +162 -82
  53. package/src/codex/auth-api.ts +467 -159
  54. package/src/codex/auth-context.ts +15 -2
  55. package/src/codex/catalog/aggregation.ts +15 -0
  56. package/src/codex/catalog/effort.ts +16 -6
  57. package/src/codex/catalog/metadata.ts +6 -0
  58. package/src/codex/catalog/parsing.ts +3 -1
  59. package/src/codex/catalog/provider-fetch.ts +29 -0
  60. package/src/codex/catalog/sync.ts +64 -7
  61. package/src/codex/catalog.ts +2 -2
  62. package/src/codex/inject.ts +5 -5
  63. package/src/codex/main-account-cache.ts +8 -1
  64. package/src/codex/model-cache.ts +81 -2
  65. package/src/codex/pool-rotation.ts +39 -0
  66. package/src/codex/project-config-warnings.ts +12 -1
  67. package/src/codex/quota.ts +35 -3
  68. package/src/codex/routing.ts +46 -1
  69. package/src/codex/shim.ts +10 -4
  70. package/src/codex/subagent-model-fallback.ts +12 -0
  71. package/src/codex/websocket-registry.ts +27 -0
  72. package/src/combos/failover.ts +31 -1
  73. package/src/combos/request.ts +9 -0
  74. package/src/combos/resolve.ts +60 -4
  75. package/src/combos/types.ts +12 -0
  76. package/src/config.ts +510 -55
  77. package/src/github/star-state.ts +13 -1
  78. package/src/images/fulfill.ts +39 -1
  79. package/src/images/loop.ts +52 -12
  80. package/src/lib/admission.ts +83 -0
  81. package/src/lib/app-owned-memory-stores.ts +173 -0
  82. package/src/lib/app-owned-memory.ts +265 -0
  83. package/src/lib/bun-stream-caps.ts +31 -7
  84. package/src/lib/config-ownership.ts +33 -0
  85. package/src/lib/crash-guard.ts +65 -5
  86. package/src/lib/debug-log-buffer.ts +47 -6
  87. package/src/lib/destination-policy.ts +12 -1
  88. package/src/lib/errors.ts +3 -0
  89. package/src/lib/gcp-adc.ts +40 -2
  90. package/src/lib/injection-debug-log.ts +26 -2
  91. package/src/lib/provider-outbound.ts +3 -0
  92. package/src/lib/sidecar-tracker.ts +5 -2
  93. package/src/lib/sse-decoder.ts +257 -37
  94. package/src/lib/state-store-registrations.ts +109 -0
  95. package/src/lib/state-store-sweeper.ts +184 -0
  96. package/src/lib/translator-budget.ts +356 -0
  97. package/src/lib/windows-secret-acl.ts +33 -12
  98. package/src/lib/winsw.ts +14 -1
  99. package/src/oauth/anthropic-routing.ts +31 -7
  100. package/src/oauth/google-antigravity.ts +2 -1
  101. package/src/oauth/health.ts +30 -12
  102. package/src/oauth/index.ts +127 -23
  103. package/src/oauth/kiro-credentials.ts +72 -1
  104. package/src/oauth/kiro.ts +23 -4
  105. package/src/oauth/store.ts +165 -18
  106. package/src/oauth/token-guardian.ts +43 -4
  107. package/src/oauth/types.ts +2 -1
  108. package/src/providers/base-url-choices.ts +10 -0
  109. package/src/providers/derive.ts +12 -0
  110. package/src/providers/free-directory.ts +4 -1
  111. package/src/providers/key-failover.ts +12 -0
  112. package/src/providers/openai-sidecar.ts +4 -1
  113. package/src/providers/quota.ts +68 -7
  114. package/src/providers/registry.ts +279 -3
  115. package/src/responses/parser.ts +5 -1
  116. package/src/responses/spill-store.ts +394 -0
  117. package/src/responses/state.ts +520 -102
  118. package/src/router.ts +18 -1
  119. package/src/server/adapter-resolve.ts +20 -3
  120. package/src/server/auth-cors.ts +121 -28
  121. package/src/server/chat-completions.ts +57 -12
  122. package/src/server/claude-messages.ts +85 -13
  123. package/src/server/index.ts +242 -100
  124. package/src/server/lifecycle.ts +155 -25
  125. package/src/server/management/agent-settings-routes.ts +79 -36
  126. package/src/server/management/api-key-usage.ts +167 -0
  127. package/src/server/management/body.ts +35 -0
  128. package/src/server/management/combo-routes.ts +5 -1
  129. package/src/server/management/config-routes.ts +42 -12
  130. package/src/server/management/logs-usage-routes.ts +41 -21
  131. package/src/server/management/model-routes.ts +188 -54
  132. package/src/server/management/oauth-account-routes.ts +115 -26
  133. package/src/server/management/provider-routes.ts +56 -6
  134. package/src/server/management/shared.ts +16 -3
  135. package/src/server/management/sidebar-routes.ts +50 -1
  136. package/src/server/management/system-restart.ts +13 -6
  137. package/src/server/management/system-routes.ts +15 -3
  138. package/src/server/management/usage-summary-cache.ts +86 -0
  139. package/src/server/management-api.ts +39 -5
  140. package/src/server/management-auth.ts +65 -14
  141. package/src/server/port-reclaim.ts +58 -12
  142. package/src/server/ports.ts +2 -0
  143. package/src/server/proxy-liveness.ts +60 -14
  144. package/src/server/relay-eager.ts +20 -4
  145. package/src/server/relay.ts +548 -154
  146. package/src/server/request-decompress.ts +51 -4
  147. package/src/server/request-log.ts +134 -15
  148. package/src/server/responses/collaboration.ts +15 -4
  149. package/src/server/responses/compact.ts +3 -0
  150. package/src/server/responses/core.ts +241 -66
  151. package/src/server/responses-image-gen-repair.ts +19 -5
  152. package/src/server/responses-item-id-repair.ts +23 -5
  153. package/src/server/sse-payload-rewrite.ts +71 -12
  154. package/src/server/startup-health-cache.ts +14 -1
  155. package/src/server/system-env.ts +8 -1
  156. package/src/server/windows-tcp-drop.ts +15 -5
  157. package/src/server/ws-bridge.ts +25 -0
  158. package/src/service.ts +179 -13
  159. package/src/storage/policy-job.ts +93 -23
  160. package/src/storage/policy-worker.ts +6 -0
  161. package/src/storage/restore-job.ts +62 -16
  162. package/src/storage/restore-worker.ts +6 -0
  163. package/src/storage/storage-mutation-coordinator.ts +36 -6
  164. package/src/storage/worker-lifecycle.ts +181 -47
  165. package/src/tray/windows.ts +97 -25
  166. package/src/types.ts +39 -6
  167. package/src/update/index.ts +24 -5
  168. package/src/update/job.ts +598 -73
  169. package/src/usage/log.ts +115 -17
  170. package/src/usage/summary.ts +67 -2
  171. package/src/vision/index.ts +112 -22
  172. package/src/web-search/loop.ts +38 -6
  173. package/src/web-search/progress-stream.ts +14 -3
  174. package/gui/dist/assets/index-BDjpkcRN.js +0 -67
  175. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
@@ -53,6 +53,7 @@ import {
53
53
  } from "../../lib/debug-settings";
54
54
  import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types";
55
55
  import { drainAndShutdown } from "../lifecycle";
56
+ import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
56
57
  import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
57
58
  import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
58
59
  import type { PersistedUsageAttempt } from "../../usage/log";
@@ -62,6 +63,7 @@ import { applySystemEnvToggle } from "../system-env";
62
63
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
63
64
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
64
65
  import type { ManagementContext } from "./context";
66
+ import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
65
67
 
66
68
  export async function handleComboRoutes(ctx: ManagementContext): Promise<Response | null> {
67
69
  const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx;
@@ -80,7 +82,7 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
80
82
 
81
83
  if (url.pathname === "/api/combos" && req.method === "PUT") {
82
84
  let rawBody: unknown;
83
- try { rawBody = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
85
+ try { rawBody = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
84
86
  if (!isPlainRecord(rawBody)) {
85
87
  return jsonResponse({ error: "request body must be an object" }, 400);
86
88
  }
@@ -186,6 +188,7 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
186
188
  }
187
189
  }
188
190
  saveConfigPreservingClaudeCode(config);
191
+ reconcileLiveStateStores();
189
192
  clearComboSelectionState(id);
190
193
  clearComboTargetCooldowns(id);
191
194
  if (renameFrom) {
@@ -207,6 +210,7 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
207
210
  delete config.combos![id];
208
211
  if (Object.keys(config.combos!).length === 0) delete config.combos;
209
212
  saveConfigPreservingClaudeCode(config);
213
+ reconcileLiveStateStores();
210
214
  clearComboSelectionState(id);
211
215
  clearComboTargetCooldowns(id);
212
216
  await refreshCodexCatalogBestEffort();
@@ -25,6 +25,13 @@ import { removeCredential } from "../../oauth/store";
25
25
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
26
26
  import { isStreamMode } from "../../lib/bun-stream-caps";
27
27
  import { shadowSourceModels } from "../../lib/shadow-call";
28
+ import {
29
+ configureAppOwnedMemoryBudget,
30
+ enforceAppOwnedMemoryBudget,
31
+ MAX_APP_OWNED_MEMORY_BUDGET_MB,
32
+ MIN_APP_OWNED_MEMORY_BUDGET_MB,
33
+ resolveAppOwnedMemoryBudgetBytes,
34
+ } from "../../lib/app-owned-memory";
28
35
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
29
36
  import { deriveProviderPresets } from "../../providers/derive";
30
37
  import { providerCodexAccountMode } from "../../providers/registry";
@@ -64,6 +71,7 @@ import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortCla
64
71
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
65
72
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
66
73
  import type { ManagementContext } from "./context";
74
+ import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
67
75
 
68
76
  export async function handleConfigRoutes(ctx: ManagementContext): Promise<Response | null> {
69
77
  const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx;
@@ -111,10 +119,16 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
111
119
  );
112
120
  }
113
121
  return jsonResponse({
122
+ // The dashboard renders request-log timestamps. Without this it formats them in the
123
+ // BROWSER's zone, so a KST proxy viewed from a UTC browser reports every request nine
124
+ // hours off (#725). Carried on settings rather than /api/logs because that route's
125
+ // array response has four consumers that would have to change with it.
126
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
114
127
  codexAutoStart: codexAutoStartEnabled(config),
115
128
  port: config.port,
116
129
  hostname: config.hostname ?? "127.0.0.1",
117
130
  streamMode: config.streamMode ?? "auto",
131
+ appOwnedMemoryBudgetMb: config.appOwnedMemoryBudgetMb ?? 256,
118
132
  startupHealth: await getCachedStartupHealth(config),
119
133
  codexRuntime: {
120
134
  path: displayCodexRuntimePath(resolved.runtime.command),
@@ -142,7 +156,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
142
156
 
143
157
  if (url.pathname === "/api/startup-action" && req.method === "POST") {
144
158
  let body: { action?: unknown; repair?: unknown };
145
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
159
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
146
160
  if (!body || !["install-service", "install-shim"].includes(String(body.action))) {
147
161
  return jsonResponse({ error: "action must be install-service or install-shim" }, 400);
148
162
  }
@@ -171,7 +185,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
171
185
 
172
186
  if (url.pathname === "/api/windows-tray" && req.method === "POST") {
173
187
  let body: { action?: unknown };
174
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
188
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
175
189
  if (!body || !["install", "start", "stop", "uninstall"].includes(String(body.action))) {
176
190
  return jsonResponse({ error: "action must be install, start, stop, or uninstall" }, 400);
177
191
  }
@@ -186,15 +200,15 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
186
200
 
187
201
  if (url.pathname === "/api/settings" && req.method === "PUT") {
188
202
  // Each field is optional but at least one must be present; fields are
189
- // validated when present. streamMode-only PUTs must work: Windows-memory
190
- // troubleshooting docs tell service users to set it here (a service does
191
- // not inherit shell env, so config.json is its only input). A stream-shape
203
+ // validated when present. streamMode-only PUTs must work: Windows/macOS
204
+ // memory troubleshooting can use this persisted stream-shape escape hatch
205
+ // (a Windows service does not inherit shell env). A stream-shape
192
206
  // change applies to NEW turns only — the config object is shared by
193
207
  // reference with the request handlers, no restart needed.
194
- let body: { codexAutoStart?: unknown; streamMode?: unknown };
195
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
196
- if (body.codexAutoStart === undefined && body.streamMode === undefined) {
197
- return jsonResponse({ error: "codexAutoStart boolean is required" }, 400);
208
+ let body: { codexAutoStart?: unknown; streamMode?: unknown; appOwnedMemoryBudgetMb?: unknown };
209
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
210
+ if (body.codexAutoStart === undefined && body.streamMode === undefined && body.appOwnedMemoryBudgetMb === undefined) {
211
+ return jsonResponse({ error: "provide codexAutoStart, streamMode, or appOwnedMemoryBudgetMb" }, 400);
198
212
  }
199
213
  if (body.codexAutoStart !== undefined && typeof body.codexAutoStart !== "boolean") {
200
214
  return jsonResponse({ error: "codexAutoStart boolean is required" }, 400);
@@ -202,6 +216,14 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
202
216
  if (body.streamMode !== undefined && !isStreamMode(body.streamMode)) {
203
217
  return jsonResponse({ error: "streamMode must be auto, legacy-tee, or eager-relay" }, 400);
204
218
  }
219
+ if (body.appOwnedMemoryBudgetMb !== undefined && (
220
+ typeof body.appOwnedMemoryBudgetMb !== "number"
221
+ || !Number.isInteger(body.appOwnedMemoryBudgetMb)
222
+ || body.appOwnedMemoryBudgetMb < MIN_APP_OWNED_MEMORY_BUDGET_MB
223
+ || body.appOwnedMemoryBudgetMb > MAX_APP_OWNED_MEMORY_BUDGET_MB
224
+ )) {
225
+ return jsonResponse({ error: `appOwnedMemoryBudgetMb must be an integer from ${MIN_APP_OWNED_MEMORY_BUDGET_MB} to ${MAX_APP_OWNED_MEMORY_BUDGET_MB}` }, 400);
226
+ }
205
227
  if (typeof body.codexAutoStart === "boolean") {
206
228
  config.codexAutoStart = body.codexAutoStart;
207
229
  }
@@ -212,12 +234,20 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
212
234
  config.streamMode = body.streamMode as "legacy-tee" | "eager-relay";
213
235
  }
214
236
  }
237
+ if (typeof body.appOwnedMemoryBudgetMb === "number") {
238
+ config.appOwnedMemoryBudgetMb = body.appOwnedMemoryBudgetMb;
239
+ }
215
240
  saveConfigPreservingClaudeCode(config);
241
+ if (typeof body.appOwnedMemoryBudgetMb === "number") {
242
+ configureAppOwnedMemoryBudget(resolveAppOwnedMemoryBudgetBytes(body.appOwnedMemoryBudgetMb));
243
+ enforceAppOwnedMemoryBudget();
244
+ }
216
245
  invalidateStartupHealthCache();
217
246
  return jsonResponse({
218
247
  ok: true,
219
248
  codexAutoStart: codexAutoStartEnabled(config),
220
249
  streamMode: config.streamMode ?? "auto",
250
+ appOwnedMemoryBudgetMb: config.appOwnedMemoryBudgetMb ?? 256,
221
251
  startupHealth: await getCachedStartupHealth(config),
222
252
  });
223
253
  }
@@ -250,7 +280,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
250
280
  if (url.pathname === "/api/update/run" && req.method === "POST") {
251
281
  const { normalizeUpdateChannel, startUpdateJob, UpdateJobError } = await import("../../update/job");
252
282
  let body: { tag?: unknown; restart?: unknown };
253
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
283
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
254
284
  if (body.tag !== undefined && body.tag !== "latest" && body.tag !== "preview") {
255
285
  return jsonResponse({ error: "tag must be latest or preview" }, 400);
256
286
  }
@@ -289,7 +319,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
289
319
 
290
320
  if (url.pathname === "/api/sidecar-settings" && req.method === "PUT") {
291
321
  let raw: unknown;
292
- try { raw = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
322
+ try { raw = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
293
323
  // Strict shape (review F2): reject non-object bodies and non-object sections instead of throwing
294
324
  // on `null` or silently accepting arrays/strings as no-op updates.
295
325
  if (!isPlainRecord(raw)) return jsonResponse({ error: "body must be a JSON object" }, 400);
@@ -364,7 +394,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
364
394
 
365
395
  if (url.pathname === "/api/shadow-call-settings" && req.method === "PUT") {
366
396
  let raw: unknown;
367
- try { raw = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
397
+ try { raw = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
368
398
  if (!isPlainRecord(raw)) return jsonResponse({ error: "body must be a JSON object" }, 400);
369
399
  const body = raw as { enabled?: unknown; model?: unknown };
370
400
  if (body.enabled !== undefined && typeof body.enabled !== "boolean") {
@@ -68,7 +68,7 @@ import {
68
68
  } from "../../lib/debug-settings";
69
69
  import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types";
70
70
  import { drainAndShutdown } from "../lifecycle";
71
- import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
71
+ import { filterRequestLogs, filteredRequestLogCount, getRequestLogEntries, type RequestLogEntry } from "../request-log";
72
72
  import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
73
73
  import type { PersistedUsageAttempt } from "../../usage/log";
74
74
  import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
@@ -77,14 +77,14 @@ import { applySystemEnvToggle } from "../system-env";
77
77
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
78
78
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
79
79
  import type { ManagementContext } from "./context";
80
+ import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
81
+ import {
82
+ discardUsageSummaryCacheEntry,
83
+ getUsageSummaryCacheEntry,
84
+ setUsageSummaryCacheEntry,
85
+ } from "./usage-summary-cache";
80
86
 
81
87
  const USAGE_DAY_MS = 86_400_000;
82
- const usageSummaryCache = new Map<string, {
83
- revisionKey: string;
84
- expiresAt: number;
85
- summary: UsageSummary;
86
- }>();
87
-
88
88
  function usageEntryMatchesSurface(entry: PersistedUsageEntry, surface: UsageSurface): boolean {
89
89
  if (surface === "claude") return entry.surface === "claude" || entry.surface === "claude-desktop";
90
90
  if (surface === "grok") return entry.surface === "grok";
@@ -115,7 +115,7 @@ function usageSummaryExpiresAt(
115
115
  return expiresAt;
116
116
  }
117
117
 
118
- function refreshedUsageSummary(summary: UsageSummary, range: UsageRange, now: number): UsageSummary {
118
+ function refreshedUsageSummary<T extends UsageSummary & { historyTruncated: boolean }>(summary: T, range: UsageRange, now: number): T {
119
119
  const since = range === "7d" ? now - 7 * USAGE_DAY_MS : range === "30d" ? now - 30 * USAGE_DAY_MS : null;
120
120
  return { ...summary, since, generatedAt: now };
121
121
  }
@@ -124,8 +124,14 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
124
124
  const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx;
125
125
 
126
126
  if (url.pathname === "/api/logs" && req.method === "GET") {
127
- const logs = filterRequestLogs(getRequestLogEntries(), url.searchParams);
128
- return jsonResponse(logs.map(requestLogDto));
127
+ const all = getRequestLogEntries();
128
+ const total = filteredRequestLogCount(all, url.searchParams);
129
+ const logs = filterRequestLogs(all, url.searchParams);
130
+ return jsonResponse({
131
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
132
+ total,
133
+ logs: logs.map(requestLogDto),
134
+ });
129
135
  }
130
136
 
131
137
  if (url.pathname === "/api/debug" && req.method === "GET") {
@@ -155,7 +161,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
155
161
 
156
162
  if (url.pathname === "/api/debug" && req.method === "PUT") {
157
163
  let body: { debug?: unknown; usage?: unknown; injection?: unknown; claude?: unknown; reset?: unknown };
158
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
164
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
159
165
  if (body.reset === true) return jsonResponse(clearDebugSettings());
160
166
  if (body.reset === "debug" || body.reset === "provider") return jsonResponse(clearDebugSetting("debug"));
161
167
  if (body.reset === "usage") return jsonResponse(clearDebugSetting("usage"));
@@ -184,16 +190,26 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
184
190
  const now = Date.now();
185
191
  try {
186
192
  const cacheKey = `${range}:${surface}`;
187
- const observedRevisionKey = usageLogRevisionKey(currentUsageLogRevision());
188
- const cached = usageSummaryCache.get(cacheKey);
193
+ const effectiveReadLimit = config.managementUsageMaxReadBytes ?? 64 * 1024 * 1024;
194
+ const observedRevisionKey = `${usageLogRevisionKey(currentUsageLogRevision())}\0${effectiveReadLimit}`;
195
+ const cached = getUsageSummaryCacheEntry(cacheKey);
189
196
  if (cached && cached.revisionKey === observedRevisionKey && now < cached.expiresAt) {
190
197
  return jsonResponse(refreshedUsageSummary(cached.summary, range, now));
191
198
  }
192
- const snapshot = await readUsageSnapshotForManagement();
193
- const summary = summarizeUsage(snapshot.entries, range, now, surface);
194
- usageSummaryCache.set(cacheKey, {
195
- revisionKey: usageLogRevisionKey(snapshot.revision),
199
+ if (cached) discardUsageSummaryCacheEntry(cacheKey);
200
+ const snapshot = await readUsageSnapshotForManagement(effectiveReadLimit);
201
+ const revisionReadAt = Date.now();
202
+ const summary = {
203
+ ...summarizeUsage(snapshot.entries, range, now, surface),
204
+ historyTruncated: snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated,
205
+ truncatedPrefixBytes: snapshot.truncatedPrefixBytes,
206
+ entriesTruncated: snapshot.entriesTruncated,
207
+ entriesDropped: snapshot.entriesDropped,
208
+ };
209
+ setUsageSummaryCacheEntry(cacheKey, {
210
+ revisionKey: `${usageLogRevisionKey(snapshot.revision)}\0${effectiveReadLimit}`,
196
211
  expiresAt: usageSummaryExpiresAt(snapshot.entries, range, surface, now),
212
+ revisionReadAt,
197
213
  summary,
198
214
  });
199
215
  return jsonResponse(summary);
@@ -227,6 +243,10 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
227
243
  days: [],
228
244
  models: [],
229
245
  providers: [],
246
+ historyTruncated: false,
247
+ truncatedPrefixBytes: 0,
248
+ entriesTruncated: false,
249
+ entriesDropped: 0,
230
250
  error: "read_failed",
231
251
  });
232
252
  }
@@ -248,7 +268,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
248
268
 
249
269
  if (url.pathname === "/api/storage/cleanup/preview" && req.method === "POST") {
250
270
  let body: { percent?: unknown };
251
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid_json" }, 400); }
271
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid_json" }, 400); }
252
272
  const percent = typeof body?.percent === "number" ? body.percent : Number.NaN;
253
273
  if (!Number.isFinite(percent) || percent < 0 || percent > 100) {
254
274
  return jsonResponse({ error: "invalid_percent" }, 400);
@@ -272,7 +292,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
272
292
 
273
293
  if (url.pathname === "/api/storage/cleanup" && req.method === "POST") {
274
294
  let body: { percent?: unknown; mode?: unknown; digest?: unknown; _test?: unknown };
275
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid_json" }, 400); }
295
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid_json" }, 400); }
276
296
  const percent = typeof body?.percent === "number" ? body.percent : Number.NaN;
277
297
  if (!Number.isFinite(percent) || percent < 0 || percent > 100) {
278
298
  return jsonResponse({ error: "invalid_percent" }, 400);
@@ -373,7 +393,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
373
393
 
374
394
  if (url.pathname === "/api/storage/trash/restore" && req.method === "POST") {
375
395
  let body: { id?: unknown };
376
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid_json" }, 400); }
396
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid_json" }, 400); }
377
397
  const id = typeof body?.id === "string" ? body.id : "";
378
398
  if (!id.trim()) {
379
399
  return jsonResponse({ error: "invalid_trash", message: "Trash entry id is required." }, 400);
@@ -453,7 +473,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
453
473
 
454
474
  if (url.pathname === "/api/storage/cleanup-policy" && req.method === "PUT") {
455
475
  let raw: unknown;
456
- try { raw = await req.json(); } catch { return jsonResponse({ error: "invalid_json" }, 400); }
476
+ try { raw = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid_json" }, 400); }
457
477
  const previous = normalizeStorageCleanupPolicy(config.storageCleanupPolicy);
458
478
  const parsed = parseStorageCleanupPolicyInput(raw, previous);
459
479
  if (!parsed.ok) return jsonResponse({ error: parsed.error }, 400);
@@ -31,6 +31,7 @@ function readInputModalities(raw: unknown): { values?: string[]; error?: string
31
31
  }
32
32
  import type { CatalogModel } from "../../codex/catalog";
33
33
  import { catalogModelSlug, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
34
+ import { CatalogGatherBusyError } from "../../codex/catalog/provider-fetch";
34
35
  import { getProviderLiveModelCount } from "../../codex/model-cache";
35
36
  import {
36
37
  DEFAULT_SUBAGENT_MODELS,
@@ -83,12 +84,128 @@ import { drainAndShutdown } from "../lifecycle";
83
84
  import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
84
85
  import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
85
86
  import type { PersistedUsageAttempt } from "../../usage/log";
86
- import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
87
+ import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO, corsHeaders } from "../auth-cors";
87
88
  import { applySystemEnvToggle } from "../system-env";
89
+ import {
90
+ EXPORT_CLIENTS,
91
+ EXPORT_CLIENT_IDS,
92
+ OPENCODE_PROVIDER_ID,
93
+ buildClientConfig,
94
+ isExportClientId,
95
+ opencodeProxyBaseUrl,
96
+ } from "../../clients/config-export";
97
+ import type {
98
+ ExportClientId,
99
+ ExportModel,
100
+ OpencodeGeneratedConfig,
101
+ PiGeneratedConfig,
102
+ } from "../../clients/config-export";
88
103
 
89
104
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
90
105
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
91
106
  import type { ManagementContext } from "./context";
107
+ import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
108
+
109
+ /**
110
+ * One row of the `/api/models` list. Routed rows spread a `CatalogModel`, so the shape is
111
+ * that model plus the identity/visibility fields this boundary computes for every row
112
+ * regardless of source. `disabled` is always present; the rest vary by row origin.
113
+ */
114
+ type ManagementModelRow = Partial<CatalogModel> & {
115
+ provider: string;
116
+ id: string;
117
+ namespaced: string;
118
+ disabled: boolean;
119
+ native?: boolean;
120
+ custom?: boolean;
121
+ customId?: string;
122
+ };
123
+
124
+ /**
125
+ * The exact row list `/api/models` returns. Extracted so `/api/client-config` exports the
126
+ * models the GUI's Models tab shows — including this function's `disabled` computation,
127
+ * which the export core (src/clients/config-export.ts) deliberately does not perform.
128
+ */
129
+ async function listManagementModelRows(config: OcxConfig): Promise<ManagementModelRow[]> {
130
+ const models = await fetchAllModels(config);
131
+ const disabled = new Set(config.disabledModels ?? []);
132
+ // Native GPT passthrough rows lead (provider "openai", bare-slug namespaced ids): sourced
133
+ // from the static supported set so a disabled model stays listed and re-enableable.
134
+ const native: ManagementModelRow[] = nativeModelRows(config).map(row => ({
135
+ provider: "openai",
136
+ id: row.slug,
137
+ namespaced: row.slug,
138
+ disabled: row.disabled,
139
+ native: true,
140
+ ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}),
141
+ }));
142
+ const customModels: ManagementModelRow[] = (config.customModels ?? []).map(cm => {
143
+ const namespaced = routedSlug(cm.provider, cm.modelId);
144
+ return {
145
+ provider: cm.provider,
146
+ id: cm.modelId,
147
+ namespaced,
148
+ disabled: [...disabled].some(stored => slugEquals(stored, cm.provider, cm.modelId)),
149
+ custom: true,
150
+ customId: cm.id,
151
+ displayName: cm.displayName,
152
+ ...(cm.contextWindow ? { contextWindow: cm.contextWindow } : {}),
153
+ ...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}),
154
+ };
155
+ });
156
+ const publicModels = uniqueCatalogModelsForPublicList(models);
157
+ const comboNamespaced = new Set(
158
+ publicModels.filter(model => model.provider === "combo").map(catalogModelSlug),
159
+ );
160
+ const visibleCustomModels = customModels.filter(model => !comboNamespaced.has(model.namespaced));
161
+ // Custom metadata wins when a physical live/static row resolves to the same Codex-facing
162
+ // slug, while a combo keeps the same precedence it has in routing and /v1/models.
163
+ const customNamespaced = new Set(visibleCustomModels.map(c => c.namespaced));
164
+ const dedupedRouted = publicModels.map((m): ManagementModelRow | null => {
165
+ // Codex-facing slug (one "/", slug-codec); disabledModels compares tolerate both forms.
166
+ const namespaced = catalogModelSlug(m);
167
+ if (m.provider !== "combo" && customNamespaced.has(namespaced)) return null;
168
+ const contextCap = providerContextCap(config, m.provider);
169
+ return {
170
+ ...m,
171
+ namespaced,
172
+ disabled: [...disabled].some(stored => (
173
+ stored === namespaced || slugEquals(stored, m.provider, m.id)
174
+ )),
175
+ ...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}),
176
+ };
177
+ }).filter((row): row is ManagementModelRow => row !== null);
178
+ return [...native, ...dedupedRouted, ...visibleCustomModels];
179
+ }
180
+
181
+ /** `/api/models` row → the narrower input the client-config serializers accept. */
182
+ function toExportModel(row: ManagementModelRow): ExportModel {
183
+ return {
184
+ namespaced: row.namespaced,
185
+ provider: row.provider,
186
+ id: row.id,
187
+ ...(row.native ? { native: true } : {}),
188
+ ...(row.displayName ? { displayName: row.displayName } : {}),
189
+ ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}),
190
+ ...(row.inputModalities ? { inputModalities: row.inputModalities } : {}),
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Counts read back off the SERIALIZED document rather than recomputed from the input rows.
196
+ * `modelsWithoutLimits` drives a GUI line claiming "these models ship without limits", so it
197
+ * has to describe the bytes the user actually receives — a parallel reimplementation of the
198
+ * core's "authoritative context window" rule would be free to drift from it silently.
199
+ */
200
+ function summarizeExportedModels(client: ExportClientId, document: unknown): { modelCount: number; modelsWithoutLimits: number } {
201
+ if (client === "opencode") {
202
+ const models = (document as OpencodeGeneratedConfig).provider[OPENCODE_PROVIDER_ID].models;
203
+ const entries = Object.values(models);
204
+ return { modelCount: entries.length, modelsWithoutLimits: entries.filter(entry => entry.limit === undefined).length };
205
+ }
206
+ const models = (document as PiGeneratedConfig).providers[OPENCODE_PROVIDER_ID].models;
207
+ return { modelCount: models.length, modelsWithoutLimits: models.filter(entry => entry.contextWindow === undefined).length };
208
+ }
92
209
 
93
210
  export async function handleModelRoutes(ctx: ManagementContext): Promise<Response | null> {
94
211
  const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx;
@@ -98,63 +215,80 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
98
215
  // ~/.opencodex/config.json with the `existing-uuid` test fixture.
99
216
  const persistConfig = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode;
100
217
 
218
+ if (url.pathname === "/api/catalog" && req.method === "GET") {
219
+ const { readCatalog, readCodexCatalogPath } = await import("../../codex/catalog");
220
+ const catalog = readCatalog(readCodexCatalogPath());
221
+ if (!catalog) return jsonResponse({ error: "catalog not found" }, 404, req, config);
222
+ const headers: Record<string, string> = {
223
+ "Content-Type": "application/json",
224
+ ...corsHeaders(req, config),
225
+ };
226
+ const { loadPersistedCodexRuntime } = await import("../../codex/runtime");
227
+ const version = loadPersistedCodexRuntime()?.selectedVersion;
228
+ if (version) headers["x-opencodex-codex-version"] = version;
229
+ return new Response(JSON.stringify(catalog), { status: 200, headers });
230
+ }
231
+
101
232
  if (url.pathname === "/api/models" && req.method === "GET") {
102
- const models = await fetchAllModels(config);
103
- const disabled = new Set(config.disabledModels ?? []);
104
- // Native GPT passthrough rows lead (provider "openai", bare-slug namespaced ids): sourced
105
- // from the static supported set so a disabled model stays listed and re-enableable.
106
- const native = nativeModelRows(config).map(row => ({
107
- provider: "openai",
108
- id: row.slug,
109
- namespaced: row.slug,
110
- disabled: row.disabled,
111
- native: true,
112
- ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}),
113
- }));
114
- const customModels = (config.customModels ?? []).map(cm => {
115
- const namespaced = routedSlug(cm.provider, cm.modelId);
116
- return {
117
- provider: cm.provider,
118
- id: cm.modelId,
119
- namespaced,
120
- disabled: [...disabled].some(stored => slugEquals(stored, cm.provider, cm.modelId)),
121
- custom: true,
122
- customId: cm.id,
123
- displayName: cm.displayName,
124
- ...(cm.contextWindow ? { contextWindow: cm.contextWindow } : {}),
125
- ...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}),
126
- };
233
+ return jsonResponse(await listManagementModelRows(config));
234
+ }
235
+
236
+ /**
237
+ * Client config document for OpenCode / Pi, built from the SAME function `ocx export`
238
+ * calls, so the bytes a user downloads here and the bytes they pipe from the CLI cannot
239
+ * disagree. Read-only: this route never writes the user's client config.
240
+ */
241
+ if (url.pathname === "/api/client-config" && req.method === "GET") {
242
+ const requested = url.searchParams.get("client")?.trim() ?? "";
243
+ if (!isExportClientId(requested)) {
244
+ return jsonResponse(
245
+ { error: `client must be one of: ${EXPORT_CLIENT_IDS.join(", ")}` },
246
+ 400,
247
+ req,
248
+ config,
249
+ );
250
+ }
251
+ const spec = EXPORT_CLIENTS[requested];
252
+ let rows: ManagementModelRow[];
253
+ try {
254
+ rows = await listManagementModelRows(config);
255
+ } catch (error) {
256
+ // A partial or empty `models` block reads as a valid config while offering nothing,
257
+ // so a catalog failure is surfaced as unavailable rather than serialized. The
258
+ // catalog-busy error keeps its own 503 from handleManagementAPI.
259
+ if (error instanceof CatalogGatherBusyError) throw error;
260
+ return jsonResponse(
261
+ { error: `model catalog unavailable: ${error instanceof Error ? error.message : String(error)}` },
262
+ 503,
263
+ req,
264
+ config,
265
+ );
266
+ }
267
+ // The export core does not filter visibility — it serializes what it is given. A model the
268
+ // user disabled in the Models tab is absent from /v1/models, so exporting it would hand the
269
+ // client a selector the proxy refuses to route.
270
+ const models = rows.filter(row => !row.disabled).map(toExportModel);
271
+ const document = buildClientConfig(requested, {
272
+ baseUrl: opencodeProxyBaseUrl(Number(url.port) || config.port, config.hostname),
273
+ models,
274
+ config,
127
275
  });
128
- const publicModels = uniqueCatalogModelsForPublicList(models);
129
- const comboNamespaced = new Set(
130
- publicModels.filter(model => model.provider === "combo").map(catalogModelSlug),
131
- );
132
- const visibleCustomModels = customModels.filter(model => !comboNamespaced.has(model.namespaced));
133
- // Custom metadata wins when a physical live/static row resolves to the same Codex-facing
134
- // slug, while a combo keeps the same precedence it has in routing and /v1/models.
135
- const customNamespaced = new Set(visibleCustomModels.map(c => c.namespaced));
136
- const dedupedRouted = publicModels.map(m => {
137
- // Codex-facing slug (one "/", slug-codec); disabledModels compares tolerate both forms.
138
- const namespaced = catalogModelSlug(m);
139
- if (m.provider !== "combo" && customNamespaced.has(namespaced)) return null;
140
- const contextCap = providerContextCap(config, m.provider);
141
- return {
142
- ...m,
143
- namespaced,
144
- disabled: [...disabled].some(stored => (
145
- stored === namespaced || slugEquals(stored, m.provider, m.id)
146
- )),
147
- ...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}),
148
- };
149
- }).filter(Boolean);
150
- return jsonResponse([...native, ...dedupedRouted, ...visibleCustomModels]);
276
+ return jsonResponse({
277
+ client: spec.id,
278
+ filename: spec.filename,
279
+ destination: spec.destination(process.env),
280
+ apiKeyEnv: spec.apiKeyEnv,
281
+ exportHint: spec.exportHint,
282
+ ...summarizeExportedModels(requested, document),
283
+ config: document,
284
+ }, 200, req, config);
151
285
  }
152
286
 
153
287
  // Enable/disable models: which routed models Codex sees. PUT hides them from the catalog +
154
288
  // /v1/models and invalidates Codex's 5-min models cache so it applies on the next turn.
155
289
  if (url.pathname === "/api/disabled-models" && req.method === "PUT") {
156
290
  let body: { models?: unknown };
157
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
291
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
158
292
  const disabled = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string") : [];
159
293
  config.disabledModels = disabled;
160
294
  persistConfig(config);
@@ -167,7 +301,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
167
301
  // state. Native rows only use the blocklist; routed/custom rows also join a non-empty allowlist.
168
302
  if (url.pathname === "/api/model-visibility" && req.method === "PUT") {
169
303
  let parsedBody: unknown;
170
- try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
304
+ try { parsedBody = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
171
305
  if (!isPlainRecord(parsedBody)) return jsonResponse({ error: "invalid model visibility request" }, 400);
172
306
  const body = parsedBody;
173
307
  const scope = body.scope === "models" || body.scope === "provider" ? body.scope : null;
@@ -267,7 +401,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
267
401
 
268
402
  if (url.pathname === "/api/custom-models" && req.method === "POST") {
269
403
  let body: { provider?: unknown; modelId?: unknown; displayName?: unknown; contextWindow?: unknown; inputModalities?: unknown };
270
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
404
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
271
405
  const provider = typeof body.provider === "string" ? body.provider.trim() : "";
272
406
  const modelId = typeof body.modelId === "string" ? body.modelId.trim() : "";
273
407
  if (!provider || !modelId) return jsonResponse({ error: "provider and modelId are required" }, 400);
@@ -305,7 +439,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
305
439
  let id: string;
306
440
  try { id = decodeURIComponent(customPutMatch[1]); } catch { return jsonResponse({ error: "invalid id encoding" }, 400); }
307
441
  let body: { displayName?: unknown; contextWindow?: unknown; inputModalities?: unknown; modelId?: unknown };
308
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
442
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
309
443
  const list = config.customModels ?? [];
310
444
  const idx = list.findIndex(cm => cm.id === id);
311
445
  if (idx === -1) return jsonResponse({ error: "not found" }, 404);
@@ -372,7 +506,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
372
506
  }
373
507
  if (url.pathname === "/api/selected-models" && req.method === "PUT") {
374
508
  let body: { provider?: unknown; models?: unknown };
375
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
509
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
376
510
  const provider = typeof body.provider === "string" ? body.provider : "";
377
511
  if (!provider || !hasOwnProvider(config.providers, provider)) {
378
512
  return jsonResponse({ error: "unknown provider" }, provider ? 404 : 400);