diff --git a/assets/codex-go-usage.png b/assets/codex-go-usage.png new file mode 100644 index 0000000..1ae4d19 Binary files /dev/null and b/assets/codex-go-usage.png differ 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 4cac687..0656391 100644 --- a/src/balance-providers.ts +++ b/src/balance-providers.ts @@ -6,6 +6,16 @@ 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 + windowSeconds?: number } /** provider 统一错误:message 即错误码(401/403/EMPTY/…),显示层直接展示。 */ @@ -149,8 +159,245 @@ 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 +} + +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", + 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() + return parseOpenAIUsage(json) + }, +} + /** 已注册的 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..a28cb81 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,34 @@ function TokenCachePanel(props: { // ── reactive translation (follows langCode signal) ── const t = createT(() => langCode()) + 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) + 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" ? " " : "") || 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 ── // SolidJS createMemo re-evaluates whenever the underlying // api.state.session state changes — no event listener needed. @@ -721,6 +803,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 +838,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 +988,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 +1265,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(formatBalanceDetailLabel(detail) + ":", formatBalanceDetailValue(detail))} + + ))} + + + + {sep()} + + {justify(t("balTotal"), formatBalanceText(balanceState().data!, balanceCurrency(), exchangeRate()))} + + 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")