@bitkyc08/opencodex 2.69.0 → 2.70.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 (147) hide show
  1. package/gui/dist/assets/App-_MrbTrBe.js +51 -0
  2. package/gui/dist/assets/{Tray-uqKRqfvw.js → Tray-5PuUalET.js} +1 -1
  3. package/gui/dist/assets/index-DKrO2eF3.js +86 -0
  4. package/gui/dist/assets/quota-summary-BCLivc0Q.js +1 -0
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-model-contract.ts +119 -0
  8. package/src/adapters/anthropic.ts +14 -83
  9. package/src/adapters/base.ts +2 -0
  10. package/src/adapters/command-code-project-context.ts +542 -0
  11. package/src/adapters/command-code-tool-text.ts +99 -67
  12. package/src/adapters/command-code.ts +5 -1
  13. package/src/adapters/cursor/catalog.ts +11 -0
  14. package/src/adapters/cursor/effort-map.ts +3 -0
  15. package/src/adapters/devin/cloud-direct/catalog.ts +91 -0
  16. package/src/adapters/devin/cloud-direct/chat.ts +78 -48
  17. package/src/adapters/devin/cloud-direct/tool-schema.ts +121 -0
  18. package/src/adapters/devin/context-overflow.ts +75 -0
  19. package/src/adapters/devin/live-models.ts +174 -13
  20. package/src/adapters/devin/reasoning-signature.ts +100 -0
  21. package/src/adapters/devin.ts +310 -117
  22. package/src/adapters/kiro/payload.ts +10 -10
  23. package/src/adapters/kiro-images.ts +58 -7
  24. package/src/adapters/openai-chat/passthrough.ts +3 -1
  25. package/src/adapters/openai-chat.ts +2 -1
  26. package/src/adapters/openai-responses/passthrough.ts +2 -1
  27. package/src/bridge/response-json.ts +15 -1
  28. package/src/bridge/sse.ts +3 -1
  29. package/src/claude/intercept/model-bindings.ts +1 -0
  30. package/src/cli/account-extended.ts +31 -1
  31. package/src/cli/account.ts +3 -0
  32. package/src/cli/capabilities.ts +13 -0
  33. package/src/cli/client-start-fence.ts +36 -0
  34. package/src/cli/dispatch.ts +3 -2
  35. package/src/cli/index.ts +98 -117
  36. package/src/cli/provider-runtime.ts +23 -2
  37. package/src/cli/provider.ts +3 -0
  38. package/src/cli/resolve.ts +18 -0
  39. package/src/cli/start-owner-fence.ts +30 -0
  40. package/src/cli/stop-approval.ts +13 -1
  41. package/src/cli/stop-restore.ts +73 -0
  42. package/src/cli/tray-proxy.ts +221 -18
  43. package/src/cli/version-skew.ts +25 -23
  44. package/src/client/runtime.ts +8 -4
  45. package/src/codex/auth-api/account-list.ts +11 -5
  46. package/src/codex/auth-api/login-flow.ts +5 -3
  47. package/src/codex/auth-api/main-account-probe.ts +186 -110
  48. package/src/codex/auth-api/pool-mode-gate.ts +61 -7
  49. package/src/codex/auth-api/pool-quota-probe.ts +142 -90
  50. package/src/codex/auth-api/reset-credit-service.ts +1 -1
  51. package/src/codex/catalog/gather-capture.ts +1 -0
  52. package/src/codex/catalog/model-hints.ts +3 -3
  53. package/src/codex/catalog/provider-models.ts +2 -0
  54. package/src/codex/low-quota-events.ts +28 -0
  55. package/src/codex/low-quota-observer.ts +23 -0
  56. package/src/codex/low-quota-protection.ts +272 -0
  57. package/src/codex/quota-query-backoff.ts +148 -0
  58. package/src/codex/quota.ts +7 -2
  59. package/src/config/diagnostics.ts +10 -1
  60. package/src/config/load-degrade.ts +17 -0
  61. package/src/config/mutation-lock.ts +3 -4
  62. package/src/config/process-state.ts +1 -1
  63. package/src/config/provider-validation.ts +22 -0
  64. package/src/config/schema/blocked-model-redirects.ts +17 -0
  65. package/src/config/schema/config-schema.ts +4 -1
  66. package/src/config/schema/leaf-validators.ts +33 -2
  67. package/src/config/serving-runtimes.ts +412 -0
  68. package/src/generated/compatibility-version.json +211 -115
  69. package/src/generated/model-metadata.ts +3 -3
  70. package/src/images/loop.ts +3 -0
  71. package/src/lib/errors.ts +2 -1
  72. package/src/lib/handled-signal-exit.ts +23 -0
  73. package/src/lib/request-failure-model.ts +1 -0
  74. package/src/lib/self-launch-argv.ts +9 -0
  75. package/src/lib/shadow-call.ts +5 -2
  76. package/src/lib/strict-semver.ts +26 -0
  77. package/src/lib/transient-service-path.ts +16 -0
  78. package/src/lib/windows-service-wrappers.ts +10 -6
  79. package/src/lib/winsw.ts +3 -1
  80. package/src/oauth/anthropic-model-routes.ts +105 -0
  81. package/src/oauth/anthropic-routing.ts +85 -48
  82. package/src/oauth/devin.ts +135 -5
  83. package/src/oauth/index.ts +20 -3
  84. package/src/oauth/kiro-terminal-failover.ts +23 -9
  85. package/src/oauth/pool-settings-capability.ts +10 -2
  86. package/src/oauth/store.ts +1 -1
  87. package/src/protocols/encoders/adapter-events.ts +3 -1
  88. package/src/providers/github-copilot-context.ts +27 -0
  89. package/src/providers/kiro-models.ts +4 -0
  90. package/src/providers/model-rename-fields.ts +3 -0
  91. package/src/providers/quota/account-cache.ts +8 -3
  92. package/src/providers/quota/devin.ts +286 -0
  93. package/src/providers/quota/vendor-probes-oauth.ts +6 -2
  94. package/src/providers/quota.ts +1 -1
  95. package/src/providers/registry/entries-core.ts +1 -1
  96. package/src/providers/registry/model-seeds.ts +5 -2
  97. package/src/responses/compaction-images.ts +27 -0
  98. package/src/router.ts +104 -16
  99. package/src/routing/analytics.ts +1 -0
  100. package/src/server/auth-cors.ts +9 -1
  101. package/src/server/background-lifecycle.ts +21 -10
  102. package/src/server/chat-completions.ts +2 -1
  103. package/src/server/chat-native.ts +1 -0
  104. package/src/server/index.ts +4 -8
  105. package/src/server/inference/client-encoder-delivery.ts +3 -0
  106. package/src/server/management/context.ts +3 -0
  107. package/src/server/management/low-quota-routes.ts +14 -0
  108. package/src/server/management/oauth-account-routes.ts +21 -1
  109. package/src/server/management/provider-overwrite-carry.ts +79 -6
  110. package/src/server/management/provider-routes.ts +41 -28
  111. package/src/server/management/route-registry.ts +1 -0
  112. package/src/server/management-api.ts +7 -0
  113. package/src/server/responses/adapter-continuation.ts +3 -0
  114. package/src/server/responses/adapter-delivery.ts +3 -0
  115. package/src/server/responses/adapter-dispatch.ts +20 -4
  116. package/src/server/responses/antigravity-validation-refusal.ts +20 -0
  117. package/src/server/responses/core-auth.ts +1 -0
  118. package/src/server/responses/core-codex-account.ts +1 -0
  119. package/src/server/responses/core-normalize.ts +4 -0
  120. package/src/server/responses/passthrough-dispatch.ts +8 -1
  121. package/src/server/responses/request-sidecar-auth.ts +2 -0
  122. package/src/server/responses/request-transport.ts +27 -10
  123. package/src/server/responses/response-effects.ts +4 -1
  124. package/src/server/responses/run-turn-execution.ts +126 -42
  125. package/src/server/responses/sidecar-execution.ts +4 -1
  126. package/src/service/diagnostics.ts +3 -1
  127. package/src/service/guarded-manager-target.ts +39 -12
  128. package/src/service/launchd.ts +14 -6
  129. package/src/service/repair.ts +6 -1
  130. package/src/service/service-child-ownership.ts +168 -0
  131. package/src/service/state.ts +22 -4
  132. package/src/service/systemd.ts +2 -2
  133. package/src/service/windows-guarded-manager.ts +530 -0
  134. package/src/service/windows-taskxml.ts +2 -2
  135. package/src/service.ts +2 -0
  136. package/src/types/config.ts +26 -0
  137. package/src/types/provider.ts +12 -0
  138. package/src/types/request.ts +2 -0
  139. package/src/types.ts +1 -0
  140. package/src/usage/expected-prices.ts +13 -0
  141. package/src/usage/telemetry-contract.ts +1 -0
  142. package/src/vision/anthropic-describe.ts +2 -1
  143. package/src/web-search/anthropic-executor.ts +2 -1
  144. package/src/web-search/loop.ts +1 -0
  145. package/gui/dist/assets/App-DDbgOTh6.js +0 -51
  146. package/gui/dist/assets/index-CuaBJkG4.js +0 -86
  147. package/gui/dist/assets/quota-summary-COppQSVq.js +0 -1
