@bitkyc08/opencodex 2.36.0-preview.20260829 → 2.36.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 (155) hide show
  1. package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
  2. package/gui/dist/assets/index-DO8liQVL.js +112 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +2 -1
  5. package/src/AGENTS.md +2 -1
  6. package/src/adapters/agentrouter.ts +50 -0
  7. package/src/adapters/anthropic.ts +1 -51
  8. package/src/adapters/cursor/call-id.ts +76 -8
  9. package/src/adapters/cursor/checkpoint-store.ts +6 -1
  10. package/src/adapters/cursor/cursor-errors.ts +44 -0
  11. package/src/adapters/cursor/native-exec.ts +13 -0
  12. package/src/adapters/cursor/protobuf-request.ts +651 -29
  13. package/src/adapters/cursor/tool-result-normalize.ts +3 -3
  14. package/src/adapters/cursor/transport-retry.ts +5 -1
  15. package/src/adapters/cursor.ts +15 -1
  16. package/src/adapters/empty-tool-output-annotation.ts +43 -0
  17. package/src/adapters/exec-tool-result-normalize.ts +70 -5
  18. package/src/adapters/google.ts +22 -2
  19. package/src/adapters/kiro.ts +26 -2
  20. package/src/adapters/ollama-native-url.ts +111 -0
  21. package/src/adapters/ollama-native.ts +1131 -0
  22. package/src/adapters/openai-chat.ts +30 -7
  23. package/src/adapters/openai-responses.ts +72 -4
  24. package/src/adapters/registry.ts +7 -0
  25. package/src/adapters/xai-web-search.ts +58 -0
  26. package/src/claude/desktop-3p.ts +21 -1
  27. package/src/claude/desktop-policy.ts +149 -0
  28. package/src/cli/account.ts +16 -2
  29. package/src/cli/claude-desktop.ts +13 -3
  30. package/src/cli/combo.ts +8 -5
  31. package/src/cli/doctor.ts +77 -11
  32. package/src/cli/help.ts +1 -1
  33. package/src/cli/index.ts +16 -0
  34. package/src/cli/models.ts +20 -3
  35. package/src/cli/registry.ts +2 -1
  36. package/src/cli/status.ts +140 -2
  37. package/src/cli/storage.ts +10 -1
  38. package/src/codex/account-runtime-state.ts +39 -5
  39. package/src/codex/account-store.ts +393 -13
  40. package/src/codex/account-usability.ts +11 -4
  41. package/src/codex/app-server-processes.ts +46 -5
  42. package/src/codex/auth-context.ts +160 -32
  43. package/src/codex/catalog/bundled.ts +7 -5
  44. package/src/codex/catalog/metadata.ts +1 -1
  45. package/src/codex/catalog/parsing.ts +57 -1
  46. package/src/codex/catalog/provider-fetch.ts +61 -4
  47. package/src/codex/catalog/sync.ts +4 -3
  48. package/src/codex/convergence.ts +3 -2
  49. package/src/codex/data/upstream-models.json +40 -8
  50. package/src/codex/inject-coordination.ts +111 -14
  51. package/src/codex/integration-record.ts +12 -2
  52. package/src/codex/main-account.ts +225 -1
  53. package/src/codex/model-entitlements.ts +339 -27
  54. package/src/codex/prompt-layers.ts +346 -7
  55. package/src/codex/prompt-text-probe.ts +272 -21
  56. package/src/codex/routing.ts +693 -132
  57. package/src/codex/runtime.ts +12 -0
  58. package/src/codex/subagent-model-fallback.ts +62 -24
  59. package/src/codex/user-identity.ts +33 -25
  60. package/src/combos/index.ts +1 -0
  61. package/src/combos/reset-window.ts +46 -0
  62. package/src/combos/resolve.ts +84 -2
  63. package/src/combos/types.ts +5 -2
  64. package/src/config/atomic-write.ts +104 -22
  65. package/src/config/provider-validation.ts +11 -0
  66. package/src/config.ts +75 -3
  67. package/src/generated/compatibility-version.json +207 -131
  68. package/src/generated/model-metadata.ts +1 -1
  69. package/src/grok/catalog.ts +71 -0
  70. package/src/grok/effort.ts +83 -0
  71. package/src/grok/inject.ts +952 -127
  72. package/src/grok/models.ts +56 -0
  73. package/src/grok/status.ts +21 -8
  74. package/src/grok/sync.ts +10 -18
  75. package/src/images/loop.ts +6 -3
  76. package/src/integrations/native/ownership-preflight.ts +4 -1
  77. package/src/lab/fabric/producer-isolate.ts +36 -3
  78. package/src/lib/destination-policy.ts +93 -7
  79. package/src/lib/redact.ts +6 -1
  80. package/src/lib/shadow-call.ts +38 -3
  81. package/src/lib/test-home-guard.ts +18 -3
  82. package/src/lib/upstream-retry.ts +43 -6
  83. package/src/lib/windows-secret-acl.ts +66 -0
  84. package/src/lib/windows-text.ts +28 -2
  85. package/src/lib/windows-user-principal.ts +35 -23
  86. package/src/oauth/account-quota-rank.ts +107 -0
  87. package/src/oauth/anthropic-routing.ts +125 -30
  88. package/src/oauth/chatgpt.ts +5 -1
  89. package/src/oauth/generic-account-failover.ts +114 -7
  90. package/src/oauth/index.ts +15 -8
  91. package/src/oauth/store.ts +16 -0
  92. package/src/providers/account-quota-disk.ts +79 -0
  93. package/src/providers/command-code-efforts.ts +24 -0
  94. package/src/providers/derive.ts +6 -0
  95. package/src/providers/key-failover.ts +33 -1
  96. package/src/providers/kiro-usage.ts +272 -0
  97. package/src/providers/ollama-show.ts +311 -0
  98. package/src/providers/openai-sidecar.ts +5 -0
  99. package/src/providers/quota-routing-cache.ts +32 -0
  100. package/src/providers/quota-types.ts +36 -0
  101. package/src/providers/quota-wire.ts +102 -0
  102. package/src/providers/quota.ts +208 -147
  103. package/src/providers/registry.ts +68 -8
  104. package/src/providers/slug-codec.ts +12 -4
  105. package/src/providers/vercel-gateway-routing.ts +108 -0
  106. package/src/router.ts +22 -12
  107. package/src/server/auth-cors.ts +26 -0
  108. package/src/server/catalog-download.ts +73 -0
  109. package/src/server/chat-native.ts +12 -2
  110. package/src/server/gui-static.ts +4 -1
  111. package/src/server/index.ts +132 -9
  112. package/src/server/management/agent-settings-routes.ts +38 -5
  113. package/src/server/management/codex-prompt-routes.ts +7 -1
  114. package/src/server/management/combo-routes.ts +10 -1
  115. package/src/server/management/config-routes.ts +9 -1
  116. package/src/server/management/context.ts +5 -0
  117. package/src/server/management/model-routes.ts +16 -6
  118. package/src/server/management/native-integration-routes.ts +12 -17
  119. package/src/server/management/oauth-account-routes.ts +13 -0
  120. package/src/server/management/provider-routes.ts +32 -5
  121. package/src/server/management/routing-profile-routes.ts +15 -0
  122. package/src/server/management/shadow-call-validation.ts +29 -0
  123. package/src/server/management-api.ts +7 -3
  124. package/src/server/request-log.ts +3 -5
  125. package/src/server/responses/agent-task-recovery-cache.ts +8 -0
  126. package/src/server/responses/agent-task-recovery.ts +52 -20
  127. package/src/server/responses/codex-auth-error.ts +26 -0
  128. package/src/server/responses/compact.ts +345 -10
  129. package/src/server/responses/core.ts +736 -108
  130. package/src/server/responses/empty-completion-guard.ts +16 -0
  131. package/src/server/responses/fetch-helpers.ts +42 -0
  132. package/src/server/responses/policy-fallback.ts +11 -6
  133. package/src/server/responses-undeclared-tool-guard.ts +16 -3
  134. package/src/server/startup-health-cache.ts +59 -13
  135. package/src/service-manager-probe.ts +115 -9
  136. package/src/service.ts +139 -40
  137. package/src/storage/cleanup.ts +10 -0
  138. package/src/storage/storage-mutation-coordinator.ts +14 -3
  139. package/src/tray/windows-tray.ps1 +10 -4
  140. package/src/tray/windows.ts +30 -2
  141. package/src/types/config.ts +27 -14
  142. package/src/types/provider.ts +54 -0
  143. package/src/types/tools.ts +13 -3
  144. package/src/types.ts +4 -0
  145. package/src/usage/summary.ts +421 -177
  146. package/src/vision/anthropic-describe.ts +3 -3
  147. package/src/vision/describe.ts +5 -3
  148. package/src/web-search/anthropic-executor.ts +9 -2
  149. package/src/web-search/exa-executor.ts +3 -3
  150. package/src/web-search/executor.ts +8 -3
  151. package/src/web-search/gemini-executor.ts +3 -3
  152. package/src/web-search/loop.ts +11 -3
  153. package/src/web-search/xai-executor.ts +3 -3
  154. package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
  155. package/gui/dist/assets/index-DX0RGj3e.js +0 -112
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Wire-level helpers shared by every provider quota reader: cache lifetimes, number and
3
+ * timestamp normalisation, and the bounded JSON reader.
4
+ *
5
+ * These lived inside `quota.ts` as module-private functions. A second quota module cannot
6
+ * import them from there without creating a cycle, and copying a TTL constant into a
7
+ * second file is how two copies of the same number drift apart. Everything here is pure or
8
+ * depends only on the bounded-body reader.
9
+ */
10
+ import { readBoundedResponseBody } from "../lib/bounded-body";
11
+
12
+ /** Provider-level quota response cache lifetime (the dashboard/display path). */
13
+ export const CACHE_TTL_MS = 5 * 60_000;
14
+
15
+ /**
16
+ * Per-account quota cache lifetime.
17
+ *
18
+ * Deliberately longer than the provider-level TTL: this path multiplies by account count,
19
+ * and at least one upstream (Anthropic) rate-limits its usage endpoint under repeated
20
+ * probing.
21
+ */
22
+ export const ACCOUNT_QUOTA_TTL_MS = 10 * 60_000;
23
+
24
+ export const REQUEST_TIMEOUT_MS = 8_000;
25
+
26
+ export const QUOTA_RESPONSE_MAX_BYTES = 512 * 1024;
27
+
28
+ export const QUOTA_JSON_READ_FAILURE = Symbol("quota-json-read-failure");
29
+
30
+ /** Unix 0 / negative values are sentinels, not reset clocks (Command Code fiveHour.resetAt: 0). */
31
+ export function epochMillis(value: number): number | undefined {
32
+ if (!Number.isFinite(value) || value <= 0) return undefined;
33
+ const milliseconds = value > 10_000_000_000 ? value : value * 1000;
34
+ // A finite number is not necessarily a representable date. ECMAScript caps time values at
35
+ // ±8.64e15 ms, and `Intl.DateTimeFormat.format()` throws a RangeError past that instead of
36
+ // rendering something wrong. A provider that reports a bogus expiry must not become a
37
+ // rendering fault in every consumer that formats it.
38
+ return Number.isFinite(new Date(milliseconds).getTime()) ? milliseconds : undefined;
39
+ }
40
+
41
+ export function normalizeResetAt(value: unknown): number | undefined {
42
+ if (typeof value === "number" && Number.isFinite(value)) return epochMillis(value);
43
+ if (typeof value === "string" && value.trim()) {
44
+ const trimmed = value.trim();
45
+ // Cursor Connect RPC returns billingCycleEnd as a unix-ms decimal string ("1771077734000").
46
+ // Date.parse treats that as invalid; numeric epoch strings must be handled explicitly.
47
+ if (/^[+-]?\d+(\.\d+)?$/.test(trimmed)) {
48
+ const numeric = Number(trimmed);
49
+ return epochMillis(numeric);
50
+ }
51
+ const parsed = Date.parse(trimmed);
52
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
53
+ }
54
+ return undefined;
55
+ }
56
+
57
+ export function toFiniteNumber(value: unknown): number | undefined {
58
+ if (typeof value === "number" && Number.isFinite(value)) return value;
59
+ if (typeof value === "string" && value.trim()) {
60
+ const parsed = Number(value);
61
+ return Number.isFinite(parsed) ? parsed : undefined;
62
+ }
63
+ return undefined;
64
+ }
65
+
66
+ export function normalizePercent(value: unknown): number | undefined {
67
+ const numeric = toFiniteNumber(value);
68
+ return numeric === undefined ? undefined : Math.max(0, Math.min(100, numeric));
69
+ }
70
+
71
+ export function asRecord(value: unknown): Record<string, unknown> | null {
72
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
73
+ }
74
+
75
+ export async function readQuotaJson(
76
+ response: Response,
77
+ timeoutMs = REQUEST_TIMEOUT_MS,
78
+ ): Promise<unknown | typeof QUOTA_JSON_READ_FAILURE> {
79
+ const declaredLength = Number(response.headers.get("content-length"));
80
+ if (Number.isFinite(declaredLength) && declaredLength > QUOTA_RESPONSE_MAX_BYTES) {
81
+ try {
82
+ void response.body?.cancel(
83
+ new DOMException("Provider quota response is too large", "QuotaExceededError"),
84
+ ).catch(() => undefined);
85
+ } catch {
86
+ // Best-effort cancellation only.
87
+ }
88
+ return QUOTA_JSON_READ_FAILURE;
89
+ }
90
+
91
+ try {
92
+ const bounded = await readBoundedResponseBody(response, {
93
+ maxBytes: QUOTA_RESPONSE_MAX_BYTES,
94
+ totalTimeoutMs: timeoutMs,
95
+ inactivityTimeoutMs: timeoutMs,
96
+ });
97
+ if (bounded.oversized || bounded.truncated || !bounded.displaySafe) return QUOTA_JSON_READ_FAILURE;
98
+ return JSON.parse(bounded.text) as unknown;
99
+ } catch {
100
+ return QUOTA_JSON_READ_FAILURE;
101
+ }
102
+ }
@@ -23,21 +23,52 @@ import {
23
23
  sweepExpiredOnWrite,
24
24
  type GenerationContext,
25
25
  } from "../lib/state-store-sweeper";
