diff --git a/README.md b/README.md index c9b7dbe6..d291a149 100644 --- a/README.md +++ b/README.md @@ -563,7 +563,7 @@ Credentials are stored with **AES-256-GCM** at `~/.chatcli/auth-profiles.json`. | **Custom personas** | Markdown with YAML frontmatter (model, tools, skills). | | **Hooks** | PreToolUse, PostToolUse, SessionStart/End, UserPromptSubmit, Pre/PostCompact — shell or webhook. | | **WebFetch / WebSearch** | DuckDuckGo + fetch with text extraction. | -| **Cost tracking** | Per-session cost with per-provider pricing tables. | +| **Cost tracking** | Real API usage across all providers, `/cost` (+ `reset`, `last`, `sessions`, `export`), session budgets with optional hard stop, persisted snapshots. | | **Git Worktrees** | Isolated work on parallel branches. | | **K8s Watcher** | Multi-target: metrics, logs, events, Prometheus scraping. | | **i18n** | Portuguese and English with automatic detection. | diff --git a/README_PT.md b/README_PT.md index 8c9b26bb..00d323f7 100644 --- a/README_PT.md +++ b/README_PT.md @@ -554,7 +554,7 @@ Credenciais armazenadas com **AES-256-GCM** em `~/.chatcli/auth-profiles.json`. | **Personas customizáveis** | Markdown com frontmatter YAML (model, tools, skills). | | **Hooks** | PreToolUse, PostToolUse, SessionStart/End, UserPromptSubmit, Compact pre/post — shell ou webhook. | | **WebFetch / WebSearch** | DuckDuckGo + fetch com extração de texto. | -| **Cost tracking** | Custo por sessão com pricing tables por provider. | +| **Cost tracking** | Uso real de API em todos os providers, `/cost` (+ `reset`, `last`, `sessions`, `export`), orçamento de sessão com hard stop opcional, snapshots persistidos. | | **Git Worktrees** | Trabalho isolado em branches paralelas. | | **K8s Watcher** | Multi-target: metrics, logs, events, Prometheus scraping. | | **i18n** | Português e Inglês com detecção automática. | diff --git a/cli/agent/workers/dispatcher.go b/cli/agent/workers/dispatcher.go index 3075bb12..87dabfb6 100644 --- a/cli/agent/workers/dispatcher.go +++ b/cli/agent/workers/dispatcher.go @@ -10,6 +10,7 @@ import ( "github.com/diillson/chatcli/cli/agent/runs" "github.com/diillson/chatcli/llm/client" "github.com/diillson/chatcli/llm/manager" + "github.com/diillson/chatcli/models" "go.uber.org/zap" ) @@ -36,6 +37,10 @@ type Dispatcher struct { config DispatcherConfig policyChecker PolicyChecker pipeline ExecutionPipeline + // usageRecorder/budgetGate are boxed behind pointers so Dispatcher + // stays a comparable type (bare func fields would break that contract). + usageRecorder *usageRecorderBox + budgetGate *budgetGateBox logger *zap.Logger } @@ -362,6 +367,25 @@ func (d *Dispatcher) executeAgent(ctx context.Context, call AgentCall) AgentResu workerCtx, cancel := context.WithTimeout(runCtx, d.config.WorkerTimeout) defer cancel() + // Decorate the worker's client so every LLM round-trip it makes lands in + // the session cost tracker AS IT HAPPENS, attributed to the + // provider+model that served this worker — per-call recording keeps the + // provider-billed accounting correct and lets the budget gate see live + // spend. The gate itself refuses further calls once the session budget + // hard stop trips, so an in-flight dispatch wave stops mid-run instead + // of finishing its ReAct loops on borrowed money. + if rec, gate := d.usageRecorder, d.budgetGate; rec != nil || gate != nil { + record := func(*models.UsageInfo) {} + if rec != nil { + record = func(u *models.UsageInfo) { rec.fn(effProvider, effModel, u) } + } + var gateFn func() error + if gate != nil { + gateFn = gate.fn + } + llmClient = wrapWithUsageRecording(llmClient, record, gateFn) + } + deps := &WorkerDeps{ LLMClient: llmClient, LockMgr: d.lockMgr, diff --git a/cli/agent/workers/usage_tally.go b/cli/agent/workers/usage_tally.go new file mode 100644 index 00000000..28581a4d --- /dev/null +++ b/cli/agent/workers/usage_tally.go @@ -0,0 +1,153 @@ +/* + * ChatCLI - Command Line Interface for LLM interaction + * Copyright (c) 2024 Edilson Freitas + * License: Apache-2.0 + */ +package workers + +import ( + "context" + "fmt" + + "github.com/diillson/chatcli/llm/client" + "github.com/diillson/chatcli/models" +) + +// UsageRecorder receives the token usage of ONE worker LLM call so the +// session cost tracker can account for subagent spend — historically the +// largest untracked slice of an agent-mode session. Called per call, not +// per worker run: merging N calls into one UsageInfo would collapse the +// tracker's per-call provider-billed accounting (one call carrying +// usage.cost would mark ALL merged tokens as billed), and per-call +// recording also keeps the tracker live mid-run so the budget gate sees +// spend as it happens instead of only when the worker finishes. +type UsageRecorder func(provider, model string, usage *models.UsageInfo) + +// BudgetGate is consulted before every worker LLM call; a non-nil error +// refuses the call (the session budget hard stop). Kept as a callback so +// the workers package stays decoupled from the CLI's cost tracker. +type BudgetGate func() error + +// usageRecorderBox wraps the recorder func so Dispatcher can hold it via a +// comparable pointer field. +type usageRecorderBox struct{ fn UsageRecorder } + +// budgetGateBox wraps the gate func so Dispatcher stays comparable. +type budgetGateBox struct{ fn BudgetGate } + +// SetUsageRecorder wires the callback that receives each worker LLM call's +// usage, attributed to the provider+model that actually served the worker. +// Nil disables recording (the default). +func (d *Dispatcher) SetUsageRecorder(fn UsageRecorder) { + if fn == nil { + d.usageRecorder = nil + return + } + d.usageRecorder = &usageRecorderBox{fn: fn} +} + +// SetBudgetGate wires the session budget hard stop into every worker LLM +// call. Without it a dispatch wave in flight kept spending after the +// budget was exhausted — up to a full ReAct loop per worker, times the +// parallel wave. Nil disables the gate (the default). +func (d *Dispatcher) SetBudgetGate(fn BudgetGate) { + if fn == nil { + d.budgetGate = nil + return + } + d.budgetGate = &budgetGateBox{fn: fn} +} + +// recordingClient decorates a worker's LLM client so every SendPrompt / +// SendPromptWithTools round-trip is (a) refused when the budget gate says +// so and (b) recorded immediately — real API usage when the inner client +// reports it, a character estimate otherwise. It preserves the tool-use +// capability of the inner client (SupportsNativeTools answers for it), so +// RunWorkerReAct routes exactly as it would undecorated. +type recordingClient struct { + inner client.LLMClient + record func(*models.UsageInfo) // never nil + gate func() error // may be nil (no budget gate) +} + +func wrapWithUsageRecording(inner client.LLMClient, record func(*models.UsageInfo), gate func() error) client.LLMClient { + return &recordingClient{inner: inner, record: record, gate: gate} +} + +func (rc *recordingClient) GetModelName() string { return rc.inner.GetModelName() } + +func (rc *recordingClient) SendPrompt(ctx context.Context, prompt string, history []models.Message, maxTokens int) (string, error) { + if rc.gate != nil { + if err := rc.gate(); err != nil { + return "", err + } + } + resp, err := rc.inner.SendPrompt(ctx, prompt, history, maxTokens) + if err == nil { + rc.record(client.GetUsageOrEstimate(rc.inner, promptChars(prompt, history), len(resp))) + } + return resp, err +} + +// SendPromptWithTools delegates to the inner client's native tool path. +// Only reachable when SupportsNativeTools() returned true. +func (rc *recordingClient) SendPromptWithTools(ctx context.Context, prompt string, history []models.Message, tools []models.ToolDefinition, maxTokens int) (*models.LLMResponse, error) { + tac, ok := client.AsToolAware(rc.inner) + if !ok { + return nil, fmt.Errorf("worker LLM client does not support native tools") + } + if rc.gate != nil { + if err := rc.gate(); err != nil { + return nil, err + } + } + resp, err := tac.SendPromptWithTools(ctx, prompt, history, tools, maxTokens) + if err == nil { + if resp != nil && resp.Usage != nil { + rc.record(resp.Usage) + } else { + outChars := 0 + if resp != nil { + outChars = len(resp.Content) + } + rc.record(client.GetUsageOrEstimate(rc.inner, promptChars(prompt, history), outChars)) + } + } + return resp, err +} + +// SupportsNativeTools answers for the inner client so the decorated client +// keeps its exact routing behavior. +func (rc *recordingClient) SupportsNativeTools() bool { + if tac, ok := client.AsToolAware(rc.inner); ok { + return tac.SupportsNativeTools() + } + return false +} + +// LastUsage forwards the inner client's real usage so nested consumers of +// the decorated client still see it. +func (rc *recordingClient) LastUsage() *models.UsageInfo { + if uac, ok := client.AsUsageAware(rc.inner); ok { + return uac.LastUsage() + } + return nil +} + +// LastStopReason forwards the inner client's stop reason (the ReAct loop +// uses it to detect max_tokens truncation). +func (rc *recordingClient) LastStopReason() string { + if src, ok := client.AsStopReasonAware(rc.inner); ok { + return src.LastStopReason() + } + return "" +} + +// promptChars sizes the outgoing payload for the estimate fallback. +func promptChars(prompt string, history []models.Message) int { + n := len(prompt) + for _, m := range history { + n += len(m.Content) + } + return n +} diff --git a/cli/agent/workers/usage_tally_test.go b/cli/agent/workers/usage_tally_test.go new file mode 100644 index 00000000..9fb17f7c --- /dev/null +++ b/cli/agent/workers/usage_tally_test.go @@ -0,0 +1,109 @@ +/* + * ChatCLI - worker usage recording tests + * Copyright (c) 2024 Edilson Freitas + * License: Apache-2.0 + */ +package workers + +import ( + "context" + "errors" + "testing" + + "github.com/diillson/chatcli/llm/client" + "github.com/diillson/chatcli/models" +) + +// fakeWorkerClient is UsageAware + ToolAware with canned responses. +type fakeWorkerClient struct { + usage *models.UsageInfo + tools bool +} + +func (f *fakeWorkerClient) GetModelName() string { return "fake-model" } +func (f *fakeWorkerClient) SendPrompt(_ context.Context, _ string, _ []models.Message, _ int) (string, error) { + return "answer", nil +} +func (f *fakeWorkerClient) SendPromptWithTools(_ context.Context, _ string, _ []models.Message, _ []models.ToolDefinition, _ int) (*models.LLMResponse, error) { + return &models.LLMResponse{Content: "tool answer", Usage: f.usage}, nil +} +func (f *fakeWorkerClient) SupportsNativeTools() bool { return f.tools } +func (f *fakeWorkerClient) LastUsage() *models.UsageInfo { return f.usage } +func (f *fakeWorkerClient) LastStopReason() string { return "end_turn" } + +// TestRecordingClientRecordsEveryCallSeparately: two rounds through the +// wrapped client must land as TWO records — per-call recording is what +// keeps the tracker's provider-billed accounting correct (a merged total +// would let one billed call mark all tokens as billed). +func TestRecordingClientRecordsEveryCallSeparately(t *testing.T) { + inner := &fakeWorkerClient{ + usage: &models.UsageInfo{PromptTokens: 100, CompletionTokens: 20, TotalTokens: 120, IsReal: true}, + tools: true, + } + var recorded []*models.UsageInfo + wrapped := wrapWithUsageRecording(inner, func(u *models.UsageInfo) { recorded = append(recorded, u) }, nil) + + if _, err := wrapped.SendPrompt(context.Background(), "p", nil, 0); err != nil { + t.Fatal(err) + } + tac, ok := client.AsToolAware(wrapped) + if !ok || !tac.SupportsNativeTools() { + t.Fatal("wrapper lost the native-tools capability") + } + if _, err := tac.SendPromptWithTools(context.Background(), "p", nil, nil, 0); err != nil { + t.Fatal(err) + } + + if len(recorded) != 2 { + t.Fatalf("recorded %d calls, want 2 (per-call recording)", len(recorded)) + } + for i, u := range recorded { + if u.PromptTokens != 100 || u.CompletionTokens != 20 || !u.IsReal { + t.Fatalf("call %d usage = %+v, want 100/20 real", i, u) + } + } +} + +// TestRecordingClientEstimatesWhenInnerSilent: a usage-less inner client +// still produces a character estimate instead of zero. +func TestRecordingClientEstimatesWhenInnerSilent(t *testing.T) { + inner := &fakeWorkerClient{usage: nil} + var recorded []*models.UsageInfo + wrapped := wrapWithUsageRecording(inner, func(u *models.UsageInfo) { recorded = append(recorded, u) }, nil) + + if _, err := wrapped.SendPrompt(context.Background(), "a 40-character prompt string goes here!!", nil, 0); err != nil { + t.Fatal(err) + } + if len(recorded) != 1 || recorded[0].PromptTokens == 0 || recorded[0].IsReal { + t.Fatalf("estimate fallback broken: %+v", recorded) + } +} + +// TestRecordingClientBudgetGateRefusesCalls: once the gate errors, no +// provider call happens — the in-flight dispatch wave stops mid-run. +func TestRecordingClientBudgetGateRefusesCalls(t *testing.T) { + inner := &fakeWorkerClient{tools: true} + blocked := errors.New("budget exhausted") + calls := 0 + wrapped := wrapWithUsageRecording(inner, func(*models.UsageInfo) { calls++ }, func() error { return blocked }) + + if _, err := wrapped.SendPrompt(context.Background(), "p", nil, 0); !errors.Is(err, blocked) { + t.Fatalf("SendPrompt not gated: %v", err) + } + tac, _ := client.AsToolAware(wrapped) + if _, err := tac.SendPromptWithTools(context.Background(), "p", nil, nil, 0); !errors.Is(err, blocked) { + t.Fatalf("SendPromptWithTools not gated: %v", err) + } + if calls != 0 { + t.Fatalf("gated calls still recorded usage: %d", calls) + } +} + +// TestRecordingClientHidesToolsWhenInnerLacksThem: capability parity. +func TestRecordingClientHidesToolsWhenInnerLacksThem(t *testing.T) { + inner := &fakeWorkerClient{tools: false} + wrapped := wrapWithUsageRecording(inner, func(*models.UsageInfo) {}, nil) + if tac, ok := client.AsToolAware(wrapped); ok && tac.SupportsNativeTools() { + t.Fatal("wrapper claims native tools the inner client lacks") + } +} diff --git a/cli/agent_mode.go b/cli/agent_mode.go index 74b0eee9..7fc97318 100644 --- a/cli/agent_mode.go +++ b/cli/agent_mode.go @@ -1947,6 +1947,13 @@ func (a *AgentMode) processAIResponseAndAct(ctx context.Context, maxTurns int) e default: } + // Budget hard stop: end the run before the next provider call when + // the session budget is exhausted and CHATCLI_BUDGET_HARD_STOP is on. + if err := a.cli.budgetBlockedErr(); err != nil { + fmt.Println(colorize(" "+err.Error(), ColorRed)) + return err + } + // Honor the session /max-tokens override exactly like chat mode does. // Re-read every turn (raising OR lowering reflects here), while // preserving any truncation-driven escalation applied meanwhile. @@ -2269,6 +2276,7 @@ func (a *AgentMode) processAIResponseAndAct(ctx context.Context, maxTurns int) e effModel = resolution.Model } a.cli.costTracker.RecordRealUsage(effProvider, effModel, turnUsage) + a.cli.maybeAnnounceBudget() } // Para o timer e obtém a duração @@ -4016,6 +4024,18 @@ func (a *AgentMode) initMultiAgent(ctx context.Context) bool { a.agentDispatcher = workers.NewDispatcher(a.agentRegistry, a.cli.manager, cfg, a.logger) + // Every worker's LLM spend lands in the session cost tracker per call, + // attributed to the provider+model that served the worker — /cost covers + // subagents live, and the budget hard stop reaches into in-flight + // dispatch waves instead of only gating the orchestrator's next turn. + if a.cli.costTracker != nil { + tracker := a.cli.costTracker + a.agentDispatcher.SetUsageRecorder(func(provider, model string, usage *models.UsageInfo) { + tracker.RecordRealUsage(provider, model, usage) + }) + a.agentDispatcher.SetBudgetGate(a.cli.budgetBlockedErr) + } + // Attach policy enforcement so parallel workers respect security rules if pa, err := newWorkerPolicyAdapter(a.logger); err == nil { pa.unattended = a.cli.unattended // gateway: auto-approve "ask" instead of blocking on stdin diff --git a/cli/chat_ask.go b/cli/chat_ask.go index ee2aae41..88d7f512 100644 --- a/cli/chat_ask.go +++ b/cli/chat_ask.go @@ -129,6 +129,11 @@ func (cli *ChatCLI) executeChatAskNative( } if resp != nil && resp.Usage != nil && cli.costTracker != nil { cli.costTracker.RecordRealUsage(resolution.Provider, resolution.Model, resp.Usage) + // Flag the turn as recorded: handleChatTurnResult would otherwise + // record the SAME usage again via LastUsage() on the buffered + // path, doubling every chat-ask turn's tokens and dollars. + cli.turnUsageRecorded = true + cli.maybeAnnounceBudget() } var calls chatExceptionCalls diff --git a/cli/chat_envelope_footer_test.go b/cli/chat_envelope_footer_test.go index 017d7c64..556e802e 100644 --- a/cli/chat_envelope_footer_test.go +++ b/cli/chat_envelope_footer_test.go @@ -19,8 +19,8 @@ import ( func TestChatEnvelopeFooter_EmptyWithoutUsage(t *testing.T) { cli := &ChatCLI{} - assert.Equal(t, "", cli.chatEnvelopeFooter(nil), "no usage → no footer") - assert.Equal(t, "", cli.chatEnvelopeFooter(&models.UsageInfo{}), "zero usage → no footer") + assert.Equal(t, "", cli.chatEnvelopeFooter("", "", nil), "no usage → no footer") + assert.Equal(t, "", cli.chatEnvelopeFooter("", "", &models.UsageInfo{}), "zero usage → no footer") } func TestChatEnvelopeFooter_ShowsCostAndContext(t *testing.T) { @@ -28,7 +28,7 @@ func TestChatEnvelopeFooter_ShowsCostAndContext(t *testing.T) { theme.SetProfile(theme.ProfileANSI) // keep ANSI so colorize doesn't strip in test cli := &ChatCLI{Provider: "OPENAI", Model: "gpt-4o"} - footer := cli.chatEnvelopeFooter(&models.UsageInfo{PromptTokens: 1000, CompletionTokens: 500}) + footer := cli.chatEnvelopeFooter("OPENAI", "gpt-4o", &models.UsageInfo{PromptTokens: 1000, CompletionTokens: 500}) // A known-priced model yields a cost token and a context percentage. assert.Contains(t, footer, "$", "footer shows a cost") diff --git a/cli/chat_envelope_render_test.go b/cli/chat_envelope_render_test.go index 377bbbab..d2c9c2c0 100644 --- a/cli/chat_envelope_render_test.go +++ b/cli/chat_envelope_render_test.go @@ -86,6 +86,7 @@ func TestRenderAssistantResponse_BoxedOutput(t *testing.T) { "Hello from the assistant.", 1400*time.Millisecond, &models.UsageInfo{PromptTokens: 312, CompletionTokens: 1800}, + "CLAUDEAI", "claude-opus-4-7", ) }) plain := stripANSIWelcome(out) diff --git a/cli/chat_pipeline.go b/cli/chat_pipeline.go index e275f3a6..a0820e66 100644 --- a/cli/chat_pipeline.go +++ b/cli/chat_pipeline.go @@ -652,6 +652,13 @@ func (cli *ChatCLI) executeLLMTurn( resolution SkillClientResolution, stopSpinner func(), ) (string, error) { + // Budget hard stop: refuse the turn before any provider call when the + // session budget is exhausted and CHATCLI_BUDGET_HARD_STOP is armed. + if err := cli.budgetBlockedErr(); err != nil { + stopSpinner() + return "", err + } + // Controlled chat exception: when CHATCLI_CHAT_ASK is on and the provider // supports native tools, chat may use ONLY ask_user (no execution tools). // Off by default, so chat keeps streaming on every turn. @@ -703,6 +710,7 @@ func (cli *ChatCLI) executeStreamingTurn( } if result.Usage != nil && cli.costTracker != nil { cli.costTracker.RecordRealUsage(resolution.Provider, resolution.Model, result.Usage) + cli.maybeAnnounceBudget() } return result.Text, nil } @@ -764,10 +772,15 @@ func (cli *ChatCLI) handleChatTurnResult( cli.persistBoundSession() usage := client.GetUsageOrEstimate(activeClient, len(userInput+additionalContext), len(aiResponse)) - if cli.costTracker != nil && !client.IsStreamingCapable(activeClient) { + // Skip the record when the chat-ask/knowledge exception already booked + // this turn per tool round — usage stays in hand for the envelope only. + alreadyRecorded := cli.turnUsageRecorded + cli.turnUsageRecorded = false + if cli.costTracker != nil && !client.IsStreamingCapable(activeClient) && !alreadyRecorded { cli.costTracker.RecordRealUsage(resolution.Provider, resolution.Model, usage) } - cli.renderAssistantResponse(activeClient, aiResponse, elapsed, usage) + cli.renderAssistantResponse(activeClient, aiResponse, elapsed, usage, resolution.Provider, resolution.Model) + cli.maybeAnnounceBudget() if cli.memWorker != nil { cli.memWorker.nudge(ctx) @@ -797,6 +810,7 @@ func (cli *ChatCLI) renderAssistantResponse( aiResponse string, elapsed time.Duration, usage *models.UsageInfo, + servedProvider, servedModel string, ) { rendered := ensureANSIReset(cli.renderMarkdown(aiResponse)) if client.IsStreamingCapable(activeClient) { @@ -805,7 +819,7 @@ func (cli *ChatCLI) renderAssistantResponse( } left, right := chatEnvelopeLabels(activeClient, elapsed, usage) - footerRight := cli.chatEnvelopeFooter(usage) + footerRight := cli.chatEnvelopeFooter(servedProvider, servedModel, usage) renderer := agent.NewUIRendererWithStyle(cli.logger, agent.UIStyleFull) renderer.RenderResponseEnvelope(agent.ResponseEnvelopeOptions{ HeaderLeft: left, @@ -824,14 +838,22 @@ func (cli *ChatCLI) renderAssistantResponse( // usage counts plus the model's pricing/context-window from the catalog — so // the footer adds no new bookkeeping. It returns "" (no footer drawn) when // usage is unreported, keeping the box clean for providers that omit counts. -func (cli *ChatCLI) chatEnvelopeFooter(usage *models.UsageInfo) string { +func (cli *ChatCLI) chatEnvelopeFooter(servedProvider, servedModel string, usage *models.UsageInfo) string { if usage == nil || (usage.PromptTokens == 0 && usage.CompletionTokens == 0) { return "" } - inputCost, outputCost := getModelPricing(cli.Provider, cli.Model) - turnCost := float64(usage.PromptTokens)/1_000_000*inputCost + - float64(usage.CompletionTokens)/1_000_000*outputCost + // Price the turn against the provider+model pair that actually served + // it — the SAME pair the tracker records under (a skill hint or route + // override may have swapped both), with the same cache semantics. The + // footer and /cost must never disagree about the same turn. + if servedProvider == "" { + servedProvider = cli.Provider + } + if servedModel == "" { + servedModel = cli.Model + } + turnCost := estimateTurnCostUSD(servedProvider, servedModel, usage) parts := cli.telemetryParts(usage, turnCost, false) if len(parts) == 0 { diff --git a/cli/cli.go b/cli/cli.go index 01f6c25e..60b1fb9a 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -339,6 +339,11 @@ type ChatCLI struct { // Cost tracking for the current session costTracker *CostTracker + // turnUsageRecorded marks that the current turn's usage already landed + // in the tracker (the chat-ask/knowledge exception records per tool + // round) so handleChatTurnResult must not record the turn a second + // time. Set and cleared on the single-threaded REPL turn path. + turnUsageRecorded bool // Cached provider models for autocomplete (populated asynchronously) cachedModels []client.ModelInfo @@ -2090,6 +2095,14 @@ func (cli *ChatCLI) cleanup(ctx context.Context) { if ws := agent.GetSessionWorkspace(); ws != nil { ws.Cleanup() } + // Final cost snapshot so /cost last in the next session sees this one + // complete (the write-through during the session is throttled). + if cli.costTracker != nil { + cli.costTracker.SetSessionName(cli.currentSessionName) + if err := cli.costTracker.SaveSession(); err != nil { + cli.logger.Debug("cost snapshot save on shutdown failed", zap.Error(err)) + } + } if err := cli.logger.Sync(); err != nil { msg := err.Error() if !strings.Contains(msg, "/dev/stdout") && diff --git a/cli/cli_completer.go b/cli/cli_completer.go index f600ca5f..a69e80cd 100644 --- a/cli/cli_completer.go +++ b/cli/cli_completer.go @@ -53,6 +53,7 @@ var slashPrefixRoutes = []slashPrefixRoute{ {"/watch", (*ChatCLI).getWatchSuggestions}, {"/mcp", (*ChatCLI).getMCPSuggestions}, {"/hooks ", (*ChatCLI).getHooksSuggestions}, + {"/cost ", (*ChatCLI).getCostSuggestions}, {"/worktree ", (*ChatCLI).getWorktreeSuggestions}, {"/channel ", (*ChatCLI).getChannelSuggestions}, {"/websearch", (*ChatCLI).getWebSearchSuggestions}, @@ -113,6 +114,21 @@ func (cli *ChatCLI) getLSPSuggestions(d prompt.Document) []prompt.Suggest { return cli.filePathCompleter(d.GetWordBeforeCursor()) } +// getCostSuggestions completes the /cost subcommands. +func (cli *ChatCLI) getCostSuggestions(d prompt.Document) []prompt.Suggest { + line := d.TextBeforeCursor() + args := strings.Fields(line) + if len(args) == 1 || (len(args) == 2 && !strings.HasSuffix(line, " ")) { + return prompt.FilterHasPrefix([]prompt.Suggest{ + {Text: "reset", Description: i18n.T("complete.cost.reset")}, + {Text: "last", Description: i18n.T("complete.cost.last")}, + {Text: "sessions", Description: i18n.T("complete.cost.sessions")}, + {Text: "export", Description: i18n.T("complete.cost.export")}, + }, d.GetWordBeforeCursor(), true) + } + return nil +} + // getGatewaySuggestions completes the /gateway subcommands. func (cli *ChatCLI) getGatewaySuggestions(d prompt.Document) []prompt.Suggest { line := d.TextBeforeCursor() diff --git a/cli/cli_config.go b/cli/cli_config.go index dc8e2864..f5289d23 100644 --- a/cli/cli_config.go +++ b/cli/cli_config.go @@ -68,6 +68,7 @@ var reloadableEnvVars = []string{ "CHATCLI_WEBFETCH_RENDER_BROWSER", "CHATCLI_EMBED_PROVIDER", "CHATCLI_EMBED_MODEL", "CHATCLI_EMBED_DIMENSIONS", "CHATCLI_COMMANDS", "CHATCLI_COMMANDS_AUTOROUTE", + "CHATCLI_SESSION_BUDGET_USD", "CHATCLI_BUDGET_WARNING_PCT", "CHATCLI_BUDGET_HARD_STOP", } // reloadConfiguration recarrega as variáveis de ambiente e reconfigura o LLMManager @@ -108,6 +109,13 @@ func (cli *ChatCLI) reloadConfiguration(ctx context.Context) { config.Global.Reload(cli.logger) + // Budget envs are read once by NewCostTracker; re-read them here so a + // .env change to CHATCLI_SESSION_BUDGET_USD / _WARNING_PCT / _HARD_STOP + // takes effect on /reload without restarting the process. + if cli.costTracker != nil { + cli.costTracker.ReloadBudget() + } + cli.reconfigureLogger() // Rebuild the embedding provider so a CHATCLI_EMBED_PROVIDER change in diff --git a/cli/command_handler.go b/cli/command_handler.go index 0a29d6f3..660b1f53 100644 --- a/cli/command_handler.go +++ b/cli/command_handler.go @@ -210,7 +210,6 @@ func (ch *CommandHandler) buildRoutes() { "/disconnect": func(ctx context.Context, _ string) bool { ch.handleDisconnectCommand(ctx); return false }, "/rewind": func(_ context.Context, _ string) bool { c.showRewindMenu(); return false }, "/metrics": func(_ context.Context, _ string) bool { ch.handleMetricsCommand(); return false }, - "/cost": func(_ context.Context, _ string) bool { c.handleCostCommand(); return false }, "/newsession": func(ctx context.Context, _ string) bool { c.clearAllHistories() c.currentSessionName = "" @@ -232,6 +231,10 @@ func (ch *CommandHandler) buildRoutes() { // Order preserved from the historical switch. word=true entries match // "exact or +space"; word=false entries are raw-prefix sub-command groups. ch.routes.prefixes = []prefixRoute{ + {"/cost", true, func(_ context.Context, in string) bool { + c.handleCostCommand(strings.TrimSpace(strings.TrimPrefix(in, "/cost"))) + return false + }}, {"/switch", false, func(ctx context.Context, in string) bool { c.handleSwitchCommand(ctx, in); return false }}, {"/provider", false, func(ctx context.Context, in string) bool { c.handleProviderCommand(ctx, in); return false }}, // Must precede "/model" (raw-prefix) so it isn't shadowed by it. diff --git a/cli/config_env_defaults.go b/cli/config_env_defaults.go index 7e803771..79dd1cc2 100644 --- a/cli/config_env_defaults.go +++ b/cli/config_env_defaults.go @@ -172,6 +172,7 @@ var envDefaults = map[string]envDefault{ // ─── Cost / budget ─────────────────────────────────────────── "CHATCLI_SESSION_BUDGET_USD": {Value: "(no budget)", Source: "cost_tracker.go"}, "CHATCLI_BUDGET_WARNING_PCT": {Value: "0.80", Source: "cost_tracker.go"}, + "CHATCLI_BUDGET_HARD_STOP": {Value: "false", IsBool: true, Source: "cost_tracker.go (refuse turns once budget exceeded)"}, "CHATCLI_SESSION_TTL": {Value: "90", Source: "session_manager.go (days)"}, "CHATCLI_DISABLE_HISTORY": {Value: "false", IsBool: true, Source: "history_manager.go"}, diff --git a/cli/config_sections.go b/cli/config_sections.go index 13162d75..417a3433 100644 --- a/cli/config_sections.go +++ b/cli/config_sections.go @@ -887,6 +887,7 @@ func (cli *ChatCLI) showConfigSession() { } kv(p, "CHATCLI_SESSION_BUDGET_USD", envOr("CHATCLI_SESSION_BUDGET_USD")) kv(p, "CHATCLI_BUDGET_WARNING_PCT", envOr("CHATCLI_BUDGET_WARNING_PCT")) + kv(p, "CHATCLI_BUDGET_HARD_STOP", envBool("CHATCLI_BUDGET_HARD_STOP")) kv(p, "CHATCLI_SESSION_TTL", envOr("CHATCLI_SESSION_TTL")) kv(p, "CHATCLI_DISABLE_HISTORY", envBool("CHATCLI_DISABLE_HISTORY")) diff --git a/cli/cost_budget.go b/cli/cost_budget.go new file mode 100644 index 00000000..35f733d2 --- /dev/null +++ b/cli/cost_budget.go @@ -0,0 +1,43 @@ +/* + * ChatCLI - Command Line Interface for LLM interaction + * Copyright (c) 2024 Edilson Freitas + * License: Apache-2.0 + */ +package cli + +import ( + "errors" + "fmt" + + "github.com/diillson/chatcli/i18n" +) + +// maybeAnnounceBudget prints the one-shot budget notice when the session +// crossed a budget level since the last check (warning threshold, or the +// limit itself). Called right after usage is recorded so the user learns +// about the crossing on the turn it happens — not only when they remember +// to run /cost. +func (cli *ChatCLI) maybeAnnounceBudget() { + if cli.costTracker == nil { + return + } + level, msg, ok := cli.costTracker.TakeBudgetTransition() + if !ok || msg == "" { + return + } + color := ColorYellow + if level == BudgetExceeded { + color = ColorRed + } + fmt.Println(colorize(" "+msg, color)) +} + +// budgetBlockedErr returns a localized error when the hard-stop gate +// (CHATCLI_BUDGET_HARD_STOP) refuses new LLM turns because the session +// budget is exhausted. Nil when turns may proceed. +func (cli *ChatCLI) budgetBlockedErr() error { + if cli.costTracker != nil && cli.costTracker.BudgetBlocked() { + return errors.New(i18n.T("cost.budget.blocked")) + } + return nil +} diff --git a/cli/cost_command.go b/cli/cost_command.go index c9d4f005..5fe41000 100644 --- a/cli/cost_command.go +++ b/cli/cost_command.go @@ -6,20 +6,149 @@ package cli import ( + "encoding/json" "fmt" + "os" + "path/filepath" + "sort" "strings" "time" "github.com/diillson/chatcli/i18n" "github.com/diillson/chatcli/ui/kit" + "github.com/diillson/chatcli/utils" ) -func (cli *ChatCLI) handleCostCommand() { +// handleCostCommand dispatches /cost and its subcommands. Parsing is +// deliberately lenient ("last", "--last" and "-l" are all accepted): a +// mistyped flag should degrade to help, never to a silent no-op. +func (cli *ChatCLI) handleCostCommand(args string) { if cli.costTracker == nil { fmt.Println(colorize(" "+i18n.T("cost.cmd.not_initialized"), ColorYellow)) return } + // Keep the persisted snapshot labeled with the live session identity. + cli.costTracker.SetSessionName(cli.currentSessionName) + fields := strings.Fields(strings.TrimSpace(args)) + sub := "" + if len(fields) > 0 { + sub = strings.ToLower(strings.TrimLeft(fields[0], "-")) + } + + switch sub { + case "": + cli.renderCostSummary() + case "reset": + cli.handleCostReset() + case "last", "l", "prev", "previous": + cli.handleCostLast() + case "sessions", "history", "list": + cli.handleCostSessions() + case "export": + path := "" + if len(fields) > 1 { + path = fields[1] + } + cli.handleCostExport(path) + default: + fmt.Println(colorize(" "+i18n.T("cost.cmd.help"), ColorGray)) + cli.renderCostSummary() + } +} + +// handleCostReset closes the current accounting period (persisting it) and +// starts a fresh one. +func (cli *ChatCLI) handleCostReset() { + previousID := cli.costTracker.CurrentSessionID() + cli.costTracker.Reset() + fmt.Println(colorize(" "+i18n.T("cost.cmd.reset_done", previousID), ColorGreen)) +} + +// handleCostLast renders the most recent persisted snapshot that is not the +// live session — the spend of the previous CLI run (or period, after a +// /cost reset). +func (cli *ChatCLI) handleCostLast() { + snapshots, err := ListCostSnapshots(0) + if err != nil { + fmt.Println(colorize(" "+i18n.T("cost.cmd.snapshot_failed", err), ColorYellow)) + return + } + currentID := cli.costTracker.CurrentSessionID() + for _, snap := range snapshots { + if snap.SessionID == currentID { + continue + } + cli.renderCostSnapshot(snap, i18n.T("cost.cmd.last_title")) + return + } + fmt.Println(colorize(" "+i18n.T("cost.cmd.last_none"), ColorGray)) +} + +// handleCostSessions lists recent persisted snapshots, most recent first. +func (cli *ChatCLI) handleCostSessions() { + snapshots, err := ListCostSnapshots(10) + if err != nil { + fmt.Println(colorize(" "+i18n.T("cost.cmd.snapshot_failed", err), ColorYellow)) + return + } + if len(snapshots) == 0 { + fmt.Println(colorize(" "+i18n.T("cost.cmd.sessions_none"), ColorGray)) + return + } + + fmt.Println() + fmt.Println(uiBox("$", i18n.T("cost.cmd.sessions_title"), ColorCyan)) + p := uiPrefix(ColorCyan) + currentID := cli.costTracker.CurrentSessionID() + for _, snap := range snapshots { + label := snap.SessionID + if snap.SessionName != "" { + label += " · " + snap.SessionName + } + if snap.SessionID == currentID { + label += " " + i18n.T("cost.cmd.sessions_current") + } + fmt.Println(p + " " + ColorBold + label + ColorReset) + fmt.Println(p + " " + colorize( + i18n.T("cost.cmd.sessions_row", + snap.LastUpdate.Format("2006-01-02 15:04"), + snap.TotalRequests, + formatTokenCount64(snap.TotalTokens), + fmt.Sprintf("$%.4f", snap.TotalCostUSD)), + ColorGray)) + } + fmt.Println(uiBoxEnd(ColorCyan)) + fmt.Println() +} + +// handleCostExport writes the current session snapshot as JSON — to the +// given path, or to the cost store with an -export suffix when omitted. +func (cli *ChatCLI) handleCostExport(path string) { + snap := cli.costTracker.Snapshot() + b, err := json.MarshalIndent(snap, "", " ") + if err != nil { + fmt.Println(colorize(" "+i18n.T("cost.cmd.export_failed", err), ColorYellow)) + return + } + if path == "" { + path = filepath.Join(costStoreDir(), snap.SessionID+"-export.json") + } else if expanded, expErr := utils.ExpandPath(path); expErr == nil { + path = expanded + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + fmt.Println(colorize(" "+i18n.T("cost.cmd.export_failed", err), ColorYellow)) + return + } + if err := os.WriteFile(path, b, 0o600); err != nil { + fmt.Println(colorize(" "+i18n.T("cost.cmd.export_failed", err), ColorYellow)) + return + } + fmt.Println(colorize(" "+i18n.T("cost.cmd.export_done", path), ColorGreen)) +} + +// renderCostSummary paints the live session box. +func (cli *ChatCLI) renderCostSummary() { ct := cli.costTracker ct.mu.RLock() defer ct.mu.RUnlock() @@ -89,11 +218,15 @@ func (cli *ChatCLI) handleCostCommand() { promptBar := "" completionBar := "" if maxToken > 0 { - promptBar = strings.Repeat("\u2588", int(ct.totalPromptTokens*20/maxToken)) - completionBar = strings.Repeat("\u2588", int(ct.totalCompletionTokens*20/maxToken)) + promptBar = strings.Repeat("█", int(ct.totalPromptTokens*20/maxToken)) + completionBar = strings.Repeat("█", int(ct.totalCompletionTokens*20/maxToken)) } - tokenW := groupWidth(i18n.T("cost.cmd.input"), i18n.T("cost.cmd.output"), i18n.T("cost.cmd.total")) + tokenLabels := []string{i18n.T("cost.cmd.input"), i18n.T("cost.cmd.output"), i18n.T("cost.cmd.total")} + if ct.totalReasoning > 0 { + tokenLabels = append(tokenLabels, i18n.T("cost.cmd.reasoning")) + } + tokenW := groupWidth(tokenLabels...) tokenRow := func(label, count, bar string) { line := " " + kit.PadRight(label, tokenW+2) + ColorBold + kit.PadRight(count, 8) + ColorReset if bar != "" { @@ -105,17 +238,25 @@ func (cli *ChatCLI) handleCostCommand() { tokenRow(i18n.T("cost.cmd.input"), formatTokenCount64(ct.totalPromptTokens), ColorGreen+promptBar+ColorReset) tokenRow(i18n.T("cost.cmd.output"), formatTokenCount64(ct.totalCompletionTokens), ColorPurple+completionBar+ColorReset) tokenRow(i18n.T("cost.cmd.total"), formatTokenCount64(totalTokens), "") + if ct.totalReasoning > 0 { + // Informational: reasoning tokens are already inside Output. + tokenRow(i18n.T("cost.cmd.reasoning"), formatTokenCount64(ct.totalReasoning), + colorize(i18n.T("cost.cmd.reasoning_note"), ColorGray)) + } - // Cache tokens (Anthropic only) + // Cache tokens if ct.totalCacheCreation > 0 || ct.totalCacheRead > 0 { cacheW := groupWidth(i18n.T("cost.cmd.cache_created"), i18n.T("cost.cmd.cache_read")) fmt.Println(p) fmt.Println(p + colorize(" "+i18n.T("cost.cmd.cache_tokens_label"), ColorCyan)) fmt.Println(p + " " + kit.PadRight(i18n.T("cost.cmd.cache_created"), cacheW+2) + ColorBold + formatTokenCount64(ct.totalCacheCreation) + ColorReset) + savings := "" + if saved := cacheSavingsUSDLocked(ct); saved > 0.00005 { + savings = " " + colorize(i18n.T("cost.cmd.cache_saved_usd", fmt.Sprintf("$%.4f", saved)), ColorGray) + } fmt.Println(p + " " + kit.PadRight(i18n.T("cost.cmd.cache_read"), cacheW+2) + - ColorBold + formatTokenCount64(ct.totalCacheRead) + ColorReset + " " + - colorize(i18n.T("cost.cmd.cache_savings"), ColorGray)) + ColorBold + formatTokenCount64(ct.totalCacheRead) + ColorReset + savings) } fmt.Println(p) @@ -123,17 +264,27 @@ func (cli *ChatCLI) handleCostCommand() { if ct.totalCostUSD > 0 { fmt.Println(p + colorize(" "+i18n.T("cost.cmd.cost_label"), ColorCyan)) - // Show per-model cost breakdown + // Show per-model cost breakdown, stable order (largest spend first). costW := groupWidth(i18n.T("cost.cmd.input_cost"), i18n.T("cost.cmd.output_cost"), i18n.T("cost.cmd.cache_cost")) - for _, rec := range ct.modelUsage { + for _, rec := range sortedRecords(ct.modelUsage) { if rec.TotalCostUSD <= 0 { continue } - fmt.Println(p + fmt.Sprintf(" %s/%s:", rec.Provider, rec.Model)) - fmt.Println(p + " " + kit.PadRight(i18n.T("cost.cmd.input_cost"), costW+2) + fmt.Sprintf("$%.4f", rec.InputCostUSD)) - fmt.Println(p + " " + kit.PadRight(i18n.T("cost.cmd.output_cost"), costW+2) + fmt.Sprintf("$%.4f", rec.OutputCostUSD)) - if rec.CacheCostUSD > 0 { - fmt.Println(p + " " + kit.PadRight(i18n.T("cost.cmd.cache_cost"), costW+2) + fmt.Sprintf("$%.4f", rec.CacheCostUSD)) + fmt.Println(p + fmt.Sprintf(" %s/%s: %s", rec.Provider, rec.Model, recordSourceTag(rec))) + tableCost := rec.InputCostUSD + rec.OutputCostUSD + rec.CacheCostUSD + if tableCost > 0 || rec.ProviderCostUSD == 0 { + // Table-priced share (all of it when no provider-billed part). + fmt.Println(p + " " + kit.PadRight(i18n.T("cost.cmd.input_cost"), costW+2) + fmt.Sprintf("$%.4f", rec.InputCostUSD)) + fmt.Println(p + " " + kit.PadRight(i18n.T("cost.cmd.output_cost"), costW+2) + fmt.Sprintf("$%.4f", rec.OutputCostUSD)) + if rec.CacheCostUSD > 0 { + fmt.Println(p + " " + kit.PadRight(i18n.T("cost.cmd.cache_cost"), costW+2) + fmt.Sprintf("$%.4f", rec.CacheCostUSD)) + } + } + if rec.ProviderCostUSD > 0 { + // Share the provider billed directly (usage.cost) — shown as + // its own line so a mixed key surfaces both parts. + fmt.Println(p + " " + kit.PadRight(i18n.T("cost.cmd.total"), costW+2) + fmt.Sprintf("$%.4f", rec.ProviderCostUSD) + + " " + colorize(i18n.T("cost.cmd.tag_provider_billed"), ColorGray)) } } @@ -143,10 +294,16 @@ func (cli *ChatCLI) handleCostCommand() { fmt.Println(p + colorize(" "+i18n.T("cost.cmd.pricing_unavailable"), ColorGray)) } + // Models whose price ChatCLI does not know: their spend is NOT in the + // total. Say so instead of silently under-reporting. + if unpriced := unpricedModelsLocked(ct); len(unpriced) > 0 { + fmt.Println(p + colorize(" "+i18n.T("cost.cmd.pricing_unknown_models", strings.Join(unpriced, ", ")), ColorYellow)) + } + // Budget status if msg := ct.budgetMessageLocked(); msg != "" { fmt.Println(p) - if ct.totalCostUSD >= ct.budgetLimitUSD { + if ct.budgetLevelLocked() == BudgetExceeded { fmt.Println(p + colorize(" "+msg, ColorRed)) } else { fmt.Println(p + colorize(" "+msg, ColorYellow)) @@ -157,3 +314,101 @@ func (cli *ChatCLI) handleCostCommand() { fmt.Println(uiBoxEnd(ColorCyan)) fmt.Println() } + +// renderCostSnapshot paints a persisted snapshot (/cost last). +func (cli *ChatCLI) renderCostSnapshot(snap *SessionCostData, title string) { + fmt.Println() + fmt.Println(uiBox("$", title, ColorCyan)) + p := uiPrefix(ColorCyan) + + label := snap.SessionID + if snap.SessionName != "" { + label += " · " + snap.SessionName + } + fmt.Println(p + " " + ColorBold + label + ColorReset) + fmt.Println(p + " " + colorize( + i18n.T("cost.cmd.sessions_row", + snap.LastUpdate.Format("2006-01-02 15:04"), + snap.TotalRequests, + formatTokenCount64(snap.TotalTokens), + fmt.Sprintf("$%.4f", snap.TotalCostUSD)), + ColorGray)) + + if len(snap.ModelUsage) > 0 { + fmt.Println(p) + for _, rec := range sortedRecords(snap.ModelUsage) { + fmt.Println(p + fmt.Sprintf(" %s/%s: %s", rec.Provider, rec.Model, recordSourceTag(rec))) + fmt.Println(p + " " + colorize( + i18n.T("cost.cmd.snapshot_model_row", + formatTokenCount64(rec.TotalTokens), + rec.Requests, + fmt.Sprintf("$%.4f", rec.TotalCostUSD)), + ColorGray)) + } + } + fmt.Println(uiBoxEnd(ColorCyan)) + fmt.Println() +} + +// sortedRecords returns the usage records ordered by descending spend, then +// tokens, then name — a stable order for rendering (map iteration is not). +func sortedRecords(usage map[string]*ModelUsageRecord) []*ModelUsageRecord { + out := make([]*ModelUsageRecord, 0, len(usage)) + for _, rec := range usage { + out = append(out, rec) + } + sort.Slice(out, func(i, j int) bool { + if out[i].TotalCostUSD != out[j].TotalCostUSD { + return out[i].TotalCostUSD > out[j].TotalCostUSD + } + if out[i].TotalTokens != out[j].TotalTokens { + return out[i].TotalTokens > out[j].TotalTokens + } + return out[i].Provider+out[i].Model < out[j].Provider+out[j].Model + }) + return out +} + +// recordSourceTag renders the per-model data-source tag: real API counts or +// character estimate. Per model — a session can mix both. +func recordSourceTag(rec *ModelUsageRecord) string { + if rec.HasRealData { + return colorize(i18n.T("cost.cmd.tag_api"), ColorGreen) + } + return colorize(i18n.T("cost.cmd.tag_estimate"), ColorYellow) +} + +// unpricedModelsLocked lists models that carry tokens but matched no pricing +// table entry (and reported no provider cost). Caller holds ct.mu. +func unpricedModelsLocked(ct *CostTracker) []string { + var out []string + for _, rec := range ct.modelUsage { + if !rec.PricingKnown && rec.TotalTokens > 0 && rec.ProviderCostUSD == 0 { + out = append(out, rec.Provider+"/"+rec.Model) + } + } + sort.Strings(out) + return out +} + +// cacheSavingsUSDLocked estimates how much the session saved because cache +// reads were billed at the discounted rate instead of the full input price. +// Caller holds ct.mu. +func cacheSavingsUSDLocked(ct *CostTracker) float64 { + saved := 0.0 + for _, rec := range ct.modelUsage { + if rec.CacheReadTokens == 0 { + continue + } + inputCost, _, known := lookupModelPricing(rec.Provider, rec.Model) + if !known || inputCost <= 0 { + continue + } + _, readCost := getCachePricing(rec.Provider, rec.Model) + if readCost <= 0 || readCost >= inputCost { + continue + } + saved += float64(rec.CacheReadTokens) / 1_000_000 * (inputCost - readCost) + } + return saved +} diff --git a/cli/cost_command_render_test.go b/cli/cost_command_render_test.go index 0c88b139..31cd1c24 100644 --- a/cli/cost_command_render_test.go +++ b/cli/cost_command_render_test.go @@ -65,7 +65,7 @@ func TestHandleCostCommandRendersAllSections(t *testing.T) { pinPlainProfile(t) c := &ChatCLI{costTracker: newCostTrackerFixture(), Provider: "CLAUDEAI"} - out := captureCommandStdout(t, func() { c.handleCostCommand() }) + out := captureCommandStdout(t, func() { c.handleCostCommand("") }) for _, want := range []string{ "CLAUDEAI", // provider row @@ -90,7 +90,7 @@ func TestHandleCostCommandAlignsTopGroup(t *testing.T) { pinPlainProfile(t) c := &ChatCLI{costTracker: newCostTrackerFixture(), Provider: "CLAUDEAI"} - out := captureCommandStdout(t, func() { c.handleCostCommand() }) + out := captureCommandStdout(t, func() { c.handleCostCommand("") }) cols := map[int]bool{} for _, ln := range strings.Split(out, "\n") { @@ -112,7 +112,7 @@ func TestHandleCostCommandAlignsTopGroup(t *testing.T) { func TestHandleCostCommandWithoutTracker(t *testing.T) { pinPlainProfile(t) c := &ChatCLI{} - out := captureCommandStdout(t, func() { c.handleCostCommand() }) + out := captureCommandStdout(t, func() { c.handleCostCommand("") }) if strings.TrimSpace(out) == "" { t.Fatal("missing not-initialized notice") } diff --git a/cli/cost_command_subcommands_test.go b/cli/cost_command_subcommands_test.go new file mode 100644 index 00000000..2b2ad700 --- /dev/null +++ b/cli/cost_command_subcommands_test.go @@ -0,0 +1,125 @@ +/* + * ChatCLI - Command Line Interface for LLM interaction + * Copyright (c) 2024 Edilson Freitas + * License: Apache-2.0 + */ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/diillson/chatcli/models" +) + +// newLiveCostCLI builds a ChatCLI whose tracker carries real recorded usage +// (unlike the render fixture, whose fields are hand-set), so subcommands +// exercise the same paths production does. +func newLiveCostCLI(t *testing.T) *ChatCLI { + t.Helper() + t.Setenv("HOME", t.TempDir()) + ct := NewCostTracker() + ct.RecordRealUsage("OPENAI", "gpt-4o", &models.UsageInfo{ + PromptTokens: 10_000, CompletionTokens: 2_000, IsReal: true, + }) + return &ChatCLI{costTracker: ct, Provider: "OPENAI"} +} + +func TestCostCommandResetSubcommand(t *testing.T) { + pinPlainProfile(t) + c := newLiveCostCLI(t) + oldID := c.costTracker.CurrentSessionID() + + out := captureCommandStdout(t, func() { c.handleCostCommand("reset") }) + + if c.costTracker.TotalTokens() != 0 { + t.Fatal("reset did not zero the live tracker") + } + if !strings.Contains(out, oldID) { + t.Errorf("reset notice does not name the saved period id %q:\n%s", oldID, out) + } +} + +func TestCostCommandLastSubcommand(t *testing.T) { + pinPlainProfile(t) + c := newLiveCostCLI(t) + + // No previous snapshot yet (only the live session's own): "last" reports none. + _ = c.costTracker.SaveSession() + out := captureCommandStdout(t, func() { c.handleCostCommand("last") }) + if !strings.Contains(strings.ToLower(out), "no previous") && !strings.Contains(out, "anterior") { + t.Errorf("expected 'none found' notice, got:\n%s", out) + } + + // After a reset the closed period becomes the previous snapshot; both + // the plain form and the --last spelling must find it. + oldID := c.costTracker.CurrentSessionID() + c.costTracker.Reset() + for _, spelling := range []string{"last", "--last", "-l"} { + out = captureCommandStdout(t, func() { c.handleCostCommand(spelling) }) + if !strings.Contains(out, oldID) { + t.Errorf("/cost %s does not show the previous period %q:\n%s", spelling, oldID, out) + } + } +} + +func TestCostCommandSessionsSubcommand(t *testing.T) { + pinPlainProfile(t) + c := newLiveCostCLI(t) + _ = c.costTracker.SaveSession() + + out := captureCommandStdout(t, func() { c.handleCostCommand("sessions") }) + if !strings.Contains(out, c.costTracker.CurrentSessionID()) { + t.Errorf("sessions listing missing the current snapshot:\n%s", out) + } +} + +func TestCostCommandExportSubcommand(t *testing.T) { + pinPlainProfile(t) + c := newLiveCostCLI(t) + dest := filepath.Join(t.TempDir(), "cost.json") + + out := captureCommandStdout(t, func() { c.handleCostCommand("export " + dest) }) + if !strings.Contains(out, dest) { + t.Errorf("export notice missing path:\n%s", out) + } + b, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("exported file: %v", err) + } + var snap SessionCostData + if err := json.Unmarshal(b, &snap); err != nil { + t.Fatalf("exported JSON invalid: %v", err) + } + if snap.TotalRequests != 1 || snap.TotalTokens != 12_000 { + t.Fatalf("exported snapshot wrong: %+v", snap) + } +} + +func TestCostCommandUnknownSubcommandShowsHelpThenSummary(t *testing.T) { + pinPlainProfile(t) + c := newLiveCostCLI(t) + out := captureCommandStdout(t, func() { c.handleCostCommand("bogus") }) + if !strings.Contains(out, "/cost [reset") { + t.Errorf("help line missing for unknown subcommand:\n%s", out) + } + if !strings.Contains(out, "gpt-4o") { + t.Errorf("summary not rendered after help:\n%s", out) + } +} + +// TestCostCommandSummaryShowsPerModelSourceTags: a session mixing real and +// estimated models must tag each model line individually. +func TestCostCommandSummaryShowsPerModelSourceTags(t *testing.T) { + pinPlainProfile(t) + c := newLiveCostCLI(t) + c.costTracker.RecordRealUsage("OLLAMA", "llama3.3", models.EstimateFromChars(4000, 400)) + + out := captureCommandStdout(t, func() { c.handleCostCommand("") }) + if !strings.Contains(out, "(API)") { + t.Errorf("per-model API tag missing:\n%s", out) + } +} diff --git a/cli/cost_completer_test.go b/cli/cost_completer_test.go new file mode 100644 index 00000000..8ffcad15 --- /dev/null +++ b/cli/cost_completer_test.go @@ -0,0 +1,52 @@ +/* + * ChatCLI - /cost completer and palette wiring tests + * Copyright (c) 2024 Edilson Freitas + * License: Apache-2.0 + */ +package cli + +import ( + "testing" +) + +// TestCostSuggestionsListSubcommands pins the /cost subcommand surface in +// the inline completer (which is also the palette's source of truth). +func TestCostSuggestionsListSubcommands(t *testing.T) { + cli := &ChatCLI{} + d := docWithCursor("/cost ", len("/cost ")) + suggs := cli.getCostSuggestions(d) + + want := map[string]bool{"reset": false, "last": false, "sessions": false, "export": false} + for _, s := range suggs { + if _, ok := want[s.Text]; ok { + want[s.Text] = true + } + } + for name, seen := range want { + if !seen { + t.Errorf("/cost completer missing subcommand %q (got %+v)", name, suggs) + } + } + + // Prefix filtering works: "/cost re" narrows to reset. + d = docWithCursor("/cost re", len("/cost re")) + suggs = cli.getCostSuggestions(d) + if len(suggs) != 1 || suggs[0].Text != "reset" { + t.Errorf("prefix filter: got %+v, want only reset", suggs) + } + + // With a complete subcommand typed there is nothing more to offer. + d = docWithCursor("/cost reset ", len("/cost reset ")) + if suggs = cli.getCostSuggestions(d); len(suggs) != 0 { + t.Errorf("no suggestions expected after a subcommand, got %+v", suggs) + } +} + +// TestCostStaysDirectRunInPalette: bare /cost must execute the summary, not +// be hijacked by the per-command palette overlay now that it has completable +// subcommands. +func TestCostStaysDirectRunInPalette(t *testing.T) { + if !paletteDirectRun["/cost"] { + t.Fatal("/cost missing from paletteDirectRun — a bare /cost would open the palette instead of the summary") + } +} diff --git a/cli/cost_tracker.go b/cli/cost_tracker.go index d026ceec..aff7ea98 100644 --- a/cli/cost_tracker.go +++ b/cli/cost_tracker.go @@ -10,11 +10,14 @@ import ( "fmt" "os" "path/filepath" + "sort" "strconv" "strings" "sync" + "sync/atomic" "time" + "github.com/diillson/chatcli/i18n" "github.com/diillson/chatcli/models" ) @@ -30,6 +33,15 @@ const ( BudgetExceeded ) +// costSnapshotRetention is how long persisted cost snapshots are kept before +// being pruned on save — aligned with the session TTL default (90 days). +const costSnapshotRetention = 90 * 24 * time.Hour + +// costSaveThrottle bounds how often the write-through snapshot hits disk. +// Recording is per turn; the file is tiny, but there is no reason to fsync +// faster than a human can read /cost. +const costSaveThrottle = 2 * time.Second + // ModelUsageRecord tracks cumulative token usage and cost for a single model. type ModelUsageRecord struct { Provider string `json:"provider"` @@ -40,14 +52,37 @@ type ModelUsageRecord struct { CompletionTokens int64 `json:"completion_tokens"` TotalTokens int64 `json:"total_tokens"` - // Anthropic cache tokens + // Prompt-cache tokens. Anthropic reports them ALONGSIDE input_tokens + // (additive); OpenAI/Gemini report cache reads as a SUBSET of the + // prompt count — recomputeCost handles both semantics. CacheCreationTokens int64 `json:"cache_creation_tokens,omitempty"` CacheReadTokens int64 `json:"cache_read_tokens,omitempty"` + // Reasoning tokens (o-series / GPT-5 / Gemini thinking). Informational: + // already billed inside CompletionTokens. + ReasoningTokens int64 `json:"reasoning_tokens,omitempty"` + // Tracking Requests int `json:"requests"` HasRealData bool `json:"has_real_data"` // true if at least one call returned API usage + // PricingKnown is false when the model matched no pricing table entry — + // the computed cost is then zero NOT because the model is free but + // because ChatCLI does not know its price. /cost surfaces the difference. + PricingKnown bool `json:"pricing_known"` + + // ProviderCostUSD accumulates the actually-billed cost reported by the + // provider itself (OpenRouter usage.cost) — authoritative for the calls + // that carried it. The Billed* pools remember WHICH tokens those calls + // covered, so a mixed key (some calls report usage.cost, some do not) + // prices only the uncovered remainder from the tables instead of + // discarding it: TotalCostUSD = table(unbilled tokens) + ProviderCostUSD. + ProviderCostUSD float64 `json:"provider_cost_usd,omitempty"` + BilledPromptTokens int64 `json:"billed_prompt_tokens,omitempty"` + BilledCompletionTokens int64 `json:"billed_completion_tokens,omitempty"` + BilledCacheReadTokens int64 `json:"billed_cache_read_tokens,omitempty"` + BilledCacheCreationTokens int64 `json:"billed_cache_creation_tokens,omitempty"` + // Computed cost (in USD) InputCostUSD float64 `json:"input_cost_usd"` OutputCostUSD float64 `json:"output_cost_usd"` @@ -58,20 +93,23 @@ type ModelUsageRecord struct { // SessionCostData is the serializable snapshot of a cost tracking session. type SessionCostData struct { SessionID string `json:"session_id"` + SessionName string `json:"session_name,omitempty"` StartTime time.Time `json:"start_time"` LastUpdate time.Time `json:"last_update"` ModelUsage map[string]*ModelUsageRecord `json:"model_usage"` // key: "provider:model" TotalCostUSD float64 `json:"total_cost_usd"` TotalRequests int `json:"total_requests"` + TotalTokens int64 `json:"total_tokens,omitempty"` } // CostTracker tracks token usage and estimated cost for the current session, // with per-model granularity, real API usage data support, cache token pricing, -// session persistence, and configurable budget enforcement. +// write-through session persistence, and configurable budget enforcement. type CostTracker struct { mu sync.RWMutex sessionID string + sessionName string sessionStart time.Time lastUpdate time.Time @@ -83,12 +121,21 @@ type CostTracker struct { totalCompletionTokens int64 totalCacheCreation int64 totalCacheRead int64 + totalReasoning int64 totalRequests int totalCostUSD float64 // Budget enforcement budgetLimitUSD float64 // 0 = no limit budgetWarningPct float64 // fraction (0.8 = 80%) + budgetHardStop bool // refuse new LLM turns once exceeded + + // lastAnnouncedLevel arms the one-shot proactive budget notice: a + // transition is reported once per escalation, not on every turn. + lastAnnouncedLevel BudgetLevel + + // Persistence write-through throttle. + lastSave time.Time // For backward compat display lastProvider string @@ -97,30 +144,72 @@ type CostTracker struct { // NewCostTracker creates a new cost tracker with optional budget limit. func NewCostTracker() *CostTracker { - budgetLimit := 0.0 + ct := &CostTracker{ + sessionID: newCostSessionID(time.Now()), + sessionStart: time.Now(), + lastUpdate: time.Now(), + modelUsage: make(map[string]*ModelUsageRecord), + } + ct.loadBudgetFromEnvLocked() + return ct +} + +// costSessionSeq disambiguates ids minted in the same second by the same +// process (e.g. /cost reset issued twice quickly). +var costSessionSeq atomic.Int64 + +// newCostSessionID builds a human-sortable snapshot id: start timestamp plus +// pid (two CLIs started in the same second must not clobber each other) plus +// a per-process sequence for same-second resets. +func newCostSessionID(t time.Time) string { + id := t.Format("20060102-150405") + "-" + strconv.Itoa(os.Getpid()) + if seq := costSessionSeq.Add(1); seq > 1 { + id += "-" + strconv.FormatInt(seq, 10) + } + return id +} + +// loadBudgetFromEnvLocked (re)reads the budget environment variables. +// Caller must hold ct.mu (or be the constructor). +func (ct *CostTracker) loadBudgetFromEnvLocked() { + ct.budgetLimitUSD = 0 if v := os.Getenv("CHATCLI_SESSION_BUDGET_USD"); v != "" { if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 { - budgetLimit = f + ct.budgetLimitUSD = f } } - warningPct := 0.80 + ct.budgetWarningPct = 0.80 if v := os.Getenv("CHATCLI_BUDGET_WARNING_PCT"); v != "" { if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 { - warningPct = f + ct.budgetWarningPct = f } } - return &CostTracker{ - sessionID: fmt.Sprintf("%d", time.Now().UnixNano()), - sessionStart: time.Now(), - lastUpdate: time.Now(), - modelUsage: make(map[string]*ModelUsageRecord), - budgetLimitUSD: budgetLimit, - budgetWarningPct: warningPct, + ct.budgetHardStop = false + if v := strings.ToLower(strings.TrimSpace(os.Getenv("CHATCLI_BUDGET_HARD_STOP"))); v != "" { + ct.budgetHardStop = v == "1" || v == "true" || v == "on" || v == "yes" } } +// ReloadBudget re-reads the budget environment variables so /reload picks up +// .env changes without restarting the process. +func (ct *CostTracker) ReloadBudget() { + ct.mu.Lock() + defer ct.mu.Unlock() + ct.loadBudgetFromEnvLocked() + // Re-arm the proactive notice against the new limits. + ct.lastAnnouncedLevel = ct.budgetLevelLocked() +} + +// SetSessionName attaches the named-session identity to the persisted +// snapshot so /cost sessions can show which conversation the spend belongs to. +func (ct *CostTracker) SetSessionName(name string) { + ct.mu.Lock() + ct.sessionName = name + ct.mu.Unlock() +} + // modelKey returns the map key for a provider+model pair. func modelKey(provider, model string) string { return strings.ToLower(provider) + ":" + strings.ToLower(model) @@ -133,28 +222,52 @@ func (ct *CostTracker) RecordRealUsage(provider, model string, usage *models.Usa return } ct.mu.Lock() - defer ct.mu.Unlock() key := modelKey(provider, model) rec := ct.getOrCreateRecord(key, provider, model) rec.PromptTokens += int64(usage.PromptTokens) rec.CompletionTokens += int64(usage.CompletionTokens) - rec.TotalTokens += int64(usage.TotalTokens) + totalTokens := usage.TotalTokens + if totalTokens == 0 { + // Providers that omit total_tokens must not zero the record's total. + totalTokens = usage.PromptTokens + usage.CompletionTokens + } + rec.TotalTokens += int64(totalTokens) rec.CacheCreationTokens += int64(usage.CacheCreationInputTokens) rec.CacheReadTokens += int64(usage.CacheReadInputTokens) + rec.ReasoningTokens += int64(usage.ReasoningTokens) + if usage.CostUSD > 0 { + // This call's tokens are covered by the provider-billed amount — + // remember them so recomputeCost prices only the uncovered rest. + rec.ProviderCostUSD += usage.CostUSD + rec.BilledPromptTokens += int64(usage.PromptTokens) + rec.BilledCompletionTokens += int64(usage.CompletionTokens) + rec.BilledCacheReadTokens += int64(usage.CacheReadInputTokens) + rec.BilledCacheCreationTokens += int64(usage.CacheCreationInputTokens) + } rec.Requests++ if usage.IsReal { rec.HasRealData = true } // Compute cost for this increment - ct.recomputeCost(rec) + recomputeRecordCost(rec) ct.recomputeAggregates() ct.lastProvider = provider ct.lastModel = model ct.lastUpdate = time.Now() + + shouldSave := time.Since(ct.lastSave) >= costSaveThrottle + if shouldSave { + ct.lastSave = time.Now() + } + ct.mu.Unlock() + + if shouldSave { + _ = ct.SaveSession() + } } // RecordUsage records tokens used for a single LLM request (legacy path). @@ -180,21 +293,65 @@ func (ct *CostTracker) RecordFromHistory(provider, model string, history []inter ct.lastModel = model } +// Reset closes the current accounting period and starts a fresh one. The +// closing period is persisted first so /cost last and /cost sessions can +// still see it — resetting never discards data. +func (ct *CostTracker) Reset() { + _ = ct.SaveSession() + + ct.mu.Lock() + defer ct.mu.Unlock() + now := time.Now() + ct.sessionID = newCostSessionID(now) + ct.sessionStart = now + ct.lastUpdate = now + ct.modelUsage = make(map[string]*ModelUsageRecord) + ct.recomputeAggregates() + ct.lastAnnouncedLevel = BudgetOK + ct.lastSave = time.Time{} +} + // CheckBudget returns the current budget level. func (ct *CostTracker) CheckBudget() BudgetLevel { ct.mu.RLock() defer ct.mu.RUnlock() + return ct.budgetLevelLocked() +} - if ct.budgetLimitUSD <= 0 { - return BudgetOK - } - if ct.totalCostUSD >= ct.budgetLimitUSD { - return BudgetExceeded +// BudgetBlocked reports whether new LLM turns must be refused: a budget is +// configured, CHATCLI_BUDGET_HARD_STOP is on, and the limit is exhausted. +func (ct *CostTracker) BudgetBlocked() bool { + ct.mu.RLock() + defer ct.mu.RUnlock() + return ct.budgetHardStop && ct.budgetLimitUSD > 0 && ct.totalCostUSD >= ct.budgetLimitUSD +} + +// BudgetHardStopEnabled reports whether the hard-stop gate is armed. +func (ct *CostTracker) BudgetHardStopEnabled() bool { + ct.mu.RLock() + defer ct.mu.RUnlock() + return ct.budgetHardStop +} + +// TakeBudgetTransition returns a one-shot notice when the budget level has +// escalated since the last check (OK→Warning, Warning→Exceeded, …). The +// returned message is already localized; ok is false when there is nothing +// new to announce. De-escalations (after /cost reset or a raised limit) +// re-arm the notice silently. +func (ct *CostTracker) TakeBudgetTransition() (BudgetLevel, string, bool) { + ct.mu.Lock() + defer ct.mu.Unlock() + + level := ct.budgetLevelLocked() + if level == ct.lastAnnouncedLevel { + return level, "", false } - if ct.totalCostUSD >= ct.budgetLimitUSD*ct.budgetWarningPct { - return BudgetWarning + escalated := level > ct.lastAnnouncedLevel + ct.lastAnnouncedLevel = level + if !escalated { + return level, "", false } - return BudgetOK + return level, ct.budgetMessageLocked(), true } // BudgetMessage returns a human-readable budget status message. @@ -202,19 +359,7 @@ func (ct *CostTracker) CheckBudget() BudgetLevel { func (ct *CostTracker) BudgetMessage() string { ct.mu.RLock() defer ct.mu.RUnlock() - - if ct.budgetLimitUSD <= 0 { - return "" - } - - pct := ct.totalCostUSD / ct.budgetLimitUSD * 100 - if ct.totalCostUSD >= ct.budgetLimitUSD { - return fmt.Sprintf("BUDGET EXCEEDED: $%.4f / $%.2f (%.0f%%)", ct.totalCostUSD, ct.budgetLimitUSD, pct) - } - if ct.totalCostUSD >= ct.budgetLimitUSD*ct.budgetWarningPct { - return fmt.Sprintf("Budget warning: $%.4f / $%.2f (%.0f%%)", ct.totalCostUSD, ct.budgetLimitUSD, pct) - } - return "" + return ct.budgetMessageLocked() } // TotalCost returns the total estimated cost in USD for the session. @@ -231,6 +376,32 @@ func (ct *CostTracker) TotalTokens() int64 { return ct.totalPromptTokens + ct.totalCompletionTokens } +// Snapshot returns a copy of the current session cost data — the same shape +// that is persisted to disk, safe for the caller to serialize or render. +func (ct *CostTracker) Snapshot() SessionCostData { + ct.mu.RLock() + defer ct.mu.RUnlock() + return ct.snapshotLocked() +} + +func (ct *CostTracker) snapshotLocked() SessionCostData { + usage := make(map[string]*ModelUsageRecord, len(ct.modelUsage)) + for k, rec := range ct.modelUsage { + cp := *rec + usage[k] = &cp + } + return SessionCostData{ + SessionID: ct.sessionID, + SessionName: ct.sessionName, + StartTime: ct.sessionStart, + LastUpdate: ct.lastUpdate, + ModelUsage: usage, + TotalCostUSD: ct.totalCostUSD, + TotalRequests: ct.totalRequests, + TotalTokens: ct.totalPromptTokens + ct.totalCompletionTokens, + } +} + // GetSummary returns a formatted cost summary string. func (ct *CostTracker) GetSummary(provider, model string, history int) string { ct.mu.RLock() @@ -299,50 +470,73 @@ func (ct *CostTracker) GetSummary(provider, model string, history int) string { return sb.String() } -// SaveSession persists the current cost data to disk for cross-session tracking. +// --- Persistence --- + +// SaveSession persists the current cost data to disk for cross-session +// tracking (write-through from RecordRealUsage, plus explicit calls on +// reset/shutdown). Snapshots older than the retention window are pruned. func (ct *CostTracker) SaveSession() error { ct.mu.RLock() - data := SessionCostData{ - SessionID: ct.sessionID, - StartTime: ct.sessionStart, - LastUpdate: ct.lastUpdate, - ModelUsage: ct.modelUsage, - TotalCostUSD: ct.totalCostUSD, - TotalRequests: ct.totalRequests, - } + data := ct.snapshotLocked() ct.mu.RUnlock() - dir := costSessionDir() + if data.TotalRequests == 0 { + return nil // nothing worth persisting + } + + dir := costStoreDir() if err := os.MkdirAll(dir, 0o700); err != nil { - return fmt.Errorf("create session dir: %w", err) + return fmt.Errorf("create cost store dir: %w", err) } b, err := json.MarshalIndent(data, "", " ") if err != nil { - return fmt.Errorf("marshal session: %w", err) + return fmt.Errorf("marshal cost session: %w", err) + } + + // Atomic write with a UNIQUE temp name: concurrent saves (worker + // recorder goroutines race the main turn) must never interleave writes + // into one shared temp file, and a crash mid-write must never corrupt + // the snapshot. + path := filepath.Join(dir, data.SessionID+".json") + tmp, err := os.CreateTemp(dir, data.SessionID+"-*.tmp") + if err != nil { + return err + } + if _, err := tmp.Write(b); err != nil { + _ = tmp.Close() + _ = os.Remove(tmp.Name()) + return err + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmp.Name()) + return err + } + if err := os.Chmod(tmp.Name(), 0o600); err != nil { + _ = os.Remove(tmp.Name()) + return err + } + if err := os.Rename(tmp.Name(), path); err != nil { + _ = os.Remove(tmp.Name()) + return err } - path := filepath.Join(dir, ct.sessionID+".json") - return os.WriteFile(path, b, 0o600) + pruneCostSnapshots(dir) + return nil } -// RestoreSession loads a previous session's cost data. +// RestoreSession loads a previous session's cost data into the tracker. func (ct *CostTracker) RestoreSession(sessionID string) error { - path := filepath.Join(costSessionDir(), sessionID+".json") - b, err := os.ReadFile(filepath.Clean(path)) + data, err := LoadCostSnapshot(sessionID) if err != nil { return err } - var data SessionCostData - if err := json.Unmarshal(b, &data); err != nil { - return fmt.Errorf("unmarshal session: %w", err) - } - ct.mu.Lock() defer ct.mu.Unlock() ct.sessionID = data.SessionID + ct.sessionName = data.SessionName ct.sessionStart = data.StartTime ct.lastUpdate = data.LastUpdate ct.modelUsage = data.ModelUsage @@ -353,6 +547,89 @@ func (ct *CostTracker) RestoreSession(sessionID string) error { return nil } +// LoadCostSnapshot reads one persisted snapshot by session id. +func LoadCostSnapshot(sessionID string) (*SessionCostData, error) { + path := filepath.Join(costStoreDir(), filepath.Base(sessionID)+".json") + b, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return nil, err + } + var data SessionCostData + if err := json.Unmarshal(b, &data); err != nil { + return nil, fmt.Errorf("unmarshal cost session: %w", err) + } + return &data, nil +} + +// ListCostSnapshots returns persisted snapshots, most recent first, capped +// at limit (0 = no cap). The current process's snapshot is included when it +// has been written. +func ListCostSnapshots(limit int) ([]*SessionCostData, error) { + dir := costStoreDir() + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + out := make([]*SessionCostData, 0, len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + data, err := LoadCostSnapshot(strings.TrimSuffix(e.Name(), ".json")) + if err != nil { + continue // unreadable snapshot: skip, never break the listing + } + out = append(out, data) + } + sort.Slice(out, func(i, j int) bool { return out[i].LastUpdate.After(out[j].LastUpdate) }) + if limit > 0 && len(out) > limit { + out = out[:limit] + } + return out, nil +} + +// CurrentSessionID returns the id under which this session's snapshot is +// persisted. +func (ct *CostTracker) CurrentSessionID() string { + ct.mu.RLock() + defer ct.mu.RUnlock() + return ct.sessionID +} + +// pruneCostSnapshots removes snapshots older than the retention window. +// Best-effort: pruning must never fail a save. +func pruneCostSnapshots(dir string) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + cutoff := time.Now().Add(-costSnapshotRetention) + // Stray .tmp files (a crash between write and rename) age out on a much + // shorter fuse — they are garbage the moment their writer is gone. + tmpCutoff := time.Now().Add(-time.Hour) + for _, e := range entries { + if e.IsDir() { + continue + } + isJSON := strings.HasSuffix(e.Name(), ".json") + isTmp := strings.HasSuffix(e.Name(), ".tmp") + if !isJSON && !isTmp { + continue + } + info, err := e.Info() + if err != nil { + continue + } + if (isJSON && info.ModTime().Before(cutoff)) || (isTmp && info.ModTime().Before(tmpCutoff)) { + _ = os.Remove(filepath.Join(dir, e.Name())) + } + } +} + // --- Internal helpers --- func (ct *CostTracker) getOrCreateRecord(key, provider, model string) *ModelUsageRecord { @@ -367,15 +644,49 @@ func (ct *CostTracker) getOrCreateRecord(key, provider, model string) *ModelUsag return rec } -func (ct *CostTracker) recomputeCost(rec *ModelUsageRecord) { - inputCost, outputCost := getModelPricing(rec.Provider, rec.Model) +// recomputeRecordCost prices one record in place — the ONLY cost formula +// in the tracker; estimateTurnCostUSD delegates here so per-turn and +// per-session math can never disagree. +func recomputeRecordCost(rec *ModelUsageRecord) { + inputCost, outputCost, known := lookupModelPricing(rec.Provider, rec.Model) + rec.PricingKnown = known cacheWriteCost, cacheReadCost := getCachePricing(rec.Provider, rec.Model) - rec.InputCostUSD = float64(rec.PromptTokens) / 1_000_000 * inputCost - rec.OutputCostUSD = float64(rec.CompletionTokens) / 1_000_000 * outputCost - rec.CacheCostUSD = float64(rec.CacheCreationTokens)/1_000_000*cacheWriteCost + - float64(rec.CacheReadTokens)/1_000_000*cacheReadCost - rec.TotalCostUSD = rec.InputCostUSD + rec.OutputCostUSD + rec.CacheCostUSD + // Table math prices only the tokens NOT covered by a provider-billed + // amount (usage.cost): billed calls' tokens live in the Billed* pools + // and their cost is ProviderCostUSD verbatim. A mixed key (some calls + // report cost, some don't) therefore adds both parts instead of letting + // one clobber the other. + unbilled := func(total, billed int64) int64 { + if d := total - billed; d > 0 { + return d + } + return 0 + } + promptTokens := unbilled(rec.PromptTokens, rec.BilledPromptTokens) + completionTokens := unbilled(rec.CompletionTokens, rec.BilledCompletionTokens) + cacheRead := unbilled(rec.CacheReadTokens, rec.BilledCacheReadTokens) + cacheCreation := unbilled(rec.CacheCreationTokens, rec.BilledCacheCreationTokens) + + billableInput := promptTokens + if !cacheTokensAdditive(rec.Provider, rec.Model) && cacheRead > 0 && cacheReadCost > 0 { + // OpenAI/Gemini-style usage reports cached tokens as a SUBSET of the + // prompt count — carve them out so they are billed once, at the + // discounted cache-read rate, instead of twice. Only when a discount + // rate exists: for families without a published cache rate the + // carve-out would make cached tokens FREE, so they stay billed at + // the plain input price instead (conservative). + billableInput -= cacheRead + if billableInput < 0 { + billableInput = 0 + } + } + + rec.InputCostUSD = float64(billableInput) / 1_000_000 * inputCost + rec.OutputCostUSD = float64(completionTokens) / 1_000_000 * outputCost + rec.CacheCostUSD = float64(cacheCreation)/1_000_000*cacheWriteCost + + float64(cacheRead)/1_000_000*cacheReadCost + rec.TotalCostUSD = rec.InputCostUSD + rec.OutputCostUSD + rec.CacheCostUSD + rec.ProviderCostUSD } func (ct *CostTracker) recomputeAggregates() { @@ -383,6 +694,7 @@ func (ct *CostTracker) recomputeAggregates() { ct.totalCompletionTokens = 0 ct.totalCacheCreation = 0 ct.totalCacheRead = 0 + ct.totalReasoning = 0 ct.totalRequests = 0 ct.totalCostUSD = 0 @@ -391,28 +703,46 @@ func (ct *CostTracker) recomputeAggregates() { ct.totalCompletionTokens += rec.CompletionTokens ct.totalCacheCreation += rec.CacheCreationTokens ct.totalCacheRead += rec.CacheReadTokens + ct.totalReasoning += rec.ReasoningTokens ct.totalRequests += rec.Requests ct.totalCostUSD += rec.TotalCostUSD } } -func (ct *CostTracker) budgetMessageLocked() string { +func (ct *CostTracker) budgetLevelLocked() BudgetLevel { if ct.budgetLimitUSD <= 0 { - return "" + return BudgetOK } - pct := ct.totalCostUSD / ct.budgetLimitUSD * 100 if ct.totalCostUSD >= ct.budgetLimitUSD { - return fmt.Sprintf("BUDGET EXCEEDED: $%.4f / $%.2f (%.0f%%)", ct.totalCostUSD, ct.budgetLimitUSD, pct) + return BudgetExceeded } if ct.totalCostUSD >= ct.budgetLimitUSD*ct.budgetWarningPct { - return fmt.Sprintf("Budget warning: $%.4f / $%.2f (%.0f%%)", ct.totalCostUSD, ct.budgetLimitUSD, pct) + return BudgetWarning } - return "" + return BudgetOK } -func costSessionDir() string { +func (ct *CostTracker) budgetMessageLocked() string { + if ct.budgetLimitUSD <= 0 { + return "" + } + pct := ct.totalCostUSD / ct.budgetLimitUSD * 100 + switch ct.budgetLevelLocked() { + case BudgetExceeded: + if ct.budgetHardStop { + return i18n.T("cost.budget.exceeded_hard", ct.totalCostUSD, ct.budgetLimitUSD, pct) + } + return i18n.T("cost.budget.exceeded", ct.totalCostUSD, ct.budgetLimitUSD, pct) + case BudgetWarning: + return i18n.T("cost.budget.warning", ct.totalCostUSD, ct.budgetLimitUSD, pct) + default: + return "" + } +} + +func costStoreDir() string { home, _ := os.UserHomeDir() - return filepath.Join(home, ".chatcli", "sessions") + return filepath.Join(home, ".chatcli", "costs") } func formatTokenCount64(tokens int64) string { @@ -425,6 +755,32 @@ func formatTokenCount64(tokens int64) string { return fmt.Sprintf("%d", tokens) } +// estimateTurnCostUSD prices a single turn's usage with the same rules the +// session tracker applies (cache semantics included), so the chat envelope +// footer and /cost never disagree about the same turn. A provider-reported +// cost wins outright. +func estimateTurnCostUSD(provider, model string, usage *models.UsageInfo) float64 { + if usage == nil { + return 0 + } + if usage.CostUSD > 0 { + return usage.CostUSD + } + // Single source of truth: run the turn through the exact record math the + // session tracker applies — a second hand-rolled formula here is how the + // footer and /cost drift apart. + rec := &ModelUsageRecord{ + Provider: provider, + Model: model, + PromptTokens: int64(usage.PromptTokens), + CompletionTokens: int64(usage.CompletionTokens), + CacheReadTokens: int64(usage.CacheReadInputTokens), + CacheCreationTokens: int64(usage.CacheCreationInputTokens), + } + recomputeRecordCost(rec) + return rec.TotalCostUSD +} + // --- Pricing tables --- // getModelPricing returns input and output cost per 1M tokens for known models. @@ -436,6 +792,15 @@ func formatTokenCount64(tokens int64) string { // authoritative for cloud providers), then provider-string fallbacks for // wrappers and self-hosted backends. func getModelPricing(provider, model string) (inputCost, outputCost float64) { + in, out, _ := lookupModelPricing(provider, model) + return in, out +} + +// lookupModelPricing is getModelPricing plus a known flag: known=false means +// the model matched NO table entry — cost zero because the price is unknown, +// not because the backend is unmetered. Ollama/StackSpot/Devin return +// known=true with zero prices (deliberately free from ChatCLI's viewpoint). +func lookupModelPricing(provider, model string) (inputCost, outputCost float64, known bool) { model = strings.ToLower(model) provider = strings.ToLower(provider) @@ -444,7 +809,7 @@ func getModelPricing(provider, model string) (inputCost, outputCost float64) { // tokens e o custo é da assinatura Cognition — sem o curto-circuito, // claudePricing/openAIPricing cobrariam como se fosse API direta. if strings.Contains(provider, "devin") { - return 0, 0 + return 0, 0, true } for _, fn := range []func(string) (float64, float64, bool){ @@ -456,7 +821,7 @@ func getModelPricing(provider, model string) (inputCost, outputCost float64) { zaiPricing, } { if in, out, ok := fn(model); ok { - return in, out + return in, out, true } } return providerFallbackPricing(provider, model) @@ -622,6 +987,7 @@ func zaiPricing(model string) (float64, float64, bool) { // deepseekPricing — geração V4 (api-docs.deepseek.com, Aug 2026). A // DeepSeek cobra peak/off-peak (off-peak = metade); cost_tracker usa o // preço de pico para não sub-reportar. V4 específicos antes dos legados. +// "deepseek-reasoner" é o alias de API do R1 — mesma tarifa. func deepseekPricing(model string) (float64, float64, bool) { switch { case strings.Contains(model, "deepseek-v4-pro"): @@ -629,7 +995,7 @@ func deepseekPricing(model string) (float64, float64, bool) { case strings.Contains(model, "deepseek-v4"): // deepseek-v4-flash e futuros ids v4 sem tier próprio. return 0.44, 1.32, true - case strings.Contains(model, "deepseek-r1"): + case strings.Contains(model, "deepseek-r1"), strings.Contains(model, "deepseek-reasoner"): return 0.55, 2.19, true case strings.Contains(model, "deepseek"): return 0.27, 1.10, true @@ -639,7 +1005,10 @@ func deepseekPricing(model string) (float64, float64, bool) { // providerFallbackPricing handles families whose model IDs are ambiguous // or where the provider name is the most reliable signal (proprietary -// wrappers like Copilot, local backends like Ollama). +// wrappers like Copilot, local backends like Ollama). The known flag is +// true for every explicit case — including the deliberately-zero backends +// (Ollama/StackSpot/Devin, unmetered from ChatCLI's viewpoint) — and false +// only on the final fallthrough, where the model is genuinely unpriced. // // Moonshot (Kimi) — kimi-k3 public list price as of 2026-07 is $3.00/M // input (cache miss; cache hit is $0.30/M) and $15.00/M output — priced @@ -650,68 +1019,106 @@ func deepseekPricing(model string) (float64, float64, bool) { // single tier — we charge the miss price so accounting stays // conservative. K2.5 and moonshot-v1-* sit below K2.6; we approximate // with K2.6 numbers to avoid under-reporting. -func providerFallbackPricing(provider, model string) (float64, float64) { +func providerFallbackPricing(provider, model string) (float64, float64, bool) { switch { case strings.Contains(model, "minimax"), strings.Contains(provider, "minimax"): - return 0.20, 1.10 + return 0.20, 1.10, true case strings.Contains(provider, "zai"), strings.Contains(model, "glm"): - return 0.50, 0.50 + return 0.50, 0.50, true case strings.HasPrefix(model, "kimi-k3"): - return 3.00, 15.00 + return 3.00, 15.00, true case strings.HasPrefix(model, "kimi-k2.7-code-highspeed"): // K2.7 Code highspeed (platform.kimi.ai pricing, Aug 2026): 2× o // tier padrão K2.x — specific beats generic, senão o match "kimi" // abaixo cobraria a metade. - return 1.90, 8.00 + return 1.90, 8.00, true case strings.Contains(provider, "moonshot"), strings.HasPrefix(model, "kimi"), strings.HasPrefix(model, "moonshot"): // Cobre também kimi-k2.7-code ($0.95/$4.00 — mesmo tier do K2.6). - return 0.95, 4.00 + return 0.95, 4.00, true case strings.Contains(provider, "copilot"): - return 2.50, 10.0 + return 2.50, 10.0, true case strings.Contains(provider, "openrouter"): return getOpenRouterModelPricing(model) case strings.Contains(provider, "ollama"), strings.Contains(provider, "stackspot"), strings.Contains(provider, "devin"): // Devin CLI: o binário não reporta tokens e o custo é da assinatura // Cognition — zero aqui, como Ollama/StackSpot. - return 0, 0 + return 0, 0, true } - return 0, 0 + return 0, 0, false +} + +// cacheTokensAdditive reports whether the usage payload counts cache tokens +// ALONGSIDE the prompt count (Anthropic Messages schema: input_tokens +// excludes cache reads/writes) rather than as a subset of it (OpenAI +// cached_tokens, Gemini cachedContentTokenCount). The semantics belong to +// the REPORTING SCHEMA, not the model name: a Claude model served through +// an OpenAI-compatible gateway (OpenRouter) reports subset-style +// cached_tokens, so the provider decides when it implies the schema. +func cacheTokensAdditive(provider, model string) bool { + if strings.Contains(strings.ToLower(provider), "openrouter") { + return false // OpenAI-compatible schema regardless of the model + } + return strings.Contains(strings.ToLower(model), "claude") } // getCachePricing returns cache write and cache read cost per 1M tokens. -// Currently only Anthropic supports prompt caching with distinct pricing. +// Rates follow each provider's published discount over the model's input +// price; families without a distinct published cache rate return zero (their +// cache reads are then billed at the plain input price by recomputeCost's +// subset carve-out being a no-op). func getCachePricing(provider, model string) (cacheWriteCost, cacheReadCost float64) { model = strings.ToLower(model) - - if !strings.Contains(model, "claude") { + inputCost, _, known := lookupModelPricing(provider, model) + if !known || inputCost <= 0 { return 0, 0 } - // Anthropic cache pricing: write = 1.25x input, read = 0.1x input - inputCost, _ := getModelPricing(provider, model) - return inputCost * 1.25, inputCost * 0.10 + switch { + case strings.Contains(model, "claude"): + // Anthropic: write = 1.25x input, read = 0.1x input. + return inputCost * 1.25, inputCost * 0.10 + case strings.Contains(model, "gemini"): + // Google implicit/context caching: reads at 25% of input. + return 0, inputCost * 0.25 + case strings.Contains(model, "gpt"), strings.Contains(model, "o1"), + strings.Contains(model, "o3"), strings.Contains(model, "o4"): + // OpenAI automatic prompt caching: hits at 50% of input, no write + // surcharge (platform.openai.com/docs/pricing). + return 0, inputCost * 0.50 + case strings.Contains(model, "deepseek"): + // DeepSeek cache hit ≈ 25% of the miss price. + return 0, inputCost * 0.25 + case strings.Contains(model, "kimi"), strings.Contains(model, "moonshot"): + // Moonshot cache hit ($0.16/M vs $0.95/M miss) ≈ 17% of input. + return 0, inputCost * 0.17 + } + return 0, 0 } // getOpenRouterModelPricing returns pricing for models accessed via OpenRouter. -func getOpenRouterModelPricing(model string) (inputCost, outputCost float64) { - // OpenRouter passes through pricing from upstream providers. - // Try to match the underlying model. +// Table-derived estimates only — when the OpenRouter response carries +// usage.cost, that actual billed amount overrides these numbers entirely. +func getOpenRouterModelPricing(model string) (inputCost, outputCost float64, known bool) { + // OpenRouter passes through pricing from upstream providers. The known + // flag PROPAGATES from the family lookup: a slug that matches a family + // substring but no actual pricing entry stays "unpriced" so /cost lists + // it instead of silently reporting it as free. switch { case strings.Contains(model, "claude"): - return getModelPricing("anthropic", model) + return lookupModelPricing("anthropic", model) case strings.Contains(model, "gpt"): - return getModelPricing("openai", model) + return lookupModelPricing("openai", model) case strings.Contains(model, "gemini"): - return getModelPricing("google", model) + return lookupModelPricing("google", model) case strings.Contains(model, "deepseek"): - return getModelPricing("deepseek", model) + return lookupModelPricing("deepseek", model) case strings.Contains(model, "llama"): - return 0.20, 0.20 + return 0.20, 0.20, true case strings.Contains(model, "mistral"): - return 0.20, 0.60 + return 0.20, 0.60, true case strings.Contains(model, "qwen"): - return 0.15, 0.15 + return 0.15, 0.15, true } - return 0, 0 + return 0, 0, false } diff --git a/cli/cost_tracker_overhaul_test.go b/cli/cost_tracker_overhaul_test.go new file mode 100644 index 00000000..6e226b61 --- /dev/null +++ b/cli/cost_tracker_overhaul_test.go @@ -0,0 +1,368 @@ +/* + * ChatCLI - Command Line Interface for LLM interaction + * Copyright (c) 2024 Edilson Freitas + * License: Apache-2.0 + */ +package cli + +import ( + "testing" + + "github.com/diillson/chatcli/models" +) + +// TestLookupModelPricingKnownFlag pins the three pricing outcomes apart: +// table-priced, deliberately unmetered (known, zero) and genuinely unknown. +func TestLookupModelPricingKnownFlag(t *testing.T) { + cases := []struct { + provider, model string + wantKnown bool + wantIn float64 + }{ + {"CLAUDEAI", "claude-sonnet-5", true, 3.0}, + {"OPENAI", "gpt-4o", true, 2.50}, + {"DEEPSEEK", "deepseek-reasoner", true, 0.55}, // API alias of R1 — not the $0.27 generic tier + {"OLLAMA", "llama3.3", true, 0}, // unmetered by design + {"STACKSPOT", "stackspot-ai", true, 0}, + {"DEVIN", "claude-sonnet-5", true, 0}, // Devin short-circuit beats model heuristics + {"UNKNOWN", "no-such-model", false, 0}, + {"OPENROUTER", "some/very-obscure-model", false, 0}, + } + for _, c := range cases { + in, _, known := lookupModelPricing(c.provider, c.model) + if known != c.wantKnown || in != c.wantIn { + t.Errorf("lookupModelPricing(%s,%s) = (in=%v, known=%v), want (in=%v, known=%v)", + c.provider, c.model, in, known, c.wantIn, c.wantKnown) + } + } +} + +// TestGetCachePricingFamilies pins the per-family cache discounts. +func TestGetCachePricingFamilies(t *testing.T) { + cases := []struct { + provider, model string + wantWrite, wantRead float64 + }{ + {"CLAUDEAI", "claude-sonnet-5", 3.0 * 1.25, 3.0 * 0.10}, + {"OPENAI", "gpt-4o", 0, 2.50 * 0.50}, + {"GOOGLEAI", "gemini-2.5-pro", 0, 1.25 * 0.25}, + {"DEEPSEEK", "deepseek-chat", 0, 0.27 * 0.25}, + {"XAI", "grok-4.6", 0, 0}, // no published cache rate → no discount + {"UNKNOWN", "no-such-model", 0, 0}, + } + for _, c := range cases { + w, r := getCachePricing(c.provider, c.model) + if !almostEqual(w, c.wantWrite) || !almostEqual(r, c.wantRead) { + t.Errorf("getCachePricing(%s,%s) = (%v,%v), want (%v,%v)", + c.provider, c.model, w, r, c.wantWrite, c.wantRead) + } + } +} + +func almostEqual(a, b float64) bool { + d := a - b + return d < 1e-9 && d > -1e-9 +} + +// TestRecomputeCostCacheSubsetSemantics: OpenAI reports cached tokens as a +// SUBSET of prompt_tokens — they must be billed once at the cache rate, not +// once full-price plus once discounted. +func TestRecomputeCostCacheSubsetSemantics(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + ct := NewCostTracker() + ct.RecordRealUsage("OPENAI", "gpt-4o", &models.UsageInfo{ + PromptTokens: 1_000_000, // includes the cached slice below + CompletionTokens: 0, + CacheReadInputTokens: 400_000, + IsReal: true, + }) + rec := ct.modelUsage[modelKey("OPENAI", "gpt-4o")] + // 600K at $2.50 + 400K at $1.25 = 1.50 + 0.50 = 2.00 + if !almostEqual(rec.TotalCostUSD, 2.00) { + t.Fatalf("subset cache cost = %v, want 2.00 (input %v, cache %v)", + rec.TotalCostUSD, rec.InputCostUSD, rec.CacheCostUSD) + } + + // Anthropic reports cache tokens ALONGSIDE input_tokens — additive. + ct2 := NewCostTracker() + ct2.RecordRealUsage("CLAUDEAI", "claude-sonnet-5", &models.UsageInfo{ + PromptTokens: 1_000_000, + CacheReadInputTokens: 400_000, + IsReal: true, + }) + rec2 := ct2.modelUsage[modelKey("CLAUDEAI", "claude-sonnet-5")] + // 1M at $3.00 + 400K at $0.30 = 3.00 + 0.12 + if !almostEqual(rec2.TotalCostUSD, 3.12) { + t.Fatalf("additive cache cost = %v, want 3.12", rec2.TotalCostUSD) + } +} + +// TestProviderCostOverride: a provider-reported billed amount (OpenRouter +// usage.cost) is authoritative over table math. +func TestProviderCostOverride(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + ct := NewCostTracker() + ct.RecordRealUsage("OPENROUTER", "some/very-obscure-model", &models.UsageInfo{ + PromptTokens: 10_000, + CompletionTokens: 2_000, + CostUSD: 0.0421, + IsReal: true, + }) + if !almostEqual(ct.TotalCost(), 0.0421) { + t.Fatalf("provider cost override: total = %v, want 0.0421", ct.TotalCost()) + } + rec := ct.modelUsage[modelKey("OPENROUTER", "some/very-obscure-model")] + if rec.ProviderCostUSD == 0 { + t.Fatal("ProviderCostUSD not accumulated") + } +} + +// TestMixedBilledAndTableCallsAddUp: a key mixing calls that reported +// usage.cost with calls that did not must price the uncovered tokens from +// the tables and ADD both parts — never let the billed amount clobber the +// table share (that under-reported spend and disarmed the budget gate). +func TestMixedBilledAndTableCallsAddUp(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + ct := NewCostTracker() + // Call 1: provider-billed (usage.cost present). + ct.RecordRealUsage("OPENROUTER", "openai/gpt-4o", &models.UsageInfo{ + PromptTokens: 2_000, CompletionTokens: 500, CostUSD: 0.01, IsReal: true, + }) + // Call 2: no usage.cost — table pricing must cover it. + // gpt-4o via openrouter re-dispatch: 1M in at $2.50 + 100K out at $10 = $3.50. + ct.RecordRealUsage("OPENROUTER", "openai/gpt-4o", &models.UsageInfo{ + PromptTokens: 1_000_000, CompletionTokens: 100_000, IsReal: true, + }) + if got, want := ct.TotalCost(), 0.01+3.50; !almostEqual(got, want) { + t.Fatalf("mixed key total = %v, want %v (billed + table)", got, want) + } +} + +// TestZeroRateFamiliesNeverGetFreeCache: for families without a published +// cache discount (getCachePricing returns 0), cached tokens must stay +// billed at the plain input price — the subset carve-out with a zero read +// rate would make them free and under-report spend. +func TestZeroRateFamiliesNeverGetFreeCache(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + ct := NewCostTracker() + // GLM-5.2 ($1.40/M input, no cache rate): 100K prompt incl. 80K cached. + ct.RecordRealUsage("ZAI", "glm-5.2", &models.UsageInfo{ + PromptTokens: 100_000, + CacheReadInputTokens: 80_000, + IsReal: true, + }) + // Full input price on ALL 100K: 0.1 × $1.40 = $0.14. + if got := ct.TotalCost(); !almostEqual(got, 0.14) { + t.Fatalf("zero-rate cache carve-out leaked: total = %v, want 0.14", got) + } +} + +// TestCacheSemanticsFollowReportingSchema: a Claude model served through an +// OpenAI-compatible gateway (OpenRouter) reports cached_tokens as a SUBSET +// of prompt_tokens — the additive Anthropic branch would bill them twice. +func TestCacheSemanticsFollowReportingSchema(t *testing.T) { + if cacheTokensAdditive("OPENROUTER", "anthropic/claude-sonnet-5") { + t.Fatal("openrouter-served claude treated as additive (double-bills cache)") + } + if !cacheTokensAdditive("CLAUDEAI", "claude-sonnet-5") { + t.Fatal("native claude lost additive semantics") + } + if !cacheTokensAdditive("BEDROCK", "anthropic.claude-sonnet-5") { + t.Fatal("bedrock claude lost additive semantics") + } + + t.Setenv("HOME", t.TempDir()) + ct := NewCostTracker() + // 100K prompt (90K cached subset) via openrouter: 10K at $3/M + 90K at + // the claude cache-read rate ($0.30/M) = 0.03 + 0.027. + ct.RecordRealUsage("OPENROUTER", "anthropic/claude-sonnet-5", &models.UsageInfo{ + PromptTokens: 100_000, + CacheReadInputTokens: 90_000, + IsReal: true, + }) + if got := ct.TotalCost(); !almostEqual(got, 0.03+0.027) { + t.Fatalf("openrouter claude cache math = %v, want 0.057", got) + } +} + +// TestOpenRouterUnknownFamilyModelStaysUnpriced: a slug matching a family +// substring but no pricing entry must propagate known=false so /cost lists +// it as unpriced instead of silently free. +func TestOpenRouterUnknownFamilyModelStaysUnpriced(t *testing.T) { + _, _, known := lookupModelPricing("OPENROUTER", "anthropic/claude-nonexistent-99") + if known { + t.Fatal("family-substring match without a pricing entry reported known=true") + } +} + +// TestTurnAndSessionCacheMathAgree: estimateTurnCostUSD delegates to the +// record formula — cache-bearing turns must price identically both ways. +func TestTurnAndSessionCacheMathAgree(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + usage := &models.UsageInfo{ + PromptTokens: 200_000, + CompletionTokens: 30_000, + CacheReadInputTokens: 150_000, + CacheCreationInputTokens: 10_000, + IsReal: true, + } + for _, tc := range []struct{ provider, model string }{ + {"CLAUDEAI", "claude-sonnet-5"}, + {"OPENAI", "gpt-4o"}, + {"ZAI", "glm-5.2"}, + {"OPENROUTER", "anthropic/claude-sonnet-5"}, + } { + ct := NewCostTracker() + ct.RecordRealUsage(tc.provider, tc.model, usage) + if got, want := estimateTurnCostUSD(tc.provider, tc.model, usage), ct.TotalCost(); !almostEqual(got, want) { + t.Errorf("%s/%s: turn cost %v != session cost %v", tc.provider, tc.model, got, want) + } + } +} + +// TestReasoningTokensAccumulate covers the informational reasoning total. +func TestReasoningTokensAccumulate(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + ct := NewCostTracker() + ct.RecordRealUsage("OPENAI", "gpt-5.6", &models.UsageInfo{ + PromptTokens: 10, CompletionTokens: 500, ReasoningTokens: 300, IsReal: true, + }) + if ct.totalReasoning != 300 { + t.Fatalf("totalReasoning = %d, want 300", ct.totalReasoning) + } +} + +// TestResetStartsFreshPeriodAndPersistsOld: reset must never lose data — +// the closing period lands on disk and the live counters restart at zero. +func TestResetStartsFreshPeriodAndPersistsOld(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + ct := NewCostTracker() + ct.RecordRealUsage("OPENAI", "gpt-4o", &models.UsageInfo{PromptTokens: 100, CompletionTokens: 50, IsReal: true}) + oldID := ct.CurrentSessionID() + + ct.Reset() + + if ct.TotalTokens() != 0 || ct.TotalCost() != 0 { + t.Fatalf("reset left counters: tokens=%d cost=%v", ct.TotalTokens(), ct.TotalCost()) + } + if ct.CurrentSessionID() == oldID { + t.Fatal("reset kept the old session id") + } + snap, err := LoadCostSnapshot(oldID) + if err != nil { + t.Fatalf("closing period not persisted: %v", err) + } + if snap.TotalRequests != 1 { + t.Fatalf("persisted snapshot requests = %d, want 1", snap.TotalRequests) + } +} + +// TestListCostSnapshotsOrdersMostRecentFirst also covers the round-trip. +func TestListCostSnapshotsOrdersMostRecentFirst(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + ct := NewCostTracker() + ct.RecordRealUsage("OPENAI", "gpt-4o", &models.UsageInfo{PromptTokens: 100, CompletionTokens: 1, IsReal: true}) + if err := ct.SaveSession(); err != nil { + t.Fatalf("save: %v", err) + } + snaps, err := ListCostSnapshots(10) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(snaps) != 1 || snaps[0].SessionID != ct.CurrentSessionID() { + t.Fatalf("unexpected listing: %+v", snaps) + } + if snaps[0].TotalTokens != 101 { + t.Fatalf("TotalTokens = %d, want 101", snaps[0].TotalTokens) + } +} + +// TestBudgetTransitionsAnnounceOncePerEscalation pins the one-shot notice. +func TestBudgetTransitionsAnnounceOncePerEscalation(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CHATCLI_SESSION_BUDGET_USD", "1.00") + t.Setenv("CHATCLI_BUDGET_WARNING_PCT", "0.5") + ct := NewCostTracker() + + // Below warning: nothing to announce. + ct.RecordRealUsage("OPENAI", "gpt-4o", &models.UsageInfo{PromptTokens: 40_000, IsReal: true}) // $0.10 + if _, _, ok := ct.TakeBudgetTransition(); ok { + t.Fatal("announced below the warning threshold") + } + + // Cross warning: announce exactly once. + ct.RecordRealUsage("OPENAI", "gpt-4o", &models.UsageInfo{PromptTokens: 200_000, IsReal: true}) // +$0.50 → $0.60 + level, msg, ok := ct.TakeBudgetTransition() + if !ok || level != BudgetWarning || msg == "" { + t.Fatalf("warning transition: level=%v ok=%v msg=%q", level, ok, msg) + } + if _, _, ok := ct.TakeBudgetTransition(); ok { + t.Fatal("warning announced twice") + } + + // Cross exceeded: announce again. + ct.RecordRealUsage("OPENAI", "gpt-4o", &models.UsageInfo{PromptTokens: 200_000, IsReal: true}) // +$0.50 → $1.10 + level, _, ok = ct.TakeBudgetTransition() + if !ok || level != BudgetExceeded { + t.Fatalf("exceeded transition: level=%v ok=%v", level, ok) + } +} + +// TestBudgetHardStopGate pins BudgetBlocked and its env reload. +func TestBudgetHardStopGate(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CHATCLI_SESSION_BUDGET_USD", "0.10") + t.Setenv("CHATCLI_BUDGET_HARD_STOP", "true") + ct := NewCostTracker() + + if ct.BudgetBlocked() { + t.Fatal("blocked before any spend") + } + ct.RecordRealUsage("OPENAI", "gpt-4o", &models.UsageInfo{PromptTokens: 100_000, IsReal: true}) // $0.25 + if !ct.BudgetBlocked() { + t.Fatal("not blocked after exceeding the limit with hard stop armed") + } + + // Raising the limit via env + ReloadBudget unblocks without restart. + t.Setenv("CHATCLI_SESSION_BUDGET_USD", "5.00") + ct.ReloadBudget() + if ct.BudgetBlocked() { + t.Fatal("still blocked after the limit was raised on reload") + } +} + +// TestEstimateTurnCostUSDMatchesTracker: footer math and session math must +// agree for the same single turn. +func TestEstimateTurnCostUSDMatchesTracker(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + usage := &models.UsageInfo{ + PromptTokens: 100_000, + CompletionTokens: 20_000, + CacheReadInputTokens: 40_000, + IsReal: true, + } + ct := NewCostTracker() + ct.RecordRealUsage("OPENAI", "gpt-4o", usage) + if got, want := estimateTurnCostUSD("OPENAI", "gpt-4o", usage), ct.TotalCost(); !almostEqual(got, want) { + t.Fatalf("turn cost %v != session cost %v for the same turn", got, want) + } + + // Provider-billed cost wins outright. + if got := estimateTurnCostUSD("OPENROUTER", "x/y", &models.UsageInfo{CostUSD: 0.5, PromptTokens: 1}); !almostEqual(got, 0.5) { + t.Fatalf("provider cost not authoritative in turn estimate: %v", got) + } +} + +// TestUnpricedModelsSurfaceInsteadOfHiding: a model with tokens but no +// pricing entry must be listed, not silently absent from the total. +func TestUnpricedModelsSurfaceInsteadOfHiding(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + ct := NewCostTracker() + ct.RecordRealUsage("SOMEPROVIDER", "mystery-model", &models.UsageInfo{PromptTokens: 1000, IsReal: true}) + ct.mu.RLock() + defer ct.mu.RUnlock() + unpriced := unpricedModelsLocked(ct) + if len(unpriced) != 1 || unpriced[0] != "SOMEPROVIDER/mystery-model" { + t.Fatalf("unpriced listing = %v", unpriced) + } +} diff --git a/cli/moa_turn.go b/cli/moa_turn.go index 268f782a..b350e527 100644 --- a/cli/moa_turn.go +++ b/cli/moa_turn.go @@ -105,17 +105,26 @@ func historyHasCCRMarkers(history []models.Message) bool { // exchange, with bounded tool rounds when the toolset grants any. func (cli *ChatCLI) moaTurn(ts moaToolset) moa.Turn { return func(ctx context.Context, ref moa.Ref, prompt string, history []models.Message) (string, error) { + // Budget hard stop applies per participant: a MoA panel is N paid + // calls in parallel — the most expensive surface to leave ungated. + if err := cli.budgetBlockedErr(); err != nil { + return "", err + } c, err := cli.moaClientFor(ref.Provider, ref.Model) if err != nil { return "", err } if !ts.any() { - return c.SendPrompt(ctx, prompt, history, 0) + out, err := c.SendPrompt(ctx, prompt, history, 0) + if err == nil { + cli.recordMoaUsage(ref, c, prompt, history, out) + } + return out, err } if tac, ok := client.AsToolAware(c); ok && tac.SupportsNativeTools() { return cli.runMoaTurnNative(ctx, tac, ref, prompt, history, ts) } - return cli.runMoaTurnXML(ctx, c, prompt, history, ts) + return cli.runMoaTurnXML(ctx, c, ref, prompt, history, ts) } } @@ -194,6 +203,7 @@ func (cli *ChatCLI) runMoaTurnNative( func (cli *ChatCLI) runMoaTurnXML( ctx context.Context, c moa.Client, + ref moa.Ref, prompt string, history []models.Message, ts moaToolset, @@ -215,6 +225,7 @@ func (cli *ChatCLI) runMoaTurnXML( if err != nil { return "", err } + cli.recordMoaUsage(ref, c, prompt, history, resp) calls, _ := agent.ParseToolCalls(resp) var kbArgs, rcArgs, memArgs string @@ -390,3 +401,23 @@ func moaRecallXMLInstruction() string { `` + "\n" + "You will receive the original verbatim and may then answer. If you do not need it, just answer normally." } + +// recordMoaUsage accounts one MoA participant exchange in the session cost +// tracker, attributed to the participant's own provider+model — real API +// usage when the client reports it, a character estimate otherwise. +func (cli *ChatCLI) recordMoaUsage(ref moa.Ref, c moa.Client, prompt string, history []models.Message, out string) { + if cli.costTracker == nil { + return + } + inChars := len(prompt) + for _, m := range history { + inChars += len(m.Content) + } + var usage *models.UsageInfo + if lc, ok := c.(client.LLMClient); ok { + usage = client.GetUsageOrEstimate(lc, inChars, len(out)) + } else { + usage = models.EstimateFromChars(inChars, len(out)) + } + cli.costTracker.RecordRealUsage(ref.Provider, ref.Model, usage) +} diff --git a/cli/palette_bridge.go b/cli/palette_bridge.go index 29cc8c4d..9b168bf3 100644 --- a/cli/palette_bridge.go +++ b/cli/palette_bridge.go @@ -20,6 +20,14 @@ var paletteModeSwitch = map[string]bool{ "/agent": true, "/run": true, "/coder": true, "/plan": true, } +// paletteDirectRun lists commands whose bare invocation has a meaningful +// action of its own (their subcommands are optional refinements): they run +// as typed instead of opening the per-command palette. Their subcommands +// still complete inline and appear in the palette when scoped explicitly. +var paletteDirectRun = map[string]bool{ + "/cost": true, +} + // paletteSuggest returns the next-token suggestions for a command line by // running the live inline completer against a synthesized document. The // palette and the prompt therefore share one source of truth for every @@ -71,7 +79,7 @@ func (cli *ChatCLI) paletteTrigger(userInput string) (target string, ok bool) { // palette: not a mode switch, and offering at least one concrete next-token // option (subcommand, flag or value). func (cli *ChatCLI) commandIsPickable(cmd string) bool { - if paletteModeSwitch[cmd] { + if paletteModeSwitch[cmd] || paletteDirectRun[cmd] { return false } return palette.HasConcreteOption(cli.paletteSuggest(cmd + " ")) diff --git a/cli/rpc_chat.go b/cli/rpc_chat.go index 222fc06e..e445df33 100644 --- a/cli/rpc_chat.go +++ b/cli/rpc_chat.go @@ -119,6 +119,12 @@ func (cli *ChatCLI) runChatTurnSerialized( ctx = cli.applyChatEffortHint(ctx, routeEffortForPrompt(input, assembly.effort)) maxTokens := cli.getMaxTokensForCurrentLLM() + // Budget hard stop applies to gateway/RPC surfaces too — long-lived + // unattended sessions are exactly where a runaway spend hurts most. + if err := cli.budgetBlockedErr(); err != nil { + return RPCChatTurn{}, err + } + reply, err := activeClient.SendPrompt(ctx, input+additionalContext, tempHistory, maxTokens) if cli.refreshClientOnAuthError(err) { reply, err = activeClient.SendPrompt(ctx, input+additionalContext, tempHistory, maxTokens) diff --git a/cli/scheduler_bridge.go b/cli/scheduler_bridge.go index 5622661c..7e57576c 100644 --- a/cli/scheduler_bridge.go +++ b/cli/scheduler_bridge.go @@ -543,7 +543,21 @@ func (b *schedulerBridge) DispatchWorker(_ context.Context, agentType, task stri // SendLLMPrompt runs a single LLM call using the currently-configured // client. func (b *schedulerBridge) SendLLMPrompt(ctx context.Context, system, prompt string, maxTokens int) (string, int, float64, error) { - c := b.cli.Client + // Budget hard stop covers scheduled runs too — unattended recurring + // jobs are exactly the runaway-spend case the gate exists for. + if err := b.cli.budgetBlockedErr(); err != nil { + return "", 0, 0, err + } + // A DEDICATED client instance, never the interactive session's: provider + // clients keep last-write usage state, and a scheduled call sharing + // b.cli.Client would clobber (and be clobbered by) a concurrent + // interactive turn's usage — cross-recording each other's tokens. + c, err := b.cli.manager.GetClient(b.cli.Provider, b.cli.Model) + if err != nil || c == nil { + // Fall back to the shared client rather than failing the job; usage + // below is a self-contained estimate, so accounting stays correct. + c = b.cli.Client + } if c == nil { return "", 0, 0, fmt.Errorf("scheduler: no LLM client configured") } @@ -558,10 +572,17 @@ func (b *schedulerBridge) SendLLMPrompt(ctx context.Context, system, prompt stri if err != nil { return "", 0, 0, err } - // Token/cost accounting — if a cost tracker is wired, the - // underlying provider will have updated it; we leave the numbers - // at zero from the bridge's point of view. - return text, 0, 0, nil + // Token/cost accounting: scheduled runs spend real money like any other + // turn — record them in the session tracker and report the figures back + // to the scheduler. Deliberately a character estimate, NOT + // GetUsageOrEstimate: even on a dedicated client the estimate is + // self-contained and immune to any state sharing a fallback path keeps. + usage := models.EstimateFromChars(len(system)+len(prompt), len(text)) + cost := estimateTurnCostUSD(b.cli.Provider, b.cli.Model, usage) + if b.cli.costTracker != nil { + b.cli.costTracker.RecordRealUsage(b.cli.Provider, b.cli.Model, usage) + } + return text, usage.TotalTokens, cost, nil } // FireHook dispatches a hook event synchronously. diff --git a/i18n/locales/en-US.json b/i18n/locales/en-US.json index 2773354a..932c7a02 100644 --- a/i18n/locales/en-US.json +++ b/i18n/locales/en-US.json @@ -2708,6 +2708,29 @@ "cost.cmd.input": "Input:", "cost.cmd.output": "Output:", "cost.cmd.total": "Total:", + "cost.cmd.reasoning": "Reasoning:", + "cost.cmd.reasoning_note": "(already counted in Output)", + "cost.cmd.cache_saved_usd": "(~ %s saved vs uncached)", + "cost.cmd.tag_api": "(API)", + "cost.cmd.tag_estimate": "(estimate)", + "cost.cmd.tag_provider_billed": "(billed by provider)", + "cost.cmd.pricing_unknown_models": "No pricing table for: %s — this spend is NOT in the total.", + "cost.cmd.help": "Usage: /cost [reset | last | sessions | export [path]]", + "cost.cmd.reset_done": "Cost tracking reset — previous period saved as %s (see /cost last).", + "cost.cmd.last_title": "Previous session cost", + "cost.cmd.last_none": "No previous cost snapshot found.", + "cost.cmd.sessions_title": "Cost sessions", + "cost.cmd.sessions_none": "No cost snapshots saved yet.", + "cost.cmd.sessions_current": "(current)", + "cost.cmd.sessions_row": "%s · %d requests · %s tokens · %s", + "cost.cmd.snapshot_model_row": "%s tokens · %d requests · %s", + "cost.cmd.snapshot_failed": "Could not read cost snapshots: %v", + "cost.cmd.export_done": "Session cost exported to %s", + "cost.cmd.export_failed": "Cost export failed: %v", + "cost.budget.warning": "Budget warning: $%.4f / $%.2f (%.0f%%)", + "cost.budget.exceeded": "BUDGET EXCEEDED: $%.4f / $%.2f (%.0f%%)", + "cost.budget.exceeded_hard": "BUDGET EXCEEDED: $%.4f / $%.2f (%.0f%%) — hard stop armed, new turns are blocked", + "cost.budget.blocked": "Session budget exhausted — turn blocked (CHATCLI_BUDGET_HARD_STOP). Raise CHATCLI_SESSION_BUDGET_USD or run /cost reset.", "cost.cmd.cache_tokens_label": "Cache Tokens:", "cost.cmd.cache_created": "Created:", "cost.cmd.cache_read": "Read:", @@ -2806,6 +2829,10 @@ "complete.root.mcp": "Manage MCP servers (status, tools, restart)", "complete.root.hooks": "Show configured lifecycle hooks", "complete.root.cost": "Show estimated cost of the current session", + "complete.cost.reset": "Reset the session cost period (previous period stays saved)", + "complete.cost.last": "Show the previous session's cost snapshot", + "complete.cost.sessions": "List recent cost snapshots", + "complete.cost.export": "Export the current session cost as JSON", "complete.root.ratelimit": "Show provider rate-limit status from response headers", "complete.root.worktree": "Manage git worktrees for isolated branch work", "complete.root.channel": "Manage MCP channels for push messages from external servers", diff --git a/i18n/locales/en.json b/i18n/locales/en.json index cbd24142..098a69d6 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -2708,6 +2708,29 @@ "cost.cmd.input": "Input:", "cost.cmd.output": "Output:", "cost.cmd.total": "Total:", + "cost.cmd.reasoning": "Reasoning:", + "cost.cmd.reasoning_note": "(already counted in Output)", + "cost.cmd.cache_saved_usd": "(~ %s saved vs uncached)", + "cost.cmd.tag_api": "(API)", + "cost.cmd.tag_estimate": "(estimate)", + "cost.cmd.tag_provider_billed": "(billed by provider)", + "cost.cmd.pricing_unknown_models": "No pricing table for: %s — this spend is NOT in the total.", + "cost.cmd.help": "Usage: /cost [reset | last | sessions | export [path]]", + "cost.cmd.reset_done": "Cost tracking reset — previous period saved as %s (see /cost last).", + "cost.cmd.last_title": "Previous session cost", + "cost.cmd.last_none": "No previous cost snapshot found.", + "cost.cmd.sessions_title": "Cost sessions", + "cost.cmd.sessions_none": "No cost snapshots saved yet.", + "cost.cmd.sessions_current": "(current)", + "cost.cmd.sessions_row": "%s · %d requests · %s tokens · %s", + "cost.cmd.snapshot_model_row": "%s tokens · %d requests · %s", + "cost.cmd.snapshot_failed": "Could not read cost snapshots: %v", + "cost.cmd.export_done": "Session cost exported to %s", + "cost.cmd.export_failed": "Cost export failed: %v", + "cost.budget.warning": "Budget warning: $%.4f / $%.2f (%.0f%%)", + "cost.budget.exceeded": "BUDGET EXCEEDED: $%.4f / $%.2f (%.0f%%)", + "cost.budget.exceeded_hard": "BUDGET EXCEEDED: $%.4f / $%.2f (%.0f%%) — hard stop armed, new turns are blocked", + "cost.budget.blocked": "Session budget exhausted — turn blocked (CHATCLI_BUDGET_HARD_STOP). Raise CHATCLI_SESSION_BUDGET_USD or run /cost reset.", "cost.cmd.cache_tokens_label": "Cache Tokens:", "cost.cmd.cache_created": "Created:", "cost.cmd.cache_read": "Read:", @@ -2806,6 +2829,10 @@ "complete.root.mcp": "Manage MCP servers (status, tools, restart)", "complete.root.hooks": "Show configured lifecycle hooks", "complete.root.cost": "Show estimated cost of the current session", + "complete.cost.reset": "Reset the session cost period (previous period stays saved)", + "complete.cost.last": "Show the previous session's cost snapshot", + "complete.cost.sessions": "List recent cost snapshots", + "complete.cost.export": "Export the current session cost as JSON", "complete.root.ratelimit": "Show provider rate-limit status from response headers", "complete.root.worktree": "Manage git worktrees for isolated branch work", "complete.root.channel": "Manage MCP channels for push messages from external servers", diff --git a/i18n/locales/pt-BR.json b/i18n/locales/pt-BR.json index ced7dffb..8560e165 100644 --- a/i18n/locales/pt-BR.json +++ b/i18n/locales/pt-BR.json @@ -2708,6 +2708,29 @@ "cost.cmd.input": "Entrada:", "cost.cmd.output": "Saída:", "cost.cmd.total": "Total:", + "cost.cmd.reasoning": "Reasoning:", + "cost.cmd.reasoning_note": "(já contado em Output)", + "cost.cmd.cache_saved_usd": "(~ %s economizados vs sem cache)", + "cost.cmd.tag_api": "(API)", + "cost.cmd.tag_estimate": "(estimativa)", + "cost.cmd.tag_provider_billed": "(cobrado pelo provider)", + "cost.cmd.pricing_unknown_models": "Sem tabela de preço para: %s — esse gasto NÃO está no total.", + "cost.cmd.help": "Uso: /cost [reset | last | sessions | export [caminho]]", + "cost.cmd.reset_done": "Cost tracking reiniciado — período anterior salvo como %s (veja /cost last).", + "cost.cmd.last_title": "Custo da sessão anterior", + "cost.cmd.last_none": "Nenhum snapshot de custo anterior encontrado.", + "cost.cmd.sessions_title": "Sessões de custo", + "cost.cmd.sessions_none": "Nenhum snapshot de custo salvo ainda.", + "cost.cmd.sessions_current": "(atual)", + "cost.cmd.sessions_row": "%s · %d requests · %s tokens · %s", + "cost.cmd.snapshot_model_row": "%s tokens · %d requests · %s", + "cost.cmd.snapshot_failed": "Não foi possível ler os snapshots de custo: %v", + "cost.cmd.export_done": "Custo da sessão exportado para %s", + "cost.cmd.export_failed": "Falha ao exportar custo: %v", + "cost.budget.warning": "Aviso de orçamento: $%.4f / $%.2f (%.0f%%)", + "cost.budget.exceeded": "ORÇAMENTO EXCEDIDO: $%.4f / $%.2f (%.0f%%)", + "cost.budget.exceeded_hard": "ORÇAMENTO EXCEDIDO: $%.4f / $%.2f (%.0f%%) — hard stop armado, novos turnos bloqueados", + "cost.budget.blocked": "Orçamento da sessão esgotado — turno bloqueado (CHATCLI_BUDGET_HARD_STOP). Aumente CHATCLI_SESSION_BUDGET_USD ou rode /cost reset.", "cost.cmd.cache_tokens_label": "Tokens de Cache:", "cost.cmd.cache_created": "Criados:", "cost.cmd.cache_read": "Lidos:", @@ -2806,6 +2829,10 @@ "complete.root.mcp": "Gerencia servidores MCP (status, tools, restart)", "complete.root.hooks": "Exibe hooks de lifecycle configurados", "complete.root.cost": "Exibe custo estimado da sessão atual", + "complete.cost.reset": "Reinicia o período de custo da sessão (o anterior fica salvo)", + "complete.cost.last": "Mostra o snapshot de custo da sessão anterior", + "complete.cost.sessions": "Lista snapshots de custo recentes", + "complete.cost.export": "Exporta o custo da sessão atual em JSON", "complete.root.ratelimit": "Exibe status de rate-limit dos providers pelos headers de resposta", "complete.root.worktree": "Gerencia git worktrees para trabalho isolado em branches", "complete.root.channel": "Gerencia MCP channels para push messages de servidores externos", diff --git a/llm/bedrock/bedrock_client.go b/llm/bedrock/bedrock_client.go index 973f7343..0384d101 100644 --- a/llm/bedrock/bedrock_client.go +++ b/llm/bedrock/bedrock_client.go @@ -205,6 +205,10 @@ type BedrockClient struct { // resolves an application-inference-profile ARN configured directly // (env/config) without going through /model discovery. profileLookupDone atomic.Bool + // usage holds the most recent call's real token usage (see usage.go). + // client.UsageState is a comparable struct, preserving the comparable + // contract documented above. + usage client.UsageState } // NewBedrockClient creates a client bound to a model id and region. @@ -369,6 +373,10 @@ func (c *BedrockClient) maybeResolveProfileModel(ctx context.Context) { // model family (Anthropic Messages vs. OpenAI Chat Completions). // Retries are delegated to utils.Retry inside each family-specific path. func (c *BedrockClient) SendPrompt(ctx context.Context, prompt string, history []models.Message, maxTokens int) (string, error) { + // Clear per-call usage so an errored or usage-less response falls back + // to estimation instead of re-counting the previous call (see usage.go). + c.resetUsage() + if err := c.ensureRuntime(ctx); err != nil { return "", err } @@ -473,7 +481,11 @@ func (c *BedrockClient) sendPromptAnthropicModel(ctx context.Context, wireModel, if err != nil { return "", wrapBedrockInferenceProfileError(wireModel, err) } - return parseAnthropicBody(out.Body) + text, perr := parseAnthropicBody(out.Body) + if perr == nil { + c.captureAnthropicUsage(out.Body) + } + return text, perr }) if err != nil { diff --git a/llm/bedrock/converse_family.go b/llm/bedrock/converse_family.go index f4dc6ec9..5d8594b6 100644 --- a/llm/bedrock/converse_family.go +++ b/llm/bedrock/converse_family.go @@ -83,7 +83,11 @@ func (c *BedrockClient) sendPromptConverse(ctx context.Context, prompt string, h if err != nil { return "", wrapBedrockInferenceProfileError(c.model, err) } - return parseConverseOutput(out) + text, perr := parseConverseOutput(out) + if perr == nil { + c.captureConverseUsage(out) + } + return text, perr }) if err != nil { client.LogRequestFinish(c.logger, "BEDROCK", c.model, "error", time.Since(start), diff --git a/llm/bedrock/mantle.go b/llm/bedrock/mantle.go index 87f683eb..c6af6090 100644 --- a/llm/bedrock/mantle.go +++ b/llm/bedrock/mantle.go @@ -285,6 +285,12 @@ func (c *BedrockClient) doMantleRequest(ctx context.Context, endpoint string, pa return "", fmt.Errorf("bedrock-mantle: read response: %w", err) } + if resp.StatusCode < 300 { + if text, perr := parseAnthropicBody(body); perr == nil { + c.captureAnthropicUsage(body) + return text, nil + } + } if resp.StatusCode >= 300 { // The endpoint answers with the standard Anthropic error envelope; // parseAnthropicBody surfaces type+message when present. diff --git a/llm/bedrock/openai_family.go b/llm/bedrock/openai_family.go index 002c7d2f..29369455 100644 --- a/llm/bedrock/openai_family.go +++ b/llm/bedrock/openai_family.go @@ -72,7 +72,11 @@ func (c *BedrockClient) sendPromptOpenAI(ctx context.Context, prompt string, his if err != nil { return "", wrapBedrockInferenceProfileError(c.model, err) } - return parseOpenAIBody(out.Body) + text, perr := parseOpenAIBody(out.Body) + if perr == nil { + c.captureOpenAIUsage(out.Body) + } + return text, perr }) if err != nil { client.LogRequestFinish(c.logger, "BEDROCK", c.model, "error", time.Since(start), diff --git a/llm/bedrock/usage.go b/llm/bedrock/usage.go new file mode 100644 index 00000000..0cb3e288 --- /dev/null +++ b/llm/bedrock/usage.go @@ -0,0 +1,102 @@ +/* + * ChatCLI - Bedrock usage capture + * Copyright (c) 2024 Edilson Freitas + * License: Apache-2.0 + * + * Read-side token accounting for every Bedrock surface. All four request + * families report real usage — InvokeModel (Anthropic Messages body), + * Converse (typed TokenUsage), the OpenAI-compatible family and the Mantle + * endpoint — and this file folds each shape into client.UsageState so + * BedrockClient satisfies UsageAwareClient/StopReasonAwareClient. Without + * it, Bedrock costs were chars/4 estimates dressed up with real prices. + */ +package bedrock + +import ( + "encoding/json" + + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime" + "github.com/diillson/chatcli/llm/client" + "github.com/diillson/chatcli/models" +) + +// LastUsage returns the token usage from the most recent API call. +// Satisfies the client.UsageAwareClient interface. +func (c *BedrockClient) LastUsage() *models.UsageInfo { + return c.usage.LastUsage() +} + +// LastStopReason returns the stop reason from the most recent API call. +// Satisfies the client.StopReasonAwareClient interface. +func (c *BedrockClient) LastStopReason() string { + return c.usage.LastStopReason() +} + +// resetUsage clears the state at the start of a call so an errored or +// usage-less response falls back to estimation instead of re-counting the +// previous call. +func (c *BedrockClient) resetUsage() { + c.usage.StoreUsage(nil) + c.usage.StoreStopReason("") +} + +// captureAnthropicUsage parses the usage block of an Anthropic Messages +// response body (InvokeModel and Mantle share the envelope). Separate parse +// of already-read bytes — the text path stays untouched. +func (c *BedrockClient) captureAnthropicUsage(body []byte) { + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return + } + if info := client.ParseAnthropicUsage(result); info != nil { + c.usage.StoreUsage(info) + } + if reason := client.ParseAnthropicStopReason(result); reason != "" { + c.usage.StoreStopReason(reason) + } +} + +// captureConverseUsage folds the Converse API's typed TokenUsage into the +// client state. Cache fields follow Anthropic semantics on Bedrock +// (reported alongside InputTokens, not as a subset). +func (c *BedrockClient) captureConverseUsage(out *bedrockruntime.ConverseOutput) { + if out == nil || out.Usage == nil { + return + } + deref := func(v *int32) int { + if v == nil { + return 0 + } + return int(*v) + } + info := &models.UsageInfo{ + PromptTokens: deref(out.Usage.InputTokens), + CompletionTokens: deref(out.Usage.OutputTokens), + TotalTokens: deref(out.Usage.TotalTokens), + CacheReadInputTokens: deref(out.Usage.CacheReadInputTokens), + CacheCreationInputTokens: deref(out.Usage.CacheWriteInputTokens), + IsReal: true, + } + if info.TotalTokens == 0 { + info.TotalTokens = info.PromptTokens + info.CompletionTokens + } + c.usage.StoreUsage(info) + if out.StopReason != "" { + c.usage.StoreStopReason(string(out.StopReason)) + } +} + +// captureOpenAIUsage parses the usage block of an OpenAI-compatible +// InvokeModel response body (gpt-oss family). +func (c *BedrockClient) captureOpenAIUsage(body []byte) { + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return + } + if info := client.ParseOpenAIUsage(result); info != nil { + c.usage.StoreUsage(info) + } + if reason := client.ParseOpenAIFinishReason(result); reason != "" { + c.usage.StoreStopReason(reason) + } +} diff --git a/llm/bedrock/usage_test.go b/llm/bedrock/usage_test.go new file mode 100644 index 00000000..2a447580 --- /dev/null +++ b/llm/bedrock/usage_test.go @@ -0,0 +1,97 @@ +/* + * ChatCLI - Bedrock usage capture tests + * Copyright (c) 2024 Edilson Freitas + * License: Apache-2.0 + */ +package bedrock + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime" + bedrockruntimetypes "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types" + "go.uber.org/zap" +) + +func newUsageTestClient(t *testing.T) *BedrockClient { + t.Helper() + return NewBedrockClient("anthropic.claude-sonnet-5", "us-east-1", "", zap.NewNop(), 1, 0) +} + +// TestCaptureAnthropicUsage covers InvokeModel and Mantle (same envelope). +func TestCaptureAnthropicUsage(t *testing.T) { + c := newUsageTestClient(t) + body := []byte(`{ + "content": [{"type":"text","text":"hi"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 120, "output_tokens": 30, + "cache_creation_input_tokens": 10, "cache_read_input_tokens": 40} + }`) + c.captureAnthropicUsage(body) + + u := c.LastUsage() + if u == nil || !u.IsReal { + t.Fatalf("usage not captured as real: %+v", u) + } + if u.PromptTokens != 120 || u.CompletionTokens != 30 || + u.CacheCreationInputTokens != 10 || u.CacheReadInputTokens != 40 { + t.Fatalf("wrong counts: %+v", u) + } + if c.LastStopReason() != "end_turn" { + t.Fatalf("stop reason = %q", c.LastStopReason()) + } +} + +// TestCaptureConverseUsage covers the typed Converse TokenUsage. +func TestCaptureConverseUsage(t *testing.T) { + c := newUsageTestClient(t) + out := &bedrockruntime.ConverseOutput{ + Usage: &bedrockruntimetypes.TokenUsage{ + InputTokens: aws.Int32(200), + OutputTokens: aws.Int32(50), + TotalTokens: aws.Int32(250), + CacheReadInputTokens: aws.Int32(80), + }, + StopReason: bedrockruntimetypes.StopReasonEndTurn, + } + c.captureConverseUsage(out) + + u := c.LastUsage() + if u == nil || u.PromptTokens != 200 || u.CompletionTokens != 50 || u.TotalTokens != 250 || + u.CacheReadInputTokens != 80 || !u.IsReal { + t.Fatalf("wrong converse usage: %+v", u) + } +} + +// TestCaptureOpenAIUsage covers the gpt-oss family body. +func TestCaptureOpenAIUsage(t *testing.T) { + c := newUsageTestClient(t) + body := []byte(`{ + "choices": [{"message": {"content": "hi"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 90, "completion_tokens": 12, "total_tokens": 102} + }`) + c.captureOpenAIUsage(body) + + u := c.LastUsage() + if u == nil || u.PromptTokens != 90 || u.CompletionTokens != 12 || !u.IsReal { + t.Fatalf("wrong openai-family usage: %+v", u) + } + if c.LastStopReason() != "stop" { + t.Fatalf("stop reason = %q", c.LastStopReason()) + } +} + +// TestResetUsageClearsStaleState: an errored call must not re-report the +// previous call's tokens. +func TestResetUsageClearsStaleState(t *testing.T) { + c := newUsageTestClient(t) + c.captureAnthropicUsage([]byte(`{"usage": {"input_tokens": 5, "output_tokens": 5}}`)) + if c.LastUsage() == nil { + t.Fatal("precondition: usage set") + } + c.resetUsage() + if c.LastUsage() != nil { + t.Fatal("resetUsage left stale usage behind") + } +} diff --git a/llm/claudeai/claude_client.go b/llm/claudeai/claude_client.go index ddd1f0b5..cd29c7b1 100644 --- a/llm/claudeai/claude_client.go +++ b/llm/claudeai/claude_client.go @@ -41,6 +41,12 @@ type ClaudeClient struct { maxAttempts int backoff time.Duration apiURL string + + // usage holds THIS instance's most recent API usage. Read-side only: + // populated from response bodies/SSE events after the original + // processing, so the OAuth-sensitive request path stays untouched. + // See usage_tracker.go for the accessors. + usage client.UsageState } const ( @@ -224,6 +230,10 @@ func applyFastModeIfRequested(reqBody map[string]interface{}, model string) bool // SendPrompt com exponential backoff usando utils.Retry func (c *ClaudeClient) SendPrompt(ctx context.Context, prompt string, history []models.Message, maxTokens int) (string, error) { + // Clear per-instance usage so a call that reports none falls back to + // estimation instead of re-counting the previous call's tokens. + c.resetUsage() + effectiveMaxTokens := maxTokens if effectiveMaxTokens <= 0 { effectiveMaxTokens = c.getMaxTokens() @@ -310,7 +320,7 @@ func (c *ClaudeClient) SendPrompt(ctx context.Context, prompt string, history [] } defer func() { _ = resp.Body.Close() }() if isOAuth { - return c.processStreamResponse(resp) + return c.processStreamResponse(resp, true) } return c.processResponse(resp) }) @@ -373,6 +383,10 @@ func (c *ClaudeClient) processResponse(resp *http.Response) (string, error) { return "", fmt.Errorf("%s", i18n.T("llm.error.no_response", "ClaudeAI")) } + // Read-side usage capture: separate parse of the already-read bytes so + // /cost gets real counts on the buffered path too (see usage_tracker.go). + c.recordUsageFromBody(bodyBytes) + return responseText, nil } @@ -382,7 +396,12 @@ func isClaudeSonnet(model string) bool { return claudeSonnetRe.MatchString(model) } -func (c *ClaudeClient) processStreamResponse(resp *http.Response) (string, error) { +// processStreamResponse decodes one Anthropic SSE stream. captureUsage +// gates the read-side token accounting: true for the user's actual turn, +// false for auxiliary calls (the OAuth title request) whose tiny usage +// would otherwise clobber the turn's numbers — the title request fires +// AFTER the main response, so it would win the last-write race. +func (c *ClaudeClient) processStreamResponse(resp *http.Response, captureUsage bool) (string, error) { decodedBody, err := decodeResponseBody(resp) if err != nil { _ = resp.Body.Close() @@ -396,6 +415,10 @@ func (c *ClaudeClient) processStreamResponse(resp *http.Response) (string, error } var out strings.Builder + // Read-side usage capture: message_start/message_delta SSE events carry + // the real token counts (see usage_tracker.go). Fed alongside the text + // decode below; committed once the stream ends. + var usageAcc streamUsageAccumulator reader := bufio.NewReader(decodedBody) for { line, err := reader.ReadString('\n') @@ -419,6 +442,7 @@ func (c *ClaudeClient) processStreamResponse(resp *http.Response) (string, error } continue } + usageAcc.observe([]byte(data)) var evt struct { Type string `json:"type"` Delta *struct { @@ -449,6 +473,10 @@ func (c *ClaudeClient) processStreamResponse(resp *http.Response) (string, error return "", fmt.Errorf("%s", i18n.T("llm.error.no_response", "ClaudeAI")) } + if captureUsage { + usageAcc.commit(c) + } + return responseText, nil } @@ -617,7 +645,7 @@ func (c *ClaudeClient) sendOAuthTitleRequest(ctx context.Context, userText strin return err } defer func() { _ = resp.Body.Close() }() - _, err = c.processStreamResponse(resp) + _, err = c.processStreamResponse(resp, false) return err } diff --git a/llm/claudeai/tool_use.go b/llm/claudeai/tool_use.go index c51ead1e..1a6bb1a9 100644 --- a/llm/claudeai/tool_use.go +++ b/llm/claudeai/tool_use.go @@ -37,6 +37,10 @@ func (c *ClaudeClient) SupportsNativeTools() bool { // SendPromptWithTools sends a prompt with tool definitions via Anthropic's native tool use API. func (c *ClaudeClient) SendPromptWithTools(ctx context.Context, prompt string, history []models.Message, tools []models.ToolDefinition, maxTokens int) (*models.LLMResponse, error) { + // Clear per-instance usage so a call that reports none falls back to + // estimation instead of re-counting the previous call's tokens. + c.resetUsage() + effectiveMaxTokens := maxTokens if effectiveMaxTokens <= 0 { effectiveMaxTokens = c.getMaxTokens() @@ -153,7 +157,16 @@ func (c *ClaudeClient) SendPromptWithTools(ctx context.Context, prompt string, h zap.Int("response_bytes", len(respBody)), ) - return parseClaudeToolResponse(respBody, c.logger) + response, err := parseClaudeToolResponse(respBody, c.logger) + if err == nil && response != nil && response.Usage != nil { + // Per-instance mirror of what parseClaudeToolResponse recorded in the + // legacy global — parallel clients must not cross-attribute tokens. + c.usage.StoreUsage(response.Usage) + if response.StopReason != "" { + c.usage.StoreStopReason(response.StopReason) + } + } + return response, err } // buildSystemBlocks creates system prompt blocks with cache_control:ephemeral for KV cache reuse. diff --git a/llm/claudeai/usage_stream_test.go b/llm/claudeai/usage_stream_test.go new file mode 100644 index 00000000..ec1967c4 --- /dev/null +++ b/llm/claudeai/usage_stream_test.go @@ -0,0 +1,71 @@ +/* + * ChatCLI - Claude stream usage accumulator tests + * Copyright (c) 2024 Edilson Freitas + * License: Apache-2.0 + */ +package claudeai + +import ( + "testing" +) + +// TestStreamUsageAccumulator drives the OAuth-stream event sequence: +// message_start carries input/cache counts, message_delta the final output +// count and stop reason. +func TestStreamUsageAccumulator(t *testing.T) { + var acc streamUsageAccumulator + + acc.observe([]byte(`{"type":"message_start","message":{"usage":{"input_tokens":150,"output_tokens":1,"cache_creation_input_tokens":20,"cache_read_input_tokens":90}}}`)) + acc.observe([]byte(`{"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}}`)) + acc.observe([]byte(`{"type":"message_delta","usage":{"output_tokens":37},"delta":{"stop_reason":"end_turn"}}`)) + + c := &ClaudeClient{} + acc.commit(c) + + u := c.LastUsage() + if u == nil || !u.IsReal { + t.Fatalf("no real usage committed: %+v", u) + } + if u.PromptTokens != 150 || u.CompletionTokens != 37 || + u.CacheCreationInputTokens != 20 || u.CacheReadInputTokens != 90 || + u.TotalTokens != 187 { + t.Fatalf("wrong accumulated usage: %+v", u) + } + if c.LastStopReason() != "end_turn" { + t.Fatalf("stop reason = %q", c.LastStopReason()) + } +} + +// TestStreamUsageAccumulatorNoEvents: commit without usage events must not +// invent data. +func TestStreamUsageAccumulatorNoEvents(t *testing.T) { + var acc streamUsageAccumulator + acc.observe([]byte(`{"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}}`)) + + c := &ClaudeClient{} + c.resetUsage() + acc.commit(c) + if u := c.usage.LastUsage(); u != nil { + t.Fatalf("usage invented from a stream without usage events: %+v", u) + } +} + +// TestBufferedBodyUsageCapture covers the non-OAuth SendPrompt path's +// read-side parse. +func TestBufferedBodyUsageCapture(t *testing.T) { + c := &ClaudeClient{} + c.resetUsage() + c.recordUsageFromBody([]byte(`{ + "content":[{"type":"text","text":"hi"}], + "stop_reason":"max_tokens", + "usage":{"input_tokens":10,"output_tokens":99} + }`)) + + u := c.usage.LastUsage() + if u == nil || u.PromptTokens != 10 || u.CompletionTokens != 99 || !u.IsReal { + t.Fatalf("buffered capture wrong: %+v", u) + } + if got := c.usage.LastStopReason(); got != "max_tokens" { + t.Fatalf("stop reason = %q", got) + } +} diff --git a/llm/claudeai/usage_tracker.go b/llm/claudeai/usage_tracker.go index e748c63c..4bcd5162 100644 --- a/llm/claudeai/usage_tracker.go +++ b/llm/claudeai/usage_tracker.go @@ -3,46 +3,160 @@ * Copyright (c) 2024 Edilson Freitas * License: Apache-2.0 * - * Adds UsageAwareClient and StopReasonAwareClient support to ClaudeClient - * WITHOUT modifying any existing code in claude_client.go. + * Adds UsageAwareClient and StopReasonAwareClient support to ClaudeClient. * - * Strategy: The tool_use.go path already parses usage from SendPromptWithTools. - * For the main SendPrompt path (OAuth), we extract usage from the response - * AFTER the original processing is complete, using a separate JSON parse - * of the already-read response data. + * Usage is captured READ-SIDE ONLY: from the already-received response body + * (buffered path) or from the already-decoded SSE events (OAuth stream + * path), never by changing how requests are built — the OAuth flow + * (headers, message structure, warmup) is extremely sensitive and stays + * byte-identical. * - * IMPORTANT: This file MUST NOT modify any function in claude_client.go. - * The OAuth flow (headers, message structure, warmup, stream parsing) is - * extremely sensitive and any change breaks the Anthropic API integration. + * State is per-client-instance (ClaudeClient.usage), so parallel clients + * (MoA panels, workers) never cross-attribute tokens. The legacy package + * global is kept as a fallback for the exported Record* helpers. */ package claudeai import ( + "encoding/json" + "github.com/diillson/chatcli/llm/client" "github.com/diillson/chatcli/models" ) -// usageState is stored alongside ClaudeClient to track usage. -// It's accessed by tool_use.go (which already parses usage) and -// can be populated by external callers via RecordUsage/RecordStopReason. var ( - // globalUsageState tracks usage for the most recent Claude API call. - // This is a package-level variable because ClaudeClient's struct cannot - // be modified (OAuth sensitivity). It's safe because Claude calls are - // serialized (one at a time per client instance). + // globalUsageState is the legacy package-level sink fed by the exported + // Record* helpers. WRITE-ONLY from the client's perspective: LastUsage / + // LastStopReason never read it (cross-instance reads double-count), it + // exists solely so external Record* callers keep compiling. globalUsageState client.UsageState ) // LastUsage returns the token usage from the most recent API call. // Satisfies the client.UsageAwareClient interface. +// +// Instance state ONLY — deliberately no globalUsageState fallback. Every +// send path stores per-instance (SendPrompt buffered + OAuth stream, +// SendPromptWithTools), so a nil here genuinely means "this call reported +// nothing" and the caller falls back to estimation. Falling through to the +// global would re-attribute another client's tokens (e.g. a worker reading +// the main loop's 120K-token turn) and double-count them. func (c *ClaudeClient) LastUsage() *models.UsageInfo { - return globalUsageState.LastUsage() + return c.usage.LastUsage() } // LastStopReason returns the stop reason from the most recent API call. -// Satisfies the client.StopReasonAwareClient interface. +// Satisfies the client.StopReasonAwareClient interface. Instance-only, same +// rationale as LastUsage. func (c *ClaudeClient) LastStopReason() string { - return globalUsageState.LastStopReason() + return c.usage.LastStopReason() +} + +// resetUsage clears this instance's usage at the start of a call, so a call +// that yields no usage (error, schema change) reads as "unknown" and falls +// back to estimation — never as a stale re-count of the previous call. +func (c *ClaudeClient) resetUsage() { + c.usage.StoreUsage(nil) + c.usage.StoreStopReason("") +} + +// storeUsage records usage on this instance (and mirrors to the legacy +// global so existing consumers of the package-level state keep working). +func (c *ClaudeClient) storeUsage(usage *models.UsageInfo) { + if usage == nil { + return + } + c.usage.StoreUsage(usage) + globalUsageState.StoreUsage(usage) +} + +// recordUsageFromBody extracts usage from a buffered Anthropic Messages +// response body with a SEPARATE parse of the already-read bytes. +func (c *ClaudeClient) recordUsageFromBody(body []byte) { + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return + } + c.storeUsage(client.ParseAnthropicUsage(result)) + if reason := client.ParseAnthropicStopReason(result); reason != "" { + c.usage.StoreStopReason(reason) + globalUsageState.StoreStopReason(reason) + } +} + +// streamUsageAccumulator collects usage across the SSE events of one +// streamed Anthropic response: message_start carries input/cache counts +// (and an initial output count), message_delta carries the final output +// count and stop reason. +type streamUsageAccumulator struct { + info models.UsageInfo + stopReason string + seen bool +} + +// anthropicStreamEvent mirrors the usage-bearing subset of Anthropic SSE +// events. Both message_start (nested under "message") and message_delta +// (top-level "usage" + "delta.stop_reason") shapes are covered. +type anthropicStreamEvent struct { + Type string `json:"type"` + Message *struct { + Usage *anthropicStreamUsage `json:"usage"` + } `json:"message"` + Usage *anthropicStreamUsage `json:"usage"` + Delta *struct { + StopReason string `json:"stop_reason"` + } `json:"delta"` +} + +type anthropicStreamUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens"` + CacheReadInputTokens int `json:"cache_read_input_tokens"` +} + +// observe folds one SSE data payload into the accumulator. Cheap no-op for +// content deltas (the JSON parse of small events is negligible next to the +// network stream itself). +func (a *streamUsageAccumulator) observe(data []byte) { + var evt anthropicStreamEvent + if err := json.Unmarshal(data, &evt); err != nil { + return + } + switch evt.Type { + case "message_start": + if evt.Message != nil && evt.Message.Usage != nil { + u := evt.Message.Usage + a.info.PromptTokens = u.InputTokens + a.info.CompletionTokens = u.OutputTokens + a.info.CacheCreationInputTokens = u.CacheCreationInputTokens + a.info.CacheReadInputTokens = u.CacheReadInputTokens + a.seen = true + } + case "message_delta": + if evt.Usage != nil && evt.Usage.OutputTokens > 0 { + a.info.CompletionTokens = evt.Usage.OutputTokens + a.seen = true + } + if evt.Delta != nil && evt.Delta.StopReason != "" { + a.stopReason = evt.Delta.StopReason + } + } +} + +// commit stores the accumulated usage on the client once the stream ends. +func (a *streamUsageAccumulator) commit(c *ClaudeClient) { + if !a.seen { + return + } + info := a.info + info.TotalTokens = info.PromptTokens + info.CompletionTokens + info.IsReal = true + c.storeUsage(&info) + if a.stopReason != "" { + c.usage.StoreStopReason(a.stopReason) + globalUsageState.StoreStopReason(a.stopReason) + } } // RecordClaudeUsage stores usage info from a Claude API response. diff --git a/llm/fallback/chain.go b/llm/fallback/chain.go index fb3982b2..5a3ca961 100644 --- a/llm/fallback/chain.go +++ b/llm/fallback/chain.go @@ -73,6 +73,11 @@ type Chain struct { mu sync.RWMutex logger *zap.Logger + // lastServed remembers which entry answered the most recent successful + // request, so usage/cost is attributed to the provider that actually + // billed it — not to whichever entry sits first in the chain. + lastServed *FallbackEntry + // Configuration maxRetries int cooldownBase time.Duration @@ -143,6 +148,7 @@ func (c *Chain) SendPrompt(ctx context.Context, prompt string, history []models. resp, err := entry.Client.SendPrompt(ctx, prompt, history, maxTokens) if err == nil { c.markSuccess(entry.Provider) + c.setLastServed(entry) return resp, nil } @@ -210,6 +216,7 @@ func (c *Chain) SendPromptWithTools(ctx context.Context, prompt string, history resp, err := tac.SendPromptWithTools(ctx, prompt, history, tools, maxTokens) if err == nil { c.markSuccess(entry.Provider) + c.setLastServed(entry) return resp, nil } @@ -335,6 +342,56 @@ func ClassifyError(err error) ErrorClass { } } +// setLastServed records the entry that answered the last successful call. +func (c *Chain) setLastServed(entry FallbackEntry) { + c.mu.Lock() + e := entry + c.lastServed = &e + c.mu.Unlock() +} + +// LastServedEntry returns the provider+model that answered the most recent +// successful request. ok is false before the first success. +func (c *Chain) LastServedEntry() (provider, model string, ok bool) { + c.mu.RLock() + defer c.mu.RUnlock() + if c.lastServed == nil { + return "", "", false + } + return c.lastServed.Provider, c.lastServed.Model, true +} + +// LastUsage forwards the served client's real usage so the chain satisfies +// client.UsageAwareClient — without this, any provider behind a fallback +// chain silently degraded to character estimates. +func (c *Chain) LastUsage() *models.UsageInfo { + c.mu.RLock() + served := c.lastServed + c.mu.RUnlock() + if served == nil { + return nil + } + if uac, ok := client.AsUsageAware(served.Client); ok { + return uac.LastUsage() + } + return nil +} + +// LastStopReason forwards the served client's stop reason (max_tokens +// escalation detection keeps working through the chain). +func (c *Chain) LastStopReason() string { + c.mu.RLock() + served := c.lastServed + c.mu.RUnlock() + if served == nil { + return "" + } + if src, ok := client.AsStopReasonAware(served.Client); ok { + return src.LastStopReason() + } + return "" +} + // GetModelName returns the model name of the first available provider. func (c *Chain) GetModelName() string { for _, e := range c.entries { diff --git a/llm/fallback/chain_usage_test.go b/llm/fallback/chain_usage_test.go new file mode 100644 index 00000000..651eafe2 --- /dev/null +++ b/llm/fallback/chain_usage_test.go @@ -0,0 +1,66 @@ +/* + * ChatCLI - fallback chain usage forwarding tests + * Copyright (c) 2024 Edilson Freitas + * License: Apache-2.0 + */ +package fallback + +import ( + "context" + "errors" + "testing" + + "github.com/diillson/chatcli/llm/client" + "github.com/diillson/chatcli/models" + "go.uber.org/zap" +) + +// fakeUsageClient implements client.UsageAwareClient with canned data. +type fakeUsageClient struct { + name string + fail bool + usage *models.UsageInfo +} + +func (f *fakeUsageClient) GetModelName() string { return f.name } +func (f *fakeUsageClient) SendPrompt(_ context.Context, _ string, _ []models.Message, _ int) (string, error) { + if f.fail { + return "", errors.New("boom") + } + return "ok from " + f.name, nil +} +func (f *fakeUsageClient) LastUsage() *models.UsageInfo { return f.usage } + +var _ client.UsageAwareClient = (*fakeUsageClient)(nil) + +// TestChainForwardsUsageFromServedEntry: the chain must expose the usage of +// the entry that actually answered — including after a failover — so cost +// tracking never degrades to estimates behind a chain. +func TestChainForwardsUsageFromServedEntry(t *testing.T) { + primary := &fakeUsageClient{name: "primary-model", fail: true} + secondary := &fakeUsageClient{name: "secondary-model", + usage: &models.UsageInfo{PromptTokens: 42, CompletionTokens: 7, IsReal: true}} + + chain := NewChain(zap.NewNop(), []FallbackEntry{ + {Provider: "P1", Model: "m1", Client: primary}, + {Provider: "P2", Model: "m2", Client: secondary}, + }, WithMaxRetries(0)) + + if u := chain.LastUsage(); u != nil { + t.Fatalf("usage before any call: %+v", u) + } + + out, err := chain.SendPrompt(context.Background(), "hi", nil, 0) + if err != nil || out == "" { + t.Fatalf("chain send: %v", err) + } + + provider, model, ok := chain.LastServedEntry() + if !ok || provider != "P2" || model != "m2" { + t.Fatalf("served entry = %s/%s ok=%v, want P2/m2", provider, model, ok) + } + u := chain.LastUsage() + if u == nil || u.PromptTokens != 42 || !u.IsReal { + t.Fatalf("chain did not forward served usage: %+v", u) + } +} diff --git a/llm/googleai/gemini_client.go b/llm/googleai/gemini_client.go index 216faf2c..ab11f57d 100644 --- a/llm/googleai/gemini_client.go +++ b/llm/googleai/gemini_client.go @@ -273,6 +273,11 @@ func (c *GeminiClient) parseResponse(bodyBytes []byte) (string, error) { PromptTokenCount int `json:"promptTokenCount"` CandidatesTokenCount int `json:"candidatesTokenCount"` TotalTokenCount int `json:"totalTokenCount"` + // Context-cache hits — a SUBSET of promptTokenCount, billed at + // the discounted cache rate. + CachedContentTokenCount int `json:"cachedContentTokenCount"` + // Thinking tokens — billed as output, informational here. + ThoughtsTokenCount int `json:"thoughtsTokenCount"` } `json:"usageMetadata"` Error struct { Code int `json:"code"` @@ -317,10 +322,12 @@ func (c *GeminiClient) parseResponse(bodyBytes []byte) (string, error) { // Store usage info if result.UsageMetadata.TotalTokenCount > 0 || result.UsageMetadata.PromptTokenCount > 0 { c.usageState.StoreUsage(&models.UsageInfo{ - PromptTokens: result.UsageMetadata.PromptTokenCount, - CompletionTokens: result.UsageMetadata.CandidatesTokenCount, - TotalTokens: result.UsageMetadata.TotalTokenCount, - IsReal: true, + PromptTokens: result.UsageMetadata.PromptTokenCount, + CompletionTokens: result.UsageMetadata.CandidatesTokenCount, + TotalTokens: result.UsageMetadata.TotalTokenCount, + CacheReadInputTokens: result.UsageMetadata.CachedContentTokenCount, + ReasoningTokens: result.UsageMetadata.ThoughtsTokenCount, + IsReal: true, }) } diff --git a/llm/minimax/tool_use.go b/llm/minimax/tool_use.go index c2037d53..f29757ca 100644 --- a/llm/minimax/tool_use.go +++ b/llm/minimax/tool_use.go @@ -36,6 +36,10 @@ func (c *MiniMaxClient) SupportsNativeTools() bool { // SendPromptWithTools sends a prompt with tool definitions via MiniMax's native tool calling API. func (c *MiniMaxClient) SendPromptWithTools(ctx context.Context, prompt string, history []models.Message, tools []models.ToolDefinition, maxTokens int) (*models.LLMResponse, error) { + // Clear per-call usage so a call whose response carries no usage block + // falls back to estimation instead of re-counting the previous call. + c.usageState.StoreUsage(nil) + effectiveMaxTokens := maxTokens if effectiveMaxTokens <= 0 { effectiveMaxTokens = c.getMaxTokens() @@ -112,7 +116,13 @@ func (c *MiniMaxClient) SendPromptWithTools(ctx context.Context, prompt string, zap.String("path", "tool_use"), zap.Int("response_chars", len(resp)), ) - return parseToolResponse(resp, c.logger) + response, err := parseToolResponse(resp, c.logger) + if err == nil && response != nil && response.Usage != nil { + // Mirror the tool-path usage into the client state so LastUsage() + // reflects THIS call instead of a stale buffered one. + c.usageState.StoreUsage(response.Usage) + } + return response, err } // buildToolMessages constructs the messages array supporting tool calls and results. @@ -254,19 +264,11 @@ func parseToolResponse(body string, logger *zap.Logger) (*models.LLMResponse, er } } - // Extract usage - if usage, ok := result["usage"].(map[string]interface{}); ok { - response.Usage = &models.UsageInfo{} - if pt, ok := usage["prompt_tokens"].(float64); ok { - response.Usage.PromptTokens = int(pt) - } - if ct, ok := usage["completion_tokens"].(float64); ok { - response.Usage.CompletionTokens = int(ct) - } - if tt, ok := usage["total_tokens"].(float64); ok { - response.Usage.TotalTokens = int(tt) - } - } + // Extract usage via the shared OpenAI-compatible parser: marks the data + // as real API usage (IsReal) and also surfaces cached/reasoning token + // details — the hand-rolled block it replaces silently dropped IsReal, + // making /cost label real counts as "character estimate". + response.Usage = client.ParseOpenAIUsage(result) return response, nil } diff --git a/llm/moonshot/tool_use.go b/llm/moonshot/tool_use.go index 5139db86..7b8ebe82 100644 --- a/llm/moonshot/tool_use.go +++ b/llm/moonshot/tool_use.go @@ -37,6 +37,10 @@ func (c *MoonshotClient) SupportsNativeTools() bool { // SendPromptWithTools sends a prompt with tool definitions via Moonshot's // native tool calling API. func (c *MoonshotClient) SendPromptWithTools(ctx context.Context, prompt string, history []models.Message, tools []models.ToolDefinition, maxTokens int) (*models.LLMResponse, error) { + // Clear per-call usage so a call whose response carries no usage block + // falls back to estimation instead of re-counting the previous call. + c.usageState.StoreUsage(nil) + effectiveMaxTokens := maxTokens if effectiveMaxTokens <= 0 { effectiveMaxTokens = c.getMaxTokens() @@ -110,7 +114,13 @@ func (c *MoonshotClient) SendPromptWithTools(ctx context.Context, prompt string, zap.String("path", "tool_use"), zap.Int("response_chars", len(resp)), ) - return parseToolResponse(resp, c.logger) + response, err := parseToolResponse(resp, c.logger) + if err == nil && response != nil && response.Usage != nil { + // Mirror the tool-path usage into the client state so LastUsage() + // reflects THIS call instead of a stale buffered one. + c.usageState.StoreUsage(response.Usage) + } + return response, err } // buildToolMessages constructs the messages array supporting tool calls and results. @@ -244,18 +254,11 @@ func parseToolResponse(body string, _ *zap.Logger) (*models.LLMResponse, error) } } - if usage, ok := result["usage"].(map[string]interface{}); ok { - response.Usage = &models.UsageInfo{} - if pt, ok := usage["prompt_tokens"].(float64); ok { - response.Usage.PromptTokens = int(pt) - } - if ct, ok := usage["completion_tokens"].(float64); ok { - response.Usage.CompletionTokens = int(ct) - } - if tt, ok := usage["total_tokens"].(float64); ok { - response.Usage.TotalTokens = int(tt) - } - } + // Shared OpenAI-compatible parser: marks the data as real API usage + // (IsReal) and surfaces cached/reasoning token details — the hand-rolled + // block it replaces silently dropped IsReal, making /cost label real + // counts as "character estimate". + response.Usage = client.ParseOpenAIUsage(result) return response, nil } diff --git a/llm/openai/tool_use.go b/llm/openai/tool_use.go index 5567dbbc..c9ca5651 100644 --- a/llm/openai/tool_use.go +++ b/llm/openai/tool_use.go @@ -34,6 +34,10 @@ func (c *OpenAIClient) SupportsNativeTools() bool { // SendPromptWithTools sends a prompt with tool definitions via OpenAI's native tool calling API. func (c *OpenAIClient) SendPromptWithTools(ctx context.Context, prompt string, history []models.Message, tools []models.ToolDefinition, maxTokens int) (*models.LLMResponse, error) { + // Clear per-call usage so a call that reports none falls back to + // estimation instead of re-counting a previous call's tokens. + c.usageState.StoreUsage(nil) + effectiveMaxTokens := maxTokens if effectiveMaxTokens <= 0 { effectiveMaxTokens = c.getMaxTokens() @@ -110,7 +114,13 @@ func (c *OpenAIClient) SendPromptWithTools(ctx context.Context, prompt string, h zap.String("path", "tool_use"), zap.Int("response_chars", len(resp)), ) - return parseToolResponse(resp, c.logger) + response, err := parseToolResponse(resp, c.logger) + if err == nil && response != nil && response.Usage != nil { + // Mirror the tool-path usage into the client state so LastUsage() + // reflects THIS call — agent mode reads it via GetUsageOrEstimate. + c.usageState.StoreUsage(response.Usage) + } + return response, err } // buildToolMessages constructs the messages array supporting tool calls and results. diff --git a/llm/openrouter/openrouter_client.go b/llm/openrouter/openrouter_client.go index c959b67a..370ffa9b 100644 --- a/llm/openrouter/openrouter_client.go +++ b/llm/openrouter/openrouter_client.go @@ -136,6 +136,17 @@ func (c *OpenRouterClient) buildPayload(messages []map[string]interface{}, maxTo "max_tokens": maxTokens, } + // Ask OpenRouter to attach accounting to the response: usage.cost is + // the actually-billed amount in USD credits — authoritative over any + // local pricing table, and the only correct source for the long tail + // of models OpenRouter serves. ONLY on the official host: the + // "usage" field is OpenRouter-proprietary, and OPENROUTER_API_URL is + // the sanctioned generic-gateway path (strict OpenAI-compatible + // backends reject unknown request fields). + if !utils.IsCustomEndpoint(c.getAPIURL(), config.OpenRouterAPIURL) { + payload["usage"] = map[string]interface{}{"include": true} + } + // OpenRouter-specific: fallback models (try primary, then fallbacks) if fallbackModels := os.Getenv("OPENROUTER_FALLBACK_MODELS"); fallbackModels != "" { modelList := strings.Split(fallbackModels, ",") @@ -341,18 +352,28 @@ func (c *OpenRouterClient) processResponse(resp *http.Response) (string, error) return "", errors.New(i18n.T("llm.error.empty_response_unspecified", "OpenRouter")) } - // Store and log usage metadata for cost tracking + // Store and log usage metadata for cost tracking — including the + // actually-billed cost (usage.cost) which overrides local table math. if result.Usage != nil { - c.usageState.StoreUsage(&models.UsageInfo{ + info := &models.UsageInfo{ PromptTokens: result.Usage.PromptTokens, CompletionTokens: result.Usage.CompletionTokens, TotalTokens: result.Usage.TotalTokens, + CostUSD: result.Usage.Cost, IsReal: true, - }) + } + if result.Usage.PromptTokensDetails != nil { + info.CacheReadInputTokens = result.Usage.PromptTokensDetails.CachedTokens + } + if result.Usage.CompletionTokensDetails != nil { + info.ReasoningTokens = result.Usage.CompletionTokensDetails.ReasoningTokens + } + c.usageState.StoreUsage(info) c.logger.Debug("OpenRouter usage", zap.Int("prompt_tokens", result.Usage.PromptTokens), zap.Int("completion_tokens", result.Usage.CompletionTokens), - zap.Int("total_tokens", result.Usage.TotalTokens)) + zap.Int("total_tokens", result.Usage.TotalTokens), + zap.Float64("cost_usd", result.Usage.Cost)) } c.usageState.StoreStopReason(firstChoice.FinishReason) @@ -443,9 +464,16 @@ type openRouterResponse struct { FinishReason string `json:"finish_reason"` } `json:"choices"` Usage *struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + Cost float64 `json:"cost"` + PromptTokensDetails *struct { + CachedTokens int `json:"cached_tokens"` + } `json:"prompt_tokens_details,omitempty"` + CompletionTokensDetails *struct { + ReasoningTokens int `json:"reasoning_tokens"` + } `json:"completion_tokens_details,omitempty"` } `json:"usage,omitempty"` Error *struct { Message string `json:"message"` diff --git a/llm/zai/tool_use.go b/llm/zai/tool_use.go index 69ec529b..9e112bd0 100644 --- a/llm/zai/tool_use.go +++ b/llm/zai/tool_use.go @@ -33,6 +33,10 @@ func (c *ZAIClient) SupportsNativeTools() bool { // SendPromptWithTools sends a prompt with tool definitions via ZAI's native tool calling API. func (c *ZAIClient) SendPromptWithTools(ctx context.Context, prompt string, history []models.Message, tools []models.ToolDefinition, maxTokens int) (*models.LLMResponse, error) { + // Clear per-call usage so a call whose response carries no usage block + // falls back to estimation instead of re-counting the previous call. + c.usageState.StoreUsage(nil) + effectiveMaxTokens := maxTokens if effectiveMaxTokens <= 0 { effectiveMaxTokens = c.getMaxTokens() @@ -107,7 +111,13 @@ func (c *ZAIClient) SendPromptWithTools(ctx context.Context, prompt string, hist zap.String("path", "tool_use"), zap.Int("response_chars", len(resp)), ) - return parseToolResponse(resp, c.logger) + response, err := parseToolResponse(resp, c.logger) + if err == nil && response != nil && response.Usage != nil { + // Mirror the tool-path usage into the client state so LastUsage() + // reflects THIS call instead of a stale buffered one. + c.usageState.StoreUsage(response.Usage) + } + return response, err } // buildToolMessages constructs the messages array supporting tool calls and results. @@ -249,19 +259,11 @@ func parseToolResponse(body string, logger *zap.Logger) (*models.LLMResponse, er } } - // Extract usage - if usage, ok := result["usage"].(map[string]interface{}); ok { - response.Usage = &models.UsageInfo{} - if pt, ok := usage["prompt_tokens"].(float64); ok { - response.Usage.PromptTokens = int(pt) - } - if ct, ok := usage["completion_tokens"].(float64); ok { - response.Usage.CompletionTokens = int(ct) - } - if tt, ok := usage["total_tokens"].(float64); ok { - response.Usage.TotalTokens = int(tt) - } - } + // Extract usage via the shared OpenAI-compatible parser: marks the data + // as real API usage (IsReal) and also surfaces cached/reasoning token + // details — the hand-rolled block it replaces silently dropped IsReal, + // making /cost label real counts as "character estimate". + response.Usage = client.ParseOpenAIUsage(result) return response, nil } diff --git a/models/models.go b/models/models.go index a4c46d2f..2f890da4 100644 --- a/models/models.go +++ b/models/models.go @@ -180,10 +180,16 @@ type UsageInfo struct { // Reasoning tokens emitted by o-series / GPT-5 reasoning models. // Reported by OpenAI under usage.completion_tokens_details.reasoning_tokens // (Chat Completions) or usage.output_tokens_details.reasoning_tokens - // (Responses API). Billed as output tokens and already counted in - // CompletionTokens — this field is informational only. + // (Responses API), and by Gemini under usageMetadata.thoughtsTokenCount. + // Billed as output tokens and already counted in CompletionTokens — + // this field is informational only. ReasoningTokens int `json:"reasoning_tokens,omitempty"` + // CostUSD is the actual billed cost reported by the provider for this + // call, when the API surfaces one (OpenRouter's usage.cost). Zero means + // "not reported" — cost is then derived from the local pricing tables. + CostUSD float64 `json:"cost_usd,omitempty"` + // Whether these values came from the API (true) or were estimated (false). // Callers can use this to decide display precision and cost accuracy. IsReal bool `json:"-"` @@ -201,6 +207,7 @@ func (u *UsageInfo) Merge(other *UsageInfo) { u.CacheCreationInputTokens += other.CacheCreationInputTokens u.CacheReadInputTokens += other.CacheReadInputTokens u.ReasoningTokens += other.ReasoningTokens + u.CostUSD += other.CostUSD if other.IsReal { u.IsReal = true }