@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
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Usage fetching helpers with cache integration.
3
+ */
4
+
5
+ import type { Dependencies, ProviderName, ProviderStatus, UsageSnapshot } from "../types.js";
6
+ import type { Settings } from "../settings-types.js";
7
+ import type { ProviderUsageEntry } from "./types.js";
8
+ import { createProvider } from "../providers/registry.js";
9
+ import { fetchWithCache, getCachedData, readCache, updateCacheStatus, type Cache } from "../cache.js";
10
+ import { fetchProviderStatusWithFallback, providerHasStatus } from "../providers/status.js";
11
+ import { hasProviderCredentials } from "../providers/registry.js";
12
+ import { isExpectedMissingData } from "../errors.js";
13
+
14
+ export function getCacheTtlMs(settings: Settings): number {
15
+ return settings.behavior.refreshInterval * 1000;
16
+ }
17
+
18
+ export function getMinRefreshIntervalMs(settings: Settings): number {
19
+ return settings.behavior.minRefreshInterval * 1000;
20
+ }
21
+
22
+ export function getStatusCacheTtlMs(settings: Settings): number {
23
+ return settings.statusRefresh.refreshInterval * 1000;
24
+ }
25
+
26
+ export function getStatusMinRefreshIntervalMs(settings: Settings): number {
27
+ return settings.statusRefresh.minRefreshInterval * 1000;
28
+ }
29
+
30
+ const PROVIDER_FETCH_CONCURRENCY = 3;
31
+
32
+ async function mapWithConcurrency<T, R>(
33
+ items: T[],
34
+ limit: number,
35
+ mapper: (item: T, index: number) => Promise<R>
36
+ ): Promise<R[]> {
37
+ if (items.length === 0) return [];
38
+ const results = new Array<R>(items.length);
39
+ let nextIndex = 0;
40
+ const workerCount = Math.min(limit, items.length);
41
+ const workers = Array.from({ length: workerCount }, async () => {
42
+ while (true) {
43
+ const currentIndex = nextIndex++;
44
+ if (currentIndex >= items.length) {
45
+ return;
46
+ }
47
+ results[currentIndex] = await mapper(items[currentIndex], currentIndex);
48
+ }
49
+ });
50
+ await Promise.all(workers);
51
+ return results;
52
+ }
53
+
54
+ function resolveStatusFetchedAt(entry?: { fetchedAt: number; statusFetchedAt?: number } | null): number | undefined {
55
+ if (!entry) return undefined;
56
+ return entry.statusFetchedAt ?? entry.fetchedAt;
57
+ }
58
+
59
+ function isWithinMinInterval(fetchedAt: number | undefined, minIntervalMs: number): boolean {
60
+ if (!fetchedAt || minIntervalMs <= 0) return false;
61
+ return Date.now() - fetchedAt < minIntervalMs;
62
+ }
63
+
64
+ function shouldRefreshStatus(
65
+ settings: Settings,
66
+ entry?: { fetchedAt: number; statusFetchedAt?: number } | null,
67
+ options?: { force?: boolean }
68
+ ): boolean {
69
+ const fetchedAt = resolveStatusFetchedAt(entry);
70
+ const minIntervalMs = getStatusMinRefreshIntervalMs(settings);
71
+ if (isWithinMinInterval(fetchedAt, minIntervalMs)) return false;
72
+ if (options?.force) return true;
73
+ const ttlMs = getStatusCacheTtlMs(settings);
74
+ if (ttlMs <= 0) return true;
75
+ if (!fetchedAt) return true;
76
+ return Date.now() - fetchedAt >= ttlMs;
77
+ }
78
+
79
+ export async function refreshStatusForProvider(
80
+ deps: Dependencies,
81
+ settings: Settings,
82
+ provider: ProviderName,
83
+ options?: { force?: boolean }
84
+ ): Promise<ProviderStatus | undefined> {
85
+ const enabledSetting = settings.providers[provider].enabled;
86
+ if (enabledSetting === "off" || enabledSetting === false) {
87
+ return undefined;
88
+ }
89
+ if (enabledSetting === "auto" && !hasProviderCredentials(provider, deps)) {
90
+ return undefined;
91
+ }
92
+ if (!settings.providers[provider].fetchStatus) {
93
+ return undefined;
94
+ }
95
+
96
+ const cache = readCache();
97
+ const entry = cache[provider];
98
+ const providerInstance = createProvider(provider);
99
+ const shouldFetch = providerHasStatus(provider, providerInstance) && shouldRefreshStatus(settings, entry, options);
100
+ if (!shouldFetch) {
101
+ return entry?.status;
102
+ }
103
+ const status = await fetchProviderStatusWithFallback(provider, providerInstance, deps);
104
+ await updateCacheStatus(provider, status, { statusFetchedAt: Date.now() });
105
+ return status;
106
+ }
107
+
108
+ export async function fetchUsageForProvider(
109
+ deps: Dependencies,
110
+ settings: Settings,
111
+ provider: ProviderName,
112
+ options?: { force?: boolean; forceStatus?: boolean }
113
+ ): Promise<{ usage?: UsageSnapshot; status?: ProviderStatus }> {
114
+ const enabledSetting = settings.providers[provider].enabled;
115
+ if (enabledSetting === "off" || enabledSetting === false) {
116
+ return {};
117
+ }
118
+ if (enabledSetting === "auto" && !hasProviderCredentials(provider, deps)) {
119
+ return {};
120
+ }
121
+
122
+ const ttlMs = getCacheTtlMs(settings);
123
+ const cache = readCache();
124
+ const cachedEntry = cache[provider];
125
+ const cachedStatus = cachedEntry?.status;
126
+ const minIntervalMs = getMinRefreshIntervalMs(settings);
127
+ if (cachedEntry?.usage && isWithinMinInterval(cachedEntry.fetchedAt, minIntervalMs)) {
128
+ const usage = { ...cachedEntry.usage, status: cachedEntry.status } as UsageSnapshot;
129
+ return { usage, status: cachedEntry.status };
130
+ }
131
+ const providerInstance = createProvider(provider);
132
+ const shouldFetchStatus = Boolean(options?.forceStatus)
133
+ && settings.providers[provider].fetchStatus
134
+ && providerHasStatus(provider, providerInstance);
135
+
136
+ if (!options?.force) {
137
+ const cachedUsage = await getCachedData(provider, ttlMs, cache);
138
+ if (cachedUsage) {
139
+ let status = cachedUsage.status;
140
+ if (shouldFetchStatus) {
141
+ status = await refreshStatusForProvider(deps, settings, provider, { force: options?.forceStatus ?? options?.force });
142
+ }
143
+ const usage = cachedUsage.usage ? { ...cachedUsage.usage, status } : undefined;
144
+ return { usage, status };
145
+ }
146
+ }
147
+
148
+ return fetchWithCache(
149
+ provider,
150
+ ttlMs,
151
+ async () => {
152
+ const usage = await providerInstance.fetchUsage(deps);
153
+ let status = cachedStatus;
154
+ let statusFetchedAt = resolveStatusFetchedAt(cachedEntry);
155
+ if (shouldFetchStatus) {
156
+ status = await fetchProviderStatusWithFallback(provider, providerInstance, deps);
157
+ statusFetchedAt = Date.now();
158
+ } else if (!status) {
159
+ status = { indicator: "none" as const };
160
+ }
161
+
162
+ return { usage, status, statusFetchedAt };
163
+ },
164
+ options,
165
+ );
166
+ }
167
+
168
+ export async function getCachedUsageEntry(
169
+ provider: ProviderName,
170
+ settings: Settings,
171
+ cacheSnapshot?: Cache
172
+ ): Promise<ProviderUsageEntry | undefined> {
173
+ const ttlMs = getCacheTtlMs(settings);
174
+ const cachedEntry = await getCachedData(provider, ttlMs, cacheSnapshot);
175
+ const usage = cachedEntry?.usage ? { ...cachedEntry.usage, status: cachedEntry.status } : undefined;
176
+ if (!usage || (usage.error && isExpectedMissingData(usage.error))) {
177
+ return undefined;
178
+ }
179
+ return { provider, usage };
180
+ }
181
+
182
+ export async function getCachedUsageEntries(
183
+ providers: ProviderName[],
184
+ settings: Settings
185
+ ): Promise<ProviderUsageEntry[]> {
186
+ const cache = readCache();
187
+ const entries: ProviderUsageEntry[] = [];
188
+ for (const provider of providers) {
189
+ const entry = await getCachedUsageEntry(provider, settings, cache);
190
+ if (entry) {
191
+ entries.push(entry);
192
+ }
193
+ }
194
+ return entries;
195
+ }
196
+
197
+ export async function fetchUsageEntries(
198
+ deps: Dependencies,
199
+ settings: Settings,
200
+ providers: ProviderName[],
201
+ options?: { force?: boolean }
202
+ ): Promise<ProviderUsageEntry[]> {
203
+ const concurrency = Math.max(1, Math.min(PROVIDER_FETCH_CONCURRENCY, providers.length));
204
+ const results = await mapWithConcurrency(providers, concurrency, async (provider) => {
205
+ const result = await fetchUsageForProvider(deps, settings, provider, options);
206
+ const usage = result.usage
207
+ ? ({ ...result.usage, status: result.status } as UsageSnapshot)
208
+ : undefined;
209
+ if (!usage || (usage.error && isExpectedMissingData(usage.error))) {
210
+ return undefined;
211
+ }
212
+ return { provider, usage } as ProviderUsageEntry;
213
+ });
214
+ return results.filter(Boolean) as ProviderUsageEntry[];
215
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Usage data types shared across modules.
3
+ */
4
+
5
+ export type { ProviderUsageEntry } from "@eiei114/pi-sub-shared";
package/src/utils.ts ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Utility functions for the sub-bar extension
3
+ */
4
+
5
+ import type { Dependencies, RateWindow } from "./types.js";
6
+ import { MODEL_MULTIPLIERS } from "./config.js";
7
+
8
+ // Only allow simple CLI names (no spaces/paths) to avoid unsafe command execution.
9
+ const SAFE_CLI_NAME = /^[a-zA-Z0-9._-]+$/;
10
+
11
+ /**
12
+ * Format a reset date as a relative time string
13
+ */
14
+ export function formatReset(date: Date): string {
15
+ const diffMs = date.getTime() - Date.now();
16
+ if (diffMs < 0) return "now";
17
+
18
+ const diffMins = Math.floor(diffMs / 60000);
19
+ if (diffMins < 60) return `${diffMins}m`;
20
+
21
+ const hours = Math.floor(diffMins / 60);
22
+ const mins = diffMins % 60;
23
+ if (hours < 24) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
24
+
25
+ const days = Math.floor(hours / 24);
26
+ const remHours = hours % 24;
27
+ return remHours > 0 ? `${days}d${remHours}h` : `${days}d`;
28
+ }
29
+
30
+ /**
31
+ * Format elapsed time since a timestamp (milliseconds)
32
+ */
33
+ export function formatElapsedSince(timestamp: number): string {
34
+ const diffMs = Date.now() - timestamp;
35
+ if (diffMs < 60000) return "just now";
36
+
37
+ const diffMins = Math.floor(diffMs / 60000);
38
+ if (diffMins < 60) return `${diffMins}m`;
39
+
40
+ const hours = Math.floor(diffMins / 60);
41
+ const mins = diffMins % 60;
42
+ if (hours < 24) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
43
+
44
+ const days = Math.floor(hours / 24);
45
+ const remHours = hours % 24;
46
+ return remHours > 0 ? `${days}d${remHours}h` : `${days}d`;
47
+ }
48
+
49
+ /**
50
+ * Strip ANSI escape codes from a string
51
+ */
52
+ export function stripAnsi(text: string): string {
53
+ return text.replace(/\x1B\[[0-9;?]*[A-Za-z]|\x1B\].*?\x07/g, "");
54
+ }
55
+
56
+ /**
57
+ * Normalize a string into tokens for fuzzy matching
58
+ */
59
+ export function normalizeTokens(value: string): string[] {
60
+ return value
61
+ .toLowerCase()
62
+ .replace(/[^a-z0-9]+/g, " ")
63
+ .trim()
64
+ .split(" ")
65
+ .filter(Boolean);
66
+ }
67
+
68
+ /**
69
+ * Reorder usage windows so those matching the active model come first.
70
+ * A window matches when every model-ID token appears in the window label
71
+ * AND the model tokens form a strict majority of the label tokens.
72
+ * The majority check prevents a short model name (e.g. "gpt-5.3") from
73
+ * falsely matching a longer, more specific label (e.g. "GPT-5.3-Codex-Spark 5h").
74
+ * Non-matching windows keep their original relative order.
75
+ */
76
+ export function prioritizeWindowsForModel(
77
+ windows: RateWindow[],
78
+ model?: { id?: string } | null,
79
+ ): RateWindow[] {
80
+ if (!model?.id || windows.length <= 1) return windows;
81
+
82
+ const modelTokens = normalizeTokens(model.id);
83
+ if (modelTokens.length === 0) return windows;
84
+
85
+ const matched: RateWindow[] = [];
86
+ const rest: RateWindow[] = [];
87
+
88
+ for (const window of windows) {
89
+ const labelTokens = normalizeTokens(window.label);
90
+ const isMatch = modelTokens.every((token) => labelTokens.includes(token))
91
+ && modelTokens.length * 2 > labelTokens.length;
92
+ if (isMatch) {
93
+ matched.push(window);
94
+ } else {
95
+ rest.push(window);
96
+ }
97
+ }
98
+
99
+ if (matched.length === 0 || matched.length === windows.length) return windows;
100
+
101
+ return [...matched, ...rest];
102
+ }
103
+
104
+ // Pre-computed token entries for model multiplier matching
105
+ const MODEL_MULTIPLIER_TOKENS = Object.entries(MODEL_MULTIPLIERS).map(([label, multiplier]) => ({
106
+ label,
107
+ multiplier,
108
+ tokens: normalizeTokens(label),
109
+ }));
110
+
111
+ /**
112
+ * Get the request multiplier for a model ID
113
+ * Uses fuzzy matching against known model names
114
+ */
115
+ export function getModelMultiplier(modelId: string | undefined): number | undefined {
116
+ if (!modelId) return undefined;
117
+ const modelTokens = normalizeTokens(modelId);
118
+ if (modelTokens.length === 0) return undefined;
119
+
120
+ let bestMatch: { multiplier: number; tokenCount: number } | undefined;
121
+ for (const entry of MODEL_MULTIPLIER_TOKENS) {
122
+ const isMatch = entry.tokens.every((token) => modelTokens.includes(token));
123
+ if (!isMatch) continue;
124
+ const tokenCount = entry.tokens.length;
125
+ if (!bestMatch || tokenCount > bestMatch.tokenCount) {
126
+ bestMatch = { multiplier: entry.multiplier, tokenCount };
127
+ }
128
+ }
129
+
130
+ return bestMatch?.multiplier;
131
+ }
132
+
133
+ /**
134
+ * Check if a command exists in PATH
135
+ */
136
+ export function whichSync(cmd: string, deps: Dependencies): string | null {
137
+ if (!SAFE_CLI_NAME.test(cmd)) {
138
+ return null;
139
+ }
140
+
141
+ try {
142
+ return deps.execFileSync("which", [cmd], { encoding: "utf-8" }).trim();
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Create an abort controller with a timeout
150
+ */
151
+ export function createTimeoutController(timeoutMs: number): { controller: AbortController; clear: () => void } {
152
+ const controller = new AbortController();
153
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
154
+ return {
155
+ controller,
156
+ clear: () => clearTimeout(timeoutId),
157
+ };
158
+ }
@@ -0,0 +1,9 @@
1
+ import "./detection.test.js";
2
+ import "./providers.test.js";
3
+ import "./prioritize.test.js";
4
+ import "./controller.test.js";
5
+ import "./extension.test.js";
6
+ import "./cache.test.js";
7
+ import "./lock.test.js";
8
+ import "./status.test.js";
9
+ import "./keybindings.test.js";
@@ -0,0 +1,157 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import {
6
+ CACHE_PATH,
7
+ fetchWithCache,
8
+ onCacheSnapshot,
9
+ onCacheUpdate,
10
+ readCache,
11
+ updateCacheStatus,
12
+ watchCacheUpdates,
13
+ } from "../src/cache.js";
14
+ import { getCacheLockPath } from "../src/paths.js";
15
+
16
+ const LOCK_PATH = getCacheLockPath();
17
+
18
+ function wait(ms: number): Promise<void> {
19
+ return new Promise((resolve) => setTimeout(resolve, ms));
20
+ }
21
+
22
+ async function withCacheFiles(fn: () => Promise<void> | void): Promise<void> {
23
+ const cacheDir = path.dirname(CACHE_PATH);
24
+ const lockDir = path.dirname(LOCK_PATH);
25
+ fs.mkdirSync(cacheDir, { recursive: true });
26
+ fs.mkdirSync(lockDir, { recursive: true });
27
+
28
+ const cacheExists = fs.existsSync(CACHE_PATH);
29
+ const lockExists = fs.existsSync(LOCK_PATH);
30
+ const cacheBackup = cacheExists ? fs.readFileSync(CACHE_PATH, "utf-8") : null;
31
+ const lockBackup = lockExists ? fs.readFileSync(LOCK_PATH, "utf-8") : null;
32
+
33
+ try {
34
+ await fn();
35
+ } finally {
36
+ if (lockBackup !== null) {
37
+ fs.writeFileSync(LOCK_PATH, lockBackup, "utf-8");
38
+ } else if (fs.existsSync(LOCK_PATH)) {
39
+ fs.unlinkSync(LOCK_PATH);
40
+ }
41
+ if (cacheBackup !== null) {
42
+ fs.writeFileSync(CACHE_PATH, cacheBackup, "utf-8");
43
+ } else if (fs.existsSync(CACHE_PATH)) {
44
+ fs.unlinkSync(CACHE_PATH);
45
+ }
46
+ }
47
+ }
48
+
49
+ test("readCache recovers from truncated JSON", async () => {
50
+ await withCacheFiles(() => {
51
+ const cacheValue = {
52
+ copilot: {
53
+ fetchedAt: 123,
54
+ usage: {
55
+ provider: "copilot",
56
+ displayName: "Copilot Plan",
57
+ windows: [],
58
+ },
59
+ },
60
+ };
61
+ const corrupted = `${JSON.stringify(cacheValue)}garbage`;
62
+ fs.writeFileSync(CACHE_PATH, corrupted, "utf-8");
63
+
64
+ const cache = readCache();
65
+ assert.equal(cache.copilot?.fetchedAt, 123);
66
+
67
+ const repaired = JSON.parse(fs.readFileSync(CACHE_PATH, "utf-8")) as typeof cacheValue;
68
+ assert.ok(repaired.copilot);
69
+ });
70
+ });
71
+
72
+ test("watchCacheUpdates waits for lock release", async () => {
73
+ await withCacheFiles(async () => {
74
+ fs.writeFileSync(CACHE_PATH, "{}", "utf-8");
75
+ fs.writeFileSync(LOCK_PATH, String(Date.now()), "utf-8");
76
+
77
+ const snapshots: Array<Record<string, unknown>> = [];
78
+ const updates: Array<string> = [];
79
+ const offSnapshot = onCacheSnapshot((cache) => snapshots.push(cache));
80
+ const offUpdate = onCacheUpdate((provider) => updates.push(provider));
81
+ const stop = watchCacheUpdates({ debounceMs: 5, pollIntervalMs: 20, lockRetryMs: 50 });
82
+
83
+ const cacheValue = {
84
+ copilot: {
85
+ fetchedAt: Date.now(),
86
+ usage: { provider: "copilot", displayName: "Copilot", windows: [] },
87
+ },
88
+ };
89
+ fs.writeFileSync(CACHE_PATH, JSON.stringify(cacheValue), "utf-8");
90
+
91
+ await wait(40);
92
+ assert.equal(snapshots.length, 0);
93
+
94
+ fs.unlinkSync(LOCK_PATH);
95
+ await wait(80);
96
+
97
+ stop();
98
+ offSnapshot();
99
+ offUpdate();
100
+
101
+ assert.ok(snapshots.length > 0);
102
+ assert.ok(updates.includes("copilot"));
103
+ });
104
+ });
105
+
106
+ test("fetchWithCache skips duplicate fetch when lock is unavailable", async () => {
107
+ await withCacheFiles(async () => {
108
+ if (fs.existsSync(CACHE_PATH)) {
109
+ fs.unlinkSync(CACHE_PATH);
110
+ }
111
+ fs.writeFileSync(LOCK_PATH, JSON.stringify({ token: "other", acquiredAt: Date.now() }), "utf-8");
112
+ const releaseTimer = setTimeout(() => {
113
+ if (fs.existsSync(LOCK_PATH)) {
114
+ fs.unlinkSync(LOCK_PATH);
115
+ }
116
+ }, 50);
117
+
118
+ let fetchCalls = 0;
119
+ const result = await fetchWithCache("copilot", 0, async () => {
120
+ fetchCalls += 1;
121
+ return {
122
+ usage: {
123
+ provider: "copilot" as const,
124
+ displayName: "Copilot",
125
+ windows: [],
126
+ },
127
+ };
128
+ });
129
+ clearTimeout(releaseTimer);
130
+
131
+ assert.equal(fetchCalls, 0);
132
+ assert.equal(result.usage, undefined);
133
+ });
134
+ });
135
+
136
+ test("updateCacheStatus skips writes when lock is unavailable", async () => {
137
+ await withCacheFiles(async () => {
138
+ const initialCache = {
139
+ copilot: {
140
+ fetchedAt: 123,
141
+ usage: {
142
+ provider: "copilot",
143
+ displayName: "Copilot",
144
+ windows: [],
145
+ },
146
+ status: { indicator: "none" as const },
147
+ },
148
+ };
149
+ fs.writeFileSync(CACHE_PATH, JSON.stringify(initialCache), "utf-8");
150
+ fs.writeFileSync(LOCK_PATH, JSON.stringify({ token: "other", acquiredAt: Date.now() }), "utf-8");
151
+
152
+ await updateCacheStatus("copilot", { indicator: "major" });
153
+
154
+ const after = JSON.parse(fs.readFileSync(CACHE_PATH, "utf-8")) as typeof initialCache;
155
+ assert.equal(after.copilot.status?.indicator, "none");
156
+ });
157
+ });
@@ -0,0 +1,101 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createUsageController } from "../src/usage/controller.js";
4
+ import { getDefaultSettings } from "../src/settings-types.js";
5
+ import { CACHE_PATH } from "../src/cache.js";
6
+ import { getStorage, setStorage, type StorageAdapter } from "../src/storage.js";
7
+ import { createDeps, createJsonResponse, getAuthPath } from "./helpers.js";
8
+ import type { UsageSnapshot } from "../src/types.js";
9
+
10
+ function createMemoryStorage(): { storage: StorageAdapter; files: Map<string, string> } {
11
+ const files = new Map<string, string>();
12
+ const storage: StorageAdapter = {
13
+ readFile: (filePath) => files.get(filePath),
14
+ writeFile: (filePath, contents) => {
15
+ files.set(filePath, contents);
16
+ },
17
+ writeFileExclusive: (filePath, contents) => {
18
+ if (files.has(filePath)) return false;
19
+ files.set(filePath, contents);
20
+ return true;
21
+ },
22
+ exists: (filePath) => files.has(filePath),
23
+ removeFile: (filePath) => {
24
+ files.delete(filePath);
25
+ },
26
+ ensureDir: () => {},
27
+ };
28
+ return { storage, files };
29
+ }
30
+
31
+ test("refresh clears state when provider is not detected", async () => {
32
+ const { deps } = createDeps({
33
+ fetch: async () => createJsonResponse({}),
34
+ });
35
+ const controller = createUsageController(deps);
36
+ const settings = getDefaultSettings();
37
+ const state = {
38
+ currentProvider: "copilot" as const,
39
+ cachedUsage: { provider: "copilot", displayName: "Copilot", windows: [] },
40
+ lastSuccessAt: Date.now(),
41
+ providerCycleIndex: 0,
42
+ };
43
+ const updates: Array<{ provider?: string }> = [];
44
+
45
+ await controller.refresh({ model: undefined } as never, settings, state, (update) => updates.push(update));
46
+
47
+ assert.equal(state.currentProvider, undefined);
48
+ assert.equal(state.cachedUsage, undefined);
49
+ assert.equal(updates.at(-1)?.provider, undefined);
50
+ });
51
+
52
+ test("refresh falls back to cached usage on fetch error", async () => {
53
+ const { storage, files } = createMemoryStorage();
54
+ const originalStorage = getStorage();
55
+ setStorage(storage);
56
+
57
+ try {
58
+ const home = "/home/test";
59
+ const { deps, files: depFiles } = createDeps({
60
+ fetch: async () => createJsonResponse({}, { ok: false, status: 500 }),
61
+ homedir: home,
62
+ });
63
+ depFiles.set(getAuthPath(home), JSON.stringify({ "github-copilot": { refresh: "token" } }));
64
+
65
+ const cachedUsage: UsageSnapshot = {
66
+ provider: "copilot",
67
+ displayName: "Copilot Plan",
68
+ windows: [{ label: "Month", usedPercent: 10 }],
69
+ };
70
+ const cacheEntry = {
71
+ fetchedAt: Date.now() - 5000,
72
+ usage: cachedUsage,
73
+ status: { indicator: "none" as const },
74
+ };
75
+ files.set(CACHE_PATH, JSON.stringify({ copilot: cacheEntry }));
76
+
77
+ const controller = createUsageController(deps);
78
+ const settings = getDefaultSettings();
79
+ settings.behavior.minRefreshInterval = 0;
80
+ settings.providers.copilot.enabled = "on";
81
+
82
+ const state = { providerCycleIndex: 0 };
83
+ const updates: Array<{ usage?: UsageSnapshot }> = [];
84
+
85
+ await controller.refresh(
86
+ { model: { provider: "github" } } as never,
87
+ settings,
88
+ state,
89
+ (update) => updates.push(update),
90
+ { force: true },
91
+ );
92
+
93
+ const finalUsage = updates.at(-1)?.usage;
94
+ assert.ok(finalUsage);
95
+ assert.equal(finalUsage?.windows.length, 1);
96
+ assert.equal(finalUsage?.status?.indicator, "minor");
97
+ assert.equal(finalUsage?.error?.code, "HTTP_ERROR");
98
+ } finally {
99
+ setStorage(originalStorage);
100
+ }
101
+ });
@@ -0,0 +1,24 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { detectProviderFromModel } from "../src/providers/detection.js";
4
+
5
+
6
+ test("detectProviderFromModel prefers provider tokens over model tokens", () => {
7
+ const provider = detectProviderFromModel({ provider: "OpenAI", id: "claude-3-opus" });
8
+ assert.equal(provider, "codex");
9
+ });
10
+
11
+ test("detectProviderFromModel is case-insensitive", () => {
12
+ const provider = detectProviderFromModel({ provider: "GITHUB", id: "copilot" });
13
+ assert.equal(provider, "copilot");
14
+ });
15
+
16
+ test("detectProviderFromModel falls back to model tokens", () => {
17
+ const provider = detectProviderFromModel({ id: "claude-3.5-sonnet" });
18
+ assert.equal(provider, "anthropic");
19
+ });
20
+
21
+ test("detectProviderFromModel handles overlapping provider tokens", () => {
22
+ const provider = detectProviderFromModel({ provider: "z.ai", id: "model" });
23
+ assert.equal(provider, "zai");
24
+ });