Skip to content

剩余 5h:98%(9m):把 OpenCode 配额压成一行 ​

我用 OpenCode Go 的额度是按窗口滚动的。5 小时一个窗口,7 天一个窗口,30 天一个窗口。接口会告诉你每个窗口用了多少,还会告诉你什么时候重置。

问题是,它给的是已用百分比和重置时间。我想看的是「还剩多少」和「还有多久」。每次打开网页换算一遍,太累。

于是我写了个脚本,只输出一行:

txt
$ uv run --no-project opencode_usage.py
剩余 5h:98%(9m) 7d:99%(1d12h) 1m:100%(29d)

一眼就知道三个窗口的状态。快用完的时候,百分比会掉,括号里的倒计时会变短。

完整代码 ​

不到两百行,先看全貌。

python
#!/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()

配额表是数据,不是代码 ​

三个窗口的展示方式不同,但我没有写三个函数:

python
QUOTAS = (
    ("5h", "rolling", (("h", 3600), ("m", 60))),
    ("7d", "weekly", (("d", 86400), ("h", 3600))),
    ("1m", "monthly", (("d", 86400),)),
)

每项是「标签、接口字段、时间单位」。5 小时窗口用小时和分钟,7 天窗口用天和小时,30 天窗口只显示天。

主流程直接遍历:

python
    quotas = " ".join(
        format_quota(label, key, time_units, usage) for label, key, time_units in QUOTAS
    )
    print(f"剩余 {quotas}")

要加新窗口,改一行元组就行,不用动逻辑。

倒计时会自己降级单位 ​

这是我最喜欢的一段:

python
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 堆叠,就是一个指针往右挪,挪到第一个有意义的单位,然后按顺序把剩下的单位全部格式化。加一个「秒」单位也不用改代码,往元组里一塞就生效。

被限流了就别装还能用 ​

python
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 ​

这个坑我踩过。

python
USER_AGENT = "opencode-usage/1.0"  # 接口会拒绝 urllib 默认 UA(403)

Python 的 urllib 默认发送 Python-urllib/3.x,这个接口看到就返回 403。不是限流,不是密钥错,就是单纯不认这个 UA。

我一开始以为是 key 的问题,换了三次密钥才想到看请求头。现在这行注释就是给未来的自己看的:别再以为是密钥错了。

缓存和另一个脚本共用思路 ​

python
CACHE_PATH = os.path.join(tempfile.gettempdir(), "opencode_usage_cache.json")
CACHE_TTL_SECONDS = 300

五分钟。和 DeepSeek 余额那个脚本一模一样的策略:临时目录、时间戳、过期就重新请求。

这两个脚本是同一个下午写的,缓存逻辑几乎是复制过来的。我没有抽成公共模块——两个独立脚本,各自三十行缓存代码,比为了复用去建一个包、写 pyproject.toml、处理导入路径要省事得多。

重复两次不算重复。第三次再抄的时候,我再考虑抽。

和状态栏一起 ​

状态栏里一行,和余额、模型信息排在一起。5 小时窗口是最需要盯的,因为它滚动得最快,一不留神就用完了。

这个脚本总共不到两百行,没有依赖,跑一次不到一秒。它做的事情很小:把接口给的「已用 2%,还有 9 分钟重置」翻译成「剩余 98%,9 分钟后满血」。

但就是这个小翻译,让我不用每天打开网页看三次。

Nine and AI — Licensed under the MIT License