@bitkyc08/opencodex 2.68.0 → 2.69.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/bin/ocx.mjs +25 -17
- package/gui/dist/assets/App-DDbgOTh6.js +51 -0
- package/gui/dist/assets/{Tray-Bn8WhKBH.js → Tray-uqKRqfvw.js} +1 -1
- package/gui/dist/assets/index-BF9QnKO_.css +1 -0
- package/gui/dist/assets/index-CuaBJkG4.js +86 -0
- package/gui/dist/assets/{quota-summary-DL-bNuNL.js → quota-summary-COppQSVq.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/factory-droid.svg +8 -0
- package/package.json +1 -1
- package/src/adapters/anthropic-output-schema.ts +109 -11
- package/src/adapters/coding-agent/protocol.ts +106 -11
- package/src/adapters/coding-agent/turn.ts +23 -18
- package/src/adapters/google-errors.ts +16 -1
- package/src/adapters/google-http.ts +14 -0
- package/src/adapters/openai-chat/passthrough.ts +2 -0
- package/src/adapters/openai-chat/summary-budget.ts +72 -0
- package/src/adapters/openai-chat/tool-call-id-remint.ts +11 -2
- package/src/adapters/openai-chat.ts +6 -9
- package/src/bridge/response-json.ts +3 -1
- package/src/bridge/sse.ts +3 -1
- package/src/claude/desktop-picker.ts +6 -2
- package/src/claude/desktop-system-proxy.ts +155 -0
- package/src/claude/inbound-model-options.ts +10 -2
- package/src/claude/inbound.ts +1 -1
- package/src/claude/intercept/picker-ca-cleanup.ts +31 -0
- package/src/claude/intercept/picker-ca.ts +283 -83
- package/src/claude/intercept/picker-models.ts +4 -1
- package/src/claude/intercept/picker-trust.ts +15 -2
- package/src/claude/intercept/runtime.ts +99 -32
- package/src/cli/account-api.ts +5 -2
- package/src/cli/account-extended.ts +47 -11
- package/src/cli/account-target.ts +12 -7
- package/src/cli/account.ts +47 -3
- package/src/cli/capabilities.ts +33 -7
- package/src/cli/claude-agent-startup-sync.ts +2 -0
- package/src/cli/claude-desktop.ts +37 -13
- package/src/cli/cross-home-owner.ts +138 -0
- package/src/cli/dispatch.ts +1 -1
- package/src/cli/doctor.ts +18 -8
- package/src/cli/ensure-desired-integrations.ts +5 -0
- package/src/cli/export-command.ts +6 -6
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +15 -18
- package/src/cli/integrations.ts +8 -1
- package/src/cli/models-runtime-subcommands.ts +1 -0
- package/src/cli/models-runtime.ts +106 -4
- package/src/cli/models.ts +6 -1
- package/src/cli/opencode.ts +2 -83
- package/src/cli/registry.ts +3 -2
- package/src/cli/status.ts +86 -7
- package/src/client/connect.ts +24 -10
- package/src/client/link-ingress.ts +3 -1
- package/src/client/link-join.ts +76 -9
- package/src/client/link-relay-transport.ts +184 -0
- package/src/client/link-relay.ts +16 -4
- package/src/client/machine-listener.ts +2 -0
- package/src/clients/config-export/constants.ts +8 -0
- package/src/clients/config-export/contracts.ts +13 -1
- package/src/clients/config-export/droid.ts +71 -0
- package/src/clients/config-export/kilo.ts +116 -0
- package/src/clients/config-export.ts +33 -2
- package/src/codex/auth-api/routes.ts +1 -1
- package/src/codex/catalog/access-programs.ts +14 -2
- package/src/codex/catalog/provider-models.ts +1 -1
- package/src/codex/catalog-auto-refresh.ts +122 -0
- package/src/codex/config-drift-heal.ts +80 -0
- package/src/codex/desktop-switches.ts +7 -5
- package/src/codex/inject/bounded-config-reader.ts +69 -0
- package/src/codex/inject/config-toml.ts +31 -3
- package/src/codex/inject/routing-target.ts +6 -1
- package/src/codex/inject.ts +4 -0
- package/src/codex/model-entitlements.ts +32 -5
- package/src/codex/native-profile-startup.ts +17 -5
- package/src/codex/project-config-warnings.ts +72 -21
- package/src/codex/quota-auto-refresh-state.ts +8 -2
- package/src/codex/quota-auto-refresh.ts +80 -9
- package/src/codex/sync.ts +10 -0
- package/src/combos/failover.ts +4 -1
- package/src/combos/jev.ts +15 -4
- package/src/combos/types.ts +16 -0
- package/src/config/diagnostics.ts +19 -1
- package/src/config/load-degrade.ts +29 -0
- package/src/config/macos-system-proxy.ts +114 -0
- package/src/config/proxy-env.ts +67 -20
- package/src/config/schema/config-schema.ts +15 -0
- package/src/config/schema/leaf-validators.ts +23 -0
- package/src/config.ts +2 -0
- package/src/generated/compatibility-version.json +220 -144
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/config-io.ts +12 -3
- package/src/integrations/droid-settings.ts +65 -0
- package/src/integrations/kilo-candidates.ts +30 -0
- package/src/integrations/mutation-plan.ts +62 -7
- package/src/integrations/native/ownership-preflight.ts +46 -12
- package/src/integrations/registry.ts +136 -7
- package/src/integrations/state.ts +52 -4
- package/src/integrations/target.ts +3 -0
- package/src/integrations/writer.ts +55 -14
- package/src/lib/account-selection-events.ts +14 -0
- package/src/lib/jsonc.ts +110 -0
- package/src/lib/standalone.ts +9 -1
- package/src/lib/windows-system-proxy.ts +101 -1
- package/src/link/relay-auth.ts +29 -0
- package/src/link/ssh-argv.ts +6 -3
- package/src/link/ssh-runner.ts +5 -5
- package/src/oauth/generic-account-failover.ts +52 -8
- package/src/oauth/index.ts +37 -14
- package/src/oauth/store.ts +96 -11
- package/src/oauth/token-guardian.ts +7 -3
- package/src/oauth/types.ts +2 -0
- package/src/plugins/loader.ts +5 -3
- package/src/providers/fastwire.ts +3 -0
- package/src/providers/key-failover.ts +13 -5
- package/src/providers/kiro-model-catalog.ts +4 -3
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/openai-tiers-destination.ts +9 -0
- package/src/providers/quota/account-cache.ts +17 -3
- package/src/providers/quota/vendor-probes-oauth.ts +2 -0
- package/src/providers/quota.ts +2 -2
- package/src/responses/code-mode-helper-compat.ts +14 -1
- package/src/responses/custom-tool-compat.ts +1 -1
- package/src/server/auth-cors.ts +1 -0
- package/src/server/images.ts +70 -20
- package/src/server/index/link-listener.ts +20 -3
- package/src/server/index/link-relay-sessions.ts +138 -0
- package/src/server/index/optional-listeners.ts +11 -1
- package/src/server/index/serve-options.ts +12 -0
- package/src/server/inference/client-encoder-delivery.ts +1 -0
- package/src/server/local-management-read-client.ts +21 -2
- package/src/server/management/agent-settings-routes.ts +19 -5
- package/src/server/management/combo-routes.ts +5 -3
- package/src/server/management/config-routes.ts +32 -6
- package/src/server/management/model-routes.ts +232 -7
- package/src/server/management/model-rows.ts +199 -1
- package/src/server/management/oauth-account-routes.ts +63 -3
- package/src/server/management/route-registry.ts +2 -0
- package/src/server/port-reclaim.ts +170 -32
- package/src/server/responses/adapter-delivery.ts +6 -4
- package/src/server/responses/adapter-dispatch.ts +72 -2
- package/src/server/responses/core-combo.ts +6 -1
- package/src/server/responses/core-normalize.ts +19 -4
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/memory-models.ts +169 -0
- package/src/server/responses/passthrough-delivery.ts +4 -0
- package/src/server/responses/passthrough-dispatch.ts +10 -0
- package/src/server/responses/request-prepare.ts +86 -4
- package/src/server/responses/request-transport.ts +4 -0
- package/src/server/responses/run-turn-execution.ts +5 -2
- package/src/server/responses/shadow-target-availability.ts +17 -4
- package/src/server/responses/sidecar-execution.ts +18 -2
- package/src/server/responses/skills-snapshot.ts +239 -0
- package/src/server/responses-undeclared-tool-guard.ts +43 -6
- package/src/server/search.ts +1 -1
- package/src/service/guards.ts +4 -5
- package/src/service/state.ts +39 -2
- package/src/service.ts +2 -2
- package/src/types/config.ts +46 -4
- package/src/types/provider.ts +9 -0
- package/src/types/request.ts +6 -0
- package/src/types/tools.ts +38 -8
- package/src/types.ts +3 -0
- package/src/update/async-check.ts +5 -1
- package/src/update/index.ts +30 -12
- package/src/update/job.ts +3 -1
- package/src/update/pnpm-read-policy.d.mts +4 -0
- package/src/update/pnpm-read-policy.mjs +44 -0
- package/src/update/worker-launch.ts +152 -11
- package/src/usage/cost.ts +6 -3
- package/src/usage/log.ts +4 -0
- package/src/web-search/backends.ts +2 -2
- package/src/web-search/devin-executor.ts +1 -1
- package/src/web-search/sidecar-providers.ts +5 -5
- package/gui/dist/assets/App-mpsibsFj.js +0 -51
- package/gui/dist/assets/index-BDUBS8PW.css +0 -1
- package/gui/dist/assets/index-kUE8tuAK.js +0 -86
|
@@ -1558,7 +1558,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1558
1558
|
catch { return jsonResponse({ error: "Claude settings are unreadable", code: "unreadable" }, 500); }
|
|
1559
1559
|
type FirstPartyMutation =
|
|
1560
1560
|
| { refusal: { error: string; code: "intercept_disabled" | "intercept_unavailable" } }
|
|
1561
|
-
| { claudeCode: OcxConfig["claudeCode"]; previous: { present: boolean; value: boolean };
|
|
1561
|
+
| { claudeCode: OcxConfig["claudeCode"]; previous: { present: boolean; value: boolean };
|
|
1562
|
+
pinnedMode: "first-party" | "gateway" | undefined; retainedAmbiguous: boolean };
|
|
1562
1563
|
let outcome: ReturnType<typeof mutatePersistedConfig<FirstPartyMutation>>;
|
|
1563
1564
|
try {
|
|
1564
1565
|
outcome = mutatePersistedConfig<FirstPartyMutation>(persisted => {
|
|
@@ -1573,14 +1574,23 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1573
1574
|
const before = structuredClone(persisted);
|
|
1574
1575
|
const previous = { present: Object.hasOwn(persisted.claudeCode ?? {}, "cliFirstParty"),
|
|
1575
1576
|
value: persisted.claudeCode?.cliFirstParty === true };
|
|
1576
|
-
|
|
1577
|
-
|
|
1577
|
+
// Pin the mode Desktop resolves to *after* this mutation: while cliFirstParty is set the
|
|
1578
|
+
// shared env is suppressed as Desktop evidence, so an opt-out observed with the flag still
|
|
1579
|
+
// on would pin gateway and disconnect a Desktop install that predates the marker.
|
|
1580
|
+
const observedBefore = structuredClone(before);
|
|
1581
|
+
if (!body.cliFirstParty) delete observedBefore.claudeCode?.cliFirstParty;
|
|
1582
|
+
const pinnedMode = persisted.claudeCode?.desktopMode === undefined
|
|
1583
|
+
? resolveClaudeDesktopMode(before, observeClaudeDesktopMode(observedBefore)) : undefined;
|
|
1578
1584
|
const nextBlock = { ...(persisted.claudeCode ?? {}) };
|
|
1579
1585
|
if (body.cliFirstParty) nextBlock.cliFirstParty = true;
|
|
1580
1586
|
else delete nextBlock.cliFirstParty;
|
|
1581
1587
|
if (pinnedMode) nextBlock.desktopMode = pinnedMode;
|
|
1582
1588
|
commitClaudeCodeBlock(persisted, nextBlock);
|
|
1583
|
-
|
|
1589
|
+
// An opt-out that pins first-party from the shared env cannot tell whether that env was
|
|
1590
|
+
// Desktop's or a hand-configured CLI-only one; the env is retained (Desktop keeps its
|
|
1591
|
+
// route) and the caller is warned so it can pin gateway explicitly to release it.
|
|
1592
|
+
const retainedAmbiguous = !body.cliFirstParty && previous.value && pinnedMode === "first-party";
|
|
1593
|
+
return { changed: true, value: { claudeCode: structuredClone(persisted.claudeCode), previous, pinnedMode, retainedAmbiguous } };
|
|
1584
1594
|
});
|
|
1585
1595
|
} catch { return jsonResponse({ error: "Could not save Claude settings", code: "write_failed" }, 500); }
|
|
1586
1596
|
if (outcome.status === "unavailable") return jsonResponse({ error: "Could not save Claude settings", code: "write_failed" }, 500);
|
|
@@ -1630,7 +1640,11 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1630
1640
|
const residual = !finalDesired.desktop && !finalDesired.cli
|
|
1631
1641
|
&& readFirstPartyProxyStatus(config, bound?.proxyPort ?? null) !== "none";
|
|
1632
1642
|
return jsonResponse({ ok: true, enabled: config.claudeCode?.enabled !== false,
|
|
1633
|
-
cliFirstParty: body.cliFirstParty,
|
|
1643
|
+
cliFirstParty: body.cliFirstParty,
|
|
1644
|
+
warnings: [
|
|
1645
|
+
...(committed.retainedAmbiguous ? ["shared_proxy_retained"] : []),
|
|
1646
|
+
...(residual ? ["settings_residual"] : []),
|
|
1647
|
+
] });
|
|
1634
1648
|
}
|
|
1635
1649
|
for (const field of ["webSearchSidecar", "visionSidecar"] as const) {
|
|
1636
1650
|
const section = body[field];
|
|
@@ -244,9 +244,11 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
244
244
|
// would add a noise key to every target of every combo, including ones that never use
|
|
245
245
|
// the policy (#5736). Only the opt-in value is stored, matching how the combo-level
|
|
246
246
|
// policy is handled in sparseComboConfig.
|
|
247
|
-
targets: normalizedBase.targets.map(({ lastResort, ...target }) =>
|
|
248
|
-
|
|
249
|
-
|
|
247
|
+
targets: normalizedBase.targets.map(({ lastResort, modelProfile, ...target }) => ({
|
|
248
|
+
...target,
|
|
249
|
+
...(lastResort ? { lastResort: true } : {}),
|
|
250
|
+
...(modelProfile ? { modelProfile } : {}),
|
|
251
|
+
})),
|
|
250
252
|
...(normalizedAlias ? { alias: normalizedAlias } : {}),
|
|
251
253
|
...(normalizedNativeAlias ? { nativeAlias: true } : {}),
|
|
252
254
|
...(normalizedDisplayName ? { displayName: normalizedDisplayName } : {}),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { compactionRoutingSchema } from "../../config/schema/leaf-validators";
|
|
1
|
+
import { compactionRoutingSchema, memoryModelsSchema } from "../../config/schema/leaf-validators";
|
|
2
2
|
import { compactionRecoverySchema } from "../../config/schema/compaction-recovery";
|
|
3
3
|
import { captureConfigTopLevelRollback } from "../../config/rebase-provenance";
|
|
4
4
|
import type { IntegrationClientId } from "../../integrations/registry";
|
|
@@ -272,7 +272,7 @@ export async function syncEnabledClientIntegrations(
|
|
|
272
272
|
},
|
|
273
273
|
config,
|
|
274
274
|
port,
|
|
275
|
-
}, ["mcode", "pi", "aside", "raycast", "omo", "cline"]));
|
|
275
|
+
}, ["mcode", "pi", "aside", "raycast", "omo", "cline", "droid"]));
|
|
276
276
|
|
|
277
277
|
return out;
|
|
278
278
|
}
|
|
@@ -374,6 +374,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
374
374
|
codexDesktopSwitches: describeCodexDesktopSwitches(config, await observedCodexDesktopSwitchApply()),
|
|
375
375
|
compactionRouting: config.compactionRouting ?? null,
|
|
376
376
|
compactionRecovery: config.compactionRecovery ?? null,
|
|
377
|
+
// Absent means both phases keep their existing routes; the GUI renders that as "Off".
|
|
378
|
+
memoryModels: config.memoryModels ?? null,
|
|
377
379
|
startupHealth: await readStartupHealthSnapshot(config),
|
|
378
380
|
codexRuntime: {
|
|
379
381
|
path: displayCodexRuntimePath(resolved.runtime.command),
|
|
@@ -467,6 +469,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
467
469
|
codexClientCompaction?: unknown;
|
|
468
470
|
compactionRouting?: unknown;
|
|
469
471
|
compactionRecovery?: unknown;
|
|
472
|
+
memoryModels?: unknown;
|
|
470
473
|
};
|
|
471
474
|
if (body.codexAutoStart === undefined
|
|
472
475
|
&& body.streamMode === undefined
|
|
@@ -479,8 +482,10 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
479
482
|
&& body.codexMainAccountHardLock === undefined
|
|
480
483
|
&& body.codexDesktopAuthless === undefined
|
|
481
484
|
&& body.codexClientCompaction === undefined
|
|
482
|
-
&& body.compactionRouting === undefined
|
|
483
|
-
|
|
485
|
+
&& body.compactionRouting === undefined
|
|
486
|
+
&& body.compactionRecovery === undefined
|
|
487
|
+
&& body.memoryModels === undefined) {
|
|
488
|
+
return jsonResponse({ error: "provide codexAutoStart, streamMode, appOwnedMemoryBudgetMb, codexAccountPickerEnabled, codexQuotaAutoRefresh, oauthOpenBrowser, ultraFastTier, fastRows, codexMainAccountHardLock, codexDesktopAuthless, codexClientCompaction, compactionRouting, compactionRecovery, or memoryModels" }, 400);
|
|
484
489
|
}
|
|
485
490
|
if (body.codexAutoStart !== undefined && typeof body.codexAutoStart !== "boolean") {
|
|
486
491
|
return jsonResponse({ error: "codexAutoStart boolean is required" }, 400);
|
|
@@ -518,6 +523,12 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
518
523
|
if (compactionRouting != null && !compactionRouting.success) {
|
|
519
524
|
return jsonResponse({ error: "compactionRouting requires a model, an optional valid reasoningEffort, and optional non-repeating triggers drawn from \"manual\" and \"auto\"" }, 400);
|
|
520
525
|
}
|
|
526
|
+
const memoryModels = body.memoryModels == null
|
|
527
|
+
? body.memoryModels
|
|
528
|
+
: memoryModelsSchema.safeParse(body.memoryModels);
|
|
529
|
+
if (memoryModels != null && !memoryModels.success) {
|
|
530
|
+
return jsonResponse({ error: "memoryModels requires a nonblank model and an optional declared reasoningEffort per configured phase, and no other fields" }, 400);
|
|
531
|
+
}
|
|
521
532
|
let quotaAutoRefreshChange: { id: string; window: "fiveHour" | "weekly"; enabled: boolean } | undefined;
|
|
522
533
|
if (body.codexQuotaAutoRefresh !== undefined) {
|
|
523
534
|
if (!isPlainRecord(body.codexQuotaAutoRefresh)) {
|
|
@@ -547,7 +558,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
547
558
|
)) {
|
|
548
559
|
return jsonResponse({ error: `appOwnedMemoryBudgetMb must be an integer from ${MIN_APP_OWNED_MEMORY_BUDGET_MB} to ${MAX_APP_OWNED_MEMORY_BUDGET_MB}` }, 400);
|
|
549
560
|
}
|
|
550
|
-
const restoreCompactionRouting = captureConfigTopLevelRollback(config, ["compactionRouting", "compactionRecovery"]);
|
|
561
|
+
const restoreCompactionRouting = captureConfigTopLevelRollback(config, ["compactionRouting", "compactionRecovery", "memoryModels"]);
|
|
551
562
|
const previousSettings = {
|
|
552
563
|
codexAutoStart: config.codexAutoStart,
|
|
553
564
|
hasCodexAutoStart: Object.hasOwn(config, "codexAutoStart"),
|
|
@@ -620,6 +631,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
620
631
|
else if (compactionRouting?.success) config.compactionRouting = compactionRouting.data;
|
|
621
632
|
if (compactionRecovery === null) deleteConfigTopLevelKey(config, "compactionRecovery");
|
|
622
633
|
else if (compactionRecovery?.success) config.compactionRecovery = compactionRecovery.data;
|
|
634
|
+
// Null clears both phases; the GUI sends that when neither row names a model.
|
|
635
|
+
if (memoryModels === null) deleteConfigTopLevelKey(config, "memoryModels");
|
|
636
|
+
else if (memoryModels?.success) config.memoryModels = memoryModels.data;
|
|
623
637
|
if (quotaAutoRefreshChange) {
|
|
624
638
|
const { id, window, enabled } = quotaAutoRefreshChange;
|
|
625
639
|
const setting = { ...(config.codexQuotaAutoRefresh?.[id] ?? {}) };
|
|
@@ -722,6 +736,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
722
736
|
codexDesktopSwitches,
|
|
723
737
|
compactionRouting: config.compactionRouting ?? null,
|
|
724
738
|
compactionRecovery: config.compactionRecovery ?? null,
|
|
739
|
+
// The panel re-reads its own save response, so a missing block would render both
|
|
740
|
+
// phases as "Off" while the server kept them.
|
|
741
|
+
memoryModels: config.memoryModels ?? null,
|
|
725
742
|
codexMainAccountHardLock: isMainAccountHardLockEnabled(config),
|
|
726
743
|
mainAccountHardLock: getMainAccountHardLockStatus(config),
|
|
727
744
|
startupHealth: await readStartupHealthSnapshot(config),
|
|
@@ -772,7 +789,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
772
789
|
}
|
|
773
790
|
|
|
774
791
|
if (url.pathname === "/api/update/run" && req.method === "POST") {
|
|
775
|
-
const { normalizeUpdateChannel, startUpdateJob, UpdateJobError } = await import("../../update/job");
|
|
792
|
+
const { normalizeUpdateChannel, startUpdateJob, UpdateJobError, spawnGuiUpdateWorker } = await import("../../update/job");
|
|
776
793
|
let body: { tag?: unknown; restart?: unknown };
|
|
777
794
|
try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
778
795
|
if (body.tag !== undefined && body.tag !== "latest" && body.tag !== "preview") {
|
|
@@ -785,8 +802,17 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
785
802
|
const channel = normalizeUpdateChannel(body.tag as string | undefined);
|
|
786
803
|
const { packageRefresh } = await import("../../update/refresh-scheduler");
|
|
787
804
|
const checked = await (deps.checkPackageUpdate ?? packageRefresh.check)(channel);
|
|
805
|
+
// Resolve the systemd-scope launcher before spawning: the first request
|
|
806
|
+
// would otherwise run up to four sequential five-second probes inside
|
|
807
|
+
// spawnSync on the shared event loop.
|
|
808
|
+
const { resolveSystemdRunAsync } = await import("../../update/worker-launch");
|
|
809
|
+
const systemdRun = process.platform === "linux" && process.env.INVOCATION_ID
|
|
810
|
+
? await resolveSystemdRunAsync()
|
|
811
|
+
: undefined;
|
|
788
812
|
return jsonResponse({ ok: true, job: startUpdateJob(channel, body.restart !== false, {
|
|
789
813
|
checkForUpdateFn: () => checked,
|
|
814
|
+
spawnWorkerFn: (jobId, runChannel, runRestart) =>
|
|
815
|
+
spawnGuiUpdateWorker(jobId, runChannel, runRestart, { resolveSystemdRun: () => systemdRun }),
|
|
790
816
|
}) });
|
|
791
817
|
} catch (err) {
|
|
792
818
|
if (err instanceof UpdateJobError) {
|
|
@@ -11,9 +11,17 @@ import { shouldInjectApiAuthHeader } from "../../codex/loopback-target";
|
|
|
11
11
|
* be handed back to the GUI and CLI as if it were real, and the offline `ocx models add` path
|
|
12
12
|
* already refuses it. Validate at ingress so all three paths agree.
|
|
13
13
|
*/
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The modalities a declaration may carry. `video` is missing on purpose: the wire carries text
|
|
16
|
+
* and image parts, so advertising it would be a promise no request path can keep.
|
|
17
|
+
*/
|
|
18
|
+
type DeclaredInputModality = NonNullable<ModelCapabilities["inputModalities"]>[number];
|
|
19
|
+
|
|
20
|
+
// A plain string set on purpose: the lookup runs against unvalidated input, and the enum lives in
|
|
21
|
+
// the return type below, where the cast is the validator's stated guarantee.
|
|
22
|
+
const ALLOWED_INPUT_MODALITIES: ReadonlySet<string> = new Set(["text", "image", "audio"]);
|
|
15
23
|
|
|
16
|
-
function readInputModalities(raw: unknown): { values?:
|
|
24
|
+
function readInputModalities(raw: unknown): { values?: DeclaredInputModality[]; error?: string } {
|
|
17
25
|
if (raw === undefined) return {};
|
|
18
26
|
if (!Array.isArray(raw)) return { error: "inputModalities must be an array" };
|
|
19
27
|
// Reject non-strings rather than filtering them out. Dropping them silently accepted a
|
|
@@ -28,7 +36,7 @@ function readInputModalities(raw: unknown): { values?: string[]; error?: string
|
|
|
28
36
|
if (rejected.length > 0) {
|
|
29
37
|
return { error: `unsupported input modality: ${rejected.join(", ")} (allowed: text, image, audio)` };
|
|
30
38
|
}
|
|
31
|
-
return { values: raw as
|
|
39
|
+
return { values: raw as DeclaredInputModality[] };
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
/**
|
|
@@ -108,6 +116,9 @@ import { encodedModelIdCollides, routedSlug, slugEquals } from "../../providers/
|
|
|
108
116
|
import { knownModelIdsForProvider } from "../../router";
|
|
109
117
|
import { effectiveModelAliases, MODEL_ALIAS_PATTERN } from "../../providers/default-aliases";
|
|
110
118
|
import { isValidModelDiscoveryModelId } from "../../providers/model-discovery-limits";
|
|
119
|
+
import { commitProviderPatch } from "./provider-patch-transaction";
|
|
120
|
+
import { ConfigWritePublishedError } from "../../config/persist-unlocked";
|
|
121
|
+
import type { CatalogDisposition } from "../../codex/convergence-types";
|
|
111
122
|
import { comboPublicModelId } from "../../combos/types";
|
|
112
123
|
import { COMBO_NAMESPACE, comboDisabledModelSelectors, comboModelId, preservesPhysicalComboProvider } from "../../combos";
|
|
113
124
|
import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
|
|
@@ -131,7 +142,7 @@ import {
|
|
|
131
142
|
setDebugSettings,
|
|
132
143
|
type DebugFlag,
|
|
133
144
|
} from "../../lib/debug-settings";
|
|
134
|
-
import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig, ProviderCostOverlay } from "../../types";
|
|
145
|
+
import type { ModelCapabilities, OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig, ProviderCostOverlay } from "../../types";
|
|
135
146
|
import { drainAndShutdown } from "../lifecycle";
|
|
136
147
|
import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
|
|
137
148
|
import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
|
|
@@ -157,7 +168,7 @@ import type {
|
|
|
157
168
|
import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
|
|
158
169
|
import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
|
|
159
170
|
import type { ManagementContext } from "./context";
|
|
160
|
-
import { listManagementModelRows, loadExportModels } from "./model-rows";
|
|
171
|
+
import { effectiveModelReasoningEfforts, inheritedModelReasoningEfforts, listManagementModelRows, loadExportModels } from "./model-rows";
|
|
161
172
|
import { initialModelSelectionPending } from "../../providers/initial-model-selection";
|
|
162
173
|
import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
|
|
163
174
|
import {
|
|
@@ -545,9 +556,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
545
556
|
if (!(error instanceof ClientPathError)) throw error;
|
|
546
557
|
return jsonResponse({ error: error.message }, 400, req, config);
|
|
547
558
|
}
|
|
548
|
-
if (requested === "raycast" && shouldInjectApiAuthHeader(config)) {
|
|
559
|
+
if ((requested === "raycast" || requested === "droid") && shouldInjectApiAuthHeader(config)) {
|
|
549
560
|
return jsonResponse({
|
|
550
|
-
error: "Raycast export requires an unauthenticated loopback destination; this listener requires an admission header
|
|
561
|
+
error: `${requested === "droid" ? "Factory Droid" : "Raycast"} export requires an unauthenticated loopback destination; this listener requires an admission header the client cannot supply.`,
|
|
551
562
|
reason: "non_loopback",
|
|
552
563
|
}, 400, req, config);
|
|
553
564
|
}
|
|
@@ -733,6 +744,220 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
733
744
|
return jsonResponse(config.customModels ?? []);
|
|
734
745
|
}
|
|
735
746
|
|
|
747
|
+
/*
|
|
748
|
+
* Per-model overrides for a routed row, addressed by provider + upstream model id.
|
|
749
|
+
*
|
|
750
|
+
* Deliberately not a custom model. A custom model is a row the operator declares; this edits
|
|
751
|
+
* facts about a row that already exists, through the provider-level per-model maps the runtime
|
|
752
|
+
* already reads. Creating a custom model to change one field would replace the row's identity
|
|
753
|
+
* and its discovery provenance, which is the opposite of what an edit means.
|
|
754
|
+
*
|
|
755
|
+
* Every field is optional and `null` CLEARS the declaration rather than writing a default: that
|
|
756
|
+
* is what hands the fact back to the registry, the catalog and the provider, instead of pinning
|
|
757
|
+
* today's answer as an override that silently outlives the data it came from.
|
|
758
|
+
*
|
|
759
|
+
* Display name is not here on purpose. The row already has a dedicated editor for it
|
|
760
|
+
* (PUT /api/providers/{provider}/model-display-names) with its own provenance display; a second
|
|
761
|
+
* control writing the same key is two statements that can drift, and this endpoint's job is the
|
|
762
|
+
* capability axes nothing else edits.
|
|
763
|
+
*/
|
|
764
|
+
if (url.pathname === "/api/model-settings" && req.method === "PUT") {
|
|
765
|
+
let parsedBody: unknown;
|
|
766
|
+
try { parsedBody = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
767
|
+
if (!isPlainRecord(parsedBody)) return jsonResponse({ error: "invalid model settings request" }, 400);
|
|
768
|
+
const body = parsedBody;
|
|
769
|
+
for (const key of Object.keys(body)) {
|
|
770
|
+
if (!["provider", "modelId", "contextWindow", "inputModalities", "reasoningEfforts", "defaultReasoningEffort"].includes(key)) {
|
|
771
|
+
return jsonResponse({ error: `unknown model settings field: ${key}` }, 400);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
const provider = typeof body.provider === "string" ? body.provider.trim() : "";
|
|
775
|
+
const modelId = typeof body.modelId === "string" ? body.modelId : "";
|
|
776
|
+
if (!provider || !modelId) return jsonResponse({ error: "provider and modelId are required" }, 400);
|
|
777
|
+
if (!isValidModelDiscoveryModelId(modelId) || ["__proto__", "constructor", "prototype"].includes(modelId)) {
|
|
778
|
+
return jsonResponse({ error: "modelId must be an exact non-reserved model id" }, 400);
|
|
779
|
+
}
|
|
780
|
+
if (!isValidProviderName(provider) || !hasOwnProvider(config.providers, provider)) {
|
|
781
|
+
return jsonResponse({ error: "unknown model settings provider" }, 400);
|
|
782
|
+
}
|
|
783
|
+
// "openai" is the native passthrough lane and "combo" is a synthetic row: neither is a
|
|
784
|
+
// provider whose per-model maps the runtime consults for these facts.
|
|
785
|
+
if (provider === "openai" || provider === "combo") {
|
|
786
|
+
return jsonResponse({ error: "model settings are only available for routed providers" }, 400);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const providerConfig = { ...config.providers[provider] };
|
|
790
|
+
const storedLadder = providerConfig.modelReasoningEfforts?.[modelId];
|
|
791
|
+
|
|
792
|
+
let contextWindow: number | null | undefined;
|
|
793
|
+
if (body.contextWindow !== undefined) {
|
|
794
|
+
if (body.contextWindow === null) contextWindow = null;
|
|
795
|
+
else if (typeof body.contextWindow === "number" && Number.isSafeInteger(body.contextWindow) && body.contextWindow > 0) {
|
|
796
|
+
contextWindow = body.contextWindow;
|
|
797
|
+
} else return jsonResponse({ error: "contextWindow must be a positive safe integer or null" }, 400);
|
|
798
|
+
}
|
|
799
|
+
let modalities: DeclaredInputModality[] | null | undefined;
|
|
800
|
+
if (body.inputModalities !== undefined) {
|
|
801
|
+
if (body.inputModalities === null) modalities = null;
|
|
802
|
+
else {
|
|
803
|
+
const parsed = readInputModalities(body.inputModalities);
|
|
804
|
+
if (parsed.error) return jsonResponse({ error: parsed.error }, 400);
|
|
805
|
+
// An empty array means "cleared", exactly like null: a model that accepts no modality at
|
|
806
|
+
// all is not a state this surface can express, and storing [] would advertise text-only
|
|
807
|
+
// through a value the catalog then has to special-case.
|
|
808
|
+
// Deduplicated before the unchanged check and before the write: `["text","text"]` against
|
|
809
|
+
// a stored `["text","image"]` passes a length test and an every-member test, so the
|
|
810
|
+
// operator's edit would be dropped as a no-op; when it did differ, the duplicates would be
|
|
811
|
+
// persisted as submitted.
|
|
812
|
+
const unique = parsed.values ? [...new Set(parsed.values)] : [];
|
|
813
|
+
modalities = unique.length > 0 ? unique : null;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
let ladder: string[] | null | undefined;
|
|
817
|
+
if (body.reasoningEfforts !== undefined) {
|
|
818
|
+
if (body.reasoningEfforts === null) ladder = null;
|
|
819
|
+
else {
|
|
820
|
+
const parsed = readReasoningEfforts(body.reasoningEfforts);
|
|
821
|
+
if (parsed.error) return jsonResponse({ error: parsed.error }, 400);
|
|
822
|
+
ladder = parsed.values ?? [];
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
/*
|
|
826
|
+
* Validated against the ladder this write leaves behind, not the one that happens to be stored:
|
|
827
|
+
* a caller replacing both in one request must not be rejected for the value it is replacing, and
|
|
828
|
+
* a default-only request must be judged against the ladder the model actually resolves — which
|
|
829
|
+
* may be the provider-level, registry, native or catalog one, not only a per-model entry.
|
|
830
|
+
* `inheritedModelReasoningEfforts` is what the clear case leaves behind: it removes the model's
|
|
831
|
+
* entry from a copy before resolving, so it answers the post-write state.
|
|
832
|
+
*/
|
|
833
|
+
const resultingLadder = ladder === undefined
|
|
834
|
+
? (Array.isArray(storedLadder) ? storedLadder : effectiveModelReasoningEfforts(config, provider, modelId))
|
|
835
|
+
: (ladder ?? inheritedModelReasoningEfforts(config, provider, modelId));
|
|
836
|
+
const defaultEffort = readDefaultReasoningEffort(body.defaultReasoningEffort, resultingLadder);
|
|
837
|
+
if (defaultEffort.error) return jsonResponse({ error: defaultEffort.error }, 400);
|
|
838
|
+
|
|
839
|
+
let changed = false;
|
|
840
|
+
const writePerModel = (
|
|
841
|
+
key: "modelContextWindows" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts",
|
|
842
|
+
value: unknown,
|
|
843
|
+
) => {
|
|
844
|
+
if (value === undefined) return;
|
|
845
|
+
const bag = providerConfig as unknown as Record<string, unknown>;
|
|
846
|
+
const record: Record<string, unknown> = Object.assign(Object.create(null), (bag[key] as Record<string, unknown> | undefined) ?? {});
|
|
847
|
+
const present = Object.prototype.hasOwnProperty.call(record, modelId);
|
|
848
|
+
if (value === null) {
|
|
849
|
+
if (!present) return;
|
|
850
|
+
delete record[modelId];
|
|
851
|
+
changed = true;
|
|
852
|
+
} else {
|
|
853
|
+
if (present && JSON.stringify(record[modelId]) === JSON.stringify(value)) return;
|
|
854
|
+
record[modelId] = value;
|
|
855
|
+
changed = true;
|
|
856
|
+
}
|
|
857
|
+
// An emptied map is removed rather than left as {}. The maps are optional config fields, and
|
|
858
|
+
// an empty one is indistinguishable from a configured-but-blank state to every reader.
|
|
859
|
+
if (Object.keys(record).length === 0) delete bag[key];
|
|
860
|
+
else bag[key] = record;
|
|
861
|
+
};
|
|
862
|
+
writePerModel("modelContextWindows", contextWindow);
|
|
863
|
+
writePerModel("modelReasoningEfforts", ladder);
|
|
864
|
+
if (body.defaultReasoningEffort !== undefined) {
|
|
865
|
+
writePerModel("modelDefaultReasoningEfforts", defaultEffort.value === undefined ? null : defaultEffort.value);
|
|
866
|
+
} else if (ladder !== undefined) {
|
|
867
|
+
/*
|
|
868
|
+
* A ladder write can strand a stored default that the model can no longer select, and the
|
|
869
|
+
* catalog would then publish a default outside its own ladder. The caller said nothing about
|
|
870
|
+
* the default, so only one that cannot survive is cleared. With `ladder === null` the override
|
|
871
|
+
* is gone and the ladder to test against is the inherited one — read from the config after the
|
|
872
|
+
* write, which no longer carries the removed entry.
|
|
873
|
+
*/
|
|
874
|
+
const storedDefault = providerConfig.modelDefaultReasoningEfforts?.[modelId];
|
|
875
|
+
const nextConfig = { ...config, providers: { ...config.providers, [provider]: providerConfig } };
|
|
876
|
+
const effectiveLadder = ladder ?? effectiveModelReasoningEfforts(nextConfig, provider, modelId);
|
|
877
|
+
if (typeof storedDefault === "string" && !(effectiveLadder ?? []).includes(storedDefault)) {
|
|
878
|
+
writePerModel("modelDefaultReasoningEfforts", null);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
if (modalities !== undefined) {
|
|
882
|
+
const capabilities = Object.assign(Object.create(null), providerConfig.modelCapabilities ?? {}) as NonNullable<typeof providerConfig.modelCapabilities>;
|
|
883
|
+
const row = { ...(capabilities[modelId] ?? {}) };
|
|
884
|
+
const declared = Array.isArray(row.inputModalities) ? [...row.inputModalities] : undefined;
|
|
885
|
+
const unchanged = modalities === null
|
|
886
|
+
? declared === undefined
|
|
887
|
+
: declared !== undefined
|
|
888
|
+
&& declared.length === modalities.length
|
|
889
|
+
&& modalities.every(modality => declared.includes(modality));
|
|
890
|
+
if (!unchanged) {
|
|
891
|
+
changed = true;
|
|
892
|
+
if (modalities === null) delete row.inputModalities;
|
|
893
|
+
else row.inputModalities = modalities;
|
|
894
|
+
if (Object.keys(row).length === 0) delete capabilities[modelId];
|
|
895
|
+
else capabilities[modelId] = row;
|
|
896
|
+
if (Object.keys(capabilities).length === 0) delete providerConfig.modelCapabilities;
|
|
897
|
+
else providerConfig.modelCapabilities = capabilities;
|
|
898
|
+
}
|
|
899
|
+
/*
|
|
900
|
+
* The legacy `modelInputModalities` record is the fallback `declaredModelInputModalities` and the
|
|
901
|
+
* router read once the capability entry is gone. A clear that left its exact entry behind would
|
|
902
|
+
* report "restored" while the old declaration stayed in force. Only the exact key is removed: a
|
|
903
|
+
* family or case-folded key covers other models too, and stays a provider-level declaration.
|
|
904
|
+
*/
|
|
905
|
+
const legacy = providerConfig.modelInputModalities;
|
|
906
|
+
if (modalities === null && legacy && Object.hasOwn(legacy, modelId)) {
|
|
907
|
+
const next: Record<string, string[]> = Object.assign(Object.create(null), legacy);
|
|
908
|
+
delete next[modelId];
|
|
909
|
+
changed = true;
|
|
910
|
+
if (Object.keys(next).length === 0) delete providerConfig.modelInputModalities;
|
|
911
|
+
else providerConfig.modelInputModalities = next;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const hasOverrides = [providerConfig.modelContextWindows, providerConfig.modelReasoningEfforts,
|
|
916
|
+
providerConfig.modelDefaultReasoningEfforts, providerConfig.modelInputModalities]
|
|
917
|
+
.some(map => map !== undefined && Object.hasOwn(map, modelId))
|
|
918
|
+
|| providerConfig.modelCapabilities?.[modelId]?.inputModalities !== undefined;
|
|
919
|
+
const state = {
|
|
920
|
+
ok: true as const,
|
|
921
|
+
provider,
|
|
922
|
+
modelId,
|
|
923
|
+
hasOverrides,
|
|
924
|
+
contextWindow: providerConfig.modelContextWindows?.[modelId] ?? null,
|
|
925
|
+
inputModalities: providerConfig.modelCapabilities?.[modelId]?.inputModalities ?? null,
|
|
926
|
+
reasoningEfforts: providerConfig.modelReasoningEfforts?.[modelId] ?? null,
|
|
927
|
+
defaultReasoningEffort: providerConfig.modelDefaultReasoningEfforts?.[modelId] ?? null,
|
|
928
|
+
};
|
|
929
|
+
// A no-op can still retain stored declarations; `hasOverrides` reports that independently.
|
|
930
|
+
if (!changed) return jsonResponse({ ...state, changed: false, saved: false,
|
|
931
|
+
catalogRefresh: { status: "skipped", reason: "not-requested", retryable: false } });
|
|
932
|
+
let publicationError = false;
|
|
933
|
+
try {
|
|
934
|
+
commitProviderPatch(config, () => { config.providers[provider] = providerConfig; }, persistConfig);
|
|
935
|
+
} catch (error) {
|
|
936
|
+
if (!(error instanceof ConfigWritePublishedError)) {
|
|
937
|
+
return jsonResponse({ error: "model settings could not be saved" }, 500);
|
|
938
|
+
}
|
|
939
|
+
publicationError = true;
|
|
940
|
+
}
|
|
941
|
+
/*
|
|
942
|
+
* Drop this provider's live `/models` cache before converging.
|
|
943
|
+
*
|
|
944
|
+
* The gather bakes the resolved hints into the rows it caches, and on a cache hit it re-applies
|
|
945
|
+
* the config through `clampObservedModelLimits`, where a configured window may only LOWER the
|
|
946
|
+
* observed one. A raised or cleared override would therefore keep reading back the previous
|
|
947
|
+
* answer for the whole TTL, and a cleared modality or ladder would keep the baked one. Clearing
|
|
948
|
+
* the entry is what makes the save visible on the next read, the same reason
|
|
949
|
+
* `PUT /api/provider-context-caps` clears it per affected provider.
|
|
950
|
+
*/
|
|
951
|
+
clearModelCache(provider);
|
|
952
|
+
let catalogRefresh: CatalogDisposition;
|
|
953
|
+
if (publicationError) catalogRefresh = { status: "skipped", reason: "not-requested", retryable: true };
|
|
954
|
+
else {
|
|
955
|
+
try { catalogRefresh = await convergeCodexCatalog(); }
|
|
956
|
+
catch { catalogRefresh = { status: "failed", reason: "internal", phase: "gather", retryable: true, partialWrite: false }; }
|
|
957
|
+
}
|
|
958
|
+
return jsonResponse({ ...state, changed: true, saved: true, catalogRefresh });
|
|
959
|
+
}
|
|
960
|
+
|
|
736
961
|
if (url.pathname === "/api/custom-models" && req.method === "POST") {
|
|
737
962
|
let parsedBody: unknown;
|
|
738
963
|
try { parsedBody = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|