26
- import { readBoundedResponseBody } from "../lib/bounded-body";
26
+ import {
27
+ ACCOUNT_QUOTA_TTL_MS,
28
+ asRecord,
29
+ CACHE_TTL_MS,
30
+ normalizePercent,
31
+ normalizeResetAt,
32
+ QUOTA_JSON_READ_FAILURE,
33
+ readQuotaJson,
34
+ REQUEST_TIMEOUT_MS,
35
+ toFiniteNumber,
36
+ } from "./quota-wire";
37
+ import {
38
+ clearCachedProviderQuotas,
39
+ replaceCachedProviderQuotas,
40
+ } from "./quota-routing-cache";
27
41
  import {
28
42
  aggregateCodexPoolCapacity,
29
43
  CODEX_CAPACITY_MAX_QUOTA_AGE_MS,
30
44
  type CodexCapacityAggregation,
31
45
  type CodexCapacityQuota,
32
46
  } from "./codex-capacity";
47
+ import type {
48
+ ProviderQuota,
49
+ ProviderQuotaCreditsUsd,
50
+ ProviderQuotaWindow,
51
+ } from "./quota-types";
52
+ import {
53
+ clearKiroAccountUsageState,
54
+ commitKiroAccountUsageState,
55
+ fetchKiroUsageSnapshot,
56
+ type KiroUsageSnapshot,
57
+ kiroUsageContextForAccount,
58
+ reconcileKiroAccountUsageState,
59
+ } from "./kiro-usage";
60
+ import {
61
+ cancelPendingAccountQuotaPersist,
62
+ readPersistedAccountQuotas,
63
+ schedulePersistAccountQuotas,
64
+ } from "./account-quota-disk";
65
+
66
+ export type { ProviderQuota, ProviderQuotaCreditsUsd, ProviderQuotaWindow } from "./quota-types";
33
67
 
