8000 feat(collector): keep Paseo quota fresh and use OpenCode Go plan limits for 0.11.1 by InfinityPacer · Pull Request #46 · InfinityPacer/meterleaf · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
8000
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## [未发布]

## [0.11.1] - 2026-10-23

- 通过 Paseo 使用 Claude Code 时额度也能保持更新。Paseo 不运行状态栏命令,新增的 `scripts/paseo-claude-quota.sh` 作为 Paseo 的 Claude 启动命令,从 Claude Code 的输出流里抄下 5 小时与 7 天额度,写进采集器读取的状态栏额度缓存,不读凭据、不发额外请求,用法见[本机采集器](docs/collector.md#通过-paseo-使用-claude-code)。
- OpenCode Go 账户可在 `modelAccounts` 里设置 `"plan": "go"` 或 `"go-plus"`,徽标显示 Go 或 Go Plus。设了档位后,「7d 预估」和「1m 预估」直接显示套餐公布的上限,例如 Go 档 DeepSeek V4.1 Flash 每周 40 美元、每月 80 美元,不再按取整后的百分比外推。
- 月额度窗口改称「1m」,与 5h、7d 的写法一致。
- 服务端与网页改动需要升级服务端镜像,本机采集器不用替换。数据库结构未变,升级无需先备份。

## [0.11.0] - 2026-10-23

- 本机采集器按模型名分账户。Claude Code 经 OpenCode Go 等兼容接口使用的非 Claude 模型(例如 DeepSeek)不再按登录时间记到 Claude 账户,默认归到「其他模型」,也可以在采集器 `config.json` 的 `modelAccounts` 里映射到独立账户,例如「OpenCode Go」。升级后,以前记错的用量会自动改归并补推一次,Claude 账户的用量和额度预估随之回落到真实值。
Expand Down
43 changes: 41 additions & 2 deletions docs/collector.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,16 @@ Claude Code 也可以通过兼容接口使用别家的模型,例如经 OpenCod
"id": "opencode-go",
"name": "OpenCode Go",
"platform": "opencode-go",
"kind": "subscription"
"kind": "subscription",
"plan": "go"
}
]
}
```

- `match` 是模型名,`*` 匹配任意字符。规则按顺序取第一条命中的,所以也能把某个 `claude-` 模型单独分出去。
- `id` 是账户在这台电脑来源里的标识,只能用字母、数字、`-` 和 `_`。改 `name` 只改显示名;改 `id` 会把历史用量整体移到新账户。
- `kind` 可填 `subscription`、`api` 或 `unknown`,省略时为 `unknown`;`plan` 可选,省略时不显示套餐。
- `kind` 可填 `subscription`、`api` 或 `unknown`,省略时为 `unknown`。`plan` 可选,省略时不显示套餐;`platform` 为 `opencode-go` 时填 `go` 或 `go-plus`,页面显示为 Go 或 Go Plus。

保存后下一次同步生效,不需要重启后台任务。映射有变化时,采集器会把已经推送过的用量按新映射重新归属并补推一次,服务端的报表随之更新。`meterleaf-collector status` 会列出当前的映射。

Expand Down Expand Up @@ -118,6 +119,8 @@ alias meterleaf-collector="/Applications/Meterleaf.app/Contents/MacOS/meterleaf-

## 状态栏额度缓存(可选)

`~/.claude.json` 里的额度缓存只在打开用量面板时刷新,平时很快过期。更新的额度只在 Claude Code 运行时出现:在终端里交给状态栏命令,在无界面模式下写进输出流。采集器不运行 Claude Code,也不调用额度接口,所以它只约定一个额度文件,由你按自己使用 Claude Code 的方式写出这 10000 个文件。仓库提供两种写法:终端用户用状态栏脚本,通过 Paseo 使用的见[下一节](#通过-paseo-使用-claude-code)。其他工具可以照文件格式自己实现,只要数据来自 Claude Code 本身给出的额度,不要用登录令牌另行请求额度接口。

Claude Code 每一轮都会把最新的 5 小时与 7 天额度传给状态栏命令。如果你的状态栏脚本把它写成文件,采集器可以读取这个文件,额度会比 `~/.claude.json` 里的缓存新得多。

没有现成状态栏脚本时,可使用仓库中的 `scripts/claude-statusline.sh`(需要 Bash 与 `jq`)。在 Claude Code 的 `~/.claude/settings.json` 中添加下面一项,将路径改为脚本的实际绝对路径;如果已配置状态栏,请把额度缓存逻辑整合进现有脚本,不要覆盖原命令:
Expand Down Expand Up @@ -147,6 +150,42 @@ meterleaf-collector statusline-cache ~/.cache/claude-statusline/rate-limits.tsv

采集器同样只读这个文件。文件里没有账户和采样时间,采集器把文件修改时间当作采样时间,并按那一刻观察到的登录账户归属;切换账户前后无法判断时跳过这次采样。Meterleaf 对同一账户和窗口使用采样时间最新的一份,两个来源不会相加。格式不对或窗口名不认识的行会被跳过,其余行照常使用,跳过时采集日志会记一行说明。停用时执行 `meterleaf-collector statusline-cache off`。

## 通过 Paseo 使用 Claude Code

Paseo 以无界面模式启动 Claude Code,状态栏命令不会执行,额度缓存因此停在最后一次在终端使用的时候,页面上的 5 小时窗口长期显示「等待更新」。无界面模式下,Claude Code 每次请求后会在输出流里给出同样的 5 小时与 7 天额度,Paseo 收到后并不保存。仓库里的 `scripts/paseo-claude-quota.sh` 包在 Claude Code 外面,参数和输入原样交给它,Paseo 收到的输出不变,脚本只把其中的额度写进上一节的额度文件。

先把脚本复制到不随仓库分支变化的位置,Paseo 每次启动 Claude 代理都要用到它,文件不在时代理无法启动。

```sh
install -m 755 scripts/paseo-claude-quota.sh ~/.local/bin/meterleaf-paseo-claude
```

再在 `~/.paseo/config.json` 中把 Claude 的启动命令换成它(需要 `jq`),执行 `paseo reload`。之后新启动的 Claude 代理生效,已在运行的代理要重新开始才会经过脚本。

```json
{
"agents": {
"providers": {
"claude": {
"command": [
"/absolute/path/to/meterleaf/scripts/paseo-claude-quota.sh"
],
"env": {
"METERLEAF_STATUSLINE_CACHE": "/Users/you/.cache/claude-statusline/rate-limits.tsv"
}
}
}
}
}
```

- `METERLEAF_STATUSLINE_CACHE` 填 `meterleaf-collector statusline-cache` 配置的同一个路径,省略时用采集器数据目录里的默认文件。终端里的状态栏脚本可以同时写这个文件,采集器取修改时间最新的一份。
- Claude Code 不在 `~/.local/bin/claude` 时,用 `METERLEAF_CLAUDE_BIN` 指定实际位置。
- 设置了 `ANTHROPIC_BASE_URL`、Bedrock 或 Vertex 的代理(例如经 OpenCode Go 使用其他模型)不属于 Claude 订阅,脚本直接运行 Claude Code,不写额度。
- 输出流里没有 Fable 周额度,Fable 仍然只来自 `~/.claude.json` 的缓存。

脚本不读取登录凭据,也不发出额外请求。Paseo 自带的用量面板会用 Claude Code 的登录令牌请求 Anthropic 的额度接口,不要把那份结果转给采集器。

## 日常查看与排障

`meterleaf-collector status` 显示同步进度、待发送数量、最近一次成功或失败的原因,以及账户归属情况,不会显示密钥。
Expand Down
4 changes: 3 additions & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,9 @@ METERLEAF_OPENCODE_GO_KEY=<OpenCode Go 的 API Key>

账户标识要和采集器 `modelAccounts` 里的 `id` 一致。改完后按[写入密钥](#写入密钥)一节的启动命令重建容器。

服务端只在有页面打开时读取额度,页面在线期间最多每 2 分钟读一次,没人看时不访问 Go。读取失败只在日志里记 `opencode_go.quota_failed` 和状态码,不记录 Key;Key 失效或被拒时页面保留上次读到的额度,直到它的周期结束。Go 的额度上限按美元计,月额度从订阅日起算,页面在周额度之后显示「月」窗口。
服务端只在有页面打开时读取额度,页面在线期间最多每 2 分钟读一次,没人看时不访问 Go。读取失败只在日志里记 `opencode_go.quota_failed` 和状态码,不记录 Key;Key 失效或被拒时页面保留上次读到的额度,直到它的周期结束。页面在周额度之后显示「1m」窗口。

Go 的额度按美元计,而且每个模型各有月上限,Go Plus 的上限更高,具体见 [OpenCode Go 文档](https://opencode.ai/docs/go/)。5 小时、每周、每月三个窗口分别是月上限的 20%、50% 和 100%,请求按该模型的 Token 单价计费,再折算成所在窗口的百分比。以 DeepSeek V4.1 Flash 在 Go 档为例,月上限 80 美元,每周 40 美元,5 小时 16 美元。5 小时窗口滚动计算,周窗口在每周一 00:00 UTC 重置,月窗口从订阅日起算。在采集器 `modelAccounts` 里设了 `plan`(`go` 或 `go-plus`)时,「7d 预估」和「1m 预估」直接显示套餐上限(按本周期各模型的花费比例折算),不再外推。本周期用了上限表里没有的模型或没设档位时,仍按百分比外推,周期初期误差较大。

## GitHub 登录

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "meterleaf",
"version": "0.11.0",
"version": "0.11.1",
"private": true,
"type": "module",
"license": "Apache-2.0",
Expand Down
41 changes: 41 additions & 0 deletions scripts/paseo-claude-quota.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/bin/bash
# Paseo 的 Claude Code 启动包装:参数原样交给 claude,只从输出流里抄下额度事件。
# Paseo 以无界面模式运行 Claude Code,状态栏命令不会执行;但每次请求后 Claude Code 会在输出流里
# 发出 rate_limit_event,内容来自这次请求的响应头,与状态栏拿到的 5 小时、7 天额度相同。
# 脚本把它写成采集器读取的状态栏额度缓存,不读凭据、不发额外请求,也不改动 Paseo 收到的输出。
set -u
real="${METERLEAF_CLAUDE_BIN:-$HOME/.local/bin/claude}"
cache="${METERLEAF_STATUSLINE_CACHE:-$HOME/Library/Application Support/Meterleaf Collector/statusline-quota.tsv}"

# 接到其他接口(例如 OpenCode Go)或云厂商时,额度事件不属于 Claude 订阅,直接运行。
if [ -n "${ANTHROPIC_BASE_URL:-}" ] || [ -n "${CLAUDE_CODE_USE_BEDROCK:-}" ] ||
[ -n "${CLAUDE_CODE_USE_VERTEX:-}" ]; then
exec "$real" "$@"
fi

record_quota() {
umask 077
mkdir -p "$(dirname "$cache")" 2>/dev/null
# grep 先筛掉绝大多数行,并且一直读到输出结束,保证 tee 不会因下游提前退出而中断。
grep --line-buffered -F '"rate_limit_event"' | while IFS= read -r line; do
pending=$(mktemp "$cache.tmp.XXXXXX" 2>/dev/null) || continue
if printf '%s\n' "$line" | jq -r '
select(.type == "rate_limit_event")
| (.rate_limit_info.unifiedWindows // {}) | to_entries[]
| select(.key == "five_hour" or .key == "seven_day")
| select(.value.utilization | type == "number")
| select(.value.resetsAt | type == "number")
| [.key, ((.value.utilization * 10000 | round) / 100), (.value.resetsAt | floor)]
| @tsv
' >"$pending" 2>/dev/null && [ -s "$pending" ]; then
# 临时文件 + rename,采集器不会读到半行。
mv "$pending" "$cache"
else
rm -f "$pending"
fi
done
}

# exec 之后本进程就是 claude,Paseo 的中断信号直接送达;输出经 tee 原样转交 Paseo。
exec > >(tee >(record_quota))
exec "$real" "$@"
72 changes: 72 additions & 0 deletions src/domain/opencode-go-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* OpenCode Go 每个模型各有美元月上限,周窗口和月窗口分别是月上限的 50% 与 100%。
* 上游百分比向下取整,周期初期按百分比外推误差很大,设了档位时直接用公布上限估算整窗价值。
* 上限来源 https://opencode.ai/docs/go/ (2026-10-23 读取),官方可能调整。
* 只登记价格表里有价格的模型,未登记模型的花费会让估算回退到百分比外推。
*/
const monthlyLimits: Record<"go" | "go-plus", Record<string, string>> = {
go: { "deepseek-v4.1-flash": "80" },
"go-plus": { "deepseek-v4.1-flash": "160" },
};

export type GoPlanWindow = "seven-day" | "monthly";

/** 窗口占月上限的比例。5 小时窗口不提供预估,因此不在此列。 */
const windowShares: Readonly<Record<GoPlanWindow, number>> = {
"seven-day": 0.5,
monthly: 1,
};

// 固定函数身份,报表索引按身份复用同一模型的窗口汇总。
const modelScopes: Readonly<Record<string, (model: string) => boolean>> = {
"deepseek-v4.1-flash": (model) => model === "deepseek-v4.1-flash",
};

export interface GoPlanModelLimit {
model: string;
/** 月上限,美元。 */
monthlyUsd: string;
/** 只汇总该模型的窗口费用,必须使用模块级固定身份。 */
scope: (model: string) => boolean;
}

export interface GoPlanBudget {
plan: "go" | "go-plus";
windowShare: Readonly<Record<GoPlanWindow, number>>;
models: readonly GoPlanModelLimit[];
}

const budgets: Record<"go" | "go-plus", GoPlanBudget> = {
go: {
plan: "go",
windowShare: windowShares,
models: [
{
model: "deepseek-v4.1-flash",
monthlyUsd: monthlyLimits.go["deepseek-v4.1-flash"],
scope: modelScopes["deepseek-v4.1-flash"],
},
],
},
"go-plus": {
plan: "go-plus",
windowShare: windowShares,
models: [
{
model: "deepseek-v4.1-flash",
monthlyUsd: monthlyLimits["go-plus"]["deepseek-v4.1-flash"],
scope: modelScopes["deepseek-v4.1-flash"],
},
],
},
};

/** 非 OpenCode Go 账户或未登记档位返回 null,调用方保持原有外推逻辑。 */
export function goPlanBudget(
platform: string | null | undefined,
plan: string | null | undefined,
): GoPlanBudget | null {
if (platform?.trim().toLowerCase() !== "opencode-go") return null;
const key = plan?.trim().toLowerCase();
return key === "go" || key === "go-plus" ? budgets[key] : null;
}
64 changes: 63 additions & 1 deletion src/domain/quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ import {
} from "./quota-estimate";
import type { QuotaFact } from "./connector";
import type { UsageFact } from "./connector";
import type { GoPlanBudget, GoPlanWindow } from "./opencode-go-plan";
import type { Valuation } from "./pricing";

export type QuotaEstimateMethod =
"segments" | "blended" | "previous-period" | "rough";
"segments" | "blended" | "previous-period" | "rough" | "plan-limit";

export interface PricedUsage {
fact: UsageFact;
Expand Down Expand Up @@ -167,11 +168,51 @@ function rowsChargeReader(rows: readonly PricedUsage[]): QuotaChargeReader {
};
}

/**
* 档位已知时用公布的模型上限反推整窗价值,不再按取整后的百分比外推。
* 本周期有上限表之外的模型花费、或费用缺少可计价部分时返回 null,调用方沿用旧逻辑。
*/
function planLimitUsd(
budget: GoPlanBudget,
window: GoPlanWindow,
startsAt: string,
now: string,
readSource: QuotaChargeReader,
periodUsd: string | null,
): string | null {
if (periodUsd === null) return null;
const share = new Decimal(budget.windowShare[window]);
const charges: { cap: Decimal; spend: Decimal }[] = [];
for (const limit of budget.models) {
const { usd } = readSource(startsAt, now, limit.scope);
if (usd === null) return null;
charges.push({
cap: new Decimal(limit.monthlyUsd).mul(share),
spend: new Decimal(usd),
});
}
const spent = new Decimal(periodUsd);
const tracked = charges.reduce(
(total, { spend }) => total.add(spend),
new Decimal(0),
);
// 表外模型的上限未知,混入后无法按模型构成折算,整窗价值也没有公布依据。
if (spent.sub(tracked).gt(0)) return null;
if (spent.eq(0))
return charges.length === 1 ? charges[0]!.cap.toString() : null;
const fraction = charges.reduce(
(total, { cap, spend }) => total.add(spend.div(cap)),
new Decimal(0),
);
return fraction.eq(0) ? null : spent.div(fraction).toString();
}

/** 百分比为整个上游账号,费用只覆盖本连接器;推算是条件性观测值而非订阅承诺额度。 */
export function quotaView(
history: CollectedQuota[],
rowsOrSumReader: readonly PricedUsage[] | QuotaChargeReader,
now: string,
planBudget?: GoPlanBudget | null,
): QuotaView | null {
if (!history.length) return null;
// 每条读数只解析一次时间,历史较长时排序和逐周期筛选不再重复解析。
Expand Down Expand Up @@ -321,6 +362,27 @@ export function quotaView(
fact.window === "five-hour"
)
return result;
// 周、月窗口的档位上限是整窗价值本身,不再套用「已花 + 整窗外推 × 剩余比例」。
if (
planBudget &&
(fact.window === "seven-day" || fact.window === "monthly")
) {
const quota = planLimitUsd(
planBudget,
fact.window,
startsAt!,
now,
readSource,
result.periodUsd,
);
if (quota !== null) {
result.estimate.usd = quota;
result.estimate.credits = null;
result.estimate.methods = { usd: "plan-limit", credits: null };
result.estimate.reason = "eligible";
return result;
}
}
const estimateCycle = (cycleReset: number, until: number) => {
const cycleStart = cycleReset - duration;
const readings = cycleReadings(cycleReset, until);
Expand Down
7 changes: 7 additions & 0 deletions src/server/snapshot.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SourceAccount, UsageFact } from "../domain/connector";
import { goPlanBudget } from "../domain/opencode-go-plan";
import { quotaView, type QuotaChargeReader } from "../domain/quota";
import { priceBookKey } from "../domain/pricing";
import { reportBounds, type DateRange } from "../shared/date-range";
Expand Down Expand Up @@ -212,6 +213,7 @@ export function liveSnapshot(
const quotaHistory = history.filter(
(row) => root(row.fact.sourceId, row.fact.accountExternalId) === id,
);
const planBudget = goPlanBudget(account.platform, account.plan);
const fiveHour = quotaView(
quotaHistory.filter((row) => row.fact.window === "five-hour"),
periodAccountUsage,
Expand All @@ -221,6 +223,7 @@ export function liveSnapshot(
quotaHistory.filter((row) => row.fact.window === "seven-day"),
periodAccountUsage,
now,
planBudget,
);
const sevenDayFable = quotaView(
quotaHistory.filter((row) => row.fact.window === "seven-day-fable"),
Expand All @@ -231,6 +234,7 @@ export function liveSnapshot(
quotaHistory.filter((row) => row.fact.window === "monthly"),
periodAccountUsage,
now,
planBudget,
);
return {
id,
Expand Down Expand Up @@ -355,6 +359,7 @@ export function indexedSnapshot(
const quotaHistory = quotaByAccount.get(id) ?? [];
const reader: QuotaChargeReader = (startInclusive, endInclusive, scope) =>
sumWindow(id, startInclusive, endInclusive, usdBasis, scope);
const planBudget = goPlanBudget(account.platform, account.plan);
const fiveHour = quotaView(
quotaHistory.filter((row) => row.fact.window === "five-hour"),
reader,
Expand All @@ -364,6 +369,7 @@ export function indexedSnapshot(
quotaHistory.filter((row) => row.fact.window === "seven-day"),
reader,
now,
planBudget,
);
const sevenDayFable = quotaView(
quotaHistory.filter((row) => row.fact.window === "seven-day-fable"),
Expand All @@ -374,6 +380,7 @@ export function indexedSnapshot(
quotaHistory.filter((row) => row.fact.window === "monthly"),
reader,
now,
planBudget,
);
return {
id,
Expand Down
Loading
Loading
0