@bitkyc08/opencodex 2.88.0 → 2.89.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 (205) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-BnRSujOq.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-7z1COnmz.js} +1 -1
  6. package/gui/dist/assets/{index-B5bsvxor.js → index-uchD-ngf.js} +13 -13
  7. package/gui/dist/assets/{quota-summary-D8y2wTsB.js → quota-summary-BY_10IEz.js} +1 -1
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/cache-retention.ts +117 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -4
  12. package/src/adapters/anthropic.ts +10 -3
  13. package/src/adapters/coding-agent/protocol.ts +6 -0
  14. package/src/adapters/google-http.ts +3 -2
  15. package/src/adapters/google-vertex-endpoint.ts +67 -0
  16. package/src/adapters/google.ts +35 -29
  17. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  18. package/src/adapters/openai-responses/passthrough.ts +17 -12
  19. package/src/adapters/openai-responses/reasoning.ts +68 -1
  20. package/src/adapters/registry.ts +1 -1
  21. package/src/adapters/upstream-http-error.ts +3 -2
  22. package/src/adapters/zed.ts +7 -4
  23. package/src/bridge/internal.ts +11 -1
  24. package/src/bridge/response-json.ts +2 -2
  25. package/src/bridge/sse.ts +10 -4
  26. package/src/claude/inbound.ts +12 -26
  27. package/src/claude/outbound.ts +5 -0
  28. package/src/cli/account-api.ts +13 -1
  29. package/src/cli/account-extended.ts +17 -5
  30. package/src/cli/account-key-quota.ts +4 -1
  31. package/src/cli/account.ts +1 -1
  32. package/src/cli/capabilities-agents-routing.ts +45 -8
  33. package/src/cli/capabilities-base.ts +5 -5
  34. package/src/cli/capabilities-provider-models.ts +5 -4
  35. package/src/cli/combo-result.ts +8 -2
  36. package/src/cli/combo.ts +17 -0
  37. package/src/cli/help.ts +1 -1
  38. package/src/cli/message-command.ts +4 -1
  39. package/src/cli/message-remote-args.ts +69 -0
  40. package/src/cli/message-remote-command.ts +77 -0
  41. package/src/cli/provider-runtime.ts +69 -1
  42. package/src/cli/registry.ts +3 -3
  43. package/src/codex/account-pause.ts +6 -1
  44. package/src/codex/account-runtime-state.ts +5 -0
  45. package/src/codex/account-store.ts +30 -3
  46. package/src/codex/auth-api/account-list.ts +3 -0
  47. package/src/codex/auth-api/main-account-probe.ts +3 -2
  48. package/src/codex/auth-context.ts +9 -4
  49. package/src/codex/catalog/aggregation.ts +15 -0
  50. package/src/codex/catalog/auto-review.ts +2 -1
  51. package/src/codex/catalog/build-entries.ts +17 -7
  52. package/src/codex/catalog/derive-entry.ts +3 -1
  53. package/src/codex/catalog/metadata.ts +2 -0
  54. package/src/codex/catalog/model-visibility.ts +0 -2
  55. package/src/codex/catalog/ownership.ts +71 -0
  56. package/src/codex/catalog/parsing.ts +12 -3
  57. package/src/codex/catalog/retained-sync.ts +2 -1
  58. package/src/codex/catalog/routed-removal.ts +4 -4
  59. package/src/codex/convergence.ts +2 -1
  60. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  61. package/src/codex/low-quota-protection.ts +57 -31
  62. package/src/codex/main-account-cache.ts +77 -3
  63. package/src/codex/main-account.ts +6 -2
  64. package/src/codex/native-residue.ts +2 -3
  65. package/src/codex/quota.ts +26 -2
  66. package/src/combos/failover.ts +2 -1
  67. package/src/combos/jev-model-backend.ts +30 -7
  68. package/src/combos/jev-model-request.ts +20 -0
  69. package/src/combos/jev-quota-activation.ts +153 -0
  70. package/src/combos/jev-quota-config.ts +62 -0
  71. package/src/combos/jev-quota-route.ts +26 -0
  72. package/src/combos/jev-quota.ts +88 -0
  73. package/src/combos/jev.ts +25 -12
  74. package/src/combos/types.ts +27 -0
  75. package/src/compatibility/index.ts +3 -0
  76. package/src/compatibility/openai-responses.ts +8 -1
  77. package/src/compatibility/opengateway-responses.ts +61 -0
  78. package/src/config/account-auto-pause-revisions.ts +136 -0
  79. package/src/config/diagnostics.ts +13 -0
  80. package/src/config/live-reconcile.ts +9 -0
  81. package/src/config/persist-unlocked.ts +10 -1
  82. package/src/config/removed-opencode-free.ts +135 -0
  83. package/src/config/schema/account-auto-pause.ts +14 -0
  84. package/src/config/schema/config-schema.ts +15 -0
  85. package/src/config/schema/leaf-validators.ts +7 -0
  86. package/src/config.ts +10 -3
  87. package/src/generated/compatibility-version.json +344 -144
  88. package/src/images/loop.ts +32 -9
  89. package/src/lib/account-auto-pause-response.ts +16 -0
  90. package/src/lib/bun-runtime.ts +18 -1
  91. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  92. package/src/lib/bun-transpiler-cache-scope.mjs +68 -0
  93. package/src/lib/decision-quota-sink.ts +76 -0
  94. package/src/lib/state-store-registrations.ts +25 -0
  95. package/src/link/ssh-argv.ts +3 -0
  96. package/src/messaging/envelope.ts +6 -3
  97. package/src/messaging/remote-auth.ts +83 -0
  98. package/src/messaging/remote-bridge.ts +270 -0
  99. package/src/messaging/remote-contract.ts +108 -0
  100. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  101. package/src/messaging/remote-enrollment.ts +244 -0
  102. package/src/messaging/remote-envelope.ts +40 -0
  103. package/src/messaging/remote-files.ts +117 -0
  104. package/src/messaging/remote-owner.ts +169 -0
  105. package/src/messaging/remote-ports.ts +56 -0
  106. package/src/messaging/remote-process.ts +131 -0
  107. package/src/messaging/remote-replies.ts +98 -0
  108. package/src/messaging/remote-rpc-admission.ts +32 -0
  109. package/src/messaging/remote-send.ts +72 -0
  110. package/src/messaging/remote-store.ts +111 -0
  111. package/src/messaging/remote-tunnels.ts +65 -0
  112. package/src/messaging/rpc.ts +13 -1
  113. package/src/oauth/account-auto-pause-policy.ts +66 -0
  114. package/src/oauth/account-auto-pause.ts +171 -0
  115. package/src/oauth/account-quota-rank.ts +2 -1
  116. package/src/oauth/anthropic-account-refusal.ts +6 -0
  117. package/src/oauth/anthropic-model-family.ts +8 -0
  118. package/src/oauth/anthropic-model-quota.ts +2 -6
  119. package/src/oauth/api-key-pool-preserve.ts +37 -0
  120. package/src/oauth/index.ts +5 -35
  121. package/src/oauth/store.ts +35 -8
  122. package/src/oauth/types.ts +2 -0
  123. package/src/plugins/upstream-hooks.ts +14 -1
  124. package/src/providers/api-key-pause.ts +32 -0
  125. package/src/providers/api-key-resolve.ts +10 -4
  126. package/src/providers/api-key-selection.ts +6 -1
  127. package/src/providers/api-keys.ts +8 -4
  128. package/src/providers/claude-cli-usage.ts +5 -0
  129. package/src/providers/key-failover.ts +11 -11
  130. package/src/providers/model-rename-fields.ts +1 -0
  131. package/src/providers/opencode-zen-rate-limit.ts +6 -72
  132. package/src/providers/quota/account-cache.ts +15 -1
  133. package/src/providers/quota/anthropic-account-quota.ts +2 -1
  134. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  135. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  136. package/src/providers/quota/antigravity.ts +39 -6
  137. package/src/providers/quota/report-cache.ts +14 -0
  138. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  139. package/src/providers/quota-decision-publication.ts +77 -0
  140. package/src/providers/quota-decision-snapshot.ts +145 -0
  141. package/src/providers/quota-routing-cache.ts +3 -0
  142. package/src/providers/quota-types.ts +6 -1
  143. package/src/providers/quota.ts +3 -2
  144. package/src/providers/registry/entries-extended.ts +8 -52
  145. package/src/providers/registry/model-seeds.ts +1 -1
  146. package/src/providers/registry.ts +1 -2
  147. package/src/providers/stale-vision-classification-migration.ts +1 -1
  148. package/src/responses/parser.ts +18 -10
  149. package/src/responses/web-search-replay.ts +138 -0
  150. package/src/routing/compatibility/behavior.ts +4 -1
  151. package/src/server/auth-cors.ts +7 -0
  152. package/src/server/background-lifecycle.ts +2 -1
  153. package/src/server/chat-native.ts +28 -13
  154. package/src/server/claude-messages.ts +2 -1
  155. package/src/server/index/optional-listeners.ts +6 -0
  156. package/src/server/management/combo-routes.ts +17 -2
  157. package/src/server/management/oauth-account-routes.ts +10 -1
  158. package/src/server/management/provider-completion-test.ts +289 -0
  159. package/src/server/management/provider-routes.ts +9 -1
  160. package/src/server/management/route-registry.ts +2 -0
  161. package/src/server/messages-native.ts +18 -4
  162. package/src/server/messages-shared.ts +4 -1
  163. package/src/server/request-log.ts +7 -2
  164. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  165. package/src/server/responses/adapter-dispatch.ts +77 -28
  166. package/src/server/responses/combo-session-recall.ts +25 -0
  167. package/src/server/responses/combo-stream-preflight.ts +3 -1
  168. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  169. package/src/server/responses/compact.ts +8 -5
  170. package/src/server/responses/context-overflow.ts +141 -2
  171. package/src/server/responses/core-codex-account.ts +7 -1
  172. package/src/server/responses/core-combo.ts +31 -5
  173. package/src/server/responses/core.ts +12 -9
  174. package/src/server/responses/empty-completion-guard.ts +2 -0
  175. package/src/server/responses/fetch-helpers.ts +35 -4
  176. package/src/server/responses/jev-model-invoke.ts +13 -14
  177. package/src/server/responses/native-steering-log.ts +2 -2
  178. package/src/server/responses/passthrough-delivery.ts +3 -2
  179. package/src/server/responses/passthrough-dispatch.ts +30 -33
  180. package/src/server/responses/passthrough-observations.ts +38 -0
  181. package/src/server/responses/request-compression.ts +98 -0
  182. package/src/server/responses/request-transport.ts +32 -3
  183. package/src/server/responses/run-turn-execution.ts +2 -0
  184. package/src/server/responses/sidecar-execution.ts +16 -3
  185. package/src/server/responses/terminal-guard.ts +2 -0
  186. package/src/server/startup-action-control.ts +2 -2
  187. package/src/server/startup-health-cache.ts +2 -2
  188. package/src/server/windows-tray-control.ts +2 -2
  189. package/src/service/definition.ts +9 -1
  190. package/src/types/account-auto-pause.ts +15 -0
  191. package/src/types/config.ts +23 -1
  192. package/src/types/provider.ts +9 -5
  193. package/src/types/request.ts +20 -2
  194. package/src/types.ts +1 -0
  195. package/src/usage/cost.ts +17 -1
  196. package/src/usage/expected-prices.ts +3 -1
  197. package/src/usage/jev-stats.ts +4 -0
  198. package/src/usage/log.ts +2 -0
  199. package/src/usage/summary.ts +7 -0
  200. package/src/web-search/loop.ts +37 -11
  201. package/src/web-search/progress-stream.ts +4 -1
  202. package/src/web-search/run-turn-loop.ts +9 -3
  203. package/src/web-search/sidecar-search-replay.ts +96 -0
  204. package/gui/dist/assets/App-CHC89EXQ.js +0 -58
  205. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
