@bitkyc08/opencodex 2.88.0 → 2.89.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 (205) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-BnRSujOq.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-7z1COnmz.js} +1 -1
  6. package/gui/dist/assets/{index-B5bsvxor.js → index-uchD-ngf.js} +13 -13
  7. package/gui/dist/assets/{quota-summary-D8y2wTsB.js → quota-summary-BY_10IEz.js} +1 -1
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/cache-retention.ts +117 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -4
  12. package/src/adapters/anthropic.ts +10 -3
  13. package/src/adapters/coding-agent/protocol.ts +6 -0
  14. package/src/adapters/google-http.ts +3 -2
  15. package/src/adapters/google-vertex-endpoint.ts +67 -0
  16. package/src/adapters/google.ts +35 -29
  17. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  18. package/src/adapters/openai-responses/passthrough.ts +17 -12
  19. package/src/adapters/openai-responses/reasoning.ts +68 -1
  20. package/src/adapters/registry.ts +1 -1
  21. package/src/adapters/upstream-http-error.ts +3 -2
  22. package/src/adapters/zed.ts +7 -4
  23. package/src/bridge/internal.ts +11 -1
  24. package/src/bridge/response-json.ts +2 -2
  25. package/src/bridge/sse.ts +10 -4
  26. package/src/claude/inbound.ts +12 -26
  27. package/src/claude/outbound.ts +5 -0
  28. package/src/cli/account-api.ts +13 -1
  29. package/src/cli/account-extended.ts +17 -5
  30. package/src/cli/account-key-quota.ts +4 -1
  31. package/src/cli/account.ts +1 -1
  32. package/src/cli/capabilities-agents-routing.ts +45 -8
  33. package/src/cli/capabilities-base.ts +5 -5
  34. package/src/cli/capabilities-provider-models.ts +5 -4
  35. package/src/cli/combo-result.ts +8 -2
  36. package/src/cli/combo.ts +17 -0
  37. package/src/cli/help.ts +1 -1
  38. package/src/cli/message-command.ts +4 -1
  39. package/src/cli/message-remote-args.ts +69 -0
  40. package/src/cli/message-remote-command.ts +77 -0
  41. package/src/cli/provider-runtime.ts +69 -1
  42. package/src/cli/registry.ts +3 -3
  43. package/src/codex/account-pause.ts +6 -1
  44. package/src/codex/account-runtime-state.ts +5 -0
  45. package/src/codex/account-store.ts +30 -3
  46. package/src/codex/auth-api/account-list.ts +3 -0
  47. package/src/codex/auth-api/main-account-probe.ts +3 -2
  48. package/src/codex/auth-context.ts +9 -4
  49. package/src/codex/catalog/aggregation.ts +15 -0
  50. package/src/codex/catalog/auto-review.ts +2 -1
  51. package/src/codex/catalog/build-entries.ts +17 -7
  52. package/src/codex/catalog/derive-entry.ts +3 -1
  53. package/src/codex/catalog/metadata.ts +2 -0
  54. package/src/codex/catalog/model-visibility.ts +0 -2
  55. package/src/codex/catalog/ownership.ts +71 -0
  56. package/src/codex/catalog/parsing.ts +12 -3
  57. package/src/codex/catalog/retained-sync.ts +2 -1
  58. package/src/codex/catalog/routed-removal.ts +4 -4
  59. package/src/codex/convergence.ts +2 -1
  60. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  61. package/src/codex/low-quota-protection.ts +57 -31
  62. package/src/codex/main-account-cache.ts +77 -3
  63. package/src/codex/main-account.ts +6 -2
  64. package/src/codex/native-residue.ts +2 -3
  65. package/src/codex/quota.ts +26 -2
  66. package/src/combos/failover.ts +2 -1
  67. package/src/combos/jev-model-backend.ts +30 -7
  68. package/src/combos/jev-model-request.ts +20 -0
  69. package/src/combos/jev-quota-activation.ts +153 -0
  70. package/src/combos/jev-quota-config.ts +62 -0
  71. package/src/combos/jev-quota-route.ts +26 -0
  72. package/src/combos/jev-quota.ts +88 -0
  73. package/src/combos/jev.ts +25 -12
  74. package/src/combos/types.ts +27 -0
  75. package/src/compatibility/index.ts +3 -0
  76. package/src/compatibility/openai-responses.ts +8 -1
  77. package/src/compatibility/opengateway-responses.ts +61 -0
  78. package/src/config/account-auto-pause-revisions.ts +136 -0
  79. package/src/config/diagnostics.ts +13 -0
  80. package/src/config/live-reconcile.ts +9 -0
  81. package/src/config/persist-unlocked.ts +10 -1
  82. package/src/config/removed-opencode-free.ts +135 -0
  83. package/src/config/schema/account-auto-pause.ts +14 -0
  84. package/src/config/schema/config-schema.ts +15 -0
  85. package/src/config/schema/leaf-validators.ts +7 -0
  86. package/src/config.ts +10 -3
  87. package/src/generated/compatibility-version.json +344 -144
  88. package/src/images/loop.ts +32 -9
  89. package/src/lib/account-auto-pause-response.ts +16 -0
  90. package/src/lib/bun-runtime.ts +18 -1
  91. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  92. package/src/lib/bun-transpiler-cache-scope.mjs +68 -0
  93. package/src/lib/decision-quota-sink.ts +76 -0
  94. package/src/lib/state-store-registrations.ts +25 -0
  95. package/src/link/ssh-argv.ts +3 -0
  96. package/src/messaging/envelope.ts +6 -3
  97. package/src/messaging/remote-auth.ts +83 -0
  98. package/src/messaging/remote-bridge.ts +270 -0
  99. package/src/messaging/remote-contract.ts +108 -0
  100. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  101. package/src/messaging/remote-enrollment.ts +244 -0
  102. package/src/messaging/remote-envelope.ts +40 -0
  103. package/src/messaging/remote-files.ts +117 -0
  104. package/src/messaging/remote-owner.ts +169 -0
  105. package/src/messaging/remote-ports.ts +56 -0
  106. package/src/messaging/remote-process.ts +131 -0
  107. package/src/messaging/remote-replies.ts +98 -0
  108. package/src/messaging/remote-rpc-admission.ts +32 -0
  109. package/src/messaging/remote-send.ts +72 -0
  110. package/src/messaging/remote-store.ts +111 -0
  111. package/src/messaging/remote-tunnels.ts +65 -0
  112. package/src/messaging/rpc.ts +13 -1
  113. package/src/oauth/account-auto-pause-policy.ts +66 -0
  114. package/src/oauth/account-auto-pause.ts +171 -0
  115. package/src/oauth/account-quota-rank.ts +2 -1
  116. package/src/oauth/anthropic-account-refusal.ts +6 -0
  117. package/src/oauth/anthropic-model-family.ts +8 -0
  118. package/src/oauth/anthropic-model-quota.ts +2 -6
  119. package/src/oauth/api-key-pool-preserve.ts +37 -0
  120. package/src/oauth/index.ts +5 -35
  121. package/src/oauth/store.ts +35 -8
  122. package/src/oauth/types.ts +2 -0
  123. package/src/plugins/upstream-hooks.ts +14 -1
  124. package/src/providers/api-key-pause.ts +32 -0
  125. package/src/providers/api-key-resolve.ts +10 -4
  126. package/src/providers/api-key-selection.ts +6 -1
  127. package/src/providers/api-keys.ts +8 -4
  128. package/src/providers/claude-cli-usage.ts +5 -0
  129. package/src/providers/key-failover.ts +11 -11
  130. package/src/providers/model-rename-fields.ts +1 -0
  131. package/src/providers/opencode-zen-rate-limit.ts +6 -72
  132. package/src/providers/quota/account-cache.ts +15 -1
  133. package/src/providers/quota/anthropic-account-quota.ts +2 -1
  134. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  135. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  136. package/src/providers/quota/antigravity.ts +39 -6
  137. package/src/providers/quota/report-cache.ts +14 -0
  138. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  139. package/src/providers/quota-decision-publication.ts +77 -0
  140. package/src/providers/quota-decision-snapshot.ts +145 -0
  141. package/src/providers/quota-routing-cache.ts +3 -0
  142. package/src/providers/quota-types.ts +6 -1
  143. package/src/providers/quota.ts +3 -2
  144. package/src/providers/registry/entries-extended.ts +8 -52
  145. package/src/providers/registry/model-seeds.ts +1 -1
  146. package/src/providers/registry.ts +1 -2
  147. package/src/providers/stale-vision-classification-migration.ts +1 -1
  148. package/src/responses/parser.ts +18 -10
  149. package/src/responses/web-search-replay.ts +138 -0
  150. package/src/routing/compatibility/behavior.ts +4 -1
  151. package/src/server/auth-cors.ts +7 -0
  152. package/src/server/background-lifecycle.ts +2 -1
  153. package/src/server/chat-native.ts +28 -13
  154. package/src/server/claude-messages.ts +2 -1
  155. package/src/server/index/optional-listeners.ts +6 -0
  156. package/src/server/management/combo-routes.ts +17 -2
  157. package/src/server/management/oauth-account-routes.ts +10 -1
  158. package/src/server/management/provider-completion-test.ts +289 -0
  159. package/src/server/management/provider-routes.ts +9 -1
  160. package/src/server/management/route-registry.ts +2 -0
  161. package/src/server/messages-native.ts +18 -4
  162. package/src/server/messages-shared.ts +4 -1
  163. package/src/server/request-log.ts +7 -2
  164. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  165. package/src/server/responses/adapter-dispatch.ts +77 -28
  166. package/src/server/responses/combo-session-recall.ts +25 -0
  167. package/src/server/responses/combo-stream-preflight.ts +3 -1
  168. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  169. package/src/server/responses/compact.ts +8 -5
  170. package/src/server/responses/context-overflow.ts +141 -2
  171. package/src/server/responses/core-codex-account.ts +7 -1
  172. package/src/server/responses/core-combo.ts +31 -5
  173. package/src/server/responses/core.ts +12 -9
  174. package/src/server/responses/empty-completion-guard.ts +2 -0
  175. package/src/server/responses/fetch-helpers.ts +35 -4
  176. package/src/server/responses/jev-model-invoke.ts +13 -14
  177. package/src/server/responses/native-steering-log.ts +2 -2
  178. package/src/server/responses/passthrough-delivery.ts +3 -2
  179. package/src/server/responses/passthrough-dispatch.ts +30 -33
  180. package/src/server/responses/passthrough-observations.ts +38 -0
  181. package/src/server/responses/request-compression.ts +98 -0
  182. package/src/server/responses/request-transport.ts +32 -3
  183. package/src/server/responses/run-turn-execution.ts +2 -0
  184. package/src/server/responses/sidecar-execution.ts +16 -3
  185. package/src/server/responses/terminal-guard.ts +2 -0
  186. package/src/server/startup-action-control.ts +2 -2
  187. package/src/server/startup-health-cache.ts +2 -2
  188. package/src/server/windows-tray-control.ts +2 -2
  189. package/src/service/definition.ts +9 -1
  190. package/src/types/account-auto-pause.ts +15 -0
  191. package/src/types/config.ts +23 -1
  192. package/src/types/provider.ts +9 -5
  193. package/src/types/request.ts +20 -2
  194. package/src/types.ts +1 -0
  195. package/src/usage/cost.ts +17 -1
  196. package/src/usage/expected-prices.ts +3 -1
  197. package/src/usage/jev-stats.ts +4 -0
  198. package/src/usage/log.ts +2 -0
  199. package/src/usage/summary.ts +7 -0
  200. package/src/web-search/loop.ts +37 -11
  201. package/src/web-search/progress-stream.ts +4 -1
  202. package/src/web-search/run-turn-loop.ts +9 -3
  203. package/src/web-search/sidecar-search-replay.ts +96 -0
  204. package/gui/dist/assets/App-CHC89EXQ.js +0 -58
  205. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
