@bitkyc08/opencodex 2.52.0-preview.20260911 → 2.52.0-preview.20260912

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 (166) hide show
  1. package/gui/dist/assets/index-D_t6sCWs.js +115 -0
  2. package/gui/dist/assets/index-EdoPnm9_.css +1 -0
  3. package/gui/dist/index.html +2 -2
  4. package/gui/dist/provider-icons/devin.svg +49 -0
  5. package/gui/dist/provider-icons/omo.svg +42 -0
  6. package/package.json +3 -1
  7. package/src/AGENTS.md +1 -1
  8. package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +0 -1
  9. package/src/adapters/command-code.ts +0 -1
  10. package/src/adapters/cursor/checkpoint-store.ts +37 -0
  11. package/src/adapters/cursor/live-transport.ts +74 -2
  12. package/src/adapters/cursor.ts +6 -0
  13. package/src/adapters/devin/cloud-direct/auth.ts +264 -0
  14. package/src/adapters/devin/cloud-direct/catalog.ts +306 -0
  15. package/src/adapters/devin/cloud-direct/chat.ts +1274 -0
  16. package/src/adapters/devin/cloud-direct/index.ts +65 -0
  17. package/src/adapters/devin/cloud-direct/metadata.ts +134 -0
  18. package/src/adapters/devin/cloud-direct/wire.ts +206 -0
  19. package/src/adapters/devin/live-models.ts +133 -0
  20. package/src/adapters/devin-cli/acp.ts +204 -0
  21. package/src/adapters/devin-cli/adapter.ts +345 -0
  22. package/src/adapters/devin-cli/binary.ts +69 -0
  23. package/src/adapters/devin-cli/models.ts +57 -0
  24. package/src/adapters/devin.ts +326 -0
  25. package/src/adapters/google.ts +1 -1
  26. package/src/adapters/openai-chat.ts +31 -10
  27. package/src/adapters/openai-responses.ts +16 -1
  28. package/src/adapters/registry.ts +25 -1
  29. package/src/bridge.ts +8 -2
  30. package/src/claude/inbound-cache-stabilize.ts +130 -0
  31. package/src/claude/inbound.ts +45 -5
  32. package/src/cli/account-auth.ts +60 -1
  33. package/src/cli/account-extended.ts +23 -30
  34. package/src/cli/account.ts +2 -1
  35. package/src/cli/capabilities.ts +50 -8
  36. package/src/cli/config-command.ts +2 -2
  37. package/src/cli/dispatch.ts +14 -2
  38. package/src/cli/export-command.ts +11 -25
  39. package/src/cli/help.ts +2 -2
  40. package/src/cli/opencode.ts +5 -0
  41. package/src/cli/registry.ts +15 -4
  42. package/src/clients/config-export/cline.ts +71 -0
  43. package/src/clients/config-export/contracts.ts +10 -1
  44. package/src/clients/config-export/model-metadata.ts +33 -0
  45. package/src/clients/config-export/zcode.ts +23 -12
  46. package/src/clients/config-export.ts +156 -4
  47. package/src/codex/account-store.ts +7 -2
  48. package/src/codex/auth-context.ts +1 -1
  49. package/src/codex/catalog/effort.ts +4 -3
  50. package/src/codex/catalog/metadata.ts +8 -4
  51. package/src/codex/catalog/native-models.ts +24 -4
  52. package/src/codex/catalog/parsing.ts +19 -1
  53. package/src/codex/catalog/provider-fetch.ts +57 -0
  54. package/src/codex/catalog/sync.ts +1 -1
  55. package/src/codex/context-compat.ts +97 -0
  56. package/src/codex/context-owner.ts +201 -0
  57. package/src/codex/history-provider.ts +161 -14
  58. package/src/codex/inject-coordination.ts +3 -2
  59. package/src/codex/inject.ts +128 -9
  60. package/src/codex/pool-rotation.ts +8 -292
  61. package/src/codex/quota.ts +41 -12
  62. package/src/codex/retired-model-migration.ts +41 -0
  63. package/src/codex/routing.ts +327 -22
  64. package/src/codex/warmup.ts +2 -2
  65. package/src/combos/failover.ts +5 -0
  66. package/src/combos/resolve.ts +11 -15
  67. package/src/config.ts +46 -14
  68. package/src/generated/compatibility-version.json +293 -113
  69. package/src/grok/grpc-web.ts +120 -0
  70. package/src/grok/reset-coupon-ledger.ts +139 -0
  71. package/src/grok/reset-coupons.ts +278 -0
  72. package/src/integrations/catalog-refresh.ts +1 -1
  73. package/src/integrations/cline-document.ts +73 -0
  74. package/src/integrations/cline-io.ts +149 -0
  75. package/src/integrations/cline-transaction.ts +42 -0
  76. package/src/integrations/config-io.ts +21 -2
  77. package/src/integrations/journal.ts +43 -1
  78. package/src/integrations/omp-yaml-source.ts +123 -4
  79. package/src/integrations/ownership-policy.ts +7 -0
  80. package/src/integrations/ownership.ts +36 -0
  81. package/src/integrations/registry.ts +27 -0
  82. package/src/integrations/state.ts +5 -2
  83. package/src/integrations/store.ts +6 -0
  84. package/src/integrations/writer.ts +53 -9
  85. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  86. package/src/lib/abort.ts +36 -0
  87. package/src/lib/local-destinations.ts +1 -1
  88. package/src/lib/upstream-retry.ts +36 -1
  89. package/src/oauth/account-quota-rank.ts +11 -0
  90. package/src/oauth/callback-server.ts +10 -5
  91. package/src/oauth/devin/api-base.ts +63 -0
  92. package/src/oauth/devin/login.ts +1 -0
  93. package/src/oauth/devin/register-user.ts +186 -0
  94. package/src/oauth/devin/types.ts +71 -0
  95. package/src/oauth/devin-cli.ts +149 -0
  96. package/src/oauth/devin.ts +170 -0
  97. package/src/oauth/generic-account-failover.ts +169 -1
  98. package/src/oauth/index.ts +20 -1
  99. package/src/oauth/login-cli.ts +19 -5
  100. package/src/oauth/pool-kernel.ts +321 -0
  101. package/src/oauth/pool-settings-capability.ts +127 -9
  102. package/src/oauth/store.ts +7 -3
  103. package/src/oauth/token-guardian.ts +1 -1
  104. package/src/providers/codebuddy-models.ts +0 -3
  105. package/src/providers/command-code-efforts.ts +23 -4
  106. package/src/providers/default-aliases.ts +4 -0
  107. package/src/providers/derive.ts +8 -0
  108. package/src/providers/devin-cli-authmode-migration.ts +57 -0
  109. package/src/providers/key-failover.ts +175 -0
  110. package/src/providers/model-rename-startup.ts +21 -1
  111. package/src/providers/qoder-models.ts +0 -1
  112. package/src/providers/quota-key-accounts.ts +50 -0
  113. package/src/providers/quota-routing-cache.ts +65 -6
  114. package/src/providers/quota-types.ts +7 -0
  115. package/src/providers/quota.ts +113 -30
  116. package/src/providers/registry.ts +237 -77
  117. package/src/providers/stale-context-window-migration.ts +92 -0
  118. package/src/providers/zai-responses-migration.ts +45 -0
  119. package/src/quota/reset-observer.ts +2 -1
  120. package/src/quota/reset-seen-store.ts +9 -1
  121. package/src/remote-control/crypto.ts +442 -0
  122. package/src/remote-control/host.ts +175 -0
  123. package/src/remote-control/index.ts +100 -0
  124. package/src/remote-control/protocol.ts +200 -0
  125. package/src/remote-control/relay.ts +162 -0
  126. package/src/remote-control/workspace-agent-protocol.ts +246 -0
  127. package/src/remote-control/workspace-rpc-framing.ts +128 -0
  128. package/src/remote-control/workspace-tools.ts +237 -0
  129. package/src/remote-control/workspace-utf8.ts +24 -0
  130. package/src/responses/custom-tool-compat.ts +23 -0
  131. package/src/router.ts +6 -1
  132. package/src/routing/compatibility/behavior.ts +3 -0
  133. package/src/server/adapter-resolve.ts +6 -2
  134. package/src/server/auth-cors.ts +71 -6
  135. package/src/server/chat-completions.ts +5 -3
  136. package/src/server/chat-native.ts +9 -0
  137. package/src/server/claude-messages.ts +25 -30
  138. package/src/server/context-history.ts +207 -0
  139. package/src/server/images.ts +28 -1
  140. package/src/server/index.ts +42 -22
  141. package/src/server/live.ts +2 -1
  142. package/src/server/management/agent-settings-routes.ts +7 -1
  143. package/src/server/management/config-routes.ts +3 -3
  144. package/src/server/management/grok-coupon-routes.ts +287 -0
  145. package/src/server/management/integration-routes.ts +6 -1
  146. package/src/server/management/oauth-account-routes.ts +124 -7
  147. package/src/server/management/provider-routes.ts +77 -2
  148. package/src/server/management/route-registry.ts +6 -1
  149. package/src/server/management-api.ts +7 -0
  150. package/src/server/request-log.ts +4 -0
  151. package/src/server/responses/codex-ws-exchange.ts +90 -14
  152. package/src/server/responses/codex-ws-wire.ts +51 -1
  153. package/src/server/responses/compact.ts +8 -1
  154. package/src/server/responses/core.ts +188 -22
  155. package/src/server/responses/ws-upstream.ts +1 -1
  156. package/src/server/responses-undeclared-tool-guard.ts +261 -12
  157. package/src/server/zai-responses-startup.ts +21 -0
  158. package/src/types/config.ts +33 -3
  159. package/src/types/provider.ts +54 -4
  160. package/src/types/request.ts +1 -1
  161. package/src/types/tools.ts +36 -6
  162. package/src/update/job.ts +33 -11
  163. package/src/vision/plan.ts +40 -37
  164. package/src/web-search/index.ts +1 -0
  165. package/gui/dist/assets/index-BoBRSehJ.css +0 -1
  166. package/gui/dist/assets/index-Dx0xv2EA.js +0 -115