@@ -0,0 +1,272 @@
1
+ import type { OcxConfig } from "../types";
2
+ import { isSelectableCodexPoolAccount } from "./account-id";
3
+ import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause";
4
+ import { createLowQuotaEventLedger, type LowQuotaEvent } from "./low-quota-events";
5
+ import { registerLowQuotaObserver } from "./low-quota-observer";
6
+ import { resetAtToMs } from "./quota-types";
7
+
8
+ type Window = "short" | "weekly";
9
+ type Notice = { window: Window; percentUsed: number; threshold: number };
10
+ type Dependencies = {
11
+ persist?: (config: OcxConfig) => void | Promise<void>;
12
+ notify?: (notice: Notice) => void | Promise<void>;
13
+ };
14
+ type EventBase = Omit<LowQuotaEvent, "timestamp" | "status" | "delivery">;
15
+ type Episode = { reset: string; notice: "in-flight" | "logged" | "delivered" | "failed" | undefined; pausedByUs: boolean; noticeBase?: EventBase };
16
+ export type LowQuotaRegistration = (() => void) & {
17
+ hasPendingSave(): boolean;
18
+ flush(): Promise<void>;
19
+ listEvents(limit?: number): LowQuotaEvent[];
20
+ };
21
+
22
+ const RETRY_DELAYS_MS = [100, 250];
23
+ const FLUSH_DEADLINE_MS = 500;
24
+
25
+ /** Each server owns its own policy, episode state and deferred writer. */
26
+ export function registerCodexLowQuotaProtection(config: OcxConfig, deps: Dependencies = {}): LowQuotaRegistration {
27
+ const episodes = new Map<string, Episode>();
28
+ const autoPausedAccounts = new Set<string>();
29
+ const ledger = createLowQuotaEventLedger();
30
+ let policyKey: string | undefined;
31
+ let closed = false;
32
+ let generation = 0;
33
+ let dirty = false;
34
+ let attempts = 0;
35
+ let retryDelay: number | undefined;
36
+ let saveFlight: Promise<void> | null = null;
37
+ let activeSave: { events: Map<string, EventBase>; started: boolean; settled: boolean } | null = null;
38
+ let saveTimer: ReturnType<typeof setTimeout> | null = null;
39
+ const pendingPauseEvents = new Map<string, EventBase>();
40
+ const pauseStatus = new WeakMap<EventBase, LowQuotaEvent["status"]>();
41
+
42
+ function event(base: EventBase,
43
+ delivery: LowQuotaEvent["delivery"], status: LowQuotaEvent["status"]): void {
44
+ if (delivery === "pause-save") pauseStatus.set(base, status);
45
+ ledger.publish({ ...base, delivery, status, timestamp: Date.now() });
46
+ }
47
+ function cancelTimer(): void {
48
+ if (saveTimer) clearTimeout(saveTimer);
49
+ saveTimer = null;
50
+ }
51
+ function scheduleSave(delay = 0): void {
52
+ if (closed || saveTimer || saveFlight) return;
53
+ const ownerGeneration = generation;
54
+ saveTimer = setTimeout(() => {
55
+ saveTimer = null;
56
+ if (closed || ownerGeneration !== generation) {
57
+ for (const base of pendingPauseEvents.values()) {
58
+ if (pauseStatus.get(base) === "pending") event(base, "pause-save", "cancelled");
59
+ }
60
+ return;
61
+ }
62
+ void runSave();
63
+ }, delay);
64
+ saveTimer.unref?.();
65
+ }
66
+ function runSave(): Promise<void> {
67
+ if (closed || !dirty) return saveFlight ?? Promise.resolve();
68
+ if (saveFlight) return saveFlight;
69
+ cancelTimer();
70
+ const ownerGeneration = generation;
71
+ const saving = new Map(pendingPauseEvents);
72
+ const flightState = { events: saving, started: false, settled: false };
73
+ activeSave = flightState;
74
+ dirty = false;
75
+ saveFlight = Promise.resolve().then(async () => {
76
+ if (closed || ownerGeneration !== generation) {
77
+ return;
78
+ }
79
+ if (deps.persist) {
80
+ flightState.started = true;
81
+ return deps.persist(config);
82
+ }
83
+ // Import only for an actual deferred save, then recheck ownership: an import
84
+ // that resolves after a timed-out flush must not start a late config write.
85
+ const { saveConfigPreservingClaudeCode } = await import("../config/live-reconcile");
86
+ if (closed || ownerGeneration !== generation) {
87
+ return;
88
+ }
89
+ flightState.started = true;
90
+ saveConfigPreservingClaudeCode(config);
91
+ }).then(() => {
92
+ flightState.settled = true;
93
+ if (flightState.started) {
94
+ attempts = 0;
95
+ for (const [key, base] of saving) {
96
+ event(base, "pause-save", isCodexAccountPaused(config, base.accountId) ? "succeeded" : "cancelled");
97
+ if (pendingPauseEvents.get(key) === base) pendingPauseEvents.delete(key);
98
+ }
99
+ }
100
+ }, () => {
101
+ flightState.settled = true;
102
+ if (flightState.started || !closed) {
103
+ for (const base of saving.values()) event(base, "pause-save", "failed");
104
+ }
105
+ if (flightState.started) {
106
+ for (const [key, base] of saving) {
107
+ if (pendingPauseEvents.get(key) === base && closed) pendingPauseEvents.delete(key);
108
+ }
109
+ }
110
+ if (closed || ownerGeneration !== generation) return;
111
+ console.warn("[codex-low-quota] pause persistence failed");
112
+ dirty = true;
113
+ retryDelay = RETRY_DELAYS_MS[attempts++];
114
+ }).finally(() => {
115
+ if (activeSave === flightState) activeSave = null;
116
+ saveFlight = null;
117
+ if (dirty && !closed) {
118
+ if (retryDelay !== undefined) scheduleSave(retryDelay);
119
+ else if (attempts === 0) scheduleSave();
120
+ retryDelay = undefined;
121
+ }
122
+ });
123
+ return saveFlight;
124
+ }
125
+ function close(): void {
126
+ if (closed) return;
127
+ closed = true;
128
+ generation++;
129
+ cancelTimer();
130
+ for (const [key, base] of pendingPauseEvents) {
131
+ if (activeSave?.started && activeSave.events.get(key) === base) continue;
132
+ if (pauseStatus.get(base) === "pending") event(base, "pause-save", "cancelled");
133
+ pendingPauseEvents.delete(key);
134
+ }
135
+ for (const episode of episodes.values()) {
136
+ if (episode.notice === "in-flight" && episode.noticeBase) event(episode.noticeBase, "notice", "cancelled");
137
+ }
138
+ dirty = false;
139
+ autoPausedAccounts.clear();
140
+ unregister();
141
+ }
142
+ async function flush(): Promise<void> {
143
+ if (closed) return;
144
+ const deadline = Date.now() + FLUSH_DEADLINE_MS;
145
+ // Drain the in-flight write and any coalesced save before closing the owner.
146
+ while (saveFlight || dirty) {
147
+ cancelTimer();
148
+ const flight = saveFlight ?? runSave();
149
+ let timeout: ReturnType<typeof setTimeout> | undefined;
150
+ const completed = await Promise.race([
151
+ flight.then(() => true),
152
+ new Promise<false>(resolve => {
153
+ timeout = setTimeout(() => resolve(false), Math.max(0, deadline - Date.now()));
154
+ }),
155
+ ]);
156
+ if (timeout) clearTimeout(timeout);
157
+ if (!completed || Date.now() >= deadline) {
158
+ const inFlight = activeSave;
159
+ const stillRunning = inFlight !== null && inFlight.started && !inFlight.settled;
160
+ if (inFlight && stillRunning) {
161
+ for (const base of inFlight.events.values()) event(base, "pause-save", "pending");
162
+ }
163
+ console.warn(stillRunning
164
+ ? "[codex-low-quota] pause persistence flush timed out; in-flight save remains pending"
165
+ : "[codex-low-quota] pause persistence flush reached its deadline");
166
+ break;
167
+ }
168
+ if (attempts > RETRY_DELAYS_MS.length) break;
169
+ }
170
+ close();
171
+ }
172
+
173
+ const unregister = registerLowQuotaObserver((accountId, quota) => {
174
+ if (closed) return;
175
+ const policy = config.codexPool?.lowQuotaProtection;
176
+ const nextKey = JSON.stringify(policy);
177
+ if (policyKey !== nextKey) {
178
+ for (const episode of episodes.values()) {
179
+ if (episode.notice === "in-flight" && episode.noticeBase) event(episode.noticeBase, "notice", "cancelled");
180
+ }
181
+ episodes.clear();
182
+ autoPausedAccounts.clear();
183
+ policyKey = nextKey;
184
+ }
185
+ if (!policy?.enabled) return;
186
+ const liveIds = new Set((config.codexAccounts ?? []).filter(isSelectableCodexPoolAccount).map(account => account.id));
187
+ for (const id of autoPausedAccounts) if (!liveIds.has(id)) autoPausedAccounts.delete(id);
188
+ for (const [key, episode] of episodes) {
189
+ if (liveIds.has(key.split("\u0000")[0]!)) continue;
190
+ if (episode.notice === "in-flight" && episode.noticeBase) event(episode.noticeBase, "notice", "cancelled");
191
+ episodes.delete(key);
192
+ }
193
+ if (!liveIds.has(accountId)) return;
194
+ // A manual resume ends this policy's active pause, but each already-high
195
+ // window keeps its episode marker until recovery or a new reset.
196
+ if (!isCodexAccountPaused(config, accountId)) autoPausedAccounts.delete(accountId);
197
+ for (const window of ["short", "weekly"] as const) {
198
+ if (!policy.windows[window]) continue;
199
+ const percentUsed = quota[`${window}Percent`];
200
+ const rawReset = quota[`${window}ResetAt`];
201
+ if (typeof percentUsed !== "number" || !Number.isFinite(percentUsed) || percentUsed < 0 || percentUsed > 100) continue;
202
+ if (rawReset !== undefined && (!Number.isFinite(rawReset) || resetAtToMs(rawReset) <= Date.now())) continue;
203
+ const key = `${accountId}\u0000${window}`;
204
+ if (percentUsed < policy.threshold) {
205
+ const prior = episodes.get(key);
206
+ if (prior?.notice === "in-flight" && prior.noticeBase) event(prior.noticeBase, "notice", "cancelled");
207
+ episodes.delete(key);
208
+ continue;
209
+ }
210
+ const resetAt = rawReset === undefined ? null : resetAtToMs(rawReset);
211
+ const reset = resetAt === null ? "unknown" : String(resetAt);
212
+ let episode = episodes.get(key);
213
+ if (!episode || episode.reset !== reset) {
214
+ episode = { reset, notice: undefined, pausedByUs: false };
215
+ episodes.set(key, episode);
216
+ }
217
+ const base = { accountId, window, percentUsed, resetAt };
218
+ if (autoPausedAccounts.has(accountId) && isCodexAccountPaused(config, accountId)) {
219
+ episode.pausedByUs = true;
220
+ }
221
+ if (dirty && attempts > RETRY_DELAYS_MS.length) {
222
+ attempts = 0;
223
+ scheduleSave();
224
+ }
225
+ if (policy.actions.pause && !isCodexAccountPaused(config, accountId) && !episode.pausedByUs) {
226
+ setCodexAccountPaused(config, accountId, true);
227
+ autoPausedAccounts.add(accountId);
228
+ episode.pausedByUs = true;
229
+ pendingPauseEvents.set(key, base);
230
+ dirty = true;
231
+ attempts = 0;
232
+ retryDelay = undefined;
233
+ event(base, "pause-save", "pending");
234
+ scheduleSave();
235
+ }
236
+ // A manual resume leaves pausedByUs set until recovery or a new reset episode.
237
+ if (policy.actions.notify && episode.notice !== "in-flight" && episode.notice !== "logged" && episode.notice !== "delivered") {
238
+ console.warn(`[codex-low-quota] ${window === "short" ? "5-hour" : "weekly"} quota reached ${percentUsed}% used (threshold ${policy.threshold}%)`);
239
+ if (!deps.notify) {
240
+ event(base, "notice", "logged");
241
+ episode.notice = "logged";
242
+ continue;
243
+ }
244
+ episode.notice = "in-flight";
245
+ episode.noticeBase = base;
246
+ event(base, "notice", "pending");
247
+ try {
248
+ void Promise.resolve(deps.notify({ window, percentUsed, threshold: policy.threshold })).then(() => {
249
+ if (closed || episodes.get(key) !== episode) return;
250
+ event(base, "notice", "delivered");
251
+ episode.notice = "delivered";
252
+ episode.noticeBase = undefined;
253
+ }, () => {
254
+ if (closed || episodes.get(key) !== episode) return;
255
+ event(base, "notice", "failed");
256
+ episode.notice = "failed";
257
+ episode.noticeBase = undefined;
258
+ });
259
+ } catch {
260
+ event(base, "notice", "failed");
261
+ episode.notice = "failed";
262
+ episode.noticeBase = undefined;
263
+ }
264
+ }
265
+ }
266
+ });
267
+ return Object.assign(close, {
268
+ hasPendingSave: () => dirty || saveFlight !== null || saveTimer !== null,
269
+ flush,
270
+ listEvents: ledger.list,
271
+ });
272
+ }
@@ -0,0 +1,148 @@
1
+ import { getConfigDir } from "../config/paths";
2
+ import { parseRetryAfterMs } from "./routing/cooldown-math";
3
+
4
+ const BASE_DELAY_MS = 5 * 60_000;
5
+ const MAX_DELAY_MS = 60 * 60_000;
6
+ const MAX_ENTRIES = 256;
7
+
8
+ /** One capped schedule for failed queries and successful-but-blocked recovery. */
9
+ export function nextQuotaQueryDelay(previous = BASE_DELAY_MS / 2): number {
10
+ return Math.min(previous * 2, MAX_DELAY_MS);
11
+ }
12
+
13
+ type Attempt = { delay: number; after: number; retryAfterUntil?: number; poolAccountId?: string; inFlight: boolean; pending?: Promise<unknown>; resolve?: (result: unknown) => void };
14
+ // Keys contain configuration-home and caller-owned generation identifiers, never credentials.
15
+ const attempts = new Map<string, Attempt>();
16
+ const sharedDeadlines = new Map<string, { after: number; retryAfterUntil: number; poolAccountId?: string }>();
17
+ const scopedKey = (key: string) => `${getConfigDir()}\0${key}`;
18
+ const baseQueryKey = (key: string) => key.replace(/:post-reset:\d+$/, "");
19
+
20
+ /** The next eligible query time for the current home and credential generation. */
21
+ export function nextCodexUsageQueryAt(key: string): number | undefined {
22
+ const scoped = scopedKey(key);
23
+ const base = scopedKey(baseQueryKey(key));
24
+ return Math.max(attempts.get(scoped)?.after ?? 0, attempts.get(base)?.after ?? 0,
25
+ sharedDeadlines.get(base)?.after ?? 0) || undefined;
26
+ }
27
+
28
+ export interface CodexUsageOwner<T> {
29
+ kind: "owner";
30
+ response: Response;
31
+ /** Publish the parsed result to same-key joiners after validation and publication. */
32
+ settle(usable: boolean, result?: T): void;
33
+ }
34
+ export type CodexUsageRead<T> = CodexUsageOwner<T> | { kind: "joined"; result: T };
35
+
36
+ export interface CodexUsageSchedule {
37
+ /** Same credential deadline for an epoch-specific dispatch key. */
38
+ pacingKey?: string;
39
+ /** Direct/non-pool main reads retain their original uncached dispatch behavior. */
40
+ unpaced?: true;
41
+ /** Opaque pool identity for removal cleanup; never logged or persisted. */
42
+ poolAccountId?: string;
43
+ /** Recovery claims already have their own five-minute admission interval. */
44
+ recoveryProbe?: true;
45
+ /** The sweep's clock also drives its quota-query deadline. */
46
+ now?: () => number;
47
+ }
48
+
49
+ /** Removal invalidates even active reads, so their late settlements cannot restore pacing. */
50
+ export function pruneRemovedCodexPoolUsageAccounts(configuredIds: ReadonlySet<string>): void {
51
+ const homePrefix = `${getConfigDir()}\0`;
52
+ for (const [key, attempt] of attempts) {
53
+ if (key.startsWith(homePrefix) && attempt.poolAccountId
54
+ && !configuredIds.has(attempt.poolAccountId)) attempts.delete(key);
55
+ }
56
+ for (const [key, deadline] of sharedDeadlines) {
57
+ if (key.startsWith(homePrefix) && deadline.poolAccountId
58
+ && !configuredIds.has(deadline.poolAccountId)) sharedDeadlines.delete(key);
59
+ }
60
+ }
61
+
62
+ /** Shared by main, pool and 401-replay usage reads. Cache bypass does not bypass pacing. */
63
+ export async function fetchCodexUsage<T>(
64
+ key: string,
65
+ init: RequestInit,
66
+ onDispatch?: () => void,
67
+ schedule: CodexUsageSchedule = {},
68
+ ): Promise<CodexUsageRead<T> | null> {
69
+ if (schedule.unpaced) {
70
+ onDispatch?.();
71
+ const response = await fetch("https://chatgpt.com/backend-api/wham/usage", init);
72
+ return { kind: "owner", response, settle: () => {} };
73
+ }
74
+ const pacingKey = scopedKey(schedule.pacingKey ?? baseQueryKey(key));
75
+ key = scopedKey(key);
76
+ const previous = attempts.get(key);
77
+ if (previous?.inFlight) {
78
+ const result = (await previous.pending) as T | undefined;
79
+ return result === undefined ? null : { kind: "joined", result };
80
+ }
81
+ const now = schedule.now ?? Date.now;
82
+ const base = attempts.get(pacingKey);
83
+ const shared = sharedDeadlines.get(pacingKey);
84
+ if (schedule.recoveryProbe
85
+ ? Math.max(previous?.retryAfterUntil ?? 0, base?.retryAfterUntil ?? 0,
86
+ shared?.retryAfterUntil ?? 0) > now()
87
+ : Math.max(previous?.after ?? 0, base?.after ?? 0, shared?.after ?? 0) > now()) return null;
88
+ if (!previous && attempts.size >= MAX_ENTRIES) {
89
+ const evict = [...attempts].find(([, entry]) => !entry.inFlight)?.[0];
90
+ if (!evict) return null;
91
+ attempts.delete(evict);
92
+ }
93
+ let resolve!: (result: unknown) => void;
94
+ const pending = new Promise<unknown>(done => { resolve = done; });
95
+ const attempt: Attempt = { delay: previous?.delay ?? BASE_DELAY_MS / 2, after: 0,
96
+ ...(schedule.poolAccountId ? { poolAccountId: schedule.poolAccountId } : {}),
97
+ inFlight: true, pending, resolve };
98
+ attempts.set(key, attempt);
99
+ let response: Response | undefined;
100
+ const settle = (usable: boolean, result?: T) => {
101
+ if (!attempt.inFlight) return;
102
+ attempt.inFlight = false;
103
+ if (attempts.get(key) === attempt) {
104
+ if (usable || response?.status === 401 || response?.status === 403) {
105
+ attempts.delete(key);
106
+ if (usable) {
107
+ sharedDeadlines.delete(pacingKey);
108
+ if (pacingKey !== key && attempts.get(pacingKey)?.inFlight === false)
109
+ attempts.delete(pacingKey);
110
+ }
111
+ }
112
+ else {
113
+ const at = now();
114
+ const delay = schedule.recoveryProbe ? BASE_DELAY_MS : nextQuotaQueryDelay(previous?.delay);
115
+ const retryAfter = parseRetryAfterMs(response?.headers.get("retry-after"), at) ?? 0;
116
+ const settledBase = pacingKey === key ? previous : attempts.get(pacingKey);
117
+ const settledShared = sharedDeadlines.get(pacingKey);
118
+ const after = Math.max(previous?.after ?? 0, settledBase?.after ?? 0, settledShared?.after ?? 0,
119
+ at + Math.max(delay, retryAfter));
120
+ const retryAfterUntil = Math.max(previous?.retryAfterUntil ?? 0,
121
+ settledBase?.retryAfterUntil ?? 0, settledShared?.retryAfterUntil ?? 0, at + retryAfter);
122
+ attempts.set(key, { delay, after,
123
+ ...(attempt.poolAccountId ? { poolAccountId: attempt.poolAccountId } : {}),
124
+ ...(retryAfterUntil > 0 ? { retryAfterUntil } : {}), inFlight: false });
125
+ if (pacingKey !== key) {
126
+ if (!sharedDeadlines.has(pacingKey) && sharedDeadlines.size >= MAX_ENTRIES)
127
+ sharedDeadlines.delete(sharedDeadlines.keys().next().value!);
128
+ sharedDeadlines.set(pacingKey, { after, retryAfterUntil,
129
+ ...(attempt.poolAccountId ? { poolAccountId: attempt.poolAccountId } : {}) });
130
+ }
131
+ }
132
+ }
133
+ attempt.resolve?.(result);
134
+ };
135
+ try {
136
+ onDispatch?.();
137
+ response = await fetch("https://chatgpt.com/backend-api/wham/usage", init);
138
+ return { kind: "owner", response, settle };
139
+ } catch (error) {
140
+ settle(false);
141
+ throw error;
142
+ }
143
+ }
144
+
145
+ export function resetQuotaQueryBackoffForTests(): void {
146
+ attempts.clear();
147
+ sharedDeadlines.clear();
148
+ }
@@ -4,6 +4,7 @@ import { atomicWriteFile, getConfigDir } from "../config";
4
4
  import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
