From 288a69bed7a380fde00b059149457e3a2dc43d76 Mon Sep 17 00:00:00 2001 From: huahai0202 <2023217276@mail.hfut.edu.cn> Date: Sun, 23 Aug 2026 22:03:53 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20OpenAI=20Codex?= =?UTF-8?q?=20=E4=BD=99=E9=A2=9D=E6=9F=A5=E8=AF=A2=E5=92=8C=E5=A4=9A?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E6=98=8E=E7=BB=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/balance-providers.ts | 103 +++++++++++++++++++++++++++++++- src/i18n.ts | 52 +++++++++++++++++ src/index.tsx | 123 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 268 insertions(+), 10 deletions(-) diff --git a/src/balance-providers.ts b/src/balance-providers.ts index 4cac687..2b062c6 100644 --- a/src/balance-providers.ts +++ b/src/balance-providers.ts @@ -6,6 +6,15 @@ export interface BalanceEntry { currency: string // 原生币种(CNY/USD…),复用现有汇率换算 total: string // 余额字符串 + display?: string // 非货币额度的预格式化显示文本 + details?: BalanceDetail[] +} + +export type BalanceDetailKey = "plan" | "used" | "remaining" | "window" | "reset" | "codeReview" | "credits" | "resetCredits" + +export interface BalanceDetail { + key: BalanceDetailKey + value: string } /** provider 统一错误:message 即错误码(401/403/EMPTY/…),显示层直接展示。 */ @@ -149,8 +158,100 @@ const hyperProvider: BalanceProvider = { }, } +function decodeJwtPayload(token: string): Record | undefined { + try { + const encoded = token.split(".")[1] + if (!encoded || typeof atob !== "function") return undefined + const binary = atob(encoded.replace(/-/g, "+").replace(/_/g, "/")) + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)) + return JSON.parse(new TextDecoder().decode(bytes)) as Record + } catch { + return undefined + } +} + +function getChatGPTAccountId(token: string): string | undefined { + const payload = decodeJwtPayload(token) + const auth = payload?.["https://api.openai.com/auth"] + if (auth && typeof auth === "object") { + const accountId = (auth as Record).chatgpt_account_id + if (typeof accountId === "string" && accountId) return accountId + } + const accountId = payload?.chatgpt_account_id + return typeof accountId === "string" && accountId ? accountId : undefined +} + +const openaiProvider: BalanceProvider = { + id: "openai", + name: "OpenAI Codex", + keyPlaceholder: "OAuth access token (eyJ...)", + async fetchBalance(accessToken, signal) { + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + Referer: "https://chatgpt.com/", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/147.0.0.0 Safari/537.36", + "OpenAI-Beta": "codex-1", + "oai-language": "zh-CN", + originator: "Codex Desktop", + } + const accountId = getChatGPTAccountId(accessToken) + if (accountId) headers["ChatGPT-Account-Id"] = accountId + + const res = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers, signal }) + if (!res.ok) { + if (res.status === 401) throw new BalanceError("401") + if (res.status === 403) throw new BalanceError("403") + throw new BalanceError(String(res.status)) + } + const json = await res.json() as { + plan_type?: string + rate_limit?: { + primary_window?: { + used_percent?: number + limit_window_seconds?: number + reset_after_seconds?: number + } + secondary_window?: { used_percent?: number } + } + code_review_rate_limit?: { primary_window?: { used_percent?: number } } | null + credits?: { unlimited?: boolean; balance?: number | null } + rate_limit_reset_credits?: { available_count?: number } + } + const used = json.rate_limit?.primary_window?.used_percent + if (typeof used !== "number" || !Number.isFinite(used)) throw new BalanceError("EMPTY") + const remaining = Math.max(0, Math.min(100, 100 - used)) + const percentage = Number.isInteger(remaining) ? remaining.toFixed(0) : remaining.toFixed(1) + const details: BalanceDetail[] = [] + if (json.plan_type) details.push({ key: "plan", value: json.plan_type.toUpperCase() }) + details.push({ key: "used", value: `${used}%` }) + details.push({ key: "remaining", value: `${percentage}%` }) + const primary = json.rate_limit?.primary_window + if (typeof primary?.limit_window_seconds === "number") { + details.push({ key: "window", value: String(primary.limit_window_seconds) }) + } + if (typeof primary?.reset_after_seconds === "number") { + details.push({ key: "reset", value: String(primary.reset_after_seconds) }) + } + const codeReviewUsed = json.code_review_rate_limit?.primary_window?.used_percent + if (typeof codeReviewUsed === "number" && Number.isFinite(codeReviewUsed)) { + details.push({ key: "codeReview", value: `${Math.max(0, 100 - codeReviewUsed)}%` }) + } + if (json.credits?.unlimited) { + details.push({ key: "credits", value: "unlimited" }) + } else if (typeof json.credits?.balance === "number") { + details.push({ key: "credits", value: `$${json.credits.balance.toFixed(2)}` }) + } + const resetCredits = json.rate_limit_reset_credits?.available_count + if (typeof resetCredits === "number") { + details.push({ key: "resetCredits", value: String(resetCredits) }) + } + return [{ currency: "CODEX", total: `${percentage}%`, display: `Codex ${percentage}%`, details }] + }, +} + /** 已注册的 provider 列表(按需追加新适配器)。 */ -export const balanceProviders: BalanceProvider[] = [deepseekProvider, siliconflowProvider, openrouterProvider, moonshotProvider, hyperProvider] +export const balanceProviders: BalanceProvider[] = [deepseekProvider, siliconflowProvider, openrouterProvider, moonshotProvider, hyperProvider, openaiProvider] /** 按 id 取 provider;未知 id 回退到第一个。 */ export function getBalanceProvider(id: string): BalanceProvider { diff --git a/src/i18n.ts b/src/i18n.ts index 894e076..4b67ab7 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -44,6 +44,19 @@ const ZH_T = { balErrEmpty:"未获取到余额数据", balErrTimeout: "查询超时", balUnsupported: "当前提供商不支持余额查询", + balDetailPlan: "套餐", + balDetailUsed: "已用", + balDetailRemaining: "剩余", + balDetailWindow: "周期", + balDetailReset: "重置", + balDetailCodeReview: "Code Review", + balDetailCredits: "Credits", + balDetailResetCredits: "重置次数", + balUnlimited: "无限", + balDay: "天", + balHour: "小时", + balMinute: "分钟", + balResetSoon: "即将重置", barHit: "命中率", barBal: "余额", barTok: "Tokens", @@ -129,6 +142,19 @@ const EN_T: Translation = { balErrEmpty:"No balance data", balErrTimeout: "Request timed out", balUnsupported: "Balance query unsupported", + balDetailPlan: "Plan", + balDetailUsed: "Used", + balDetailRemaining: "Remaining", + balDetailWindow: "Window", + balDetailReset: "Reset", + balDetailCodeReview: "Code Review", + balDetailCredits: "Credits", + balDetailResetCredits: "Reset credits", + balUnlimited: "Unlimited", + balDay: "d", + balHour: "h", + balMinute: "m", + balResetSoon: "soon", barHit: "Hit", barBal: "Balance", barTok: "Tokens", @@ -211,6 +237,19 @@ const JA_T: Translation = { balErrEmpty:"残高データなし", balErrTimeout: "タイムアウト", balUnsupported: "このプロバイダは残高照会非対応", + balDetailPlan: "プラン", + balDetailUsed: "使用済み", + balDetailRemaining: "残り", + balDetailWindow: "期間", + balDetailReset: "リセット", + balDetailCodeReview: "Code Review", + balDetailCredits: "Credits", + balDetailResetCredits: "リセット回数", + balUnlimited: "無制限", + balDay: "日", + balHour: "時間", + balMinute: "分", + balResetSoon: "まもなく", barHit: "ヒット率", barBal: "残高", barTok: "Tokens", @@ -293,6 +332,19 @@ const KO_T: Translation = { balErrEmpty:"잔액 데이터 없음", balErrTimeout: "시간 초과", balUnsupported: "이 프로바이더는 잔액 조회 미지원", + balDetailPlan: "플랜", + balDetailUsed: "사용", + balDetailRemaining: "잔여", + balDetailWindow: "주기", + balDetailReset: "재설정", + balDetailCodeReview: "Code Review", + balDetailCredits: "Credits", + balDetailResetCredits: "재설정 횟수", + balUnlimited: "무제한", + balDay: "일", + balHour: "시간", + balMinute: "분", + balResetSoon: "곧 재설정", barHit: "히트율", barBal: "잔액", barTok: "Tokens", diff --git a/src/index.tsx b/src/index.tsx index c01a441..02b1674 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -22,15 +22,18 @@ import type { } from "@opencode-ai/sdk/v2" import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, For, untrack } from "solid-js" import { PLUGIN_VERSION } from "./_version" -import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider, type BalanceEntry, type BalanceProvider } from "./balance-providers" -import { LANG_META, createT, detectLang, type LangCode } from "./i18n" +import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider, type BalanceDetail, type BalanceDetailKey, type BalanceEntry, type BalanceProvider } from "./balance-providers" +import { LANG_META, createT, detectLang, type LangCode, type Translation } from "./i18n" // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- // Bun / Node globals — available at runtime in the OpenCode TUI process -declare const process: { env: Record } | undefined +declare const process: { + env: Record + getBuiltinModule?: (id: string) => unknown +} | undefined // ── terminal-width helpers ──────────────────────────────────────── // CJK characters occupy 2 terminal columns; padEnd/padStart count @@ -299,15 +302,52 @@ function convertBalance(target: string, targetRate: number, amount: number, from /** * 从 OpenCode 已认证的 provider 读取 API key 作为余额查询的自动兜底。 * 匹配复用前缀逻辑:先精确匹配 id,再前缀匹配(如 moonshotai-cn → moonshot)。 - * key 来源:auth.json(provider.key)或配置(provider.options.apiKey)。 - * 仅当手动配置的 key 缺失时使用;读取失败或未匹配返回空串。 + * OpenAI 优先读取 auth.json OAuth;其他 provider 读取 provider.key / provider.options.apiKey。 + * 读取失败或未匹配返回空串。 */ +function readOpenAIOAuthToken(api: TuiPluginApi): string { + try { + // OpenAI OAuth credentials are stored separately from provider.key. + const loader = typeof process !== "undefined" ? process?.getBuiltinModule : undefined + const fs = loader?.("node:fs") as { readFileSync(path: string, encoding: "utf8"): string } | undefined + if (!fs) return "" + const stateDir = api.state.path.state.replace(/[\\/]+$/, "") + const home = typeof process !== "undefined" ? (process?.env.HOME || process?.env.USERPROFILE || "") : "" + const dataHome = typeof process !== "undefined" ? process?.env.XDG_DATA_HOME : undefined + const paths = [ + stateDir ? `${stateDir}/auth.json` : "", + dataHome ? `${dataHome}/opencode/auth.json` : "", + home ? `${home}/.local/share/opencode/auth.json` : "", + ] + for (const path of paths) { + if (!path) continue + try { + const auth = JSON.parse(fs.readFileSync(path, "utf8")) as Record + const openai = auth.openai + if (openai && typeof openai === "object") { + const record = openai as Record + if (record.type === "oauth" && typeof record.access === "string") return record.access + } + } catch { /* try the next known auth path */ } + } + return "" + } catch { + return "" + } +} + function findOpencodeKey(api: TuiPluginApi, provider: BalanceProvider): string { try { const provs = api.state.provider as unknown as Array<{ id: string; key?: string; options?: { apiKey?: string } }> // 大小写不敏感:精确匹配 id,否则前缀匹配(如 moonshotai-cn → moonshot) const id = provider.id.toLowerCase() const hit = provs.find((p) => p.id.toLowerCase() === id) ?? provs.find((p) => p.id.toLowerCase().startsWith(id)) + const isOpenAI = id === "openai" + // OAuth token 优先于 provider.key,避免把配置中的占位值当成 access token。 + if (isOpenAI) { + const oauth = readOpenAIOAuthToken(api) + if (oauth) return oauth + } if (!hit) return "" const k = typeof hit.key === "string" ? hit.key : "" if (k) return k @@ -343,6 +383,8 @@ function formatBalanceAmount(total: string): string { * 优先直接显示偏好币种(CNY/USD…);偏好币种为换算币种时按汇率折算第一条余额。 */ function formatBalanceText(list: BalanceEntry[], pref: string, rate: number): string { + const custom = list.find((x) => x.display) + if (custom?.display) return custom.display const native = pref ? list.find((x) => x.currency === pref) : undefined if (native) return balanceSymbol(native.currency) + formatBalanceAmount(native.total) const base = list[0] @@ -356,6 +398,17 @@ function formatBalanceText(list: BalanceEntry[], pref: string, rate: number): st return balanceSymbol(pref || base.currency) + shown } +const BALANCE_DETAIL_LABELS: Record = { + plan: "balDetailPlan", + used: "balDetailUsed", + remaining: "balDetailRemaining", + window: "balDetailWindow", + reset: "balDetailReset", + codeReview: "balDetailCodeReview", + credits: "balDetailCredits", + resetCredits: "balDetailResetCredits", +} + // --------------------------------------------------------------------------- // Sidebar component // --------------------------------------------------------------------------- @@ -443,6 +496,7 @@ function TokenCachePanel(props: { const [modelOpen, setModelOpen] = createSignal(true) const [distOpen, setDistOpen] = createSignal(false) const [skillsOpen, setSkillsOpen] = createSignal(true) + const [balanceOpen, setBalanceOpen] = createSignal(false) let boxEl: any // 侧边栏可见性通知:本面板挂载 ⇒ 宿主侧边栏可见(固定占用 42 列输入框宽度) @@ -473,6 +527,24 @@ function TokenCachePanel(props: { // ── reactive translation (follows langCode signal) ── const t = createT(() => langCode()) + const formatBalanceDetailValue = (detail: BalanceDetail): string => { + if (detail.value === "unlimited") return t("balUnlimited") + if (detail.key !== "window" && detail.key !== "reset") return detail.value + + let remaining = Math.max(0, Math.round(Number(detail.value))) + if (!Number.isFinite(remaining)) return detail.value + const days = Math.floor(remaining / 86400) + remaining %= 86400 + const hours = Math.floor(remaining / 3600) + remaining %= 3600 + const minutes = Math.floor(remaining / 60) + const parts: string[] = [] + if (days > 0) parts.push(`${days}${t("balDay")}`) + if (hours > 0 && parts.length < 2) parts.push(`${hours}${t("balHour")}`) + if (minutes > 0 && parts.length < 2) parts.push(`${minutes}${t("balMinute")}`) + return parts.join(langCode() === "en" ? " " : "") || t("balResetSoon") + } + // ── scan session messages reactively ── // SolidJS createMemo re-evaluates whenever the underlying // api.state.session state changes — no event listener needed. @@ -721,6 +793,8 @@ function TokenCachePanel(props: { return dataSignal() }) + const balanceDetails = createMemo(() => balanceState().data?.find((entry) => entry.details)?.details ?? []) + // Persist the last valid distribution so that data() can fall back // to it while api.state.part() is re-hydrating after a view switch. createEffect(() => { @@ -754,6 +828,7 @@ function TokenCachePanel(props: { setModelOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.model`, true))) setDistOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.dist`, false))) setSkillsOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.skills`, true))) + setBalanceOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.balance.open`, false))) } catch {} // Restore user config (currency, rate, section visibility). @@ -903,6 +978,15 @@ function TokenCachePanel(props: { return label + " ".repeat(gap) + value + (unit ? " " + unit : "") } + const balanceHeader = () => { + const arrow = balanceDetails().length > 0 ? (balanceOpen() ? "\u25bc " : "\u25b6 ") : "" + const title = t("secBalance") + const summary = balanceState().data ? formatBalanceText(balanceState().data!, balanceCurrency(), exchangeRate()) : "" + const gauge = panelWidth() - gutter() + const dividerLength = Math.max(1, gauge - visualWidth(arrow + title) - visualWidth(summary) - 1) + return { arrow, title, summary, divider: sep().slice(0, dividerLength) } + } + return ( - {sep()} {"> "} @@ -1172,9 +1255,31 @@ function TokenCachePanel(props: { - - {justify(t("balTotal"), formatBalanceText(balanceState().data!, balanceCurrency(), exchangeRate()))} - + 0}> + { + const next = !balanceOpen() + setBalanceOpen(next) + persistFold("balance.open", next) + }}> + {balanceHeader().arrow} + {balanceHeader().title} + {balanceHeader().divider} + {" " + balanceHeader().summary} + + + {balanceDetails().map((detail) => ( + + {justify(t(BALANCE_DETAIL_LABELS[detail.key]) + ":", formatBalanceDetailValue(detail))} + + ))} + + + + {sep()} + + {justify(t("balTotal"), formatBalanceText(balanceState().data!, balanceCurrency(), exchangeRate()))} + + From 71ba7898f31b59bc96d665e474dadd7fd329517d Mon Sep 17 00:00:00 2001 From: huahai0202 <2023217276@mail.hfut.edu.cn> Date: Sun, 23 Aug 2026 22:31:14 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E5=85=BC=E5=AE=B9=20Codex=20?= =?UTF-8?q?=E6=89=98=E7=AE=A1=E9=A2=9D=E5=BA=A6=E5=92=8C=E5=A4=9A=E9=87=8D?= =?UTF-8?q?=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + src/balance-providers.ts | 232 +++++++++++++++++++++++++++++------- src/index.tsx | 26 ++-- tests/codex-balance.test.ts | 60 ++++++++++ 4 files changed, 268 insertions(+), 51 deletions(-) create mode 100644 tests/codex-balance.test.ts diff --git a/package.json b/package.json index 1edd773..a7d1035 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "build": "tsc && node build.tui.mjs", "build:tui": "node build.tui.mjs", "typecheck": "tsc --noEmit", + "test:balance": "tsx tests/codex-balance.test.ts", "version": "node -e \"require('fs').writeFileSync('src/_version.ts','// auto-generated\\nexport const PLUGIN_VERSION='+JSON.stringify(require('./package.json').version)+';\\n')\"", "prepublishOnly": "tsc" }, diff --git a/src/balance-providers.ts b/src/balance-providers.ts index 2b062c6..0656391 100644 --- a/src/balance-providers.ts +++ b/src/balance-providers.ts @@ -15,6 +15,7 @@ export type BalanceDetailKey = "plan" | "used" | "remaining" | "window" | "reset export interface BalanceDetail { key: BalanceDetailKey value: string + windowSeconds?: number } /** provider 统一错误:message 即错误码(401/403/EMPTY/…),显示层直接展示。 */ @@ -181,6 +182,192 @@ function getChatGPTAccountId(token: string): string | undefined { return typeof accountId === "string" && accountId ? accountId : undefined } +type OpenAIRecord = Record + +interface CodexPercentages { + used: number + remaining: number +} + +interface CodexRateWindow { + data: OpenAIRecord + windowSeconds?: number + order: number +} + +function asRecord(value: unknown): OpenAIRecord | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? value as OpenAIRecord : undefined +} + +function asFiniteNumber(value: unknown): number | undefined { + const number = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : NaN + return Number.isFinite(number) ? number : undefined +} + +function clampPercent(value: number): number { + return Math.max(0, Math.min(100, value)) +} + +function formatPercent(value: number): string { + return Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1) +} + +function formatCreditAmount(value: number): string { + if (Number.isInteger(value)) return String(value) + return value.toFixed(2).replace(/\.?0+$/, "") +} + +function getPercentages(snapshot: OpenAIRecord): CodexPercentages | undefined { + const explicitUsed = asFiniteNumber(snapshot.used_percent) + const explicitRemaining = asFiniteNumber(snapshot.remaining_percent) + if (explicitUsed !== undefined || explicitRemaining !== undefined) { + const used = clampPercent(explicitUsed ?? 100 - explicitRemaining!) + const remaining = clampPercent(explicitRemaining ?? 100 - used) + return { used, remaining } + } + + const limit = asFiniteNumber(snapshot.limit) + const usedAmount = asFiniteNumber(snapshot.used) + const remainingAmount = asFiniteNumber(snapshot.remaining) + if (limit !== undefined && limit > 0 && (usedAmount !== undefined || remainingAmount !== undefined)) { + const used = usedAmount !== undefined ? (usedAmount / limit) * 100 : 100 - (remainingAmount! / limit) * 100 + const remaining = remainingAmount !== undefined ? (remainingAmount / limit) * 100 : 100 - used + return { used: clampPercent(used), remaining: clampPercent(remaining) } + } + + const amountTotal = (usedAmount ?? 0) + (remainingAmount ?? 0) + if (amountTotal > 0 && (usedAmount !== undefined || remainingAmount !== undefined)) { + const used = usedAmount !== undefined ? (usedAmount / amountTotal) * 100 : 0 + return { used: clampPercent(used), remaining: clampPercent(100 - used) } + } + return undefined +} + +function getRateWindows(rateLimit: unknown): CodexRateWindow[] { + const record = asRecord(rateLimit) + if (!record) return [] + return Object.entries(record) + .map(([name, value], order): CodexRateWindow | undefined => { + const data = asRecord(value) + if (!data) return undefined + const normalizedName = name.toLowerCase() + if (normalizedName.includes("individual")) return undefined + const windowSeconds = asFiniteNumber(data.limit_window_seconds) + const looksLikeWindow = normalizedName.includes("window") || + windowSeconds !== undefined || + "used_percent" in data || + "remaining_percent" in data + if (!looksLikeWindow) return undefined + return { data, windowSeconds, order } + }) + .filter((window): window is CodexRateWindow => window !== undefined) + .sort((a, b) => (a.windowSeconds ?? Number.MAX_SAFE_INTEGER) - (b.windowSeconds ?? Number.MAX_SAFE_INTEGER) || a.order - b.order) +} + +function getResetAfterSeconds(snapshot: OpenAIRecord, nowMs: number): number | undefined { + const relative = asFiniteNumber(snapshot.reset_after_seconds) + if (relative !== undefined) return Math.max(0, Math.round(relative)) + + for (const key of ["reset_at", "resets_at", "resetAt", "resetsAt"]) { + const timestamp = asFiniteNumber(snapshot[key]) + if (timestamp === undefined) continue + const timestampSeconds = timestamp > 1e12 ? timestamp / 1000 : timestamp + return Math.max(0, Math.round(timestampSeconds - nowMs / 1000)) + } + return undefined +} + +function appendQuotaDetails(details: BalanceDetail[], percentages: CodexPercentages, windowSeconds?: number): void { + const scope = windowSeconds === undefined ? {} : { windowSeconds } + details.push({ key: "used", value: `${formatPercent(percentages.used)}%`, ...scope }) + details.push({ key: "remaining", value: `${formatPercent(percentages.remaining)}%`, ...scope }) +} + +export function parseOpenAIUsage(raw: unknown, nowMs = Date.now()): BalanceEntry[] { + const json = asRecord(raw) + if (!json) throw new BalanceError("EMPTY") + + const details: BalanceDetail[] = [] + if (typeof json.plan_type === "string" && json.plan_type) { + details.push({ key: "plan", value: json.plan_type.toUpperCase() }) + } + + const rateLimit = asRecord(json.rate_limit) + const remainingValues: number[] = [] + let hasRateQuota = false + for (const window of getRateWindows(rateLimit)) { + const percentages = getPercentages(window.data) + if (percentages) { + appendQuotaDetails(details, percentages, window.windowSeconds) + remainingValues.push(percentages.remaining) + hasRateQuota = true + } + const resetAfter = getResetAfterSeconds(window.data, nowMs) + if (resetAfter !== undefined) { + details.push({ + key: "reset", + value: String(resetAfter), + ...(window.windowSeconds === undefined ? {} : { windowSeconds: window.windowSeconds }), + }) + } + } + + const spendControl = asRecord(asRecord(json.spend_control)?.individual_limit) + const individualLimit = asRecord(json.individual_limit) ?? asRecord(rateLimit?.individual_limit) ?? spendControl + const individualPercentages = individualLimit ? getPercentages(individualLimit) : undefined + if (individualPercentages) { + remainingValues.push(individualPercentages.remaining) + if (!hasRateQuota) appendQuotaDetails(details, individualPercentages) + } + if (individualLimit) { + const resetAfter = getResetAfterSeconds(individualLimit, nowMs) + if (resetAfter !== undefined) details.push({ key: "reset", value: String(resetAfter) }) + } + + const codeReviewWindow = asRecord(asRecord(json.code_review_rate_limit)?.primary_window) + const codeReviewUsed = asFiniteNumber(codeReviewWindow?.used_percent) + if (codeReviewUsed !== undefined) { + details.push({ key: "codeReview", value: `${formatPercent(clampPercent(100 - codeReviewUsed))}%` }) + } + + const credits = asRecord(json.credits) + let hasCreditDetail = false + if (credits?.unlimited === true) { + details.push({ key: "credits", value: "unlimited" }) + hasCreditDetail = true + } else { + const creditBalance = asFiniteNumber(credits?.balance) + if (creditBalance !== undefined) { + details.push({ key: "credits", value: `$${creditBalance.toFixed(2)}` }) + hasCreditDetail = true + } else if (individualLimit) { + const remaining = asFiniteNumber(individualLimit.remaining) + const limit = asFiniteNumber(individualLimit.limit) + const amounts = [remaining, limit].filter((value): value is number => value !== undefined) + if (amounts.length > 0) { + details.push({ key: "credits", value: amounts.map(formatCreditAmount).join(" / ") }) + hasCreditDetail = true + } + } + } + + const resetCredits = asFiniteNumber(asRecord(json.rate_limit_reset_credits)?.available_count) + if (resetCredits !== undefined) details.push({ key: "resetCredits", value: String(resetCredits) }) + + if (details.length === 0 || (!hasRateQuota && !individualPercentages && !hasCreditDetail && resetCredits === undefined)) { + throw new BalanceError("EMPTY") + } + + const summaryRemaining = remainingValues.length > 0 ? Math.min(...remainingValues) : undefined + const summary = summaryRemaining === undefined ? undefined : formatPercent(summaryRemaining) + return [{ + currency: "CODEX", + total: summary === undefined ? "0" : `${summary}%`, + display: summary === undefined ? "Codex" : `Codex ${summary}%`, + details, + }] +} + const openaiProvider: BalanceProvider = { id: "openai", name: "OpenAI Codex", @@ -204,49 +391,8 @@ const openaiProvider: BalanceProvider = { if (res.status === 403) throw new BalanceError("403") throw new BalanceError(String(res.status)) } - const json = await res.json() as { - plan_type?: string - rate_limit?: { - primary_window?: { - used_percent?: number - limit_window_seconds?: number - reset_after_seconds?: number - } - secondary_window?: { used_percent?: number } - } - code_review_rate_limit?: { primary_window?: { used_percent?: number } } | null - credits?: { unlimited?: boolean; balance?: number | null } - rate_limit_reset_credits?: { available_count?: number } - } - const used = json.rate_limit?.primary_window?.used_percent - if (typeof used !== "number" || !Number.isFinite(used)) throw new BalanceError("EMPTY") - const remaining = Math.max(0, Math.min(100, 100 - used)) - const percentage = Number.isInteger(remaining) ? remaining.toFixed(0) : remaining.toFixed(1) - const details: BalanceDetail[] = [] - if (json.plan_type) details.push({ key: "plan", value: json.plan_type.toUpperCase() }) - details.push({ key: "used", value: `${used}%` }) - details.push({ key: "remaining", value: `${percentage}%` }) - const primary = json.rate_limit?.primary_window - if (typeof primary?.limit_window_seconds === "number") { - details.push({ key: "window", value: String(primary.limit_window_seconds) }) - } - if (typeof primary?.reset_after_seconds === "number") { - details.push({ key: "reset", value: String(primary.reset_after_seconds) }) - } - const codeReviewUsed = json.code_review_rate_limit?.primary_window?.used_percent - if (typeof codeReviewUsed === "number" && Number.isFinite(codeReviewUsed)) { - details.push({ key: "codeReview", value: `${Math.max(0, 100 - codeReviewUsed)}%` }) - } - if (json.credits?.unlimited) { - details.push({ key: "credits", value: "unlimited" }) - } else if (typeof json.credits?.balance === "number") { - details.push({ key: "credits", value: `$${json.credits.balance.toFixed(2)}` }) - } - const resetCredits = json.rate_limit_reset_credits?.available_count - if (typeof resetCredits === "number") { - details.push({ key: "resetCredits", value: String(resetCredits) }) - } - return [{ currency: "CODEX", total: `${percentage}%`, display: `Codex ${percentage}%`, details }] + const json = await res.json() + return parseOpenAIUsage(json) }, } diff --git a/src/index.tsx b/src/index.tsx index 02b1674..a28cb81 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -527,12 +527,9 @@ function TokenCachePanel(props: { // ── reactive translation (follows langCode signal) ── const t = createT(() => langCode()) - const formatBalanceDetailValue = (detail: BalanceDetail): string => { - if (detail.value === "unlimited") return t("balUnlimited") - if (detail.key !== "window" && detail.key !== "reset") return detail.value - - let remaining = Math.max(0, Math.round(Number(detail.value))) - if (!Number.isFinite(remaining)) return detail.value + const formatBalanceDuration = (seconds: number, fallback = ""): string => { + if (!Number.isFinite(seconds)) return "" + let remaining = Math.max(0, Math.round(seconds)) const days = Math.floor(remaining / 86400) remaining %= 86400 const hours = Math.floor(remaining / 3600) @@ -542,7 +539,20 @@ function TokenCachePanel(props: { if (days > 0) parts.push(`${days}${t("balDay")}`) if (hours > 0 && parts.length < 2) parts.push(`${hours}${t("balHour")}`) if (minutes > 0 && parts.length < 2) parts.push(`${minutes}${t("balMinute")}`) - return parts.join(langCode() === "en" ? " " : "") || t("balResetSoon") + return parts.join(langCode() === "en" ? " " : "") || fallback + } + + const formatBalanceDetailValue = (detail: BalanceDetail): string => { + if (detail.value === "unlimited") return t("balUnlimited") + if (detail.key !== "reset") return detail.value + return formatBalanceDuration(Number(detail.value), t("balResetSoon")) || detail.value + } + + const formatBalanceDetailLabel = (detail: BalanceDetail): string => { + const label = t(BALANCE_DETAIL_LABELS[detail.key]) + if (detail.windowSeconds === undefined) return label + const window = formatBalanceDuration(detail.windowSeconds) + return window ? `${label} (${window})` : label } // ── scan session messages reactively ── @@ -1269,7 +1279,7 @@ function TokenCachePanel(props: { {balanceDetails().map((detail) => ( - {justify(t(BALANCE_DETAIL_LABELS[detail.key]) + ":", formatBalanceDetailValue(detail))} + {justify(formatBalanceDetailLabel(detail) + ":", formatBalanceDetailValue(detail))} ))} diff --git a/tests/codex-balance.test.ts b/tests/codex-balance.test.ts new file mode 100644 index 0000000..2d1eb08 --- /dev/null +++ b/tests/codex-balance.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict" +import { parseOpenAIUsage, type BalanceDetail } from "../src/balance-providers" + +const nowMs = 1_700_000_000_000 +const nowSeconds = nowMs / 1000 + +function getDetails(raw: unknown): BalanceDetail[] { + return parseOpenAIUsage(raw, nowMs)[0]?.details ?? [] +} + +function findDetail(details: BalanceDetail[], key: BalanceDetail["key"], windowSeconds?: number): BalanceDetail | undefined { + return details.find((detail) => detail.key === key && detail.windowSeconds === windowSeconds) +} + +const managedDetails = getDetails({ + plan_type: "team", + rate_limit: null, + credits: { balance: null, unlimited: false }, + spend_control: { + individual_limit: { + limit: "1000", + used: "36.7974872589", + remaining: "963.2025127411", + used_percent: 4, + remaining_percent: 96, + reset_at: nowSeconds + 3600, + }, + }, +}) +assert.equal(findDetail(managedDetails, "used")?.value, "4%") +assert.equal(findDetail(managedDetails, "remaining")?.value, "96%") +assert.equal(findDetail(managedDetails, "credits")?.value, "963.2 / 1000") +assert.equal(findDetail(managedDetails, "reset")?.value, "3600") + +const multiWindow = parseOpenAIUsage({ + rate_limit: { + secondary_window: { + used_percent: 90, + limit_window_seconds: 604800, + reset_at: nowSeconds + 7200, + }, + primary_window: { + used_percent: 15, + limit_window_seconds: 18000, + reset_after_seconds: 1800, + }, + }, +}, nowMs)[0] +assert.equal(multiWindow.display, "Codex 10%") +const multiDetails = multiWindow.details ?? [] +assert.deepEqual( + multiDetails.filter((detail) => detail.key === "remaining").map((detail) => [detail.windowSeconds, detail.value]), + [[18000, "85%"], [604800, "10%"]], +) +assert.equal(findDetail(multiDetails, "reset", 18000)?.value, "1800") +assert.equal(findDetail(multiDetails, "reset", 604800)?.value, "7200") + +assert.throws(() => parseOpenAIUsage({ rate_limit: null, credits: { balance: null } }, nowMs), /EMPTY/) + +console.log("Codex balance shape tests passed") From 9794ae4ddda245d8c7aa3c52582845674798649d Mon Sep 17 00:00:00 2001 From: huahai0202 <2023217276@mail.hfut.edu.cn> Date: Sun, 23 Aug 2026 22:38:59 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20Codex=20Go=20?= =?UTF-8?q?=E9=85=8D=E9=A2=9D=E6=88=AA=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/codex-go-usage.png | Bin 0 -> 9371 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 assets/codex-go-usage.png diff --git a/assets/codex-go-usage.png b/assets/codex-go-usage.png new file mode 100644 index 0000000000000000000000000000000000000000..1ae4d19e4af2b030b63990c3af10e864351d667a GIT binary patch literal 9371 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 BlUD!$ literal 0 HcmV?d00001