fix: correct OpenCode cache hit rates

Include cache reads and writes in the complete input denominator for OpenCode usage. Use the same calculation for stored records, summary totals, and renderer fallbacks so existing history is corrected without migration.

Add regressions for full cache hits, cache writes, mixed Runtime aggregation, and persisted conversation/system usage. Focused validation passed 41 tests; repository-wide checks were blocked by concurrent work-mode changes.

Release note: 修复 OpenCode 缓存命中率偏高或完全命中时显示为空的问题,已有用量记录在重新查询时按完整输入量计算。
This commit is contained in:
mesalogo
2026-10-04 18:03:33 +08:00
parent 4d18794e74
commit 0cd5da33f4
6 changed files with 88 additions and 23 deletions
+3 -1
View File
@@ -788,7 +788,9 @@ records are listed separately and do not introduce another feature status.
without a persistent legacy-cache truncation warning; previously lost
history is not restored. Token usage is grouped by Runtime and
model and normalizes the different OpenAI-compatible and Anthropic Messages
cache-reporting semantics when showing cache hit rate. Project and conversation
cache-reporting semantics when showing cache hit rate. OpenCode usage adds cache
reads and writes back to its uncached input count before calculating the rate,
including when querying existing records. Project and conversation
usage groups show collapsed totals by default and expand to Runtime/model child
rows; model grouping remains flat. A separate System tasks tab groups
background usage by source, with manual refresh and updates when usage is
+1
View File
@@ -119,6 +119,7 @@
### 工作管理、长期协作与工作流
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。运行记录改由 Main SQLite 保存,不再受旧 Renderer 的 500 条、单详情 4,000 字符或约 2 MB 上限影响,页面按批继续显示;不再常驻旧缓存截断警告,但不会恢复此前已丢失的历史。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;用量按项目或会话分组时默认收起并显示汇总,展开后显示 Runtime / 模型子行,按模型分组时保持平铺。独立“系统任务”页签按来源汇总后台用量,支持手动刷新和记录更新后刷新;监督回顾、图片重新生成、图谱提取和远程嵌入补充用量记录,不补算缺失的历史或供应商数据。活动按会话分组并默认收起,避免长历史占满页面。
OpenCode 的输入量已扣除缓存,计算命中率时会加回缓存读取和写入量;查询已有记录时也按此口径重算。
- [x] **紧凑会话工具记录**:逐项展开工具记录即可查看和复制结果、错误及输入参数;会话与子任务进度使用简洁行,减少重复 Runtime 摘要。OpenCode、Continue、DeepSeek Harness 和直连模型可显示已有文件路径、命令或搜索摘要,并在工具完成和会话重载后保留。
- [x] **Task 与定制任务体验**:每个产品级 Task 只关联一条 Conversation,一条 Conversation 可承载多个 Task;左侧会话列表通过行首展开按钮显示带共享状态点的 Task 子项,父会话行不重复任务标签,UI 只展示到 Task,不暴露 Job/Run 层级。新建定制任务可关联当前或新 Conversation,默认 Execute 并沿用 Runtime、工具和审批边界;重复触发复用同一 Task,文本结果回写 Conversation,独立文件和图片保留为成果。普通消息与到期 Scheduled Task 共用 Conversation 级持久发送队列,同一会话一次只执行一项;当前回复期间仍可继续发送,队列按顺序续跑,并允许删除或“立即中断并插入”。Task Center 继续作为完整索引,不建设独立 Automation Center。当前计划触发支持单次、每日和每周;高级时区、Cron、事件触发与重试治理仍按 PRD 逐步实现。详见 [Task Center PRD](./docs/features/task-and-job/task-center-prd.md) 和 [Scheduled Task PRD](./docs/features/task-and-job/scheduled-task-prd.md)。
- [x] **记忆与智能心跳**:计划触发共享增量回顾,不再单独生成心跳报告;可只更新记忆图谱,或从未决事项、分歧、修订和候选约定生成有依据的建议。接受未决事项创建暂停任务,确认约定写入长期背景;建议失败可单独重试,不撤销已完成回顾。
@@ -7395,6 +7395,49 @@ describe('AssistantDatabase', () => {
database.close()
})
it.each([false, true])('normalizes persisted OpenCode cache input in records and totals (system=%s)', async (system) => {
const database = await createDatabase()
try {
const taskId = randomUUID()
database.createTask({
id: taskId,
conversationId: system ? `knowledge:${randomUUID()}` : randomUUID(),
title: 'Cache usage',
instructions: 'Cache usage',
workMode: 'ask'
})
const calls = [
{ runtime: 'opencode', provider: 'goodbuddy-openai-chat', input: 60, cacheRead: 40, cacheWrite: 0 },
{ runtime: 'opencode', provider: 'goodbuddy-openai-responses', input: 10, cacheRead: 90, cacheWrite: 0 },
{ runtime: 'opencode', provider: 'custom', input: 0, cacheRead: 100, cacheWrite: 0 },
{ runtime: 'opencode', provider: 'goodbuddy-anthropic', input: 50, cacheRead: 30, cacheWrite: 20 },
{ runtime: 'model', provider: 'openai', input: 100, cacheRead: 40, cacheWrite: 0 },
{ runtime: 'model', provider: 'anthropic', input: 50, cacheRead: 30, cacheWrite: 20 }
]
for (const [index, call] of calls.entries()) {
database.upsertModelUsageCall({
requestId: taskId, callId: `call-${index}`, model: 'test-model',
output: 10, ...call
})
}
// Reopen persisted raw usage: existing records need no data migration.
database.close()
database.initialize('C:\\Workspace')
const summary = database.getTokenUsageSummary()
expect(summary.records).toHaveLength(6)
for (const record of summary.records) {
expect(record.cacheInput).toBe(100)
}
expect(summary.totals).toMatchObject({ callCount: 6, cacheRead: 330, cacheInput: 600 })
expect(system ? summary.systemTotals : summary.conversationTotals).toEqual(summary.totals)
expect(system ? summary.conversationTotals : summary.systemTotals).toMatchObject({
callCount: 0, cacheRead: 0, cacheInput: 0
})
} finally {
database.close()
}
})
it('persists global magic notes and AI comments without todo proposals', async () => {
const database = await createDatabase()
const globalNote = database.createMagicNote({
+9 -19
View File
@@ -5918,6 +5918,13 @@ export class AssistantDatabase {
}
getTokenUsageSummary(): TokenUsageSummary {
// OpenCode reports uncached input for every provider, including OpenAI.
const cacheInputSql = `usage.input_tokens + CASE
WHEN usage.runtime = 'opencode'
OR LOWER(usage.provider) LIKE '%anthropic%'
THEN usage.cache_read_tokens + usage.cache_write_tokens
ELSE 0
END`
const systemSourceSql = `CASE
WHEN tasks.conversation_id LIKE 'heartbeat:%' THEN 'heartbeat'
WHEN tasks.conversation_id LIKE 'supervision:%' THEN 'supervision'
@@ -5947,14 +5954,7 @@ export class AssistantDatabase {
SUM(usage.output_tokens) AS output_tokens,
SUM(usage.cache_read_tokens) AS cache_read_tokens,
SUM(usage.cache_write_tokens) AS cache_write_tokens,
SUM(
usage.input_tokens +
CASE
WHEN LOWER(usage.provider) LIKE '%anthropic%'
THEN usage.cache_read_tokens + usage.cache_write_tokens
ELSE 0
END
) AS cache_input_tokens
SUM(${cacheInputSql}) AS cache_input_tokens
FROM model_usage_calls usage
JOIN tasks ON tasks.id = usage.request_id
LEFT JOIN projects ON projects.id = tasks.project_id
@@ -6017,17 +6017,7 @@ export class AssistantDatabase {
COALESCE(SUM(usage.output_tokens), 0) AS output_tokens,
COALESCE(SUM(usage.cache_read_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(usage.cache_write_tokens), 0) AS cache_write_tokens,
COALESCE(
SUM(
usage.input_tokens +
CASE
WHEN LOWER(usage.provider) LIKE '%anthropic%'
THEN usage.cache_read_tokens + usage.cache_write_tokens
ELSE 0
END
),
0
) AS cache_input_tokens
COALESCE(SUM(${cacheInputSql}), 0) AS cache_input_tokens
FROM model_usage_calls usage
LEFT JOIN tasks ON tasks.id = usage.request_id
GROUP BY is_system`
+28
View File
@@ -125,6 +125,34 @@ describe('token usage aggregation', () => {
})
})
it.each([
{ provider: 'goodbuddy-openai-chat', input: 60, cacheRead: 40, cacheWrite: 0, rate: 0.4 },
{ provider: 'goodbuddy-openai-responses', input: 10, cacheRead: 90, cacheWrite: 0, rate: 0.9 },
{ provider: 'custom', input: 0, cacheRead: 100, cacheWrite: 0, rate: 1 },
{ provider: 'goodbuddy-anthropic', input: 50, cacheRead: 30, cacheWrite: 20, rate: 0.3 },
{ provider: 'openai', input: 100, cacheRead: 0, cacheWrite: 0, rate: 0 }
])('uses complete OpenCode input for $provider ($rate)', ({ provider, input, cacheRead, cacheWrite, rate }) => {
const usage = makeTokenUsage()
usage.records = [{
...usage.records[0]!,
runtime: 'opencode', provider, input, cacheRead, cacheWrite
}]
expect(groupTokenUsage(usage, 'model')[0]).toMatchObject({
cacheInputTokens: 100,
cacheHitRate: rate
})
usage.records[0]!.cacheInput = 100
expect(groupTokenUsage(usage, 'model')[0]?.cacheHitRate).toBe(rate)
// Mixed Runtime groups must sum complete inputs, not average percentages.
usage.records.push({ ...makeTokenUsage().records[1]!, input: 300 })
expect(groupTokenUsage(usage, 'project')[0]).toMatchObject({
cacheInputTokens: 400,
cacheHitRate: cacheRead / 400
})
})
it('uses fallback labels when grouping metadata is unavailable', () => {
const usage = makeTokenUsage()
usage.records = [
+4 -3
View File
@@ -31,7 +31,8 @@ function usesSeparatedAnthropicInput(provider: unknown): boolean {
function usageNumbers(
source: unknown,
provider?: unknown
provider?: unknown,
runtime?: unknown
): TokenUsageTotals {
const values = source as Record<string, unknown>
const read = (preferred: string, legacy: string): number => {
@@ -50,7 +51,7 @@ function usageNumbers(
Number.isFinite(reportedCacheInput)
? reportedCacheInput
: inputTokens +
(usesSeparatedAnthropicInput(provider)
(runtime === 'opencode' || usesSeparatedAnthropicInput(provider)
? cacheReadTokens + cacheWriteTokens
: 0)
@@ -172,7 +173,7 @@ export function groupTokenUsage(
records.push(record)
groupedRecords.set(identity.key, records)
}
const usage = usageNumbers(record, record.provider)
const usage = usageNumbers(record, record.provider, record.runtime)
const existing = rows.get(identity.key)
if (existing) {