@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.
- package/README.md +24 -0
- package/bin/ocx.mjs +32 -4
- package/gui/dist/assets/index-CHwf3tTD.css +1 -0
- package/gui/dist/assets/index-u5eFOv2y.js +67 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-normalize.ts +114 -20
- package/src/adapters/anthropic.ts +126 -10
- package/src/adapters/azure.ts +3 -3
- package/src/adapters/base.ts +7 -3
- package/src/adapters/cursor/discovery.ts +14 -4
- package/src/adapters/cursor/effort-map.ts +18 -6
- package/src/adapters/cursor/framing.ts +102 -27
- package/src/adapters/cursor/kv-store.ts +30 -3
- package/src/adapters/cursor/live-models.ts +22 -2
- package/src/adapters/cursor/live-transport.ts +245 -49
- package/src/adapters/cursor/mcp-manager.ts +105 -8
- package/src/adapters/cursor/native-exec-mcp.ts +5 -3
- package/src/adapters/cursor/native-exec-shell.ts +296 -14
- package/src/adapters/cursor/native-exec.ts +381 -33
- package/src/adapters/cursor/protobuf-events.ts +28 -1
- package/src/adapters/cursor/protobuf-request.ts +71 -39
- package/src/adapters/cursor/request-builder.ts +2 -2
- package/src/adapters/cursor/transport.ts +2 -0
- package/src/adapters/cursor.ts +13 -2
- package/src/adapters/google-antigravity-replay.ts +184 -17
- package/src/adapters/google.ts +58 -8
- package/src/adapters/kiro-thinking.ts +23 -9
- package/src/adapters/kiro-tools.ts +49 -18
- package/src/adapters/kiro.ts +377 -133
- package/src/adapters/mimo-free.ts +36 -4
- package/src/adapters/openai-chat.ts +143 -17
- package/src/adapters/openai-responses.ts +130 -14
- package/src/adapters/run-turn-queue.ts +7 -1
- package/src/bridge.ts +466 -69
- package/src/chat/outbound.ts +144 -38
- package/src/claude/inbound-debug.ts +53 -8
- package/src/claude/outbound.ts +224 -38
- package/src/cli/agent-driven.ts +34 -1
- package/src/cli/catalog-prewarm.ts +5 -2
- package/src/cli/claude-desktop.ts +2 -2
- package/src/cli/doctor.ts +12 -0
- package/src/cli/export-command.ts +187 -0
- package/src/cli/help.ts +11 -0
- package/src/cli/index.ts +13 -3
- package/src/cli/init.ts +129 -102
- package/src/cli/opencode.ts +36 -151
- package/src/cli/star-prompt.ts +13 -4
- package/src/cli/status-oauth.ts +12 -2
- package/src/clients/config-export.ts +377 -0
- package/src/codex/account-runtime-state.ts +19 -1
- package/src/codex/account-store.ts +162 -82
- package/src/codex/auth-api.ts +467 -159
- package/src/codex/auth-context.ts +15 -2
- package/src/codex/catalog/aggregation.ts +15 -0
- package/src/codex/catalog/effort.ts +16 -6
- package/src/codex/catalog/metadata.ts +6 -0
- package/src/codex/catalog/parsing.ts +3 -1
- package/src/codex/catalog/provider-fetch.ts +29 -0
- package/src/codex/catalog/sync.ts +64 -7
- package/src/codex/catalog.ts +2 -2
- package/src/codex/inject.ts +5 -5
- package/src/codex/main-account-cache.ts +8 -1
- package/src/codex/model-cache.ts +81 -2
- package/src/codex/pool-rotation.ts +39 -0
- package/src/codex/project-config-warnings.ts +12 -1
- package/src/codex/quota.ts +35 -3
- package/src/codex/routing.ts +46 -1
- package/src/codex/shim.ts +10 -4
- package/src/codex/subagent-model-fallback.ts +12 -0
- package/src/codex/websocket-registry.ts +27 -0
- package/src/combos/failover.ts +31 -1
- package/src/combos/request.ts +9 -0
- package/src/combos/resolve.ts +60 -4
- package/src/combos/types.ts +12 -0
- package/src/config.ts +510 -55
- package/src/github/star-state.ts +13 -1
- package/src/images/fulfill.ts +39 -1
- package/src/images/loop.ts +52 -12
- package/src/lib/admission.ts +83 -0
- package/src/lib/app-owned-memory-stores.ts +173 -0
- package/src/lib/app-owned-memory.ts +265 -0
- package/src/lib/bun-stream-caps.ts +31 -7
- package/src/lib/config-ownership.ts +33 -0
- package/src/lib/crash-guard.ts +65 -5
- package/src/lib/debug-log-buffer.ts +47 -6
- package/src/lib/destination-policy.ts +12 -1
- package/src/lib/errors.ts +3 -0
- package/src/lib/gcp-adc.ts +40 -2
- package/src/lib/injection-debug-log.ts +26 -2
- package/src/lib/provider-outbound.ts +3 -0
- package/src/lib/sidecar-tracker.ts +5 -2
- package/src/lib/sse-decoder.ts +257 -37
- package/src/lib/state-store-registrations.ts +109 -0
- package/src/lib/state-store-sweeper.ts +184 -0
- package/src/lib/translator-budget.ts +356 -0
- package/src/lib/windows-secret-acl.ts +33 -12
- package/src/lib/winsw.ts +14 -1
- package/src/oauth/anthropic-routing.ts +31 -7
- package/src/oauth/google-antigravity.ts +2 -1
- package/src/oauth/health.ts +30 -12
- package/src/oauth/index.ts +127 -23
- package/src/oauth/kiro-credentials.ts +72 -1
- package/src/oauth/kiro.ts +23 -4
- package/src/oauth/store.ts +165 -18
- package/src/oauth/token-guardian.ts +43 -4
- package/src/oauth/types.ts +2 -1
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/derive.ts +12 -0
- package/src/providers/free-directory.ts +4 -1
- package/src/providers/key-failover.ts +12 -0
- package/src/providers/openai-sidecar.ts +4 -1
- package/src/providers/quota.ts +68 -7
- package/src/providers/registry.ts +279 -3
- package/src/responses/parser.ts +5 -1
- package/src/responses/spill-store.ts +394 -0
- package/src/responses/state.ts +520 -102
- package/src/router.ts +18 -1
- package/src/server/adapter-resolve.ts +20 -3
- package/src/server/auth-cors.ts +121 -28
- package/src/server/chat-completions.ts +57 -12
- package/src/server/claude-messages.ts +85 -13
- package/src/server/index.ts +242 -100
- package/src/server/lifecycle.ts +155 -25
- package/src/server/management/agent-settings-routes.ts +79 -36
- package/src/server/management/api-key-usage.ts +167 -0
- package/src/server/management/body.ts +35 -0
- package/src/server/management/combo-routes.ts +5 -1
- package/src/server/management/config-routes.ts +42 -12
- package/src/server/management/logs-usage-routes.ts +41 -21
- package/src/server/management/model-routes.ts +188 -54
- package/src/server/management/oauth-account-routes.ts +115 -26
- package/src/server/management/provider-routes.ts +56 -6
- package/src/server/management/shared.ts +16 -3
- package/src/server/management/sidebar-routes.ts +50 -1
- package/src/server/management/system-restart.ts +13 -6
- package/src/server/management/system-routes.ts +15 -3
- package/src/server/management/usage-summary-cache.ts +86 -0
- package/src/server/management-api.ts +39 -5
- package/src/server/management-auth.ts +65 -14
- package/src/server/port-reclaim.ts +58 -12
- package/src/server/ports.ts +2 -0
- package/src/server/proxy-liveness.ts +60 -14
- package/src/server/relay-eager.ts +20 -4
- package/src/server/relay.ts +548 -154
- package/src/server/request-decompress.ts +51 -4
- package/src/server/request-log.ts +134 -15
- package/src/server/responses/collaboration.ts +15 -4
- package/src/server/responses/compact.ts +3 -0
- package/src/server/responses/core.ts +241 -66
- package/src/server/responses-image-gen-repair.ts +19 -5
- package/src/server/responses-item-id-repair.ts +23 -5
- package/src/server/sse-payload-rewrite.ts +71 -12
- package/src/server/startup-health-cache.ts +14 -1
- package/src/server/system-env.ts +8 -1
- package/src/server/windows-tcp-drop.ts +15 -5
- package/src/server/ws-bridge.ts +25 -0
- package/src/service.ts +179 -13
- package/src/storage/policy-job.ts +93 -23
- package/src/storage/policy-worker.ts +6 -0
- package/src/storage/restore-job.ts +62 -16
- package/src/storage/restore-worker.ts +6 -0
- package/src/storage/storage-mutation-coordinator.ts +36 -6
- package/src/storage/worker-lifecycle.ts +181 -47
- package/src/tray/windows.ts +97 -25
- package/src/types.ts +39 -6
- package/src/update/index.ts +24 -5
- package/src/update/job.ts +598 -73
- package/src/usage/log.ts +115 -17
- package/src/usage/summary.ts +67 -2
- package/src/vision/index.ts +112 -22
- package/src/web-search/loop.ts +38 -6
- package/src/web-search/progress-stream.ts +14 -3
- package/gui/dist/assets/index-BDjpkcRN.js +0 -67
- 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
|
|
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
|
|
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
|
|
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
|
|
190
|
-
// troubleshooting
|
|
191
|
-
// not inherit shell env
|
|
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
|
|
196
|
-
if (body.codexAutoStart === undefined && body.streamMode === undefined) {
|
|
197
|
-
return jsonResponse({ error: "codexAutoStart
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
128
|
-
|
|
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
|
|
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
|
|
188
|
-
const
|
|
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
|
-
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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);
|