@@ -1,295 +1,11 @@
1
- import type { OcxAccountPoolRotationStrategy } from "../types";
2
- import type { GenerationContext } from "../lib/state-store-sweeper";
3
-
4
- export const POOL_KEY_CODEX = "codex";
5
- export const POOL_KEY_ANTHROPIC = "anthropic";
6
-
7
- interface SelectionState {
8
- activeKey?: string;
9
- successes: number;
10
- currentWeights: Map<string, number>;
11
- }
12
-
13
- const selectionState = new Map<string, SelectionState>();
14
- let lastReconciledGeneration = 0;
15
-
16
- const DEFAULT_STICKY_LIMIT = 1;
17
- const MIN_STICKY_LIMIT = 1;
18
- const MAX_STICKY_LIMIT = 100;
19
- const DEFAULT_STRATEGY: OcxAccountPoolRotationStrategy = "quota";
20
- const VALID_STRATEGIES = new Set<OcxAccountPoolRotationStrategy>(["quota", "round-robin", "fill-first"]);
21
-
22
- /** Selection order for an account with no stored preference: one flat tier. */
23
- export const DEFAULT_ACCOUNT_PRIORITY = 0;
24
- export const MIN_ACCOUNT_PRIORITY = -100;
25
- export const MAX_ACCOUNT_PRIORITY = 100;
26
-
27
- /** Strict parse for management APIs — returns null instead of defaulting. */
28
- export function parseAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy | null {
29
- if (typeof raw === "string" && VALID_STRATEGIES.has(raw as OcxAccountPoolRotationStrategy)) {
30
- return raw as OcxAccountPoolRotationStrategy;
31
- }
32
- return null;
33
- }
34
-
35
- /** Strict parse for management APIs — returns null instead of defaulting. */
36
- export function parseAccountPoolStickyLimit(raw: unknown): number | null {
37
- if (typeof raw === "number" && Number.isInteger(raw) && raw >= MIN_STICKY_LIMIT && raw <= MAX_STICKY_LIMIT) {
38
- return raw;
39
- }
40
- return null;
41
- }
42
-
43
- export function normalizeAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy {
44
- return parseAccountPoolStrategy(raw) ?? DEFAULT_STRATEGY;
45
- }
46
-
47
- export function normalizeAccountPoolStickyLimit(raw: unknown): number {
48
- return parseAccountPoolStickyLimit(raw) ?? DEFAULT_STICKY_LIMIT;
49
- }
50
-
51
- /** Strict parse for management APIs — returns null instead of defaulting. */
52
- export function parseAccountPriority(raw: unknown): number | null {
53
- if (
54
- typeof raw === "number"
55
- && Number.isInteger(raw)
56
- && raw >= MIN_ACCOUNT_PRIORITY
57
- && raw <= MAX_ACCOUNT_PRIORITY
58
- ) {
59
- return raw;
60
- }
61
- return null;
62
- }
63
-
64
- export function normalizeAccountPriority(raw: unknown): number {
65
- return parseAccountPriority(raw) ?? DEFAULT_ACCOUNT_PRIORITY;
66
- }
67
-
68
1
  /**
69
- * Narrow an already-eligible account list to the highest selection-order tier that
70
- * still has usable quota. Priority is an *ordering* boundary layered on top of
71
- * eligibility: it never admits an account the caller already filtered out, and it
72
- * never keeps the pool on a tier whose every member is drained.
2
+ * Compatibility shim. The rotation primitives moved to `src/oauth/pool-kernel.ts`
3
+ * so every credential kind can share them, not only Codex and Anthropic.
73
4
  *
74
- * Contract (each clause is load-bearing for "no behavior change when unconfigured"):
75
- * - one distinct priority across `ids` (the unconfigured case) returns `ids` unchanged,
76
- * so today's pick sequence is preserved byte for byte;
77
- * - input order is preserved inside the returned tier, which keeps the `__main__`
78
- * head-of-list bias and the first-index tie-break used by SWRR/lowest-usage;
79
- * - every tier drained returns `ids` unchanged, reproducing today's
80
- * stay-put-until-429 behavior rather than inventing a pick;
81
- * - a `pinnedId` that is present *and* has headroom lowers the ceiling to its own
82
- * tier, which is what makes a manual "use this now" survive round-robin and
83
- * fill-first without any mutable selection state. A drained or absent pin is
84
- * ignored, so the pin expires on its own once the account crosses the threshold.
5
+ * This file stays because the move is behaviour-preserving and its importers are
6
+ * spread across files that other work owns right now. Re-exporting keeps
7
+ * `routing.ts`, `auth-api.ts`, `account-priority.ts` and
8
+ * `state-store-registrations.ts` on their existing import path, so the extraction
9
+ * lands without editing any of them.
85
10
  */