5
5
  import { isThirtyDayOnlyCodexPlan } from "./plan";
6
6
  import { stampCodexQuotaUsageObservation } from "./quota-observation-freshness";
7
+ import { observeCodexLowQuota } from "./low-quota-observer";
7
8
  import { MAIN_CODEX_ACCOUNT_ID } from "./account-id";
8
9
  import { getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, type MainQuotaWriter } from "./main-account-cache";
9
10
 
@@ -276,7 +277,7 @@ function snapshotHasUsage(quota: Omit<StoredAccountQuota, "updatedAt">): boolean
276
277
  return snapshotHasWeekly(quota) || snapshotHasMonthly(quota) || snapshotHasShort(quota) || snapshotHasCustom(quota);
277
278
  }
278
279
  /**
279
- * Publish parsed display quota and separately validated main-policy evidence after writer checks.
280
+ * Publish parsed display quota and separately validated policy evidence after writer checks.
280
281
  * A null policy observation retains only the matching main identity's previous evidence;
281
282
  * transient replacement markers are consumed during merging and never enter stored snapshots.
282
283
  */
@@ -285,7 +286,7 @@ export function setAccountQuotaFromParsed(
285
286
  quota: Omit<StoredAccountQuota, "updatedAt"> | null,
286
287
  writerGeneration = captureConfigGeneration(),
287
288
  mainWriter?: MainQuotaWriter,
288
- policyQuota: MainPolicyQuotaObservation | null = quota,
289
+ policyQuota: MainPolicyQuotaObservation | null = accountId === MAIN_CODEX_ACCOUNT_ID ? quota : null,
289
290
  historyEvidence?: QuotaObservationEvidence,
290
291
  ): void {
291
292
  quota = withoutRetiredCodexQuota(quota);
@@ -322,6 +323,7 @@ export function setAccountQuotaFromParsed(
322
323
  schedulePersistAccountQuotas();
323
324
  // Credits carry the previous usage tuple; they must not refresh its observation clock.
324
325
  if (!(quota.resetCredits !== undefined && !snapshotHasUsage(quota))) {
326
+ if (!isMain && policyQuota) observeCodexLowQuota(accountId, policyQuota);
325
327
  notifyCodexQuotaSnapshot(accountId, next);
326
328
  }
327
329
  }
@@ -650,6 +652,9 @@ export function updateAccountQuota(
650
652
  // otherwise bypass detection AND leave a stale baseline that corrupts the next real diff.
651
653
  // The credits-only path at setAccountQuotaFromParsed deliberately does not notify; this one
652
654
  // writes window percentages and deadlines, so it must.
655
+ if (accountId !== MAIN_CODEX_ACCOUNT_ID && nextWeekly !== undefined && !isInvalidPolicyUsagePercent(weekly)) observeCodexLowQuota(accountId, {
656
+ weeklyPercent: nextWeekly, weeklyResetAt: nextWeeklyResetAt,
657
+ });
653
658
  notifyCodexQuotaSnapshot(accountId, quota);
654
659
  }
655
660
 
@@ -3,7 +3,9 @@ import { lstatSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import * as z from "zod/v4";
5
5
  import { compactionRecoveryConfigError } from "./schema/compaction-recovery";
6
+ import { blockedModelRedirectsError } from "./schema/blocked-model-redirects";
6
7
  import type { OcxConfig } from "../types";
8
+ import { parseAnthropicModelRoutes } from "../oauth/anthropic-model-routes";
7
9
  import { configReasoningPinsConfigError } from "./provider-validation";
8
10
  import { loopbackCompanionAllowed } from "../codex/loopback-target";
9
11
  import { UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD } from "../codex/upstream-host-health";
@@ -103,6 +105,7 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf
103
105
  warnings.push(...inheritedFastWireConflictProviderNames(normalized).map(inheritedFastWireConflictWarning));
104
106
  warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized));
