mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-19 00:14:46 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user