@bitkyc08/opencodex 2.8.2-preview.20260731 → 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 (166) 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 +86 -5
  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 +89 -14
  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/doctor.ts +12 -0
  42. package/src/cli/export-command.ts +187 -0
  43. package/src/cli/help.ts +11 -0
  44. package/src/cli/index.ts +13 -3
  45. package/src/cli/opencode.ts +36 -151
  46. package/src/cli/star-prompt.ts +13 -4
  47. package/src/cli/status-oauth.ts +12 -2
  48. package/src/clients/config-export.ts +377 -0
  49. package/src/codex/account-runtime-state.ts +19 -1
  50. package/src/codex/account-store.ts +162 -82
  51. package/src/codex/auth-api.ts +467 -159
  52. package/src/codex/auth-context.ts +15 -2
  53. package/src/codex/catalog/aggregation.ts +15 -0
  54. package/src/codex/catalog/effort.ts +16 -6
  55. package/src/codex/catalog/parsing.ts +3 -1
  56. package/src/codex/catalog/provider-fetch.ts +29 -0
  57. package/src/codex/catalog/sync.ts +64 -7
  58. package/src/codex/inject.ts +5 -5
  59. package/src/codex/main-account-cache.ts +8 -1
  60. package/src/codex/model-cache.ts +81 -2
  61. package/src/codex/pool-rotation.ts +39 -0
  62. package/src/codex/project-config-warnings.ts +12 -1
  63. package/src/codex/quota.ts +35 -3
  64. package/src/codex/routing.ts +46 -1
  65. package/src/codex/shim.ts +10 -4
  66. package/src/codex/subagent-model-fallback.ts +12 -0
  67. package/src/codex/websocket-registry.ts +27 -0
  68. package/src/combos/failover.ts +31 -1
  69. package/src/combos/request.ts +9 -0
  70. package/src/combos/resolve.ts +60 -4
  71. package/src/combos/types.ts +12 -0
  72. package/src/config.ts +510 -55
  73. package/src/github/star-state.ts +13 -1
  74. package/src/images/fulfill.ts +39 -1
  75. package/src/images/loop.ts +52 -12
  76. package/src/lib/admission.ts +83 -0
  77. package/src/lib/app-owned-memory-stores.ts +173 -0
  78. package/src/lib/app-owned-memory.ts +265 -0
  79. package/src/lib/bun-stream-caps.ts +31 -7
  80. package/src/lib/config-ownership.ts +33 -0
  81. package/src/lib/crash-guard.ts +65 -5
  82. package/src/lib/debug-log-buffer.ts +47 -6
  83. package/src/lib/errors.ts +3 -0
  84. package/src/lib/gcp-adc.ts +40 -2
  85. package/src/lib/injection-debug-log.ts +26 -2
  86. package/src/lib/sidecar-tracker.ts +5 -2
  87. package/src/lib/sse-decoder.ts +257 -37
  88. package/src/lib/state-store-registrations.ts +109 -0
  89. package/src/lib/state-store-sweeper.ts +184 -0
  90. package/src/lib/translator-budget.ts +356 -0
  91. package/src/lib/windows-secret-acl.ts +33 -12
  92. package/src/lib/winsw.ts +8 -1
  93. package/src/oauth/anthropic-routing.ts +31 -7
  94. package/src/oauth/google-antigravity.ts +2 -1
  95. package/src/oauth/health.ts +30 -12
  96. package/src/oauth/index.ts +127 -23
  97. package/src/oauth/kiro.ts +10 -2
  98. package/src/oauth/store.ts +165 -18
  99. package/src/oauth/token-guardian.ts +43 -4
  100. package/src/oauth/types.ts +2 -1
  101. package/src/providers/base-url-choices.ts +10 -0
  102. package/src/providers/derive.ts +12 -0
  103. package/src/providers/key-failover.ts +12 -0
  104. package/src/providers/openai-sidecar.ts +4 -1
  105. package/src/providers/quota.ts +68 -7
  106. package/src/providers/registry.ts +279 -3
  107. package/src/responses/parser.ts +5 -1
  108. package/src/responses/spill-store.ts +394 -0
  109. package/src/responses/state.ts +520 -102
  110. package/src/router.ts +18 -1
  111. package/src/server/adapter-resolve.ts +20 -3
  112. package/src/server/auth-cors.ts +121 -28
  113. package/src/server/chat-completions.ts +57 -12
  114. package/src/server/claude-messages.ts +85 -13
  115. package/src/server/index.ts +239 -97
  116. package/src/server/lifecycle.ts +155 -25
  117. package/src/server/management/agent-settings-routes.ts +75 -32
  118. package/src/server/management/api-key-usage.ts +167 -0
  119. package/src/server/management/body.ts +35 -0
  120. package/src/server/management/combo-routes.ts +5 -1
  121. package/src/server/management/config-routes.ts +37 -12
  122. package/src/server/management/logs-usage-routes.ts +41 -21
  123. package/src/server/management/model-routes.ts +173 -53
  124. package/src/server/management/oauth-account-routes.ts +115 -26
  125. package/src/server/management/provider-routes.ts +56 -6
  126. package/src/server/management/sidebar-routes.ts +50 -1
  127. package/src/server/management/system-restart.ts +13 -6
  128. package/src/server/management/system-routes.ts +15 -3
  129. package/src/server/management/usage-summary-cache.ts +86 -0
  130. package/src/server/management-api.ts +39 -5
  131. package/src/server/management-auth.ts +65 -14
  132. package/src/server/port-reclaim.ts +58 -12
  133. package/src/server/ports.ts +2 -0
  134. package/src/server/proxy-liveness.ts +51 -12
  135. package/src/server/relay-eager.ts +20 -4
  136. package/src/server/relay.ts +548 -154
  137. package/src/server/request-decompress.ts +51 -4
  138. package/src/server/request-log.ts +134 -15
  139. package/src/server/responses/collaboration.ts +15 -4
  140. package/src/server/responses/compact.ts +3 -0
  141. package/src/server/responses/core.ts +241 -66
  142. package/src/server/responses-image-gen-repair.ts +19 -5
  143. package/src/server/responses-item-id-repair.ts +23 -5
  144. package/src/server/sse-payload-rewrite.ts +71 -12
  145. package/src/server/startup-health-cache.ts +14 -1
  146. package/src/server/system-env.ts +8 -1
  147. package/src/server/windows-tcp-drop.ts +15 -5
  148. package/src/server/ws-bridge.ts +25 -0
  149. package/src/service.ts +23 -3
  150. package/src/storage/policy-job.ts +93 -23
  151. package/src/storage/policy-worker.ts +6 -0
  152. package/src/storage/restore-job.ts +62 -16
  153. package/src/storage/restore-worker.ts +6 -0
  154. package/src/storage/storage-mutation-coordinator.ts +36 -6
  155. package/src/storage/worker-lifecycle.ts +181 -47
  156. package/src/tray/windows.ts +45 -18
  157. package/src/types.ts +34 -6
  158. package/src/update/index.ts +24 -5
  159. package/src/update/job.ts +598 -73
  160. package/src/usage/log.ts +115 -17
  161. package/src/usage/summary.ts +67 -2
  162. package/src/vision/index.ts +112 -22
  163. package/src/web-search/loop.ts +38 -6
  164. package/src/web-search/progress-stream.ts +14 -3
  165. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
  166. package/gui/dist/assets/index-GC0Vlu1Z.js +0 -67
