@eiei114/pi-sub-core 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +178 -0
  3. package/index.ts +540 -0
  4. package/package.json +35 -0
  5. package/src/cache.ts +546 -0
  6. package/src/config.ts +35 -0
  7. package/src/dependencies.ts +37 -0
  8. package/src/errors.ts +71 -0
  9. package/src/paths.ts +55 -0
  10. package/src/provider.ts +66 -0
  11. package/src/providers/detection.ts +51 -0
  12. package/src/providers/impl/anthropic.ts +174 -0
  13. package/src/providers/impl/antigravity.ts +226 -0
  14. package/src/providers/impl/codex.ts +186 -0
  15. package/src/providers/impl/copilot.ts +176 -0
  16. package/src/providers/impl/gemini.ts +130 -0
  17. package/src/providers/impl/kiro.ts +92 -0
  18. package/src/providers/impl/zai.ts +120 -0
  19. package/src/providers/index.ts +5 -0
  20. package/src/providers/metadata.ts +16 -0
  21. package/src/providers/registry.ts +54 -0
  22. package/src/providers/settings.ts +109 -0
  23. package/src/providers/status.ts +25 -0
  24. package/src/settings/behavior.ts +58 -0
  25. package/src/settings/menu.ts +83 -0
  26. package/src/settings/tools.ts +38 -0
  27. package/src/settings/ui.ts +450 -0
  28. package/src/settings-types.ts +95 -0
  29. package/src/settings-ui.ts +1 -0
  30. package/src/settings.ts +137 -0
  31. package/src/status.ts +245 -0
  32. package/src/storage/lock.ts +150 -0
  33. package/src/storage.ts +61 -0
  34. package/src/types.ts +33 -0
  35. package/src/ui/keybindings.ts +92 -0
  36. package/src/ui/settings-list.ts +290 -0
  37. package/src/usage/controller.ts +250 -0
  38. package/src/usage/fetch.ts +215 -0
  39. package/src/usage/types.ts +5 -0
  40. package/src/utils.ts +158 -0
  41. package/test/all.test.ts +9 -0
  42. package/test/cache.test.ts +157 -0
  43. package/test/controller.test.ts +101 -0
  44. package/test/detection.test.ts +24 -0
  45. package/test/extension.test.ts +233 -0
  46. package/test/helpers.ts +48 -0
  47. package/test/keybindings.test.ts +59 -0
  48. package/test/lock.test.ts +49 -0
  49. package/test/prioritize.test.ts +81 -0
  50. package/test/providers.test.ts +385 -0
  51. package/test/status.test.ts +70 -0
  52. package/tsconfig.json +5 -0
