@bitkyc08/opencodex 2.90.0 → 2.92.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 (250) hide show
  1. package/bin/ocx.mjs +37 -3
  2. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  3. package/gui/dist/assets/{App-DgxkE6Md.js → App-sUf_8afJ.js} +18 -18
  4. package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BSsxznDm.js} +1 -1
  5. package/gui/dist/assets/index-BlFE_VB_.js +94 -0
  6. package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
  7. package/gui/dist/index.html +1 -1
  8. package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
  9. package/package.json +4 -3
  10. package/src/adapters/anthropic.ts +2 -5
  11. package/src/adapters/claude-cli/usage-admission.ts +14 -0
  12. package/src/adapters/google-vertex-endpoint.ts +9 -1
  13. package/src/adapters/google-vertex-key-project.ts +223 -0
  14. package/src/adapters/google.ts +13 -3
  15. package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
  16. package/src/adapters/openai-chat.ts +2 -2
  17. package/src/adapters/openai-responses/passthrough.ts +2 -1
  18. package/src/adapters/openai-responses/request-strips.ts +92 -0
  19. package/src/adapters/openai-responses/transport.ts +6 -0
  20. package/src/adapters/registry.ts +5 -0
  21. package/src/adapters/workbuddy-schema.ts +61 -0
  22. package/src/adapters/workbuddy.ts +68 -0
  23. package/src/bridge/response-json.ts +1 -1
  24. package/src/bridge/sse.ts +1 -1
  25. package/src/chat/inbound.ts +20 -2
  26. package/src/chat/outbound.ts +47 -8
  27. package/src/chat/reasoning-details.ts +137 -0
  28. package/src/cli/capabilities-agents-routing.ts +53 -3
  29. package/src/cli/capabilities-base.ts +15 -2
  30. package/src/cli/capabilities-observe-system.ts +4 -4
  31. package/src/cli/codex-cli-update.ts +8 -0
  32. package/src/cli/codex-shim-autorestore.ts +5 -0
  33. package/src/cli/combo-result.ts +6 -0
  34. package/src/cli/combo-stats.ts +3 -0
  35. package/src/cli/combo.ts +66 -42
  36. package/src/cli/dispatch.ts +30 -0
  37. package/src/cli/fallback.ts +127 -0
  38. package/src/cli/help.ts +1 -0
  39. package/src/cli/index.ts +1 -2
  40. package/src/cli/launcher-context.ts +49 -2
  41. package/src/cli/link-apply-restart.ts +72 -0
  42. package/src/cli/link-child.ts +6 -2
  43. package/src/cli/link-cleanup.ts +95 -0
  44. package/src/cli/link-enrollment.ts +4 -0
  45. package/src/cli/link.ts +123 -35
  46. package/src/cli/provider.ts +46 -3
  47. package/src/cli/registry.ts +13 -1
  48. package/src/cli/restart-failure.ts +4 -0
  49. package/src/cli/runtime-api.ts +1 -1
  50. package/src/cli/status-codex-settings.ts +56 -0
  51. package/src/cli/status-fallback.ts +29 -0
  52. package/src/cli/status-install.ts +52 -0
  53. package/src/cli/status.ts +20 -1
  54. package/src/cli/system-restart-client.ts +32 -1
  55. package/src/cli/update-restart-child.ts +4 -3
  56. package/src/cli/update-restart-home.ts +20 -8
  57. package/src/cli/usage-compare.ts +68 -12
  58. package/src/client/connect.ts +82 -6
  59. package/src/client/home-unreachable.ts +16 -0
  60. package/src/client/hub-relay.ts +8 -2
  61. package/src/client/link-home.ts +119 -6
  62. package/src/client/link-ingress.ts +1 -1
  63. package/src/client/link-parity-reporter.ts +107 -0
  64. package/src/client/link-readiness.ts +97 -0
  65. package/src/client/link-relay-transport.ts +84 -9
  66. package/src/client/link-relay.ts +98 -15
  67. package/src/client/link-tunnel.ts +45 -18
  68. package/src/client/machine-listener.ts +103 -3
  69. package/src/client/runtime-restart.ts +127 -0
  70. package/src/client/runtime.ts +155 -25
  71. package/src/codex/account-store.ts +17 -3
  72. package/src/codex/app-server-restart-service.ts +6 -5
  73. package/src/codex/auth-context.ts +10 -6
  74. package/src/codex/catalog/effort.ts +9 -2
  75. package/src/codex/catalog/gather-capture.ts +4 -2
  76. package/src/codex/catalog/parsing.ts +4 -0
  77. package/src/codex/catalog/provider-models.ts +14 -8
  78. package/src/codex/chatgpt-refresh-failure.ts +6 -2
  79. package/src/codex/cli-install-provenance.ts +418 -0
  80. package/src/codex/cli-installation-targets.ts +5 -1
  81. package/src/codex/cli-update-plan.ts +43 -16
  82. package/src/codex/desktop-app/picker-freshness.ts +10 -8
  83. package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
  84. package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
  85. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  86. package/src/codex/pool-refresh-backoff.ts +3 -1
  87. package/src/codex/quota-history.ts +7 -0
  88. package/src/codex/quota.ts +10 -0
  89. package/src/codex/subagent-model-fallback.ts +24 -1
  90. package/src/codex/user-identity.ts +35 -6
  91. package/src/codex/windows-current-sid.ts +162 -0
  92. package/src/combos/index.ts +1 -0
  93. package/src/combos/jev-quota-activation.ts +5 -1
  94. package/src/combos/jev-quota-route.ts +1 -1
  95. package/src/combos/jev-quota.ts +9 -1
  96. package/src/combos/jev-session-pin-config.ts +51 -0
  97. package/src/combos/jev.ts +6 -0
  98. package/src/combos/resolve.ts +12 -0
  99. package/src/combos/types.ts +19 -0
  100. package/src/config/diagnostics.ts +5 -0
  101. package/src/config/load-degrade.ts +2 -0
  102. package/src/config/provider-validation.ts +7 -0
  103. package/src/config/schema/config-schema.ts +3 -0
  104. package/src/config/schema/fallback-policy.ts +40 -0
  105. package/src/config/schema/leaf-validators.ts +1 -0
  106. package/src/generated/compatibility-version.json +375 -195
  107. package/src/lib/authorization-scheme.ts +95 -0
  108. package/src/lib/decision-quota-sink.ts +10 -0
  109. package/src/lib/provider-client-headers.ts +1 -0
  110. package/src/lib/redact.ts +2 -2
  111. package/src/lib/system-restart-contract.ts +24 -0
  112. package/src/lib/test-home-guard.ts +78 -11
  113. package/src/lib/translator-budget.ts +4 -4
  114. package/src/lib/upstream-retry.ts +4 -1
  115. package/src/link/child-parity.ts +124 -0
  116. package/src/link/codex-settings.ts +37 -0
  117. package/src/link/compatibility.ts +119 -0
  118. package/src/link/diagnostics.ts +193 -0
  119. package/src/link/management-routes.ts +2 -0
  120. package/src/link/peer-report.ts +100 -0
  121. package/src/link/relay-auth.ts +6 -0
  122. package/src/link/remote-platform.ts +78 -0
  123. package/src/link/ssh-argv.ts +19 -0
  124. package/src/link/status-projection.ts +63 -2
  125. package/src/link/supervisor.ts +45 -12
  126. package/src/link/tunnel-history.ts +109 -0
  127. package/src/link/tunnel-state.ts +80 -17
  128. package/src/messaging/remote-files.ts +24 -1
  129. package/src/oauth/generic-account-failover.ts +91 -7
  130. package/src/oauth/index.ts +16 -1
  131. package/src/oauth/kimi.ts +2 -0
  132. package/src/oauth/model-discovery-auth.ts +5 -0
  133. package/src/oauth/nous.ts +4 -1
  134. package/src/oauth/store.ts +6 -2
  135. package/src/oauth/token-guardian.ts +7 -0
  136. package/src/oauth/types.ts +9 -0
  137. package/src/oauth/workbuddy.ts +139 -0
  138. package/src/protocols/codecs/chat.ts +3 -3
  139. package/src/protocols/encoders/adapter-events.ts +26 -2
  140. package/src/protocols/encoders/chat.ts +55 -3
  141. package/src/providers/antigravity-quota-identity.ts +37 -0
  142. package/src/providers/base-url-choices.ts +8 -5
  143. package/src/providers/derive.ts +23 -3
  144. package/src/providers/mistral-transport.ts +37 -4
  145. package/src/providers/model-discovery.ts +2 -0
  146. package/src/providers/model-rename-fields.ts +1 -0
  147. package/src/providers/quota/account-cache.ts +3 -2
  148. package/src/providers/quota/antigravity.ts +27 -22
  149. package/src/providers/quota/report-cache.ts +13 -4
  150. package/src/providers/quota/vendor-probes-key.ts +60 -0
  151. package/src/providers/quota/workbuddy.ts +83 -0
  152. package/src/providers/quota-decision-antigravity.ts +64 -0
  153. package/src/providers/quota-decision-publication.ts +11 -0
  154. package/src/providers/quota-decision-snapshot.ts +21 -4
  155. package/src/providers/quota-types.ts +2 -0
  156. package/src/providers/quota.ts +3 -1
  157. package/src/providers/registry/entries-core.ts +9 -0
  158. package/src/providers/registry/entries-extended.ts +20 -7
  159. package/src/providers/registry/model-ids.ts +3 -0
  160. package/src/providers/registry/model-seeds.ts +27 -28
  161. package/src/providers/registry/types.ts +19 -1
  162. package/src/providers/resolved-model-policy.ts +4 -4
  163. package/src/providers/routed-config.ts +15 -0
  164. package/src/providers/stale-model-roster-migration.ts +83 -15
  165. package/src/providers/workbuddy-credit-balance.ts +81 -0
  166. package/src/providers/workbuddy-models.ts +37 -0
  167. package/src/providers/workbuddy.ts +60 -0
  168. package/src/quota-pool/assemble.ts +326 -0
  169. package/src/quota-pool/contract.ts +58 -0
  170. package/src/quota-pool/identity.ts +203 -0
  171. package/src/remote/features.ts +74 -0
  172. package/src/remote/protocol.ts +2 -0
  173. package/src/remote/served-by.ts +57 -0
  174. package/src/responses/parser.ts +7 -0
  175. package/src/responses/reasoning-envelope.ts +12 -0
  176. package/src/responses/reasoning-replay-cache.ts +18 -14
  177. package/src/router.ts +2 -0
  178. package/src/routing/analytics.ts +2 -3
  179. package/src/routing/fallback-policy.ts +290 -0
  180. package/src/routing/fallback-state.ts +179 -0
  181. package/src/server/audio-client.ts +2 -1
  182. package/src/server/audio-dictation.ts +2 -1
  183. package/src/server/audio-upstream.ts +2 -1
  184. package/src/server/auth-cors.ts +7 -5
  185. package/src/server/background-lifecycle.ts +4 -0
  186. package/src/server/chat-completions.ts +22 -2
  187. package/src/server/chat-native-reasoning.ts +10 -3
  188. package/src/server/chat-native.ts +13 -2
  189. package/src/server/claude-messages.ts +3 -0
  190. package/src/server/gui-session.ts +2 -1
  191. package/src/server/index/link-listener.ts +11 -1
  192. package/src/server/index/link-management.ts +47 -0
  193. package/src/server/index/link-relay-sessions.ts +20 -7
  194. package/src/server/index/optional-listeners.ts +11 -2
  195. package/src/server/index/serve-options.ts +19 -2
  196. package/src/server/live.ts +5 -0
  197. package/src/server/local-management-read-client.ts +3 -1
  198. package/src/server/management/combo-routes.ts +10 -4
  199. package/src/server/management/context.ts +1 -1
  200. package/src/server/management/fallback-routes.ts +75 -0
  201. package/src/server/management/link-enrollment.ts +21 -3
  202. package/src/server/management/link-route-state.ts +2 -0
  203. package/src/server/management/link-routes.ts +16 -2
  204. package/src/server/management/provider-routes.ts +1 -1
  205. package/src/server/management/quota-pool-routes.ts +24 -0
  206. package/src/server/management/route-registry.ts +3 -0
  207. package/src/server/management-api.ts +16 -0
  208. package/src/server/management-auth.ts +2 -1
  209. package/src/server/port-reclaim.ts +51 -9
  210. package/src/server/request-log-conversation.ts +49 -0
  211. package/src/server/request-log.ts +2 -0
  212. package/src/server/responses/agent-task-recovery.ts +1 -1
  213. package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
  214. package/src/server/responses/compact.ts +12 -4
  215. package/src/server/responses/core-auth.ts +11 -1
  216. package/src/server/responses/core-combo.ts +125 -32
  217. package/src/server/responses/core-lifetime.ts +2 -2
  218. package/src/server/responses/core-normalize.ts +6 -3
  219. package/src/server/responses/core-opaque-recovery.ts +1 -0
  220. package/src/server/responses/core-options.ts +5 -0
  221. package/src/server/responses/core-replay.ts +2 -1
  222. package/src/server/responses/encrypted-payload.ts +3 -3
  223. package/src/server/responses/jev-combo-choice.ts +39 -0
  224. package/src/server/responses/jev-session-pin.ts +203 -0
  225. package/src/server/responses/main-hard-lock-routing.ts +54 -2
  226. package/src/server/responses/passthrough-delivery.ts +4 -0
  227. package/src/server/responses/policy-fallback.ts +44 -1
  228. package/src/server/responses/request-prepare.ts +19 -6
  229. package/src/server/responses/request-transport.ts +25 -6
  230. package/src/server/responses/run-turn-execution.ts +2 -4
  231. package/src/server/responses/sidecar-execution.ts +2 -4
  232. package/src/server/responses-phase-inference.ts +267 -0
  233. package/src/server/restart-replacement.ts +50 -2
  234. package/src/server/sse-payload-rewrite.ts +14 -1
  235. package/src/server/ws-bridge.ts +5 -0
  236. package/src/service/cli.ts +25 -2
  237. package/src/service/definition.ts +20 -8
  238. package/src/service/diagnostics.ts +20 -6
  239. package/src/service/status-json.ts +116 -0
  240. package/src/types/config.ts +13 -0
  241. package/src/types/fallback.ts +38 -0
  242. package/src/types/provider.ts +9 -1
  243. package/src/types/request.ts +1 -0
  244. package/src/types/wire.ts +1 -0
  245. package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
  246. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  247. package/src/usage/jev-stats.ts +54 -14
  248. package/gui/dist/assets/App-dnkJ85te.css +0 -1
  249. package/gui/dist/assets/index-TmaHa54u.js +0 -94
  250. package/gui/dist/assets/quota-summary-B16x5DZZ.js +0 -1
