@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.
@@ -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
+ };