8000 feat(collector): detect Claude plans from the login credential for 0.10.0 by InfinityPacer · Pull Request #41 · 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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## [未发布]

## [0.10.0] - 2026-10-20

- 新版 Claude Code 不再把套餐写进 `~/.claude.json`,采集器改为从登录凭据只读套餐字段,重新识别 Pro、Max 5x、Max 20x 和 Team Premium,Team 席位还能从席位档位识别。令牌不解析、不上传,也不运行 Claude Code 命令。
- 订阅账户不再因为缺少组织类型显示为「账户类型未知」。
- 账户菜单新增「设置套餐」,采集器读不到套餐的 Claude 账户可以手动选择档位。
- 之前不知道套餐的账户在识别出或手动设置套餐后,本周期的额度预估继续使用已有数据,不再从识别时刻重新起算。
- 新登录的账户约一分钟后显示凭据里的套餐,切换账户途中读到的结果不会记到旧账户上。
- 数据库结构未变,升级无需先备份。先升级服务端,再替换本机的 Meterleaf.app。

## [0.9.11] - 2026-10-14

- 升级 Claude 套餐后,7 天与 Fable 额度预估从上游清零的时刻重新起算,新套餐的预估更早出现,初期也不再明显偏低。
Expand Down
9 changes: 6 additions & 3 deletions docs/collector.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@

## 它读什么、不碰什么

采集器只以只读方式打开两处文件。
采集器只读取下面三处,不写入任何一处。

- `~/.claude/projects` 下的会话记录,从中读取每次请求的模型与 Token 用量。
- `~/.claude.json`,从中读取登录账户的 UUID、组织类型、套餐档位,以及 Claude Code 缓存的最近一次额度数据,包括 5 小时、7 天和 Fable 独立周额度。
- `~/.claude.json`,从中读取登录账户的 UUID、组织类型、席位档位,以及 Claude Code 缓存的最近一次额度数据,包括 5 小时、7 天和 Fable 独立周额度。
- 当前登录账户的套餐字段。新版 Claude Code 只把订阅类型和速率档位保存在登录凭据里,macOS 上是钥匙串中的「Claude Code-credentials」,其他系统是 Claude 配置目录下的 `.credentials.json`。采集器用系统自带的 `security` 命令或直接读文件取出整条凭据,只保留这两个字段,访问令牌和刷新令牌不解析、不保存、不记录、不上传。

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

