diff --git a/CHANGELOG.md b/CHANGELOG.md index e2fb77924..74de86ff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess - Auto-reply: preserve spacing when stripping inline directives. (#539) — thanks @joshp123 - Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj +- Status: compact /status with session token usage + estimated cost, add `/cost` per-response usage lines (tokens-only for OAuth). - macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy - WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj - Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini). diff --git a/README.md b/README.md index a15d2bcf4..52fe98fc7 100644 --- a/README.md +++ b/README.md @@ -240,11 +240,12 @@ ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can searc Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only): -- `/status` — health + session info (group shows activation mode) +- `/status` — compact session status (model + tokens, cost when available) - `/new` or `/reset` — reset the session - `/compact` — compact session context (summary) - `/think ` — off|minimal|low|medium|high - `/verbose on|off` +- `/cost on|off` — append per-response token/cost usage lines - `/restart` — restart the gateway (owner-only in groups) - `/activation mention|always` — group activation toggle (groups only) @@ -460,10 +461,10 @@ Thanks to all clawtributors: claude scald andranik-sahakyan nachx639 sircrumpet rafaelreis-r meaningfool ratulsarna lutr0 abhisekbasu1 emanuelst osolmaz kiranjd thewilloftheshadow CashWilliams manuelhettich minghinmatthewlam buddyh sheeek timkrase mcinteerj azade-c imfing petter-b RandyVentures Yurii Chukhlib jalehman obviyus dan-dr iamadig - manmal ogulcancelik VACInc zats Django Navarro L36 Server pcty-nextgen-service-account Syhids erik-agens fcatuhe - jayhickey jonasjancarik Jonathan D. Rhyne (DJ-D) jverdi mitschabaude-bot oswalpalash philipp-spiess pkrmf Sash Catanzarite VAC - alejandro maza antons Asleep123 cash-echo-bot Clawd conhecendocontato erikpr1994 gtsifrikas hrdwdmrbl hugobarauna - Jarvis Keith the Silly Goose Kit kitze kkarimi loukotal mrdbstn MSch neist nexty5870 - ngutman onutc prathamdby RLTCmpe Rolf Fredheim snopoke wstock YuriNachos Azade ddyo - Erik Manuel Maly Mourad Boustani pcty-nextgen-ios-builder Quentin Randy Torres reeltimeapps Tobias Bischoff William Stock + manmal VACInc zats Django Navarro L36 Server pcty-nextgen-service-account Syhids erik-agens fcatuhe jayhickey + Jonathan D. Rhyne (DJ-D) jverdi mitschabaude-bot oswalpalash philipp-spiess pkrmf Sash Catanzarite VAC alejandro maza antons + Asleep123 cash-echo-bot Clawd conhecendocontato erikpr1994 gtsifrikas hrdwdmrbl hugobarauna Jarvis jonasjancarik + Keith the Silly Goose Kit kitze kkarimi loukotal mrdbstn MSch neist nexty5870 ngutman + onutc prathamdby reeltimeapps RLTCmpe Rolf Fredheim snopoke wstock YuriNachos Azade ddyo + Erik latitudeki5223 Manuel Maly Mourad Boustani pcty-nextgen-ios-builder Quentin Randy Torres Tobias Bischoff William Stock

diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift index 9a4761215..713239414 100644 --- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift @@ -664,19 +664,22 @@ public struct SessionsListParams: Codable, Sendable { public let includeglobal: Bool? public let includeunknown: Bool? public let spawnedby: String? + public let agentid: String? public init( limit: Int?, activeminutes: Int?, includeglobal: Bool?, includeunknown: Bool?, - spawnedby: String? + spawnedby: String?, + agentid: String? ) { self.limit = limit self.activeminutes = activeminutes self.includeglobal = includeglobal self.includeunknown = includeunknown self.spawnedby = spawnedby + self.agentid = agentid } private enum CodingKeys: String, CodingKey { case limit @@ -684,6 +687,7 @@ public struct SessionsListParams: Codable, Sendable { case includeglobal = "includeGlobal" case includeunknown = "includeUnknown" case spawnedby = "spawnedBy" + case agentid = "agentId" } } @@ -692,6 +696,7 @@ public struct SessionsPatchParams: Codable, Sendable { public let thinkinglevel: AnyCodable? public let verboselevel: AnyCodable? public let reasoninglevel: AnyCodable? + public let responseusage: AnyCodable? public let elevatedlevel: AnyCodable? public let model: AnyCodable? public let spawnedby: AnyCodable? @@ -703,6 +708,7 @@ public struct SessionsPatchParams: Codable, Sendable { thinkinglevel: AnyCodable?, verboselevel: AnyCodable?, reasoninglevel: AnyCodable?, + responseusage: AnyCodable?, elevatedlevel: AnyCodable?, model: AnyCodable?, spawnedby: AnyCodable?, @@ -713,6 +719,7 @@ public struct SessionsPatchParams: Codable, Sendable { self.thinkinglevel = thinkinglevel self.verboselevel = verboselevel self.reasoninglevel = reasoninglevel + self.responseusage = responseusage self.elevatedlevel = elevatedlevel self.model = model self.spawnedby = spawnedby @@ -724,6 +731,7 @@ public struct SessionsPatchParams: Codable, Sendable { case thinkinglevel = "thinkingLevel" case verboselevel = "verboseLevel" case reasoninglevel = "reasoningLevel" + case responseusage = "responseUsage" case elevatedlevel = "elevatedLevel" case model case spawnedby = "spawnedBy" @@ -1100,6 +1108,51 @@ public struct WebLoginWaitParams: Codable, Sendable { } } +public struct AgentSummary: Codable, Sendable { + public let id: String + public let name: String? + + public init( + id: String, + name: String? + ) { + self.id = id + self.name = name + } + private enum CodingKeys: String, CodingKey { + case id + case name + } +} + +public struct AgentsListParams: Codable, Sendable { +} + +public struct AgentsListResult: Codable, Sendable { + public let defaultid: String + public let mainkey: String + public let scope: AnyCodable + public let agents: [AgentSummary] + + public init( + defaultid: String, + mainkey: String, + scope: AnyCodable, + agents: [AgentSummary] + ) { + self.defaultid = defaultid + self.mainkey = mainkey + self.scope = scope + self.agents = agents + } + private enum CodingKeys: String, CodingKey { + case defaultid = "defaultId" + case mainkey = "mainKey" + case scope + case agents + } +} + public struct ModelChoice: Codable, Sendable { public let id: String public let name: String diff --git a/docs/concepts/usage-tracking.md b/docs/concepts/usage-tracking.md index 9921567c5..84329a656 100644 --- a/docs/concepts/usage-tracking.md +++ b/docs/concepts/usage-tracking.md @@ -11,7 +11,8 @@ read_when: - No estimated costs; only the provider-reported windows. ## Where it shows up -- `/status` in chats: adds a short “Usage” line (only if available). +- `/status` in chats: compact one‑liner with session tokens + estimated cost (API key only) and provider quota windows when available. +- `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only). - CLI: `clawdbot status --usage` prints a full per-provider breakdown. - CLI: `clawdbot providers list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip). - macOS menu bar: “Usage” section under Context (only if available). diff --git a/docs/docs.json b/docs/docs.json index 84c3179aa..b74b08aab 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -556,6 +556,7 @@ "concepts/agent", "concepts/agent-loop", "concepts/system-prompt", + "token-use", "concepts/oauth", "concepts/agent-workspace", "concepts/multi-agent", diff --git a/docs/token-use.md b/docs/token-use.md new file mode 100644 index 000000000..d142dcfc4 --- /dev/null +++ b/docs/token-use.md @@ -0,0 +1,72 @@ +--- +summary: "How Clawdbot builds prompt context and reports token usage + costs" +read_when: + - Explaining token usage, costs, or context windows + - Debugging context growth or compaction behavior +--- +# Token use & costs + +Clawdbot tracks **tokens**, not characters. Tokens are model-specific, but most +OpenAI-style models average ~4 characters per token for English text. + +## How the system prompt is built + +Clawdbot assembles its own system prompt on every run. It includes: + +- Tool list + short descriptions +- Skills list (only metadata; instructions are loaded on demand with `read`) +- Self-update instructions +- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new) +- Time (UTC + user timezone) +- Reply tags + heartbeat behavior +- Runtime metadata (host/OS/model/thinking) + +See the full breakdown in [System Prompt](/concepts/system-prompt). + +## What counts in the context window + +Everything the model receives counts toward the context limit: + +- System prompt (all sections listed above) +- Conversation history (user + assistant messages) +- Tool calls and tool results +- Attachments/transcripts (images, audio, files) +- Compaction summaries and pruning artifacts +- Provider wrappers or safety headers (not visible, but still counted) + +## How to see current token usage + +Use these in chat: + +- `/status` → **compact one‑liner** with the session model, context usage, + last response input/output tokens, and **estimated cost** (API key only). +- `/cost on|off` → appends a **per-response usage line** to every reply. + - Persists per session (stored as `responseUsage`). + - OAuth auth **hides cost** (tokens only). + +Other surfaces: + +- **TUI/Web TUI:** `/status` + `/cost` are supported. +- **CLI:** `clawdbot status --usage` and `clawdbot providers list` show + provider quota windows (not per-response costs). + +## Cost estimation (when shown) + +Costs are estimated from your model pricing config: + +``` +models.providers..models[].cost +``` + +These are **USD per 1M tokens** for `input`, `output`, `cacheRead`, and +`cacheWrite`. If pricing is missing, Clawdbot shows tokens only. OAuth tokens +never show dollar cost. + +## Tips for reducing token pressure + +- Use `/compact` to summarize long sessions. +- Trim large tool outputs in your workflows. +- Keep skill descriptions short (skill list is injected into the prompt). +- Prefer smaller models for verbose, exploratory work. + +See [Skills](/tools/skills) for the exact skill list overhead formula. diff --git a/docs/tools/skills.md b/docs/tools/skills.md index 4d6e04654..f5f4e4374 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -163,6 +163,23 @@ This is **scoped to the agent run**, not a global shell environment. Clawdbot snapshots the eligible skills **when a session starts** and reuses that list for subsequent turns in the same session. Changes to skills or config take effect on the next new session. +## Token impact (skills list) + +When skills are eligible, Clawdbot injects a compact XML list of available skills into the system prompt (via `formatSkillsForPrompt` in `pi-coding-agent`). The cost is deterministic: + +- **Base overhead (only when ≥1 skill):** 195 characters. +- **Per skill:** 97 characters + the length of the XML-escaped ``, ``, and `` values. + +Formula (characters): + +``` +total = 195 + Σ (97 + len(name_escaped) + len(description_escaped) + len(location_escaped)) +``` + +Notes: +- XML escaping expands `& < > " '` into entities (`&`, `<`, etc.), increasing length. +- Token counts vary by model tokenizer. A rough OpenAI-style estimate is ~4 chars/token, so **97 chars ≈ 24 tokens** per skill plus your actual field lengths. + ## Managed skills lifecycle Clawdbot ships a baseline set of skills as **bundled skills** as part of the diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 7e06c4abb..6b306b798 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -35,6 +35,7 @@ Directives (`/think`, `/verbose`, `/reasoning`, `/elevated`) are parsed even whe Text + native (when enabled): - `/help` - `/status` +- `/cost on|off` (toggle per-response usage line) - `/stop` - `/restart` - `/activation mention|always` (groups only) @@ -52,6 +53,7 @@ Text-only: Notes: - Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`). +- `/cost` appends per-response token usage; it only shows dollar cost when the model uses an API key (OAuth hides cost). - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. - `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats. diff --git a/docs/tui.md b/docs/tui.md index c164ec129..6b5207d76 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -77,6 +77,7 @@ Session controls: - `/think ` - `/verbose ` - `/reasoning ` +- `/cost ` - `/elevated ` (alias: `/elev`) - `/activation ` - `/deliver ` diff --git a/docs/web/tui.md b/docs/web/tui.md index b4daa0e5a..5135a4cf6 100644 --- a/docs/web/tui.md +++ b/docs/web/tui.md @@ -53,6 +53,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS. - `/think ` - `/verbose ` - `/reasoning ` (stream = Telegram draft only) +- `/cost ` - `/elevated ` - `/elev ` - `/activation ` diff --git a/scripts/clawtributors-map.json b/scripts/clawtributors-map.json index bafdd13f1..5d75a5e8a 100644 --- a/scripts/clawtributors-map.json +++ b/scripts/clawtributors-map.json @@ -1,6 +1,7 @@ { "ensureLogins": [ "jdrhyne", + "latitudeki5223", "manmal" ], "seedCommit": "d6863f87", diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 1716f7800..4390747ac 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -100,6 +100,7 @@ export async function resolveApiKeyForProvider(params: { } export type EnvApiKeyResult = { apiKey: string; source: string }; +export type ModelAuthMode = "api-key" | "oauth" | "mixed" | "unknown"; export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { const applied = new Set(getShellEnvAppliedKeys()); @@ -143,6 +144,37 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { return pick(envVar); } +export function resolveModelAuthMode( + provider?: string, + cfg?: ClawdbotConfig, + store?: AuthProfileStore, +): ModelAuthMode | undefined { + const resolved = provider?.trim(); + if (!resolved) return undefined; + + const authStore = store ?? ensureAuthProfileStore(); + const profiles = listProfilesForProvider(authStore, resolved); + if (profiles.length > 0) { + const modes = new Set( + profiles + .map((id) => authStore.profiles[id]?.type) + .filter((mode): mode is "api_key" | "oauth" => Boolean(mode)), + ); + if (modes.has("oauth") && modes.has("api_key")) return "mixed"; + if (modes.has("oauth")) return "oauth"; + if (modes.has("api_key")) return "api-key"; + } + + const envKey = resolveEnvApiKey(resolved); + if (envKey?.apiKey) { + return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key"; + } + + if (getCustomProviderApiKey(cfg, resolved)) return "api-key"; + + return "unknown"; +} + export async function getApiKeyForModel(params: { model: Model; cfg?: ClawdbotConfig; diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 8fbbe611e..80dd5b892 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -27,6 +27,13 @@ const CHAT_COMMANDS: ChatCommandDefinition[] = [ description: "Show current status.", textAliases: ["/status"], }, + { + key: "cost", + nativeName: "cost", + description: "Toggle per-response usage line.", + textAliases: ["/cost"], + acceptsArgs: true, + }, { key: "stop", nativeName: "stop", diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 371da1a8c..da3a52c33 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -212,7 +212,7 @@ describe("trigger handling", () => { makeCfg(home), ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("ClawdBot"); + expect(text).toContain("status"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 1225491b2..076b92fa2 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -2,12 +2,13 @@ import crypto from "node:crypto"; import fs from "node:fs"; import { lookupContextTokens } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; +import { resolveModelAuthMode } from "../../agents/model-auth.js"; import { runWithModelFallback } from "../../agents/model-fallback.js"; import { queueEmbeddedPiMessage, runEmbeddedPiAgent, } from "../../agents/pi-embedded.js"; -import { hasNonzeroUsage } from "../../agents/usage.js"; +import { hasNonzeroUsage, type NormalizedUsage } from "../../agents/usage.js"; import { loadSessionStore, resolveSessionTranscriptPath, @@ -18,6 +19,12 @@ import type { TypingMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; import { defaultRuntime } from "../../runtime.js"; +import { + estimateUsageCost, + formatTokenCount, + formatUsd, + resolveModelCostConfig, +} from "../../utils/usage-format.js"; import { stripHeartbeatToken } from "../heartbeat.js"; import type { OriginatingChannelType, TemplateContext } from "../templating.js"; import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js"; @@ -62,6 +69,65 @@ const formatBunFetchSocketError = (message: string) => { ].join("\n"); }; +const formatResponseUsageLine = (params: { + usage?: NormalizedUsage; + showCost: boolean; + costConfig?: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + }; +}): string | null => { + const usage = params.usage; + if (!usage) return null; + const input = usage.input; + const output = usage.output; + if (typeof input !== "number" && typeof output !== "number") return null; + const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?"; + const outputLabel = + typeof output === "number" ? formatTokenCount(output) : "?"; + const cost = + params.showCost && typeof input === "number" && typeof output === "number" + ? estimateUsageCost({ + usage: { + input, + output, + cacheRead: usage.cacheRead, + cacheWrite: usage.cacheWrite, + }, + cost: params.costConfig, + }) + : undefined; + const costLabel = params.showCost ? formatUsd(cost) : undefined; + const suffix = costLabel ? ` · est ${costLabel}` : ""; + return `Usage: ${inputLabel} in / ${outputLabel} out${suffix}`; +}; + +const appendUsageLine = ( + payloads: ReplyPayload[], + line: string, +): ReplyPayload[] => { + let index = -1; + for (let i = payloads.length - 1; i >= 0; i -= 1) { + if (payloads[i]?.text) { + index = i; + break; + } + } + if (index === -1) return [...payloads, { text: line }]; + const existing = payloads[index]; + const existingText = existing.text ?? ""; + const separator = existingText.endsWith("\n") ? "" : "\n"; + const next = { + ...existing, + text: `${existingText}${separator}${line}`, + }; + const updated = payloads.slice(); + updated[index] = next; + return updated; +}; + const withTimeout = async ( promise: Promise, timeoutMs: number, @@ -191,6 +257,7 @@ export async function runReplyAgent(params: { replyToChannel, ); const applyReplyToMode = createReplyToModeFilter(replyToMode); + const cfg = followupRun.run.config; if (shouldSteer && isStreaming) { const steered = queueEmbeddedPiMessage( @@ -242,6 +309,7 @@ export async function runReplyAgent(params: { let didLogHeartbeatStrip = false; let autoCompactionCompleted = false; + let responseUsageLine: string | undefined; try { const runId = crypto.randomUUID(); if (sessionKey) { @@ -641,20 +709,20 @@ export async function runReplyAgent(params: { await typingSignals.signalRunStart(); } - if (sessionStore && sessionKey) { - const usage = runResult.meta.agentMeta?.usage; - const modelUsed = - runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel; - const providerUsed = - runResult.meta.agentMeta?.provider ?? - fallbackProvider ?? - followupRun.run.provider; - const contextTokensUsed = - agentCfgContextTokens ?? - lookupContextTokens(modelUsed) ?? - sessionEntry?.contextTokens ?? - DEFAULT_CONTEXT_TOKENS; + const usage = runResult.meta.agentMeta?.usage; + const modelUsed = + runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel; + const providerUsed = + runResult.meta.agentMeta?.provider ?? + fallbackProvider ?? + followupRun.run.provider; + const contextTokensUsed = + agentCfgContextTokens ?? + lookupContextTokens(modelUsed) ?? + sessionEntry?.contextTokens ?? + DEFAULT_CONTEXT_TOKENS; + if (sessionStore && sessionKey) { if (hasNonzeroUsage(usage)) { const entry = sessionEntry ?? sessionStore[sessionKey]; if (entry) { @@ -694,6 +762,29 @@ export async function runReplyAgent(params: { } } + const responseUsageEnabled = + (sessionEntry?.responseUsage ?? + (sessionKey + ? sessionStore?.[sessionKey]?.responseUsage + : undefined)) === "on"; + if (responseUsageEnabled && hasNonzeroUsage(usage)) { + const authMode = resolveModelAuthMode(providerUsed, cfg); + const showCost = authMode === "api-key"; + const costConfig = showCost + ? resolveModelCostConfig({ + provider: providerUsed, + model: modelUsed, + config: cfg, + }) + : undefined; + const formatted = formatResponseUsageLine({ + usage, + showCost, + costConfig, + }); + if (formatted) responseUsageLine = formatted; + } + // If verbose is enabled and this is a new session, prepend a session hint. let finalPayloads = replyPayloads; if (autoCompactionCompleted) { @@ -717,6 +808,9 @@ export async function runReplyAgent(params: { ...finalPayloads, ]; } + if (responseUsageLine) { + finalPayloads = appendUsageLine(finalPayloads, responseUsageLine); + } return finalizeWithFollowup( finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads, diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index fba52f153..14289f6c9 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -1,11 +1,5 @@ -import { - ensureAuthProfileStore, - listProfilesForProvider, -} from "../../agents/auth-profiles.js"; -import { - getCustomProviderApiKey, - resolveEnvApiKey, -} from "../../agents/model-auth.js"; +import crypto from "node:crypto"; +import { resolveModelAuthMode } from "../../agents/model-auth.js"; import { normalizeProviderId } from "../../agents/model-selection.js"; import { abortEmbeddedPiRun, @@ -55,8 +49,10 @@ import type { ElevatedLevel, ReasoningLevel, ThinkLevel, + UsageDisplayLevel, VerboseLevel, } from "../thinking.js"; +import { normalizeUsageDisplay } from "../thinking.js"; import type { ReplyPayload } from "../types.js"; import { isAbortTrigger, setAbortMemory } from "./abort.js"; import type { InlineDirectives } from "./directive-handling.js"; @@ -109,36 +105,6 @@ export type CommandContext = { to?: string; }; -function resolveModelAuthLabel( - provider?: string, - cfg?: ClawdbotConfig, -): string | undefined { - const resolved = provider?.trim(); - if (!resolved) return undefined; - - const store = ensureAuthProfileStore(); - const profiles = listProfilesForProvider(store, resolved); - if (profiles.length > 0) { - const modes = new Set( - profiles - .map((id) => store.profiles[id]?.type) - .filter((mode): mode is "api_key" | "oauth" => Boolean(mode)), - ); - if (modes.has("oauth") && modes.has("api_key")) return "mixed"; - if (modes.has("oauth")) return "oauth"; - if (modes.has("api_key")) return "api-key"; - } - - const envKey = resolveEnvApiKey(resolved); - if (envKey?.apiKey) { - return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key"; - } - - if (getCustomProviderApiKey(cfg, resolved)) return "api-key"; - - return "unknown"; -} - function extractCompactInstructions(params: { rawBody?: string; ctx: MsgContext; @@ -468,6 +434,7 @@ export async function handleCommands(params: { defaultGroupActivation()) : undefined; const statusText = buildStatusMessage({ + config: cfg, agent: { ...cfg.agent, model: { @@ -488,7 +455,7 @@ export async function handleCommands(params: { resolvedVerbose: resolvedVerboseLevel, resolvedReasoning: resolvedReasoningLevel, resolvedElevated: resolvedElevatedLevel, - modelAuth: resolveModelAuthLabel(provider, cfg), + modelAuth: resolveModelAuthMode(provider, cfg), usageLine: usageLine ?? undefined, queue: { mode: queueSettings.mode, @@ -503,6 +470,51 @@ export async function handleCommands(params: { return { shouldContinue: false, reply: { text: statusText } }; } + const costRequested = + command.commandBodyNormalized === "/cost" || + command.commandBodyNormalized.startsWith("/cost "); + if (allowTextCommands && costRequested) { + if (!command.isAuthorizedSender) { + logVerbose( + `Ignoring /cost from unauthorized sender: ${command.senderE164 || ""}`, + ); + return { shouldContinue: false }; + } + const rawArgs = command.commandBodyNormalized.slice("/cost".length).trim(); + const normalized = + rawArgs.length > 0 ? normalizeUsageDisplay(rawArgs) : undefined; + if (rawArgs.length > 0 && !normalized) { + return { + shouldContinue: false, + reply: { text: "⚙️ Usage: /cost on|off" }, + }; + } + const current: UsageDisplayLevel = + sessionEntry?.responseUsage === "on" ? "on" : "off"; + const next = normalized ?? (current === "on" ? "off" : "on"); + if (sessionStore && sessionKey) { + const entry = sessionEntry ?? + sessionStore[sessionKey] ?? { + sessionId: crypto.randomUUID(), + updatedAt: Date.now(), + }; + if (next === "off") delete entry.responseUsage; + else entry.responseUsage = next; + entry.updatedAt = Date.now(); + sessionStore[sessionKey] = entry; + if (storePath) { + await saveSessionStore(storePath, sessionStore); + } + } + return { + shouldContinue: false, + reply: { + text: + next === "on" ? "⚙️ Usage line enabled." : "⚙️ Usage line disabled.", + }, + }; + } + const stopRequested = command.commandBodyNormalized === "/stop"; if (allowTextCommands && stopRequested) { if (!command.isAuthorizedSender) { diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 65744c62e..5cf3bd8cc 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -194,6 +194,7 @@ export async function initSessionState(params: { // Persist previously stored thinking/verbose levels when present. thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel, verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel, + responseUsage: baseEntry?.responseUsage, modelOverride: persistedModelOverride ?? baseEntry?.modelOverride, providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride, sendPolicy: baseEntry?.sendPolicy, diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index fe6035c88..093690ea3 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -63,20 +63,18 @@ describe("buildStatusMessage", () => { resolvedThink: "medium", resolvedVerbose: "off", queue: { mode: "collect", depth: 0 }, - now: 10 * 60_000, // 10 minutes later + modelAuth: "api-key", }); - expect(text).toContain("🦞 ClawdBot"); - expect(text).toContain("🧠 Model:"); - expect(text).toContain("Runtime: direct"); - expect(text).toContain("Context: 16k/32k (50%)"); - expect(text).toContain("🧹 Compactions: 2"); - expect(text).toContain("Session: agent:main:main"); - expect(text).toContain("updated 10m ago"); - expect(text).toContain("Think: medium"); - expect(text).not.toContain("Verbose"); - expect(text).toContain("Elevated"); - expect(text).toContain("Queue: collect"); + expect(text).toContain("status agent:main:main"); + expect(text).toContain("model anthropic/pi:opus (api-key)"); + expect(text).toContain("Context 16k/32k (50%)"); + expect(text).toContain("compactions 2"); + expect(text).toContain("think medium"); + expect(text).toContain("verbose off"); + expect(text).toContain("reasoning off"); + expect(text).toContain("elevated on"); + expect(text).toContain("queue collect"); }); it("shows verbose/elevated labels only when enabled", () => { @@ -91,10 +89,8 @@ describe("buildStatusMessage", () => { queue: { mode: "collect", depth: 0 }, }); - expect(text).toContain("Verbose"); - expect(text).toContain("Elevated"); - expect(text).not.toContain("Verbose:"); - expect(text).not.toContain("Elevated:"); + expect(text).toContain("verbose on"); + expect(text).toContain("elevated on"); }); it("prefers model overrides over last-run model", () => { @@ -115,9 +111,10 @@ describe("buildStatusMessage", () => { sessionKey: "agent:main:main", sessionScope: "per-sender", queue: { mode: "collect", depth: 0 }, + modelAuth: "api-key", }); - expect(text).toContain("🧠 Model: openai/gpt-4.1-mini"); + expect(text).toContain("model openai/gpt-4.1-mini"); }); it("keeps provider prefix from configured model", () => { @@ -127,21 +124,23 @@ describe("buildStatusMessage", () => { }, sessionScope: "per-sender", queue: { mode: "collect", depth: 0 }, + modelAuth: "api-key", }); - expect(text).toContain("🧠 Model: google-antigravity/claude-sonnet-4-5"); + expect(text).toContain("model google-antigravity/claude-sonnet-4-5"); }); it("handles missing agent config gracefully", () => { const text = buildStatusMessage({ agent: {}, sessionScope: "per-sender", - webLinked: false, + queue: { mode: "collect", depth: 0 }, + modelAuth: "api-key", }); - expect(text).toContain("🧠 Model:"); - expect(text).toContain("Context:"); - expect(text).toContain("Queue:"); + expect(text).toContain("model"); + expect(text).toContain("Context"); + expect(text).toContain("queue collect"); }); it("includes group activation for group sessions", () => { @@ -156,9 +155,10 @@ describe("buildStatusMessage", () => { sessionKey: "agent:main:whatsapp:group:123@g.us", sessionScope: "per-sender", queue: { mode: "collect", depth: 0 }, + modelAuth: "api-key", }); - expect(text).toContain("Activation: always"); + expect(text).toContain("activation always"); }); it("shows queue details when overridden", () => { @@ -175,10 +175,11 @@ describe("buildStatusMessage", () => { dropPolicy: "old", showDetails: true, }, + modelAuth: "api-key", }); expect(text).toContain( - "Queue: collect (depth 3 · debounce 2s · cap 5 · drop old)", + "queue collect (depth 3 · debounce 2s · cap 5 · drop old)", ); }); @@ -190,12 +191,10 @@ describe("buildStatusMessage", () => { sessionScope: "per-sender", queue: { mode: "collect", depth: 0 }, usageLine: "📊 Usage: Claude 80% left (5h)", + modelAuth: "api-key", }); - const lines = text.split("\n"); - const contextIndex = lines.findIndex((line) => line.startsWith("📚 ")); - expect(contextIndex).toBeGreaterThan(-1); - expect(lines[contextIndex + 1]).toBe("📊 Usage: Claude 80% left (5h)"); + expect(text).toContain("📊 Usage: Claude 80% left (5h)"); }); it("prefers cached prompt tokens from the session log", async () => { @@ -255,9 +254,10 @@ describe("buildStatusMessage", () => { sessionScope: "per-sender", queue: { mode: "collect", depth: 0 }, includeTranscriptUsage: true, + modelAuth: "api-key", }); - expect(text).toContain("Context: 1.0k/32k"); + expect(text).toContain("Context 1.0k/32k"); } finally { restoreHomeEnv(previousHome); fs.rmSync(dir, { recursive: true, force: true }); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index fc374d83a..5ad5ec8b9 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -6,6 +6,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER, } from "../agents/defaults.js"; +import { resolveModelAuthMode } from "../agents/model-auth.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import { derivePromptTokens, @@ -14,13 +15,16 @@ import { } from "../agents/usage.js"; import type { ClawdbotConfig } from "../config/config.js"; import { - resolveMainSessionKey, resolveSessionFilePath, type SessionEntry, type SessionScope, } from "../config/sessions.js"; -import { resolveCommitHash } from "../infra/git-commit.js"; -import { VERSION } from "../version.js"; +import { + estimateUsageCost, + formatTokenCount as formatTokenCountShared, + formatUsd, + resolveModelCostConfig, +} from "../utils/usage-format.js"; import type { ElevatedLevel, ReasoningLevel, @@ -30,6 +34,8 @@ import type { type AgentConfig = NonNullable; +export const formatTokenCount = formatTokenCountShared; + type QueueStatus = { mode?: string; depth?: number; @@ -40,6 +46,7 @@ type QueueStatus = { }; type StatusArgs = { + config?: ClawdbotConfig; agent: AgentConfig; sessionEntry?: SessionEntry; sessionKey?: string; @@ -53,37 +60,20 @@ type StatusArgs = { usageLine?: string; queue?: QueueStatus; includeTranscriptUsage?: boolean; - now?: number; }; -const formatAge = (ms?: number | null) => { - if (!ms || ms < 0) return "unknown"; - const minutes = Math.round(ms / 60_000); - if (minutes < 1) return "just now"; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.round(minutes / 60); - if (hours < 48) return `${hours}h ago`; - const days = Math.round(hours / 24); - return `${days}d ago`; -}; - -const formatKTokens = (value: number) => - `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`; - -export const formatTokenCount = (value: number) => formatKTokens(value); - const formatTokens = ( total: number | null | undefined, contextTokens: number | null, ) => { const ctx = contextTokens ?? null; if (total == null) { - const ctxLabel = ctx ? formatKTokens(ctx) : "?"; - return `unknown/${ctxLabel}`; + const ctxLabel = ctx ? formatTokenCount(ctx) : "?"; + return `?/${ctxLabel}`; } const pct = ctx ? Math.min(999, Math.round((total / ctx) * 100)) : null; - const totalLabel = formatKTokens(total); - const ctxLabel = ctx ? formatKTokens(ctx) : "?"; + const totalLabel = formatTokenCount(total); + const ctxLabel = ctx ? formatTokenCount(ctx) : "?"; return `${totalLabel}/${ctxLabel}${pct !== null ? ` (${pct}%)` : ""}`; }; @@ -171,8 +161,15 @@ const readUsageFromSessionLog = ( } }; +const formatUsagePair = (input?: number | null, output?: number | null) => { + if (input == null && output == null) return null; + const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?"; + const outputLabel = + typeof output === "number" ? formatTokenCount(output) : "?"; + return `usage ${inputLabel} in / ${outputLabel} out`; +}; + export function buildStatusMessage(args: StatusArgs): string { - const now = args.now ?? Date.now(); const entry = args.sessionEntry; const resolved = resolveConfiguredModelRef({ cfg: { agent: args.agent ?? {} }, @@ -188,6 +185,8 @@ export function buildStatusMessage(args: StatusArgs): string { lookupContextTokens(model) ?? DEFAULT_CONTEXT_TOKENS; + let inputTokens = entry?.inputTokens; + let outputTokens = entry?.outputTokens; let totalTokens = entry?.totalTokens ?? (entry?.inputTokens ?? 0) + (entry?.outputTokens ?? 0); @@ -205,6 +204,8 @@ export function buildStatusMessage(args: StatusArgs): string { if (!contextTokens && logUsage.model) { contextTokens = lookupContextTokens(logUsage.model) ?? contextTokens; } + if (!inputTokens || inputTokens === 0) inputTokens = logUsage.input; + if (!outputTokens || outputTokens === 0) outputTokens = logUsage.output; } } @@ -218,33 +219,6 @@ export function buildStatusMessage(args: StatusArgs): string { args.agent?.elevatedDefault ?? "on"; - const runtime = (() => { - const sandboxMode = args.agent?.sandbox?.mode ?? "off"; - if (sandboxMode === "off") return { label: "direct" }; - const sessionScope = args.sessionScope ?? "per-sender"; - const mainKey = resolveMainSessionKey({ - session: { scope: sessionScope }, - }); - const sessionKey = args.sessionKey?.trim(); - const sandboxed = sessionKey - ? sandboxMode === "all" || sessionKey !== mainKey.trim() - : false; - const runtime = sandboxed ? "docker" : sessionKey ? "direct" : "unknown"; - return { - label: `${runtime}/${sandboxMode}`, - }; - })(); - - const updatedAt = entry?.updatedAt; - const sessionLine = [ - `Session: ${args.sessionKey ?? "unknown"}`, - typeof updatedAt === "number" - ? `updated ${formatAge(now - updatedAt)}` - : "no activity", - ] - .filter(Boolean) - .join(" • "); - const isGroupSession = entry?.chatType === "group" || entry?.chatType === "room" || @@ -255,52 +229,66 @@ export function buildStatusMessage(args: StatusArgs): string { ? (args.groupActivation ?? entry?.groupActivation ?? "mention") : undefined; - const contextLine = [ - `Context: ${formatTokens(totalTokens, contextTokens ?? null)}`, - `🧹 Compactions: ${entry?.compactionCount ?? 0}`, - ] - .filter(Boolean) - .join(" · "); + const authMode = + args.modelAuth ?? resolveModelAuthMode(provider, args.config); + const showCost = authMode === "api-key"; + const costConfig = showCost + ? resolveModelCostConfig({ + provider, + model, + config: args.config, + }) + : undefined; + const hasUsage = + typeof inputTokens === "number" || typeof outputTokens === "number"; + const cost = + showCost && hasUsage + ? estimateUsageCost({ + usage: { + input: inputTokens ?? undefined, + output: outputTokens ?? undefined, + }, + cost: costConfig, + }) + : undefined; + const costLabel = showCost && hasUsage ? formatUsd(cost) : undefined; + + const parts: Array = []; + parts.push(`status ${args.sessionKey ?? "unknown"}`); + + const modelLabel = model ? `${provider}/${model}` : "unknown"; + const authLabel = authMode && authMode !== "unknown" ? ` (${authMode})` : ""; + parts.push(`model ${modelLabel}${authLabel}`); + + const usagePair = formatUsagePair(inputTokens, outputTokens); + if (usagePair) parts.push(usagePair); + if (costLabel) parts.push(`cost ${costLabel}`); + + const contextSummary = formatContextUsageShort( + totalTokens && totalTokens > 0 ? totalTokens : null, + contextTokens ?? null, + ); + parts.push(contextSummary); + parts.push(`compactions ${entry?.compactionCount ?? 0}`); + parts.push(`think ${thinkLevel}`); + parts.push(`verbose ${verboseLevel}`); + parts.push(`reasoning ${reasoningLevel}`); + parts.push(`elevated ${elevatedLevel}`); + if (groupActivationValue) parts.push(`activation ${groupActivationValue}`); const queueMode = args.queue?.mode ?? "unknown"; const queueDetails = formatQueueDetails(args.queue); - const optionParts = [ - `Runtime: ${runtime.label}`, - `Think: ${thinkLevel}`, - verboseLevel === "on" ? "Verbose" : null, - reasoningLevel !== "off" ? `Reasoning: ${reasoningLevel}` : null, - elevatedLevel === "on" ? "Elevated" : null, - ]; - const optionsLine = optionParts.filter(Boolean).join(" · "); - const activationParts = [ - groupActivationValue ? `👥 Activation: ${groupActivationValue}` : null, - `🪢 Queue: ${queueMode}${queueDetails}`, - ]; - const activationLine = activationParts.filter(Boolean).join(" · "); + parts.push(`queue ${queueMode}${queueDetails}`); - const modelLabel = model ? `${provider}/${model}` : "unknown"; - const authLabel = args.modelAuth ? ` · 🔑 ${args.modelAuth}` : ""; - const modelLine = `🧠 Model: ${modelLabel}${authLabel}`; - const commit = resolveCommitHash(); - const versionLine = `🦞 ClawdBot ${VERSION}${commit ? ` (${commit})` : ""}`; + if (args.usageLine) parts.push(args.usageLine); - return [ - versionLine, - modelLine, - `📚 ${contextLine}`, - args.usageLine, - `🧵 ${sessionLine}`, - `⚙️ ${optionsLine}`, - activationLine, - ] - .filter(Boolean) - .join("\n"); + return parts.filter(Boolean).join(" · "); } export function buildHelpMessage(): string { return [ "ℹ️ Help", "Shortcuts: /new reset | /compact [instructions] | /restart relink", - "Options: /think | /verbose on|off | /reasoning on|off | /elevated on|off | /model ", + "Options: /think | /verbose on|off | /reasoning on|off | /elevated on|off | /model | /cost on|off", ].join("\n"); } diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index 1550fde76..90ac4ff44 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -2,6 +2,7 @@ export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high"; export type VerboseLevel = "off" | "on"; export type ElevatedLevel = "off" | "on"; export type ReasoningLevel = "off" | "on" | "stream"; +export type UsageDisplayLevel = "off" | "on"; // Normalize user-provided thinking level strings to the canonical enum. export function normalizeThinkLevel( @@ -46,6 +47,19 @@ export function normalizeVerboseLevel( return undefined; } +// Normalize response-usage display flags used to toggle cost/token lines. +export function normalizeUsageDisplay( + raw?: string | null, +): UsageDisplayLevel | undefined { + if (!raw) return undefined; + const key = raw.toLowerCase(); + if (["off", "false", "no", "0", "disable", "disabled"].includes(key)) + return "off"; + if (["on", "true", "yes", "1", "enable", "enabled"].includes(key)) + return "on"; + return undefined; +} + // Normalize elevated flags used to toggle elevated bash permissions. export function normalizeElevatedLevel( raw?: string | null, diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 93e4c0d93..6dfa7b3be 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -87,6 +87,7 @@ export type SessionEntry = { verboseLevel?: string; reasoningLevel?: string; elevatedLevel?: string; + responseUsage?: "on" | "off"; providerOverride?: string; modelOverride?: string; authProfileOverride?: string; diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index ac11af14c..4e2e98700 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -325,6 +325,9 @@ export const SessionsPatchParamsSchema = Type.Object( thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + responseUsage: Type.Optional( + Type.Union([Type.Literal("on"), Type.Literal("off"), Type.Null()]), + ), elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 3e86dfdb1..d3cc38f04 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -19,6 +19,7 @@ import { normalizeGroupActivation } from "../../auto-reply/group-activation.js"; import { normalizeReasoningLevel, normalizeThinkLevel, + normalizeUsageDisplay, normalizeVerboseLevel, } from "../../auto-reply/thinking.js"; import { loadConfig } from "../../config/config.js"; @@ -234,6 +235,28 @@ export const sessionsHandlers: GatewayRequestHandlers = { } } + if ("responseUsage" in p) { + const raw = p.responseUsage; + if (raw === null) { + delete next.responseUsage; + } else if (raw !== undefined) { + const normalized = normalizeUsageDisplay(String(raw)); + if (!normalized) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + 'invalid responseUsage (use "on"|"off")', + ), + ); + return; + } + if (normalized === "off") delete next.responseUsage; + else next.responseUsage = normalized; + } + } + if ("model" in p) { const raw = p.model; if (raw === null) { @@ -394,6 +417,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, reasoningLevel: entry?.reasoningLevel, + responseUsage: entry?.responseUsage, model: entry?.model, contextTokens: entry?.contextTokens, sendPolicy: entry?.sendPolicy, diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index dd3bb0024..6df2cf9e5 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -51,6 +51,8 @@ export type GatewaySessionRow = { inputTokens?: number; outputTokens?: number; totalTokens?: number; + responseUsage?: "on" | "off"; + modelProvider?: string; model?: string; contextTokens?: number; lastProvider?: SessionEntry["lastProvider"]; @@ -503,6 +505,8 @@ export function listSessionsFromStore(params: { inputTokens: entry?.inputTokens, outputTokens: entry?.outputTokens, totalTokens: total, + responseUsage: entry?.responseUsage, + modelProvider: entry?.modelProvider, model: entry?.model, contextTokens: entry?.contextTokens, lastProvider: entry?.lastProvider, diff --git a/src/tui/commands.ts b/src/tui/commands.ts index 6b4d29947..516ed7f89 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -64,6 +64,14 @@ export function getSlashCommands(): SlashCommand[] { (value) => ({ value, label: value }), ), }, + { + name: "cost", + description: "Toggle per-response usage line", + getArgumentCompletions: (prefix) => + TOGGLE.filter((v) => v.startsWith(prefix.toLowerCase())).map( + (value) => ({ value, label: value }), + ), + }, { name: "elevated", description: "Set elevated on/off", @@ -116,6 +124,7 @@ export function helpText(): string { "/think ", "/verbose ", "/reasoning ", + "/cost ", "/elevated ", "/elev ", "/activation ", diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 2b9f0c65b..bd8afd21c 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -44,7 +44,11 @@ export type GatewaySessionList = { sendPolicy?: string; model?: string; contextTokens?: number | null; + inputTokens?: number | null; + outputTokens?: number | null; totalTokens?: number | null; + responseUsage?: "on" | "off"; + modelProvider?: string; displayName?: string; provider?: string; room?: string; diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 03d2cc86e..5a189ba39 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -6,12 +6,14 @@ import { Text, TUI, } from "@mariozechner/pi-tui"; +import { normalizeUsageDisplay } from "../auto-reply/thinking.js"; import { loadConfig } from "../config/config.js"; import { buildAgentMainSessionKey, normalizeAgentId, parseAgentSessionKey, } from "../routing/session-key.js"; +import { formatTokenCount } from "../utils/usage-format.js"; import { getSlashCommands, helpText, parseCommand } from "./commands.js"; import { ChatLog } from "./components/chat-log.js"; import { CustomEditor } from "./components/custom-editor.js"; @@ -52,8 +54,12 @@ type SessionInfo = { verboseLevel?: string; reasoningLevel?: string; model?: string; + modelProvider?: string; contextTokens?: number | null; + inputTokens?: number | null; + outputTokens?: number | null; totalTokens?: number | null; + responseUsage?: "on" | "off"; updatedAt?: number | null; displayName?: string; }; @@ -99,13 +105,16 @@ function extractTextFromMessage( } function formatTokens(total?: number | null, context?: number | null) { - if (!total && !context) return "tokens ?"; - if (!context) return `tokens ${total ?? 0}`; + if (total == null && context == null) return "tokens ?"; + const totalLabel = total == null ? "?" : formatTokenCount(total); + if (context == null) return `tokens ${totalLabel}`; const pct = typeof total === "number" && context > 0 ? Math.min(999, Math.round((total / context) * 100)) : null; - return `tokens ${total ?? 0}/${context}${pct !== null ? ` (${pct}%)` : ""}`; + return `tokens ${totalLabel}/${formatTokenCount(context)}${ + pct !== null ? ` (${pct}%)` : "" + }`; } function asString(value: unknown, fallback = ""): string { @@ -213,7 +222,11 @@ export async function runTui(opts: TuiOptions) { ? `${sessionKeyLabel} (${sessionInfo.displayName})` : sessionKeyLabel; const agentLabel = formatAgentLabel(currentAgentId); - const modelLabel = sessionInfo.model ?? "unknown"; + const modelLabel = sessionInfo.model + ? sessionInfo.modelProvider + ? `${sessionInfo.modelProvider}/${sessionInfo.model}` + : sessionInfo.model + : "unknown"; const tokens = formatTokens( sessionInfo.totalTokens ?? null, sessionInfo.contextTokens ?? null, @@ -321,8 +334,12 @@ export async function runTui(opts: TuiOptions) { verboseLevel: entry?.verboseLevel, reasoningLevel: entry?.reasoningLevel, model: entry?.model ?? result.defaults?.model ?? undefined, + modelProvider: entry?.modelProvider, contextTokens: entry?.contextTokens ?? result.defaults?.contextTokens, + inputTokens: entry?.inputTokens ?? null, + outputTokens: entry?.outputTokens ?? null, totalTokens: entry?.totalTokens ?? null, + responseUsage: entry?.responseUsage, updatedAt: entry?.updatedAt ?? null, displayName: entry?.displayName, }; @@ -773,6 +790,28 @@ export async function runTui(opts: TuiOptions) { chatLog.addSystem(`reasoning failed: ${String(err)}`); } break; + case "cost": { + const normalized = args ? normalizeUsageDisplay(args) : undefined; + if (args && !normalized) { + chatLog.addSystem("usage: /cost "); + break; + } + const current = sessionInfo.responseUsage === "on" ? "on" : "off"; + const next = normalized ?? (current === "on" ? "off" : "on"); + try { + await client.patchSession({ + key: currentSessionKey, + responseUsage: next === "off" ? null : next, + }); + chatLog.addSystem( + next === "on" ? "usage line enabled" : "usage line disabled", + ); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`cost failed: ${String(err)}`); + } + break; + } case "elevated": if (!args) { chatLog.addSystem("usage: /elevated "); diff --git a/src/utils/usage-format.test.ts b/src/utils/usage-format.test.ts new file mode 100644 index 000000000..d77a89356 --- /dev/null +++ b/src/utils/usage-format.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import type { ClawdbotConfig } from "../config/config.js"; +import { + estimateUsageCost, + formatTokenCount, + formatUsd, + resolveModelCostConfig, +} from "./usage-format.js"; + +describe("usage-format", () => { + it("formats token counts", () => { + expect(formatTokenCount(999)).toBe("999"); + expect(formatTokenCount(1234)).toBe("1.2k"); + expect(formatTokenCount(12000)).toBe("12k"); + expect(formatTokenCount(2_500_000)).toBe("2.5m"); + }); + + it("formats USD values", () => { + expect(formatUsd(1.234)).toBe("$1.23"); + expect(formatUsd(0.5)).toBe("$0.50"); + expect(formatUsd(0.0042)).toBe("$0.0042"); + }); + + it("resolves model cost config and estimates usage cost", () => { + const config = { + models: { + providers: { + test: { + models: [ + { + id: "m1", + cost: { input: 1, output: 2, cacheRead: 0.5, cacheWrite: 0 }, + }, + ], + }, + }, + }, + } as ClawdbotConfig; + + const cost = resolveModelCostConfig({ + provider: "test", + model: "m1", + config, + }); + + expect(cost).toEqual({ + input: 1, + output: 2, + cacheRead: 0.5, + cacheWrite: 0, + }); + + const total = estimateUsageCost({ + usage: { input: 1000, output: 500, cacheRead: 2000 }, + cost, + }); + + expect(total).toBeCloseTo(0.003); + }); +}); diff --git a/src/utils/usage-format.ts b/src/utils/usage-format.ts new file mode 100644 index 000000000..3d391b1a1 --- /dev/null +++ b/src/utils/usage-format.ts @@ -0,0 +1,69 @@ +import type { NormalizedUsage } from "../agents/usage.js"; +import type { ClawdbotConfig } from "../config/config.js"; + +export type ModelCostConfig = { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; +}; + +export type UsageTotals = { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + total?: number; +}; + +export function formatTokenCount(value?: number): string { + if (value === undefined || !Number.isFinite(value)) return "0"; + const safe = Math.max(0, value); + if (safe >= 1_000_000) return `${(safe / 1_000_000).toFixed(1)}m`; + if (safe >= 1_000) + return `${(safe / 1_000).toFixed(safe >= 10_000 ? 0 : 1)}k`; + return String(Math.round(safe)); +} + +export function formatUsd(value?: number): string | undefined { + if (value === undefined || !Number.isFinite(value)) return undefined; + if (value >= 1) return `$${value.toFixed(2)}`; + if (value >= 0.01) return `$${value.toFixed(2)}`; + return `$${value.toFixed(4)}`; +} + +export function resolveModelCostConfig(params: { + provider?: string; + model?: string; + config?: ClawdbotConfig; +}): ModelCostConfig | undefined { + const provider = params.provider?.trim(); + const model = params.model?.trim(); + if (!provider || !model) return undefined; + const providers = params.config?.models?.providers ?? {}; + const entry = providers[provider]?.models?.find((item) => item.id === model); + return entry?.cost; +} + +const toNumber = (value: number | undefined): number => + typeof value === "number" && Number.isFinite(value) ? value : 0; + +export function estimateUsageCost(params: { + usage?: NormalizedUsage | UsageTotals | null; + cost?: ModelCostConfig; +}): number | undefined { + const usage = params.usage; + const cost = params.cost; + if (!usage || !cost) return undefined; + const input = toNumber(usage.input); + const output = toNumber(usage.output); + const cacheRead = toNumber(usage.cacheRead); + const cacheWrite = toNumber(usage.cacheWrite); + const total = + input * cost.input + + output * cost.output + + cacheRead * cost.cacheRead + + cacheWrite * cost.cacheWrite; + if (!Number.isFinite(total)) return undefined; + return total / 1_000_000; +}