@wayner6/pi-usage 0.1.0
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 +89 -0
- package/SECURITY.md +15 -0
- package/index.ts +1 -0
- package/package.json +31 -0
- package/src/core/cache.ts +44 -0
- package/src/core/config.ts +77 -0
- package/src/core/security.ts +49 -0
- package/src/core/types.ts +67 -0
- package/src/index.ts +142 -0
- package/src/modules/provider/adapters/anthropic.ts +209 -0
- package/src/modules/provider/adapters/cliproxy-pi-bridge.ts +76 -0
- package/src/modules/provider/adapters/deepseek.ts +66 -0
- package/src/modules/provider/adapters/glm.ts +291 -0
- package/src/modules/provider/adapters/openai-codex.ts +196 -0
- package/src/modules/provider/adapters/xai.ts +178 -0
- package/src/modules/provider/controller.ts +147 -0
- package/src/modules/provider/matching.ts +228 -0
- package/src/settings.ts +23 -0
- package/src/ui/details.ts +26 -0
- package/src/ui/format.ts +62 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wayne and Pi Usage contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Pi Usage
|
|
2
|
+
|
|
3
|
+
**Real-time provider balances, quota windows, and rate limits for [Pi](https://github.com/earendil-works/pi-mono) and [pi-web](https://github.com/agegr/pi-web).**
|
|
4
|
+
|
|
5
|
+
[简体中文文档](./README_zh.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Supported Providers & Authentication Types
|
|
10
|
+
|
|
11
|
+
| Provider / Target | Auth Type | Monitored Quota / Balance Data |
|
|
12
|
+
| :--- | :--- | :--- |
|
|
13
|
+
| **OpenAI Codex** | **OAuth** (ChatGPT Plus / Pro) | Official 5-hour and 7-day sliding quota windows with reset countdowns |
|
|
14
|
+
| **xAI / Grok** | **OAuth** | Identity verification and active spending/credit limit status probe |
|
|
15
|
+
| **Anthropic Claude** | **OAuth** / **API Key** | Claude Pro/Max OAuth subscription detection or official API Key rate limits via headers |
|
|
16
|
+
| **DeepSeek Direct** | **API Key** | Exact account monetary balance (granted + topped-up in CNY/USD) |
|
|
17
|
+
| **GLM / 智谱 BigModel** | **API Key** | Coding Plan multi-tier sliding windows (5h, 7d, MCP); notice displayed if using standard Pay-as-you-go key (official API only supports quota display for Coding Plan) |
|
|
18
|
+
| **CLIProxyAPI Proxies** | **API Key / Bridge** | Upstream quota windows and reset intervals via server-side [`pi-bridge`](https://github.com/abix5/pi-cliproxyapi-bridge) |
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Key Features
|
|
23
|
+
|
|
24
|
+
- **Real-Time Quota & Multi-Window Tracking**: Automatically monitors balances, subscription states, and multiple sliding windows (e.g. `Codex 5h 92% · 7d 85%`) tailored to your currently active model.
|
|
25
|
+
- **Privacy & Security First**: Zero browser cookies, telemetry, or external proxies—reuses your local Pi credentials securely with strict same-origin protection.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
### Method 1: Install in Pi (CLI / TUI)
|
|
32
|
+
|
|
33
|
+
Run the following command directly in your terminal:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pi install npm:@wayner6/pi-usage
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Method 2: Install in pi-web (Web Interface)
|
|
40
|
+
|
|
41
|
+
1. Open **Settings** (设置) at the bottom left -> **Plugins** (插件).
|
|
42
|
+
2. Click **+ Add Plugin** (添加插件).
|
|
43
|
+
3. In the **Source** input box, enter:
|
|
44
|
+
```text
|
|
45
|
+
npm:@wayner6/pi-usage
|
|
46
|
+
```
|
|
47
|
+
4. Choose `global` scope, then click **Install** (安装).
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Usage & Refresh Behavior
|
|
52
|
+
|
|
53
|
+
### Refresh Mechanism
|
|
54
|
+
1. **Automatic Refresh on Dialogue**:
|
|
55
|
+
- In both `pi` and `pi-web`, **starting a conversation (sending any prompt) automatically refreshes and streams the latest balance/quota** for the active model.
|
|
56
|
+
2. **Model Switching in pi-web**:
|
|
57
|
+
- When switching models while the agent is idle, the footer quota updates **automatically on your next prompt**.
|
|
58
|
+
- To refresh immediately without sending a message, click **"Reload Session"** (重载会话) in the menu or sidebar.
|
|
59
|
+
3. **Background Updates**:
|
|
60
|
+
- Polls and updates countdowns periodically in the background (default: every 120s).
|
|
61
|
+
|
|
62
|
+
### Available Commands
|
|
63
|
+
|
|
64
|
+
Type `/usage` or `/quota` in the chat input:
|
|
65
|
+
|
|
66
|
+
| Command | Description |
|
|
67
|
+
| :--- | :--- |
|
|
68
|
+
| `/usage` | Show aggregated balance and quota details for all configured providers |
|
|
69
|
+
| `/usage current` | Show usage details specifically for the currently active model |
|
|
70
|
+
| `/usage refresh` | Force an immediate network refresh for the current provider |
|
|
71
|
+
| `/usage doctor` | Diagnose adapter states, provider authentication, and bridge connectivity |
|
|
72
|
+
| `/usage settings` | View or adjust configuration (status bar, widget, intervals) |
|
|
73
|
+
|
|
74
|
+
### Configuration Options
|
|
75
|
+
|
|
76
|
+
Adjust preferences directly in chat via `/usage settings`:
|
|
77
|
+
```text
|
|
78
|
+
/usage settings status on|off # Toggle the compact footer status line (Default: on)
|
|
79
|
+
/usage settings widget on|off # Toggle the detailed persistent widget (Default: off)
|
|
80
|
+
/usage settings interval <seconds> # Set background polling interval (Default: 120s)
|
|
81
|
+
/usage settings timeout <seconds> # Set request timeout (Default: 10s)
|
|
82
|
+
```
|
|
83
|
+
Configuration is automatically saved to `~/.pi/agent/pi-usage/config.json`.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT License. See [LICENSE](./LICENSE) for details.
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
Pi Usage runs as a Pi extension with the user's local permissions. Review extensions before installing them.
|
|
4
|
+
|
|
5
|
+
## Credential policy
|
|
6
|
+
|
|
7
|
+
- Provider credentials are resolved through Pi at runtime and are never written by Pi Usage.
|
|
8
|
+
- CLIProxyAPI quota access uses an ordinary inference API key through `pi-bridge`; management keys are unsupported.
|
|
9
|
+
- Authenticated requests stay on the configured provider origin. Cross-origin redirects are rejected.
|
|
10
|
+
- Browser cookies, local storage, and private provider dashboard sessions are not read.
|
|
11
|
+
- Quota responses are cached in memory only.
|
|
12
|
+
|
|
13
|
+
## Reporting
|
|
14
|
+
|
|
15
|
+
Do not include API keys, management passwords, full auth files, or raw Authorization headers in issues. Revoke exposed credentials immediately.
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/index.ts";
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wayner6/pi-usage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "Provider balance and quota monitoring for Pi and pi-web",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"keywords": ["pi-package", "pi", "usage", "quota", "deepseek", "cliproxyapi"],
|
|
11
|
+
"files": ["index.ts", "src", "README.md", "LICENSE", "SECURITY.md"],
|
|
12
|
+
"pi": { "extensions": ["./index.ts"] },
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
15
|
+
"@earendil-works/pi-tui": "*"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@earendil-works/pi-ai": "0.84.4",
|
|
19
|
+
"@earendil-works/pi-coding-agent": "0.84.4",
|
|
20
|
+
"@earendil-works/pi-tui": "0.84.4",
|
|
21
|
+
"@types/node": "^24.10.0",
|
|
22
|
+
"tsx": "^4.20.6",
|
|
23
|
+
"typescript": "^5.9.3"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"check": "tsc --noEmit",
|
|
27
|
+
"test": "node --import tsx --test test/**/*.test.ts",
|
|
28
|
+
"verify": "npm run check && npm test",
|
|
29
|
+
"pack:check": "npm pack --dry-run"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { UsageSnapshot } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export class UsageCache {
|
|
4
|
+
private snapshots = new Map<string, UsageSnapshot>();
|
|
5
|
+
private pending = new Map<string, Promise<UsageSnapshot>>();
|
|
6
|
+
|
|
7
|
+
get(key: string): UsageSnapshot | undefined {
|
|
8
|
+
return this.snapshots.get(key);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
values(): UsageSnapshot[] {
|
|
12
|
+
return [...this.snapshots.values()];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async coalesce(key: string, operation: () => Promise<UsageSnapshot>): Promise<UsageSnapshot> {
|
|
16
|
+
const existing = this.pending.get(key);
|
|
17
|
+
if (existing) return existing;
|
|
18
|
+
const promise = operation()
|
|
19
|
+
.then((snapshot) => {
|
|
20
|
+
this.snapshots.set(key, snapshot);
|
|
21
|
+
return snapshot;
|
|
22
|
+
})
|
|
23
|
+
.catch((error) => {
|
|
24
|
+
const old = this.snapshots.get(key);
|
|
25
|
+
if (!old) throw error;
|
|
26
|
+
const stale: UsageSnapshot = {
|
|
27
|
+
...old,
|
|
28
|
+
state: "stale",
|
|
29
|
+
stale: true,
|
|
30
|
+
error: error instanceof Error ? error.message : String(error),
|
|
31
|
+
};
|
|
32
|
+
this.snapshots.set(key, stale);
|
|
33
|
+
return stale;
|
|
34
|
+
})
|
|
35
|
+
.finally(() => this.pending.delete(key));
|
|
36
|
+
this.pending.set(key, promise);
|
|
37
|
+
return promise;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
clear(): void {
|
|
41
|
+
this.snapshots.clear();
|
|
42
|
+
this.pending.clear();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface UsageConfig {
|
|
6
|
+
display: { status: boolean; widget: boolean; detailsDefault: "all" | "current" };
|
|
7
|
+
refresh: { intervalSeconds: number; timeoutSeconds: number };
|
|
8
|
+
adapters: {
|
|
9
|
+
deepseek: { enabled: boolean };
|
|
10
|
+
cliproxyPiBridge: { enabled: boolean };
|
|
11
|
+
openaiCodex: { enabled: boolean };
|
|
12
|
+
xai: { enabled: boolean };
|
|
13
|
+
anthropic: { enabled: boolean };
|
|
14
|
+
glm: { enabled: boolean };
|
|
15
|
+
};
|
|
16
|
+
providerOverrides: Record<string, "deepseek" | "cliproxy-pi-bridge" | "openai-codex" | "xai" | "anthropic" | "glm" | "disabled">;
|
|
17
|
+
modelMappings: Array<{ provider: string; modelPattern: string; quotaProvider: string }>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_CONFIG: UsageConfig = {
|
|
21
|
+
display: { status: true, widget: false, detailsDefault: "all" },
|
|
22
|
+
refresh: { intervalSeconds: 120, timeoutSeconds: 10 },
|
|
23
|
+
adapters: {
|
|
24
|
+
deepseek: { enabled: true },
|
|
25
|
+
cliproxyPiBridge: { enabled: true },
|
|
26
|
+
openaiCodex: { enabled: true },
|
|
27
|
+
xai: { enabled: true },
|
|
28
|
+
anthropic: { enabled: true },
|
|
29
|
+
glm: { enabled: true },
|
|
30
|
+
},
|
|
31
|
+
providerOverrides: {},
|
|
32
|
+
modelMappings: [],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function configPath(): string {
|
|
36
|
+
return join(getAgentDir(), "pi-usage", "config.json");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function clamp(value: unknown, min: number, max: number, fallback: number): number {
|
|
40
|
+
return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function loadConfig(): Promise<UsageConfig> {
|
|
44
|
+
try {
|
|
45
|
+
const raw = await readFile(configPath(), "utf8");
|
|
46
|
+
const parsed = JSON.parse(raw) as Partial<UsageConfig>;
|
|
47
|
+
return {
|
|
48
|
+
display: {
|
|
49
|
+
status: parsed.display?.status ?? DEFAULT_CONFIG.display.status,
|
|
50
|
+
widget: parsed.display?.widget ?? DEFAULT_CONFIG.display.widget,
|
|
51
|
+
detailsDefault: parsed.display?.detailsDefault === "current" ? "current" : "all",
|
|
52
|
+
},
|
|
53
|
+
refresh: {
|
|
54
|
+
intervalSeconds: clamp(parsed.refresh?.intervalSeconds, 10, 86400, DEFAULT_CONFIG.refresh.intervalSeconds),
|
|
55
|
+
timeoutSeconds: clamp(parsed.refresh?.timeoutSeconds, 2, 60, DEFAULT_CONFIG.refresh.timeoutSeconds),
|
|
56
|
+
},
|
|
57
|
+
adapters: {
|
|
58
|
+
deepseek: { enabled: parsed.adapters?.deepseek?.enabled ?? DEFAULT_CONFIG.adapters.deepseek.enabled },
|
|
59
|
+
cliproxyPiBridge: { enabled: parsed.adapters?.cliproxyPiBridge?.enabled ?? DEFAULT_CONFIG.adapters.cliproxyPiBridge.enabled },
|
|
60
|
+
openaiCodex: { enabled: parsed.adapters?.openaiCodex?.enabled ?? DEFAULT_CONFIG.adapters.openaiCodex.enabled },
|
|
61
|
+
xai: { enabled: parsed.adapters?.xai?.enabled ?? DEFAULT_CONFIG.adapters.xai.enabled },
|
|
62
|
+
anthropic: { enabled: parsed.adapters?.anthropic?.enabled ?? DEFAULT_CONFIG.adapters.anthropic.enabled },
|
|
63
|
+
glm: { enabled: parsed.adapters?.glm?.enabled ?? DEFAULT_CONFIG.adapters.glm.enabled },
|
|
64
|
+
},
|
|
65
|
+
providerOverrides: parsed.providerOverrides ?? {},
|
|
66
|
+
modelMappings: Array.isArray(parsed.modelMappings) ? parsed.modelMappings : [],
|
|
67
|
+
};
|
|
68
|
+
} catch {
|
|
69
|
+
return DEFAULT_CONFIG;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function saveConfig(config: UsageConfig): Promise<void> {
|
|
74
|
+
const file = configPath();
|
|
75
|
+
await mkdir(dirname(file), { recursive: true });
|
|
76
|
+
await writeFile(file, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
77
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const SENSITIVE_KEYS = /authorization|api[-_]?key|token|secret|password/i;
|
|
2
|
+
|
|
3
|
+
export function normalizeOrigin(baseUrl: string): URL | undefined {
|
|
4
|
+
try {
|
|
5
|
+
return new URL(baseUrl);
|
|
6
|
+
} catch {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function bridgeUrl(baseUrl: string, path: "capabilities" | "usage", force = false): URL {
|
|
12
|
+
const base = new URL(baseUrl);
|
|
13
|
+
const result = new URL(`/v0/resource/plugins/pi-bridge/${path}`, base.origin);
|
|
14
|
+
if (force && path === "usage") result.searchParams.set("refresh", "1");
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function sameOriginFetch(
|
|
19
|
+
url: URL,
|
|
20
|
+
init: RequestInit,
|
|
21
|
+
fetchFn: typeof fetch,
|
|
22
|
+
expectedOrigin: string,
|
|
23
|
+
redirects = 0,
|
|
24
|
+
): Promise<Response> {
|
|
25
|
+
if (url.origin !== expectedOrigin) throw new Error("Refusing to send credentials across origins");
|
|
26
|
+
const response = await fetchFn(url, { ...init, redirect: "manual" });
|
|
27
|
+
if (response.status >= 300 && response.status < 400) {
|
|
28
|
+
if (redirects >= 3) throw new Error("Too many redirects");
|
|
29
|
+
const location = response.headers.get("location");
|
|
30
|
+
if (!location) return response;
|
|
31
|
+
const next = new URL(location, url);
|
|
32
|
+
if (next.origin !== expectedOrigin) throw new Error("Refusing cross-origin authenticated redirect");
|
|
33
|
+
return sameOriginFetch(next, init, fetchFn, expectedOrigin, redirects + 1);
|
|
34
|
+
}
|
|
35
|
+
return response;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function redact(value: unknown): unknown {
|
|
39
|
+
if (Array.isArray(value)) return value.map(redact);
|
|
40
|
+
if (value && typeof value === "object") {
|
|
41
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, SENSITIVE_KEYS.test(key) ? "[REDACTED]" : redact(item)]));
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function safeError(error: unknown): string {
|
|
47
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
48
|
+
return message.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]").replace(/sk-[A-Za-z0-9_-]+/g, "sk-…");
|
|
49
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Api, AuthResult, Model, Provider } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
export type UsageState =
|
|
4
|
+
| "ok"
|
|
5
|
+
| "empty"
|
|
6
|
+
| "stale"
|
|
7
|
+
| "unsupported"
|
|
8
|
+
| "not-installed"
|
|
9
|
+
| "unauthorized"
|
|
10
|
+
| "incompatible"
|
|
11
|
+
| "unavailable";
|
|
12
|
+
|
|
13
|
+
export type Metric =
|
|
14
|
+
| { kind: "balance"; id: string; label: string; amount: number; currency: string; detail?: string }
|
|
15
|
+
| { kind: "quota-window"; id: string; label: string; remainingFraction: number; resetAt?: string; detail?: string }
|
|
16
|
+
| { kind: "credits"; id: string; label: string; remaining: number; unit: string; detail?: string }
|
|
17
|
+
| { kind: "usage-limit"; id: string; label: string; used: number; limit: number; unit: string; detail?: string }
|
|
18
|
+
| { kind: "rate-limit"; id: string; label: string; value: number; unit: string; detail?: string }
|
|
19
|
+
| { kind: "status"; id: string; label: string; value: string; detail?: string }
|
|
20
|
+
| { kind: "custom"; id: string; label: string; value: string; detail?: string };
|
|
21
|
+
|
|
22
|
+
export interface UsageAccount {
|
|
23
|
+
id: string;
|
|
24
|
+
provider: string;
|
|
25
|
+
label: string;
|
|
26
|
+
status?: string;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
unavailable?: boolean;
|
|
29
|
+
metrics: Metric[];
|
|
30
|
+
rawGroups?: unknown;
|
|
31
|
+
error?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface UsageSnapshot {
|
|
35
|
+
adapterId: string;
|
|
36
|
+
sourceProviderId: string;
|
|
37
|
+
displayName: string;
|
|
38
|
+
state: UsageState;
|
|
39
|
+
fetchedAt: string;
|
|
40
|
+
stale?: boolean;
|
|
41
|
+
accounts: UsageAccount[];
|
|
42
|
+
summary?: string;
|
|
43
|
+
error?: string;
|
|
44
|
+
diagnostic?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ProviderTarget {
|
|
48
|
+
providerId: string;
|
|
49
|
+
model?: Model<Api>;
|
|
50
|
+
provider?: Provider<Api>;
|
|
51
|
+
baseUrl?: string;
|
|
52
|
+
auth?: AuthResult;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface FetchContext {
|
|
56
|
+
target: ProviderTarget;
|
|
57
|
+
signal: AbortSignal;
|
|
58
|
+
force: boolean;
|
|
59
|
+
fetchFn: typeof fetch;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface UsageAdapter {
|
|
63
|
+
id: string;
|
|
64
|
+
label: string;
|
|
65
|
+
canHandle(target: ProviderTarget): boolean;
|
|
66
|
+
fetch(context: FetchContext): Promise<UsageSnapshot>;
|
|
67
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
3
|
+
import { loadConfig, type UsageConfig } from "./core/config.ts";
|
|
4
|
+
import type { UsageSnapshot } from "./core/types.ts";
|
|
5
|
+
import { ProviderUsageController } from "./modules/provider/controller.ts";
|
|
6
|
+
import { showDetails } from "./ui/details.ts";
|
|
7
|
+
import { compactSnapshot, snapshotLines } from "./ui/format.ts";
|
|
8
|
+
import { handleSettings } from "./settings.ts";
|
|
9
|
+
|
|
10
|
+
const STATUS_ID = "pi-usage";
|
|
11
|
+
const WIDGET_ID = "pi-usage-provider";
|
|
12
|
+
|
|
13
|
+
export default function (pi: ExtensionAPI) {
|
|
14
|
+
let config: UsageConfig;
|
|
15
|
+
let controller: ProviderUsageController;
|
|
16
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
17
|
+
let modelWatchTimer: ReturnType<typeof setInterval> | undefined;
|
|
18
|
+
let lastContext: ExtensionContext | undefined;
|
|
19
|
+
let observedModelKey: string | undefined;
|
|
20
|
+
let renderGeneration = 0;
|
|
21
|
+
|
|
22
|
+
function modelKey(model: Model<Api> | undefined): string | undefined {
|
|
23
|
+
return model ? `${model.provider}/${model.id}/${model.baseUrl}` : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function liveModel(ctx: ExtensionContext): Model<Api> | undefined {
|
|
27
|
+
return ctx.model;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function render(ctx: ExtensionContext, snapshot?: UsageSnapshot, model: Model<Api> | undefined = ctx.model): void {
|
|
31
|
+
const current = controller.currentView(ctx, snapshot, model);
|
|
32
|
+
ctx.ui.setStatus(STATUS_ID, config.display.status ? ctx.ui.theme.fg(current?.state === "ok" ? "success" : current?.state === "stale" ? "warning" : "dim", compactSnapshot(current)) : undefined);
|
|
33
|
+
ctx.ui.setWidget(WIDGET_ID, config.display.widget && current ? snapshotLines(current) : undefined, { placement: "belowEditor" });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function refreshCurrent(ctx: ExtensionContext, force = false, model: Model<Api> | undefined = liveModel(ctx)): Promise<UsageSnapshot | undefined> {
|
|
37
|
+
lastContext = ctx;
|
|
38
|
+
const generation = ++renderGeneration;
|
|
39
|
+
try {
|
|
40
|
+
// If we already have a cached snapshot for this provider, render it immediately
|
|
41
|
+
// with group recalculation so model switches are instant without flicker.
|
|
42
|
+
const cached = model ? controller.cache.values().find((item) => item.sourceProviderId === model.provider) : undefined;
|
|
43
|
+
render(ctx, cached, model);
|
|
44
|
+
const snapshot = await controller.refreshCurrent(ctx, force, model);
|
|
45
|
+
if (generation === renderGeneration) render(ctx, snapshot, model);
|
|
46
|
+
return snapshot;
|
|
47
|
+
} catch (error) {
|
|
48
|
+
const fallback = model ? controller.cache.values().find((item) => item.sourceProviderId === model.provider) : undefined;
|
|
49
|
+
if (generation === renderGeneration) render(ctx, fallback, model);
|
|
50
|
+
return fallback;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function startTimer(ctx: ExtensionContext): void {
|
|
55
|
+
if (timer) clearInterval(timer);
|
|
56
|
+
timer = setInterval(() => { if (lastContext) void refreshCurrent(lastContext); }, config.refresh.intervalSeconds * 1000);
|
|
57
|
+
timer.unref?.();
|
|
58
|
+
lastContext = ctx;
|
|
59
|
+
observedModelKey = modelKey(liveModel(ctx));
|
|
60
|
+
|
|
61
|
+
// pi-web versions can update ctx.model without reliably delivering model_select
|
|
62
|
+
// to package extensions. This watcher performs no network I/O unless the model
|
|
63
|
+
// identity actually changes, and keeps the footer honest during that gap.
|
|
64
|
+
if (modelWatchTimer) clearInterval(modelWatchTimer);
|
|
65
|
+
modelWatchTimer = setInterval(() => {
|
|
66
|
+
if (!lastContext) return;
|
|
67
|
+
const nextModel = liveModel(lastContext);
|
|
68
|
+
const nextKey = modelKey(nextModel);
|
|
69
|
+
if (nextKey === observedModelKey) return;
|
|
70
|
+
observedModelKey = nextKey;
|
|
71
|
+
void refreshCurrent(lastContext, false, nextModel);
|
|
72
|
+
}, 750);
|
|
73
|
+
modelWatchTimer.unref?.();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function command(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
77
|
+
const [action = config.display.detailsDefault, ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
78
|
+
if (action === "settings") {
|
|
79
|
+
config = await handleSettings(rest.join(" "), ctx, config);
|
|
80
|
+
controller.setConfig(config);
|
|
81
|
+
startTimer(ctx);
|
|
82
|
+
await refreshCurrent(ctx);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (action === "doctor") {
|
|
86
|
+
const current = await controller.refreshCurrent(ctx, false);
|
|
87
|
+
const deepSeekAuth = ctx.modelRegistry.getProviderAuthStatus("deepseek");
|
|
88
|
+
const lines = [
|
|
89
|
+
`Model: ${ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none"}`,
|
|
90
|
+
`Provider base URL: ${ctx.model?.baseUrl ? new URL(ctx.model.baseUrl).origin : "not exposed by model"}`,
|
|
91
|
+
`Current adapter: ${current?.adapterId ?? "none"}`,
|
|
92
|
+
`Current state: ${current?.state ?? "unavailable"}`,
|
|
93
|
+
`Current auth: ${current?.state === "unauthorized" ? "missing or rejected" : "resolved without displaying secret"}`,
|
|
94
|
+
`DeepSeek auth: ${deepSeekAuth.configured ? `configured${deepSeekAuth.source ? ` (${deepSeekAuth.source})` : ""}` : "not configured"}`,
|
|
95
|
+
`Hint: /usage current shows only ${ctx.model?.provider ?? "the active provider"}; use /usage all for DeepSeek plus other configured providers.`,
|
|
96
|
+
...(current?.error ? [`Problem: ${current.error}`] : []),
|
|
97
|
+
...(current?.state === "not-installed" ? ["Fix: install and enable pi-bridge on the CLIProxyAPI server."] : []),
|
|
98
|
+
];
|
|
99
|
+
ctx.ui.notify(lines.join("\n"), current?.state === "ok" || current?.state === "stale" ? "info" : "warning");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (action === "refresh") {
|
|
103
|
+
const snapshot = await refreshCurrent(ctx, true);
|
|
104
|
+
await showDetails(ctx, snapshot ? [controller.currentView(ctx, snapshot) ?? snapshot] : []);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (action === "current") {
|
|
108
|
+
const snapshot = await refreshCurrent(ctx);
|
|
109
|
+
await showDetails(ctx, snapshot ? [controller.currentView(ctx, snapshot) ?? snapshot] : []);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const snapshots = await controller.refreshAll(ctx, false);
|
|
113
|
+
await showDetails(ctx, snapshots);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
pi.registerCommand("usage", { description: "Show provider balances and quota windows", handler: command });
|
|
117
|
+
pi.registerCommand("quota", { description: "Alias for /usage", handler: command });
|
|
118
|
+
|
|
119
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
120
|
+
config = await loadConfig();
|
|
121
|
+
controller = new ProviderUsageController(config);
|
|
122
|
+
startTimer(ctx);
|
|
123
|
+
await refreshCurrent(ctx);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
pi.on("model_select", async (event, ctx) => {
|
|
127
|
+
observedModelKey = modelKey(event.model);
|
|
128
|
+
const cached = event.model ? controller.cache.values().find((item) => item.sourceProviderId === event.model.provider) : undefined;
|
|
129
|
+
render(ctx, cached, event.model);
|
|
130
|
+
await refreshCurrent(ctx, false, event.model);
|
|
131
|
+
});
|
|
132
|
+
pi.on("session_shutdown", async () => {
|
|
133
|
+
if (timer) clearInterval(timer);
|
|
134
|
+
if (modelWatchTimer) clearInterval(modelWatchTimer);
|
|
135
|
+
timer = undefined;
|
|
136
|
+
modelWatchTimer = undefined;
|
|
137
|
+
observedModelKey = undefined;
|
|
138
|
+
renderGeneration++;
|
|
139
|
+
lastContext = undefined;
|
|
140
|
+
controller?.cache.clear();
|
|
141
|
+
});
|
|
142
|
+
}
|