105
107
  warnings.push(...degradedListenerWarnings(rawParsed, normalized));
108
+ if (blockedModelRedirectsError(rawParsed)) warnings.push("blockedModelRedirects ignored: expected a map of nonempty model keys to nonempty string targets");
106
109
  const quotaAutoRefreshWarning = degradedCodexQuotaAutoRefreshWarning(rawParsed, normalized);
107
110
  if (quotaAutoRefreshWarning) warnings.push(quotaAutoRefreshWarning);
108
111
  if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
@@ -616,7 +619,13 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
616
619
  if (memoryModels !== undefined && !memoryModelsSchema.safeParse(memoryModels).success) {
617
620
  return { ok: false, error: "schema_invalid: memoryModels: requires a nonblank model and an optional declared reasoningEffort per configured phase, and no other fields" };
618
621
  }
619
- const boundaryError = compactionRecoveryConfigError(value) ?? configReasoningPinsConfigError(value)
622
+ const routeValue = (rawConfigRecord(value)?.anthropicAccountPool as Record<string, unknown> | undefined)?.routes;
623
+ if (routeValue !== undefined) {
624
+ const parsed = parseAnthropicModelRoutes(routeValue);
625
+ if (!parsed.ok) return { ok: false, error: `schema_invalid: anthropicAccountPool.routes: ${parsed.error}` };
626
+ }
627
+ const boundaryError = blockedModelRedirectsError(value)
628
+ ?? compactionRecoveryConfigError(value) ?? configReasoningPinsConfigError(value)
620
629
  ?? blankHostnameError(value)
