@raingor/pi-web-switch 0.4.3 → 0.4.4
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/dist/index.html +4 -4
- package/index.html +2 -2
- package/package.json +5 -3
- package/public/manifest.webmanifest +2 -2
- package/public/sw.js +51 -51
- package/server/pi-reader.ts +326 -19
- package/src/App.tsx +2 -0
- package/src/components/dashboard/DashboardPage.tsx +341 -27
- package/src/components/layout/AppShell.tsx +45 -13
- package/src/components/layout/Sidebar.tsx +78 -74
- package/src/components/providers/ProvidersModelsPage.tsx +125 -131
- package/src/components/sessions/MemoryPage.tsx +32 -12
- package/src/components/sessions/SessionsPage.tsx +18 -4
- package/src/components/settings/SettingsPage.tsx +6 -1
- package/src/components/speedtest/ModelSpeedTestPage.tsx +429 -0
- package/src/components/ui/EmptyState.tsx +7 -6
- package/src/components/ui/Modal.tsx +33 -25
- package/src/components/ui/StatCard.tsx +10 -13
- package/src/data/builtin-providers.test.ts +109 -0
- package/src/data/builtin-providers.ts +67 -44
- package/src/data/model-catalog.test.ts +122 -0
- package/src/data/model-catalog.ts +697 -478
- package/src/index.css +624 -210
- package/src/lib/translations/en.ts +88 -9
- package/src/lib/translations/ja.ts +88 -9
- package/src/lib/translations/zh-CN.ts +88 -9
- package/src/lib/translations/zh-TW.ts +87 -9
- package/src/main.tsx +99 -22
- package/vite.config.ts +62 -137
- package/src/data/mock-config.ts +0 -247
- package/src/data/mock-usage.ts +0 -151
package/dist/index.html
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
<link rel="icon" type="image/svg+xml" href="./pi.svg" />
|
|
8
8
|
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
|
|
9
9
|
<link rel="manifest" href="./manifest.webmanifest" />
|
|
10
|
-
<meta name="theme-color" content="#
|
|
10
|
+
<meta name="theme-color" content="#05090d" />
|
|
11
11
|
<meta name="description" content="Web UI for pi coding agent — configuration management, session browser, and usage dashboard" />
|
|
12
|
-
<script type="module" crossorigin src="./assets/main-
|
|
13
|
-
<link rel="stylesheet" crossorigin href="./assets/main-
|
|
12
|
+
<script type="module" crossorigin src="./assets/main-H-GOgQ3d.js"></script>
|
|
13
|
+
<link rel="stylesheet" crossorigin href="./assets/main-CpBGy80y.css">
|
|
14
14
|
</head>
|
|
15
|
-
<body
|
|
15
|
+
<body>
|
|
16
16
|
<div id="root"></div>
|
|
17
17
|
</body>
|
|
18
18
|
</html>
|
package/index.html
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
<link rel="icon" type="image/svg+xml" href="/pi.svg" />
|
|
8
8
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
9
9
|
<link rel="manifest" href="/manifest.webmanifest" />
|
|
10
|
-
<meta name="theme-color" content="#
|
|
10
|
+
<meta name="theme-color" content="#05090d" />
|
|
11
11
|
<meta name="description" content="Web UI for pi coding agent — configuration management, session browser, and usage dashboard" />
|
|
12
12
|
</head>
|
|
13
|
-
<body
|
|
13
|
+
<body>
|
|
14
14
|
<div id="root"></div>
|
|
15
15
|
<script type="module" src="/src/main.tsx"></script>
|
|
16
16
|
</body>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raingor/pi-web-switch",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.4",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.html",
|
|
7
7
|
"description": "Web UI for pi coding agent — live configuration management, session browser, and memory viewer",
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"scripts": {
|
|
27
27
|
"dev": "vite",
|
|
28
28
|
"build": "tsc -b && vite build",
|
|
29
|
-
"preview": "vite preview"
|
|
29
|
+
"preview": "vite preview",
|
|
30
|
+
"test": "vitest run"
|
|
30
31
|
},
|
|
31
32
|
"files": [
|
|
32
33
|
"pi-package",
|
|
@@ -69,7 +70,8 @@
|
|
|
69
70
|
"typescript": "~5.8.3",
|
|
70
71
|
"undici": "^8.9.0",
|
|
71
72
|
"vite": "^6.3.2",
|
|
72
|
-
"vite-plugin-pwa": "^1.3.0"
|
|
73
|
+
"vite-plugin-pwa": "^1.3.0",
|
|
74
|
+
"vitest": "3.2.4"
|
|
73
75
|
},
|
|
74
76
|
"build": {
|
|
75
77
|
"appId": "com.raingor.pi-web-switch",
|
package/public/sw.js
CHANGED
|
@@ -1,27 +1,29 @@
|
|
|
1
1
|
// ─── pi-web-switch Service Worker ───────────────────────
|
|
2
2
|
// Strategy:
|
|
3
|
-
// - HTML navigation
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
3
|
+
// - HTML navigation: NETWORK-FIRST with `cache: no-store` so Cmd+R always
|
|
4
|
+
// receives the current index.html instead of an HTTP/SW cached shell.
|
|
5
|
+
// - API calls: NETWORK-ONLY. Configuration/auth data must never be restored
|
|
6
|
+
// from a stale service-worker cache.
|
|
7
|
+
// - Static assets: CACHE-FIRST. Vite fingerprints JS/CSS filenames, so a new
|
|
8
|
+
// build receives a new URL and cannot collide with an older asset.
|
|
9
|
+
//
|
|
10
|
+
// v3 also purges the v1 cache-first HTML shell that can remain registered on
|
|
11
|
+
// the same localhost origin and cause a blank page after a normal refresh.
|
|
10
12
|
|
|
11
|
-
const CACHE_VERSION = "pi-web-switch-
|
|
13
|
+
const CACHE_VERSION = "pi-web-switch-v3";
|
|
12
14
|
const STATIC_CACHE = `${CACHE_VERSION}-static`;
|
|
13
15
|
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
|
|
16
|
+
const CACHE_PREFIX = "pi-web-switch-";
|
|
17
|
+
const SCOPE_URL = self.registration.scope;
|
|
18
|
+
const OFFLINE_INDEX_URL = new URL("./index.html", SCOPE_URL).href;
|
|
14
19
|
|
|
15
|
-
// NOTE: index.html is intentionally NOT precached. Pre-caching it would
|
|
16
|
-
// make Cmd+R reloads serve the stale page after a deploy.
|
|
17
20
|
const PRECACHE_URLS = [
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
];
|
|
21
|
+
"./manifest.webmanifest",
|
|
22
|
+
"./icon-192.png",
|
|
23
|
+
"./icon-512.png",
|
|
24
|
+
"./apple-touch-icon.png",
|
|
25
|
+
].map((path) => new URL(path, SCOPE_URL).href);
|
|
23
26
|
|
|
24
|
-
// ─── Install: precache core assets ─────────────────────
|
|
25
27
|
self.addEventListener("install", (event) => {
|
|
26
28
|
event.waitUntil(
|
|
27
29
|
caches
|
|
@@ -31,7 +33,6 @@ self.addEventListener("install", (event) => {
|
|
|
31
33
|
);
|
|
32
34
|
});
|
|
33
35
|
|
|
34
|
-
// ─── Activate: clean old caches ────────────────────────
|
|
35
36
|
self.addEventListener("activate", (event) => {
|
|
36
37
|
event.waitUntil(
|
|
37
38
|
caches
|
|
@@ -39,66 +40,65 @@ self.addEventListener("activate", (event) => {
|
|
|
39
40
|
.then((keys) =>
|
|
40
41
|
Promise.all(
|
|
41
42
|
keys
|
|
42
|
-
.filter((
|
|
43
|
-
.map((
|
|
43
|
+
.filter((key) => key.startsWith(CACHE_PREFIX) && !key.startsWith(CACHE_VERSION))
|
|
44
|
+
.map((key) => caches.delete(key))
|
|
44
45
|
)
|
|
45
46
|
)
|
|
46
47
|
.then(() => self.clients.claim())
|
|
47
48
|
);
|
|
48
49
|
});
|
|
49
50
|
|
|
50
|
-
// ─── Fetch: strategy by request type ───────────────────
|
|
51
51
|
self.addEventListener("fetch", (event) => {
|
|
52
52
|
const { request } = event;
|
|
53
|
-
|
|
54
|
-
// Only handle GET; ignore cross-origin and chrome-extension.
|
|
55
53
|
if (request.method !== "GET") return;
|
|
54
|
+
|
|
56
55
|
const url = new URL(request.url);
|
|
57
56
|
if (url.origin !== self.location.origin) return;
|
|
58
57
|
|
|
59
|
-
//
|
|
60
|
-
if (url.pathname.
|
|
61
|
-
event.respondWith(
|
|
62
|
-
fetch(request)
|
|
63
|
-
.then((response) => {
|
|
64
|
-
const copy = response.clone();
|
|
65
|
-
caches.open(RUNTIME_CACHE).then((cache) => cache.put(request, copy));
|
|
66
|
-
return response;
|
|
67
|
-
})
|
|
68
|
-
.catch(() => caches.match(request))
|
|
69
|
-
);
|
|
58
|
+
// Never cache local configuration, authentication, or usage responses.
|
|
59
|
+
if (url.pathname.includes("/api/")) {
|
|
60
|
+
event.respondWith(fetch(request, { cache: "no-store" }));
|
|
70
61
|
return;
|
|
71
62
|
}
|
|
72
63
|
|
|
73
|
-
//
|
|
74
|
-
// latest build. Fall back to the last cached copy only when offline.
|
|
64
|
+
// Always revalidate the SPA shell. The cached copy is offline fallback only.
|
|
75
65
|
if (request.mode === "navigate") {
|
|
76
66
|
event.respondWith(
|
|
77
|
-
fetch(request)
|
|
67
|
+
fetch(request, { cache: "no-store" })
|
|
78
68
|
.then((response) => {
|
|
79
|
-
if (response && response.
|
|
69
|
+
if (response && response.ok && response.type === "basic") {
|
|
80
70
|
const copy = response.clone();
|
|
81
|
-
|
|
71
|
+
event.waitUntil(
|
|
72
|
+
caches.open(RUNTIME_CACHE).then((cache) => cache.put(OFFLINE_INDEX_URL, copy))
|
|
73
|
+
);
|
|
82
74
|
}
|
|
83
75
|
return response;
|
|
84
76
|
})
|
|
85
|
-
.catch(() =>
|
|
77
|
+
.catch(async () => {
|
|
78
|
+
const cached = await caches.match(OFFLINE_INDEX_URL);
|
|
79
|
+
return cached ?? new Response("Offline", {
|
|
80
|
+
status: 503,
|
|
81
|
+
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
82
|
+
});
|
|
83
|
+
})
|
|
86
84
|
);
|
|
87
85
|
return;
|
|
88
86
|
}
|
|
89
87
|
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
88
|
+
// Fingerprinted build assets are safe to cache by URL.
|
|
89
|
+
if (url.pathname.includes("/assets/")) {
|
|
90
|
+
event.respondWith(
|
|
91
|
+
caches.match(request).then(async (cached) => {
|
|
92
|
+
if (cached) return cached;
|
|
93
|
+
const response = await fetch(request);
|
|
94
|
+
if (response && response.ok && response.type === "basic") {
|
|
95
|
+
const copy = response.clone();
|
|
96
|
+
event.waitUntil(
|
|
97
|
+
caches.open(RUNTIME_CACHE).then((cache) => cache.put(request, copy))
|
|
98
|
+
);
|
|
97
99
|
}
|
|
98
|
-
const copy = response.clone();
|
|
99
|
-
caches.open(RUNTIME_CACHE).then((cache) => cache.put(request, copy));
|
|
100
100
|
return response;
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
|
|
101
|
+
})
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
104
|
});
|
package/server/pi-reader.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { readFileSync, readdirSync, existsSync, statSync, unlinkSync, writeFileSync, mkdirSync, renameSync } from "fs";
|
|
1
|
+
import { readFileSync, readdirSync, existsSync, statSync, unlinkSync, writeFileSync, mkdirSync, renameSync, chmodSync } from "fs";
|
|
2
2
|
import { homedir, platform } from "os";
|
|
3
3
|
import { join, resolve, dirname, relative, sep } from "path";
|
|
4
4
|
import { spawnSync } from "child_process";
|
|
5
5
|
import { DatabaseSync } from "node:sqlite";
|
|
6
6
|
|
|
7
7
|
const PI_DIR = join(homedir(), ".pi", "agent");
|
|
8
|
+
const CODEX_DIR = join(homedir(), ".codex");
|
|
8
9
|
|
|
9
10
|
// ─── Cindy Pi-Agent Sessions ───────────────────────────
|
|
10
11
|
// When Cindy (the AI assistant) delegates to a pi coding agent, sessions
|
|
@@ -45,7 +46,6 @@ export function readSettings() {
|
|
|
45
46
|
export function writeSettings(settings: any): boolean {
|
|
46
47
|
try {
|
|
47
48
|
const path = piPath("settings.json");
|
|
48
|
-
const backup = existsSync(path) ? readFileSync(path, "utf-8") : null;
|
|
49
49
|
const raw = JSON.stringify(settings, null, 2);
|
|
50
50
|
writeFileSync(path, raw, "utf-8");
|
|
51
51
|
return true;
|
|
@@ -54,6 +54,175 @@ export function writeSettings(settings: any): boolean {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// ─── Official Usage Query ──────────────────────────────
|
|
58
|
+
|
|
59
|
+
export type OfficialUsageAuthMode = "auto" | "bearer" | "x-api-key" | "api-key";
|
|
60
|
+
|
|
61
|
+
export interface OfficialUsageConfig {
|
|
62
|
+
endpoint: string;
|
|
63
|
+
apiKeys: string[];
|
|
64
|
+
authMode: OfficialUsageAuthMode;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface OfficialUsageSummary {
|
|
68
|
+
total: number;
|
|
69
|
+
used: number;
|
|
70
|
+
remaining: number;
|
|
71
|
+
remainingPercent: number;
|
|
72
|
+
unit: string;
|
|
73
|
+
source: string;
|
|
74
|
+
checkedAt: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const OFFICIAL_USAGE_CONFIG_FILE = "official-usage.json";
|
|
78
|
+
|
|
79
|
+
function officialUsagePath(): string {
|
|
80
|
+
return piPath(OFFICIAL_USAGE_CONFIG_FILE);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeOfficialUsageConfig(value: any): OfficialUsageConfig {
|
|
84
|
+
const endpoint = typeof value?.endpoint === "string" ? value.endpoint.trim() : "";
|
|
85
|
+
const apiKeys = Array.isArray(value?.apiKeys)
|
|
86
|
+
? value.apiKeys.filter((key: unknown): key is string => typeof key === "string").map((key) => key.trim()).filter(Boolean)
|
|
87
|
+
: typeof value?.apiKey === "string" && value.apiKey.trim()
|
|
88
|
+
? [value.apiKey.trim()]
|
|
89
|
+
: [];
|
|
90
|
+
const authMode: OfficialUsageAuthMode = ["auto", "bearer", "x-api-key", "api-key"].includes(value?.authMode)
|
|
91
|
+
? value.authMode
|
|
92
|
+
: "auto";
|
|
93
|
+
return { endpoint, apiKeys: Array.from(new Set(apiKeys)), authMode };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function readOfficialUsageConfig(): OfficialUsageConfig {
|
|
97
|
+
try {
|
|
98
|
+
if (!existsSync(officialUsagePath())) return { endpoint: "", apiKeys: [], authMode: "auto" };
|
|
99
|
+
return normalizeOfficialUsageConfig(JSON.parse(readFileSync(officialUsagePath(), "utf-8")));
|
|
100
|
+
} catch {
|
|
101
|
+
return { endpoint: "", apiKeys: [], authMode: "auto" };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function writeOfficialUsageConfig(config: OfficialUsageConfig): boolean {
|
|
106
|
+
try {
|
|
107
|
+
const normalized = normalizeOfficialUsageConfig(config);
|
|
108
|
+
if (!normalized.endpoint || normalized.apiKeys.length === 0) return false;
|
|
109
|
+
const url = new URL(normalized.endpoint);
|
|
110
|
+
if (!/^https?:$/.test(url.protocol)) return false;
|
|
111
|
+
mkdirSync(PI_DIR, { recursive: true });
|
|
112
|
+
writeFileSync(officialUsagePath(), JSON.stringify(normalized, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
113
|
+
chmodSync(officialUsagePath(), 0o600);
|
|
114
|
+
return true;
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function officialNumber(value: unknown): number | null {
|
|
121
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
122
|
+
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value.replace(/[, ]/g, "")))) return Number(value.replace(/[, ]/g, ""));
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function findOfficialMetric(root: unknown, names: string[]): { value: number; unit?: string } | null {
|
|
127
|
+
const wanted = new Set(names.map((name) => name.toLowerCase().replace(/[^a-z0-9]/g, "")));
|
|
128
|
+
const queue: Array<{ value: unknown; path: string[] }> = [{ value: root, path: [] }];
|
|
129
|
+
while (queue.length) {
|
|
130
|
+
const current = queue.shift()!;
|
|
131
|
+
if (!current.value || typeof current.value !== "object") continue;
|
|
132
|
+
for (const [key, value] of Object.entries(current.value as Record<string, unknown>)) {
|
|
133
|
+
const normalizedKey = key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
134
|
+
const number = officialNumber(value);
|
|
135
|
+
if (number !== null && wanted.has(normalizedKey)) {
|
|
136
|
+
const parent = current.value as Record<string, unknown>;
|
|
137
|
+
const unit = typeof parent.unit === "string"
|
|
138
|
+
? parent.unit
|
|
139
|
+
: typeof parent.currency === "string"
|
|
140
|
+
? parent.currency
|
|
141
|
+
: normalizedKey.includes("usd") ? "USD" : undefined;
|
|
142
|
+
return { value: number, unit };
|
|
143
|
+
}
|
|
144
|
+
if (value && typeof value === "object") queue.push({ value, path: [...current.path, key] });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parseOfficialUsagePayload(payload: unknown, endpoint: string): OfficialUsageSummary {
|
|
151
|
+
const total = findOfficialMetric(payload, ["total", "totalquota", "quota", "limit", "usagelimit", "monthlylimit", "included"])?.value ?? null;
|
|
152
|
+
const used = findOfficialMetric(payload, ["used", "usage", "currentusage", "consumed", "spend", "spent", "utilized"])?.value ?? null;
|
|
153
|
+
const explicitRemaining = findOfficialMetric(payload, ["remaining", "remainingquota", "balance", "available", "left"])?.value ?? null;
|
|
154
|
+
const resolvedTotal = total !== null && (used !== null || explicitRemaining !== null)
|
|
155
|
+
? total
|
|
156
|
+
: used !== null && explicitRemaining !== null
|
|
157
|
+
? used + explicitRemaining
|
|
158
|
+
: Number.NaN;
|
|
159
|
+
const resolvedUsed = used ?? (resolvedTotal - (explicitRemaining ?? 0));
|
|
160
|
+
const resolvedRemaining = explicitRemaining ?? Math.max(resolvedTotal - resolvedUsed, 0);
|
|
161
|
+
if (!Number.isFinite(resolvedTotal) || resolvedTotal <= 0 || !Number.isFinite(resolvedUsed) || !Number.isFinite(resolvedRemaining)) {
|
|
162
|
+
throw new Error("Unable to find total/used/remaining quota fields in the response");
|
|
163
|
+
}
|
|
164
|
+
const remainingPercent = Math.min(100, Math.max(0, (resolvedRemaining / resolvedTotal) * 100));
|
|
165
|
+
return {
|
|
166
|
+
total: resolvedTotal,
|
|
167
|
+
used: Math.max(0, resolvedUsed),
|
|
168
|
+
remaining: Math.max(0, resolvedRemaining),
|
|
169
|
+
remainingPercent,
|
|
170
|
+
unit: findOfficialMetric(payload, ["total", "totalquota", "quota", "limit", "usagelimit", "monthlylimit", "included"])?.unit ?? "units",
|
|
171
|
+
source: endpoint,
|
|
172
|
+
checkedAt: new Date().toISOString(),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function officialEndpoint(endpoint: string, apiKey: string): string {
|
|
177
|
+
return endpoint.replace(/\{apiKey\}/gi, encodeURIComponent(apiKey));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function queryOfficialUsage(configInput: OfficialUsageConfig): Promise<OfficialUsageSummary> {
|
|
181
|
+
const config = normalizeOfficialUsageConfig(configInput);
|
|
182
|
+
if (!config.endpoint || config.apiKeys.length === 0) throw new Error("Endpoint and at least one API key are required");
|
|
183
|
+
const errors: string[] = [];
|
|
184
|
+
const results: OfficialUsageSummary[] = [];
|
|
185
|
+
for (const apiKey of config.apiKeys) {
|
|
186
|
+
const modes: OfficialUsageAuthMode[] = config.authMode === "auto" ? ["bearer", "x-api-key", "api-key"] : [config.authMode];
|
|
187
|
+
let keySucceeded = false;
|
|
188
|
+
for (const mode of modes) {
|
|
189
|
+
try {
|
|
190
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
191
|
+
if (mode === "bearer") headers.Authorization = `Bearer ${apiKey}`;
|
|
192
|
+
if (mode === "x-api-key") headers["x-api-key"] = apiKey;
|
|
193
|
+
if (mode === "api-key") headers["api-key"] = apiKey;
|
|
194
|
+
const response = await fetch(officialEndpoint(config.endpoint, apiKey), { headers, signal: AbortSignal.timeout(15_000) });
|
|
195
|
+
const text = await response.text();
|
|
196
|
+
let payload: unknown;
|
|
197
|
+
try { payload = JSON.parse(text); } catch { payload = text; }
|
|
198
|
+
if (!response.ok) {
|
|
199
|
+
errors.push(`${response.status} ${response.statusText}`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
results.push(parseOfficialUsagePayload(payload, config.endpoint));
|
|
203
|
+
keySucceeded = true;
|
|
204
|
+
break;
|
|
205
|
+
} catch (error) {
|
|
206
|
+
errors.push(error instanceof Error ? error.message : "request failed");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (!keySucceeded) continue;
|
|
210
|
+
}
|
|
211
|
+
if (results.length === 0) throw new Error(errors[0] || "Official usage query failed");
|
|
212
|
+
const total = results.reduce((sum, result) => sum + result.total, 0);
|
|
213
|
+
const used = results.reduce((sum, result) => sum + result.used, 0);
|
|
214
|
+
const remaining = results.reduce((sum, result) => sum + result.remaining, 0);
|
|
215
|
+
return {
|
|
216
|
+
total,
|
|
217
|
+
used,
|
|
218
|
+
remaining,
|
|
219
|
+
remainingPercent: total > 0 ? Math.min(100, Math.max(0, (remaining / total) * 100)) : 0,
|
|
220
|
+
unit: results.find((result) => result.unit)?.unit ?? "units",
|
|
221
|
+
source: config.endpoint,
|
|
222
|
+
checkedAt: new Date().toISOString(),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
57
226
|
// ─── Auth ───────────────────────────────────────────────
|
|
58
227
|
|
|
59
228
|
export function readAuth() {
|
|
@@ -404,23 +573,125 @@ export function readCodexUsage(): UsageRecord[] {
|
|
|
404
573
|
return allRecords;
|
|
405
574
|
}
|
|
406
575
|
|
|
407
|
-
// ───
|
|
576
|
+
// ─── ChatGPT / Codex Desktop Usage ─────────────────────
|
|
408
577
|
|
|
409
578
|
/**
|
|
410
|
-
*
|
|
411
|
-
*
|
|
579
|
+
* ChatGPT/Codex Desktop stores local rollout sessions as JSONL under
|
|
580
|
+
* ~/.codex/sessions and ~/.codex/archived_sessions. Each `token_count` event
|
|
581
|
+
* contains the usage for the latest model response, so it can be normalized
|
|
582
|
+
* into the same UsageRecord shape used by Pi sessions.
|
|
412
583
|
*/
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
584
|
+
function collectJsonlFiles(dir: string, out: string[] = []): string[] {
|
|
585
|
+
if (!existsSync(dir)) return out;
|
|
586
|
+
try {
|
|
587
|
+
for (const name of readdirSync(dir)) {
|
|
588
|
+
const path = join(dir, name);
|
|
589
|
+
try {
|
|
590
|
+
if (statSync(path).isDirectory()) collectJsonlFiles(path, out);
|
|
591
|
+
else if (name.endsWith(".jsonl")) out.push(path);
|
|
592
|
+
} catch {
|
|
593
|
+
// Ignore files that disappear while the desktop app is writing.
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
} catch {
|
|
597
|
+
// Ignore inaccessible directories.
|
|
598
|
+
}
|
|
599
|
+
return out;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function numericUsage(value: unknown): number {
|
|
603
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function modelFromCodexPayload(payload: any): string | undefined {
|
|
607
|
+
const candidates = [
|
|
608
|
+
payload?.model,
|
|
609
|
+
payload?.model_id,
|
|
610
|
+
payload?.modelId,
|
|
611
|
+
payload?.state?.model,
|
|
612
|
+
payload?.thread_settings?.model,
|
|
613
|
+
payload?.thread_settings?.collaboration_mode?.settings?.model,
|
|
614
|
+
payload?.collaboration_mode?.settings?.model,
|
|
615
|
+
payload?.item?.model,
|
|
616
|
+
payload?.item?.content?.model,
|
|
617
|
+
payload?.base_instructions?.provenance?.model,
|
|
421
618
|
];
|
|
422
|
-
|
|
423
|
-
|
|
619
|
+
return candidates.find((value) => typeof value === "string" && value.trim())?.trim();
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function parseCodexSessionFile(filePath: string): UsageRecord[] {
|
|
623
|
+
const records: UsageRecord[] = [];
|
|
624
|
+
let currentModel = "chatgpt";
|
|
625
|
+
try {
|
|
626
|
+
for (const line of readFileSync(filePath, "utf-8").split("\n")) {
|
|
627
|
+
if (!line.trim()) continue;
|
|
628
|
+
let envelope: any;
|
|
629
|
+
try {
|
|
630
|
+
envelope = JSON.parse(line);
|
|
631
|
+
} catch {
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const payload = envelope?.payload;
|
|
636
|
+
if (!payload || typeof payload !== "object") continue;
|
|
637
|
+
currentModel = modelFromCodexPayload(payload) || currentModel;
|
|
638
|
+
if (payload.type !== "token_count") continue;
|
|
639
|
+
|
|
640
|
+
const usage = payload.info?.last_token_usage;
|
|
641
|
+
if (!usage || typeof usage !== "object") continue;
|
|
642
|
+
const timestamp = typeof envelope.timestamp === "string" ? envelope.timestamp : "";
|
|
643
|
+
if (!timestamp) continue;
|
|
644
|
+
const { date, hour } = cnDateParts(timestamp);
|
|
645
|
+
const rawInputTokens = numericUsage(usage.input_tokens);
|
|
646
|
+
const cachedInputTokens = numericUsage(usage.cached_input_tokens);
|
|
647
|
+
const cacheWriteTokens = numericUsage(usage.cache_write_input_tokens);
|
|
648
|
+
// Codex reports cached/cache-write tokens as subsets of input_tokens.
|
|
649
|
+
// Split them out so the dashboard total remains raw input + output,
|
|
650
|
+
// rather than counting cached context twice.
|
|
651
|
+
const inputTokens = Math.max(rawInputTokens - cachedInputTokens - cacheWriteTokens, 0);
|
|
652
|
+
// reasoning_output_tokens is informational and already included in
|
|
653
|
+
// output_tokens (total_tokens = input_tokens + output_tokens).
|
|
654
|
+
const outputTokens = numericUsage(usage.output_tokens);
|
|
655
|
+
|
|
656
|
+
// The local format has no per-call price. Keep cost at zero rather than
|
|
657
|
+
// inventing a price for a ChatGPT subscription/Codex plan.
|
|
658
|
+
records.push({
|
|
659
|
+
date,
|
|
660
|
+
hour,
|
|
661
|
+
providerId: "chatgpt",
|
|
662
|
+
modelId: currentModel,
|
|
663
|
+
inputTokens,
|
|
664
|
+
outputTokens,
|
|
665
|
+
cacheReadTokens: cachedInputTokens,
|
|
666
|
+
cacheWriteTokens,
|
|
667
|
+
requests: 1,
|
|
668
|
+
cost: 0,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
} catch {
|
|
672
|
+
// Ignore unreadable or partially-written rollout files.
|
|
673
|
+
}
|
|
674
|
+
return records;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const CODEX_USAGE_TTL_MS = 30_000;
|
|
678
|
+
let codexUsageCache: { records: UsageRecord[]; at: number } | null = null;
|
|
679
|
+
|
|
680
|
+
export function readChatgptUsage(): UsageRecord[] {
|
|
681
|
+
if (codexUsageCache && Date.now() - codexUsageCache.at < CODEX_USAGE_TTL_MS) {
|
|
682
|
+
return codexUsageCache.records;
|
|
683
|
+
}
|
|
684
|
+
const files = [
|
|
685
|
+
...collectJsonlFiles(join(CODEX_DIR, "sessions")),
|
|
686
|
+
...collectJsonlFiles(join(CODEX_DIR, "archived_sessions")),
|
|
687
|
+
];
|
|
688
|
+
const records = files.flatMap(parseCodexSessionFile).sort((a, b) => a.date.localeCompare(b.date));
|
|
689
|
+
codexUsageCache = { records, at: Date.now() };
|
|
690
|
+
return records;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
export function clearChatgptUsageCache(): void {
|
|
694
|
+
codexUsageCache = null;
|
|
424
695
|
}
|
|
425
696
|
|
|
426
697
|
// ─── AtomCode Usage ────────────────────────────────────
|
|
@@ -635,9 +906,13 @@ export interface ProviderFilter {
|
|
|
635
906
|
|
|
636
907
|
export const PROVIDER_FILTERS: ProviderFilter[] = [
|
|
637
908
|
{
|
|
638
|
-
id: "
|
|
639
|
-
label: "
|
|
640
|
-
|
|
909
|
+
id: "chatgpt",
|
|
910
|
+
label: "ChatGPT",
|
|
911
|
+
// ChatGPT/OpenAI model calls can be recorded under a direct OpenAI
|
|
912
|
+
// provider or behind a compatible gateway. Match provider and model names
|
|
913
|
+
// so the dashboard can surface them as one source without changing the
|
|
914
|
+
// original records used by the default Pi view.
|
|
915
|
+
patterns: [/^(openai|chatgpt|openai-chatgpt)$/i, /chatgpt/i, /openai/i, /^gpt[-_]/i, /^o[1345](?:[-_]|$)/i],
|
|
641
916
|
},
|
|
642
917
|
{
|
|
643
918
|
id: "atomcode",
|
|
@@ -1199,6 +1474,37 @@ function walkJsonl(dir: string, out: string[]): void {
|
|
|
1199
1474
|
}
|
|
1200
1475
|
}
|
|
1201
1476
|
|
|
1477
|
+
const STALE_SESSION_DAYS = 14;
|
|
1478
|
+
|
|
1479
|
+
/**
|
|
1480
|
+
* Move sessions with no activity for more than 14 days into the recoverable
|
|
1481
|
+
* trash. The last message/session timestamp is preferred; file mtime is the
|
|
1482
|
+
* fallback for malformed or very old session files without timestamps.
|
|
1483
|
+
*/
|
|
1484
|
+
export function autoTrashStaleSessions(maxAgeDays = STALE_SESSION_DAYS): { moved: number; paths: string[] } {
|
|
1485
|
+
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
|
|
1486
|
+
const files: string[] = [];
|
|
1487
|
+
walkJsonl(SESSIONS_DIR, files);
|
|
1488
|
+
const moved: string[] = [];
|
|
1489
|
+
|
|
1490
|
+
for (const filePath of files) {
|
|
1491
|
+
let lastActiveMs = 0;
|
|
1492
|
+
const info = parseSessionFileInfo(filePath);
|
|
1493
|
+
if (info?.lastActive) lastActiveMs = new Date(info.lastActive).getTime();
|
|
1494
|
+
if (!Number.isFinite(lastActiveMs) || lastActiveMs <= 0) {
|
|
1495
|
+
try {
|
|
1496
|
+
lastActiveMs = statSync(filePath).mtimeMs;
|
|
1497
|
+
} catch {
|
|
1498
|
+
continue;
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
if (lastActiveMs >= cutoff) continue;
|
|
1502
|
+
if (trashSessionFile(filePath)) moved.push(filePath);
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
return { moved: moved.length, paths: moved };
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1202
1508
|
/** Move a session file into the trash, preserving its path relative to the sessions dir. */
|
|
1203
1509
|
export function trashSessionFile(filePath: string): boolean {
|
|
1204
1510
|
try {
|
|
@@ -1681,7 +1987,8 @@ function heuristicFlags(id: string): { reasoning?: boolean; vision?: boolean; au
|
|
|
1681
1987
|
const vision = VISION_RE.test(k);
|
|
1682
1988
|
const audio = AUDIO_RE.test(k);
|
|
1683
1989
|
let contextWindow: number | undefined;
|
|
1684
|
-
if (/[-_](
|
|
1990
|
+
if (/deepseek[-_]v4[-_](flash|chat)(?:[-_:]|$)/i.test(k)) contextWindow = 1_048_576;
|
|
1991
|
+
else if (/[-_](1m|1024k|1048576)\b/i.test(k)) contextWindow = 1_048_576;
|
|
1685
1992
|
else if (/[-_](256k)\b/i.test(k)) contextWindow = 262_144;
|
|
1686
1993
|
else if (/[-_](128k)\b/i.test(k)) contextWindow = 131_072;
|
|
1687
1994
|
else if (/[-_](64k)\b/i.test(k)) contextWindow = 65_536;
|
package/src/App.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { MemoryPage } from "@/components/sessions/MemoryPage";
|
|
|
6
6
|
import { ProvidersModelsPage } from "@/components/providers/ProvidersModelsPage";
|
|
7
7
|
import { SubagentsPage } from "@/components/subagents/SubagentsPage";
|
|
8
8
|
import { SettingsPage } from "@/components/settings/SettingsPage";
|
|
9
|
+
import { ModelSpeedTestPage } from "@/components/speedtest/ModelSpeedTestPage";
|
|
9
10
|
|
|
10
11
|
export default function App() {
|
|
11
12
|
return (
|
|
@@ -19,6 +20,7 @@ export default function App() {
|
|
|
19
20
|
<Route path="/models" element={<ProvidersModelsPage />} />
|
|
20
21
|
<Route path="/subagents" element={<SubagentsPage />} />
|
|
21
22
|
<Route path="/settings" element={<SettingsPage />} />
|
|
23
|
+
<Route path="/speed-test" element={<ModelSpeedTestPage />} />
|
|
22
24
|
</Route>
|
|
23
25
|
</Routes>
|
|
24
26
|
</BrowserRouter>
|