3.9 成本预警 --- 退出钩子、$5 阈值对话框与多通道成本通知
对应原书 :第3章 第3.10.2节 tokenBudget.ts(预算决策引擎)及 3.3.3 节(循环不变式)中的成本控制上下文
辅助源码 :
costHook.ts(22行)、utils/billing.ts(78行)、components/CostThresholdDialog.tsx(49行)、commands/cost/cost.ts(24行)、hooks/notifs/useRateLimitWarningNotification.tsx(113行)、services/rateLimitMessages.ts(345行)、screens/REPL.tsx(成本对话框与会话切换部分)、utils/fpsTracker.ts(FPS 指标)、utils/warningHandler.ts(122行)、utils/envUtils.ts(环境变量工具)重点关注:成本 Hook 的退出时摘要打印与持久化、$5 阈值对话框的触发与确认机制、双轨 Billing Access 控制(Console vs Claude.ai)、订阅用户速率限制预警通知
1. 导语:从"记账"到"预警"的成本感知闭环
在 3.8 中,我们分析了成本控制的"四层架构"------定价层、追踪层、预算决策层、终止保护层。那套体系解决了"如何精确记账"和"何时强制停止"的问题。但仅有记账和终止是不够的------用户还需要主动感知当前的花费状态。
Claude Code 的成本预警系统通过三个独立通道实现这种感知:
| 通道 | 触发时机 | 目标用户 | 表现形式 |
|---|---|---|---|
| 退出摘要 | 进程退出时 | API Key 用户(非订阅) | stdout 打印成本明细 |
| $5 阈值对话框 | 会话累计成本 ≥ $5 时 | API Key 用户(有 billing 权限) | 模态对话框,需用户确认 |
| 速率限制预警 | 订阅用户接近配额上限时 | Claude.ai 订阅用户 | 通知栏 + 状态栏文字 |
这三个通道覆盖了两种完全不同的用户群体:API Key 用户 关心的是"这个会话花了多少美元",而订阅用户 关心的是"我的 5 小时/7 天配额还剩多少"。两套体系完全独立,通过 hasConsoleBillingAccess() 和 hasClaudeAiBillingAccess() 两个函数实现分流。
核心设计原则:成本信息不是"一刀切"地展示给所有人,而是根据用户身份、权限角色、订阅类型进行精细化分流。这避免了订阅用户看到不相关的美元金额(他们按配额而非按量付费),也避免了 API Key 用户看到不相关的配额信息。
2. 成本预警全景:三通道架构
┌─────────────────────────────────────────────┐
│ 成本预警三通道架构 │
└─────────────────────────────────────────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
┌───────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐
│ 退出摘要 │ │ $5 阈值对话框 │ │ 速率限制预警 │
│ costHook.ts │ │ CostThreshold │ │ useRateLimit │
│ │ │ Dialog.tsx │ │ WarningNotif │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
process.on('exit') useEffect 监听 useEffect 监听
│ messages 变化 claudeAiLimits
│ │ │
┌───────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐
│ hasConsole │ │ hasConsole │ │ hasClaudeAi │
│ BillingAccess │ │ BillingAccess │ │ BillingAccess │
│ () ? │ │ () ? │ │ () ? │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
API Key 用户 API Key 用户 订阅用户
(非订阅) (admin/billing) (Max/Pro/Team)
│ │ │
┌───────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐
│ formatTotal │ │ "You've spent │ │ 5h/7d/overage │
│ Cost() 打印 │ │ $5 on the │ │ 配额预警通知 │
│ + saveCosts │ │ API..." │ │ + overage 通知│
└───────────────┘ └───────────────┘ └───────────────┘
三个通道完全正交:一个用户只会命中其中一个或两个,永远不会同时命中全部三个。这种正交性由 billing.ts 中的两个访问控制函数保证。
3. 退出钩子:costHook.ts 完整解析
3.1 完整源码
typescript
// costHook.ts --- 完整 22 行
import { useEffect } from 'react'
import { formatTotalCost, saveCurrentSessionCosts } from './cost-tracker.js'
import { hasConsoleBillingAccess } from './utils/billing.js'
import type { FpsMetrics } from './utils/fpsTracker.js'
export function useCostSummary(
getFpsMetrics?: () => FpsMetrics | undefined,
): void {
useEffect(() => {
const f = () => {
if (hasConsoleBillingAccess()) {
process.stdout.write('\n' + formatTotalCost() + '\n')
}
saveCurrentSessionCosts(getFpsMetrics?.())
}
process.on('exit', f)
return () => {
process.off('exit', f)
}
}, [])
}
3.2 逐行解析
第1行 :import { useEffect } from 'react'
costHook.ts 是一个 React Hook,不是普通的 Node.js 模块。它通过 useEffect 在组件挂载时注册退出监听器,在组件卸载时移除。这保证了:
- 多次调用不会重复注册(useEffect 依赖数组为空
[]) - 组件卸载时自动清理(返回的 cleanup 函数调用
process.off)
第2-4行:导入三个依赖
| 导入 | 来源 | 职责 |
|---|---|---|
formatTotalCost |
cost-tracker.ts |
生成格式化的成本摘要字符串 |
saveCurrentSessionCosts |
cost-tracker.ts |
保存 15 个字段到项目配置 |
hasConsoleBillingAccess |
utils/billing.ts |
检查用户是否有控制台成本展示权限 |
FpsMetrics |
utils/fpsTracker.ts |
FPS 性能指标类型 |
第6-8行:函数签名
typescript
export function useCostSummary(
getFpsMetrics?: () => FpsMetrics | undefined,
): void
接收一个可选的 FPS 指标获取函数 。注意它是一个 getter 函数(() => FpsMetrics),而不是直接传入 FpsMetrics 值。这是因为退出时的 FPS 指标需要实时获取,而不是使用挂载时的快照。
第9-16行:核心逻辑
typescript
const f = () => {
if (hasConsoleBillingAccess()) {
process.stdout.write('\n' + formatTotalCost() + '\n')
}
saveCurrentSessionCosts(getFpsMetrics?.())
}
退出回调 f 做两件事:
-
有条件地打印成本摘要 :只有
hasConsoleBillingAccess()返回true时才打印。这意味着订阅用户、未认证用户、没有 admin/billing 角色的用户都看不到成本摘要。 -
无条件地保存成本数据 :无论是否有 billing access,都会调用
saveCurrentSessionCosts保存成本数据到项目配置。这确保了即使当前用户看不到成本,切换会话后恢复成本状态仍然有效。
第17-20行:注册与清理
typescript
process.on('exit', f)
return () => {
process.off('exit', f)
}
使用 Node.js 的 process.on('exit', ...) 注册同步退出钩子。注意:
exit事件的回调必须是同步的------不支持 async 操作- cleanup 函数通过
process.off('exit', f)移除监听器 - 依赖数组
[]确保只在组件挂载/卸载时执行一次
3.3 调用点:REPL.tsx
typescript
// REPL.tsx 第3822-3823行
// Register cost summary tracker
useCostSummary(useFpsMetrics());
useFpsMetrics() 是一个 React Context Hook,返回一个 () => FpsMetrics | undefined 函数。这个函数在退出时被调用,获取最终的 FPS 指标。
设计决策 :为什么用 React Hook 而不是直接在
main.tsx中注册?因为
useCostSummary需要访问 React Context 中的 FPS 指标。如果放在main.tsx中,就需要通过其他方式传递 FPS 数据。通过 React Hook,可以自然地接入组件树中的 Context,同时利用useEffect的生命周期管理自动处理注册和清理。
4. $5 阈值对话框:CostThresholdDialog
4.1 触发机制
typescript
// REPL.tsx 第1480行
const [showCostDialog, setShowCostDialog] = useState(false);
// REPL.tsx 第1506行 --- 从全局配置读取是否已确认过
const [haveShownCostDialog, setHaveShownCostDialog] =
useState(getGlobalConfig().hasAcknowledgedCostThreshold);
// REPL.tsx 第2203-2215行 --- 触发逻辑
useEffect(() => {
const totalCost = getTotalCost();
if (totalCost >= 5 /* $5 */ && !showCostDialog && !haveShownCostDialog) {
logEvent('tengu_cost_threshold_reached', {});
// Mark as shown even if the dialog won't render (no console billing
// access). Otherwise this effect re-fires on every message change for
// the rest of the session --- 200k+ spurious events observed.
setHaveShownCostDialog(true);
if (hasConsoleBillingAccess()) {
setShowCostDialog(true);
}
}
}, [messages, showCostDialog, haveShownCostDialog]);
触发条件(全部满足):
totalCost >= 5--- 会话累计成本达到 $5!showCostDialog--- 对话框当前未显示!haveShownCostDialog--- 本会话(或全局)未已确认
关键防抖设计:
注释中提到的 bug 值得注意------即使 hasConsoleBillingAccess() 返回 false(用户看不到对话框),也必须调用 setHaveShownCostDialog(true)。否则这个 useEffect 会在每次 messages 变化时重新触发,导致 20 万+ 次冗余的 analytics 事件。
这是一个典型的 React + analytics 陷阱:useEffect 的依赖 messages 在每轮对话中都会变化,如果不在第一次触发时就标记为"已处理",就会无限重复触发。
4.2 对话框组件
typescript
// CostThresholdDialog.tsx --- 编译前源码(从 sourcemap 还原)
export function CostThresholdDialog({ onDone }: Props): React.ReactNode {
return (
<Dialog
title="You've spent $5 on the Anthropic API this session."
onCancel={onDone}
>
<Box flexDirection="column">
<Text>Learn more about how to monitor your spending:</Text>
<Link url="https://code.claude.com/docs/en/costs" />
</Box>
<Select
options={[{
value: 'ok',
label: 'Got it, thanks!',
}]}
onChange={onDone}
/>
</Dialog>
)
}
极简设计------一个标题、一个文档链接、一个确认按钮。没有"停止会话"或"调整预算"的选项。这个对话框的目的是提醒 ,不是干预。
4.3 确认回调
typescript
// REPL.tsx 第4750-4758行
{focusedInputDialog === 'cost' && <CostThresholdDialog onDone={() => {
setShowCostDialog(false);
setHaveShownCostDialog(true);
saveGlobalConfig(current => ({
...current,
hasAcknowledgedCostThreshold: true // 持久化到全局配置
}));
logEvent('tengu_cost_threshold_acknowledged', {});
}} />}
确认时做四件事:
- 关闭对话框(
setShowCostDialog(false)) - 标记已显示(
setHaveShownCostDialog(true)) - 持久化到全局配置 (
hasAcknowledgedCostThreshold: true) - 上报 analytics 事件
关键设计 :hasAcknowledgedCostThreshold 被保存到全局配置(~/.claude.json),这意味着用户只需要确认一次------以后所有新会话都不会再弹出这个对话框。这是一个"一次性教育"模式:提醒用户关注成本,确认后不再打扰。
4.4 对话框优先级
typescript
// REPL.tsx 第2035行
if (allowDialogsWithAnimation && showingCostDialog) return 'cost';
成本对话框在对话框优先级链中的位置(从高到低):
tool-permission > prompt > worker-sandbox-permission > elicitation
> cost > idle-return > ultraplan-choice > ultraplan-launch > ...
成本对话框的优先级中等------高于 idle-return 和 ultraplan,但低于权限请求和用户输入提示。这意味着如果同时有权限请求弹出,成本对话框会被推迟。
4.5 加载状态保护
typescript
// REPL.tsx 第2010-2011行
const showingCostDialog = !isLoading && showCostDialog;
成本对话框不会在加载/处理中显示 。即使用户已经达到 $5 阈值,也要等到当前查询完成(isLoading 变为 false)后才弹出。这避免了打断正在进行的操作。
5. 双轨 Billing Access:Console vs Claude.ai
成本预警系统的核心分流逻辑在 utils/billing.ts 中,定义了两个独立的访问控制函数。
5.1 hasConsoleBillingAccess --- 控制台成本展示
typescript
// utils/billing.ts 第10-44行
export function hasConsoleBillingAccess(): boolean {
// 1. 环境变量全局禁用
if (isEnvTruthy(process.env.DISABLE_COST_WARNINGS)) {
return false
}
// 2. 订阅用户不显示控制台成本(他们按配额付费)
const isSubscriber = isClaudeAISubscriber()
if (isSubscriber) return false
// 3. 未认证用户不显示
const authSource = getAuthTokenSource()
const hasApiKey = getAnthropicApiKey() !== null
if (!authSource.hasToken && !hasApiKey) {
return false
}
// 4. 没有 org/workspace 角色的老用户不显示
const config = getGlobalConfig()
const orgRole = config.oauthAccount?.organizationRole
const workspaceRole = config.oauthAccount?.workspaceRole
if (!orgRole || !workspaceRole) {
return false
}
// 5. 只有 admin/billing 角色才显示
return (
['admin', 'billing'].includes(orgRole) ||
['workspace_admin', 'workspace_billing'].includes(workspaceRole)
)
}
四层过滤:
| 层 | 条件 | 返回 | 说明 |
|---|---|---|---|
| 1 | DISABLE_COST_WARNINGS 为 truthy |
false |
全局静默开关 |
| 2 | 用户是 Claude.ai 订阅者 | false |
订阅用户按配额付费,不看美元 |
| 3 | 无任何认证(无 token + 无 API key) | false |
未登录用户不显示 |
| 4 | 无 org/workspace 角色 | false |
老用户未重新认证 |
| 5 | 角色 ∈ {admin, billing, workspace_admin, workspace_billing} | true |
只有管理/计费角色可见 |
5.2 hasClaudeAiBillingAccess --- 订阅用户计费展示
typescript
// utils/billing.ts 第53-78行
export function hasClaudeAiBillingAccess(): boolean {
// 测试 mock
if (mockBillingAccessOverride !== null) {
return mockBillingAccessOverride
}
// 非订阅用户直接返回 false
if (!isClaudeAISubscriber()) {
return false
}
const subscriptionType = getSubscriptionType()
// Consumer 计划(Max/Pro)--- 个人用户始终有 billing 权限
if (subscriptionType === 'max' || subscriptionType === 'pro') {
return true
}
// Team/Enterprise --- 需要 admin/billing/owner/primary_owner 角色
const config = getGlobalConfig()
const orgRole = config.oauthAccount?.organizationRole
return (
!!orgRole &&
['admin', 'billing', 'owner', 'primary_owner'].includes(orgRole)
)
}
与 Console 版本的关键差异:
| 维度 | hasConsoleBillingAccess | hasClaudeAiBillingAccess |
|---|---|---|
| 目标用户 | API Key 用户 | Claude.ai 订阅用户 |
| 订阅用户 | 返回 false |
返回 true(有权限时) |
| Max/Pro 用户 | 返回 false |
返回 true |
| Team/Enterprise | 需要 admin/billing | 需要 admin/billing/owner/primary_owner |
| 环境变量 | 受 DISABLE_COST_WARNINGS 控制 |
不受控制 |
| Mock 支持 | 无 | 有 mockBillingAccessOverride |
5.3 互斥关系
用户身份判断
│
┌──────────┴──────────┐
│ │
isClaudeAISubscriber 非订阅用户
= true │
│ │
┌─────────▼─────────┐ │
│ │ │
hasClaudeAi hasConsole
BillingAccess BillingAccess
= true/false = false (被第2层拦截)
│ │
│ ┌────▼────┐
│ │ 有API Key│
│ │ + 角色? │
│ └────┬────┘
│ │
│ hasConsole
│ BillingAccess
│ = true/false
│
▼
显示速率限制
预警 + overage
两个函数形成互斥 关系:hasConsoleBillingAccess 对订阅用户返回 false,而 hasClaudeAiBillingAccess 对非订阅用户返回 false。这确保了一个用户只会看到一种类型的成本信息。
6. DISABLE_COST_WARNINGS:全局静默开关
6.1 isEnvTruthy 函数
typescript
// utils/envUtils.ts 第32-37行
export function isEnvTruthy(envVar: string | boolean | undefined): boolean {
if (!envVar) return false
if (typeof envVar === 'boolean') return envVar
const normalizedValue = envVar.toLowerCase().trim()
return ['1', 'true', 'yes', 'on'].includes(normalizedValue)
}
接受以下值(不区分大小写)为 truthy:'1'、'true'、'yes'、'on'。其他所有值(包括 '0'、'false'、空字符串、undefined)均为 falsy。
6.2 受控环境变量列表
typescript
// utils/managedEnvConstants.ts 第151-156行
'DISABLE_AUTOUPDATER',
'DISABLE_BUG_COMMAND',
'DISABLE_COST_WARNINGS', // ← 成本预警开关
'DISABLE_ERROR_REPORTING',
'DISABLE_FEEDBACK_COMMAND',
'DISABLE_TELEMETRY',
DISABLE_COST_WARNINGS 被列入 managedEnvConstants.ts 的受控环境变量列表。这个列表的作用是允许企业管理员通过远程管理设置(managed settings)统一控制这些环境变量,而不需要用户手动设置。
6.3 影响范围
DISABLE_COST_WARNINGS 只影响 hasConsoleBillingAccess() 的返回值,进而影响:
| 受影响功能 | 说明 |
|---|---|
| 退出时成本摘要打印 | costHook.ts 中的 formatTotalCost() 调用 |
| $5 阈值对话框显示 | REPL.tsx 中的 setShowCostDialog(true) |
不受影响的功能:
saveCurrentSessionCosts()--- 无论是否禁用警告,成本数据仍然保存hasClaudeAiBillingAccess()--- 订阅用户的速率限制预警不受影响/cost命令 --- 仍然可用,只是 API Key 用户的输出受 billing access 控制
7. /cost 命令:按用户类型分流展示
typescript
// commands/cost/cost.ts --- 完整 24 行
import { formatTotalCost } from '../../cost-tracker.js'
import { currentLimits } from '../../services/claudeAiLimits.js'
import type { LocalCommandCall } from '../../types/command.js'
import { isClaudeAISubscriber } from '../../utils/auth.js'
export const call: LocalCommandCall = async () => {
if (isClaudeAISubscriber()) {
let value: string
if (currentLimits.isUsingOverage) {
value =
'You are currently using your overages to power your Claude Code usage. '
+ 'We will automatically switch you back to your subscription rate limits '
+ 'when they reset'
} else {
value =
'You are currently using your subscription to power your Claude Code usage'
}
// [ANT-ONLY] 内部用户仍然显示完整成本
if (process.env.USER_TYPE === 'ant') {
value += `\n\n[ANT-ONLY] Showing cost anyway:\n ${formatTotalCost()}`
}
return { type: 'text', value }
}
return { type: 'text', value: formatTotalCost() }
}
7.1 三种输出模式
| 用户类型 | isUsingOverage | 输出 |
|---|---|---|
| API Key 用户 | N/A | formatTotalCost() --- 完整成本明细 |
| 订阅用户 | false |
"You are currently using your subscription..." |
| 订阅用户 | true |
"You are currently using your overages..." |
内部用户(USER_TYPE === 'ant') |
任意 | 订阅信息 + [ANT-ONLY] 完整成本 |
7.2 设计意图
订阅用户通过 /cost 看到的是配额状态 而非美元金额 。这是因为订阅用户按月付费,关心的是"我的配额还够用吗"而不是"这次会话花了多少钱"。currentLimits.isUsingOverage 来自 claudeAiLimits.ts,是一个全局可变状态,记录当前是否处于超额使用模式。
8. 订阅用户速率限制预警:useRateLimitWarningNotification
8.1 Hook 概览
typescript
// hooks/notifs/useRateLimitWarningNotification.tsx
export function useRateLimitWarningNotification(model) {
const { addNotification } = useNotifications();
const claudeAiLimits = useClaudeAiLimits();
const rateLimitWarning = getRateLimitWarning(claudeAiLimits, model);
const usingOverageText = getUsingOverageText(claudeAiLimits);
// ... 两个独立的 useEffect
}
这个 Hook 为订阅用户提供两种通知:
- 速率限制预警 --- 配额接近上限时的提前警告
- Overage 模式通知 --- 从订阅配额切换到超额使用时的通知
8.2 速率限制预警:五小时/七天窗口
typescript
// services/claudeAiLimits.ts 第53-70行
const EARLY_WARNING_CONFIGS: EarlyWarningConfig[] = [
{
rateLimitType: 'five_hour',
claimAbbrev: '5h',
windowSeconds: 5 * 60 * 60, // 5小时
thresholds: [{ utilization: 0.9, timePct: 0.72 }],
// 当使用量 ≥ 90% 且时间进度 ≤ 72% 时触发
},
{
rateLimitType: 'seven_day',
claimAbbrev: '7d',
windowSeconds: 7 * 24 * 60 * 60, // 7天
thresholds: [
{ utilization: 0.75, timePct: 0.6 },
{ utilization: 0.5, timePct: 0.35 },
{ utilization: 0.25, timePct: 0.15 },
],
},
]
预警逻辑 :当配额使用率超过阈值 且 时间窗口已过比例低于阈值时触发。这意味着用户"花钱太快了"------如果在 5 小时窗口的 72% 时间点(3.6小时)就用掉了 90% 的配额,就会收到警告。
7 天窗口有三个阈值层次:
- 25% 配额 + 15% 时间 → 早期提醒
- 50% 配额 + 35% 时间 → 中期警告
- 75% 配额 + 60% 时间 → 紧急警告
8.3 通知优先级
typescript
// Overage 通知
addNotification({
key: "limit-reached",
text: usingOverageText,
priority: "immediate" // 最高优先级
});
// 速率限制预警
addNotification({
key: "rate-limit-warning",
jsx: <Text><Text color="warning">{rateLimitWarning}</Text></Text>,
priority: "high" // 高优先级
});
两种通知的优先级不同------overage 通知为 immediate(立即显示),速率限制预警为 high(高优先级但可排队)。
8.4 远程模式排除
typescript
if (getIsRemoteMode()) {
return;
}
远程模式(通过 claude.ai 网页版使用)下不显示这些通知,因为远程 UI 有自己的通知机制。
8.5 Team/Enterprise 过滤
typescript
const isTeamOrEnterprise = subscriptionType === "team" || subscriptionType === "enterprise";
// Overage 通知只对有 billing access 的 Team/Enterprise 用户显示
if (claudeAiLimits.isUsingOverage && !hasShownOverageNotification
&& (!isTeamOrEnterprise || hasBillingAccess)) {
addNotification({ ... });
}
Team/Enterprise 用户的 overage 通知需要 hasClaudeAiBillingAccess() 返回 true(即 admin/billing/owner/primary_owner 角色)。普通成员看不到 overage 通知------因为他们无法控制是否启用超额使用。
9. Overage 模式通知:从订阅到超额的平滑过渡
9.1 三态配额状态
typescript
type QuotaStatus = 'allowed' | 'allowed_warning' | 'rejected'
| 状态 | 含义 | 用户感知 |
|---|---|---|
allowed |
配额充足 | 无通知 |
allowed_warning |
接近上限 | 预警通知 |
rejected |
配额耗尽 | 错误消息 + 可能切换到 overage |
9.2 五种速率限制类型
typescript
type RateLimitType =
| 'five_hour' // 5小时会话限制
| 'seven_day' // 7天周限制
| 'seven_day_opus' // 7天 Opus 专用限制
| 'seven_day_sonnet' // 7天 Sonnet 专用限制
| 'overage' // 超额使用限制
9.3 Overage 通知文本
typescript
// services/rateLimitMessages.ts 第303-331行
export function getUsingOverageText(limits: ClaudeAILimits): string {
const resetTime = limits.resetsAt
? formatResetTime(limits.resetsAt, true)
: ''
let limitName = ''
if (limits.rateLimitType === 'five_hour') {
limitName = 'session limit'
} else if (limits.rateLimitType === 'seven_day') {
limitName = 'weekly limit'
} else if (limits.rateLimitType === 'seven_day_opus') {
limitName = 'Opus limit'
} else if (limits.rateLimitType === 'seven_day_sonnet') {
limitName = 'Sonnet limit'
}
if (!limitName) {
return 'Now using extra usage'
}
const resetMessage = resetTime
? ` · Your ${limitName} resets ${resetTime}`
: ''
return `You're now using extra usage${resetMessage}`
}
设计要点:
- 通知文本包含重置时间,让用户知道何时恢复正常
- 不同限制类型有不同的名称展示(session/weekly/Opus/Sonnet)
- 通用兜底文本为 "Now using extra usage"
9.4 接近超额上限的警告
typescript
// services/rateLimitMessages.ts 第51-57行
if (limits.isUsingOverage) {
if (limits.overageStatus === 'allowed_warning') {
return {
message: "You're close to your extra usage spending limit",
severity: 'warning',
}
}
}
当用户处于 overage 模式 且 overage 配额也接近上限时,会显示额外的警告消息。这是成本预警的"最后一道防线"------之后就是 rejected 状态,用户被完全阻止。
10. StatusLine 实时成本暴露
typescript
// components/StatusLine.tsx 第83-89行
cost: {
total_cost_usd: getTotalCost(),
total_duration_ms: getTotalDuration(),
total_api_duration_ms: getTotalAPIDuration(),
total_lines_added: getTotalLinesAdded(),
total_lines_removed: getTotalLinesRemoved()
},
StatusLine 组件将成本数据暴露为结构化字段,供外部消费者(如 IDE 集成、状态栏插件)使用。注意这里没有 billing access 检查------StatusLine 的成本数据始终被收集,只是展示与否由消费者决定。
getTotalCost() 是 getTotalCostUSD 的重导出别名:
typescript
// cost-tracker.ts 第49-50行
export {
getTotalCostUSD as getTotalCost,
// ...
}
11. 会话切换成本保存与恢复流程
当用户通过 /resume 或 /branch 切换会话时,成本状态需要完整保存和恢复。这个流程在 REPL.tsx 中实现:
typescript
// REPL.tsx 第1833-1841行
// 1. 先读取目标会话的成本(因为 save 会覆盖配置)
const targetSessionCosts = getStoredSessionCosts(sessionId);
// 2. 保存当前会话的成本
saveCurrentSessionCosts();
// 3. 重置成本状态
resetCostState();
// 4. 恢复目标会话的成本(如果有匹配的 sessionId)
// → 通过 setCostStateForRestore() 在后续流程中完成
四步流程的必要性:
步骤1: getStoredSessionCosts(targetId)
→ 读取项目配置中的 lastSessionId
→ 如果 lastSessionId === targetId,返回存储的成本
→ 否则返回 undefined
步骤2: saveCurrentSessionCosts()
→ 将当前 STATE 中的成本写入项目配置
→ ⚠️ 这会覆盖步骤1读取的数据!
→ 所以必须先执行步骤1
步骤3: resetCostState()
→ 清空 STATE.totalCostUSD、modelUsage 等
→ 为恢复目标会话成本准备干净的 slate
步骤4: restoreCostStateForSession(targetId)
→ 使用步骤1获取的 targetSessionCosts
→ 通过 setCostStateForRestore() 恢复到 STATE
11.1 saveCurrentSessionCosts 保存的 15 个字段
typescript
// cost-tracker.ts 第143-175行
export function saveCurrentSessionCosts(fpsMetrics?: FpsMetrics): void {
saveCurrentProjectConfig(current => ({
...current,
lastCost: getTotalCostUSD(), // 1. 总美元成本
lastAPIDuration: getTotalAPIDuration(), // 2. API 调用总时长
lastAPIDurationWithoutRetries: getTotalAPIDurationWithoutRetries(), // 3. 无重试时长
lastToolDuration: getTotalToolDuration(), // 4. 工具执行时长
lastDuration: getTotalDuration(), // 5. 墙钟总时长
lastLinesAdded: getTotalLinesAdded(), // 6. 新增行数
lastLinesRemoved: getTotalLinesRemoved(), // 7. 删除行数
lastTotalInputTokens: getTotalInputTokens(), // 8. 输入 token
lastTotalOutputTokens: getTotalOutputTokens(), // 9. 输出 token
lastTotalCacheCreationInputTokens: getTotalCacheCreationInputTokens(), // 10. 缓存创建
lastTotalCacheReadInputTokens: getTotalCacheReadInputTokens(), // 11. 缓存读取
lastTotalWebSearchRequests: getTotalWebSearchRequests(), // 12. Web 搜索次数
lastFpsAverage: fpsMetrics?.averageFps, // 13. 平均 FPS
lastFpsLow1PctFps: fpsMetrics?.low1PctFps, // 14. 最低 1% FPS
lastModelUsage: Object.fromEntries(/* ... */), // 15. 按模型分拆用量
lastSessionId: getSessionId(), // 16. 会话 ID(用于匹配)
}))
}
11.2 恢复时的 sessionId 匹配
typescript
// cost-tracker.ts 第87-96行
export function getStoredSessionCosts(
sessionId: string,
): StoredCostState | undefined {
const projectConfig = getCurrentProjectConfig()
// 只有当存储的 lastSessionId 与请求的 sessionId 匹配时才返回
if (projectConfig.lastSessionId !== sessionId) {
return undefined
}
// ... 构建并返回 StoredCostState
}
匹配逻辑 :如果用户先在会话 A 中工作(成本已保存),然后打开会话 B(成本覆盖了 A 的数据),再切回会话 A 时,lastSessionId 已经是 B,不匹配 A,因此返回 undefined------会话 A 的成本丢失。
这是一个已知的设计限制 :项目配置中只存储最后一个会话的成本数据。这不是 bug,而是在单文件配置约束下的合理折衷------如果要支持多会话成本独立存储,需要引入独立的成本存储文件。
12. FPS 指标与成本的联合持久化
12.1 FpsMetrics 类型
typescript
// utils/fpsTracker.ts 第1-4行
export type FpsMetrics = {
averageFps: number // 平均帧率
low1PctFps: number // 最低 1% 帧率
}
12.2 为什么成本保存需要 FPS?
FPS 指标与成本一起保存到项目配置(lastFpsAverage、lastFpsLow1PctFps),用于:
- 性能回归检测:对比不同会话的 FPS 数据,发现渲染性能退化
- 成本-性能关联分析:高成本会话是否伴随低 FPS(可能是渲染负载过重)
- 用户体验监控:低 FPS 意味着卡顿,可能影响用户对成本价值的感知
12.3 获取时机
typescript
// costHook.ts 第15行
saveCurrentSessionCosts(getFpsMetrics?.())
FPS 指标在进程退出时获取------这是最终的性能快照,反映了整个会话的渲染性能。
13. 进程退出钩子全景:costHook 在生态中的位置
process.on('exit', ...) 在 Claude Code 中被多处使用,costHook 是其中之一。
13.1 所有退出钩子一览
| 文件 | 职责 | 同步/异步 |
|---|---|---|
costHook.ts |
打印成本摘要 + 保存成本数据 | 同步 |
main.tsx |
resetCursor() --- 恢复终端光标 |
同步 |
context/stats.tsx |
flush() --- 保存会话指标到配置 |
同步 |
utils/telemetry/instrumentation.ts |
forceFlush() --- 刷新日志和追踪 |
同步 |
utils/telemetry/perfettoTracing.ts |
写入 Perfetto 追踪文件 | 同步 |
services/analytics/growthbook.ts |
client?.destroy() --- 销毁 GrowthBook 客户端 |
同步 |
services/lsp/LSPClient.ts |
LSP 客户端清理 | 同步 |
utils/nativeInstaller/pidLock.ts |
PID 锁文件清理 | 同步 |
13.2 执行顺序
Node.js 的 exit 事件回调按注册顺序同步执行。costHook 通过 React useEffect 在 REPL 组件挂载时注册,通常在 main.tsx 的 resetCursor 之后、telemetry forceFlush 之前。
13.3 同步约束
process.on('exit', ...) 的回调必须是同步的。这意味着:
- 不能使用
async/await - 不能使用 Promise
- 不能使用
fs.writeFile(异步),但可以使用fs.writeFileSync(同步)
saveCurrentSessionCosts 内部使用 saveCurrentProjectConfig,后者最终使用同步文件写入。这确保了在进程退出时成本数据能够可靠保存。
13.4 与 warningHandler 的对比
typescript
// utils/warningHandler.ts --- 全局警告处理器
export function initializeWarningHandler(): void {
// 移除默认的 Node.js 警告处理器
process.removeAllListeners('warning')
// 安装自定义处理器
warningHandler = (warning: Error) => {
// 上报到 analytics
logEvent('tengu_node_warning', { ... })
// 对用户隐藏所有警告
}
process.on('warning', warningHandler)
}
warningHandler 监听的是 process.on('warning', ...)(Node.js 警告事件),而 costHook 监听的是 process.on('exit', ...)(进程退出事件)。两者完全独立,但都体现了 Claude Code "拦截系统事件 → 上报到 analytics → 对用户透明" 的设计模式。
14. 设计模式提炼
模式一:条件展示 + 无条件保存
typescript
// costHook.ts
const f = () => {
if (hasConsoleBillingAccess()) { // 条件展示
process.stdout.write(formatTotalCost())
}
saveCurrentSessionCosts(...) // 无条件保存
}
模式:信息的"展示"和"保存"分离。展示受权限控制,保存始终执行。
好处:
- 即使用户看不到成本(如订阅用户),成本数据仍然被记录
- 会话切换时的成本恢复不依赖于当前用户的 billing access
- 简化了权限逻辑------只需要在展示层做一次检查
模式二:一次性教育 + 持久化确认
typescript
// REPL.tsx
saveGlobalConfig(current => ({
...current,
hasAcknowledgedCostThreshold: true // 全局持久化
}));
模式:重要的提醒只展示一次,确认状态持久化到全局配置。
好处:
- 避免重复打扰已知晓的用户
- 跨会话生效------新会话不会重复弹出
- 配合 analytics 事件(
tengu_cost_threshold_reached/tengu_cost_threshold_acknowledged)可以追踪用户行为
模式三:双轨分流 --- 按用户身份选择通知通道
API Key 用户 → Console Billing Access → 退出摘要 + $5 对话框
订阅用户 → Claude.ai Billing Access → 速率限制预警 + Overage 通知
模式:同一关注点(成本感知)针对不同用户群体提供完全不同的通知机制。
好处:
- 每种用户只看到与自己相关的信息
- 避免订阅用户看到不理解的美元金额
- 避免 API Key 用户看到不相关的配额信息
- 两套系统完全独立,互不干扰
15. 原书对照验证
| # | 原书描述 | 代码实际 | 验证 |
|---|---|---|---|
| 1 | "Token Budget检查:每轮结束后检查累计token使用量是否超过预算" | tokenBudget.ts 的 checkTokenBudget() 在每轮循环后调用 |
✅ 准确 |
| 2 | "TokenBudgetDecision = continue | stop | warn_and_continue" | 实际只有 ContinueDecision 和 StopDecision,无 warn_and_continue |
❌ 原书不准确(详见 3.8 笔记) |
| 3 | 原书未提及 costHook.ts | costHook.ts 是独立的 React Hook,负责退出时成本摘要和持久化 |
❌ 原书未描述 |
| 4 | 原书未提及 $5 阈值对话框 | CostThresholdDialog.tsx 在累计成本 ≥ $5 时弹出,确认后全局持久化 |
❌ 原书未描述 |
| 5 | 原书未提及双轨 Billing Access | hasConsoleBillingAccess vs hasClaudeAiBillingAccess 实现用户分流 |
❌ 原书未描述 |
| 6 | 原书未提及 DISABLE_COST_WARNINGS | 环境变量全局静默成本警告,受 managedEnvConstants.ts 管理 |
❌ 原书未描述 |
| 7 | 原书未提及速率限制预警 | 5小时/7天窗口的多阈值递进式预警 | ❌ 原书未描述 |
| 8 | 原书未提及 Overage 模式通知 | 订阅用户从配额切换到超额使用时的即时通知 | ❌ 原书未描述 |
| 9 | 原书未提及 FPS 指标与成本的联合保存 | saveCurrentSessionCosts 接收 FpsMetrics 参数 |
❌ 原书未描述 |
| 10 | 原书未提及会话切换时的成本保存/恢复流程 | REPL.tsx 中的四步流程(读取→保存→重置→恢复) | ❌ 原书未描述 |
| 11 | "成本高:调用LLM生成摘要本身需要消耗token" | 这是指上下文压缩的成本,与成本预警系统间接相关 | ✅ 准确(但属于 3.7 范畴) |
| 12 | 原书描述 tokenBudget.ts 有"预算决策"职责 | tokenBudget 只做"继续/停止"决策,成本"预警"由独立的 costHook + Dialog + RateLimit 体系完成 | ⚠️ 原书将"预警"和"决策"混为一谈 |
验证总结
| 状态 | 数量 | 说明 |
|---|---|---|
| ✅ 准确 | 2 | 原书描述与代码一致 |
| ❌ 原书未描述 | 8 | costHook、$5 对话框、双轨 Billing、DISABLE 开关、速率预警、Overage 通知、FPS 联合保存、会话切换流程 |
| ❌ 原书不准确 | 1 | 三态决策实际只有两态 |
| ⚠️ 偏少 | 1 | 将"预警"和"决策"混为一谈 |
核心发现:原书第3章对成本"预警"机制的描述几乎为空白。原书将"Token Budget 检查"视为唯一的成本控制手段,但实际上 Claude Code 有一套完整的多通道成本预警系统:
- API Key 用户:退出摘要 + $5 对话框(主动提醒)
- 订阅用户:5h/7d 速率限制预警 + Overage 通知(实时监控)
- 所有用户:成本数据始终保存(即使不展示)
这套系统体现了 Claude Code 对不同用户群体的精细化设计------API Key 用户需要关心"花了多少钱",订阅用户需要关心"配额够不够用",两套通知机制完全独立但共享底层成本追踪数据。
附录:源码文件索引
| 文件 | 行数 | 职责 |
|---|---|---|
costHook.ts |
22 | 退出钩子:注册 process.on('exit') 回调 |
utils/billing.ts |
78 | 双轨 Billing Access 控制 |
components/CostThresholdDialog.tsx |
49 | $5 阈值对话框组件 |
commands/cost/cost.ts |
24 | /cost 命令实现 |
hooks/notifs/useRateLimitWarningNotification.tsx |
113 | 订阅用户速率限制预警 Hook |
services/rateLimitMessages.ts |
345 | 速率限制消息生成 |
services/claudeAiLimits.ts |
~300 | 速率限制管理与配额状态 |
utils/fpsTracker.ts |
~60 | FPS 指标追踪 |
utils/warningHandler.ts |
122 | 全局 Node.js 警告处理器 |
utils/envUtils.ts |
~120 | 环境变量工具(isEnvTruthy) |
utils/managedEnvConstants.ts |
~200 | 受控环境变量列表 |
screens/REPL.tsx |
~5000 | REPL 主屏幕(成本对话框触发与确认) |
components/StatusLine.tsx |
~200 | 状态栏(成本数据暴露) |
cost-tracker.ts |
324 | 成本追踪核心(详见 3.8 笔记) |
下一篇 :按照
SOURCE_CODE_READING_PLAN.md计划,阶段二剩余 3.10 API 通信(services/api/)。如果准备继续,可以深入分析 API 调用层的流式响应处理、重试机制、错误恢复等。或者直接进入阶段三:工具系统(第4章),重点分析Tool.ts/tools.ts工具基类与注册表、BashTool七层安全防御、FileEditTool权限检查等。