34
68
  /** Match oauth/index REFRESH_SKEW_MS — use stored access without refresh when still fresh. */
35
69
  const ACCOUNT_TOKEN_SKEW_MS = 60_000;
36
-
37
- const CACHE_TTL_MS = 5 * 60_000;
38
- const REQUEST_TIMEOUT_MS = 8_000;
39
70
  /** Successful provider quota payloads are small; reject oversized or stalled JSON before parsing. */
40
- export const QUOTA_RESPONSE_MAX_BYTES = 512 * 1024;
71
+ export { QUOTA_RESPONSE_MAX_BYTES } from "./quota-wire";
41
72
  const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1";
42
73
  const KIMI_CODE_USAGE_URL = `${KIMI_CODE_BASE_URL}/usages`;
43
74
  const COMMAND_CODE_BASE_URL = "https://api.commandcode.ai";
@@ -73,34 +104,23 @@ export function setProviderQuotaBeforePublishForTests(
73
104
  providerQuotaBeforePublishForTests = hook;
74
105
  }
75
106
  const TERMINAL_QUOTA_FAILURE = Symbol("terminal-quota-failure");
76
- type ProviderQuotaProbeResult = ProviderQuotaReport | null | typeof TERMINAL_QUOTA_FAILURE;
77
-
78
- export interface ProviderQuotaWindow {
79
- label: string;
80
- percent: number;
81
- resetAt?: number;
82
- }
83
-
84
- export interface ProviderQuotaCreditsUsd {
85
- used: number;
86
- limit: number;
87
- remaining: number;
88
- percent: number;
89
- expiresAt?: number;
90
- unlimited?: boolean;
91
- }
92
-
93
- export interface ProviderQuota {
94
- fiveHourPercent?: number;
95
- fiveHourResetAt?: number;
96
- weeklyPercent?: number;
97
- weeklyResetAt?: number;
98
- monthlyPercent?: number;
99
- monthlyResetAt?: number;
100
- customWindows?: ProviderQuotaWindow[];
101
- creditsUsd?: ProviderQuotaCreditsUsd;
102
- updatedAt: number;
103
- }
107
+ /**
108
+ * The probe succeeded and the upstream authoritatively reported NO model-quota windows.
109
+ *
110
+ * Distinct from `null`, which means "this probe told us nothing" and deliberately preserves
111
+ * the last-good row for up to 30 minutes. Collapsing the two would let a stale report outlive
112
+ * the authoritative answer that replaced it: a GLM plan whose payload carries only MCP
113
+ * `TIME_LIMIT` rows has no model windows, and the dashboard and quota-aware routing must stop
114
+ * showing the previous token windows rather than keep them for another half hour.
115
+ *
116
+ * Suppression is shared with `TERMINAL_QUOTA_FAILURE`; only the reason differs.
117
+ */
118
+ const AUTHORITATIVE_EMPTY_QUOTA = Symbol("authoritative-empty-quota");
119
+ type ProviderQuotaProbeResult =
120
+ | ProviderQuotaReport
121
+ | null
122
+ | typeof TERMINAL_QUOTA_FAILURE
123
+ | typeof AUTHORITATIVE_EMPTY_QUOTA;
104
124
 
