From 4bcf90268d13569189cd8b53caa2faf0ae3eb961 Mon Sep 17 00:00:00 2001 From: AlbertLuo <46886876+kokotao@users.noreply.github.com> Date: Wed, 3 Jun 2026 08:57:25 +0800 Subject: [PATCH] release codex token usage 0.1.6 --- index.json | 11 +- package.json | 2 +- scripts/codex-token-usage.js | 643 ++++++++++++++++++++++++++++++-- tests/codex-token-usage.test.js | 442 +++++++++++++++++++++- 4 files changed, 1055 insertions(+), 43 deletions(-) diff --git a/index.json b/index.json index ed8bccb..1e15030 100644 --- a/index.json +++ b/index.json @@ -1,22 +1,23 @@ { "version": 1, - "updated_at": "2026-06-02T06:07:06.349Z", + "updated_at": "2026-06-02T11:14:21.482Z", "scripts": [ { "id": "codex-token-usage", "name": "Codex Token Usage", - "description": "在 Codex 每次回复完成后显示本次响应的输入、输出、缓存 token 和耗时。", - "version": "0.1.5", + "description": "在 Codex 每次回复完成后显示本轮调用合计、输入输出、缓存命中、上下文用量和耗时,并支持按会话恢复历史统计。", + "version": "0.1.6", "author": "Albert_Luo", "tags": [ "codex", "tokens", "usage", - "metrics" + "metrics", + "cache" ], "homepage": "https://github.com/kokotao/codex-token-usage-script", "script_url": "https://raw.githubusercontent.com/kokotao/codex-token-usage-script/main/scripts/codex-token-usage.js", - "sha256": "2bffb40de30ed4406713b188682b8f1c9d21163a27fe5a03ad041bf20d6b0025" + "sha256": "5da53a487181a02ea8cc0aeca7974c4f708f35585cb60f2fdd575a9504caf05b" } ] } diff --git a/package.json b/package.json index 9bd4aa9..3508737 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-token-usage-script", - "version": "0.1.5", + "version": "0.1.6", "private": true, "type": "commonjs", "scripts": { diff --git a/scripts/codex-token-usage.js b/scripts/codex-token-usage.js index 21cc738..2e9be68 100644 --- a/scripts/codex-token-usage.js +++ b/scripts/codex-token-usage.js @@ -2,13 +2,16 @@ "use strict"; const SCRIPT_ID = "codex-token-usage"; - const SCRIPT_VERSION = "0.1.5"; + const SCRIPT_VERSION = "0.1.6"; const BADGE_CLASS = "codex-token-usage-badge"; const STYLE_ID = "codex-token-usage-style"; const RECENT_LIMIT = 20; const DEBUG_LIMIT = 50; + const LEDGER_LIMIT = 500; const CONTEXT_POLL_INTERVAL_MS = 1000; const TURN_IDLE_TIMEOUT_MS = 120000; + const CONTEXT_MERGE_WINDOW_MS = 30000; + const CROSS_SOURCE_DEDUPE_WINDOW_MS = 3000; const STORAGE_KEY = "__codexTokenUsageRecentDetails"; if (window.__codexTokenUsageScriptInstalled && window.__codexTokenUsageVersion === SCRIPT_VERSION) return; @@ -19,13 +22,19 @@ lastMetric: null, lastMetricKey: "", recent: [], + ledger: [], byConversation: Object.create(null), + byScope: Object.create(null), + turnsByScope: Object.create(null), + activeProjectId: "", activeConversationId: "", currentTurn: null, + eventSeq: 0, turnSeq: 0, turnStartedAt: 0, contextPollTimer: 0, pendingTurnStartAt: 0, + historyRestoreState: Object.create(null), debug: [], }; @@ -36,6 +45,20 @@ currentTurn: null, recent: [], debug: state.debug, + export: () => ({ + version: SCRIPT_VERSION, + activeProjectId: currentProjectId(), + activeConversationId: currentConversationId(), + activeScopeKey: currentScopeKey(), + last: null, + currentTurn: null, + calls: [], + ledgerEvents: [], + recent: [], + debug: state.debug.slice(), + storedDetails: readStoredDetails(), + turns: [], + }), }; function normalizeNumber(value) { @@ -49,9 +72,9 @@ const outputTokens = normalizeNumber( raw.output_tokens ?? raw.outputTokens ?? raw.completion_tokens ?? raw.completionTokens, ); - const totalTokens = normalizeNumber( - raw.total_tokens ?? raw.totalTokens ?? raw.usedTokens ?? raw.used_tokens ?? raw.used ?? inputTokens + outputTokens, - ); + const explicitTotal = raw.total_tokens ?? raw.totalTokens ?? raw.usedTokens ?? raw.used_tokens ?? raw.used; + const totalEstimated = explicitTotal == null && !!(inputTokens || outputTokens); + const totalTokens = normalizeNumber(explicitTotal ?? inputTokens + outputTokens); const cachedTokens = normalizeNumber( raw.cached_tokens ?? raw.cachedTokens ?? @@ -64,6 +87,7 @@ ); const cacheReadTokens = normalizeNumber(raw.cache_read_input_tokens ?? raw.cacheReadInputTokens); const cacheCreationTokens = normalizeNumber(raw.cache_creation_input_tokens ?? raw.cacheCreationInputTokens); + const cachedReadTokens = cacheReadTokens || cachedTokens; const contextUsed = normalizeNumber(raw.contextUsed ?? raw.context_used ?? raw.usedTokens ?? raw.used_tokens ?? raw.used); const contextLimit = normalizeNumber( raw.contextLimit ?? raw.context_limit ?? raw.modelContextWindow ?? raw.model_context_window ?? raw.contextWindow ?? raw.context_window ?? raw.limit, @@ -81,11 +105,16 @@ } return { inputTokens, + inputTotalTokens: inputTokens, outputTokens, + outputTotalTokens: outputTokens, totalTokens, + requestTotalTokens: totalTokens, cachedTokens, + cachedReadTokens, cacheReadTokens, cacheCreationTokens, + totalEstimated, hasBreakdown: !!(inputTokens || outputTokens || cachedTokens || cacheReadTokens || cacheCreationTokens), contextUsed: contextUsed || totalTokens, contextLimit, @@ -240,7 +269,10 @@ } function formatSeconds(elapsedMs) { - return `${(Math.max(0, normalizeNumber(elapsedMs)) / 1000).toFixed(1)}s`; + const seconds = Math.max(0, normalizeNumber(elapsedMs)) / 1000; + if (seconds >= 3600) return `${(seconds / 3600).toFixed(1)}h`; + if (seconds >= 60) return `${(seconds / 60).toFixed(1)}min`; + return `${seconds.toFixed(1)}s`; } function usageHasBreakdown(usage) { @@ -256,11 +288,12 @@ } function formatCacheDetails(usage) { - const cacheTokens = usage.cachedTokens || usage.cacheReadTokens || 0; + const cacheTokens = usage.cachedReadTokens || usage.cachedTokens || usage.cacheReadTokens || 0; if (!cacheTokens) return []; - const details = [`缓存命中 ${formatNumber(cacheTokens)}`]; - if (usage.inputTokens) { - const ratio = Math.min(100, Math.max(0, (cacheTokens / usage.inputTokens) * 100)); + const details = [`缓存读 ${formatNumber(cacheTokens)}`]; + const inputTokens = usage.inputTotalTokens || usage.inputTokens || 0; + if (inputTokens) { + const ratio = Math.min(100, Math.max(0, (cacheTokens / inputTokens) * 100)); details.push(`缓存命中率 ${ratio.toFixed(1)}%`); } if (usage.cacheCreationTokens) details.push(`缓存写 ${formatNumber(usage.cacheCreationTokens)}`); @@ -270,9 +303,15 @@ function formatBadgeText(metric) { if (metric?.status === "running") return "运行中 · 正在统计本次回复 token..."; const usage = metric?.usage || {}; - const parts = [`总计 ${formatNumber(usage.totalTokens)}`]; + const requestTotal = usage.requestTotalTokens || usage.totalTokens || 0; + const estimatedLabel = usage.totalEstimated ? "(估算)" : ""; + const parts = [`本轮调用合计 ${formatNumber(requestTotal)}${estimatedLabel}`]; if (usageHasBreakdown(usage)) { - parts.push(`输入 ${formatNumber(usage.inputTokens)}`, `输出 ${formatNumber(usage.outputTokens)}`, ...formatCacheDetails(usage)); + parts.push( + `输入 ${formatNumber(usage.inputTotalTokens || usage.inputTokens)}`, + `输出 ${formatNumber(usage.outputTotalTokens || usage.outputTokens)}`, + ...formatCacheDetails(usage), + ); } else { parts.push("输入 -", "输出 -"); } @@ -327,6 +366,36 @@ return /^[A-Za-z0-9_.:-]{3,180}$/.test(text) ? text : ""; } + function normalizeProjectId(value) { + return normalizeConversationId(value); + } + + function parseObservedAt(value) { + if (typeof value === "number" && Number.isFinite(value)) return value; + const time = Date.parse(String(value || "")); + return Number.isFinite(time) ? time : nowMs(); + } + + function projectIdFromLocation() { + const locationText = `${window.location?.pathname || ""}${window.location?.search || ""}${window.location?.hash || ""}`; + const match = locationText.match(/(?:project|workspace)(?:\/|=|:|-)([A-Za-z0-9_.:-]+)/i); + return normalizeProjectId(match?.[1]); + } + + function projectIdFromActiveRow() { + try { + const row = document.querySelector?.( + "[data-app-action-sidebar-project-active='true'],[data-project-id],[data-workspace-id]", + ); + const id = row?.getAttribute?.("data-project-id") + || row?.getAttribute?.("data-workspace-id") + || row?.getAttribute?.("data-testid"); + return normalizeProjectId(id); + } catch (_) { + return ""; + } + } + function conversationIdFromLocation() { const locationText = `${window.location?.pathname || ""}${window.location?.search || ""}${window.location?.hash || ""}`; const match = locationText.match(/(?:session|conversation|thread)(?:\/|=|:|-)([A-Za-z0-9_.:-]+)/i) @@ -354,42 +423,273 @@ return live || state.activeConversationId; } + function currentProjectId() { + const live = projectIdFromActiveRow() || projectIdFromLocation(); + return live || state.activeProjectId; + } + + function scopeKeyFor(projectId, conversationId) { + const conversation = normalizeConversationId(conversationId); + if (!conversation) return ""; + const project = normalizeProjectId(projectId); + return project ? `${project}:${conversation}` : conversation; + } + + function currentScopeKey() { + return scopeKeyFor(currentProjectId(), currentConversationId()); + } + + function isSameOrMissingIdentity(currentValue, nextValue) { + return !currentValue || !nextValue || currentValue === nextValue; + } + + function canAdoptScopeIdentity(turn, projectId, conversationId) { + if (!turn) return false; + return ( + isSameOrMissingIdentity(turn.projectId, normalizeProjectId(projectId)) && + isSameOrMissingIdentity(turn.conversationId, normalizeConversationId(conversationId)) + ); + } + + function applyTurnScopeIdentity(turn, projectId, conversationId) { + if (!turn) return turn; + const nextProjectId = normalizeProjectId(projectId) || turn.projectId || ""; + const nextConversationId = normalizeConversationId(conversationId) || turn.conversationId || ""; + turn.projectId = nextProjectId; + turn.conversationId = nextConversationId; + turn.scopeKey = scopeKeyFor(nextProjectId, nextConversationId); + return turn; + } + function scopedMetric(metric) { + const projectId = normalizeProjectId(metric?.projectId) || currentProjectId(); const conversationId = normalizeConversationId(metric?.conversationId) || currentConversationId(); - return conversationId ? { ...metric, conversationId } : metric; + const scopeKey = scopeKeyFor(projectId, conversationId); + return conversationId ? { ...metric, projectId, conversationId, scopeKey } : metric; } function conversationMatchesActive(metric) { const active = currentConversationId(); const metricConversationId = normalizeConversationId(metric?.conversationId); - return active ? metricConversationId === active : true; + if (active && metricConversationId !== active) return false; + const activeScope = currentScopeKey(); + return activeScope && metric?.scopeKey ? metric.scopeKey === activeScope : true; + } + + function aggregateLedgerEvents(events, scopeKey, conversationId, projectId) { + if (!events.length) return null; + const orderedEvents = events.slice().sort((left, right) => (left.observedAt || 0) - (right.observedAt || 0)); + const usageEvents = []; + const contextEvents = []; + orderedEvents.forEach((event) => { + if (usageHasBreakdown(event.usage)) usageEvents.push(event); + else if (event.usage?.contextLimit || event.usage?.contextUsed) contextEvents.push(event); + }); + const calls = []; + usageEvents.forEach((event) => { + const existing = calls.find((call) => { + const identity = strongCallIdentity(event); + const callIdentity = strongCallIdentity(call); + if (identity && callIdentity && identity === callIdentity) return true; + if (!sameUsageDetails(event, call)) return false; + if (event.source === call.source) return false; + return Math.abs((event.observedAt || 0) - (call.observedAt || 0)) <= CROSS_SOURCE_DEDUPE_WINDOW_MS; + }); + if (existing) { + Object.assign(existing, mergeMetric(event, existing), { + observedAt: Math.min(existing.observedAt || event.observedAt || 0, event.observedAt || 0), + sourceSet: Array.from(new Set([...(existing.sourceSet || [existing.source]), event.source].filter(Boolean))), + }); + } else { + calls.push({ + ...event, + sourceSet: [event.source].filter(Boolean), + }); + } + }); + const usage = calls.reduce( + (total, event) => { + const item = event.usage || {}; + total.inputTokens += item.inputTokens || 0; + total.inputTotalTokens += item.inputTotalTokens || item.inputTokens || 0; + total.outputTokens += item.outputTokens || 0; + total.outputTotalTokens += item.outputTotalTokens || item.outputTokens || 0; + total.totalTokens += item.totalTokens || item.inputTokens + item.outputTokens || 0; + total.requestTotalTokens += item.requestTotalTokens || item.totalTokens || item.inputTokens + item.outputTokens || 0; + total.cachedTokens += item.cachedTokens || 0; + total.cachedReadTokens += item.cachedReadTokens || item.cacheReadTokens || item.cachedTokens || 0; + total.cacheReadTokens += item.cacheReadTokens || 0; + total.cacheCreationTokens += item.cacheCreationTokens || 0; + total.totalEstimated = total.totalEstimated || !!item.totalEstimated; + return total; + }, + { + inputTokens: 0, + inputTotalTokens: 0, + outputTokens: 0, + outputTotalTokens: 0, + totalTokens: 0, + requestTotalTokens: 0, + cachedTokens: 0, + cachedReadTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalEstimated: false, + }, + ); + const lastUsageEvent = calls[calls.length - 1] || orderedEvents[orderedEvents.length - 1]; + const contextEvent = contextEvents[contextEvents.length - 1] || lastUsageEvent; + usage.hasBreakdown = calls.length > 0; + usage.contextUsed = contextEvent?.usage?.contextUsed || contextEvent?.usage?.totalTokens || lastUsageEvent?.usage?.contextUsed || usage.totalTokens; + usage.contextLimit = contextEvent?.usage?.contextLimit || lastUsageEvent?.usage?.contextLimit || 0; + const latest = orderedEvents[orderedEvents.length - 1]; + return { + usage, + elapsedMs: Math.max(...orderedEvents.map((event) => event.elapsedMs || 0), 0), + source: "turn-aggregate", + projectId: projectId || latest?.projectId || "", + conversationId: conversationId || latest?.conversationId || "", + scopeKey: scopeKey || latest?.scopeKey || "", + turnId: latest?.turnId || "", + calls: calls.map((event) => ({ ...event, __usageCallKey: undefined })), + callCount: calls.length, + confidence: calls.some((event) => event.usage?.totalEstimated) ? "estimated" : "observed", + }; + } + + function syncLedgerTurnIdentity(turn) { + if (!turn?.id) return; + state.ledger.forEach((event) => { + if (event.turnId !== turn.id) return; + event.projectId = turn.projectId || event.projectId || ""; + event.conversationId = turn.conversationId || event.conversationId || ""; + event.scopeKey = turn.scopeKey || event.scopeKey || ""; + }); + } + + function deriveTurnsFromLedger(activeScope, activeConversationId) { + const scopedEvents = state.ledger.filter((event) => { + if (!event?.turnId) return false; + if (activeScope) return event.scopeKey === activeScope; + return activeConversationId ? event.conversationId === activeConversationId : false; + }); + if (!scopedEvents.length) return []; + const grouped = []; + const byTurnId = new Map(); + scopedEvents.forEach((event) => { + if (!byTurnId.has(event.turnId)) { + const bucket = []; + byTurnId.set(event.turnId, bucket); + grouped.push(bucket); + } + byTurnId.get(event.turnId).push(event); + }); + return grouped + .map((events) => { + const latest = events[events.length - 1]; + return aggregateLedgerEvents( + events, + latest?.scopeKey || activeScope || "", + latest?.conversationId || activeConversationId || "", + latest?.projectId || "", + ); + }) + .filter((metric) => metric && (metric.callCount >= 1 || metric.usage?.contextLimit)); + } + + function deriveLatestMetricFromLedger(activeScope, activeConversationId) { + const turns = deriveTurnsFromLedger(activeScope, activeConversationId); + return turns.length ? turns[turns.length - 1] : null; + } + + function adoptLedgerScopeIdentity(projectId, conversationId) { + const normalizedProjectId = normalizeProjectId(projectId); + const normalizedConversationId = normalizeConversationId(conversationId); + if (!normalizedProjectId || !normalizedConversationId) return false; + let changed = false; + state.ledger.forEach((event) => { + if (event.conversationId !== normalizedConversationId) return; + if (event.projectId && event.projectId !== normalizedProjectId) return; + const nextScopeKey = scopeKeyFor(normalizedProjectId, normalizedConversationId); + if (event.projectId !== normalizedProjectId || event.scopeKey !== nextScopeKey) { + event.projectId = normalizedProjectId; + event.scopeKey = nextScopeKey; + changed = true; + } + }); + return changed; } function metricForActiveConversation() { const active = currentConversationId(); - if (active && state.byConversation[active]) return state.byConversation[active]; + const activeScope = currentScopeKey(); if (state.currentTurn && !state.currentTurn.calls.length && state.currentTurn.status === "running") { - if (!active || !state.currentTurn.conversationId || active === state.currentTurn.conversationId) { + if ( + (!active || !state.currentTurn.conversationId || active === state.currentTurn.conversationId) && + (!activeScope || !state.currentTurn.scopeKey || activeScope === state.currentTurn.scopeKey) + ) { return { status: "running", + projectId: state.currentTurn.projectId || currentProjectId(), conversationId: state.currentTurn.conversationId || active, + scopeKey: state.currentTurn.scopeKey || activeScope, startedAt: state.currentTurn.startedAt, elapsedMs: elapsedSinceTurnStarted(), source: "turn-running", }; } } + if (activeScope) { + const derivedScoped = deriveLatestMetricFromLedger(activeScope, active); + if (derivedScoped) return derivedScoped; + if (state.byScope[activeScope]) return state.byScope[activeScope]; + return null; + } + const derived = deriveLatestMetricFromLedger("", active); + if (derived) return derived; + if (active && state.byConversation[active]) return state.byConversation[active]; return conversationMatchesActive(state.lastMetric) ? state.lastMetric : null; } + function setActiveProjectId(projectId) { + const next = normalizeProjectId(projectId); + const previous = state.activeProjectId; + if (previous === next) return; + state.activeProjectId = next; + if (next && canAdoptScopeIdentity(state.currentTurn, next, state.currentTurn?.conversationId)) { + applyTurnScopeIdentity(state.currentTurn, next, state.currentTurn?.conversationId); + syncLedgerTurnIdentity(state.currentTurn); + } + if (next && currentConversationId() && adoptLedgerScopeIdentity(next, currentConversationId())) { + const restored = deriveLatestMetricFromLedger(scopeKeyFor(next, currentConversationId()), currentConversationId()); + if (restored) publishMetric(restored, false); + } + scheduleRender(); + } + function setActiveConversationId(conversationId) { const next = normalizeConversationId(conversationId); const previous = state.activeConversationId; + if (!next && state.currentTurn) { + scheduleRender(); + return; + } if (previous === next) return; state.activeConversationId = next; - if (state.currentTurn && state.currentTurn.conversationId && state.currentTurn.conversationId !== next) { - state.currentTurn = null; - state.turnStartedAt = 0; + if (next && canAdoptScopeIdentity(state.currentTurn, state.currentTurn?.projectId, next)) { + applyTurnScopeIdentity(state.currentTurn, state.currentTurn?.projectId, next); + syncLedgerTurnIdentity(state.currentTurn); + } + if (next && currentProjectId() && adoptLedgerScopeIdentity(currentProjectId(), next)) { + const restored = deriveLatestMetricFromLedger(scopeKeyFor(currentProjectId(), next), next); + if (restored) publishMetric(restored, false); + } + if (typeof window.queueMicrotask === "function") { + window.queueMicrotask(() => { + restoreHistoryForConversation(next).catch(() => {}); + }); + } else { + Promise.resolve().then(() => restoreHistoryForConversation(next).catch(() => {})); } scheduleRender(); } @@ -397,6 +697,7 @@ function metricKey(metric) { const usage = metric?.usage || {}; return [ + metric?.scopeKey || "", metric?.conversationId || "", metric?.source || "", usage.totalTokens || 0, @@ -415,6 +716,8 @@ function usageCallKey(metric) { const usage = metric?.usage || {}; return [ + metric?.callId || metric?.eventId || metric?.requestId || metric?.responseId || "", + metric?.scopeKey || "", metric?.conversationId || "", usage.totalTokens || 0, usage.inputTokens || 0, @@ -427,6 +730,8 @@ function createTurn(started = nowMs()) { state.turnSeq += 1; + const projectId = currentProjectId(); + const conversationId = currentConversationId(); return { id: `${Date.now()}-${state.turnSeq}`, startedAt: started, @@ -434,7 +739,9 @@ calls: [], callKeys: new Set(), contextUsage: null, - conversationId: currentConversationId(), + projectId, + conversationId, + scopeKey: scopeKeyFor(projectId, conversationId), elapsedMs: 0, status: "running", }; @@ -480,12 +787,43 @@ function sameUsage(metric, other) { const usage = metric?.usage || {}; const otherUsage = other?.usage || {}; + if (metric?.scopeKey && other?.scopeKey && metric.scopeKey !== other.scopeKey) return false; if (!usage.totalTokens || !otherUsage.totalTokens) return false; if (usage.totalTokens !== otherUsage.totalTokens) return false; if (metric.conversationId && other.conversationId && metric.conversationId !== other.conversationId) return false; return true; } + function sameUsageDetails(metric, other) { + const usage = metric?.usage || {}; + const otherUsage = other?.usage || {}; + return !!( + usage.totalTokens && + otherUsage.totalTokens && + usage.totalTokens === otherUsage.totalTokens && + (usage.inputTokens || 0) === (otherUsage.inputTokens || 0) && + (usage.outputTokens || 0) === (otherUsage.outputTokens || 0) && + (usage.cachedTokens || 0) === (otherUsage.cachedTokens || 0) && + (usage.cacheReadTokens || 0) === (otherUsage.cacheReadTokens || 0) && + (usage.cacheCreationTokens || 0) === (otherUsage.cacheCreationTokens || 0) + ); + } + + function strongCallIdentity(metric) { + return metric?.callId || metric?.eventId || metric?.requestId || metric?.responseId || ""; + } + + function shouldDedupeCall(metric, existing) { + const identity = strongCallIdentity(metric); + const existingIdentity = strongCallIdentity(existing); + if (identity && existingIdentity && identity === existingIdentity) return true; + if (!sameUsageDetails(metric, existing)) return false; + if (metric.scopeKey && existing.scopeKey && metric.scopeKey !== existing.scopeKey) return false; + if (metric.source === existing.source) return false; + const elapsedDelta = Math.abs((metric.elapsedMs || 0) - (existing.elapsedMs || 0)); + return elapsedDelta <= CROSS_SOURCE_DEDUPE_WINDOW_MS; + } + function mergeUsage(preferredUsage, fallbackUsage) { const preferredHasBreakdown = usageHasBreakdown(preferredUsage); const fallbackHasBreakdown = usageHasBreakdown(fallbackUsage); @@ -493,11 +831,16 @@ const contextUsage = preferredUsage.contextLimit ? preferredUsage : fallbackUsage.contextLimit ? fallbackUsage : preferredUsage.contextUsed ? preferredUsage : fallbackUsage; return { inputTokens: detailUsage.inputTokens || 0, + inputTotalTokens: detailUsage.inputTotalTokens || detailUsage.inputTokens || 0, outputTokens: detailUsage.outputTokens || 0, + outputTotalTokens: detailUsage.outputTotalTokens || detailUsage.outputTokens || 0, totalTokens: detailUsage.totalTokens || contextUsage.totalTokens || 0, + requestTotalTokens: detailUsage.requestTotalTokens || detailUsage.totalTokens || contextUsage.totalTokens || 0, cachedTokens: detailUsage.cachedTokens || 0, + cachedReadTokens: detailUsage.cachedReadTokens || detailUsage.cacheReadTokens || detailUsage.cachedTokens || 0, cacheReadTokens: detailUsage.cacheReadTokens || 0, cacheCreationTokens: detailUsage.cacheCreationTokens || 0, + totalEstimated: !!detailUsage.totalEstimated, hasBreakdown: usageHasBreakdown(detailUsage), contextUsed: contextUsage.contextUsed || contextUsage.totalTokens || detailUsage.totalTokens || 0, contextLimit: contextUsage.contextLimit || detailUsage.contextLimit || 0, @@ -510,13 +853,15 @@ ...preferred, usage: mergeUsage(preferred.usage || {}, fallback.usage || {}), elapsedMs: preferred.elapsedMs || fallback.elapsedMs || 0, + projectId: preferred.projectId || fallback.projectId || "", conversationId: preferred.conversationId || fallback.conversationId || "", + scopeKey: preferred.scopeKey || fallback.scopeKey || "", source: preferred.source || fallback.source, }; } function findMergeCandidate(metric) { - const matches = [...state.recent, ...readStoredDetails()].filter((item) => conversationMatchesActive(item) && sameUsage(metric, item)); + const matches = [...state.recent, ...readStoredDetails()].filter((item) => conversationMatchesActive(item) && sameUsageDetails(metric, item)); return matches.find((item) => usageHasBreakdown(item.usage)) || matches[0] || null; } @@ -553,6 +898,133 @@ }; } + function appendLedgerEvent(kind, metric, extra = {}) { + const usage = metric?.usage || {}; + state.eventSeq += 1; + const entry = { + id: `ledger-${state.eventSeq}`, + kind, + source: metric?.source || "", + observedAt: extra.observedAt ?? nowMs(), + projectId: extra.projectId ?? metric?.projectId ?? "", + conversationId: extra.conversationId ?? metric?.conversationId ?? "", + scopeKey: extra.scopeKey ?? metric?.scopeKey ?? "", + turnId: extra.turnId ?? "", + elapsedMs: metric?.elapsedMs || 0, + usage: metric?.usage || null, + rawSummary: { + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + totalTokens: usage.totalTokens || 0, + cachedTokens: usage.cachedReadTokens || usage.cachedTokens || usage.cacheReadTokens || 0, + contextLimit: usage.contextLimit || 0, + }, + }; + state.ledger.push(entry); + if (state.ledger.length > LEDGER_LIMIT) state.ledger = state.ledger.slice(-LEDGER_LIMIT); + } + + function hasLedgerForConversation(conversationId) { + const normalizedConversationId = normalizeConversationId(conversationId); + return !!(normalizedConversationId && state.ledger.some((event) => event.conversationId === normalizedConversationId)); + } + + function appendHistoryLedgerEvent(item, fallbackConversationId, fallbackProjectId) { + const usage = normalizeUsage(item?.usage); + if (!usage) return false; + const conversationId = normalizeConversationId(item?.conversation_id || item?.conversationId || fallbackConversationId); + if (!conversationId) return false; + const projectId = normalizeProjectId(fallbackProjectId); + const scopeKey = scopeKeyFor(projectId, conversationId); + const turnId = String(item?.turn_id || item?.turnId || ""); + const observedAt = parseObservedAt(item?.observed_at || item?.observedAt); + const duplicate = state.ledger.some( + (event) => + event.turnId === turnId && + event.conversationId === conversationId && + event.observedAt === observedAt && + (event.usage?.totalTokens || 0) === (usage.totalTokens || 0) && + (event.usage?.inputTokens || 0) === (usage.inputTokens || 0) && + (event.usage?.outputTokens || 0) === (usage.outputTokens || 0), + ); + if (duplicate) return false; + appendLedgerEvent( + "usage", + { + usage, + elapsedMs: normalizeNumber(item?.elapsedMs || item?.elapsed_ms), + source: item?.source || "rollout-history", + conversationId, + projectId, + scopeKey, + }, + { + observedAt, + turnId, + conversationId, + projectId, + scopeKey, + }, + ); + return true; + } + + async function requestBridge(path, payload) { + const bridge = window.__codexSessionDeleteBridge; + if (typeof bridge === "function") return bridge(path, payload || {}); + throw new Error("bridge unavailable"); + } + + async function restoreHistoryForConversation(conversationId, options = {}) { + const normalizedConversationId = normalizeConversationId(conversationId); + if (!normalizedConversationId) return null; + const restoreState = state.historyRestoreState[normalizedConversationId] || (state.historyRestoreState[normalizedConversationId] = {}); + if (restoreState.promise) return restoreState.promise; + if (!options.force && (restoreState.completed || hasLedgerForConversation(normalizedConversationId))) { + return deriveLatestMetricFromLedger(currentScopeKey(), normalizedConversationId); + } + restoreState.promise = (async () => { + try { + const result = await requestBridge("/thread-usage-history", { + session_id: normalizedConversationId, + title: "", + }); + if (!result || result.status !== "ok" || !Array.isArray(result.history)) { + return null; + } + const fallbackProjectId = currentProjectId(); + let appended = 0; + result.history.forEach((item) => { + if (appendHistoryLedgerEvent(item, normalizedConversationId, fallbackProjectId)) appended += 1; + }); + if (!fallbackProjectId && currentProjectId()) { + adoptLedgerScopeIdentity(currentProjectId(), normalizedConversationId); + } + restoreState.completed = true; + pushDebug({ + type: "history-restore", + conversationId: normalizedConversationId, + appended, + source: "bridge", + }); + const metric = deriveLatestMetricFromLedger(currentScopeKey(), normalizedConversationId) + || deriveLatestMetricFromLedger("", normalizedConversationId); + if (metric) publishMetric(metric, false); + return metric; + } catch (error) { + pushDebug({ + type: "history-restore-failed", + conversationId: normalizedConversationId, + message: String(error?.message || error), + }); + return null; + } finally { + restoreState.promise = null; + } + })(); + return restoreState.promise; + } + function pushDebug(entry) { state.debug.unshift({ at: new Date().toISOString(), @@ -571,20 +1043,30 @@ (total, call) => { const item = call.usage || {}; total.inputTokens += item.inputTokens || 0; + total.inputTotalTokens += item.inputTotalTokens || item.inputTokens || 0; total.outputTokens += item.outputTokens || 0; + total.outputTotalTokens += item.outputTotalTokens || item.outputTokens || 0; total.totalTokens += item.totalTokens || item.inputTokens + item.outputTokens || 0; + total.requestTotalTokens += item.requestTotalTokens || item.totalTokens || item.inputTokens + item.outputTokens || 0; total.cachedTokens += item.cachedTokens || 0; + total.cachedReadTokens += item.cachedReadTokens || item.cacheReadTokens || item.cachedTokens || 0; total.cacheReadTokens += item.cacheReadTokens || 0; total.cacheCreationTokens += item.cacheCreationTokens || 0; + total.totalEstimated = total.totalEstimated || !!item.totalEstimated; return total; }, { inputTokens: 0, + inputTotalTokens: 0, outputTokens: 0, + outputTotalTokens: 0, totalTokens: 0, + requestTotalTokens: 0, cachedTokens: 0, + cachedReadTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, + totalEstimated: false, }, ); const lastCallUsage = turn.calls[turn.calls.length - 1]?.usage || {}; @@ -596,14 +1078,65 @@ usage, elapsedMs: turn.elapsedMs, source: "turn-aggregate", + projectId: turn.projectId, conversationId: turn.conversationId, + scopeKey: turn.scopeKey, turnId: turn.id, + calls: turn.calls.map((call) => ({ ...call, __usageCallKey: undefined })), callCount: turn.calls.length, + confidence: turn.calls.some((call) => call.usage?.totalEstimated) ? "estimated" : "observed", + }; + } + + function rememberTurnMetric(metric) { + if (!metric?.scopeKey || !metric.turnId || metric.source !== "turn-aggregate") return; + const turns = state.turnsByScope[metric.scopeKey] || []; + const nextMetric = { + ...metric, + calls: (metric.calls || []).map((call) => ({ ...call })), + }; + const existingIndex = turns.findIndex((item) => item.turnId === metric.turnId); + if (existingIndex >= 0) turns[existingIndex] = nextMetric; + else turns.push(nextMetric); + state.turnsByScope[metric.scopeKey] = turns.slice(-RECENT_LIMIT); + } + + function exportUsage() { + const activeScope = currentScopeKey(); + const currentTurn = state.currentTurn + ? { + id: state.currentTurn.id, + startedAt: state.currentTurn.startedAt, + lastUpdatedAt: state.currentTurn.lastUpdatedAt, + callCount: state.currentTurn.calls.length, + projectId: state.currentTurn.projectId, + conversationId: state.currentTurn.conversationId, + scopeKey: state.currentTurn.scopeKey, + } + : null; + const activeMetric = metricForActiveConversation(); + return { + version: SCRIPT_VERSION, + activeProjectId: currentProjectId(), + activeConversationId: currentConversationId(), + activeScopeKey: activeScope, + last: activeMetric || state.lastMetric, + currentTurn, + calls: (state.currentTurn?.scopeKey === activeScope ? state.currentTurn.calls : activeMetric?.calls || []).map((call) => ({ ...call, __usageCallKey: undefined })), + ledgerEvents: state.ledger.slice().map((event) => ({ ...event })), + recent: state.recent.slice(), + debug: state.debug.slice(), + storedDetails: readStoredDetails(), + turns: deriveTurnsFromLedger(activeScope, currentConversationId()), }; } function publishMetric(metric, storeDetails = true) { metric = scopedMetric(metric); + if (metric?.source !== "turn-running") { + const derived = deriveLatestMetricFromLedger(metric?.scopeKey || "", metric?.conversationId || ""); + if (derived) metric = scopedMetric(derived); + } const nextKey = metricKey(metric); if (nextKey && nextKey === state.lastMetricKey) { scheduleRender(); @@ -615,6 +1148,10 @@ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, createdAt: new Date().toISOString(), }; + if (state.lastMetric.scopeKey) { + state.byScope[state.lastMetric.scopeKey] = state.lastMetric; + rememberTurnMetric(state.lastMetric); + } if (state.lastMetric.conversationId) state.byConversation[state.lastMetric.conversationId] = state.lastMetric; state.recent.unshift(state.lastMetric); state.recent = state.recent.slice(0, RECENT_LIMIT); @@ -627,11 +1164,14 @@ startedAt: state.currentTurn.startedAt, lastUpdatedAt: state.currentTurn.lastUpdatedAt, callCount: state.currentTurn.calls.length, + projectId: state.currentTurn.projectId, conversationId: state.currentTurn.conversationId, + scopeKey: state.currentTurn.scopeKey, } : null, recent: state.recent.slice(), debug: state.debug.slice(), + export: exportUsage, }; if (storeDetails) writeStoredDetails(state.lastMetric); scheduleRender(); @@ -639,15 +1179,30 @@ function rememberContextMetric(metric) { metric = scopedMetric(metric); - if (state.currentTurn?.calls.length) { + const activeTurnMatches = + state.currentTurn?.calls.length && + (!metric.scopeKey || !state.currentTurn.scopeKey || metric.scopeKey === state.currentTurn.scopeKey); + if (activeTurnMatches) { + appendLedgerEvent("context", metric, { + turnId: state.currentTurn.id, + projectId: state.currentTurn.projectId || metric.projectId, + conversationId: state.currentTurn.conversationId || metric.conversationId, + scopeKey: state.currentTurn.scopeKey || metric.scopeKey, + }); state.currentTurn.contextUsage = metric.usage; - state.currentTurn.conversationId = metric.conversationId || state.currentTurn.conversationId; + applyTurnScopeIdentity(state.currentTurn, metric.projectId, metric.conversationId); + syncLedgerTurnIdentity(state.currentTurn); state.currentTurn.elapsedMs = Math.max(state.currentTurn.elapsedMs || 0, metric.elapsedMs || 0); state.currentTurn.lastUpdatedAt = nowMs(); publishMetric(aggregateTurnMetric(state.currentTurn), false); return; } - if (state.lastMetric) { + appendLedgerEvent("context", metric); + if ( + state.lastMetric && + (!metric.scopeKey || !state.lastMetric.scopeKey || metric.scopeKey === state.lastMetric.scopeKey) && + nowMs() - (state.currentTurn?.lastUpdatedAt || 0) <= CONTEXT_MERGE_WINDOW_MS + ) { publishMetric(mergeMetric(state.lastMetric, metric), false); return; } @@ -657,15 +1212,28 @@ function rememberUsageMetric(metric) { metric = scopedMetric(metric); const turn = ensureTurnStarted(); - if (metric.conversationId && turn.conversationId && metric.conversationId !== turn.conversationId) { + if (canAdoptScopeIdentity(turn, metric.projectId, metric.conversationId)) { + applyTurnScopeIdentity(turn, metric.projectId, metric.conversationId); + syncLedgerTurnIdentity(turn); + } + if ( + (metric.conversationId && turn.conversationId && metric.conversationId !== turn.conversationId) || + (metric.scopeKey && turn.scopeKey && metric.scopeKey !== turn.scopeKey) + ) { beginTurn(); return rememberUsageMetric(metric); } + appendLedgerEvent("usage", metric, { + turnId: turn.id, + projectId: turn.projectId || metric.projectId, + conversationId: turn.conversationId || metric.conversationId, + scopeKey: turn.scopeKey || metric.scopeKey, + }); const key = usageCallKey(metric); - const existing = turn.calls.find((call) => call.__usageCallKey === key); + const existing = turn.calls.find((call) => shouldDedupeCall(metric, call)); if (existing) { const merged = mergeMetric(metric, existing); - Object.assign(existing, merged, { __usageCallKey: key }); + Object.assign(existing, merged, { __usageCallKey: existing.__usageCallKey || key, dedupeReason: strongCallIdentity(metric) ? "identity" : "cross-source-window" }); } else { const candidate = findMergeCandidate(metric); if (candidate) { @@ -674,7 +1242,8 @@ turn.calls.push({ ...metric, __usageCallKey: key }); turn.callKeys.add(key); } - turn.conversationId = metric.conversationId || turn.conversationId; + applyTurnScopeIdentity(turn, metric.projectId, metric.conversationId); + syncLedgerTurnIdentity(turn); turn.status = "complete"; turn.elapsedMs = Math.max(turn.elapsedMs || 0, metric.elapsedMs || elapsedSinceTurnStarted()); turn.lastUpdatedAt = nowMs(); @@ -1158,7 +1727,10 @@ function installRouteObserver() { if (window.__codexTokenUsageRouteObserver === SCRIPT_VERSION) return; window.__codexTokenUsageRouteObserver = SCRIPT_VERSION; - const sync = () => setActiveConversationId(conversationIdFromActiveRow() || conversationIdFromLocation()); + const sync = () => { + setActiveConversationId(conversationIdFromActiveRow() || conversationIdFromLocation()); + restoreHistoryForConversation(currentConversationId()).catch(() => {}); + }; const originals = window.__codexTokenUsageRouteOriginals || {}; window.__codexTokenUsageRouteOriginals = originals; const routeHistory = window.history; @@ -1185,6 +1757,7 @@ installContextMeterObserver(); installRouteObserver(); installDomObserver(); + restoreHistoryForConversation(currentConversationId()).catch(() => {}); if (window.__CODEX_TOKEN_USAGE_SCRIPT_TEST__) { window.__codexTokenUsageScriptTest = { @@ -1197,11 +1770,25 @@ processPayload, rememberMetric, markTurnStarted: markNetworkTurnStarted, + setActiveProjectId, setActiveConversationId, dispatchDocumentEvent: (type, event) => document.listeners?.[type]?.({ type, ...event }), + exportUsage, getDisplayMetric: metricForActiveConversation, getStoredDetails: readStoredDetails, + getTurnsForActiveConversation: () => deriveTurnsFromLedger(currentScopeKey(), currentConversationId()), getTokenUsage: () => window.__codexTokenUsage, + restoreHistoryForConversation, + resetDerivedStatePreservingLedger: () => { + state.lastMetric = null; + state.lastMetricKey = ""; + state.recent = []; + state.byConversation = Object.create(null); + state.byScope = Object.create(null); + state.turnsByScope = Object.create(null); + state.currentTurn = null; + state.turnStartedAt = 0; + }, }; } })(); diff --git a/tests/codex-token-usage.test.js b/tests/codex-token-usage.test.js index 8223f03..b10eab8 100644 --- a/tests/codex-token-usage.test.js +++ b/tests/codex-token-usage.test.js @@ -4,7 +4,7 @@ const path = require("node:path"); const test = require("node:test"); const vm = require("node:vm"); -function loadHelpers() { +function loadHelpers(overrides = {}) { const source = fs.readFileSync( path.join(__dirname, "..", "scripts", "codex-token-usage.js"), "utf8", @@ -66,6 +66,10 @@ function loadHelpers() { location: { href: "https://chatgpt.com/codex" }, performance: { now: () => currentNow }, sessionStorage, + queueMicrotask(fn) { + Promise.resolve().then(fn); + }, + ...overrides, }, }; sandbox.window.window = sandbox.window; @@ -171,11 +175,16 @@ test("extractUsage finds Responses API usage from JSON", () => { assert.deepEqual(JSON.parse(JSON.stringify(usage)), { inputTokens: 1200, + inputTotalTokens: 1200, outputTokens: 345, + outputTotalTokens: 345, totalTokens: 1545, + requestTotalTokens: 1545, cachedTokens: 800, + cachedReadTokens: 800, cacheReadTokens: 0, cacheCreationTokens: 0, + totalEstimated: false, hasBreakdown: true, contextUsed: 1545, contextLimit: 0, @@ -189,11 +198,11 @@ test("script exposes version and reinstalls over older injected version", () => __codexTokenUsageMessageObserver: "0.1.3", }); - assert.equal(win.__codexTokenUsageVersion, "0.1.5"); - assert.equal(win.__codexTokenUsageMessageObserver, "0.1.5"); + assert.equal(win.__codexTokenUsageVersion, "0.1.6"); + assert.equal(win.__codexTokenUsageMessageObserver, "0.1.6"); assert.equal(Array.isArray(win.__codexTokenUsageDebug), true); assert.equal(win.__codexTokenUsageDebug.length, 0); - assert.equal(win.__codexTokenUsage.version, "0.1.5"); + assert.equal(win.__codexTokenUsage.version, "0.1.6"); assert.equal(typeof win.__codexTokenUsageScriptTest?.processPayload, "function"); }); @@ -494,7 +503,26 @@ test("formatBadgeText includes tokens, cache, and seconds", () => { elapsedMs: 12345, }); - assert.equal(text, "总计 1,250 · 输入 1,000 · 输出 250 · 缓存命中 600 · 缓存命中率 60.0% · 耗时 12.3s"); + assert.equal(text, "本轮调用合计 1,250 · 输入 1,000 · 输出 250 · 缓存读 600 · 缓存命中率 60.0% · 耗时 12.3s"); +}); + +test("formatBadgeText formats elapsed time as seconds minutes or hours", () => { + const helpers = loadHelpers(); + const baseMetric = { + usage: { + inputTokens: 1000, + outputTokens: 250, + totalTokens: 1250, + cachedTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + hasBreakdown: true, + }, + }; + + assert.match(helpers.formatBadgeText({ ...baseMetric, elapsedMs: 59900 }), /耗时 59\.9s$/); + assert.match(helpers.formatBadgeText({ ...baseMetric, elapsedMs: 65000 }), /耗时 1\.1min$/); + assert.match(helpers.formatBadgeText({ ...baseMetric, elapsedMs: 3900000 }), /耗时 1\.1h$/); }); test("formatBadgeText labels unknown breakdown from fallback", () => { @@ -514,7 +542,7 @@ test("formatBadgeText labels unknown breakdown from fallback", () => { elapsedMs: 0, }); - assert.equal(text, "总计 46,205 · 输入 - · 输出 - · 上下文 46,205/258,400 (17.9%) · 耗时 -"); + assert.equal(text, "本轮调用合计 46,205 · 输入 - · 输出 - · 上下文 46,205/258,400 (17.9%) · 耗时 -"); }); test("mergeMetric keeps detailed usage when context-only update arrives later", () => { @@ -600,7 +628,7 @@ test("rememberMetric keeps detailed usage after context-only update", () => { assert.equal(last.usage.outputTokens, 495); assert.equal(last.usage.cachedTokens, 125824); assert.equal(last.usage.contextLimit, 258400); - assert.equal(helpers.formatBadgeText(last), "总计 127,552 · 输入 127,057 · 输出 495 · 缓存命中 125,824 · 缓存命中率 99.0% · 上下文 127,552/258,400 (49.4%) · 调用 1 次 · 耗时 42.0s"); + assert.equal(helpers.formatBadgeText(last), "本轮调用合计 127,552 · 输入 127,057 · 输出 495 · 缓存读 125,824 · 缓存命中率 99.0% · 上下文 127,552/258,400 (49.4%) · 调用 1 次 · 耗时 42.0s"); }); test("rememberMetric aggregates multiple model calls in one Codex turn", () => { @@ -645,7 +673,7 @@ test("rememberMetric aggregates multiple model calls in one Codex turn", () => { assert.equal(last.usage.totalTokens, 3350); assert.equal(last.usage.cachedTokens, 1800); assert.equal(last.callCount, 2); - assert.equal(helpers.formatBadgeText(last), "总计 3,350 · 输入 3,000 · 输出 350 · 缓存命中 1,800 · 缓存命中率 60.0% · 调用 2 次 · 耗时 24.0s"); + assert.equal(helpers.formatBadgeText(last), "本轮调用合计 3,350 · 输入 3,000 · 输出 350 · 缓存读 1,800 · 缓存命中率 60.0% · 调用 2 次 · 耗时 24.0s"); }); test("rememberMetric keeps long-running assistant calls in the same turn", () => { @@ -700,6 +728,100 @@ test("rememberMetric aggregates cached post-message usage entries for one reply" assert.equal(last.callCount, 4); }); +test("transient empty conversation id does not split an active turn", () => { + const helpers = loadHelpers(); + helpers.setActiveConversationId("local:019e8762-c921-7ed3-a343-e705e10e9dab"); + + helpers.rememberMetric({ + elapsedMs: 12000, + source: "post-message", + conversationId: "local:019e8762-c921-7ed3-a343-e705e10e9dab", + usage: { + inputTokens: 24390, + outputTokens: 352, + totalTokens: 24742, + cachedTokens: 23424, + cacheReadTokens: 0, + cacheCreationTokens: 0, + hasBreakdown: true, + contextUsed: 24742, + contextLimit: 258400, + }, + }); + + helpers.setActiveConversationId(""); + + helpers.rememberMetric({ + elapsedMs: 32000, + source: "post-message", + conversationId: "local:019e8762-c921-7ed3-a343-e705e10e9dab", + usage: { + inputTokens: 26171, + outputTokens: 364, + totalTokens: 26535, + cachedTokens: 23936, + cacheReadTokens: 0, + cacheCreationTokens: 0, + hasBreakdown: true, + contextUsed: 26535, + contextLimit: 258400, + }, + }); + + const last = helpers.getTokenUsage().last; + assert.equal(last.usage.inputTokens, 50561); + assert.equal(last.usage.outputTokens, 716); + assert.equal(last.usage.totalTokens, 51277); + assert.equal(last.callCount, 2); +}); + +test("project id becoming available does not split an active turn", () => { + const helpers = loadHelpers(); + helpers.setActiveConversationId("thread-a"); + + helpers.rememberMetric({ + elapsedMs: 12000, + source: "post-message", + conversationId: "thread-a", + usage: { + inputTokens: 24390, + outputTokens: 352, + totalTokens: 24742, + cachedTokens: 23424, + cacheReadTokens: 0, + cacheCreationTokens: 0, + hasBreakdown: true, + contextUsed: 24742, + contextLimit: 258400, + }, + }); + + helpers.setActiveProjectId("project-a"); + + helpers.rememberMetric({ + elapsedMs: 32000, + source: "post-message", + conversationId: "thread-a", + usage: { + inputTokens: 26171, + outputTokens: 364, + totalTokens: 26535, + cachedTokens: 23936, + cacheReadTokens: 0, + cacheCreationTokens: 0, + hasBreakdown: true, + contextUsed: 26535, + contextLimit: 258400, + }, + }); + + const last = helpers.getTokenUsage().last; + assert.equal(last.usage.inputTokens, 50561); + assert.equal(last.usage.outputTokens, 716); + assert.equal(last.usage.totalTokens, 51277); + assert.equal(last.callCount, 2); +}); + test("user send starts a fresh turn on the next network request", () => { const helpers = loadHelpers(); helpers.setActiveConversationId("thread-a"); @@ -743,7 +865,264 @@ test("rememberMetric deduplicates the same model call across observers", () => { assert.equal(last.usage.totalTokens, 1320); assert.equal(last.callCount, 1); assert.equal(last.elapsedMs, 11000); - assert.equal(helpers.formatBadgeText(last), "总计 1,320 · 输入 1,200 · 输出 120 · 缓存命中 900 · 缓存命中率 75.0% · 调用 1 次 · 耗时 11.0s"); + assert.equal(helpers.formatBadgeText(last), "本轮调用合计 1,320 · 输入 1,200 · 输出 120 · 缓存读 900 · 缓存命中率 75.0% · 调用 1 次 · 耗时 11.0s"); +}); + +test("formatBadgeText labels estimated total and split request/context metrics", () => { + const helpers = loadHelpers(); + const usage = helpers.normalizeUsage({ + input_tokens: 1200, + output_tokens: 120, + cached_input_tokens: 900, + contextLimit: 258400, + }); + + const text = helpers.formatBadgeText({ + usage, + callCount: 1, + elapsedMs: 11000, + }); + + assert.equal(usage.totalEstimated, true); + assert.equal(usage.requestTotalTokens, 1320); + assert.equal(usage.inputTotalTokens, 1200); + assert.equal(usage.outputTotalTokens, 120); + assert.equal(text, "本轮调用合计 1,320(估算) · 输入 1,200 · 输出 120 · 缓存读 900 · 缓存命中率 75.0% · 上下文 1,320/258,400 (0.5%) · 调用 1 次 · 耗时 11.0s"); +}); + +test("identical token counts from separate calls are not deduplicated without matching identity", () => { + const helpers = loadHelpers(); + helpers.setActiveConversationId("thread-a"); + const usage = detailedUsage(1320); + + helpers.rememberMetric({ usage, elapsedMs: 9000, source: "network", conversationId: "thread-a" }); + helpers.advanceTime(1500); + helpers.rememberMetric({ usage: { ...usage }, elapsedMs: 11000, source: "network", conversationId: "thread-a" }); + + const last = helpers.getTokenUsage().last; + assert.equal(last.callCount, 2); + assert.equal(last.usage.totalTokens, 2640); +}); + +test("context-only update from another conversation does not merge into last metric", () => { + const helpers = loadHelpers(); + + helpers.setActiveConversationId("thread-a"); + helpers.rememberMetric({ usage: detailedUsage(1320), elapsedMs: 11000, source: "network", conversationId: "thread-a" }); + helpers.setActiveConversationId("thread-b"); + helpers.rememberMetric({ + usage: { + inputTokens: 0, + outputTokens: 0, + totalTokens: 5000, + cachedTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + hasBreakdown: false, + contextUsed: 5000, + contextLimit: 258400, + }, + elapsedMs: 12000, + source: "context-meter", + conversationId: "thread-b", + }); + + helpers.setActiveConversationId("thread-a"); + const metric = helpers.getDisplayMetric(); + assert.equal(metric.usage.totalTokens, 1320); + assert.equal(metric.usage.contextLimit, 0); +}); + +test("export returns scoped calls and stored details", () => { + const helpers = loadHelpers(); + + helpers.setActiveProjectId("project-a"); + helpers.setActiveConversationId("thread-a"); + helpers.rememberMetric({ usage: detailedUsage(1320), elapsedMs: 11000, source: "network" }); + + const snapshot = helpers.exportUsage(); + assert.equal(snapshot.version, "0.1.6"); + assert.equal(snapshot.activeProjectId, "project-a"); + assert.equal(snapshot.activeConversationId, "thread-a"); + assert.equal(snapshot.calls.length, 1); + assert.equal(snapshot.calls[0].usage.totalTokens, 1320); + assert.equal(Array.isArray(snapshot.storedDetails), true); +}); + +test("export includes recent ledger event summaries", () => { + const helpers = loadHelpers(); + + helpers.setActiveProjectId("project-a"); + helpers.setActiveConversationId("thread-a"); + helpers.rememberMetric({ usage: detailedUsage(1320), elapsedMs: 11000, source: "network" }); + + const snapshot = helpers.exportUsage(); + assert.equal(Array.isArray(snapshot.ledgerEvents), true); + assert.equal(snapshot.ledgerEvents.length, 1); + assert.equal(snapshot.ledgerEvents[0].kind, "usage"); + assert.equal(snapshot.ledgerEvents[0].source, "network"); + assert.equal(snapshot.ledgerEvents[0].rawSummary.totalTokens, 1320); +}); + +test("display metric can be rebuilt from ledger when derived caches are cleared", () => { + const helpers = loadHelpers(); + + helpers.setActiveProjectId("project-a"); + helpers.setActiveConversationId("thread-a"); + helpers.rememberMetric({ + usage: { + inputTokens: 1000, + outputTokens: 100, + totalTokens: 1100, + cachedTokens: 600, + cacheReadTokens: 0, + cacheCreationTokens: 0, + hasBreakdown: true, + contextUsed: 1100, + contextLimit: 0, + }, + elapsedMs: 10000, + source: "post-message", + conversationId: "thread-a", + projectId: "project-a", + }); + helpers.rememberMetric({ + usage: { + inputTokens: 2000, + outputTokens: 250, + totalTokens: 2250, + cachedTokens: 1200, + cacheReadTokens: 0, + cacheCreationTokens: 0, + hasBreakdown: true, + contextUsed: 2250, + contextLimit: 258400, + }, + elapsedMs: 24000, + source: "websocket", + conversationId: "thread-a", + projectId: "project-a", + }); + + helpers.resetDerivedStatePreservingLedger(); + + const metric = helpers.getDisplayMetric(); + assert.equal(metric.usage.inputTokens, 3000); + assert.equal(metric.usage.outputTokens, 350); + assert.equal(metric.usage.totalTokens, 3350); + assert.equal(metric.callCount, 2); +}); + +test("history restore rebuilds latest turn from bridge when cache is empty", async () => { + const helpers = loadHelpers({ + __codexSessionDeleteBridge: async (path, payload) => { + assert.equal(path, "/thread-usage-history"); + assert.equal(payload.session_id, "thread-a"); + return { + status: "ok", + session_id: "thread-a", + history: [ + { + source: "rollout-history", + conversation_id: "thread-a", + turn_id: "turn-1", + observed_at: "2026-06-02T05:00:00Z", + usage: { + inputTokens: 1000, + outputTokens: 100, + totalTokens: 1100, + cachedTokens: 600, + contextUsed: 1100, + contextLimit: 258400, + hasBreakdown: true, + }, + }, + { + source: "rollout-history", + conversation_id: "thread-a", + turn_id: "turn-1", + observed_at: "2026-06-02T05:00:02Z", + usage: { + inputTokens: 1200, + outputTokens: 120, + totalTokens: 1320, + cachedTokens: 900, + contextUsed: 1320, + contextLimit: 258400, + hasBreakdown: true, + }, + }, + { + source: "rollout-history", + conversation_id: "thread-a", + turn_id: "turn-2", + observed_at: "2026-06-02T05:01:00Z", + usage: { + inputTokens: 2000, + outputTokens: 250, + totalTokens: 2250, + cachedTokens: 1200, + contextUsed: 2250, + contextLimit: 258400, + hasBreakdown: true, + }, + }, + ], + }; + }, + }); + + helpers.setActiveConversationId("thread-a"); + await helpers.restoreHistoryForConversation("thread-a", { force: true }); + + const metric = helpers.getDisplayMetric(); + assert.equal(metric.usage.totalTokens, 2250); + assert.equal(metric.callCount, 1); + + const turns = helpers.getTurnsForActiveConversation(); + assert.equal(turns.length, 2); + assert.equal(turns[0].usage.totalTokens, 2420); + assert.equal(turns[0].callCount, 2); + assert.equal(turns[1].usage.totalTokens, 2250); +}); + +test("history restore does not crash when bridge has no history", async () => { + const helpers = loadHelpers({ + __codexSessionDeleteBridge: async () => ({ + status: "failed", + message: "not found", + history: [], + }), + }); + + helpers.setActiveConversationId("thread-a"); + const restored = await helpers.restoreHistoryForConversation("thread-a", { force: true }); + + assert.equal(restored, null); + assert.equal(helpers.getDisplayMetric(), null); +}); + +test("turn history can be rebuilt from ledger when derived caches are cleared", () => { + const helpers = loadHelpers(); + + helpers.setActiveProjectId("project-a"); + helpers.setActiveConversationId("thread-a"); + helpers.rememberMetric({ usage: detailedUsage(1320), elapsedMs: 11000, source: "network", conversationId: "thread-a", projectId: "project-a" }); + + helpers.dispatchDocumentEvent("keydown", { + key: "Enter", + shiftKey: false, + target: { tagName: "TEXTAREA", ariaLabel: "", textContent: "next" }, + }); + helpers.markTurnStarted(); + helpers.rememberMetric({ usage: detailedUsage(2450), elapsedMs: 15000, source: "network", conversationId: "thread-a", projectId: "project-a" }); + + helpers.resetDerivedStatePreservingLedger(); + + const turns = helpers.getTurnsForActiveConversation(); + assert.equal(turns.length, 2); + assert.equal(turns[0].usage.totalTokens, 1320); + assert.equal(turns[1].usage.totalTokens, 2450); }); test("rememberMetric applies context-only update to aggregated turn without adding a call", () => { @@ -873,6 +1252,51 @@ test("conversation switch restores cached metric for that conversation", () => { assert.equal(helpers.getDisplayMetric().usage.totalTokens, 1320); }); +test("same conversation id is isolated across projects", () => { + const helpers = loadHelpers(); + + helpers.setActiveProjectId("project-a"); + helpers.setActiveConversationId("thread-a"); + helpers.rememberMetric({ usage: detailedUsage(1320), elapsedMs: 11000, source: "network" }); + + helpers.setActiveProjectId("project-b"); + helpers.setActiveConversationId("thread-a"); + assert.equal(helpers.getDisplayMetric(), null); + + helpers.rememberMetric({ usage: detailedUsage(2450), elapsedMs: 15000, source: "network" }); + assert.equal(helpers.getDisplayMetric().usage.totalTokens, 2450); + + helpers.setActiveProjectId("project-a"); + helpers.setActiveConversationId("thread-a"); + assert.equal(helpers.getDisplayMetric().usage.totalTokens, 1320); +}); + +test("same conversation keeps turns isolated while displaying the latest turn", () => { + const helpers = loadHelpers(); + + helpers.setActiveProjectId("project-a"); + helpers.setActiveConversationId("thread-a"); + helpers.rememberMetric({ usage: detailedUsage(1320), elapsedMs: 11000, source: "network" }); + const firstTurn = helpers.getDisplayMetric(); + + helpers.dispatchDocumentEvent("keydown", { + key: "Enter", + shiftKey: false, + target: { tagName: "TEXTAREA", ariaLabel: "", textContent: "next" }, + }); + helpers.markTurnStarted(); + helpers.rememberMetric({ usage: detailedUsage(2450), elapsedMs: 15000, source: "network" }); + + const latest = helpers.getDisplayMetric(); + assert.equal(latest.usage.totalTokens, 2450); + assert.notEqual(latest.turnId, firstTurn.turnId); + + const turns = helpers.getTurnsForActiveConversation(); + assert.equal(turns.length, 2); + assert.equal(turns[0].usage.totalTokens, 1320); + assert.equal(turns[1].usage.totalTokens, 2450); +}); + test("parseElapsedMs reads Codex processed duration text", () => { const helpers = loadHelpers();