Files
claude-code/src/cost-tracker.ts
claude-code-best 2e9aaf4993 feat: ACP 协议版本 remote control (#293)
* fix: 添加 usage 字段缺失时的防御性防护

第三方 API(如智谱 GLM)在某些流式响应中不返回 usage 字段,
导致 usage.input_tokens 访问 undefined 崩溃并连锁影响后续所有请求。

- claude.ts: content_block_stop 创建消息时 fallback 到 EMPTY_USAGE
- LocalAgentTask.tsx: usage 为 undefined 时提前返回
- tokens.ts: getTokenCountFromUsage 加 null guard 和 ?? 0
- cost-tracker.ts: input_tokens/output_tokens 加 ?? 0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: ACP Plan 展示 — 支持 session/update plan 类型的可视化

补全 PlanUpdate 类型定义(PlanEntry/Priority/Status),新建 PlanView 组件
渲染进度条、状态图标和优先级标签,在 ChatInterface 中处理 plan 更新逻辑。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: 穷鬼模式下跳过 verification agent 以节省 token

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: 补充 RCS 后端 + 前端测试覆盖 (+116 tests)

后端新增 3 个测试文件 (70 tests):
- automationState: normalize/snapshot/equals 纯函数
- client-payload: toClientPayload 协议转换
- transport-normalize: normalizePayload + extractContent

前端新增 2 个测试文件 (46 tests):
- utils: formatTime/statusClass/truncate/extractEventText 等
- api-client: getUuid/setUuid/api GET/POST 错误处理

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: RCS ACP 页面添加权限模式选择器 + 权限响应修复

- 新增权限模式选择器 UI(6种模式:默认/自动接受编辑/跳过权限/规划/不询问/自动判断)
- 权限模式通过 ACP _meta 从 web → acp-link → agent 全链路传递
- 修复 PermissionPanel 点击"允许"发送 cancelled 而非 selected 的 bug
- 权限模式和模型选择持久化到 localStorage
- acp-link 直接连接路径同步支持 permissionMode 透传

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: RCS Web UI 重构 + QR 修复 + ACP 扫描自动跳转

- RCS Web UI 组件全面重构: Dialog 迁移 Radix UI, lazy loading,
  主题系统改进, 组件样式优化
- IdentityPanel QR 码显示修复: requestAnimationFrame 延迟绘制
  解决 Radix Dialog Portal 挂载时序问题
- ACP QR 扫描自动跳转: IdentityPanel 扫描 ACP 格式 { url, token }
  后存储 sessionStorage 并跳转 /code/?acp=1
- 新增 ACPDirectView 组件: ACP 直连视图, 用 ACPClient 连接并
  渲染 ACPMain 聊天界面

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: ACP 权限管道改进 — 模式同步 + bypass 检测 + 统一权限流水线

- agent.ts: applySessionMode 同步 appState.toolPermissionContext.mode
- agent.ts: bypassPermissions 可用性检测 (非 root 或 sandbox 环境)
- permissions.ts: createAcpCanUseTool 接入 hasPermissionsToUseTool
  统一权限流水线, 替代原来分散的处理逻辑
- permissions.ts: 支持 onModeChange 回调, 模式变更时实时同步

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: acp-link 支持 permissionMode 默认值传递给 agent

客户端 (Zed/VS Code 等) 的 new_session 不一定携带 permissionMode,
导致 agent 收到 _meta: undefined, permission 回退到 default。

修复: handleNewSession 使用 fallback 链:
  客户端传值 > config.permissionMode > ACP_PERMISSION_MODE 环境变量

使用: ACP_PERMISSION_MODE=auto acp-link claude

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: 更新文档及说明

* fix: 修复类型错误

* chore: 提交脚本

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 21:54:22 +08:00

324 lines
10 KiB
TypeScript

import type { BetaUsage as Usage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import chalk from 'chalk'
import {
addToTotalCostState,
addToTotalLinesChanged,
getCostCounter,
getModelUsage,
getSdkBetas,
getSessionId,
getTokenCounter,
getTotalAPIDuration,
getTotalAPIDurationWithoutRetries,
getTotalCacheCreationInputTokens,
getTotalCacheReadInputTokens,
getTotalCostUSD,
getTotalDuration,
getTotalInputTokens,
getTotalLinesAdded,
getTotalLinesRemoved,
getTotalOutputTokens,
getTotalToolDuration,
getTotalWebSearchRequests,
getUsageForModel,
hasUnknownModelCost,
resetCostState,
resetStateForTests,
setCostStateForRestore,
setHasUnknownModelCost,
} from './bootstrap/state.js'
import type { ModelUsage } from './entrypoints/agentSdkTypes.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from './services/analytics/index.js'
import { getAdvisorUsage } from './utils/advisor.js'
import {
getCurrentProjectConfig,
saveCurrentProjectConfig,
} from './utils/config.js'
import {
getContextWindowForModel,
getModelMaxOutputTokens,
} from './utils/context.js'
import { isFastModeEnabled } from './utils/fastMode.js'
import { formatDuration, formatNumber } from './utils/format.js'
import type { FpsMetrics } from './utils/fpsTracker.js'
import { getCanonicalName } from './utils/model/model.js'
import { calculateUSDCost } from './utils/modelCost.js'
export {
getTotalCostUSD as getTotalCost,
getTotalDuration,
getTotalAPIDuration,
getTotalAPIDurationWithoutRetries,
addToTotalLinesChanged,
getTotalLinesAdded,
getTotalLinesRemoved,
getTotalInputTokens,
getTotalOutputTokens,
getTotalCacheReadInputTokens,
getTotalCacheCreationInputTokens,
getTotalWebSearchRequests,
formatCost,
hasUnknownModelCost,
resetStateForTests,
resetCostState,
setHasUnknownModelCost,
getModelUsage,
getUsageForModel,
}
type StoredCostState = {
totalCostUSD: number
totalAPIDuration: number
totalAPIDurationWithoutRetries: number
totalToolDuration: number
totalLinesAdded: number
totalLinesRemoved: number
lastDuration: number | undefined
modelUsage: { [modelName: string]: ModelUsage } | undefined
}
/**
* Gets stored cost state from project config for a specific session.
* Returns the cost data if the session ID matches, or undefined otherwise.
* Use this to read costs BEFORE overwriting the config with saveCurrentSessionCosts().
*/
export function getStoredSessionCosts(
sessionId: string,
): StoredCostState | undefined {
const projectConfig = getCurrentProjectConfig()
// Only return costs if this is the same session that was last saved
if (projectConfig.lastSessionId !== sessionId) {
return undefined
}
// Build model usage with context windows
let modelUsage: { [modelName: string]: ModelUsage } | undefined
if (projectConfig.lastModelUsage) {
modelUsage = Object.fromEntries(
Object.entries(projectConfig.lastModelUsage).map(([model, usage]) => [
model,
{
...usage,
contextWindow: getContextWindowForModel(model, getSdkBetas()),
maxOutputTokens: getModelMaxOutputTokens(model).default,
},
]),
)
}
return {
totalCostUSD: projectConfig.lastCost ?? 0,
totalAPIDuration: projectConfig.lastAPIDuration ?? 0,
totalAPIDurationWithoutRetries:
projectConfig.lastAPIDurationWithoutRetries ?? 0,
totalToolDuration: projectConfig.lastToolDuration ?? 0,
totalLinesAdded: projectConfig.lastLinesAdded ?? 0,
totalLinesRemoved: projectConfig.lastLinesRemoved ?? 0,
lastDuration: projectConfig.lastDuration,
modelUsage,
}
}
/**
* Restores cost state from project config when resuming a session.
* Only restores if the session ID matches the last saved session.
* @returns true if cost state was restored, false otherwise
*/
export function restoreCostStateForSession(sessionId: string): boolean {
const data = getStoredSessionCosts(sessionId)
if (!data) {
return false
}
setCostStateForRestore(data)
return true
}
/**
* Saves the current session's costs to project config.
* Call this before switching sessions to avoid losing accumulated costs.
*/
export function saveCurrentSessionCosts(fpsMetrics?: FpsMetrics): void {
saveCurrentProjectConfig(current => ({
...current,
lastCost: getTotalCostUSD(),
lastAPIDuration: getTotalAPIDuration(),
lastAPIDurationWithoutRetries: getTotalAPIDurationWithoutRetries(),
lastToolDuration: getTotalToolDuration(),
lastDuration: getTotalDuration(),
lastLinesAdded: getTotalLinesAdded(),
lastLinesRemoved: getTotalLinesRemoved(),
lastTotalInputTokens: getTotalInputTokens(),
lastTotalOutputTokens: getTotalOutputTokens(),
lastTotalCacheCreationInputTokens: getTotalCacheCreationInputTokens(),
lastTotalCacheReadInputTokens: getTotalCacheReadInputTokens(),
lastTotalWebSearchRequests: getTotalWebSearchRequests(),
lastFpsAverage: fpsMetrics?.averageFps,
lastFpsLow1Pct: fpsMetrics?.low1PctFps,
lastModelUsage: Object.fromEntries(
Object.entries(getModelUsage()).map(([model, usage]) => [
model,
{
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cacheReadInputTokens: usage.cacheReadInputTokens,
cacheCreationInputTokens: usage.cacheCreationInputTokens,
webSearchRequests: usage.webSearchRequests,
costUSD: usage.costUSD,
},
]),
),
lastSessionId: getSessionId(),
}))
}
function formatCost(cost: number, maxDecimalPlaces: number = 4): string {
return `$${cost > 0.5 ? round(cost, 100).toFixed(2) : cost.toFixed(maxDecimalPlaces)}`
}
function formatModelUsage(): string {
const modelUsageMap = getModelUsage()
if (Object.keys(modelUsageMap).length === 0) {
return 'Usage: 0 input, 0 output, 0 cache read, 0 cache write'
}
// Accumulate usage by short name
const usageByShortName: { [shortName: string]: ModelUsage } = {}
for (const [model, usage] of Object.entries(modelUsageMap)) {
const shortName = getCanonicalName(model)
if (!usageByShortName[shortName]) {
usageByShortName[shortName] = {
inputTokens: 0,
outputTokens: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
webSearchRequests: 0,
costUSD: 0,
contextWindow: 0,
maxOutputTokens: 0,
}
}
const accumulated = usageByShortName[shortName]
accumulated.inputTokens += usage.inputTokens
accumulated.outputTokens += usage.outputTokens
accumulated.cacheReadInputTokens += usage.cacheReadInputTokens
accumulated.cacheCreationInputTokens += usage.cacheCreationInputTokens
accumulated.webSearchRequests += usage.webSearchRequests
accumulated.costUSD += usage.costUSD
}
let result = 'Usage by model:'
for (const [shortName, usage] of Object.entries(usageByShortName)) {
const usageString =
` ${formatNumber(usage.inputTokens)} input, ` +
`${formatNumber(usage.outputTokens)} output, ` +
`${formatNumber(usage.cacheReadInputTokens)} cache read, ` +
`${formatNumber(usage.cacheCreationInputTokens)} cache write` +
(usage.webSearchRequests > 0
? `, ${formatNumber(usage.webSearchRequests)} web search`
: '') +
` (${formatCost(usage.costUSD)})`
result += `\n` + `${shortName}:`.padStart(21) + usageString
}
return result
}
export function formatTotalCost(): string {
const costDisplay =
formatCost(getTotalCostUSD()) +
(hasUnknownModelCost()
? ' (costs may be inaccurate due to usage of unknown models)'
: '')
const modelUsageDisplay = formatModelUsage()
return chalk.dim(
`Total cost: ${costDisplay}\n` +
`Total duration (API): ${formatDuration(getTotalAPIDuration())}
Total duration (wall): ${formatDuration(getTotalDuration())}
Total code changes: ${getTotalLinesAdded()} ${getTotalLinesAdded() === 1 ? 'line' : 'lines'} added, ${getTotalLinesRemoved()} ${getTotalLinesRemoved() === 1 ? 'line' : 'lines'} removed
${modelUsageDisplay}`,
)
}
function round(number: number, precision: number): number {
return Math.round(number * precision) / precision
}
function addToTotalModelUsage(
cost: number,
usage: Usage,
model: string,
): ModelUsage {
const modelUsage = getUsageForModel(model) ?? {
inputTokens: 0,
outputTokens: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
webSearchRequests: 0,
costUSD: 0,
contextWindow: 0,
maxOutputTokens: 0,
}
modelUsage.inputTokens += usage.input_tokens ?? 0
modelUsage.outputTokens += usage.output_tokens ?? 0
modelUsage.cacheReadInputTokens += usage.cache_read_input_tokens ?? 0
modelUsage.cacheCreationInputTokens += usage.cache_creation_input_tokens ?? 0
modelUsage.webSearchRequests +=
usage.server_tool_use?.web_search_requests ?? 0
modelUsage.costUSD += cost
modelUsage.contextWindow = getContextWindowForModel(model, getSdkBetas())
modelUsage.maxOutputTokens = getModelMaxOutputTokens(model).default
return modelUsage
}
export function addToTotalSessionCost(
cost: number,
usage: Usage,
model: string,
): number {
const modelUsage = addToTotalModelUsage(cost, usage, model)
addToTotalCostState(cost, modelUsage, model)
const attrs =
isFastModeEnabled() && usage.speed === 'fast'
? { model, speed: 'fast' }
: { model }
getCostCounter()?.add(cost, attrs)
getTokenCounter()?.add(usage.input_tokens, { ...attrs, type: 'input' })
getTokenCounter()?.add(usage.output_tokens, { ...attrs, type: 'output' })
getTokenCounter()?.add(usage.cache_read_input_tokens ?? 0, {
...attrs,
type: 'cacheRead',
})
getTokenCounter()?.add(usage.cache_creation_input_tokens ?? 0, {
...attrs,
type: 'cacheCreation',
})
let totalCost = cost
for (const advisorUsage of getAdvisorUsage(usage)) {
const advisorCost = calculateUSDCost(advisorUsage.model, advisorUsage)
logEvent('tengu_advisor_tool_token_usage', {
advisor_model:
advisorUsage.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
input_tokens: advisorUsage.input_tokens,
output_tokens: advisorUsage.output_tokens,
cache_read_input_tokens: advisorUsage.cache_read_input_tokens ?? 0,
cache_creation_input_tokens:
advisorUsage.cache_creation_input_tokens ?? 0,
cost_usd_micros: Math.round(advisorCost * 1_000_000),
})
totalCost += addToTotalSessionCost(
advisorCost,
advisorUsage,
advisorUsage.model,
)
}
return totalCost
}