8000 feat(collector): split non-Claude models into their own accounts and track OpenCode Go for 0.11.0 by InfinityPacer · Pull Request #45 · 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
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ METERLEAF_USD_BASIS=subscription
# 登录有效天数,使用中自动顺延,范围 1 至 365。
# METERLEAF_SESSION_DAYS=30

# OpenCode Go 额度:填写 Key 后在页面打开时读取;额度挂到 来源标识:账户标识,只有一个推送来源时可省略。
# METERLEAF_OPENCODE_GO_KEY=
# METERLEAF_OPENCODE_GO_ACCOUNT=claude-code-macbook:opencode-go

# 演示模式必须显式开启,不连接真实数据源。
METERLEAF_DEMO=false

Expand Down
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.0] - 2026-10-23

- 本机采集器按模型名分账户。Claude Code 经 OpenCode Go 等兼容接口使用的非 Claude 模型(例如 DeepSeek)不再按登录时间记到 Claude 账户,默认归到「其他模型」,也可以在采集器 `config.json` 的 `modelAccounts` 里映射到独立账户,例如「OpenCode Go」。升级后,以前记错的用量会自动改归并补推一次,Claude 账户的用量和额度预估随之回落到真实值。
- 价格表新增经 OpenCode Go 使用的 DeepSeek V4.1 Flash,按 Go 公布的价格估值,工作日 UTC 01:00–04:00 和 06:00–10:00 按峰时价计算。价格表规则新增 `peak` 时段价。
- 服务端可配置 `METERLEAF_OPENCODE_GO_KEY`,在页面打开时读取 Go 的 5 小时、每周和每月额度;账户卡片在周额度之后显示「月」窗口。
- 需要同时升级服务端和本机采集器,先升级服务端。数据库结构未变,升级无需先备份。

## [0.10.3] - 2026-10-21

- 后台同步现在真正从登录凭据识别套餐。0.10.0 起只有 `scan` 会读凭据,每分钟一次的同步一直没读,新版 Claude Code 登录的 Pro、Max 账户因此仍显示未识别套餐。
Expand Down
2 changes: 2 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ services:
METERLEAF_GITHUB_USERS: ${METERLEAF_GITHUB_USERS:-}
METERLEAF_PUBLIC_URL: ${METERLEAF_PUBLIC_URL:-}
METERLEAF_SESSION_DAYS: ${METERLEAF_SESSION_DAYS:-30}
METERLEAF_OPENCODE_GO_KEY: ${METERLEAF_OPENCODE_GO_KEY:-}
METERLEAF_OPENCODE_GO_ACCOUNT: ${METERLEAF_OPENCODE_GO_ACCOUNT:-}
METERLEAF_DATA_DIR: /app/app_data
METERLEAF_HOST: 0.0.0.0
METERLEAF_PORT: 4318
Expand Down
28 changes: 28 additions & 0 deletions docs/collector.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,34 @@ Claude Code 会把一次请求按内容块拆成多行保存,每行带着同

费用由 Meterleaf 按[计价说明](pricing.md)中的价格表估算,1 小时与 5 分钟缓存写入分开计价。它是按公开价格计算的估值,不是 Claude 订阅账单。

## 其他供应商的模型

Claude Code 也可以通过兼容接口使用别家的模型,例如经 OpenCode Go 使用 DeepSeek。这些请求的会话记录和 Claude 订阅写在同一个目录,记录里没有账户,也没有请求发往的地址,采集器只能靠模型名区分。

所以采集器只把 `claude-` 开头的模型按登录时间归到 Claude 账户。其他模型默认归到一个叫「其他模型」的账户,不会算进 Claude 订阅的用量。想让它们显示成独立的供应商账户,在采集器数据目录的 `config.json`(macOS 上是 `~/Library/Application Support/Meterleaf Collector/config.json`)里加一项 `modelAccounts`:

```json
{
"modelAccounts": [
{
"match": "deepseek-*",
"id": "opencode-go",
"name": "OpenCode Go",
"platform": "opencode-go",
"kind": "subscription"
}
]
}
```

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

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