105
125
  export interface ProviderQuotaReport {
106
126
  provider: string;
@@ -125,6 +145,7 @@ let invalidationEpoch = 0;
125
145
  /** Invalidate the report cache (e.g. after switching a provider's active account). */
126
146
  export function clearProviderQuotaCache(): void {
127
147
  cache = null;
148
+ clearCachedProviderQuotas();
128
149
  invalidationEpoch += 1;
129
150
  }
130
151
 
@@ -260,77 +281,6 @@ function providerLabel(providerId: string): string {
260
281
  return getProviderRegistryEntry(providerId)?.label ?? providerId;
261
282
  }
262
283
 
263
- function normalizeResetAt(value: unknown): number | undefined {
264
- if (typeof value === "number" && Number.isFinite(value)) return epochMillis(value);
265
- if (typeof value === "string" && value.trim()) {
266
- const trimmed = value.trim();
267
- // Cursor Connect RPC returns billingCycleEnd as a unix-ms decimal string ("1771077734000").
268
- // Date.parse treats that as invalid; numeric epoch strings must be handled explicitly.
269
- if (/^[+-]?\d+(\.\d+)?$/.test(trimmed)) {
270
- const numeric = Number(trimmed);
271
- return epochMillis(numeric);
272
- }
273
- const parsed = Date.parse(trimmed);
274
- return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
275
- }
276
- return undefined;
277
- }
278
-
279
- /** Unix 0 / negative values are sentinels, not reset clocks (Command Code fiveHour.resetAt: 0). */
280
- function epochMillis(value: number): number | undefined {
281
- if (!Number.isFinite(value) || value <= 0) return undefined;
282
- return value > 10_000_000_000 ? value : value * 1000;
283
- }
284
-
285
- function toFiniteNumber(value: unknown): number | undefined {
286
- if (typeof value === "number" && Number.isFinite(value)) return value;
287
- if (typeof value === "string" && value.trim()) {
288
- const parsed = Number(value);
289
- return Number.isFinite(parsed) ? parsed : undefined;
290
- }
291
- return undefined;
292
- }
293
-
294
- function normalizePercent(value: unknown): number | undefined {
295
- const numeric = toFiniteNumber(value);
296
- return numeric === undefined ? undefined : Math.max(0, Math.min(100, numeric));
297
- }
298
-
299
- function asRecord(value: unknown): Record<string, unknown> | null {
300
- return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
301
- }
302
-
303
- const QUOTA_JSON_READ_FAILURE = Symbol("quota-json-read-failure");
304
-
305
- async function readQuotaJson(
306
- response: Response,
307
- timeoutMs = REQUEST_TIMEOUT_MS,
308
- ): Promise<unknown | typeof QUOTA_JSON_READ_FAILURE> {
309
- const declaredLength = Number(response.headers.get("content-length"));
310
- if (Number.isFinite(declaredLength) && declaredLength > QUOTA_RESPONSE_MAX_BYTES) {
311
- try {
312
- void response.body?.cancel(
313
- new DOMException("Provider quota response is too large", "QuotaExceededError"),
314
- ).catch(() => undefined);
315
- } catch {
316
- // Best-effort cancellation only.
317
- }
318
- return QUOTA_JSON_READ_FAILURE;
319
- }
320
-
321
- try {
322
- const bounded = await readBoundedResponseBody(response, {
323
- maxBytes: QUOTA_RESPONSE_MAX_BYTES,
324
- totalTimeoutMs: timeoutMs,
325
- inactivityTimeoutMs: timeoutMs,
326
- });
327
- if (bounded.oversized || bounded.truncated || !bounded.displaySafe) return QUOTA_JSON_READ_FAILURE;
328
- return JSON.parse(bounded.text) as unknown;
329
- } catch {
330
- return QUOTA_JSON_READ_FAILURE;
331
- }
332
- }
333
-
334
284
  /** Test-only access to the quota reader's deadline and cancellation contract. */
335
285
  export async function readProviderQuotaJsonForTests(response: Response, timeoutMs: number): Promise<unknown> {
336
286
  const result = await readQuotaJson(response, timeoutMs);
@@ -702,10 +652,19 @@ async function fetchClineQuota(provider: string, config: OcxProviderConfig): Pro
702
652
  * same rows `CREDIT_LIMIT`) and `TIME_LIMIT` rows. `TOKENS_LIMIT`/`CREDIT_LIMIT`
703
653
  * rows carry the window length as `unit`/`number`: unit 3 is hours (number 5 →
704
654
  * the rolling five-hour window), unit 6 is weeks (number 1 → the weekly
705
- * window). `TIME_LIMIT` rows are the monthly MCP tool budget (Web Search / Web
706
- * Reader / Zread). Every row's `percentage` is the consumed share (falling
655
+ * window). Every row's `percentage` is the consumed share (falling
707
656
  * back to `currentValue`/`usage` when absent) and `nextResetTime` (unix ms)
