@thelioo/opencode-balancer 0.1.8 → 0.2.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.
- package/INSTALL.txt +53 -25
- package/README.md +95 -51
- package/dist/core/accounts.ts +404 -0
- package/dist/core/database.ts +67 -0
- package/dist/core/events.ts +75 -0
- package/dist/core/native-auth-suppression.ts +36 -0
- package/dist/core/native-connect.ts +31 -0
- package/dist/core/path.ts +34 -0
- package/dist/core/pending.ts +351 -0
- package/dist/core/priority.ts +193 -0
- package/dist/core/schema.ts +439 -0
- package/dist/core/time.ts +3 -0
- package/dist/core/types.ts +72 -0
- package/dist/core/usage/index.ts +23 -0
- package/dist/core/usage/providers/copilot.ts +243 -0
- package/dist/core/usage/providers/openai.ts +179 -0
- package/dist/core/usage/redact.ts +80 -0
- package/dist/core/usage/store.ts +66 -0
- package/dist/core/usage/types.ts +24 -0
- package/dist/index.js +173 -4
- package/dist/index.js.map +1 -1
- package/dist/server/auth-watcher.ts +318 -0
- package/dist/server/commands.ts +58 -0
- package/dist/server/fetch-patch.ts +162 -0
- package/dist/server/index.ts +134 -0
- package/dist/server/native.ts +49 -0
- package/dist/server/request-balancer.ts +67 -0
- package/dist/tui/actions.ts +176 -112
- package/dist/tui/balancer-bar-sync.ts +55 -45
- package/dist/tui/components/alias-dialog.tsx +71 -56
- package/dist/tui/components/dashboard.tsx +530 -358
- package/dist/tui/components/priority-screen.tsx +389 -267
- package/dist/tui/components/provider-model-dialog.tsx +71 -64
- package/dist/tui/components/rename-dialog.tsx +35 -28
- package/dist/tui/components/sidebar.tsx +103 -79
- package/dist/tui/components/status-indicator.tsx +78 -59
- package/dist/tui/components/usage-bar.tsx +18 -7
- package/dist/tui/components/usage-display.tsx +32 -16
- package/dist/tui/connect.ts +104 -73
- package/dist/tui/dashboard-keys.ts +53 -41
- package/dist/tui/native-model-apply.ts +45 -36
- package/dist/tui/priority-keys.ts +44 -36
- package/dist/tui/provider-models.ts +32 -25
- package/dist/tui/responsive.ts +10 -7
- package/dist/tui/selected-account-bar-sync.ts +23 -23
- package/dist/tui/selection-colors.ts +38 -30
- package/dist/tui/state.ts +61 -44
- package/dist/tui/status-format.ts +24 -20
- package/dist/tui/tui.js +165 -153
- package/dist/tui/tui.js.map +1 -1
- package/dist/tui/tui.tsx +194 -144
- package/dist/tui/usage-auto-refresh.ts +52 -45
- package/dist/tui/usage-format.ts +9 -9
- package/package.json +61 -52
- package/dist/core/accounts.d.ts +0 -14
- package/dist/core/accounts.js +0 -260
- package/dist/core/accounts.js.map +0 -1
- package/dist/core/database.d.ts +0 -4
- package/dist/core/database.js +0 -69
- package/dist/core/database.js.map +0 -1
- package/dist/core/events.d.ts +0 -18
- package/dist/core/events.js +0 -39
- package/dist/core/events.js.map +0 -1
- package/dist/core/native-auth-suppression.d.ts +0 -3
- package/dist/core/native-auth-suppression.js +0 -19
- package/dist/core/native-auth-suppression.js.map +0 -1
- package/dist/core/native-connect.d.ts +0 -4
- package/dist/core/native-connect.js +0 -19
- package/dist/core/native-connect.js.map +0 -1
- package/dist/core/path.d.ts +0 -4
- package/dist/core/path.js +0 -26
- package/dist/core/path.js.map +0 -1
- package/dist/core/pending.d.ts +0 -9
- package/dist/core/pending.js +0 -237
- package/dist/core/pending.js.map +0 -1
- package/dist/core/priority.d.ts +0 -20
- package/dist/core/priority.js +0 -120
- package/dist/core/priority.js.map +0 -1
- package/dist/core/schema.d.ts +0 -2
- package/dist/core/schema.js +0 -265
- package/dist/core/schema.js.map +0 -1
- package/dist/core/time.d.ts +0 -1
- package/dist/core/time.js +0 -4
- package/dist/core/time.js.map +0 -1
- package/dist/core/types.d.ts +0 -59
- package/dist/core/types.js +0 -2
- package/dist/core/types.js.map +0 -1
- package/dist/core/usage/index.d.ts +0 -4
- package/dist/core/usage/index.js +0 -16
- package/dist/core/usage/index.js.map +0 -1
- package/dist/core/usage/providers/copilot.d.ts +0 -2
- package/dist/core/usage/providers/copilot.js +0 -169
- package/dist/core/usage/providers/copilot.js.map +0 -1
- package/dist/core/usage/providers/openai.d.ts +0 -2
- package/dist/core/usage/providers/openai.js +0 -133
- package/dist/core/usage/providers/openai.js.map +0 -1
- package/dist/core/usage/redact.d.ts +0 -3
- package/dist/core/usage/redact.js +0 -67
- package/dist/core/usage/redact.js.map +0 -1
- package/dist/core/usage/store.d.ts +0 -4
- package/dist/core/usage/store.js +0 -31
- package/dist/core/usage/store.js.map +0 -1
- package/dist/core/usage/types.d.ts +0 -21
- package/dist/core/usage/types.js +0 -2
- package/dist/core/usage/types.js.map +0 -1
- package/dist/index.d.ts +0 -5
- package/dist/server/auth-watcher.d.ts +0 -32
- package/dist/server/auth-watcher.js +0 -227
- package/dist/server/auth-watcher.js.map +0 -1
- package/dist/server/commands.d.ts +0 -2
- package/dist/server/commands.js +0 -46
- package/dist/server/commands.js.map +0 -1
- package/dist/server/fetch-patch.d.ts +0 -3
- package/dist/server/fetch-patch.js +0 -118
- package/dist/server/fetch-patch.js.map +0 -1
- package/dist/server/index.d.ts +0 -8
- package/dist/server/index.js +0 -94
- package/dist/server/index.js.map +0 -1
- package/dist/server/native.d.ts +0 -6
- package/dist/server/native.js +0 -35
- package/dist/server/native.js.map +0 -1
- package/dist/server/request-balancer.d.ts +0 -16
- package/dist/server/request-balancer.js +0 -43
- package/dist/server/request-balancer.js.map +0 -1
- package/dist/tui/actions.d.ts +0 -41
- package/dist/tui/actions.js +0 -92
- package/dist/tui/actions.js.map +0 -1
- package/dist/tui/balancer-bar-sync.d.ts +0 -19
- package/dist/tui/balancer-bar-sync.js +0 -45
- package/dist/tui/balancer-bar-sync.js.map +0 -1
- package/dist/tui/components/alias-dialog.d.ts +0 -4
- package/dist/tui/components/dashboard.d.ts +0 -12
- package/dist/tui/components/priority-screen.d.ts +0 -9
- package/dist/tui/components/provider-model-dialog.d.ts +0 -14
- package/dist/tui/components/rename-dialog.d.ts +0 -4
- package/dist/tui/components/sidebar.d.ts +0 -10
- package/dist/tui/components/status-indicator.d.ts +0 -9
- package/dist/tui/components/usage-bar.d.ts +0 -8
- package/dist/tui/components/usage-display.d.ts +0 -10
- package/dist/tui/connect.d.ts +0 -30
- package/dist/tui/connect.js +0 -75
- package/dist/tui/connect.js.map +0 -1
- package/dist/tui/dashboard-keys.d.ts +0 -45
- package/dist/tui/dashboard-keys.js +0 -44
- package/dist/tui/dashboard-keys.js.map +0 -1
- package/dist/tui/native-model-apply.d.ts +0 -21
- package/dist/tui/native-model-apply.js +0 -53
- package/dist/tui/native-model-apply.js.map +0 -1
- package/dist/tui/priority-keys.d.ts +0 -40
- package/dist/tui/priority-keys.js +0 -38
- package/dist/tui/priority-keys.js.map +0 -1
- package/dist/tui/provider-models.d.ts +0 -19
- package/dist/tui/provider-models.js +0 -17
- package/dist/tui/provider-models.js.map +0 -1
- package/dist/tui/responsive.d.ts +0 -9
- package/dist/tui/responsive.js +0 -13
- package/dist/tui/responsive.js.map +0 -1
- package/dist/tui/selected-account-bar-sync.d.ts +0 -10
- package/dist/tui/selected-account-bar-sync.js +0 -26
- package/dist/tui/selected-account-bar-sync.js.map +0 -1
- package/dist/tui/selection-colors.d.ts +0 -10
- package/dist/tui/selection-colors.js +0 -38
- package/dist/tui/selection-colors.js.map +0 -1
- package/dist/tui/state.d.ts +0 -14
- package/dist/tui/state.js +0 -46
- package/dist/tui/state.js.map +0 -1
- package/dist/tui/status-format.d.ts +0 -15
- package/dist/tui/status-format.js +0 -17
- package/dist/tui/status-format.js.map +0 -1
- package/dist/tui/tui.d.ts +0 -7
- package/dist/tui/usage-auto-refresh.d.ts +0 -16
- package/dist/tui/usage-auto-refresh.js +0 -46
- package/dist/tui/usage-auto-refresh.js.map +0 -1
- package/dist/tui/usage-format.d.ts +0 -2
- package/dist/tui/usage-format.js +0 -17
- package/dist/tui/usage-format.js.map +0 -1
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import type { Account } from "../../types";
|
|
2
|
+
import { redactUsageError, redactUsagePayload } from "../redact";
|
|
3
|
+
import type { ProviderUsageService, ProviderUsageSnapshot } from "../types";
|
|
4
|
+
|
|
5
|
+
function unavailable(
|
|
6
|
+
account: Account,
|
|
7
|
+
message: string,
|
|
8
|
+
error?: string,
|
|
9
|
+
): ProviderUsageSnapshot {
|
|
10
|
+
return {
|
|
11
|
+
alias: account.alias,
|
|
12
|
+
confidence: "unavailable",
|
|
13
|
+
fetchedAt: Date.now(),
|
|
14
|
+
message,
|
|
15
|
+
providerID: account.providerID,
|
|
16
|
+
...(error ? { error: redactUsageError(error, account) } : {}),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function githubHeaders(account: Account) {
|
|
21
|
+
return {
|
|
22
|
+
Accept: "application/vnd.github+json",
|
|
23
|
+
Authorization: `Bearer ${account.auth.type === "oauth" ? account.auth.access : ""}`,
|
|
24
|
+
"X-GitHub-Api-Version": "2026-03-10",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function fetchJSON(url: string, account: Account) {
|
|
29
|
+
const response = await fetch(url, { headers: githubHeaders(account) });
|
|
30
|
+
if (!response.ok) return { ok: false as const, status: response.status };
|
|
31
|
+
try {
|
|
32
|
+
return { body: await response.json(), ok: true as const };
|
|
33
|
+
} catch {
|
|
34
|
+
return { body: undefined, ok: true as const };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sumNetQuantity(body: unknown) {
|
|
39
|
+
const items = (body as { usageItems?: unknown } | undefined)?.usageItems;
|
|
40
|
+
if (!Array.isArray(items)) return undefined;
|
|
41
|
+
|
|
42
|
+
let total = 0;
|
|
43
|
+
let found = false;
|
|
44
|
+
for (const item of items) {
|
|
45
|
+
const quantity = (
|
|
46
|
+
item as { netQuantity?: unknown; quantity?: unknown } | undefined
|
|
47
|
+
)?.netQuantity;
|
|
48
|
+
const legacyQuantity = (item as { quantity?: unknown } | undefined)
|
|
49
|
+
?.quantity;
|
|
50
|
+
const value =
|
|
51
|
+
typeof quantity === "number"
|
|
52
|
+
? quantity
|
|
53
|
+
: typeof legacyQuantity === "number"
|
|
54
|
+
? legacyQuantity
|
|
55
|
+
: undefined;
|
|
56
|
+
if (value === undefined) continue;
|
|
57
|
+
total += value;
|
|
58
|
+
found = true;
|
|
59
|
+
}
|
|
60
|
+
return found ? total : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function numberAt(value: unknown, path: string[]) {
|
|
64
|
+
let current = value;
|
|
65
|
+
for (const key of path)
|
|
66
|
+
current = (current as Record<string, unknown> | undefined)?.[key];
|
|
67
|
+
return typeof current === "number" && Number.isFinite(current)
|
|
68
|
+
? current
|
|
69
|
+
: undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseInternalQuota(body: unknown) {
|
|
73
|
+
const premium = (
|
|
74
|
+
body as {
|
|
75
|
+
quota_snapshots?: {
|
|
76
|
+
premium_interactions?: unknown;
|
|
77
|
+
premium_models?: unknown;
|
|
78
|
+
chat?: unknown;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
)?.quota_snapshots;
|
|
82
|
+
const snapshot =
|
|
83
|
+
premium?.premium_interactions ?? premium?.premium_models ?? premium?.chat;
|
|
84
|
+
const entitlement = numberAt(snapshot, ["entitlement"]);
|
|
85
|
+
const remaining =
|
|
86
|
+
numberAt(snapshot, ["remaining"]) ??
|
|
87
|
+
numberAt(snapshot, ["quota_remaining"]);
|
|
88
|
+
const percentRemaining = numberAt(snapshot, ["percent_remaining"]);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
entitlement,
|
|
92
|
+
percentRemaining,
|
|
93
|
+
remaining,
|
|
94
|
+
used:
|
|
95
|
+
entitlement !== undefined && remaining !== undefined
|
|
96
|
+
? Math.max(0, entitlement - remaining)
|
|
97
|
+
: undefined,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function refreshInternalUserQuota(account: Account) {
|
|
102
|
+
const internal = await fetchJSON(
|
|
103
|
+
"https://api.github.com/copilot_internal/user",
|
|
104
|
+
account,
|
|
105
|
+
);
|
|
106
|
+
if (!internal.ok) return { ok: false as const, status: internal.status };
|
|
107
|
+
|
|
108
|
+
const login =
|
|
109
|
+
typeof internal.body?.login === "string"
|
|
110
|
+
? internal.body.login
|
|
111
|
+
: "GitHub user";
|
|
112
|
+
const plan =
|
|
113
|
+
typeof internal.body?.copilot_plan === "string"
|
|
114
|
+
? internal.body.copilot_plan
|
|
115
|
+
: undefined;
|
|
116
|
+
const quota = parseInternalQuota(internal.body);
|
|
117
|
+
if (!plan && quota.used === undefined && quota.remaining === undefined)
|
|
118
|
+
return { ok: false as const, status: 200 };
|
|
119
|
+
return {
|
|
120
|
+
ok: true as const,
|
|
121
|
+
snapshot: {
|
|
122
|
+
alias: account.alias,
|
|
123
|
+
confidence: "exact" as const,
|
|
124
|
+
fetchedAt: Date.now(),
|
|
125
|
+
providerID: account.providerID,
|
|
126
|
+
...(quota.used !== undefined ? { usedTokens: quota.used } : {}),
|
|
127
|
+
...(quota.remaining !== undefined
|
|
128
|
+
? { remainingTokens: quota.remaining }
|
|
129
|
+
: {}),
|
|
130
|
+
message: plan
|
|
131
|
+
? `GitHub Copilot personal quota fetched for ${login} (${plan}).`
|
|
132
|
+
: `GitHub Copilot personal quota fetched for ${login}.`,
|
|
133
|
+
rawRedacted: redactUsagePayload(internal.body, account),
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function refreshPersonalUsage(
|
|
139
|
+
account: Account,
|
|
140
|
+
): Promise<ProviderUsageSnapshot> {
|
|
141
|
+
const internal = await refreshInternalUserQuota(account);
|
|
142
|
+
if (internal.ok) return internal.snapshot;
|
|
143
|
+
|
|
144
|
+
const user = await fetchJSON("https://api.github.com/user", account);
|
|
145
|
+
if (!user.ok) {
|
|
146
|
+
return unavailable(
|
|
147
|
+
account,
|
|
148
|
+
`GitHub Copilot personal billing usage requires user API access; /user returned HTTP ${user.status}.`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const login =
|
|
153
|
+
typeof user.body?.login === "string" ? user.body.login : undefined;
|
|
154
|
+
if (!login)
|
|
155
|
+
return unavailable(
|
|
156
|
+
account,
|
|
157
|
+
"GitHub Copilot personal billing usage requires a GitHub username from /user.",
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const premiumURL = `https://api.github.com/users/${encodeURIComponent(login)}/settings/billing/premium_request/usage`;
|
|
161
|
+
const premium = await fetchJSON(premiumURL, account);
|
|
162
|
+
if (premium.ok) {
|
|
163
|
+
return {
|
|
164
|
+
alias: account.alias,
|
|
165
|
+
confidence: "exact",
|
|
166
|
+
fetchedAt: Date.now(),
|
|
167
|
+
message: `GitHub Copilot personal premium request usage fetched for ${login}.`,
|
|
168
|
+
providerID: account.providerID,
|
|
169
|
+
rawRedacted: redactUsagePayload(premium.body, account),
|
|
170
|
+
usedTokens: sumNetQuantity(premium.body),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const summaryURL = `https://api.github.com/users/${encodeURIComponent(login)}/settings/billing/usage/summary?product=copilot`;
|
|
175
|
+
const summary = await fetchJSON(summaryURL, account);
|
|
176
|
+
if (summary.ok) {
|
|
177
|
+
return {
|
|
178
|
+
alias: account.alias,
|
|
179
|
+
confidence: "exact",
|
|
180
|
+
fetchedAt: Date.now(),
|
|
181
|
+
message: `GitHub Copilot personal billing usage fetched for ${login}.`,
|
|
182
|
+
providerID: account.providerID,
|
|
183
|
+
rawRedacted: redactUsagePayload(summary.body, account),
|
|
184
|
+
usedTokens: sumNetQuantity(summary.body),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return unavailable(
|
|
189
|
+
account,
|
|
190
|
+
`GitHub Copilot personal billing usage unavailable; copilot_internal/user returned HTTP ${internal.status}, premium requests returned HTTP ${premium.status}, and billing summary returned HTTP ${summary.status}.`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export const copilotUsageService: ProviderUsageService = {
|
|
195
|
+
providerID: "github-copilot",
|
|
196
|
+
async refreshUsage(account) {
|
|
197
|
+
const githubOrg = account.auth.metadata?.githubOrg;
|
|
198
|
+
if (account.auth.type !== "oauth") {
|
|
199
|
+
return unavailable(account, "GitHub Copilot usage requires OAuth auth.");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!githubOrg) return refreshPersonalUsage(account);
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const response = await fetch(
|
|
206
|
+
`https://api.github.com/orgs/${encodeURIComponent(githubOrg)}/copilot/billing`,
|
|
207
|
+
{
|
|
208
|
+
headers: githubHeaders(account),
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
if (!response.ok) {
|
|
212
|
+
return unavailable(
|
|
213
|
+
account,
|
|
214
|
+
`GitHub Copilot billing request failed with HTTP ${response.status}.`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const body = await response.json();
|
|
219
|
+
const planName =
|
|
220
|
+
typeof body?.plan_type === "string" ? body.plan_type : undefined;
|
|
221
|
+
return {
|
|
222
|
+
alias: account.alias,
|
|
223
|
+
confidence: "exact",
|
|
224
|
+
fetchedAt: Date.now(),
|
|
225
|
+
providerID: account.providerID,
|
|
226
|
+
...(planName ? { planName } : {}),
|
|
227
|
+
message: planName
|
|
228
|
+
? `GitHub Copilot billing fetched for ${githubOrg} (${planName}).`
|
|
229
|
+
: `GitHub Copilot billing fetched for ${githubOrg}.`,
|
|
230
|
+
rawRedacted: redactUsagePayload(body, account),
|
|
231
|
+
};
|
|
232
|
+
} catch (error) {
|
|
233
|
+
return unavailable(
|
|
234
|
+
account,
|
|
235
|
+
"GitHub Copilot billing request failed; exact billing data is unavailable.",
|
|
236
|
+
error instanceof Error ? error.message : String(error),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
supports(providerID) {
|
|
241
|
+
return providerID === "github-copilot" || providerID === "copilot";
|
|
242
|
+
},
|
|
243
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { Account } from "../../types";
|
|
2
|
+
import { redactUsageError, redactUsagePayload } from "../redact";
|
|
3
|
+
import type { ProviderUsageService, ProviderUsageSnapshot } from "../types";
|
|
4
|
+
|
|
5
|
+
function unavailable(
|
|
6
|
+
account: Account,
|
|
7
|
+
message: string,
|
|
8
|
+
error?: string,
|
|
9
|
+
): ProviderUsageSnapshot {
|
|
10
|
+
return {
|
|
11
|
+
alias: account.alias,
|
|
12
|
+
confidence: "unavailable",
|
|
13
|
+
fetchedAt: Date.now(),
|
|
14
|
+
message,
|
|
15
|
+
providerID: account.providerID,
|
|
16
|
+
...(error ? { error: redactUsageError(error, account) } : {}),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
21
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function asNumber(value: unknown): number | undefined {
|
|
25
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
26
|
+
? value
|
|
27
|
+
: undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
|
|
31
|
+
const payload = token.split(".")[1];
|
|
32
|
+
if (!payload) return undefined;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
|
|
36
|
+
const padded = `${normalized}${"=".repeat((4 - (normalized.length % 4)) % 4)}`;
|
|
37
|
+
const decoded = JSON.parse(
|
|
38
|
+
Buffer.from(padded, "base64").toString("utf8"),
|
|
39
|
+
) as unknown;
|
|
40
|
+
return isRecord(decoded) ? decoded : undefined;
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function chatgptAccountID(account: Account): string | undefined {
|
|
47
|
+
if (account.auth.type !== "oauth") return undefined;
|
|
48
|
+
if (account.auth.accountId) return account.auth.accountId;
|
|
49
|
+
|
|
50
|
+
const payload = decodeJwtPayload(account.auth.access);
|
|
51
|
+
const authClaim = payload?.["https://api.openai.com/auth"];
|
|
52
|
+
if (!isRecord(authClaim)) return undefined;
|
|
53
|
+
|
|
54
|
+
const accountID = authClaim.chatgpt_account_id;
|
|
55
|
+
return typeof accountID === "string" && accountID.trim()
|
|
56
|
+
? accountID
|
|
57
|
+
: undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function planName(planType: unknown): string | undefined {
|
|
61
|
+
if (typeof planType !== "string" || !planType.trim()) return undefined;
|
|
62
|
+
const normalized = planType.trim().toLowerCase();
|
|
63
|
+
const names: Record<string, string> = {
|
|
64
|
+
enterprise: "ChatGPT Enterprise",
|
|
65
|
+
plus: "ChatGPT Plus",
|
|
66
|
+
pro: "ChatGPT Pro",
|
|
67
|
+
team: "ChatGPT Team",
|
|
68
|
+
};
|
|
69
|
+
return names[normalized] ?? planType;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function resetAtMillis(value: unknown): number | undefined {
|
|
73
|
+
const timestamp = asNumber(value);
|
|
74
|
+
if (timestamp === undefined || timestamp < 0) return undefined;
|
|
75
|
+
return timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function refreshChatGPTUsage(
|
|
79
|
+
account: Account,
|
|
80
|
+
): Promise<ProviderUsageSnapshot> {
|
|
81
|
+
if (account.auth.type !== "oauth") {
|
|
82
|
+
return unavailable(account, "OpenAI ChatGPT usage requires OAuth auth.");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const headers: Record<string, string> = {
|
|
87
|
+
Authorization: `Bearer ${account.auth.access}`,
|
|
88
|
+
"User-Agent": "opencode-balancer/0.1.3",
|
|
89
|
+
};
|
|
90
|
+
const accountID = chatgptAccountID(account);
|
|
91
|
+
if (accountID) headers["ChatGPT-Account-Id"] = accountID;
|
|
92
|
+
|
|
93
|
+
const response = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
94
|
+
headers,
|
|
95
|
+
});
|
|
96
|
+
if (!response.ok) {
|
|
97
|
+
return unavailable(
|
|
98
|
+
account,
|
|
99
|
+
`OpenAI ChatGPT usage request failed with HTTP ${response.status}.`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const body = (await response.json()) as unknown;
|
|
104
|
+
const rateLimit =
|
|
105
|
+
isRecord(body) && isRecord(body.rate_limit) ? body.rate_limit : undefined;
|
|
106
|
+
const primary =
|
|
107
|
+
rateLimit && isRecord(rateLimit.primary_window)
|
|
108
|
+
? rateLimit.primary_window
|
|
109
|
+
: undefined;
|
|
110
|
+
return {
|
|
111
|
+
alias: account.alias,
|
|
112
|
+
confidence: "exact",
|
|
113
|
+
fetchedAt: Date.now(),
|
|
114
|
+
message: "OpenAI ChatGPT usage fetched.",
|
|
115
|
+
planName: isRecord(body) ? planName(body.plan_type) : undefined,
|
|
116
|
+
providerID: account.providerID,
|
|
117
|
+
rawRedacted: redactUsagePayload(body, account),
|
|
118
|
+
resetAt: primary ? resetAtMillis(primary.reset_at) : undefined,
|
|
119
|
+
usedPercent: primary ? asNumber(primary.used_percent) : undefined,
|
|
120
|
+
};
|
|
121
|
+
} catch (error) {
|
|
122
|
+
return unavailable(
|
|
123
|
+
account,
|
|
124
|
+
"OpenAI ChatGPT usage request failed; exact usage is unavailable.",
|
|
125
|
+
error instanceof Error ? error.message : String(error),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const openaiUsageService: ProviderUsageService = {
|
|
131
|
+
providerID: "openai",
|
|
132
|
+
async refreshUsage(account) {
|
|
133
|
+
if (account.auth.type !== "api") {
|
|
134
|
+
return refreshChatGPTUsage(account);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const url = new URL(
|
|
138
|
+
"https://api.openai.com/v1/organization/usage/completions",
|
|
139
|
+
);
|
|
140
|
+
url.searchParams.set(
|
|
141
|
+
"start_time",
|
|
142
|
+
String(Math.floor((Date.now() - 24 * 60 * 60 * 1000) / 1000)),
|
|
143
|
+
);
|
|
144
|
+
url.searchParams.set("bucket_width", "1d");
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const response = await fetch(url, {
|
|
148
|
+
headers: {
|
|
149
|
+
Authorization: `Bearer ${account.auth.key}`,
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
return unavailable(
|
|
154
|
+
account,
|
|
155
|
+
`OpenAI usage request failed with HTTP ${response.status}.`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const body = await response.json();
|
|
160
|
+
return {
|
|
161
|
+
alias: account.alias,
|
|
162
|
+
confidence: "exact",
|
|
163
|
+
fetchedAt: Date.now(),
|
|
164
|
+
message: "OpenAI organization usage fetched.",
|
|
165
|
+
providerID: account.providerID,
|
|
166
|
+
rawRedacted: redactUsagePayload(body, account),
|
|
167
|
+
};
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return unavailable(
|
|
170
|
+
account,
|
|
171
|
+
"OpenAI usage request failed; exact usage is unavailable.",
|
|
172
|
+
error instanceof Error ? error.message : String(error),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
supports(providerID) {
|
|
177
|
+
return providerID === "openai";
|
|
178
|
+
},
|
|
179
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Account } from "../types";
|
|
2
|
+
|
|
3
|
+
const REDACTED = "[redacted]";
|
|
4
|
+
|
|
5
|
+
function accountSecretValues(account: Account) {
|
|
6
|
+
const values = new Set<string>();
|
|
7
|
+
if (account.auth.type === "api") values.add(account.auth.key);
|
|
8
|
+
if (account.auth.type === "oauth") {
|
|
9
|
+
values.add(account.auth.access);
|
|
10
|
+
values.add(account.auth.refresh);
|
|
11
|
+
}
|
|
12
|
+
if (account.auth.type === "wellknown") {
|
|
13
|
+
values.add(account.auth.key);
|
|
14
|
+
values.add(account.auth.token);
|
|
15
|
+
}
|
|
16
|
+
values.delete("");
|
|
17
|
+
return values;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function keyWords(key: string) {
|
|
21
|
+
return key
|
|
22
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.split(/[^a-z0-9]+/)
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isCredentialKey(key: string) {
|
|
29
|
+
const lower = key.toLowerCase();
|
|
30
|
+
const compact = lower.replace(/[^a-z0-9]/g, "");
|
|
31
|
+
if (
|
|
32
|
+
lower.includes("api_key") ||
|
|
33
|
+
compact.includes("apikey") ||
|
|
34
|
+
lower.includes("access_token") ||
|
|
35
|
+
compact.includes("accesstoken") ||
|
|
36
|
+
lower.includes("refresh_token") ||
|
|
37
|
+
compact.includes("refreshtoken") ||
|
|
38
|
+
lower.includes("x-api-key") ||
|
|
39
|
+
compact.includes("xapikey") ||
|
|
40
|
+
lower.includes("authorization")
|
|
41
|
+
) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const words = keyWords(key);
|
|
46
|
+
return ["token", "key", "secret", "password"].some(
|
|
47
|
+
(credential) => lower === credential || words.includes(credential),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function redactUsagePayload(value: unknown, account: Account): unknown {
|
|
52
|
+
return redactValue(value, accountSecretValues(account));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function redactUsageError(error: string, account: Account) {
|
|
56
|
+
return redactString(error, accountSecretValues(account));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function redactString(value: string, secrets: Set<string>) {
|
|
60
|
+
let redacted = value;
|
|
61
|
+
for (const secret of [...secrets].sort((a, b) => b.length - a.length)) {
|
|
62
|
+
redacted = redacted.split(secret).join(REDACTED);
|
|
63
|
+
}
|
|
64
|
+
return redacted;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function redactValue(value: unknown, secrets: Set<string>): unknown {
|
|
68
|
+
if (typeof value === "string") return redactString(value, secrets);
|
|
69
|
+
if (!value || typeof value !== "object") return value;
|
|
70
|
+
if (Array.isArray(value))
|
|
71
|
+
return value.map((item) => redactValue(item, secrets));
|
|
72
|
+
|
|
73
|
+
const redacted: Record<string, unknown> = {};
|
|
74
|
+
for (const [key, child] of Object.entries(value)) {
|
|
75
|
+
redacted[redactString(key, secrets)] = isCredentialKey(key)
|
|
76
|
+
? REDACTED
|
|
77
|
+
: redactValue(child, secrets);
|
|
78
|
+
}
|
|
79
|
+
return redacted;
|
|
80
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import type { ProviderUsageSnapshot } from "./types";
|
|
3
|
+
|
|
4
|
+
export function saveUsageSnapshot(
|
|
5
|
+
db: Database,
|
|
6
|
+
snapshot: ProviderUsageSnapshot,
|
|
7
|
+
) {
|
|
8
|
+
const { rawRedacted, ...normalizedSnapshot } = snapshot;
|
|
9
|
+
const rawRedactedJSON = Object.hasOwn(snapshot, "rawRedacted")
|
|
10
|
+
? JSON.stringify(rawRedacted)
|
|
11
|
+
: null;
|
|
12
|
+
|
|
13
|
+
db.query(
|
|
14
|
+
`INSERT INTO usage_snapshots (
|
|
15
|
+
provider_id, alias, fetched_at, confidence, normalized_json, raw_redacted_json, error
|
|
16
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
17
|
+
ON CONFLICT(provider_id, alias) DO UPDATE SET
|
|
18
|
+
fetched_at = excluded.fetched_at,
|
|
19
|
+
confidence = excluded.confidence,
|
|
20
|
+
normalized_json = excluded.normalized_json,
|
|
21
|
+
raw_redacted_json = excluded.raw_redacted_json,
|
|
22
|
+
error = excluded.error`,
|
|
23
|
+
).run(
|
|
24
|
+
snapshot.providerID,
|
|
25
|
+
snapshot.alias,
|
|
26
|
+
snapshot.fetchedAt,
|
|
27
|
+
snapshot.confidence,
|
|
28
|
+
JSON.stringify(normalizedSnapshot),
|
|
29
|
+
rawRedactedJSON,
|
|
30
|
+
snapshot.error ?? null,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
if (
|
|
34
|
+
snapshot.confidence === "exact" &&
|
|
35
|
+
snapshot.usedPercent !== undefined &&
|
|
36
|
+
snapshot.usedPercent < 100
|
|
37
|
+
) {
|
|
38
|
+
db.query<unknown, [number, string, string]>(
|
|
39
|
+
`UPDATE accounts
|
|
40
|
+
SET rate_limited_until = NULL,
|
|
41
|
+
updated_at = ?
|
|
42
|
+
WHERE provider_id = ? AND alias = ?`,
|
|
43
|
+
).run(snapshot.fetchedAt, snapshot.providerID, snapshot.alias);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getUsageSnapshot(
|
|
48
|
+
db: Database,
|
|
49
|
+
providerID: string,
|
|
50
|
+
alias: string,
|
|
51
|
+
) {
|
|
52
|
+
const row = db
|
|
53
|
+
.query<
|
|
54
|
+
{ normalized_json: string; raw_redacted_json: string | null },
|
|
55
|
+
[string, string]
|
|
56
|
+
>(
|
|
57
|
+
"SELECT normalized_json, raw_redacted_json FROM usage_snapshots WHERE provider_id = ? AND alias = ?",
|
|
58
|
+
)
|
|
59
|
+
.get(providerID, alias);
|
|
60
|
+
if (!row) return undefined;
|
|
61
|
+
|
|
62
|
+
const snapshot = JSON.parse(row.normalized_json) as ProviderUsageSnapshot;
|
|
63
|
+
if (row.raw_redacted_json !== null)
|
|
64
|
+
snapshot.rawRedacted = JSON.parse(row.raw_redacted_json);
|
|
65
|
+
return snapshot;
|
|
66
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Account } from "../types";
|
|
2
|
+
|
|
3
|
+
export type UsageConfidence = "exact" | "estimated" | "unavailable";
|
|
4
|
+
|
|
5
|
+
export type ProviderUsageSnapshot = {
|
|
6
|
+
providerID: string;
|
|
7
|
+
alias: string;
|
|
8
|
+
fetchedAt: number;
|
|
9
|
+
confidence: UsageConfidence;
|
|
10
|
+
planName?: string;
|
|
11
|
+
usedTokens?: number;
|
|
12
|
+
remainingTokens?: number;
|
|
13
|
+
usedPercent?: number;
|
|
14
|
+
resetAt?: number;
|
|
15
|
+
message: string;
|
|
16
|
+
rawRedacted?: unknown;
|
|
17
|
+
error?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type ProviderUsageService = {
|
|
21
|
+
providerID: string;
|
|
22
|
+
supports(providerID: string): boolean;
|
|
23
|
+
refreshUsage(account: Account): Promise<ProviderUsageSnapshot>;
|
|
24
|
+
};
|