@oh-my-pi/pi-ai 17.2.11 → 17.2.13

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 (79) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/types/auth-retry.d.ts +3 -3
  3. package/dist/types/auth-storage.d.ts +2 -0
  4. package/dist/types/error/auth-classify.d.ts +5 -5
  5. package/dist/types/error/aws.d.ts +5 -1
  6. package/dist/types/error/flags.d.ts +4 -0
  7. package/dist/types/providers/aws-credentials.d.ts +4 -3
  8. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  9. package/dist/types/providers/cursor-pi-args.d.ts +14 -0
  10. package/dist/types/providers/openai-shared.d.ts +9 -1
  11. package/dist/types/registry/api-key-login.d.ts +6 -4
  12. package/dist/types/registry/cloudflare-ai-gateway.d.ts +2 -2
  13. package/dist/types/registry/kagi.d.ts +2 -2
  14. package/dist/types/registry/litellm.d.ts +2 -2
  15. package/dist/types/registry/llama-cpp.d.ts +2 -2
  16. package/dist/types/registry/lm-studio.d.ts +2 -2
  17. package/dist/types/registry/parallel.d.ts +2 -2
  18. package/dist/types/registry/tavily.d.ts +1 -1
  19. package/dist/types/registry/vercel-ai-gateway.d.ts +2 -2
  20. package/dist/types/registry/vllm.d.ts +2 -2
  21. package/dist/types/types.d.ts +7 -0
  22. package/dist/types/usage/cursor.d.ts +11 -0
  23. package/dist/types/usage/openai-codex-reset.d.ts +0 -20
  24. package/dist/types/usage/shared.d.ts +13 -1
  25. package/dist/types/utils/block-symbols.d.ts +12 -0
  26. package/package.json +5 -5
  27. package/src/auth-retry.ts +5 -4
  28. package/src/auth-storage.ts +77 -33
  29. package/src/dialect/owned-stream.ts +3 -0
  30. package/src/error/auth-classify.ts +7 -6
  31. package/src/error/aws.ts +5 -1
  32. package/src/error/flags.ts +14 -0
  33. package/src/providers/amazon-bedrock.ts +38 -0
  34. package/src/providers/aws-credentials.ts +222 -29
  35. package/src/providers/cursor/exec-modern.ts +1 -0
  36. package/src/providers/cursor-pi-args.ts +22 -0
  37. package/src/providers/cursor.ts +81 -1
  38. package/src/providers/google-gemini-cli.ts +49 -15
  39. package/src/providers/google-shared.ts +7 -1
  40. package/src/providers/openai-codex/request-transformer.ts +38 -17
  41. package/src/providers/openai-codex-responses.ts +2 -3
  42. package/src/providers/openai-responses.ts +4 -0
  43. package/src/providers/openai-shared.ts +55 -1
  44. package/src/providers/pi-native-server.ts +1 -0
  45. package/src/providers/register-builtins.ts +18 -14
  46. package/src/registry/api-key-login.ts +26 -12
  47. package/src/registry/aws.ts +13 -6
  48. package/src/registry/cloudflare-ai-gateway.ts +10 -29
  49. package/src/registry/kagi.ts +11 -29
  50. package/src/registry/litellm.ts +11 -29
  51. package/src/registry/llama-cpp.ts +11 -21
  52. package/src/registry/lm-studio.ts +9 -20
  53. package/src/registry/oauth/callback-server.ts +93 -5
  54. package/src/registry/parallel.ts +10 -28
  55. package/src/registry/tavily.ts +9 -27
  56. package/src/registry/vercel-ai-gateway.ts +10 -28
  57. package/src/registry/vllm.ts +11 -21
  58. package/src/stream.ts +10 -7
  59. package/src/types.ts +7 -0
  60. package/src/usage/alibaba-token-plan.ts +8 -15
  61. package/src/usage/claude.ts +12 -19
  62. package/src/usage/cursor.ts +179 -59
  63. package/src/usage/gemini.ts +3 -2
  64. package/src/usage/github-copilot.ts +3 -2
  65. package/src/usage/google-antigravity.ts +10 -17
  66. package/src/usage/kimi.ts +35 -17
  67. package/src/usage/minimax-code.ts +7 -27
  68. package/src/usage/openai-codex-reset.ts +3 -2
  69. package/src/usage/openai-codex.ts +5 -5
  70. package/src/usage/opencode-go.ts +1 -2
  71. package/src/usage/shared.ts +33 -10
  72. package/src/usage/synthetic.ts +4 -11
  73. package/src/usage/umans.ts +2 -2
  74. package/src/usage/xai-oauth.ts +13 -26
  75. package/src/usage/zai.ts +4 -6
  76. package/src/utils/aws-profile.ts +39 -1
  77. package/src/utils/block-symbols.ts +18 -0
  78. package/src/utils/leaked-thinking-stream.ts +3 -0
  79. package/src/utils/openrouter-headers.ts +3 -3