@@ -0,0 +1,125 @@
1
+ import { existsSync } from "node:fs";
2
+ import { isTestHomeGuardArmed } from "../lib/test-home-guard";
3
+
4
+ /** What a low-quota notice may tell the operating system. Never an email, raw id or credential. */
5
+ export type LowQuotaNotice = {
6
+ /** Pseudonymous `p<hex6>` log label of the pool account. */
7
+ accountLabel: string;
8
+ window: "short" | "weekly";
9
+ percentUsed: number;
10
+ threshold: number;
11
+ /** Epoch milliseconds of the window reset, when known. */
12
+ resetAt: number | null;
13
+ };
14
+
15
+ /** Resolves when the OS notifier accepted the notice; rejects on failure, timeout or abort. */
16
+ export type LowQuotaNoticeSink = (notice: LowQuotaNotice, signal: AbortSignal) => Promise<void>;
17
+
18
+ type SpawnedNotifier = { exited: Promise<number | null>; kill(): void };
19
+ export type LowQuotaNotifierIo = {
20
+ platform?: NodeJS.Platform;
21
+ /** Returns true when the absolute path is an existing executable candidate. */
22
+ exists?: (path: string) => boolean;
23
+ spawn?: (argv: string[]) => SpawnedNotifier;
24
+ timeoutMs?: number;
25
+ /** Formats the reset time for display; defaults to the host locale. */
26
+ formatTime?: (epochMs: number) => string;
27
+ };
28
+
29
+ const OSASCRIPT = "/usr/bin/osascript";
30
+ /** Literal system locations only: a PATH lookup would let any earlier PATH entry run as the proxy. */
31
+ const NOTIFY_SEND_CANDIDATES = ["/usr/bin/notify-send", "/usr/local/bin/notify-send", "/bin/notify-send"];
32
+ const DEFAULT_TIMEOUT_MS = 5_000;
33
+ const TITLE = "OpenCodex quota";
34
+
35
+ export function formatLowQuotaNotice(notice: LowQuotaNotice, formatTime: (epochMs: number) => string = defaultFormatTime): { title: string; body: string } {
36
+ const window = notice.window === "short" ? "5-hour" : "weekly";
37
+ const reset = notice.resetAt === null ? "reset time unknown" : `resets ${formatTime(notice.resetAt)}`;
38
+ return {
39
+ title: TITLE,
40
+ body: `Account ${notice.accountLabel}: ${window} quota at ${Math.round(notice.percentUsed)}% (threshold ${notice.threshold}%), ${reset}.`,
41
+ };
42
+ }
43
+
44
+ function defaultFormatTime(epochMs: number): string {
45
+ return new Date(epochMs).toLocaleString();
46
+ }
47
+
48
+ /** notify-send bodies may be parsed as markup by the notification server. */
49
+ function escapeMarkup(text: string): string {
50
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
51
+ }
52
+
53
+ /**
54
+ * Builds the fixed argv for the host notifier. The notice text is always a separate argv item:
55
+ * the AppleScript source is constant and reads its arguments, and notify-send stops option
56
+ * parsing at `--`, so no account or quota text is ever interpreted as script or flags.
57
+ */
58
+ export function lowQuotaNotifierArgv(
59
+ platform: NodeJS.Platform,
60
+ exists: (path: string) => boolean,
61
+ title: string,
62
+ body: string,
63
+ ): string[] | undefined {
64
+ if (platform === "darwin") {
65
+ if (!exists(OSASCRIPT)) return undefined;
66
+ return [OSASCRIPT, "-e", "on run argv", "-e", "display notification (item 2 of argv) with title (item 1 of argv)", "-e", "end run", title, body];
67
+ }
68
+ if (platform === "linux") {
69
+ const notifySend = NOTIFY_SEND_CANDIDATES.find(exists);
70
+ if (!notifySend) return undefined;
71
+ return [notifySend, "--app-name=OpenCodex", "--", title, escapeMarkup(body)];
72
+ }
73
+ return undefined;
74
+ }
75
+
76
+ function defaultSpawn(argv: string[]): SpawnedNotifier {
77
+ const proc = Bun.spawn(argv, { stdin: "ignore", stdout: "ignore", stderr: "ignore", windowsHide: true });
78
+ return { exited: proc.exited, kill: () => { try { proc.kill(); } catch { /* already gone */ } } };
79
+ }
80
+
81
+ /**
82
+ * Returns a sink that hands notices to this host's notification service, or undefined when the
83
+ * platform has none we support (Windows and others stay log-only). An armed test process never
84
+ * reaches a real notifier unless a spawn is injected.
85
+ */
86
+ export function resolveLowQuotaDesktopSink(io: LowQuotaNotifierIo = {}): LowQuotaNoticeSink | undefined {
87
+ if (!io.spawn && isTestHomeGuardArmed()) return undefined;
88
+ const platform = io.platform ?? process.platform;
89
+ const exists = io.exists ?? existsSync;
90
+ const spawn = io.spawn ?? defaultSpawn;
91
+ const timeoutMs = io.timeoutMs ?? DEFAULT_TIMEOUT_MS;
92
+ const probe = lowQuotaNotifierArgv(platform, exists, TITLE, "");
93
+ if (!probe) return undefined;
94
+ return (notice, signal) => {
95
+ if (signal.aborted) return Promise.reject(new Error("low-quota notice cancelled"));
96
+ const { title, body } = formatLowQuotaNotice(notice, io.formatTime);
97
+ const argv = lowQuotaNotifierArgv(platform, exists, title, body);
98
+ if (!argv) return Promise.reject(new Error("low-quota notifier unavailable"));
99
+ let child: SpawnedNotifier;
100
+ try {
101
+ child = spawn(argv);
102
+ } catch (error) {
103
+ return Promise.reject(error);
104
+ }
105
+ return new Promise<void>((resolve, reject) => {
106
+ let settled = false;
107
+ const finish = (error?: Error) => {
108
+ if (settled) return;
109
+ settled = true;
110
+ clearTimeout(timer);
111
+ signal.removeEventListener("abort", onAbort);
112
+ if (error) reject(error);
113
+ else resolve();
114
+ };
115
+ const onAbort = () => { child.kill(); finish(new Error("low-quota notice cancelled")); };
116
+ const timer = setTimeout(() => { child.kill(); finish(new Error("low-quota notifier timed out")); }, timeoutMs);
117
+ (timer as { unref?: () => void }).unref?.();
118
+ signal.addEventListener("abort", onAbort, { once: true });
119
+ child.exited.then(
120
+ code => finish(code === 0 ? undefined : new Error(`low-quota notifier exited ${code}`)),
121
+ error => finish(error instanceof Error ? error : new Error("low-quota notifier failed")),
122
+ );
123
+ });
124
+ };
125
+ }
@@ -4,15 +4,28 @@ import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause";
4
4
  import { createLowQuotaEventLedger, type LowQuotaEvent } from "./low-quota-events";