凭据只代表当前登录的账户,而 Claude Code 切换账户时会先换凭据、后改 `~/.claude.json`。所以采集器只采用连续两次运行都对同一账户读到的同一套餐,新登录的账户要过一分钟左右才显示凭据里的套餐,切换途中读到的结果不会记到旧账户上。`scan` 只运行一次,会显示从凭据换算出的套餐并注明同步时确认后采用。读不到凭据时,例如钥匙串拒绝访问,采集器沿用此前为该账户记录的套餐;从没读到过时只能从席位档位识别 Team,其余显示为订阅账户,可以在 Meterleaf 中手动设置套餐,见[使用与报表](reporting.md#账户管理)。

## 统计口径

Expand Down
7 changes: 4 additions & 3 deletions docs/reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,14 @@
账户管理都在总览的账户区完成,手机首页相同。

- 点击标题右侧的「排序」后,每个账户下方出现上移和下移按钮,点「完成」结束。新账户追加在末尾。
- 每个账户右侧的「⋯」菜单提供合并额度与用量、重命名、归档和删除。
- 每个账户右侧的「⋯」菜单提供合并额度与用量、重命名、设置套餐、归档和删除。设置套餐只对 Claude 账户显示。
- 同一账号从多个来源采集时,在来源账户菜单选择「合并额度与用量」,选定目标账户后确认。全部历史及后续用量都会归入目标,原来源记录仍可在请求明细查看,不做跨来源去重;若两个来源都记录了同一次请求,会累计两次。同一额度窗口使用最新快照,百分比不会相加,未知或到期状态仍如实显示。目标账户的名称和套餐保留。
- 在目标账户的「合并额度与用量」弹窗查看已合并账户,点击「解除合并」恢复各自展示。原始记录不删除,同步不会覆盖合并关系;连续合并多个账户时,解除某一层也会带回合并到它的账户。合并和解除后报表需要重新计算,数据较多时稍等片刻。
- 归档账户不在账户区显示。有归档账户时,标题右侧出现「已归档 N」,点击后查看归档列表,可在菜单中恢复或删除,点「使用中账户」返回。
- 删除账户只移除账户展示,不会删除历史请求、累计统计,也不影响 Sub2API 或本机采集器。
- 重命名的名称只在 Meterleaf 中显示,最长 40 个字,不会改动上游。清空名称或选择恢复原名后,重新显示上游提供的名称。
- 套餐徽标按使用倍数分三档显示,同一档外观相同。Claude Pro 与 ChatGPT Plus 为一档,Claude Max 5x 与 ChatGPT Pro 5x 为二档,Claude Max 20x 与 ChatGPT Pro 20x 为三档。无法识别的套餐只显示名称。
- 套餐徽标按使用倍数分三档显示,同一档外观相同。Claude Pro、Claude Team Standard 与 ChatGPT Plus 为一档,Claude Max 5x、Claude Team Premium 与 ChatGPT Pro 5x 为二档,Claude Max 20x 与 ChatGPT Pro 20x 为三档。只知道订阅类型、不知道倍数时,例如只显示 Max 或 Team,不分档。无法识别的套餐只显示名称。
- 采集器读不到套餐时,例如账户已不在这台电脑上登录,可以在「设置套餐」里选择 Pro、Max 5x、Max 20x、Team Standard 或 Team Premium,选「自动识别」恢复采集器上报的套餐。设置只保存在 Meterleaf 中,不会被同步覆盖,也不会让本周期的额度预估重新起算。
- 本地归档、删除状态和账户名称不会被同步覆盖。上游删除账户后,本地历史请求仍保留。

![账户合并示例](images/account-merge.png)
Expand All @@ -58,7 +59,7 @@

本周期观测较少时,只在同一来源、同一已知套餐的连续观测区间内参考上一周期。有足够的多段观测后随当前观测跨度增加逐步调整,跨度达到 20 个百分点后完全使用本周期数据。上一周期也没有足够多段观测时,只有已观测到至少 10% 才把其加权粗估作为参考。没有可用历史时,按本周期不同百分比的累计消费加权粗估,较高百分比权重更大。账户详情说明估算依据。周期重置后重新积累当前样本,不参考久远周期。

服务端发现套餐变化后立即停止参考旧套餐,只用之后的新快照计算消费和百分比增量;百分比大幅回退(至少 5 个百分点且不高于此前最高值一半)也会重新取基准。Claude Code 只在打开 `/usage` 等刷新账户资料时更新套餐,升级时上游通常已先把百分比清零,这次清零就作为新套餐的起点,清零后首个百分比读数即可开始粗估。没有清零、还没有两个可比较的新点时暂时无法估算,有有效增量后恢复粗估。周期累计用量仍保留变化前后的全部消费,已花金额也包含变化前的消费,所以该周期的预估偏高,下个周期恢复正常。套餐观测记录从升级后开始积累,不把当前套餐补写到旧快照,也不把服务端发现时间当作上游实际生效时间。上游没有提供套餐信息或套餐标签不变的额度调整,无法保证即时识别。
只有两个不同的已知套餐之间才算套餐变化。之前不知道套餐、后来识别出或手动设置了套餐,只是补上一直成立的标签,本周期已有的读数和花费继续参与预估;暂时读不到套餐也不算变化。服务端发现套餐变化后立即停止参考旧套餐,只用之后的新快照计算消费和百分比增量;百分比大幅回退(至少 5 个百分点且不高于此前最高值一半)也会重新取基准。Claude Code 只在打开 `/usage` 等刷新账户资料时更新套餐,升级时上游通常已先把百分比清零,这次清零就作为新套餐的起点,清零后首个百分比读数即可开始粗估。没有清零、还没有两个可比较的新点时暂时无法估算,有有效增量后恢复粗估。周期累计用量仍保留变化前后的全部消费,已花金额也包含变化前的消费,所以该周期的预估偏高,下个周期恢复正常。套餐观测记录从升级后开始积累,不把当前套餐补写到旧快照,也不把服务端发现时间当作上游实际生效时间。上游没有提供套餐信息或套餐标签不变的额度调整,无法保证即时识别。

手机上预估跟在已用费用之后,桌面账户列表在右侧单独显示。缺少有效比例、可计价消费或窗口失效时保留未知,不补零。快照可能包含其他入口的消费,本地请求只覆盖已采集来源,模型组合和供应商计量规则也可能变化,因此估值不是供应商承诺额度。

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.9.11",
"version": "0.10.0",
"private": true,
"type": "module",
"license": "Apache-2.0",
Expand Down
78 changes: 66 additions & 12 deletions src/collector/claude-code/account.ts

Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import type { IngestAccount } from "../../shared/ingest";
import type { ClaudeCredentialPlan } from "./credential";
export const UNATTRIBUTED_ACCOUNT_ID = "unattributed";

Expand All @@ -11,6 +12,8 @@ export interface ClaudeAccount {
organizationType: string | null;
rateLimitTier: string | null;
billingType: string | null;
/** Team 席位档位,例如 team_tier_1;个人订阅为空。 */
seatTier: string | null;
}

export interface CachedUtilization {
Expand Down Expand Up @@ -73,6 +76,7 @@ function accountFrom(value: unknown): ClaudeAccount | null {
organizationType: text(oauth.organizationType),
rateLimitTier: text(oauth.organizationRateLimitTier),
billingType: text(oauth.billingType),
seatTier: text(oauth.seatTier),
};
}

Expand All @@ -89,23 +93,70 @@ function utilizationFrom(value: unknown): CachedUtilization | null {
};
}

