@oh-my-pi/pi-ai 16.2.5 → 16.2.7

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 (31) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/types/auth-storage.d.ts +11 -10
  3. package/dist/types/providers/__tests__/kimi-code-thinking.test.d.ts +1 -0
  4. package/dist/types/providers/google-auth.d.ts +8 -0
  5. package/dist/types/providers/google-interactions.d.ts +65 -0
  6. package/dist/types/providers/google-shared.d.ts +20 -5
  7. package/dist/types/providers/google-types.d.ts +5 -0
  8. package/dist/types/providers/openai-codex/request-transformer.d.ts +1 -1
  9. package/dist/types/providers/openai-codex-responses.d.ts +1 -1
  10. package/dist/types/providers/openai-shared.d.ts +3 -3
  11. package/dist/types/types.d.ts +80 -30
  12. package/dist/types/utils/leaked-thinking-stream.d.ts +29 -0
  13. package/package.json +4 -4
  14. package/src/auth-storage.ts +56 -52
  15. package/src/providers/__tests__/kimi-code-thinking.test.ts +112 -0
  16. package/src/providers/anthropic.ts +11 -10
  17. package/src/providers/google-auth.ts +25 -0
  18. package/src/providers/google-gemini-cli.ts +90 -41
  19. package/src/providers/google-interactions.ts +753 -0
  20. package/src/providers/google-shared.ts +74 -13
  21. package/src/providers/google-types.ts +5 -1
  22. package/src/providers/google-vertex.ts +117 -26
  23. package/src/providers/google.ts +61 -19
  24. package/src/providers/openai-chat-server.ts +2 -2
  25. package/src/providers/openai-codex/request-transformer.ts +17 -4
  26. package/src/providers/openai-codex-responses.ts +23 -5
  27. package/src/providers/openai-shared.ts +5 -11
  28. package/src/stream.ts +34 -12
  29. package/src/types.ts +164 -51
  30. package/src/usage/google-antigravity.ts +59 -5
  31. package/src/utils/leaked-thinking-stream.ts +260 -0
package/src/stream.ts CHANGED
@@ -71,6 +71,7 @@ import type {
71
71
  ToolChoice,
72
72
  } from "./types";
73
73
  import { AssistantMessageEventStream } from "./utils/event-stream";
74
+ import { wrapLeakedThinkingStream } from "./utils/leaked-thinking-stream";
74
75
  import { wrapFetchForProxy } from "./utils/proxy";
75
76
  import { withRequestDebugFetch } from "./utils/request-debug";
76
77
  import { withGeminiThinkingLoopGuard } from "./utils/thinking-loop";
@@ -367,14 +368,17 @@ async function tryAcquireProviderInFlightLease(
367
368
  }
368
369
  }
369
370
 
370
- async function signalProviderInFlightWaiters(provider: string): Promise<void> {
371
+ async function signalProviderInFlightWaitersInDir(dir: string): Promise<void> {
371
372
  try {
372
- const dir = providerInFlightDir(provider);
373
373
  await fs.mkdir(dir, { recursive: true });
374
- await Bun.write(providerInFlightSignalPath(provider), String(Date.now()));
374
+ await Bun.write(path.join(dir, ".wakeup"), String(Date.now()));
375
375
  } catch {}
376
376
  }
377
377
 
378
+ async function signalProviderInFlightWaiters(provider: string): Promise<void> {
379
+ await signalProviderInFlightWaitersInDir(providerInFlightDir(provider));
380
+ }
381
+
378
382
  function waitForProviderInFlightSignal(provider: string, signal?: AbortSignal): Promise<void> {
379
383
  if (signal?.aborted)
380
384
  return Promise.reject(signal.reason ?? new AIError.AbortError("Provider request aborted before dispatch"));
@@ -434,11 +438,15 @@ async function removeProviderInFlightLeaseDir(leasePath: string): Promise<void>
434
438
  }
435
439
  }
436
440
 