@@ -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;
@@ -120,6 +128,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
120
128
  port: config.port,
121
129
  hostname: config.hostname ?? "127.0.0.1",
122
130
  streamMode: config.streamMode ?? "auto",
131
+ appOwnedMemoryBudgetMb: config.appOwnedMemoryBudgetMb ?? 256,
123
132
  startupHealth: await getCachedStartupHealth(config),
124
133
  codexRuntime: {
125
134
  path: displayCodexRuntimePath(resolved.runtime.command),
@@ -147,7 +156,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
147
156
 
148
157
  if (url.pathname === "/api/startup-action" && req.method === "POST") {
149
158
  let body: { action?: unknown; repair?: unknown };
150
- 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); }
151
160
  if (!body || !["install-service", "install-shim"].includes(String(body.action))) {
152
161
  return jsonResponse({ error: "action must be install-service or install-shim" }, 400);
153
162
  }
@@ -176,7 +185,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
176
185
 
177
186
  if (url.pathname === "/api/windows-tray" && req.method === "POST") {
178
187
  let body: { action?: unknown };
179
- 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); }
180
189
  if (!body || !["install", "start", "stop", "uninstall"].includes(String(body.action))) {
181
190
  return jsonResponse({ error: "action must be install, start, stop, or uninstall" }, 400);
182
191
  }
@@ -191,15 +200,15 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
191
200
 
