@eiei114/pi-sub-core 1.5.1

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 (52) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +178 -0
  3. package/index.ts +540 -0
  4. package/package.json +35 -0
  5. package/src/cache.ts +546 -0
  6. package/src/config.ts +35 -0
  7. package/src/dependencies.ts +37 -0
  8. package/src/errors.ts +71 -0
  9. package/src/paths.ts +55 -0
  10. package/src/provider.ts +66 -0
  11. package/src/providers/detection.ts +51 -0
  12. package/src/providers/impl/anthropic.ts +174 -0
  13. package/src/providers/impl/antigravity.ts +226 -0
  14. package/src/providers/impl/codex.ts +186 -0
  15. package/src/providers/impl/copilot.ts +176 -0
  16. package/src/providers/impl/gemini.ts +130 -0
  17. package/src/providers/impl/kiro.ts +92 -0
  18. package/src/providers/impl/zai.ts +120 -0
  19. package/src/providers/index.ts +5 -0
  20. package/src/providers/metadata.ts +16 -0
  21. package/src/providers/registry.ts +54 -0
  22. package/src/providers/settings.ts +109 -0
  23. package/src/providers/status.ts +25 -0
  24. package/src/settings/behavior.ts +58 -0
  25. package/src/settings/menu.ts +83 -0
  26. package/src/settings/tools.ts +38 -0
  27. package/src/settings/ui.ts +450 -0
  28. package/src/settings-types.ts +95 -0
  29. package/src/settings-ui.ts +1 -0
  30. package/src/settings.ts +137 -0
  31. package/src/status.ts +245 -0
  32. package/src/storage/lock.ts +150 -0
  33. package/src/storage.ts +61 -0
  34. package/src/types.ts +33 -0
  35. package/src/ui/keybindings.ts +92 -0
  36. package/src/ui/settings-list.ts +290 -0
  37. package/src/usage/controller.ts +250 -0
  38. package/src/usage/fetch.ts +215 -0
  39. package/src/usage/types.ts +5 -0
  40. package/src/utils.ts +158 -0
  41. package/test/all.test.ts +9 -0
  42. package/test/cache.test.ts +157 -0
  43. package/test/controller.test.ts +101 -0
  44. package/test/detection.test.ts +24 -0
  45. package/test/extension.test.ts +233 -0
  46. package/test/helpers.ts +48 -0
  47. package/test/keybindings.test.ts +59 -0
  48. package/test/lock.test.ts +49 -0
  49. package/test/prioritize.test.ts +81 -0
  50. package/test/providers.test.ts +385 -0
  51. package/test/status.test.ts +70 -0
  52. package/tsconfig.json +5 -0