621
630
  ?? claudeSubagentEffortError(value)
622
631
  ?? appOwnedMemoryBudgetError(value)
@@ -1,6 +1,7 @@
1
1
  import { chmodSync, existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { compactionRecoveryConfigError } from "./schema/compaction-recovery";
4
+ import { blockedModelRedirectsError } from "./schema/blocked-model-redirects";
4
5
  import {
5
6
  modelPinnedEffortsConfigError,
6
7
  pinnedReasoningEffortConfigError,
@@ -36,6 +37,7 @@ import {
36
37
  remoteGuiConfigSchema,
37
38
  retryOn429PolicySchema,
38
39
  retryOnResetPolicySchema,
40
+ transientRetryOn5xxPolicySchema,
39
41
  runtimeRoleSchema,
40
42
  spendSchema,
41
43
  } from "./schema/leaf-validators";
@@ -120,6 +122,7 @@ export function warnDegradedCompactionRouting(rawParsed: unknown, validated: Ocx
120
122
  */
121
123
  export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxConfig): void {
122
124
  if (compactionRecoveryConfigError(rawParsed)) console.warn("⚠️ invalid compactionRecovery disabled; the original compaction failure is preserved");
125
+ if (blockedModelRedirectsError(rawParsed)) console.warn("⚠️ invalid blockedModelRedirects ignored; provider routing remains available");
123
126
  warnDegradedStreamMode(rawParsed, validated);
124
127
  warnDegradedCompactionRouting(rawParsed, validated);
125
128
  warnDegradedMemoryModels(rawParsed, validated);
@@ -241,6 +244,20 @@ export function retryOnResetPolicyConfigError(policy: unknown): string | null {
241
244
  return strictPolicyConfigError("retryOnReset", retryOnResetPolicySchema, policy);
242
245
  }
243
246
 
247
+ /**
248
+ * Management write-boundary validation for `transientRetryOn5xx`, with the same fail-closed
249
+ * contract as the other retry policies.
250
+ *
251
+ * The load-time schema does not degrade a malformed block: `transientRetryOn5xxPolicySchema` has
252
+ * no `.catch`, so a bad value fails the whole config parse and the loader substitutes the default
253
+ * config, taking every provider with it. That makes this check the only place a malformed ladder
254
+ * can be refused without losing the file, and PATCH never runs the schema — only POST does,
255
+ * through `validateConfigCandidate`.
256
+ */
257
+ export function transientRetryOn5xxPolicyConfigError(policy: unknown): string | null {
258
+ return strictPolicyConfigError("transientRetryOn5xx", transientRetryOn5xxPolicySchema, policy);
259
+ }
260
+
244
261
  /**
245
262
  * The shared body of both. Written once because the two differ only in the field name they
246
263
  * report, and a second hand-copied formatter is a second place for the redaction to be
@@ -34,8 +34,7 @@ export class ConfigMutationLockError extends Error {
34
34
  }
35
35
  }
36
36
 
37
- function configMutationDatabasePath(): string {
38
- const dir = getConfigDir();
37
+ function configMutationDatabasePath(dir: string = getConfigDir()): string {
39
38
  // First statement on purpose: a rejected mutation must leave nothing behind, not a
40
39
  // freshly created/chmod'd directory or database. See src/lib/test-home-guard.ts.
41
40
  assertNotRealHomeUnderTest(dir);
@@ -102,7 +101,7 @@ let configMutationDatabase: Database | null = null;
102
101
  *
103
102
  * Reentrancy is limited to the current synchronous call stack; never return a Promise from `fn`.
104
103
  */
105
- export function withConfigMutationLockSync<T>(fn: () => T): T {
104
+ export function withConfigMutationLockSync<T>(fn: () => T, dir?: string): T {
106
105
  if (configMutationLockDepth > 0) {
107
106
  configMutationLockDepth += 1;
108
107
  try {
@@ -111,7 +110,7 @@ export function withConfigMutationLockSync<T>(fn: () => T): T {
111
110
  configMutationLockDepth -= 1;
112
111
  }
113
112
  }
114
- const path = configMutationDatabasePath();
113
+ const path = configMutationDatabasePath(dir);
115
114
  let database: Database | undefined;
116
115
  let transactionOpen = false;
117
116
  try {
@@ -293,7 +293,7 @@ export function setProcessCommandLinePlatformForTests(next: NodeJS.Platform | nu
293
293
  processCommandLinePlatformForTests = next;
294
294
  }
295
295
 
296
- function readProcessCommandLine(pid: number): string | undefined {
296
+ export function readProcessCommandLine(pid: number): string | undefined {
297
297
  if (!Number.isSafeInteger(pid) || pid <= 0) return undefined;
298
298
  const platform = processCommandLinePlatformForTests ?? process.platform;
299
299
  try {
@@ -142,6 +142,15 @@ export function apiKeyTransportConfigError(
142
142
  return null;
143
143
  }
144
144
 
145
+ /** Keep local project-file disclosure specific to the Command Code native adapter. */
146
+ export function projectContextConfigError(provider: Pick<OcxProviderConfig, "adapter" | "projectContext">): string | null {
147
+ if (provider.projectContext === undefined) return null;
148
+ if (provider.projectContext !== "off" && provider.projectContext !== "on") {
149
+ return 'projectContext must be "off" or "on"';
150
+ }
151
+ return provider.adapter === "command-code" ? null : "projectContext is supported only by the command-code adapter";
152
+ }
153
+
145
154
  /** Shared strict boundary for the per-provider upstream HTTP-version pin. */
146
155
  export function upstreamHttpVersionConfigError(value: unknown): string | null {
147
156
  if (value === undefined || value === null) return null;
@@ -383,6 +392,19 @@ function capabilityRecord(value: unknown): value is Record<string, unknown> {
383
392
  && [Object.prototype, null].includes(Object.getPrototypeOf(value));
384
393
  }
385
394
 
395
+ /** Strict per-model context-tier values; PATCH accepts null to clear the entire map. */
396
+ export function contextTierRecordConfigError(value: unknown, allowClear = false): string | null {
397
+ if (value === undefined || (allowClear && value === null)) return null;
398
+ if (!capabilityRecord(value)) return "modelContextTiers must be a plain object";
399
+ if (Object.keys(value).length > MODEL_DISCOVERY_MAX_MODELS) return "modelContextTiers has too many models";
400
+ for (const [id, tier] of Object.entries(value)) {
401
+ if (!isValidModelDiscoveryModelId(id) || ["__proto__", "prototype", "constructor"].includes(id))
402
+ return "modelContextTiers keys must be exact non-reserved model ids";
403
+ if (tier !== "default" && tier !== "long_context") return "modelContextTiers values must be default or long_context";
404
+ }
405
+ return null;
406
+ }
407
+
386
408
  /** Strict writes; only PATCH may carry deletion tombstones. Model IDs are exact. */
387
409
  export function modelCapabilitiesConfigError(value: unknown, allowTombstones = false): string | null {
388
410
  if (value === undefined || (allowTombstones && value === null)) return null;
@@ -0,0 +1,17 @@
1
+ import * as z from "zod/v4";
2
+
3
+ /** Keep invalid hand edits out of the live map without losing the rest of config.json. */
4
+ export const blockedModelRedirectsSchema = z.record(
5
+ z.string().refine(key => key.trim().length > 0),
6
+ z.string().refine(target => target.trim().length > 0),
7
+ );
8
+
9
+ /** Candidate writes must reject maps the read schema would silently discard. */
10
+ export function blockedModelRedirectsError(value: unknown): string | undefined {
11
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
12
+ const raw = (value as Record<string, unknown>).blockedModelRedirects;
13
+ if (raw === undefined) return undefined;
14
+ return blockedModelRedirectsSchema.safeParse(raw).success
15
+ ? undefined
16
+ : "schema_invalid: blockedModelRedirects requires a map of nonempty model keys to nonempty string targets";
17
+ }
@@ -1,5 +1,6 @@
1
1
  import * as z from "zod/v4";
2
2
  import { compactionRecoverySchema } from "./compaction-recovery";
3
+ import { blockedModelRedirectsSchema } from "./blocked-model-redirects";
3
4
  import {
4
5
  agentTaskRecoverySchema,
5
6
  catalogAutoRefreshSchema,
@@ -284,7 +285,9 @@ export const configSchema = z.object({
284
285
  // parse: a hand-edited typo must never trip the backup-and-defaults repair
285
286
  // path below and wipe providers/pool accounts. Warning emitted in loadConfig.
286
287
  streamMode: z.enum(["auto", "legacy-tee", "eager-relay"]).optional().catch(undefined),
287
- blockedModelRedirects: z.record(z.string(), z.string()).optional().catch(undefined),
288
+ blockedModelRedirects: blockedModelRedirectsSchema.optional().catch(undefined),
289
+ // Preserve malformed hand edits for a local routing error; candidate writes use the shared parser.
290
+ anthropicAccountPool: z.unknown().optional(),
288
291
  // Same degrade-don't-reject rationale as the fields above: a hand-edited
289
292
  // non-string must not trip the backup-and-defaults repair path. Unset then
290
293
  // takes the canonical sideband path (src/server/live.ts normalizeSidebandRoot).