@fyeeme/pi-statusline 1.0.0 → 1.0.2
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 +37 -0
- package/README.md +1 -1
- package/cache.ts +34 -10
- package/footer.ts +4 -12
- package/index.ts +1 -11
- package/package.json +1 -2
- package/providers/deepseek.ts +0 -3
- package/providers/types.ts +0 -2
- package/providers/zai.ts +9 -13
- package/session-scanner.ts +19 -9
- package/week.ts +30 -0
- package/quota/deepseek.ts +0 -31
- package/quota/types.ts +0 -34
- package/quota/zai.ts +0 -90
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.0.2] - 2025-07-15
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- `getCachedUsage` crash when TTL expires and model provider has no registered UsageProvider (TypeError on null)
|
|
15
|
+
- `scanWeeklyTokens` UTC/local timezone mismatch: file date parsing now uses local timezone to match `startOfCurrentWeekLocal`
|
|
16
|
+
- Unhandled rejection in `refreshUsage` fire-and-forget callers: added catch-all error handler
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Background usage refresh now deduplicates concurrent TTL-expired callers via memoized `refreshInFlight` promise
|
|
21
|
+
- Weekly token scan results cached for 1 minute to reduce redundant filesystem I/O
|
|
22
|
+
|
|
23
|
+
### Removed
|
|
24
|
+
|
|
25
|
+
- MCP server status segment from footer and its event listeners (`mcp:status`/`mcp:disconnect`): no pi version emits these events
|
|
26
|
+
|
|
27
|
+
## [1.0.1] - 2026-06-19
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- ZAI weekly token count for plans without a `unit:6` weekly quota now comes from the backend `model-usage` API for the current natural week (Mon 00:00 UTC → now) instead of scanning local session files, so it matches the backend's per-week tally. Footer label changed from `7d:` to `W:` to reflect the natural-week window.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- Natural-week window (DeepSeek local scan and ZAI fallback) now uses the host's local timezone instead of UTC, so "this week" matches the user's expectation. Both providers share the same boundary via `startOfCurrentWeekLocal`.
|
|
36
|
+
|
|
37
|
+
### Removed
|
|
38
|
+
|
|
39
|
+
- `quota/` directory (`QuotaCalculator` and its DeepSeek/ZAI implementations) and the `quotaCalculator` field on `UsageProvider`. These were never invoked at runtime; the live logic lives in the providers directly.
|
|
40
|
+
|
|
41
|
+
## [1.0.1] - 2026-06-19
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- Tighten tokens-per-second spacing in the footer timing segment: render `39.5tok/s` instead of `39.5 tok/s`.
|
|
46
|
+
|
|
10
47
|
## [1.0.0] - 2025-05-31
|
|
11
48
|
|
|
12
49
|
### Added
|
package/README.md
CHANGED
|
@@ -59,7 +59,7 @@ See the Pi Packages guide on [pi.dev](https://pi.dev) for the full list of sourc
|
|
|
59
59
|
|
|
60
60
|
```
|
|
61
61
|
~/projects/my-repo (main) deepseek-v3 · xhigh
|
|
62
|
-
in 12k, out 8k, cache 45k, total 65k · ¥0.12/50.00 · 12.3%/64k · 45s 38.
|
|
62
|
+
in 12k, out 8k, cache 45k, total 65k · ¥0.12/50.00 · 12.3%/64k · 45s 38.2tok/s · MCP:2(15)
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
Line 1: cwd + git branch (left) | model + thinking level (right)
|
package/cache.ts
CHANGED
|
@@ -12,9 +12,13 @@ interface CacheEntry {
|
|
|
12
12
|
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
13
13
|
|
|
14
14
|
let usageCache: CacheEntry | null = null;
|
|
15
|
+
let refreshInFlight: Promise<ProviderUsageResult> | null = null;
|
|
16
|
+
let refreshGeneration = 0;
|
|
15
17
|
|
|
16
18
|
export function resetUsageCache(): void {
|
|
17
19
|
usageCache = null;
|
|
20
|
+
refreshInFlight = null;
|
|
21
|
+
refreshGeneration += 1;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
export function getUsageCache(): CacheEntry | null {
|
|
@@ -33,13 +37,22 @@ export async function refreshUsage(
|
|
|
33
37
|
return null;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
usageCache
|
|
40
|
+
const gen = ++refreshGeneration;
|
|
41
|
+
try {
|
|
42
|
+
const result = await provider.fetchUsage(modelRegistry, model);
|
|
43
|
+
// Drop stale responses from older generations
|
|
44
|
+
if (gen !== refreshGeneration) return usageCache?.result ?? null;
|
|
45
|
+
if (result) {
|
|
46
|
+
usageCache = { result, fetchedAt: Date.now() };
|
|
47
|
+
} else {
|
|
48
|
+
usageCache = null;
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
} catch {
|
|
52
|
+
// Total catch: fire-and-forget callers must never see an unhandled rejection
|
|
53
|
+
if (gen === refreshGeneration) usageCache = null;
|
|
54
|
+
return null;
|
|
41
55
|
}
|
|
42
|
-
return result;
|
|
43
56
|
}
|
|
44
57
|
|
|
45
58
|
export function getUsageCacheAge(): number | null {
|
|
@@ -53,11 +66,22 @@ export function getCachedUsage(
|
|
|
53
66
|
lastModel: ExtensionContext["model"] | undefined,
|
|
54
67
|
): ProviderUsageResult {
|
|
55
68
|
if (!usageCache) return null;
|
|
56
|
-
|
|
57
|
-
|
|
69
|
+
// Capture local snapshot before triggering background refresh: refreshUsage
|
|
70
|
+
// may synchronously set usageCache = null when the provider is not found,
|
|
71
|
+
// which would make the eventual `return cache.result` throw on null.
|
|
72
|
+
const cache = usageCache;
|
|
73
|
+
if (Date.now() - cache.fetchedAt > CACHE_TTL_MS) {
|
|
74
|
+
// TTL expired: return stale data but trigger background refresh.
|
|
75
|
+
// Memoize inflight promise so concurrent callers share one fetch.
|
|
58
76
|
if (lastCtx && lastModel) {
|
|
59
|
-
|
|
77
|
+
if (!refreshInFlight) {
|
|
78
|
+
const promise = refreshUsage(providers, lastCtx.modelRegistry, lastModel);
|
|
79
|
+
refreshInFlight = promise;
|
|
80
|
+
promise.finally(() => {
|
|
81
|
+
if (refreshInFlight === promise) refreshInFlight = null;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
60
84
|
}
|
|
61
85
|
}
|
|
62
|
-
return
|
|
86
|
+
return cache.result;
|
|
63
87
|
}
|
package/footer.ts
CHANGED
|
@@ -75,7 +75,6 @@ export function buildStatLine(
|
|
|
75
75
|
providers: Record<string, UsageProvider>,
|
|
76
76
|
getElapsedSec: () => number,
|
|
77
77
|
lastTps: number,
|
|
78
|
-
mcpStatuses: Map<string, { connected: boolean; toolCount: number }>,
|
|
79
78
|
): string {
|
|
80
79
|
const mods: string[] = [];
|
|
81
80
|
|
|
@@ -122,24 +121,17 @@ export function buildStatLine(
|
|
|
122
121
|
);
|
|
123
122
|
}
|
|
124
123
|
|
|
125
|
-
// timing: 2m30s 39.
|
|
124
|
+
// timing: 2m30s 39.5tok/s
|
|
126
125
|
{
|
|
127
126
|
const t: string[] = [];
|
|
128
127
|
const elapsed = getElapsedSec();
|
|
129
128
|
if (elapsed > 0) t.push(formatDuration(elapsed));
|
|
130
|
-
if (lastTps > 0) t.push(`${lastTps.toFixed(1)}
|
|
129
|
+
if (lastTps > 0) t.push(`${lastTps.toFixed(1)}tok/s`);
|
|
131
130
|
if (t.length) mods.push(t.join(" "));
|
|
132
131
|
}
|
|
133
132
|
|
|
134
|
-
// MCP
|
|
135
|
-
|
|
136
|
-
const entries = Array.from(mcpStatuses.entries());
|
|
137
|
-
if (entries.length) {
|
|
138
|
-
const connected = entries.filter(([, s]) => s.connected).length;
|
|
139
|
-
const tools = entries.reduce((sum, [, s]) => sum + s.toolCount, 0);
|
|
140
|
-
mods.push(connected > 0 ? `MCP:${connected}(${tools})` : "MCP:0");
|
|
141
|
-
}
|
|
142
|
-
}
|
|
133
|
+
// MCP status: no pi version emits mcp:status/mcp:disconnect events.
|
|
134
|
+
// The segment is disabled until a documented event source exists.
|
|
143
135
|
|
|
144
136
|
return mods.join(" · ");
|
|
145
137
|
}
|
package/index.ts
CHANGED
|
@@ -47,8 +47,6 @@ let lastElapsedSec = 0;
|
|
|
47
47
|
let lastTps = 0;
|
|
48
48
|
let currencyOverride: "¥" | "$" | undefined = undefined;
|
|
49
49
|
|
|
50
|
-
const mcpStatuses = new Map<string, { connected: boolean; toolCount: number }>();
|
|
51
|
-
|
|
52
50
|
const tokenCalculator = new SessionTokenUsageCalculator(() => currencyOverride);
|
|
53
51
|
|
|
54
52
|
// ---------------------------------------------------------------------------
|
|
@@ -99,14 +97,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
99
97
|
}
|
|
100
98
|
});
|
|
101
99
|
|
|
102
|
-
pi.events.on("mcp:status", (raw) => {
|
|
103
|
-
const data = raw as { id: string; connected: boolean; toolCount: number };
|
|
104
|
-
mcpStatuses.set(data.id, { connected: data.connected, toolCount: data.toolCount });
|
|
105
|
-
});
|
|
106
|
-
pi.events.on("mcp:disconnect", (raw) => {
|
|
107
|
-
mcpStatuses.delete((raw as { id: string }).id);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
100
|
// ---- /status-debug ----
|
|
111
101
|
pi.registerCommand("status-debug", {
|
|
112
102
|
description: "Dump session stats to /tmp/pi-status-debug.log + console.error",
|
|
@@ -235,7 +225,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
235
225
|
|
|
236
226
|
// Line 1: tokens + cost + provider usage + context + elapsed + tps + mcp
|
|
237
227
|
lines.push(truncateToWidth(
|
|
238
|
-
dim(buildStatLine(stats, cu, providerResult, providers, getElapsedSec, lastTps
|
|
228
|
+
dim(buildStatLine(stats, cu, providerResult, providers, getElapsedSec, lastTps)),
|
|
239
229
|
width,
|
|
240
230
|
dim("..."),
|
|
241
231
|
));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fyeeme/pi-statusline",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Rich custom status bar for pi with token usage, cost, DeepSeek balance, context window, timing, and MCP connection status.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,7 +34,6 @@
|
|
|
34
34
|
"files": [
|
|
35
35
|
"*.ts",
|
|
36
36
|
"providers/*.ts",
|
|
37
|
-
"quota/*.ts",
|
|
38
37
|
"README.md",
|
|
39
38
|
"LICENSE",
|
|
40
39
|
"CHANGELOG.md"
|
package/providers/deepseek.ts
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { DeepSeekResult, ProviderUsageResult } from "../types.ts";
|
|
3
3
|
import type { UsageProvider } from "./types.ts";
|
|
4
|
-
import type { QuotaCalculator } from "../quota/types.ts";
|
|
5
|
-
import { DeepSeekQuotaCalculator } from "../quota/deepseek.ts";
|
|
6
4
|
import { scanWeeklyTokens } from "../session-scanner.ts";
|
|
7
5
|
import { fmt } from "../footer.ts";
|
|
8
6
|
|
|
9
7
|
export class DeepSeekUsageProvider implements UsageProvider {
|
|
10
|
-
quotaCalculator: QuotaCalculator = new DeepSeekQuotaCalculator();
|
|
11
8
|
|
|
12
9
|
async fetchUsage(
|
|
13
10
|
modelRegistry: ExtensionContext["modelRegistry"],
|
package/providers/types.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { ProviderUsageResult } from "../types.ts";
|
|
3
|
-
import type { QuotaCalculator } from "../quota/types.ts";
|
|
4
3
|
|
|
5
4
|
export interface UsageProvider {
|
|
6
5
|
fetchUsage(
|
|
@@ -9,5 +8,4 @@ export interface UsageProvider {
|
|
|
9
8
|
): Promise<ProviderUsageResult>;
|
|
10
9
|
formatForFooter(result: NonNullable<ProviderUsageResult>, sessionCost: number, currency: string): string;
|
|
11
10
|
debugDump(result: NonNullable<ProviderUsageResult>, w: (s: string) => void): void;
|
|
12
|
-
quotaCalculator: QuotaCalculator;
|
|
13
11
|
}
|
package/providers/zai.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { ZaiResult, ProviderUsageResult } from "../types.ts";
|
|
3
3
|
import type { UsageProvider } from "./types.ts";
|
|
4
|
-
import type { QuotaCalculator } from "../quota/types.ts";
|
|
5
|
-
import { ZaiQuotaCalculator } from "../quota/zai.ts";
|
|
6
4
|
import { fmt, formatCountdown, formatWeeklyCountdown } from "../footer.ts";
|
|
7
|
-
import {
|
|
5
|
+
import { startOfCurrentWeekLocal, startOfNextWeekLocal } from "../week.ts";
|
|
8
6
|
|
|
9
7
|
/** Check if the provider name is a ZAI/GLM variant. */
|
|
10
8
|
function isZaiProvider(provider: string): boolean {
|
|
@@ -12,7 +10,6 @@ function isZaiProvider(provider: string): boolean {
|
|
|
12
10
|
}
|
|
13
11
|
|
|
14
12
|
export class ZaiUsageProvider implements UsageProvider {
|
|
15
|
-
quotaCalculator: QuotaCalculator = new ZaiQuotaCalculator();
|
|
16
13
|
|
|
17
14
|
async fetchUsage(
|
|
18
15
|
modelRegistry: ExtensionContext["modelRegistry"],
|
|
@@ -75,16 +72,15 @@ export class ZaiUsageProvider implements UsageProvider {
|
|
|
75
72
|
const cycleStart = weeklyResetAt - 7 * 24 * 60 * 60 * 1000;
|
|
76
73
|
weeklyTokens = await this.fetchCycleUsage(origin, headers, cycleStart, now);
|
|
77
74
|
} else {
|
|
78
|
-
// No unit:6 — natural week fallback
|
|
75
|
+
// No unit:6 — natural week fallback. Fetch real usage from the
|
|
76
|
+
// model-usage API for the current natural week (Mon 00:00 local → now)
|
|
77
|
+
// so the figure matches the backend's per-week tally instead of
|
|
78
|
+
// only counting tokens from local session files.
|
|
79
79
|
isNaturalWeek = true;
|
|
80
|
-
weeklyTokens = scanWeeklyTokens(model.provider);
|
|
81
|
-
|
|
82
80
|
const d = new Date(now);
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + daysUntilMonday, 0, 0, 0, 0,
|
|
87
|
-
);
|
|
81
|
+
const cycleStart = startOfCurrentWeekLocal(d);
|
|
82
|
+
weeklyResetAt = startOfNextWeekLocal(d);
|
|
83
|
+
weeklyTokens = await this.fetchCycleUsage(origin, headers, cycleStart, now);
|
|
88
84
|
}
|
|
89
85
|
|
|
90
86
|
return {
|
|
@@ -114,7 +110,7 @@ export class ZaiUsageProvider implements UsageProvider {
|
|
|
114
110
|
// Weekly quota
|
|
115
111
|
if (zai.isNaturalWeek) {
|
|
116
112
|
if (zai.weeklyTokens > 0) {
|
|
117
|
-
parts.push(`
|
|
113
|
+
parts.push(`W:${fmt(zai.weeklyTokens)}`);
|
|
118
114
|
}
|
|
119
115
|
} else if (zai.weeklyTokens > 0 || zai.weeklyPct > 0) {
|
|
120
116
|
const weeklyCountdown = formatWeeklyCountdown(zai.weeklyResetAt);
|
package/session-scanner.ts
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
import { readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { startOfCurrentWeekLocal } from "./week.ts";
|
|
5
|
+
|
|
6
|
+
const SCAN_CACHE_TTL_MS = 60_000; // 1 minute
|
|
7
|
+
let scanCache: { providerName: string; sessionsDir: string; weekStartMs: number; total: number; fetchedAt: number } | null = null;
|
|
4
8
|
|
|
5
9
|
export function scanWeeklyTokens(providerName: string): number {
|
|
6
10
|
const sessionsDir = join(getAgentDir(), "sessions");
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
const weekStartMs = startOfCurrentWeekLocal();
|
|
12
|
+
|
|
13
|
+
// Return cached result if still valid for the same week window and sessions dir
|
|
14
|
+
if (
|
|
15
|
+
scanCache &&
|
|
16
|
+
scanCache.providerName === providerName &&
|
|
17
|
+
scanCache.sessionsDir === sessionsDir &&
|
|
18
|
+
scanCache.weekStartMs === weekStartMs &&
|
|
19
|
+
Date.now() - scanCache.fetchedAt < SCAN_CACHE_TTL_MS
|
|
20
|
+
) {
|
|
21
|
+
return scanCache.total;
|
|
22
|
+
}
|
|
14
23
|
|
|
15
24
|
let total = 0;
|
|
16
25
|
|
|
@@ -28,8 +37,8 @@ export function scanWeeklyTokens(providerName: string): number {
|
|
|
28
37
|
for (const fname of files) {
|
|
29
38
|
if (!fname.endsWith(".jsonl")) continue;
|
|
30
39
|
try {
|
|
31
|
-
const fileDate = new Date(fname.slice(0, 10) + "T00:00:
|
|
32
|
-
if (fileDate <
|
|
40
|
+
const fileDate = new Date(fname.slice(0, 10) + "T00:00:00");
|
|
41
|
+
if (fileDate.getTime() < weekStartMs) continue;
|
|
33
42
|
} catch {
|
|
34
43
|
continue;
|
|
35
44
|
}
|
|
@@ -60,5 +69,6 @@ export function scanWeeklyTokens(providerName: string): number {
|
|
|
60
69
|
// sessions dir doesn't exist or is unreadable
|
|
61
70
|
}
|
|
62
71
|
|
|
72
|
+
scanCache = { providerName, sessionsDir, weekStartMs, total, fetchedAt: Date.now() };
|
|
63
73
|
return total;
|
|
64
74
|
}
|
package/week.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Natural-week window helpers.
|
|
3
|
+
*
|
|
4
|
+
* Both DeepSeek (local session scan) and ZAI (backend usage API) derive their
|
|
5
|
+
* weekly window from here, so the two providers always agree on the same
|
|
6
|
+
* natural-week boundary — Monday 00:00 in the host's local timezone.
|
|
7
|
+
*
|
|
8
|
+
* Note: UTC was used previously; local timezone is now intentional so the
|
|
9
|
+
* figures line up with how users perceive "this week".
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Epoch ms of the start of the current natural week (this Monday 00:00 local).
|
|
16
|
+
* `now` is overridable for testing.
|
|
17
|
+
*/
|
|
18
|
+
export function startOfCurrentWeekLocal(now: Date = new Date()): number {
|
|
19
|
+
const dayOfWeek = now.getDay(); // 0=Sun .. 6=Sat
|
|
20
|
+
const mondayOffset = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
|
21
|
+
return new Date(
|
|
22
|
+
now.getFullYear(), now.getMonth(), now.getDate() - mondayOffset,
|
|
23
|
+
0, 0, 0, 0,
|
|
24
|
+
).getTime();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Epoch ms of the start of the next natural week (next Monday 00:00 local). */
|
|
28
|
+
export function startOfNextWeekLocal(now: Date = new Date()): number {
|
|
29
|
+
return startOfCurrentWeekLocal(now) + 7 * DAY_MS;
|
|
30
|
+
}
|
package/quota/deepseek.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { QuotaCalculator, QuotaInfo, WeeklyQuota } from "./types.ts";
|
|
3
|
-
import { scanWeeklyTokens } from "../session-scanner.ts";
|
|
4
|
-
|
|
5
|
-
export class DeepSeekQuotaCalculator implements QuotaCalculator {
|
|
6
|
-
async calculate(
|
|
7
|
-
_modelRegistry: ExtensionContext["modelRegistry"],
|
|
8
|
-
_model: ExtensionContext["model"],
|
|
9
|
-
): Promise<QuotaInfo> {
|
|
10
|
-
const weeklyTokens = scanWeeklyTokens("deepseek");
|
|
11
|
-
|
|
12
|
-
// Next Monday 00:00 UTC
|
|
13
|
-
const now = new Date();
|
|
14
|
-
const dayOfWeek = now.getUTCDay();
|
|
15
|
-
const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
|
|
16
|
-
const nextMonday = new Date(
|
|
17
|
-
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + daysUntilMonday, 0, 0, 0, 0),
|
|
18
|
-
);
|
|
19
|
-
|
|
20
|
-
const weekly: WeeklyQuota = {
|
|
21
|
-
usedTokens: weeklyTokens,
|
|
22
|
-
resetAt: nextMonday.getTime(),
|
|
23
|
-
crossesNaturalWeek: false,
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
return {
|
|
27
|
-
rolling: null,
|
|
28
|
-
weekly,
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
}
|
package/quota/types.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
|
|
3
|
-
/** Rolling quota window (e.g. ZAI 5-hour token quota) */
|
|
4
|
-
export interface RollingQuota {
|
|
5
|
-
/** Usage percentage 0–100 */
|
|
6
|
-
usedPct: number;
|
|
7
|
-
/** Epoch ms when the window resets */
|
|
8
|
-
resetAt: number;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/** Weekly subscription quota */
|
|
12
|
-
export interface WeeklyQuota {
|
|
13
|
-
/** Total tokens used in the subscription week */
|
|
14
|
-
usedTokens: number;
|
|
15
|
-
/** Epoch ms when the subscription week resets */
|
|
16
|
-
resetAt: number;
|
|
17
|
-
/** Whether resetAt crosses a natural week boundary */
|
|
18
|
-
crossesNaturalWeek: boolean;
|
|
19
|
-
/** Usage percentage from unit:6 TOKENS_LIMIT (0 if natural week) */
|
|
20
|
-
weeklyPct?: number;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Provider-specific quota. Fields are null when the provider doesn't support them. */
|
|
24
|
-
export interface QuotaInfo {
|
|
25
|
-
rolling: RollingQuota | null;
|
|
26
|
-
weekly: WeeklyQuota | null;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface QuotaCalculator {
|
|
30
|
-
calculate(
|
|
31
|
-
modelRegistry: ExtensionContext["modelRegistry"],
|
|
32
|
-
model: ExtensionContext["model"],
|
|
33
|
-
): Promise<QuotaInfo>;
|
|
34
|
-
}
|
package/quota/zai.ts
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { QuotaCalculator, QuotaInfo, WeeklyQuota } from "./types.ts";
|
|
3
|
-
import { scanWeeklyTokens } from "../session-scanner.ts";
|
|
4
|
-
|
|
5
|
-
export class ZaiQuotaCalculator implements QuotaCalculator {
|
|
6
|
-
async calculate(
|
|
7
|
-
modelRegistry: ExtensionContext["modelRegistry"],
|
|
8
|
-
model: ExtensionContext["model"],
|
|
9
|
-
): Promise<QuotaInfo> {
|
|
10
|
-
if (!model || (model.provider !== "zai" && model.provider !== "zai-coding-cn")) {
|
|
11
|
-
return { rolling: null, weekly: null };
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const apiKey = await modelRegistry.getApiKeyForProvider(model.provider);
|
|
15
|
-
if (!apiKey) return { rolling: null, weekly: null };
|
|
16
|
-
|
|
17
|
-
const defaultOrigin = model.provider === "zai-coding-cn"
|
|
18
|
-
? "https://open.bigmodel.cn"
|
|
19
|
-
: "https://api.z.ai";
|
|
20
|
-
let origin = defaultOrigin;
|
|
21
|
-
try { origin = new URL(model.baseUrl ?? defaultOrigin).origin; } catch { /* use default */ }
|
|
22
|
-
|
|
23
|
-
const headers: Record<string, string> = {
|
|
24
|
-
Authorization: apiKey,
|
|
25
|
-
"Accept-Language": "en-US,en",
|
|
26
|
-
"Content-Type": "application/json",
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
let rolling: QuotaInfo["rolling"] = null;
|
|
30
|
-
let weekly: QuotaInfo["weekly"] = null;
|
|
31
|
-
|
|
32
|
-
try {
|
|
33
|
-
const quotaRes = await fetch(`${origin}/api/monitor/usage/quota/limit`, {
|
|
34
|
-
headers,
|
|
35
|
-
signal: AbortSignal.timeout(5000),
|
|
36
|
-
});
|
|
37
|
-
if (quotaRes.ok) {
|
|
38
|
-
const quotaData = (await quotaRes.json()) as {
|
|
39
|
-
data?: {
|
|
40
|
-
limits?: Array<{
|
|
41
|
-
type: string;
|
|
42
|
-
unit?: number;
|
|
43
|
-
percentage?: number;
|
|
44
|
-
nextResetTime?: number;
|
|
45
|
-
}>;
|
|
46
|
-
};
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const limits = quotaData.data?.limits ?? [];
|
|
50
|
-
const fiveHourLimit = limits.find((l) => l.type === "TOKENS_LIMIT" && l.unit === 3);
|
|
51
|
-
const weeklyLimit = limits.find((l) => l.type === "TOKENS_LIMIT" && l.unit === 6);
|
|
52
|
-
|
|
53
|
-
if (fiveHourLimit) {
|
|
54
|
-
rolling = {
|
|
55
|
-
usedPct: fiveHourLimit.percentage ?? 0,
|
|
56
|
-
resetAt: fiveHourLimit.nextResetTime ?? 0,
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
if (weeklyLimit && weeklyLimit.nextResetTime && weeklyLimit.nextResetTime > 0) {
|
|
61
|
-
// unit:6 — weekly quota from API
|
|
62
|
-
weekly = {
|
|
63
|
-
usedTokens: 0, // actual token count fetched separately by provider
|
|
64
|
-
resetAt: weeklyLimit.nextResetTime ?? 0,
|
|
65
|
-
crossesNaturalWeek: true,
|
|
66
|
-
weeklyPct: weeklyLimit.percentage ?? 0,
|
|
67
|
-
};
|
|
68
|
-
} else {
|
|
69
|
-
// No unit:6 — natural week fallback
|
|
70
|
-
const weeklyTokens = scanWeeklyTokens(model.provider);
|
|
71
|
-
const now = new Date();
|
|
72
|
-
const dayOfWeek = now.getUTCDay();
|
|
73
|
-
const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
|
|
74
|
-
const nextMonday = new Date(
|
|
75
|
-
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + daysUntilMonday, 0, 0, 0, 0),
|
|
76
|
-
);
|
|
77
|
-
weekly = {
|
|
78
|
-
usedTokens: weeklyTokens,
|
|
79
|
-
resetAt: nextMonday.getTime(),
|
|
80
|
-
crossesNaturalWeek: false,
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
} catch {
|
|
85
|
-
// quota unavailable
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
return { rolling, weekly };
|
|
89
|
-
}
|
|
90
|
-
}
|