mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
* 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>
324 lines
10 KiB
TypeScript
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
|
|
}
|