@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
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { Metric, UsageAdapter, UsageSnapshot } from "../../../core/types.ts";
|
|
5
|
+
import { safeError, sameOriginFetch } from "../../../core/security.ts";
|
|
6
|
+
|
|
7
|
+
const ANTHROPIC_API_ORIGIN = "https://api.anthropic.com";
|
|
8
|
+
|
|
9
|
+
interface StoredAuth {
|
|
10
|
+
anthropic?: {
|
|
11
|
+
type?: string;
|
|
12
|
+
access?: string;
|
|
13
|
+
refresh?: string;
|
|
14
|
+
apiKey?: string;
|
|
15
|
+
key?: string;
|
|
16
|
+
expires?: number;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function resolveLocalAnthropicAuth(): Promise<{ token?: string | undefined; isOAuth: boolean; expires?: number | undefined }> {
|
|
21
|
+
try {
|
|
22
|
+
const authPath = join(getAgentDir(), "auth.json");
|
|
23
|
+
const raw = await readFile(authPath, "utf8");
|
|
24
|
+
const parsed = JSON.parse(raw) as StoredAuth;
|
|
25
|
+
const anthropic = parsed.anthropic;
|
|
26
|
+
if (anthropic) {
|
|
27
|
+
const isOAuth = anthropic.type === "oauth" || !!anthropic.access;
|
|
28
|
+
const token = anthropic.access || anthropic.apiKey || anthropic.key;
|
|
29
|
+
return { token, isOAuth, expires: anthropic.expires };
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// Ignore fallback errors
|
|
33
|
+
}
|
|
34
|
+
return { isOAuth: false };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const anthropicAdapter: UsageAdapter = {
|
|
38
|
+
id: "anthropic",
|
|
39
|
+
label: "Anthropic Claude",
|
|
40
|
+
canHandle(target) {
|
|
41
|
+
const pid = target.providerId.toLowerCase();
|
|
42
|
+
if (pid === "anthropic" || pid === "claude") return true;
|
|
43
|
+
if (target.baseUrl) {
|
|
44
|
+
try {
|
|
45
|
+
const origin = new URL(target.baseUrl).origin;
|
|
46
|
+
if (origin.includes("anthropic.com")) return true;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
},
|
|
53
|
+
async fetch({ target, signal, fetchFn }): Promise<UsageSnapshot> {
|
|
54
|
+
const fetchedAt = new Date().toISOString();
|
|
55
|
+
const authRecord = target.auth?.auth as Record<string, unknown> | undefined;
|
|
56
|
+
let token = (authRecord?.apiKey ?? authRecord?.access ?? authRecord?.key) as string | undefined;
|
|
57
|
+
let isOAuth = false;
|
|
58
|
+
let expires: number | undefined;
|
|
59
|
+
|
|
60
|
+
if (!token) {
|
|
61
|
+
const local = await resolveLocalAnthropicAuth();
|
|
62
|
+
token = local.token;
|
|
63
|
+
isOAuth = local.isOAuth;
|
|
64
|
+
expires = local.expires;
|
|
65
|
+
} else {
|
|
66
|
+
const local = await resolveLocalAnthropicAuth();
|
|
67
|
+
isOAuth = local.isOAuth;
|
|
68
|
+
expires = local.expires;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!token) {
|
|
72
|
+
return {
|
|
73
|
+
adapterId: this.id,
|
|
74
|
+
sourceProviderId: target.providerId,
|
|
75
|
+
displayName: "Claude",
|
|
76
|
+
state: "unauthorized",
|
|
77
|
+
fetchedAt,
|
|
78
|
+
accounts: [],
|
|
79
|
+
error: "No API key or OAuth subscription found in Pi auth for Anthropic",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
// For Claude Pro / Max OAuth subscription accounts or API keys:
|
|
85
|
+
// Send a lightweight headers probe to https://api.anthropic.com/v1/messages
|
|
86
|
+
// This retrieves active rate-limit headers without consuming tokens or failing unexpectedly
|
|
87
|
+
const headers: Record<string, string> = {
|
|
88
|
+
"anthropic-version": "2023-06-01",
|
|
89
|
+
"content-type": "application/json",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (isOAuth) {
|
|
93
|
+
headers["authorization"] = `Bearer ${token}`;
|
|
94
|
+
} else {
|
|
95
|
+
headers["x-api-key"] = token;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const res = await sameOriginFetch(
|
|
99
|
+
new URL("/v1/messages", ANTHROPIC_API_ORIGIN),
|
|
100
|
+
{
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers,
|
|
103
|
+
body: JSON.stringify({
|
|
104
|
+
model: "claude-sonnet-4-6",
|
|
105
|
+
messages: [{ role: "user", content: "" }],
|
|
106
|
+
max_tokens: 1,
|
|
107
|
+
}),
|
|
108
|
+
signal,
|
|
109
|
+
},
|
|
110
|
+
fetchFn,
|
|
111
|
+
ANTHROPIC_API_ORIGIN,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
if (res.status === 401) {
|
|
115
|
+
return {
|
|
116
|
+
adapterId: this.id,
|
|
117
|
+
sourceProviderId: target.providerId,
|
|
118
|
+
displayName: "Claude",
|
|
119
|
+
state: "unauthorized",
|
|
120
|
+
fetchedAt,
|
|
121
|
+
accounts: [],
|
|
122
|
+
error: "Anthropic returned HTTP 401 Unauthorized (token or key expired)",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Check standard anthropic rate-limit headers
|
|
127
|
+
const reqRemaining = res.headers.get("anthropic-ratelimit-requests-remaining");
|
|
128
|
+
const reqLimit = res.headers.get("anthropic-ratelimit-requests-limit");
|
|
129
|
+
const reqReset = res.headers.get("anthropic-ratelimit-requests-reset");
|
|
130
|
+
const tokensRemaining = res.headers.get("anthropic-ratelimit-tokens-remaining");
|
|
131
|
+
const tokensLimit = res.headers.get("anthropic-ratelimit-tokens-limit");
|
|
132
|
+
const tokensReset = res.headers.get("anthropic-ratelimit-tokens-reset");
|
|
133
|
+
|
|
134
|
+
const metrics: Metric[] = [];
|
|
135
|
+
|
|
136
|
+
if (reqRemaining != null && reqLimit != null) {
|
|
137
|
+
const remaining = Number(reqRemaining);
|
|
138
|
+
const limit = Number(reqLimit);
|
|
139
|
+
if (limit > 0) {
|
|
140
|
+
const m: Metric = {
|
|
141
|
+
kind: "quota-window",
|
|
142
|
+
id: "claude-requests",
|
|
143
|
+
label: "Claude Requests",
|
|
144
|
+
remainingFraction: Math.max(0, Math.min(1, remaining / limit)),
|
|
145
|
+
...(reqReset ? { resetAt: reqReset } : {}),
|
|
146
|
+
};
|
|
147
|
+
metrics.push(m);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (tokensRemaining != null && tokensLimit != null) {
|
|
152
|
+
const remaining = Number(tokensRemaining);
|
|
153
|
+
const limit = Number(tokensLimit);
|
|
154
|
+
if (limit > 0) {
|
|
155
|
+
const m: Metric = {
|
|
156
|
+
kind: "quota-window",
|
|
157
|
+
id: "claude-tokens",
|
|
158
|
+
label: "Claude Tokens",
|
|
159
|
+
remainingFraction: Math.max(0, Math.min(1, remaining / limit)),
|
|
160
|
+
...(tokensReset ? { resetAt: tokensReset } : {}),
|
|
161
|
+
};
|
|
162
|
+
metrics.push(m);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const planType = isOAuth ? "Subscription (Claude Pro/Max)" : "API Key";
|
|
167
|
+
let summary = `Claude · ${isOAuth ? "Pro/Max" : "Active"}`;
|
|
168
|
+
|
|
169
|
+
if (metrics.length > 0) {
|
|
170
|
+
const primary = metrics[0]!;
|
|
171
|
+
if (primary.kind === "quota-window") {
|
|
172
|
+
summary = `Claude ${Math.round(primary.remainingFraction * 100)}%`;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const statusMetric: Metric = {
|
|
177
|
+
kind: "status",
|
|
178
|
+
id: "claude-plan",
|
|
179
|
+
label: "Plan",
|
|
180
|
+
value: planType,
|
|
181
|
+
detail: expires ? `Expires in ${Math.max(0, Math.round((expires - Date.now()) / 60000))}m` : "Active",
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
metrics.push(statusMetric);
|
|
185
|
+
|
|
186
|
+
const accounts = [
|
|
187
|
+
{
|
|
188
|
+
id: isOAuth ? "claude-subscription" : "claude-api-key",
|
|
189
|
+
provider: "anthropic",
|
|
190
|
+
label: isOAuth ? "Claude Subscription" : "Claude API Account",
|
|
191
|
+
status: "available" as const,
|
|
192
|
+
metrics,
|
|
193
|
+
},
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
adapterId: this.id,
|
|
198
|
+
sourceProviderId: target.providerId,
|
|
199
|
+
displayName: "Claude",
|
|
200
|
+
state: "ok",
|
|
201
|
+
fetchedAt,
|
|
202
|
+
summary,
|
|
203
|
+
accounts,
|
|
204
|
+
};
|
|
205
|
+
} catch (error) {
|
|
206
|
+
throw new Error(safeError(error));
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Metric, UsageAdapter, UsageSnapshot } from "../../../core/types.ts";
|
|
2
|
+
import { bridgeUrl, safeError, sameOriginFetch } from "../../../core/security.ts";
|
|
3
|
+
|
|
4
|
+
type BridgeGroup = { id?: string; label?: string; remainingFraction?: number; resetTime?: string; models?: Array<{ id?: string; displayName?: string; remainingFraction?: number; resetTime?: string }> };
|
|
5
|
+
type BridgeAccount = { provider?: string; account?: string; authIndex?: string; label?: string; status?: string; disabled?: boolean; unavailable?: boolean; supported?: boolean; error?: string; groups?: BridgeGroup[] };
|
|
6
|
+
type BridgeUsage = { schemaVersion?: number; generatedAt?: string; cache?: { updatedAt?: string; stale?: boolean; ttlMs?: number }; accounts?: BridgeAccount[]; unsupportedProviders?: string[] };
|
|
7
|
+
|
|
8
|
+
function metrics(groups: BridgeGroup[]): Metric[] {
|
|
9
|
+
return groups.flatMap((group, index) => {
|
|
10
|
+
const base = group.remainingFraction;
|
|
11
|
+
return typeof base === "number"
|
|
12
|
+
? [{ kind: "quota-window" as const, id: group.id ?? `quota-${index}`, label: group.label ?? group.id ?? "Quota", remainingFraction: Math.min(1, Math.max(0, base)), ...(group.resetTime ? { resetAt: group.resetTime } : {}) }]
|
|
13
|
+
: [];
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const cliProxyBridgeAdapter: UsageAdapter = {
|
|
18
|
+
id: "cliproxy-pi-bridge",
|
|
19
|
+
label: "CLIProxyAPI / pi-bridge",
|
|
20
|
+
canHandle(target) {
|
|
21
|
+
// 1. If explicit baseUrl exists and is not official deepseek/openai, it's a potential bridge target
|
|
22
|
+
if (target.baseUrl) {
|
|
23
|
+
try {
|
|
24
|
+
const url = new URL(target.baseUrl);
|
|
25
|
+
if (url.origin.includes("deepseek.com")) return false;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const pid = target.providerId.toLowerCase();
|
|
31
|
+
// 2. Accept if providerId hints at proxy/bridge or if any custom baseUrl is present
|
|
32
|
+
return pid.includes("cpa") || pid.includes("cliproxy") || pid.includes("bridge") || pid.includes("proxy") || Boolean(target.baseUrl);
|
|
33
|
+
},
|
|
34
|
+
async fetch({ target, signal, force, fetchFn }): Promise<UsageSnapshot> {
|
|
35
|
+
const fetchedAt = new Date().toISOString();
|
|
36
|
+
const apiKey = target.auth?.auth.apiKey;
|
|
37
|
+
const baseUrl = target.auth?.auth.baseUrl ?? target.baseUrl;
|
|
38
|
+
if (!baseUrl || !apiKey) return { adapterId: this.id, sourceProviderId: target.providerId, displayName: target.providerId, state: "unauthorized", fetchedAt, accounts: [], error: "Missing base URL or API key" };
|
|
39
|
+
const origin = new URL(baseUrl).origin;
|
|
40
|
+
try {
|
|
41
|
+
const response = await sameOriginFetch(bridgeUrl(baseUrl, "usage", force), {
|
|
42
|
+
method: "GET",
|
|
43
|
+
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json", "X-Pi-Contract": "2" },
|
|
44
|
+
signal,
|
|
45
|
+
}, fetchFn, origin);
|
|
46
|
+
if (response.status === 404) return { adapterId: this.id, sourceProviderId: target.providerId, displayName: target.providerId, state: "not-installed", fetchedAt, accounts: [], error: "pi-bridge usage endpoint was not found" };
|
|
47
|
+
if (response.status === 401 || response.status === 403) return { adapterId: this.id, sourceProviderId: target.providerId, displayName: target.providerId, state: "unauthorized", fetchedAt, accounts: [], error: "The API key is not authorized for pi-bridge" };
|
|
48
|
+
if (!response.ok) throw new Error(`pi-bridge returned HTTP ${response.status}`);
|
|
49
|
+
const data = await response.json() as BridgeUsage;
|
|
50
|
+
if (data.schemaVersion !== 1) return { adapterId: this.id, sourceProviderId: target.providerId, displayName: target.providerId, state: "incompatible", fetchedAt, accounts: [], error: `Unsupported pi-bridge schemaVersion ${String(data.schemaVersion)}` };
|
|
51
|
+
const accounts = (data.accounts ?? []).map((account, index) => ({
|
|
52
|
+
id: account.authIndex ?? `${account.provider ?? "provider"}-${index}`,
|
|
53
|
+
provider: account.provider ?? "unknown",
|
|
54
|
+
label: account.label || account.account || account.provider || `Account ${index + 1}`,
|
|
55
|
+
...(account.status ? { status: account.status } : {}),
|
|
56
|
+
...(account.disabled !== undefined ? { disabled: account.disabled } : {}),
|
|
57
|
+
...(account.unavailable !== undefined ? { unavailable: account.unavailable } : {}),
|
|
58
|
+
metrics: metrics(account.groups ?? []),
|
|
59
|
+
rawGroups: account.groups,
|
|
60
|
+
...(account.error ? { error: account.error } : {}),
|
|
61
|
+
}));
|
|
62
|
+
return {
|
|
63
|
+
adapterId: this.id,
|
|
64
|
+
sourceProviderId: target.providerId,
|
|
65
|
+
displayName: target.providerId,
|
|
66
|
+
state: accounts.length ? (data.cache?.stale ? "stale" : "ok") : "empty",
|
|
67
|
+
fetchedAt: data.cache?.updatedAt ?? data.generatedAt ?? fetchedAt,
|
|
68
|
+
...(data.cache?.stale ? { stale: true } : {}),
|
|
69
|
+
accounts,
|
|
70
|
+
...(data.unsupportedProviders?.length ? { diagnostic: `Unsupported upstream providers: ${data.unsupportedProviders.join(", ")}` } : {}),
|
|
71
|
+
};
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw new Error(safeError(error));
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { UsageAdapter, UsageSnapshot } from "../../../core/types.ts";
|
|
2
|
+
import { safeError, sameOriginFetch } from "../../../core/security.ts";
|
|
3
|
+
|
|
4
|
+
type BalanceInfo = {
|
|
5
|
+
currency?: string;
|
|
6
|
+
total_balance?: string | number | null;
|
|
7
|
+
granted_balance?: string | number | null;
|
|
8
|
+
topped_up_balance?: string | number | null;
|
|
9
|
+
};
|
|
10
|
+
type BalanceResponse = { is_available?: boolean; balance_infos?: BalanceInfo[] };
|
|
11
|
+
|
|
12
|
+
const OFFICIAL_ORIGIN = "https://api.deepseek.com";
|
|
13
|
+
|
|
14
|
+
function number(value: unknown): number | undefined {
|
|
15
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
|
|
16
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function format(amount: number, currency: string): string {
|
|
20
|
+
return `${currency === "CNY" || currency === "RMB" ? "¥" : currency === "USD" ? "$" : `${currency} `}${amount.toFixed(2)}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const deepSeekAdapter: UsageAdapter = {
|
|
24
|
+
id: "deepseek",
|
|
25
|
+
label: "DeepSeek",
|
|
26
|
+
canHandle(target) {
|
|
27
|
+
const origin = target.baseUrl ? new URL(target.baseUrl).origin : undefined;
|
|
28
|
+
return target.providerId.toLowerCase() === "deepseek" && (!origin || origin === OFFICIAL_ORIGIN);
|
|
29
|
+
},
|
|
30
|
+
async fetch({ target, signal, fetchFn }): Promise<UsageSnapshot> {
|
|
31
|
+
const fetchedAt = new Date().toISOString();
|
|
32
|
+
const apiKey = target.auth?.auth.apiKey;
|
|
33
|
+
if (!apiKey) return { adapterId: this.id, sourceProviderId: target.providerId, displayName: "DeepSeek", state: "unauthorized", fetchedAt, accounts: [], error: "No API key resolved from Pi provider auth" };
|
|
34
|
+
try {
|
|
35
|
+
const response = await sameOriginFetch(new URL("/user/balance", OFFICIAL_ORIGIN), {
|
|
36
|
+
method: "GET",
|
|
37
|
+
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
|
38
|
+
signal,
|
|
39
|
+
}, fetchFn, OFFICIAL_ORIGIN);
|
|
40
|
+
if (response.status === 401 || response.status === 403) return { adapterId: this.id, sourceProviderId: target.providerId, displayName: "DeepSeek", state: "unauthorized", fetchedAt, accounts: [], error: `DeepSeek returned HTTP ${response.status}` };
|
|
41
|
+
if (!response.ok) throw new Error(`DeepSeek returned HTTP ${response.status}`);
|
|
42
|
+
const data = await response.json() as BalanceResponse;
|
|
43
|
+
const metrics = (data.balance_infos ?? []).flatMap((info, index) => {
|
|
44
|
+
const total = number(info.total_balance);
|
|
45
|
+
if (total === undefined) return [];
|
|
46
|
+
const currency = (info.currency ?? "credits").toUpperCase();
|
|
47
|
+
const granted = number(info.granted_balance);
|
|
48
|
+
const topped = number(info.topped_up_balance);
|
|
49
|
+
const detail = [topped !== undefined ? `Paid ${format(topped, currency)}` : undefined, granted !== undefined ? `Granted ${format(granted, currency)}` : undefined].filter(Boolean).join(" · ");
|
|
50
|
+
return [{ kind: "balance" as const, id: `balance-${currency}-${index}`, label: "Balance", amount: total, currency, ...(detail ? { detail } : {}) }];
|
|
51
|
+
});
|
|
52
|
+
const summary = metrics.map((metric) => format(metric.amount, metric.currency)).join(" · ");
|
|
53
|
+
return {
|
|
54
|
+
adapterId: this.id,
|
|
55
|
+
sourceProviderId: target.providerId,
|
|
56
|
+
displayName: "DeepSeek",
|
|
57
|
+
state: metrics.length ? "ok" : "empty",
|
|
58
|
+
fetchedAt,
|
|
59
|
+
accounts: [{ id: target.providerId, provider: "deepseek", label: "DeepSeek API", status: data.is_available === false ? "unavailable" : "available", metrics }],
|
|
60
|
+
...(summary ? { summary: `Balance ${summary}` } : {}),
|
|
61
|
+
};
|
|
62
|
+
} catch (error) {
|
|
63
|
+
throw new Error(safeError(error));
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
};
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { Metric, UsageAdapter, UsageSnapshot } from "../../../core/types.ts";
|
|
5
|
+
import { safeError, sameOriginFetch } from "../../../core/security.ts";
|
|
6
|
+
|
|
7
|
+
const BIGMODEL_API_ORIGIN = "https://open.bigmodel.cn";
|
|
8
|
+
const ZAI_API_ORIGIN = "https://api.z.ai";
|
|
9
|
+
|
|
10
|
+
interface StoredAuth {
|
|
11
|
+
[key: string]: {
|
|
12
|
+
type?: string;
|
|
13
|
+
apiKey?: string;
|
|
14
|
+
key?: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface QuotaLimitResponse {
|
|
19
|
+
code: number;
|
|
20
|
+
msg?: string;
|
|
21
|
+
success?: boolean;
|
|
22
|
+
data?: {
|
|
23
|
+
level?: string;
|
|
24
|
+
limits?: Array<{
|
|
25
|
+
type: string;
|
|
26
|
+
percentage: number;
|
|
27
|
+
nextResetTime?: number;
|
|
28
|
+
currentValue?: number;
|
|
29
|
+
usage?: number;
|
|
30
|
+
remaining?: number;
|
|
31
|
+
}>;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function resolveLocalGLMAuth(): Promise<string | undefined> {
|
|
36
|
+
try {
|
|
37
|
+
const authPath = join(getAgentDir(), "auth.json");
|
|
38
|
+
const raw = await readFile(authPath, "utf8");
|
|
39
|
+
const parsed = JSON.parse(raw) as StoredAuth;
|
|
40
|
+
const candidates = [
|
|
41
|
+
parsed.glm,
|
|
42
|
+
parsed["zai-coding-cn"],
|
|
43
|
+
parsed.zai,
|
|
44
|
+
parsed.zhipu,
|
|
45
|
+
parsed.bigmodel,
|
|
46
|
+
];
|
|
47
|
+
for (const item of candidates) {
|
|
48
|
+
if (item) {
|
|
49
|
+
const key = item.apiKey || item.key;
|
|
50
|
+
if (key) return key;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// Ignore fallback errors
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const glmAdapter: UsageAdapter = {
|
|
60
|
+
id: "glm",
|
|
61
|
+
label: "GLM / 智谱 BigModel",
|
|
62
|
+
canHandle(target) {
|
|
63
|
+
const pid = target.providerId.toLowerCase();
|
|
64
|
+
if (
|
|
65
|
+
pid === "glm" ||
|
|
66
|
+
pid === "zhipu" ||
|
|
67
|
+
pid === "bigmodel" ||
|
|
68
|
+
pid === "zai" ||
|
|
69
|
+
pid === "zai-coding-cn"
|
|
70
|
+
) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
if (target.baseUrl) {
|
|
74
|
+
try {
|
|
75
|
+
const host = new URL(target.baseUrl).host;
|
|
76
|
+
if (host.includes("bigmodel.cn") || host.includes("z.ai")) return true;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
},
|
|
83
|
+
async fetch({ target, signal, fetchFn }): Promise<UsageSnapshot> {
|
|
84
|
+
const fetchedAt = new Date().toISOString();
|
|
85
|
+
const authRecord = target.auth?.auth as Record<string, unknown> | undefined;
|
|
86
|
+
let apiKey = (authRecord?.apiKey ?? authRecord?.key) as string | undefined;
|
|
87
|
+
|
|
88
|
+
if (!apiKey) {
|
|
89
|
+
apiKey = await resolveLocalGLMAuth();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!apiKey) {
|
|
93
|
+
return {
|
|
94
|
+
adapterId: this.id,
|
|
95
|
+
sourceProviderId: target.providerId,
|
|
96
|
+
displayName: "GLM",
|
|
97
|
+
state: "unauthorized",
|
|
98
|
+
fetchedAt,
|
|
99
|
+
accounts: [],
|
|
100
|
+
error: "No API key found in Pi auth for GLM / Zhipu",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
// Determine base URL: choose between open.bigmodel.cn or api.z.ai based on target.baseUrl
|
|
106
|
+
const isZai = target.baseUrl?.includes("api.z.ai");
|
|
107
|
+
const baseOrigin = isZai ? ZAI_API_ORIGIN : BIGMODEL_API_ORIGIN;
|
|
108
|
+
|
|
109
|
+
// 1. Priority: Query official GLM Coding Plan usage quota limit endpoint
|
|
110
|
+
// GET /api/monitor/usage/quota/limit
|
|
111
|
+
// Authorization: <apiKey> (or Bearer <apiKey>)
|
|
112
|
+
try {
|
|
113
|
+
const quotaRes = await sameOriginFetch(
|
|
114
|
+
new URL("/api/monitor/usage/quota/limit", baseOrigin),
|
|
115
|
+
{
|
|
116
|
+
method: "GET",
|
|
117
|
+
headers: {
|
|
118
|
+
Authorization: apiKey,
|
|
119
|
+
Accept: "application/json",
|
|
120
|
+
},
|
|
121
|
+
signal,
|
|
122
|
+
},
|
|
123
|
+
fetchFn,
|
|
124
|
+
baseOrigin,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
if (quotaRes.ok) {
|
|
128
|
+
const quotaJson = (await quotaRes.json()) as QuotaLimitResponse;
|
|
129
|
+
if (quotaJson.success && quotaJson.data?.limits) {
|
|
130
|
+
const limits = quotaJson.data.limits;
|
|
131
|
+
const tokenLimits = limits
|
|
132
|
+
.filter((l) => l.type === "TOKENS_LIMIT")
|
|
133
|
+
.sort((a, b) => (a.nextResetTime ?? 0) - (b.nextResetTime ?? 0));
|
|
134
|
+
|
|
135
|
+
const metrics: Metric[] = [];
|
|
136
|
+
const planLevel = quotaJson.data.level ? quotaJson.data.level.toUpperCase() : "Coding Plan";
|
|
137
|
+
|
|
138
|
+
// Multi-window tokens limit (e.g. 5h and weekly/7d)
|
|
139
|
+
if (tokenLimits.length > 0) {
|
|
140
|
+
tokenLimits.forEach((tl, idx) => {
|
|
141
|
+
const label = idx === 0 ? "GLM 5h" : "GLM 7d";
|
|
142
|
+
const used = Math.max(0, Math.min(100, tl.percentage));
|
|
143
|
+
const remainingFraction = (100 - used) / 100;
|
|
144
|
+
const resetAt = tl.nextResetTime ? new Date(tl.nextResetTime).toISOString() : undefined;
|
|
145
|
+
metrics.push({
|
|
146
|
+
kind: "quota-window",
|
|
147
|
+
id: `glm-window-${idx}`,
|
|
148
|
+
label,
|
|
149
|
+
remainingFraction,
|
|
150
|
+
...(resetAt ? { resetAt } : {}),
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// MCP monthly limit
|
|
156
|
+
const mcp = limits.find((l) => l.type === "TIME_LIMIT");
|
|
157
|
+
if (mcp && mcp.usage && mcp.usage > 0) {
|
|
158
|
+
const remaining = mcp.remaining ?? (mcp.usage - (mcp.currentValue ?? 0));
|
|
159
|
+
metrics.push({
|
|
160
|
+
kind: "quota-window",
|
|
161
|
+
id: "glm-mcp",
|
|
162
|
+
label: "MCP Monthly",
|
|
163
|
+
remainingFraction: Math.max(0, Math.min(1, remaining / mcp.usage)),
|
|
164
|
+
detail: `${mcp.currentValue ?? 0}/${mcp.usage}`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
metrics.push({
|
|
169
|
+
kind: "status",
|
|
170
|
+
id: "glm-plan-level",
|
|
171
|
+
label: "Plan",
|
|
172
|
+
value: planLevel,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Format summary
|
|
176
|
+
let summary = `GLM · ${planLevel}`;
|
|
177
|
+
if (metrics.length > 0 && metrics[0]!.kind === "quota-window") {
|
|
178
|
+
const parts = metrics
|
|
179
|
+
.filter((m): m is Extract<Metric, { kind: "quota-window" }> => m.kind === "quota-window" && m.id.startsWith("glm-window"))
|
|
180
|
+
.map((m) => {
|
|
181
|
+
const sub = m.label.replace(/^GLM\s+/, "");
|
|
182
|
+
return `${sub} ${Math.round(m.remainingFraction * 100)}%`;
|
|
183
|
+
});
|
|
184
|
+
if (parts.length > 0) {
|
|
185
|
+
summary = `GLM ${parts.join(" · ")}`;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
adapterId: this.id,
|
|
191
|
+
sourceProviderId: target.providerId,
|
|
192
|
+
displayName: "GLM",
|
|
193
|
+
state: "ok",
|
|
194
|
+
fetchedAt,
|
|
195
|
+
summary,
|
|
196
|
+
accounts: [
|
|
197
|
+
{
|
|
198
|
+
id: "glm-coding-plan",
|
|
199
|
+
provider: "glm",
|
|
200
|
+
label: `GLM ${planLevel}`,
|
|
201
|
+
status: "available",
|
|
202
|
+
metrics,
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
// Fallback to Pay-as-you-go verification
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 2. Fallback: Pay-As-You-Go standard API Key probe via completions
|
|
213
|
+
const probeRes = await sameOriginFetch(
|
|
214
|
+
new URL(target.baseUrl ? new URL(target.baseUrl).pathname + "/chat/completions" : "/api/paas/v4/chat/completions", baseOrigin),
|
|
215
|
+
{
|
|
216
|
+
method: "POST",
|
|
217
|
+
headers: {
|
|
218
|
+
Authorization: `Bearer ${apiKey}`,
|
|
219
|
+
"Content-Type": "application/json",
|
|
220
|
+
},
|
|
221
|
+
body: JSON.stringify({
|
|
222
|
+
model: "glm-4.7",
|
|
223
|
+
messages: [{ role: "user", content: "hi" }],
|
|
224
|
+
max_tokens: 1,
|
|
225
|
+
}),
|
|
226
|
+
signal,
|
|
227
|
+
},
|
|
228
|
+
fetchFn,
|
|
229
|
+
baseOrigin,
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
if (probeRes.status === 401 || probeRes.status === 403) {
|
|
233
|
+
return {
|
|
234
|
+
adapterId: this.id,
|
|
235
|
+
sourceProviderId: target.providerId,
|
|
236
|
+
displayName: "GLM",
|
|
237
|
+
state: "unauthorized",
|
|
238
|
+
fetchedAt,
|
|
239
|
+
accounts: [],
|
|
240
|
+
error: `GLM returned HTTP ${probeRes.status} (API key invalid or expired)`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Read rate-limit headers if returned by proxy/gateway
|
|
245
|
+
const reqRemaining = probeRes.headers.get("x-ratelimit-remaining-requests");
|
|
246
|
+
const reqLimit = probeRes.headers.get("x-ratelimit-limit-requests");
|
|
247
|
+
const metrics: Metric[] = [];
|
|
248
|
+
|
|
249
|
+
if (reqRemaining != null && reqLimit != null) {
|
|
250
|
+
const rem = Number(reqRemaining);
|
|
251
|
+
const lim = Number(reqLimit);
|
|
252
|
+
if (lim > 0) {
|
|
253
|
+
metrics.push({
|
|
254
|
+
kind: "quota-window",
|
|
255
|
+
id: "glm-rpm",
|
|
256
|
+
label: "GLM Requests",
|
|
257
|
+
remainingFraction: Math.max(0, Math.min(1, rem / lim)),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
metrics.push({
|
|
263
|
+
kind: "status",
|
|
264
|
+
id: "glm-plan-type",
|
|
265
|
+
label: "Notice",
|
|
266
|
+
value: "Quota display only supported for Coding Plan",
|
|
267
|
+
detail: "GLM official API does not provide quota/balance query for standard Pay-as-you-go keys",
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
adapterId: this.id,
|
|
272
|
+
sourceProviderId: target.providerId,
|
|
273
|
+
displayName: "GLM",
|
|
274
|
+
state: "ok",
|
|
275
|
+
fetchedAt,
|
|
276
|
+
summary: "GLM · Coding Plan Only",
|
|
277
|
+
accounts: [
|
|
278
|
+
{
|
|
279
|
+
id: "glm-payg-account",
|
|
280
|
+
provider: "glm",
|
|
281
|
+
label: "GLM (BigModel)",
|
|
282
|
+
status: "available",
|
|
283
|
+
metrics,
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
};
|
|
287
|
+
} catch (error) {
|
|
288
|
+
throw new Error(safeError(error));
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
};
|