@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,196 @@
|
|
|
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 CODEX_BASE_ORIGIN = "https://chatgpt.com";
|
|
8
|
+
const CODEX_USAGE_PATH = "/backend-api/wham/usage";
|
|
9
|
+
|
|
10
|
+
interface WhamWindow {
|
|
11
|
+
used_percent?: number;
|
|
12
|
+
limit_window_seconds?: number;
|
|
13
|
+
reset_after_seconds?: number;
|
|
14
|
+
reset_at?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface WhamUsageResponse {
|
|
18
|
+
user_id?: string;
|
|
19
|
+
account_id?: string;
|
|
20
|
+
email?: string;
|
|
21
|
+
plan_type?: string;
|
|
22
|
+
rate_limit?: {
|
|
23
|
+
allowed?: boolean;
|
|
24
|
+
limit_reached?: boolean;
|
|
25
|
+
primary_window?: WhamWindow | null;
|
|
26
|
+
secondary_window?: WhamWindow | null;
|
|
27
|
+
} | null;
|
|
28
|
+
credits?: {
|
|
29
|
+
has_credits?: boolean;
|
|
30
|
+
balance?: string;
|
|
31
|
+
} | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseWindow(
|
|
35
|
+
window: WhamWindow | null | undefined,
|
|
36
|
+
defaultId: string,
|
|
37
|
+
defaultLabel: string,
|
|
38
|
+
): Metric | undefined {
|
|
39
|
+
if (!window || typeof window.used_percent !== "number") return undefined;
|
|
40
|
+
const used = Math.min(100, Math.max(0, window.used_percent));
|
|
41
|
+
const remainingFraction = Math.max(0, (100 - used) / 100);
|
|
42
|
+
|
|
43
|
+
let resetAt: string | undefined;
|
|
44
|
+
if (typeof window.reset_at === "number" && window.reset_at > 0) {
|
|
45
|
+
resetAt = new Date(window.reset_at * 1000).toISOString();
|
|
46
|
+
} else if (typeof window.reset_after_seconds === "number" && window.reset_after_seconds > 0) {
|
|
47
|
+
resetAt = new Date(Date.now() + window.reset_after_seconds * 1000).toISOString();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
kind: "quota-window",
|
|
52
|
+
id: defaultId,
|
|
53
|
+
label: defaultLabel,
|
|
54
|
+
remainingFraction,
|
|
55
|
+
...(resetAt ? { resetAt } : {}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function resolveLocalCodexAuth(): Promise<{ accessToken?: string | undefined; accountId?: string | undefined }> {
|
|
60
|
+
try {
|
|
61
|
+
const authPath = join(getAgentDir(), "auth.json");
|
|
62
|
+
const raw = await readFile(authPath, "utf8");
|
|
63
|
+
const parsed = JSON.parse(raw) as Record<string, { access?: string; apiKey?: string; accountId?: string; chatgpt_account_id?: string }>;
|
|
64
|
+
const codex = parsed["openai-codex"];
|
|
65
|
+
if (codex) {
|
|
66
|
+
return {
|
|
67
|
+
accessToken: codex.access ?? codex.apiKey,
|
|
68
|
+
accountId: codex.accountId ?? codex.chatgpt_account_id,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// Ignore fallback errors
|
|
73
|
+
}
|
|
74
|
+
return {};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const openAICodexAdapter: UsageAdapter = {
|
|
78
|
+
id: "openai-codex",
|
|
79
|
+
label: "OpenAI Codex (ChatGPT)",
|
|
80
|
+
canHandle(target) {
|
|
81
|
+
const pid = target.providerId.toLowerCase();
|
|
82
|
+
if (pid === "openai-codex") return true;
|
|
83
|
+
if (target.baseUrl) {
|
|
84
|
+
try {
|
|
85
|
+
const origin = new URL(target.baseUrl).origin;
|
|
86
|
+
if (origin.includes("chatgpt.com")) return true;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
},
|
|
93
|
+
async fetch({ target, signal, fetchFn }): Promise<UsageSnapshot> {
|
|
94
|
+
const fetchedAt = new Date().toISOString();
|
|
95
|
+
const authRecord = target.auth?.auth as Record<string, unknown> | undefined;
|
|
96
|
+
let accessToken = (authRecord?.apiKey ?? authRecord?.access) as string | undefined;
|
|
97
|
+
let accountId = (authRecord?.accountId ?? authRecord?.chatgpt_account_id) as string | undefined;
|
|
98
|
+
|
|
99
|
+
if (!accessToken || !accountId) {
|
|
100
|
+
const local = await resolveLocalCodexAuth();
|
|
101
|
+
accessToken = accessToken ?? local.accessToken;
|
|
102
|
+
accountId = accountId ?? local.accountId;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!accessToken) {
|
|
106
|
+
return {
|
|
107
|
+
adapterId: this.id,
|
|
108
|
+
sourceProviderId: target.providerId,
|
|
109
|
+
displayName: "OpenAI Codex",
|
|
110
|
+
state: "unauthorized",
|
|
111
|
+
fetchedAt,
|
|
112
|
+
accounts: [],
|
|
113
|
+
error: "No access token found in Pi auth for openai-codex",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const headers: Record<string, string> = {
|
|
119
|
+
Authorization: `Bearer ${accessToken}`,
|
|
120
|
+
Accept: "application/json",
|
|
121
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
|
122
|
+
};
|
|
123
|
+
if (accountId) {
|
|
124
|
+
headers["ChatGPT-Account-Id"] = accountId;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const response = await sameOriginFetch(
|
|
128
|
+
new URL(CODEX_USAGE_PATH, CODEX_BASE_ORIGIN),
|
|
129
|
+
{ method: "GET", headers, signal },
|
|
130
|
+
fetchFn,
|
|
131
|
+
CODEX_BASE_ORIGIN,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
if (response.status === 401 || response.status === 403) {
|
|
135
|
+
return {
|
|
136
|
+
adapterId: this.id,
|
|
137
|
+
sourceProviderId: target.providerId,
|
|
138
|
+
displayName: "OpenAI Codex",
|
|
139
|
+
state: "unauthorized",
|
|
140
|
+
fetchedAt,
|
|
141
|
+
accounts: [],
|
|
142
|
+
error: `ChatGPT returned HTTP ${response.status} (token may need refresh)`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!response.ok) {
|
|
147
|
+
throw new Error(`ChatGPT wham/usage returned HTTP ${response.status}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const data = (await response.json()) as WhamUsageResponse;
|
|
151
|
+
const metrics: Metric[] = [];
|
|
152
|
+
|
|
153
|
+
const primary = parseWindow(data.rate_limit?.primary_window, "primary-window", "Codex 5h");
|
|
154
|
+
if (primary) metrics.push(primary);
|
|
155
|
+
|
|
156
|
+
const secondary = parseWindow(data.rate_limit?.secondary_window, "secondary-window", "Codex 7d");
|
|
157
|
+
if (secondary) metrics.push(secondary);
|
|
158
|
+
|
|
159
|
+
const planLabel = data.plan_type ? `ChatGPT ${data.plan_type.toUpperCase()}` : "ChatGPT Plus/Pro";
|
|
160
|
+
const accountLabel = data.email || data.user_id || planLabel;
|
|
161
|
+
|
|
162
|
+
const rawGroups = metrics.map((m) => {
|
|
163
|
+
const qw = m as Extract<Metric, { kind: "quota-window" }>;
|
|
164
|
+
return {
|
|
165
|
+
id: qw.id,
|
|
166
|
+
label: qw.label,
|
|
167
|
+
remainingFraction: qw.remainingFraction,
|
|
168
|
+
...(qw.resetAt ? { resetTime: qw.resetAt } : {}),
|
|
169
|
+
models: [{ id: qw.id }],
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const accounts = [
|
|
174
|
+
{
|
|
175
|
+
id: data.account_id || data.user_id || "openai-codex-account",
|
|
176
|
+
provider: "openai-codex",
|
|
177
|
+
label: accountLabel,
|
|
178
|
+
status: data.rate_limit?.limit_reached ? "limit_reached" : "available",
|
|
179
|
+
metrics,
|
|
180
|
+
rawGroups,
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
adapterId: this.id,
|
|
186
|
+
sourceProviderId: target.providerId,
|
|
187
|
+
displayName: "OpenAI Codex",
|
|
188
|
+
state: metrics.length ? "ok" : "empty",
|
|
189
|
+
fetchedAt,
|
|
190
|
+
accounts,
|
|
191
|
+
};
|
|
192
|
+
} catch (error) {
|
|
193
|
+
throw new Error(safeError(error));
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
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 XAI_AUTH_ORIGIN = "https://auth.x.ai";
|
|
8
|
+
const XAI_USERINFO_PATH = "/oauth2/userinfo";
|
|
9
|
+
const XAI_API_ORIGIN = "https://api.x.ai";
|
|
10
|
+
|
|
11
|
+
interface XAIUserInfo {
|
|
12
|
+
sub?: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
email?: string;
|
|
15
|
+
email_verified?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface XAIErrorResponse {
|
|
19
|
+
code?: string;
|
|
20
|
+
error?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function resolveLocalXAIAuth(): Promise<{ accessToken?: string | undefined }> {
|
|
24
|
+
try {
|
|
25
|
+
const authPath = join(getAgentDir(), "auth.json");
|
|
26
|
+
const raw = await readFile(authPath, "utf8");
|
|
27
|
+
const parsed = JSON.parse(raw) as Record<string, { access?: string; apiKey?: string }>;
|
|
28
|
+
const xai = parsed.xai;
|
|
29
|
+
if (xai) {
|
|
30
|
+
return { accessToken: xai.access ?? xai.apiKey };
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
// Ignore fallback errors
|
|
34
|
+
}
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const xaiAdapter: UsageAdapter = {
|
|
39
|
+
id: "xai",
|
|
40
|
+
label: "xAI / Grok",
|
|
41
|
+
canHandle(target) {
|
|
42
|
+
const pid = target.providerId.toLowerCase();
|
|
43
|
+
if (pid === "xai" || pid === "grok") return true;
|
|
44
|
+
if (target.baseUrl) {
|
|
45
|
+
try {
|
|
46
|
+
const origin = new URL(target.baseUrl).origin;
|
|
47
|
+
if (origin.includes("x.ai")) return true;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
},
|
|
54
|
+
async fetch({ target, signal, fetchFn }): Promise<UsageSnapshot> {
|
|
55
|
+
const fetchedAt = new Date().toISOString();
|
|
56
|
+
const authRecord = target.auth?.auth as Record<string, unknown> | undefined;
|
|
57
|
+
let accessToken = (authRecord?.apiKey ?? authRecord?.access) as string | undefined;
|
|
58
|
+
|
|
59
|
+
if (!accessToken) {
|
|
60
|
+
const local = await resolveLocalXAIAuth();
|
|
61
|
+
accessToken = local.accessToken;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!accessToken) {
|
|
65
|
+
return {
|
|
66
|
+
adapterId: this.id,
|
|
67
|
+
sourceProviderId: target.providerId,
|
|
68
|
+
displayName: "xAI Grok",
|
|
69
|
+
state: "unauthorized",
|
|
70
|
+
fetchedAt,
|
|
71
|
+
accounts: [],
|
|
72
|
+
error: "No access token found in Pi auth for xai",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
// 1. Verify OAuth token identity with auth.x.ai
|
|
78
|
+
const userinfoRes = await sameOriginFetch(
|
|
79
|
+
new URL(XAI_USERINFO_PATH, XAI_AUTH_ORIGIN),
|
|
80
|
+
{
|
|
81
|
+
method: "GET",
|
|
82
|
+
headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
|
|
83
|
+
signal,
|
|
84
|
+
},
|
|
85
|
+
fetchFn,
|
|
86
|
+
XAI_AUTH_ORIGIN,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
if (userinfoRes.status === 401 || userinfoRes.status === 403) {
|
|
90
|
+
return {
|
|
91
|
+
adapterId: this.id,
|
|
92
|
+
sourceProviderId: target.providerId,
|
|
93
|
+
displayName: "xAI Grok",
|
|
94
|
+
state: "unauthorized",
|
|
95
|
+
fetchedAt,
|
|
96
|
+
accounts: [],
|
|
97
|
+
error: `xAI returned HTTP ${userinfoRes.status} (token expired or invalid)`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let userLabel = "Grok Account";
|
|
102
|
+
let userId = "xai-user";
|
|
103
|
+
if (userinfoRes.ok) {
|
|
104
|
+
const info = (await userinfoRes.json()) as XAIUserInfo;
|
|
105
|
+
userLabel = info.email || info.name || userLabel;
|
|
106
|
+
userId = info.sub || userId;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 2. Query billing / spending limit status via lightweight probe
|
|
110
|
+
// xAI returns HTTP 402 with code: 'personal-team-blocked:spending-limit' when credit limit is hit or subscription is required
|
|
111
|
+
let statusText = "Active";
|
|
112
|
+
let limitHit = false;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const probeRes = await sameOriginFetch(
|
|
116
|
+
new URL("/v1/chat/completions", XAI_API_ORIGIN),
|
|
117
|
+
{
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: {
|
|
120
|
+
Authorization: `Bearer ${accessToken}`,
|
|
121
|
+
"Content-Type": "application/json",
|
|
122
|
+
},
|
|
123
|
+
body: JSON.stringify({
|
|
124
|
+
model: "grok-4.6",
|
|
125
|
+
messages: [{ role: "user", content: "" }],
|
|
126
|
+
max_tokens: 1,
|
|
127
|
+
}),
|
|
128
|
+
signal,
|
|
129
|
+
},
|
|
130
|
+
fetchFn,
|
|
131
|
+
XAI_API_ORIGIN,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
if (probeRes.status === 402) {
|
|
135
|
+
limitHit = true;
|
|
136
|
+
const errData = (await probeRes.json().catch(() => ({}))) as XAIErrorResponse;
|
|
137
|
+
if (errData.code?.includes("spending-limit") || errData.error?.includes("credits")) {
|
|
138
|
+
statusText = "Limit Reached";
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
// Probe is best-effort
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const metrics: Metric[] = [
|
|
146
|
+
{
|
|
147
|
+
kind: "status",
|
|
148
|
+
id: "grok-subscription",
|
|
149
|
+
label: "Subscription",
|
|
150
|
+
value: statusText,
|
|
151
|
+
detail: limitHit ? "Out of credits or subscription needed" : "Connected",
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
const accounts = [
|
|
156
|
+
{
|
|
157
|
+
id: userId,
|
|
158
|
+
provider: "xai",
|
|
159
|
+
label: userLabel,
|
|
160
|
+
status: limitHit ? "limit_reached" : "available",
|
|
161
|
+
metrics,
|
|
162
|
+
},
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
adapterId: this.id,
|
|
167
|
+
sourceProviderId: target.providerId,
|
|
168
|
+
displayName: "xAI Grok",
|
|
169
|
+
state: "ok",
|
|
170
|
+
fetchedAt,
|
|
171
|
+
summary: `Grok · ${statusText}`,
|
|
172
|
+
accounts,
|
|
173
|
+
};
|
|
174
|
+
} catch (error) {
|
|
175
|
+
throw new Error(safeError(error));
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
3
|
+
import type { UsageConfig } from "../../core/config.ts";
|
|
4
|
+
import { UsageCache } from "../../core/cache.ts";
|
|
5
|
+
import type { Metric, ProviderTarget, UsageAdapter, UsageSnapshot } from "../../core/types.ts";
|
|
6
|
+
import { anthropicAdapter } from "./adapters/anthropic.ts";
|
|
7
|
+
import { cliProxyBridgeAdapter } from "./adapters/cliproxy-pi-bridge.ts";
|
|
8
|
+
import { deepSeekAdapter } from "./adapters/deepseek.ts";
|
|
9
|
+
import { glmAdapter } from "./adapters/glm.ts";
|
|
10
|
+
import { openAICodexAdapter } from "./adapters/openai-codex.ts";
|
|
11
|
+
import { xaiAdapter } from "./adapters/xai.ts";
|
|
12
|
+
import { chooseAdapter, matchModelAcrossAccounts } from "./matching.ts";
|
|
13
|
+
import { relativeTime } from "../../ui/format.ts";
|
|
14
|
+
|
|
15
|
+
export class ProviderUsageController {
|
|
16
|
+
readonly cache = new UsageCache();
|
|
17
|
+
private adapters: UsageAdapter[];
|
|
18
|
+
|
|
19
|
+
constructor(private config: UsageConfig, private fetchFn: typeof fetch = fetch) {
|
|
20
|
+
this.adapters = [deepSeekAdapter, openAICodexAdapter, xaiAdapter, anthropicAdapter, glmAdapter, cliProxyBridgeAdapter];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
setConfig(config: UsageConfig): void { this.config = config; }
|
|
24
|
+
|
|
25
|
+
async target(ctx: ExtensionContext, providerId: string, model = ctx.model): Promise<ProviderTarget> {
|
|
26
|
+
const provider = ctx.modelRegistry.getProvider(providerId);
|
|
27
|
+
const auth = await ctx.modelRegistry.getProviderAuth(providerId);
|
|
28
|
+
const baseUrl = auth?.auth.baseUrl ?? model?.baseUrl ?? provider?.baseUrl;
|
|
29
|
+
return {
|
|
30
|
+
providerId,
|
|
31
|
+
...(model?.provider === providerId ? { model } : {}),
|
|
32
|
+
...(provider ? { provider } : {}),
|
|
33
|
+
...(auth ? { auth } : {}),
|
|
34
|
+
...(baseUrl ? { baseUrl } : {}),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private enabled(adapter: UsageAdapter): boolean {
|
|
39
|
+
if (adapter.id === "deepseek") return this.config.adapters.deepseek.enabled;
|
|
40
|
+
if (adapter.id === "cliproxy-pi-bridge") return this.config.adapters.cliproxyPiBridge.enabled;
|
|
41
|
+
if (adapter.id === "openai-codex") return this.config.adapters.openaiCodex.enabled;
|
|
42
|
+
if (adapter.id === "xai") return this.config.adapters.xai.enabled;
|
|
43
|
+
if (adapter.id === "anthropic") return this.config.adapters.anthropic.enabled;
|
|
44
|
+
if (adapter.id === "glm") return this.config.adapters.glm.enabled;
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async fetchTarget(target: ProviderTarget, force = false): Promise<UsageSnapshot> {
|
|
49
|
+
const adapter = chooseAdapter(target, this.adapters.filter((item) => this.enabled(item)), this.config);
|
|
50
|
+
if (!adapter) return { adapterId: "none", sourceProviderId: target.providerId, displayName: target.providerId, state: "unsupported", fetchedAt: new Date().toISOString(), accounts: [], error: "No enabled usage adapter matched this provider" };
|
|
51
|
+
const key = `${target.providerId}:${adapter.id}`;
|
|
52
|
+
return this.cache.coalesce(key, async () => {
|
|
53
|
+
const timeout = AbortSignal.timeout(this.config.refresh.timeoutSeconds * 1000);
|
|
54
|
+
return adapter.fetch({ target, signal: timeout, force, fetchFn: this.fetchFn });
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async refreshCurrent(ctx: ExtensionContext, force = false, model: Model<Api> | undefined = ctx.model): Promise<UsageSnapshot | undefined> {
|
|
59
|
+
if (!model) return undefined;
|
|
60
|
+
return this.fetchTarget(await this.target(ctx, model.provider, model), force);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async refreshAll(ctx: ExtensionContext, force = false): Promise<UsageSnapshot[]> {
|
|
64
|
+
const providerIds = new Set(ctx.modelRegistry.getAvailable().map((model) => model.provider));
|
|
65
|
+
for (const id of ctx.modelRegistry.getRegisteredProviderIds()) providerIds.add(id);
|
|
66
|
+
if (ctx.modelRegistry.getProviderAuthStatus("deepseek").configured) providerIds.add("deepseek");
|
|
67
|
+
if (ctx.modelRegistry.getProviderAuthStatus("openai-codex").configured) providerIds.add("openai-codex");
|
|
68
|
+
if (ctx.modelRegistry.getProviderAuthStatus("xai").configured) providerIds.add("xai");
|
|
69
|
+
if (ctx.modelRegistry.getProviderAuthStatus("anthropic").configured) providerIds.add("anthropic");
|
|
70
|
+
if (ctx.modelRegistry.getProviderAuthStatus("zai-coding-cn").configured) providerIds.add("zai-coding-cn");
|
|
71
|
+
if (ctx.modelRegistry.getProviderAuthStatus("zai").configured) providerIds.add("zai");
|
|
72
|
+
if (ctx.modelRegistry.getProviderAuthStatus("glm").configured) providerIds.add("glm");
|
|
73
|
+
for (const id of Object.keys(this.config.providerOverrides)) providerIds.add(id);
|
|
74
|
+
if (ctx.model) providerIds.add(ctx.model.provider);
|
|
75
|
+
const targets = await Promise.all([...providerIds].map((id) => this.target(ctx, id)));
|
|
76
|
+
const snapshots = await Promise.all(targets.map((target) => this.fetchTarget(target, force)));
|
|
77
|
+
return snapshots.filter((snapshot) => snapshot.state !== "unsupported");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Derive a view tailored to the active model using universal cross-account group matching.
|
|
82
|
+
*/
|
|
83
|
+
currentView(ctx: ExtensionContext, snapshot?: UsageSnapshot, model: Model<Api> | undefined = ctx.model): UsageSnapshot | undefined {
|
|
84
|
+
if (!snapshot) return undefined;
|
|
85
|
+
if (snapshot.adapterId !== "cliproxy-pi-bridge" && snapshot.adapterId !== "openai-codex") return snapshot;
|
|
86
|
+
|
|
87
|
+
const matched = matchModelAcrossAccounts(snapshot.accounts, model?.id);
|
|
88
|
+
if (matched) {
|
|
89
|
+
let summary: string;
|
|
90
|
+
|
|
91
|
+
if (matched.quota.multiWindows && matched.quota.multiWindows.length > 1) {
|
|
92
|
+
// Special case: Multiple time windows for the same model (e.g. Codex 5h and 7d)
|
|
93
|
+
const parts = matched.quota.multiWindows.map((q) => {
|
|
94
|
+
const sub = q.label.replace(/^Codex\s+/, "");
|
|
95
|
+
const reset = q.resetAt ? relativeTime(q.resetAt) : undefined;
|
|
96
|
+
return `${sub} ${Math.round(q.remainingFraction * 100)}%${reset ? ` (${reset})` : ""}`;
|
|
97
|
+
});
|
|
98
|
+
summary = `Codex ${parts.join(" · ")}`;
|
|
99
|
+
} else {
|
|
100
|
+
const reset = matched.quota.resetAt ? relativeTime(matched.quota.resetAt) : undefined;
|
|
101
|
+
summary = `${matched.quota.label} ${Math.round(matched.quota.remainingFraction * 100)}%${reset ? ` (${reset})` : ""}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
...snapshot,
|
|
106
|
+
accounts: [matched.account as never],
|
|
107
|
+
state: snapshot.state,
|
|
108
|
+
summary,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Fallback: If snapshot has accounts with metrics, check if it's an openai-codex or time-window multi metric
|
|
113
|
+
const activeAccounts = snapshot.accounts.filter((a) => !a.disabled && !a.unavailable);
|
|
114
|
+
const firstAccount = activeAccounts[0];
|
|
115
|
+
if (firstAccount && firstAccount.metrics.length > 1) {
|
|
116
|
+
const windowMetrics = firstAccount.metrics.filter((m): m is Extract<Metric, { kind: "quota-window" }> => m.kind === "quota-window");
|
|
117
|
+
const allCodex = windowMetrics.length > 1 && windowMetrics.every((m) => m.label.startsWith("Codex"));
|
|
118
|
+
if (allCodex) {
|
|
119
|
+
const parts = windowMetrics.map((m) => {
|
|
120
|
+
const sub = m.label.replace(/^Codex\s+/, "");
|
|
121
|
+
const reset = m.resetAt ? relativeTime(m.resetAt) : undefined;
|
|
122
|
+
return `${sub} ${Math.round(m.remainingFraction * 100)}%${reset ? ` (${reset})` : ""}`;
|
|
123
|
+
});
|
|
124
|
+
return {
|
|
125
|
+
...snapshot,
|
|
126
|
+
state: snapshot.state,
|
|
127
|
+
summary: `Codex ${parts.join(" · ")}`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Standard fallback: pick the lowest metric from all active accounts
|
|
133
|
+
const worst = activeAccounts
|
|
134
|
+
.flatMap((a) => a.metrics)
|
|
135
|
+
.filter((m): m is Extract<Metric, { kind: "quota-window" }> => m.kind === "quota-window")
|
|
136
|
+
.sort((a, b) => a.remainingFraction - b.remainingFraction)[0];
|
|
137
|
+
|
|
138
|
+
const reset = worst?.resetAt ? relativeTime(worst.resetAt) : undefined;
|
|
139
|
+
const summary = worst ? `${worst.label} ${Math.round(worst.remainingFraction * 100)}%${reset ? ` (${reset})` : ""}` : undefined;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
...snapshot,
|
|
143
|
+
state: snapshot.accounts.length ? snapshot.state : "empty",
|
|
144
|
+
...(summary ? { summary } : {}),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|