@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.
Files changed (176) hide show
  1. package/INSTALL.txt +53 -25
  2. package/README.md +95 -51
  3. package/dist/core/accounts.ts +404 -0
  4. package/dist/core/database.ts +67 -0
  5. package/dist/core/events.ts +75 -0
  6. package/dist/core/native-auth-suppression.ts +36 -0
  7. package/dist/core/native-connect.ts +31 -0
  8. package/dist/core/path.ts +34 -0
  9. package/dist/core/pending.ts +351 -0
  10. package/dist/core/priority.ts +193 -0
  11. package/dist/core/schema.ts +439 -0
  12. package/dist/core/time.ts +3 -0
  13. package/dist/core/types.ts +72 -0
  14. package/dist/core/usage/index.ts +23 -0
  15. package/dist/core/usage/providers/copilot.ts +243 -0
  16. package/dist/core/usage/providers/openai.ts +179 -0
  17. package/dist/core/usage/redact.ts +80 -0
  18. package/dist/core/usage/store.ts +66 -0
  19. package/dist/core/usage/types.ts +24 -0
  20. package/dist/index.js +173 -4
  21. package/dist/index.js.map +1 -1
  22. package/dist/server/auth-watcher.ts +318 -0
  23. package/dist/server/commands.ts +58 -0
  24. package/dist/server/fetch-patch.ts +162 -0
  25. package/dist/server/index.ts +134 -0
  26. package/dist/server/native.ts +49 -0
  27. package/dist/server/request-balancer.ts +67 -0
  28. package/dist/tui/actions.ts +176 -112
  29. package/dist/tui/balancer-bar-sync.ts +55 -45
  30. package/dist/tui/components/alias-dialog.tsx +71 -56
  31. package/dist/tui/components/dashboard.tsx +530 -358
  32. package/dist/tui/components/priority-screen.tsx +389 -267
  33. package/dist/tui/components/provider-model-dialog.tsx +71 -64
  34. package/dist/tui/components/rename-dialog.tsx +35 -28
  35. package/dist/tui/components/sidebar.tsx +103 -79
  36. package/dist/tui/components/status-indicator.tsx +78 -59
  37. package/dist/tui/components/usage-bar.tsx +18 -7
  38. package/dist/tui/components/usage-display.tsx +32 -16
  39. package/dist/tui/connect.ts +104 -73
  40. package/dist/tui/dashboard-keys.ts +53 -41
  41. package/dist/tui/native-model-apply.ts +45 -36
  42. package/dist/tui/priority-keys.ts +44 -36
  43. package/dist/tui/provider-models.ts +32 -25
  44. package/dist/tui/responsive.ts +10 -7
  45. package/dist/tui/selected-account-bar-sync.ts +23 -23
  46. package/dist/tui/selection-colors.ts +38 -30
  47. package/dist/tui/state.ts +61 -44
  48. package/dist/tui/status-format.ts +24 -20
  49. package/dist/tui/tui.js +165 -153
  50. package/dist/tui/tui.js.map +1 -1
  51. package/dist/tui/tui.tsx +194 -144
  52. package/dist/tui/usage-auto-refresh.ts +52 -45
  53. package/dist/tui/usage-format.ts +9 -9
  54. package/package.json +61 -52
  55. package/dist/core/accounts.d.ts +0 -14
  56. package/dist/core/accounts.js +0 -260
  57. package/dist/core/accounts.js.map +0 -1
  58. package/dist/core/database.d.ts +0 -4
  59. package/dist/core/database.js +0 -69
  60. package/dist/core/database.js.map +0 -1
  61. package/dist/core/events.d.ts +0 -18
  62. package/dist/core/events.js +0 -39
  63. package/dist/core/events.js.map +0 -1
  64. package/dist/core/native-auth-suppression.d.ts +0 -3
  65. package/dist/core/native-auth-suppression.js +0 -19
  66. package/dist/core/native-auth-suppression.js.map +0 -1
  67. package/dist/core/native-connect.d.ts +0 -4
  68. package/dist/core/native-connect.js +0 -19
  69. package/dist/core/native-connect.js.map +0 -1
  70. package/dist/core/path.d.ts +0 -4
  71. package/dist/core/path.js +0 -26
  72. package/dist/core/path.js.map +0 -1
  73. package/dist/core/pending.d.ts +0 -9
  74. package/dist/core/pending.js +0 -237
  75. package/dist/core/pending.js.map +0 -1
  76. package/dist/core/priority.d.ts +0 -20
  77. package/dist/core/priority.js +0 -120
  78. package/dist/core/priority.js.map +0 -1
  79. package/dist/core/schema.d.ts +0 -2
  80. package/dist/core/schema.js +0 -265
  81. package/dist/core/schema.js.map +0 -1
  82. package/dist/core/time.d.ts +0 -1
  83. package/dist/core/time.js +0 -4
  84. package/dist/core/time.js.map +0 -1
  85. package/dist/core/types.d.ts +0 -59
  86. package/dist/core/types.js +0 -2
  87. package/dist/core/types.js.map +0 -1
  88. package/dist/core/usage/index.d.ts +0 -4
  89. package/dist/core/usage/index.js +0 -16
  90. package/dist/core/usage/index.js.map +0 -1
  91. package/dist/core/usage/providers/copilot.d.ts +0 -2
  92. package/dist/core/usage/providers/copilot.js +0 -169
  93. package/dist/core/usage/providers/copilot.js.map +0 -1
  94. package/dist/core/usage/providers/openai.d.ts +0 -2
  95. package/dist/core/usage/providers/openai.js +0 -133
  96. package/dist/core/usage/providers/openai.js.map +0 -1
  97. package/dist/core/usage/redact.d.ts +0 -3
  98. package/dist/core/usage/redact.js +0 -67
  99. package/dist/core/usage/redact.js.map +0 -1
  100. package/dist/core/usage/store.d.ts +0 -4
  101. package/dist/core/usage/store.js +0 -31
  102. package/dist/core/usage/store.js.map +0 -1
  103. package/dist/core/usage/types.d.ts +0 -21
  104. package/dist/core/usage/types.js +0 -2
  105. package/dist/core/usage/types.js.map +0 -1
  106. package/dist/index.d.ts +0 -5
  107. package/dist/server/auth-watcher.d.ts +0 -32
  108. package/dist/server/auth-watcher.js +0 -227
  109. package/dist/server/auth-watcher.js.map +0 -1
  110. package/dist/server/commands.d.ts +0 -2
  111. package/dist/server/commands.js +0 -46
  112. package/dist/server/commands.js.map +0 -1
  113. package/dist/server/fetch-patch.d.ts +0 -3
  114. package/dist/server/fetch-patch.js +0 -118
  115. package/dist/server/fetch-patch.js.map +0 -1
  116. package/dist/server/index.d.ts +0 -8
  117. package/dist/server/index.js +0 -94
  118. package/dist/server/index.js.map +0 -1
  119. package/dist/server/native.d.ts +0 -6
  120. package/dist/server/native.js +0 -35
  121. package/dist/server/native.js.map +0 -1
  122. package/dist/server/request-balancer.d.ts +0 -16
  123. package/dist/server/request-balancer.js +0 -43
  124. package/dist/server/request-balancer.js.map +0 -1
  125. package/dist/tui/actions.d.ts +0 -41
  126. package/dist/tui/actions.js +0 -92
  127. package/dist/tui/actions.js.map +0 -1
  128. package/dist/tui/balancer-bar-sync.d.ts +0 -19
  129. package/dist/tui/balancer-bar-sync.js +0 -45
  130. package/dist/tui/balancer-bar-sync.js.map +0 -1
  131. package/dist/tui/components/alias-dialog.d.ts +0 -4
  132. package/dist/tui/components/dashboard.d.ts +0 -12
  133. package/dist/tui/components/priority-screen.d.ts +0 -9
  134. package/dist/tui/components/provider-model-dialog.d.ts +0 -14
  135. package/dist/tui/components/rename-dialog.d.ts +0 -4
  136. package/dist/tui/components/sidebar.d.ts +0 -10
  137. package/dist/tui/components/status-indicator.d.ts +0 -9
  138. package/dist/tui/components/usage-bar.d.ts +0 -8
  139. package/dist/tui/components/usage-display.d.ts +0 -10
  140. package/dist/tui/connect.d.ts +0 -30
  141. package/dist/tui/connect.js +0 -75
  142. package/dist/tui/connect.js.map +0 -1
  143. package/dist/tui/dashboard-keys.d.ts +0 -45
  144. package/dist/tui/dashboard-keys.js +0 -44
  145. package/dist/tui/dashboard-keys.js.map +0 -1
  146. package/dist/tui/native-model-apply.d.ts +0 -21
  147. package/dist/tui/native-model-apply.js +0 -53
  148. package/dist/tui/native-model-apply.js.map +0 -1
  149. package/dist/tui/priority-keys.d.ts +0 -40
  150. package/dist/tui/priority-keys.js +0 -38
  151. package/dist/tui/priority-keys.js.map +0 -1
  152. package/dist/tui/provider-models.d.ts +0 -19
  153. package/dist/tui/provider-models.js +0 -17
  154. package/dist/tui/provider-models.js.map +0 -1
  155. package/dist/tui/responsive.d.ts +0 -9
  156. package/dist/tui/responsive.js +0 -13
  157. package/dist/tui/responsive.js.map +0 -1
  158. package/dist/tui/selected-account-bar-sync.d.ts +0 -10
  159. package/dist/tui/selected-account-bar-sync.js +0 -26
  160. package/dist/tui/selected-account-bar-sync.js.map +0 -1
  161. package/dist/tui/selection-colors.d.ts +0 -10
  162. package/dist/tui/selection-colors.js +0 -38
  163. package/dist/tui/selection-colors.js.map +0 -1
  164. package/dist/tui/state.d.ts +0 -14
  165. package/dist/tui/state.js +0 -46
  166. package/dist/tui/state.js.map +0 -1
  167. package/dist/tui/status-format.d.ts +0 -15
  168. package/dist/tui/status-format.js +0 -17
  169. package/dist/tui/status-format.js.map +0 -1
  170. package/dist/tui/tui.d.ts +0 -7
  171. package/dist/tui/usage-auto-refresh.d.ts +0 -16
  172. package/dist/tui/usage-auto-refresh.js +0 -46
  173. package/dist/tui/usage-auto-refresh.js.map +0 -1
  174. package/dist/tui/usage-format.d.ts +0 -2
  175. package/dist/tui/usage-format.js +0 -17
  176. 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
+ };