@coseung2/opencodex 2.8.0-cs.13 → 2.8.0-cs.14

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 (60) hide show
  1. package/gui/dist/assets/index-MUpaVatk.js +67 -0
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +3 -3
  4. package/packages/ocx-notch/README.md +2 -1
  5. package/src/adapters/cursor/discovery.ts +6 -2
  6. package/src/adapters/cursor/effort-map.ts +3 -0
  7. package/src/adapters/google-antigravity-replay.ts +24 -0
  8. package/src/adapters/google.ts +16 -11
  9. package/src/chat/inbound.ts +5 -11
  10. package/src/cli/account-api.ts +9 -1
  11. package/src/cli/account-extended.ts +4 -1
  12. package/src/codex/account-label.ts +14 -1
  13. package/src/codex/account-lifecycle.ts +12 -1
  14. package/src/codex/account-namespaces.ts +21 -0
  15. package/src/codex/account-priority.ts +49 -0
  16. package/src/codex/account-store.ts +2 -1
  17. package/src/codex/auth-api.ts +108 -17
  18. package/src/codex/auth-context.ts +61 -16
  19. package/src/codex/catalog/metadata.ts +34 -12
  20. package/src/codex/catalog/parsing.ts +8 -1
  21. package/src/codex/catalog/provider-fetch.ts +24 -6
  22. package/src/codex/catalog.ts +1 -1
  23. package/src/codex/pool-rotation.ts +51 -4
  24. package/src/codex/quota.ts +154 -35
  25. package/src/codex/routing.ts +133 -33
  26. package/src/codex/warmup.ts +193 -85
  27. package/src/config.ts +84 -1
  28. package/src/lib/bounded-body.ts +13 -6
  29. package/src/lib/bun-stream-caps.ts +5 -6
  30. package/src/lib/redact.ts +13 -0
  31. package/src/oauth/index.ts +79 -12
  32. package/src/oauth/log.ts +3 -1
  33. package/src/oauth/store.ts +31 -8
  34. package/src/providers/antigravity-models.ts +53 -24
  35. package/src/providers/codex-capacity.ts +303 -0
  36. package/src/providers/model-rename-migration.ts +147 -0
  37. package/src/providers/model-rename-startup.ts +29 -0
  38. package/src/providers/quota.ts +126 -16
  39. package/src/providers/registry.ts +258 -38
  40. package/src/responses/parser.ts +19 -12
  41. package/src/responses/spill-store.ts +14 -1
  42. package/src/responses/state.ts +108 -14
  43. package/src/server/index.ts +9 -1
  44. package/src/server/management/logs-usage-routes.ts +1 -0
  45. package/src/server/management/oauth-account-routes.ts +8 -1
  46. package/src/server/relay.ts +10 -42
  47. package/src/server/request-log.ts +42 -1
  48. package/src/server/responses/compact.ts +16 -4
  49. package/src/server/responses/core.ts +217 -59
  50. package/src/server/responses/empty-completion-guard.ts +275 -0
  51. package/src/server/responses/encrypted-payload.ts +54 -39
  52. package/src/server/responses/fetch-helpers.ts +24 -3
  53. package/src/server/responses/ws-upstream.ts +318 -0
  54. package/src/server/sse-frame-buffer.ts +292 -0
  55. package/src/server/ws-bridge.ts +17 -11
  56. package/src/types.ts +8 -0
  57. package/src/usage/log.ts +24 -0
  58. package/src/usage/summary.ts +152 -2
  59. package/vendor/ocx-notch/win32-x64/ocx-notch.exe +0 -0
  60. package/gui/dist/assets/index-BucjyD4I.js +0 -67
@@ -58,7 +58,7 @@ import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } fr
58
58
 
