@bitkyc08/opencodex 2.58.0 → 2.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (303) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-BTuCbqQd.css +1 -0
  3. package/gui/dist/assets/index-DoBVdPHP.js +134 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +4 -1
  8. package/src/adapters/anthropic-image-codec.ts +16 -2
  9. package/src/adapters/anthropic-image-normalize.ts +49 -2
  10. package/src/adapters/anthropic.ts +4 -1
  11. package/src/adapters/base.ts +34 -1
  12. package/src/adapters/coding-agent/turn.ts +22 -2
  13. package/src/adapters/command-code.ts +50 -3
  14. package/src/adapters/cursor/catalog.ts +11 -0
  15. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  16. package/src/adapters/cursor/discovery.ts +11 -8
  17. package/src/adapters/cursor/effort-map.ts +16 -2
  18. package/src/adapters/cursor/envelope-echo.ts +55 -2
  19. package/src/adapters/cursor/live-transport.ts +26 -9
  20. package/src/adapters/cursor/message-mapper.ts +3 -2
  21. package/src/adapters/cursor/protobuf-request.ts +8 -5
  22. package/src/adapters/cursor/request-builder.ts +21 -4
  23. package/src/adapters/cursor/thread-continuity.ts +105 -31
  24. package/src/adapters/cursor/tool-guidance.ts +5 -4
  25. package/src/adapters/cursor/transport.ts +19 -0
  26. package/src/adapters/cursor.ts +45 -2
  27. package/src/adapters/devin/cloud-direct/chat.ts +14 -3
  28. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  29. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
  30. package/src/adapters/devin.ts +125 -26
  31. package/src/adapters/google-antigravity-replay.ts +1 -1
  32. package/src/adapters/google-antigravity-wire.ts +55 -4
  33. package/src/adapters/google-http.ts +57 -11
  34. package/src/adapters/google-tool-schema.ts +595 -31
  35. package/src/adapters/google-wire-compiler.ts +93 -10
  36. package/src/adapters/google-wire-shape.ts +461 -0
  37. package/src/adapters/google.ts +60 -10
  38. package/src/adapters/openai-chat/response-events.ts +61 -0
  39. package/src/adapters/openai-chat-images.ts +3 -1
  40. package/src/adapters/openai-chat.ts +10 -11
  41. package/src/adapters/openai-responses/image-gen.ts +8 -6
  42. package/src/adapters/openai-responses/passthrough.ts +25 -4
  43. package/src/adapters/openai-responses/reasoning.ts +7 -0
  44. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  45. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  46. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  47. package/src/adapters/responses-tool-schema.ts +76 -46
  48. package/src/adapters/run-turn-queue.ts +17 -4
  49. package/src/bridge/response-json.ts +1 -1
  50. package/src/bridge/sse.ts +28 -35
  51. package/src/claude/context-windows.ts +22 -0
  52. package/src/claude/outbound.ts +35 -4
  53. package/src/cli/account-api.ts +4 -3
  54. package/src/cli/account-extended.ts +26 -6
  55. package/src/cli/account-orca-import.ts +63 -0
  56. package/src/cli/account.ts +32 -4
  57. package/src/cli/capabilities.ts +40 -0
  58. package/src/cli/claude.ts +29 -1
  59. package/src/cli/codex-cli-update.ts +97 -2
  60. package/src/cli/dispatch.ts +57 -3
  61. package/src/cli/doctor.ts +218 -4
  62. package/src/cli/help.ts +4 -1
  63. package/src/cli/hub.ts +3 -2
  64. package/src/cli/index.ts +95 -22
  65. package/src/cli/models-runtime.ts +33 -4
  66. package/src/cli/opencode.ts +2 -2
  67. package/src/cli/provider.ts +13 -1
  68. package/src/cli/registry.ts +11 -1
  69. package/src/cli/runtime-api.ts +44 -0
  70. package/src/cli/start-args.ts +94 -0
  71. package/src/cli/system-command.ts +2 -0
  72. package/src/client/machine-api.ts +6 -5
  73. package/src/client/machine-listener.ts +16 -3
  74. package/src/client/runtime.ts +26 -2
  75. package/src/clients/config-export/constants.ts +2 -3
  76. package/src/clients/config-export.ts +5 -5
  77. package/src/codex/account-store.ts +146 -5
  78. package/src/codex/auth-api/account-list.ts +19 -11
  79. package/src/codex/auth-api/pool-quota-probe.ts +44 -10
  80. package/src/codex/auth-api/routes.ts +17 -2
  81. package/src/codex/auth-context.ts +16 -12
  82. package/src/codex/catalog/build-entries.ts +25 -4
  83. package/src/codex/catalog/derive-entry.ts +8 -1
  84. package/src/codex/catalog/effort.ts +10 -6
  85. package/src/codex/catalog/gather-capture.ts +22 -2
  86. package/src/codex/catalog/model-hints.ts +66 -33
  87. package/src/codex/catalog/parsing.ts +90 -5
  88. package/src/codex/catalog/provider-models.ts +19 -2
  89. package/src/codex/catalog/reserve-warn.ts +96 -0
  90. package/src/codex/catalog/retained-sync.ts +41 -26
  91. package/src/codex/catalog/routed-gather.ts +61 -3
  92. package/src/codex/cli-installation-identity.ts +210 -0
  93. package/src/codex/cli-installation-targets.ts +158 -0
  94. package/src/codex/context-compat.ts +5 -2
  95. package/src/codex/convergence.ts +5 -0
  96. package/src/codex/desired-state.ts +4 -1
  97. package/src/codex/history-job.ts +6 -6
  98. package/src/codex/history-provider.ts +24 -167
  99. package/src/codex/history-rollout-read.ts +174 -0
  100. package/src/codex/history-state-open.ts +105 -0
  101. package/src/codex/inject/config-toml.ts +44 -2
  102. package/src/codex/inject.ts +3 -2
  103. package/src/codex/internal/catalog-writer.ts +33 -1
  104. package/src/codex/lineage.ts +83 -32
  105. package/src/codex/loopback-target.ts +31 -0
  106. package/src/codex/main-account-hard-lock.ts +2 -1
  107. package/src/codex/main-account.ts +10 -3
  108. package/src/codex/main-device-reauth.ts +17 -9
  109. package/src/codex/model-cache.ts +47 -0
  110. package/src/codex/model-entitlements.ts +80 -2
  111. package/src/codex/observed-model-denials.ts +230 -0
  112. package/src/codex/orca-auth-source.ts +94 -0
  113. package/src/codex/orca-import.ts +219 -0
  114. package/src/codex/prompt-text-probe.ts +289 -16
  115. package/src/codex/quota-401-recovery.ts +12 -0
  116. package/src/codex/quota-types.ts +65 -0
  117. package/src/codex/quota.ts +24 -19
  118. package/src/codex/routing/cooldown-math.ts +8 -47
  119. package/src/codex/routing/pin-drain.ts +57 -0
  120. package/src/codex/routing.ts +20 -16
  121. package/src/codex/shim.ts +1 -1
  122. package/src/codex/subagent-model-fallback.ts +114 -2
  123. package/src/codex/windows-installation-files.ts +224 -0
  124. package/src/combos/failover.ts +125 -5
  125. package/src/config/admitted-identity.ts +222 -0
  126. package/src/config/diagnostics.ts +43 -1
  127. package/src/config/feature-flags.ts +5 -0
  128. package/src/config/load-degrade.ts +33 -0
  129. package/src/config/pending-teardown.ts +8 -0
  130. package/src/config/process-state.ts +36 -3
  131. package/src/config/provider-relative-send-path.ts +16 -0
  132. package/src/config/proxy-env.ts +31 -7
  133. package/src/config/schema/compaction-triggers.ts +11 -0
  134. package/src/config/schema/config-schema.ts +25 -0
  135. package/src/config/schema/leaf-validators.ts +76 -17
  136. package/src/config.ts +2 -2
  137. package/src/generated/compatibility-version.json +410 -258
  138. package/src/generated/model-metadata.ts +1 -1
  139. package/src/grok/reset-coupons.ts +38 -19
  140. package/src/images/loop.ts +6 -1
  141. package/src/integrations/aside-profile-context.ts +37 -3
  142. package/src/integrations/aside-profile-journal.ts +68 -3
  143. package/src/integrations/aside-profiles.ts +128 -3
  144. package/src/integrations/mutation-plan.ts +815 -0
  145. package/src/integrations/writer.ts +85 -99
  146. package/src/lab/live/transport.ts +4 -0
  147. package/src/lab/live/types.ts +5 -0
  148. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  149. package/src/lib/admin-secrets.ts +9 -1
  150. package/src/lib/bounded-body.ts +4 -2
  151. package/src/lib/debug-log-buffer.ts +6 -1
  152. package/src/lib/debug.ts +23 -0
  153. package/src/lib/destination-policy.ts +48 -6
  154. package/src/lib/errors.ts +82 -15
  155. package/src/lib/http-response-semantics.ts +57 -0
  156. package/src/lib/lab-live-pinned-sender.ts +26 -12
  157. package/src/lib/local-destinations.ts +32 -5
  158. package/src/lib/pinned-http.ts +142 -2
  159. package/src/lib/plain-data.ts +103 -0
  160. package/src/lib/process-control.ts +13 -5
  161. package/src/lib/provider-outbound.ts +53 -5
  162. package/src/lib/proxy-env.ts +70 -3
  163. package/src/lib/request-execution-budget.ts +11 -3
  164. package/src/lib/response-body-inactivity.ts +193 -0
  165. package/src/lib/retry-delay.ts +69 -0
  166. package/src/lib/socks5-fetch.ts +741 -0
  167. package/src/lib/spend-ledger-owner.ts +364 -0
  168. package/src/lib/spend-reservation-ledger.ts +332 -35
  169. package/src/lib/windows-system-proxy.ts +16 -11
  170. package/src/lib/workflow-budget.ts +145 -8
  171. package/src/oauth/account-quota-rank.ts +72 -15
  172. package/src/oauth/callback-server.ts +4 -3
  173. package/src/oauth/generic-account-failover.ts +41 -27
  174. package/src/oauth/health.ts +12 -1
  175. package/src/oauth/index.ts +3 -107
  176. package/src/oauth/login-flow-state.ts +127 -0
  177. package/src/oauth/orcarouter.ts +15 -2
  178. package/src/oauth/store.ts +8 -0
  179. package/src/providers/codex-capacity.ts +9 -0
  180. package/src/providers/derive.ts +34 -17
  181. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  182. package/src/providers/devin-provider-merge-migration.ts +33 -12
  183. package/src/providers/free-directory.ts +20 -2
  184. package/src/providers/key-failover.ts +327 -25
  185. package/src/providers/model-rename-migration.ts +56 -1
  186. package/src/providers/model-rename-startup.ts +7 -5
  187. package/src/providers/openai-sidecar.ts +4 -0
  188. package/src/providers/openai-virtual-models.ts +42 -2
  189. package/src/providers/opencode-go-transport.ts +14 -5
  190. package/src/providers/quota/antigravity.ts +22 -2
  191. package/src/providers/quota/report-cache.ts +3 -0
  192. package/src/providers/quota/vendor-probes-key.ts +1 -1
  193. package/src/providers/registry/entries-core.ts +39 -17
  194. package/src/providers/registry/entries-extended.ts +131 -1
  195. package/src/providers/registry/model-ids.ts +168 -0
  196. package/src/providers/registry/model-seeds.ts +87 -21
  197. package/src/providers/registry/types.ts +2 -0
  198. package/src/providers/resolved-model-policy-merge.ts +167 -0
  199. package/src/providers/resolved-model-policy.ts +406 -0
  200. package/src/providers/stale-vision-classification-migration.ts +137 -0
  201. package/src/responses/apply-patch-envelope.ts +32 -11
  202. package/src/responses/bridge-search-replay-cache.ts +152 -0
  203. package/src/responses/code-mode-helper-compat.ts +26 -16
  204. package/src/responses/custom-tool-compat.ts +1 -1
  205. package/src/responses/freeform-wrapper-scan.ts +279 -0
  206. package/src/responses/hosted-tool-policy.ts +85 -2
  207. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  208. package/src/responses/progressive-freeform-input.ts +130 -0
  209. package/src/responses/reasoning-envelope.ts +30 -0
  210. package/src/responses/schema.ts +9 -2
  211. package/src/responses/state.ts +5 -12
  212. package/src/responses/tool-name-aliases.ts +15 -1
  213. package/src/router.ts +91 -115
  214. package/src/routing/compatibility/behavior.ts +9 -0
  215. package/src/routing/compatibility/subject.ts +16 -1
  216. package/src/server/adapter-resolve.ts +9 -0
  217. package/src/server/auth-cors.ts +29 -0
  218. package/src/server/chat-completions.ts +13 -5
  219. package/src/server/chat-native-sse.ts +26 -9
  220. package/src/server/chat-native.ts +10 -4
  221. package/src/server/claude-messages.ts +30 -5
  222. package/src/server/effort-row.ts +11 -3
  223. package/src/server/grok-responses-control-frame.ts +160 -1
  224. package/src/server/gui-static.ts +36 -2
  225. package/src/server/inbound-body-admission.ts +187 -0
  226. package/src/server/index/serve-options.ts +86 -29
  227. package/src/server/index/spend-ledger-lifecycle.ts +66 -0
  228. package/src/server/index/websocket-handler.ts +6 -1
  229. package/src/server/index.ts +24 -25
  230. package/src/server/management/api-access.ts +3 -4
  231. package/src/server/management/aside-profile-routes.ts +266 -7
  232. package/src/server/management/config-routes.ts +48 -7
  233. package/src/server/management/context.ts +3 -0
  234. package/src/server/management/integration-routes.ts +287 -5
  235. package/src/server/management/metrics-routes.ts +20 -0
  236. package/src/server/management/model-rows.ts +224 -12
  237. package/src/server/management/provider-capability-config.ts +35 -7
  238. package/src/server/management/provider-routes.ts +70 -18
  239. package/src/server/management/route-registry.ts +14 -0
  240. package/src/server/management/shared.ts +10 -3
  241. package/src/server/management/system-restart.ts +7 -2
  242. package/src/server/management/system-routes.ts +2 -0
  243. package/src/server/management/usage-aggregate-cache.ts +4 -0
  244. package/src/server/management-api.ts +2 -0
  245. package/src/server/management-auth.ts +15 -1
  246. package/src/server/proxy-liveness.ts +97 -2
  247. package/src/server/readiness.ts +29 -10
  248. package/src/server/relay-eager.ts +24 -2
  249. package/src/server/relay.ts +136 -34
  250. package/src/server/request-log.ts +80 -3
  251. package/src/server/request-metrics.ts +236 -0
  252. package/src/server/responses/adapter-continuation.ts +74 -30
  253. package/src/server/responses/adapter-delivery.ts +39 -8
  254. package/src/server/responses/adapter-dispatch.ts +63 -30
  255. package/src/server/responses/compact.ts +89 -18
  256. package/src/server/responses/compaction-routing.ts +111 -0
  257. package/src/server/responses/core-codex-account.ts +90 -24
  258. package/src/server/responses/core-combo.ts +7 -7
  259. package/src/server/responses/core-normalize.ts +16 -15
  260. package/src/server/responses/core-opaque-recovery.ts +1 -0
  261. package/src/server/responses/core-options.ts +4 -0
  262. package/src/server/responses/encrypted-payload.ts +20 -2
  263. package/src/server/responses/fetch-helpers.ts +68 -2
  264. package/src/server/responses/passthrough-delivery.ts +41 -15
  265. package/src/server/responses/passthrough-dispatch.ts +145 -53
  266. package/src/server/responses/passthrough-execution.ts +11 -1
  267. package/src/server/responses/policy-fallback.ts +5 -13
  268. package/src/server/responses/request-prepare.ts +96 -17
  269. package/src/server/responses/request-send-budget.ts +89 -8
  270. package/src/server/responses/request-sidecar-auth.ts +17 -9
  271. package/src/server/responses/request-spend.ts +38 -9
  272. package/src/server/responses/request-transport.ts +15 -12
  273. package/src/server/responses/run-turn-execution.ts +45 -8
  274. package/src/server/responses/sidecar-execution.ts +19 -2
  275. package/src/server/responses/ws-upstream.ts +16 -28
  276. package/src/server/responses-custom-tool-repair.ts +29 -56
  277. package/src/server/responses-undeclared-tool-guard.ts +31 -1
  278. package/src/server/sse-frame-buffer.ts +12 -10
  279. package/src/server/sse-payload-rewrite.ts +37 -10
  280. package/src/server/system-env-shell.ts +5 -1
  281. package/src/server/system-env.ts +7 -1
  282. package/src/server/workflow-refusal.ts +56 -2
  283. package/src/service/cli.ts +16 -6
  284. package/src/service/guards.ts +10 -0
  285. package/src/service/health.ts +43 -0
  286. package/src/service/state.ts +7 -2
  287. package/src/tray/windows-tray.ps1 +155 -3
  288. package/src/types/accounts.ts +4 -0
  289. package/src/types/config.ts +111 -6
  290. package/src/types/provider.ts +37 -0
  291. package/src/types/request.ts +9 -1
  292. package/src/types/tools.ts +14 -0
  293. package/src/types/wire.ts +9 -1
  294. package/src/types.ts +1 -0
  295. package/src/usage/expected-prices.ts +28 -0
  296. package/src/usage/log.ts +87 -4
  297. package/src/vision/eligibility.ts +88 -9
  298. package/src/vision/plan.ts +34 -10
  299. package/src/web-search/executor.ts +41 -2
  300. package/src/web-search/loop.ts +6 -1
  301. package/src/web-search/passthrough-bridge.ts +39 -5
  302. package/gui/dist/assets/index-BbrHOIY0.js +0 -128
  303. package/gui/dist/assets/index-C5-RdDmD.css +0 -1