想在 Meterleaf 里看到 OpenCode Go 的额度,需要在服务端单独配置 Go 的 Key,见[部署指南](deployment.md#opencode-go-额度可选)。采集器本身不读取 Go 的 Key,也不调用任何供应商接口。

## 安装

采集器需要 macOS 13 或更新版本,目前不提供下载,需要在这台 Mac 上从仓库构建。构建需要 Bun 1.4.2 或更新版本,以及 Xcode 命令行工具(没有时执行 `xcode-select --install`)。
Expand Down
15 changes: 15 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ docker compose up -d --no-build --pull never
| `METERLEAF_LOG_LEVEL` | `info` | `debug`、`info`、`warn`、`error`、`silent` |
| `METERLEAF_INGEST_KEYS` | 未启用 | 本机采集器写入密钥,见下文 |
| `METERLEAF_GITHUB_CLIENT_ID` 等 | 未启用 | GitHub 登录,见下文 |
| `METERLEAF_OPENCODE_GO_KEY` 等 | 未启用 | OpenCode Go 额度,见下文 |

`SUB2API_DATABASE_URL` 与 `METERLEAF_INGEST_KEYS` 至少填写一项,未填写的来源不启用。

Expand Down Expand Up @@ -116,6 +117,20 @@ location = /api/ingest/v1/batches {
}
```

## OpenCode Go 额度(可选)

通过 OpenCode Go 使用 DeepSeek 等模型时,用量由本机采集器按模型名归到独立的 Go 账户,见[本机采集器](collector.md#其他供应商的模型)。Go 的 5 小时、每周和每月额度则由服务端用 Go 的 Key 读取,采集器不经手这把 Key。

```dotenv
METERLEAF_OPENCODE_GO_KEY=<OpenCode Go 的 API Key>
# 额度显示在哪个账户上:来源标识:账户标识。只有一个推送来源时可省略,默认取该来源的 opencode-go 账户。
# METERLEAF_OPENCODE_GO_ACCOUNT=claude-code-macbook:opencode-go
```

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

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

## GitHub 登录

Meterleaf 可以要求访问者先用 GitHub 账号登录,只有允许名单里的账号能查看页面和读取接口。不开启时,任何能访问到服务的人都能看到账本,只适合本机或可信局域网。
Expand Down
6 changes: 5 additions & 1 deletion docs/pricing.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[返回首页](../README.md)

Meterleaf 将费率保存在独立 JSON 文件中,内置文件为
`prices/meterleaf-2026-09-25.json`,同时包含 OpenAI 与 Anthropic Claude 模型。使用自定义价格表时,设置
`prices/meterleaf-2026-09-25.json`,同时包含 OpenAI、Anthropic Claude 以及通过 OpenCode Go 接入的 DeepSeek 模型。使用自定义价格表时,设置
`METERLEAF_PRICE_BOOK=/path/to/prices.json` 并重启服务。自定义文件完整替换内置表,不进行隐式合并。

## 版本元数据
Expand Down Expand Up @@ -40,6 +40,8 @@ Credits 使用独立规则,不设置 `usdBasis`。两个 USD 分支与 Credits
`null` 表示没有可用费率,不是零价。当对应 token 数量为正但费率为 `null` 时,估值保留为未计价。缺少任一基础 token 桶或匹配规则时,也保留为未计价。
可选的 `longContext` 指定 `threshold` 和替代 `rates`;只有总输入 Tokens 严格超过阈值才使用该档位,总输入包含普通输入、缓存读取和缓存写入。来源未声明档位时按 Standard 估值并标注为假设,不从其他档位或缺失费率推导。

可选的 `peak` 表示供应商公布的时段价,包含 `days`、`hoursUtc` 和替代的 `rates`。`days` 使用 ISO 星期编号,周一为 1、周日为 7;`hoursUtc` 的每个时段从 `from` 开始,到 `until` 前结束,写成 `HH:MM` 的 UTC 时间。判断记录是否处于峰时只看 `occurredAt` 换算后的 UTC 星期和时刻,与服务器或采集机器的本地时区无关。峰时费率是完整价格,不按倍率从基础费率推导;峰时与 `longContext`、`imageRates` 不能配置在同一条规则里,避免两套替代费率互相覆盖。

Sol 的已公布促销价已经写入两个 USD 分支,不再额外乘以 `0.8`;`promotion.confirmedThrough` 只表示至少确认到该日期。Astra Credits 超过 272K 后按选定订阅规则继续使用标准上下文费率。

GPT-5.6 的 Credits 使用对应模型的标准 Token 费率,不套用 API 长上下文加价。
Expand All @@ -57,6 +59,8 @@ GPT-5.4 与 Mini 使用各自规则,不相互继承长上下文档位;未配

内置表覆盖 GPT Image 2、GPT Image 2.5 Sunburst 和 Flare,以及各自已公布的日期快照。两代图像模型配置相同的 USD 和 Credits Token 单价。

通过 OpenCode Go 接入的 DeepSeek V4.1 Flash 使用 Go 公布的每百万 token 价格,并按 Go 公布的峰时规则区分工作日峰时与非峰时。这是该模型目前唯一可用的按 token 计价依据,两个 USD 分支都按它估值。Go 订阅按月计费,不随 token 用量出账,所以这些金额是估值而不是 Go 账单。该模型未公布缓存写入价,含缓存写入的记录保留未计价。

未配置的模型不会自动别名到其他模型。网关参考价不是已核实的官方报价,也不能用于推导订阅 Credits。图像与实时模型需要相应媒体 Token 分桶,不能用文本四桶冒充完整费用。

排查缺失金额时,依次核对计价模型、服务档位、币种、USD 口径、有效日期和实际使用的 Token 桶。来源缺价时的零费用不是免费的证据;Meterleaf 保留未知值。
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.10.3",
"version": "0.11.0",
"private": true,
"type": "module",
"license": "Apache-2.0",
Expand Down
61 changes: 58 additions & 3 deletions prices/meterleaf-2026-09-25.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schemaVersion": 1,
"id": "meterleaf",
"supersedes": ["meterleaf-openai"],
"version": "2026-09-25.4",
"version": "2026-09-25.5",
"publishedAt": "2026-09-25T00:00:00Z",
"unit": "per_million_tokens",
"sources": [
Expand All @@ -17,7 +17,8 @@
"https://developers.openai.com/api/docs/models/gpt-image-2.5-sunburst",
"https://developers.openai.com/api/docs/models/gpt-image-2.5-flare",
"https://platform.claude.com/docs/en/about-claude/pricing",
"https://platform.claude.com/docs/en/about-claude/models/overview"
"https://platform.claude.com/docs/en/about-claude/models/overview",
"https://opencode.ai/docs/go/"
],
"notes": [
"Subscription-equivalent USD uses the same published base prices but excludes long-context uplifts; it is an estimate, not an invoice or a credit-to-dollar exchange rate.",
Expand All @@ -33,7 +34,9 @@
"Claude fast mode maps to the priority tier for Opus 5.5 and Opus 5, with the published fast input and output prices and cache multipliers applied on top. US-only inference (1.1x) and web search fees are not applied.",
"Claude models on the current price list include the full context window at standard prices, so no long-context tier is configured.",
"Only Claude model IDs and aliases listed on the official models overview are configured: claude-fable-5-1, claude-opus-5-5, claude-opus-5 (legacy), claude-sonnet-5, claude-haiku-4-5-20251001 and its alias claude-haiku-4-5. Other models stay unpriced.",
"Codex Fast mode credits map to the priority tier at the published 2.5x of Standard credits for GPT-5.6 Sol, Terra and Luna and GPT-6 Astra, Sol and Luna, and 2x for GPT-5.4. GPT-5.4 mini has no published Fast credits rate."
"Codex Fast mode credits map to the priority tier at the published 2.5x of Standard credits for GPT-5.6 Sol, Terra and Luna and GPT-6 Astra, Sol and Luna, and 2x for GPT-5.4. GPT-5.4 mini has no published Fast credits rate.",
"DeepSeek V4.1 Flash is valued at the per-token prices published for OpenCode Go, the only per-token basis Go publishes. Both USD branches use those prices as an estimate, not a Go invoice; the Go subscription is billed monthly regardless of token usage.",
"OpenCode Go peak hours are 01:00-04:00 and 06:00-10:00 UTC on weekdays; all other UTC hours and weekends use the off-peak prices. Cache writes are not published for this model."
],
"rules": [
{
Expand Down Expand Up @@ -1536,6 +1539,58 @@
"cacheWrite1h": "2",
"output": "5"
}
},
{
"model": "deepseek-v4.1-flash",
"effectiveFrom": "1970-01-01T00:00:00Z",
"currency": "usd",
"usdBasis": "subscription",
"tier": "standard",
"rates": {
"input": "0.15",
"cacheRead": "0.003",
"cacheWrite": null,
"output": "0.6"
},
"peak": {
"days": [1, 2, 3, 4, 5],
"hoursUtc": [
{ "from": "01:00", "until": "04:00" },
{ "from": "06:00", "until": "10:00" }
],
"rates": {
"input": "0.3",
"cacheRead": "0.006",
"cacheWrite": null,
"output": "1.2"
}
}
},
{
"model": "deepseek-v4.1-flash",
"effectiveFrom": "1970-01-01T00:00:00Z",
"currency": "usd",
"usdBasis": "api",
"tier": "standard",
"rates": {
"input": "0.15",
"cacheRead": "0.003",
"cacheWrite": null,
"output": "0.6"
},
"peak": {
"days": [1, 2, 3, 4, 5],
"hoursUtc": [
{ "from": "01:00", "until": "04:00" },
{ "from": "06:00", "until": "10:00" }
],
"rates": {
"input": "0.3",
"cacheRead": "0.006",
"cacheWrite": null,
"output": "1.2"
}
}
}
]
}
19 changes: 14 additions & 5 deletions src/collector/collect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
statuslineQuotaSnapshot,
} from "./claude-code/statusline-cache";
import { attribute } from "./attribution";
import { modelAccountsDigest, type ModelAccountRule } from "./model-accounts";
import { UNATTRIBUTED_ACCOUNT_ID } from "./claude-code/account";
import { mayContainUsage, parseLine } from "./claude-code/usage";
import type { CollectorState, FileCursor, FileObservation } from "./state";
Expand All @@ -37,6 +38,8 @@ export interface CollectSources {
read: CredentialTextReader;
version: CredentialVersionReader;
} | null;
/** 可选:模型到独立账户的映射,见 model-accounts.ts。 */
modelAccounts?: readonly ModelAccountRule[];
}

export interface CollectReport {
Expand Down Expand Up @@ -245,8 +248,11 @@ export function emptyReport(): CollectReport {
}

const attributionRulesKey = "attribution_rules";
/** 归属规则版本;变化时按新规则重新归属全部事件。2 起不同账户之间的空档按切换时刻分配。 */
const attributionRules = "2";
/**
* 归属规则版本;变化时按新规则重新归属全部事件。2 起不同账户之间的空档按切换时刻分配;
* 3 起非 Claude 模型按模型映射归入独立账户,映射内容变化也触发重新归属。
*/
const attributionRules = "3";
const credentialCandidateKey = "credential_plan_candidate";
const credentialCheckKey = "credential_plan_check";
/** 读不到凭据修改标记时,退回按此间隔复查。 */
Expand Down Expand Up @@ -405,6 +411,9 @@ export function collect(
finishedAt: () => Date = () => now,
): CollectReport {
const report = emptyReport();
const modelAccounts = sources.modelAccounts ?? [];
state.setModelAccounts(modelAccounts);
const rules = `${attributionRules}:${modelAccountsDigest(modelAccounts)}`;
const snapshot = readClaudeJson(sources.claudeJson);
report.claudeJsonRead = snapshot !== null;
if (snapshot?.account) {
Expand Down Expand Up @@ -438,10 +447,10 @@ export function collect(
);
report.reattributed += state.reattribute(since);
}
// 早期版本把不同账户之间的空档记为未归属;按现行规则把已有事件重新归属一次。
if (state.getMeta(attributionRulesKey) !== attributionRules) {
// 规则或模型映射与上次不同(含早期版本把空档记为未归属)时,按现行规则把已有事件重新归属一次。
if (state.getMeta(attributionRulesKey) !== rules) {
report.reattributed += state.reattribute(null);
state.setMeta(attributionRulesKey, attributionRules);
state.setMeta(attributionRulesKey, rules);
}

const files = listSessionFiles(sources.projectsDir);
Expand Down
3 changes: 3 additions & 0 deletions src/collector/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { hostname } from "node:os";
import { z } from "zod";
import { INGEST_KEY_PREFIX, sourceIdSchema } from "../shared/ingest";
import { modelAccountRuleSchema } from "./model-accounts";

const configSchema = z.object({
server: z.string().url(),
Expand All @@ -11,6 +12,8 @@ const configSchema = z.object({
createdAt: z.string(),
/** 可选额度来源:用户状态栏脚本写出的 rate_limits TSV 绝对路径。 */
statuslineCache: z.string().startsWith("/").optional(),
/** 可选:按模型名把用量归入独立账户,按顺序取第一条命中的规则。 */
modelAccounts: z.array(modelAccountRuleSchema).max(50).optional(),
});

export type CollectorConfig = z.infer<typeof configSchema>;
Expand Down
35 changes: 26 additions & 9 deletions src/collector/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
loadConfig,
normalizeServer,
saveConfig,
type CollectorConfig,
} from "./config";
import {
appBundleOf,
Expand Down Expand Up @@ -78,9 +79,12 @@ function shortId(id: string): string {
return id === "unattributed" ? id : `${id.slice(0, 8)}…`;
}

/** 任何会重新归属事件的命令都必须带上模型映射,否则映射账户的用量会被改归「其他模型」。 */
function openState(paths: CollectorPaths): CollectorState {
mkdirSync(paths.dataDir, { recursive: true, mode: 0o700 });
return new CollectorState(paths.stateFile);
const state = new CollectorState(paths.stateFile);
state.setModelAccounts(loadConfig(paths.configFile)?.modelAccounts ?? []);
return state;
}

function printTotals(totals: UsageTotals[]) {
Expand Down Expand Up @@ -267,10 +271,14 @@ function runStatuslineCache(paths: CollectorPaths, value: string | undefined) {
return 0;
}

function collectSources(paths: CollectorPaths, statuslineCache: string | null) {
function collectSources(
paths: CollectorPaths,
config: Pick<CollectorConfig, "statuslineCache" | "modelAccounts"> | null,
) {
return {
...paths,
statuslineCache,
statuslineCache: config?.statuslineCache ?? null,
modelAccounts: config?.modelAccounts ?? [],
credential: {
location: {
claudeRoot: paths.claudeRoot,
Expand All @@ -285,15 +293,15 @@ function collectSources(paths: CollectorPaths, statuslineCache: string | null) {
function runScan(paths: CollectorPaths, json: boolean) {
const state = new CollectorState(":memory:");
try {
let statuslineCache: string | null = null;
let config: CollectorConfig | null = null;
try {
statuslineCache = loadConfig(paths.configFile)?.statuslineCache ?? null;
config = loadConfig(paths.configFile);
} catch {
statuslineCache = null;
config = null;
}
const report = collect(
state,
collectSources(paths, statuslineCache),
collectSources(paths, config),
new Date(),
() => new Date(),
);
Expand Down Expand Up @@ -368,7 +376,7 @@ async function runSync(paths: CollectorPaths): Promise<number> {
state = openState(paths);
const report = collect(
state,
collectSources(paths, config.statuslineCache ?? null),
collectSources(paths, config),
new Date(),
() => new Date(),
);
Expand Down Expand Up @@ -535,8 +543,17 @@ function runStatus(paths: CollectorPaths) {
} else {
console.log("配置: 尚未初始化");
}
if (config)
if (config) {
console.log(`状态栏额度缓存: ${config.statuslineCache ?? "未启用"}`);
const rules = config.modelAccounts ?? [];
console.log(
`模型账户映射: ${
rules.length
? rules.map((rule) => `${rule.match} → ${rule.name}`).join(",")
: "未配置(非 claude- 模型归入「其他模型」)"
}`,
);
}
console.log(`后台任务: ${backgroundTaskStatus()}`);
if (!existsSync(paths.stateFile)) {
console.log("同步状态: 尚未运行 sync");
Expand Down
Loading
Loading
0