59
59
  import { JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing";
60
60
  import type { CatalogModel } from "./parsing";
61
- import { disabledNativeSlugs, hasComboTargets, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata";
61
+ import { disabledNativeSlugs, hasComboTargets, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata";
62
62
  import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation";
63
63
  import type { ComboCatalogOmission } from "./aggregation";
64
64
 
@@ -274,10 +274,15 @@ export function warnDroppedConfiguredIdsOnce(name: string, droppedConfiguredIds:
274
274
  }
275
275
 
276
276
  export function isGlm52ModelId(id: string): boolean {
277
- const normalized = id.toLowerCase();
277
+ const normalized = id.trim().toLowerCase();
278
278
  return normalized === "glm-5.2" || normalized === "glm-5.2[1m]";
279
279
  }
280
280
 
281
+ export function isGlm53ModelId(id: string): boolean {
282
+ const normalized = id.trim().toLowerCase();
283
+ return normalized === "glm-5.3" || normalized === "glm-5.3[1m]";
284
+ }
285
+
281
286
  function plainRecord(value: unknown): Record<string, unknown> | undefined {
282
287
  return value !== null && typeof value === "object" && !Array.isArray(value)
283
288
  ? value as Record<string, unknown>
@@ -358,6 +363,17 @@ function modelInputModalities(
358
363
  value === "text" || value === "image" || value === "audio"
359
364
  ));
360
365
  if (explicit && explicit.length > 0) return explicit;
366
+ const architecture = plainRecord(item.architecture);
367
+ const architectureModality = typeof architecture?.modality === "string"
368
+ ? normalizedMetadataString(architecture.modality, 64)
369
+ : undefined;
370
+ if (architectureModality?.includes("->")) {
371
+ const [rawInput = ""] = architectureModality.split("->");
372
+ const inferred = rawInput
373
+ .split("+")
374
+ .filter(value => value === "text" || value === "image" || value === "audio");
375
+ if (inferred.length > 0) return [...new Set(inferred)];
376
+ }
361
377
  if (capabilityRecord?.vision === false) return ["text"];
362
378
  if (capabilityRecord?.vision === true || capabilities?.some(value => value === "vision" || value === "image-input")) {
363
379
  return ["text", "image"];
@@ -385,9 +401,11 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid
385
401
  ? sanitizeCodexReasoningEfforts(listedReasoningEfforts)
386
402
  : typeof rawReasoningEfforts === "boolean"
387
403
  ? (rawReasoningEfforts
388
- ? ((providerName === "neuralwatt" || providerName === "zai") && isGlm52ModelId(item.id)
389
- ? ["low", "medium", "high", "xhigh", "max"]
390
- : ["low", "medium", "high", "xhigh"])
404
+ ? ((providerName === "neuralwatt" || providerName === "zai") && isGlm53ModelId(item.id)
405
+ ? ["low", "high", "max"]
406
+ : (providerName === "neuralwatt" || providerName === "zai") && isGlm52ModelId(item.id)
407
+ ? ["low", "medium", "high", "xhigh", "max"]
408
+ : ["low", "medium", "high", "xhigh"])
391
409
  : [])
392
410
  : undefined;
393
411
  const capabilities = modelCapabilities(item);
@@ -756,7 +774,7 @@ async function gatherRoutedModelsUncached(
756
774
  id: slug,
757
775
  owned_by: "openai",
758
776
  contextWindow,
759
- maxInputTokens: contextWindow,
777
+ maxInputTokens: nativeOpenAiMaxInputTokens(slug) ?? contextWindow,
760
778
  inputModalities: nativeInputModalities(slug),
761
779
  reasoningEfforts: nativeReasoningEfforts(slug),
762
780
  ...(nativeParallelToolCalls(slug) ? { parallelToolCalls: true } : {}),
@@ -2,7 +2,7 @@
2
2
  // Public surface preserved exactly; importers keep using "src/codex/catalog".
3
3
  export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, readCatalog, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing";
4
4
  export type { CatalogModel, MultiAgentMode } from "./catalog/parsing";
5
- export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort } from "./catalog/metadata";
5
+ export { NATIVE_DAYBREAK_BLUE_MODEL, NATIVE_GPT56_CONTEXT_WINDOW, NATIVE_GPT56_MAX_INPUT_TOKENS, NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort } from "./catalog/metadata";
6
6
  export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled";
7
7
  export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort";
8
8
  export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch";
@@ -19,6 +19,10 @@ const MAX_STICKY_LIMIT = 100;
19
19
  const DEFAULT_STRATEGY: OcxAccountPoolRotationStrategy = "quota";
20
20
  const VALID_STRATEGIES = new Set<OcxAccountPoolRotationStrategy>(["quota", "round-robin", "fill-first"]);
21
21
 
22
+ export const DEFAULT_ACCOUNT_PRIORITY = 0;
23
+ export const MIN_ACCOUNT_PRIORITY = -100;
24
+ export const MAX_ACCOUNT_PRIORITY = 100;
25
+
22
26
  /** Strict parse for management APIs — returns null instead of defaulting. */
23
27
  export function parseAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy | null {
24
28
  if (typeof raw === "string" && VALID_STRATEGIES.has(raw as OcxAccountPoolRotationStrategy)) {
@@ -43,6 +47,49 @@ export function normalizeAccountPoolStickyLimit(raw: unknown): number {
43
47
  return parseAccountPoolStickyLimit(raw) ?? DEFAULT_STICKY_LIMIT;
44
48
  }
45
49
 
50
+ export function parseAccountPriority(raw: unknown): number | null {
51
+ return typeof raw === "number"
52
+ && Number.isInteger(raw)
53
+ && raw >= MIN_ACCOUNT_PRIORITY
54
+ && raw <= MAX_ACCOUNT_PRIORITY
55
+ ? raw
56
+ : null;
57
+ }
58
+
59
+ export function normalizeAccountPriority(raw: unknown): number {
60
+ return parseAccountPriority(raw) ?? DEFAULT_ACCOUNT_PRIORITY;
61
+ }
62
+
63
+ /**
64
+ * Narrow eligible ids to the highest configured tier that still has quota headroom.
65
+ * Equal-priority pools and fully drained pools preserve the caller's exact list/order.
66
+ */
67
+ export function selectPriorityTier(
68
+ ids: readonly string[],
69
+ priorityOf: (id: string) => number,
70
+ hasHeadroom: (id: string) => boolean,
71
+ pinnedId?: string,
72
+ ): readonly string[] {
73
+ if (ids.length <= 1) return ids;
74
+ const priorities = ids.map(priorityOf);
75
+ const firstPriority = priorities[0]!;
76
+ if (priorities.every(priority => priority === firstPriority)) return ids;
77
+
78
+ let ceiling = Number.POSITIVE_INFINITY;
79
+ if (pinnedId !== undefined) {
80
+ const pinnedIndex = ids.indexOf(pinnedId);
81
+ if (pinnedIndex >= 0 && hasHeadroom(pinnedId)) ceiling = priorities[pinnedIndex]!;
82
+ }
83
+
84
+ const tiers = [...new Set(priorities)].sort((a, b) => b - a);
85
+ for (const tier of tiers) {
86
+ if (tier > ceiling) continue;
87
+ const members = ids.filter((_, index) => priorities[index] === tier);
88
+ if (members.some(hasHeadroom)) return members;
89
+ }
90
+ return ids;
91
+ }
92
+
46
93
  function getOrCreateState(poolKey: string): SelectionState {
47
94
  let state = selectionState.get(poolKey);
48
95
  if (!state) {
@@ -60,7 +107,7 @@ function cloneSelectionState(state: SelectionState): SelectionState {
60
107
  };
61
108
  }
62
109
 
63
- function smoothWeightedIndex(ids: string[], state: SelectionState): number {
110
+ function smoothWeightedIndex(ids: readonly string[], state: SelectionState): number {
64
111
  let best = -1;
65
112
  let bestScore = Number.NEGATIVE_INFINITY;
66
113
  let total = 0;
@@ -87,7 +134,7 @@ function smoothWeightedIndex(ids: string[], state: SelectionState): number {
87
134
  * either the live map entry or a scratch/clone for dry-run peek.
88
135
  */
89
136
  function pickRoundRobinFromState(
90
- eligibleIds: string[],
137
+ eligibleIds: readonly string[],
91
138
  stickyLimit: number,
92
139
  state: SelectionState,
93
140
  commitSticky: boolean,
@@ -118,7 +165,7 @@ function pickRoundRobinFromState(
118
165
 
119
166
  export function pickRoundRobinAccount(
120
167
  poolKey: string,
121
- eligibleIds: string[],
168
+ eligibleIds: readonly string[],
122
169
  stickyLimit: number,
123
170
  ): string | null {
124
171
  return pickRoundRobinFromState(eligibleIds, stickyLimit, getOrCreateState(poolKey), true);
@@ -130,7 +177,7 @@ export function pickRoundRobinAccount(
130
177
  */
131
178
  export function peekRoundRobinAccount(
132
179
  poolKey: string,
133
- eligibleIds: string[],
180
+ eligibleIds: readonly string[],
134
181
  stickyLimit: number,
135
182
  ): string | null {
136
183
  const live = selectionState.get(poolKey);
@@ -4,7 +4,9 @@ import { atomicWriteFile, getConfigDir } from "../config";
4
4
  import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
5
5
 
6
6
  export type StoredAccountQuota = {
7
+ fiveHourPercent?: number;
7
8
  weeklyPercent?: number;
9
+ fiveHourResetAt?: number;
8
10
  monthlyPercent?: number;
9
11
  weeklyResetAt?: number;
10
12
  monthlyResetAt?: number;
@@ -41,13 +43,15 @@ export type WhamUsageResponse = {
41
43
  };
42
44
 
43
45
  type WhamUsageWindow = {
44
- used_percent?: number;
45
- reset_at?: number;
46
- limit_window_seconds?: number;
46
+ used_percent?: number | string | null;
47
+ reset_at?: number | string | null;
48
+ limit_window_seconds?: number | string | null;
47
49
  };
48
50
 
49
51
  const MONTHLY_WINDOW_MIN_SECONDS = 28 * 24 * 60 * 60;
50
52
  const MONTHLY_WINDOW_MIN_MINUTES = MONTHLY_WINDOW_MIN_SECONDS / 60;
53
+ const FIVE_HOUR_WINDOW_MIN_SECONDS = 2 * 60 * 60;
54
+ const FIVE_HOUR_WINDOW_MAX_SECONDS = 12 * 60 * 60;
51
55
 
52
56
  const accountQuota = new Map<string, StoredAccountQuota>();
53
57
  let lastReconciledGeneration = 0;
@@ -62,15 +66,26 @@ function mayCommitAccountQuota(accountId: string, writerGeneration: number): boo
62
66
  export const CODEX_UNKNOWN_USAGE_SCORE = 101;
63
67
  export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
64
68
 
69
+ /** Plans whose WHAM payloads expose a separate rolling five-hour window. */
70
+ export function isCodexFiveHourQuotaPlan(plan: string | null | undefined): boolean {
71
+ const normalized = plan?.trim().toLowerCase();
72
+ // Business is the current name for the older Team workspace plan.
73
+ return normalized === "plus" || normalized === "team" || normalized === "business";
74
+ }
75
+
65
76
  export function isCodexQuotaExhausted(
66
- quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent"> | null,
77
+ quota: Pick<StoredAccountQuota, "fiveHourPercent" | "weeklyPercent" | "monthlyPercent"> | null,
67
78
  plan?: string | null,
68
79
  ): boolean {
69
80
  if (!quota) return false;
70
81
  const normalizedPlan = plan?.trim().toLowerCase();
71
82
  const values = normalizedPlan === "go" || normalizedPlan === "free"
72
83
  ? [quota.monthlyPercent]
73
- : [quota.weeklyPercent, quota.monthlyPercent];
84
+ : [
85
+ ...(isCodexFiveHourQuotaPlan(plan) ? [quota.fiveHourPercent] : []),
86
+ quota.weeklyPercent,
87
+ quota.monthlyPercent,
88
+ ];
74
89
  return values.some(value => typeof value === "number"
75
90
  && Number.isFinite(value)
76
91
  && value >= CODEX_EXHAUSTED_USAGE_PERCENT);
@@ -97,15 +112,23 @@ function normalizeResetAt(value: unknown): number | undefined {
97
112
  }
98
113
 
99
114
  function hasKnownQuotaValue(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
100
- return [quota.weeklyPercent, quota.monthlyPercent]
115
+ return [quota.fiveHourPercent, quota.weeklyPercent, quota.monthlyPercent]
101
116
  .some(value => typeof value === "number" && Number.isFinite(value));
102
117
  }
103
118
 
119
+ function windowDurationSeconds(window: WhamUsageWindow | null | undefined): number | undefined {
120
+ const raw: unknown = window?.limit_window_seconds;
121
+ const seconds = typeof raw === "number"
122
+ ? raw
123
+ : typeof raw === "string" && raw.trim() !== ""
124
+ ? Number(raw)
125
+ : undefined;
126
+ return typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0 ? seconds : undefined;
127
+ }
128
+
104
129
  function isExplicitMonthlyWindow(window: WhamUsageWindow | null | undefined): boolean {
105
- const seconds = window?.limit_window_seconds;
106
- return typeof seconds === "number"
107
- && Number.isFinite(seconds)
108
- && seconds >= MONTHLY_WINDOW_MIN_SECONDS;
130
+ const seconds = windowDurationSeconds(window);
131
+ return seconds !== undefined && seconds >= MONTHLY_WINDOW_MIN_SECONDS;
109
132
  }
110
133
 
111
134
  function isExplicitMonthlyWindowMinutes(windowMinutes: unknown): boolean {
@@ -119,17 +142,39 @@ function isExplicitMonthlyWindowMinutes(windowMinutes: unknown): boolean {
119
142
  && minutes >= MONTHLY_WINDOW_MIN_MINUTES;
120
143
  }
121
144
 
145
+ function isExplicitFiveHourWindow(window: WhamUsageWindow | null | undefined): boolean {
146
+ const seconds = windowDurationSeconds(window);
147
+ return seconds !== undefined
148
+ && seconds >= FIVE_HOUR_WINDOW_MIN_SECONDS
149
+ && seconds <= FIVE_HOUR_WINDOW_MAX_SECONDS;
150
+ }
151
+
152
+ function isExplicitFiveHourWindowMinutes(windowMinutes: unknown): boolean {
153
+ const minutes = typeof windowMinutes === "number"
154
+ ? windowMinutes
155
+ : typeof windowMinutes === "string" && windowMinutes.trim() !== ""
156
+ ? Number(windowMinutes)
157
+ : undefined;
158
+ return typeof minutes === "number"
159
+ && Number.isFinite(minutes)
160
+ && minutes >= FIVE_HOUR_WINDOW_MIN_SECONDS / 60
161
+ && minutes <= FIVE_HOUR_WINDOW_MAX_SECONDS / 60;
162
+ }
122
163
 
123
164
  function snapshotHasWeekly(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
124
165
  return quota.weeklyPercent !== undefined || quota.weeklyResetAt !== undefined;
125
166
  }
126
167
 
168
+ function snapshotHasFiveHour(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
169
+ return quota.fiveHourPercent !== undefined || quota.fiveHourResetAt !== undefined;
170
+ }
171
+
127
172
  function snapshotHasMonthly(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
128
173
  return quota.monthlyPercent !== undefined || quota.monthlyResetAt !== undefined;
129
174
  }
130
175
 
131
176
  function snapshotHasUsage(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
132
- return snapshotHasWeekly(quota) || snapshotHasMonthly(quota);
177
+ return snapshotHasFiveHour(quota) || snapshotHasWeekly(quota) || snapshotHasMonthly(quota);
133
178
  }
134
179
  export function setAccountQuotaFromParsed(
135
180
  accountId: string,
@@ -143,6 +188,8 @@ export function setAccountQuotaFromParsed(
143
188
  const creditsOnly = quota.resetCredits !== undefined && !snapshotHasUsage(quota);
144
189
 
145
190
  if (creditsOnly) {
191
+ if (existing?.fiveHourPercent !== undefined) next.fiveHourPercent = existing.fiveHourPercent;
192
+ if (existing?.fiveHourResetAt !== undefined) next.fiveHourResetAt = existing.fiveHourResetAt;
146
193
  if (existing?.weeklyPercent !== undefined) next.weeklyPercent = existing.weeklyPercent;
147
194
  if (existing?.weeklyResetAt !== undefined) next.weeklyResetAt = existing.weeklyResetAt;
148
195
  if (existing?.monthlyPercent !== undefined) next.monthlyPercent = existing.monthlyPercent;
@@ -153,20 +200,32 @@ export function setAccountQuotaFromParsed(
153
200
  return;
154
201
  }
155
202
 
156
- if (snapshotHasWeekly(quota)) {
203
+ const hasFiveHour = snapshotHasFiveHour(quota);
204
+ const hasWeekly = snapshotHasWeekly(quota);
205
+ const hasMonthly = snapshotHasMonthly(quota);
206
+
207
+ if (hasFiveHour) {
208
+ if (quota.fiveHourPercent !== undefined) next.fiveHourPercent = quota.fiveHourPercent;
209
+ if (quota.fiveHourResetAt !== undefined) next.fiveHourResetAt = quota.fiveHourResetAt;
210
+ // A five-hour-only response can be a partial snapshot; retain a known
211
+ // weekly value until a later response supplies that window.
212
+ if (!hasWeekly && existing?.weeklyPercent !== undefined) {
213
+ next.weeklyPercent = existing.weeklyPercent;
214
+ if (existing.weeklyResetAt !== undefined) next.weeklyResetAt = existing.weeklyResetAt;
215
+ }
216
+ }
217
+
218
+ if (hasWeekly) {
157
219
  if (quota.weeklyPercent !== undefined) next.weeklyPercent = quota.weeklyPercent;
158
220
  if (quota.weeklyResetAt !== undefined) next.weeklyResetAt = quota.weeklyResetAt;
159
- } else if (snapshotHasMonthly(quota) && !snapshotHasWeekly(quota)) {
221
+ } else if (hasMonthly && !hasFiveHour) {
160
222
  // Monthly-only snapshots intentionally clear stale weekly values (issue #382).
161
- } else if (existing?.weeklyPercent !== undefined) {
162
- next.weeklyPercent = existing.weeklyPercent;
163
- if (existing.weeklyResetAt !== undefined) next.weeklyResetAt = existing.weeklyResetAt;
164
223
  }
165
224
 
166
- if (snapshotHasMonthly(quota)) {
225
+ if (hasMonthly) {
167
226
  if (quota.monthlyPercent !== undefined) next.monthlyPercent = quota.monthlyPercent;
168
227
  if (quota.monthlyResetAt !== undefined) next.monthlyResetAt = quota.monthlyResetAt;
169
- } else if (snapshotHasWeekly(quota) && existing?.monthlyPercent !== undefined) {
228
+ } else if ((hasWeekly || hasFiveHour) && existing?.monthlyPercent !== undefined) {
170
229
  next.monthlyPercent = existing.monthlyPercent;
171
230
  if (existing.monthlyResetAt !== undefined) next.monthlyResetAt = existing.monthlyResetAt;
172
231
  }
@@ -178,7 +237,10 @@ export function setAccountQuotaFromParsed(
178
237
  schedulePersistAccountQuotas();
179
238
  }
180
239
 
181
- export function parseUpstreamQuotaHeaders(headers: Headers): Omit<StoredAccountQuota, "updatedAt"> | null {
240
+ export function parseUpstreamQuotaHeaders(
241
+ headers: Headers,
242
+ plan?: string | null,
243
+ ): Omit<StoredAccountQuota, "updatedAt"> | null {
182
244
  const primaryRaw = headers.get("x-codex-primary-used-percent");
183
245
  const secondaryRaw = headers.get("x-codex-secondary-used-percent");
184
246
  const tertiaryRaw = headers.get("x-codex-tertiary-used-percent");
@@ -196,25 +258,52 @@ export function parseUpstreamQuotaHeaders(headers: Headers): Omit<StoredAccountQ
196
258
  const secondaryResetAt = normalizeResetAt(secondaryResetRaw);
197
259
  const tertiaryResetAt = normalizeResetAt(tertiaryResetRaw);
198
260
  const primaryIsMonthly = primaryRaw !== null && isExplicitMonthlyWindowMinutes(primaryWindowMinutes);
261
+ const trackFiveHour = plan == null || isCodexFiveHourQuotaPlan(plan);
262
+ const primaryIsFiveHour = trackFiveHour && primaryRaw !== null && isExplicitFiveHourWindowMinutes(primaryWindowMinutes);
263
+ const secondaryIsFiveHour = trackFiveHour && secondaryRaw !== null && isExplicitFiveHourWindowMinutes(secondaryWindowMinutes);
199
264
 
200
265
  if (primaryIsMonthly) {
201
266
  if (primaryPercent !== undefined) {
202
267
  quota.monthlyPercent = primaryPercent;
203
268
  if (primaryResetAt !== undefined) quota.monthlyResetAt = primaryResetAt;
204
269
  }
205
- if (secondaryPercent !== undefined) {
270
+ if (secondaryIsFiveHour && secondaryPercent !== undefined) {
271
+ quota.fiveHourPercent = secondaryPercent;
272
+ if (secondaryResetAt !== undefined) quota.fiveHourResetAt = secondaryResetAt;
273
+ } else if (secondaryPercent !== undefined) {
206
274
  quota.weeklyPercent = secondaryPercent;
207
275
  if (secondaryResetAt !== undefined) quota.weeklyResetAt = secondaryResetAt;
208
276
  }
209
277
  } else {
210
- const weeklyPercent = primaryPercent ?? secondaryPercent;
211
- const weeklyResetAt = primaryPercent !== undefined
212
- ? primaryResetAt
213
- : secondaryResetAt;
278
+ if (primaryIsFiveHour && primaryPercent !== undefined) {
279
+ quota.fiveHourPercent = primaryPercent;
280
+ if (primaryResetAt !== undefined) quota.fiveHourResetAt = primaryResetAt;
281
+ }
282
+ if (secondaryIsFiveHour && secondaryPercent !== undefined) {
283
+ quota.fiveHourPercent ??= secondaryPercent;
284
+ if (quota.fiveHourResetAt === undefined && secondaryResetAt !== undefined) {
285
+ quota.fiveHourResetAt = secondaryResetAt;
286
+ }
287
+ }
288
+ const weeklyPercent = primaryIsFiveHour ? secondaryPercent : primaryPercent ?? secondaryPercent;
289
+ const weeklyResetAt = primaryIsFiveHour
290
+ ? secondaryResetAt
291
+ : primaryPercent !== undefined ? primaryResetAt : secondaryResetAt;
214
292
  if (weeklyPercent !== undefined) {
215
293
  quota.weeklyPercent = weeklyPercent;
216
294
  if (weeklyResetAt !== undefined) quota.weeklyResetAt = weeklyResetAt;
217
295
  }
296
+ if (trackFiveHour
297
+ && !primaryIsFiveHour
298
+ && primaryPercent !== undefined
299
+ && secondaryPercent !== undefined
300
+ && primaryWindowMinutes === null
301
+ && secondaryWindowMinutes === null) {
302
+ quota.fiveHourPercent = primaryPercent;
303
+ if (primaryResetAt !== undefined) quota.fiveHourResetAt = primaryResetAt;
304
+ quota.weeklyPercent = secondaryPercent;
305
+ if (secondaryResetAt !== undefined) quota.weeklyResetAt = secondaryResetAt;
306
+ }
218
307
  }
219
308
 
220
309
  if (tertiaryPercent !== undefined && quota.monthlyPercent === undefined) {
@@ -229,8 +318,9 @@ export function applyAccountQuotaFromUpstreamHeaders(
229
318
  accountId: string,
230
319
  headers: Headers,
231
320
  writerGeneration = captureConfigGeneration(),
321
+ plan?: string | null,
232
322
  ): void {
233
- const quota = parseUpstreamQuotaHeaders(headers);
323
+ const quota = parseUpstreamQuotaHeaders(headers, plan);
234
324
  if (!quota) return;
235
325
  setAccountQuotaFromParsed(accountId, quota, writerGeneration);
236
326
  }
@@ -251,7 +341,9 @@ export function updateAccountQuota(
251
341
  if (nextWeekly === undefined && nextMonthly === undefined && resetCredits === undefined) return;
252
342
 
253
343
  const quota: StoredAccountQuota = {
344
+ ...(existing?.fiveHourPercent !== undefined ? { fiveHourPercent: existing.fiveHourPercent } : {}),
254
345
  ...(existing?.weeklyPercent !== undefined ? { weeklyPercent: existing.weeklyPercent } : {}),
346
+ ...(existing?.fiveHourResetAt !== undefined ? { fiveHourResetAt: existing.fiveHourResetAt } : {}),
255
347
  ...(existing?.monthlyPercent !== undefined ? { monthlyPercent: existing.monthlyPercent } : {}),
256
348
  ...(existing?.weeklyResetAt !== undefined ? { weeklyResetAt: existing.weeklyResetAt } : {}),
257
349
  ...(existing?.monthlyResetAt !== undefined ? { monthlyResetAt: existing.monthlyResetAt } : {}),
@@ -378,20 +470,43 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
378
470
  const secondaryResetAt = normalizeResetAt(secondaryWindow?.reset_at);
379
471
  const tertiaryResetAt = normalizeResetAt(tertiaryWindow?.reset_at);
380
472
  const primaryIsMonthly = isExplicitMonthlyWindow(primaryWindow);
381
-
382
- // [Decision Log]
383
- // - 목적과 의도: distinguish weekly and roughly monthly WHAM primary windows without plan-name guesses.
384
- // - 기존 구현 제약 조건: primary meant weekly, and older responses omit limit_window_seconds.
385
- // - 검토한 주요 대안: exact-duration matching, plan-specific mapping, and a duration lower bound.
386
- // - 선택한 방식: only an explicit primary duration of at least 28 days changes it to monthly.
387
- // - 다른 대안 대신 방식을 선택한 이유: it accepts calendar-month variance and preserves legacy payloads.
388
- // - 장점, 단점 및 영향: Team monthly quotas classify correctly; unknown durations remain weekly by design.
389
- const weeklyPercent = primaryIsMonthly ? secondaryPercent : primaryPercent ?? secondaryPercent;
390
- const weeklyResetAt = primaryIsMonthly
473
+ const primaryIsFiveHour = isExplicitFiveHourWindow(primaryWindow);
474
+ const secondaryIsFiveHour = isExplicitFiveHourWindow(secondaryWindow);
475
+
476
+ // Explicit durations win over positional assumptions. This matters because
477
+ // WHAM has shipped both a legacy primary=weekly shape and a dual-window
478
+ // primary=5h, secondary=weekly shape over time.
479
+ let weeklyPercent = primaryIsMonthly ? secondaryPercent : primaryPercent ?? secondaryPercent;
480
+ let weeklyResetAt = primaryIsMonthly
391
481
  ? secondaryResetAt
392
482
  : primaryPercent !== undefined ? primaryResetAt : secondaryResetAt;
393
483
  const monthlyPercent = primaryIsMonthly ? primaryPercent ?? tertiaryPercent : tertiaryPercent;
394
484
  const monthlyResetAt = primaryIsMonthly && primaryPercent !== undefined ? primaryResetAt : tertiaryResetAt;
485
+ const trackFiveHour = !thirtyDayOnly
486
+ && (isCodexFiveHourQuotaPlan(data.plan_type)
487
+ // If WHAM omitted plan_type, an explicit 5h duration is still
488
+ // unambiguous and should not be discarded from the display contract.
489
+ || (data.plan_type == null && (primaryIsFiveHour || secondaryIsFiveHour)));
490
+ let fiveHourPercent = trackFiveHour && primaryIsFiveHour ? primaryPercent : undefined;
491
+ let fiveHourResetAt = trackFiveHour && primaryIsFiveHour ? primaryResetAt : undefined;
492
+ if (trackFiveHour && secondaryIsFiveHour && fiveHourPercent === undefined) {
493
+ fiveHourPercent = secondaryPercent;
494
+ fiveHourResetAt = secondaryResetAt;
495
+ }
496
+ // Older dual-window payloads omitted durations. For Plus/Team/Business,
497
+ // the primary+secondary pair is the legacy positional 5h+weekly shape.
498
+ if (trackFiveHour && !primaryIsMonthly && !primaryIsFiveHour
499
+ && primaryPercent !== undefined && secondaryPercent !== undefined
500
+ && windowDurationSeconds(primaryWindow) === undefined
501
+ && windowDurationSeconds(secondaryWindow) === undefined) {
502
+ fiveHourPercent = primaryPercent;
503
+ fiveHourResetAt = primaryResetAt;
504
+ weeklyPercent = secondaryPercent;
505
+ weeklyResetAt = secondaryResetAt;
506
+ } else if (trackFiveHour && primaryIsFiveHour) {
507
+ weeklyPercent = secondaryPercent;
508
+ weeklyResetAt = secondaryResetAt;
509
+ }
395
510
  if (thirtyDayOnly) {
396
511
  if (monthlyPercent !== undefined) {
397
512
  quota.monthlyPercent = monthlyPercent;
@@ -401,6 +516,10 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
401
516
  quota.weeklyPercent = weeklyPercent;
402
517
  if (weeklyResetAt !== undefined) quota.weeklyResetAt = weeklyResetAt;
403
518
  }
519
+ if (trackFiveHour && fiveHourPercent !== undefined) {
520
+ quota.fiveHourPercent = fiveHourPercent;
521
+ if (fiveHourResetAt !== undefined) quota.fiveHourResetAt = fiveHourResetAt;
522
+ }
404
523
  if (!thirtyDayOnly && monthlyPercent !== undefined) {
405
524
  quota.monthlyPercent = monthlyPercent;
406
525
  if (monthlyResetAt !== undefined) quota.monthlyResetAt = monthlyResetAt;