86
- export function selectPriorityTier(
87
- ids: readonly string[],
88
- priorityOf: (id: string) => number,
89
- hasHeadroom: (id: string) => boolean,
90
- pinnedId?: string,
91
- ): readonly string[] {
92
- // Readonly out as well as in: the no-change cases return the caller's own array, so a
93
- // mutating caller would corrupt its input in exactly the cases that must not change.
94
- const list = ids;
95
- if (list.length <= 1) return list;
96
-
97
- const priorities = list.map(priorityOf);
98
- const firstPriority = priorities[0]!;
99
- if (priorities.every(priority => priority === firstPriority)) return list;
100
-
101
- let ceiling = Number.POSITIVE_INFINITY;
102
- if (pinnedId !== undefined) {
103
- const pinnedIndex = list.indexOf(pinnedId);
104
- if (pinnedIndex >= 0 && hasHeadroom(pinnedId)) ceiling = priorities[pinnedIndex]!;
105
- }
106
-
107
- const tiers = [...new Set(priorities)].sort((a, b) => b - a);
108
- for (const tier of tiers) {
109
- if (tier > ceiling) continue;
110
- const members = list.filter((_, index) => priorities[index] === tier);
111
- if (members.some(hasHeadroom)) return members;
112
- }
113
- return list;
114
- }
115
-
116
- function getOrCreateState(poolKey: string): SelectionState {
117
- let state = selectionState.get(poolKey);
118
- if (!state) {
119
- state = { successes: 0, currentWeights: new Map() };
120
- selectionState.set(poolKey, state);
121
- }
122
- return state;
123
- }
124
-
125
- function cloneSelectionState(state: SelectionState): SelectionState {
126
- return {
127
- activeKey: state.activeKey,
128
- successes: state.successes,
129
- currentWeights: new Map(state.currentWeights),
130
- };
131
- }
132
-
133
- function smoothWeightedIndex(ids: readonly string[], state: SelectionState): number {
134
- let best = -1;
135
- let bestScore = Number.NEGATIVE_INFINITY;
136
- let total = 0;
137
- const weight = 1;
138
- for (let i = 0; i < ids.length; i++) {
139
- const id = ids[i]!;
140
- const score = (state.currentWeights.get(id) ?? 0) + weight;
141
- state.currentWeights.set(id, score);
142
- total += weight;
143
- if (score > bestScore) {
144
- best = i;
145
- bestScore = score;
146
- }
147
- }
148
- if (best >= 0) {
149
- const key = ids[best]!;
150
- state.currentWeights.set(key, (state.currentWeights.get(key) ?? 0) - total);
151
- }
152
- return best;
153
- }
154
-
155
- /**
156
- * Shared pick core. Mutates `state` the same way live resolve does; callers pass
157
- * either the live map entry or a scratch/clone for dry-run peek.
158
- */
159
- function pickRoundRobinFromState(
160
- eligibleIds: readonly string[],
161
- stickyLimit: number,
162
- state: SelectionState,
163
- commitSticky: boolean,
164
- ): string | null {
165
- if (eligibleIds.length === 0) return null;
166
-
167
- const limit = normalizeAccountPoolStickyLimit(stickyLimit);
168
-
169
- if (state.activeKey && eligibleIds.includes(state.activeKey)) {
170
- return state.activeKey;
171
- }
172
-
173
- if (state.activeKey) {
174
- delete state.activeKey;
175
- state.successes = 0;
176
- }
177
-
178
- const index = smoothWeightedIndex(eligibleIds, state);
179
- if (index < 0) return null;
180
-
181
- const picked = eligibleIds[index]!;
182
- if (commitSticky && limit > 1) {
183
- state.activeKey = picked;
184
- state.successes = 0;
185
- }
186
- return picked;
187
- }
188
-
189
- export function pickRoundRobinAccount(
190
- poolKey: string,
191
- eligibleIds: readonly string[],
192
- stickyLimit: number,
193
- ): string | null {
194
- return pickRoundRobinFromState(eligibleIds, stickyLimit, getOrCreateState(poolKey), true);
195
- }
196
-
197
- /**
198
- * Dry-run of {@link pickRoundRobinAccount}: returns the same account resolve would
199
- * pick without advancing ring weights, activeKey, or successes.
200
- */
201
- export function peekRoundRobinAccount(
202
- poolKey: string,
203
- eligibleIds: readonly string[],
204
- stickyLimit: number,
205
- ): string | null {
206
- const live = selectionState.get(poolKey);
207
- const scratch = live
208
- ? cloneSelectionState(live)
209
- : { successes: 0, currentWeights: new Map<string, number>() };
210
- return pickRoundRobinFromState(eligibleIds, stickyLimit, scratch, false);
211
- }
212
-
213
- export function notePoolRotationSuccess(
214
- poolKey: string,
215
- accountId: string,
216
- stickyLimit: number,
217
- ): void {
218
- const limit = normalizeAccountPoolStickyLimit(stickyLimit);
219
- const state = selectionState.get(poolKey);
220
- if (!state) return;
221
- if (state.activeKey !== accountId) {
222
- state.activeKey = accountId;
223
- state.successes = 0;
224
- }
225
- state.successes += 1;
226
- if (state.successes >= limit) {
227
- delete state.activeKey;
228
- state.successes = 0;
229
- }
230
- }
231
-
232
- export function notePoolRotationFailure(poolKey: string, accountId: string): void {
233
- const state = selectionState.get(poolKey);
234
- if (state?.activeKey === accountId) {
235
- delete state.activeKey;
236
- state.successes = 0;
237
- }
238
- }
239
-
240
- /**
241
- * Force the next sticky/RR pick onto `accountId` (manual dashboard selection).
242
- * Clears sticky success counters and ring weights so the seeded account is held
243
- * for the next new-session pick before ordinary rotation resumes.
244
- */
245
- export function seedPoolRotationAccount(poolKey: string, accountId: string): void {
246
- const state = getOrCreateState(poolKey);
247
- state.activeKey = accountId;
248
- state.successes = 0;
249
- state.currentWeights.clear();
250
- }
251
-
252
- export function clearPoolRotationState(poolKey?: string): void {
253
- if (poolKey === undefined) {
254
- selectionState.clear();
255
- return;
256
- }
257
- selectionState.delete(poolKey);
258
- }
259
-
260
- export function reconcilePoolRotationState(context: GenerationContext): number {
261
- if (context.generation <= lastReconciledGeneration) return 0;
262
- const anthropicIds = new Set<string>();
263
- for (const key of context.oauthAccountKeys) {
264
- const separator = key.indexOf("\0");
265
- if (separator > 0 && key.slice(0, separator) === "anthropic") {
266
- anthropicIds.add(key.slice(separator + 1));
267
- }
268
- }
269
- let removed = 0;
270
- for (const [poolKey, state] of selectionState) {
271
- const valid = poolKey === POOL_KEY_ANTHROPIC
272
- ? anthropicIds
273
- : poolKey === POOL_KEY_CODEX || poolKey.startsWith(`${POOL_KEY_CODEX}:`)
274
- ? context.codexAccountIds
275
- : null;
276
- if (!valid) continue;
277
- if (valid.size === 0) {
278
- selectionState.delete(poolKey);
279
- removed += 1;
280
- continue;
281
- }
282
- if (state.activeKey && !valid.has(state.activeKey)) {
283
- delete state.activeKey;
284
- state.successes = 0;
285
- removed += 1;
286
- }
287
- for (const accountId of state.currentWeights.keys()) {
288
- if (valid.has(accountId)) continue;
289
- state.currentWeights.delete(accountId);
290
- removed += 1;
291
- }
292
- }
293
- lastReconciledGeneration = context.generation;
294
- return removed;
295
- }
11
+ export * from "../oauth/pool-kernel";
@@ -44,6 +44,21 @@ const MONTHLY_WINDOW_MIN_MINUTES = MONTHLY_WINDOW_MIN_SECONDS / 60;
44
44
  // Derived, never written as a literal: the header parser and the WHAM parser must not be able
