@bitkyc08/opencodex 2.68.0 → 2.69.0-preview.20260928

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/bin/ocx.mjs +25 -17
  2. package/gui/dist/assets/App-cDPyCb_V.js +51 -0
  3. package/gui/dist/assets/{Tray-Bn8WhKBH.js → Tray-H1IAB8mN.js} +1 -1
  4. package/gui/dist/assets/index-BF9QnKO_.css +1 -0
  5. package/gui/dist/assets/index-CI6YTp8z.js +86 -0
  6. package/gui/dist/assets/{quota-summary-DL-bNuNL.js → quota-summary-CutOlPu2.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/factory-droid.svg +8 -0
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-output-schema.ts +109 -11
  11. package/src/adapters/coding-agent/protocol.ts +106 -11
  12. package/src/adapters/coding-agent/turn.ts +23 -18
  13. package/src/adapters/google-errors.ts +16 -1
  14. package/src/adapters/google-http.ts +14 -0
  15. package/src/adapters/openai-chat/passthrough.ts +2 -0
  16. package/src/adapters/openai-chat/summary-budget.ts +72 -0
  17. package/src/adapters/openai-chat/tool-call-id-remint.ts +11 -2
  18. package/src/adapters/openai-chat.ts +6 -9
  19. package/src/bridge/response-json.ts +3 -1
  20. package/src/bridge/sse.ts +3 -1
  21. package/src/claude/desktop-picker.ts +6 -2
  22. package/src/claude/desktop-system-proxy.ts +155 -0
  23. package/src/claude/inbound-model-options.ts +10 -2
  24. package/src/claude/inbound.ts +1 -1
  25. package/src/claude/intercept/picker-ca-cleanup.ts +31 -0
  26. package/src/claude/intercept/picker-ca.ts +283 -83
  27. package/src/claude/intercept/picker-models.ts +4 -1
  28. package/src/claude/intercept/picker-trust.ts +15 -2
  29. package/src/claude/intercept/runtime.ts +99 -32
  30. package/src/cli/account-api.ts +5 -2
  31. package/src/cli/account-extended.ts +47 -11
  32. package/src/cli/account-target.ts +12 -7
  33. package/src/cli/account.ts +47 -3
  34. package/src/cli/capabilities.ts +33 -7
  35. package/src/cli/claude-agent-startup-sync.ts +2 -0
  36. package/src/cli/claude-desktop.ts +37 -13
  37. package/src/cli/cross-home-owner.ts +138 -0
  38. package/src/cli/dispatch.ts +1 -1
  39. package/src/cli/doctor.ts +18 -8
  40. package/src/cli/ensure-desired-integrations.ts +5 -0
  41. package/src/cli/export-command.ts +6 -6
  42. package/src/cli/help.ts +1 -1
  43. package/src/cli/index.ts +15 -18
  44. package/src/cli/integrations.ts +8 -1
  45. package/src/cli/models-runtime-subcommands.ts +1 -0
  46. package/src/cli/models-runtime.ts +106 -4
  47. package/src/cli/models.ts +6 -1
  48. package/src/cli/opencode.ts +2 -83
  49. package/src/cli/registry.ts +3 -2
  50. package/src/cli/status.ts +86 -7
  51. package/src/client/connect.ts +24 -10
  52. package/src/client/link-ingress.ts +3 -1
  53. package/src/client/link-join.ts +76 -9
  54. package/src/client/link-relay-transport.ts +184 -0
  55. package/src/client/link-relay.ts +16 -4
  56. package/src/client/machine-listener.ts +2 -0
  57. package/src/clients/config-export/constants.ts +8 -0
  58. package/src/clients/config-export/contracts.ts +13 -1
  59. package/src/clients/config-export/droid.ts +71 -0
  60. package/src/clients/config-export/kilo.ts +116 -0
  61. package/src/clients/config-export.ts +33 -2
  62. package/src/codex/auth-api/routes.ts +1 -1
  63. package/src/codex/catalog/access-programs.ts +14 -2
  64. package/src/codex/catalog/provider-models.ts +1 -1
  65. package/src/codex/catalog-auto-refresh.ts +122 -0
  66. package/src/codex/config-drift-heal.ts +80 -0
  67. package/src/codex/desktop-switches.ts +7 -5
  68. package/src/codex/inject/bounded-config-reader.ts +69 -0
  69. package/src/codex/inject/config-toml.ts +31 -3
  70. package/src/codex/inject/routing-target.ts +6 -1
  71. package/src/codex/inject.ts +4 -0
  72. package/src/codex/model-entitlements.ts +32 -5
  73. package/src/codex/native-profile-startup.ts +17 -5
  74. package/src/codex/project-config-warnings.ts +72 -21
  75. package/src/codex/quota-auto-refresh-state.ts +8 -2
  76. package/src/codex/quota-auto-refresh.ts +80 -9
  77. package/src/codex/sync.ts +10 -0
  78. package/src/combos/failover.ts +4 -1
  79. package/src/combos/jev.ts +15 -4
  80. package/src/combos/types.ts +16 -0
  81. package/src/config/diagnostics.ts +19 -1
  82. package/src/config/load-degrade.ts +29 -0
  83. package/src/config/macos-system-proxy.ts +114 -0
  84. package/src/config/proxy-env.ts +67 -20
  85. package/src/config/schema/config-schema.ts +15 -0
  86. package/src/config/schema/leaf-validators.ts +23 -0
  87. package/src/config.ts +2 -0
  88. package/src/generated/compatibility-version.json +220 -144
  89. package/src/integrations/catalog-refresh.ts +1 -1
  90. package/src/integrations/config-io.ts +12 -3
  91. package/src/integrations/droid-settings.ts +65 -0
  92. package/src/integrations/kilo-candidates.ts +30 -0
  93. package/src/integrations/mutation-plan.ts +62 -7
  94. package/src/integrations/native/ownership-preflight.ts +46 -12
  95. package/src/integrations/registry.ts +136 -7
  96. package/src/integrations/state.ts +52 -4
  97. package/src/integrations/target.ts +3 -0
  98. package/src/integrations/writer.ts +55 -14
  99. package/src/lib/account-selection-events.ts +14 -0
  100. package/src/lib/jsonc.ts +110 -0
  101. package/src/lib/standalone.ts +9 -1
  102. package/src/lib/windows-system-proxy.ts +101 -1
  103. package/src/link/relay-auth.ts +29 -0
  104. package/src/link/ssh-argv.ts +6 -3
  105. package/src/link/ssh-runner.ts +5 -5
  106. package/src/oauth/generic-account-failover.ts +52 -8
  107. package/src/oauth/index.ts +37 -14
  108. package/src/oauth/store.ts +96 -11
  109. package/src/oauth/token-guardian.ts +7 -3
  110. package/src/oauth/types.ts +2 -0
  111. package/src/plugins/loader.ts +5 -3
  112. package/src/providers/fastwire.ts +3 -0
  113. package/src/providers/key-failover.ts +13 -5
  114. package/src/providers/kiro-model-catalog.ts +4 -3
  115. package/src/providers/model-rename-fields.ts +1 -0
  116. package/src/providers/openai-tiers-destination.ts +9 -0
  117. package/src/providers/quota/account-cache.ts +17 -3
  118. package/src/providers/quota/vendor-probes-oauth.ts +2 -0
  119. package/src/providers/quota.ts +2 -2
  120. package/src/responses/code-mode-helper-compat.ts +14 -1
  121. package/src/responses/custom-tool-compat.ts +1 -1
  122. package/src/server/auth-cors.ts +1 -0
  123. package/src/server/images.ts +70 -20
  124. package/src/server/index/link-listener.ts +20 -3
  125. package/src/server/index/link-relay-sessions.ts +138 -0
  126. package/src/server/index/optional-listeners.ts +11 -1
  127. package/src/server/index/serve-options.ts +12 -0
  128. package/src/server/inference/client-encoder-delivery.ts +1 -0
  129. package/src/server/local-management-read-client.ts +21 -2
  130. package/src/server/management/agent-settings-routes.ts +19 -5
  131. package/src/server/management/combo-routes.ts +5 -3
  132. package/src/server/management/config-routes.ts +32 -6
  133. package/src/server/management/model-routes.ts +232 -7
  134. package/src/server/management/model-rows.ts +199 -1
  135. package/src/server/management/oauth-account-routes.ts +63 -3
  136. package/src/server/management/route-registry.ts +2 -0
  137. package/src/server/port-reclaim.ts +170 -32
  138. package/src/server/responses/adapter-delivery.ts +6 -4
  139. package/src/server/responses/adapter-dispatch.ts +72 -2
  140. package/src/server/responses/core-combo.ts +6 -1
  141. package/src/server/responses/core-normalize.ts +19 -4
  142. package/src/server/responses/core-options.ts +2 -0
  143. package/src/server/responses/memory-models.ts +169 -0
  144. package/src/server/responses/passthrough-delivery.ts +4 -0
  145. package/src/server/responses/passthrough-dispatch.ts +10 -0
  146. package/src/server/responses/request-prepare.ts +86 -4
  147. package/src/server/responses/request-transport.ts +4 -0
  148. package/src/server/responses/run-turn-execution.ts +5 -2
  149. package/src/server/responses/shadow-target-availability.ts +17 -4
  150. package/src/server/responses/sidecar-execution.ts +18 -2
  151. package/src/server/responses/skills-snapshot.ts +239 -0
  152. package/src/server/responses-undeclared-tool-guard.ts +43 -6
  153. package/src/server/search.ts +1 -1
  154. package/src/service/guards.ts +4 -5
  155. package/src/service/state.ts +39 -2
  156. package/src/service.ts +2 -2
  157. package/src/types/config.ts +46 -4
  158. package/src/types/provider.ts +9 -0
  159. package/src/types/request.ts +6 -0
  160. package/src/types/tools.ts +38 -8
  161. package/src/types.ts +3 -0
  162. package/src/update/async-check.ts +5 -1
  163. package/src/update/index.ts +30 -12
  164. package/src/update/job.ts +3 -1
  165. package/src/update/pnpm-read-policy.d.mts +4 -0
  166. package/src/update/pnpm-read-policy.mjs +44 -0
  167. package/src/update/worker-launch.ts +152 -11
  168. package/src/usage/cost.ts +6 -3
  169. package/src/usage/log.ts +4 -0
  170. package/src/web-search/backends.ts +2 -2
  171. package/src/web-search/devin-executor.ts +1 -1
  172. package/src/web-search/sidecar-providers.ts +5 -5
  173. package/gui/dist/assets/App-mpsibsFj.js +0 -51
  174. package/gui/dist/assets/index-BDUBS8PW.css +0 -1
  175. 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 }; pinnedMode: "first-party" | "gateway" | undefined };
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
- const pinnedMode = body.cliFirstParty && persisted.claudeCode?.desktopMode === undefined
1577
- ? resolveClaudeDesktopMode(before, observeClaudeDesktopMode(before)) : undefined;
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
- return { changed: true, value: { claudeCode: structuredClone(persisted.claudeCode), previous, pinnedMode } };
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, warnings: residual ? ["settings_residual"] : [] });
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
- lastResort ? { ...target, lastResort: true } : target,
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 && body.compactionRecovery === undefined) {
483
- return jsonResponse({ error: "provide codexAutoStart, streamMode, appOwnedMemoryBudgetMb, codexAccountPickerEnabled, codexQuotaAutoRefresh, oauthOpenBrowser, ultraFastTier, fastRows, codexMainAccountHardLock, codexDesktopAuthless, codexClientCompaction, compactionRouting, or compactionRecovery" }, 400);
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
- const ALLOWED_INPUT_MODALITIES = new Set(["text", "image", "audio"]);
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?: string[]; error?: string } {
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 string[] };
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 Raycast cannot supply.",
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); }