/** * OpenCode Go usage / 限额 解析与展示。 * 零 pi 依赖的纯业务模块。 * * 实测:GET https://opencode.ai/zen/go/v1/usage * { "usage": { * "rolling": { "status": "ok", "percent": 11, "resetsAt": "..." }, * "weekly": { "status": "ok", "percent": 4, "resetsAt": "..." }, * "monthly": { "status": "ok", "percent": 2, "resetsAt": "..." } * } } * 只返回 percent + resetsAt(不返回美元明细)。 */ export const OPENCODE_GO_USAGE_URL = "https://opencode.ai/zen/go/v1/usage"; export const OPENCODE_GO_USAGE_TIMEOUT_MS = 10_000; export type UsageWindowStatus = "ok" | "rate-limited" | "unknown"; export interface UsageWindow { /** rolling(5h) / weekly / monthly */ name: string; status: UsageWindowStatus; percent?: number; resetsAt?: string; startAt?: string; endAt?: string; } export interface UsageResponse { windows: UsageWindow[]; } function isRecord(v: unknown): v is Record { return typeof v === "object" && v !== null; } function readString(v: Record, keys: string[]): string | undefined { for (const k of keys) { const x = v[k]; if (typeof x === "string") return x; } return undefined; } function readNumber(v: Record, keys: string[]): number | undefined { for (const k of keys) { const x = v[k]; if (typeof x === "number") return x; if (typeof x === "string" && x.trim() !== "" && !isNaN(Number(x))) return Number(x); } return undefined; } export function parseUsageWindow(name: string, value: unknown): UsageWindow | undefined { if (!isRecord(value)) return undefined; const statusRaw = readString(value, ["status", "state"]); const status: UsageWindowStatus = statusRaw === "ok" || statusRaw === "active" ? "ok" : statusRaw === "rate-limited" ? "rate-limited" : "unknown"; return { name, status, ...(readNumber(value, ["percent", "usagePercent", "usage_percent"]) !== undefined ? { percent: readNumber(value, ["percent", "usagePercent", "usage_percent"]) } : {}), ...(readString(value, ["resetsAt", "resetAt", "resets_at", "reset_at"]) !== undefined ? { resetsAt: readString(value, ["resetsAt", "resetAt", "resets_at", "reset_at"]) } : {}), ...(readString(value, ["startAt", "start_at"]) !== undefined ? { startAt: readString(value, ["startAt", "start_at"]) } : {}), ...(readString(value, ["endAt", "end_at"]) !== undefined ? { endAt: readString(value, ["endAt", "end_at"]) } : {}), }; } /** 解析 usage 响应,兼容两种形态:{usage:{name:window}} 与 {windows:[...]}。 */ export function parseUsage(value: unknown): UsageResponse | undefined { if (!isRecord(value)) return undefined; const windows: UsageWindow[] = []; if (Array.isArray(value.windows)) { for (const w of value.windows) { if (!isRecord(w)) continue; const name = readString(w, ["name", "window", "period", "label"]) ?? "window"; const parsed = parseUsageWindow(name, w); if (parsed) windows.push(parsed); } return { windows }; } if (!isRecord(value.usage)) return undefined; for (const [name, window] of Object.entries(value.usage)) { const parsed = parseUsageWindow(name, window); if (parsed) windows.push(parsed); } return { windows }; } export interface FetchLike { (url: string, init: { method: string; headers: Record; signal?: AbortSignal; }): Promise<{ ok: boolean; status?: number; json(): Promise }>; } export interface UsageFetchResult { ok: boolean; usage?: UsageResponse; message?: string; } export async function fetchUsage( bearerToken: string | undefined, fetchImpl: FetchLike = fetch as unknown as FetchLike, timeoutMs = OPENCODE_GO_USAGE_TIMEOUT_MS, ): Promise { if (!bearerToken) return { ok: false, message: "No API key configured." }; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetchImpl(OPENCODE_GO_USAGE_URL, { method: "GET", headers: { Accept: "application/json", Authorization: `Bearer ${bearerToken}` }, signal: controller.signal, }); if (!response.ok) { return { ok: false, message: `Usage request failed with HTTP ${response.status ?? "?"}.` }; } const usage = parseUsage(await response.json()); if (!usage) return { ok: false, message: "Usage response did not match the expected OpenCode Go shape." }; return { ok: true, usage }; } catch (err) { if ((err as Error)?.name === "AbortError") return { ok: false, message: "Usage request timed out (10s)." }; return { ok: false, message: "Usage request failed." }; } finally { clearTimeout(timer); } } /** 判断 usage 里是否有被限流的窗口(配额耗尽信号)。 */ export function hasRateLimitedWindow(usage: UsageResponse): boolean { return usage.windows.some((w) => w.status === "rate-limited"); } export function formatReset(iso?: string): string { if (!iso) return "?"; const t = Date.parse(iso); if (isNaN(t)) return iso; return new Date(t).toLocaleString("zh-CN", { hour12: false }); } export function formatUsageWindow(window: UsageWindow): string { const parts: string[] = []; if (window.percent !== undefined) parts.push(`${Math.round(window.percent)}% used`); if (window.status === "rate-limited") parts.push("RATE-LIMITED"); const reset = window.resetsAt ? `reset ${formatReset(window.resetsAt)}` : window.endAt ? `end ${formatReset(window.endAt)}` : ""; if (reset) parts.push(reset); return `${window.name}: ${parts.join(", ") || "n/a"}`; } // --------------------------------------------------------------------------- // 多窗口进度条渲染(widget / footer 用) // --------------------------------------------------------------------------- /** 生成一段长度为 width 的 ASCII 进度条。percent 0-100。 */ export function progressBar(percent: number, width = 10): string { const p = Math.max(0, Math.min(100, percent)); const filled = Math.round((p / 100) * width); return "▓".repeat(filled) + "░".repeat(width - filled); } export interface WindowBarConf { /** 警告阈值:任意窗口 percent >= 该值则标记 ⚠ */ warnThreshold: number; /** reset 前提前量(天):距 reset 小于等于该值则进入预警 */ resetWarnDays: number; } export const DEFAULT_WINDOW_BAR_CONF: WindowBarConf = { warnThreshold: 80, resetWarnDays: 2 }; /** 进度条状态分级。 */ export type LevelStatus = "ok" | "warn" | "critical" | "limited"; /** 根据 percent 与 reset 时间判定窗口状态(含“提前 resetWarnDays 天警告”)。 */ export function windowStatus(w: UsageWindow | undefined, now: number, conf: WindowBarConf = DEFAULT_WINDOW_BAR_CONF): LevelStatus { if (!w) return "ok"; if (w.status === "rate-limited") return "limited"; const pct = w.percent ?? 0; const resetAt = w.resetsAt; let days = Infinity; if (resetAt) { const t = Date.parse(resetAt); if (!isNaN(t)) days = (t - now) / 86_400_000; } if (days <= 0 || pct >= 95) return "critical"; if (pct >= conf.warnThreshold || days <= conf.resetWarnDays) return "warn"; return "ok"; } /** ANSI 真彩色码(widget/footer 嵌入用)。 */ export const ANSI = { ok: "\u001b[38;2;90;200;120m", warn: "\u001b[38;2;255;200;60m", critical: "\u001b[38;2;255;80;60m", limited: "\u001b[38;2;255;40;40m", dim: "\u001b[38;2;120;120;130m", reset: "\u001b[0m", }; /** 包一层 ANSI 颜色(若带 color 标记)。 */ export function paint(text: string, status: LevelStatus, colored: boolean): string { if (!colored) return text; return `${ANSI[status]}${text}${ANSI.reset}`; } /** 一个 key 的三行进度条(rolling / weekly / monthly)。返回 string[],每行一条。 */ export function keyWindowBars( keyName: string, windows: UsageWindow[], opts?: { active?: boolean; conf?: WindowBarConf; colored?: boolean; now?: number }, ): string[] { const { active = false, conf = DEFAULT_WINDOW_BAR_CONF, colored = false, now = Date.now() } = opts ?? {}; const byName = new Map(); for (const w of windows) { const stem = (w.name || "").toLowerCase(); if (stem.includes("roll")) byName.set("rolling", w); else if (stem.includes("week")) byName.set("weekly", w); else if (stem.includes("month")) byName.set("monthly", w); else byName.set(w.name, w); } const limited = windows.some((w) => w.status === "rate-limited"); const title = paint( `${active ? "★" : "·"} ${keyName}${active ? " (active)" : ""}${limited ? " ⛔LIMITED" : ""}`, limited ? "limited" : "ok", colored, ); return [ title, windowBarRow("R", "5h", byName.get("rolling"), conf, colored, now), windowBarRow("W", "wk", byName.get("weekly"), conf, colored, now), windowBarRow("M", "mo", byName.get("monthly"), conf, colored, now), ]; } function windowBarRow(label: string, shortName: string, w: UsageWindow | undefined, conf: WindowBarConf, colored: boolean, now: number): string { if (!w) return `${label} ${shortName} n/a`; const pct = w.percent ?? 0; const status = windowStatus(w, now, conf); const mark = status !== "ok" ? " ⚠" : ""; const pulse = w.status === "rate-limited" ? " [LIMITED]" : ""; const reset = w.resetsAt ? formatResetShort(w.resetsAt) : w.endAt ? formatResetShort(w.endAt) : "--"; const body = `${label} ${shortName} ${progressBar(pct)} ${String(Math.round(pct)).padStart(3)}%${mark}${pulse} reset ${reset}`; return paint(body, status, colored); } /** 紧凑时间:今天只显示 HH:MM,否则 MM-DD。 */ export function formatResetShort(iso?: string): string { if (!iso) return "--"; const t = Date.parse(iso); if (isNaN(t)) return "--"; const d = new Date(t); const now = new Date(); if (d.toDateString() === now.toDateString()) { return d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }); } return `${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } /** footer 单行紧凑摘要:仅当前活跃 key 的 monthly 百分比。支持变色。 */ export function footerSummary(keyName: string, windows: UsageWindow[], opts?: { conf?: WindowBarConf; colored?: boolean; now?: number }): string { const { conf = DEFAULT_WINDOW_BAR_CONF, colored = false, now = Date.now() } = opts ?? {}; const m = windows.find((w) => (w.name || "").toLowerCase().includes("month")); const pct = m?.percent ?? 0; const status = windows.length ? windowStatus(m, now, conf) : "ok"; const days = m?.resetsAt ? Math.floor((Date.parse(m.resetsAt) - now) / 86_400_000) : Infinity; const countdown = Number.isFinite(days) && days <= conf.resetWarnDays ? ` ⏳${Math.max(0, Math.round(days))}d` : ""; const body = `ocgo ${keyName} M${Math.round(pct)}%${countdown}${status !== "ok" ? " ⚠" : ""}`; return paint(body, status, colored); }