45
45
  // to drift to different thresholds, which is the class of defect this pair exists to prevent.
46
46
  const WEEKLY_WINDOW_MIN_MINUTES = WEEKLY_WINDOW_MIN_SECONDS / 60;
47
+ /**
48
+ * Seconds/milliseconds split for a stored reset instant: below it the value is Unix seconds,
49
+ * at or above it milliseconds.
50
+ *
51
+ * Both units reach storage — `normalizeResetAt` does not scale, and the GUI disambiguates by
52
+ * magnitude at read time — so a comparison written against one assumption is off by 1000x
53
+ * against the other. In the seconds-read-as-milliseconds direction every reading looks like it
54
+ * elapsed in 1970, which is a check that passes its own test and does nothing. Exported so
55
+ * `isTerminalShortWindow` in routing.ts shares this one split instead of repeating the literal.
56
+ */
57
+ const RESET_AT_SECONDS_MAX = 10_000_000_000;
58
+
59
+ export function resetAtToMs(resetAt: number): number {
60
+ return resetAt < RESET_AT_SECONDS_MAX ? resetAt * 1000 : resetAt;
61
+ }
47
62
 
48
63
  const accountQuota = new Map<string, StoredAccountQuota>();
49
64
  let lastReconciledGeneration = 0;
@@ -221,6 +236,25 @@ function snapshotHasShort(quota: Omit<StoredAccountQuota, "updatedAt">): boolean
221
236
  || quota.shortWindowSeconds !== undefined;