192
201
  if (url.pathname === "/api/settings" && req.method === "PUT") {
193
202
  // Each field is optional but at least one must be present; fields are
194
- // validated when present. streamMode-only PUTs must work: Windows-memory
195
- // troubleshooting docs tell service users to set it here (a service does
196
- // 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
197
206
  // change applies to NEW turns only — the config object is shared by
198
207
  // reference with the request handlers, no restart needed.
199
- let body: { codexAutoStart?: unknown; streamMode?: unknown };
200
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
201
- if (body.codexAutoStart === undefined && body.streamMode === undefined) {
202
- 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);
203
212
  }
204
213
  if (body.codexAutoStart !== undefined && typeof body.codexAutoStart !== "boolean") {
205
214
  return jsonResponse({ error: "codexAutoStart boolean is required" }, 400);
@@ -207,6 +216,14 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
207
216
  if (body.streamMode !== undefined && !isStreamMode(body.streamMode)) {
208
217
  return jsonResponse({ error: "streamMode must be auto, legacy-tee, or eager-relay" }, 400);
209
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
+ }
210
227
  if (typeof body.codexAutoStart === "boolean") {
211
228
  config.codexAutoStart = body.codexAutoStart;
212
229
  }
@@ -217,12 +234,20 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
217
234
  config.streamMode = body.streamMode as "legacy-tee" | "eager-relay";
218
235
  }
219
236
  }
237
+ if (typeof body.appOwnedMemoryBudgetMb === "number") {
238
+ config.appOwnedMemoryBudgetMb = body.appOwnedMemoryBudgetMb;
239
+ }
220
240
  saveConfigPreservingClaudeCode(config);
241
+ if (typeof body.appOwnedMemoryBudgetMb === "number") {
242
+ configureAppOwnedMemoryBudget(resolveAppOwnedMemoryBudgetBytes(body.appOwnedMemoryBudgetMb));
243
+ enforceAppOwnedMemoryBudget();
244
+ }
221
245
  invalidateStartupHealthCache();
222
246
  return jsonResponse({
223
247
  ok: true,
224
248
  codexAutoStart: codexAutoStartEnabled(config),
225
249
  streamMode: config.streamMode ?? "auto",
250
+ appOwnedMemoryBudgetMb: config.appOwnedMemoryBudgetMb ?? 256,
226
251
  startupHealth: await getCachedStartupHealth(config),
227
252
  });
228
253
  }
@@ -255,7 +280,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
255
280
  if (url.pathname === "/api/update/run" && req.method === "POST") {
256
281
  const { normalizeUpdateChannel, startUpdateJob, UpdateJobError } = await import("../../update/job");
257
282
  let body: { tag?: unknown; restart?: unknown };
258
- 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); }
259
284
  if (body.tag !== undefined && body.tag !== "latest" && body.tag !== "preview") {
260
285
  return jsonResponse({ error: "tag must be latest or preview" }, 400);
261
286
  }
@@ -294,7 +319,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
294
319
 
295
320
  if (url.pathname === "/api/sidecar-settings" && req.method === "PUT") {
296
321
  let raw: unknown;
297
- 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); }
298
323
  // Strict shape (review F2): reject non-object bodies and non-object sections instead of throwing
299
324
  // on `null` or silently accepting arrays/strings as no-op updates.
300
325
  if (!isPlainRecord(raw)) return jsonResponse({ error: "body must be a JSON object" }, 400);
@@ -369,7 +394,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
369
394
 
370
395
  if (url.pathname === "/api/shadow-call-settings" && req.method === "PUT") {
371
396
  let raw: unknown;
372
- 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); }
373
398
  if (!isPlainRecord(raw)) return jsonResponse({ error: "body must be a JSON object" }, 400);
374
399
  const body = raw as { enabled?: unknown; model?: unknown };
375
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,
@@ -85,10 +86,126 @@ import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerS
85
86
  import type { PersistedUsageAttempt } from "../../usage/log";
86
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;
@@ -113,62 +230,65 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
113
230
  }
114
231
 