@@ -1,4 +1,5 @@
1
1
  import { OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST } from "./openai-responses";
2
+ import { OPENGATEWAY_KEY_GLM53_RESPONSES_MANIFEST } from "./opengateway-responses";
2
3
  import type { CompatibilityManifestV1 } from "./manifest";
3
4
 
4
5
  export {
@@ -16,9 +17,11 @@ export type {
16
17
  CompatibilitySubjectV1,
17
18
  } from "./manifest";
18
19
  export { OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST } from "./openai-responses";
20
+ export { OPENGATEWAY_KEY_GLM53_RESPONSES_MANIFEST } from "./opengateway-responses";
19
21
 
20
22
  export const COMPATIBILITY_MANIFESTS: readonly CompatibilityManifestV1[] = Object.freeze([
21
23
  OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST,
24
+ OPENGATEWAY_KEY_GLM53_RESPONSES_MANIFEST,
22
25
  ]);
23
26
 
24
27
  export function getCompatibilityManifest(id: string): CompatibilityManifestV1 | undefined {
@@ -9,7 +9,7 @@ const FIXTURE_ID = "openai-codex-forward-gpt56-sol-v1";
9
9
  export const OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST = defineCompatibilityManifest({
10
10
  schemaVersion: 1,
11
11
  id: "openai.codex-forward.gpt-5-6-sol.responses",
12
- version: "1.3.0",
12
+ version: "1.4.0",
13
13
  subject: {
14
14
  providerId: "openai",
15
15
  baseUrl: "https://chatgpt.com/backend-api/codex",
@@ -117,5 +117,12 @@ export const OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST = defineCompatibilityManife
117
117
  limitation: "The field is removed only for the canonical forward destination; public and custom Responses providers keep it.",
118
118
  evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["prompt-cache-options-removed"] }],
119
119
  },
120
+ {
121
+ id: "custom-tool-grammar",
122
+ feature: "request.custom_tools.grammar",
123
+ disposition: "passthrough",
124
+ summary: "A custom tool's grammar format, including its definition, reaches the backend unchanged.",
125
+ evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["custom-tool-grammar-preserved"] }],
126
+ },
120
127
  ],