222
237
  }
223
238
 
239
+ function shortResetHasElapsed(resetAt: number | undefined, now: number): boolean {
240
+ if (typeof resetAt !== "number" || !Number.isFinite(resetAt) || resetAt <= 0) return false;
241
+ return resetAtToMs(resetAt) <= now;
242
+ }
243
+
244
+ /** Display/rotation carry expires; a reset clock cannot retract hard-lock evidence. */
245
+ function assignCarriedShort(
246
+ next: StoredAccountQuota,
247
+ existing: StoredAccountQuota | undefined,
248
+ now: number,
249
+ policyEvidence = false,
250
+ ): void {
251
+ if (!existing || (!policyEvidence && shortResetHasElapsed(existing.shortResetAt, now))) return;
252
+ if (existing.shortPercent !== undefined) next.shortPercent = existing.shortPercent;
253
+ if (existing.shortObservedAt !== undefined) next.shortObservedAt = existing.shortObservedAt;
254
+ if (existing.shortResetAt !== undefined) next.shortResetAt = existing.shortResetAt;
255
+ if (existing.shortWindowSeconds !== undefined) next.shortWindowSeconds = existing.shortWindowSeconds;
256
+ }
257
+
224
258
  function snapshotHasCustom(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
225
259
  return quota.customWindows !== undefined;
226
260
  }