@@ -0,0 +1,186 @@
1
+ /**
2
+ * OpenAI Codex usage provider
3
+ */
4
+
5
+ import * as path from "node:path";
6
+ import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
7
+ import { BaseProvider } from "../../provider.js";
8
+ import { noCredentials, fetchFailed, httpError } from "../../errors.js";
9
+ import { formatReset, createTimeoutController } from "../../utils.js";
10
+ import { API_TIMEOUT_MS } from "../../config.js";
11
+
12
+ interface CodexRateWindow {
13
+ reset_at?: number;
14
+ limit_window_seconds?: number;
15
+ used_percent?: number;
16
+ }
17
+
18
+ interface CodexRateLimit {
19
+ primary_window?: CodexRateWindow;
20
+ secondary_window?: CodexRateWindow;
21
+ }
22
+
23
+ interface CodexAdditionalRateLimit {
24
+ limit_name?: string;
25
+ metered_feature?: string;
26
+ rate_limit?: CodexRateLimit;
27
+ }
28
+
29
+ /**
30
+ * Load Codex credentials from auth.json
31
+ * First tries pi's auth.json, then falls back to legacy codex location
32
+ */
33
+ function loadCodexCredentials(deps: Dependencies): { accessToken?: string; accountId?: string } {
34
+ // Explicit override via env var
35
+ const envAccessToken = (
36
+ deps.env.OPENAI_CODEX_OAUTH_TOKEN ||
37
+ deps.env.OPENAI_CODEX_ACCESS_TOKEN ||
38
+ deps.env.CODEX_OAUTH_TOKEN ||
39
+ deps.env.CODEX_ACCESS_TOKEN
40
+ )?.trim();
41
+ const envAccountId = (deps.env.OPENAI_CODEX_ACCOUNT_ID || deps.env.CHATGPT_ACCOUNT_ID)?.trim();
42
+ if (envAccessToken) {
43
+ return { accessToken: envAccessToken, accountId: envAccountId || undefined };
44
+ }
45
+
46
+ // Try pi's auth.json first
47
+ const piAuthPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
48
+ try {
49
+ if (deps.fileExists(piAuthPath)) {
50
+ const data = JSON.parse(deps.readFile(piAuthPath) ?? "{}");
51
+ if (data["openai-codex"]?.access) {
52
+ return {
53
+ accessToken: data["openai-codex"].access,
54
+ accountId: data["openai-codex"].accountId,
55
+ };
56
+ }
57
+ }
58
+ } catch {
59
+ // Ignore parse errors, try legacy location
60
+ }
61
+
62
+ // Fall back to legacy codex location
63
+ const codexHome = deps.env.CODEX_HOME || path.join(deps.homedir(), ".codex");
64
+ const authPath = path.join(codexHome, "auth.json");
65
+ try {
66
+ if (deps.fileExists(authPath)) {
67
+ const data = JSON.parse(deps.readFile(authPath) ?? "{}");
68
+ if (data.OPENAI_API_KEY) {
69
+ return { accessToken: data.OPENAI_API_KEY };
70
+ } else if (data.tokens?.access_token) {
71
+ return {
72
+ accessToken: data.tokens.access_token,
73
+ accountId: data.tokens.account_id,
74
+ };
75
+ }
76
+ }
77
+ } catch {
78
+ // Ignore parse errors
79
+ }
80
+
81
+ return {};
82
+ }
83
+
84
+ function getWindowLabel(windowSeconds?: number, fallbackWindowSeconds?: number): string {
85
+ const safeWindowSeconds =
86
+ typeof windowSeconds === "number" && windowSeconds > 0
87
+ ? windowSeconds
88
+ : typeof fallbackWindowSeconds === "number" && fallbackWindowSeconds > 0
89
+ ? fallbackWindowSeconds
90
+ : 0;
91
+ if (!safeWindowSeconds) {
92
+ return "0h";
93
+ }
94
+ const windowHours = Math.round(safeWindowSeconds / 3600);
95
+ if (windowHours >= 144) return "Week";
96
+ if (windowHours >= 24) return "Day";
97
+ return `${windowHours}h`;
98
+ }
99
+
100
+ function pushWindow(
101
+ windows: RateWindow[],
102
+ prefix: string | undefined,
103
+ window: CodexRateWindow | undefined,
104
+ fallbackWindowSeconds?: number
105
+ ): void {
106
+ if (!window) return;
107
+ const resetDate = window.reset_at ? new Date(window.reset_at * 1000) : undefined;
108
+ const label = getWindowLabel(window.limit_window_seconds, fallbackWindowSeconds);
109
+ const windowLabel = prefix ? `${prefix} ${label}` : label;
110
+ windows.push({
111
+ label: windowLabel,
112
+ usedPercent: window.used_percent || 0,
113
+ resetDescription: resetDate ? formatReset(resetDate) : undefined,
114
+ resetAt: resetDate?.toISOString(),
115
+ });
116
+ }
117
+
118
+ function addRateWindows(windows: RateWindow[], rateLimit: CodexRateLimit | undefined, prefix?: string): void {
119
+ pushWindow(windows, prefix, rateLimit?.primary_window, 10800);
120
+ pushWindow(windows, prefix, rateLimit?.secondary_window, 86400);
121
+ }
122
+
123
+ export class CodexProvider extends BaseProvider {
124
+ readonly name = "codex" as const;
125
+ readonly displayName = "Codex Plan";
126
+
127
+ hasCredentials(deps: Dependencies): boolean {
128
+ return Boolean(loadCodexCredentials(deps).accessToken);
129
+ }
130
+
131
+ async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
132
+ const { accessToken, accountId } = loadCodexCredentials(deps);
133
+ if (!accessToken) {
134
+ return this.emptySnapshot(noCredentials());
135
+ }
136
+
137
+ const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
138
+
139
+ try {
140
+ const headers: Record<string, string> = {
141
+ Authorization: `Bearer ${accessToken}`,
142
+ Accept: "application/json",
143
+ };
144
+ if (accountId) {
145
+ headers["ChatGPT-Account-Id"] = accountId;
146
+ }
147
+
148
+ const res = await deps.fetch("https://chatgpt.com/backend-api/wham/usage", {
149
+ headers,
150
+ signal: controller.signal,
151
+ });
152
+ clear();
153
+
154
+ if (!res.ok) {
155
+ return this.emptySnapshot(httpError(res.status));
156
+ }
157
+
158
+ const data = (await res.json()) as {
159
+ rate_limit?: CodexRateLimit;
160
+ additional_rate_limits?: CodexAdditionalRateLimit[];
161
+ };
162
+
163
+ const windows: RateWindow[] = [];
164
+ addRateWindows(windows, data.rate_limit);
165
+
166
+ if (Array.isArray(data.additional_rate_limits)) {
167
+ for (const entry of data.additional_rate_limits) {
168
+ if (!entry || typeof entry !== "object") continue;
169
+ const prefix =
170
+ typeof entry.limit_name === "string" && entry.limit_name.trim().length > 0
171
+ ? entry.limit_name.trim()
172
+ : typeof entry.metered_feature === "string" && entry.metered_feature.trim().length > 0
173
+ ? entry.metered_feature.trim()
174
+ : "Additional";
175
+ addRateWindows(windows, entry.rate_limit, prefix);
176
+ }
177
+ }
178
+
179
+ return this.snapshot({ windows });
180
+ } catch {
181
+ clear();
182
+ return this.emptySnapshot(fetchFailed());
183
+ }
184
+ }
185
+
186
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * GitHub Copilot usage provider
3
+ */
4
+
5
+ import * as path from "node:path";
6
+ import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
7
+ import { BaseProvider } from "../../provider.js";
8
+ import { noCredentials, fetchFailed, httpError } from "../../errors.js";
9
+ import { formatReset, createTimeoutController } from "../../utils.js";
10
+ import { API_TIMEOUT_MS } from "../../config.js";
11
+
12
+ /**
13
+ * Copilot token entries stored by legacy GitHub Copilot CLI
14
+ */
15
+ type CopilotHostEntry = {
16
+ oauth_token?: string;
17
+ user_token?: string;
18
+ github_token?: string;
19
+ token?: string;
20
+ };
21
+
22
+ const COPILOT_TOKEN_KEYS: Array<keyof CopilotHostEntry> = [
23
+ "oauth_token",
24
+ "user_token",
25
+ "github_token",
26
+ "token",
27
+ ];
28
+
29
+ function getTokenFromHostEntry(entry: CopilotHostEntry | undefined): string | undefined {
30
+ if (!entry) return undefined;
31
+ for (const key of COPILOT_TOKEN_KEYS) {
32
+ const value = entry[key];
33
+ if (typeof value === "string" && value.length > 0) {
34
+ return value;
35
+ }
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ function loadLegacyCopilotToken(deps: Dependencies): string | undefined {
41
+ const configHome = deps.env.XDG_CONFIG_HOME || path.join(deps.homedir(), ".config");
42
+ const legacyPaths = [
43
+ path.join(configHome, "github-copilot", "hosts.json"),
44
+ path.join(deps.homedir(), ".github-copilot", "hosts.json"),
45
+ ];
46
+
47
+ for (const hostsPath of legacyPaths) {
48
+ try {
49
+ if (!deps.fileExists(hostsPath)) continue;
50
+ const data = JSON.parse(deps.readFile(hostsPath) ?? "{}");
51
+ if (!data || typeof data !== "object") continue;
52
+
53
+ const normalizedHosts: Record<string, CopilotHostEntry> = {};
54
+ for (const [host, entry] of Object.entries(data as Record<string, CopilotHostEntry>)) {
55
+ normalizedHosts[host.toLowerCase()] = entry;
56
+ }
57
+
58
+ const preferredToken =
59
+ getTokenFromHostEntry(normalizedHosts["github.com"]) ||
60
+ getTokenFromHostEntry(normalizedHosts["api.github.com"]);
61
+ if (preferredToken) return preferredToken;
62
+
63
+ for (const entry of Object.values(normalizedHosts)) {
64
+ const token = getTokenFromHostEntry(entry);
65
+ if (token) return token;
66
+ }
67
+ } catch {
68
+ // Ignore parse errors
69
+ }
70
+ }
71
+
72
+ return undefined;
73
+ }
74
+
75
+ /**
76
+ * Load Copilot token from pi auth.json first, then fallback to legacy locations.
77
+ */
78
+ function loadCopilotToken(deps: Dependencies): string | undefined {
79
+ // Explicit override via env var
80
+ const envToken = (deps.env.COPILOT_GITHUB_TOKEN || deps.env.GH_TOKEN || deps.env.GITHUB_TOKEN || deps.env.COPILOT_TOKEN)?.trim();
81
+ if (envToken) return envToken;
82
+
83
+ const authPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
84
+ try {
85
+ if (deps.fileExists(authPath)) {
86
+ const data = JSON.parse(deps.readFile(authPath) ?? "{}");
87
+ // Prefer refresh token (GitHub access token) for GitHub API endpoints.
88
+ const piToken = data["github-copilot"]?.refresh || data["github-copilot"]?.access;
89
+ if (typeof piToken === "string" && piToken.length > 0) return piToken;
90
+ }
91
+ } catch {
92
+ // Ignore parse errors
93
+ }
94
+
95
+ return loadLegacyCopilotToken(deps);
96
+ }
97
+
98
+ export class CopilotProvider extends BaseProvider {
99
+ readonly name = "copilot" as const;
100
+ readonly displayName = "Copilot Plan";
101
+
102
+ hasCredentials(deps: Dependencies): boolean {
103
+ return Boolean(loadCopilotToken(deps));
104
+ }
105
+
106
+ async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
107
+ const token = loadCopilotToken(deps);
108
+ if (!token) {
109
+ return this.emptySnapshot(noCredentials());
110
+ }
111
+
112
+ const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
113
+
114
+ try {
115
+ const res = await deps.fetch("https://api.github.com/copilot_internal/user", {
116
+ headers: {
117
+ "Editor-Version": "vscode/1.96.2",
118
+ "User-Agent": "GitHubCopilotChat/0.26.7",
119
+ "X-Github-Api-Version": "2025-04-01",
120
+ Accept: "application/json",
121
+ Authorization: `token ${token}`,
122
+ },
123
+ signal: controller.signal,
124
+ });
125
+ clear();
126
+
127
+ if (!res.ok) {
128
+ return this.emptySnapshot(httpError(res.status));
129
+ }
130
+
131
+ const data = (await res.json()) as {
132
+ quota_reset_date_utc?: string;
133
+ quota_snapshots?: {
134
+ premium_interactions?: {
135
+ percent_remaining?: number;
136
+ remaining?: number;
137
+ entitlement?: number;
138
+ };
139
+ };
140
+ };
141
+
142
+ const windows: RateWindow[] = [];
143
+ const resetDate = data.quota_reset_date_utc ? new Date(data.quota_reset_date_utc) : undefined;
144
+ const resetDesc = resetDate ? formatReset(resetDate) : undefined;
145
+
146
+ let requestsRemaining: number | undefined;
147
+ let requestsEntitlement: number | undefined;
148
+
149
+ if (data.quota_snapshots?.premium_interactions) {
150
+ const pi = data.quota_snapshots.premium_interactions;
151
+ const monthUsedPercent = Math.max(0, 100 - (pi.percent_remaining || 0));
152
+ windows.push({
153
+ label: "Month",
154
+ usedPercent: monthUsedPercent,
155
+ resetDescription: resetDesc,
156
+ resetAt: resetDate?.toISOString(),
157
+ });
158
+
159
+ const remaining = pi.remaining ?? 0;
160
+ const entitlement = pi.entitlement ?? 0;
161
+ requestsRemaining = remaining;
162
+ requestsEntitlement = entitlement;
163
+ }
164
+
165
+ return this.snapshot({
166
+ windows,
167
+ requestsRemaining,
168
+ requestsEntitlement,
169
+ });
170
+ } catch {
171
+ clear();
172
+ return this.emptySnapshot(fetchFailed());
173
+ }
174
+ }
175
+
176
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Google Gemini usage provider
3
+ */
4
+
5
+ import * as path from "node:path";
6
+ import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
7
+ import { BaseProvider } from "../../provider.js";
8
+ import { noCredentials, fetchFailed, httpError } from "../../errors.js";
9
+ import { createTimeoutController } from "../../utils.js";
10
+ import { API_TIMEOUT_MS } from "../../config.js";
11
+
12
+ /**
13
+ * Load Gemini access token from various sources
14
+ */
15
+ function loadGeminiToken(deps: Dependencies): string | undefined {
16
+ // Explicit override via env var
17
+ const envToken = (
18
+ deps.env.GOOGLE_GEMINI_CLI_OAUTH_TOKEN ||
19
+ deps.env.GOOGLE_GEMINI_CLI_ACCESS_TOKEN ||
20
+ deps.env.GEMINI_OAUTH_TOKEN ||
21
+ deps.env.GOOGLE_GEMINI_OAUTH_TOKEN
22
+ )?.trim();
23
+ if (envToken) return envToken;
24
+
25
+ // Try pi auth.json first
26
+ const piAuthPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
27
+ try {
28
+ if (deps.fileExists(piAuthPath)) {
29
+ const data = JSON.parse(deps.readFile(piAuthPath) ?? "{}");
30
+ if (data["google-gemini-cli"]?.access) return data["google-gemini-cli"].access;
31
+ }
32
+ } catch {
33
+ // Ignore parse errors
34
+ }
35
+
36
+ // Try ~/.gemini/oauth_creds.json
37
+ const credPath = path.join(deps.homedir(), ".gemini", "oauth_creds.json");
38
+ try {
39
+ if (deps.fileExists(credPath)) {
40
+ const data = JSON.parse(deps.readFile(credPath) ?? "{}");
41
+ if (data.access_token) return data.access_token;
42
+ }
43
+ } catch {
44
+ // Ignore parse errors
45
+ }
46
+
47
+ return undefined;
48
+ }
49
+
50
+ export class GeminiProvider extends BaseProvider {
51
+ readonly name = "gemini" as const;
52
+ readonly displayName = "Gemini Plan";
53
+
54
+ hasCredentials(deps: Dependencies): boolean {
55
+ return Boolean(loadGeminiToken(deps));
56
+ }
57
+
58
+ async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
59
+ const token = loadGeminiToken(deps);
60
+ if (!token) {
61
+ return this.emptySnapshot(noCredentials());
62
+ }
63
+
64
+ const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
65
+
66
+ try {
67
+ const res = await deps.fetch("https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", {
68
+ method: "POST",
69
+ headers: {
70
+ Authorization: `Bearer ${token}`,
71
+ "Content-Type": "application/json",
72
+ },
73
+ body: "{}",
74
+ signal: controller.signal,
75
+ });
76
+ clear();
77
+
78
+ if (!res.ok) {
79
+ return this.emptySnapshot(httpError(res.status));
80
+ }
81
+
82
+ const data = (await res.json()) as {
83
+ buckets?: Array<{
84
+ modelId?: string;
85
+ remainingFraction?: number;
86
+ }>;
87
+ };
88
+
89
+ // Aggregate quotas by model type
90
+ const quotas: Record<string, number> = {};
91
+ for (const bucket of data.buckets || []) {
92
+ const model = bucket.modelId || "unknown";
93
+ const frac = bucket.remainingFraction ?? 1;
94
+ if (!quotas[model] || frac < quotas[model]) {
95
+ quotas[model] = frac;
96
+ }
97
+ }
98
+
99
+ const windows: RateWindow[] = [];
100
+ let proMin = 1;
101
+ let flashMin = 1;
102
+ let hasProModel = false;
103
+ let hasFlashModel = false;
104
+
105
+ for (const [model, frac] of Object.entries(quotas)) {
106
+ if (model.toLowerCase().includes("pro")) {
107
+ hasProModel = true;
108
+ if (frac < proMin) proMin = frac;
109
+ }
110
+ if (model.toLowerCase().includes("flash")) {
111
+ hasFlashModel = true;
112
+ if (frac < flashMin) flashMin = frac;
113
+ }
114
+ }
115
+
116
+ if (hasProModel) {
117
+ windows.push({ label: "Pro", usedPercent: (1 - proMin) * 100 });
118
+ }
119
+ if (hasFlashModel) {
120
+ windows.push({ label: "Flash", usedPercent: (1 - flashMin) * 100 });
121
+ }
122
+
123
+ return this.snapshot({ windows });
124
+ } catch {
125
+ clear();
126
+ return this.emptySnapshot(fetchFailed());
127
+ }
128
+ }
129
+
130
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * AWS Kiro usage provider
3
+ */
4
+
5
+ import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
6
+ import { BaseProvider } from "../../provider.js";
7
+ import { noCli, notLoggedIn, fetchFailed } from "../../errors.js";
8
+ import { formatReset, stripAnsi, whichSync } from "../../utils.js";
9
+ import { CLI_TIMEOUT_MS } from "../../config.js";
10
+
11
+ export class KiroProvider extends BaseProvider {
12
+ readonly name = "kiro" as const;
13
+ readonly displayName = "Kiro Plan";
14
+
15
+ hasCredentials(deps: Dependencies): boolean {
16
+ return Boolean(whichSync("kiro-cli", deps));
17
+ }
18
+
19
+ async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
20
+ const kiroBinary = whichSync("kiro-cli", deps);
21
+ if (!kiroBinary) {
22
+ return this.emptySnapshot(noCli("kiro-cli"));
23
+ }
24
+
25
+ try {
26
+ // Check if logged in
27
+ try {
28
+ deps.execFileSync(kiroBinary, ["whoami"], {
29
+ encoding: "utf-8",
30
+ timeout: API_TIMEOUT_MS,
31
+ stdio: ["ignore", "pipe", "pipe"],
32
+ });
33
+ } catch {
34
+ return this.emptySnapshot(notLoggedIn());
35
+ }
36
+
37
+ // Get usage
38
+ const output = deps.execFileSync(kiroBinary, ["chat", "--no-interactive", "/usage"], {
39
+ encoding: "utf-8",
40
+ timeout: CLI_TIMEOUT_MS,
41
+ env: { ...deps.env, TERM: "xterm-256color" },
42
+ stdio: ["ignore", "pipe", "pipe"],
43
+ });
44
+
45
+ const stripped = stripAnsi(output);
46
+ const windows: RateWindow[] = [];
47
+
48
+ // Parse credits percentage from "████...█ X%"
49
+ let creditsPercent = 0;
50
+ const percentMatch = stripped.match(/█+\s*(\d+)%/);
51
+ if (percentMatch) {
52
+ creditsPercent = parseInt(percentMatch[1], 10);
53
+ }
54
+
55
+ // Parse credits used/total from "(X.XX of Y covered in plan)"
56
+ const creditsMatch = stripped.match(/\((\d+\.?\d*)\s+of\s+(\d+)\s+covered/);
57
+ if (creditsMatch && !percentMatch) {
58
+ const creditsUsed = parseFloat(creditsMatch[1]);
59
+ const creditsTotal = parseFloat(creditsMatch[2]);
60
+ if (creditsTotal > 0) {
61
+ creditsPercent = (creditsUsed / creditsTotal) * 100;
62
+ }
63
+ }
64
+
65
+ // Parse reset date from "resets on 01/01"
66
+ let resetsAt: Date | undefined;
67
+ const resetMatch = stripped.match(/resets on (\d{2}\/\d{2})/);
68
+ if (resetMatch) {
69
+ const [month, day] = resetMatch[1].split("/").map(Number);
70
+ const now = new Date();
71
+ const year = now.getFullYear();
72
+ resetsAt = new Date(year, month - 1, day);
73
+ if (resetsAt < now) resetsAt.setFullYear(year + 1);
74
+ }
75
+
76
+ windows.push({
77
+ label: "Credits",
78
+ usedPercent: creditsPercent,
79
+ resetDescription: resetsAt ? formatReset(resetsAt) : undefined,
80
+ resetAt: resetsAt?.toISOString(),
81
+ });
82
+
83
+ return this.snapshot({ windows });
84
+ } catch {
85
+ return this.emptySnapshot(fetchFailed());
86
+ }
87
+ }
88
+
89
+ // Kiro doesn't have a public status page
90
+ }
91
+
92
+ const API_TIMEOUT_MS = 5000;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * z.ai usage provider
3
+ */
4
+
5
+ import * as path from "node:path";
6
+ import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
7
+ import { BaseProvider } from "../../provider.js";
8
+ import { noCredentials, fetchFailed, httpError, apiError } from "../../errors.js";
9
+ import { formatReset, createTimeoutController } from "../../utils.js";
10
+ import { API_TIMEOUT_MS } from "../../config.js";
11
+
12
+ /**
13
+ * Load z.ai API key from environment or auth.json
14
+ */
15
+ function loadZaiApiKey(deps: Dependencies): string | undefined {
16
+ // Try environment variable first
17
+ if (deps.env.ZAI_API_KEY) {
18
+ return deps.env.ZAI_API_KEY;
19
+ }
20
+ if (deps.env.Z_AI_API_KEY) {
21
+ return deps.env.Z_AI_API_KEY;
22
+ }
23
+
24
+ // Try pi auth.json
25
+ const authPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
26
+ try {
27
+ if (deps.fileExists(authPath)) {
28
+ const auth = JSON.parse(deps.readFile(authPath) ?? "{}");
29
+ return auth["z-ai"]?.access || auth["z-ai"]?.key || auth["zai"]?.access || auth["zai"]?.key;
30
+ }
31
+ } catch {
32
+ // Ignore parse errors
33
+ }
34
+
35
+ return undefined;
36
+ }
37
+
38
+ export class ZaiProvider extends BaseProvider {
39
+ readonly name = "zai" as const;
40
+ readonly displayName = "z.ai Plan";
41
+
42
+ hasCredentials(deps: Dependencies): boolean {
43
+ return Boolean(loadZaiApiKey(deps));
44
+ }
45
+
46
+ async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
47
+ const apiKey = loadZaiApiKey(deps);
48
+ if (!apiKey) {
49
+ return this.emptySnapshot(noCredentials());
50
+ }
51
+
52
+ const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
53
+
54
+ try {
55
+ const res = await deps.fetch("https://api.z.ai/api/monitor/usage/quota/limit", {
56
+ method: "GET",
57
+ headers: {
58
+ Authorization: `Bearer ${apiKey}`,
59
+ Accept: "application/json",
60
+ },
61
+ signal: controller.signal,
62
+ });
63
+ clear();
64
+
65
+ if (!res.ok) {
66
+ return this.emptySnapshot(httpError(res.status));
67
+ }
68
+
69
+ const data = (await res.json()) as {
70
+ success?: boolean;
71
+ code?: number;
72
+ msg?: string;
73
+ data?: {
74
+ limits?: Array<{
75
+ type?: string;
76
+ unit?: number;
77
+ number?: number;
78
+ percentage?: number;
79
+ nextResetTime?: string;
80
+ }>;
81
+ };
82
+ };
83
+
84
+ if (!data.success || data.code !== 200) {
85
+ return this.emptySnapshot(apiError(data.msg || "API error"));
86
+ }
87
+
88
+ const windows: RateWindow[] = [];
89
+ const limits = data.data?.limits || [];
90
+
91
+ for (const limit of limits) {
92
+ const percent = limit.percentage || 0;
93
+ const nextReset = limit.nextResetTime ? new Date(limit.nextResetTime) : undefined;
94
+
95
+ if (limit.type === "TOKENS_LIMIT") {
96
+ windows.push({
97
+ label: "Tokens",
98
+ usedPercent: percent,
99
+ resetDescription: nextReset ? formatReset(nextReset) : undefined,
100
+ resetAt: nextReset?.toISOString(),
101
+ });
102
+ } else if (limit.type === "TIME_LIMIT") {
103
+ windows.push({
104
+ label: "Monthly",
105
+ usedPercent: percent,
106
+ resetDescription: nextReset ? formatReset(nextReset) : undefined,
107
+ resetAt: nextReset?.toISOString(),
108
+ });
109
+ }
110
+ }
111
+
112
+ return this.snapshot({ windows });
113
+ } catch {
114
+ clear();
115
+ return this.emptySnapshot(fetchFailed());
116
+ }
117
+ }
118
+
119
+ // z.ai doesn't have a public status page
120
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Provider registry exports.
3
+ */
4
+
5
+ export * from "./registry.js";