@iislee/opencodex 2.31.0 → 2.35.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 +3 -3
- package/bin/ocx.mjs +99 -70
- package/gui/dist/assets/index-Be1sDpWw.css +1 -0
- package/gui/dist/assets/index-D4OKgUV4.js +114 -0
- package/gui/dist/index.html +2 -2
- package/package.json +3 -1
- package/src/adapters/anthropic.ts +104 -15
- package/src/adapters/base.ts +29 -1
- package/src/adapters/command-code.ts +42 -6
- package/src/adapters/cursor/call-id.ts +44 -0
- package/src/adapters/cursor/catalog.ts +541 -0
- package/src/adapters/cursor/checkpoint-store.ts +15 -10
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/discovery.ts +69 -18
- package/src/adapters/cursor/effort-map.ts +79 -1
- package/src/adapters/cursor/envelope-echo.ts +290 -0
- package/src/adapters/cursor/live-models.ts +7 -2
- package/src/adapters/cursor/live-transport.ts +14 -0
- package/src/adapters/cursor/message-mapper.ts +4 -1
- package/src/adapters/cursor/native-exec-desktop.ts +23 -0
- package/src/adapters/cursor/native-exec-fs.ts +2 -2
- package/src/adapters/cursor/native-exec-network.ts +1 -1
- package/src/adapters/cursor/native-exec-shell.ts +5 -2
- package/src/adapters/cursor/native-exec.ts +20 -0
- package/src/adapters/cursor/protobuf-request.ts +68 -14
- package/src/adapters/cursor/request-builder.ts +40 -10
- package/src/adapters/cursor/tool-definitions.ts +14 -4
- package/src/adapters/cursor/tool-result-normalize.ts +28 -5
- package/src/adapters/cursor/types.ts +20 -0
- package/src/adapters/cursor.ts +209 -38
- package/src/adapters/exec-tool-result-normalize.ts +99 -0
- package/src/adapters/google-antigravity-replay.ts +173 -25
- package/src/adapters/google-antigravity-wire.ts +5 -0
- package/src/adapters/google-errors.ts +44 -12
- package/src/adapters/google.ts +136 -24
- package/src/adapters/image.ts +1 -1
- package/src/adapters/kiro-constants.ts +27 -0
- package/src/adapters/kiro-tools.ts +55 -16
- package/src/adapters/kiro.ts +182 -23
- package/src/adapters/openai-chat.ts +300 -242
- package/src/adapters/openai-responses.ts +353 -27
- package/src/adapters/run-turn-queue.ts +36 -1
- package/src/adapters/tool-catalog-nudge.ts +4 -3
- package/src/adapters/xai-tool-schema.ts +436 -0
- package/src/adapters/xai-web-search.ts +17 -16
- package/src/bridge.ts +90 -37
- package/src/chat/inbound.ts +14 -1
- package/src/chat/outbound.ts +15 -7
- package/src/claude/agents-inject.ts +8 -1
- package/src/claude/context-windows.ts +16 -9
- package/src/claude/outbound.ts +24 -11
- package/src/cli/access.ts +46 -3
- package/src/cli/account-api.ts +116 -19
- package/src/cli/account-extended.ts +262 -29
- package/src/cli/account-main.ts +12 -12
- package/src/cli/account.ts +69 -15
- package/src/cli/agent.ts +8 -1
- package/src/cli/alias.ts +66 -0
- package/src/cli/capabilities-command.ts +94 -0
- package/src/cli/capabilities.ts +496 -0
- package/src/cli/claude-desktop.ts +31 -11
- package/src/cli/claude.ts +26 -1
- package/src/cli/dispatch.ts +256 -28
- package/src/cli/doctor.ts +126 -14
- package/src/cli/help.ts +17 -6
- package/src/cli/index.ts +39 -9
- package/src/cli/init.ts +1 -0
- package/src/cli/inspect.ts +230 -0
- package/src/cli/models-runtime.ts +95 -0
- package/src/cli/models.ts +26 -10
- package/src/cli/observe.ts +31 -8
- package/src/cli/provider-runtime.ts +16 -2
- package/src/cli/provider.ts +2 -1
- package/src/cli/registry.ts +49 -10
- package/src/cli/runtime-api.ts +51 -7
- package/src/cli/status.ts +41 -1
- package/src/cli/storage.ts +234 -0
- package/src/cli/system-command.ts +16 -0
- package/src/cli/system-restart-client.ts +1 -1
- package/src/cli/usage-report.ts +184 -0
- package/src/cli/v2.ts +34 -10
- package/src/cli/version-skew.ts +46 -0
- package/src/clients/config-export.ts +85 -3
- package/src/clients/effective-status.ts +30 -2
- package/src/clients/probes/cline.ts +81 -0
- package/src/codex/account-label.ts +21 -0
- package/src/codex/account-pause.ts +2 -1
- package/src/codex/account-priority.ts +3 -2
- package/src/codex/app-server-processes.ts +83 -7
- package/src/codex/auth-api.ts +50 -10
- package/src/codex/auth-context.ts +21 -18
- package/src/codex/autostart-health.ts +16 -0
- package/src/codex/catalog/aggregation.ts +18 -0
- package/src/codex/catalog/effort.ts +18 -3
- package/src/codex/catalog/metadata.ts +27 -1
- package/src/codex/catalog/native-models.ts +5 -2
- package/src/codex/catalog/parsing.ts +72 -27
- package/src/codex/catalog/provider-fetch.ts +187 -31
- package/src/codex/catalog/sync.ts +131 -5
- package/src/codex/catalog.ts +1 -1
- package/src/codex/codex-write-lock.ts +3 -1
- package/src/codex/convergence-types.ts +1 -1
- package/src/codex/convergence.ts +27 -2
- package/src/codex/desired-state.ts +2 -2
- package/src/codex/desktop-app-restart.ts +355 -0
- package/src/codex/history-job.ts +32 -3
- package/src/codex/history-manifest.ts +112 -0
- package/src/codex/history-migration-guardian.ts +5 -5
- package/src/codex/history-provider.ts +825 -247
- package/src/codex/history-worker.ts +8 -5
- package/src/codex/inject-coordination.ts +83 -0
- package/src/codex/inject.ts +63 -22
- package/src/codex/injected-marker.ts +1 -1
- package/src/codex/internal/history-writer.ts +4 -3
- package/src/codex/log-guard/inspect.ts +22 -4
- package/src/codex/model-entitlements.ts +9 -2
- package/src/codex/native-profile-startup.ts +157 -27
- package/src/codex/native-residue.ts +26 -33
- package/src/codex/prompt-layers.ts +371 -25
- package/src/codex/prompt-text-probe.ts +238 -0
- package/src/codex/quota.ts +91 -10
- package/src/codex/routing.ts +9 -0
- package/src/codex/shim.ts +56 -3
- package/src/codex/subagent-model-fallback.ts +104 -25
- package/src/codex/transition-state.ts +116 -8
- package/src/combos/failover.ts +27 -0
- package/src/combos/types.ts +10 -0
- package/src/compatibility/index.ts +26 -0
- package/src/compatibility/manifest.ts +253 -0
- package/src/compatibility/openai-responses.ts +121 -0
- package/src/config/atomic-write.ts +219 -0
- package/src/config/paths.ts +40 -0
- package/src/config/process-state.ts +309 -0
- package/src/config/provider-validation.ts +177 -0
- package/src/config/rebase-provenance.ts +68 -0
- package/src/config.ts +206 -824
- package/src/generated/compatibility-version.json +373 -213
- package/src/images/loop.ts +5 -4
- package/src/images/plan.ts +5 -4
- package/src/integrations/ownership-policy.ts +160 -0
- package/src/integrations/ownership.ts +46 -2
- package/src/integrations/registry.ts +7 -0
- package/src/integrations/state.ts +79 -7
- package/src/integrations/writer.ts +26 -2
- package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
- package/src/lab/fabric/producer-child.ts +1 -1
- package/src/lib/admin-secrets.ts +24 -0
- package/src/lib/bounded-body.ts +14 -2
- package/src/lib/config-ownership.ts +20 -0
- package/src/lib/errors.ts +36 -3
- package/src/lib/package-tree-integrity.ts +101 -0
- package/src/lib/process-control.ts +2 -1
- package/src/lib/service-secrets.ts +15 -0
- package/src/lib/tool-argument-integers.ts +56 -5
- package/src/oauth/callback-server.ts +22 -2
- package/src/oauth/cline.ts +203 -0
- package/src/oauth/generic-account-failover.ts +231 -0
- package/src/oauth/health.ts +1 -1
- package/src/oauth/index.ts +65 -6
- package/src/oauth/key-providers.ts +10 -1
- package/src/oauth/kimi.ts +9 -1
- package/src/oauth/kiro.ts +45 -0
- package/src/oauth/local-token-detect.ts +2 -0
- package/src/oauth/open-browser-choice.ts +26 -0
- package/src/oauth/store.ts +14 -5
- package/src/oauth/types.ts +15 -0
- package/src/providers/auto-compact-budget.ts +65 -0
- package/src/providers/command-code-efforts.ts +38 -6
- package/src/providers/context-cap.ts +4 -3
- package/src/providers/default-aliases.ts +65 -0
- package/src/providers/derive.ts +28 -0
- package/src/providers/fastwire.ts +7 -1
- package/src/providers/label.ts +34 -1
- package/src/providers/model-presets.ts +119 -0
- package/src/providers/new-model-policy.ts +146 -0
- package/src/providers/provider-id-rewrite.ts +2 -1
- package/src/providers/quota.ts +65 -7
- package/src/providers/registry.ts +184 -69
- package/src/providers/slug-codec.ts +52 -0
- package/src/providers/xai-transport.ts +21 -0
- package/src/reasoning-effort.ts +19 -2
- package/src/responses/apply-patch-envelope.ts +63 -0
- package/src/responses/code-mode-helper-compat.ts +50 -0
- package/src/responses/custom-tool-compat.ts +157 -39
- package/src/responses/namespace-tool-compat.ts +84 -4
- package/src/responses/parser.ts +7 -2
- package/src/responses/reasoning-replay-cache.ts +81 -3
- package/src/responses/schema.ts +5 -1
- package/src/responses/state.ts +94 -4
- package/src/responses/thought-signature-replay.ts +17 -0
- package/src/responses/turn-termination.ts +107 -0
- package/src/router.ts +40 -2
- package/src/routing/analytics.ts +1 -0
- package/src/routing/quota.ts +10 -0
- package/src/server/auth-cors.ts +27 -7
- package/src/server/chat-completions.ts +24 -16
- package/src/server/chat-native-sse.ts +3 -3
- package/src/server/chat-native.ts +30 -11
- package/src/server/claude-messages.ts +1 -1
- package/src/server/index.ts +179 -32
- package/src/server/lifecycle.ts +52 -1
- package/src/server/local-management-read-client.ts +1 -1
- package/src/server/local-provider-reload-client.ts +1 -1
- package/src/server/management/agent-settings-routes.ts +45 -23
- package/src/server/management/codex-prompt-routes.ts +570 -0
- package/src/server/management/combo-routes.ts +2 -1
- package/src/server/management/config-routes.ts +59 -12
- package/src/server/management/context.ts +10 -1
- package/src/server/management/logs-usage-routes.ts +30 -24
- package/src/server/management/model-routes.ts +274 -4
- package/src/server/management/model-rows.ts +4 -0
- package/src/server/management/native-integration-routes.ts +2 -1
- package/src/server/management/oauth-account-routes.ts +10 -4
- package/src/server/management/provider-capability-config.ts +1 -1
- package/src/server/management/provider-routes.ts +81 -11
- package/src/server/management/route-registry.ts +332 -0
- package/src/server/management/routing-profile-routes.ts +2 -2
- package/src/server/management/system-restart.ts +1 -1
- package/src/server/management-api.ts +18 -16
- package/src/server/port-reclaim.ts +1 -1
- package/src/server/proxy-liveness.ts +29 -5
- package/src/server/relay-eager.ts +147 -20
- package/src/server/relay.ts +251 -19
- package/src/server/request-log-conversation.ts +51 -0
- package/src/server/request-log.ts +70 -16
- package/src/server/responses/codex-auth-error.ts +55 -0
- package/src/server/responses/collaboration.ts +37 -3
- package/src/server/responses/combo-stream-preflight.ts +178 -0
- package/src/server/responses/compact.ts +6 -21
- package/src/server/responses/core.ts +810 -208
- package/src/server/responses/empty-completion-guard.ts +63 -6
- package/src/server/responses/fetch-helpers.ts +17 -102
- package/src/server/responses/input-admission.ts +3 -1
- package/src/server/responses/passthrough-error.ts +33 -9
- package/src/server/responses/responses-field-backfill.ts +105 -13
- package/src/server/responses/ws-upstream.ts +75 -2
- package/src/server/responses-custom-tool-repair.ts +87 -6
- package/src/server/responses-terminal-repair.ts +25 -4
- package/src/server/responses-undeclared-tool-guard.ts +241 -18
- package/src/server/sse-frame-buffer.ts +31 -4
- package/src/server/ws-bridge.ts +14 -2
- package/src/service.ts +42 -4
- package/src/storage/policy-job.ts +14 -4
- package/src/storage/policy.ts +88 -23
- package/src/types/config.ts +66 -0
- package/src/types/provider.ts +69 -0
- package/src/types/request.ts +17 -4
- package/src/types/tools.ts +116 -11
- package/src/types.ts +3 -1
- package/src/update/index.ts +5 -4
- package/src/update/job.ts +3 -1
- package/src/update/transactional-install.mjs +8 -1
- package/src/usage/expected-prices.ts +43 -24
- package/src/usage/log.ts +63 -12
- package/src/usage/summary.ts +211 -8
- package/src/vision/describe.ts +18 -13
- package/src/web-search/exa-executor.ts +40 -9
- package/src/web-search/executor.ts +10 -3
- package/src/web-search/loop.ts +5 -4
- package/gui/dist/assets/index-BF38heuV.js +0 -104
- package/gui/dist/assets/index-DMiI18Kv.css +0 -1
|
@@ -5,6 +5,7 @@ import { catalogModelSlug, invalidateCodexModelsCache, nativeContextLimits, nati
|
|
|
5
5
|
import {
|
|
6
6
|
DEFAULT_SUBAGENT_MODELS,
|
|
7
7
|
codexAutoStartEnabled,
|
|
8
|
+
deleteConfigTopLevelKey,
|
|
8
9
|
hasOwnProvider,
|
|
9
10
|
isValidProviderName,
|
|
10
11
|
multiAgentGuidanceEnabled,
|
|
@@ -298,6 +299,12 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
298
299
|
streamMode: config.streamMode ?? "auto",
|
|
299
300
|
appOwnedMemoryBudgetMb: config.appOwnedMemoryBudgetMb ?? 256,
|
|
300
301
|
codexAccountPickerEnabled: codexAccountPickerEnabled(config),
|
|
302
|
+
// Absent means hidden, so the GUI renders the switch without having to know that
|
|
303
|
+
// `undefined` and `false` mean the same thing.
|
|
304
|
+
showCodexSparkQuota: config.showCodexSparkQuota === true,
|
|
305
|
+
// Absent means the historical auto-open, so the GUI can render the toggle
|
|
306
|
+
// without having to know that `undefined` and `true` mean the same thing.
|
|
307
|
+
oauthOpenBrowser: config.oauthOpenBrowser !== false,
|
|
301
308
|
startupHealth: await readStartupHealth(config),
|
|
302
309
|
codexRuntime: {
|
|
303
310
|
path: displayCodexRuntimePath(resolved.runtime.command),
|
|
@@ -382,16 +389,23 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
382
389
|
streamMode?: unknown;
|
|
383
390
|
appOwnedMemoryBudgetMb?: unknown;
|
|
384
391
|
codexAccountPickerEnabled?: unknown;
|
|
392
|
+
oauthOpenBrowser?: unknown;
|
|
393
|
+
showCodexSparkQuota?: unknown;
|
|
385
394
|
};
|
|
386
395
|
if (body.codexAutoStart === undefined
|
|
387
396
|
&& body.streamMode === undefined
|
|
388
397
|
&& body.appOwnedMemoryBudgetMb === undefined
|
|
389
|
-
&& body.codexAccountPickerEnabled === undefined
|
|
390
|
-
|
|
398
|
+
&& body.codexAccountPickerEnabled === undefined
|
|
399
|
+
&& body.oauthOpenBrowser === undefined
|
|
400
|
+
&& body.showCodexSparkQuota === undefined) {
|
|
401
|
+
return jsonResponse({ error: "provide codexAutoStart, streamMode, appOwnedMemoryBudgetMb, codexAccountPickerEnabled, oauthOpenBrowser, or showCodexSparkQuota" }, 400);
|
|
391
402
|
}
|
|
392
403
|
if (body.codexAutoStart !== undefined && typeof body.codexAutoStart !== "boolean") {
|
|
393
404
|
return jsonResponse({ error: "codexAutoStart boolean is required" }, 400);
|
|
394
405
|
}
|
|
406
|
+
if (body.oauthOpenBrowser !== undefined && typeof body.oauthOpenBrowser !== "boolean") {
|
|
407
|
+
return jsonResponse({ error: "oauthOpenBrowser boolean is required" }, 400);
|
|
408
|
+
}
|
|
395
409
|
if (body.streamMode !== undefined && !isStreamMode(body.streamMode)) {
|
|
396
410
|
return jsonResponse({ error: "streamMode must be auto, legacy-tee, or eager-relay" }, 400);
|
|
397
411
|
}
|
|
@@ -399,6 +413,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
399
413
|
&& typeof body.codexAccountPickerEnabled !== "boolean") {
|
|
400
414
|
return jsonResponse({ error: "codexAccountPickerEnabled boolean is required" }, 400);
|
|
401
415
|
}
|
|
416
|
+
if (body.showCodexSparkQuota !== undefined && typeof body.showCodexSparkQuota !== "boolean") {
|
|
417
|
+
return jsonResponse({ error: "showCodexSparkQuota boolean is required" }, 400);
|
|
418
|
+
}
|
|
402
419
|
if (body.appOwnedMemoryBudgetMb !== undefined && (
|
|
403
420
|
typeof body.appOwnedMemoryBudgetMb !== "number"
|
|
404
421
|
|| !Number.isInteger(body.appOwnedMemoryBudgetMb)
|
|
@@ -418,6 +435,10 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
418
435
|
hasCodexAccountNamespaces: Object.hasOwn(config, "codexAccountNamespaces"),
|
|
419
436
|
codexAccountPickerEnabled: config.codexAccountPickerEnabled,
|
|
420
437
|
hasCodexAccountPickerEnabled: Object.hasOwn(config, "codexAccountPickerEnabled"),
|
|
438
|
+
oauthOpenBrowser: config.oauthOpenBrowser,
|
|
439
|
+
hasOauthOpenBrowser: Object.hasOwn(config, "oauthOpenBrowser"),
|
|
440
|
+
showCodexSparkQuota: config.showCodexSparkQuota,
|
|
441
|
+
hasShowCodexSparkQuota: Object.hasOwn(config, "showCodexSparkQuota"),
|
|
421
442
|
};
|
|
422
443
|
const pickerWasEnabled = codexAccountPickerEnabled(config);
|
|
423
444
|
let pickerIsEnabled = pickerWasEnabled;
|
|
@@ -427,7 +448,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
427
448
|
}
|
|
428
449
|
if (body.streamMode !== undefined) {
|
|
429
450
|
if (body.streamMode === "auto") {
|
|
430
|
-
|
|
451
|
+
deleteConfigTopLevelKey(config, "streamMode");
|
|
431
452
|
} else {
|
|
432
453
|
config.streamMode = body.streamMode as "legacy-tee" | "eager-relay";
|
|
433
454
|
}
|
|
@@ -441,22 +462,34 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
441
462
|
} else if (body.codexAccountPickerEnabled === false) {
|
|
442
463
|
config.codexAccountPickerEnabled = false;
|
|
443
464
|
}
|
|
465
|
+
if (typeof body.oauthOpenBrowser === "boolean") {
|
|
466
|
+
config.oauthOpenBrowser = body.oauthOpenBrowser;
|
|
467
|
+
}
|
|
468
|
+
if (typeof body.showCodexSparkQuota === "boolean") {
|
|
469
|
+
config.showCodexSparkQuota = body.showCodexSparkQuota;
|
|
470
|
+
}
|
|
444
471
|
pickerIsEnabled = codexAccountPickerEnabled(config);
|
|
445
472
|
(deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode)(config);
|
|
446
473
|
} catch (error) {
|
|
447
474
|
if (previousSettings.hasCodexAutoStart) config.codexAutoStart = previousSettings.codexAutoStart;
|
|
448
|
-
else
|
|
475
|
+
else deleteConfigTopLevelKey(config, "codexAutoStart");
|
|
449
476
|
if (previousSettings.hasStreamMode) config.streamMode = previousSettings.streamMode;
|
|
450
|
-
else
|
|
477
|
+
else deleteConfigTopLevelKey(config, "streamMode");
|
|
451
478
|
if (previousSettings.hasAppOwnedMemoryBudgetMb) {
|
|
452
479
|
config.appOwnedMemoryBudgetMb = previousSettings.appOwnedMemoryBudgetMb;
|
|
453
|
-
} else
|
|
480
|
+
} else deleteConfigTopLevelKey(config, "appOwnedMemoryBudgetMb");
|
|
454
481
|
if (previousSettings.hasCodexAccountNamespaces) {
|
|
455
482
|
config.codexAccountNamespaces = previousSettings.codexAccountNamespaces;
|
|
456
|
-
} else
|
|
483
|
+
} else deleteConfigTopLevelKey(config, "codexAccountNamespaces");
|
|
457
484
|
if (previousSettings.hasCodexAccountPickerEnabled) {
|
|
458
485
|
config.codexAccountPickerEnabled = previousSettings.codexAccountPickerEnabled;
|
|
459
|
-
} else
|
|
486
|
+
} else deleteConfigTopLevelKey(config, "codexAccountPickerEnabled");
|
|
487
|
+
if (previousSettings.hasOauthOpenBrowser) {
|
|
488
|
+
config.oauthOpenBrowser = previousSettings.oauthOpenBrowser;
|
|
489
|
+
} else deleteConfigTopLevelKey(config, "oauthOpenBrowser");
|
|
490
|
+
if (previousSettings.hasShowCodexSparkQuota) {
|
|
491
|
+
config.showCodexSparkQuota = previousSettings.showCodexSparkQuota;
|
|
492
|
+
} else deleteConfigTopLevelKey(config, "showCodexSparkQuota");
|
|
460
493
|
throw error;
|
|
461
494
|
}
|
|
462
495
|
if (typeof body.appOwnedMemoryBudgetMb === "number") {
|
|
@@ -476,7 +509,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
476
509
|
streamMode: config.streamMode ?? "auto",
|
|
477
510
|
appOwnedMemoryBudgetMb: config.appOwnedMemoryBudgetMb ?? 256,
|
|
478
511
|
codexAccountPickerEnabled: pickerIsEnabled,
|
|
512
|
+
oauthOpenBrowser: config.oauthOpenBrowser !== false,
|
|
479
513
|
catalogRefreshPending,
|
|
514
|
+
showCodexSparkQuota: config.showCodexSparkQuota === true,
|
|
480
515
|
startupHealth: await readStartupHealth(config),
|
|
481
516
|
});
|
|
482
517
|
}
|
|
@@ -490,7 +525,10 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
490
525
|
if (url.pathname === "/api/sync" && req.method === "POST") {
|
|
491
526
|
const { syncModelsToCodex } = await import("../../codex/sync");
|
|
492
527
|
const { attachStaleAppServerHint } = await import("../../codex/app-server-processes");
|
|
493
|
-
const { readRuntimePort, loadConfig } = await
|
|
528
|
+
const [{ readRuntimePort }, { loadConfig }] = await Promise.all([
|
|
529
|
+
import("../../config/process-state"),
|
|
530
|
+
import("../../config"),
|
|
531
|
+
]);
|
|
494
532
|
// Never use the server-captured startup object for a durable integration
|
|
495
533
|
// decision. A toggle may have persisted while this process was gathering.
|
|
496
534
|
const runtime = readRuntimePort(process.pid);
|
|
@@ -662,9 +700,18 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
662
700
|
|
|
663
701
|
if (body.webSearch) {
|
|
664
702
|
const pairTouched = body.webSearch.model !== undefined || body.webSearch.backend !== undefined;
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
703
|
+
// Validate against the backend the caller SUBMITTED, across the whole
|
|
704
|
+
// union — not just openai/anthropic (#2457). The union check above has
|
|
705
|
+
// already refused unknown literals, so a surviving string is a member;
|
|
706
|
+
// Array.includes does not narrow, hence the cast. Falling back to the
|
|
707
|
+
// stored backend for xai/gemini/exa both rejected legal pairs and
|
|
708
|
+
// accepted illegal ones: a submitted gemini was checked against a stored
|
|
709
|
+
// openai. null means "unset the backend", and unset resolves to openai.
|
|
710
|
+
const submittedBackend = body.webSearch.backend;
|
|
711
|
+
const effectiveBackend = typeof submittedBackend === "string"
|
|
712
|
+
&& WEB_SEARCH_BACKENDS_UNION.includes(submittedBackend as never)
|
|
713
|
+
? submittedBackend as typeof WEB_SEARCH_BACKENDS_UNION[number]
|
|
714
|
+
: submittedBackend === null
|
|
668
715
|
? "openai"
|
|
669
716
|
: config.webSearchSidecar?.backend ?? "openai";
|
|
670
717
|
const effectiveModel = typeof body.webSearch.model === "string"
|
|
@@ -6,9 +6,10 @@ import type { StartupHealth } from "../../codex/autostart-health";
|
|
|
6
6
|
import type { StartupInstallAction } from "../startup-action-control";
|
|
7
7
|
import type { ManagementPrincipal } from "../management-auth";
|
|
8
8
|
import type { CatalogModel } from "../../codex/catalog";
|
|
9
|
+
import type { Paths as CodexPromptPaths } from "../../codex/prompt-layers";
|
|
9
10
|
import type { injectGrokConfig } from "../../grok/inject";
|
|
10
11
|
import type { removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p";
|
|
11
|
-
import type { RuntimePortState } from "../../config";
|
|
12
|
+
import type { RuntimePortState } from "../../config/process-state";
|
|
12
13
|
import type { CatalogDisposition, ConvergeCodex } from "../../codex/convergence-types";
|
|
13
14
|
import type {
|
|
14
15
|
performCodexRestart,
|
|
@@ -87,6 +88,14 @@ export interface ManagementApiDeps {
|
|
|
87
88
|
* state inside their temporary Codex home.
|
|
88
89
|
*/
|
|
89
90
|
codexLogGuardMaintenanceDeps?: CodexLogGuardMaintenanceDeps;
|
|
91
|
+
/**
|
|
92
|
+
* Prompt-layer path seam. Production leaves this unset and
|
|
93
|
+
* `src/codex/prompt-layers.ts` resolves the real CODEX_HOME. A route test
|
|
94
|
+
* that could not inject paths would read AND WRITE the developer's live
|
|
95
|
+
* ~/.codex/config.toml — the same class of incident
|
|
96
|
+
* `saveConfigPreservingClaudeCode` above exists to prevent.
|
|
97
|
+
*/
|
|
98
|
+
codexPromptPaths?: CodexPromptPaths;
|
|
90
99
|
}
|
|
91
100
|
|
|
92
101
|
|
|
@@ -32,8 +32,6 @@ import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
|
32
32
|
import { clearThreadAccountMap } from "../../codex/routing";
|
|
33
33
|
import { primeCodexPoolQuotas } from "../../codex/auth-api";
|
|
34
34
|
import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
|
|
35
|
-
import { resolveCodexHomeDir } from "../../codex/home";
|
|
36
|
-
import { scanStorage } from "../../storage/scanner";
|
|
37
35
|
import { executeArchivedCleanup, listTrashEntries, pickWireCleanupTestHooks, previewArchivedCleanup, type CleanupMode, type RestoreErrorCode } from "../../storage/cleanup";
|
|
38
36
|
import { runArchivedCleanupJob } from "../../storage/cleanup-job";
|
|
39
37
|
import { getRestoreTrashTestStreamResponse, runRestoreTrashEntryJob } from "../../storage/restore-job";
|
|
@@ -55,7 +53,7 @@ import {
|
|
|
55
53
|
type PersistedUsageEntry,
|
|
56
54
|
} from "../../usage/log";
|
|
57
55
|
import { getUsageDebugLogEntries } from "../../usage/debug";
|
|
58
|
-
import { parseRange, parseUsageSurface, rangeWindow, summarizeUsage, type UsageRange, type UsageSummary, type UsageSurface } from "../../usage/summary";
|
|
56
|
+
import { USAGE_RANGES, USAGE_SURFACES, parseRange, parseUsageSurface, projectUsageSummary, rangeWindow, summarizeUsage, type UsageRange, type UsageSummary, type UsageSurface } from "../../usage/summary";
|
|
59
57
|
import { stripCodexRuntimeProviderFields } from "../../codex/auth-context";
|
|
60
58
|
import { getProviderRegistryEntry } from "../../providers/registry";
|
|
61
59
|
import { getDebugLogEntries } from "../../lib/debug-log-buffer";
|
|
@@ -316,6 +314,17 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
316
314
|
if (url.pathname === "/api/usage" && req.method === "GET") {
|
|
317
315
|
const range = parseRange(url.searchParams.get("range"));
|
|
318
316
|
const surface = parseUsageSurface(url.searchParams.get("surface"));
|
|
317
|
+
// Applied to the OUTGOING payload only. A filtered summary must never reach
|
|
318
|
+
// the cache or the warm loop below: the key is `range:surface`, so a
|
|
319
|
+
// filtered entry stored under it would be served to the next unfiltered
|
|
320
|
+
// caller, dashboard included.
|
|
321
|
+
const filter = {
|
|
322
|
+
provider: url.searchParams.get("provider"),
|
|
323
|
+
model: url.searchParams.get("model"),
|
|
324
|
+
};
|
|
325
|
+
const project = <T extends UsageSummary>(summary: T, entries?: PersistedUsageEntry[]) =>
|
|
326
|
+
projectUsageSummary(summary, filter, entries);
|
|
327
|
+
const filterRequested = Boolean(filter.provider ?? filter.model);
|
|
319
328
|
const now = Date.now();
|
|
320
329
|
try {
|
|
321
330
|
const cacheKey = `${range}:${surface}`;
|
|
@@ -325,6 +334,12 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
325
334
|
const observedSize = observed?.size ?? 0;
|
|
326
335
|
const cached = getUsageSummaryCacheEntry(cacheKey);
|
|
327
336
|
if (cached
|
|
337
|
+
// A filtered response is re-summarised from entries, which a cached
|
|
338
|
+
// summary does not carry. Serving it from cache would mean projecting
|
|
339
|
+
// over collapsed breakdown rows again — the exact defect the
|
|
340
|
+
// re-summarisation replaced. The unfiltered cache stays warm either
|
|
341
|
+
// way; only the filtered caller pays for the read.
|
|
342
|
+
&& !filterRequested
|
|
328
343
|
&& cached.identityKey === identityKey
|
|
329
344
|
&& cached.maxReadBytes === effectiveReadLimit
|
|
330
345
|
&& cached.overlayVersion === userCostOverlayVersion()
|
|
@@ -333,7 +348,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
333
348
|
&& observedSize >= cached.lastSeenSize) {
|
|
334
349
|
return jsonResponse(refreshedUsageSummary(cached.summary, range, now));
|
|
335
350
|
}
|
|
336
|
-
if (cached) discardUsageSummaryCacheEntry(cacheKey);
|
|
351
|
+
if (cached && !filterRequested) discardUsageSummaryCacheEntry(cacheKey);
|
|
337
352
|
// Capture the overlay version BEFORE reading/computing: the cache entry
|
|
338
353
|
// must be stamped with the version the summary was priced under. Reading
|
|
339
354
|
// it again at stamp time could cache an old-price summary as current,
|
|
@@ -357,14 +372,18 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
357
372
|
// summary may mix old and new prices. Serve it uncached: the next
|
|
358
373
|
// request recomputes against the settled overlay instead of caching a
|
|
359
374
|
// mixed-price entry under either version.
|
|
360
|
-
return jsonResponse(summary);
|
|
375
|
+
return jsonResponse(project(summary, snapshot.entries));
|
|
361
376
|
}
|
|
362
377
|
const freshUntil = now + 60_000;
|
|
363
378
|
const snapshotIdentity = `${usageLogIdentityKey(snapshot.revision)}\0${effectiveReadLimit}`;
|
|
364
379
|
const revisionKey = `${usageLogRevisionKey(snapshot.revision)}\0${effectiveReadLimit}`;
|
|
365
380
|
const lastSeenSize = snapshot.revision?.size ?? 0;
|
|
366
|
-
|
|
367
|
-
|
|
381
|
+
// Derived from the canonical constants rather than re-listed: a subset
|
|
382
|
+
// literal type-checks perfectly happily, so a range added to the union
|
|
383
|
+
// and forgotten here would never be warmed and never invalidated
|
|
384
|
+
// alongside its siblings.
|
|
385
|
+
const ranges: readonly UsageRange[] = USAGE_RANGES;
|
|
386
|
+
const surfaces: readonly UsageSurface[] = USAGE_SURFACES;
|
|
368
387
|
for (const nextRange of ranges) {
|
|
369
388
|
for (const nextSurface of surfaces) {
|
|
370
389
|
const nextSummary = nextRange === range && nextSurface === surface ? summary : {
|
|
@@ -398,7 +417,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
398
417
|
effectiveReadLimit,
|
|
399
418
|
now,
|
|
400
419
|
);
|
|
401
|
-
return jsonResponse(summary);
|
|
420
|
+
return jsonResponse(project(summary, snapshot.entries));
|
|
402
421
|
} catch {
|
|
403
422
|
return jsonResponse({
|
|
404
423
|
range,
|
|
@@ -450,8 +469,9 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
450
469
|
if (url.pathname === "/api/usage/ingest" && req.method === "POST") {
|
|
451
470
|
let body: unknown;
|
|
452
471
|
try {
|
|
453
|
-
body = await req
|
|
454
|
-
} catch {
|
|
472
|
+
body = await readManagementJsonBody(req);
|
|
473
|
+
} catch (error) {
|
|
474
|
+
rethrowManagementBodyTooLarge(error);
|
|
455
475
|
return jsonResponse({ error: "invalid JSON body" }, 400);
|
|
456
476
|
}
|
|
457
477
|
const rawEntries = extractIngestRawEntries(body);
|
|
@@ -512,20 +532,6 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
512
532
|
}, status);
|
|
513
533
|
}
|
|
514
534
|
|
|
515
|
-
if (url.pathname === "/api/storage" && req.method === "GET") {
|
|
516
|
-
try {
|
|
517
|
-
return jsonResponse(scanStorage());
|
|
518
|
-
} catch {
|
|
519
|
-
return jsonResponse({
|
|
520
|
-
codexHome: resolveCodexHomeDir(),
|
|
521
|
-
generatedAt: Date.now(),
|
|
522
|
-
total: { bytes: 0, fileCount: 0 },
|
|
523
|
-
buckets: [],
|
|
524
|
-
error: "scan_failed",
|
|
525
|
-
});
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
535
|
if (url.pathname === "/api/storage/cleanup/preview" && req.method === "POST") {
|
|
530
536
|
let body: { percent?: unknown };
|
|
531
537
|
try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid_json" }, 400); }
|
|
@@ -99,6 +99,8 @@ import { deriveProviderPresets } from "../../providers/derive";
|
|
|
99
99
|
import { providerCodexAccountMode } from "../../providers/registry";
|
|
100
100
|
import { encodedModelIdCollides, routedSlug, slugEquals } from "../../providers/slug-codec";
|
|
101
101
|
import { knownModelIdsForProvider } from "../../router";
|
|
102
|
+
import { effectiveModelAliases, MODEL_ALIAS_PATTERN } from "../../providers/default-aliases";
|
|
103
|
+
import { comboPublicModelId } from "../../combos/types";
|
|
102
104
|
import { COMBO_NAMESPACE, comboDisabledModelSelectors, comboModelId, preservesPhysicalComboProvider } from "../../combos";
|
|
103
105
|
import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
|
|
104
106
|
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
@@ -149,6 +151,12 @@ import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, C
|
|
|
149
151
|
import type { ManagementContext } from "./context";
|
|
150
152
|
import { listManagementModelRows, loadExportModels } from "./model-rows";
|
|
151
153
|
import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
|
|
154
|
+
import {
|
|
155
|
+
hasModelPreset,
|
|
156
|
+
markModelPresetDiverged,
|
|
157
|
+
materializeModelPreset,
|
|
158
|
+
modelPresetFor,
|
|
159
|
+
} from "../../providers/model-presets";
|
|
152
160
|
|
|
153
161
|
/**
|
|
154
162
|
* Counts read back off the SERIALIZED document rather than recomputed from the input rows.
|
|
@@ -171,6 +179,158 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
171
179
|
// ~/.opencodex/config.json with the `existing-uuid` test fixture.
|
|
172
180
|
const persistConfig = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode;
|
|
173
181
|
|
|
182
|
+
if (url.pathname === "/api/model-discovery" && req.method === "GET") {
|
|
183
|
+
const providers = Object.fromEntries(Object.entries(config.providers).map(([name, provider]) => [
|
|
184
|
+
name, provider.newModelPolicy ?? "inherit",
|
|
185
|
+
]));
|
|
186
|
+
const recentArrivals = Object.fromEntries(Object.entries(config.modelDiscovery?.recentArrivals ?? {}).map(([name, rows]) => [
|
|
187
|
+
name,
|
|
188
|
+
rows.map(row => ({
|
|
189
|
+
...row,
|
|
190
|
+
state: (config.disabledModels ?? []).some(slug => slugEquals(slug, name, row.id))
|
|
191
|
+
? "auto-disabled" : "enabled",
|
|
192
|
+
})),
|
|
193
|
+
]));
|
|
194
|
+
const baselineCounts = Object.fromEntries(Object.entries(config.modelDiscovery?.knownModels ?? {}).map(([name, baseline]) => [
|
|
195
|
+
name, baseline.ids.length,
|
|
196
|
+
]));
|
|
197
|
+
return jsonResponse({
|
|
198
|
+
policy: config.modelDiscovery?.newModelPolicy ?? "on", providers, recentArrivals, baselineCounts,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (url.pathname === "/api/model-discovery" && req.method === "PUT") {
|
|
203
|
+
let body: { policy?: unknown; provider?: unknown };
|
|
204
|
+
try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
205
|
+
if (body.policy !== "on" && body.policy !== "off") return jsonResponse({ error: "policy must be on or off" }, 400);
|
|
206
|
+
const provider = typeof body.provider === "string" && body.provider.trim() ? body.provider.trim() : null;
|
|
207
|
+
let baselineBootstrapped = false;
|
|
208
|
+
if (provider) {
|
|
209
|
+
if (!hasOwnProvider(config.providers, provider)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
210
|
+
config.providers[provider].newModelPolicy = body.policy;
|
|
211
|
+
} else {
|
|
212
|
+
const wasAbsent = config.modelDiscovery?.newModelPolicy === undefined;
|
|
213
|
+
config.modelDiscovery ??= {};
|
|
214
|
+
config.modelDiscovery.newModelPolicy = body.policy;
|
|
215
|
+
if (body.policy === "off" && wasAbsent) {
|
|
216
|
+
const models = await fetchAllModels(config);
|
|
217
|
+
const known = config.modelDiscovery.knownModels ??= {};
|
|
218
|
+
const at = new Date().toISOString();
|
|
219
|
+
for (const name of Object.keys(config.providers)) {
|
|
220
|
+
known[name] ??= { ids: [...new Set(models.filter(m => m.provider === name).map(m => m.id))].sort(), removed: [], updatedAt: at };
|
|
221
|
+
}
|
|
222
|
+
baselineBootstrapped = true;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
persistConfig(config);
|
|
226
|
+
const catalogRefresh = await convergeCodexCatalog();
|
|
227
|
+
return jsonResponse({ ok: true, policy: body.policy, provider, ...(baselineBootstrapped ? { baselineBootstrapped } : {}), catalogRefresh });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (url.pathname === "/api/model-discovery/acknowledge" && req.method === "POST") {
|
|
231
|
+
let body: { provider?: unknown; ids?: unknown };
|
|
232
|
+
try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
233
|
+
const provider = typeof body.provider === "string" ? body.provider.trim() : "";
|
|
234
|
+
if (!provider || !Array.isArray(body.ids) || body.ids.some(id => typeof id !== "string")) {
|
|
235
|
+
return jsonResponse({ error: "provider and string ids are required" }, 400);
|
|
236
|
+
}
|
|
237
|
+
const acknowledged = new Set(body.ids as string[]);
|
|
238
|
+
const recent = config.modelDiscovery?.recentArrivals;
|
|
239
|
+
if (recent?.[provider]) recent[provider] = recent[provider].filter(row => !acknowledged.has(row.id));
|
|
240
|
+
persistConfig(config);
|
|
241
|
+
const catalogRefresh = await convergeCodexCatalog();
|
|
242
|
+
return jsonResponse({ ok: true, provider, acknowledged: [...acknowledged], catalogRefresh });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (url.pathname === "/api/aliases" && req.method === "GET") {
|
|
246
|
+
const providers: Record<string, string> = {};
|
|
247
|
+
const models: Record<string, Record<string, { alias: string; source: "user" | "builtin"; stale?: boolean }>> = {};
|
|
248
|
+
for (const [name, provider] of Object.entries(config.providers)) {
|
|
249
|
+
if (provider.alias) providers[name] = provider.alias;
|
|
250
|
+
const known = knownModelIdsForProvider(name, provider, config);
|
|
251
|
+
const knownSet = new Set(known);
|
|
252
|
+
const rows: Record<string, { alias: string; source: "user" | "builtin"; stale?: boolean }> = {};
|
|
253
|
+
for (const [id, value] of effectiveModelAliases(config, provider, new Set([...known, ...Object.keys(provider.modelAliases ?? {})]))) {
|
|
254
|
+
rows[id] = { ...value, ...(!knownSet.has(id) ? { stale: true } : {}) };
|
|
255
|
+
}
|
|
256
|
+
if (Object.keys(rows).length) models[name] = rows;
|
|
257
|
+
}
|
|
258
|
+
return jsonResponse({ providers, models, defaults: {
|
|
259
|
+
global: config.defaultModelAliases ?? false,
|
|
260
|
+
providers: Object.fromEntries(Object.entries(config.providers).filter(([, p]) => p.defaultAliases !== undefined).map(([n, p]) => [n, p.defaultAliases])),
|
|
261
|
+
} });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const providerAliasMatch = url.pathname.match(/^\/api\/providers\/([^/]+)\/alias$/);
|
|
265
|
+
if (providerAliasMatch && req.method === "PUT") {
|
|
266
|
+
const name = decodeURIComponent(providerAliasMatch[1]!);
|
|
267
|
+
// `keys` is not a provider name: `/api/providers/keys/alias` is the API-KEY POOL's rename
|
|
268
|
+
// endpoint (oauth-account-routes.ts), and model routes are dispatched BEFORE it. Without
|
|
269
|
+
// this guard the alias route matched `name = "keys"`, found no such provider, and returned
|
|
270
|
+
// 404 for every key-pool rename.
|
|
271
|
+
if (name === "keys") return null;
|
|
272
|
+
const provider = config.providers[name];
|
|
273
|
+
if (!provider) return jsonResponse({ error: `provider '${name}' not found` }, 404, req, config);
|
|
274
|
+
let raw: unknown;
|
|
275
|
+
try { raw = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
276
|
+
if (!isPlainRecord(raw) || (raw.alias !== null && typeof raw.alias !== "string")) return jsonResponse({ error: "alias must be a string or null" }, 400, req, config);
|
|
277
|
+
const alias = typeof raw.alias === "string" ? raw.alias.trim() : null;
|
|
278
|
+
if (alias && !isValidProviderName(alias)) return jsonResponse({ error: "invalid provider alias" }, 400, req, config);
|
|
279
|
+
const lower = alias?.toLowerCase();
|
|
280
|
+
const collision = lower && Object.entries(config.providers).find(([other, p]) =>
|
|
281
|
+
other !== name && (other.toLowerCase() === lower || p.alias?.toLowerCase() === lower));
|
|
282
|
+
const comboCollision = lower && Object.entries(config.combos ?? {}).find(([, combo]) => comboPublicModelId("", combo).toLowerCase() === lower);
|
|
283
|
+
const accountCollision = lower && Object.keys(config.codexAccountNamespaces ?? {}).find(value => value.toLowerCase() === lower);
|
|
284
|
+
if (collision || comboCollision || accountCollision) return jsonResponse({ error: `alias conflicts with '${collision?.[0] ?? comboCollision?.[0] ?? accountCollision}'` }, 409, req, config);
|
|
285
|
+
if (alias) provider.alias = alias; else delete provider.alias;
|
|
286
|
+
persistConfig(config);
|
|
287
|
+
const catalogRefresh = await convergeCodexCatalog();
|
|
288
|
+
return jsonResponse({ ok: true, provider: name, alias, catalogRefresh });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const modelAliasMatch = url.pathname.match(/^\/api\/providers\/([^/]+)\/model-aliases$/);
|
|
292
|
+
if (modelAliasMatch && req.method === "PUT") {
|
|
293
|
+
const name = decodeURIComponent(modelAliasMatch[1]!);
|
|
294
|
+
if (name === "keys") return null;
|
|
295
|
+
const provider = config.providers[name];
|
|
296
|
+
if (!provider) return jsonResponse({ error: `provider '${name}' not found` }, 404, req, config);
|
|
297
|
+
let raw: unknown;
|
|
298
|
+
try { raw = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
299
|
+
if (!isPlainRecord(raw) || (raw.set !== undefined && !isPlainRecord(raw.set)) || (raw.remove !== undefined && !Array.isArray(raw.remove))) return jsonResponse({ error: "invalid model alias update" }, 400, req, config);
|
|
300
|
+
const next = { ...(provider.modelAliases ?? {}) };
|
|
301
|
+
for (const id of (raw.remove ?? []) as unknown[]) if (typeof id === "string") delete next[id];
|
|
302
|
+
const conflicts: Array<{ alias: string; heldBy: string }> = [];
|
|
303
|
+
const known = knownModelIdsForProvider(name, provider, config);
|
|
304
|
+
for (const [id, value] of Object.entries((raw.set ?? {}) as Record<string, unknown>)) {
|
|
305
|
+
if (typeof value !== "string" || !MODEL_ALIAS_PATTERN.test(value)) return jsonResponse({ error: `invalid model alias for '${id}'` }, 400, req, config);
|
|
306
|
+
const lower = value.toLowerCase();
|
|
307
|
+
const heldBy = Object.entries(next).find(([other, alias]) => other !== id && alias.toLowerCase() === lower)?.[0]
|
|
308
|
+
?? known.find(native => native.toLowerCase() === lower)
|
|
309
|
+
?? Object.entries(config.combos ?? {}).find(([, combo]) => comboPublicModelId("", combo).toLowerCase() === lower)?.[0];
|
|
310
|
+
if (heldBy || /^(?:gpt-|o1-|o3-|o4-|codex-)/i.test(value)) conflicts.push({ alias: value, heldBy: heldBy ?? "native OpenAI family" });
|
|
311
|
+
else next[id] = value;
|
|
312
|
+
}
|
|
313
|
+
if (conflicts.length) return jsonResponse({ error: "model alias collision", conflicts }, 409, req, config);
|
|
314
|
+
provider.modelAliases = next;
|
|
315
|
+
persistConfig(config);
|
|
316
|
+
const catalogRefresh = await convergeCodexCatalog();
|
|
317
|
+
return jsonResponse({ ok: true, aliases: next, catalogRefresh });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (url.pathname === "/api/default-aliases" && req.method === "PUT") {
|
|
321
|
+
let raw: unknown;
|
|
322
|
+
try { raw = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
323
|
+
if (!isPlainRecord(raw) || typeof raw.enabled !== "boolean" || (raw.provider !== undefined && typeof raw.provider !== "string")) return jsonResponse({ error: "enabled must be boolean" }, 400, req, config);
|
|
324
|
+
if (typeof raw.provider === "string") {
|
|
325
|
+
const provider = config.providers[raw.provider];
|
|
326
|
+
if (!provider) return jsonResponse({ error: `provider '${raw.provider}' not found` }, 404, req, config);
|
|
327
|
+
provider.defaultAliases = raw.enabled;
|
|
328
|
+
} else config.defaultModelAliases = raw.enabled;
|
|
329
|
+
persistConfig(config);
|
|
330
|
+
const catalogRefresh = await convergeCodexCatalog();
|
|
331
|
+
return jsonResponse({ ok: true, catalogRefresh });
|
|
332
|
+
}
|
|
333
|
+
|
|
174
334
|
if (url.pathname === "/api/catalog" && req.method === "GET") {
|
|
175
335
|
const { readCatalog, readCodexCatalogPath } = await import("../../codex/catalog");
|
|
176
336
|
const catalog = readCatalog(readCodexCatalogPath());
|
|
@@ -370,6 +530,10 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
370
530
|
providerConfig.selectedModels = [...new Set([...providerConfig.selectedModels, ...additions])];
|
|
371
531
|
}
|
|
372
532
|
disabled = disabled.filter(stored => !targets.some(target => matchesTarget(stored, target)));
|
|
533
|
+
const arrivals = config.modelDiscovery?.recentArrivals?.[provider];
|
|
534
|
+
if (arrivals) config.modelDiscovery!.recentArrivals![provider] = arrivals.filter(row => (
|
|
535
|
+
!targets.some(target => !target.native && target.id === row.id)
|
|
536
|
+
));
|
|
373
537
|
}
|
|
374
538
|
} else {
|
|
375
539
|
for (const target of targets) {
|
|
@@ -394,8 +558,10 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
394
558
|
}
|
|
395
559
|
|
|
396
560
|
if (url.pathname === "/api/custom-models" && req.method === "POST") {
|
|
397
|
-
let
|
|
398
|
-
try {
|
|
561
|
+
let parsedBody: unknown;
|
|
562
|
+
try { parsedBody = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
563
|
+
if (!isPlainRecord(parsedBody)) return jsonResponse({ error: "invalid JSON body" }, 400);
|
|
564
|
+
const body = parsedBody;
|
|
399
565
|
const provider = typeof body.provider === "string" ? body.provider.trim() : "";
|
|
400
566
|
const modelId = typeof body.modelId === "string" ? body.modelId.trim() : "";
|
|
401
567
|
if (!provider || !modelId) return jsonResponse({ error: "provider and modelId are required" }, 400);
|
|
@@ -441,8 +607,10 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
441
607
|
if (customPutMatch && req.method === "PUT") {
|
|
442
608
|
let id: string;
|
|
443
609
|
try { id = decodeURIComponent(customPutMatch[1]); } catch { return jsonResponse({ error: "invalid id encoding" }, 400); }
|
|
444
|
-
let
|
|
445
|
-
try {
|
|
610
|
+
let parsedBody: unknown;
|
|
611
|
+
try { parsedBody = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
612
|
+
if (!isPlainRecord(parsedBody)) return jsonResponse({ error: "invalid JSON body" }, 400);
|
|
613
|
+
const body = parsedBody;
|
|
446
614
|
const list = config.customModels ?? [];
|
|
447
615
|
const idx = list.findIndex(cm => cm.id === id);
|
|
448
616
|
if (idx === -1) return jsonResponse({ error: "not found" }, 404);
|
|
@@ -562,6 +730,104 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
562
730
|
const refreshing = catalogGatherNeedsLiveRefresh(config);
|
|
563
731
|
return jsonResponse({ selected, available, liveModelCounts, refreshing });
|
|
564
732
|
}
|
|
733
|
+
if (url.pathname === "/api/model-presets" && req.method === "GET") {
|
|
734
|
+
// Preview without applying: rules evaluated against the CURRENT catalog, so the count the
|
|
735
|
+
// user sees is the count they would get.
|
|
736
|
+
const models = await fetchAllModels(config);
|
|
737
|
+
const byProvider = new Map<string, string[]>();
|
|
738
|
+
for (const m of models) {
|
|
739
|
+
const ids = byProvider.get(m.provider) ?? [];
|
|
740
|
+
ids.push(m.id);
|
|
741
|
+
byProvider.set(m.provider, ids);
|
|
742
|
+
}
|
|
743
|
+
const providers: Record<string, unknown> = {};
|
|
744
|
+
for (const [name, prov] of Object.entries(config.providers)) {
|
|
745
|
+
const preset = modelPresetFor(name);
|
|
746
|
+
if (!preset) continue;
|
|
747
|
+
const catalogIds = byProvider.get(name) ?? [];
|
|
748
|
+
const presetIds = materializeModelPreset(name, catalogIds);
|
|
749
|
+
providers[name] = {
|
|
750
|
+
mode: prov.modelPreset?.mode ?? "all",
|
|
751
|
+
...(prov.modelPreset?.appliedVersion !== undefined
|
|
752
|
+
? { appliedVersion: prov.modelPreset.appliedVersion }
|
|
753
|
+
: {}),
|
|
754
|
+
availableVersion: preset.version,
|
|
755
|
+
presetIds,
|
|
756
|
+
presetCount: presetIds.length,
|
|
757
|
+
totalCount: catalogIds.length,
|
|
758
|
+
...(prov.modelPreset?.fallback ? { fallback: prov.modelPreset.fallback } : {}),
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
return jsonResponse({ providers });
|
|
762
|
+
}
|
|
763
|
+
if (url.pathname === "/api/model-presets" && req.method === "PUT") {
|
|
764
|
+
let body: { provider?: unknown; mode?: unknown };
|
|
765
|
+
try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
766
|
+
const provider = typeof body.provider === "string" ? body.provider : "";
|
|
767
|
+
if (!provider || !hasOwnProvider(config.providers, provider)) {
|
|
768
|
+
return jsonResponse({ error: "unknown provider" }, provider ? 404 : 400);
|
|
769
|
+
}
|
|
770
|
+
const mode = body.mode;
|
|
771
|
+
if (mode !== "preset" && mode !== "all" && mode !== "custom") {
|
|
772
|
+
return jsonResponse({ error: "mode must be preset, all, or custom" }, 400);
|
|
773
|
+
}
|
|
774
|
+
const target = config.providers[provider];
|
|
775
|
+
if (mode === "all") {
|
|
776
|
+
// Same effect as today's empty-list PUT: no allowlist, no marker to reconcile.
|
|
777
|
+
delete target.selectedModels;
|
|
778
|
+
delete target.modelPreset;
|
|
779
|
+
persistConfig(config);
|
|
780
|
+
return jsonResponse({ ok: true, provider, mode, selected: [], catalogRefresh: await convergeCodexCatalog() });
|
|
781
|
+
}
|
|
782
|
+
if (mode === "custom") {
|
|
783
|
+
// Keep whatever is selected; only the marker changes, so a user can pin their edits
|
|
784
|
+
// without the proxy re-materializing over them.
|
|
785
|
+
target.modelPreset = { ...(target.modelPreset ?? {}), mode: "custom" };
|
|
786
|
+
persistConfig(config);
|
|
787
|
+
return jsonResponse({ ok: true, provider, mode, selected: [...(target.selectedModels ?? [])] });
|
|
788
|
+
}
|
|
789
|
+
if (!hasModelPreset(provider)) {
|
|
790
|
+
return jsonResponse({ error: `no model preset is shipped for provider '${provider}'` }, 400);
|
|
791
|
+
}
|
|
792
|
+
const models = await fetchAllModels(config);
|
|
793
|
+
const catalogIds = models.filter(m => m.provider === provider).map(m => m.id);
|
|
794
|
+
const presetIds = materializeModelPreset(provider, catalogIds);
|
|
795
|
+
const preset = modelPresetFor(provider)!;
|
|
796
|
+
if (presetIds.length === 0) {
|
|
797
|
+
// NEVER write an empty allowlist from a preset: empty means ALL, so it would silently
|
|
798
|
+
// un-curate instead of curating. Keep the previous selection and record the fallback so
|
|
799
|
+
// the next convergence can retry.
|
|
800
|
+
target.modelPreset = {
|
|
801
|
+
mode: "all",
|
|
802
|
+
appliedVersion: preset.version,
|
|
803
|
+
appliedAt: new Date().toISOString(),
|
|
804
|
+
fallback: "preset-empty",
|
|
805
|
+
};
|
|
806
|
+
persistConfig(config);
|
|
807
|
+
return jsonResponse({
|
|
808
|
+
ok: true,
|
|
809
|
+
provider,
|
|
810
|
+
mode: "all",
|
|
811
|
+
fallback: "preset-empty",
|
|
812
|
+
selected: [...(target.selectedModels ?? [])],
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
target.selectedModels = presetIds;
|
|
816
|
+
target.modelPreset = {
|
|
817
|
+
mode: "preset",
|
|
818
|
+
appliedVersion: preset.version,
|
|
819
|
+
appliedAt: new Date().toISOString(),
|
|
820
|
+
};
|
|
821
|
+
persistConfig(config);
|
|
822
|
+
return jsonResponse({
|
|
823
|
+
ok: true,
|
|
824
|
+
provider,
|
|
825
|
+
mode: "preset",
|
|
826
|
+
appliedVersion: preset.version,
|
|
827
|
+
selected: presetIds,
|
|
828
|
+
catalogRefresh: await convergeCodexCatalog(),
|
|
829
|
+
});
|
|
830
|
+
}
|
|
565
831
|
if (url.pathname === "/api/selected-models" && req.method === "PUT") {
|
|
566
832
|
let body: { provider?: unknown; models?: unknown };
|
|
567
833
|
try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
@@ -575,6 +841,10 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
575
841
|
// Empty list clears the allowlist (provider reverts to exposing all models).
|
|
576
842
|
if (models.length > 0) config.providers[provider].selectedModels = models;
|
|
577
843
|
else delete config.providers[provider].selectedModels;
|
|
844
|
+
// Divergence is detected at the WRITE path, not by diffing (#2465): a user edit while the
|
|
845
|
+
// provider is in preset mode makes the selection theirs, and the proxy must never
|
|
846
|
+
// re-materialize over it afterwards.
|
|
847
|
+
markModelPresetDiverged(config.providers[provider]);
|
|
578
848
|
persistConfig(config);
|
|
579
849
|
const catalogRefresh = await convergeCodexCatalog();
|
|
580
850
|
return jsonResponse({ ok: true, provider, selected: models, catalogRefresh });
|