@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.
- package/CHANGELOG.md +190 -0
- package/README.md +178 -0
- package/index.ts +540 -0
- package/package.json +35 -0
- package/src/cache.ts +546 -0
- package/src/config.ts +35 -0
- package/src/dependencies.ts +37 -0
- package/src/errors.ts +71 -0
- package/src/paths.ts +55 -0
- package/src/provider.ts +66 -0
- package/src/providers/detection.ts +51 -0
- package/src/providers/impl/anthropic.ts +174 -0
- package/src/providers/impl/antigravity.ts +226 -0
- package/src/providers/impl/codex.ts +186 -0
- package/src/providers/impl/copilot.ts +176 -0
- package/src/providers/impl/gemini.ts +130 -0
- package/src/providers/impl/kiro.ts +92 -0
- package/src/providers/impl/zai.ts +120 -0
- package/src/providers/index.ts +5 -0
- package/src/providers/metadata.ts +16 -0
- package/src/providers/registry.ts +54 -0
- package/src/providers/settings.ts +109 -0
- package/src/providers/status.ts +25 -0
- package/src/settings/behavior.ts +58 -0
- package/src/settings/menu.ts +83 -0
- package/src/settings/tools.ts +38 -0
- package/src/settings/ui.ts +450 -0
- package/src/settings-types.ts +95 -0
- package/src/settings-ui.ts +1 -0
- package/src/settings.ts +137 -0
- package/src/status.ts +245 -0
- package/src/storage/lock.ts +150 -0
- package/src/storage.ts +61 -0
- package/src/types.ts +33 -0
- package/src/ui/keybindings.ts +92 -0
- package/src/ui/settings-list.ts +290 -0
- package/src/usage/controller.ts +250 -0
- package/src/usage/fetch.ts +215 -0
- package/src/usage/types.ts +5 -0
- package/src/utils.ts +158 -0
- package/test/all.test.ts +9 -0
- package/test/cache.test.ts +157 -0
- package/test/controller.test.ts +101 -0
- package/test/detection.test.ts +24 -0
- package/test/extension.test.ts +233 -0
- package/test/helpers.ts +48 -0
- package/test/keybindings.test.ts +59 -0
- package/test/lock.test.ts +49 -0
- package/test/prioritize.test.ts +81 -0
- package/test/providers.test.ts +385 -0
- package/test/status.test.ts +70 -0
- 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
|
+
}
|
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
|
+
}
|
package/test/all.test.ts
ADDED
|
@@ -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
|
+
});
|