115
232
  if (url.pathname === "/api/models" && req.method === "GET") {
116
- const models = await fetchAllModels(config);
117
- const disabled = new Set(config.disabledModels ?? []);
118
- // Native GPT passthrough rows lead (provider "openai", bare-slug namespaced ids): sourced
119
- // from the static supported set so a disabled model stays listed and re-enableable.
120
- const native = nativeModelRows(config).map(row => ({
121
- provider: "openai",
122
- id: row.slug,
123
- namespaced: row.slug,
124
- disabled: row.disabled,
125
- native: true,
126
- ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}),
127
- }));
128
- const customModels = (config.customModels ?? []).map(cm => {
129
- const namespaced = routedSlug(cm.provider, cm.modelId);
130
- return {
131
- provider: cm.provider,
132
- id: cm.modelId,
133
- namespaced,
134
- disabled: [...disabled].some(stored => slugEquals(stored, cm.provider, cm.modelId)),
135
- custom: true,
136
- customId: cm.id,
137
- displayName: cm.displayName,
138
- ...(cm.contextWindow ? { contextWindow: cm.contextWindow } : {}),
139
- ...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}),
140
- };
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,
141
275
  });
142
- const publicModels = uniqueCatalogModelsForPublicList(models);
143
- const comboNamespaced = new Set(
144
- publicModels.filter(model => model.provider === "combo").map(catalogModelSlug),
145
- );
146
- const visibleCustomModels = customModels.filter(model => !comboNamespaced.has(model.namespaced));
147
- // Custom metadata wins when a physical live/static row resolves to the same Codex-facing
148
- // slug, while a combo keeps the same precedence it has in routing and /v1/models.
149
- const customNamespaced = new Set(visibleCustomModels.map(c => c.namespaced));
150
- const dedupedRouted = publicModels.map(m => {
151
- // Codex-facing slug (one "/", slug-codec); disabledModels compares tolerate both forms.
152
- const namespaced = catalogModelSlug(m);
153
- if (m.provider !== "combo" && customNamespaced.has(namespaced)) return null;
154
- const contextCap = providerContextCap(config, m.provider);
155
- return {
156
- ...m,
157
- namespaced,
158
- disabled: [...disabled].some(stored => (
159
- stored === namespaced || slugEquals(stored, m.provider, m.id)
160
- )),
161
- ...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}),
162
- };
163
- }).filter(Boolean);
164
- 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);
165
285
  }
166
286
 
167
287
  // Enable/disable models: which routed models Codex sees. PUT hides them from the catalog +
168
288
  // /v1/models and invalidates Codex's 5-min models cache so it applies on the next turn.
169
289
  if (url.pathname === "/api/disabled-models" && req.method === "PUT") {
170
290
  let body: { models?: unknown };
171
- 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); }
172
292
  const disabled = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string") : [];
173
293
  config.disabledModels = disabled;
174
294
  persistConfig(config);
@@ -181,7 +301,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
181
301
  // state. Native rows only use the blocklist; routed/custom rows also join a non-empty allowlist.
182
302
  if (url.pathname === "/api/model-visibility" && req.method === "PUT") {
183
303
  let parsedBody: unknown;
184
- 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); }
185
305
  if (!isPlainRecord(parsedBody)) return jsonResponse({ error: "invalid model visibility request" }, 400);
186
306
  const body = parsedBody;
187
307
  const scope = body.scope === "models" || body.scope === "provider" ? body.scope : null;
@@ -281,7 +401,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
281
401
 
282
402
  if (url.pathname === "/api/custom-models" && req.method === "POST") {
283
403
  let body: { provider?: unknown; modelId?: unknown; displayName?: unknown; contextWindow?: unknown; inputModalities?: unknown };
284
- 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); }
285
405
  const provider = typeof body.provider === "string" ? body.provider.trim() : "";
286
406
  const modelId = typeof body.modelId === "string" ? body.modelId.trim() : "";
287
407
  if (!provider || !modelId) return jsonResponse({ error: "provider and modelId are required" }, 400);
@@ -319,7 +439,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
319
439
  let id: string;
320
440
  try { id = decodeURIComponent(customPutMatch[1]); } catch { return jsonResponse({ error: "invalid id encoding" }, 400); }
321
441
  let body: { displayName?: unknown; contextWindow?: unknown; inputModalities?: unknown; modelId?: unknown };
322
- 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); }
323
443
  const list = config.customModels ?? [];
324
444
  const idx = list.findIndex(cm => cm.id === id);
325
445
  if (idx === -1) return jsonResponse({ error: "not found" }, 404);
@@ -386,7 +506,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
386
506
  }
387
507
  if (url.pathname === "/api/selected-models" && req.method === "PUT") {
388
508
  let body: { provider?: unknown; models?: unknown };
389
- 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); }
390
510
  const provider = typeof body.provider === "string" ? body.provider : "";
391
511
  if (!provider || !hasOwnProvider(config.providers, provider)) {
392
512
  return jsonResponse({ error: "unknown provider" }, provider ? 404 : 400);