/** 套餐只从速率档位或组织类型读取,不从接入方式推断。 */
export function planFor(account: ClaudeAccount): string | null {
/**
* Team 席位档位。team_tier_1 是 Premium 席位,速率档位与 Max 5x 相同;team_standard 是
* Standard 席位。Anthropic 未公开其他取值,未登记的席位只认定为 Team。
*/
function teamPlan(seatTier: string | null): string {
if (seatTier === "team_tier_1") return "team-premium";
if (seatTier === "team_standard") return "team-standard";
return "team";
}

/**
* 套餐只从上游给出的订阅类型、速率档位、组织类型或席位档位读取,不从接入方式或额度推断。
* 先用能确定倍数的依据,凭据优先于旧版 Claude Code 写在 ~/.claude.json 的字段;
* 凭据只说明 Max 或 Team、没有倍数时,排在旧字段和席位档位之后。
*/
export function planFor(
account: ClaudeAccount,
credential: ClaudeCredentialPlan | null = null,
): string | null {
const credentialTier = credential?.rateLimitTier ?? null;
const credentialType = credential?.subscriptionType ?? null;
if (credentialType === "team" && credentialTier === "default_claude_max_5x")
return "team-premium";
if (credentialType ! 8000 == "team") {
if (credentialTier === "default_claude_max_5x") return "max-5x";
if (credentialTier === "default_claude_max_20x") return "max-20x";
}
if (credentialType === "pro" || credentialType === "enterprise")
return credentialType;
const tier = account.rateLimitTier;
if (account.organizationType === "claude_team")
return tier === "default_claude_max_5x"
? "team-premium"
: teamPlan(account.seatTier);
if (tier === "default_claude_max_5x") return "max-5x";
if (tier === "default_claude_max_20x") return "max-20x";
if (account.organizationType === "claude_pro") return "pro";
if (account.seatTier?.startsWith("team_")) return teamPlan(account.seatTier);
if (credentialType === "team" || credentialType === "max")
return credentialType;
return account.organizationType;
}

/** 订阅需同时满足 Claude 订阅组织类型和订阅计费方式,任一未知都不认定为订阅。 */
export function kindFor(account: ClaudeAccount): IngestAccount["kind"] {
const organizationType = account.organizationType ?? "";
const subscriptionTypes = new Set(["pro", "max", "team", "enterprise"]);

/**
* 订阅需有订阅计费方式,且组织类型属于 Claude 订阅。新版 Claude Code 不再写组织类型,
* 缺失时由凭据的订阅类型证明;两者都缺时,订阅计费方式本身只来自 Claude 订阅登录。
* 组织类型存在但不是 Claude 订阅,或计费方式未知时,不认定为订阅。
*/
export function kindFor(
account: ClaudeAccount,
credential: ClaudeCredentialPlan | null = null,
): IngestAccount["kind"] {
const billingType = account.billingType ?? "";
return organizationType.startsWith("claude_") &&
billingType.includes("subscription")
? "subscription"
: "unknown";
if (!billingType.includes("subscription")) return "unknown";
if (account.organizationType !== null)
return account.organizationType.startsWith("claude_")
? "subscription"
: "unknown";
const subscriptionType = credential?.subscriptionType;
return subscriptionType && !subscriptionTypes.has(subscriptionType)
? "unknown"
: "subscription";
}

/**
Expand All @@ -126,13 +177,16 @@ export function subjectKeyFor(account: ClaudeAccount): string | null {
* 默认名只说明来源;套餐ç” 10BC8 ± plan 单独展示,UUID 对用户没有意义,也不放进名称。
* 多个账户需要区分时由用户在 Meterleaf 中设置别名。
*/
export function accountFact(account: ClaudeAccount): IngestAccount {
const plan = planFor(account);
export function accountFact(
account: ClaudeAccount,
credential: ClaudeCredentialPlan | null = null,
): IngestAccount {
const plan = planFor(account, credential);
return {
externalId: account.accountUuid,
name: "Claude Code",
platform: "anthropic",
kind: kindFor(account),
kind: kindFor(account, credential),
plan,
subjectKey: subjectKeyFor(account),
};
Expand Down
117 changes: 117 additions & 0 deletions src/collector/claude-code/credential.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { userInfo } from "node:os";
import { join } from "node:path";

/**
* Claude Code 登录凭据里的套餐字段。新版 Claude Code 不再把套餐写入 ~/.claude.json,
* 只有凭据还保存订阅类型和速率档位。凭据只代表当前登录账户,不带账户 UUID,
* 调用方必须确认读取前后登录账户未变,才能把套餐归给该账户。
*/
export interface ClaudeCredentialPlan {
subscriptionType: string | null;
rateLimitTier: string | null;
}

export interface CredentialLocation {
/** 已解析的 Claude Code 配置目录。 */
claudeRoot: string;
/**
* CLAUDE_CONFIG_DIR 的原值;未设置为 null。设置后 Claude Code 的钥匙串条目名带该值的
* 摘要后缀,摘要按原值计算,不是解析后的路径。
*/
configDir: string | null;
}

/** 读出凭据原文;读不到返回 null。测试可替换,生产实现见 readCredentialText。 */
export type CredentialTextReader = (
location: CredentialLocation,
) => string | null;

const keychainTimeoutMs = 5_000;

/** 与 Claude Code 保持一致的钥匙串条目名和账户名。 */
export function keychainService(location: CredentialLocation): string {
const suffix = location.configDir
? `-${createHash("sha256")
.update(location.configDir.normalize("NFC"))
.digest("hex")
.slice(0, 8)}`
: "";
return `Claude Code-credentials${suffix}`;
}

function keychainAccount(): string {
let name: string;
try {
name = process.env.USER || userInfo().username;
} catch {
name = "claude-code-user";
}
return /^[a-zA-Z0-9._-]+$/.test(name) ? name : "claude-code-user";
}

/**
* macOS 上通过系统自带的 security 读取钥匙串条目,其他平台或钥匙串没有条目时读取
* 配置目录下的 .credentials.json。只运行系统命令、只读本机,不调用 Claude Code,
* 也不发出网络请求;被拒绝或超时时返回 null,本轮不更新套餐。
*/
export const readCredentialText: CredentialTextReader = (location) => {
if (process.platform === "darwin") {
const result = spawnSync(
"/usr/bin/security",
[
"find-generic-password",
"-s",
keychainService(location),
"-a",
keychainAccount(),
"-w",
],
{ encoding: "utf8", timeout: keychainTimeoutMs, maxBuffer: 1024 * 1024 },
);
if (result.status === 0 && result.stdout) return result.stdout;
}
try {
return readFileSync(join(location.claudeRoot, ".credentials.json"), {
encoding: "utf8",
flag: "r",
});
} catch {
return null;
}
};

function text(value: unknown): string | null {
return typeof value === "string" && value.length > 0 && value.length <= 64
? value
: null;
}

/**
* 只取 claudeAiOauth 的 subscriptionType 和 rateLimitTier。令牌等其余字段不解析、
* 不保存、不记录,原文在本函数返回后即可回收。
*/
export function credentialPlanFrom(
raw: string | null,
): ClaudeCredentialPlan | null {
if (!raw) return null;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
const oauth =
parsed !== null && typeof parsed === "object"
? (parsed as Record<string, unknown>).claudeAiOauth
: null;
if (oauth === null || typeof oauth !== "object") return null;
const record = oauth as Record<string, unknown>;
const plan = {
subscriptionType: text(record.subscriptionType),
rateLimitTier: text(record.rateLimitTier),
};
return plan.subscriptionType || plan.rateLimitTier ? plan : null;
}
Loading
Loading
0