package/src/paths.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Shared path helpers for sub-core storage.
3
+ */
4
+
5
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const SETTINGS_FILE_NAME = "pi-sub-core-settings.json";
10
+ const CACHE_DIR_NAME = "cache";
11
+ const CACHE_NAMESPACE_DIR = "sub-core";
12
+ const CACHE_FILE_NAME = "cache.json";
13
+ const CACHE_LOCK_FILE_NAME = "cache.lock";
14
+ const LEGACY_AGENT_CACHE_FILE_NAME = "pi-sub-core-cache.json";
15
+ const LEGACY_AGENT_LOCK_FILE_NAME = "pi-sub-core-cache.lock";
16
+
17
+ export function getExtensionDir(): string {
18
+ return join(dirname(fileURLToPath(import.meta.url)), "..");
19
+ }
20
+
21
+ export function getCacheDir(): string {
22
+ return join(getAgentDir(), CACHE_DIR_NAME, CACHE_NAMESPACE_DIR);
23
+ }
24
+
25
+ export function getCachePath(): string {
26
+ return join(getCacheDir(), CACHE_FILE_NAME);
27
+ }
28
+
29
+ export function getCacheLockPath(): string {
30
+ return join(getCacheDir(), CACHE_LOCK_FILE_NAME);
31
+ }
32
+
33
+ export function getLegacyCachePath(): string {
34
+ return join(getExtensionDir(), "cache.json");
35
+ }
36
+
37
+ export function getLegacyCacheLockPath(): string {
38
+ return join(getExtensionDir(), "cache.lock");
39
+ }
40
+
41
+ export function getLegacyAgentCachePath(): string {
42
+ return join(getAgentDir(), LEGACY_AGENT_CACHE_FILE_NAME);
43
+ }
44
+
45
+ export function getLegacyAgentCacheLockPath(): string {
46
+ return join(getAgentDir(), LEGACY_AGENT_LOCK_FILE_NAME);
47
+ }
48
+
49
+ export function getSettingsPath(): string {
50
+ return join(getAgentDir(), SETTINGS_FILE_NAME);
51
+ }
52
+
53
+ export function getLegacySettingsPath(): string {
54
+ return join(getExtensionDir(), "settings.json");
55
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Provider interface and registry
3
+ */
4
+
5
+ import type { Dependencies, ProviderName, ProviderStatus, UsageSnapshot } from "./types.js";
6
+
7
+ /**
8
+ * Interface for a usage provider
9
+ */
10
+ export interface UsageProvider {
11
+ readonly name: ProviderName;
12
+ readonly displayName: string;
13
+
14
+ /**
15
+ * Fetch current usage data for this provider
16
+ */
17
+ fetchUsage(deps: Dependencies): Promise<UsageSnapshot>;
18
+
19
+ /**
20
+ * Fetch current status for this provider (optional)
21
+ */
22
+ fetchStatus?(deps: Dependencies): Promise<ProviderStatus>;
23
+
24
+ /**
25
+ * Check if credentials are available (optional)
26
+ */
27
+ hasCredentials?(deps: Dependencies): boolean;
28
+ }
29
+
30
+ /**
31
+ * Base class for providers with common functionality
32
+ */
33
+ export abstract class BaseProvider implements UsageProvider {
34
+ abstract readonly name: ProviderName;
35
+ abstract readonly displayName: string;
36
+
37
+ abstract fetchUsage(deps: Dependencies): Promise<UsageSnapshot>;
38
+
39
+ hasCredentials(_deps: Dependencies): boolean {
40
+ return true;
41
+ }
42
+
43
+ /**
44
+ * Create an empty snapshot with an error
45
+ */
46
+ protected emptySnapshot(error?: import("./types.js").UsageError): UsageSnapshot {
47
+ return {
48
+ provider: this.name,
49
+ displayName: this.displayName,
50
+ windows: [],
51
+ error,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Create a snapshot with usage data
57
+ */
58
+ protected snapshot(data: Partial<Omit<UsageSnapshot, "provider" | "displayName">>): UsageSnapshot {
59
+ return {
60
+ provider: this.name,
61
+ displayName: this.displayName,
62
+ windows: [],
63
+ ...data,
64
+ };
65
+ }
66
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Provider detection helpers.
3
+ */
4
+
5
+ import type { ProviderName } from "../types.js";
6
+ import { PROVIDERS } from "../types.js";
7
+ import { PROVIDER_METADATA } from "./metadata.js";
8
+
9
+ interface ProviderDetectionHint {
10
+ provider: ProviderName;
11
+ providerTokens: string[];
12
+ modelTokens: string[];
13
+ }
14
+
15
+ const PROVIDER_DETECTION_HINTS: ProviderDetectionHint[] = PROVIDERS.map((provider) => {
16
+ const detection = PROVIDER_METADATA[provider].detection ?? { providerTokens: [], modelTokens: [] };
17
+ return {
18
+ provider,
19
+ providerTokens: detection.providerTokens,
20
+ modelTokens: detection.modelTokens,
21
+ };
22
+ });
23
+
24
+ /**
25
+ * Detect the provider from model metadata.
26
+ */
27
+ export function detectProviderFromModel(
28
+ model: { provider?: string; id?: string } | undefined
29
+ ): ProviderName | undefined {
30
+ if (!model) return undefined;
31
+ const providerValue = model.provider?.toLowerCase() || "";
32
+ const idValue = model.id?.toLowerCase() || "";
33
+
34
+ if (providerValue.includes("antigravity") || idValue.includes("antigravity")) {
35
+ return "antigravity";
36
+ }
37
+
38
+ for (const hint of PROVIDER_DETECTION_HINTS) {
39
+ if (hint.providerTokens.some((token) => providerValue.includes(token))) {
40
+ return hint.provider;
41
+ }
42
+ }
43
+
44
+ for (const hint of PROVIDER_DETECTION_HINTS) {
45
+ if (hint.modelTokens.some((token) => idValue.includes(token))) {
46
+ return hint.provider;
47
+ }
48
+ }
49
+
50
+ return undefined;
51
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Anthropic/Claude usage provider
3
+ */
4
+
5
+ import * as path from "node:path";
6
+ import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
7
+ import { BaseProvider } from "../../provider.js";
8
+ import { noCredentials, fetchFailed, httpError } from "../../errors.js";
9
+ import { formatReset, createTimeoutController } from "../../utils.js";
10
+ import { API_TIMEOUT_MS } from "../../config.js";
11
+ import { getSettings } from "../../settings.js";
12
+
13
+ /**
14
+ * Load Claude API token from various sources
15
+ */
16
+ function loadClaudeToken(deps: Dependencies): string | undefined {
17
+ // Explicit override via env var (useful in CI / menu bar apps)
18
+ const envToken = deps.env.ANTHROPIC_OAUTH_TOKEN?.trim();
19
+ if (envToken) return envToken;
20
+
21
+ // Try pi auth.json next
22
+ const piAuthPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
23
+ try {
24
+ if (deps.fileExists(piAuthPath)) {
25
+ const data = JSON.parse(deps.readFile(piAuthPath) ?? "{}");
26
+ if (data.anthropic?.access) return data.anthropic.access;
27
+ }
28
+ } catch {
29
+ // Ignore parse errors
30
+ }
31
+
32
+ // Try macOS Keychain (Claude Code credentials)
33
+ try {
34
+ const keychainData = deps.execFileSync(
35
+ "security",
36
+ ["find-generic-password", "-s", "Claude Code-credentials", "-w"],
37
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
38
+ ).trim();
39
+ if (keychainData) {
40
+ const parsed = JSON.parse(keychainData);
41
+ const scopes = parsed.claudeAiOauth?.scopes || [];
42
+ if (scopes.includes("user:profile") && parsed.claudeAiOauth?.accessToken) {
43
+ return parsed.claudeAiOauth.accessToken;
44
+ }
45
+ }
46
+ } catch {
47
+ // Keychain access failed
48
+ }
49
+
50
+ return undefined;
51
+ }
52
+
53
+ type ExtraUsageFormat = {
54
+ symbol: string;
55
+ decimalSeparator: "." | ",";
56
+ };
57
+
58
+ function getExtraUsageFormat(): ExtraUsageFormat {
59
+ const settings = getSettings();
60
+ const providerSettings = settings.providers.anthropic;
61
+ return {
62
+ symbol: providerSettings.extraUsageCurrencySymbol?.trim() ?? "",
63
+ decimalSeparator: providerSettings.extraUsageDecimalSeparator === "," ? "," : ".",
64
+ };
65
+ }
66
+
67
+ function formatExtraUsageCredits(credits: number, format: ExtraUsageFormat): string {
68
+ const amount = (credits / 100).toFixed(2);
69
+ const formatted = format.decimalSeparator === "," ? amount.replace(".", ",") : amount;
70
+ return format.symbol ? `${format.symbol}${formatted}` : formatted;
71
+ }
72
+
73
+
74
+ export class AnthropicProvider extends BaseProvider {
75
+ readonly name = "anthropic" as const;
76
+ readonly displayName = "Claude Plan";
77
+
78
+ hasCredentials(deps: Dependencies): boolean {
79
+ return Boolean(loadClaudeToken(deps));
80
+ }
81
+
82
+ async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
83
+ const token = loadClaudeToken(deps);
84
+ if (!token) {
85
+ return this.emptySnapshot(noCredentials());
86
+ }
87
+
88
+ const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
89
+
90
+ try {
91
+ const res = await deps.fetch("https://api.anthropic.com/api/oauth/usage", {
92
+ headers: {
93
+ Authorization: `Bearer ${token}`,
94
+ "anthropic-beta": "oauth-2025-04-20",
95
+ },
96
+ signal: controller.signal,
97
+ });
98
+ clear();
99
+
100
+ if (!res.ok) {
101
+ return this.emptySnapshot(httpError(res.status));
102
+ }
103
+
104
+ const data = (await res.json()) as {
105
+ five_hour?: { utilization?: number; resets_at?: string };
106
+ seven_day?: { utilization?: number; resets_at?: string };
107
+ extra_usage?: {
108
+ is_enabled?: boolean;
109
+ used_credits?: number;
110
+ monthly_limit?: number;
111
+ utilization?: number;
112
+ };
113
+ };
114
+
115
+ const windows: RateWindow[] = [];
116
+
117
+ if (data.five_hour?.utilization !== undefined) {
118
+ const resetAt = data.five_hour.resets_at ? new Date(data.five_hour.resets_at) : undefined;
119
+ windows.push({
120
+ label: "5h",
121
+ usedPercent: data.five_hour.utilization,
122
+ resetDescription: resetAt ? formatReset(resetAt) : undefined,
123
+ resetAt: resetAt?.toISOString(),
124
+ });
125
+ }
126
+
127
+ if (data.seven_day?.utilization !== undefined) {
128
+ const resetAt = data.seven_day.resets_at ? new Date(data.seven_day.resets_at) : undefined;
129
+ windows.push({
130
+ label: "Week",
131
+ usedPercent: data.seven_day.utilization,
132
+ resetDescription: resetAt ? formatReset(resetAt) : undefined,
133
+ resetAt: resetAt?.toISOString(),
134
+ });
135
+ }
136
+
137
+ // Extra usage
138
+ const extraUsageEnabled = data.extra_usage?.is_enabled === true;
139
+ const fiveHourUsage = data.five_hour?.utilization ?? 0;
140
+
141
+ if (extraUsageEnabled) {
142
+ const extra = data.extra_usage!;
143
+ const usedCredits = extra.used_credits || 0;
144
+ const monthlyLimit = extra.monthly_limit;
145
+ const utilization = extra.utilization || 0;
146
+ const format = getExtraUsageFormat();
147
+ // "active" when 5h >= 99%, otherwise "on"
148
+ const extraStatus = fiveHourUsage >= 99 ? "active" : "on";
149
+ let label: string;
150
+ if (monthlyLimit && monthlyLimit > 0) {
151
+ label = `Extra [${extraStatus}] ${formatExtraUsageCredits(usedCredits, format)}/${formatExtraUsageCredits(monthlyLimit, format)}`;
152
+ } else {
153
+ label = `Extra [${extraStatus}] ${formatExtraUsageCredits(usedCredits, format)}`;
154
+ }
155
+
156
+ windows.push({
157
+ label,
158
+ usedPercent: utilization,
159
+ resetDescription: extraStatus === "active" ? "__ACTIVE__" : undefined,
160
+ });
161
+ }
162
+
163
+ return this.snapshot({
164
+ windows,
165
+ extraUsageEnabled,
166
+ fiveHourUsage,
167
+ });
168
+ } catch {
169
+ clear();
170
+ return this.emptySnapshot(fetchFailed());
171
+ }
172
+ }
173
+
174
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Google Antigravity usage provider
3
+ */
4
+
5
+ import * as path from "node:path";
6
+ import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
7
+ import { BaseProvider } from "../../provider.js";
8
+ import { noCredentials, fetchFailed, httpError } from "../../errors.js";
9
+ import { createTimeoutController, formatReset } from "../../utils.js";
10
+ import { API_TIMEOUT_MS } from "../../config.js";
11
+
12
+ const ANTIGRAVITY_ENDPOINTS = [
13
+ "https://daily-cloudcode-pa.sandbox.googleapis.com",
14
+ "https://cloudcode-pa.googleapis.com",
15
+ ] as const;
16
+
17
+ const ANTIGRAVITY_HEADERS = {
18
+ "User-Agent": "antigravity/1.11.5 darwin/arm64",
19
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
20
+ "Client-Metadata": JSON.stringify({
21
+ ideType: "IDE_UNSPECIFIED",
22
+ platform: "PLATFORM_UNSPECIFIED",
23
+ pluginType: "GEMINI",
24
+ }),
25
+ };
26
+
27
+ const ANTIGRAVITY_HIDDEN_MODELS = new Set(["tab_flash_lite_preview"]);
28
+
29
+ interface AntigravityAuth {
30
+ access?: string;
31
+ accessToken?: string;
32
+ token?: string;
33
+ key?: string;
34
+ projectId?: string;
35
+ project?: string;
36
+ }
37
+
38
+ interface CloudCodeQuotaResponse {
39
+ models?: Record<string, {
40
+ displayName?: string;
41
+ model?: string;
42
+ isInternal?: boolean;
43
+ quotaInfo?: {
44
+ remainingFraction?: number;
45
+ limit?: string;
46
+ resetTime?: string;
47
+ };
48
+ }>;
49
+ }
50
+
51
+ interface ParsedModelQuota {
52
+ name: string;
53
+ remainingFraction: number;
54
+ resetAt?: Date;
55
+ }
56
+
57
+ /**
58
+ * Load Antigravity access token from auth.json
59
+ */
60
+ function loadAntigravityAuth(deps: Dependencies): AntigravityAuth | undefined {
61
+ // Explicit override via env var
62
+ const envProjectId = (deps.env.GOOGLE_ANTIGRAVITY_PROJECT_ID || deps.env.GOOGLE_ANTIGRAVITY_PROJECT)?.trim();
63
+ const envToken = (deps.env.GOOGLE_ANTIGRAVITY_OAUTH_TOKEN || deps.env.ANTIGRAVITY_OAUTH_TOKEN)?.trim();
64
+ if (envToken) {
65
+ return { token: envToken, projectId: envProjectId || undefined };
66
+ }
67
+
68
+ // Also support passing pi-ai style JSON api key: { token, projectId }
69
+ const envApiKey = (deps.env.GOOGLE_ANTIGRAVITY_API_KEY || deps.env.ANTIGRAVITY_API_KEY)?.trim();
70
+ if (envApiKey) {
71
+ try {
72
+ const parsed = JSON.parse(envApiKey) as { token?: string; projectId?: string };
73
+ if (parsed?.token) {
74
+ return { token: parsed.token, projectId: parsed.projectId || envProjectId || undefined };
75
+ }
76
+ } catch {
77
+ // not JSON
78
+ }
79
+ return { token: envApiKey, projectId: envProjectId || undefined };
80
+ }
81
+
82
+ const piAuthPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
83
+ try {
84
+ if (deps.fileExists(piAuthPath)) {
85
+ const data = JSON.parse(deps.readFile(piAuthPath) ?? "{}");
86
+ const entry = data["google-antigravity"];
87
+ if (!entry) return undefined;
88
+ if (typeof entry === "string") {
89
+ return { token: entry };
90
+ }
91
+ return {
92
+ access: entry.access,
93
+ accessToken: entry.accessToken,
94
+ token: entry.token,
95
+ key: entry.key,
96
+ projectId: entry.projectId ?? entry.project,
97
+ };
98
+ }
99
+ } catch {
100
+ // Ignore parse errors
101
+ }
102
+
103
+ return undefined;
104
+ }
105
+
106
+ function resolveAntigravityToken(auth: AntigravityAuth | undefined): string | undefined {
107
+ return auth?.access ?? auth?.accessToken ?? auth?.token ?? auth?.key;
108
+ }
109
+
110
+ function parseResetTime(value?: string): Date | undefined {
111
+ if (!value) return undefined;
112
+ const date = new Date(value);
113
+ if (Number.isNaN(date.getTime())) return undefined;
114
+ return date;
115
+ }
116
+
117
+ function toUsedPercent(remainingFraction: number): number {
118
+ const fraction = Number.isFinite(remainingFraction) ? remainingFraction : 1;
119
+ const used = (1 - fraction) * 100;
120
+ return Math.max(0, Math.min(100, used));
121
+ }
122
+
123
+ async function fetchAntigravityQuota(
124
+ deps: Dependencies,
125
+ endpoint: string,
126
+ token: string,
127
+ projectId?: string
128
+ ): Promise<{ data?: CloudCodeQuotaResponse; status?: number }> {
129
+ const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
130
+ try {
131
+ const payload = projectId ? { project: projectId } : {};
132
+ const res = await deps.fetch(`${endpoint}/v1internal:fetchAvailableModels`, {
133
+ method: "POST",
134
+ headers: {
135
+ Authorization: `Bearer ${token}`,
136
+ "Content-Type": "application/json",
137
+ ...ANTIGRAVITY_HEADERS,
138
+ },
139
+ body: JSON.stringify(payload),
140
+ signal: controller.signal,
141
+ });
142
+ clear();
143
+ if (!res.ok) return { status: res.status };
144
+ const data = (await res.json()) as CloudCodeQuotaResponse;
145
+ return { data };
146
+ } catch {
147
+ clear();
148
+ return {};
149
+ }
150
+ }
151
+
152
+ export class AntigravityProvider extends BaseProvider {
153
+ readonly name = "antigravity" as const;
154
+ readonly displayName = "Antigravity";
155
+
156
+ hasCredentials(deps: Dependencies): boolean {
157
+ return Boolean(resolveAntigravityToken(loadAntigravityAuth(deps)));
158
+ }
159
+
160
+ async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
161
+ const auth = loadAntigravityAuth(deps);
162
+ const token = resolveAntigravityToken(auth);
163
+ if (!token) {
164
+ return this.emptySnapshot(noCredentials());
165
+ }
166
+
167
+ let data: CloudCodeQuotaResponse | undefined;
168
+ let lastStatus: number | undefined;
169
+ for (const endpoint of ANTIGRAVITY_ENDPOINTS) {
170
+ const result = await fetchAntigravityQuota(deps, endpoint, token, auth?.projectId);
171
+ if (result.data) {
172
+ data = result.data;
173
+ break;
174
+ }
175
+ if (result.status) {
176
+ lastStatus = result.status;
177
+ }
178
+ }
179
+
180
+ if (!data) {
181
+ return lastStatus ? this.emptySnapshot(httpError(lastStatus)) : this.emptySnapshot(fetchFailed());
182
+ }
183
+
184
+ const modelByName = new Map<string, ParsedModelQuota>();
185
+ for (const [modelId, model] of Object.entries(data.models ?? {})) {
186
+ if (model.isInternal) continue;
187
+ if (modelId && ANTIGRAVITY_HIDDEN_MODELS.has(modelId.toLowerCase())) continue;
188
+ const name = model.displayName ?? modelId ?? model.model ?? "unknown";
189
+ if (!name) continue;
190
+ if (ANTIGRAVITY_HIDDEN_MODELS.has(name.toLowerCase())) continue;
191
+ const remainingFraction = model.quotaInfo?.remainingFraction ?? 1;
192
+ const resetAt = parseResetTime(model.quotaInfo?.resetTime);
193
+ const existing = modelByName.get(name);
194
+ if (!existing) {
195
+ modelByName.set(name, { name, remainingFraction, resetAt });
196
+ continue;
197
+ }
198
+ let next = existing;
199
+ if (remainingFraction < existing.remainingFraction) {
200
+ next = { name, remainingFraction, resetAt };
201
+ } else if (remainingFraction === existing.remainingFraction && resetAt) {
202
+ if (!existing.resetAt || resetAt.getTime() < existing.resetAt.getTime()) {
203
+ next = { ...existing, resetAt };
204
+ }
205
+ } else if (!existing.resetAt && resetAt) {
206
+ next = { ...existing, resetAt };
207
+ }
208
+ if (next !== existing) {
209
+ modelByName.set(name, next);
210
+ }
211
+ }
212
+
213
+ const parsedModels = Array.from(modelByName.values()).sort((a, b) => a.name.localeCompare(b.name));
214
+
215
+ const buildWindow = (label: string, remainingFraction: number, resetAt?: Date): RateWindow => ({
216
+ label,
217
+ usedPercent: toUsedPercent(remainingFraction),
218
+ resetDescription: resetAt ? formatReset(resetAt) : undefined,
219
+ resetAt: resetAt?.toISOString(),
220
+ });
221
+
222
+ const windows = parsedModels.map((model) => buildWindow(model.name, model.remainingFraction, model.resetAt));
223
+
224
+ return this.snapshot({ windows });
225
+ }
226
+ }