From 2c59ea8a2e76d7ce786cb17cc373537d8b9c95b1 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Sat, 16 May 2026 08:40:09 -0700 Subject: [PATCH] fix(sessions): estimate local transcript usage Fixes #73990.\n\nAdds a transcript-derived token estimate for local/OpenAI-compatible session transcripts that have real content but no provider usage telemetry, preserving provider-reported usage when available and gating estimation on assistant model identity.\n\nVerification:\n- CI run 25965717279: success\n- Real behavior proof run 25965716561: success\n- Azure Crabbox clean-clone proof: pnpm test src/gateway/session-utils.fs.test.ts src/status/status-message.test.ts; pnpm check:changed; pnpm exec tsx /tmp/openclaw-transcript-proof.mts; git diff --check origin/main...HEAD --- CHANGELOG.md | 1 + src/gateway/session-utils.fs.test.ts | 39 +++++++++++ src/gateway/session-utils.fs.ts | 100 +++++++++++++++++++++++++++ 3 files changed, 140 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 822b2cb003b..33c1222b7f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes - TUI: update the displayed model in real time when an auto-fallback resolution swaps in a different model mid-turn, so the status line reflects the actual model handling the run. Fixes #82296. Thanks @giodl73-repo. +- Gateway/sessions: estimate context usage from local/OpenAI-compatible transcripts when provider usage telemetry is missing, so status no longer shows empty usage for real local-model sessions. Fixes #73990. (#82317) Thanks @giodl73-repo. - Agents/sessions: preserve fresh post-compaction token snapshots across stale usage updates, preventing repeated auto-compaction after every message. Fixes #82576. (#82578) Thanks @njuboy11. - Agents/OpenAI Responses: log redacted diagnostics for detail-less `response.failed` events while preserving failed response ids, so operators can correlate provider-side failures. Fixes #82558. - Gateway/sessions: discard stale metadata when recreating dead main session rows, so replacement sessions do not inherit old labels or transcript paths. diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index 98e8de4bfa5..23eaf03ebd4 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { SessionManager } from "@earendil-works/pi-coding-agent"; import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest"; +import { estimateStringChars, estimateTokensFromChars } from "../utils/cjk-chars.js"; import { createToolSummaryPreviewTranscriptLines } from "./session-preview.test-helpers.js"; import { clearSessionTranscriptIndexCache } from "./session-transcript-index.fs.js"; import { @@ -1812,6 +1813,44 @@ describe("readLatestSessionUsageFromTranscript", () => { } }); + test("estimates transcript context when local model usage telemetry is missing", () => { + const sessionId = "usage-local-missing-telemetry"; + const userText = "local prompt ".repeat(200); + const assistantText = "local response ".repeat(120); + writeTranscript(tmpDir, sessionId, [ + { type: "session", version: 1, id: sessionId }, + { message: { role: "user", content: userText } }, + { + message: { + role: "assistant", + provider: "openai-completions", + model: "local-llama", + content: [{ type: "text", text: assistantText }], + }, + }, + ]); + + const expectedTotalTokens = estimateTokensFromChars( + estimateStringChars(userText) + estimateStringChars(assistantText), + ); + + expectUsageFields(readLatestSessionUsageFromTranscript(sessionId, storePath), { + modelProvider: "openai-completions", + model: "local-llama", + totalTokens: expectedTotalTokens, + totalTokensFresh: true, + }); + expectUsageFields( + readRecentSessionUsageFromTranscript(sessionId, storePath, undefined, undefined, 64 * 1024), + { + modelProvider: "openai-completions", + model: "local-llama", + totalTokens: expectedTotalTokens, + totalTokensFresh: true, + }, + ); + }); + test("returns null when the transcript has no assistant usage snapshot", () => { const sessionId = "usage-empty"; writeTranscript(tmpDir, sessionId, [ diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index 5b8c0057db7..785f4356108 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -5,6 +5,7 @@ import { jsonUtf8Bytes } from "../infra/json-utf8-bytes.js"; import { hasInterSessionUserProvenance } from "../sessions/input-provenance.js"; import { extractAssistantVisibleText } from "../shared/chat-message-content.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { estimateStringChars, estimateTokensFromChars } from "../utils/cjk-chars.js"; import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js"; import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js"; import { stripEnvelope } from "./chat-sanitize.js"; @@ -1159,6 +1160,85 @@ function resolvePositiveUsageNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; } +function extractTranscriptContentEstimatedChars(content: unknown): number { + if (typeof content === "string") { + const normalized = stripInlineDirectiveTagsForDisplay(content).text.trim(); + return normalized ? estimateStringChars(normalized) : 0; + } + if (!Array.isArray(content)) { + return 0; + } + let chars = 0; + for (const part of content) { + if (!part || typeof part !== "object" || Array.isArray(part)) { + continue; + } + const record = part as Record; + if (typeof record.text !== "string") { + continue; + } + const type = typeof record.type === "string" ? record.type : "text"; + if (type !== "text" && type !== "output_text" && type !== "input_text") { + continue; + } + const normalized = stripInlineDirectiveTagsForDisplay(record.text).text.trim(); + if (normalized) { + chars += estimateStringChars(normalized); + } + } + return chars; +} + +function extractTranscriptTokenEstimateFromLine(line: string): { + estimatedChars: number; + hasModelIdentity: boolean; +} | null { + if (isOversizedTranscriptLine(line)) { + return null; + } + try { + const parsed = JSON.parse(line) as Record; + const message = + parsed.message && typeof parsed.message === "object" && !Array.isArray(parsed.message) + ? (parsed.message as Record) + : undefined; + if (!message) { + return null; + } + const role = typeof message.role === "string" ? message.role : undefined; + if (role !== "user" && role !== "assistant") { + return null; + } + const modelProvider = + typeof message.provider === "string" + ? message.provider.trim() + : typeof parsed.provider === "string" + ? parsed.provider.trim() + : undefined; + const model = + typeof message.model === "string" + ? message.model.trim() + : typeof parsed.model === "string" + ? parsed.model.trim() + : undefined; + const isDeliveryMirror = + role === "assistant" && modelProvider === "openclaw" && model === "delivery-mirror"; + if (isDeliveryMirror) { + return null; + } + const contentChars = extractTranscriptContentEstimatedChars(message.content); + if (contentChars <= 0) { + return null; + } + return { + estimatedChars: contentChars, + hasModelIdentity: role === "assistant" && Boolean(modelProvider || model), + }; + } catch { + return null; + } +} + function extractUsageSnapshotFromTranscriptLine( line: string, ): SessionTranscriptUsageSnapshot | null { @@ -1261,8 +1341,17 @@ function extractAggregateUsageFromTranscriptLines( let sawCacheWrite = false; let costUsdTotal = 0; let sawCost = false; + let estimatedTranscriptChars = 0; + let sawEstimatedTranscriptContent = false; + let sawEstimateModelIdentity = false; for (const line of lines) { + const estimate = extractTranscriptTokenEstimateFromLine(line); + if (estimate) { + estimatedTranscriptChars += estimate.estimatedChars; + sawEstimatedTranscriptContent = true; + sawEstimateModelIdentity ||= estimate.hasModelIdentity; + } const current = extractUsageSnapshotFromTranscriptLine(line); if (!current) { continue; @@ -1318,6 +1407,17 @@ function extractAggregateUsageFromTranscriptLines( if (sawCost) { snapshot.costUsd = costUsdTotal; } + if ( + typeof snapshot.totalTokens !== "number" && + sawEstimatedTranscriptContent && + sawEstimateModelIdentity + ) { + const estimatedTotalTokens = estimateTokensFromChars(estimatedTranscriptChars); + if (estimatedTotalTokens > 0) { + snapshot.totalTokens = estimatedTotalTokens; + snapshot.totalTokensFresh = true; + } + } return snapshot; }