@bitkyc08/opencodex 2.40.0 → 2.42.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 (97) hide show
  1. package/README.md +4 -0
  2. package/gui/dist/assets/index-BU1tE0sr.js +112 -0
  3. package/gui/dist/assets/index-DL9-iS6J.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/meta.svg +1 -0
  6. package/package.json +4 -3
  7. package/src/adapters/cursor/catalog.ts +71 -29
  8. package/src/adapters/cursor/claude-id.ts +76 -0
  9. package/src/adapters/cursor/discovery.ts +16 -3
  10. package/src/adapters/cursor/effort-map.ts +27 -12
  11. package/src/adapters/cursor/protobuf-request.ts +41 -21
  12. package/src/adapters/google.ts +39 -2
  13. package/src/adapters/identity.ts +8 -2
  14. package/src/adapters/openai-responses.ts +57 -4
  15. package/src/bridge.ts +25 -3
  16. package/src/cli/account-auth.ts +28 -3
  17. package/src/cli/account-extended.ts +7 -1
  18. package/src/cli/capabilities.ts +2 -2
  19. package/src/cli/claude.ts +11 -2
  20. package/src/cli/connect.ts +7 -1
  21. package/src/cli/observe.ts +3 -1
  22. package/src/cli/registry.ts +1 -1
  23. package/src/cli/status.ts +19 -4
  24. package/src/client/connect.ts +5 -1
  25. package/src/client/hub-client.ts +29 -5
  26. package/src/clients/config-export.ts +12 -2
  27. package/src/codex/auth-api.ts +102 -9
  28. package/src/codex/catalog/aggregation.ts +8 -0
  29. package/src/codex/catalog/effort.ts +15 -2
  30. package/src/codex/catalog/metadata.ts +119 -9
  31. package/src/codex/catalog/native-models.ts +71 -0
  32. package/src/codex/catalog/parsing.ts +5 -3
  33. package/src/codex/catalog/provider-fetch.ts +166 -28
  34. package/src/codex/catalog.ts +1 -1
  35. package/src/codex/convergence-types.ts +1 -0
  36. package/src/codex/data/upstream-models.json +169 -0
  37. package/src/codex/desired-state.ts +18 -11
  38. package/src/codex/inject.ts +96 -6
  39. package/src/codex/injected-marker.ts +30 -4
  40. package/src/codex/journal.ts +14 -0
  41. package/src/combos/failover.ts +185 -6
  42. package/src/combos/index.ts +6 -0
  43. package/src/combos/resolve.ts +43 -6
  44. package/src/config.ts +5 -1
  45. package/src/generated/compatibility-version.json +115 -83
  46. package/src/generated/model-metadata.ts +1 -1
  47. package/src/grok/sync.ts +10 -2
  48. package/src/integrations/cursor-effort-table.ts +143 -0
  49. package/src/integrations/state.ts +1 -1
  50. package/src/integrations/writer.ts +2 -2
  51. package/src/lib/app-owned-memory-stores.ts +27 -8
  52. package/src/lib/bounded-body.ts +16 -1
  53. package/src/oauth/account-quota-rank.ts +40 -1
  54. package/src/oauth/chatgpt-device.ts +187 -0
  55. package/src/oauth/chatgpt.ts +31 -4
  56. package/src/oauth/generic-account-failover.ts +2 -2
  57. package/src/oauth/index.ts +24 -3
  58. package/src/oauth/log.ts +3 -0
  59. package/src/oauth/meta-muse.ts +235 -0
  60. package/src/providers/antigravity-models.ts +71 -13
  61. package/src/providers/command-code-efforts.ts +15 -0
  62. package/src/providers/free-directory.ts +4 -1
  63. package/src/providers/muse-subscription-usage.ts +95 -0
  64. package/src/providers/quota.ts +96 -0
  65. package/src/providers/registry.ts +116 -8
  66. package/src/responses/code-mode-helper-compat.ts +4 -1
  67. package/src/responses/state.ts +5 -4
  68. package/src/server/auth-cors.ts +241 -56
  69. package/src/server/chat-completions.ts +11 -2
  70. package/src/server/chat-native.ts +30 -4
  71. package/src/server/claude-messages.ts +17 -3
  72. package/src/server/effort-row.ts +131 -0
  73. package/src/server/index.ts +82 -45
  74. package/src/server/live.ts +18 -4
  75. package/src/server/management/api-key-rotation.ts +2 -1
  76. package/src/server/management/api-key-usage.ts +97 -43
  77. package/src/server/management/context.ts +3 -0
  78. package/src/server/management/cursor-integration-routes.ts +36 -7
  79. package/src/server/management/logs-usage-routes.ts +64 -87
  80. package/src/server/management/oauth-account-routes.ts +10 -3
  81. package/src/server/management/provider-routes.ts +218 -1
  82. package/src/server/management/route-registry.ts +1 -0
  83. package/src/server/management/usage-aggregate-cache.ts +464 -0
  84. package/src/server/management/usage-summary-cache.ts +4 -0
  85. package/src/server/models-capabilities.ts +60 -5
  86. package/src/server/responses/core.ts +95 -7
  87. package/src/server/responses/empty-completion-guard.ts +4 -0
  88. package/src/types/config.ts +10 -1
  89. package/src/types/request.ts +8 -0
  90. package/src/types/tools.ts +12 -9
  91. package/src/usage/expected-prices.ts +43 -7
  92. package/src/usage/ledger-scanner.ts +448 -0
  93. package/src/usage/log.ts +1 -1
  94. package/src/usage/summary.ts +915 -655
  95. package/src/web-search/index.ts +1 -1
  96. package/gui/dist/assets/index-BHe2rl_C.js +0 -112
  97. package/gui/dist/assets/index-CJSb3HPe.css +0 -1