@@ -34,6 +34,7 @@ import {
34
34
  malformedQuotaResetNotifyWarning,
35
35
  malformedCatalogAutoRefreshWarning,
36
36
  malformedCodexPoolWarning,
37
+ malformedSpendWarning,
37
38
  rawConfigRecord,
38
39
  malformedNativeSubagentFields,
39
40
  malformedNativeSubagentFieldWarning,
@@ -58,6 +59,8 @@ import {
58
59
  quotaResetNotifySchema,
59
60
  remoteGuiConfigSchema,
60
61
  runtimeRoleSchema,
62
+ spendSchema,
63
+ compactionRoutingSchema,
61
64
  } from "./schema/leaf-validators";
62
65
 
63
66
  export type ConfigDiagnostics = {
@@ -122,6 +125,8 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf
122
125
  if (catalogRefreshWarning) warnings.push(catalogRefreshWarning);
123
126
  const codexPoolWarning = malformedCodexPoolWarning(rawParsed);
124
127
  if (codexPoolWarning) warnings.push(codexPoolWarning);
128
+ const spendWarning = malformedSpendWarning(rawParsed);
129
+ if (spendWarning) warnings.push(spendWarning);
125
130
  const plaintextWarning = malformedPlaintextV2AgentMessagesWarning(rawParsed);
126
131
  if (plaintextWarning) warnings.push(plaintextWarning);
127
132
  if (syncDisabledReason) {
@@ -295,6 +300,22 @@ function catalogAutoRefreshError(value: unknown): string | null {
295
300
  return `schema_invalid: catalogAutoRefresh${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`;
296
301
  }
297
302
 
303
+ /**
304
+ * The read path degrades a malformed spend section to undefined, which means no ceiling is
305
+ * enforced. Reject it on write so `ocx config set` cannot store a budget that reads back as
306
+ * configured and refuses nothing -- a ceiling that is not enforced looks identical to one
307
+ * nothing has reached.
308
+ */
309
+ function spendError(value: unknown): string | null {
310
+ const raw = rawConfigRecord(value);
311
+ if (!raw || !Object.hasOwn(raw, "spend") || raw.spend === undefined) return null;
312
+ const result = spendSchema.safeParse(raw.spend);
313
+ if (result.success) return null;
314
+ const issue = result.error.issues[0];
315
+ const field = issue?.path.join(".");
316
+ return `schema_invalid: spend${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`;
317
+ }
318
+
298
319
  /**
299
320
  * The read path degrades a malformed pool policy to undefined, which for an exclusion policy means
300
321
  * the excluded accounts quietly keep serving traffic. Reject it on write so `ocx config set` cannot
@@ -541,7 +562,26 @@ function managementIngressConfigError(value: unknown): string | null {
541
562
  return null;
542
563
  }
543
564
 
565
+ /** Load degrades malformed metrics export config to off; live writes reject the same shape. */
566
+ export function metricsExportConfigError(value: unknown): string | null {
567
+ const raw = rawConfigRecord(value);
568
+ if (!raw || !Object.hasOwn(raw, "metricsExport") || raw.metricsExport === undefined) return null;
569
+ const metricsExport = rawConfigRecord(raw.metricsExport);
570
+ if (!metricsExport) return "schema_invalid: metricsExport: must be an object or omitted";
571
+ if (Object.keys(metricsExport).some(key => key !== "enabled")) {
572
+ return "schema_invalid: metricsExport: contains an unsupported field";
573
+ }
574
+ if (metricsExport.enabled !== undefined && typeof metricsExport.enabled !== "boolean") {
575
+ return "schema_invalid: metricsExport.enabled: must be a boolean";
576
+ }
577
+ return null;
578
+ }
579
+
544
580
  export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } {
581
+ const compactionRouting = rawConfigRecord(value)?.compactionRouting;
582
+ if (compactionRouting !== undefined && !compactionRoutingSchema.safeParse(compactionRouting).success) {
583
+ return { ok: false, error: "schema_invalid: compactionRouting: requires a nonblank model, an optional valid reasoningEffort, and optional non-repeating triggers drawn from \"manual\" and \"auto\"" };
584
+ }
545
585
  const boundaryError = configReasoningPinsConfigError(value)
546
586
  ?? blankHostnameError(value)
547
587
  ?? claudeSubagentEffortError(value)
@@ -551,6 +591,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
551
591
  ?? agentTaskRecoveryError(value)
552
592
  ?? quotaResetNotifyError(value)
553
593
  ?? catalogAutoRefreshError(value)
594
+ ?? spendError(value)
554
595
  ?? codexPoolError(value)
555
596
  ?? googleAntigravityStaticCatalogVersionError(value)
556
597
  ?? codexAccountPrioritiesError(value)
@@ -565,7 +606,8 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
565
606
  ?? clientConnectionConfigError(value)
566
607
  ?? clientRolePairError(value)
567
608
  ?? loopbackListenerPortError(value)
568
- ?? managementIngressConfigError(value);
609
+ ?? managementIngressConfigError(value)
610
+ ?? metricsExportConfigError(value);
569
611
  if (boundaryError) return { ok: false, error: boundaryError };
570
612
  const result = configSchema.safeParse(value);
571
613
  if (result.success) {
@@ -12,6 +12,11 @@ export function ultraFastTierEnabled(config: Pick<OcxConfig, "ultraFastTier">):
12
12
  return config.ultraFastTier === true;
13
13
  }
14
14
 
15
+ /** Default-off aggregate request metrics; activation is fixed for one server process lifetime. */
16
+ export function metricsExportEnabled(config: Pick<OcxConfig, "metricsExport">): boolean {
17
+ return config.metricsExport?.enabled === true;
18
+ }
19
+
15
20
  /**
16
21
  * Default cadence for the opt-in catalog auto-refresh (issue #3630): one converge pass
17
22
  * per hour. Each pass spends a live /models call against every enabled provider, and
@@ -33,6 +33,7 @@ import {
33
33
  remoteGuiConfigSchema,
34
34
  retryOn429PolicySchema,
35
35
  runtimeRoleSchema,
36
+ spendSchema,
36
37
  } from "./schema/leaf-validators";
37
38
  import { hasWarnedInheritedFastWireConflict, markWarnedInheritedFastWireConflict } from "./warn-memo";
38
39
 
@@ -100,6 +101,24 @@ export function warnDegradedStreamMode(rawParsed: unknown, validated: OcxConfig)
100
101
  }
101
102
  }
102
103
 
104
+ export function warnDegradedCompactionRouting(rawParsed: unknown, validated: OcxConfig): void {
105
+ if (!rawParsed || typeof rawParsed !== "object") return;
106
+ const raw = (rawParsed as Record<string, unknown>).compactionRouting;
107
+ if (raw !== undefined && validated.compactionRouting === undefined) {
108
+ console.warn("⚠️ config.json compactionRouting is invalid (expected { model, reasoningEffort?, triggers? } with a nonblank model, a declared effort, and triggers drawn without repetition from \"manual\" and \"auto\") — compaction keeps the conversation model");
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Top-level opt-in blocks whose hand-edited form degrades to "off" instead of failing the whole
114
+ * schema. Grouped behind one entry point because `src/config.ts` sits at its file-size cap, and
115
+ * the ratchet only ever moves down: a per-block call there costs a line the file does not have.
116
+ */
117
+ export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxConfig): void {
118
+ warnDegradedStreamMode(rawParsed, validated);
119
+ warnDegradedCompactionRouting(rawParsed, validated);
120
+ }
121
+
103
122
  /**
104
123
  * Load-time degradation for `retryOn429` (loadConfig only): one hand-edited invalid optional
105
124
  * field (e.g. `attempts: 0` or a string) must not trip the whole provider schema and hide every
@@ -636,6 +655,20 @@ export function malformedCatalogAutoRefreshWarning(rawParsed: unknown): string |
636
655
  return `catalogAutoRefresh${field ? `.${field}` : ""} ignored: invalid catalog auto-refresh configuration`;
637
656
  }
638
657
 
658
+ /**
659
+ * The same silent-in-the-wrong-direction failure, and the most expensive instance of it here:
660
+ * a dropped spend section means the ceilings are not enforced, and an unenforced ceiling is
661
+ * indistinguishable from one nothing has reached. The operator finds out from the bill.
662
+ */
663
+ export function malformedSpendWarning(rawParsed: unknown): string | null {
664
+ const raw = rawConfigRecord(rawParsed);
665
+ if (!raw || !Object.hasOwn(raw, "spend")) return null;
666
+ const result = spendSchema.safeParse(raw.spend);
667
+ if (result.success) return null;
668
+ const field = result.error.issues[0]?.path.join(".");
669
+ return `spend${field ? `.${field}` : ""} ignored: invalid spend ceiling configuration, so no token ceiling is enforced`;
670
+ }
671
+
639
672
  /**
640
673
  * Same silent-in-the-wrong-direction failure as the notification block: a dropped pool policy means
641
674
  * the accounts the operator meant to exclude keep taking traffic, and the only visible symptom is
@@ -295,6 +295,14 @@ export function quarantinePendingTeardown(nonce: string): string | null {
295
295
  * alone. Only an abandoned obligation is a candidate, and a VALID one still has to prove
296
296
  * its endpoint is down before anything is restored — an invalid one never can, which is
297
297
  * what {@link quarantinePendingTeardown} exists for.
298
+ *
299
+ * `isAlive` must answer "is the OWNER still running", which is not "is this PID alive".
300
+ * PIDs are reused: after the owner exits its number can be handed to an unrelated process,
301
+ * and a bare liveness probe then reports the owner as running for as long as that process
302
+ * lives. The receipt is neither recovered nor quarantined nor reported while both updater
303
+ * gates keep refusing on it, which is a permanent fail-closed with no way forward (#4897).
304
+ * `handleStop` therefore passes an identity-aware predicate; do not substitute a bare
305
+ * `process.kill(pid, 0)` here for cheapness.
298
306
  */
299
307
  export function isPendingTeardownAbandoned(
300
308
  read: PendingTeardownRead | TeardownScanFailure,
@@ -110,16 +110,29 @@ export function removeRuntimePortIfPidIs(snapshotPid: number | null): void {
110
110
  try { unlinkSync(getRuntimePortPath()); } catch { /* ignore */ }
111
111
  }
112
112
 
113
- export function isOcxStartCommandLine(commandLine: string): boolean {
113
+ /**
114
+ * Does this command line belong to an opencodex process at all, whatever it is doing?
115
+ *
116
+ * {@link isOcxStartCommandLine} answers the narrower question the pidfile needs — "is this
117
+ * the proxy?" — by additionally requiring the `start` verb. Ownership of a pending-teardown
118
+ * receipt is the broader question: the owner is whichever invocation claimed it, which is an
119
+ * `ocx stop` or the `ocx update` worker that drove it, never an `ocx start`. Asking the
120
+ * start-shaped question there would call every real owner foreign.
121
+ */
122
+ export function isOcxCommandLine(commandLine: string): boolean {
114
123
  const normalized = commandLine.toLowerCase().replace(/\\/g, "/");
115
124
  // Keep legacy source launches and npm's in-place Windows rename recognizable:
116
125
  // a service wrapper may respawn from `.opencodex-*` during a global update.
117
- const hasOcxEntrypoint = normalized.includes("src/cli.ts")
126
+ return normalized.includes("src/cli.ts")
118
127
  || normalized.includes("src/cli/index.ts")
119
128
  || normalized.includes("@bitkyc08/opencodex")
120
129
  || /@bitkyc08\/\.opencodex-/.test(normalized)
121
130
  || /(?:^|[\s/"'])(?:ocx|opencodex)(?:\.cmd)?(?:$|[\s"'])/.test(normalized);
122
- return hasOcxEntrypoint && /(?:^|[\s"'])start(?:$|[\s"'])/.test(normalized);
131
+ }
132
+
133
+ export function isOcxStartCommandLine(commandLine: string): boolean {
134
+ const normalized = commandLine.toLowerCase().replace(/\\/g, "/");
135
+ return isOcxCommandLine(commandLine) && /(?:^|[\s"'])start(?:$|[\s"'])/.test(normalized);
123
136
  }
124
137
 
125
138
  /** Avoid spawning WMIC/PowerShell on every short liveness poll. */
@@ -227,6 +240,26 @@ export function verifyPidIdentity(candidatePid: number): number | null {
227
240
  return isLikelyOcxStartProcess(candidatePid) ? candidatePid : null;
228
241
  }
229
242
 
243
+ /**
244
+ * Is this PID an opencodex process, rather than merely a live one?
245
+ *
246
+ * `process.kill(pid, 0)` answers "does this number name a process", which is not the same
247
+ * question. PIDs are reused, so a recorded owner that exited can have its number handed to
248
+ * an unrelated process, and bare liveness then reports the owner as still running forever.
249
+ *
250
+ * Deliberately uncached, unlike {@link isLikelyOcxStartProcess}. That cache exists because
251
+ * liveness polling asks about the same PID many times a second; this is asked once per
252
+ * outstanding teardown receipt in a short-lived `ocx stop`, so the cache would only add an
253
+ * unswept map. The Windows WMIC/PowerShell probe cost is the point rather than a regression:
254
+ * the runtime contract requires the identity check, not the cheap one, before acting on
255
+ * stale state.
256
+ */
257
+ export function isLikelyOcxProcess(pid: number): boolean {
258
+ const commandLine = readProcessCommandLine(pid);
259
+ if (commandLine === undefined) return false;
260
+ return isOcxCommandLine(commandLine);
261
+ }
262
+
230
263
  type ProcessCommandLineExec = (
231
264
  executable: string,
232
265
  args: string[],
@@ -0,0 +1,16 @@
1
+ /** Shared, initialization-independent shape check for provider send-path overrides. */
2
+ export function providerRelativeSendPathConfigError(
3
+ field: "responsesPath" | "chatCompletionsPath",
4
+ value: unknown,
5
+ ): string | null {
6
+ if (value === undefined) return null;
7
+ if (typeof value !== "string") return `${field} must be a string`;
8
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) || value.includes("://")) {
9
+ return `${field} must be a relative path without a URL scheme`;
10
+ }
11
+ if (!value.startsWith("/")) return `${field} must start with /`;
12
+ if (value.includes("?") || value.includes("#")) {
13
+ return `${field} must not include query strings or fragments`;
14
+ }
15
+ return null;
16
+ }
@@ -1,3 +1,5 @@
1
+ import { configureSocks5Fetch } from "../lib/proxy-env";
2
+ import { redactUrlForLog } from "../lib/redact";
1
3
  import { join } from "node:path";
2
4
  import { DEFAULT_SUBAGENT_MODELS, SUBAGENT_MODELS_VERSION } from "./subagent-models";
3
5
  import { MULTI_AGENT_SURFACE_ADVISORY_VERSION } from "./multi-agent-surface";
@@ -102,10 +104,15 @@ function warnProxyConfigDiscardOnce(kind: "proxy" | "noProxy" | "noProxyElements
102
104
  * variables win; config fills missing scheme proxies, which take precedence over ALL_PROXY for WS.
103
105
  * localhost/127.0.0.1 are appended to NO_PROXY so the CLI's own health checks and
104
106
  * running-proxy API calls stay direct. Call once per process entry that makes outbound provider
105
- * requests (server start, catalog sync).
107
+ * requests (server start, catalog sync). `announce` prints the one-line startup banner naming
108
+ * the outbound proxy actually in effect; only server start passes it, because on catalog sync
109
+ * that line is noise. The URL is redacted because a proxy URL can carry credentials.
106
110
  */
107
- export function applyProxyEnv(config: OcxConfig): void {
111
+ export function applyProxyEnv(config: OcxConfig, announce = false): void {
108
112
  applyProxyEnvWith(config);
113
+ if (!announce) return;
114
+ const outbound = process.env.ALL_PROXY?.trim() || process.env.HTTPS_PROXY?.trim() || process.env.HTTP_PROXY?.trim();
115
+ if (outbound) console.log(` outbound proxy: ${redactUrlForLog(outbound)}`);
109
116
  }
110
117
 
111
118
  /** Test seam for `proxy: "auto"`: the registry reader and platform are injectable. */
@@ -123,6 +130,7 @@ export function applyProxyEnvWith(
123
130
  let proxy = typeof rawProxy === "string" ? resolveEnvValue(rawProxy) : undefined;
124
131
  if (!proxy) {
125
132
  if (rawProxy !== undefined) warnProxyConfigDiscardOnce("proxy");
133
+ configureSocks5Fetch();
126
134
  return;
127
135
  }
128
136
  if (proxy.trim().toLowerCase() === "auto") {
@@ -135,8 +143,14 @@ export function applyProxyEnvWith(
135
143
  } else {
136
144
  const found = readWindowsSystemProxy(auto.reader, auto.platform);
137
145
  if (found.kind === "proxy") {
138
- console.log(`[opencodex] proxy "auto": using Windows system proxy ${describeProxyForLog(found.url)}`);
139
- proxy = found.url;
146
+ const origins = [
147
+ found.httpUrl && `HTTP ${describeProxyForLog(found.httpUrl)}`,
148
+ found.httpsUrl && `HTTPS ${describeProxyForLog(found.httpsUrl)}`,
149
+ ].filter(Boolean).join(", ");
150
+ console.log(`[opencodex] proxy "auto": using Windows system proxy ${origins}`);
151
+ if (found.httpUrl) process.env.HTTP_PROXY = found.httpUrl;
152
+ if (found.httpsUrl) process.env.HTTPS_PROXY = found.httpsUrl;
153
+ proxy = undefined;
140
154
  } else {
141
155
  const reason = found.kind === "unsupported"
142
156
  ? "only Windows system proxy discovery is supported; using direct egress on this OS"
@@ -151,8 +165,18 @@ export function applyProxyEnvWith(
151
165
  }
152
166
  }
153
167
  if (proxy) {
154
- if (!process.env.HTTP_PROXY?.trim() && !process.env.http_proxy?.trim()) process.env.HTTP_PROXY = proxy;
155
- if (!process.env.HTTPS_PROXY?.trim() && !process.env.https_proxy?.trim()) process.env.HTTPS_PROXY = proxy;
168
+ if (/^socks/i.test(proxy.trim()) && !/^socks5h?:\/\//i.test(proxy.trim())) {
169
+ throw new Error("Only SOCKS5 proxy URLs are supported; use socks5://host:port");
170
+ }
171
+ if (/^socks5h?:\/\//i.test(proxy.trim())) {
172
+ for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy"] as const) {
173
+ delete process.env[key];
174
+ }
175
+ process.env.ALL_PROXY = proxy;
176
+ } else {
177
+ if (!process.env.HTTP_PROXY?.trim() && !process.env.http_proxy?.trim()) process.env.HTTP_PROXY = proxy;
178
+ if (!process.env.HTTPS_PROXY?.trim() && !process.env.https_proxy?.trim()) process.env.HTTPS_PROXY = proxy;
179
+ }
156
180
  }
157
181
  const existing = process.env.NO_PROXY ?? process.env.no_proxy ?? "";
158
182
  const entries = existing.split(",").map(s => s.trim()).filter(Boolean);
@@ -184,5 +208,5 @@ export function applyProxyEnvWith(
184
208
  }
185
209
  }
186
210
  process.env.NO_PROXY = entries.join(",");
211
+ configureSocks5Fetch();
187
212
  }
188
-
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The `compaction.trigger` values Codex labels a compaction turn with (`CompactionTrigger`
3
+ * in codex-rs: a manual `/compact` versus automatic compaction). `compactionRouting.triggers`
4
+ * selects which of them an override applies to.
5
+ *
6
+ * This is its own leaf module and imports nothing on purpose. The request path reads it from
7
+ * `src/server/responses/compaction-routing.ts`; importing `leaf-validators.ts` there instead
8
+ * closes a cycle with `config-schema.ts` that survives typecheck and then fails at run time
9
+ * with "Cannot access 'runtimeRoleSchema' before initialization".
10
+ */
11
+ export const COMPACTION_TRIGGERS = ["manual", "auto"] as const;
@@ -13,6 +13,7 @@ import {
13
13
  quotaResetNotifySchema,
14
14
  remoteGuiConfigSchema,
15
15
  runtimeRoleSchema,
16
+ spendSchema,
16
17
  configuredCodexPoolAccountIds,
17
18
  apiKeyEntrySchema,
18
19
  asideProfileSyncSchema,
@@ -20,6 +21,7 @@ import {
20
21
  CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR,
21
22
  codexAccountNamespacesSchema,
22
23
  modelPinnedEffortsSchema,
24
+ compactionRoutingSchema,
23
25
  modelPreferHostedToolsConfigError,
24
26
  providerModelCostsConfigError,
25
27
  providerRelativeSendPathConfigError,
@@ -72,6 +74,8 @@ export const configSchema = z.object({
72
74
  // A malformed privacy block must never be read as "unmask": .catch(undefined) drops it and
73
75
  // emailMaskingEnabled then falls back to masked, which is also what an absent block means.
74
76
  privacy: z.object({ maskEmails: z.boolean().optional() }).strict().optional().catch(undefined),
77
+ // Malformed hand edits disable this opt-in exporter. Live writes reject them in diagnostics.ts.
78
+ metricsExport: z.object({ enabled: z.boolean().optional() }).strict().optional().catch(undefined),
75
79
  // A malformed present client block must remain diagnosable from raw config and
76
80
  // fail closed through src/client/state.ts; unrelated provider state still loads.
77
81
  client: clientConnectionSchema.optional().catch(undefined),
@@ -124,6 +128,7 @@ export const configSchema = z.object({
124
128
  ]).optional().catch(undefined),
125
129
  providers: z.record(z.string(), providerConfigSchema),
126
130
  modelPinnedEfforts: modelPinnedEffortsSchema.optional(),
131
+ compactionRouting: compactionRoutingSchema.optional().catch(undefined),
127
132
  defaultProvider: z.string().min(1).default("openai"),
128
133
  defaultModelAliases: z.boolean().optional(),
129
134
  // Malformed hand edits disable this opt-in projection without rejecting providers.
@@ -166,6 +171,11 @@ export const configSchema = z.object({
166
171
  quotaResetNotify: quotaResetNotifySchema.optional().catch(undefined),
167
172
  // Same rationale: a bad auto-refresh section must not cost the operator their providers.
168
173
  catalogAutoRefresh: catalogAutoRefreshSchema.optional().catch(undefined),
174
+ // Same rationale again, with the failure direction stated: a malformed spend section
175
+ // degrades to "no ceiling", which means observe-only accounting rather than an outage. That
176
+ // is the safe degrade for traffic and the dangerous one for the operator, so the write path
177
+ // rejects it and loadConfig warns -- the same pair codexPool uses below.
178
+ spend: spendSchema.optional().catch(undefined),
169
179
  // These selections pre-date schema validation and used to pass through as
170
180
  // unknown fields. Invalid hand edits must disable only the optional
171
181
  // delegation/native-default feature, not reject the whole config and hide
@@ -182,6 +192,10 @@ export const configSchema = z.object({
182
192
  codexShimAutoRestore: z.boolean().optional(),
183
193
  codexDesktopAuthless: z.boolean().optional().catch(undefined),
184
194
  codexClientCompaction: z.boolean().optional().catch(undefined),
195
+ // Presentation-only label for the injected provider. A malformed value degrades to undefined
196
+ // and the default label is emitted, rather than failing the parse or writing a config Codex
197
+ // would refuse to load — the provider id routing depends on is never derived from it.
198
+ codexProviderDisplayName: z.string().trim().min(1).max(128).optional().catch(undefined),
185
199
  pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(),
186
200
  // A malformed policy degrades to "no policy" rather than failing the parse, so a hand-edited
187
201
  // typo cannot trip the backup-and-defaults repair path and wipe providers or pool accounts.
@@ -464,6 +478,17 @@ export const configSchema = z.object({
464
478
  message: reasoningSummariesError,
465
479
  });
466
480
  }
481
+ const suppressSyntheticMaxError = booleanRecordConfigError(
482
+ (provider as { modelSuppressSyntheticMax?: unknown }).modelSuppressSyntheticMax,
483
+ "modelSuppressSyntheticMax",
484
+ );
485
+ if (suppressSyntheticMaxError) {
486
+ ctx.addIssue({
487
+ code: "custom",
488
+ path: ["providers", redactSecretString(name), "modelSuppressSyntheticMax"],
489
+ message: suppressSyntheticMaxError,
490
+ });
491
+ }
467
492
  const verbositySupportError = booleanRecordConfigError(
468
493
  (provider as { modelSupportsVerbosity?: unknown }).modelSupportsVerbosity,
469
494
  "modelSupportsVerbosity",
@@ -32,14 +32,27 @@ import { getProviderRegistryEntry, providerMatchesRegistryTransport, providerMod
32
32
  import { resolveOpenAiVirtualModel } from "../../providers/openai-virtual-models";
33
33
  import { COST4_RATE_KEYS, isValidCost4Rate } from "../../usage/user-cost-overlays";
34
34
  import { MAX_COST4_RATE } from "../../usage/expected-prices";
35
- import { isHostedToolUnsupportedForModel } from "../../responses/hosted-tool-policy";
35
+ import {
36
+ DECLARABLE_HOSTED_TOOL_TYPES,
37
+ declaredUnsupportedHostedTools,
38
+ isHostedToolUnsupportedForModel,
39
+ } from "../../responses/hosted-tool-policy";
36
40
  import { getConfigDir } from "../paths";
41
+ import { COMPACTION_TRIGGERS } from "./compaction-triggers";
37
42
 
38
43
  /** One definition of "usable secret", shared by the schema and the warnings. */
39
44
  export function isUsableApiKeySecret(value: unknown): value is string {
40
45
  return typeof value === "string" && value.length > 0 && value === value.trim();
41
46
  }
42
47
 
48
+ export const compactionRoutingSchema = z.object({
49
+ model: z.string().trim().min(1),
50
+ reasoningEffort: z.string().refine(value => pinnedReasoningEffortConfigError(value) === null).optional(),
51
+ triggers: z.array(z.enum(COMPACTION_TRIGGERS)).nonempty()
52
+ .refine(values => new Set(values).size === values.length, "triggers must not repeat a value")
53
+ .optional(),
54
+ }).strict();
55
+
43
56
  /**
44
57
  * Bounds for the opt-in same-target 429 wait-and-retry policy. Single source of truth
45
58
  * shared by the config schema, the load-time sanitizer, and the management write
@@ -248,7 +261,10 @@ export const providerConfigSchema = z.object({
248
261
  fastWire: fastWireSchema.nullable().optional(),
249
262
  supportsServiceTier: z.boolean().optional(),
250
263
  modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(),
264
+ modelSuppressSyntheticMax: z.record(z.string().min(1), z.boolean()).optional(),
251
265
  preserveResponsesReasoningContent: z.boolean().optional(),
266
+ dropResponsesReasoningItems: z.boolean().optional(),
267
+ modelReasoningEffortsAuthoritative: z.boolean().optional(),
252
268
  decodesNativeCompactionBlobs: z.boolean().optional(),
253
269
  allowEncryptedV2AgentTasks: z.boolean().optional(),
254
270
  allowPrivateNetwork: z.boolean().optional(),
@@ -263,6 +279,7 @@ export const providerConfigSchema = z.object({
263
279
  // canonical ChatGPT backend WS selection is independent of this flag.
264
280
  upstreamWebsocket: z.boolean().optional(),
265
281
  directGeminiWireRenames: z.boolean().optional(),
282
+ googleToolSchemaPolicy: z.enum(["compatible", "reject-lossy"]).optional(),
266
283
  noStructuredOutputModels: z.array(z.string().min(1))
267
284
  .transform(normalizeNonBlankStringArray)
268
285
  .optional(),
@@ -275,6 +292,18 @@ export const providerConfigSchema = z.object({
275
292
  omitReasoningEffortWithToolsModels: z.array(z.string().min(1))
276
293
  .transform(normalizeNonBlankStringArray)
277
294
  .optional(),
295
+ // Validated against a closed vocabulary rather than accepted as free strings. This
296
+ // schema ends in `.passthrough()`, so a misspelled `web_serch` would otherwise be
297
+ // accepted, persisted, and strip nothing -- leaving the operator with the upstream 400
298
+ // the field was set to prevent, and no message saying why (the `codexToolMode` lesson,
299
+ // #2106).
300
+ unsupportedHostedTools: z.array(z.string().min(1))
301
+ .transform(normalizeNonBlankStringArray)
302
+ .refine(
303
+ tools => tools.every(tool => DECLARABLE_HOSTED_TOOL_TYPES.has(tool)),
304
+ { message: `unsupportedHostedTools accepts only hosted tool types: ${[...DECLARABLE_HOSTED_TOOL_TYPES].join(", ")}` },
305
+ )
306
+ .optional(),
278
307
  retryOn429: retryOn429PolicySchema.optional(),
279
308
  transientRetryOn5xx: transientRetryOn5xxPolicySchema.optional(),
280
309
  codexAccountMode: z.enum(["pool", "direct"]).optional(),
@@ -300,21 +329,7 @@ export const providerConfigSchema = z.object({
300
329
  }).passthrough();
301
330
 
302
331
 
303
- /**
304
- * Shared shape check for the two relative send-path overrides. `field` names the
305
- * offending key so the message stays specific to what the user actually wrote.
306
- */
307
- export function providerRelativeSendPathConfigError(field: string, value: string | undefined): string | null {
308
- if (value === undefined) return null;
309
- if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) || value.includes("://")) {
310
- return `${field} must be a relative path without a URL scheme`;
311
- }
312
- if (!value.startsWith("/")) return `${field} must start with /`;
313
- if (value.includes("?") || value.includes("#")) {
314
- return `${field} must not include query strings or fragments`;
315
- }
316
- return null;
317
- }
332
+ export { providerRelativeSendPathConfigError } from "../provider-relative-send-path";
318
333
 
319
334
  /**
320
335
  * Validate `providers.<name>.modelCosts`: a plain object keyed by exact model
@@ -391,7 +406,13 @@ export function modelPreferHostedToolsConfigError(
391
406
  value: unknown,
392
407
  field: string,
393
408
  providerName: string,
394
- provider: { adapter?: unknown; authMode?: unknown; modelAdapters?: unknown; baseUrl?: unknown },
409
+ provider: {
410
+ adapter?: unknown;
411
+ authMode?: unknown;
412
+ modelAdapters?: unknown;
413
+ baseUrl?: unknown;
414
+ unsupportedHostedTools?: unknown;
415
+ },
395
416
  ): string | null {
396
417
  if (value === undefined) return null;
397
418
  if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
@@ -399,6 +420,12 @@ export function modelPreferHostedToolsConfigError(
399
420
  if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
400
421
  const entries = Object.entries(value);
401
422
  const registry = getProviderRegistryEntry(providerName);
423
+ // A provider that denies a hosted tool cannot also prefer it. Both keys are
424
+ // provider-owned capability statements about the same tool, and the denial wins at
425
+ // request time, so accepting the pair would silently ignore the preference.
426
+ const declaredUnsupported = declaredUnsupportedHostedTools(
427
+ provider as { unsupportedHostedTools?: readonly string[] },
428
+ );
402
429
  // Effective transport: a `preserveCustomDestination` registry row reused under a
403
430
  // different endpoint keeps its own adapter AND its own auth at runtime, because
404
431
  // `routedProviderConfig()` honors `providerMatchesRegistryTransport()`. Both the
@@ -459,6 +486,9 @@ export function modelPreferHostedToolsConfigError(
459
486
  if (typeof tool !== "string" || !SUPPORTED_PREFERRED_HOSTED_TOOLS.has(tool)) {
460
487
  return `${field}.${key} supports only image_generation`;
461
488
  }
489
+ if (declaredUnsupported.has(tool)) {
490
+ return `${field}.${key} cannot prefer ${tool}: unsupportedHostedTools declares it unsupported`;
491
+ }
462
492
  if (isHostedToolUnsupportedForModel(key, tool)) {
463
493
  return `${field}.${key} cannot prefer ${tool}: the model does not support it`;
464
494
  }
@@ -854,3 +884,32 @@ export const catalogAutoRefreshSchema = z.object({
854
884
  enabled: z.boolean().optional(),
855
885
  intervalMinutes: z.number().int().min(0).max(1440).optional(),
856
886
  }).strict();
887
+
888
+ /**
889
+ * One spend scope's ceiling.
890
+ *
891
+ * `.strict()` for the usual reason and one sharper one. Elsewhere a silently ignored key
892
+ * leaves a feature off that the operator believed was on; here it leaves a BUDGET off, and a
893
+ * budget nobody is enforcing looks exactly like a budget nobody has exceeded. That is #2106 --
894
+ * an undeclared option accepted, persisted, and then read as its default -- aimed at spend.
895
+ *
896
+ * Only positive integers: 0 would read as "no tokens at all" and refuse every request under
897
+ * the scope, which is never what writing a budget means. An operator who wants no ceiling
898
+ * removes the key.
899
+ */
900
+ const spendScopeSchema = z.object({
901
+ maxTokens: z.number().int().positive().optional(),
902
+ }).strict();
903
+
904
+ /**
905
+ * Durable spend ceilings (#4546).
906
+ *
907
+ * Absent, empty, and all-scopes-absent are the same thing: observe-only accounting. There is
908
+ * no default ceiling anywhere in this section, deliberately.
909
+ */
910
+ export const spendSchema = z.object({
911
+ root: spendScopeSchema.optional(),
912
+ identity: spendScopeSchema.optional(),
913
+ pool: spendScopeSchema.optional(),
914
+ retentionDays: z.number().int().min(1).max(365).optional(),
915
+ }).strict();
package/src/config.ts CHANGED
@@ -170,7 +170,7 @@ import {
170
170
  sanitizeModelCostsForLoad,
171
171
  sanitizeCapabilityDeclarationsForLoad,
172
172
  warnInheritedFastWireConflicts,
173
- warnDegradedStreamMode,
173
+ warnDegradedTopLevelOptIns,
174
174
  warnDegradedHostname,
175
175
  warnDegradedListeners,
176
176
  warnDegradedApiKeys,
@@ -227,7 +227,7 @@ export function loadConfig(): OcxConfig {
227
227
  if (result.success) {
228
228
  const config = normalizeApiKeyIds(result.data as OcxConfig);
229
229
  warnInheritedFastWireConflicts(configPath, config);
230
- warnDegradedStreamMode(parsed, config);
230
+ warnDegradedTopLevelOptIns(parsed, config);
231
231
  warnDegradedHostname(parsed, config);
232
232
  warnDegradedListeners(parsed, config);
233
233
  warnDegradedApiKeys(parsed, config);