@sreetej510/pi-usage 0.1.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/LICENSE +21 -0
- package/README.md +85 -0
- package/package.json +45 -0
- package/src/anthropic-auth.ts +33 -0
- package/src/anthropic-query.ts +72 -0
- package/src/args.ts +110 -0
- package/src/codex-app-server.ts +165 -0
- package/src/codex-auth.ts +66 -0
- package/src/codex-query.ts +52 -0
- package/src/codex-reset-credits.ts +131 -0
- package/src/command.ts +184 -0
- package/src/constants.ts +56 -0
- package/src/errors.ts +49 -0
- package/src/footer.ts +188 -0
- package/src/format.ts +208 -0
- package/src/http.ts +21 -0
- package/src/index.ts +100 -0
- package/src/models.ts +73 -0
- package/src/normalize-anthropic.ts +160 -0
- package/src/normalize-codex.ts +187 -0
- package/src/query.ts +121 -0
- package/src/shared-cache.ts +70 -0
- package/src/statusline.ts +244 -0
- package/src/types.ts +228 -0
- package/src/utils.ts +144 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AppServerCreditsSnapshot,
|
|
3
|
+
AppServerRateLimitResetCredits,
|
|
4
|
+
AppServerRateLimitResponse,
|
|
5
|
+
AppServerRateLimitSnapshot,
|
|
6
|
+
AppServerWindowSnapshot,
|
|
7
|
+
BackendAdditionalRateLimit,
|
|
8
|
+
BackendCreditsSnapshot,
|
|
9
|
+
BackendRateLimitDetails,
|
|
10
|
+
BackendRateLimitResetCredits,
|
|
11
|
+
BackendWindowSnapshot,
|
|
12
|
+
CodexUsageReport,
|
|
13
|
+
NormalizedCredits,
|
|
14
|
+
NormalizedRateLimitSnapshot,
|
|
15
|
+
NormalizedRateLimitWindow,
|
|
16
|
+
RateLimitStatusPayload,
|
|
17
|
+
} from "./types.js";
|
|
18
|
+
import { asBoolean, asNumber, asString, assertObject } from "./utils.js";
|
|
19
|
+
|
|
20
|
+
export function normalizeBackendPayload(
|
|
21
|
+
payload: RateLimitStatusPayload,
|
|
22
|
+
capturedAt: number,
|
|
23
|
+
source: "pi-auth" | "codex-app-server",
|
|
24
|
+
): CodexUsageReport {
|
|
25
|
+
const snapshots: NormalizedRateLimitSnapshot[] = [];
|
|
26
|
+
const planType = asString(payload.plan_type);
|
|
27
|
+
const bankedResetsAvailable = normalizeResetCreditCount(payload.rate_limit_reset_credits);
|
|
28
|
+
const primary = normalizeBackendSnapshot("codex", undefined, payload.rate_limit, payload.credits);
|
|
29
|
+
if (primary) snapshots.push(primary);
|
|
30
|
+
|
|
31
|
+
const additional = Array.isArray(payload.additional_rate_limits) ? payload.additional_rate_limits : [];
|
|
32
|
+
for (const item of additional) {
|
|
33
|
+
const additionalLimit = assertObject(item, "additional rate limit") as BackendAdditionalRateLimit;
|
|
34
|
+
const limitId = asString(additionalLimit.metered_feature) ?? asString(additionalLimit.limit_name);
|
|
35
|
+
if (!limitId) continue;
|
|
36
|
+
const snapshot = normalizeBackendSnapshot(
|
|
37
|
+
limitId,
|
|
38
|
+
asString(additionalLimit.limit_name),
|
|
39
|
+
additionalLimit.rate_limit,
|
|
40
|
+
undefined,
|
|
41
|
+
);
|
|
42
|
+
if (snapshot) snapshots.push(snapshot);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (snapshots.length === 0) {
|
|
46
|
+
throw new Error("Codex usage endpoint returned no displayable rate-limit windows.");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { provider: "codex", source, capturedAt, planType, bankedResetsAvailable, snapshots };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeBackendSnapshot(
|
|
53
|
+
limitId: string,
|
|
54
|
+
limitName: string | undefined,
|
|
55
|
+
rateLimit: unknown,
|
|
56
|
+
credits: unknown,
|
|
57
|
+
): NormalizedRateLimitSnapshot | undefined {
|
|
58
|
+
if (rateLimit === null || rateLimit === undefined) {
|
|
59
|
+
const normalizedCredits = normalizeBackendCredits(credits);
|
|
60
|
+
return normalizedCredits ? { limitId, limitName, credits: normalizedCredits } : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const details = assertObject(rateLimit, "rate limit") as BackendRateLimitDetails;
|
|
64
|
+
const primary = normalizeBackendWindow(details.primary_window);
|
|
65
|
+
const secondary = normalizeBackendWindow(details.secondary_window);
|
|
66
|
+
const normalizedCredits = normalizeBackendCredits(credits);
|
|
67
|
+
|
|
68
|
+
if (!primary && !secondary && !normalizedCredits) return undefined;
|
|
69
|
+
return { limitId, limitName, primary, secondary, credits: normalizedCredits };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeBackendWindow(value: unknown): NormalizedRateLimitWindow | undefined {
|
|
73
|
+
if (value === null || value === undefined) return undefined;
|
|
74
|
+
const window = assertObject(value, "rate-limit window") as BackendWindowSnapshot;
|
|
75
|
+
const usedPercent = asNumber(window.used_percent);
|
|
76
|
+
if (usedPercent === undefined) return undefined;
|
|
77
|
+
const limitSeconds = asNumber(window.limit_window_seconds);
|
|
78
|
+
const resetsAt = asNumber(window.reset_at);
|
|
79
|
+
return {
|
|
80
|
+
usedPercent,
|
|
81
|
+
windowMinutes: limitSeconds && limitSeconds > 0 ? Math.ceil(limitSeconds / 60) : undefined,
|
|
82
|
+
resetsAt,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizeBackendCredits(value: unknown): NormalizedCredits | undefined {
|
|
87
|
+
if (value === null || value === undefined) return undefined;
|
|
88
|
+
const credits = assertObject(value, "credits") as BackendCreditsSnapshot;
|
|
89
|
+
const hasCredits = asBoolean(credits.has_credits);
|
|
90
|
+
const unlimited = asBoolean(credits.unlimited);
|
|
91
|
+
if (hasCredits === undefined || unlimited === undefined) return undefined;
|
|
92
|
+
return { hasCredits, unlimited, balance: asString(credits.balance) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function normalizeAppServerResponse(response: AppServerRateLimitResponse, capturedAt: number): CodexUsageReport {
|
|
96
|
+
const snapshots: NormalizedRateLimitSnapshot[] = [];
|
|
97
|
+
const bankedResetsAvailable = normalizeResetCreditCount(response.rateLimitResetCredits);
|
|
98
|
+
const addSnapshot = (raw: unknown, fallbackId: string) => {
|
|
99
|
+
const snapshot = normalizeAppServerSnapshot(raw, fallbackId);
|
|
100
|
+
if (!snapshot) return;
|
|
101
|
+
const existingIndex = snapshots.findIndex((item) => item.limitId === snapshot.limitId);
|
|
102
|
+
if (existingIndex >= 0) snapshots[existingIndex] = mergeSnapshot(snapshots[existingIndex], snapshot);
|
|
103
|
+
else snapshots.push(snapshot);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
addSnapshot(response.rateLimits, "codex");
|
|
107
|
+
if (response.rateLimitsByLimitId && typeof response.rateLimitsByLimitId === "object") {
|
|
108
|
+
for (const [limitId, raw] of Object.entries(response.rateLimitsByLimitId)) {
|
|
109
|
+
addSnapshot(raw, limitId);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (snapshots.length === 0) {
|
|
114
|
+
throw new Error("codex app-server returned no displayable rate-limit windows.");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const planType = asAppServerPlanType(response.rateLimits);
|
|
118
|
+
return {
|
|
119
|
+
provider: "codex",
|
|
120
|
+
source: "codex-app-server",
|
|
121
|
+
capturedAt,
|
|
122
|
+
planType,
|
|
123
|
+
bankedResetsAvailable,
|
|
124
|
+
snapshots,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function asAppServerPlanType(raw: unknown): string | undefined {
|
|
129
|
+
if (raw === null || raw === undefined) return undefined;
|
|
130
|
+
const snapshot = assertObject(raw, "app-server rate-limit snapshot") as AppServerRateLimitSnapshot;
|
|
131
|
+
return asString(snapshot.planType);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function normalizeAppServerSnapshot(raw: unknown, fallbackId: string): NormalizedRateLimitSnapshot | undefined {
|
|
135
|
+
if (raw === null || raw === undefined) return undefined;
|
|
136
|
+
const snapshot = assertObject(raw, "app-server rate-limit snapshot") as AppServerRateLimitSnapshot;
|
|
137
|
+
const limitId = asString(snapshot.limitId) ?? fallbackId;
|
|
138
|
+
const limitName = asString(snapshot.limitName);
|
|
139
|
+
const primary = normalizeAppServerWindow(snapshot.primary);
|
|
140
|
+
const secondary = normalizeAppServerWindow(snapshot.secondary);
|
|
141
|
+
const credits = normalizeAppServerCredits(snapshot.credits);
|
|
142
|
+
if (!primary && !secondary && !credits) return undefined;
|
|
143
|
+
return { limitId, limitName, primary, secondary, credits };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function normalizeAppServerWindow(value: unknown): NormalizedRateLimitWindow | undefined {
|
|
147
|
+
if (value === null || value === undefined) return undefined;
|
|
148
|
+
const window = assertObject(value, "app-server rate-limit window") as AppServerWindowSnapshot;
|
|
149
|
+
const usedPercent = asNumber(window.usedPercent);
|
|
150
|
+
if (usedPercent === undefined) return undefined;
|
|
151
|
+
return {
|
|
152
|
+
usedPercent,
|
|
153
|
+
windowMinutes: asNumber(window.windowDurationMins),
|
|
154
|
+
resetsAt: asNumber(window.resetsAt),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function normalizeAppServerCredits(value: unknown): NormalizedCredits | undefined {
|
|
159
|
+
if (value === null || value === undefined) return undefined;
|
|
160
|
+
const credits = assertObject(value, "app-server credits") as AppServerCreditsSnapshot;
|
|
161
|
+
const hasCredits = asBoolean(credits.hasCredits);
|
|
162
|
+
const unlimited = asBoolean(credits.unlimited);
|
|
163
|
+
if (hasCredits === undefined || unlimited === undefined) return undefined;
|
|
164
|
+
return { hasCredits, unlimited, balance: asString(credits.balance) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normalizeResetCreditCount(value: unknown): number | undefined {
|
|
168
|
+
if (value === null || value === undefined) return undefined;
|
|
169
|
+
const credits = assertObject(value, "rate limit reset credits") as
|
|
170
|
+
| BackendRateLimitResetCredits
|
|
171
|
+
| AppServerRateLimitResetCredits;
|
|
172
|
+
const count = asNumber(credits.available_count) ?? asNumber(credits.availableCount);
|
|
173
|
+
return count !== undefined ? Math.max(0, Math.trunc(count)) : undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function mergeSnapshot(
|
|
177
|
+
left: NormalizedRateLimitSnapshot,
|
|
178
|
+
right: NormalizedRateLimitSnapshot,
|
|
179
|
+
): NormalizedRateLimitSnapshot {
|
|
180
|
+
return {
|
|
181
|
+
limitId: right.limitId || left.limitId,
|
|
182
|
+
limitName: right.limitName ?? left.limitName,
|
|
183
|
+
primary: right.primary ?? left.primary,
|
|
184
|
+
secondary: right.secondary ?? left.secondary,
|
|
185
|
+
credits: right.credits ?? left.credits,
|
|
186
|
+
};
|
|
187
|
+
}
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { queryAnthropicUsage } from "./anthropic-query.js";
|
|
3
|
+
import { queryViaCodexAppServer } from "./codex-app-server.js";
|
|
4
|
+
import { queryCodexUsageWithFallback, queryViaPiAuth } from "./codex-query.js";
|
|
5
|
+
import { STATUSLINE_RETRY_ATTEMPTS, STATUSLINE_RETRY_DELAY_MS } from "./constants.js";
|
|
6
|
+
import { isRateLimitErrorMessage, isStaleExtensionContextError } from "./errors.js";
|
|
7
|
+
import {
|
|
8
|
+
anthropicAuthCandidateModels,
|
|
9
|
+
codexAuthCandidateModels,
|
|
10
|
+
isAnthropicModel,
|
|
11
|
+
isOpenAICodexModel,
|
|
12
|
+
} from "./models.js";
|
|
13
|
+
import type { QueryUsageOptions, QueryUsageResult, UsageQueryError, UsageReport, UsageSource } from "./types.js";
|
|
14
|
+
import { delay, errorMessage } from "./utils.js";
|
|
15
|
+
|
|
16
|
+
export async function queryAllUsage(
|
|
17
|
+
ctx: ExtensionContext,
|
|
18
|
+
options: Pick<QueryUsageOptions, "timeoutMs">,
|
|
19
|
+
): Promise<{ reports: UsageReport[]; errors: UsageQueryError[] }> {
|
|
20
|
+
const hasCodex = codexAuthCandidateModels(ctx).length > 0;
|
|
21
|
+
const hasAnthropic = anthropicAuthCandidateModels(ctx).length > 0;
|
|
22
|
+
|
|
23
|
+
type ProviderResult = { report?: UsageReport; errors: UsageQueryError[] };
|
|
24
|
+
const empty: ProviderResult = { errors: [] };
|
|
25
|
+
|
|
26
|
+
const [codexSettled, anthropicSettled] = await Promise.allSettled([
|
|
27
|
+
hasCodex ? queryCodexUsageWithFallback(ctx, options.timeoutMs) : Promise.resolve<ProviderResult>(empty),
|
|
28
|
+
hasAnthropic
|
|
29
|
+
? queryAnthropicUsage(ctx, options.timeoutMs).then((report): ProviderResult => ({ report, errors: [] }))
|
|
30
|
+
: Promise.resolve<ProviderResult>(empty),
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const reports: UsageReport[] = [];
|
|
34
|
+
const errors: UsageQueryError[] = [];
|
|
35
|
+
|
|
36
|
+
const collect = (
|
|
37
|
+
settled: PromiseSettledResult<{ report?: UsageReport; errors: UsageQueryError[] }>,
|
|
38
|
+
source: UsageSource,
|
|
39
|
+
) => {
|
|
40
|
+
if (settled.status === "rejected") {
|
|
41
|
+
if (isStaleExtensionContextError(settled.reason)) throw settled.reason;
|
|
42
|
+
errors.push({ source, message: errorMessage(settled.reason), cause: settled.reason });
|
|
43
|
+
} else {
|
|
44
|
+
if (settled.value.report) reports.push(settled.value.report);
|
|
45
|
+
errors.push(...settled.value.errors);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
collect(codexSettled, "pi-auth");
|
|
49
|
+
collect(anthropicSettled, "anthropic-oauth");
|
|
50
|
+
|
|
51
|
+
return { reports, errors };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function queryUsageWithRetries(
|
|
55
|
+
ctx: ExtensionContext,
|
|
56
|
+
options: Pick<QueryUsageOptions, "timeoutMs">,
|
|
57
|
+
): Promise<QueryUsageResult> {
|
|
58
|
+
let lastResult: QueryUsageResult | undefined;
|
|
59
|
+
for (let attempt = 1; attempt <= STATUSLINE_RETRY_ATTEMPTS; attempt += 1) {
|
|
60
|
+
lastResult = await queryUsage(ctx, options);
|
|
61
|
+
if (lastResult.ok) return lastResult;
|
|
62
|
+
// Retrying a 429 seconds later only makes the rate limiting worse.
|
|
63
|
+
if (lastResult.errors.some((error) => isRateLimitErrorMessage(error.message))) {
|
|
64
|
+
return lastResult;
|
|
65
|
+
}
|
|
66
|
+
if (attempt < STATUSLINE_RETRY_ATTEMPTS) {
|
|
67
|
+
await delay(STATUSLINE_RETRY_DELAY_MS);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return (
|
|
71
|
+
lastResult ?? {
|
|
72
|
+
ok: false,
|
|
73
|
+
errors: [{ source: "pi-auth", message: "Usage query failed." }],
|
|
74
|
+
}
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function queryUsage(
|
|
79
|
+
ctx: ExtensionContext,
|
|
80
|
+
options: Pick<QueryUsageOptions, "timeoutMs">,
|
|
81
|
+
): Promise<QueryUsageResult> {
|
|
82
|
+
if (isAnthropicModel(ctx.model)) {
|
|
83
|
+
try {
|
|
84
|
+
const report = await queryAnthropicUsage(ctx, options.timeoutMs);
|
|
85
|
+
return { ok: true, report };
|
|
86
|
+
} catch (cause) {
|
|
87
|
+
if (isStaleExtensionContextError(cause)) throw cause;
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
errors: [{ source: "anthropic-oauth", message: errorMessage(cause), cause }],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!isOpenAICodexModel(ctx.model)) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
errors: [{ source: "pi-auth", message: "Current model provider is not supported." }],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const errors: UsageQueryError[] = [];
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const report = await queryViaPiAuth(ctx, options.timeoutMs);
|
|
106
|
+
return { ok: true, report };
|
|
107
|
+
} catch (cause) {
|
|
108
|
+
if (isStaleExtensionContextError(cause)) throw cause;
|
|
109
|
+
errors.push({ source: "pi-auth", message: errorMessage(cause), cause });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const report = await queryViaCodexAppServer(options.timeoutMs);
|
|
114
|
+
return { ok: true, report };
|
|
115
|
+
} catch (cause) {
|
|
116
|
+
if (isStaleExtensionContextError(cause)) throw cause;
|
|
117
|
+
errors.push({ source: "codex-app-server", message: errorMessage(cause), cause });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { ok: false, errors };
|
|
121
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { RATE_LIMIT_BACKOFF_MAX_MS, SHARED_CACHE_FILE, SHARED_CACHE_VERSION } from "./constants.js";
|
|
4
|
+
import type { SharedCacheEntry, SharedUsageCache, UsageProviderKey, UsageReport } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export function readSharedUsageCache(): SharedUsageCache | undefined {
|
|
7
|
+
try {
|
|
8
|
+
const parsed = JSON.parse(readFileSync(SHARED_CACHE_FILE, "utf8")) as SharedUsageCache;
|
|
9
|
+
if (!parsed || parsed.version !== SHARED_CACHE_VERSION) return undefined;
|
|
10
|
+
if (!parsed.entries || typeof parsed.entries !== "object") return undefined;
|
|
11
|
+
return parsed;
|
|
12
|
+
} catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function writeSharedUsageCache(mutate: (cacheFile: SharedUsageCache) => void): void {
|
|
18
|
+
try {
|
|
19
|
+
const cacheFile = readSharedUsageCache() ?? { version: SHARED_CACHE_VERSION, entries: {} };
|
|
20
|
+
mutate(cacheFile);
|
|
21
|
+
mkdirSync(dirname(SHARED_CACHE_FILE), { recursive: true });
|
|
22
|
+
const tmpFile = `${SHARED_CACHE_FILE}.${process.pid}.tmp`;
|
|
23
|
+
writeFileSync(tmpFile, JSON.stringify(cacheFile));
|
|
24
|
+
try {
|
|
25
|
+
renameSync(tmpFile, SHARED_CACHE_FILE);
|
|
26
|
+
} catch {
|
|
27
|
+
// Windows can refuse to rename over an open file; fall back to direct write.
|
|
28
|
+
writeFileSync(SHARED_CACHE_FILE, JSON.stringify(cacheFile));
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// Best-effort — a broken shared cache must never break usage display.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function saveSharedUsageReport(report: UsageReport): void {
|
|
36
|
+
writeSharedUsageCache((cacheFile) => {
|
|
37
|
+
cacheFile.entries[report.provider] = { createdAt: Date.now(), report };
|
|
38
|
+
if (cacheFile.backoffUntil) delete cacheFile.backoffUntil[report.provider];
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function clearSharedUsageReport(provider: UsageProviderKey): void {
|
|
43
|
+
writeSharedUsageCache((cacheFile) => {
|
|
44
|
+
delete cacheFile.entries[provider];
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function saveSharedBackoff(provider: UsageProviderKey, untilMs: number): void {
|
|
49
|
+
// Never persist a backoff further out than the max — protects against
|
|
50
|
+
// clock skew between sessions producing absurd values.
|
|
51
|
+
const clamped = Math.min(untilMs, Date.now() + RATE_LIMIT_BACKOFF_MAX_MS);
|
|
52
|
+
writeSharedUsageCache((cacheFile) => {
|
|
53
|
+
cacheFile.backoffUntil = { ...(cacheFile.backoffUntil ?? {}), [provider]: clamped };
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function clearSharedBackoff(): void {
|
|
58
|
+
writeSharedUsageCache((cacheFile) => {
|
|
59
|
+
cacheFile.backoffUntil = undefined;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function sharedBackoffRemainingMs(provider: UsageProviderKey): number {
|
|
64
|
+
const until = readSharedUsageCache()?.backoffUntil?.[provider] ?? 0;
|
|
65
|
+
// Distrust stored timestamps: a skewed clock in another session must not
|
|
66
|
+
// lock us out for hours. Cap the effective backoff at the configured max.
|
|
67
|
+
return Math.min(until - Date.now(), RATE_LIMIT_BACKOFF_MAX_MS);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type { SharedCacheEntry };
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { CACHE_TTL_MS, DEFAULT_TIMEOUT_MS, STATUS_KEY } from "./constants.js";
|
|
3
|
+
import { isRateLimitErrorMessage, isStaleExtensionContextError, rateLimitBackoffMs } from "./errors.js";
|
|
4
|
+
import { formatUsageStatusline } from "./format.js";
|
|
5
|
+
import { isUsageSupportedModel, providerKeyForModel, reportMatchesModel } from "./models.js";
|
|
6
|
+
import { queryUsageWithRetries } from "./query.js";
|
|
7
|
+
import {
|
|
8
|
+
readSharedUsageCache,
|
|
9
|
+
saveSharedBackoff,
|
|
10
|
+
saveSharedUsageReport,
|
|
11
|
+
sharedBackoffRemainingMs,
|
|
12
|
+
} from "./shared-cache.js";
|
|
13
|
+
import type { CachedReport, ProviderUsageModel, UsageReport } from "./types.js";
|
|
14
|
+
import { formatAgeShort } from "./utils.js";
|
|
15
|
+
|
|
16
|
+
let cache: CachedReport | undefined;
|
|
17
|
+
let combinedCache: { createdAt: number; reports: UsageReport[] } | undefined;
|
|
18
|
+
let statuslineClearTimer: ReturnType<typeof setTimeout> | undefined;
|
|
19
|
+
let statuslineRefreshTimer: ReturnType<typeof setTimeout> | undefined;
|
|
20
|
+
let statuslineRequestId = 0;
|
|
21
|
+
let sessionActive = false;
|
|
22
|
+
let activeStatuslineContext: ExtensionContext | undefined;
|
|
23
|
+
|
|
24
|
+
export function isSessionActive(): boolean {
|
|
25
|
+
return sessionActive;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function setSessionActive(value: boolean): void {
|
|
29
|
+
sessionActive = value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getCombinedCache(): { createdAt: number; reports: UsageReport[] } | undefined {
|
|
33
|
+
return combinedCache;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function setCombinedCache(value: { createdAt: number; reports: UsageReport[] } | undefined): void {
|
|
37
|
+
combinedCache = value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function clearCodexMemoryCache(): void {
|
|
41
|
+
cache = undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const clearStatuslineTimers = () => {
|
|
45
|
+
if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
|
|
46
|
+
if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
|
|
47
|
+
statuslineClearTimer = undefined;
|
|
48
|
+
statuslineRefreshTimer = undefined;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function handleStaleContextError(ctx: ExtensionContext, error: unknown): boolean {
|
|
52
|
+
if (!isStaleExtensionContextError(error)) return false;
|
|
53
|
+
if (ctx === activeStatuslineContext) {
|
|
54
|
+
statuslineRequestId += 1;
|
|
55
|
+
clearStatuslineTimers();
|
|
56
|
+
activeStatuslineContext = undefined;
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const rethrowUnlessStaleContextError = (ctx: ExtensionContext) => (error: unknown) => {
|
|
62
|
+
if (!handleStaleContextError(ctx, error)) throw error;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const setStatuslineValue = (ctx: ExtensionContext, value: string | undefined): boolean => {
|
|
66
|
+
try {
|
|
67
|
+
ctx.ui.setStatus(STATUS_KEY, value);
|
|
68
|
+
return true;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (handleStaleContextError(ctx, error)) return false;
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export function clearUsageStatusline(ctx: ExtensionContext) {
|
|
76
|
+
statuslineRequestId += 1;
|
|
77
|
+
clearStatuslineTimers();
|
|
78
|
+
activeStatuslineContext = undefined;
|
|
79
|
+
setStatuslineValue(ctx, undefined);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const scheduleTemporaryStatuslineClear = (ctx: ExtensionContext) => {
|
|
83
|
+
if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
|
|
84
|
+
const requestId = statuslineRequestId;
|
|
85
|
+
statuslineClearTimer = setTimeout(() => {
|
|
86
|
+
statuslineClearTimer = undefined;
|
|
87
|
+
if (!sessionActive || requestId !== statuslineRequestId) return;
|
|
88
|
+
setStatuslineValue(ctx, undefined);
|
|
89
|
+
}, CACHE_TTL_MS);
|
|
90
|
+
statuslineClearTimer.unref?.();
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const scheduleStatuslineRefresh = (
|
|
94
|
+
ctx: ExtensionContext,
|
|
95
|
+
model: ProviderUsageModel | undefined,
|
|
96
|
+
delayMs: number = CACHE_TTL_MS,
|
|
97
|
+
) => {
|
|
98
|
+
if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
|
|
99
|
+
const requestId = statuslineRequestId;
|
|
100
|
+
statuslineRefreshTimer = setTimeout(() => {
|
|
101
|
+
statuslineRefreshTimer = undefined;
|
|
102
|
+
if (!sessionActive || requestId !== statuslineRequestId) return;
|
|
103
|
+
void refreshCurrentUsageStatusline(ctx, model).catch(rethrowUnlessStaleContextError(ctx));
|
|
104
|
+
}, delayMs);
|
|
105
|
+
statuslineRefreshTimer.unref?.();
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const setUsageStatusline = (
|
|
109
|
+
ctx: ExtensionContext,
|
|
110
|
+
report: UsageReport,
|
|
111
|
+
options: {
|
|
112
|
+
autoRefresh: boolean;
|
|
113
|
+
model: ProviderUsageModel | undefined;
|
|
114
|
+
staleAgeMs?: number;
|
|
115
|
+
refreshDelayMs?: number;
|
|
116
|
+
},
|
|
117
|
+
) => {
|
|
118
|
+
let text = formatUsageStatusline(report, options.model);
|
|
119
|
+
if (options.staleAgeMs !== undefined && options.staleAgeMs > CACHE_TTL_MS) {
|
|
120
|
+
text = `${text} (${formatAgeShort(options.staleAgeMs)} old)`;
|
|
121
|
+
}
|
|
122
|
+
if (!setStatuslineValue(ctx, text)) return;
|
|
123
|
+
activeStatuslineContext = ctx;
|
|
124
|
+
if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
|
|
125
|
+
statuslineClearTimer = undefined;
|
|
126
|
+
if (options.autoRefresh) scheduleStatuslineRefresh(ctx, options.model, options.refreshDelayMs);
|
|
127
|
+
else scheduleTemporaryStatuslineClear(ctx);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const getCachedReportForModel = (model: ProviderUsageModel | undefined): CachedReport | undefined => {
|
|
131
|
+
let best = cache && reportMatchesModel(cache.report, model) ? cache : undefined;
|
|
132
|
+
if (combinedCache) {
|
|
133
|
+
const report = combinedCache.reports.find((item) => reportMatchesModel(item, model));
|
|
134
|
+
if (report) {
|
|
135
|
+
const candidate = { createdAt: combinedCache.createdAt, report };
|
|
136
|
+
if (!best || candidate.createdAt > best.createdAt) best = candidate;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Another pi session may have fetched more recently — use its data.
|
|
140
|
+
const shared = readSharedUsageCache()?.entries?.[providerKeyForModel(model)];
|
|
141
|
+
if (shared?.report && reportMatchesModel(shared.report, model)) {
|
|
142
|
+
if (!best || shared.createdAt > best.createdAt) best = shared;
|
|
143
|
+
}
|
|
144
|
+
return best;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export async function refreshCurrentUsageStatusline(ctx: ExtensionContext, model?: ProviderUsageModel) {
|
|
148
|
+
if (!sessionActive) return;
|
|
149
|
+
activeStatuslineContext = ctx;
|
|
150
|
+
const selectedModel = model ?? ctx.model;
|
|
151
|
+
if (!isUsageSupportedModel(selectedModel)) {
|
|
152
|
+
clearUsageStatusline(ctx);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const requestId = statuslineRequestId + 1;
|
|
157
|
+
statuslineRequestId = requestId;
|
|
158
|
+
const cached = getCachedReportForModel(selectedModel);
|
|
159
|
+
const freshCached = cached && Date.now() - cached.createdAt < CACHE_TTL_MS ? cached : undefined;
|
|
160
|
+
// Fresh cache is always good enough — avoids double-fetching when /usage
|
|
161
|
+
// just updated it or another pi session already fetched. The scheduled
|
|
162
|
+
// timer fires when the TTL actually expires.
|
|
163
|
+
if (freshCached) {
|
|
164
|
+
const remainingMs = CACHE_TTL_MS - (Date.now() - freshCached.createdAt);
|
|
165
|
+
setUsageStatusline(ctx, freshCached.report, {
|
|
166
|
+
autoRefresh: true,
|
|
167
|
+
model: selectedModel,
|
|
168
|
+
refreshDelayMs: Math.max(remainingMs, 10_000),
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Respect a shared rate-limit backoff set by any session.
|
|
174
|
+
const providerKey = providerKeyForModel(selectedModel);
|
|
175
|
+
const backoffRemaining = sharedBackoffRemainingMs(providerKey);
|
|
176
|
+
if (backoffRemaining > 0) {
|
|
177
|
+
if (cached) {
|
|
178
|
+
setUsageStatusline(ctx, cached.report, {
|
|
179
|
+
autoRefresh: true,
|
|
180
|
+
model: selectedModel,
|
|
181
|
+
staleAgeMs: Date.now() - cached.report.capturedAt,
|
|
182
|
+
refreshDelayMs: Math.max(backoffRemaining, 10_000),
|
|
183
|
+
});
|
|
184
|
+
} else if (setStatuslineValue(ctx, `usage rate-limited (${formatAgeShort(backoffRemaining)})`)) {
|
|
185
|
+
scheduleStatuslineRefresh(ctx, selectedModel, Math.max(backoffRemaining, 10_000));
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!cached && !setStatuslineValue(ctx, "checking")) return;
|
|
191
|
+
const result = await queryUsageWithRetries(ctx, { timeoutMs: DEFAULT_TIMEOUT_MS });
|
|
192
|
+
if (!sessionActive || requestId !== statuslineRequestId) return;
|
|
193
|
+
|
|
194
|
+
if (!result.ok) {
|
|
195
|
+
const rateLimited = result.errors.some((error) => isRateLimitErrorMessage(error.message));
|
|
196
|
+
// Honor the server's Retry-After when it sends one; fall back to default.
|
|
197
|
+
const retryDelayMs = rateLimited ? rateLimitBackoffMs(result.errors) : CACHE_TTL_MS;
|
|
198
|
+
if (rateLimited) {
|
|
199
|
+
saveSharedBackoff(providerKey, Date.now() + retryDelayMs);
|
|
200
|
+
}
|
|
201
|
+
// Background refreshes fail silently — the statusline text is the only
|
|
202
|
+
// indicator. Detailed errors are shown when /usage is run explicitly.
|
|
203
|
+
if (cached) {
|
|
204
|
+
setUsageStatusline(ctx, cached.report, {
|
|
205
|
+
autoRefresh: true,
|
|
206
|
+
model: selectedModel,
|
|
207
|
+
staleAgeMs: Date.now() - cached.report.capturedAt,
|
|
208
|
+
refreshDelayMs: retryDelayMs,
|
|
209
|
+
});
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const errorLabel = rateLimited ? `usage rate-limited (${formatAgeShort(retryDelayMs)})` : "usage error";
|
|
213
|
+
if (setStatuslineValue(ctx, errorLabel)) {
|
|
214
|
+
scheduleStatuslineRefresh(ctx, selectedModel, retryDelayMs);
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
cache = { createdAt: Date.now(), report: result.report };
|
|
220
|
+
saveSharedUsageReport(result.report);
|
|
221
|
+
setUsageStatusline(ctx, result.report, { autoRefresh: true, model: selectedModel });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function applyCurrentProviderStatusline(ctx: ExtensionContext, reports: UsageReport[]): boolean {
|
|
225
|
+
const current = reports.find((report) => reportMatchesModel(report, ctx.model));
|
|
226
|
+
if (!current) {
|
|
227
|
+
setStatuslineValue(ctx, undefined);
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
cache = { createdAt: Date.now(), report: current };
|
|
231
|
+
setUsageStatusline(ctx, current, {
|
|
232
|
+
autoRefresh: isUsageSupportedModel(ctx.model),
|
|
233
|
+
model: ctx.model,
|
|
234
|
+
});
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function setStatuslineChecking(ctx: ExtensionContext): boolean {
|
|
239
|
+
return setStatuslineValue(ctx, "checking");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function clearStatuslineValue(ctx: ExtensionContext): boolean {
|
|
243
|
+
return setStatuslineValue(ctx, undefined);
|
|
244
|
+
}
|