opencode-visual-cache 1.6.4 → 1.6.5
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/dist/_version.d.ts +1 -1
- package/dist/_version.js +1 -1
- package/dist/balance-providers.d.ts +9 -0
- package/dist/balance-providers.js +219 -1
- package/dist/i18n.d.ts +13 -0
- package/dist/i18n.js +52 -0
- package/dist/index.js +106 -4
- package/dist/tui.js +554 -161
- package/package.json +2 -1
- package/src/_version.ts +1 -1
- package/src/balance-providers.ts +248 -1
- package/src/i18n.ts +52 -0
- package/src/index.tsx +124 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-visual-cache",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.5",
|
|
4
4
|
"description": "OpenCode TUI plugin displaying real-time token cache hit rate in the sidebar",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"build": "tsc && node build.tui.mjs",
|
|
31
31
|
"build:tui": "node build.tui.mjs",
|
|
32
32
|
"typecheck": "tsc --noEmit",
|
|
33
|
+
"test:balance": "tsx tests/codex-balance.test.ts",
|
|
33
34
|
"version": "node -e \"require('fs').writeFileSync('src/_version.ts','// auto-generated\\nexport const PLUGIN_VERSION='+JSON.stringify(require('./package.json').version)+';\\n')\"",
|
|
34
35
|
"prepublishOnly": "tsc"
|
|
35
36
|
},
|
package/src/_version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// auto-generated
|
|
2
|
-
export const PLUGIN_VERSION="1.6.
|
|
2
|
+
export const PLUGIN_VERSION="1.6.5";
|
package/src/balance-providers.ts
CHANGED
|
@@ -6,6 +6,16 @@
|
|
|
6
6
|
export interface BalanceEntry {
|
|
7
7
|
currency: string // 原生币种(CNY/USD…),复用现有汇率换算
|
|
8
8
|
total: string // 余额字符串
|
|
9
|
+
display?: string // 非货币额度的预格式化显示文本
|
|
10
|
+
details?: BalanceDetail[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type BalanceDetailKey = "plan" | "used" | "remaining" | "window" | "reset" | "codeReview" | "credits" | "resetCredits"
|
|
14
|
+
|
|
15
|
+
export interface BalanceDetail {
|
|
16
|
+
key: BalanceDetailKey
|
|
17
|
+
value: string
|
|
18
|
+
windowSeconds?: number
|
|
9
19
|
}
|
|
10
20
|
|
|
11
21
|
/** provider 统一错误:message 即错误码(401/403/EMPTY/…),显示层直接展示。 */
|
|
@@ -149,8 +159,245 @@ const hyperProvider: BalanceProvider = {
|
|
|
149
159
|
},
|
|
150
160
|
}
|
|
151
161
|
|
|
162
|
+
function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
|
|
163
|
+
try {
|
|
164
|
+
const encoded = token.split(".")[1]
|
|
165
|
+
if (!encoded || typeof atob !== "function") return undefined
|
|
166
|
+
const binary = atob(encoded.replace(/-/g, "+").replace(/_/g, "/"))
|
|
167
|
+
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0))
|
|
168
|
+
return JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>
|
|
169
|
+
} catch {
|
|
170
|
+
return undefined
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function getChatGPTAccountId(token: string): string | undefined {
|
|
175
|
+
const payload = decodeJwtPayload(token)
|
|
176
|
+
const auth = payload?.["https://api.openai.com/auth"]
|
|
177
|
+
if (auth && typeof auth === "object") {
|
|
178
|
+
const accountId = (auth as Record<string, unknown>).chatgpt_account_id
|
|
179
|
+
if (typeof accountId === "string" && accountId) return accountId
|
|
180
|
+
}
|
|
181
|
+
const accountId = payload?.chatgpt_account_id
|
|
182
|
+
return typeof accountId === "string" && accountId ? accountId : undefined
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
type OpenAIRecord = Record<string, unknown>
|
|
186
|
+
|
|
187
|
+
interface CodexPercentages {
|
|
188
|
+
used: number
|
|
189
|
+
remaining: number
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
interface CodexRateWindow {
|
|
193
|
+
data: OpenAIRecord
|
|
194
|
+
windowSeconds?: number
|
|
195
|
+
order: number
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function asRecord(value: unknown): OpenAIRecord | undefined {
|
|
199
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as OpenAIRecord : undefined
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function asFiniteNumber(value: unknown): number | undefined {
|
|
203
|
+
const number = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : NaN
|
|
204
|
+
return Number.isFinite(number) ? number : undefined
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function clampPercent(value: number): number {
|
|
208
|
+
return Math.max(0, Math.min(100, value))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function formatPercent(value: number): string {
|
|
212
|
+
return Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function formatCreditAmount(value: number): string {
|
|
216
|
+
if (Number.isInteger(value)) return String(value)
|
|
217
|
+
return value.toFixed(2).replace(/\.?0+$/, "")
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function getPercentages(snapshot: OpenAIRecord): CodexPercentages | undefined {
|
|
221
|
+
const explicitUsed = asFiniteNumber(snapshot.used_percent)
|
|
222
|
+
const explicitRemaining = asFiniteNumber(snapshot.remaining_percent)
|
|
223
|
+
if (explicitUsed !== undefined || explicitRemaining !== undefined) {
|
|
224
|
+
const used = clampPercent(explicitUsed ?? 100 - explicitRemaining!)
|
|
225
|
+
const remaining = clampPercent(explicitRemaining ?? 100 - used)
|
|
226
|
+
return { used, remaining }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const limit = asFiniteNumber(snapshot.limit)
|
|
230
|
+
const usedAmount = asFiniteNumber(snapshot.used)
|
|
231
|
+
const remainingAmount = asFiniteNumber(snapshot.remaining)
|
|
232
|
+
if (limit !== undefined && limit > 0 && (usedAmount !== undefined || remainingAmount !== undefined)) {
|
|
233
|
+
const used = usedAmount !== undefined ? (usedAmount / limit) * 100 : 100 - (remainingAmount! / limit) * 100
|
|
234
|
+
const remaining = remainingAmount !== undefined ? (remainingAmount / limit) * 100 : 100 - used
|
|
235
|
+
return { used: clampPercent(used), remaining: clampPercent(remaining) }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const amountTotal = (usedAmount ?? 0) + (remainingAmount ?? 0)
|
|
239
|
+
if (amountTotal > 0 && (usedAmount !== undefined || remainingAmount !== undefined)) {
|
|
240
|
+
const used = usedAmount !== undefined ? (usedAmount / amountTotal) * 100 : 0
|
|
241
|
+
return { used: clampPercent(used), remaining: clampPercent(100 - used) }
|
|
242
|
+
}
|
|
243
|
+
return undefined
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function getRateWindows(rateLimit: unknown): CodexRateWindow[] {
|
|
247
|
+
const record = asRecord(rateLimit)
|
|
248
|
+
if (!record) return []
|
|
249
|
+
return Object.entries(record)
|
|
250
|
+
.map(([name, value], order): CodexRateWindow | undefined => {
|
|
251
|
+
const data = asRecord(value)
|
|
252
|
+
if (!data) return undefined
|
|
253
|
+
const normalizedName = name.toLowerCase()
|
|
254
|
+
if (normalizedName.includes("individual")) return undefined
|
|
255
|
+
const windowSeconds = asFiniteNumber(data.limit_window_seconds)
|
|
256
|
+
const looksLikeWindow = normalizedName.includes("window") ||
|
|
257
|
+
windowSeconds !== undefined ||
|
|
258
|
+
"used_percent" in data ||
|
|
259
|
+
"remaining_percent" in data
|
|
260
|
+
if (!looksLikeWindow) return undefined
|
|
261
|
+
return { data, windowSeconds, order }
|
|
262
|
+
})
|
|
263
|
+
.filter((window): window is CodexRateWindow => window !== undefined)
|
|
264
|
+
.sort((a, b) => (a.windowSeconds ?? Number.MAX_SAFE_INTEGER) - (b.windowSeconds ?? Number.MAX_SAFE_INTEGER) || a.order - b.order)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function getResetAfterSeconds(snapshot: OpenAIRecord, nowMs: number): number | undefined {
|
|
268
|
+
const relative = asFiniteNumber(snapshot.reset_after_seconds)
|
|
269
|
+
if (relative !== undefined) return Math.max(0, Math.round(relative))
|
|
270
|
+
|
|
271
|
+
for (const key of ["reset_at", "resets_at", "resetAt", "resetsAt"]) {
|
|
272
|
+
const timestamp = asFiniteNumber(snapshot[key])
|
|
273
|
+
if (timestamp === undefined) continue
|
|
274
|
+
const timestampSeconds = timestamp > 1e12 ? timestamp / 1000 : timestamp
|
|
275
|
+
return Math.max(0, Math.round(timestampSeconds - nowMs / 1000))
|
|
276
|
+
}
|
|
277
|
+
return undefined
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function appendQuotaDetails(details: BalanceDetail[], percentages: CodexPercentages, windowSeconds?: number): void {
|
|
281
|
+
const scope = windowSeconds === undefined ? {} : { windowSeconds }
|
|
282
|
+
details.push({ key: "used", value: `${formatPercent(percentages.used)}%`, ...scope })
|
|
283
|
+
details.push({ key: "remaining", value: `${formatPercent(percentages.remaining)}%`, ...scope })
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function parseOpenAIUsage(raw: unknown, nowMs = Date.now()): BalanceEntry[] {
|
|
287
|
+
const json = asRecord(raw)
|
|
288
|
+
if (!json) throw new BalanceError("EMPTY")
|
|
289
|
+
|
|
290
|
+
const details: BalanceDetail[] = []
|
|
291
|
+
if (typeof json.plan_type === "string" && json.plan_type) {
|
|
292
|
+
details.push({ key: "plan", value: json.plan_type.toUpperCase() })
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const rateLimit = asRecord(json.rate_limit)
|
|
296
|
+
const remainingValues: number[] = []
|
|
297
|
+
let hasRateQuota = false
|
|
298
|
+
for (const window of getRateWindows(rateLimit)) {
|
|
299
|
+
const percentages = getPercentages(window.data)
|
|
300
|
+
if (percentages) {
|
|
301
|
+
appendQuotaDetails(details, percentages, window.windowSeconds)
|
|
302
|
+
remainingValues.push(percentages.remaining)
|
|
303
|
+
hasRateQuota = true
|
|
304
|
+
}
|
|
305
|
+
const resetAfter = getResetAfterSeconds(window.data, nowMs)
|
|
306
|
+
if (resetAfter !== undefined) {
|
|
307
|
+
details.push({
|
|
308
|
+
key: "reset",
|
|
309
|
+
value: String(resetAfter),
|
|
310
|
+
...(window.windowSeconds === undefined ? {} : { windowSeconds: window.windowSeconds }),
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const spendControl = asRecord(asRecord(json.spend_control)?.individual_limit)
|
|
316
|
+
const individualLimit = asRecord(json.individual_limit) ?? asRecord(rateLimit?.individual_limit) ?? spendControl
|
|
317
|
+
const individualPercentages = individualLimit ? getPercentages(individualLimit) : undefined
|
|
318
|
+
if (individualPercentages) {
|
|
319
|
+
remainingValues.push(individualPercentages.remaining)
|
|
320
|
+
if (!hasRateQuota) appendQuotaDetails(details, individualPercentages)
|
|
321
|
+
}
|
|
322
|
+
if (individualLimit) {
|
|
323
|
+
const resetAfter = getResetAfterSeconds(individualLimit, nowMs)
|
|
324
|
+
if (resetAfter !== undefined) details.push({ key: "reset", value: String(resetAfter) })
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const codeReviewWindow = asRecord(asRecord(json.code_review_rate_limit)?.primary_window)
|
|
328
|
+
const codeReviewUsed = asFiniteNumber(codeReviewWindow?.used_percent)
|
|
329
|
+
if (codeReviewUsed !== undefined) {
|
|
330
|
+
details.push({ key: "codeReview", value: `${formatPercent(clampPercent(100 - codeReviewUsed))}%` })
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const credits = asRecord(json.credits)
|
|
334
|
+
let hasCreditDetail = false
|
|
335
|
+
if (credits?.unlimited === true) {
|
|
336
|
+
details.push({ key: "credits", value: "unlimited" })
|
|
337
|
+
hasCreditDetail = true
|
|
338
|
+
} else {
|
|
339
|
+
const creditBalance = asFiniteNumber(credits?.balance)
|
|
340
|
+
if (creditBalance !== undefined) {
|
|
341
|
+
details.push({ key: "credits", value: `$${creditBalance.toFixed(2)}` })
|
|
342
|
+
hasCreditDetail = true
|
|
343
|
+
} else if (individualLimit) {
|
|
344
|
+
const remaining = asFiniteNumber(individualLimit.remaining)
|
|
345
|
+
const limit = asFiniteNumber(individualLimit.limit)
|
|
346
|
+
const amounts = [remaining, limit].filter((value): value is number => value !== undefined)
|
|
347
|
+
if (amounts.length > 0) {
|
|
348
|
+
details.push({ key: "credits", value: amounts.map(formatCreditAmount).join(" / ") })
|
|
349
|
+
hasCreditDetail = true
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const resetCredits = asFiniteNumber(asRecord(json.rate_limit_reset_credits)?.available_count)
|
|
355
|
+
if (resetCredits !== undefined) details.push({ key: "resetCredits", value: String(resetCredits) })
|
|
356
|
+
|
|
357
|
+
if (details.length === 0 || (!hasRateQuota && !individualPercentages && !hasCreditDetail && resetCredits === undefined)) {
|
|
358
|
+
throw new BalanceError("EMPTY")
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const summaryRemaining = remainingValues.length > 0 ? Math.min(...remainingValues) : undefined
|
|
362
|
+
const summary = summaryRemaining === undefined ? undefined : formatPercent(summaryRemaining)
|
|
363
|
+
return [{
|
|
364
|
+
currency: "CODEX",
|
|
365
|
+
total: summary === undefined ? "0" : `${summary}%`,
|
|
366
|
+
display: summary === undefined ? "Codex" : `Codex ${summary}%`,
|
|
367
|
+
details,
|
|
368
|
+
}]
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const openaiProvider: BalanceProvider = {
|
|
372
|
+
id: "openai",
|
|
373
|
+
name: "OpenAI Codex",
|
|
374
|
+
keyPlaceholder: "OAuth access token (eyJ...)",
|
|
375
|
+
async fetchBalance(accessToken, signal) {
|
|
376
|
+
const headers: Record<string, string> = {
|
|
377
|
+
Authorization: `Bearer ${accessToken}`,
|
|
378
|
+
Accept: "application/json",
|
|
379
|
+
Referer: "https://chatgpt.com/",
|
|
380
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/147.0.0.0 Safari/537.36",
|
|
381
|
+
"OpenAI-Beta": "codex-1",
|
|
382
|
+
"oai-language": "zh-CN",
|
|
383
|
+
originator: "Codex Desktop",
|
|
384
|
+
}
|
|
385
|
+
const accountId = getChatGPTAccountId(accessToken)
|
|
386
|
+
if (accountId) headers["ChatGPT-Account-Id"] = accountId
|
|
387
|
+
|
|
388
|
+
const res = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers, signal })
|
|
389
|
+
if (!res.ok) {
|
|
390
|
+
if (res.status === 401) throw new BalanceError("401")
|
|
391
|
+
if (res.status === 403) throw new BalanceError("403")
|
|
392
|
+
throw new BalanceError(String(res.status))
|
|
393
|
+
}
|
|
394
|
+
const json = await res.json()
|
|
395
|
+
return parseOpenAIUsage(json)
|
|
396
|
+
},
|
|
397
|
+
}
|
|
398
|
+
|
|
152
399
|
/** 已注册的 provider 列表(按需追加新适配器)。 */
|
|
153
|
-
export const balanceProviders: BalanceProvider[] = [deepseekProvider, siliconflowProvider, openrouterProvider, moonshotProvider, hyperProvider]
|
|
400
|
+
export const balanceProviders: BalanceProvider[] = [deepseekProvider, siliconflowProvider, openrouterProvider, moonshotProvider, hyperProvider, openaiProvider]
|
|
154
401
|
|
|
155
402
|
/** 按 id 取 provider;未知 id 回退到第一个。 */
|
|
156
403
|
export function getBalanceProvider(id: string): BalanceProvider {
|
package/src/i18n.ts
CHANGED
|
@@ -44,6 +44,19 @@ const ZH_T = {
|
|
|
44
44
|
balErrEmpty:"未获取到余额数据",
|
|
45
45
|
balErrTimeout: "查询超时",
|
|
46
46
|
balUnsupported: "当前提供商不支持余额查询",
|
|
47
|
+
balDetailPlan: "套餐",
|
|
48
|
+
balDetailUsed: "已用",
|
|
49
|
+
balDetailRemaining: "剩余",
|
|
50
|
+
balDetailWindow: "周期",
|
|
51
|
+
balDetailReset: "重置",
|
|
52
|
+
balDetailCodeReview: "Code Review",
|
|
53
|
+
balDetailCredits: "Credits",
|
|
54
|
+
balDetailResetCredits: "重置次数",
|
|
55
|
+
balUnlimited: "无限",
|
|
56
|
+
balDay: "天",
|
|
57
|
+
balHour: "小时",
|
|
58
|
+
balMinute: "分钟",
|
|
59
|
+
balResetSoon: "即将重置",
|
|
47
60
|
barHit: "命中率",
|
|
48
61
|
barBal: "余额",
|
|
49
62
|
barTok: "Tokens",
|
|
@@ -129,6 +142,19 @@ const EN_T: Translation = {
|
|
|
129
142
|
balErrEmpty:"No balance data",
|
|
130
143
|
balErrTimeout: "Request timed out",
|
|
131
144
|
balUnsupported: "Balance query unsupported",
|
|
145
|
+
balDetailPlan: "Plan",
|
|
146
|
+
balDetailUsed: "Used",
|
|
147
|
+
balDetailRemaining: "Remaining",
|
|
148
|
+
balDetailWindow: "Window",
|
|
149
|
+
balDetailReset: "Reset",
|
|
150
|
+
balDetailCodeReview: "Code Review",
|
|
151
|
+
balDetailCredits: "Credits",
|
|
152
|
+
balDetailResetCredits: "Reset credits",
|
|
153
|
+
balUnlimited: "Unlimited",
|
|
154
|
+
balDay: "d",
|
|
155
|
+
balHour: "h",
|
|
156
|
+
balMinute: "m",
|
|
157
|
+
balResetSoon: "soon",
|
|
132
158
|
barHit: "Hit",
|
|
133
159
|
barBal: "Balance",
|
|
134
160
|
barTok: "Tokens",
|
|
@@ -211,6 +237,19 @@ const JA_T: Translation = {
|
|
|
211
237
|
balErrEmpty:"残高データなし",
|
|
212
238
|
balErrTimeout: "タイムアウト",
|
|
213
239
|
balUnsupported: "このプロバイダは残高照会非対応",
|
|
240
|
+
balDetailPlan: "プラン",
|
|
241
|
+
balDetailUsed: "使用済み",
|
|
242
|
+
balDetailRemaining: "残り",
|
|
243
|
+
balDetailWindow: "期間",
|
|
244
|
+
balDetailReset: "リセット",
|
|
245
|
+
balDetailCodeReview: "Code Review",
|
|
246
|
+
balDetailCredits: "Credits",
|
|
247
|
+
balDetailResetCredits: "リセット回数",
|
|
248
|
+
balUnlimited: "無制限",
|
|
249
|
+
balDay: "日",
|
|
250
|
+
balHour: "時間",
|
|
251
|
+
balMinute: "分",
|
|
252
|
+
balResetSoon: "まもなく",
|
|
214
253
|
barHit: "ヒット率",
|
|
215
254
|
barBal: "残高",
|
|
216
255
|
barTok: "Tokens",
|
|
@@ -293,6 +332,19 @@ const KO_T: Translation = {
|
|
|
293
332
|
balErrEmpty:"잔액 데이터 없음",
|
|
294
333
|
balErrTimeout: "시간 초과",
|
|
295
334
|
balUnsupported: "이 프로바이더는 잔액 조회 미지원",
|
|
335
|
+
balDetailPlan: "플랜",
|
|
336
|
+
balDetailUsed: "사용",
|
|
337
|
+
balDetailRemaining: "잔여",
|
|
338
|
+
balDetailWindow: "주기",
|
|
339
|
+
balDetailReset: "재설정",
|
|
340
|
+
balDetailCodeReview: "Code Review",
|
|
341
|
+
balDetailCredits: "Credits",
|
|
342
|
+
balDetailResetCredits: "재설정 횟수",
|
|
343
|
+
balUnlimited: "무제한",
|
|
344
|
+
balDay: "일",
|
|
345
|
+
balHour: "시간",
|
|
346
|
+
balMinute: "분",
|
|
347
|
+
balResetSoon: "곧 재설정",
|
|
296
348
|
barHit: "히트율",
|
|
297
349
|
barBal: "잔액",
|
|
298
350
|
barTok: "Tokens",
|
package/src/index.tsx
CHANGED
|
@@ -22,15 +22,18 @@ import type {
|
|
|
22
22
|
} from "@opencode-ai/sdk/v2"
|
|
23
23
|
import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, For, untrack } from "solid-js"
|
|
24
24
|
import { PLUGIN_VERSION } from "./_version"
|
|
25
|
-
import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider, type BalanceEntry, type BalanceProvider } from "./balance-providers"
|
|
26
|
-
import { LANG_META, createT, detectLang, type LangCode } from "./i18n"
|
|
25
|
+
import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider, type BalanceDetail, type BalanceDetailKey, type BalanceEntry, type BalanceProvider } from "./balance-providers"
|
|
26
|
+
import { LANG_META, createT, detectLang, type LangCode, type Translation } from "./i18n"
|
|
27
27
|
|
|
28
28
|
// ---------------------------------------------------------------------------
|
|
29
29
|
// Helpers
|
|
30
30
|
// ---------------------------------------------------------------------------
|
|
31
31
|
|
|
32
32
|
// Bun / Node globals — available at runtime in the OpenCode TUI process
|
|
33
|
-
declare const process: {
|
|
33
|
+
declare const process: {
|
|
34
|
+
env: Record<string, string | undefined>
|
|
35
|
+
getBuiltinModule?: (id: string) => unknown
|
|
36
|
+
} | undefined
|
|
34
37
|
|
|
35
38
|
// ── terminal-width helpers ────────────────────────────────────────
|
|
36
39
|
// CJK characters occupy 2 terminal columns; padEnd/padStart count
|
|
@@ -299,15 +302,52 @@ function convertBalance(target: string, targetRate: number, amount: number, from
|
|
|
299
302
|
/**
|
|
300
303
|
* 从 OpenCode 已认证的 provider 读取 API key 作为余额查询的自动兜底。
|
|
301
304
|
* 匹配复用前缀逻辑:先精确匹配 id,再前缀匹配(如 moonshotai-cn → moonshot)。
|
|
302
|
-
*
|
|
303
|
-
*
|
|
305
|
+
* OpenAI 优先读取 auth.json OAuth;其他 provider 读取 provider.key / provider.options.apiKey。
|
|
306
|
+
* 读取失败或未匹配返回空串。
|
|
304
307
|
*/
|
|
308
|
+
function readOpenAIOAuthToken(api: TuiPluginApi): string {
|
|
309
|
+
try {
|
|
310
|
+
// OpenAI OAuth credentials are stored separately from provider.key.
|
|
311
|
+
const loader = typeof process !== "undefined" ? process?.getBuiltinModule : undefined
|
|
312
|
+
const fs = loader?.("node:fs") as { readFileSync(path: string, encoding: "utf8"): string } | undefined
|
|
313
|
+
if (!fs) return ""
|
|
314
|
+
const stateDir = api.state.path.state.replace(/[\\/]+$/, "")
|
|
315
|
+
const home = typeof process !== "undefined" ? (process?.env.HOME || process?.env.USERPROFILE || "") : ""
|
|
316
|
+
const dataHome = typeof process !== "undefined" ? process?.env.XDG_DATA_HOME : undefined
|
|
317
|
+
const paths = [
|
|
318
|
+
stateDir ? `${stateDir}/auth.json` : "",
|
|
319
|
+
dataHome ? `${dataHome}/opencode/auth.json` : "",
|
|
320
|
+
home ? `${home}/.local/share/opencode/auth.json` : "",
|
|
321
|
+
]
|
|
322
|
+
for (const path of paths) {
|
|
323
|
+
if (!path) continue
|
|
324
|
+
try {
|
|
325
|
+
const auth = JSON.parse(fs.readFileSync(path, "utf8")) as Record<string, unknown>
|
|
326
|
+
const openai = auth.openai
|
|
327
|
+
if (openai && typeof openai === "object") {
|
|
328
|
+
const record = openai as Record<string, unknown>
|
|
329
|
+
if (record.type === "oauth" && typeof record.access === "string") return record.access
|
|
330
|
+
}
|
|
331
|
+
} catch { /* try the next known auth path */ }
|
|
332
|
+
}
|
|
333
|
+
return ""
|
|
334
|
+
} catch {
|
|
335
|
+
return ""
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
305
339
|
function findOpencodeKey(api: TuiPluginApi, provider: BalanceProvider): string {
|
|
306
340
|
try {
|
|
307
341
|
const provs = api.state.provider as unknown as Array<{ id: string; key?: string; options?: { apiKey?: string } }>
|
|
308
342
|
// 大小写不敏感:精确匹配 id,否则前缀匹配(如 moonshotai-cn → moonshot)
|
|
309
343
|
const id = provider.id.toLowerCase()
|
|
310
344
|
const hit = provs.find((p) => p.id.toLowerCase() === id) ?? provs.find((p) => p.id.toLowerCase().startsWith(id))
|
|
345
|
+
const isOpenAI = id === "openai"
|
|
346
|
+
// OAuth token 优先于 provider.key,避免把配置中的占位值当成 access token。
|
|
347
|
+
if (isOpenAI) {
|
|
348
|
+
const oauth = readOpenAIOAuthToken(api)
|
|
349
|
+
if (oauth) return oauth
|
|
350
|
+
}
|
|
311
351
|
if (!hit) return ""
|
|
312
352
|
const k = typeof hit.key === "string" ? hit.key : ""
|
|
313
353
|
if (k) return k
|
|
@@ -343,6 +383,8 @@ function formatBalanceAmount(total: string): string {
|
|
|
343
383
|
* 优先直接显示偏好币种(CNY/USD…);偏好币种为换算币种时按汇率折算第一条余额。
|
|
344
384
|
*/
|
|
345
385
|
function formatBalanceText(list: BalanceEntry[], pref: string, rate: number): string {
|
|
386
|
+
const custom = list.find((x) => x.display)
|
|
387
|
+
if (custom?.display) return custom.display
|
|
346
388
|
const native = pref ? list.find((x) => x.currency === pref) : undefined
|
|
347
389
|
if (native) return balanceSymbol(native.currency) + formatBalanceAmount(native.total)
|
|
348
390
|
const base = list[0]
|
|
@@ -356,6 +398,17 @@ function formatBalanceText(list: BalanceEntry[], pref: string, rate: number): st
|
|
|
356
398
|
return balanceSymbol(pref || base.currency) + shown
|
|
357
399
|
}
|
|
358
400
|
|
|
401
|
+
const BALANCE_DETAIL_LABELS: Record<BalanceDetailKey, keyof Translation> = {
|
|
402
|
+
plan: "balDetailPlan",
|
|
403
|
+
used: "balDetailUsed",
|
|
404
|
+
remaining: "balDetailRemaining",
|
|
405
|
+
window: "balDetailWindow",
|
|
406
|
+
reset: "balDetailReset",
|
|
407
|
+
codeReview: "balDetailCodeReview",
|
|
408
|
+
credits: "balDetailCredits",
|
|
409
|
+
resetCredits: "balDetailResetCredits",
|
|
410
|
+
}
|
|
411
|
+
|
|
359
412
|
// ---------------------------------------------------------------------------
|
|
360
413
|
// Sidebar component
|
|
361
414
|
// ---------------------------------------------------------------------------
|
|
@@ -443,6 +496,7 @@ function TokenCachePanel(props: {
|
|
|
443
496
|
const [modelOpen, setModelOpen] = createSignal(true)
|
|
444
497
|
const [distOpen, setDistOpen] = createSignal(false)
|
|
445
498
|
const [skillsOpen, setSkillsOpen] = createSignal(true)
|
|
499
|
+
const [balanceOpen, setBalanceOpen] = createSignal(false)
|
|
446
500
|
let boxEl: any
|
|
447
501
|
|
|
448
502
|
// 侧边栏可见性通知:本面板挂载 ⇒ 宿主侧边栏可见(固定占用 42 列输入框宽度)
|
|
@@ -473,6 +527,34 @@ function TokenCachePanel(props: {
|
|
|
473
527
|
// ── reactive translation (follows langCode signal) ──
|
|
474
528
|
const t = createT(() => langCode())
|
|
475
529
|
|
|
530
|
+
const formatBalanceDuration = (seconds: number, fallback = ""): string => {
|
|
531
|
+
if (!Number.isFinite(seconds)) return ""
|
|
532
|
+
let remaining = Math.max(0, Math.round(seconds))
|
|
533
|
+
const days = Math.floor(remaining / 86400)
|
|
534
|
+
remaining %= 86400
|
|
535
|
+
const hours = Math.floor(remaining / 3600)
|
|
536
|
+
remaining %= 3600
|
|
537
|
+
const minutes = Math.floor(remaining / 60)
|
|
538
|
+
const parts: string[] = []
|
|
539
|
+
if (days > 0) parts.push(`${days}${t("balDay")}`)
|
|
540
|
+
if (hours > 0 && parts.length < 2) parts.push(`${hours}${t("balHour")}`)
|
|
541
|
+
if (minutes > 0 && parts.length < 2) parts.push(`${minutes}${t("balMinute")}`)
|
|
542
|
+
return parts.join(langCode() === "en" ? " " : "") || fallback
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const formatBalanceDetailValue = (detail: BalanceDetail): string => {
|
|
546
|
+
if (detail.value === "unlimited") return t("balUnlimited")
|
|
547
|
+
if (detail.key !== "reset") return detail.value
|
|
548
|
+
return formatBalanceDuration(Number(detail.value), t("balResetSoon")) || detail.value
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const formatBalanceDetailLabel = (detail: BalanceDetail): string => {
|
|
552
|
+
const label = t(BALANCE_DETAIL_LABELS[detail.key])
|
|
553
|
+
if (detail.windowSeconds === undefined) return label
|
|
554
|
+
const window = formatBalanceDuration(detail.windowSeconds)
|
|
555
|
+
return window ? `${label} (${window})` : label
|
|
556
|
+
}
|
|
557
|
+
|
|
476
558
|
// ── scan session messages reactively ──
|
|
477
559
|
// SolidJS createMemo re-evaluates whenever the underlying
|
|
478
560
|
// api.state.session state changes — no event listener needed.
|
|
@@ -721,6 +803,8 @@ function TokenCachePanel(props: {
|
|
|
721
803
|
return dataSignal()
|
|
722
804
|
})
|
|
723
805
|
|
|
806
|
+
const balanceDetails = createMemo(() => balanceState().data?.find((entry) => entry.details)?.details ?? [])
|
|
807
|
+
|
|
724
808
|
// Persist the last valid distribution so that data() can fall back
|
|
725
809
|
// to it while api.state.part() is re-hydrating after a view switch.
|
|
726
810
|
createEffect(() => {
|
|
@@ -754,6 +838,7 @@ function TokenCachePanel(props: {
|
|
|
754
838
|
setModelOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.model`, true)))
|
|
755
839
|
setDistOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.dist`, false)))
|
|
756
840
|
setSkillsOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.skills`, true)))
|
|
841
|
+
setBalanceOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.balance.open`, false)))
|
|
757
842
|
} catch {}
|
|
758
843
|
|
|
759
844
|
// Restore user config (currency, rate, section visibility).
|
|
@@ -903,6 +988,15 @@ function TokenCachePanel(props: {
|
|
|
903
988
|
return label + " ".repeat(gap) + value + (unit ? " " + unit : "")
|
|
904
989
|
}
|
|
905
990
|
|
|
991
|
+
const balanceHeader = () => {
|
|
992
|
+
const arrow = balanceDetails().length > 0 ? (balanceOpen() ? "\u25bc " : "\u25b6 ") : ""
|
|
993
|
+
const title = t("secBalance")
|
|
994
|
+
const summary = balanceState().data ? formatBalanceText(balanceState().data!, balanceCurrency(), exchangeRate()) : ""
|
|
995
|
+
const gauge = panelWidth() - gutter()
|
|
996
|
+
const dividerLength = Math.max(1, gauge - visualWidth(arrow + title) - visualWidth(summary) - 1)
|
|
997
|
+
return { arrow, title, summary, divider: sep().slice(0, dividerLength) }
|
|
998
|
+
}
|
|
999
|
+
|
|
906
1000
|
return (
|
|
907
1001
|
<box
|
|
908
1002
|
border={borderVisible()}
|
|
@@ -1138,7 +1232,6 @@ function TokenCachePanel(props: {
|
|
|
1138
1232
|
|
|
1139
1233
|
{/* ── provider balance (single line) ── */}
|
|
1140
1234
|
<Show when={sectionBalance()}>
|
|
1141
|
-
<text fg={pal().muted}>{sep()}</text>
|
|
1142
1235
|
<Show when={balanceUnsupported()}>
|
|
1143
1236
|
<text fg={pal().muted}>
|
|
1144
1237
|
<span style={{ fg: pal().muted }}>{"> "}</span>
|
|
@@ -1172,9 +1265,31 @@ function TokenCachePanel(props: {
|
|
|
1172
1265
|
</text>
|
|
1173
1266
|
</Show>
|
|
1174
1267
|
<Show when={balanceState().status === "ok" && balanceState().data}>
|
|
1175
|
-
<
|
|
1176
|
-
{
|
|
1177
|
-
|
|
1268
|
+
<Show when={balanceDetails().length > 0}>
|
|
1269
|
+
<text fg={pal().text} onMouseUp={() => {
|
|
1270
|
+
const next = !balanceOpen()
|
|
1271
|
+
setBalanceOpen(next)
|
|
1272
|
+
persistFold("balance.open", next)
|
|
1273
|
+
}}>
|
|
1274
|
+
<span style={{ fg: pal().muted }}>{balanceHeader().arrow}</span>
|
|
1275
|
+
<span style={{ fg: pal().primary }}><b>{balanceHeader().title}</b></span>
|
|
1276
|
+
<span style={{ fg: pal().muted }}>{balanceHeader().divider}</span>
|
|
1277
|
+
<span>{" " + balanceHeader().summary}</span>
|
|
1278
|
+
</text>
|
|
1279
|
+
<Show when={balanceOpen()}>
|
|
1280
|
+
{balanceDetails().map((detail) => (
|
|
1281
|
+
<text fg={pal().muted}>
|
|
1282
|
+
{justify(formatBalanceDetailLabel(detail) + ":", formatBalanceDetailValue(detail))}
|
|
1283
|
+
</text>
|
|
1284
|
+
))}
|
|
1285
|
+
</Show>
|
|
1286
|
+
</Show>
|
|
1287
|
+
<Show when={balanceDetails().length === 0}>
|
|
1288
|
+
<text fg={pal().muted}>{sep()}</text>
|
|
1289
|
+
<text fg={pal().text}>
|
|
1290
|
+
{justify(t("balTotal"), formatBalanceText(balanceState().data!, balanceCurrency(), exchangeRate()))}
|
|
1291
|
+
</text>
|
|
1292
|
+
</Show>
|
|
1178
1293
|
</Show>
|
|
1179
1294
|
</Show>
|
|
1180
1295
|
</Show>
|