121
128
  } as const);
@@ -0,0 +1,61 @@
1
+ import { defineCompatibilityManifest } from "./manifest";
2
+
3
+ const FIXTURE_ID = "opengateway-key-glm53-responses-v1";
4
+
5
+ /**
6
+ * A routed Responses subject whose destination rejects native custom tools, so OpenCodex lowers
7
+ * them to functions before dispatch. The claims describe that local wire transformation only;
8
+ * they do not certify how the upstream model treats the lowered declaration.
9
+ */
10
+ export const OPENGATEWAY_KEY_GLM53_RESPONSES_MANIFEST = defineCompatibilityManifest({
11
+ schemaVersion: 1,
12
+ id: "opengateway.key.glm-5-3-flash-ultrafast.responses",
13
+ version: "1.0.0",
14
+ subject: {
15
+ providerId: "opengateway",
16
+ baseUrl: "https://apis.opengateway.ai/v1",
17
+ adapterId: "openai-responses",
18
+ authMode: "key",
19
+ inboundProtocol: "responses",
20
+ upstreamProtocol: "openai-responses",
21
+ modelIds: ["z-ai/glm-5.3-flash-ultrafast"],
22
+ },
23
+ claims: [
24
+ {
25
+ id: "custom-tools",
26
+ feature: "request.custom_tools",
27
+ disposition: "translated",
28
+ summary: "Custom tool declarations are sent as functions that take one raw string input.",
29
+ limitation: "The destination rejects native custom tools, so each declaration is lowered to a function with a single required string parameter named input.",
30
+ evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["custom-tool-lowered-to-function"] }],
31
+ },
32
+ {
33
+ id: "custom-tool-grammar",
34
+ feature: "request.custom_tools.grammar",
35
+ disposition: "degraded",
36
+ summary: "The tool and its description survive, but its grammar does not reach the destination.",
37
+ limitation: "The format, including any grammar definition, is removed during lowering, so the upstream model is not constrained by it; only the string input type remains.",
38
+ evidence: [{
39
+ kind: "fixture",
40
+ id: FIXTURE_ID,
41
+ assertionIds: ["custom-tool-grammar-removed", "custom-tool-lowered-to-function"],
42
+ }],
43
+ },
44
+ {
45
+ id: "custom-tool-call-history",
46
+ feature: "continuation.custom_tool_call",
47
+ disposition: "translated",
48
+ summary: "Replayed custom tool calls are sent as function calls with the same call_id and name.",
49
+ limitation: "The raw input is wrapped as the JSON arguments object {\"input\": <raw input>}.",
50
+ evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["custom-tool-call-lowered"] }],
51
+ },
52
+ {
53
+ id: "custom-tool-output-history",
54
+ feature: "continuation.custom_tool_call_output",
55
+ disposition: "translated",
56
+ summary: "Replayed custom tool outputs are sent as function call outputs with the same call_id and output.",
57
+ limitation: "Only the item type changes from custom_tool_call_output to function_call_output.",
58
+ evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["custom-tool-output-lowered"] }],
59
+ },
60
+ ],
61
+ } as const);
@@ -0,0 +1,136 @@
1
+ import * as z from "zod/v4";
2
+ import { randomUUID } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+ import type { OcxConfig } from "../types";
5
+ import { getConfigPath } from "./paths";
6
+
7
+ type PolicyConfig = { providers?: unknown; accountAutoPausePolicyRevisions?: unknown };
8
+ // fingerprint follows the live config; diskFingerprint follows the last observed disk state, so a
9
+ // detached load compares disk with disk and still fences an observed external policy change.
10
+ type Observed = { diskRevision: string; localGen: number; unknown: boolean; fingerprint: string; diskFingerprint: string };
11
+ export type AccountAutoPauseFence = { configPath: string; diskRevision: string; localGen: number; threshold: number; route: string };
12
+ const observed = new Map<string, Map<string, Observed>>();
13
+ // One process-wide allocator for every new entry and every advance: a deleted and recreated name
14
+ // can never reproduce an old {diskRevision, localGen} pair, even when both read "legacy".
15
+ let nextGeneration = 0;
16
+ const allocateGeneration = (): number => ++nextGeneration;
17
+ function hasProvider(config: PolicyConfig | undefined, name: string): boolean {
18
+ const providers = record(config?.providers);
19
+ return !!providers && Object.hasOwn(providers, name);
20
+ }
21
+ const UUID = z.uuid();
22
+ function record(value: unknown): Record<string, unknown> | undefined {
23
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
24
+ }
25
+ function revisions(config: PolicyConfig): Record<string, string> | undefined | null {
26
+ const value = config.accountAutoPausePolicyRevisions;
27
+ if (value === undefined) return undefined;
28
+ const map = record(value);
29
+ return map && Object.values(map).every(v => typeof v === "string" && UUID.safeParse(v).success) ? map as Record<string, string> : null;
30
+ }
31
+ function revisionFor(map: Record<string, string> | undefined | null, name: string): string | undefined {
32
+ return map && Object.hasOwn(map, name) ? map[name] : undefined;
33
+ }
34
+ function provider(config: PolicyConfig, name: string): Record<string, unknown> | undefined {
35
+ const providers = record(config.providers);
36
+ return providers && Object.hasOwn(providers, name) ? record(providers[name]) : undefined;
37
+ }
38
+ function route(row: Record<string, unknown> | undefined): string {
39
+ return JSON.stringify([row?.baseUrl, row?.adapter, row?.authMode]);
40
+ }
41
+ export function accountAutoPauseFingerprint(row: unknown): string {
42
+ const p = record(row), policy = record(p?.accountAutoPause);
43
+ return JSON.stringify([!!p, policy?.enabled === true, policy?.consecutiveFailures ?? 2, p?.disabled === true, route(p)]);
44
+ }
45
+ export function snapshotAutoPauseFingerprints(config: PolicyConfig): Map<string, string> {
46
+ return new Map(Object.entries(record(config.providers) ?? {}).map(([name, row]) => [name, accountAutoPauseFingerprint(row)]));
47
+ }
48
+ /** Caller revisions never authorize a write. The literal pre-write file owns the epoch. */
49
+ export function normalizeAccountAutoPauseRevisions(before: PolicyConfig | undefined, persisted: OcxConfig): void {
50
+ const old = revisions(before ?? {});
51
+ const names = new Set(Object.keys(old ?? {}));
52
+ for (const config of [before, persisted]) {
53
+ for (const [name, row] of Object.entries(record(config?.providers) ?? {})) {
54
+ if (record(row)?.accountAutoPause !== undefined) names.add(name);
55
+ }
56
+ }
57
+ // Records follow the providers that exist after disk-only preservation; a deleted provider's
58
+ // record is reclaimed and a recreated one is minted a fresh UUID.
59
+ const entries = [...names].filter(name => hasProvider(persisted, name)).map(name => [name, revisionFor(old, name)
60
+ && accountAutoPauseFingerprint(provider(before ?? {}, name)) === accountAutoPauseFingerprint(provider(persisted, name))
61
+ ? revisionFor(old, name)! : randomUUID()]);
62
+ if (entries.length) persisted.accountAutoPausePolicyRevisions = Object.fromEntries(entries);
63
+ else delete persisted.accountAutoPausePolicyRevisions;
64
+ }
65
+ /** Adopt disk epochs even when an external ABA left the effective policy unchanged. */
66
+ export function observeAccountAutoPauseRevisions(config: PolicyConfig, disk: PolicyConfig = config, previous?: Map<string, string>): void {
67
+ const path = getConfigPath();
68
+ let entries = observed.get(path);
69
+ if (!entries) { entries = new Map(); observed.set(path, entries); }
70
+ const map = revisions(disk);
71
+ const names = new Set([...entries.keys(), ...Object.keys(record(config.providers) ?? {}), ...Object.keys(map ?? {})]);
72
+ for (const name of names) {
73
+ if (!hasProvider(config, name) && !hasProvider(disk, name)) { entries.delete(name); continue; }
74
+ const fingerprint = accountAutoPauseFingerprint(provider(config, name));
75
+ const old = entries.get(name);
76
+ const prior = previous?.get(name) ?? old?.fingerprint ?? fingerprint;
77
+ entries.set(name, { fingerprint, diskRevision: revisionFor(map, name) ?? "legacy", unknown: map === null,
78
+ diskFingerprint: accountAutoPauseFingerprint(provider(disk, name)),
79
+ localGen: !old || prior !== fingerprint ? allocateGeneration() : old.localGen });
80
+ }
81
+ }
82
+ /**
83
+ * A loaded snapshot adopts the disk epoch and compares its policy with the previously observed
84
+ * disk policy. Many callers read detached snapshots; comparing one with the live fingerprint would
85
+ * advance localGen on every live/snapshot difference. A disk-to-disk change still advances it.
86
+ */
87
+ function adoptDiskAutoPauseRevisions(config: PolicyConfig): void {
88
+ const path = getConfigPath();
89
+ let entries = observed.get(path);
90
+ if (!entries) { entries = new Map(); observed.set(path, entries); }
91
+ const map = revisions(config);
92
+ for (const name of new Set([...entries.keys(), ...Object.keys(record(config.providers) ?? {}), ...Object.keys(map ?? {})])) {
93
+ if (!hasProvider(config, name)) { entries.delete(name); continue; }
94
+ const old = entries.get(name);
95
+ const diskRevision = revisionFor(map, name) ?? "legacy";
96
+ const diskFingerprint = accountAutoPauseFingerprint(provider(config, name));
97
+ entries.set(name, old
98
+ ? { ...old, diskRevision, unknown: map === null, diskFingerprint, localGen: old.diskFingerprint !== diskFingerprint ? allocateGeneration() : old.localGen }
99
+ : { fingerprint: diskFingerprint, diskFingerprint, diskRevision, unknown: map === null, localGen: allocateGeneration() });
100
+ }
101
+ }
102
+ /** Test inspection only: observed records for the current config path. */
103
+ export function accountAutoPauseObservedSizeForTests(): number {
104
+ return observed.get(getConfigPath())?.size ?? 0;
105
+ }
106
+ /** Initial load is an observation, never a persistence operation. */
107
+ export function observeLoadedAutoPauseConfig(config: OcxConfig): OcxConfig {
108
+ adoptDiskAutoPauseRevisions(config);
109
+ return config;
110
+ }
111
+ export function captureAccountAutoPauseFence(config: OcxConfig, name: string): AccountAutoPauseFence | undefined {
112
+ // Only a config path no load or save has observed may be seeded from the caller's object. Once a
113
+ // path is observed, a missing name means an authoritative observation removed it, and a stale
114
+ // live snapshot must not re-enroll it; adoption by load, reload or save is required.
115
+ if (!observed.has(getConfigPath())) observeAccountAutoPauseRevisions(config);
116
+ const state = observed.get(getConfigPath())?.get(name);
117
+ const p = provider(config, name), policy = record(p?.accountAutoPause);
118
+ const threshold = policy?.consecutiveFailures ?? 2;
119
+ if (!state || state.unknown || !p || p.disabled === true || policy?.enabled !== true || typeof threshold !== "number" || !Number.isInteger(threshold) || threshold < 1 || threshold > 100) return;
120
+ return { configPath: getConfigPath(), diskRevision: state.diskRevision, localGen: state.localGen, threshold, route: route(p) };
121
+ }
122
+ export function accountAutoPauseFenceIsLocal(name: string, fence: AccountAutoPauseFence): boolean {
123
+ const state = observed.get(fence.configPath)?.get(name);
124
+ return getConfigPath() === fence.configPath && !!state && !state.unknown
125
+ && state.diskRevision === fence.diskRevision && state.localGen === fence.localGen;
126
+ }
127
+ /** Must run inside the config mutation lock at the final persistence boundary. */
128
+ export function accountAutoPauseFenceMatchesDisk(name: string, fence: AccountAutoPauseFence, fresh?: PolicyConfig): boolean {
129
+ if (!accountAutoPauseFenceIsLocal(name, fence)) return false;
130
+ try {
131
+ const disk = fresh ?? JSON.parse(readFileSync(fence.configPath, "utf8").replace(/^\uFEFF/, "")) as PolicyConfig;
132
+ const map = revisions(disk), p = provider(disk, name), policy = record(p?.accountAutoPause);
133
+ return map !== null && (revisionFor(map, name) ?? "legacy") === fence.diskRevision && !!p && p.disabled !== true
134
+ && policy?.enabled === true && (policy.consecutiveFailures ?? 2) === fence.threshold && route(p) === fence.route;
135
+ } catch { return false; }
136
+ }
@@ -21,6 +21,7 @@ import { isMissingPathError } from "./atomic-write";
21
21
  import { getConfigDir, getConfigPath } from "./paths";