@@ -3,6 +3,7 @@ import { COST_VENDOR_PRIORITY, getModelMetadata, getModelMetadataCaseInsensitive
3
3
  import { cloneFastWire } from "./fastwire";
4
4
  import { isAzureModelMetadataDestination, publishedAzureModelMetadata, type AzureModelMetadata } from "./azure-model-metadata";
5
5
  import { resolveModelPolicy } from "./resolved-model-policy";
6
+ import { mapFill, nestedMapFill, stableUnion } from "./resolved-model-policy-merge";
6
7
  import {
7
8
  PROVIDER_REGISTRY,
8
9
  registryEntryForProviderDestination,
@@ -56,6 +57,7 @@ export interface DerivedKeyLoginProvider {
56
57
  thinkingBudgetModels?: string[];
57
58
  escapeBuiltinToolNames?: boolean;
58
59
  openaiChatEofTolerance?: boolean;
60
+ foldDeveloperRoleToSystem?: boolean;
59
61
  googleMode?: "ai-studio" | "vertex" | "cloud-code-assist";
60
62
  project?: string;
61
63
  location?: string;
@@ -280,6 +282,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
280
282
  ...(entry.promptCacheKey !== undefined ? { promptCacheKey: entry.promptCacheKey } : {}),
281
283
  ...(entry.chatServiceTier !== undefined ? { chatServiceTier: entry.chatServiceTier } : {}),
282
284
  ...(entry.openaiChatEofTolerance !== undefined ? { openaiChatEofTolerance: entry.openaiChatEofTolerance } : {}),
285
+ ...(entry.foldDeveloperRoleToSystem !== undefined ? { foldDeveloperRoleToSystem: entry.foldDeveloperRoleToSystem } : {}),
283
286
  ...(entry.responsesPath !== undefined ? { responsesPath: entry.responsesPath } : {}),
284
287
  ...(entry.chatCompletionsPath !== undefined ? { chatCompletionsPath: entry.chatCompletionsPath } : {}),
285
288
  ...(entry.statelessResponses !== undefined ? { statelessResponses: entry.statelessResponses } : {}),
@@ -312,6 +315,9 @@ export function deriveKeyLoginMap(): Record<string, DerivedKeyLoginProvider> {
312
315
  const out: Record<string, DerivedKeyLoginProvider> = {};
313
316
  for (const entry of PROVIDER_REGISTRY) {
314
317
  if (entry.authKind !== "key") continue;
318
+ // A deprecated route cannot complete a login, but its entry must stay resolvable for
319
+ // configs saved while it still worked.
320
+ if (entry.deprecated) continue;
315
321
  if (!entry.dashboardUrl) throw new Error(`Registry key provider missing dashboardUrl: ${entry.id}`);
316
322
  const liveModels = registryEntrySupportsLiveModelDiscovery(entry) ? entry.liveModels : false;
317
323
  out[entry.id] = {
@@ -355,6 +361,7 @@ export function deriveKeyLoginMap(): Record<string, DerivedKeyLoginProvider> {
355
361
  ...(entry.thinkingBudgetModels ? { thinkingBudgetModels: [...entry.thinkingBudgetModels] } : {}),
356
362
  ...(entry.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: entry.escapeBuiltinToolNames } : {}),
357
363
  ...(entry.openaiChatEofTolerance !== undefined ? { openaiChatEofTolerance: entry.openaiChatEofTolerance } : {}),
364
+ ...(entry.foldDeveloperRoleToSystem !== undefined ? { foldDeveloperRoleToSystem: entry.foldDeveloperRoleToSystem } : {}),
358
365
  ...(entry.googleMode ? { googleMode: entry.googleMode } : {}),
359
366
  ...(entry.project ? { project: entry.project } : {}),
360
367
  ...(entry.location ? { location: entry.location } : {}),
@@ -364,7 +371,7 @@ export function deriveKeyLoginMap(): Record<string, DerivedKeyLoginProvider> {
364
371
  }
365
372
 
366
373
  export function deriveInitProviders(): DerivedInitProvider[] {
367
- return PROVIDER_REGISTRY.map(entry => ({
374
+ return PROVIDER_REGISTRY.filter(entry => !entry.deprecated).map(entry => ({
368
375
  id: entry.id,
369
376
  label: formatInitLabel(entry),
370
377
  adapter: entry.adapter,
@@ -391,6 +398,7 @@ export function deriveOAuthIds(): string[] {
391
398
 
392
399
  export function deriveProviderPresets(): DerivedProviderPreset[] {
393
400
  const presets = PROVIDER_REGISTRY
401
+ .filter(entry => !entry.deprecated)
394
402
  .filter(entry => entry.featured || entry.authKind === "key" || entry.dashboardPreset)
395
403
  .map(entryToPreset);
396
404
  return [...dedupePresets(presets), customPreset()];
@@ -470,13 +478,22 @@ function applyVerbosityDefaults(prov: OcxProviderConfig, entry: ProviderRegistry
470
478
  * was skipped and the reasoning ladder was advertised without summary support — exactly the
471
479
  * inconsistency that makes Codex drop the inbound reasoning object.
472
480
  *
473
- * Deliberately narrow: reasoning-summary, effort, and replay-compatibility metadata only, via
481
+ * Deliberately narrow: reasoning-summary, effort, structured-output, and replay-compatibility metadata only, via
474
482
  * `registryEntryForProviderDestination`, which matches fixed key destinations and refuses
475
483
  * templated or overridable base URLs. A custom row keeps its own identity for everything else.
476
484
  */
477
485
  function enrichReasoningMetadataByDestination(prov: OcxProviderConfig): void {
478
486
  const destination = registryEntryForProviderDestination(prov);
479
487
  applyReasoningSummaryDefaults(prov, destination?.modelSupportsReasoningSummaries);
488
+ if (destination?.modelDefaultReasoningEfforts) {
489
+ prov.modelDefaultReasoningEfforts = mapFill(destination.modelDefaultReasoningEfforts, prov.modelDefaultReasoningEfforts)[0];
490
+ }
491
+ if (destination?.modelReasoningEffortMap) {
492
+ prov.modelReasoningEffortMap = nestedMapFill(destination.modelReasoningEffortMap, prov.modelReasoningEffortMap)[0];
493
+ }
494
+ if (destination?.noStructuredOutputModels?.length) {
495
+ prov.noStructuredOutputModels = stableUnion(destination.noStructuredOutputModels, prov.noStructuredOutputModels)[0];
496
+ }
480
497
  if (prov.dropResponsesReasoningItems === undefined && destination?.dropResponsesReasoningItems !== undefined) {
481
498
  prov.dropResponsesReasoningItems = destination.dropResponsesReasoningItems;
482
499
  }
@@ -567,6 +584,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
567
584
  if (prov.openaiChatEofTolerance === undefined && seed.openaiChatEofTolerance !== undefined) {
568
585
  prov.openaiChatEofTolerance = seed.openaiChatEofTolerance;
569
586
  }
587
+ if (prov.foldDeveloperRoleToSystem === undefined && seed.foldDeveloperRoleToSystem !== undefined) {
588
+ prov.foldDeveloperRoleToSystem = seed.foldDeveloperRoleToSystem;
589
+ }
570
590
  // Fill-only: a hand-edited path must survive, and a config saved before the registry
571
591
  // learned this route still gets backfilled.
572
592
  if (prov.responsesPath === undefined && seed.responsesPath !== undefined) prov.responsesPath = seed.responsesPath;
@@ -633,7 +653,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
633
653
  }
634
654
 
635
655
  export function deriveFeaturedProviderIds(): string[] {
636
- return PROVIDER_REGISTRY.filter(entry => entry.featured).map(entry => entry.id);
656
+ return PROVIDER_REGISTRY.filter(entry => entry.featured && !entry.deprecated).map(entry => entry.id);
637
657
  }
638
658
 
639
659
  export function deriveJawcodeAliases(): Record<string, string> {
@@ -8,6 +8,14 @@ import { RequestPacingQueueOverloadError } from "./request-pacing";
8
8
  import type { OcxProviderTransport } from "./xai-transport";
9
9
 
10
10
  const MAX_IDENTITIES = 128;
11
+ /** Queued waiters per credential+model identity; one hot identity never refuses another. */
12
+ const MAX_WAITERS_PER_IDENTITY = 128;
13
+ /**
14
+ * Process-wide waiter bound. Model strings come from clients, so identities are not bounded by
15
+ * configuration; this keeps memory and pending requests finite. It is a multiple of the
16
+ * per-identity cap, so no single identity can fill it alone.
17
+ */
18
+ const MAX_WAITERS_TOTAL = 4 * MAX_WAITERS_PER_IDENTITY;
11
19
  const DEFAULT_MAX_WAIT_MS = 60_000; // Existing retry wait budget, not an inferred rate window.
12
20
  const MINUTE_MS = 60 * 1000; // Unit named by the observed headers, never an account quota.
13
21
  type RecoveryState = { generation: number; probe?: { done: Promise<void>; finish(): void } };
@@ -61,7 +69,14 @@ export class MistralBudgetPacer {
61
69
  private readonly recoveries = new Map<string, RecoveryState>();
62
70
  /** Latest live deadline evicted at capacity; unknown identities wait it out instead of sending early. */
63
71
  private evictedUntil = 0;
64
- private waiting = 0;
72
+ /**
73
+ * Parked waiters keyed by identity. A request counts only once it is about to wait, so a held
74
+ * credential or model fills only its own queue and a request that can send now is never answered
75
+ * queue_full (HTTP 429). The map holds identities with live waiters only; the total is capped by
76
+ * MAX_WAITERS_TOTAL.
77
+ */
78
+ private readonly waiting = new Map<string, number>();
79
+ private waitingTotal = 0;
65
80
 
66
81
  constructor(
67
82
  private readonly now: () => number = Date.now,
@@ -103,9 +118,18 @@ export class MistralBudgetPacer {
103
118
  /** Wait within the admission budget and return a release callback for one recovery probe. */
104
119
  async acquire(identity: string, signal?: AbortSignal, maxWaitMs = DEFAULT_MAX_WAIT_MS): Promise<(success?: boolean) => void> {
105
120
  signal?.throwIfAborted();
106
- if (this.waiting >= MAX_IDENTITIES) throw new RequestPacingQueueOverloadError("mistral", "queue_full", 60);
107
- this.waiting++;
108
121
  const deadline = this.now() + maxWaitMs;
122
+ let parked = false;
123
+ const park = (): void => {
124
+ if (parked) return;
125
+ const queued = this.waiting.get(identity) ?? 0;
126
+ if (queued >= MAX_WAITERS_PER_IDENTITY || this.waitingTotal >= MAX_WAITERS_TOTAL) {
127
+ throw new RequestPacingQueueOverloadError("mistral", "queue_full", 60);
128
+ }
129
+ this.waiting.set(identity, queued + 1);
130
+ this.waitingTotal++;
131
+ parked = true;
132
+ };
109
133
  try {
110
134
  for (;;) {
111
135
  signal?.throwIfAborted();
@@ -118,6 +142,7 @@ export class MistralBudgetPacer {
118
142
  throw new RequestPacingQueueOverloadError("mistral", "queue_expired", Math.ceil(delay / 1000));
119
143
  }
120
144
  if (readyAt !== undefined && readyAt > this.now()) {
145
+ park();
121
146
  await this.sleep(readyAt - this.now(), signal);
122
147
  continue;
123
148
  }
@@ -126,6 +151,7 @@ export class MistralBudgetPacer {
126
151
  if (state.probe) {
127
152
  const remaining = deadline - this.now();
128
153
  if (remaining <= 0) throw new RequestPacingQueueOverloadError("mistral", "queue_expired", 1);
154
+ park();
129
155
  const local = new AbortController();
130
156
  try {
131
157
  await Promise.race([state.probe.done, this.sleep(remaining, AbortSignal.any([local.signal, ...(signal ? [signal] : [])]))]);
@@ -150,7 +176,14 @@ export class MistralBudgetPacer {
150
176
  probe.finish();
151
177
  };
152
178
  }
153
- } finally { this.waiting--; }
179
+ } finally {
180
+ if (parked) {
181
+ this.waitingTotal--;
182
+ const left = (this.waiting.get(identity) ?? 1) - 1;
183
+ if (left > 0) this.waiting.set(identity, left);
184
+ else this.waiting.delete(identity);
185
+ }
186
+ }
154
187
  }
155
188
  }
156
189
 
@@ -1,3 +1,4 @@
1
+ import { extractWorkBuddyModels } from "./workbuddy-models";
1
2
  import type { OcxProviderConfig } from "../types";
2
3
  import {
3
4
  isValidModelDiscoveryModelId,
@@ -513,6 +514,7 @@ export function extractProviderModelItems(
513
514
  discovery: ResolvedProviderModelDiscovery,
514
515
  ): ProviderModelItemsResult {
515
516
  const limit = positiveIntegerAtMost(discovery.maxModels, MODEL_DISCOVERY_MAX_MODELS);
517
+ if (discovery.spec?.format === "workbuddy") return extractWorkBuddyModels(value, limit);
516
518
  let data: unknown[];
517
519
  let siblings: SiblingIndex | null = null;
518
520
  if (Array.isArray(value)) {
@@ -153,6 +153,7 @@ export const PROVIDER_MODEL_RENAME_ROLES = {
153
153
  desktopExecutor: "none",
154
154
  unsafeAllowNativeLocalExec: "none",
155
155
  nativeLocalExec: "none",
156
+ messagePhaseInference: "none",
156
157
  tlsProfile: "none",
157
158
  } as const satisfies Record<keyof OcxProviderConfig, ModelRenameRole>;
158
159
 
@@ -203,7 +203,7 @@ export function supportsPerAccountQuota(provider: string): boolean {
203
203
 
204
204
  export function explicitAccountReader(provider: string): boolean {
205
205
  return provider === "xai" || provider === "cursor" || provider === "kimi" || provider === "command-code"
206
- || provider === "devin" || provider === "dsh-account";
206
+ || provider === "devin" || provider === "workbuddy-ai" || provider === "dsh-account";
207
207
  }
208
208
 
209
209
  export function providerOAuthAccountQuotaMode(provider: string): AccountQuotaMode {
@@ -558,6 +558,7 @@ export function quotaCredentialIdentity(provider: string, accountId: string, cre
558
558
  // Only credentials that carry their own endpoint (Devin tenants) extend the identity, so
559
559
  // every other provider's existing cache keys stay valid.
560
560
  ...(provider === "devin" && credential.apiBaseUrl ? [credential.apiBaseUrl] : []),
561
+ ...(provider === "workbuddy-ai" ? [credential.workbuddy] : []),
561
562
  ])).digest("hex");
562
563
  }
563
564
 
@@ -567,5 +568,5 @@ export function explicitQuotaDestination(provider: string, config: OcxProviderCo
567
568
  if (provider === "command-code") return isCanonicalCommandCodeBaseUrl(config.baseUrl);
568
569
  // These readers use fixed canonical billing origins, never config.baseUrl. Devin reads
569
570
  // the credential's own allowlisted api-server host instead (fetchDevinQuota revalidates it).
570
- return provider === "xai" || provider === "cursor" || provider === "devin" || provider === "dsh-account";
571
+ return provider === "xai" || provider === "cursor" || provider === "devin" || provider === "workbuddy-ai" || provider === "dsh-account";
571
572
  }
@@ -3,11 +3,16 @@ import { DestinationDnsResolutionError } from "../../lib/destination-policy";
3
3
  import { PinnedHttpError, type PinnedHttpErrorCode } from "../../lib/pinned-http";
4
4
  import { ProviderOutboundPolicyError, providerOutboundPost, providerRedirectError, type ProviderOutboundDependencies } from "../../lib/provider-outbound";
5
5
  import { getValidAccessToken } from "../../oauth";
6
- import { getAccountCredential, getCredential } from "../../oauth/store";
6
+ import { getAccountCredential, getAccountSet, getCredential } from "../../oauth/store";
7
7
  import { asRecord, normalizePercent, normalizeResetAt, readQuotaJson, REQUEST_TIMEOUT_MS, toFiniteNumber } from "../quota-wire";
8
- import { report, type ProviderQuotaReport } from "./report-cache";
8
+ import { antigravityEvidence, report, type ProviderQuotaReport } from "./report-cache";
9
+ import { captureAntigravityQuotaReceipt, decisionQuotaSinkActive } from "../../lib/decision-quota-sink";
10
+ import { antigravityDecisionWindows } from "../quota-decision-publication";
11
+ import { antigravityQuotaCredentialGeneration, isAntigravityArgonIdentity } from "../antigravity-quota-identity";
12
+ import type { OcxProviderConfig } from "../../types";
9
13
  import { quotaCredentialIdentity } from "./account-cache";
10
14
  import type { ProviderQuota, ProviderQuotaWindow, QuotaFailureCode } from "../quota-types";
15
+ import { httpQuotaFailure } from "./failure-codes";
11
16
 
12
17
  export function antigravityQuotaDiagnosticIdentity(accountId: string, credential = getAccountCredential("google-antigravity", accountId)): string | undefined {
13
18
  return credential ? quotaCredentialIdentity("google-antigravity", accountId, credential, {
@@ -45,11 +50,6 @@ function quotaInfoEntries(modelInfo: Record<string, unknown>): Record<string, un
45
50
  return entries;
46
51
  }
47
52
 
48
- /**
49
- * Gemini 4 Argon has its own quota pool. Its names also contain "gemini", so every family
50
- * decision below checks Argon first; otherwise Argon readings fill or replace the Gem family.
51
- */
52
- const ARGON_TOKEN = /(?:^|[^a-z0-9])argon(?:[^a-z0-9]|$)/i;
53
53
  const GEMINI_TOKEN = /(?:^|[^a-z0-9])gemini(?:[^a-z0-9]|$)/i;
54
54
  const CLAUDE_FAMILY_TOKEN = /(?:^|[^a-z0-9])(?:claude|3p|gpt|opus|sonnet)(?:[^a-z0-9]|$)/i;
55
55
  /** Canonical model of the Argon summary pool; tagging keeps the window out of routing. */
@@ -60,7 +60,7 @@ type AntigravitySummaryFamily = "argon" | "gem" | "cla";
60
60
  /** A bucket's own id and name decide its family when they name one; the group decides otherwise. */
61
61
  function antigravitySummaryBucketFamily(bucket: Record<string, unknown>): AntigravitySummaryFamily | undefined {
62
62
  const identity = `${typeof bucket.bucketId === "string" ? bucket.bucketId : ""} ${typeof bucket.displayName === "string" ? bucket.displayName : ""}`;
63
- if (ARGON_TOKEN.test(identity)) return "argon";
63
+ if (isAntigravityArgonIdentity(identity)) return "argon";
64
64
  if (GEMINI_TOKEN.test(identity)) return "gem";
65
65
  if (CLAUDE_FAMILY_TOKEN.test(identity)) return "cla";
66
66
  return undefined;
@@ -69,7 +69,7 @@ function antigravitySummaryBucketFamily(bucket: Record<string, unknown>): Antigr
69
69
  function classifyAntigravityFamily(modelId: string, modelInfo: Record<string, unknown>, quotaInfo: Record<string, unknown>): "Gem" | "Cla" | null {
70
70
  const displayName = typeof modelInfo.displayName === "string" ? modelInfo.displayName : "";
71
71
  // Argon models keep their exact model rows but never seed the shared Gem family bar.
72
- if (ARGON_TOKEN.test(`${modelId} ${displayName}`)) return null;
72
+ if (isAntigravityArgonIdentity(`${modelId} ${displayName}`)) return null;
73
73
  const tier = typeof quotaInfo.tier === "string" ? quotaInfo.tier : "";
74
74
  const haystack = `${modelId} ${displayName} ${tier}`.toLowerCase();
75
75
  if (haystack.includes("gemini")) return "Gem";
@@ -154,7 +154,7 @@ function parseAntigravityQuotaSummary(body: Record<string, unknown> | null): Pro
154
154
  const groupDisplayName = typeof group.displayName === "string" ? group.displayName : "";
155
155
  const groupName = `${groupDisplayName} ${typeof group.description === "string" ? group.description : ""}`.toLowerCase();
156
156
  // A description that merely mentions Argon must not move a whole Gemini group.
157
- const groupFamily: AntigravitySummaryFamily | undefined = ARGON_TOKEN.test(groupDisplayName) ? "argon"
157
+ const groupFamily: AntigravitySummaryFamily | undefined = isAntigravityArgonIdentity(groupDisplayName) ? "argon"
158
158
  : groupName.includes("gemini") ? "gem"
159
159
  : groupName.includes("claude") || groupName.includes("3p") || groupName.includes("gpt") ? "cla"
160
160
  : undefined;
@@ -275,13 +275,6 @@ function quotaTransportFailure(error: unknown): QuotaFailureCode {
275
275
  return "transport_error";
276
276
  }
277
277
 
278
- function quotaHttpFailure(status: number): QuotaFailureCode {
279
- if (status >= 300 && status < 400) return "redirect_blocked";
280
- if (status === 401 || status === 403) return "access_denied";
281
- if (status === 429) return "rate_limited";
282
- return "upstream_error";
283
- }
284
-
285
278
  function unavailableAntigravityQuota(failure: QuotaFailureCode): AntigravityQuotaProbeResult {
286
279
  return { kind: "unavailable", failure, legacy: { kind: "null" } };
287
280
  }
@@ -342,7 +335,7 @@ export async function probeAntigravityUsageQuota(accessToken: string, projectId:
342
335
  return unavailableAntigravityQuota(antigravityUnavailableFailure(summaryFailure, "redirect_blocked"));
343
336
  }
344
337
  if (!response.ok) {
345
- return unavailableAntigravityQuota(antigravityUnavailableFailure(summaryFailure, quotaHttpFailure(response.status)));
338
+ return unavailableAntigravityQuota(antigravityUnavailableFailure(summaryFailure, httpQuotaFailure(response.status)));
346
339
  }
347
340
  const customWindows = antigravityWindowsFromModels(asRecord(await readQuotaJson(response)));
348
341
  if (!customWindows.length) {
@@ -366,13 +359,25 @@ export async function fetchAntigravityUsageQuota(accessToken: string, projectId:
366
359
  return null;
367
360
  }
368
361
 
369
- export async function fetchAntigravityQuota(provider: string): Promise<ProviderQuotaReport | null> {
370
- const credential = getCredential("google-antigravity");
362
+ /** Every store read uses `provider`, the same key inference resolves, so a row never probes another row's account. */
363
+ export async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaReport | null> {
364
+ const credential = getCredential(provider);
371
365
  if (!credential?.projectId) return null;
372
366
  let accessToken: string;
373
- try { accessToken = await getValidAccessToken("google-antigravity"); } catch { return null; }
367
+ try { accessToken = await getValidAccessToken(provider); } catch { return null; }
368
+ const set = decisionQuotaSinkActive() ? getAccountSet(provider) : undefined;
369
+ const account = set?.accounts.find(row => row.id === set.activeAccountId);
370
+ const probed = account?.credential.access === accessToken && account.credential.projectId === credential.projectId ? account : undefined;
371
+ const receipt = probed ? captureAntigravityQuotaReceipt(provider, config, probed.id, antigravityQuotaCredentialGeneration(probed.credential)) : undefined;
374
372
  const result = await probeAntigravityUsageQuota(accessToken, credential.projectId);
375
- if (result.kind === "available") return report(provider, result.source, result.quota);
373
+ if (result.kind === "available") {
374
+ const value = report(provider, result.source, result.quota);
375
+ // Only the summary endpoint reports real family buckets. The models fallback labels one model's
376
+ // counter Gem/Cla, so it stays display-only: no windows, and retained summary clocks survive.
377
+ const windows = result.source === "google-antigravity:retrieveUserQuotaSummary" ? antigravityDecisionWindows(result.quota) : [];
378
+ if (value && receipt) antigravityEvidence.set(value, { receipt, windows: Object.freeze(windows.map(row => Object.freeze(row))) });
379
+ return value;
380
+ }
376
381
  if (result.legacy.kind === "throw") throw result.legacy.error;
377
382
  return null;
378
383
  }
@@ -1,5 +1,6 @@
1
- import { decisionQuotaSinkActive, invalidateDecisionKeyQuotas, publishDecisionKeyQuota } from "../../lib/decision-quota-sink";
1
+ import { decisionQuotaSinkActive, invalidateDecisionKeyQuotas, replaceCollectedDecisionQuotas, type AntigravityQuotaObservation, type AntigravityQuotaReceipt, type DecisionKeyObservation } from "../../lib/decision-quota-sink";
2
2
  import { providerDecisionWindows } from "../quota-decision-publication";
3
+ import type { DecisionQuotaWindow } from "../quota-decision-snapshot";
3
4
  import { createHash } from "node:crypto";
4
5
  import { effectiveCodexAuthAccountId, listCodexAuthAccountsSnapshot } from "../../codex/auth-api";
5
6
  import { withoutRetiredCodexQuota, type StoredAccountQuota } from "../../codex/quota";
@@ -21,6 +22,8 @@ export const LAST_GOOD_MAX_AGE_MS = CODEX_CAPACITY_MAX_QUOTA_AGE_MS;
21
22
  const nativeMainReportGenerations = new WeakMap<ProviderQuotaReport, number>();
22
23
  export const accountReportCurrent = new WeakMap<ProviderQuotaReport, () => boolean>();
23
24
  export const routingEvidence = new WeakMap<ProviderQuotaReport, ProviderQuotaRoutingEvidence>();
25
+ /** Only the live Antigravity probe binds this projection; display/last-good rows cannot create it. */
26
+ export const antigravityEvidence = new WeakMap<ProviderQuotaReport, { receipt: AntigravityQuotaReceipt; windows: readonly DecisionQuotaWindow[] }>();
24
27
  export let providerQuotaBeforePublishForTests: (() => void | Promise<void>) | null = null;
25
28
 
26
29
  /** Test-only seam for identity/config invalidation after probes but before publication. */
@@ -100,6 +103,7 @@ export function clearProviderQuotaCache(): void {
100
103
  cache = null;
101
104
  clearCachedProviderQuotas();
102
105
  clearProviderApiKeyQuotaCache();
106
+ invalidateDecisionKeyQuotas();
103
107
  invalidationEpoch += 1;
104
108
  }
105
109
 
@@ -350,13 +354,18 @@ export function isProviderQuotaReportCurrent(value: ProviderQuotaReport): boolea
350
354
  /** Called only by an accepted collector/cache publication, never the JEV read path. */
351
355
  export function publishCollectedDecisionQuota(reports: ProviderQuotaReport[], config: OcxConfig): void {
352
356
  if (!decisionQuotaSinkActive()) return;
353
- invalidateDecisionKeyQuotas();
357
+ const keys: DecisionKeyObservation[] = [];
358
+ const oauth: AntigravityQuotaObservation[] = [];
354
359
  for (const report of reports) {
355
360
  const provider = config.providers[report.provider];
361
+ if (!provider || !isProviderQuotaReportCurrent(report)) continue;
362
+ const observation = antigravityEvidence.get(report);
363
+ if (observation) oauth.push({ name: report.provider, provider, ...observation });
356
364
  const evidence = routingEvidence.get(report);
357
- if (provider && evidence && evidence.binding === providerQuotaRoutingBinding(report.provider, provider))
358
- publishDecisionKeyQuota(report.provider, provider, providerDecisionWindows(evidence.quota));
365
+ if (evidence && evidence.binding === providerQuotaRoutingBinding(report.provider, provider))
366
+ keys.push({ name: report.provider, provider, windows: providerDecisionWindows(evidence.quota) });
359
367
  }
368
+ replaceCollectedDecisionQuotas(keys, oauth);
360
369
  }
361
370
 
362
371
  /**
@@ -47,6 +47,8 @@ const VENICE_BASE_URL = "https://api.venice.ai/api/v1";
47
47
  const SYNTHETIC_BASE_URL = "https://api.synthetic.new/v2";
48
48
  const DEEPINFRA_BASE_URL = "https://api.deepinfra.com";
49
49
  const NEURALWATT_BASE_URL = "https://api.neuralwatt.com/v1";
50
+ const ABLITERATION_BASE_URL = "https://api.abliteration.ai";
51
+ const ABLITERATION_CREDITS_URL = `${ABLITERATION_BASE_URL}/v1/credits`;
50
52
 
51
53
 
52
54
  function isCanonicalA6apiBaseUrl(baseUrl: string): boolean {
@@ -132,6 +134,11 @@ function isCanonicalNeuralwattBaseUrl(baseUrl: string): boolean {
132
134
  return normalizedBaseUrl(baseUrl) === NEURALWATT_BASE_URL;
133
135
  }
134
136
 
137
+ function isCanonicalAbliterationBaseUrl(baseUrl: string): boolean {
138
+ const normalized = normalizedBaseUrl(baseUrl);
139
+ return normalized === ABLITERATION_BASE_URL || normalized === `${ABLITERATION_BASE_URL}/v1`;
140
+ }
141
+
135
142
  function a6apiPayload(value: unknown): Record<string, unknown> | null {
136
143
  const body = asRecord(value);
137
144
  return asRecord(body?.data) ?? body;
@@ -1043,6 +1050,56 @@ async function fetchNeuralwattQuota(provider: string, config: OcxProviderConfig)
1043
1050
  return windows > 0 ? report(provider, "neuralwatt:quota", quota) : null;
1044
1051
  }
1045
1052
 
1053
+ /**
1054
+ * Abliteration `GET /v1/credits` body: `{ data: { total_credits, total_usage } }`, both in USD.
1055
+ * `total_credits` is everything granted or purchased and `total_usage` is finalized spend, so the
1056
+ * balance is their difference. A well-formed reply with zero total credits is authoritative: it has
1057
+ * no ceiling to meter against, so it clears any previous row instead of fabricating a bar. A
1058
+ * malformed reply returns null and leaves the last-good row in place.
1059
+ */
1060
+ export function parseAbliterationCredits(
1061
+ body: Record<string, unknown> | null,
1062
+ ): ProviderQuota | typeof AUTHORITATIVE_EMPTY_QUOTA | null {
1063
+ const data = asRecord(body?.data);
1064
+ const total = toFiniteNumber(data?.total_credits);
1065
+ const used = toFiniteNumber(data?.total_usage);
1066
+ if (total === undefined || used === undefined || total < 0 || used < 0) return null;
1067
+ if (total === 0) return AUTHORITATIVE_EMPTY_QUOTA;
1068
+ const percent = normalizePercent((used / total) * 100);
1069
+ if (percent === undefined) return null;
1070
+ return {
1071
+ creditsUsd: { used, limit: total, remaining: Math.max(0, total - used), percent },
1072
+ updatedAt: Date.now(),
1073
+ };
1074
+ }
1075
+
1076
+ /**
1077
+ * Abliteration `GET /v1/credits` — the organization credit balance for the active project key.
1078
+ * The balance funds every inference request on this key, so display and routing scope agree.
1079
+ * `503 credit_balance_unavailable`, 408 and 429 are transient and keep the last-good row.
1080
+ */
1081
+ async function fetchAbliterationQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
1082
+ // Never send a configured API key to a lookalike host or through a redirect.
1083
+ if (!isCanonicalAbliterationBaseUrl(config.baseUrl)) return null;
1084
+ const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
1085
+ if (!apiKey) return null;
1086
+ const response = await quotaFetch(provider, config, ABLITERATION_CREDITS_URL, {
1087
+ headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
1088
+ redirect: "error",
1089
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1090
+ });
1091
+ if (!response.ok) {
1092
+ return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
1093
+ ? TERMINAL_QUOTA_FAILURE
1094
+ : null;
1095
+ }
1096
+ const raw = await readQuotaJson(response);
1097
+ if (raw === QUOTA_JSON_READ_FAILURE) return null;
1098
+ const quota = parseAbliterationCredits(asRecord(raw));
1099
+ if (quota === null || quota === AUTHORITATIVE_EMPTY_QUOTA) return quota;
1100
+ return keyReport(provider, "abliteration:credits", quota, config, apiKey, quota);
1101
+ }
1102
+
1046
1103
 
1047
1104
  export function normalizedBaseUrl(value: string): string | null {
1048
1105
  try {
@@ -1371,6 +1428,9 @@ export function keyQuotaReaderForProvider(name: string, provider: OcxProviderCon
1371
1428
  if (name === "synthetic" && isCanonicalSyntheticBaseUrl(provider.baseUrl)) return fetchSyntheticQuota;
1372
1429
  if (name === "deepinfra" && isCanonicalDeepInfraBaseUrl(provider.baseUrl)) return fetchDeepInfraQuota;
1373
1430
  if (name === "neuralwatt" && isCanonicalNeuralwattBaseUrl(provider.baseUrl)) return fetchNeuralwattQuota;
1431
+ // Abliteration has no registry preset, so it is usually a custom provider under any name; the
1432
+ // canonical-URL guard alone decides eligibility and keeps the key off lookalike hosts.
1433
+ if (isCanonicalAbliterationBaseUrl(provider.baseUrl)) return fetchAbliterationQuota;
1374
1434
  return null;
1375
1435
  }
1376
1436
 
@@ -0,0 +1,83 @@
1
+ import type { OAuthCredentials } from "../../oauth/types";
2
+ import { workBuddyApi, WorkBuddyApiError } from "../../oauth/workbuddy";
3
+ import { workBuddyHeaders } from "../workbuddy";
4
+ import { parseWorkBuddyCredits, workBuddyCapacity, workBuddyRecord, type WorkBuddyCreditBalance } from "../workbuddy-credit-balance";
5
+ import { REQUEST_TIMEOUT_MS } from "../quota-wire";
6
+ import { TERMINAL_QUOTA_FAILURE, type ProviderQuotaProbeResult } from "./report-cache";
7
+
8
+ function packageEnd(row: Record<string, unknown>): number | undefined {
9
+ const deduction = workBuddyCapacity(row.DeductionEndTime);
10
+ if (deduction && Number.isFinite(new Date(deduction).getTime())) return deduction;
11
+ for (const value of [row.ExpiredTime, row.CycleEndTime]) {
12
+ if (typeof value !== "string") continue;
13
+ const date = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(value)
14
+ ? `${value.replace(" ", "T")}+08:00` : value;
15
+ const parsed = Date.parse(date);
16
+ if (parsed > 0) return parsed;
17
+ }
18
+ return undefined;
19
+ }
20
+
21
+ export function workBuddyExpiringCredits(packages: unknown[], codes: ReadonlySet<string>, now: number): WorkBuddyCreditBalance["expiring"] {
22
+ let at = Infinity, amount = 0;
23
+ for (const raw of packages) {
24
+ const row = workBuddyRecord(raw);
25
+ if (typeof row.PackageCode !== "string" || !codes.has(row.PackageCode) || String(row.CapacityType) === "4") continue;
26
+ const remaining = workBuddyCapacity(row.CycleCapacityRemainPrecise || row.CycleCapacityRemain);
27
+ const end = packageEnd(row);
28
+ if (!remaining || !end || end <= now) continue;
29
+ if (end < at) { at = end; amount = remaining; }
30
+ else if (end === at) amount += remaining;
31
+ }
32
+ return Number.isFinite(at) && Number.isFinite(amount) ? { amount, at } : undefined;
33
+ }
34
+
35
+ async function expiringCredits(summary: Record<string, unknown>, headers: Record<string, string>, signal: AbortSignal, now: number) {
36
+ const packages = Array.isArray(summary.Packages) ? summary.Packages : [];
37
+ const codes = [...new Set(packages.map(raw => workBuddyRecord(raw).PackageCode)
38
+ .filter((code): code is string => typeof code === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(code)))];
39
+ if (!codes.length) return undefined;
40
+ const day = new Date(now + 8 * 3600_000).toISOString().slice(0, 10);
41
+ const lists = await Promise.all(["paid", "free"].map(async kind => {
42
+ const accounts: unknown[] = [];
43
+ for (let page = 1; page <= 10; page++) {
44
+ const data = await workBuddyApi(`/billing/meter/get-user-resource-${kind}-packages`, headers, signal, true, {
45
+ PageNumber: page, PageSize: 200, Status: [0, 3], PackageCodes: codes,
46
+ ...(kind === "paid" ? { NeedRenewInfo: true }
47
+ : { SlicePeriodStartTime: `${day} 00:00:00`, SlicePeriodEndTime: `${day} 23:59:59` }),
48
+ });
49
+ if (!Array.isArray(data.Accounts) || data.Accounts.length > 200) throw new Error("Invalid WorkBuddy package list");
50
+ accounts.push(...data.Accounts);
51
+ const total = workBuddyCapacity(data.TotalCount);
52
+ if (data.Accounts.length < 200 || total !== undefined && accounts.length >= total) return accounts;
53
+ }
54
+ throw new Error("WorkBuddy package list exceeded page limit");
55
+ }));
56
+ return workBuddyExpiringCredits(lists.flat(), new Set(codes), now);
57
+ }
58
+
59
+ export async function fetchWorkBuddyQuota(provider: string, credential: OAuthCredentials): Promise<ProviderQuotaProbeResult> {
60
+ if (!credential.workbuddy) return null;
61
+ const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS), now = Date.now();
62
+ try {
63
+ const headers = workBuddyHeaders(credential.access, credential.workbuddy);
64
+ const summary = await workBuddyApi("/billing/meter/get-user-resource-summary", headers, signal, true);
65
+ let credits = parseWorkBuddyCredits(summary);
66
+ if (credential.workbuddy.enterpriseId && (!credits || credits.total === 0)) {
67
+ const team = await workBuddyApi("/billing/meter/get-enterprise-user-usage", headers, signal, true);
68
+ const total = workBuddyCapacity(team.limitNum), used = workBuddyCapacity(team.credit);
69
+ if (total === undefined || used === undefined) return null;
70
+ credits = { total, used, remaining: Math.max(0, total - used), frozen: 0, groups: [] };
71
+ }
72
+ if (!credits) return null;
73
+ if (credits.remaining > 0) {
74
+ // Optional expiry failures must not erase a successful balance reading.
75
+ credits.expiring = await expiringCredits(summary, headers, signal, now).catch(() => undefined);
76
+ }
77
+ const updatedAt = Date.now();
78
+ return { provider, label: "WorkBuddy AI", source: "WorkBuddy credits", updatedAt,
79
+ reverseEngineered: true, quota: { updatedAt, workbuddyCredits: credits } };
80
+ } catch (error) {
81
+ return error instanceof WorkBuddyApiError && error.status === 401 ? TERMINAL_QUOTA_FAILURE : null;
82
+ }
83
+ }
@@ -0,0 +1,64 @@
1
+ /** Optional snapshot owner. No store reads: roster input comes only from loaded-store notifications. */
2
+ import { getConfigDir } from "../config/paths";
3
+ import { captureConfigGeneration } from "../lib/state-store-sweeper";
4
+ import { ANTIGRAVITY_OAUTH_STORE_KEY, antigravityQuotaCredentialGeneration, antigravityQuotaEligible, antigravityQuotaPolicy } from "./antigravity-quota-identity";
5
+ import type { AuthStore } from "../oauth/store";
6
+ import type { OcxProviderConfig } from "../types";
7
+ import type { AntigravityQuotaObservation, AntigravityQuotaReceipt } from "../lib/decision-quota-sink";
8
+ import type { DecisionQuotaWindow } from "./quota-decision-snapshot";
9
+
10
+ type Owner = { root: string; id: string; generation: string; revision: string };
11
+ type Receipt = Owner & { owner: Owner; name: string; policy: string; configGeneration: number; epoch: number };
12
+ let owner: Owner | undefined;
13
+ let epoch = 0;
14
+ const receipts = new WeakMap<AntigravityQuotaReceipt, Receipt>();
15
+ let observations = new Map<string, { receipt: Receipt; windows: readonly DecisionQuotaWindow[] }>();
16
+
17
+ /** Any roster transition retires old receipts, including switching away and back. */
18
+ export function observeAntigravityQuotaStore(store: AuthStore): void {
19
+ const set = store[ANTIGRAVITY_OAUTH_STORE_KEY];
20
+ const account = set?.accounts.length === 1 ? set.accounts[0] : undefined;
21
+ const next = account && set?.activeAccountId === account.id && !account.paused && !account.needsReauth
22
+ && account.credential.access && account.credential.projectId
23
+ ? { root: getConfigDir(), id: account.id, generation: antigravityQuotaCredentialGeneration(account.credential), revision: JSON.stringify([account.loginId, account.pauseRevision, set.selectionRevision]) } : undefined;
24
+ if (owner?.root === next?.root && owner?.id === next?.id && owner?.generation === next?.generation && owner?.revision === next?.revision) return;
25
+ owner = next;
26
+ invalidateAntigravityQuota();
27
+ }
28
+
29
+ /** Epoch fences also retire outstanding probes on explicit cache/evidence invalidation. */
30
+ export function invalidateAntigravityQuota(): void { epoch++; observations.clear(); }
31
+
32
+ export function captureAntigravityReceipt(name: string, provider: OcxProviderConfig, id: string, generation: string): AntigravityQuotaReceipt | undefined {
33
+ if (!owner || owner.root !== getConfigDir() || owner.id !== id || owner.generation !== generation || !antigravityQuotaEligible(name, provider)) return undefined;
34
+ const token: AntigravityQuotaReceipt = Object.freeze({ kind: "antigravity" });
35
+ receipts.set(token, { ...owner, owner, name, policy: antigravityQuotaPolicy(name, provider), configGeneration: captureConfigGeneration(), epoch });
36
+ return token;
37
+ }
38
+
39
+ function current(receipt: Receipt): boolean {
40
+ return receipt.owner === owner && receipt.root === getConfigDir() && receipt.epoch === epoch && receipt.configGeneration === captureConfigGeneration();
41
+ }
42
+
43
+ /** Only accepted, live immutable receipts can retain omitted family rows; clocks never change. */
44
+ export function replaceAntigravityQuotas(rows: readonly AntigravityQuotaObservation[]): void {
45
+ const next = new Map<string, { receipt: Receipt; windows: readonly DecisionQuotaWindow[] }>();
46
+ for (const row of rows.slice(0, 256)) {
47
+ const receipt = receipts.get(row.receipt);
48
+ if (!receipt) continue;
49
+ if (!current(receipt) || receipt.name !== row.name || !antigravityQuotaEligible(row.name, row.provider)
50
+ || receipt.policy !== antigravityQuotaPolicy(row.name, row.provider)) { receipts.delete(row.receipt); continue; }
51
+ const previous = observations.get(receipt.name);
52
+ const fresh = row.windows.length > 64 ? [] : row.windows.filter(window => window.family === "gem" || window.family === "cla");
53
+ const retained = previous && current(previous.receipt) && previous.receipt.owner === receipt.owner && previous.receipt.policy === receipt.policy
54
+ ? previous.windows.filter(window => !fresh.some(value => value.family === window.family && value.window === window.window)) : [];
55
+ next.set(receipt.name, { receipt, windows: [...retained, ...fresh].map(window => ({ ...window })) });
56
+ }
57
+ observations = next;
58
+ }
59
+
60
+ export function readAntigravityQuota(name: string, provider: OcxProviderConfig): readonly DecisionQuotaWindow[] | undefined {
61
+ const row = observations.get(name);
62
+ if (!row || !current(row.receipt) || !antigravityQuotaEligible(name, provider) || row.receipt.policy !== antigravityQuotaPolicy(name, provider)) return undefined;
63
+ return row.windows.map(window => ({ ...window }));
64
+ }
@@ -35,6 +35,17 @@ export function providerDecisionWindows(quota: ProviderQuota): DecisionQuotaWind
35
35
  }
36
36
  return windows;
37
37
  }
38
+ /** Antigravity family buckets: Gem/Cla are 5h counters, "(Weekly)" their weekly pair; per-model rows and other groups are display-only. */
39
+ export function antigravityDecisionWindows(quota: ProviderQuota): DecisionQuotaWindow[] {
40
+ const windows: DecisionQuotaWindow[] = [];
41
+ for (const row of Array.isArray(quota.customWindows) ? quota.customWindows : []) {
42
+ const match = typeof row?.label === "string" && row.modelId === undefined ? /^(Gem|Cla)( \(Weekly\))?$/.exec(row.label) : null;
43
+ if (!match || !Number.isFinite(row.percent)) continue;
44
+ windows.push({ window: match[2] ? "weekly" : "5h", family: match[1] === "Gem" ? "gem" : "cla", percent: row.percent,
45
+ observedAt: quota.updatedAt, ...(row.resetAt !== undefined ? { resetAt: row.resetAt } : {}) });
46
+ }
47
+ return windows;
48
+ }
38
49
  /** Publish decision windows for one Anthropic pool account, bound to its credential generation; a null quota publishes no windows. */
39
50
  export function publishAnthropicDecisionQuota(id: string, generation: string, quota: ProviderQuota | null, partial = false): void {
40
51
  if (!decisionQuotaSinkActive()) return;