From 15b7438f24b373b05fe8291ff27979a8d6c29014 Mon Sep 17 00:00:00 2001 From: AlbertLuo <46886876+kokotao@users.noreply.github.com> Date: Mon, 1 Jun 2026 10:55:54 +0800 Subject: [PATCH 1/4] Update Codex Token Usage to 0.1.1 --- index.json | 6 +- scripts/codex-token-usage.js | 180 +++++++++++++++++++++++++++++------ 2 files changed, 153 insertions(+), 33 deletions(-) diff --git a/index.json b/index.json index c0edad9..152687e 100644 --- a/index.json +++ b/index.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-05-26T00:00:00Z", + "updated_at": "2026-06-01T02:31:09Z", "scripts": [ { "id": "codex-context-ring-restore", @@ -55,7 +55,7 @@ "id": "codex-token-usage", "name": "Codex Token Usage", "description": "在 Codex 每次回复完成后显示本次 token 输入、输出、缓存命中、上下文用量和耗时。", - "version": "0.1.0", + "version": "0.1.1", "author": "Albert_Luo", "tags": [ "codex", @@ -66,7 +66,7 @@ ], "homepage": "https://github.com/kokotao/codex-token-usage-script", "script_url": "https://raw.githubusercontent.com/BigPizzaV3/CodexPlusPlusScriptMarket/main/scripts/codex-token-usage.js", - "sha256": "cee3188c1a90083f27c01d3e1d48af42b074b0e38f05507c970f18145f376ad5" + "sha256": "291aae5efa44b30e161f88ab4139adda4fd7430aba59e0e313257dd67c7e857d" }, { "id": "codex-list-pagebuster", diff --git a/scripts/codex-token-usage.js b/scripts/codex-token-usage.js index d155b4e..9d6d9f5 100644 --- a/scripts/codex-token-usage.js +++ b/scripts/codex-token-usage.js @@ -16,6 +16,8 @@ lastMetric: null, lastMetricKey: "", recent: [], + byConversation: Object.create(null), + activeConversationId: "", currentTurn: null, turnSeq: 0, turnStartedAt: 0, @@ -186,6 +188,7 @@ } function formatBadgeText(metric) { + if (metric?.status === "running") return "运行中 · 正在统计本次回复 token..."; const usage = metric?.usage || {}; const parts = [`总计 ${formatNumber(usage.totalTokens)}`]; if (usageHasBreakdown(usage)) { @@ -238,6 +241,79 @@ return String(input || ""); } + function normalizeConversationId(value) { + const text = String(value || "").trim(); + if (!text || text === "__proto__" || text === "prototype" || text === "constructor") return ""; + return /^[A-Za-z0-9_.:-]{3,180}$/.test(text) ? text : ""; + } + + 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) + || locationText.match(/\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})(?:[/?#]|$)/) + || locationText.match(/\/([A-Za-z0-9_-]{12,})(?:[/?#]|$)/); + return normalizeConversationId(match?.[1]); + } + + function conversationIdFromActiveRow() { + try { + const row = document.querySelector?.( + "[data-app-action-sidebar-thread-active='true'],[aria-current='page'],[aria-current='true']", + ); + const id = row?.getAttribute?.("data-app-action-sidebar-thread-id") + || row?.getAttribute?.("data-session-id") + || row?.getAttribute?.("data-testid"); + return normalizeConversationId(id); + } catch (_) { + return ""; + } + } + + function currentConversationId() { + const live = conversationIdFromActiveRow() || conversationIdFromLocation(); + return live || state.activeConversationId; + } + + function scopedMetric(metric) { + const conversationId = normalizeConversationId(metric?.conversationId) || currentConversationId(); + return conversationId ? { ...metric, conversationId } : metric; + } + + function conversationMatchesActive(metric) { + const active = currentConversationId(); + const metricConversationId = normalizeConversationId(metric?.conversationId); + return active ? metricConversationId === active : true; + } + + function metricForActiveConversation() { + const active = currentConversationId(); + if (state.currentTurn && !state.currentTurn.calls.length && state.currentTurn.status === "running") { + if (!active || !state.currentTurn.conversationId || active === state.currentTurn.conversationId) { + return { + status: "running", + conversationId: state.currentTurn.conversationId || active, + startedAt: state.currentTurn.startedAt, + elapsedMs: elapsedSinceTurnStarted(), + source: "turn-running", + }; + } + } + if (active && state.byConversation[active]) return state.byConversation[active]; + return conversationMatchesActive(state.lastMetric) ? state.lastMetric : null; + } + + function setActiveConversationId(conversationId) { + const next = normalizeConversationId(conversationId); + const previous = state.activeConversationId; + if (previous === next) return; + state.activeConversationId = next; + if (state.currentTurn && state.currentTurn.conversationId && state.currentTurn.conversationId !== next) { + state.currentTurn = null; + state.turnStartedAt = 0; + } + scheduleRender(); + } + function metricKey(metric) { const usage = metric?.usage || {}; return [ @@ -278,8 +354,9 @@ calls: [], callKeys: new Set(), contextUsage: null, - conversationId: "", + conversationId: currentConversationId(), elapsedMs: 0, + status: "running", }; } @@ -302,6 +379,12 @@ function markTurnStarted(started = nowMs()) { beginTurn(started); + scheduleRender(); + } + + function markNetworkTurnStarted(started = nowMs()) { + const turn = ensureTurnStarted(started); + if (!turn.calls.length) scheduleRender(); } function elapsedSinceTurnStarted() { @@ -347,7 +430,7 @@ } function findMergeCandidate(metric) { - const matches = [...state.recent, ...readStoredDetails()].filter((item) => sameUsage(metric, item)); + const matches = [...state.recent, ...readStoredDetails()].filter((item) => conversationMatchesActive(item) && sameUsage(metric, item)); return matches.find((item) => usageHasBreakdown(item.usage)) || matches[0] || null; } @@ -407,6 +490,7 @@ } function publishMetric(metric, storeDetails = true) { + metric = scopedMetric(metric); const nextKey = metricKey(metric); if (nextKey && nextKey === state.lastMetricKey) { scheduleRender(); @@ -418,6 +502,7 @@ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, createdAt: new Date().toISOString(), }; + if (state.lastMetric.conversationId) state.byConversation[state.lastMetric.conversationId] = state.lastMetric; state.recent.unshift(state.lastMetric); state.recent = state.recent.slice(0, RECENT_LIMIT); window.__codexTokenUsage = { @@ -438,6 +523,7 @@ } function rememberContextMetric(metric) { + metric = scopedMetric(metric); if (state.currentTurn?.calls.length) { state.currentTurn.contextUsage = metric.usage; state.currentTurn.conversationId = metric.conversationId || state.currentTurn.conversationId; @@ -454,7 +540,12 @@ } function rememberUsageMetric(metric) { + metric = scopedMetric(metric); const turn = ensureTurnStarted(); + if (metric.conversationId && turn.conversationId && metric.conversationId !== turn.conversationId) { + beginTurn(); + return rememberUsageMetric(metric); + } const key = usageCallKey(metric); const existing = turn.calls.find((call) => call.__usageCallKey === key); if (existing) { @@ -469,6 +560,7 @@ turn.callKeys.add(key); } turn.conversationId = metric.conversationId || turn.conversationId; + turn.status = "complete"; turn.elapsedMs = Math.max(turn.elapsedMs || 0, metric.elapsedMs || elapsedSinceTurnStarted()); turn.lastUpdatedAt = nowMs(); publishMetric(aggregateTurnMetric(turn)); @@ -508,7 +600,7 @@ function wrappedFetch(input, init) { const url = requestUrl(input); const started = nowMs(); - if (isCodexApiUrl(url)) ensureTurnStarted(started); + if (isCodexApiUrl(url)) markNetworkTurnStarted(started); return originalFetch(input, init).then((response) => { if (isCodexApiUrl(url) && response?.clone) { response @@ -535,7 +627,7 @@ }; Xhr.prototype.send = function send(...args) { const started = nowMs(); - if (isCodexApiUrl(this.__codexTokenUsageUrl)) ensureTurnStarted(started); + if (isCodexApiUrl(this.__codexTokenUsageUrl)) markNetworkTurnStarted(started); this.addEventListener?.("loadend", () => { const url = this.__codexTokenUsageUrl; if (!isCodexApiUrl(url)) return; @@ -682,25 +774,6 @@ } } - function installTurnActivityObserver() { - if (window.__codexTokenUsageActivityObserver) return; - const markFromEvent = (event) => { - const target = event.target; - const text = `${target?.tagName || ""} ${target?.ariaLabel || ""} ${target?.textContent || ""}`; - if ( - event.type === "submit" || - (event.type === "keydown" && event.key === "Enter" && !event.shiftKey) || - /send|submit|发送|提交/i.test(text) - ) { - markTurnStarted(); - } - }; - ["click", "submit", "keydown"].forEach((type) => { - document.addEventListener?.(type, markFromEvent, true); - }); - window.__codexTokenUsageActivityObserver = true; - } - function ensureStyle() { let style = document.getElementById?.(STYLE_ID); if (!style) { @@ -714,13 +787,18 @@ align-items: center; gap: 6px; margin: 8px 0 0; - padding: 4px 8px; - border: 1px solid rgba(99, 102, 241, .26); + padding: 5px 9px; + border: 1px solid rgba(20, 184, 166, .3); border-radius: 7px; - background: rgba(99, 102, 241, .08); + background: rgba(20, 184, 166, .08); color: inherit; font: 12px/1.35 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - opacity: .86; + opacity: .9; + letter-spacing: 0; + } + .${BADGE_CLASS}[data-status="running"] { + border-color: rgba(245, 158, 11, .36); + background: rgba(245, 158, 11, .1); } .${BADGE_CLASS}[data-placement="message-actions"] { display: flex; @@ -837,7 +915,19 @@ return 0; } - function renderMetric(metric = state.lastMetric) { + function removeBadges() { + document.querySelectorAll?.(`.${BADGE_CLASS}`).forEach((node) => node.remove()); + } + + function renderMetric(metric = metricForActiveConversation()) { + if (!metric) { + removeBadges(); + return; + } + if (!conversationMatchesActive(metric)) { + removeBadges(); + return; + } if (!metric) return; ensureStyle(); const target = latestAssistantNode(); @@ -854,6 +944,8 @@ target.appendChild(badge); } badge.dataset.metricId = displayMetric.id || ""; + badge.dataset.status = displayMetric.status || "complete"; + badge.dataset.conversationId = displayMetric.conversationId || ""; badge.dataset.placement = target === document.querySelector("main") ? "fallback" : "message-actions"; badge.textContent = formatBadgeText(displayMetric); document.querySelectorAll(`.${BADGE_CLASS}`).forEach((node) => { @@ -869,7 +961,9 @@ function installDomObserver() { if (!window.MutationObserver || window.__codexTokenUsageDomObserver) return; window.__codexTokenUsageDomObserver = new MutationObserver(() => { - if (state.lastMetric) scheduleRender(); + const nextConversationId = conversationIdFromActiveRow() || conversationIdFromLocation(); + if (nextConversationId && nextConversationId !== state.activeConversationId) setActiveConversationId(nextConversationId); + if (metricForActiveConversation()) scheduleRender(); }); const start = () => { const root = document.querySelector("main") || document.body || document.documentElement; @@ -882,12 +976,34 @@ } } + function installRouteObserver() { + if (window.__codexTokenUsageRouteObserver) return; + window.__codexTokenUsageRouteObserver = true; + const sync = () => setActiveConversationId(conversationIdFromActiveRow() || conversationIdFromLocation()); + const originals = window.__codexTokenUsageRouteOriginals || {}; + window.__codexTokenUsageRouteOriginals = originals; + const routeHistory = window.history; + ["pushState", "replaceState"].forEach((method) => { + const original = originals[method] || routeHistory?.[method]; + originals[method] = original; + if (typeof original !== "function") return; + routeHistory[method] = function codexTokenUsagePatchedHistory(...args) { + const result = original.apply(routeHistory, args); + setTimeout(sync, 0); + return result; + }; + }); + window.addEventListener?.("popstate", sync, true); + window.addEventListener?.("hashchange", sync, true); + sync(); + } + installFetchObserver(); installXhrObserver(); installPostMessageObserver(); installWebSocketObserver(); installContextMeterObserver(); - installTurnActivityObserver(); + installRouteObserver(); installDomObserver(); if (window.__CODEX_TOKEN_USAGE_SCRIPT_TEST__) { @@ -899,6 +1015,10 @@ normalizeContextReading, parseElapsedMs, rememberMetric, + markTurnStarted: markNetworkTurnStarted, + setActiveConversationId, + dispatchDocumentEvent: (type, event) => document.listeners?.[type]?.({ type, ...event }), + getDisplayMetric: metricForActiveConversation, getTokenUsage: () => window.__codexTokenUsage, }; } From b2736195507fcb09d954ffd0400f9869be2db020 Mon Sep 17 00:00:00 2001 From: AlbertLuo <46886876+kokotao@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:48:15 +0800 Subject: [PATCH 2/4] Fix Codex Token Usage aggregate totals --- index.json | 6 +++--- scripts/codex-token-usage.js | 12 +++++++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/index.json b/index.json index 152687e..d3c886b 100644 --- a/index.json +++ b/index.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-06-01T02:31:09Z", + "updated_at": "2026-06-01T02:56:00Z", "scripts": [ { "id": "codex-context-ring-restore", @@ -55,7 +55,7 @@ "id": "codex-token-usage", "name": "Codex Token Usage", "description": "在 Codex 每次回复完成后显示本次 token 输入、输出、缓存命中、上下文用量和耗时。", - "version": "0.1.1", + "version": "0.1.2", "author": "Albert_Luo", "tags": [ "codex", @@ -66,7 +66,7 @@ ], "homepage": "https://github.com/kokotao/codex-token-usage-script", "script_url": "https://raw.githubusercontent.com/BigPizzaV3/CodexPlusPlusScriptMarket/main/scripts/codex-token-usage.js", - "sha256": "291aae5efa44b30e161f88ab4139adda4fd7430aba59e0e313257dd67c7e857d" + "sha256": "716053f95d60984dd54ed4e219a881fc8b447c183862b5175768fe93204fec4c" }, { "id": "codex-list-pagebuster", diff --git a/scripts/codex-token-usage.js b/scripts/codex-token-usage.js index 9d6d9f5..e1d6a38 100644 --- a/scripts/codex-token-usage.js +++ b/scripts/codex-token-usage.js @@ -86,6 +86,16 @@ } if (typeof value !== "object") return null; + const totalTokenUsage = value.totalTokenUsage || value.total_token_usage; + if (totalTokenUsage) { + const totalUsage = normalizeUsage({ + ...totalTokenUsage, + modelContextWindow: value.modelContextWindow ?? value.model_context_window, + contextWindow: value.contextWindow ?? value.context_window, + }); + if (totalUsage) return totalUsage; + } + const tokenStatus = value.last || value.lastUsage || value.lastTokenUsage || value.last_token_usage; if (tokenStatus && (value.modelContextWindow || value.model_context_window || value.contextWindow || value.context_window)) { const statusUsage = normalizeUsage({ @@ -201,7 +211,7 @@ const contextPercent = usage.contextLimit ? ` (${((contextUsed / usage.contextLimit) * 100).toFixed(1)}%)` : ""; parts.push(`上下文 ${formatNumber(contextUsed)}/${formatNumber(usage.contextLimit)}${contextPercent}`); } - if (metric?.callCount > 1) parts.push(`调用 ${formatNumber(metric.callCount)} 次`); + if (metric?.callCount >= 1) parts.push(`调用 ${formatNumber(metric.callCount)} 次`); parts.push(`耗时 ${Number.isFinite(metric?.elapsedMs) && metric.elapsedMs > 0 ? formatSeconds(metric.elapsedMs) : "-"}`); return parts.join(" · "); } From 5c64f432c4218a4167a65fb0eda41ba874d15221 Mon Sep 17 00:00:00 2001 From: AlbertLuo <46886876+kokotao@users.noreply.github.com> Date: Mon, 1 Jun 2026 23:37:29 +0800 Subject: [PATCH 3/4] Restore Codex Token Usage call aggregation --- index.json | 6 +++--- scripts/codex-token-usage.js | 10 ---------- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/index.json b/index.json index d3c886b..4d84482 100644 --- a/index.json +++ b/index.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-06-01T02:56:00Z", + "updated_at": "2026-06-01T03:08:00Z", "scripts": [ { "id": "codex-context-ring-restore", @@ -55,7 +55,7 @@ "id": "codex-token-usage", "name": "Codex Token Usage", "description": "在 Codex 每次回复完成后显示本次 token 输入、输出、缓存命中、上下文用量和耗时。", - "version": "0.1.2", + "version": "0.1.3", "author": "Albert_Luo", "tags": [ "codex", @@ -66,7 +66,7 @@ ], "homepage": "https://github.com/kokotao/codex-token-usage-script", "script_url": "https://raw.githubusercontent.com/BigPizzaV3/CodexPlusPlusScriptMarket/main/scripts/codex-token-usage.js", - "sha256": "716053f95d60984dd54ed4e219a881fc8b447c183862b5175768fe93204fec4c" + "sha256": "a459445529ac0421ef9828b3990974c8ef271dff134b225471ee80c2073fd707" }, { "id": "codex-list-pagebuster", diff --git a/scripts/codex-token-usage.js b/scripts/codex-token-usage.js index e1d6a38..d104daa 100644 --- a/scripts/codex-token-usage.js +++ b/scripts/codex-token-usage.js @@ -86,16 +86,6 @@ } if (typeof value !== "object") return null; - const totalTokenUsage = value.totalTokenUsage || value.total_token_usage; - if (totalTokenUsage) { - const totalUsage = normalizeUsage({ - ...totalTokenUsage, - modelContextWindow: value.modelContextWindow ?? value.model_context_window, - contextWindow: value.contextWindow ?? value.context_window, - }); - if (totalUsage) return totalUsage; - } - const tokenStatus = value.last || value.lastUsage || value.lastTokenUsage || value.last_token_usage; if (tokenStatus && (value.modelContextWindow || value.model_context_window || value.contextWindow || value.context_window)) { const statusUsage = normalizeUsage({ From 55eaf8d1aa185b2500a729988dafc136aec136fa Mon Sep 17 00:00:00 2001 From: AlbertLuo <46886876+kokotao@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:29:53 +0800 Subject: [PATCH 4/4] Update Codex Token Usage to 0.1.5 --- index.json | 4 +- scripts/codex-token-usage.js | 272 +++++++++++++++++++++++++++++------ 2 files changed, 229 insertions(+), 47 deletions(-) diff --git a/index.json b/index.json index 4d84482..9051a2c 100644 --- a/index.json +++ b/index.json @@ -55,7 +55,7 @@ "id": "codex-token-usage", "name": "Codex Token Usage", "description": "在 Codex 每次回复完成后显示本次 token 输入、输出、缓存命中、上下文用量和耗时。", - "version": "0.1.3", + "version": "0.1.5", "author": "Albert_Luo", "tags": [ "codex", @@ -66,7 +66,7 @@ ], "homepage": "https://github.com/kokotao/codex-token-usage-script", "script_url": "https://raw.githubusercontent.com/BigPizzaV3/CodexPlusPlusScriptMarket/main/scripts/codex-token-usage.js", - "sha256": "a459445529ac0421ef9828b3990974c8ef271dff134b225471ee80c2073fd707" + "sha256": "2bffb40de30ed4406713b188682b8f1c9d21163a27fe5a03ad041bf20d6b0025" }, { "id": "codex-list-pagebuster", diff --git a/scripts/codex-token-usage.js b/scripts/codex-token-usage.js index d104daa..21cc738 100644 --- a/scripts/codex-token-usage.js +++ b/scripts/codex-token-usage.js @@ -2,15 +2,18 @@ "use strict"; const SCRIPT_ID = "codex-token-usage"; + const SCRIPT_VERSION = "0.1.5"; const BADGE_CLASS = "codex-token-usage-badge"; const STYLE_ID = "codex-token-usage-style"; const RECENT_LIMIT = 20; + const DEBUG_LIMIT = 50; const CONTEXT_POLL_INTERVAL_MS = 1000; const TURN_IDLE_TIMEOUT_MS = 120000; const STORAGE_KEY = "__codexTokenUsageRecentDetails"; - if (window.__codexTokenUsageScriptInstalled) return; + if (window.__codexTokenUsageScriptInstalled && window.__codexTokenUsageVersion === SCRIPT_VERSION) return; window.__codexTokenUsageScriptInstalled = true; + window.__codexTokenUsageVersion = SCRIPT_VERSION; const state = { lastMetric: null, @@ -22,6 +25,17 @@ turnSeq: 0, turnStartedAt: 0, contextPollTimer: 0, + pendingTurnStartAt: 0, + debug: [], + }; + + window.__codexTokenUsageDebug = state.debug; + window.__codexTokenUsage = { + version: SCRIPT_VERSION, + last: null, + currentTurn: null, + recent: [], + debug: state.debug, }; function normalizeNumber(value) { @@ -50,7 +64,10 @@ ); const cacheReadTokens = normalizeNumber(raw.cache_read_input_tokens ?? raw.cacheReadInputTokens); const cacheCreationTokens = normalizeNumber(raw.cache_creation_input_tokens ?? raw.cacheCreationInputTokens); - const contextLimit = normalizeNumber(raw.modelContextWindow ?? raw.model_context_window ?? raw.contextWindow ?? raw.context_window ?? raw.limit); + 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, + ); if ( !inputTokens && !outputTokens && @@ -70,7 +87,7 @@ cacheReadTokens, cacheCreationTokens, hasBreakdown: !!(inputTokens || outputTokens || cachedTokens || cacheReadTokens || cacheCreationTokens), - contextUsed: totalTokens, + contextUsed: contextUsed || totalTokens, contextLimit, }; } @@ -124,6 +141,65 @@ return null; } + function collectUsagesInObject(value, depth = 0, usages = [], seen = new WeakSet()) { + if (!value || depth > 8) return usages; + if (Array.isArray(value)) { + value.forEach((item) => collectUsagesInObject(item, depth + 1, usages, seen)); + return usages; + } + if (typeof value !== "object") return usages; + if (seen.has(value)) return usages; + seen.add(value); + + const tokenStatus = value.last || value.lastUsage || value.lastTokenUsage || value.last_token_usage; + if (tokenStatus && (value.modelContextWindow || value.model_context_window || value.contextWindow || value.context_window)) { + const statusUsage = normalizeUsage({ + ...tokenStatus, + modelContextWindow: value.modelContextWindow ?? value.model_context_window, + contextWindow: value.contextWindow ?? value.context_window, + }); + if (statusUsage) { + usages.push(statusUsage); + return usages; + } + } + + const directKeys = ["usage", "last", "lastUsage", "lastTokenUsage", "last_token_usage"]; + const consumedKeys = new Set(); + for (const key of directKeys) { + const direct = normalizeUsage(value[key]); + if (direct) { + usages.push(direct); + consumedKeys.add(key); + } + } + + const self = normalizeUsage(value); + if (self) { + usages.push(self); + return usages; + } + + for (const key of [ + "response", + "data", + "body", + "message", + "result", + "event", + "params", + "tokenUsage", + "token_usage", + "contextUsage", + "context_usage", + "info", + ]) { + if (consumedKeys.has(key)) continue; + collectUsagesInObject(value[key], depth + 1, usages, seen); + } + return usages; + } + function extractJsonFragmentsFromSse(text) { return String(text || "") .split(/\r?\n/) @@ -133,26 +209,30 @@ .filter((line) => line && line !== "[DONE]"); } - function extractUsage(payload) { + function extractUsages(payload) { if (typeof payload === "string") { try { const parsed = JSON.parse(payload); - const usage = findUsageInObject(parsed); - if (usage) return usage; + const usages = collectUsagesInObject(parsed); + if (usages.length) return usages; } catch (_) { // Treat non-JSON text as a possible SSE stream below. } + const usages = []; for (const fragment of extractJsonFragmentsFromSse(payload)) { try { - const usage = findUsageInObject(JSON.parse(fragment)); - if (usage) return usage; + collectUsagesInObject(JSON.parse(fragment), 0, usages); } catch (_) { // Ignore malformed stream fragments. } } - return null; + return usages; } - return findUsageInObject(payload); + return collectUsagesInObject(payload); + } + + function extractUsage(payload) { + return extractUsages(payload)[0] || null; } function formatNumber(value) { @@ -287,6 +367,7 @@ function metricForActiveConversation() { const active = currentConversationId(); + if (active && state.byConversation[active]) return state.byConversation[active]; if (state.currentTurn && !state.currentTurn.calls.length && state.currentTurn.status === "running") { if (!active || !state.currentTurn.conversationId || active === state.currentTurn.conversationId) { return { @@ -298,7 +379,6 @@ }; } } - if (active && state.byConversation[active]) return state.byConversation[active]; return conversationMatchesActive(state.lastMetric) ? state.lastMetric : null; } @@ -363,13 +443,15 @@ function beginTurn(started = nowMs()) { state.currentTurn = createTurn(started); state.turnStartedAt = started; + state.pendingTurnStartAt = 0; return state.currentTurn; } function ensureTurnStarted(started = nowMs()) { if ( !state.currentTurn || - (state.currentTurn.calls.length && started - state.currentTurn.lastUpdatedAt > TURN_IDLE_TIMEOUT_MS) + state.pendingTurnStartAt || + (!state.currentTurn.calls.length && started - state.currentTurn.lastUpdatedAt > TURN_IDLE_TIMEOUT_MS) ) { return beginTurn(started); } @@ -382,6 +464,10 @@ scheduleRender(); } + function markUserTurnPending(started = nowMs()) { + state.pendingTurnStartAt = started; + } + function markNetworkTurnStarted(started = nowMs()) { const turn = ensureTurnStarted(started); if (!turn.calls.length) scheduleRender(); @@ -437,7 +523,9 @@ function readStoredDetails() { try { const parsed = JSON.parse(window.sessionStorage?.getItem(STORAGE_KEY) || "[]"); - return Array.isArray(parsed) ? parsed.filter((item) => item?.usage) : []; + return Array.isArray(parsed) + ? parsed.filter((item) => item?.usage && (item.callCount >= 1 || item.source === "turn-aggregate")) + : []; } catch (_) { return []; } @@ -445,6 +533,7 @@ function writeStoredDetails(metric) { if (!usageHasBreakdown(metric?.usage)) return; + if (!(metric.callCount >= 1 || metric.source === "turn-aggregate")) return; try { const recent = [metric, ...readStoredDetails().filter((item) => !sameUsage(metric, item))].slice(0, RECENT_LIMIT); window.sessionStorage?.setItem(STORAGE_KEY, JSON.stringify(recent)); @@ -453,6 +542,30 @@ } } + function usageDebugSummary(usage) { + return { + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + totalTokens: usage.totalTokens || 0, + cachedTokens: usage.cachedTokens || usage.cacheReadTokens || 0, + contextLimit: usage.contextLimit || 0, + hasBreakdown: usageHasBreakdown(usage), + }; + } + + function pushDebug(entry) { + state.debug.unshift({ + at: new Date().toISOString(), + activeConversationId: currentConversationId(), + currentCallCount: state.currentTurn?.calls.length || 0, + pendingTurn: !!state.pendingTurnStartAt, + ...entry, + }); + state.debug = state.debug.slice(0, DEBUG_LIMIT); + window.__codexTokenUsageDebug = state.debug.slice(); + if (window.__codexTokenUsage) window.__codexTokenUsage.debug = state.debug.slice(); + } + function aggregateTurnMetric(turn) { const usage = turn.calls.reduce( (total, call) => { @@ -506,6 +619,7 @@ state.recent.unshift(state.lastMetric); state.recent = state.recent.slice(0, RECENT_LIMIT); window.__codexTokenUsage = { + version: SCRIPT_VERSION, last: state.lastMetric, currentTurn: state.currentTurn ? { @@ -517,6 +631,7 @@ } : null, recent: state.recent.slice(), + debug: state.debug.slice(), }; if (storeDetails) writeStoredDetails(state.lastMetric); scheduleRender(); @@ -564,7 +679,6 @@ turn.elapsedMs = Math.max(turn.elapsedMs || 0, metric.elapsedMs || elapsedSinceTurnStarted()); turn.lastUpdatedAt = nowMs(); publishMetric(aggregateTurnMetric(turn)); - writeStoredDetails(metric); } function rememberMetric(metric) { @@ -576,18 +690,35 @@ } } + function rememberUsages(usages, baseMetric) { + let captured = false; + usages.forEach((usage) => { + rememberMetric({ ...baseMetric, usage }); + captured = true; + }); + return captured; + } + + function processPayload(payload, source, conversationId, elapsedMs, url) { + const usages = extractUsages(payload); + pushDebug({ + type: "payload", + source, + conversationId: conversationId || "", + url: url || "", + elapsedMs: elapsedMs || 0, + usageCount: usages.length, + usages: usages.map(usageDebugSummary), + }); + return rememberUsages(usages, { elapsedMs, source, conversationId, url }); + } + function parseResponseText(text, elapsedMs, url) { - const usage = extractUsage(text); - if (usage) rememberMetric({ usage, elapsedMs, url, source: "network" }); + processPayload(text, "network", "", elapsedMs, url); } function inspectPayload(payload, source, conversationId) { - const usage = extractUsage(payload); - if (usage) { - rememberMetric({ usage, elapsedMs: elapsedSinceTurnStarted(), source, conversationId }); - return true; - } - return false; + return processPayload(payload, source, conversationId, elapsedSinceTurnStarted()); } function inspectPayloadText(text, source, conversationId) { @@ -595,8 +726,9 @@ } function installFetchObserver() { - if (typeof window.fetch !== "function" || window.fetch.__codexTokenUsageWrapped) return; - const originalFetch = window.fetch.bind(window); + if (typeof window.fetch !== "function" || window.fetch.__codexTokenUsageWrapped === SCRIPT_VERSION) return; + const baseFetch = window.fetch.__codexTokenUsageOriginal || window.fetch; + const originalFetch = baseFetch.bind(window); function wrappedFetch(input, init) { const url = requestUrl(input); const started = nowMs(); @@ -612,15 +744,16 @@ return response; }); } - wrappedFetch.__codexTokenUsageWrapped = true; + wrappedFetch.__codexTokenUsageWrapped = SCRIPT_VERSION; + wrappedFetch.__codexTokenUsageOriginal = baseFetch; window.fetch = wrappedFetch; } function installXhrObserver() { const Xhr = window.XMLHttpRequest; - if (!Xhr || Xhr.prototype.__codexTokenUsageWrapped) return; - const originalOpen = Xhr.prototype.open; - const originalSend = Xhr.prototype.send; + if (!Xhr || Xhr.prototype.__codexTokenUsageWrapped === SCRIPT_VERSION) return; + const originalOpen = Xhr.prototype.__codexTokenUsageOriginalOpen || Xhr.prototype.open; + const originalSend = Xhr.prototype.__codexTokenUsageOriginalSend || Xhr.prototype.send; Xhr.prototype.open = function open(method, url, ...rest) { this.__codexTokenUsageUrl = url; return originalOpen.call(this, method, url, ...rest); @@ -639,11 +772,53 @@ }); return originalSend.apply(this, args); }; - Xhr.prototype.__codexTokenUsageWrapped = true; + Xhr.prototype.__codexTokenUsageOriginalOpen = originalOpen; + Xhr.prototype.__codexTokenUsageOriginalSend = originalSend; + Xhr.prototype.__codexTokenUsageWrapped = SCRIPT_VERSION; + } + + function isEditableTarget(target) { + return !!( + target && + (target.tagName === "TEXTAREA" || + target.tagName === "INPUT" || + target.isContentEditable || + target.closest?.("textarea,input,[contenteditable='true']")) + ); + } + + function isSendTrigger(event) { + const target = event.target; + if (event.type === "submit") return true; + if (event.type === "keydown") { + return event.key === "Enter" && !event.shiftKey && isEditableTarget(target); + } + if (event.type === "click") { + const label = `${target?.getAttribute?.("aria-label") || ""} ${target?.textContent || ""}`; + return /^(发送|提交|Send|Submit)$|send|submit/i.test(label); + } + return false; + } + + function installTurnPendingObserver() { + if (window.__codexTokenUsageTurnPendingObserver === SCRIPT_VERSION) return; + const handler = (event) => { + try { + if (!isSendTrigger(event)) return; + markUserTurnPending(); + pushDebug({ type: "pending-turn", source: event.type }); + } catch (_) { + // Keep page input handling untouched. + } + }; + ["click", "submit", "keydown"].forEach((type) => { + document.addEventListener?.(type, handler, true); + }); + window.__codexTokenUsageTurnPendingObserver = SCRIPT_VERSION; } function installPostMessageObserver() { - if (window.__codexTokenUsageMessageObserver) return; + if (window.__codexTokenUsageMessageObserver === SCRIPT_VERSION) return; window.addEventListener?.( "message", (event) => { @@ -655,12 +830,12 @@ }, true, ); - window.__codexTokenUsageMessageObserver = true; + window.__codexTokenUsageMessageObserver = SCRIPT_VERSION; } function installWebSocketObserver() { - if (typeof window.WebSocket !== "function" || window.__codexTokenUsageWebSocketWrapped) return; - const NativeWebSocket = window.WebSocket; + if (typeof window.WebSocket !== "function" || window.__codexTokenUsageWebSocketWrapped === SCRIPT_VERSION) return; + const NativeWebSocket = window.__codexTokenUsageNativeWebSocket || window.WebSocket; function TokenUsageWebSocket(...args) { const socket = new NativeWebSocket(...args); @@ -689,7 +864,8 @@ } window.WebSocket = TokenUsageWebSocket; - window.__codexTokenUsageWebSocketWrapped = true; + window.__codexTokenUsageNativeWebSocket = NativeWebSocket; + window.__codexTokenUsageWebSocketWrapped = SCRIPT_VERSION; } function normalizeContextReading(reading) { @@ -731,14 +907,13 @@ function installContextMeterObserver() { const captureState = window.__codexContextMeterCaptureState; - if (captureState && !captureState.__codexTokenUsageWrapped) { - const originalInspectText = captureState.inspectText; + if (captureState && captureState.__codexTokenUsageWrapped !== SCRIPT_VERSION) { + const originalInspectText = captureState.__codexTokenUsageOriginalInspectText || captureState.inspectText; if (typeof originalInspectText === "function") { captureState.inspectText = function codexTokenUsageInspectText(text, source, conversationId) { const started = elapsedSinceTurnStarted(); try { - const usage = extractUsage(text); - if (usage) rememberMetric({ usage, elapsedMs: started, source: source || "context-capture", conversationId }); + processPayload(text, source || "context-capture", conversationId, started); } catch (_) { // Keep the upstream context meter path intact. } @@ -746,13 +921,12 @@ }; } - const originalInspectValue = captureState.inspectValue; + const originalInspectValue = captureState.__codexTokenUsageOriginalInspectValue || captureState.inspectValue; if (typeof originalInspectValue === "function") { captureState.inspectValue = function codexTokenUsageInspectValue(value, source, conversationId) { let reading = null; try { - const usage = extractUsage(value); - if (usage) rememberMetric({ usage, elapsedMs: elapsedSinceTurnStarted(), source: source || "context-value", conversationId }); + processPayload(value, source || "context-value", conversationId, elapsedSinceTurnStarted()); } catch (_) { // Continue to the original inspector. } @@ -761,7 +935,9 @@ return reading; }; } - captureState.__codexTokenUsageWrapped = true; + captureState.__codexTokenUsageOriginalInspectText = originalInspectText; + captureState.__codexTokenUsageOriginalInspectValue = originalInspectValue; + captureState.__codexTokenUsageWrapped = SCRIPT_VERSION; } readContextMeterMetric(); @@ -946,6 +1122,7 @@ badge.dataset.metricId = displayMetric.id || ""; badge.dataset.status = displayMetric.status || "complete"; badge.dataset.conversationId = displayMetric.conversationId || ""; + badge.dataset.version = SCRIPT_VERSION; badge.dataset.placement = target === document.querySelector("main") ? "fallback" : "message-actions"; badge.textContent = formatBadgeText(displayMetric); document.querySelectorAll(`.${BADGE_CLASS}`).forEach((node) => { @@ -959,7 +1136,8 @@ } function installDomObserver() { - if (!window.MutationObserver || window.__codexTokenUsageDomObserver) return; + if (!window.MutationObserver || window.__codexTokenUsageDomObserverVersion === SCRIPT_VERSION) return; + window.__codexTokenUsageDomObserver?.disconnect?.(); window.__codexTokenUsageDomObserver = new MutationObserver(() => { const nextConversationId = conversationIdFromActiveRow() || conversationIdFromLocation(); if (nextConversationId && nextConversationId !== state.activeConversationId) setActiveConversationId(nextConversationId); @@ -974,11 +1152,12 @@ } else { start(); } + window.__codexTokenUsageDomObserverVersion = SCRIPT_VERSION; } function installRouteObserver() { - if (window.__codexTokenUsageRouteObserver) return; - window.__codexTokenUsageRouteObserver = true; + if (window.__codexTokenUsageRouteObserver === SCRIPT_VERSION) return; + window.__codexTokenUsageRouteObserver = SCRIPT_VERSION; const sync = () => setActiveConversationId(conversationIdFromActiveRow() || conversationIdFromLocation()); const originals = window.__codexTokenUsageRouteOriginals || {}; window.__codexTokenUsageRouteOriginals = originals; @@ -1000,6 +1179,7 @@ installFetchObserver(); installXhrObserver(); + installTurnPendingObserver(); installPostMessageObserver(); installWebSocketObserver(); installContextMeterObserver(); @@ -1014,11 +1194,13 @@ normalizeUsage, normalizeContextReading, parseElapsedMs, + processPayload, rememberMetric, markTurnStarted: markNetworkTurnStarted, setActiveConversationId, dispatchDocumentEvent: (type, event) => document.listeners?.[type]?.({ type, ...event }), getDisplayMetric: metricForActiveConversation, + getStoredDetails: readStoredDetails, getTokenUsage: () => window.__codexTokenUsage, }; }