@@ -15,6 +15,20 @@ export function isRootOpenaiBaseUrlLine(line: string): boolean {
15
15
  return /^\s*openai_base_url\s*=/.test(line);
16
16
  }
17
17
 
18
+ /**
19
+ * codex-rs root key that redirects the realtime sideband WebSocket (WebRTC voice
20
+ * join + standalone realtime WS) without touching ordinary provider HTTP. Since
21
+ * openai/codex 438c9e98d (#35830) the sideband ignores the provider base URL and
22
+ * dials `https://api.openai.com/v1` unless this key is set, so a Pool-routed
23
+ * call-create and a directly-joined sideband end up on different accounts (404).
24
+ * Injected next to `openai_base_url` with the same value.
25
+ */
26
+ export const REALTIME_WS_BASE_URL_KEY = "experimental_realtime_ws_base_url";
27
+
28
+ export function isRootRealtimeWsBaseUrlLine(line: string): boolean {
29
+ return /^\s*experimental_realtime_ws_base_url\s*=/.test(line);
30
+ }
31
+
18
32
  export function tomlStringPattern(key: string): RegExp {
19
33
  const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
20
34
  const keyToken = `(?:${escaped}|"${escaped}"|'${escaped}')`;
@@ -65,16 +79,28 @@ export function providerTableString(content: string, provider: string, key: stri
65
79
  * EXACT value match against what we recorded writing: a user gateway we never wrote
66
80
  * cannot match, so restore can never delete a URL that was not ours.
67
81
  */
68
- export function stripJournaledOpenaiBaseUrl(content: string, injectedUrl: string | null): string {
69
- if (!injectedUrl) return content;
82
+ export function stripJournaledOpenaiBaseUrl(
83
+ content: string,
84
+ injectedUrl: string | null,
85
+ injectedRealtimeWsUrl: string | null = null,
86
+ ): string {
87
+ if (!injectedUrl && !injectedRealtimeWsUrl) return content;
70
88
  const lines = content.split(String.fromCharCode(10));
71
89
  const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
72
90
  const rootEnd = firstTable === -1 ? lines.length : firstTable;
73
91
  const drop = new Set<number>();
74
92
  for (let i = 0; i < rootEnd; i++) {
75
93
  const line = lines[i]!;
76
- if (!isRootOpenaiBaseUrlLine(line)) continue;
77
- if (rootTomlString(line, "openai_base_url") !== injectedUrl) continue;
94
+ // Each key is matched against ITS OWN recorded value. The realtime override is
95
+ // journaled separately so a user-owned override that happens to equal the proxy
96
+ // URL is never mistaken for ours.
97
+ if (isRootOpenaiBaseUrlLine(line)) {
98
+ if (!injectedUrl || rootTomlString(line, "openai_base_url") !== injectedUrl) continue;
99
+ } else if (isRootRealtimeWsBaseUrlLine(line)) {
100
+ if (!injectedRealtimeWsUrl || rootTomlString(line, REALTIME_WS_BASE_URL_KEY) !== injectedRealtimeWsUrl) continue;
101
+ } else {
102
+ continue;
103
+ }
78
104
  drop.add(i);
79
105
  // Take an ownership marker directly above it too, so repeated cycles cannot
80
106
  // accumulate orphaned comments.
@@ -35,6 +35,13 @@ interface Journal {
35
35
  * and it is what lets restore tell OUR loopback URL apart from a gateway the user set.
36
36
  */
37
37
  injectedOpenaiBaseUrl?: string | null;
38
+ /**
39
+ * The root `experimental_realtime_ws_base_url` this injection wrote, when it wrote one.
40
+ * Recorded on its own rather than inferred from `injectedOpenaiBaseUrl`: a user can own a
41
+ * realtime override whose value happens to equal the proxy URL, and restore must not treat
42
+ * that as ours. Null when the key was preserved or not injected.
43
+ */
44
+ injectedRealtimeWsBaseUrl?: string | null;
38
45
  /**
39
46
  * The catalog path this injection actually wrote to.
40
47
  *
@@ -119,6 +126,7 @@ export function writeJournal(options: WriteJournalOptions = {}): void {
119
126
 
120
127
  export interface InjectedJournalOwnership {
121
128
  injectedOpenaiBaseUrl: string | null;
129
+ injectedRealtimeWsBaseUrl: string | null;
122
130
  injectedCatalogPath: string | null;
123
131
  }
124
132
 
@@ -140,6 +148,7 @@ export function markJournalInjectedState(
140
148
  // Only the caller knows which values it actually owns. Deriving these from the final TOML
141
149
  // would mistake a preserved user override for injected routing.
142
150
  journal.injectedOpenaiBaseUrl = ownership.injectedOpenaiBaseUrl;
151
+ journal.injectedRealtimeWsBaseUrl = ownership.injectedRealtimeWsBaseUrl;
143
152
  journal.injectedCatalogPath = ownership.injectedCatalogPath;
144
153
  atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal));
145
154
  }
@@ -156,6 +165,11 @@ export function journaledInjectedOpenaiBaseUrl(): string | null {
156
165
  return readJournal()?.injectedOpenaiBaseUrl ?? null;
157
166
  }
158
167
 
168
+ /** The root `experimental_realtime_ws_base_url` the last injection wrote, or null. */
169
+ export function journaledInjectedRealtimeWsBaseUrl(): string | null {
170
+ return readJournal()?.injectedRealtimeWsBaseUrl ?? null;
171
+ }
172
+
159
173
  /** The catalog path the last injection wrote to, or null when none was recorded. */
160
174
  export function journaledInjectedCatalogPath(): string | null {
161
175
  return readJournal()?.injectedCatalogPath ?? null;
@@ -13,6 +13,28 @@ interface TargetCooldown {
13
13
 
14
14
  const DEFAULT_COOLDOWN_MS = 60_000;
15
15
  const MAX_COOLDOWN_MS = 10 * 60_000;
16
+ /** Short cooldown for request-rate 429s (for example provider code 1302) that omit Retry-After. */
17
+ export const COMBO_REQUEST_RATE_COOLDOWN_MS = 5_000;
18
+
19
+ const QUOTA_LIMIT_CODES = new Set([
20
+ "1308",
21
+ "1310",
22
+ "1316",
23
+ "1317",
24
+ "1318",
25
+ "1319",
26
+ "1320",
27
+ "1321",
28
+ "insufficient_quota",
29
+ ]);
30
+ const TRANSIENT_REQUEST_RATE_CODES = new Set(["1302", "1305"]);
31
+ const IMF_FIXDATE_RE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/i;
32
+ const RFC850_DATE_RE = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/i;
33
+ const ASCTIME_DATE_RE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ( \d|\d{2}) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/i;
34
+ const HTTP_MONTH_INDEX: Record<string, number> = {
35
+ jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5,
36
+ jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
37
+ };
16
38
 
17
39
  /** Map<`${comboId}\0${provider/model}`, TargetCooldown> */
18
40
  const targetCooldowns = new Map<string, TargetCooldown>();
@@ -26,22 +48,92 @@ function cooldownMapKey(
26
48
  return `${comboId}\0${targetKey(target)}`;
27
49
  }
28
50
 
51
+ function parseUtcDateParts(
52
+ year: number,
53
+ monthName: string,
54
+ day: number,
55
+ hour: number,
56
+ minute: number,
57
+ second: number,
58
+ ): number | undefined {
59
+ const month = HTTP_MONTH_INDEX[monthName.toLowerCase()];
60
+ if (month === undefined) return undefined;
61
+ const timestamp = Date.UTC(year, month, day, hour, minute, second);
62
+ const parsed = new Date(timestamp);
63
+ return parsed.getUTCFullYear() === year
64
+ && parsed.getUTCMonth() === month
65
+ && parsed.getUTCDate() === day
66
+ && parsed.getUTCHours() === hour
67
+ && parsed.getUTCMinutes() === minute
68
+ && parsed.getUTCSeconds() === second
69
+ ? timestamp
70
+ : undefined;
71
+ }
72
+
73
+ function parseHttpDate(value: string, now: number): number | undefined {
74
+ const imf = IMF_FIXDATE_RE.exec(value);
75
+ if (imf) {
76
+ return parseUtcDateParts(
77
+ Number(imf[3]), imf[2]!, Number(imf[1]),
78
+ Number(imf[4]), Number(imf[5]), Number(imf[6]),
79
+ );
80
+ }
81
+ const rfc850 = RFC850_DATE_RE.exec(value);
82
+ if (rfc850) {
83
+ const current = new Date(now);
84
+ const currentYear = current.getUTCFullYear();
85
+ const month = HTTP_MONTH_INDEX[rfc850[2]!.toLowerCase()];
86
+ if (month === undefined) return undefined;
87
+ let year = Math.floor(currentYear / 100) * 100 + Number(rfc850[3]);
88
+ const yearDelta = year - currentYear;
89
+ const candidateTimeOfYear = Date.UTC(
90
+ 2000, month, Number(rfc850[1]),
91
+ Number(rfc850[4]), Number(rfc850[5]), Number(rfc850[6]),
92
+ );
93
+ const currentTimeOfYear = Date.UTC(
94
+ 2000, current.getUTCMonth(), current.getUTCDate(),
95
+ current.getUTCHours(), current.getUTCMinutes(), current.getUTCSeconds(),
96
+ current.getUTCMilliseconds(),
97
+ );
98
+ if (yearDelta < -50 || (yearDelta === -50 && candidateTimeOfYear < currentTimeOfYear)) {
99
+ year += 100;
100
+ } else if (yearDelta > 50 || (yearDelta === 50 && candidateTimeOfYear > currentTimeOfYear)) {
101
+ year -= 100;
102
+ }
103
+ return parseUtcDateParts(
104
+ year, rfc850[2]!, Number(rfc850[1]),
105
+ Number(rfc850[4]), Number(rfc850[5]), Number(rfc850[6]),
106
+ );
107
+ }
108
+ const asctime = ASCTIME_DATE_RE.exec(value);
109
+ if (!asctime) return undefined;
110
+ return parseUtcDateParts(
111
+ Number(asctime[6]), asctime[1]!, Number(asctime[2]),
112
+ Number(asctime[3]), Number(asctime[4]), Number(asctime[5]),
113
+ );
114
+ }
115
+
29
116
  export function parseRetryAfterMs(
30
117
  value: string | null | undefined,
31
118
  now = Date.now(),
119
+ options?: { preserveImmediate?: boolean },
32
120
  ): number | undefined {
33
121
  const text = value?.trim();
34
122
  if (!text) return undefined;
35
123
  if (/^\d+(?:\.\d+)?$/.test(text)) {
36
124
  const seconds = Number(text);
37
- if (Number.isFinite(seconds) && seconds > 0) {
125
+ if (
126
+ Number.isFinite(seconds)
127
+ && (seconds > 0 || (options?.preserveImmediate && seconds === 0))
128
+ ) {
38
129
  return Math.min(Math.max(Math.ceil(seconds * 1000), 1), MAX_COOLDOWN_MS);
39
130
  }
40
131
  }
41
- const timestamp = Date.parse(text);
42
- if (!Number.isFinite(timestamp)) return undefined;
132
+ const timestamp = parseHttpDate(text, now);
133
+ if (timestamp === undefined) return undefined;
43
134
  const delay = timestamp - now;
44
- return delay > 0 ? Math.min(delay, MAX_COOLDOWN_MS) : undefined;
135
+ if (delay > 0) return Math.min(delay, MAX_COOLDOWN_MS);
136
+ return options?.preserveImmediate ? 1 : undefined;
45
137
  }
46
138
 
47
139
  export function isComboTargetInCooldown(
@@ -59,10 +151,59 @@ export function isComboTargetInCooldown(
59
151
  return true;
60
152
  }
61
153
 
154
+ export function isTransientRequestRateLimit(input: {
155
+ status?: number;
156
+ code?: string | null;
157
+ message?: string;
158
+ }): boolean {
159
+ if (isProviderScopedQuotaCap(input.status, input.message ?? "", input.code)) return false;
160
+ const code = (input.code ?? "").trim().toLowerCase().replaceAll("-", "_");
161
+ if (QUOTA_LIMIT_CODES.has(code)) return false;
162
+ if (TRANSIENT_REQUEST_RATE_CODES.has(code)) return true;
163
+ const text = (input.message ?? "").toLowerCase();
164
+ if (
165
+ text.includes("usage limit reached")
166
+ || text.includes("insufficient_quota")
167
+ || text.includes("quota exhausted")
168
+ ) {
169
+ return false;
170
+ }
171
+ return text.includes("rate limit reached for requests");
172
+ }
173
+
174
+ export function remainingComboCooldownMs(comboId: string, now = Date.now()): number | undefined {
175
+ const prefix = `${comboId}\0`;
176
+ let soonest: number | undefined;
177
+ for (const [key, cooldown] of targetCooldowns) {
178
+ if (!key.startsWith(prefix)) continue;
179
+ const remaining = cooldown.cooldownUntil - now;
180
+ if (remaining <= 0) {
181
+ targetCooldowns.delete(key);
182
+ continue;
183
+ }
184
+ if (soonest === undefined || remaining < soonest) soonest = remaining;
185
+ }
186
+ return soonest;
187
+ }
188
+
189
+ export function comboCooldownRetryAfterSeconds(comboId: string, now = Date.now()): string | undefined {
190
+ const remainingMs = remainingComboCooldownMs(comboId, now);
191
+ if (remainingMs === undefined) return undefined;
192
+ return String(Math.max(1, Math.ceil(remainingMs / 1000)));
193
+ }
194
+
62
195
  export function coolComboTarget(
63
196
  comboId: string,
64
197
  target: Pick<OcxComboTarget, "provider" | "model">,
65
- options?: { retryAfter?: string | null; now?: number; cooldownMs?: number; writerGeneration?: number },
198
+ options?: {
199
+ retryAfter?: string | null;
200
+ now?: number;
201
+ cooldownMs?: number;
202
+ writerGeneration?: number;
203
+ status?: number;
204
+ code?: string | null;
205
+ message?: string;
206
+ },
66
207
  ): void {
67
208
  const now = options?.now ?? Date.now();
68
209
  const writerGeneration = options?.writerGeneration ?? captureConfigGeneration();
@@ -70,7 +211,11 @@ export function coolComboTarget(
70
211
  if (writerGeneration < lastReconciledGeneration && !liveComboTargets.has(ownerKey)) return;
71
212
  const cooldownMs = options?.cooldownMs
72
213
  ?? parseRetryAfterMs(options?.retryAfter, now)
73
- ?? DEFAULT_COOLDOWN_MS;
214
+ ?? (isTransientRequestRateLimit({
215
+ status: options?.status,
216
+ code: options?.code,
217
+ message: options?.message,
218
+ }) ? COMBO_REQUEST_RATE_COOLDOWN_MS : DEFAULT_COOLDOWN_MS);
74
219
  targetCooldowns.set(cooldownMapKey(comboId, target), {
75
220
  cooldownUntil: now + Math.min(Math.max(cooldownMs, 1), MAX_COOLDOWN_MS),
76
221
  });
@@ -108,6 +253,37 @@ export function clearComboTargetCooldowns(comboId?: string): void {
108
253
  }
109
254
 
110
255
  export type ComboFailureDecision = "hop" | "stop";
256
+ export type ComboFailureCooldownScope = "target" | "provider";
257
+
258
+ function normalizedFailureCode(code?: string | null): string {
259
+ return code?.trim().toLowerCase().replaceAll("-", "_") ?? "";
260
+ }
261
+
262
+ function isProviderScopedQuotaCap(
263
+ status: number | undefined,
264
+ message: string,
265
+ code?: string | null,
266
+ ): boolean {
267
+ const normalizedCode = normalizedFailureCode(code);
268
+ const text = message.toLowerCase();
269
+ if (
270
+ status === 429
271
+ && (normalizedCode === "gousagelimiterror" || text.includes("monthly usage limit reached"))
272
+ ) {
273
+ return true;
274
+ }
275
+ return normalizedCode === "free_rate_limited"
276
+ || text.includes("err_free_prompt_cap")
277
+ || (text.includes("free tier") && text.includes("single request"));
278
+ }
279
+
280
+ export function comboFailureCooldownScope(
281
+ status: number,
282
+ message: string,
283
+ options?: { code?: string | null },
284
+ ): ComboFailureCooldownScope {
285
+ return isProviderScopedQuotaCap(status, message, options?.code) ? "provider" : "target";
286
+ }
111
287
 
112
288
  function isModelLifecycleGone(
113
289
  status: number,
@@ -168,6 +344,9 @@ export function comboFailureDecision(
168
344
  if (options?.code === "input_admission_refused" || error.code === "input_admission_refused") {
169
345
  return "hop";
170
346
  }
347
+ if (isProviderScopedQuotaCap(status, message, options?.code || error.code)) {
348
+ return "hop";
349
+ }
171
350
  if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) {
172
351
  return "stop";
173
352
  }
@@ -31,11 +31,17 @@ export {
31
31
  } from "./resolve";
32
32
  export {
33
33
  clearComboTargetCooldowns,
34
+ comboCooldownRetryAfterSeconds,
35
+ COMBO_REQUEST_RATE_COOLDOWN_MS,
34
36
  coolComboTarget,
35
37
  isComboTargetInCooldown,
38
+ isTransientRequestRateLimit,
36
39
  parseRetryAfterMs,
40
+ remainingComboCooldownMs,
37
41
  comboFailureDecision,
42
+ comboFailureCooldownScope,
38
43
  type ComboFailureDecision,
44
+ type ComboFailureCooldownScope,
39
45
  } from "./failover";
40
46
  export {
41
47
  comboIdFromRawBody,
@@ -1,6 +1,7 @@
1
1
  import type { OcxComboTarget, OcxConfig } from "../types";
2
2
  import { getCachedProviderQuota } from "../providers/quota-routing-cache";
3
- import { coolComboTarget, isComboTargetInCooldown } from "./failover";
3
+ import type { ProviderQuota } from "../providers/quota-types";
4
+ import { coolComboTarget, isComboTargetInCooldown, type ComboFailureCooldownScope } from "./failover";
4
5
  import { quotaResetRemainingMs } from "./reset-window";
5
6
  import { getCombo, resolveComboId, targetKey } from "./types";
6
7
  import type { NormalizedComboConfig } from "./types";
@@ -58,6 +59,28 @@ function targetProviderIsUsable(config: OcxConfig, target: OcxComboTarget): bool
58
59
  && config.providers[target.provider]?.disabled !== true;
59
60
  }
60
61
 
62
+ function quotaWindowExhausted(percent: number | undefined, resetAt: number | undefined, now: number): boolean {
63
+ if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 100) return false;
64
+ return typeof resetAt !== "number" || !Number.isFinite(resetAt) || resetAt > now;
65
+ }
66
+
67
+ export function cachedProviderQuotaIsExhausted(
68
+ quota: ProviderQuota | null,
69
+ now = Date.now(),
70
+ ): boolean {
71
+ if (!quota) return false;
72
+ if (quotaWindowExhausted(quota.fiveHourPercent, quota.fiveHourResetAt, now)) return true;
73
+ if (quotaWindowExhausted(quota.weeklyPercent, quota.weeklyResetAt, now)) return true;
74
+ if (quotaWindowExhausted(quota.monthlyPercent, quota.monthlyResetAt, now)) return true;
75
+ if (quota.customWindows?.some(window => quotaWindowExhausted(window.percent, window.resetAt, now))) return true;
76
+ if (quota.creditsUsd?.unlimited !== true
77
+ && typeof quota.creditsUsd?.percent === "number"
78
+ && Number.isFinite(quota.creditsUsd.percent)
79
+ && quota.creditsUsd.percent >= 100
80
+ && quota.creditsUsd.remaining <= 0) return true;
81
+ return false;
82
+ }
83
+
61
84
  function smoothWeightedIndex(
62
85
  targets: Required<OcxComboTarget>[],
63
86
  state: SelectionState,
@@ -121,14 +144,17 @@ export function pickComboTarget(
121
144
  options: {
122
145
  exclude?: Iterable<string>;
123
146
  eligible?: (target: Required<OcxComboTarget>) => boolean;
147
+ now?: number;
124
148
  } = {},
125
149
  ): ComboPick | null {
126
150
  const writerGeneration = captureConfigGeneration();
127
151
  const combo = getCombo(config, comboId);
128
152
  if (!combo) throw new UnknownComboError(comboId);
129
153
  const excluded = new Set(options.exclude ?? []);
154
+ const now = options.now ?? Date.now();
130
155
  const eligible = (target: Required<OcxComboTarget>): boolean =>
131
156
  targetProviderIsUsable(config, target)
157
+ && !cachedProviderQuotaIsExhausted(getCachedProviderQuota(target.provider, now), now)
132
158
  && !excluded.has(targetKey(target))
133
159
  && (options.eligible?.(target) ?? true);
134
160
 
@@ -187,7 +213,7 @@ export function pickComboTarget(
187
213
  }
188
214
  }
189
215
  } else if (combo.strategy === "reset-window") {
190
- targetIndex = resetWindowIndex(combo.targets, eligible);
216
+ targetIndex = resetWindowIndex(combo.targets, eligible, now);
191
217
  } else {
192
218
  targetIndex = combo.targets.findIndex(eligible);
193
219
  }
@@ -250,15 +276,26 @@ export function advanceComboAfterFailure(
250
276
  retryAfter?: string | null;
251
277
  now?: number;
252
278
  eligible?: (target: Required<OcxComboTarget>) => boolean;
279
+ cooldownScope?: ComboFailureCooldownScope;
280
+ status?: number;
281
+ code?: string | null;
282
+ message?: string;
253
283
  } = {},
254
284
  ): ComboPick | null {
255
285
  noteComboFailure(pick.comboId, pick.target, pick.writerGeneration);
256
- coolComboTarget(pick.comboId, pick.target, {
257
- ...options,
258
- writerGeneration: pick.writerGeneration,
259
- });
286
+ const combo = getCombo(config, pick.comboId);
287
+ const cooldownTargets = options.cooldownScope === "provider" && combo
288
+ ? combo.targets.filter(target => target.provider === pick.target.provider)
289
+ : [pick.target];
290
+ for (const target of cooldownTargets) {
291
+ coolComboTarget(pick.comboId, target, {
292
+ ...options,
293
+ writerGeneration: pick.writerGeneration,
294
+ });
295
+ }
260
296
  return pickComboTarget(config, pick.comboId, {
261
297
  exclude: pick.attempted,
298
+ now: options.now,
262
299
  eligible: target => !isComboTargetInCooldown(pick.comboId, target, options.now)
263
300
  && (options.eligible?.(target) ?? true),
264
301
  });
package/src/config.ts CHANGED
@@ -1010,7 +1010,9 @@ const configSchema = z.object({
1010
1010
  // A malformed present client block must remain diagnosable from raw config and
1011
1011
  // fail closed through src/client/state.ts; unrelated provider state still loads.
1012
1012
  client: clientConnectionSchema.optional().catch(undefined),
1013
- managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024),
1013
+ managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024).describe(
1014
+ "Deprecated compatibility limit for bounded legacy usage readers; GET /api/usage always aggregates the complete ledger",
1015
+ ),
1014
1016
  // Invalid hand edits disable only this opt-in circuit. Live writes remain strict.
1015
1017
  upstreamHostCircuitThreshold: z.number().int()
1016
1018
  .min(0)
@@ -1046,6 +1048,8 @@ const configSchema = z.object({
1046
1048
  providers: z.record(z.string(), providerConfigSchema),
1047
1049
  defaultProvider: z.string().min(1).default("openai"),
1048
1050
  defaultModelAliases: z.boolean().optional(),
1051
+ // Malformed hand edits disable this opt-in projection without rejecting providers.
1052
+ cursorEffortRows: z.boolean().optional().catch(false),
1049
1053
  // Future versions remain opaque through passthrough-compatible whole-config saves.
1050
1054
  // Only version 1 grants deletion authority in the rebase path.
1051
1055
  configRebaseProvenance: z.unknown().optional(),