5
5
  import { registerLowQuotaObserver } from "./low-quota-observer";
6
6
  import { resetAtToMs } from "./quota-types";
7
+ import { codexAccountLogLabel, fallbackCodexAccountLogLabel } from "./account-label";
8
+ import type { LowQuotaNotice, LowQuotaNoticeSink } from "./low-quota-desktop-notifier";
7
9
 
8
- type Window = "short" | "weekly";
9
- type Notice = { window: Window; percentUsed: number; threshold: number };
10
10
  type Dependencies = {
11
11
  persist?: (config: OcxConfig) => void | Promise<void>;
12
- notify?: (notice: Notice) => void | Promise<void>;
12
+ /** Injected sink; takes precedence over desktop delivery. */
13
+ notify?: (notice: LowQuotaNotice, signal: AbortSignal) => void | Promise<void>;
14
+ /** Resolved per notice, and only while the live policy opts into actions.desktop. */
15
+ desktopSink?: () => LowQuotaNoticeSink | undefined;
13
16
  };
14
17
  type EventBase = Omit<LowQuotaEvent, "timestamp" | "status" | "delivery">;
15
- type Episode = { reset: string; notice: "in-flight" | "logged" | "delivered" | "failed" | undefined; pausedByUs: boolean; noticeBase?: EventBase };
18
+ type Episode = {
19
+ reset: string;
20
+ notice: "in-flight" | "logged" | "delivered" | "failed" | undefined;
21
+ pausedByUs: boolean;
22
+ noticeBase?: EventBase;
23
+ noticeAbort?: AbortController;
24
+ /** A failed notice may retry only after this time, so a broken notifier is not respawned per request. */
25
+ noticeRetryAt?: number;
26
+ /** The episode's log line was written; retries do not repeat it. */
27
+ warned?: boolean;
28
+ };
16
29
  export type LowQuotaRegistration = (() => void) & {
17
30
  hasPendingSave(): boolean;
18
31
  flush(): Promise<void>;
@@ -20,6 +33,7 @@ export type LowQuotaRegistration = (() => void) & {
20
33
  };
21
34
 
22
35
  const RETRY_DELAYS_MS = [100, 250];
36
+ const NOTICE_RETRY_MS = 5 * 60_000;
23
37
  const FLUSH_DEADLINE_MS = 500;
24
38
 
25
39
  /** Each server owns its own policy, episode state and deferred writer. */
@@ -44,6 +58,12 @@ export function registerCodexLowQuotaProtection(config: OcxConfig, deps: Depende
44
58
  if (delivery === "pause-save") pauseStatus.set(base, status);
45
59
  ledger.publish({ ...base, delivery, status, timestamp: Date.now() });
46
60
  }
61
+ /** Publishes one cancellation for an in-flight notice and stops its delivery. */
62
+ function cancelNotice(episode: Episode): void {
63
+ if (episode.notice === "in-flight" && episode.noticeBase) event(episode.noticeBase, "notice", "cancelled");
64
+ episode.noticeAbort?.abort();
65
+ episode.noticeAbort = undefined;
66
+ }
47
67
  function cancelTimer(): void {
48
68
  if (saveTimer) clearTimeout(saveTimer);
49
69
  saveTimer = null;
@@ -132,9 +152,7 @@ export function registerCodexLowQuotaProtection(config: OcxConfig, deps: Depende
132
152
  if (pauseStatus.get(base) === "pending") event(base, "pause-save", "cancelled");
133
153
  pendingPauseEvents.delete(key);
134
154
  }
135
- for (const episode of episodes.values()) {
136
- if (episode.notice === "in-flight" && episode.noticeBase) event(episode.noticeBase, "notice", "cancelled");
137
- }
155
+ for (const episode of episodes.values()) cancelNotice(episode);
138
156
  dirty = false;
139
157
  autoPausedAccounts.clear();
140
158
  unregister();
@@ -175,9 +193,7 @@ export function registerCodexLowQuotaProtection(config: OcxConfig, deps: Depende
175
193
  const policy = config.codexPool?.lowQuotaProtection;
176
194
  const nextKey = JSON.stringify(policy);
177
195
  if (policyKey !== nextKey) {
178
- for (const episode of episodes.values()) {
179
- if (episode.notice === "in-flight" && episode.noticeBase) event(episode.noticeBase, "notice", "cancelled");
180
- }
196
+ for (const episode of episodes.values()) cancelNotice(episode);
181
197
  episodes.clear();
182
198
  autoPausedAccounts.clear();
183
199
  policyKey = nextKey;
@@ -187,7 +203,7 @@ export function registerCodexLowQuotaProtection(config: OcxConfig, deps: Depende
187
203
  for (const id of autoPausedAccounts) if (!liveIds.has(id)) autoPausedAccounts.delete(id);
188
204
  for (const [key, episode] of episodes) {
189
205
  if (liveIds.has(key.split("\u0000")[0]!)) continue;
190
- if (episode.notice === "in-flight" && episode.noticeBase) event(episode.noticeBase, "notice", "cancelled");
206
+ cancelNotice(episode);
191
207
  episodes.delete(key);
192
208
  }
193
209
  if (!liveIds.has(accountId)) return;
@@ -203,7 +219,7 @@ export function registerCodexLowQuotaProtection(config: OcxConfig, deps: Depende
203
219
  const key = `${accountId}\u0000${window}`;
204
220
  if (percentUsed < policy.threshold) {
205
221
  const prior = episodes.get(key);
206
- if (prior?.notice === "in-flight" && prior.noticeBase) event(prior.noticeBase, "notice", "cancelled");
222
+ if (prior) cancelNotice(prior);
207
223
  episodes.delete(key);
208
224
  continue;
209
225
  }
@@ -211,6 +227,7 @@ export function registerCodexLowQuotaProtection(config: OcxConfig, deps: Depende
211
227
  const reset = resetAt === null ? "unknown" : String(resetAt);
212
228
  let episode = episodes.get(key);
213
229
  if (!episode || episode.reset !== reset) {
230
+ if (episode) cancelNotice(episode);
214
231
  episode = { reset, notice: undefined, pausedByUs: false };
215
232
  episodes.set(key, episode);
216
233
  }
@@ -234,32 +251,41 @@ export function registerCodexLowQuotaProtection(config: OcxConfig, deps: Depende
234
251
  scheduleSave();
235
252
  }
236
253
  // A manual resume leaves pausedByUs set until recovery or a new reset episode.
237
- if (policy.actions.notify && episode.notice !== "in-flight" && episode.notice !== "logged" && episode.notice !== "delivered") {
238
- console.warn(`[codex-low-quota] ${window === "short" ? "5-hour" : "weekly"} quota reached ${percentUsed}% used (threshold ${policy.threshold}%)`);
239
- if (!deps.notify) {
254
+ if (policy.actions.notify && episode.notice !== "in-flight" && episode.notice !== "logged" && episode.notice !== "delivered"
255
+ && !(episode.notice === "failed" && (episode.noticeRetryAt ?? 0) > Date.now())) {
256
+ if (!episode.warned) {
257
+ episode.warned = true;
258
+ console.warn(`[codex-low-quota] ${window === "short" ? "5-hour" : "weekly"} quota reached ${percentUsed}% used (threshold ${policy.threshold}%)`);
259
+ }
260
+ const sink = deps.notify ?? (policy.actions.desktop === true ? deps.desktopSink?.() : undefined);
261
+ if (!sink) {
240
262
  event(base, "notice", "logged");
241
263
  episode.notice = "logged";
242
264
  continue;
243
265
  }
244
- episode.notice = "in-flight";
245
- episode.noticeBase = base;
266
+ const account = config.codexAccounts?.find(candidate => candidate.id === accountId);
267
+ const notice: LowQuotaNotice = {
268
+ accountLabel: account ? codexAccountLogLabel(account) : fallbackCodexAccountLogLabel(accountId),
269
+ window, percentUsed, threshold: policy.threshold, resetAt,
270
+ };
271
+ const abort = new AbortController();
272
+ const current = episode;
273
+ current.notice = "in-flight";
274
+ current.noticeBase = base;
275
+ current.noticeAbort = abort;
246
276
  event(base, "notice", "pending");
277
+ const settle = (status: "delivered" | "failed") => {
278
+ if (closed || abort.signal.aborted || episodes.get(key) !== current) return;
279
+ event(base, "notice", status);
280
+ current.notice = status;
281
+ current.noticeRetryAt = status === "failed" ? Date.now() + NOTICE_RETRY_MS : undefined;
282
+ current.noticeBase = undefined;
283
+ current.noticeAbort = undefined;
284
+ };
247
285
  try {
248
- void Promise.resolve(deps.notify({ window, percentUsed, threshold: policy.threshold })).then(() => {
249
- if (closed || episodes.get(key) !== episode) return;
250
- event(base, "notice", "delivered");
251
- episode.notice = "delivered";
252
- episode.noticeBase = undefined;
253
- }, () => {
254
- if (closed || episodes.get(key) !== episode) return;
255
- event(base, "notice", "failed");
256
- episode.notice = "failed";
257
- episode.noticeBase = undefined;
258
- });
286
+ void Promise.resolve(sink(notice, abort.signal)).then(() => settle("delivered"), () => settle("failed"));
259
287
  } catch {
260
- event(base, "notice", "failed");
261
- episode.notice = "failed";
262
- episode.noticeBase = undefined;
288
+ settle("failed");
263
289
  }
264
290
  }
265
291
  }
@@ -1,3 +1,5 @@
1
+ import { getConfigDir } from "../config/paths";
2
+ import { notifyMainRosterChanged, type MainDecisionRow } from "../lib/decision-quota-sink";
1
3
  import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
4
  import { codexCredentialMutationEpoch } from "./credential-mutation-epoch";
3
5
  import type { StoredAccountQuota } from "./quota-types";
@@ -22,11 +24,33 @@ let observedMainQuotaIdentityKey: string | undefined;
22
24
  const mainQuotaCredentialKey = randomBytes(32);
23
25
  let mainQuotaCredential: { bearerHmac: Buffer; writer: MainQuotaWriter } | undefined;
24
26
  let mainQuotaCredentialGeneration = 0;
27
+ let mainDecisionCredentialUsable = true;
28
+ // Advisory-only generation for the decision row and its writers. It follows every core credential
29
+ // generation change and additionally advances when physical presence is lost, so absence retires
30
+ // advisory evidence without touching the core credential binding that policy checks rely on.
31
+ let mainDecisionGeneration = 0;
32
+ // Advisory-only: the retained credential was re-observed since presence was last lost. Absence and
33
+ // presence-forgetting clear it, so the decision row stays unusable until a fresh owned observation.
34
+ let mainDecisionCredentialFresh = false;
35
+ // Config root that owns the observed main identity/credential for advisory decision evidence only.
36
+ // Stamped at identity/credential observations while a sink is active; other transitions never claim a root.
37
+ let mainDecisionRoot: string | undefined;
38
+ /** Existing physical credential owner supplies terminal grant/usability observations. */
39
+ export function observeMainDecisionCredentialUsable(usable: boolean): void {
40
+ mainDecisionCredentialUsable = usable;
41
+ publishMainDecisionRoster();
42
+ }
25
43
 
26
44
  /** Process-local transition fence; no credential material or persisted identity. */
27
45
  export function getMainQuotaCredentialGeneration(): number { return mainQuotaCredentialGeneration; }
28
46
 
29
47
  export type MainQuotaWriter = Readonly<{ identityKey: string; identityGeneration: number }>;
48
+ const decisionWriterGenerations = new WeakMap<MainQuotaWriter, number>();
49
+ /** Return the credential generation a main quota writer was captured under, or undefined once that writer is stale or its credential generation has moved. */
50
+ export function mainDecisionQuotaWriterGeneration(writer: MainQuotaWriter): number | undefined {
51
+ const generation = decisionWriterGenerations.get(writer);
52
+ return generation === mainDecisionGeneration && isMainQuotaWriterLive(writer) && mainDecisionRoot === getConfigDir() ? generation : undefined;
53
+ }
30
54
 
31
55
  function mainQuotaIdentityKey(accountId: string): string {
32
56
  return createHash("sha256").update("opencodex-main-quota-v1\0").update(accountId).digest("hex");
@@ -38,16 +62,22 @@ export function observeMainQuotaIdentity(accountId: string): void {
38
62
  const identityKey = mainQuotaIdentityKey(accountId);
39
63
  if (identityKey === observedMainQuotaIdentityKey) return;
40
64
  observedMainQuotaIdentityKey = identityKey;
65
+ mainDecisionCredentialUsable = true;
41
66
  mainAccountIdentityGeneration += 1;
42
67
  mainQuotaCredential = undefined;
43
68
  mainQuotaCredentialGeneration += 1;
69
+ mainDecisionGeneration += 1;
70
+ publishMainDecisionRoster(true);
44
71
  }
45
72
 
73
+ /** Capture a main quota writer for an account id only when it matches the observed physical identity, remembering the credential generation it was captured under. */
46
74
  export function captureMainQuotaWriter(accountId: string): MainQuotaWriter | undefined {
47
75
  if (!accountId) return undefined;
48
76
  const identityKey = mainQuotaIdentityKey(accountId);
49
77
  if (identityKey !== observedMainQuotaIdentityKey) return undefined;
50
- return { identityKey, identityGeneration: mainAccountIdentityGeneration };
78
+ const writer = { identityKey, identityGeneration: mainAccountIdentityGeneration };
79
+ if (mainQuotaCredential) decisionWriterGenerations.set(writer, mainDecisionGeneration);
80
+ return writer;
51
81
  }
52
82
 
53
83
  /** Credential material must come from an already-owned read, never an incoming request. */
@@ -56,9 +86,13 @@ export function observeMainQuotaCredential(accessToken: string, accountId: strin
56
86
  if (!accessToken || !writer) return undefined;
57
87
  const bearerHmac = createHmac("sha256", mainQuotaCredentialKey).update(accessToken).digest();
58
88
  if (!mainQuotaCredential || !isMainQuotaWriterLive(mainQuotaCredential.writer)
59
- || !timingSafeEqual(bearerHmac, mainQuotaCredential.bearerHmac)) mainQuotaCredentialGeneration += 1;
89
+ || !timingSafeEqual(bearerHmac, mainQuotaCredential.bearerHmac)) { mainQuotaCredentialGeneration += 1; mainDecisionGeneration += 1; }
60
90
  mainQuotaCredential = { bearerHmac, writer };
61
- return { ...writer };
91
+ mainDecisionCredentialFresh = true;
92
+ publishMainDecisionRoster(true);
93
+ const result = { ...writer };
94
+ decisionWriterGenerations.set(result, mainDecisionGeneration);
95
+ return result;
62
96
  }
63
97
 
64
98
  export function matchesMainQuotaCredential(accessToken: string, effectiveAccountId: string | undefined): boolean {
@@ -140,11 +174,14 @@ export function setMainAccountInfoCache(value: CachedMainAccountInfo): void {
140
174
  };
141
175
  }
142
176
 
177
+ /** Drop the cached main-account info and invalidate the observed identity and credential generation, then republish the main decision roster as unusable. */
143
178
  export function clearMainAccountInfoCache(): void {
144
179
  cachedMainAccountInfo = null;
145
180
  mainAccountIdentityGeneration += 1;
146
181
  mainQuotaCredential = undefined;
147
182
  mainQuotaCredentialGeneration += 1;
183
+ mainDecisionGeneration += 1;
184
+ publishMainDecisionRoster();
148
185
  }
149
186
 
150
187
  /** Last physical credential presence observed while native-main ownership was held. */
@@ -152,10 +189,47 @@ export function getMainAccountCredentialPresence(): boolean | null {
152
189
  return cachedMainCredentialPresence;
153
190
  }
154
191
 
192
+ /** Record whether the main credential is physically present. The core credential binding is left intact (a failed read must not change policy matching); losing presence only retires advisory decision evidence. */
155
193
  export function setMainAccountCredentialPresence(present: boolean): void {
194
+ if (!present && cachedMainCredentialPresence !== false) { mainDecisionGeneration += 1; mainDecisionCredentialFresh = false; }
156
195
  cachedMainCredentialPresence = present;
196
+ publishMainDecisionRoster();
157
197
  }
158
198
 
199
+ /** Forget the last observed main credential presence; retires advisory decision evidence only, leaving the core credential binding intact. */
159
200
  export function clearMainAccountCredentialPresence(): void {
160
201
  cachedMainCredentialPresence = null;
202
+ mainDecisionGeneration += 1;
203
+ mainDecisionCredentialFresh = false;
204
+ publishMainDecisionRoster();
205
+ }
206
+
207
+ /**
208
+ * Build the single `__main__` decision row from in-memory fields. With no retained credential the
209
+ * row is a known-unusable main (nothing to misattribute), as before. A retained credential counts only
210
+ * for the config root that observed it: for any other root the row is undefined (unknown) and the
211
+ * claimed root is forgotten, so returning to the old root never resurrects it without a new owned
212
+ * observation.
213
+ */
214
+ function mainDecisionRow(): MainDecisionRow | undefined {
215
+ if (mainQuotaCredential === undefined) return { generation: mainDecisionGeneration, usable: false };
216
+ if (mainDecisionRoot === undefined || mainDecisionRoot !== getConfigDir()) {
217
+ mainDecisionRoot = undefined;
218
+ return undefined;
219
+ }
220
+ return { generation: mainDecisionGeneration,
221
+ usable: mainDecisionCredentialFresh && mainDecisionCredentialUsable && cachedMainCredentialPresence !== false };
222
+ }
223
+
224
+ /** Hand the main decision row to the opt-in decision-quota slot (a no-op unless JEV quota signals are active); identity/credential observations claim the current root. */
225
+ function publishMainDecisionRoster(claimsRoot = false): void {
226
+ notifyMainRosterChanged(() => {
227
+ if (claimsRoot) mainDecisionRoot = getConfigDir();
228
+ return mainDecisionRow();
229
+ });
230
+ }
231
+
232
+ /** Activation replay for the JEV quota owner: the current row, or undefined when the observed credential belongs to another config root. */
233
+ export function peekMainDecisionRow(): MainDecisionRow | undefined {
234
+ return mainDecisionRow();
161
235
  }
@@ -1,3 +1,4 @@
1
+ import { observeMainDecisionCredentialUsable } from "./main-account-cache";
1
2
  import { classifyChatgptRefreshFailure, noteChatgptRefreshFailure } from "./chatgpt-refresh-failure";
2
3
  import { readBoundedResponseBody } from "../lib/bounded-body";
3
4
  import { createHash } from "node:crypto";
@@ -245,10 +246,12 @@ export function getMainAccountCredentialStatus(now = Date.now()): MainAccountCre
245
246
  const current = readMainAuthJsonCredential();
246
247
  const refreshGrantRejected = credentialRefreshGrantRejected(current);
247
248
  const hasRefreshGrant = !!current?.refreshToken && !refreshGrantRejected;
249
+ const usable = !refreshGrantRejected && (hasRefreshGrant || mainAccessTokenFresh(current?.accessToken, now, 0));
250
+ observeMainDecisionCredentialUsable(usable);
248
251
  return {
249
252
  refreshGrantRejected,
250
253
  hasRefreshGrant,
251
- usable: !refreshGrantRejected && (hasRefreshGrant || mainAccessTokenFresh(current?.accessToken, now, 0)),
254
+ usable,
252
255
  };
253
256
  }
254
257
 
@@ -444,6 +447,7 @@ export function beginNativeMainReauth(): {
444
447
  };
445
448
  }
446
449
 
450
+ /** Resolve the main account access token from the owned auth.json read, refreshing through the token endpoint when needed, or return null when no usable credential exists. */
447
451
  async function resolveMainAccountToken(
448
452
  dependencies: NativeMainRefreshDependencies = {},
449
453
  rejectedAccessToken?: string,
@@ -546,7 +550,7 @@ async function resolveMainAccountToken(
546
550
  if (signal.aborted) throw signal.reason;
547
551
  if (reason === "reauth") {
548
552
  assertMainAuthJsonSnapshotUnchanged(locked);
549
- markMainRefreshGrantRejected(mainRefreshGrantKey(locked)!);
553
+ markMainRefreshGrantRejected(mainRefreshGrantKey(locked)!); observeMainDecisionCredentialUsable(false);
550
554
  }
551
555
  throw new MainAccountTokenRefreshError(reason, { cause });
552
556
  }
@@ -17,6 +17,7 @@ import { Database } from "bun:sqlite";
17
17
 
18
18
  import { getConfigDir } from "../config";
19
19
  import { catalogHasRoutedEntries, parseCatalogJson } from "./catalog/parsing";
20
+ import { hasOcxRoutedAuthorship } from "./catalog/ownership";
20
21
  import { codexHistoryBackupId, legacyCodexHistoryBackupId, validateCodexHistoryBackupManifest } from "./history-manifest";
21
22
  import {
22
23
  hasInjectedCodexRouting,
@@ -79,7 +80,6 @@ const PROFILE_FILE_NAME = basename(CODEX_PROFILE_PATH);
79
80
  const CATALOG_FILE_NAME = basename(DEFAULT_CATALOG_PATH);
80
81
  const MODELS_CACHE_FILE_NAME = basename(CODEX_MODELS_CACHE_PATH);
81
82
  const JOURNAL_FILE_NAME = "opencodex-journal.json";
82
- const ROUTED_CATALOG_DESCRIPTION_PREFIX = "Routed via opencodex → ";
83
83
  const MAX_ROLLOUT_INSPECTION_BYTES = 64 * 1024 * 1024;
84
84
  const ROLLOUT_READ_CHUNK_BYTES = 64 * 1024;
85
85
  // Bound one observation across every referenced rollout, not just each file.
@@ -339,8 +339,7 @@ function classifyProfile(path: string): NativeRoutedResidueResult {
339
339
  }
340
340
 
341
341
  function isOcxRoutedCatalogEntry(entry: Record<string, unknown>): boolean {
342
- return typeof entry.description === "string"
343
- && entry.description.startsWith(ROUTED_CATALOG_DESCRIPTION_PREFIX);
342
+ return hasOcxRoutedAuthorship(entry);
344
343
  }
345
344
 
346
345
  function classifyCatalogLike(
@@ -1,3 +1,5 @@
1
+ import { bindRawCodexDecisionResets, publishCodexDecisionQuota } from "../providers/quota-decision-publication";
2
+ import { clearDecisionAccountQuotas, decisionQuotaSinkActive } from "../lib/decision-quota-sink";
1
3
  import { parseCodexCredits } from "./credits";
2
4
  import { forgetMainAccountUsage, observeMainAccountUsage, type FreshWindow } from "./main-account-external-usage";
3
5
  import { closeSync, constants as fsConstants, existsSync, fstatSync, openSync, readSync, unlinkSync } from "node:fs";
@@ -8,7 +10,7 @@ import { isThirtyDayOnlyCodexPlan } from "./plan";
8
10
  import { stampCodexQuotaUsageObservation } from "./quota-observation-freshness";
9
11
  import { observeCodexLowQuota } from "./low-quota-observer";
10
12
  import { MAIN_CODEX_ACCOUNT_ID } from "./account-id";
11
- import { getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, type MainQuotaWriter } from "./main-account-cache";
13
+ import { getObservedMainQuotaIdentityKey, mainDecisionQuotaWriterGeneration, isMainQuotaWriterLive, type MainQuotaWriter } from "./main-account-cache";
12
14
 
13
15
  import { CodexQuotaHistory, QUOTA_HISTORY_LIMITS, type QuotaHistoryWindow } from "./quota-history";
14
16
  import { isPoolQuotaWriterLive, poolQuotaHistoryIdentity } from "./account-store";
@@ -301,6 +303,9 @@ export function setAccountQuotaFromParsed(
301
303
  // writer: when capture failed before dispatch, an absent writer must fail closed instead
302
304
  // of reading as a writer-free legacy/login observation.
303
305
  poolRequest = false,
306
+ decisionObservationPartial = true,
307
+ // Advisory publication is explicit opt-in evidence: display or policy data never becomes it by default.
308
+ decisionRaw: Omit<StoredAccountQuota, "updatedAt"> | null = null,
304
309
  ): void {
305
310
  quota = withoutRetiredCodexQuota(quota);
306
311
  policyQuota = withoutRetiredCodexQuota(policyQuota);
@@ -315,12 +320,16 @@ export function setAccountQuotaFromParsed(
315
320
  ? historyEvidence.writer.accountId === accountId && isPoolQuotaWriterLive(historyEvidence.writer)
316
321
  : !poolRequest;
317
322
  if (historyEvidence && livePoolEvidence) {
323
+ publishCodexDecisionQuota(accountId, historyEvidence.writer.credentialGeneration, historyEvidence.raw, historyEvidence.observedAt, "codex", historyEvidence.source === "response-header");
318
324
  quotaHistory.append(historyEvidence.writer, { observedAt: historyEvidence.observedAt, source: historyEvidence.source,
319
325
  credentialGeneration: historyEvidence.writer.credentialGeneration, windows: historyWindows(historyEvidence.raw),
320
326
  }, updatedAt);
321
327
  }
322
328
  // Rotation shares validated main-window retirement, but never inherits policy-only
323
329
  // evidence that outlived its disk TTL. Policy has a separate, identity-checked base.
330
+ const mainDecisionGeneration = mainWriter && mainDecisionQuotaWriterGeneration(mainWriter);
331
+ if (isMain && mainDecisionGeneration !== undefined && decisionRaw)
332
+ publishCodexDecisionQuota(accountId, mainDecisionGeneration, decisionRaw, updatedAt, "codex-main", decisionObservationPartial);
324
333
  const displayQuota: MainPolicyQuotaObservation = isMain && mainWriter && policyQuota?.shortWindowAbsent === true
325
334
  ? { ...quota, shortWindowAbsent: true }
326
335
  : quota;
@@ -544,6 +553,7 @@ export function withoutRetiredCodexQuota<T extends Omit<StoredAccountQuota, "upd
544
553
  return snapshotHasUsage(next) || snapshotHasCredits(next) ? next as T : null;
545
554
  }
546
555
 
556
+ /** Parse `x-codex-*` rate-limit headers into a quota observation, binding the raw reset values so decision evidence can keep invalid resets distinct from the display fields. */
547
557
  export function parseUpstreamQuotaHeaders(headers: Headers, options?: { modelId?: string }): Omit<StoredAccountQuota, "updatedAt"> | null {
548
558
  if (isRetiredCodexSparkModel(options?.modelId)) return null;
549
559
  const primaryRaw = headers.get("x-codex-primary-used-percent");
@@ -610,9 +620,15 @@ export function parseUpstreamQuotaHeaders(headers: Headers, options?: { modelId?
610
620
  if (tertiaryResetAt !== undefined) quota.monthlyResetAt = tertiaryResetAt;
611
621
  }
612
622
 
623
+ if (decisionQuotaSinkActive()) bindRawCodexDecisionResets(quota, {
624
+ short: primaryResetRaw,
625
+ weekly: primaryIsMonthly || primaryIsShort || primaryPercent === undefined ? secondaryResetRaw : primaryResetRaw,
626
+ monthly: primaryIsMonthly && primaryPercent !== undefined ? primaryResetRaw : tertiaryResetRaw,
627
+ }, Date.now());
613
628
  return hasKnownQuotaValue(quota) ? quota : null;
614
629
  }
615
630
 
631
+ /** Apply upstream rate-limit headers for an account: publish display and policy quota and, only for a valid observation, the advisory decision evidence. */
616
632
  export function applyAccountQuotaFromUpstreamHeaders(
617
633
  accountId: string,
618
634
  headers: Headers,
@@ -629,7 +645,7 @@ export function applyAccountQuotaFromUpstreamHeaders(
629
645
  .some(name => isInvalidPolicyUsagePercent(headers.get(name)));
630
646
  setAccountQuotaFromParsed(accountId, quota, writerGeneration, mainWriter, policyQuota,
631
647
  options?.poolWriter && validHistory ? { writer: options.poolWriter, observedAt: Date.now(), source: "response-header", raw: quota } : undefined,
632
- options?.poolResponse === true);
648
+ options?.poolResponse === true, true, validHistory ? quota : null);
633
649
  }
634
650
 
635
651
  export function updateAccountQuota(
@@ -822,7 +838,10 @@ function forgetCodexQuotaBaseline(accountId?: string): void {
822
838
  });
823
839
  }
824
840
 
841
+ /** Clear stored quota, advisory decision evidence and history for one account, or for all accounts when no id is given. */
825
842
  export function clearAccountQuota(accountId?: string): void {
843
+ clearDecisionAccountQuotas("codex", accountId);
844
+ if (!accountId || accountId === MAIN_CODEX_ACCOUNT_ID) clearDecisionAccountQuotas("codex-main");
826
845
  if (!accountId || accountId === MAIN_CODEX_ACCOUNT_ID) forgetMainAccountUsage();
827
846
  if (accountId) hydrateAccountQuotasFromDisk();
828
847
  quotaHistory.clear(accountId);
@@ -1009,6 +1028,11 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
1009
1028
 
1010
1029
  if (resetCredits !== undefined) quota.resetCredits = resetCredits;
1011
1030
 
1031
+ if (decisionQuotaSinkActive()) bindRawCodexDecisionResets(quota, {
1032
+ short: primaryWindow?.reset_at,
1033
+ weekly: primaryIsMonthly || weeklyCandidatePercent === undefined ? secondaryWindow?.reset_at : primaryWindow?.reset_at,
1034
+ monthly: primaryIsMonthly && primaryPercent !== undefined ? primaryWindow?.reset_at : tertiaryWindow?.reset_at,
1035
+ }, Date.now());
1012
1036
  return hasKnownQuotaValue(quota) || snapshotHasCredits(quota) ? quota : null;
1013
1037
  }
1014
1038
 
@@ -666,8 +666,9 @@ function isDefiniteContextOverflowMessage(text: string): boolean {
666
666
  * arbitrary field that happens to sit in the first 500 bytes authorize a hop, so it fails closed.
667
667
  *
668
668
  * Only the exact proxy wrapper is unwrapped, within a fixed envelope budget and 16,384 characters.
669
+ * Exported for the sidecar loops, which classify their own provider failures with the same rule.
669
670
  */
670
- function isDefiniteContextOverflow(status: number, message: string): boolean {
671
+ export function isDefiniteContextOverflow(status: number, message: string): boolean {
671
672
  if (!CONTEXT_VERDICT_STATUSES.has(status) && status < 500) return false;
672
673
  if (message.length > 16_384) return false;
673
674
  let text = message.trim();