437
- async function releaseProviderInFlightLease(provider: string, lease: ProviderInFlightLease): Promise<void> {
441
+ // Signal into the lease's OWN provider directory (derived from `lease.path`)
442
+ // rather than recomputing it from the current root. A release that lands after
443
+ // the in-flight root has been repointed (only the test seam does that) must not
444
+ // write `.wakeup` into an unrelated provider directory.
445
+ async function releaseProviderInFlightLease(lease: ProviderInFlightLease): Promise<void> {
438
446
  clearInterval(lease.heartbeat);
439
447
  await lease.flushHeartbeat();
440
448
  await removeProviderInFlightLeaseDir(lease.path);
441
- await signalProviderInFlightWaiters(provider);
449
+ await signalProviderInFlightWaitersInDir(path.dirname(lease.path));
442
450
  }
443
451
 
444
452
  async function acquireProviderInFlightSlot(
@@ -451,7 +459,7 @@ async function acquireProviderInFlightSlot(
451
459
  while (true) {
452
460
  if (signal?.aborted) throw signal.reason ?? new AIError.AbortError("Provider request aborted before dispatch");
453
461
  const lease = await tryAcquireProviderInFlightLease(provider, limit, signal);
454
- if (lease) return () => releaseProviderInFlightLease(provider, lease);
462
+ if (lease) return () => releaseProviderInFlightLease(lease);
455
463
  if (!loggedWait) {
456
464
  loggedWait = true;
457
465
  logger.debug("Provider in-flight limit blocked request", { provider, limit });
@@ -492,8 +500,11 @@ function withProviderInFlightLimit<TOptions extends Pick<StreamOptions, "signal"
492
500
  options: TOptions | undefined,
493
501
  dispatch: () => AssistantMessageEventStream,
494
502
  ): AssistantMessageEventStream {
503
+ // Leaked-thinking healing folds in here — the one shared provider-dispatch
504
+ // chokepoint — so the loop guard (which wraps this) sees healed events and all
505
+ // six provider exits are covered by one wrap. Healing is idempotent.
495
506
  const limit = resolveProviderInFlightLimit(model.provider, options);
496
- if (limit === undefined) return dispatch();
507
+ if (limit === undefined) return wrapLeakedThinkingStream(dispatch());
497
508
 
498
509
  const outer = new AssistantMessageEventStream();
499
510
  void (async () => {
@@ -513,7 +524,7 @@ function withProviderInFlightLimit<TOptions extends Pick<StreamOptions, "signal"
513
524
  if (options?.signal?.aborted) {
514
525
  throw options.signal.reason ?? new AIError.AbortError("Provider request aborted before dispatch");
515
526
  }
516
- const inner = dispatch();
527
+ const inner = wrapLeakedThinkingStream(dispatch());
517
528
  try {
518
529
  for await (const event of inner) {
519
530
  outer.push(event);
@@ -1098,10 +1109,13 @@ export function streamSimple<TApi extends Api>(
1098
1109
 
1099
1110
  // GitLab Duo Workflow - IDE workflow protocol + WebSocket action bridge
1100
1111
  if (model.api === "gitlab-duo-agent") {
1101
- return streamGitLabDuoWorkflow(model as Model<"gitlab-duo-agent">, context, {
1102
- ...requestOptions,
1103
- apiKey,
1104
- });
1112
+ // Does not route through withProviderInFlightLimit, so heal explicitly.
1113
+ return wrapLeakedThinkingStream(
1114
+ streamGitLabDuoWorkflow(model as Model<"gitlab-duo-agent">, context, {
1115
+ ...requestOptions,
1116
+ apiKey,
1117
+ }),
1118
+ );
1105
1119
  }
1106
1120
 
1107
1121
  // Kimi Code - route to dedicated handler that wraps OpenAI or Anthropic API
@@ -1348,6 +1362,9 @@ function mapOptionsForApi<TApi extends Api>(
1348
1362
  streamFirstEventTimeoutMs: options?.streamFirstEventTimeoutMs,
1349
1363
  streamIdleTimeoutMs: options?.streamIdleTimeoutMs,
1350
1364
  providerSessionState: options?.providerSessionState,
1365
+ useInteractionsApi: options?.useInteractionsApi,
1366
+ storeInteraction: options?.storeInteraction,
1367
+ previousInteractionId: options?.previousInteractionId,
1351
1368
  maxInFlightRequests: options?.maxInFlightRequests,
1352
1369
  onPayload: options?.onPayload,
1353
1370
  onResponse: options?.onResponse,
@@ -1558,6 +1575,7 @@ function mapOptionsForApi<TApi extends Api>(
1558
1575
  if (!reasoning || !model.reasoning) {
1559
1576
  return castApi<"google-generative-ai">({
1560
1577
  ...base,
1578
+ serviceTier: options?.serviceTier,
1561
1579
  thinking: { enabled: false },
1562
1580
  toolChoice: mapGoogleToolChoice(options?.toolChoice),
1563
1581
  });
@@ -1571,6 +1589,7 @@ function mapOptionsForApi<TApi extends Api>(
1571
1589
  if (googleModel.thinking?.mode === "google-level") {
1572
1590
  return castApi<"google-generative-ai">({
1573
1591
  ...base,
1592
+ serviceTier: options?.serviceTier,
1574
1593
  thinking: {
1575
1594
  enabled: true,
1576
1595
  level: mapEffortToGoogleThinkingLevel(effort),
@@ -1654,6 +1673,7 @@ function mapOptionsForApi<TApi extends Api>(
1654
1673
  if (!reasoning || !model.reasoning) {
1655
1674
  return castApi<"google-vertex">({
1656
1675
  ...base,
1676
+ serviceTier: options?.serviceTier,
1657
1677
  thinking: { enabled: false },
1658
1678
  toolChoice: mapGoogleToolChoice(options?.toolChoice),
1659
1679
  });
@@ -1666,6 +1686,7 @@ function mapOptionsForApi<TApi extends Api>(
1666
1686
  if (geminiModel.thinking?.mode === "google-level") {
1667
1687
  return castApi<"google-vertex">({
1668
1688
  ...base,
1689
+ serviceTier: options?.serviceTier,
1669
1690
  thinking: {
1670
1691
  enabled: true,
1671
1692
  level: mapEffortToGoogleThinkingLevel(effort),
@@ -1676,6 +1697,7 @@ function mapOptionsForApi<TApi extends Api>(
1676
1697
 
1677
1698
  return castApi<"google-vertex">({
1678
1699
  ...base,
1700
+ serviceTier: options?.serviceTier,
1679
1701
  thinking: {
1680
1702
  enabled: true,
1681
1703
  budgetTokens: getGoogleBudget(geminiModel, effort, options?.thinkingBudgets),
package/src/types.ts CHANGED
@@ -105,84 +105,181 @@ export type ToolChoice =
105
105
  export type CacheRetention = "none" | "short" | "long";
106
106
 
107
107
  /**
108
- * Service tier hint for processing priority / cost control.
108
+ * Service tier hint for processing priority / cost control. These are the
109
+ * values providers consume on the wire:
109
110
  *
110
- * The unscoped values (`"auto"`, `"default"`, `"flex"`, `"scale"`,
111
- * `"priority"`) are passed through to providers that understand them
112
- * (OpenAI's `service_tier` field directly; Anthropic translates
113
- * `"priority"` into `speed: "fast"` on supported Opus models).
111
+ * - OpenAI / OpenAI-Codex: sent verbatim as the `service_tier` field
112
+ * (`flex`/`scale`/`priority`).
113
+ * - Google (Gemini API + Vertex AI): sent as the top-level `serviceTier`
114
+ * field (`flex`/`priority`).
115
+ * - OpenRouter: passed through as `service_tier`; OpenRouter realizes it for
116
+ * the OpenAI- and Google-family upstreams it supports and ignores it
117
+ * otherwise.
118
+ * - Direct Anthropic: `"priority"` is translated into `speed: "fast"` plus the
119
+ * fast-mode beta on supported Opus models. Other tiers are ignored.
114
120
  *
115
- * The scoped values target a specific provider family and behave as the
116
- * unscoped value on the matching provider, or `undefined` everywhere else.
117
- * They let users opt into priority on one family without paying premium
118
- * costs on the other when switching models mid-session.
119
- *
120
- * - `"openai-only"` → `"priority"` on `openai` and `openai-codex`; ignored elsewhere.
121
- * - `"claude-only"` → `"priority"` on direct `anthropic` (not Bedrock/Vertex Claude).
121
+ * Per-family scoping is expressed by {@link ServiceTierByFamily}, not by
122
+ * scoped sentinel values see {@link serviceTierFamily}.
122
123
  */
123
- export type ServiceTier = "auto" | "default" | "flex" | "scale" | "priority" | "openai-only" | "claude-only";
124
+ export type ServiceTier = "auto" | "default" | "flex" | "scale" | "priority";
124
125
 
125
- /** Resolved tier — one of the values that providers actually consume on the wire. */
126
- export type ResolvedServiceTier = Exclude<ServiceTier, "openai-only" | "claude-only">;
126
+ /** Provider families that expose an independent service-tier knob. */
127
+ export type ServiceTierFamily = "openai" | "anthropic" | "google";
127
128
 
128
129
  /**
129
- * Resolves a possibly scoped `ServiceTier` to the effective tier for the
130
- * given provider. Scoped values match their target family and otherwise
131
- * collapse to `undefined`; unscoped values pass through unchanged.
130
+ * Per-family service-tier selection. A request consults only the entry for the
131
+ * family its model belongs to (see {@link resolveModelServiceTier}), so a user
132
+ * can opt one family into priority without affecting the others when switching
133
+ * models mid-session.
132
134
  */
133
- export function resolveServiceTier(
134
- serviceTier: ServiceTier | null | undefined,
135
- provider: Provider | undefined,
136
- ): ResolvedServiceTier | undefined {
137
- if (!serviceTier) return undefined;
138
- switch (serviceTier) {
139
- case "openai-only":
140
- return provider === "openai" || provider === "openai-codex" ? "priority" : undefined;
141
- case "claude-only":
142
- return provider === "anthropic" ? "priority" : undefined;
143
- default:
144
- return serviceTier;
135
+ export type ServiceTierByFamily = Partial<Record<ServiceTierFamily, ServiceTier>>;
136
+
137
+ /**
138
+ * Classify a model into the service-tier family whose knob governs it, or
139
+ * `undefined` when the model exposes no serving-priority control.
140
+ *
141
+ * OpenRouter models are classified by id namespace (`anthropic/`, `google/`,
142
+ * `openai/`); Claude on Bedrock/Vertex (api `anthropic-messages`) is the
143
+ * anthropic family even though its provider is `amazon-bedrock`/`google-vertex`.
144
+ */
145
+ export function serviceTierFamily(model: Pick<Model, "provider" | "api" | "id">): ServiceTierFamily | undefined {
146
+ const provider = model.provider;
147
+ if (provider === "openrouter") {
148
+ const id = model.id.toLowerCase();
149
+ if (id.startsWith("anthropic/")) return "anthropic";
150
+ if (id.startsWith("google/")) return "google";
151
+ if (id.startsWith("openai/")) return "openai";
152
+ return undefined;
145
153
  }
154
+ if (provider === "openai" || provider === "openai-codex") return "openai";
155
+ if (model.api === "anthropic-messages") return "anthropic";
156
+ if (provider === "google" || provider === "google-vertex") return "google";
157
+ return undefined;
146
158
  }
147
159
 
148
160
  /**
149
- * True when the (possibly scoped) tier should be sent on the wire as the
150
- * `service_tier` request field for the given provider. OpenAI / OpenAI-Codex
151
- * accept `flex`/`scale`/`priority`; Fireworks Serverless realizes only its
152
- * Priority serving path (`service_tier: "priority"`) on the OpenAI-compatible
153
- * chat-completions endpoint. Unsupported tiers (`"auto"`, `"default"`), other
154
- * providers, and scope mismatches all return false.
161
+ * Reduce a per-family tier map to the single wire tier for `model` the entry
162
+ * for the model's family, or `undefined` when the model has no family.
163
+ */
164
+ export function resolveModelServiceTier(
165
+ tiers: ServiceTierByFamily | null | undefined,
166
+ model: Pick<Model, "provider" | "api" | "id">,
167
+ ): ServiceTier | undefined {
168
+ if (!tiers) return undefined;
169
+ const family = serviceTierFamily(model);
170
+ return family ? tiers[family] : undefined;
171
+ }
172
+
173
+ /**
174
+ * True when the tier should be sent on the wire as the provider's service-tier
175
+ * request field. OpenAI / OpenAI-Codex accept `flex`/`scale`/`priority`; Google
176
+ * (Gemini API + Vertex) and OpenRouter accept `flex`/`priority`; Fireworks
177
+ * Serverless realizes only its Priority serving path. Anthropic is absent — it
178
+ * realizes `priority` via `speed: "fast"`, not a service-tier field.
155
179
  */
156
180
  export function shouldSendServiceTier(
157
181
  serviceTier: ServiceTier | null | undefined,
158
182
  provider: Provider | undefined,
159
183
  ): boolean {
160
- const resolved = resolveServiceTier(serviceTier, provider);
161
- if (provider === "openai" || provider === "openai-codex") {
162
- return resolved === "flex" || resolved === "scale" || resolved === "priority";
184
+ if (!serviceTier) return false;
185
+ if (provider === "openai" || provider === "openai-codex" || provider === "openrouter") {
186
+ return serviceTier === "flex" || serviceTier === "scale" || serviceTier === "priority";
187
+ }
188
+ if (provider === "google") {
189
+ return serviceTier === "flex" || serviceTier === "priority";
163
190
  }
164
- if (provider === "fireworks") {
165
- return resolved === "priority";
191
+ // Vertex realizes only priority (via header); flex has no documented control.
192
+ if (provider === "google-vertex" || provider === "fireworks") {
193
+ return serviceTier === "priority";
166
194
  }
167
195
  return false;
168
196
  }
169
197
 
170
198
  /**
171
- * Premium-request weight contributed by sending priority to a provider
172
- * that supports it. Mirrors GitHub Copilot's `premiumRequests` accounting
173
- * so the "premium requests" stat aggregates priority traffic across the
174
- * OpenAI family and Anthropic fast-mode realizations.
199
+ * True when `priority` will actually be realized on the wire for `model`.
200
+ * Direct Anthropic realizes fast mode; OpenAI/Google/Fireworks emit the
201
+ * service-tier field; OpenRouter realizes it only for its OpenAI- and
202
+ * Google-family upstreams. Bedrock/Vertex Claude and OpenRouter Anthropic
203
+ * models do not realize priority and return `false`.
204
+ */
205
+ export function realizesPriorityServiceTier(
206
+ serviceTier: ServiceTier | null | undefined,
207
+ model: Pick<Model, "provider" | "api" | "id">,
208
+ ): boolean {
209
+ if (serviceTier !== "priority") return false;
210
+ if (model.provider === "anthropic") return true;
211
+ if (model.provider === "openrouter") {
212
+ const family = serviceTierFamily(model);
213
+ return family === "openai" || family === "google";
214
+ }
215
+ if (model.api === "anthropic-messages") return false;
216
+ return shouldSendServiceTier(serviceTier, model.provider);
217
+ }
218
+
219
+ /**
220
+ * Premium-request weight contributed by a priority request to a provider that
221
+ * realizes it and bills extra. Mirrors GitHub Copilot's `premiumRequests`
222
+ * accounting so the "premium requests" stat aggregates priority traffic across
223
+ * the OpenAI family, direct Anthropic fast mode, and Google priority.
175
224
  *
176
- * Returns 1 per resolved priority request, 0 otherwise.
225
+ * Returns 1 only when priority is actually realized on the wire for `model`
226
+ * (see {@link realizesPriorityServiceTier}) and the provider bills it as a
227
+ * premium request. OpenRouter is excluded — it bills per its own pricing, not
228
+ * Copilot-premium semantics — as are Bedrock/Vertex Claude, where priority is
229
+ * silently dropped.
177
230
  */
178
231
  export function getPriorityPremiumRequests(
179
232
  serviceTier: ServiceTier | null | undefined,
180
- provider: Provider | undefined,
233
+ model: Pick<Model, "provider" | "api" | "id">,
181
234
  ): number {
182
- if (resolveServiceTier(serviceTier, provider) !== "priority") return 0;
183
- // Only providers that realize `priority` on the wire bill the user.
184
- // Everywhere else, the field is silently dropped and nothing is charged.
185
- return provider === "openai" || provider === "openai-codex" || provider === "anthropic" ? 1 : 0;
235
+ if (!realizesPriorityServiceTier(serviceTier, model)) return 0;
236
+ const provider = model.provider;
237
+ return provider === "openai" ||
238
+ provider === "openai-codex" ||
239
+ provider === "anthropic" ||
240
+ provider === "google" ||
241
+ provider === "google-vertex"
242
+ ? 1
243
+ : 0;
244
+ }
245
+
246
+ /**
247
+ * Coerce a persisted service-tier value to a {@link ServiceTierByFamily}. Newer
248
+ * sessions store the family map directly; legacy sessions stored a single
249
+ * scalar — `"priority"` applied everywhere, `"openai-only"`/`"claude-only"`
250
+ * scoped to one family, and the remaining values were OpenAI-only semantics.
251
+ */
252
+ export function coerceServiceTierByFamily(value: unknown): ServiceTierByFamily | undefined {
253
+ if (value === null || value === undefined) return undefined;
254
+ if (typeof value === "object") {
255
+ const src = value as Record<string, unknown>;
256
+ const out: ServiceTierByFamily = {};
257
+ for (const family of ["openai", "anthropic", "google"] as const) {
258
+ const tier = src[family];
259
+ if (tier === "auto" || tier === "default" || tier === "flex" || tier === "scale" || tier === "priority") {
260
+ out[family] = tier;
261
+ }
262
+ }
263
+ return Object.keys(out).length > 0 ? out : undefined;
264
+ }
265
+ switch (value) {
266
+ case "priority":
267
+ return { openai: "priority", anthropic: "priority", google: "priority" };
268
+ case "openai-only":
269
+ return { openai: "priority" };
270
+ case "claude-only":
271
+ return { anthropic: "priority" };
272
+ case "auto":
273
+ return { openai: "auto" };
274
+ case "default":
275
+ return { openai: "default" };
276
+ case "flex":
277
+ return { openai: "flex" };
278
+ case "scale":
279
+ return { openai: "scale" };
280
+ default:
281
+ return undefined;
282
+ }
186
283
  }
187
284
 
188
285
  export interface ProviderSessionState {
@@ -277,6 +374,22 @@ export interface StreamOptions {
277
374
  * Providers can use this to persist transport/session state between turns.
278
375
  */
279
376
  providerSessionState?: Map<string, ProviderSessionState>;
377
+ /**
378
+ * Force Gemini model-mode Interactions API transport for providers that support it.
379
+ * When unset, those providers may still use Interactions to continue known
380
+ * server-side conversation lineage via `previousInteractionId` or stored state.
381
+ */
382
+ useInteractionsApi?: boolean;
383
+ /**
384
+ * Whether supported Interactions transports should store server-side conversation
385
+ * state and return response ids for follow-up turns. Defaults to true.
386
+ */
387
+ storeInteraction?: boolean;
388
+ /**
389
+ * Explicit Interactions response id to continue. Mutually exclusive with
390
+ * `storeInteraction: false` because the follow-up itself must be storable.
391
+ */
392
+ previousInteractionId?: string;
280
393
  /**
281
394
  * Optional per-provider concurrent request cap for LLM stream calls. Keys are
282
395
  * provider ids (`model.provider`); positive numeric values cap in-flight
@@ -68,6 +68,57 @@ function classifyWindow(id: string | undefined, label: string | undefined): Anti
68
68
  return undefined;
69
69
  }
70
70
 
71
+ function parseResetTime(info: AntigravityQuotaInfo): number | undefined {
72
+ const resetAt = info.resetTime ? Date.parse(info.resetTime) : undefined;
73
+ return resetAt !== undefined && Number.isFinite(resetAt) ? resetAt : undefined;
74
+ }
75
+
76
+ function inferWindowFromReset(resetAt: number | undefined, nowMs: number): AntigravityWindowDescriptor {
77
+ if (resetAt !== undefined && resetAt - nowMs > ONE_DAY_MS) {
78
+ return { id: "weekly", label: "Weekly", durationMs: ONE_WEEK_MS };
79
+ }
80
+ return { id: "daily", label: "Daily", durationMs: ONE_DAY_MS };
81
+ }
82
+
83
+ function quotaInferenceKey(info: AntigravityQuotaInfo): string {
84
+ return [info.modelProvider ?? "", info.apiProvider ?? "", info.tier ?? ""].join("|");
85
+ }
86
+
87
+ function inferWindowDescriptors(
88
+ quotaInfos: AntigravityQuotaInfo[],
89
+ nowMs: number,
90
+ ): WeakMap<AntigravityQuotaInfo, AntigravityWindowDescriptor> {
91
+ const descriptors = new WeakMap<AntigravityQuotaInfo, AntigravityWindowDescriptor>();
92
+ const groups = new Map<string, { info: AntigravityQuotaInfo; resetAt: number | undefined }[]>();
93
+
94
+ for (const info of quotaInfos) {
95
+ const explicitDescriptor = classifyWindow(info.windowId, info.windowLabel);
96
+ if (explicitDescriptor) {
97
+ descriptors.set(info, explicitDescriptor);
98
+ continue;
99
+ }
100
+ const group = groups.get(quotaInferenceKey(info)) ?? [];
101
+ group.push({ info, resetAt: parseResetTime(info) });
102
+ groups.set(quotaInferenceKey(info), group);
103
+ }
104
+
105
+ for (const group of groups.values()) {
106
+ const resetTimes = [...new Set(group.map(entry => entry.resetAt).filter(resetAt => resetAt !== undefined))].sort(
107
+ (a, b) => a - b,
108
+ );
109
+ const latestReset = resetTimes.length > 1 ? resetTimes.at(-1) : undefined;
110
+ for (const entry of group) {
111
+ const descriptor =
112
+ latestReset !== undefined && entry.resetAt === latestReset
113
+ ? { id: "weekly", label: "Weekly", durationMs: ONE_WEEK_MS }
114
+ : inferWindowFromReset(entry.resetAt, nowMs);
115
+ descriptors.set(entry.info, descriptor);
116
+ }
117
+ }
118
+
119
+ return descriptors;
120
+ }
121
+
71
122
  function withWindowDescriptor(
72
123
  info: AntigravityQuotaInfo,
73
124
  descriptor: AntigravityWindowDescriptor | undefined,
@@ -94,10 +145,12 @@ function getUsageStatus(remainingFraction: number | undefined): UsageStatus | un
94
145
  return "ok";
95
146
  }
96
147
 
97
- function parseWindow(info: AntigravityQuotaInfo): UsageWindow | undefined {
98
- const descriptor = classifyWindow(info.windowId, info.windowLabel);
99
- const resetAt = info.resetTime ? Date.parse(info.resetTime) : undefined;
100
- const hasResetAt = resetAt !== undefined && Number.isFinite(resetAt);
148
+ function parseWindow(
149
+ info: AntigravityQuotaInfo,
150
+ descriptor: AntigravityWindowDescriptor | undefined,
151
+ ): UsageWindow | undefined {
152
+ const resetAt = parseResetTime(info);
153
+ const hasResetAt = resetAt !== undefined;
101
154
  if (!descriptor && !hasResetAt) return undefined;
102
155
  return {
103
156
  id: descriptor?.id ?? info.windowId ?? "default",
@@ -276,9 +329,10 @@ async function fetchAntigravityUsage(params: UsageFetchParams, ctx: UsageFetchCo
276
329
 
277
330
  for (const [_modelId, modelInfo] of Object.entries(data.models ?? {})) {
278
331
  const quotaInfos = normalizeQuotaInfos(modelInfo);
332
+ const inferredDescriptors = inferWindowDescriptors(quotaInfos, nowMs);
279
333
  for (const quotaInfo of quotaInfos) {
280
334
  const amount = buildAmount(quotaInfo);
281
- const window = parseWindow(quotaInfo);
335
+ const window = parseWindow(quotaInfo, inferredDescriptors.get(quotaInfo));
282
336
  if (window?.resetsAt) {
283
337
  earliestReset = earliestReset ? Math.min(earliestReset, window.resetsAt) : window.resetsAt;
284
338
  }