@@ -282,10 +316,7 @@ function mergeAccountQuota(
282
316
  if (existing?.monthlyPercent !== undefined) next.monthlyPercent = existing.monthlyPercent;
283
317
  if (existing?.monthlyResetAt !== undefined) next.monthlyResetAt = existing.monthlyResetAt;
284
318
  if (existing?.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true;
285
- if (existing?.shortPercent !== undefined) next.shortPercent = existing.shortPercent;
286
- if (existing?.shortObservedAt !== undefined) next.shortObservedAt = existing.shortObservedAt;
287
- if (existing?.shortResetAt !== undefined) next.shortResetAt = existing.shortResetAt;
288
- if (existing?.shortWindowSeconds !== undefined) next.shortWindowSeconds = existing.shortWindowSeconds;
319
+ assignCarriedShort(next, existing, updatedAt, policyEvidence);
289
320
  if (existing?.customWindows !== undefined) next.customWindows = existing.customWindows;
290
321
  next.resetCredits = quota.resetCredits;
291
322
  return next;
@@ -329,10 +360,10 @@ function mergeAccountQuota(
329
360
  } else {
330
361
  // Unknown usage is not a lower reading. Retain the entire known tuple: pairing
331
362
  // its percentage with new metadata would silently extend or shorten its reset.
332
- if (existing?.shortPercent !== undefined) next.shortPercent = existing.shortPercent;
333
- if (existing?.shortObservedAt !== undefined) next.shortObservedAt = existing.shortObservedAt;
334
- if (existing?.shortResetAt !== undefined) next.shortResetAt = existing.shortResetAt;
335
- if (existing?.shortWindowSeconds !== undefined) next.shortWindowSeconds = existing.shortWindowSeconds;
363
+ // An elapsed reset is the exception. It describes a window that has already rolled over,
364
+ // and carrying it republishes updatedAt, which is exactly what kept a Spark-polluted Pro
365
+ // row alive past the six-hour disk TTL that #4122 expected to expire it.
366
+ assignCarriedShort(next, existing, updatedAt, policyEvidence);
336
367
  }
337
368
 
338
369
  if (snapshotHasCustom(quota)) next.customWindows = quota.customWindows;
@@ -394,6 +425,7 @@ function notifyCodexQuotaSnapshot(accountId: string, next: StoredAccountQuota):
394
425
  scope: "codex",
395
426
  accountKey: accountId,
396
427
  windows: codexWindowObservations(snapshot),
428
+ retainAbsentShortWindow: true,
397
429
  });
