mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-09 03:22:40 +00:00
fix(usage): warn on broken footer templates
This commit is contained in:
@@ -48,8 +48,87 @@ footer when valid:
|
||||
}
|
||||
```
|
||||
|
||||
Missing, unreadable, invalid, or empty templates fall back to the built-in
|
||||
footer.
|
||||
Missing or empty templates fall back to the built-in footer quietly. Unreadable
|
||||
or invalid configured templates also fall back to the built-in footer and emit an
|
||||
operator warning.
|
||||
|
||||
Start custom templates from the built-in shape, then edit the parts you want to
|
||||
change:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schema": "openclaw.usageBar.v1",
|
||||
"scales": {
|
||||
"braille": "⠐⡀⡄⡆⡇⣇⣧⣷⣿",
|
||||
"block": "░▏▎▍▌▋▊▉█",
|
||||
"shade": "░▒▓█",
|
||||
"moon": "🌑🌘🌗🌖🌕",
|
||||
"level": "▁▂▃▄▅▆▇█",
|
||||
"weather": ["🥶", "☁️", "🌥", "⛅️", "🌤", "☀️"],
|
||||
"plants": ["", "🍂", "🌱", "☘️", "🍀", "🌿"],
|
||||
"moons6": ["🌑", "🌚", "🌘", "🌗", "🌖", "🌝"],
|
||||
},
|
||||
"aliases": {
|
||||
"models": {
|
||||
"claude-opus-4-6": "opus46",
|
||||
"claude-opus-4-8": "opus48",
|
||||
"claude-sonnet-4-6": "sonnet46",
|
||||
"claude-haiku-4-5": "haiku45",
|
||||
"gpt-5.5": "gpt5.5",
|
||||
},
|
||||
"reasoning": {
|
||||
"off": "🌑",
|
||||
"minimal": "🌚",
|
||||
"low": "🌘",
|
||||
"medium": "🌗",
|
||||
"high": "🌕",
|
||||
"xhigh": "🌝",
|
||||
},
|
||||
},
|
||||
"output": {
|
||||
"sep": "",
|
||||
"default": [
|
||||
{ "text": "{model.provider}{identity.emoji|🤖} {model.display_name|alias:models}" },
|
||||
{ "map": "model.is_fallback", "cases": { "true": " 🔄" } },
|
||||
{ "map": "model.is_override", "cases": { "true": " 📌" } },
|
||||
{ "when": "model.reasoning", "text": " {model.reasoning|alias:reasoning}" },
|
||||
{ "map": "state.fast_mode", "cases": { "true": " ⚡", "false": " 🐌" } },
|
||||
{
|
||||
"when": "context.max_tokens",
|
||||
"text": " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}",
|
||||
},
|
||||
{
|
||||
"when": "usage.has_split_tokens",
|
||||
"text": " ↕️ {usage.input_tokens|num|?}/{usage.output_tokens|num|?}",
|
||||
},
|
||||
{ "when": "usage.has_total_only_tokens", "text": " ↕️ {usage.total_tokens|num}" },
|
||||
{ "when": "usage.cache_hit_pct", "text": " 🗄 {usage.cache_hit_pct|pct}" },
|
||||
{ "when": "cost.turn_usd", "text": " 💰{cost.turn_usd|fixed:4}" },
|
||||
],
|
||||
"surfaces": {
|
||||
"discord": [
|
||||
{ "text": "-# -\n" },
|
||||
{ "text": "-# {model.provider}{identity.emoji|🤖} {model.display_name|alias:models}" },
|
||||
{ "map": "model.is_fallback", "cases": { "true": "🔄" } },
|
||||
{ "map": "model.is_override", "cases": { "true": "📌" } },
|
||||
{ "when": "model.reasoning", "text": " {model.reasoning|alias:reasoning}" },
|
||||
{ "map": "state.fast_mode", "cases": { "true": " ⚡️", "false": " 🐌" } },
|
||||
{
|
||||
"when": "context.max_tokens",
|
||||
"text": " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}",
|
||||
},
|
||||
{
|
||||
"when": "usage.has_split_tokens",
|
||||
"text": " ↕️ {usage.input_tokens|num|?}/{usage.output_tokens|num|?}",
|
||||
},
|
||||
{ "when": "usage.has_total_only_tokens", "text": " ↕️ {usage.total_tokens|num}" },
|
||||
{ "when": "usage.cache_hit_pct", "text": " 🗄 {usage.cache_hit_pct|pct}" },
|
||||
{ "when": "cost.turn_usd", "text": " 💰{cost.turn_usd|fixed:4}" },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Shape
|
||||
|
||||
@@ -92,7 +171,8 @@ empty (so a `when` guard or a `|fallback` keeps the piece clean).
|
||||
| `model.is_fallback` / `model.is_override` | bool: fallback used / model pinned |
|
||||
| `state.fast_mode` | bool: fast vs slow |
|
||||
| `context.max_tokens` / `context.pct_used` | window budget / 0-100 used |
|
||||
| `usage.input_tokens` / `usage.output_tokens` / `usage.cache_hit_pct` | turn aggregate |
|
||||
| `usage.input_tokens` / `usage.output_tokens` / `usage.total_tokens` | turn aggregate |
|
||||
| `usage.has_split_tokens` / `usage.has_total_only_tokens` / `usage.cache_hit_pct` | token display guards and cache percent |
|
||||
| `usage.last.input_tokens` / `usage.last.output_tokens` / `usage.last.cache_hit_pct` | final model call only |
|
||||
| `cost.turn_usd` | estimated turn cost |
|
||||
| `identity.name` / `identity.emoji` | agent name / chosen emoji |
|
||||
|
||||
@@ -2729,6 +2729,29 @@ describe("runReplyAgent response usage footer", () => {
|
||||
expect(text).not.toContain("Usage:");
|
||||
});
|
||||
|
||||
it("shows aggregate-only token totals in the built-in full footer", async () => {
|
||||
runEmbeddedAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
agentMeta: {
|
||||
provider: "anthropic",
|
||||
model: "claude",
|
||||
usage: { total: 1250 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await createRun({
|
||||
responseUsage: "full",
|
||||
sessionKey: "agent:main:whatsapp:dm:+1000",
|
||||
});
|
||||
const payload = Array.isArray(res) ? res[0] : res;
|
||||
const text = payload?.text ?? "";
|
||||
expect(text).toContain("↕️ 1.3k");
|
||||
expect(text).not.toContain("↕️ ?/?");
|
||||
expect(text).not.toContain("Usage:");
|
||||
});
|
||||
|
||||
it("shows configured costs for aws-sdk providers when responseUsage=full", async () => {
|
||||
runEmbeddedAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "ok" }],
|
||||
|
||||
@@ -11,12 +11,10 @@ export function buildUsageContract(
|
||||
const cacheRead = usage.cacheRead;
|
||||
const cacheWrite = usage.cacheWrite;
|
||||
const total = usage.total;
|
||||
const hasSplitTokens = input !== undefined || output !== undefined;
|
||||
const hasTotalOnlyTokens = !hasSplitTokens && total !== undefined;
|
||||
const hasTokens =
|
||||
input !== undefined ||
|
||||
output !== undefined ||
|
||||
cacheRead !== undefined ||
|
||||
cacheWrite !== undefined ||
|
||||
total !== undefined;
|
||||
hasSplitTokens || cacheRead !== undefined || cacheWrite !== undefined || total !== undefined;
|
||||
|
||||
const promptTotal = (cacheRead ?? 0) + (cacheWrite ?? 0) + (input ?? 0);
|
||||
const cacheHitPct =
|
||||
@@ -77,6 +75,8 @@ export function buildUsageContract(
|
||||
total_tokens: total,
|
||||
cache_hit_pct: cacheHitPct,
|
||||
has_tokens: hasTokens,
|
||||
has_split_tokens: hasSplitTokens,
|
||||
has_total_only_tokens: hasTotalOnlyTokens,
|
||||
last: last
|
||||
? {
|
||||
input_tokens: last.input,
|
||||
|
||||
@@ -35,9 +35,10 @@ export const DEFAULT_USAGE_BAR_TEMPLATE: UsageBarTemplate = {
|
||||
text: " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}",
|
||||
},
|
||||
{
|
||||
when: "usage.has_tokens",
|
||||
when: "usage.has_split_tokens",
|
||||
text: " ↕️ {usage.input_tokens|num|?}/{usage.output_tokens|num|?}",
|
||||
},
|
||||
{ when: "usage.has_total_only_tokens", text: " ↕️ {usage.total_tokens|num}" },
|
||||
{ when: "usage.cache_hit_pct", text: " 🗄 {usage.cache_hit_pct|pct}" },
|
||||
{ when: "cost.turn_usd", text: " 💰{cost.turn_usd|fixed:4}" },
|
||||
],
|
||||
@@ -54,9 +55,10 @@ export const DEFAULT_USAGE_BAR_TEMPLATE: UsageBarTemplate = {
|
||||
text: " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}",
|
||||
},
|
||||
{
|
||||
when: "usage.has_tokens",
|
||||
when: "usage.has_split_tokens",
|
||||
text: " ↕️ {usage.input_tokens|num|?}/{usage.output_tokens|num|?}",
|
||||
},
|
||||
{ when: "usage.has_total_only_tokens", text: " ↕️ {usage.total_tokens|num}" },
|
||||
{ when: "usage.cache_hit_pct", text: " 🗄 {usage.cache_hit_pct|pct}" },
|
||||
{ when: "cost.turn_usd", text: " 💰{cost.turn_usd|fixed:4}" },
|
||||
],
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_USAGE_BAR_TEMPLATE } from "./default-template.js";
|
||||
import { clearUsageBarTemplateCacheForTest, loadUsageBarTemplate } from "./template.js";
|
||||
|
||||
const warnSpy = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../logging/subsystem.js", () => ({
|
||||
createSubsystemLogger: () => ({ warn: warnSpy }),
|
||||
}));
|
||||
|
||||
const tplA = { segments: [{ text: "A" }] };
|
||||
const tplB = { output: { default: [{ text: "B" }] } };
|
||||
|
||||
@@ -12,6 +18,7 @@ let dir: string | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
clearUsageBarTemplateCacheForTest();
|
||||
warnSpy.mockClear();
|
||||
if (dir) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
dir = undefined;
|
||||
@@ -36,8 +43,17 @@ describe("loadUsageBarTemplate", () => {
|
||||
|
||||
it("falls back to the built-in template for an unusable inline object", () => {
|
||||
expect(loadUsageBarTemplate({ nope: true })).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy.mock.calls[0]).toMatchObject([
|
||||
"configured usage template could not be used; using built-in footer",
|
||||
{ source: "inline", reason: "unsupported-shape" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back quietly for an empty inline template", () => {
|
||||
expect(loadUsageBarTemplate({ output: {} })).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
|
||||
expect(loadUsageBarTemplate({ output: { default: [] } })).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads and parses a template file", () => {
|
||||
@@ -48,17 +64,24 @@ describe("loadUsageBarTemplate", () => {
|
||||
it("falls back to the built-in template for invalid JSON", () => {
|
||||
const path = tmpFile("bad.json", "{ not json");
|
||||
expect(loadUsageBarTemplate(path)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy.mock.calls[0]).toMatchObject([
|
||||
"configured usage template could not be used; using built-in footer",
|
||||
{ source: "file", reason: "invalid-json", path },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the built-in template for an empty template file", () => {
|
||||
const path = tmpFile("empty.json", JSON.stringify({ output: { default: [] } }));
|
||||
expect(loadUsageBarTemplate(path)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reloads a path after an initial miss", () => {
|
||||
dir = mkdtempSync(join(tmpdir(), "usage-template-"));
|
||||
const missing = join(dir, "missing.json");
|
||||
expect(loadUsageBarTemplate(missing)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
writeFileSync(missing, JSON.stringify(tplB));
|
||||
expect(loadUsageBarTemplate(missing)).toMatchObject(tplB);
|
||||
});
|
||||
@@ -66,6 +89,7 @@ describe("loadUsageBarTemplate", () => {
|
||||
it("reloads a path after invalid JSON is fixed", () => {
|
||||
const path = tmpFile("bad.json", "{ not json");
|
||||
expect(loadUsageBarTemplate(path)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
writeFileSync(path, JSON.stringify(tplB));
|
||||
expect(loadUsageBarTemplate(path)).toMatchObject(tplB);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type FSWatcher, readFileSync, watch } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { DEFAULT_USAGE_BAR_TEMPLATE } from "./default-template.js";
|
||||
import type { UsageBarTemplate } from "./translator.js";
|
||||
|
||||
@@ -8,6 +9,8 @@ export type UsageTemplateConfig = string | Record<string, unknown> | undefined;
|
||||
|
||||
type CacheEntry = { template: UsageBarTemplate | undefined; watcher?: FSWatcher };
|
||||
const fileCache = new Map<string, CacheEntry>();
|
||||
const warnedTemplateOverrides = new Set<string>();
|
||||
const usageTemplateLog = createSubsystemLogger("usage-template");
|
||||
|
||||
function expandPath(p: string): string {
|
||||
if (p === "~") {
|
||||
@@ -41,6 +44,20 @@ function hasOutputPieces(output: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isEmptyTemplate(value: unknown): boolean {
|
||||
if (!isPlainObject(value)) {
|
||||
return false;
|
||||
}
|
||||
if (Object.keys(value).length === 0) {
|
||||
return true;
|
||||
}
|
||||
if ("segments" in value && Array.isArray(value.segments)) {
|
||||
return value.segments.length === 0;
|
||||
}
|
||||
const output = value.output;
|
||||
return isPlainObject(output) && !hasOutputPieces(output);
|
||||
}
|
||||
|
||||
function isUsableTemplate(value: unknown): value is UsageBarTemplate {
|
||||
if (!isPlainObject(value)) {
|
||||
return false;
|
||||
@@ -55,27 +72,68 @@ function isUsableTemplate(value: unknown): value is UsageBarTemplate {
|
||||
);
|
||||
}
|
||||
|
||||
function readTemplateFile(path: string): UsageBarTemplate | undefined {
|
||||
type InvalidTemplateReason = "invalid-json" | "unreadable" | "unsupported-shape";
|
||||
type TemplateReadResult = { template?: UsageBarTemplate; reason?: InvalidTemplateReason };
|
||||
|
||||
function getErrorCode(error: unknown): string | undefined {
|
||||
if (typeof error !== "object" || error === null || !("code" in error)) {
|
||||
return undefined;
|
||||
}
|
||||
const code = error.code;
|
||||
return typeof code === "string" ? code : undefined;
|
||||
}
|
||||
|
||||
function warnInvalidUsageTemplate(source: "inline" | "file", reason: string, path?: string): void {
|
||||
const key = `${source}:${reason}:${path ?? ""}`;
|
||||
if (warnedTemplateOverrides.has(key)) {
|
||||
return;
|
||||
}
|
||||
warnedTemplateOverrides.add(key);
|
||||
usageTemplateLog.warn("configured usage template could not be used; using built-in footer", {
|
||||
source,
|
||||
reason,
|
||||
...(path ? { path } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function parseTemplate(value: unknown): TemplateReadResult {
|
||||
if (isUsableTemplate(value)) {
|
||||
return { template: value };
|
||||
}
|
||||
return isEmptyTemplate(value) ? {} : { reason: "unsupported-shape" };
|
||||
}
|
||||
|
||||
function readTemplateFile(path: string): TemplateReadResult {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(path, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
return getErrorCode(error) === "ENOENT" ? {} : { reason: "unreadable" };
|
||||
}
|
||||
if (raw.trim().length === 0) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return isUsableTemplate(parsed) ? parsed : undefined;
|
||||
return parseTemplate(JSON.parse(raw));
|
||||
} catch {
|
||||
return undefined;
|
||||
return { reason: "invalid-json" };
|
||||
}
|
||||
}
|
||||
|
||||
function cacheTemplateFile(path: string): UsageBarTemplate | undefined {
|
||||
const entry: CacheEntry = { template: readTemplateFile(path) };
|
||||
const result = readTemplateFile(path);
|
||||
if (result.reason) {
|
||||
warnInvalidUsageTemplate("file", result.reason, path);
|
||||
}
|
||||
const entry: CacheEntry = { template: result.template };
|
||||
if (entry.template) {
|
||||
try {
|
||||
const watcher = watch(path, { persistent: false }, () => {
|
||||
entry.template = readTemplateFile(path);
|
||||
const next = readTemplateFile(path);
|
||||
if (next.reason) {
|
||||
warnInvalidUsageTemplate("file", next.reason, path);
|
||||
}
|
||||
entry.template = next.template;
|
||||
});
|
||||
watcher.on("error", () => {
|
||||
watcher.close();
|
||||
@@ -94,7 +152,11 @@ export function loadUsageBarTemplate(configured: UsageTemplateConfig): UsageBarT
|
||||
return DEFAULT_USAGE_BAR_TEMPLATE;
|
||||
}
|
||||
if (typeof configured === "object") {
|
||||
return isUsableTemplate(configured) ? configured : DEFAULT_USAGE_BAR_TEMPLATE;
|
||||
const result = parseTemplate(configured);
|
||||
if (result.reason) {
|
||||
warnInvalidUsageTemplate("inline", result.reason);
|
||||
}
|
||||
return result.template ?? DEFAULT_USAGE_BAR_TEMPLATE;
|
||||
}
|
||||
const path = expandPath(configured);
|
||||
const cached = fileCache.get(path);
|
||||
@@ -110,4 +172,5 @@ export function clearUsageBarTemplateCacheForTest(): void {
|
||||
entry.watcher?.close();
|
||||
}
|
||||
fileCache.clear();
|
||||
warnedTemplateOverrides.clear();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user