8000 feat(otel): attribute Claude usage to the account that sent each request for 0.12.0 by InfinityPacer · Pull Request #48 · 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
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.12.0] - 2026-10-23

- 一台 Mac 上的多个 Claude 订阅账户可以分别记账。采集器新增 `claude-profiles`,登记其他账户的 `CLAUDE_CONFIG_DIR` 并读取各自的账户、套餐与额度;Claude Code 开启 OpenTelemetry 日志导出后,服务端按每次请求的 `request_id` 把用量归到实际账户,不再一律记到默认账户。用法见[本机采集器](docs/collector.md#多个-claude-账户)。
- 采集器推送的用量新增 `attribution` 元数据,标明归属来自模型映射还是登录时间线。升级后采集器会把已推送的用量重发一次,服务端据此补正历史请求。模型映射优先于 OTEL,OTEL 优先于登录时间线。用过模型映射时,服务端升级后要尽快升级采集器,否则旧采集器推送的用量会在升级完成前暂时被 OTEL 覆盖。
- 服务端新增 `POST /api/otel/v1/logs`,只接收 `application/json`(可 gzip),只保存请求 ID、账户、组织、会话、模型与请求时间,不保存提示词、回复或其他属性。使用反向代理时需放行这个路径,见[部署指南](docs/deployment.md#让采集器访问服务)。
- 本版改动数据库结构(新增 `request_accounts` 表),旧版本镜像读不了迁移后的账本。升级前先停止服务并备份完整数据目录,先升级服务端,再升级采集器。

## [0.11.2] - 2026-10-23

- OpenCode Go 的套餐上限预估只在本周期每次请求都来自已登记上限的模型时使用。0.11.1 按金额核对,没有价格的模型(例如映射到 Go 账户的 Gemini)不计入金额,会让页面误把 DeepSeek 的上限当成整窗价值。本周期还没有请求时也不再假定所用模型。
Expand Down
8000
48 changes: 48 additions & 0 deletions docs/collector.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- `~/.claude/projects` 下的会话记录,从中读取每次请求的模型与 Token 用量,以及会话里 `/login` 成功的时间。
- `~/.claude.json`,从中读取登录账户的 UUID、组织类型、席位档位,以及 Claude Code 缓存的最近一次额度数据,包括 5 小时、7 天和 Fable 独立周额度。
- 当前登录账户的套餐字段。新版 Claude Code 只把订阅类型和速率档位保存在登录凭据里,macOS 上是钥匙串中的「Claude Code-credentials」,其他系统是 Claude 配置目录下的 `.credentials.json`。采集器用系统自带的 `security` 命令或直接读文件取出整条凭据,只保留这两个字段,访问令牌和刷新令牌不解析、不保存、不记录、不上传。
- 如果登记了额外 Claude 账户,还会读取每个目录下的 `.claude.json` 和该目录对应的登录凭据,读取方式与默认账户相同,见[多个 Claude 账户](#多个-claude-账户)。

它不会在 Claude Code 的目录里写入、改名、加锁或改权限,不改设置、hooks 或状态栏,不运行 Claude Code 命令,也不调用任何 Anthropic 接口,读取凭据不会产生网络请求。上传内容只有计量数字、模型、时间、账户标识和套餐,不包含对话、工具输出、文件路径、工作目录、邮箱、显示名或令牌。

Expand Down Expand Up @@ -53,6 +54,53 @@ Claude Code 也可以通过兼容接口使用别家的模型,例如经 OpenCod

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

## 多个 Claude 账户

一台 Mac 上可以用多个 Claude 订阅账户。默认账户使用 `~/.claude`,其他账户分别设 `CLAUDE_CONFIG_DIR` 登录,例如 `~/.claude-profiles/work`。如果把这些目录的 `projects` 软链接到 `~/.claude/projects`,会话记录集中在一处,方便跨账户恢复,但 JSONL 的每一行都没有账户字段。采集器只能按登录时间线归属,而时间线只代表默认目录当前登录的账户,额外账户的用量会被记到默认账户。

Claude Code 的 OpenTelemetry 日志能补上这个信息。开启导出后,每次 API 请求都会发出一条 `claude_code.api_request` 日志,带 `user.account_uuid`、`organization.id`、`session.id` 和 `request_id`。`request_id` 与会话记录里的 `requestId` 相同,服务端据此把那次请求归到正确的账户。

Token 用量仍由采集器读取会话记录,因为 OTEL 没有 1 小时与 5 分钟缓存写入的拆分、推理 Tokens 和 web search 次数等计价需要的字段。OTEL 只回答这次请求属于哪个账户。归属优先级从高到低是采集器的模型映射、OTEL 给出的请求账户、登录时间线。模型映射是用户明确指定的,服务端不会覆盖。

### 登记额外账户

先为每个额外账户登记配置目录,可以同时指定该账户的状态栏额度缓存。

```sh
meterleaf-collector claude-profiles \
"$HOME/.claude-profiles/work" \
"$HOME/.claude-profiles/personal=$HOME/.cache/claude-statusline/personal.tsv"
```

每个参数是 `CLAUDE_CONFIG_DIR` 的绝对路径,可用 `=` 接该账户的状态栏额度缓存。最多 20 个,目录不能是默认 Claude 目录。保存后下一轮同步生效,`meterleaf-collector status` 会列出已登记的目录与最近一次结果,执行 `meterleaf-collector claude-profiles off` 清除。

采集器从每个目录的 `.claude.json` 读取账户与额度,从该目录对应的登录凭据读取订阅类型,再把账户资料推送到 Meterleaf。额外账户不进入默认账户的登录时间线,因此不会改变已有会话的归属;目录读不到或未登录时只跳过该项,不影响默认账户和其他目录。状态栏额度缓存按读取前后两次看到的同一账户归属,前后不一致时跳过并在 `status` 中说明。

额外账户的 `projects` 通常是指回 `~/.claude/projects` 的软链接,采集器仍然只扫描默认目录。会话被拆到独立的 `projects` 目录时,这部分会话目前不会被采集。

### 开启 OTEL 导出

在 `~/.claude/settings.json` 中加上下面的设置,地址换成这台电脑访问 Meterleaf 的地址。`otelHeadersHelper` 让 Claude Code 每次导出前向采集器取一次写入密钥,密钥只在本机传递。

```json
{
"env": {
"CLAUDE_CODE_ENABLE_TELEMETRY": "1",
"OTEL_LOGS_EXPORTER": "otlp",
"OTEL_METRICS_EXPORTER": "none",
"OTEL_EXPORTER_OTLP_LOGS_PROTOCOL": "http/json",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT": "https://meterleaf.example.com/api/otel/v1/logs"
},
"otelHeadersHelper": "/Applications/Meterleaf.app/Contents/MacOS/meterleaf-collector otel-headers"
}
```

`meterleaf-collector otel-headers` 从采集器配置读取写入密钥,向标准输出打印一行 `{"Authorization":"Bearer ..."}`,不输出其他内容。没有初始化时命令报错并以非 0 退出,Claude Code 的日志导出会失败,但用量仍由采集器正常推送,不受影响。服务端要先升级到支持该入口的版本,见[部署指南](deployment.md#让采集器访问服务)。

这些日志只发到你自己的 Meterleaf,不发给 Anthropic。Claude Code 默认不导出提示词与回复;Meterleaf 也只保存请求 ID、账户 UUID、组织 UUID、会话 ID、模型和请求时间,其余属性即使到达也不落库。

服务端比采集器先升级时,旧采集器推送的用量还没有 `attribution` 字段。如果配置过 `modelAccounts`,这段时间里 OTEL 会暂时覆盖模型映射的归属;采集器升级后会重发全部已推送用量,归属随之恢复。所以用过模型映射时,请在升级服务端后尽快升级采集器。

## 安装

采集器需要 macOS 13 或更新版本,目前不提供下载,需要在这台 Mac 上从仓库构建。构建需要 Bun 1.4.2 或更新版本,以及 Xcode 命令行工具(没有时执行 `xcode-select --install`)。
Expand Down
12 changes: 11 additions & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ docker compose up -d --no-build --pull never # 已发布镜像

采集器需要能访问 Meterleaf。采集器与服务在同一台电脑时使用 `http://127.0.0.1:4318`。服务在 NAS 等其他主机时,随附 Compose 默认只监听回环地址,局域网访问需把 `METERLEAF_BIND_ADDRESS` 改为 `0.0.0.0` 或宿主机局域网地址,或者经由已有的反向代理访问。HTTP 下写入密钥明文传输,只在可信局域网中使用。

若要让外网的电脑推送,地址必须走 HTTPS。开启了 [GitHub 登录](#github-登录)时采集器写入已绕过登录;如果改由反向代理做登录保护,只为 `POST /api/ingest/v1/batches` 绕过它,其余页面照旧。这个地址用写入密钥鉴权。反向代理的请求体上限至少设为 8 MB,Nginx 默认 1 MB 会拒绝大批次。以 Nginx 为例:
若要让外网的电脑推送,地址必须走 HTTPS。开启了 [GitHub 登录](#github-登录)时采集器写入与 OTEL 日志已绕过登录;如果改由反向代理做登录保护,只为 `POST /api/ingest/v1/batches` 和 `POST /api/otel/v1/logs` 绕过它,其余页面照旧。这两个地址都用写入密钥鉴权。反向代理的请求体上限至少设为 8 MB,Nginx 默认 1 MB 会拒绝大批次。以 Nginx 为例:

```nginx
location = /api/ingest/v1/batches {
Expand All @@ -115,8 +115,18 @@ location = /api/ingest/v1/batches {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
}

location = /api/otel/v1/logs {
limit_except POST { deny all; }
client_max_body_size 8m;
proxy_pass http://meterleaf:4318;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
}
```

OTEL 日志只在采集器配置了 `claudeProfiles`、用来区分多个 Claude 账户时才需要放行,见[本机采集器](collector.md#多个-claude-账户)。

## OpenCode Go 额度(可选)

通过 OpenCode Go 使用 DeepSeek 等模型时,用量由本机采集器按模型名归到独立的 Go 账户,见[本机采集器](collector.md#其他供应商的模型)。Go 的 5 小时、每周和每月额度则由服务端用 Go 的 Key 读取,采集器不经手这把 Key。
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.2",
"version": "0.12.0",
"private": true,
"type": "module",
"license": "Apache-2.0",
Expand Down
15 changes: 3 additions & 12 deletions src/collector/claude-code/account.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { subjectKeyFrom } from "../../shared/account-subject";
import type { IngestAccount } from "../../shared/ingest";
import type { ClaudeCredentialPlan } from "./credential";

Expand Down Expand Up @@ -159,18 +159,9 @@ export function kindFor(
: "subscription";
}

/**
* 同一上游主体的稳定摘要,用于服务端跨来源识别同一账户;只含 UUID,不含邮箱或名称。
* 组织 UUID 缺失时无法构成完整主体,保留未知。
*/
/** 公式与 `src/shared/account-subject.ts` 共用,服务端补建账户时保持一致。 */
export function subjectKeyFor(account: ClaudeAccount): string | null {
if (!account.organizationUuid) return null;
const digest = createHash("sha256")
.update(
`anthropic_account_uuid=${account.accountUuid}\nanthropic_organization_uuid=${account.organizationUuid}`,
)
.digest("hex");
return `sha256:${digest}`;
return subjectKeyFrom(account.accountUuid, account.organizationUuid);
}

/**
Expand Down
Loading
Loading
0