剩余 5h:98%(9m):把 OpenCode 配额压成一行
我用 OpenCode Go 的额度是按窗口滚动的。5 小时一个窗口,7 天一个窗口,30 天一个窗口。接口会告诉你每个窗口用了多少,还会告诉你什么时候重置。
问题是,它给的是已用百分比和重置时间。我想看的是「还剩多少」和「还有多久」。每次打开网页换算一遍,太累。
于是我写了个脚本,只输出一行:
$ uv run --no-project opencode_usage.py
剩余 5h:98%(9m) 7d:99%(1d12h) 1m:100%(29d)一眼就知道三个窗口的状态。快用完的时候,百分比会掉,括号里的倒计时会变短。
完整代码
不到两百行,先看全貌。
#!/usr/bin/env python3
"""查询并打印 OpenCode Go 的剩余用量。
用量数据来自 https://opencode.ai/zen/go/v1/usage,
API key 从 $SECRETS49/.env 的 opencode-go-key 读取。
输出单行:剩余 5h:98%(9m) 7d:99%(1d12h) 1m:100%(29d)
查询结果缓存在系统临时目录,5 分钟内重复运行不再请求接口。
"""
import json
import os
import sys
import tempfile
import time
import urllib.request
from datetime import datetime
USAGE_URL = "https://opencode.ai/zen/go/v1/usage"
USER_AGENT = "opencode-usage/1.0" # 接口会拒绝 urllib 默认 UA(403)
REQUEST_TIMEOUT_SECONDS = 30
CACHE_PATH = os.path.join(tempfile.gettempdir(), "opencode_usage_cache.json")
CACHE_TTL_SECONDS = 300
SECRETS_DIR_ENV = "SECRETS49"
API_KEY_NAME = "opencode-go-key"
# 每项配额:(展示标签, 接口字段名, 时间单位(从大到小))
QUOTAS = (
("5h", "rolling", (("h", 3600), ("m", 60))),
("7d", "weekly", (("d", 86400), ("h", 3600))),
("1m", "monthly", (("d", 86400),)),
)
# ------------------------------------------------------------
# 配置与请求
# ------------------------------------------------------------
def read_api_key() -> str:
"""从 $SECRETS49/.env 读取 opencode-go-key,缺失时退出并说明原因。"""
secrets_dir = os.environ.get(SECRETS_DIR_ENV)
if not secrets_dir:
sys.exit(f"环境变量 {SECRETS_DIR_ENV} 未设置")
env_file = os.path.join(secrets_dir, ".env")
try:
with open(env_file, encoding="utf-8") as file:
lines = file.read().splitlines()
except OSError:
sys.exit(f"无法读取 {env_file}")
for line in lines:
name, separator, value = line.partition("=")
if separator and name.strip() == API_KEY_NAME:
return value.strip().strip("'\"")
sys.exit(f"{env_file} 里没有 {API_KEY_NAME}")
def fetch_usage(api_key: str) -> dict:
"""请求用量接口,返回响应中的 usage 对象。"""
request = urllib.request.Request(
USAGE_URL,
headers={
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
"User-Agent": USER_AGENT,
},
)
try:
with urllib.request.urlopen(
request, timeout=REQUEST_TIMEOUT_SECONDS
) as response:
payload = json.load(response)
except (OSError, ValueError):
sys.exit("查询 OpenCode 用量失败:网络或响应异常")
usage = payload.get("usage") if isinstance(payload, dict) else None
if not isinstance(usage, dict) or not usage:
sys.exit("响应里没有 OpenCode 用量数据")
return usage
# ------------------------------------------------------------
# 缓存
# ------------------------------------------------------------
def load_cached_usage() -> dict | None:
"""返回 5 分钟内的缓存用量,没有或已过期时返回 None。"""
try:
with open(CACHE_PATH, encoding="utf-8") as file:
cached = json.load(file)
except (OSError, ValueError):
return None
if not isinstance(cached, dict):
return None
fetched_at = cached.get("fetched_at")
if not isinstance(fetched_at, (int, float)):
return None
if time.time() - fetched_at > CACHE_TTL_SECONDS:
return None
usage = cached.get("usage")
return usage if isinstance(usage, dict) and usage else None
def save_cached_usage(usage: dict) -> None:
try:
with open(CACHE_PATH, "w", encoding="utf-8") as file:
json.dump({"fetched_at": time.time(), "usage": usage}, file)
except OSError:
print(f"警告:无法写入缓存 {CACHE_PATH}", file=sys.stderr)
# ------------------------------------------------------------
# 格式化
# ------------------------------------------------------------
def remaining_percent(quota: object) -> str:
"""把接口给的已用百分比换算成剩余百分比。"""
if not isinstance(quota, dict):
return "--"
if quota.get("status") == "rate-limited":
return "0%"
try:
used = float(quota["percent"]) # type: ignore[arg-type]
except (KeyError, TypeError, ValueError):
return "--"
used = min(100.0, max(0.0, used))
return f"{int(100 - used + 0.5)}%"
def seconds_until_reset(quota: dict) -> int | None:
"""距离 resetsAt 的剩余秒数,无法解析时返回 None。"""
resets_at = quota.get("resetsAt")
if not isinstance(resets_at, str):
return None
try:
reset_timestamp = datetime.fromisoformat(resets_at).timestamp()
except (TypeError, ValueError):
return None
return max(0, int(reset_timestamp - time.time()))
def format_time_left(quota: object, time_units: tuple[tuple[str, int], ...]) -> str:
"""按给定的时间单位格式化剩余时间,最高位为零时自动降级。"""
if not isinstance(quota, dict):
return "--"
seconds = seconds_until_reset(quota)
if seconds is None:
return "--"
# 跳过值为零的高位单位,例如 90 秒显示 1m 而不是 0h1m
start = 0
while start < len(time_units) - 1 and seconds < time_units[start][1]:
start += 1
parts = []
for unit_name, unit_seconds in time_units[start:]:
parts.append(f"{seconds // unit_seconds}{unit_name}")
seconds %= unit_seconds
return "".join(parts)
def format_quota(
label: str,
key: str,
time_units: tuple[tuple[str, int], ...],
usage: dict,
) -> str:
"""输出一项配额,例如 5h:98%(9m)。"""
quota = usage.get(key)
return f"{label}:{remaining_percent(quota)}({format_time_left(quota, time_units)})"
# ------------------------------------------------------------
# 入口
# ------------------------------------------------------------
def main() -> None:
usage = load_cached_usage()
if usage is None:
usage = fetch_usage(read_api_key())
save_cached_usage(usage)
quotas = " ".join(
format_quota(label, key, time_units, usage) for label, key, time_units in QUOTAS
)
print(f"剩余 {quotas}")
if __name__ == "__main__":
main()配额表是数据,不是代码
三个窗口的展示方式不同,但我没有写三个函数:
QUOTAS = (
("5h", "rolling", (("h", 3600), ("m", 60))),
("7d", "weekly", (("d", 86400), ("h", 3600))),
("1m", "monthly", (("d", 86400),)),
)每项是「标签、接口字段、时间单位」。5 小时窗口用小时和分钟,7 天窗口用天和小时,30 天窗口只显示天。
主流程直接遍历:
quotas = " ".join(
format_quota(label, key, time_units, usage) for label, key, time_units in QUOTAS
)
print(f"剩余 {quotas}")要加新窗口,改一行元组就行,不用动逻辑。
倒计时会自己降级单位
这是我最喜欢的一段:
def format_time_left(quota: object, time_units: tuple[tuple[str, int], ...]) -> str:
seconds = seconds_until_reset(quota)
if seconds is None:
return "--"
# 跳过值为零的高位单位,例如 90 秒显示 1m 而不是 0h1m
start = 0
while start < len(time_units) - 1 and seconds < time_units[start][1]:
start += 1
parts = []
for unit_name, unit_seconds in time_units[start:]:
parts.append(f"{seconds // unit_seconds}{unit_name}")
seconds %= unit_seconds
return "".join(parts)5 小时窗口如果只剩 90 秒,90 小于 3600,跳过小时,直接显示 1m。不会出现 0h1m 这种看着像还没开始的东西。
7 天窗口剩 36 小时,显示 1d12h;只剩 50 分钟,跳过天,显示 50m。
这套逻辑没有 if-else 堆叠,就是一个指针往右挪,挪到第一个有意义的单位,然后按顺序把剩下的单位全部格式化。加一个「秒」单位也不用改代码,往元组里一塞就生效。
被限流了就别装还能用
def remaining_percent(quota: object) -> str:
if not isinstance(quota, dict):
return "--"
if quota.get("status") == "rate-limited":
return "0%"
try:
used = float(quota["percent"]) # type: ignore[arg-type]
except (KeyError, TypeError, ValueError):
return "--"
used = min(100.0, max(0.0, used))
return f"{int(100 - used + 0.5)}%"接口有个 status 字段,被限流时是 rate-limited。这种情况下即使 percent 显示 80,实际也用不了了,所以直接显示 0%。
不做这个判断,就会出现「剩余 80% 但请求全部 429」的诡异状态。
percent 的换算也做了钳制。接口理论上给 0 到 100,但接口不可信,负数或者 120 都可能出现。先夹到区间里再算,输出永远不会是 -20% 或 120%。
int(100 - used + 0.5) 是四舍五入。used=0.6 时返回 99%,不会因为浮点截断变成 100%;used=99.6 时返回 0%,表示额度基本见底。
User-Agent 不写就被 403
这个坑我踩过。
USER_AGENT = "opencode-usage/1.0" # 接口会拒绝 urllib 默认 UA(403)Python 的 urllib 默认发送 Python-urllib/3.x,这个接口看到就返回 403。不是限流,不是密钥错,就是单纯不认这个 UA。
我一开始以为是 key 的问题,换了三次密钥才想到看请求头。现在这行注释就是给未来的自己看的:别再以为是密钥错了。
缓存和另一个脚本共用思路
CACHE_PATH = os.path.join(tempfile.gettempdir(), "opencode_usage_cache.json")
CACHE_TTL_SECONDS = 300五分钟。和 DeepSeek 余额那个脚本一模一样的策略:临时目录、时间戳、过期就重新请求。
这两个脚本是同一个下午写的,缓存逻辑几乎是复制过来的。我没有抽成公共模块——两个独立脚本,各自三十行缓存代码,比为了复用去建一个包、写 pyproject.toml、处理导入路径要省事得多。
重复两次不算重复。第三次再抄的时候,我再考虑抽。
和状态栏一起
状态栏里一行,和余额、模型信息排在一起。5 小时窗口是最需要盯的,因为它滚动得最快,一不留神就用完了。
这个脚本总共不到两百行,没有依赖,跑一次不到一秒。它做的事情很小:把接口给的「已用 2%,还有 9 分钟重置」翻译成「剩余 98%,9 分钟后满血」。
但就是这个小翻译,让我不用每天打开网页看三次。