@@ -17,10 +17,9 @@ import {
17
17
  type UsageWindow,
18
18
  } from "../usage";
19
19
  import { isRecord } from "../utils";
20
+ import { HOUR_MS, parseIsoTimestamp, WEEK_MS } from "./shared";
20
21
 
21
22
  const DEFAULT_ENDPOINT = "https://api.anthropic.com/api/oauth";
22
- const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
23
- const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
24
23
  const MAX_ATTEMPTS = 3;
25
24
  const BASE_RETRY_DELAY_MS = 500;
26
25
 
@@ -123,16 +122,10 @@ interface ParsedApiLimitEntry {
123
122
  displayName?: string;
124
123
  }
125
124
 
126
- function parseIsoTime(value: string | undefined): number | undefined {
127
- if (!value) return undefined;
128
- const parsed = Date.parse(value);
129
- return Number.isFinite(parsed) ? parsed : undefined;
130
- }
131
-
132
125
  function parseBucket(bucket: unknown): ParsedUsageBucket | undefined {
133
126
  if (!isRecord(bucket)) return undefined;
134
127
  const utilization = toNumber(bucket.utilization);
135
- const resetsAt = parseIsoTime(typeof bucket.resets_at === "string" ? bucket.resets_at : undefined);
128
+ const resetsAt = parseIsoTimestamp(typeof bucket.resets_at === "string" ? bucket.resets_at : undefined);
136
129
  if (utilization === undefined && resetsAt === undefined) {
137
130
  return undefined;
138
131
  }
@@ -168,7 +161,7 @@ function parseApiLimitEntries(raw: unknown): ParsedApiLimitEntry[] {
168
161
  const entry = rawEntry as ClaudeApiLimitEntry;
169
162
  if (typeof entry.kind !== "string") continue;
170
163
  const utilization = toNumber(entry.percent);
171
- const resetsAt = parseIsoTime(typeof entry.resets_at === "string" ? entry.resets_at : undefined);
164
+ const resetsAt = parseIsoTimestamp(typeof entry.resets_at === "string" ? entry.resets_at : undefined);
172
165
  if (utilization === undefined && resetsAt === undefined) continue;
173
166
  const displayName = getApiLimitDisplayName(entry.scope);
174
167
  entries.push({
@@ -554,7 +547,7 @@ function buildScopedWeeklyUsageLimits(entries: readonly ParsedApiLimitEntry[]):
554
547
  label: `Claude 7 Day (${entry.displayName})`,
555
548
  windowId: "7d",
556
549
  windowLabel: "7 Day",
557
- durationMs: SEVEN_DAYS_MS,
550
+ durationMs: WEEK_MS,
558
551
  bucket: entry.bucket,
559
552
  provider: "anthropic",
560
553
  tier: slug,
@@ -574,7 +567,7 @@ export function parseClaudeRateLimitHeaders(headers: Record<string, string>, now
574
567
  label: "Claude 5 Hour",
575
568
  windowId: "5h",
576
569
  windowLabel: "5 Hour",
577
- durationMs: FIVE_HOURS_MS,
570
+ durationMs: 5 * HOUR_MS,
578
571
  bucket: fiveHour,
579
572
  provider: "anthropic",
580
573
  shared: true,
@@ -584,7 +577,7 @@ export function parseClaudeRateLimitHeaders(headers: Record<string, string>, now
584
577
  label: "Claude 7 Day",
585
578
  windowId: "7d",
586
579
  windowLabel: "7 Day",
587
- durationMs: SEVEN_DAYS_MS,
580
+ durationMs: WEEK_MS,
588
581
  bucket: sevenDay,
589
582
  provider: "anthropic",
590
583
  shared: true,
@@ -594,7 +587,7 @@ export function parseClaudeRateLimitHeaders(headers: Record<string, string>, now
594
587
  label: "Claude 7 Day (Fable)",
595
588
  windowId: "7d",
596
589
  windowLabel: "7 Day",
597
- durationMs: SEVEN_DAYS_MS,
590
+ durationMs: WEEK_MS,
598
591
  bucket: modelScopedSevenDay,
599
592
  provider: "anthropic",
600
593
  tier: "fable",
@@ -638,7 +631,7 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
638
631
  label: "Claude 5 Hour",
639
632
  windowId: "5h",
640
633
  windowLabel: "5 Hour",
641
- durationMs: FIVE_HOURS_MS,
634
+ durationMs: 5 * HOUR_MS,
642
635
  bucket: fiveHour,
643
636
  provider: "anthropic",
644
637
  shared: true,
@@ -648,7 +641,7 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
648
641
  label: "Claude 7 Day",
649
642
  windowId: "7d",
650
643
  windowLabel: "7 Day",
651
- durationMs: SEVEN_DAYS_MS,
644
+ durationMs: WEEK_MS,
652
645
  bucket: sevenDay,
653
646
  provider: "anthropic",
654
647
  shared: true,
@@ -658,7 +651,7 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
658
651
  label: "Claude 7 Day (Opus)",
659
652
  windowId: "7d",
660
653
  windowLabel: "7 Day",
661
- durationMs: SEVEN_DAYS_MS,
654
+ durationMs: WEEK_MS,
662
655
  bucket: sevenDayOpus,
663
656
  provider: "anthropic",
664
657
  tier: "opus",
@@ -668,7 +661,7 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
668
661
  label: "Claude 7 Day (Sonnet)",
669
662
  windowId: "7d",
670
663
  windowLabel: "7 Day",
671
- durationMs: SEVEN_DAYS_MS,
664
+ durationMs: WEEK_MS,
672
665
  bucket: sevenDaySonnet,
673
666
  provider: "anthropic",
674
667
  tier: "sonnet",
@@ -774,7 +767,7 @@ function rankingUsedFraction(limit: UsageLimit): number {
774
767
 
775
768
  function rankingDrainRate(limit: UsageLimit, nowMs: number): number {
776
769
  const usedFraction = rankingUsedFraction(limit);
777
- const durationMs = limit.window?.durationMs ?? SEVEN_DAYS_MS;
770
+ const durationMs = limit.window?.durationMs ?? WEEK_MS;
778
771
  if (!Number.isFinite(durationMs) || durationMs <= 0) return usedFraction;
779
772
  const resetAt = limit.window?.resetsAt;
780
773
  if (typeof resetAt !== "number" || !Number.isFinite(resetAt)) return usedFraction;
@@ -1,3 +1,4 @@
1
+ import { toNumber } from "@oh-my-pi/pi-catalog/utils";
1
2
  import { extractCursorAccessTokenUserId } from "../registry/oauth/cursor";
2
3
  import type {
3
4
  UsageAmount,
@@ -6,21 +7,15 @@ import type {
6
7
  UsageLimit,
7
8
  UsageProvider,
8
9
  UsageReport,
9
- UsageStatus,
10
10
  UsageWindow,
11
11
  } from "../usage";
12
- import { toNumber } from "./shared";
13
-
14
- function isRecord(value: unknown): value is Record<string, unknown> {
15
- return typeof value === "object" && value !== null && !Array.isArray(value);
16
- }
12
+ import { isRecord } from "../utils";
13
+ import { parseIsoTimestamp, usageStatus } from "./shared";
17
14
 
18
15
  function parseTimestamp(value: unknown): number | undefined {
19
16
  const numeric = toNumber(value);
20
17
  if (numeric !== undefined) return numeric < 1_000_000_000_000 ? numeric * 1000 : numeric;
21
- if (typeof value !== "string" || !value.trim()) return undefined;
22
- const parsed = Date.parse(value);
23
- return Number.isFinite(parsed) ? parsed : undefined;
18
+ return parseIsoTimestamp(value);
24
19
  }
25
20
 
26
21
  const DEFAULT_CURSOR_BASE_URL = "https://api2.cursor.sh";
@@ -78,51 +73,145 @@ function deriveResetsAt(payload: Record<string, unknown>): number | undefined {
78
73
  return undefined;
79
74
  }
80
75
 
81
- function resolveCursorStatus(usedFraction: number | undefined): UsageStatus {
82
- if (usedFraction === undefined) return "unknown";
83
- if (usedFraction >= 1) return "exhausted";
84
- if (usedFraction >= 0.9) return "warning";
85
- return "ok";
86
- }
87
-
88
- export function parseCursorIndividualUsage(payload: unknown, fetchedAt = Date.now()): UsageReport | null {
89
- if (!isRecord(payload) || !isRecord(payload.individualUsage) || !isRecord(payload.individualUsage.overall)) {
90
- return null;
91
- }
92
- const individual = payload.individualUsage.overall;
93
- if (individual.enabled === false) return null;
76
+ /**
77
+ * Parse a Cursor cents bucket (`used`/`limit`/`remaining` in USD cents).
78
+ * Returns null for disabled or non-positive / malformed buckets.
79
+ */
80
+ function parseCursorCentsBucket(bucket: Record<string, unknown>): UsageAmount | null {
81
+ if (bucket.enabled === false) return null;
94
82
 
95
- const reportedUsed = toNumber(individual.used);
96
- const reportedRemaining = toNumber(individual.remaining);
83
+ const reportedUsed = toNumber(bucket.used);
84
+ const reportedRemaining = toNumber(bucket.remaining);
97
85
  const hasValidUsed = reportedUsed !== undefined && reportedUsed >= 0;
98
86
  const hasValidRemaining = reportedRemaining !== undefined && reportedRemaining >= 0;
99
- const limit = toNumber(individual.limit);
87
+ const limit = toNumber(bucket.limit);
100
88
 
101
- let amount: UsageAmount;
102
- if (individual.limit === null || individual.limit === undefined) {
89
+ if (bucket.limit === null || bucket.limit === undefined) {
103
90
  if (!hasValidUsed) return null;
104
- amount = { used: reportedUsed / 100, unit: "usd" };
91
+ return { used: reportedUsed / 100, unit: "usd" };
92
+ }
93
+
94
+ if (limit === undefined || limit <= 0) return null;
95
+ let used: number;
96
+ if (reportedUsed !== undefined && reportedUsed > 0) {
97
+ used = reportedUsed;
98
+ } else if (hasValidRemaining && reportedRemaining < limit) {
99
+ used = Math.max(0, limit - reportedRemaining);
100
+ } else if (hasValidUsed) {
101
+ used = reportedUsed;
105
102
  } else {
106
- if (limit === undefined || limit <= 0) return null;
107
- let used: number;
108
- if (reportedUsed !== undefined && reportedUsed > 0) {
109
- used = reportedUsed;
110
- } else if (hasValidRemaining && reportedRemaining < limit) {
111
- used = Math.max(0, limit - reportedRemaining);
112
- } else if (hasValidUsed) {
113
- used = reportedUsed;
103
+ return null;
104
+ }
105
+ const remaining = Math.max(0, limit - used);
106
+ return {
107
+ used: used / 100,
108
+ limit: limit / 100,
109
+ remaining: remaining / 100,
110
+ usedFraction: used / limit,
111
+ remainingFraction: remaining / limit,
112
+ unit: "usd",
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Cursor's dashboard does not treat plan.used / plan.limit as the visible %.
118
+ * Pro+ shows separate quota pools (not one shared percent):
119
+ * - Cursor Models ← autoPercentUsed
120
+ * (includes Cursor Grok 4.5 and Composer 2.5)
121
+ * - Other Models ← apiPercentUsed (separate included-$ pool; different quota)
122
+ * Prefer those fractions when present; fall back to cents only for older overall buckets.
123
+ */
124
+ function parseCursorPlanDashboardAmounts(bucket: Record<string, unknown>): {
125
+ auto?: UsageAmount;
126
+ api?: UsageAmount;
127
+ fallback?: UsageAmount;
128
+ } {
129
+ if (bucket.enabled === false) return {};
130
+
131
+ const limitCents = toNumber(bucket.limit);
132
+ const limitUsd = limitCents !== undefined && limitCents > 0 ? limitCents / 100 : undefined;
133
+ const autoPct = toNumber(bucket.autoPercentUsed);
134
+ const apiPct = toNumber(bucket.apiPercentUsed);
135
+ const totalPct = toNumber(bucket.totalPercentUsed);
136
+
137
+ const fromPercent = (pct: number, withLimit: boolean): UsageAmount => {
138
+ const usedFraction = Math.max(0, pct) / 100;
139
+ if (withLimit && limitUsd !== undefined) {
140
+ const used = limitUsd * usedFraction;
141
+ return {
142
+ used,
143
+ limit: limitUsd,
144
+ remaining: Math.max(0, limitUsd - used),
145
+ usedFraction,
146
+ remainingFraction: Math.max(0, 1 - usedFraction),
147
+ unit: "usd",
148
+ };
149
+ }
150
+ return { used: usedFraction * 100, usedFraction, unit: "percent" };
151
+ };
152
+
153
+ const result: { auto?: UsageAmount; api?: UsageAmount; fallback?: UsageAmount } = {};
154
+ if (autoPct !== undefined) result.auto = fromPercent(autoPct, false);
155
+ if (apiPct !== undefined) result.api = fromPercent(apiPct, true);
156
+ if (!result.auto && !result.api) {
157
+ if (totalPct !== undefined) {
158
+ result.fallback = fromPercent(totalPct, true);
114
159
  } else {
115
- return null;
160
+ const cents = parseCursorCentsBucket(bucket);
161
+ if (cents) result.fallback = cents;
116
162
  }
117
- const remaining = Math.max(0, limit - used);
118
- amount = {
119
- used: used / 100,
120
- limit: limit / 100,
121
- remaining: remaining / 100,
122
- usedFraction: used / limit,
123
- remainingFraction: remaining / limit,
124
- unit: "usd",
125
- };
163
+ }
164
+ return result;
165
+ }
166
+
167
+ function pushCursorPlanRails(limits: UsageLimit[], bucket: Record<string, unknown>, window: UsageWindow): void {
168
+ const rails = parseCursorPlanDashboardAmounts(bucket);
169
+ if (rails.auto) {
170
+ limits.push({
171
+ id: "cursor:usd:individual-auto",
172
+ label: "Cursor Models",
173
+ scope: { provider: "cursor", windowId: window.id },
174
+ window,
175
+ amount: rails.auto,
176
+ ...(rails.auto.usedFraction !== undefined ? { status: usageStatus(rails.auto.usedFraction) } : {}),
177
+ });
178
+ }
179
+ if (rails.api) {
180
+ limits.push({
181
+ id: "cursor:usd:individual-api",
182
+ label: "Other Models",
183
+ scope: { provider: "cursor", windowId: window.id },
184
+ window,
185
+ amount: rails.api,
186
+ ...(rails.api.usedFraction !== undefined ? { status: usageStatus(rails.api.usedFraction) } : {}),
187
+ });
188
+ }
189
+ if (rails.fallback) {
190
+ limits.push({
191
+ id: "cursor:usd:individual-plan",
192
+ label: "Personal Usage",
193
+ scope: { provider: "cursor", windowId: window.id },
194
+ window,
195
+ amount: rails.fallback,
196
+ ...(rails.fallback.usedFraction !== undefined ? { status: usageStatus(rails.fallback.usedFraction) } : {}),
197
+ });
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Cursor's `/api/usage-summary` has shipped two personal-bucket shapes:
203
+ * - Enterprise/team dashboards historically exposed `individualUsage.overall`
204
+ * - Current Pro / Pro+ / Ultra dashboards expose `individualUsage.plan`
205
+ * (plus optional `onDemand`)
206
+ *
207
+ * Prefer a *usable* overall bucket; if overall is absent/disabled/malformed,
208
+ * fall through to plan rails (`autoPercentUsed` / `apiPercentUsed`). Always
209
+ * consider on-demand afterward so a valid on-demand meter is not dropped when
210
+ * the included plan bucket is empty.
211
+ */
212
+ export function parseCursorIndividualUsage(payload: unknown, fetchedAt = Date.now()): UsageReport | null {
213
+ if (!isRecord(payload) || !isRecord(payload.individualUsage)) {
214
+ return null;
126
215
  }
127
216
 
128
217
  const resetsAt = deriveResetsAt(payload);
@@ -131,21 +220,52 @@ export function parseCursorIndividualUsage(payload: unknown, fetchedAt = Date.no
131
220
  label: "Monthly",
132
221
  ...(resetsAt !== undefined ? { resetsAt } : {}),
133
222
  };
134
- const limitEntry: UsageLimit = {
135
- id: "cursor:usd:individual-overall",
136
- label: "Personal Usage",
137
- scope: {
138
- provider: "cursor",
139
- windowId: window.id,
140
- },
141
- window,
142
- amount,
143
- ...(amount.usedFraction !== undefined ? { status: resolveCursorStatus(amount.usedFraction) } : {}),
144
- };
223
+ const limits: UsageLimit[] = [];
224
+
225
+ const overall = isRecord(payload.individualUsage.overall) ? payload.individualUsage.overall : null;
226
+ const plan = isRecord(payload.individualUsage.plan) ? payload.individualUsage.plan : null;
227
+
228
+ // Prefer a usable overall bucket; if it is disabled/malformed, fall through to plan.
229
+ let usedOverall = false;
230
+ if (overall) {
231
+ const amount = parseCursorCentsBucket(overall);
232
+ if (amount) {
233
+ usedOverall = true;
234
+ limits.push({
235
+ id: "cursor:usd:individual-overall",
236
+ label: "Personal Usage",
237
+ scope: { provider: "cursor", windowId: window.id },
238
+ window,
239
+ amount,
240
+ ...(amount.usedFraction !== undefined ? { status: usageStatus(amount.usedFraction) } : {}),
241
+ });
242
+ }
243
+ }
244
+ if (!usedOverall && plan) {
245
+ pushCursorPlanRails(limits, plan, window);
246
+ }
247
+
248
+ // Keep on-demand even when the included plan/overall bucket is absent or unusable.
249
+ if (isRecord(payload.individualUsage.onDemand)) {
250
+ const onDemandAmount = parseCursorCentsBucket(payload.individualUsage.onDemand);
251
+ if (onDemandAmount && onDemandAmount.limit !== undefined && onDemandAmount.limit > 0) {
252
+ limits.push({
253
+ id: "cursor:usd:individual-ondemand",
254
+ label: "On-Demand Usage",
255
+ scope: { provider: "cursor", windowId: window.id },
256
+ window,
257
+ amount: onDemandAmount,
258
+ ...(onDemandAmount.usedFraction !== undefined ? { status: usageStatus(onDemandAmount.usedFraction) } : {}),
259
+ });
260
+ }
261
+ }
262
+
263
+ if (limits.length === 0) return null;
264
+
145
265
  return {
146
266
  provider: "cursor",
147
267
  fetchedAt,
148
- limits: [limitEntry],
268
+ limits,
149
269
  raw: payload,
150
270
  };
151
271
  }
@@ -197,7 +317,7 @@ export function parseCursorUsage(payload: unknown, fetchedAt = Date.now()): Usag
197
317
  unit,
198
318
  };
199
319
 
200
- const status = resolveCursorStatus(amount.usedFraction);
320
+ const status = usageStatus(amount.usedFraction);
201
321
 
202
322
  limits.push({
203
323
  id: limitId,
@@ -8,6 +8,7 @@ import type {
8
8
  UsageReport,
9
9
  UsageWindow,
10
10
  } from "../usage";
11
+ import { parseIsoTimestamp } from "./shared";
11
12
 
12
13
  // (Refresh is the sole responsibility of AuthStorage; no provider-direct refresh here.)
13
14
 
@@ -79,8 +80,8 @@ function parseWindow(resetTime: string | undefined): UsageWindow {
79
80
  label: "Quota window",
80
81
  };
81
82
  }
82
- const resetsAt = Date.parse(resetTime);
83
- if (Number.isNaN(resetsAt)) {
83
+ const resetsAt = parseIsoTimestamp(resetTime);
84
+ if (resetsAt === undefined) {
84
85
  return {
85
86
  id: "quota",
86
87
  label: "Quota window",
@@ -18,6 +18,7 @@ import type {
18
18
  UsageWindow,
19
19
  } from "../usage";
20
20
  import { isRecord } from "../utils";
21
+ import { parseIsoTimestamp } from "./shared";
21
22
 
22
23
  type CopilotQuotaDetail = {
23
24
  entitlement: number;
@@ -74,8 +75,8 @@ function resolveGitHubApiBaseUrl(params: UsageFetchParams): string {
74
75
 
75
76
  function buildWindow(resetDate: string | undefined): UsageWindow | undefined {
76
77
  if (!resetDate) return undefined;
77
- const resetAt = Date.parse(resetDate);
78
- if (!Number.isFinite(resetAt)) return undefined;
78
+ const resetAt = parseIsoTimestamp(resetDate);
79
+ if (resetAt === undefined) return undefined;
79
80
  return {
80
81
  id: "monthly",
81
82
  label: "Monthly",
@@ -12,6 +12,7 @@ import type {
12
12
  UsageStatus,
13
13
  UsageWindow,
14
14
  } from "../usage";
15
+ import { DAY_MS, parseIsoTimestamp, WEEK_MS } from "./shared";
15
16
 
16
17
  // (Refresh is the sole responsibility of AuthStorage; no provider-direct refresh here.)
17
18
 
@@ -47,9 +48,6 @@ interface AntigravityUsageResponse {
47
48
  const DEFAULT_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
48
49
  const FETCH_AVAILABLE_MODELS_PATH = "/v1internal:fetchAvailableModels";
49
50
 
50
- const ONE_DAY_MS = 24 * 60 * 60 * 1000;
51
- const ONE_WEEK_MS = 7 * ONE_DAY_MS;
52
-
53
51
  interface AntigravityWindowDescriptor {
54
52
  id: string;
55
53
  label: string;
@@ -59,25 +57,20 @@ interface AntigravityWindowDescriptor {
59
57
  function classifyWindow(id: string | undefined, label: string | undefined): AntigravityWindowDescriptor | undefined {
60
58
  const source = `${id ?? ""} ${label ?? ""}`.toLowerCase();
61
59
  if (source.includes("week") || source.includes("7d") || /7[\s_-]*day/.test(source)) {
62
- return { id: "weekly", label: "Weekly", durationMs: ONE_WEEK_MS };
60
+ return { id: "weekly", label: "Weekly", durationMs: WEEK_MS };
63
61
  }
64
62
  if (source.includes("day") || source.includes("daily") || source.includes("24h")) {
65
- return { id: "daily", label: "Daily", durationMs: ONE_DAY_MS };
63
+ return { id: "daily", label: "Daily", durationMs: DAY_MS };
66
64
  }
67
65
  if (id || label) return { id: id ?? label ?? "default", label: label ?? id ?? "Default" };
68
66
  return undefined;
69
67
  }
70
68
 
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
69
  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 };
70
+ if (resetAt !== undefined && resetAt - nowMs > DAY_MS) {
71
+ return { id: "weekly", label: "Weekly", durationMs: WEEK_MS };
79
72
  }
80
- return { id: "daily", label: "Daily", durationMs: ONE_DAY_MS };
73
+ return { id: "daily", label: "Daily", durationMs: DAY_MS };
81
74
  }
82
75
 
83
76
  function quotaInferenceKey(info: AntigravityQuotaInfo): string {
@@ -98,7 +91,7 @@ function inferWindowDescriptors(
98
91
  continue;
99
92
  }
100
93
  const group = groups.get(quotaInferenceKey(info)) ?? [];
101
- group.push({ info, resetAt: parseResetTime(info) });
94
+ group.push({ info, resetAt: parseIsoTimestamp(info.resetTime) });
102
95
  groups.set(quotaInferenceKey(info), group);
103
96
  }
104
97
 
@@ -110,7 +103,7 @@ function inferWindowDescriptors(
110
103
  for (const entry of group) {
111
104
  const descriptor =
112
105
  latestReset !== undefined && entry.resetAt === latestReset
113
- ? { id: "weekly", label: "Weekly", durationMs: ONE_WEEK_MS }
106
+ ? { id: "weekly", label: "Weekly", durationMs: WEEK_MS }
114
107
  : inferWindowFromReset(entry.resetAt, nowMs);
115
108
  descriptors.set(entry.info, descriptor);
116
109
  }
@@ -149,7 +142,7 @@ function parseWindow(
149
142
  info: AntigravityQuotaInfo,
150
143
  descriptor: AntigravityWindowDescriptor | undefined,
151
144
  ): UsageWindow | undefined {
152
- const resetAt = parseResetTime(info);
145
+ const resetAt = parseIsoTimestamp(info.resetTime);
153
146
  const hasResetAt = resetAt !== undefined;
154
147
  if (!descriptor && !hasResetAt) return undefined;
155
148
  return {
@@ -493,5 +486,5 @@ export const antigravityRankingStrategy: CredentialRankingStrategy = {
493
486
  // Antigravity windows carry `durationMs` when the response identifies them
494
487
  // as daily/weekly. Fall back to daily for legacy unlabelled quotaInfo
495
488
  // entries from `daily-cloudcode-pa.googleapis.com`.
496
- windowDefaults: { primaryMs: ONE_DAY_MS, secondaryMs: ONE_DAY_MS },
489
+ windowDefaults: { primaryMs: DAY_MS, secondaryMs: DAY_MS },
497
490
  };
package/src/usage/kimi.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { toNumber } from "@oh-my-pi/pi-catalog/utils";
1
2
  import { $env } from "@oh-my-pi/pi-utils";
2
3
  import { getKimiCommonHeaders } from "../registry/oauth/kimi";
3
4
  import type {
@@ -7,12 +8,12 @@ import type {
7
8
  UsageLimit,
8
9
  UsageProvider,
9
10
  UsageReport,
10
- UsageStatus,
11
11
  UsageWindow,
12
12
  } from "../usage";
13
13
  import { isRecord } from "../utils";
14
+ import { parseIsoTimestamp, usageStatus } from "./shared";
15
+
14
16
  // (Refresh is the sole responsibility of AuthStorage; no provider-direct refresh here.)
15
- import { toNumber } from "./shared";
16
17
 
17
18
  const DEFAULT_BASE_URL = "https://api.kimi.com/coding/v1";
18
19
  const USAGE_PATH = "usages";
@@ -47,8 +48,8 @@ function parseResetTime(data: Record<string, unknown>, nowMs: number): number |
47
48
  for (const key of timeKeys) {
48
49
  const value = data[key];
49
50
  if (typeof value === "string" && value.trim()) {
50
- const parsed = Date.parse(value);
51
- if (Number.isFinite(parsed)) return parsed;
51
+ const parsed = parseIsoTimestamp(value);
52
+ if (parsed !== undefined) return parsed;
52
53
  }
53
54
  if (typeof value === "number" && Number.isFinite(value)) {
54
55
  return value > 1_000_000_000_000 ? value : value * 1000;
@@ -76,6 +77,23 @@ function formatDurationLabel(duration: number, timeUnit: string): string | undef
76
77
  return undefined;
77
78
  }
78
79
 
80
+ const MINUTE_MS = 60_000;
81
+ const HOUR_MS = 3_600_000;
82
+ const DAY_MS = 86_400_000;
83
+
84
+ /**
85
+ * Status-line and ranking consumers match on canonical window ids ("5h",
86
+ * "7d"), so derive the id from the reported span: the 300-minute burst window
87
+ * surfaces as "5h" instead of "300time_unit_minute". Mirrors the
88
+ * intervalWindowId convention in minimax-code.ts.
89
+ */
90
+ function canonicalWindowId(durationMs: number): string {
91
+ if (durationMs > 0 && durationMs % DAY_MS === 0) return `${durationMs / DAY_MS}d`;
92
+ if (durationMs > 0 && durationMs % HOUR_MS === 0) return `${durationMs / HOUR_MS}h`;
93
+ const minutes = Math.round(durationMs / MINUTE_MS);
94
+ return minutes > 0 ? `${minutes}m` : "default";
95
+ }
96
+
79
97
  function buildWindow(windowData: Record<string, unknown>, nowMs: number): UsageWindow | undefined {
80
98
  const duration = toNumber(windowData.duration);
81
99
  const timeUnit = typeof windowData.timeUnit === "string" ? windowData.timeUnit : "";
@@ -85,14 +103,15 @@ function buildWindow(windowData: Record<string, unknown>, nowMs: number): UsageW
85
103
  if (duration === undefined && !label && !resetsAt) return undefined;
86
104
  let durationMs: number | undefined;
87
105
  if (duration !== undefined) {
88
- if (timeUnit.toUpperCase().includes("MINUTE")) durationMs = duration * 60_000;
89
- else if (timeUnit.toUpperCase().includes("HOUR")) durationMs = duration * 3_600_000;
90
- else if (timeUnit.toUpperCase().includes("DAY")) durationMs = duration * 86_400_000;
106
+ if (timeUnit.toUpperCase().includes("MINUTE")) durationMs = duration * MINUTE_MS;
107
+ else if (timeUnit.toUpperCase().includes("HOUR")) durationMs = duration * HOUR_MS;
108
+ else if (timeUnit.toUpperCase().includes("DAY")) durationMs = duration * DAY_MS;
109
+ else if (timeUnit.toUpperCase().includes("WEEK")) durationMs = duration * 7 * DAY_MS;
91
110
  else if (timeUnit.toUpperCase().includes("SECOND")) durationMs = duration * 1000;
92
111
  }
93
112
 
94
113
  return {
95
- id: duration !== undefined && timeUnit ? `${duration}${timeUnit.toLowerCase()}` : "default",
114
+ id: durationMs !== undefined ? canonicalWindowId(durationMs) : "default",
96
115
  label: label ?? "Usage window",
97
116
  durationMs,
98
117
  resetsAt,
@@ -136,13 +155,6 @@ function buildUsageAmount(row: KimiUsageRow): UsageAmount {
136
155
  return amount;
137
156
  }
138
157
 
139
- function buildUsageStatus(amount: UsageAmount): UsageStatus {
140
- if (amount.usedFraction === undefined) return "unknown";
141
- if (amount.usedFraction >= 1) return "exhausted";
142
- if (amount.usedFraction >= 0.9) return "warning";
143
- return "ok";
144
- }
145
-
146
158
  function toUsageLimit(row: KimiUsageRow, provider: string, index: number, accountId?: string): UsageLimit {
147
159
  // Kimi puts `resetTime` on the limit `detail`, not on `window`, so a
148
160
  // window built from `duration`/`timeUnit` alone carries no resetsAt.
@@ -172,7 +184,7 @@ function toUsageLimit(row: KimiUsageRow, provider: string, index: number, accoun
172
184
  },
173
185
  window,
174
186
  amount,
175
- status: buildUsageStatus(amount),
187
+ status: usageStatus(amount.usedFraction),
176
188
  };
177
189
  }
178
190
 
@@ -183,7 +195,13 @@ function parseUsagePayload(payload: unknown, nowMs: number): { rows: KimiUsageRo
183
195
 
184
196
  if (isRecord(data.usage)) {
185
197
  const summary = buildUsageRow(data.usage, "Total quota", nowMs);
186
- if (summary) rows.push(summary);
198
+ if (summary) {
199
+ // Kimi Code's aggregate quota resets weekly, but the payload carries
200
+ // only `resetTime` and no duration. Attach the canonical weekly
201
+ // window explicitly so status-line/ranking consumers recognize it.
202
+ summary.window = { id: "7d", label: "7 Day", resetsAt: summary.resetsAt };
203
+ rows.push(summary);
204
+ }
187
205
  }
188
206
 
189
207
  if (Array.isArray(data.limits)) {