708
657
  * the window reset.
658
+ *
659
+ * `TIME_LIMIT` rows are deliberately ignored (issue #1168). They are the shared
660
+ * monthly MCP *call* allowance for Web Search / Web Reader / Zread — not a
661
+ * model-token budget — and `ProviderQuota.monthlyPercent` is consumed as a
662
+ * model-capacity signal: `headroomOf()` in `src/oauth/account-quota-rank.ts`
663
+ * takes the MAX across every window, so a user who spent their MCP search
664
+ * allowance would be ranked as having no model capacity left, and the dashboard
665
+ * would draw a full monthly bar for a plan whose model tokens are untouched.
666
+ * A payload carrying only `TIME_LIMIT` rows therefore reports no quota at all,
667
+ * which is the honest answer rather than a fabricated one.
709
668
  */
710
669
  export function parseZaiQuotaLimits(data: Record<string, unknown> | null): ProviderQuota | null {
711
670
  const limits = Array.isArray(data?.limits) ? data.limits as unknown[] : null;
@@ -715,6 +674,9 @@ export function parseZaiQuotaLimits(data: Record<string, unknown> | null): Provi
715
674
  for (const raw of limits) {
716
675
  const row = asRecord(raw);
717
676
  if (!row) continue;
677
+ // Gate on row type before deriving a percentage: an MCP row must not even
678
+ // contribute a parsed value to a model-quota report.
679
+ if (row.type !== "TOKENS_LIMIT" && row.type !== "CREDIT_LIMIT") continue;
718
680
  const resetAt = normalizeResetAt(row.nextResetTime);
719
681
  let percent = normalizePercent(row.percentage);
720
682
  if (percent === undefined) {
@@ -725,21 +687,15 @@ export function parseZaiQuotaLimits(data: Record<string, unknown> | null): Provi
725
687
  }
726
688
  }
727
689
  if (percent === undefined) continue;
728
- if (row.type === "TOKENS_LIMIT" || row.type === "CREDIT_LIMIT") {
729
- const unit = toFiniteNumber(row.unit);
730
- const number = toFiniteNumber(row.number);
731
- if (unit === 3 && number === 5) {
732
- quota.fiveHourPercent = percent;
733
- if (resetAt !== undefined) quota.fiveHourResetAt = resetAt;
734
- windows += 1;
735
- } else if (unit === 6 && number === 1) {
736
- quota.weeklyPercent = percent;
737
- if (resetAt !== undefined) quota.weeklyResetAt = resetAt;
738
- windows += 1;
739
- }
740
- } else if (row.type === "TIME_LIMIT") {
741
- quota.monthlyPercent = percent;
742
- if (resetAt !== undefined) quota.monthlyResetAt = resetAt;
690
+ const unit = toFiniteNumber(row.unit);
691
+ const number = toFiniteNumber(row.number);
692
+ if (unit === 3 && number === 5) {
693
+ quota.fiveHourPercent = percent;
694
+ if (resetAt !== undefined) quota.fiveHourResetAt = resetAt;
695
+ windows += 1;
696
+ } else if (unit === 6 && number === 1) {
697
+ quota.weeklyPercent = percent;
698
+ if (resetAt !== undefined) quota.weeklyResetAt = resetAt;
743
699
  windows += 1;
744
700
  }
745
701
  }
@@ -781,9 +737,16 @@ function parseZaiQuotaLegacyFields(data: Record<string, unknown> | null): Provid
781
737
 
782
738
  /**
783
739
  * Fetches the Z.AI GLM Coding Plan quota — on whichever region the provider
784
- * points at (api.z.ai or open.bigmodel.cn). Authenticates with the API key as
785
- * a Bearer token per Z.AI's API reference. The `limits` array shape is
740
+ * points at (api.z.ai or open.bigmodel.cn). The `limits` array shape is
786
741
  * preferred; older field-name payloads fall back to the legacy parser.
742
+ *
743
+ * Authentication differs by host (issue #1168). `api.z.ai` takes the API key as
744
+ * a Bearer token per Z.AI's API reference; `open.bigmodel.cn` expects the key
745
+ * directly in `Authorization` with no scheme prefix and answers a Bearer header
746
+ * with an auth error, which is why BigModel Coding Plan quota never rendered.
747
+ * The host is already canonicalized by `isCanonicalZaiBaseUrl` above and
748
+ * `redirect: "error"` stays set, so the bare key cannot travel to a lookalike
749
+ * host or follow a redirect off-origin.
787
750
  */
788
751
  async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
789
752
  if (!isCanonicalZaiBaseUrl(config.baseUrl)) return null;
@@ -793,8 +756,9 @@ async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promi
793
756
  const monitorHost = normalized === ZAI_BASE_URL || normalized === `${ZAI_BASE_URL}/api/coding/paas/v4`
794
757
  ? ZAI_BASE_URL
795
758
  : ZAI_CN_BASE_URL;
759
+ const authorization = monitorHost === ZAI_CN_BASE_URL ? apiKey : `Bearer ${apiKey}`;
796
760
  const response = await fetch(`${monitorHost}/api/monitor/usage/quota/limit`, {
797
- headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
761
+ headers: { Accept: "application/json", Authorization: authorization },
798
762
  redirect: "error",
799
763
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
800
764
  });
@@ -806,10 +770,16 @@ async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promi
806
770
  const body = asRecord(await readQuotaJson(response));
807
771
  if (!body || body.success === false) return null;
808
772
  const data = asRecord(body.data) ?? body;
809
- const quota = Array.isArray(data?.limits)
810
- ? parseZaiQuotaLimits(data)
811
- : parseZaiQuotaLegacyFields(data);
812
- return quota ? report(provider, "zai:quota-limit", quota) : null;
773
+ if (Array.isArray(data?.limits)) {
774
+ const quota = parseZaiQuotaLimits(data);
775
+ // A well-formed `limits[]` we fully understood is authoritative even when it yields no
776
+ // model window for example a plan reporting only the monthly MCP `TIME_LIMIT` row.
777
+ // Returning `null` here would preserve the previous token windows for up to 30 minutes
778
+ // and keep quota-aware routing acting on a report the provider has already superseded.
779
+ return quota ? report(provider, "zai:quota-limit", quota) : AUTHORITATIVE_EMPTY_QUOTA;
780
+ }
781
+ const legacy = parseZaiQuotaLegacyFields(data);
782
+ return legacy ? report(provider, "zai:quota-limit", legacy) : null;
813
783
  }
814
784
 
815
785
  /**
@@ -1219,14 +1189,17 @@ export function parseXaiCreditsResponse(value: unknown): { percent: number; rese
1219
1189
  if (!config) return null;
1220
1190
  const period = asRecord(config.currentPeriod);
1221
1191
  if (!period || period.type !== "USAGE_PERIOD_TYPE_WEEKLY") return null;
1222
- const resetAt = normalizeResetAt(period.end);
1223
- if (resetAt === undefined) return null;
1192
+ let percent = 0;
1224
1193
  if (config.creditUsagePercent !== undefined) {
1225
- const percent = normalizePercent(config.creditUsagePercent);
1226
- if (percent === undefined) return null;
1227
- return { percent, resetAt };
1194
+ const normalized = normalizePercent(config.creditUsagePercent);
1195
+ if (normalized === undefined) return null;
1196
+ percent = normalized;
1228
1197
  }
1229
- return { percent: 0, resetAt };
1198
+ const resetAt = normalizeResetAt(period.end);
1199
+ return {
1200
+ percent,
1201
+ ...(resetAt !== undefined ? { resetAt } : {}),
1202
+ };
1230
1203
  }
1231
1204
 
1232
1205
  async function fetchXaiWeeklyCredits(accessToken: string, userId: string): Promise<ProviderQuota | null> {
@@ -1409,20 +1382,44 @@ async function fetchAnthropicQuota(provider: string): Promise<ProviderQuotaRepor
1409
1382
  return report(provider, "anthropic:oauth-usage", quota);
1410
1383
  }
1411
1384
 
1385
+ /**
1386
+ * Provider-level Kiro row: the active account's usage, shown on the Providers page.
1387
+ *
1388
+ * The per-account cache is seeded from the same probe so opening that page does not read
1389
+ * the active account twice, and the account id is captured before the await so a
1390
+ * concurrent account switch cannot file this answer under the wrong account.
1391
+ */
1392
+ async function fetchKiroQuota(provider: string): Promise<ProviderQuotaReport | null> {
1393
+ const probedAccountId = getAccountSet("kiro")?.activeAccountId;
1394
+ if (!probedAccountId) return null;
1395
+ const probedAccountKey = accountCacheKey("kiro", probedAccountId);
1396
+ const writerGeneration = captureConfigGeneration();
1397
+ let snapshot: KiroUsageSnapshot | null;
1398
+ try {
1399
+ snapshot = await fetchKiroUsageSnapshot(await kiroUsageContextForAccount(probedAccountId));
1400
+ } catch {
1401
+ return null;
1402
+ }
1403
+ if (!snapshot) return null;
1404
+ if (mayCommitAccountQuotaKey(probedAccountKey, writerGeneration)) {
1405
+ accountQuotaCache.set(probedAccountKey, { ts: Date.now(), quota: snapshot.quota });
1406
+ commitKiroAccountUsageState(probedAccountKey, snapshot);
1407
+ }
1408
+ return report(provider, "kiro:usage-limits", snapshot.quota);
1409
+ }
1410
+
1412
1411
  // ---------------------------------------------------------------------------
1413
1412
  // Per-account quota (multiauth)
1414
1413
  // ---------------------------------------------------------------------------
1415
1414
 
1416
1415
  /**
1417
- * Anthropic reports usage per CREDENTIAL, so every logged-in account can be probed with its
1418
- * own bearer token — the active-account selection and the local usage log are irrelevant here.
1419
- * Mirrors the Codex pool behaviour (codex/auth-api.ts:fetchPoolAccountQuota), including a
1420
- * per-account TTL so N accounts cost at most N upstream calls per window.
1421
- *
1422
- * The TTL is deliberately longer than the provider-level one: this path multiplies by account
1423
- * count, and Anthropic rate-limits the usage endpoint (observed 429 under repeated probing).
1416
+ * Anthropic and Kiro both report usage per CREDENTIAL, so every logged-in account can be
1417
+ * probed with its own bearer token — the active-account selection and the local usage log
1418
+ * are irrelevant here. Mirrors the Codex pool behaviour
1419
+ * (codex/auth-api.ts:fetchPoolAccountQuota), including a per-account TTL so N accounts cost
1420
+ * at most N upstream calls per window. `ACCOUNT_QUOTA_TTL_MS` lives in `quota-wire.ts`
1421
+ * because the Kiro exhaustion reader applies the same staleness bound.
1424
1422
  */
1425
- const ACCOUNT_QUOTA_TTL_MS = 10 * 60_000;
1426
1423
  type AccountQuotaCacheEntry = {
1427
1424
  ts: number;
1428
1425
  quota: ProviderQuota | null;
@@ -1430,6 +1427,31 @@ type AccountQuotaCacheEntry = {
1430
1427
  unavailable?: true;
1431
1428
  };
1432
1429
  const accountQuotaCache = new Map<string, AccountQuotaCacheEntry>();
1430
+
1431
+ /**
1432
+ * Seed the cache from the last run, once.
1433
+ *
1434
+ * Without this a restart forgets every measurement, so the pool opens its next turn with
1435
+ * no idea which account has room — the exact blindness pre-dispatch selection exists to
1436
+ * remove. A hydrated row is still subject to the ordinary TTL, so it orders the first
1437
+ * request and is replaced by a live probe immediately after.
1438
+ */
1439
+ let diskHydrated = false;
1440
+ function hydrateAccountQuotaCache(): void {
1441
+ if (diskHydrated) return;
1442
+ diskHydrated = true;
1443
+ for (const [key, quota] of readPersistedAccountQuotas()) {
1444
+ if (!accountQuotaCache.has(key)) accountQuotaCache.set(key, { ts: quota.updatedAt, quota });
1445
+ }
1446
+ }
1447
+
1448
+ function persistAccountQuotaCache(): void {
1449
+ schedulePersistAccountQuotas(function* () {
1450
+ for (const [key, entry] of accountQuotaCache) {
1451
+ if (entry.quota) yield [key, entry.quota] as [string, ProviderQuota];
1452
+ }
1453
+ });
1454
+ }
1433
1455
  const accountQuotaInflight = new Map<string, Promise<AccountQuotaCacheEntry>>();
1434
1456
  let lastReconciledGeneration = 0;
1435
1457
  let liveAccountQuotaKeys = new Set<string>();
@@ -1452,7 +1474,7 @@ export interface ProviderAccountQuota {
1452
1474
 
1453
1475
  /** Providers whose per-account quota can be probed. Extend as other OAuth APIs are covered. */
1454
1476
  export function supportsPerAccountQuota(provider: string): boolean {
1455
- return provider === "anthropic";
1477
+ return provider === "anthropic" || provider === "kiro";
1456
1478
  }
1457
1479
 
1458
1480
  function accountCacheKey(provider: string, accountId: string): string {
@@ -1500,10 +1522,14 @@ export function reconcileProviderAccountQuotaRows(context: GenerationContext): n
1500
1522
  accountQuotaCache.delete(key);
1501
1523
  removed += 1;
1502
1524
  }
1525
+ // Kiro exhaustion rows are keyed identically, so they retire with their quota row; a
1526
+ // verdict outliving its account would hand the replacement a cooldown it never earned.
1527
+ removed += reconcileKiroAccountUsageState(context.oauthAccountKeys);
1503
1528
  if (cache) {
1504
1529
  const reports = cache.response.reports.filter(report => context.providerNames.has(report.provider));
1505
1530
  removed += cache.response.reports.length - reports.length;
1506
1531
  cache = { ...cache, response: { ...cache.response, reports } };
1532
+ replaceCachedProviderQuotas(reports);
1507
1533
  }
1508
1534
  liveAccountQuotaKeys = new Set(context.oauthAccountKeys);
1509
1535
  liveProviderQuotaKeys = new Set(context.providerNames);
@@ -1523,16 +1549,23 @@ export function clearAccountQuotaCache(provider?: string): void {
1523
1549
  if (!provider) {
1524
1550
  accountQuotaCache.clear();
1525
1551
  accountQuotaInflight.clear();
1552
+ clearKiroAccountUsageState();
1553
+ // A cleared cache must not be re-seeded from the file it was just cleared of, and any
1554
+ // pending write of the old rows is abandoned.
1555
+ diskHydrated = false;
1556
+ cancelPendingAccountQuotaPersist();
1526
1557
  return;
1527
1558
  }
1528
1559
  const prefix = `${provider}\u0000`;
1529
1560
  for (const key of [...accountQuotaCache.keys()]) {
1530
1561
  if (key.startsWith(prefix)) accountQuotaCache.delete(key);
1531
1562
  }
1563
+ clearKiroAccountUsageState(prefix);
1532
1564
  // Drop in-flight probes too so a late resolve cannot repopulate after logout/remove.
1533
1565
  for (const key of [...accountQuotaInflight.keys()]) {
1534
1566
  if (key.startsWith(prefix)) accountQuotaInflight.delete(key);
1535
1567
  }
1568
+ persistAccountQuotaCache();
1536
1569
  }
1537
1570
 
1538
1571
  /**
@@ -1571,8 +1604,21 @@ async function fetchAccountQuota(
1571
1604
 
1572
1605
  const probe = (async (): Promise<AccountQuotaCacheEntry> => {
1573
1606
  try {
1574
- const token = await getTokenForAccountQuotaProbe(provider, accountId);
1575
- const quota = await fetchAnthropicUsageQuota(token);
1607
+ let quota: ProviderQuota | null;
1608
+ let kiroSnapshot: KiroUsageSnapshot | null = null;
1609
+ if (provider === "kiro") {
1610
+ // Kiro resolves the bearer and its routing metadata from ONE account-scoped
1611
+ // snapshot. It deliberately does not use getTokenForAccountQuotaProbe: that
1612
+ // helper refuses to refresh a background `local-cli` slot because Anthropic's
1613
+ // lock can adopt a mismatched Claude CLI identity, but Kiro marks every
1614
+ // CLI-imported credential `local-cli`, so the same rule would blank the quota of
1615
+ // every inactive pool account the moment its token expired.
1616
+ kiroSnapshot = await fetchKiroUsageSnapshot(await kiroUsageContextForAccount(accountId));
1617
+ quota = kiroSnapshot?.quota ?? null;
1618
+ } else {
1619
+ const token = await getTokenForAccountQuotaProbe(provider, accountId);
1620
+ quota = await fetchAnthropicUsageQuota(token);
1621
+ }
1576
1622
  if (!quota) {
1577
1623
  // Preserve last-good bars and mark unavailable; advance TTL so failures
1578
1624
  // negative-cache instead of re-probing on every GUI poll.
@@ -1583,6 +1629,7 @@ async function fetchAccountQuota(
1583
1629
  };
1584
1630
  if (mayCommitAccountQuotaKey(key, writerGeneration)) {
1585
1631
  accountQuotaCache.set(key, entry);
1632
+ if (provider === "kiro") commitKiroAccountUsageState(key, null);
1586
1633
  sweepExpiredOnWrite(entry.ts);
1587
1634
  }
1588
1635
  return entry;
@@ -1590,6 +1637,9 @@ async function fetchAccountQuota(
1590
1637
  const entry: AccountQuotaCacheEntry = { ts: Date.now(), quota };
1591
1638
  if (mayCommitAccountQuotaKey(key, writerGeneration)) {
1592
1639
  accountQuotaCache.set(key, entry);
1640
+ // Exhaustion state rides the SAME commit guard as the quota row: a probe from a
1641
+ // superseded config generation must not publish either half.
1642
+ if (provider === "kiro") commitKiroAccountUsageState(key, kiroSnapshot);
1593
1643
  sweepExpiredOnWrite(entry.ts);
1594
1644
  }
1595
1645
  return entry;
@@ -2199,6 +2249,7 @@ async function maybeFetchProviderQuota(
2199
2249
  if (provider.authMode === "oauth" && name === "anthropic") return fetchAnthropicQuota(name);
2200
2250
  if (provider.authMode === "oauth" && name === "cursor") return fetchCursorQuota(name);
2201
2251
  if (provider.authMode === "oauth" && name === "google-antigravity") return fetchAntigravityQuota(name, provider);
2252
+ if (provider.authMode === "oauth" && name === "kiro") return fetchKiroQuota(name);
2202
2253
  // Kimi Code `/usages` accepts OAuth or coding-plan API keys, but only on the canonical
2203
2254
  // host and only for real key auth — forward/local modes carry no credential of ours.
2204
2255
  if (provider.authMode === "oauth" && name === "kimi") return fetchKimiQuota(name, provider);
@@ -2293,9 +2344,18 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh
2293
2344
  maybeFetchProviderQuota(name, provider, config, forceRefresh, prefetchedCodexSnapshot)
2294
2345
  )),
2295
2346
  );
2296
- const fresh = probeResults.filter((item): item is ProviderQuotaReport => item !== null && item !== TERMINAL_QUOTA_FAILURE);
2347
+ const fresh = probeResults.filter((item): item is ProviderQuotaReport => (
2348
+ item !== null && item !== TERMINAL_QUOTA_FAILURE && item !== AUTHORITATIVE_EMPTY_QUOTA
2349
+ ));
2350
+ // Both sentinels suppress the previous row. A terminal failure means the response was
2351
+ // invalid; an authoritative empty means the response was valid and said there are no
2352
+ // model windows. Either way the old row is no longer true, which is what separates them
2353
+ // from `null` (told us nothing — keep the last-good row).
2297
2354
  const terminalFailures = new Set(
2298
- Object.keys(config.providers).filter((_, index) => probeResults[index] === TERMINAL_QUOTA_FAILURE),
2355
+ Object.keys(config.providers).filter((_, index) => (
2356
+ probeResults[index] === TERMINAL_QUOTA_FAILURE
2357
+ || probeResults[index] === AUTHORITATIVE_EMPTY_QUOTA
2358
+ )),
2299
2359
  );
2300
2360
  await providerQuotaBeforePublishForTests?.();
2301
2361
  let commitKey: string | null = null;
@@ -2341,6 +2401,7 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh
2341
2401
  ) {
2342
2402
  const reports = response.reports.filter(item => mayCommitProviderQuotaKey(item.provider, writerGeneration));
2343
2403
  cache = { key, ts: Date.now(), response: { ...response, reports } };
2404
+ replaceCachedProviderQuotas(reports);
2344
2405
  }
2345
2406
  return response;
2346
2407
  })();