398
430
  })
399
431
  .catch(() => {
@@ -561,14 +593,11 @@ export function updateAccountQuota(
561
593
  : {}),
562
594
  ...(existing?.weeklyResetAt !== undefined ? { weeklyResetAt: existing.weeklyResetAt } : {}),
563
595
  ...(existing?.monthlyResetAt !== undefined ? { monthlyResetAt: existing.monthlyResetAt } : {}),
564
- ...(existing?.shortPercent !== undefined ? { shortPercent: existing.shortPercent } : {}),
565
- ...(existing?.shortObservedAt !== undefined ? { shortObservedAt: existing.shortObservedAt } : {}),
566
- ...(existing?.shortResetAt !== undefined ? { shortResetAt: existing.shortResetAt } : {}),
567
- ...(existing?.shortWindowSeconds !== undefined ? { shortWindowSeconds: existing.shortWindowSeconds } : {}),
568
596
  ...(existing?.customWindows !== undefined ? { customWindows: existing.customWindows } : {}),
569
597
  ...(existing?.resetCredits !== undefined ? { resetCredits: existing.resetCredits } : {}),
570
598
  updatedAt: Date.now(),
571
599
  };
600
+ assignCarriedShort(quota, existing, quota.updatedAt);
572
601
 
573
602
  const nextWeeklyResetAt = normalizeResetAt(weeklyResetAt);
574
603
  const nextMonthlyResetAt = normalizeResetAt(monthlyResetAt);
@@ -0,0 +1,41 @@
1
+ /**
2
+ * One-time migration off Codex-login models that upstream has retired.
3
+ *
4
+ * Originally this only moved the two sidecar defaults, and it was written when
5
+ * `gpt-5.4-mini` was merely superseded rather than withdrawn. Once OpenAI retired the
6
+ * model the same stored value stopped being a stale preference and became a guaranteed
7
+ * 404 on every web-search, vision and pool-warmup call, so the pool warmup slug joined
8
+ * the set.
9
+ *
10
+ * The match is exact equality, which means an explicitly chosen `gpt-5.4-mini` is
11
+ * rewritten too. That is deliberate: there is no configuration in which continuing to
12
+ * send a withdrawn model is what the operator wanted. Any other value is left alone.
13
+ */
14
+ import type { OcxConfig } from "../types";
15
+
16
+ /** July 9 21:00 UTC = KST July 10 06:00. */
17
+ export const RETIRED_MODEL_MIGRATION_CUTOFF = Date.UTC(2026, 6, 9, 21, 0);
18
+
19
+ const RETIRED_SIDECAR_MODEL = "gpt-5.4-mini";
20
+ const REPLACEMENT_MODEL = "gpt-5.6-luna";
21
+
22
+ /**
23
+ * @returns true when the config changed and the caller should persist it.
24
+ */
25
+ export function runRetiredCodexModelMigration(config: OcxConfig, now = Date.now()): boolean {
26
+ if (now < RETIRED_MODEL_MIGRATION_CUTOFF) return false;
27
+ let migrated = false;
28
+ if (config.webSearchSidecar?.model === RETIRED_SIDECAR_MODEL) {
29
+ config.webSearchSidecar = { ...config.webSearchSidecar, model: REPLACEMENT_MODEL };
30
+ migrated = true;
31
+ }
32
+ if (config.visionSidecar?.model === RETIRED_SIDECAR_MODEL) {
33
+ config.visionSidecar = { ...config.visionSidecar, model: REPLACEMENT_MODEL };
34
+ migrated = true;
35
+ }
36
+ if (config.tokenGuardian?.codexWarmupModel === RETIRED_SIDECAR_MODEL) {
37
+ config.tokenGuardian = { ...config.tokenGuardian, codexWarmupModel: REPLACEMENT_MODEL };
38
+ migrated = true;
39
+ }
40
+ return migrated;
41
+ }