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
This commit is contained in:
Gio Della-Libera
2026-05-16 08:40:09 -07:00
committed by GitHub
parent 575936473d
commit 2c59ea8a2e
3 changed files with 140 additions and 0 deletions

View File

@@ -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.

View File

@@ -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, [

View File

@@ -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<string, unknown>;
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<string, unknown>;
const message =
parsed.message && typeof parsed.message === "object" && !Array.isArray(parsed.message)
? (parsed.message as Record<string, unknown>)
: 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;
}