fix(status): show configured cost for aws-sdk models (#85619)

* fix(status): show configured cost for aws-sdk models

Decouple status cost display from provider auth mode so explicit model pricing is used for Bedrock and other non-api-key providers. Include cache read/write tokens in the status cost estimate and cover the behavior with regression tests.

* fix: show configured response usage costs

* docs: align configured cost visibility

* fix(status): keep usage tokens mode cost-free

---------

Co-authored-by: ItsOtherMauridian <165866613+ItsOtherMauridian@users.noreply.github.com>
Co-authored-by: ItsOtherMauridian <itsothermauridian@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
ItsOtherMauridian
2026-05-23 15:49:57 +01:00
committed by GitHub
parent ec43acb432
commit 6e289b4889
6 changed files with 184 additions and 52 deletions

View File

@@ -15,7 +15,9 @@ OpenClaw features that can generate provider usage or paid API calls.
**Per-session cost snapshot**
- `/status` shows the current session model, context usage, and last response tokens.
- If the model uses **API-key auth**, `/status` also shows **estimated cost** for the last reply.
- If OpenClaw has usage metadata and local pricing for the active model,
`/status` also shows **estimated cost** for the last reply. This can include
explicitly priced non-API-key providers such as Bedrock `aws-sdk` models.
- If live session metadata is sparse, `/status` can recover token/cache
counters and the active runtime model label from the latest transcript usage
entry. Existing nonzero live values still take precedence, and prompt-sized
@@ -23,8 +25,12 @@ OpenClaw features that can generate provider usage or paid API calls.
**Per-message cost footer**
- `/usage full` appends a usage footer to every reply, including **estimated cost** (API-key only).
- `/usage tokens` shows tokens only; subscription-style OAuth/token and CLI flows hide dollar cost.
- `/usage full` appends a usage footer to every reply, including **estimated cost**
when local pricing is configured for the active model and usage metadata is
available.
- `/usage tokens` shows tokens only; subscription-style OAuth/token and CLI flows
still show tokens only unless that runtime supplies compatible usage metadata
and an explicit local price is configured.
- Gemini CLI note: when the CLI returns JSON output, OpenClaw reads usage from
`stats`, normalizes `stats.cached` into `cacheRead`, and derives input tokens
from `stats.input_tokens - stats.cached` when needed.

View File

@@ -66,10 +66,12 @@ For a practical breakdown (per injected file, tools, skills, and system prompt s
Use these in chat:
- `/status`**emoji-rich status card** with the session model, context usage,
last response input/output tokens, and **estimated cost** (API key only).
last response input/output tokens, and **estimated cost** when local pricing is
configured for the active model.
- `/usage off|tokens|full` → appends a **per-response usage footer** to every reply.
- Persists per session (stored as `responseUsage`).
- OAuth auth **hides cost** (tokens only).
- `/usage full` shows estimated cost only when OpenClaw has usage metadata and
local pricing for the active model. Otherwise it shows tokens only.
- `/usage cost` → shows a local cost summary from OpenClaw session logs.
Other surfaces:
@@ -119,8 +121,10 @@ models.providers.<provider>.models[].cost
```
These are **USD per 1M tokens** for `input`, `output`, `cacheRead`, and
`cacheWrite`. If pricing is missing, OpenClaw shows tokens only. OAuth tokens
never show dollar cost.
`cacheWrite`. If pricing is missing, OpenClaw shows tokens only. Cost display is
not limited to API-key auth: non-API-key providers such as `aws-sdk` can show
estimated cost when their configured model entry includes local pricing and the
provider returns usage metadata.
After sidecars and channels reach the Gateway ready path, OpenClaw starts an
optional background pricing bootstrap for configured model refs that do not

View File

@@ -2493,7 +2493,13 @@ describe("runReplyAgent fallback reasoning tags", () => {
});
describe("runReplyAgent response usage footer", () => {
function createRun(params: { responseUsage: "tokens" | "full"; sessionKey: string }) {
function createRun(params: {
responseUsage: "tokens" | "full";
sessionKey: string;
config?: unknown;
provider?: string;
model?: string;
}) {
const typing = createMockTypingController();
const sessionCtx = {
Provider: "whatsapp",
@@ -2521,10 +2527,10 @@ describe("runReplyAgent response usage footer", () => {
messageProvider: "whatsapp",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: createCliBackendTestConfig(),
config: params.config ?? createCliBackendTestConfig(),
skillsSnapshot: {},
provider: "anthropic",
model: "claude",
provider: params.provider ?? "anthropic",
model: params.model ?? "claude",
thinkLevel: "low",
verboseLevel: "off",
elevatedLevel: "off",
@@ -2582,26 +2588,95 @@ describe("runReplyAgent response usage footer", () => {
expect(text).toContain(`· session \`${sessionKey}\``);
});
it("does not append session key when responseUsage=tokens", async () => {
it("does not append session key or cost when responseUsage=tokens", async () => {
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
meta: {
agentMeta: {
provider: "anthropic",
model: "claude",
provider: "amazon-bedrock",
model: "us.anthropic.claude-sonnet-4-6",
usage: { input: 12, output: 3, cacheRead: 4, cacheWrite: 2 },
},
},
});
const sessionKey = "agent:main:whatsapp:dm:+1000";
const res = await createRun({ responseUsage: "tokens", sessionKey });
const res = await createRun({
responseUsage: "tokens",
sessionKey,
provider: "amazon-bedrock",
model: "us.anthropic.claude-sonnet-4-6",
config: {
models: {
providers: {
"amazon-bedrock": {
auth: "aws-sdk",
models: [
{
id: "us.anthropic.claude-sonnet-4-6",
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
},
],
},
},
},
},
});
const payload = Array.isArray(res) ? res[0] : res;
const text = payload?.text ?? "";
expect(text).toContain("Usage:");
expect(text).toContain("cache 4 cached / 2 new");
expect(text).not.toContain("est $");
expect(text).not.toContain("· session ");
});
it("shows configured costs for aws-sdk providers when responseUsage=full", async () => {
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
meta: {
agentMeta: {
provider: "amazon-bedrock",
model: "us.anthropic.claude-sonnet-4-6",
usage: { input: 1_000, output: 2_000, cacheRead: 500, cacheWrite: 2_000 },
},
},
});
const sessionKey = "agent:main:whatsapp:dm:+1000";
const res = await createRun({
responseUsage: "full",
sessionKey,
provider: "amazon-bedrock",
model: "us.anthropic.claude-sonnet-4-6",
config: {
models: {
providers: {
"amazon-bedrock": {
auth: "aws-sdk",
models: [
{
id: "us.anthropic.claude-sonnet-4-6",
cost: {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
},
},
],
},
},
},
},
});
const payload = Array.isArray(res) ? res[0] : res;
const text = payload?.text ?? "";
expect(text).toContain("Usage: 1.0k in / 2.0k out");
expect(text).toContain("cache 500 cached / 2.0k new");
expect(text).toContain("est $0.04");
expect(text).toContain(`· session \`${sessionKey}\``);
});
});
describe("runReplyAgent transient HTTP retry", () => {

View File

@@ -1860,17 +1860,13 @@ export async function runReplyAgent(params: {
(sessionKey ? activeSessionStore?.[sessionKey]?.responseUsage : undefined);
const responseUsageMode = resolveResponseUsageMode(responseUsageRaw);
if (responseUsageMode !== "off" && hasNonzeroUsage(usage)) {
const authMode = resolveModelAuthMode(providerUsed, cfg, undefined, {
workspaceDir: followupRun.run.workspaceDir,
const costConfig = resolveModelCostConfig({
provider: providerUsed,
model: modelUsed,
config: cfg,
allowPluginNormalization: false,
});
const showCost = authMode === "api-key";
const costConfig = showCost
? resolveModelCostConfig({
provider: providerUsed,
model: modelUsed,
config: cfg,
})
: undefined;
const showCost = responseUsageMode === "full" && costConfig !== undefined;
let formatted = formatResponseUsageLine({
usage,
showCost,

View File

@@ -105,6 +105,57 @@ describe("buildStatusMessage", () => {
expect(normalized).toContain("Queue: collect");
});
it("shows configured model costs for aws-sdk providers", () => {
const text = buildStatusMessage({
config: {
models: {
providers: {
"amazon-bedrock": {
auth: "aws-sdk",
models: [
{
id: "us.anthropic.claude-sonnet-4-6",
cost: {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
},
},
],
},
},
},
} as unknown as OpenClawConfig,
agent: {
model: "amazon-bedrock/us.anthropic.claude-sonnet-4-6",
contextTokens: 200_000,
},
sessionEntry: {
sessionId: "bedrock-session",
updatedAt: 0,
inputTokens: 1_000,
outputTokens: 2_000,
cacheRead: 500,
cacheWrite: 2_000,
totalTokens: 5_500,
contextTokens: 200_000,
},
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
modelAuth: "aws-sdk",
activeModelAuth: "aws-sdk",
now: 10 * 60_000,
});
const normalized = normalizeTestText(text);
expect(normalized).toContain("Model: amazon-bedrock/us.anthropic.claude-sonnet-4-6");
expect(normalized).toContain("aws-sdk");
expect(normalized).toContain("Tokens: 1.0k in / 2.0k out");
expect(normalized).toContain("Cost: $0.04");
});
it("does not render stale totalTokens as current context usage", () => {
const text = buildStatusMessage({
agent: {
@@ -1499,7 +1550,7 @@ describe("buildStatusMessage", () => {
expect(lines[contextIndex + 1]).toContain("Usage: Claude 80% left (5h)");
});
it("hides cost when not using an API key", () => {
it("shows configured model costs when not using an API key", () => {
const text = buildStatusMessage({
config: {
models: {
@@ -1528,7 +1579,7 @@ describe("buildStatusMessage", () => {
modelAuth: "oauth",
});
expect(text).not.toContain("💵 Cost:");
expect(text).toContain("💵 Cost: $0.0000");
});
function writeTranscriptUsageLog(params: {

View File

@@ -897,31 +897,31 @@ export function buildStatusMessage(args: StatusArgs): string {
activeModelRef: activeModelLabel,
state: entry,
});
const effectiveCostAuthMode = fallbackState.active
? activeAuthMode
: (selectedAuthMode ?? activeAuthMode);
const showCost = effectiveCostAuthMode === "api-key" || effectiveCostAuthMode === "mixed";
const hasUsage = typeof inputTokens === "number" || typeof outputTokens === "number";
const costConfig =
showCost && hasUsage
? resolveModelCostConfig({
provider: activeProvider,
model: activeModel,
config: args.config,
allowPluginNormalization: false,
})
: undefined;
const cost =
showCost && hasUsage
? estimateUsageCost({
usage: {
input: inputTokens ?? undefined,
output: outputTokens ?? undefined,
},
cost: costConfig,
})
: undefined;
const costLabel = showCost && hasUsage ? formatUsd(cost) : undefined;
const hasUsage =
typeof inputTokens === "number" ||
typeof outputTokens === "number" ||
typeof cacheRead === "number" ||
typeof cacheWrite === "number";
const costConfig = hasUsage
? resolveModelCostConfig({
provider: activeProvider,
model: activeModel,
config: args.config,
allowPluginNormalization: false,
})
: undefined;
const cost = hasUsage
? estimateUsageCost({
usage: {
input: inputTokens ?? undefined,
output: outputTokens ?? undefined,
cacheRead: cacheRead ?? undefined,
cacheWrite: cacheWrite ?? undefined,
},
cost: costConfig,
})
: undefined;
const costLabel = hasUsage ? formatUsd(cost) : undefined;
const selectedAuthLabel = selectedAuthLabelValue ? ` · 🔑 ${selectedAuthLabelValue}` : "";
const modelNote = channelModelNote ? ` · ${channelModelNote}` : "";