mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
committed by
GitHub
parent
ec43acb432
commit
6e289b4889
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}` : "";
|
||||
|
||||
Reference in New Issue
Block a user