22
22
  import { getDefaultConfig } from "./proxy-env";
23
23
  import { salvageConfigCandidate } from "./salvage";
24
+ import { sanitizeRemovedOpenCodeFreeForLoad } from "./removed-opencode-free";
24
25
  import { sanitizeOpenCodeOverridesForLoad } from "./opencode-wire-upgrade";
25
26
  import {
26
27
  sanitizeReasoningPinsForLoad,
@@ -267,6 +268,16 @@ function upstreamHostCircuitThresholdError(value: unknown): string | null {
267
268
  return `schema_invalid: upstreamHostCircuitThreshold: must be an integer from 0 to ${UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD}`;
268
269
  }
269
270
 
271
+ // The schema degrades an unknown codec to identity so a hand edit cannot break startup; a live write
272
+ // or import must instead be refused, or it would report success and then drop the value (#7247).
273
+ function upstreamRequestCompressionError(value: unknown): string | null {
274
+ const raw = rawConfigRecord(value);
275
+ if (!raw || !Object.hasOwn(raw, "upstreamRequestCompression")) return null;
276
+ const mode = raw.upstreamRequestCompression;
277
+ if (mode === undefined || mode === "off" || mode === "gzip" || mode === "zstd") return null;
278
+ return "schema_invalid: upstreamRequestCompression: must be \"off\", \"gzip\" or \"zstd\"";
279
+ }
280
+
270
281
  function plaintextV2AgentMessagesError(value: unknown): string | null {
271
282
  return malformedPlaintextV2AgentMessagesWarning(value)
272
283
  ? "schema_invalid: plaintextV2AgentMessages: must be a boolean or omitted"
@@ -726,6 +737,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
726
737
  ?? claudeSubagentEffortError(value)
727
738
  ?? appOwnedMemoryBudgetError(value)
728
739
  ?? upstreamHostCircuitThresholdError(value)
740
+ ?? upstreamRequestCompressionError(value)
729
741
  ?? plaintextV2AgentMessagesError(value)
730
742
  ?? agentTaskRecoveryError(value)
731
743
  ?? quotaResetNotifyError(value)
@@ -766,6 +778,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
766
778
  export function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics {
767
779
  try {
768
780
  const parsed = JSON.parse(raw.replace(/^\uFEFF/, ""));
781
+ sanitizeRemovedOpenCodeFreeForLoad(parsed);
769
782
  sanitizeOpenCodeOverridesForLoad(parsed);
770
783
  sanitizeReasoningPinsForLoad(parsed);
771
784
  // Same degradation as loadConfig: a hand-edited invalid retryOn429 must not trip the
@@ -1,3 +1,4 @@
1
+ import { observeAccountAutoPauseRevisions, snapshotAutoPauseFingerprints } from "./account-auto-pause-revisions";
1
2
  import type { OcxConfig, OcxProviderConfig, ProviderCostOverlay } from "../types";
2
3
  import { configReasoningPinsConfigError } from "./provider-validation";
3
4
  import { adoptCustomModelCatalogMigration, projectCustomModelCatalogMigration } from "../codex/custom-model-catalog-migration";
@@ -18,6 +19,7 @@ import { withConfigMutationLockSync, bumpGenerationForCooperatingConfigWrite } f
18
19
  import { ConfigWritePublishedError, persistConfigUnlocked, readRawConfigJson } from "./persist-unlocked";
19
20
  import { configDiagnosticsFromRaw, readConfigDiagnostics, validateConfigCandidate } from "./diagnostics";
20
21
  import { normalizePersistedClaudeCode } from "./load-degrade";
22
+ import { sanitizeRemovedOpenCodeFreeForLoad } from "./removed-opencode-free";
21
23
  import { reconcileApiKeys } from "./api-key-reconcile";
22
24
  import { captureLiveTopologyBaseline, LIVE_TOPOLOGY_KEYS, projectLiveTopology } from "./live-topology";
23
25
 
@@ -161,7 +163,9 @@ export function adoptPersistedProviderIntoLiveConfig(
161
163
  provider: OcxProviderConfig,
162
164
  persistedConfig?: OcxConfig,
163
165
  ): void {
166
+ const pauseBefore = snapshotAutoPauseFingerprints(config);
164
167
  config.providers[name] = structuredClone(provider);
168
+ observeAccountAutoPauseRevisions(config, persistedConfig ?? readRawConfigJson() ?? { accountAutoPausePolicyRevisions: null }, pauseBefore);
165
169
  const baseline = liveConfigBaseline.get(config);
166
170
  if (baseline) baseline.providers[name] = structuredClone(provider);
167
171
  if (persistedConfig) refreshPreservedProviderOwner(config, persistedConfig);
@@ -436,6 +440,7 @@ export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline
436
440
  if (diagnostics.source === "fallback") {
437
441
  throw new Error(`OAuth config reconciliation failed: ${diagnostics.error ?? "invalid config file"}`);
438
442
  }
443
+ const pauseBefore = snapshotAutoPauseFingerprints(config);
439
444
  const persisted = diagnostics.config;
440
445
  const claudeGuardArmed = claudeCodeBaseline.has(config);
441
446
  const pendingLiveClaudeMutation = claudeGuardArmed
@@ -460,6 +465,7 @@ export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline
460
465
  else config.claudeCode = structuredClone(persisted.claudeCode);
461
466
  claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
462
467
  }
468
+ observeAccountAutoPauseRevisions(config, persisted, pauseBefore);
463
469
  // The reconciliation may have adopted a providers.<name>.modelCosts edit made
464
470
  // by a cooperating process while the OAuth login was pending; keep the overlay
465
471
  // registry (and the usage-cache overlay version) in sync with the live config.
@@ -526,6 +532,8 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
526
532
  // One authoritative pre-write read feeds both the live-config reconciliation and
527
533
  // custom-model deletion migration. A second read could observe different bytes.
528
534
  const onDisk = readRawConfigJson();
535
+ sanitizeRemovedOpenCodeFreeForLoad(onDisk);
536
+ const pauseBefore = snapshotAutoPauseFingerprints(config);
529
537
  const baseline = liveConfigBaseline.get(config);
530
538
  let diskDeletedKeys = new Set<string>();
531
539
  let persistedSnapshot: OcxConfig | undefined;
@@ -576,6 +584,7 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
576
584
  }
577
585
  }
578
586
  }
587
+ observeAccountAutoPauseRevisions(config, onDisk ?? config, pauseBefore);
579
588
  applyConfigObjectChildDeletions(config, childDeletions);
580
589
  // Recursive three-way merging preserves sibling rows; explicit PUT/reset
581
590
  // replaces only its model after merging, including absent policy fields.
@@ -1,3 +1,4 @@
1
+ import { normalizeAccountAutoPauseRevisions, observeAccountAutoPauseRevisions, snapshotAutoPauseFingerprints } from "./account-auto-pause-revisions";
1
2
  import { existsSync, readFileSync } from "node:fs";
2
3
  import { configReasoningPinsConfigError } from "./provider-validation";
3
4
  import type { OcxConfig } from "../types";
@@ -78,6 +79,8 @@ export function persistConfigUnlocked(config: OcxConfig): boolean {
78
79
  const persisted = withPreservedDiskOnlyProviders(config);
79
80
  if (provenanceProjection.configRebaseProvenance === undefined) delete persisted.configRebaseProvenance;
80
81
  else persisted.configRebaseProvenance = provenanceProjection.configRebaseProvenance;
82
+ normalizeAccountAutoPauseRevisions(rawBeforeWrite, persisted);
83
+ const beforeFingerprints = snapshotAutoPauseFingerprints(rawBeforeWrite ?? {});
81
84
  const bytes = JSON.stringify(persisted, null, 2) + "\n";
82
85
  let unchanged = false;
83
86
  try {
@@ -90,6 +93,7 @@ export function persistConfigUnlocked(config: OcxConfig): boolean {
90
93
  // the same bytes (e.g. before a proxy notification), and Logs/Usage must
91
94
  // adopt the overlay without waiting for a changed save or restart.
92
95
  let published = unchanged;
96
+ let observedPublication = false;
93
97
  try {
94
98
  if (!unchanged) {
95
99
  atomicWriteFile(configPath, bytes, undefined, { afterRename: () => { published = true; } });
@@ -97,10 +101,15 @@ export function persistConfigUnlocked(config: OcxConfig): boolean {
97
101
  }
98
102
  // Publication, not successful cache refresh, is the rollback boundary. A
99
103
  // byte-identical save already has the requested state on disk as well.
104
+ observeAccountAutoPauseRevisions(persisted, persisted, beforeFingerprints);
105
+ observedPublication = true;
100
106
  refreshConfigDerivedRegistries(persisted);
101
107
  return !unchanged;
102
108
  } catch (error) {
103
- if (published) throw new ConfigWritePublishedError(error);
109
+ if (published) {
110
+ if (!observedPublication) observeAccountAutoPauseRevisions(persisted, persisted, beforeFingerprints);
111
+ throw new ConfigWritePublishedError(error);
112
+ }
104
113
  throw error;
105
114
  }
106
115
  }
@@ -0,0 +1,135 @@
1
+ import { getDefaultConfig } from "./proxy-env";
2
+ import { DESKTOP_FAMILIES } from "../claude/desktop-profile";
3
+
4
+ const REMOVED_PROVIDER = "opencode-free";
5
+ let warned = false;
6
+
7
+ function record(value: unknown): Record<string, unknown> | undefined {
8
+ return value !== null && typeof value === "object" && !Array.isArray(value)
9
+ ? value as Record<string, unknown> : undefined;
10
+ }
11
+
12
+ /** Load-only projection, like the OpenCode wire upgrades: never rewrites config.json. */
13
+ export function sanitizeRemovedOpenCodeFreeForLoad(parsed: unknown): void {
14
+ const config = record(parsed);
15
+ if (!config) return;
16
+ const providers = record(config.providers);
17
+ const retiredProviders = new Set([REMOVED_PROVIDER]);
18
+ const retiredRoutes = new Set<string>();
19
+ let changed = false;
20
+ if (providers) {
21
+ for (const [name, value] of Object.entries(providers)) {
22
+ const provider = record(value);
23
+ if (name !== REMOVED_PROVIDER && ![provider?.adapter, provider?.type, provider?.id, provider?.preset, record(provider?.preset)?.id, provider?.presetId, provider?.providerType].includes(REMOVED_PROVIDER)) continue;
24
+ retiredProviders.add(name);
25
+ if (typeof provider?.alias === "string") retiredProviders.add(provider.alias.trim());
26
+ delete providers[name];
27
+ changed = true;
28
+ }
29
+ }
30
+ const retired = (value: unknown): boolean => typeof value === "string"
31
+ && (retiredProviders.has(value.trim()) || retiredProviders.has(value.trim().split("/")[0]!) || retiredRoutes.has(value.trim()));
32
+
33
+ // Prune references in arrays/maps as well as structured provider/model targets. A decision
34
+ // model is required operator intent: disable that combo instead of choosing another service.
35
+ function prune(value: unknown, preserveContainer = false): unknown {
36
+ if (retired(value)) { changed = true; return undefined; }
37
+ if (Array.isArray(value)) return value.map(item => prune(item)).filter(item => item !== undefined);
38
+ const row = record(value);
39
+ if (!row) return value;
40
+ if (!preserveContainer && [row.provider, row.model, row.decisionModel, row.decisionProvider].some(retired)) {
41
+ changed = true;
42
+ return undefined;
43
+ }
44
+ const hadTargets = Array.isArray(row.targets) && row.targets.length > 0;
45
+ const hadCandidates = Array.isArray(row.candidates) && row.candidates.length > 0;
46
+ const hadSourceModels = Array.isArray(row.sourceModels) && row.sourceModels.length > 0;
47
+ for (const [key, child] of Object.entries(row)) {
48
+ // Descriptions and credential/transport data are not model references.
49
+ if (["note", "description", "displayName", "label", "modelProfile", "headers", "apiKey", "apiKeys", "baseUrl", "modelDisplayNames"].includes(key)) continue;
50
+ const next = retired(key) ? undefined : prune(child);
51
+ if (next === undefined) { delete row[key]; changed = true; }
52
+ else row[key] = next;
53
+ }
54
+ // Empty combos/profiles cannot pass schema validation or execute a request.
55
+ if ((hadTargets && Array.isArray(row.targets) && row.targets.length === 0)
56
+ || (hadCandidates && Array.isArray(row.candidates) && row.candidates.length === 0)
57
+ || (hadSourceModels && Array.isArray(row.sourceModels) && row.sourceModels.length === 0)) return undefined;
58
+ return row;
59
+ }
60
+
61
+ // Register removed public aliases before pruning remaining references, including a
62
+ // combo whose decision model points to another combo disabled later in the map.
63
+ let removedRoute: boolean;
64
+ do {
65
+ removedRoute = false;
66
+ for (const [section, namespace] of [["combos", "combo"], ["routingProfiles", "policy"]] as const) {
67
+ const routes = record(config[section]);
68
+ if (!routes) continue;
69
+ for (const [id, value] of Object.entries(routes)) {
70
+ const alias = record(value)?.alias;
71
+ if (prune(value) !== undefined) continue;
72
+ delete routes[id];
73
+ retiredRoutes.add(`${namespace}/${id}`);
74
+ if (typeof alias === "string") retiredRoutes.add(alias.trim());
75
+ changed = removedRoute = true;
76
+ }
77
+ }
78
+ } while (removedRoute);
79
+ // A retired default falls back to the standard built-in OAuth route, without moving a
80
+ // user's choice to a different billable provider. Preserve any configured OpenAI row.
81
+ if (retired(config.defaultProvider)) {
82
+ const defaults = getDefaultConfig();
83
+ config.providers = { ...defaults.providers, ...providers };
84
+ config.defaultProvider = defaults.defaultProvider;
85
+ changed = true;
86
+ }
87
+ const depletedFallbacks = new Set(Object.entries(record(config.subagentModelFallbackByModel) ?? {})
88
+ .filter(([, chain]) => Array.isArray(chain) && chain.some(retired)).map(([model]) => model));
89
+ const desktopProfile = record(record(config.claudeCode)?.desktopProfile);
90
+ const desktopAssignments = record(desktopProfile?.assignments);
91
+ const desktopDefaults = record(desktopProfile?.defaults);
92
+ const retiredDesktopRoute = Object.keys(desktopAssignments ?? {}).some(retired)
93
+ || Object.values(desktopDefaults ?? {}).some(retired);
94
+ if (retired(config.injectionModel)) {
95
+ delete config.syncCodexSubagentDefaults;
96
+ delete config.injectionEffort;
97
+ }
98
+ for (const [key, value] of Object.entries(config)) {
99
+ if (key === "providers" || !/model|provider|combo|routing|claudeCode|fallback|redirect|compaction|autoReview|advisor|tokenGuardian|images|codexRoleTiers|sidecar|shadowCallIntercept/i.test(key)) continue;
100
+ const next = prune(value, key === "claudeCode");
101
+ if (next === undefined) delete config[key];
102
+ else config[key] = next;
103
+ }
104
+ // Desktop defaults require a member of their family, or null for an empty family.
105
+ // Repair only profiles affected by this migration; unrelated invalid input still degrades.
106
+ if (retiredDesktopRoute && desktopAssignments && desktopDefaults) {
107
+ for (const family of DESKTOP_FAMILIES) {
108
+ const current = desktopDefaults[family];
109
+ if (typeof current === "string" && record(desktopAssignments[current])?.family === family) continue;
110
+ desktopDefaults[family] = Object.keys(desktopAssignments)
111
+ .filter(route => record(desktopAssignments[route])?.family === family).sort()[0] ?? null;
112
+ }
113
+ }
114
+ if (providers) {
115
+ for (const provider of Object.values(providers)) {
116
+ const row = record(provider);
117
+ if (!row) continue;
118
+ for (const key of ["defaultModel", "autoReviewModel", "autoReviewModelOverrides"]) {
119
+ const next = prune(row[key], key === "autoReviewModelOverrides");
120
+ if (next === undefined) delete row[key];
121
+ else row[key] = next;
122
+ }
123
+ }
124
+ }
125
+ const fallbacks = record(config.subagentModelFallbackByModel);
126
+ if (fallbacks) {
127
+ for (const [model, chain] of Object.entries(fallbacks)) {
128
+ if (depletedFallbacks.has(model) && Array.isArray(chain) && chain.length === 0) delete fallbacks[model];
129
+ }
130
+ }
131
+ if (changed && !warned) {
132
+ warned = true;
133
+ console.warn("opencode-free support was removed because every free model now returns 403 permission_denied under OpenCode's upstream free-tier gate (#7255); the entry and dependent references are disabled. Use opencode-go, or another provider such as opencode-zen or openrouter.");
134
+ }
135
+ }
@@ -0,0 +1,14 @@
1
+ import * as z from "zod/v4";
2
+ import { ACCOUNT_PAUSE_REASONS } from "../../types/account-auto-pause";
3
+
4
+ export const accountAutoPausePolicySchema = z.object({
5
+ enabled: z.boolean().optional(),
6
+ consecutiveFailures: z.number().int().min(1).max(100).optional(),
7
+ }).strict();
8
+ export const accountPauseDetailsSchema = z.object({
9
+ reason: z.enum(ACCOUNT_PAUSE_REASONS),
10
+ pausedAt: z.number().int().nonnegative(),
11
+ }).strict();
12
+ export const accountPauseStateSchema = z.record(z.string(), z.object({
13
+ revision: z.string(), details: accountPauseDetailsSchema.optional(),
14
+ }).strict());
@@ -1,6 +1,7 @@
1
1
  import { MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT } from "../../codex/quota-types";
2
2
  import { validRemainingQuotaAccountIds } from "../../codex/account-use-remaining";
3
3
  import * as z from "zod/v4";
4
+ import { accountPauseStateSchema } from "./account-auto-pause";
4
5
  import { anthropicAccountPoolSchema } from "./anthropic-account-pool";
5
6
  import { compactionRecoverySchema } from "./compaction-recovery";
6
7
  import { nativeReasoningRetentionSchema } from "./native-reasoning-retention";
@@ -163,6 +164,9 @@ export const configSchema = z.object({
163
164
  .min(0)
164
165
  .optional()
165
166
  .catch(undefined),
167
+ // Opt-in outbound request compression (#7247). An unknown codec degrades to identity, matching
168
+ // the guards above: a hand-edit typo must not change what the proxy sends or refuse traffic.
169
+ upstreamRequestCompression: z.enum(["off", "gzip", "zstd"]).optional().catch(undefined),
166
170
  // Opt-in inbound body ceiling (#3573). An invalid hand edit degrades to the 256 MiB default
167
171
  // rather than failing the parse, matching the outbound guard above: a malformed number must
168
172
  // not change what the proxy admits. The hard ceiling is NOT enforced here — because of that
@@ -303,6 +307,17 @@ export const configSchema = z.object({
303
307
  // and the default label is emitted, rather than failing the parse or writing a config Codex
304
308
  // would refuse to load — the provider id routing depends on is never derived from it.
305
309
  codexProviderDisplayName: z.string().trim().min(1).max(128).optional().catch(undefined),
310
+ // Preserve an invalid marker: silently treating corrupt epochs as legacy would grant stale attempts authority.
311
+ accountAutoPausePolicyRevisions: z.record(z.string(), z.uuid()).nullable().optional().catch(null),
312
+ // Details are display-only; keep each usable resume revision and the separate paused-id authority.
313
+ codexAccountPauseState: z.preprocess(value => {
314
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
315
+ return Object.fromEntries(Object.entries(value).flatMap(([id, row]) => {
316
+ if (!row || typeof row !== "object" || Array.isArray(row) || typeof row.revision !== "string") return [];
317
+ const parsed = accountPauseStateSchema.safeParse({ [id]: row });
318
+ return [[id, parsed.success ? parsed.data[id] : { revision: row.revision }]];
319
+ }));
320
+ }, accountPauseStateSchema.optional()),
306
321
  pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(),
307
322
  // A malformed allow-list degrades to "no account spends credits" rather than failing the parse,
308
323
  // so a hand-edited typo cannot trip the backup-and-defaults repair path; the write path rejects
@@ -1,4 +1,5 @@
1
1
  import * as z from "zod/v4";
2
+ import { accountAutoPausePolicySchema, accountPauseDetailsSchema } from "./account-auto-pause";
2
3
  import { anthropicAccountPoolSchema } from "./anthropic-account-pool";
3
4
  import { join } from "node:path";
4
5
  import { isValidProviderName } from "../provider-name";
@@ -303,6 +304,8 @@ const providerNoProxySchema = z.unknown().superRefine((value, ctx) => {
303
304
  * fields pass through (preserved for runtime extensions).
304
305
  */
305
306
  export const providerConfigSchema = z.object({
307
+ accountAutoPause: accountAutoPausePolicySchema.optional(),
308
+ apiKeyPool: z.array(z.object({ id: z.string(), key: z.string(), label: z.string().optional(), addedAt: z.number().optional(), disabled: z.boolean().optional(), pauseRevision: z.string().optional(), pauseDetails: accountPauseDetailsSchema.optional() }).passthrough()).optional(),
306
309
  anthropicAccountPool: anthropicAccountPoolSchema,
307
310
  // Raw diagnostics/writes reject malformed provenance; tolerant loads preserve the custom row.
308
311
  anthropicOAuthInstance: z.literal("anthropic2").optional().catch(undefined),
@@ -1020,12 +1023,16 @@ export const codexPoolSchema = z.object({
1020
1023
  actions: z.object({
1021
1024
  pause: z.boolean(),
1022
1025
  notify: z.boolean(),
1026
+ desktop: z.boolean().optional(),
1023
1027
  }).strict(),
1024
1028
  windows: z.object({
1025
1029
  short: z.boolean(),
1026
1030
  weekly: z.boolean(),
1027
1031
  }).strict(),
1028
1032
  }).strict().superRefine((policy, ctx) => {
1033
+ if (policy.actions.desktop === true && !policy.actions.notify) {
1034
+ ctx.addIssue({ code: "custom", path: ["actions", "desktop"], message: "desktop low-quota notification requires notify" });
1035
+ }
1029
1036
  if (!policy.enabled) return;
1030
1037
  if (!policy.actions.pause && !policy.actions.notify) {
1031
1038
  ctx.addIssue({ code: "custom", path: ["actions"], message: "enabled low-quota protection needs an action" });