From 4a8bcb4ec6c7959ea87c937cbd71de487f079220 Mon Sep 17 00:00:00 2001 From: Siwen Yu Date: Sat, 10 Oct 2026 17:42:09 +0800 Subject: [PATCH 1/3] feat(usage): account for what a call charged and what it cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "A3" left two questions with no way to answer either. Some routes report what a request cost; the rest have to be priced from rates. And rates need the lanes: DeepSeek bills four input lanes (cache hit vs cache miss) times a peak/off-peak rule, which one input price cannot express, and `types.Usage` could not hold a lane. - `types.Usage` gains `CachedPromptTokens`, `CacheWriteTokens` and `ReasoningTokens`: one field per *billing lane*, so OpenAI's nested `prompt_tokens_details` and DeepSeek's flat `prompt_cache_hit_tokens` land in the same place and nothing downstream has to know which route it is on. The nested shape wins when a route sends both, so nothing is counted twice. - `types.Cost{Amount, Currency}`, `CostSource` and `CostEvent` carry money with its unit and say where the number came from. `CostTotals` accumulates per unit: an account's credits and the currency of a user's invoice are added apart, never together, because their sum would mean nothing. - The OpenAI-compatible provider reads a reported charge (`cost`, falling back to `cost_details.upstream_inference_cost` for a BYOK request that billed nothing) and carries it verbatim. `SetRouteInfo` names the configuration route and the unit it bills in, so the provider knows something the agent cannot: which route a call went through. - `PriceTable` holds declared rates keyed by `/`, with `*` allowed in either position, resolved most-specific-first so two entries can never both claim a call. `Amount` carves the lanes out of the totals (the details are subsets, not extra tokens), falls each detail rate back to the lane it belongs to, and floors every subtraction at zero. - A call is priced from a declaration only when its tokens were *reported*: multiplying a declared rate by a guessed token count would dress a second estimate as a measurement. A call with no rate and no reported charge is counted as unknown — never as zero, which would read as free in exactly the decisions the number exists to inform. - The status bar shows `cost: ` plus `(+N unpriced)` once there is something to say, and is byte-identical to before when there is not. Cost/pricing is tokens-only in a second sense worth stating: a price table of its own is still an open decision (#182), and nothing here waits on it. Closes #181. Tests: `go test ./... -count=1` and `-race` green (13 packages); gofmt and staticcheck v0.8.1 clean. Eight mutations of the new behaviour each fail on an assertion — the nested usage shape ignored, the upstream cost ignored, the detail lanes added on top of the totals instead of carved out, a wildcard outranking an exact key, an estimated usage priced anyway, an unknown cost recorded as zero, a declared rate outranking the route's own figure, and the UI dropping the unpriced count. --- agent/agent.go | 70 +++++-- agent/cost_test.go | 398 +++++++++++++++++++++++++++++++++++++ agent/pricing.go | 171 ++++++++++++++++ agent/provider_ollama.go | 24 +++ agent/provider_openai.go | 97 ++++++++- agent/usage_detail_test.go | 123 ++++++++++++ config.example.json | 19 +- config/config.go | 58 ++++++ config/config_test.go | 66 ++++++ main.go | 35 +++- tool/bgtask.go | 1 + tool/task.go | 5 + tui/cost_test.go | 108 ++++++++++ tui/messages.go | 9 + tui/model.go | 9 + tui/update.go | 17 ++ tui/view.go | 21 +- types/types.go | 121 ++++++++++- 18 files changed, 1330 insertions(+), 22 deletions(-) create mode 100644 agent/cost_test.go create mode 100644 agent/pricing.go create mode 100644 agent/usage_detail_test.go create mode 100644 tui/cost_test.go diff --git a/agent/agent.go b/agent/agent.go index 6497a6d..e334219 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -59,6 +59,17 @@ type Agent struct { // runUsage is UsageTotal restricted to the run in flight, reset by each Run. runUsage types.Usage + // PriceTable prices a call whose route reported no charge. Nil prices + // nothing, which is reported as an unknown cost rather than as zero. + PriceTable *PriceTable + // CostTotal is every cost this Agent knows, per unit, and CostUnknownCalls + // counts the calls whose cost it does not know. They are kept together + // because a total without its unknown count overstates what is known: + // "0.40 USD" and "0.40 USD and three calls nobody priced" are different + // statements. + CostTotal types.CostTotals + CostUnknownCalls int + ContentStreamed bool // true when content was streamed via SSE; skip glamour re-print } @@ -80,19 +91,47 @@ func callSpend(resp *types.ChatResponse) (spend types.Usage, reported bool) { return types.Usage{CompletionTokens: n, TotalTokens: n}, false } -// recordUsage accumulates one provider call into the run and session totals and -// publishes a *reported* record to the run's callbacks. The callback never fires -// for the estimate fallback, so no consumer is handed an estimate dressed as a -// provider number. -func (a *Agent) recordUsage(cb *types.StreamCallbacks, resp *types.ChatResponse) { +// recordCall accounts one provider call: its tokens and its cost. +// +// Tokens: accumulated into the run and session totals, with a *reported* record +// published to the callbacks. The callback never fires for the estimate +// fallback, so no consumer is handed an estimate dressed as a provider number. +// +// Cost: a charge the route reported wins outright; otherwise a rate the user +// declared for this route and model is applied to the *reported* usage. A call +// whose tokens we estimated is not priced at all — multiplying a declared rate +// by a guessed token count would dress a second estimate as a measurement — and +// neither is a call with no declared rate. Both are counted as unknown, which is +// not the same as free. +func (a *Agent) recordCall(cb *types.StreamCallbacks, resp *types.ChatResponse) { spend, reported := callSpend(resp) - if !reported && spend.TotalTokens == 0 { - return + if reported || spend.TotalTokens > 0 { + a.UsageTotal = a.UsageTotal.Add(spend) + a.runUsage = a.runUsage.Add(spend) + if reported && cb != nil && cb.OnUsage != nil { + cb.OnUsage(spend) + } } - a.UsageTotal = a.UsageTotal.Add(spend) - a.runUsage = a.runUsage.Add(spend) - if reported && cb != nil && cb.OnUsage != nil { - cb.OnUsage(spend) + + event := types.CostEvent{Source: types.CostUnknown} + switch { + case resp.Cost != nil: + event = types.CostEvent{Cost: *resp.Cost, Source: types.CostReported} + case reported && a.PriceTable != nil: + if key, ok := a.priceKey(); ok { + if cost, priced := a.PriceTable.Amount(key, spend); priced { + event = types.CostEvent{Cost: cost, Source: types.CostDeclared} + } + } + } + + if event.Source == types.CostUnknown { + a.CostUnknownCalls++ + } else { + a.CostTotal = a.CostTotal.Add(event.Cost) + } + if cb != nil && cb.OnCost != nil { + cb.OnCost(event) } } @@ -331,7 +370,7 @@ func (a *Agent) Run(ctx context.Context, prompt string) (string, error) { a.HandleContextError(err) return "", fmt.Errorf("LLM call failed: %w", err) } - a.recordUsage(callbacks, resp) + a.recordCall(callbacks, resp) // Reasoning already handled by streaming callback (OnReasoningDelta) if a.ShowThinking { @@ -562,6 +601,11 @@ func (a *Agent) Run(ctx context.Context, prompt string) (string, error) { a.StreamCallbacks.OnUsage(usage) } }, + OnCost: func(event types.CostEvent) { + if a.StreamCallbacks != nil && a.StreamCallbacks.OnCost != nil { + a.StreamCallbacks.OnCost(event) + } + }, } resp, err := a.Provider.Chat(ctx, types.ChatRequest{ Messages: messages, @@ -573,7 +617,7 @@ func (a *Agent) Run(ctx context.Context, prompt string) (string, error) { if err != nil { return "", fmt.Errorf("step limit summary failed: %w", err) } - a.recordUsage(summaryCallbacks, resp) + a.recordCall(summaryCallbacks, resp) return resp.Content, nil } diff --git a/agent/cost_test.go b/agent/cost_test.go new file mode 100644 index 0000000..d708c45 --- /dev/null +++ b/agent/cost_test.go @@ -0,0 +1,398 @@ +package agent + +import ( + "context" + "io" + "strings" + "testing" + "time" + + "github.com/yusiwen/tinycode/types" +) + +// ─── S2: a route that reports its own charge ─────────────────── + +// TestReportedCostIsCarriedVerbatim: the number the route stated is the number +// that is recorded, in the unit the configuration names for that route. Nothing +// multiplies it, and nothing adds a table-derived figure to it. +func TestReportedCostIsCarriedVerbatim(t *testing.T) { + body := `{"choices":[{"message":{"content":"hi"}}],` + + `"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12,"cost":0.00042}}` + + // Routing is what makes the unit knowable; without it the amount would be + // displayed with no unit at all. + provider := &OpenAIProvider{model: "m"} + provider.SetRouteInfo("openrouter", "anthropic/claude-sonnet-4", "credits") + + res, err := provider.chatBatch(context.Background(), io.NopCloser(strings.NewReader(body)), time.Now()) + if err != nil { + t.Fatalf("chatBatch: %v", err) + } + if res.Cost == nil { + t.Fatal("reported cost was dropped") + } + if res.Cost.Amount != 0.00042 || res.Cost.Currency != "credits" { + t.Fatalf("cost = %+v, want 0.00042 credits", *res.Cost) + } +} + +// TestUpstreamCostIsUsedWhenNothingWasBilled covers a BYOK request: the account +// is charged nothing and the upstream figure is the informative one. +func TestUpstreamCostIsUsedWhenNothingWasBilled(t *testing.T) { + body := `{"choices":[{"message":{"content":"hi"}}],` + + `"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12,` + + `"cost_details":{"upstream_inference_cost":19}}}` + provider := &OpenAIProvider{model: "m"} + provider.SetRouteInfo("openrouter", "m", "USD") + + res, err := provider.chatBatch(context.Background(), io.NopCloser(strings.NewReader(body)), time.Now()) + if err != nil { + t.Fatalf("chatBatch: %v", err) + } + if res.Cost == nil || res.Cost.Amount != 19 || res.Cost.Currency != "USD" { + t.Fatalf("cost = %+v, want 19 USD", res.Cost) + } +} + +// TestNoReportedCostStaysNil keeps the three-state rule at the wire boundary: an +// endpoint that says nothing about money reports nothing, not zero. +func TestNoReportedCostStaysNil(t *testing.T) { + body := `{"choices":[{"message":{"content":"hi"}}],` + + `"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}` + res, err := (&OpenAIProvider{model: "m"}).chatBatch(context.Background(), + io.NopCloser(strings.NewReader(body)), time.Now()) + if err != nil { + t.Fatalf("chatBatch: %v", err) + } + if res.Cost != nil { + t.Fatalf("cost = %+v, want nil", *res.Cost) + } +} + +func TestReportedCostFromTheStreamingTail(t *testing.T) { + input := "" + + "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\n" + + "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n" + + "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":194,\"completion_tokens\":2,\"total_tokens\":196,\"cost\":0.95}}\n\n" + + "data: [DONE]\n" + provider := &OpenAIProvider{model: "m"} + provider.SetRouteInfo("openrouter", "m", "credits") + res, err := provider.chatStream(context.Background(), + io.NopCloser(strings.NewReader(input)), time.Now(), &types.StreamCallbacks{}, nil) + if err != nil { + t.Fatalf("chatStream: %v", err) + } + if res.Cost == nil || res.Cost.Amount != 0.95 { + t.Fatalf("cost = %+v, want 0.95", res.Cost) + } +} + +// TestAgentAccumulatesCostPerUnit: two routes billing in different units must not +// be added together — the totals are keyed by unit for exactly that reason. +func TestAgentAccumulatesCostPerUnit(t *testing.T) { + step := 0 + var events []types.CostEvent + provider := &MockProvider{ + ChatFunc: func(ctx context.Context, req types.ChatRequest) (*types.ChatResponse, error) { + step++ + return &types.ChatResponse{ + Content: "answer", + Usage: &types.Usage{PromptTokens: 10, CompletionTokens: 2, TotalTokens: 12}, + Cost: &types.Cost{Amount: float64(step), Currency: []string{"credits", "USD"}[step-1]}, + }, nil + }, + } + a := &Agent{ + Provider: provider, MaxSteps: 5, MaxTokens: 100, SystemPrompt: "test", + StreamCallbacks: &types.StreamCallbacks{ + OnCost: func(ev types.CostEvent) { events = append(events, ev) }, + }, + } + + // Two runs, one call each. + for i := 0; i < 2; i++ { + if _, err := a.Run(context.Background(), "hi"); err != nil { + t.Fatalf("Run %d: %v", i, err) + } + } + + if len(events) != 2 { + t.Fatalf("OnCost fired %d times, want once per call", len(events)) + } + for _, ev := range events { + if ev.Source != types.CostReported { + t.Errorf("event source = %q, want %q", ev.Source, types.CostReported) + } + } + if got := a.CostTotal.String(); got != "1 credits, 2 USD" { + t.Errorf("CostTotal = %q, want %q", got, "1 credits, 2 USD") + } + if a.CostUnknownCalls != 0 { + t.Errorf("CostUnknownCalls = %d, want 0", a.CostUnknownCalls) + } +} + +// ─── S3: a price the user declared ───────────────────────────── + +func TestPriceTableMatchesMostSpecificKeyFirst(t *testing.T) { + table := NewPriceTable("USD") + table.Set("*/*", Price{InputPerMillion: 1}) + table.Set("*/claude-sonnet-4", Price{InputPerMillion: 2}) + table.Set("openrouter/*", Price{InputPerMillion: 3}) + table.Set("openrouter/claude-sonnet-4", Price{InputPerMillion: 4}) + + cases := []struct { + key string + want float64 + }{ + {"openrouter/claude-sonnet-4", 4}, // exact + {"openrouter/claude-opus", 3}, // route wildcard + {"deepseek/claude-sonnet-4", 2}, // model wildcard + {"deepseek/deepseek-v4-flash", 1}, // everything + {"openrouter/anthropic/claude-sonnet-4", 3}, // a model name with a separator: route/* wins + } + for _, tc := range cases { + price, ok := table.Lookup(tc.key) + if !ok { + t.Errorf("Lookup(%q) found nothing", tc.key) + continue + } + if price.InputPerMillion != tc.want { + t.Errorf("Lookup(%q).InputPerMillion = %v, want %v", tc.key, price.InputPerMillion, tc.want) + } + } + if _, ok := NewPriceTable("USD").Lookup("route/model"); ok { + t.Error("an empty table matched a key") + } +} + +// TestPriceAmountCarvesTheLanes: the detail counts are subsets of the totals, so +// they are subtracted out rather than added on top, and each lane uses its own +// rate with the documented fallback. +func TestPriceAmountCarvesTheLanes(t *testing.T) { + // 1M prompt of which 400k cached and 100k written, 200k completion of which + // 50k reasoning. + usage := types.Usage{ + PromptTokens: 1_000_000, CompletionTokens: 200_000, TotalTokens: 1_200_000, + CachedPromptTokens: 400_000, CacheWriteTokens: 100_000, ReasoningTokens: 50_000, + } + price := Price{ + InputPerMillion: 10, + OutputPerMillion: 20, + CacheReadPerMillion: 1, + CacheWritePerMillion: 12, + ReasoningPerMillion: 30, + } + // plain in 500k*10 = 5.0, cached 400k*1 = 0.4, written 100k*12 = 1.2, + // plain out 150k*20 = 3.0, reasoning 50k*30 = 1.5 + want := 5.0 + 0.4 + 1.2 + 3.0 + 1.5 + if got := price.amount(usage); got != want { + t.Errorf("amount = %v, want %v", got, want) + } + + // With no detail rates declared, every lane falls back to the plain rate. + plain := Price{InputPerMillion: 10, OutputPerMillion: 20} + // in 1M*10 = 10, out 200k*20 = 4 + if got, want := plain.amount(usage), 14.0; got != want { + t.Errorf("fallback amount = %v, want %v", got, want) + } +} + +// TestPriceAmountFloorsAtZero: a route reporting a detail larger than its total +// is a route whose numbers we do not understand, and the answer to that is not +// negative money. +func TestPriceAmountFloorsAtZero(t *testing.T) { + usage := types.Usage{ + PromptTokens: 100, CompletionTokens: 10, TotalTokens: 110, + CachedPromptTokens: 500, CacheWriteTokens: 500, ReasoningTokens: 900, + } + price := Price{InputPerMillion: 1, OutputPerMillion: 1} + if got := price.amount(usage); got < 0 { + t.Errorf("amount = %v, want it floored at zero", got) + } +} + +// TestDeclaredPriceAppliesToARouteKey is the end-to-end of S3: a route that +// reports no charge is priced from the declaration, and the event says the number +// was derived rather than reported. +func TestDeclaredPriceAppliesToARouteKey(t *testing.T) { + var events []types.CostEvent + provider := &MockProvider{ + ChatFunc: func(ctx context.Context, req types.ChatRequest) (*types.ChatResponse, error) { + return &types.ChatResponse{ + Content: "answer", + Usage: &types.Usage{PromptTokens: 1_000_000, CompletionTokens: 1_000_000, TotalTokens: 2_000_000}, + }, nil + }, + } + table := NewPriceTable("USD") + table.Set("deepseek/deepseek-v4-flash", Price{InputPerMillion: 0.3, OutputPerMillion: 1.2}) + + a := &Agent{ + Provider: provider, MaxSteps: 5, MaxTokens: 100, SystemPrompt: "test", + PriceTable: table, + StreamCallbacks: &types.StreamCallbacks{ + OnCost: func(ev types.CostEvent) { events = append(events, ev) }, + }, + } + + // The provider is a mock, so the route has to come from somewhere: give the + // agent a provider that names one, as the real providers do. + a.Provider = &routeMockProvider{MockProvider: provider, route: "deepseek", model: "deepseek-v4-flash"} + + if _, err := a.Run(context.Background(), "hi"); err != nil { + t.Fatalf("Run: %v", err) + } + if len(events) != 1 { + t.Fatalf("OnCost fired %d times, want 1", len(events)) + } + if events[0].Source != types.CostDeclared { + t.Errorf("source = %q, want %q", events[0].Source, types.CostDeclared) + } + if want := 0.3 + 1.2; events[0].Cost.Amount != want { + t.Errorf("amount = %v, want %v", events[0].Cost.Amount, want) + } + if got := a.CostTotal.String(); got != "1.5 USD" { + t.Errorf("CostTotal = %q, want %q", got, "1.5 USD") + } +} + +// TestUnpricedCallIsUnknownNotZero is the acceptance that keeps a missing price +// from reading as free. +func TestUnpricedCallIsUnknownNotZero(t *testing.T) { + var events []types.CostEvent + provider := &MockProvider{ + ChatFunc: func(ctx context.Context, req types.ChatRequest) (*types.ChatResponse, error) { + return &types.ChatResponse{ + Content: "answer", + Usage: &types.Usage{PromptTokens: 100, CompletionTokens: 10, TotalTokens: 110}, + }, nil + }, + } + a := &Agent{ + Provider: &routeMockProvider{MockProvider: provider, route: "deepseek", model: "m"}, + MaxSteps: 5, MaxTokens: 100, SystemPrompt: "test", + PriceTable: NewPriceTable("USD"), // declared, but with no rate for this route + StreamCallbacks: &types.StreamCallbacks{ + OnCost: func(ev types.CostEvent) { events = append(events, ev) }, + }, + } + + if _, err := a.Run(context.Background(), "hi"); err != nil { + t.Fatalf("Run: %v", err) + } + if len(events) != 1 || events[0].Source != types.CostUnknown { + t.Fatalf("events = %+v, want one unknown", events) + } + if got := a.CostTotal.String(); got != "" { + t.Errorf("CostTotal = %q, want empty: an unknown cost must not become a zero", got) + } + if a.CostUnknownCalls != 1 { + t.Errorf("CostUnknownCalls = %d, want 1", a.CostUnknownCalls) + } +} + +// TestEstimatedUsageIsNotPriced: multiplying a declared rate by a guessed token +// count would dress a second estimate as a measurement, so a call whose usage we +// had to estimate is unknown even when a rate matches. +func TestEstimatedUsageIsNotPriced(t *testing.T) { + var events []types.CostEvent + provider := &MockProvider{ + ChatFunc: func(ctx context.Context, req types.ChatRequest) (*types.ChatResponse, error) { + return &types.ChatResponse{Content: strings.Repeat("x", 400)}, nil // no usage, no cost + }, + } + table := NewPriceTable("USD") + table.Set("*/*", Price{InputPerMillion: 1, OutputPerMillion: 1}) + + a := &Agent{ + Provider: &routeMockProvider{MockProvider: provider, route: "deepseek", model: "m"}, + MaxSteps: 5, MaxTokens: 100, SystemPrompt: "test", PriceTable: table, + StreamCallbacks: &types.StreamCallbacks{ + OnCost: func(ev types.CostEvent) { events = append(events, ev) }, + }, + } + + if _, err := a.Run(context.Background(), "hi"); err != nil { + t.Fatalf("Run: %v", err) + } + if len(events) != 1 || events[0].Source != types.CostUnknown { + t.Fatalf("events = %+v, want one unknown", events) + } + if a.CostTotal.String() != "" { + t.Errorf("CostTotal = %q, want empty", a.CostTotal.String()) + } +} + +// TestReportedCostWinsOverADeclaredPrice: the route's own figure is a reading of +// the bill; a declared rate is a guess at it. +func TestReportedCostWinsOverADeclaredPrice(t *testing.T) { + provider := &MockProvider{ + ChatFunc: func(ctx context.Context, req types.ChatRequest) (*types.ChatResponse, error) { + return &types.ChatResponse{ + Content: "answer", + Usage: &types.Usage{PromptTokens: 1_000_000, CompletionTokens: 1_000_000, TotalTokens: 2_000_000}, + Cost: &types.Cost{Amount: 7, Currency: "credits"}, + }, nil + }, + } + table := NewPriceTable("USD") + table.Set("*/*", Price{InputPerMillion: 100, OutputPerMillion: 100}) + + a := &Agent{ + Provider: &routeMockProvider{MockProvider: provider, route: "openrouter", model: "m"}, + MaxSteps: 5, MaxTokens: 100, SystemPrompt: "test", PriceTable: table, + } + + if _, err := a.Run(context.Background(), "hi"); err != nil { + t.Fatalf("Run: %v", err) + } + if got := a.CostTotal.String(); got != "7 credits" { + t.Errorf("CostTotal = %q, want %q: the reported figure, not 200 USD from the table", got, "7 credits") + } +} + +// TestAnAgentOverrideChangesThePricedModel: the model a call used is the one the +// agent asked for, not the provider's default, or a session on a cheaper model +// would be billed at the expensive one's rate. +func TestAnAgentOverrideChangesThePricedModel(t *testing.T) { + provider := &MockProvider{ + ChatFunc: func(ctx context.Context, req types.ChatRequest) (*types.ChatResponse, error) { + return &types.ChatResponse{ + Content: "answer", + Usage: &types.Usage{PromptTokens: 1_000_000, TotalTokens: 1_000_000}, + }, nil + }, + } + table := NewPriceTable("USD") + table.Set("deepseek/*", Price{InputPerMillion: 1}) + table.Set("deepseek/deepseek-v4-pro", Price{InputPerMillion: 10}) + + a := &Agent{ + Provider: &routeMockProvider{MockProvider: provider, route: "deepseek", model: "deepseek-v4-flash"}, + MaxSteps: 5, MaxTokens: 100, SystemPrompt: "test", PriceTable: table, + Config: &AgentConfig{Name: "build", Model: "deepseek/deepseek-v4-pro"}, + } + + if _, err := a.Run(context.Background(), "hi"); err != nil { + t.Fatalf("Run: %v", err) + } + // The override is the whole "deepseek/deepseek-v4-pro" string, so the key is + // "deepseek/deepseek/deepseek-v4-pro": the wildcard matches and the exact + // model rate does not. Documented behaviour — the model is used verbatim. + if got := a.CostTotal.String(); got != "1 USD" { + t.Errorf("CostTotal = %q, want 1 USD from the route wildcard", got) + } +} + +// routeMockProvider is a MockProvider that can name a route and a default model, +// which is what the real providers do through RouteInfo. +type routeMockProvider struct { + *MockProvider + route string + model string +} + +func (p *routeMockProvider) Route() string { return p.route } +func (p *routeMockProvider) DefaultModel() string { return p.model } +func (p *routeMockProvider) CostCurrency() string { return "" } diff --git a/agent/pricing.go b/agent/pricing.go new file mode 100644 index 0000000..b2da423 --- /dev/null +++ b/agent/pricing.go @@ -0,0 +1,171 @@ +package agent + +import ( + "strings" + + "github.com/yusiwen/tinycode/types" +) + +// Price is what a route charges for a million tokens, per billing lane. +// +// A zero rate is "not declared" rather than "free", and the fallbacks in Amount +// say what it falls back to: a route that charges the same for cached and +// uncached input declares one input rate, and one that charges nothing for +// anything is not expressible by accident. +type Price struct { + InputPerMillion float64 + OutputPerMillion float64 + CacheReadPerMillion float64 // 0 → the input rate + CacheWritePerMillion float64 // 0 → the input rate + ReasoningPerMillion float64 // 0 → the output rate +} + +// PriceTable is a user's declaration of what their routes charge, keyed by +// "/". +// +// The route is part of the key because a price belongs to a billing endpoint, +// not to a model name: the same model costs differently through a reseller, a +// gateway and the vendor's own API. A route that reports the charge itself never +// consults this table. +// +// A key may use "*" for the route, for the model, or for both, so one rate can +// cover a whole route or a whole family. An exact key always wins, then +// "route/*", then "*/model", then "*/*" — the order is fixed so two entries can +// never both claim a call. +type PriceTable struct { + // Currency names the unit the declared rates are in, e.g. "USD". It is the + // user's invoice unit, which is not necessarily the unit a route reports its + // own charge in; the two are accumulated apart rather than added. + Currency string + prices map[string]Price +} + +// NewPriceTable returns an empty table. A nil table prices nothing, which is +// reported as an unknown cost rather than as zero. +func NewPriceTable(currency string) *PriceTable { + return &PriceTable{Currency: currency, prices: map[string]Price{}} +} + +// Set declares the rate for one key. An empty key is ignored: a declaration that +// cannot match anything is a typo, not a rate for everything. +func (t *PriceTable) Set(key string, price Price) { + if t == nil || strings.TrimSpace(key) == "" { + return + } + if t.prices == nil { + t.prices = map[string]Price{} + } + t.prices[key] = price +} + +// Len is the number of declared keys, for a caller that has to report whether a +// table is empty. +func (t *PriceTable) Len() int { + if t == nil { + return 0 + } + return len(t.prices) +} + +// Lookup returns the rate that covers a key, most specific first. +func (t *PriceTable) Lookup(key string) (Price, bool) { + if t == nil { + return Price{}, false + } + route, model, ok := splitPriceKey(key) + if !ok { + return Price{}, false + } + for _, candidate := range []string{ + route + "/" + model, + route + "/*", + "*/" + model, + "*/*", + } { + if price, found := t.prices[candidate]; found { + return price, true + } + } + return Price{}, false +} + +// Amount prices one usage record, or reports that no declared rate covers the +// call. The result is an estimate of the bill computed from rates the user +// typed, which is why it comes back with its source attached by the caller. +func (t *PriceTable) Amount(key string, usage types.Usage) (types.Cost, bool) { + price, ok := t.Lookup(key) + if !ok { + return types.Cost{}, false + } + return types.Cost{Amount: price.amount(usage), Currency: t.Currency}, true +} + +// amount applies the lane rates to one usage record. +// +// The detail fields are subsets of the counts beside them (see types.Usage), so +// the lanes are carved out of the totals rather than added on top: cached and +// written tokens come out of the prompt, reasoning tokens out of the completion. +// Each subtraction is floored at zero, because a route that reports a detail +// larger than its total is a route whose numbers we do not understand, and +// negative money is not a useful way to say so. +func (p Price) amount(u types.Usage) float64 { + const million = 1_000_000 + + writeRate := p.CacheWritePerMillion + if writeRate == 0 { + writeRate = p.InputPerMillion + } + readRate := p.CacheReadPerMillion + if readRate == 0 { + readRate = p.InputPerMillion + } + reasoningRate := p.ReasoningPerMillion + if reasoningRate == 0 { + reasoningRate = p.OutputPerMillion + } + + written := min(max(u.CacheWriteTokens, 0), u.PromptTokens) + cached := min(max(u.CachedPromptTokens, 0), u.PromptTokens-written) + plainInput := max(u.PromptTokens-written-cached, 0) + + reasoning := min(max(u.ReasoningTokens, 0), u.CompletionTokens) + plainOutput := max(u.CompletionTokens-reasoning, 0) + + total := float64(plainInput)*p.InputPerMillion + + float64(cached)*readRate + + float64(written)*writeRate + + float64(plainOutput)*p.OutputPerMillion + + float64(reasoning)*reasoningRate + return total / million +} + +// splitPriceKey splits "route/model" on the first separator, because a model +// name may contain one: an OpenRouter model is itself "vendor/model". +func splitPriceKey(key string) (route, model string, ok bool) { + route, model, ok = strings.Cut(key, "/") + if !ok || route == "" || model == "" { + return "", "", false + } + return route, model, true +} + +// priceKey names the route and model a call is priced against. +// +// The model is whatever the request actually used: an agent-level override when +// it has one, otherwise the provider's own default. It comes from the provider +// at call time rather than from a value captured when the agent was built, +// because the route can be switched mid-session. +func (a *Agent) priceKey() (string, bool) { + info, ok := a.Provider.(RouteInfo) + if !ok { + return "", false + } + route, model := info.Route(), info.DefaultModel() + if override := a.getModel(); override != "" { + model = override + } + if route == "" || model == "" { + return "", false + } + return route + "/" + model, true +} diff --git a/agent/provider_ollama.go b/agent/provider_ollama.go index 964df40..023ec09 100644 --- a/agent/provider_ollama.go +++ b/agent/provider_ollama.go @@ -59,8 +59,32 @@ type OllamaProvider struct { baseURL string model string client *http.Client + + // route is the name the user gave this provider in config.json. A local + // route never reports a charge, so there is no cost currency to carry: a + // declared price for it is denominated by the pricing configuration. + route string +} + +// SetRouteInfo names the configuration route this provider serves. The cost +// currency is accepted for the shared signature and ignored: a local endpoint +// reports no charge in any unit. +func (p *OllamaProvider) SetRouteInfo(route, model, _ string) { + p.route = route + if model != "" { + p.model = model + } } +// Route returns the configuration name the user gave this provider. +func (p *OllamaProvider) Route() string { return p.route } + +// DefaultModel returns the model this provider uses when a request names none. +func (p *OllamaProvider) DefaultModel() string { return p.model } + +// CostCurrency is always empty: this route reports no charge. +func (p *OllamaProvider) CostCurrency() string { return "" } + func NewOllamaProvider(baseURL, model string) *OllamaProvider { if baseURL == "" { baseURL = "http://localhost:11434" diff --git a/agent/provider_openai.go b/agent/provider_openai.go index fea9a32..b8f8315 100644 --- a/agent/provider_openai.go +++ b/agent/provider_openai.go @@ -63,8 +63,46 @@ type OpenAIProvider struct { model string apiKey string baseURL string + + // route is the name the user gave this provider in config.json, and + // costCurrency is the unit its route bills a reported cost in. Both are set + // once from the configuration (SetRouteInfo) and are empty for a provider + // built without one. + route string + costCurrency string +} + +// RouteInfo is implemented by a provider that can say which configuration route +// it serves and which model it uses by default. +// +// The agent needs both to look a price up, and only the provider knows them: a +// route can be switched while a session is running, so a value captured when the +// agent was built would go stale and price a call against another route's rates. +type RouteInfo interface { + Route() string + DefaultModel() string + CostCurrency() string } +// SetRouteInfo names the configuration route this provider serves, its default +// model and the unit that route bills a reported cost in. +func (p *OpenAIProvider) SetRouteInfo(route, model, costCurrency string) { + p.route = route + if model != "" { + p.model = model + } + p.costCurrency = costCurrency +} + +// Route returns the configuration name the user gave this provider. +func (p *OpenAIProvider) Route() string { return p.route } + +// DefaultModel returns the model this provider uses when a request names none. +func (p *OpenAIProvider) DefaultModel() string { return p.model } + +// CostCurrency returns the unit this route bills a reported cost in. +func (p *OpenAIProvider) CostCurrency() string { return p.costCurrency } + // NewOpenAIProvider creates a provider for OpenAI-compatible APIs (DeepSeek, OpenAI, etc.). func NewOpenAIProvider(apiKey, baseURL, model string) *OpenAIProvider { config := openai.DefaultConfig(apiKey) @@ -243,6 +281,7 @@ func (p *OpenAIProvider) chatBatch(ctx context.Context, body io.ReadCloser, star Content: choice.Content, ReasoningContent: choice.ReasoningContent, Usage: rawResp.Usage.toUsage(), + Cost: rawResp.Usage.toCost(p.costCurrency), } if len(choice.ToolCalls) > 0 { @@ -327,6 +366,7 @@ func (p *OpenAIProvider) chatStream(ctx context.Context, body io.ReadCloser, sta // array, so it has to be read before the choices check below. if event.Usage != nil { result.Usage = event.Usage.toUsage() + result.Cost = event.Usage.toCost(p.costCurrency) } if len(event.Choices) == 0 { @@ -413,10 +453,36 @@ type jsonToolCallRef struct { // usageJSON mirrors the OpenAI usage object, which the batch body and the final // streaming chunk both carry. It is a pointer at every call site: an absent // object means the endpoint reported nothing, which is not the same as zero. +// +// The wire names differ per route while the meaning does not, so this type is +// the only place that knows them: prompt_tokens_details.cached_tokens (OpenAI, +// OpenRouter, DeepSeek's newer shape) and prompt_cache_hit_tokens (DeepSeek's +// older one) are the same lane, and both end up in Usage.CachedPromptTokens. type usageJSON struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` TotalTokens int `json:"total_tokens"` + + PromptTokensDetails *struct { + CachedTokens int `json:"cached_tokens"` + CacheWriteTokens int `json:"cache_write_tokens"` + } `json:"prompt_tokens_details,omitempty"` + CompletionTokensDetails *struct { + ReasoningTokens int `json:"reasoning_tokens"` + } `json:"completion_tokens_details,omitempty"` + + // DeepSeek's own names for the input split. They are read only when the + // nested shape above is absent, so a route that sends both is not + // double-counted. + PromptCacheHitTokens int `json:"prompt_cache_hit_tokens,omitempty"` + PromptCacheMissTokens int `json:"prompt_cache_miss_tokens,omitempty"` + + // Reported cost (OpenRouter). Its unit is the account's, not a currency code, + // which is why it is carried through verbatim rather than converted. + Cost *float64 `json:"cost,omitempty"` + CostDetail *struct { + UpstreamInferenceCost *float64 `json:"upstream_inference_cost,omitempty"` + } `json:"cost_details,omitempty"` } // toUsage normalizes a reported usage object for the shared types.Usage. A @@ -430,9 +496,38 @@ func (u *usageJSON) toUsage() *types.Usage { if total == 0 { total = u.PromptTokens + u.CompletionTokens } - return &types.Usage{ + usage := &types.Usage{ PromptTokens: u.PromptTokens, CompletionTokens: u.CompletionTokens, TotalTokens: total, } + if d := u.PromptTokensDetails; d != nil { + usage.CachedPromptTokens = d.CachedTokens + usage.CacheWriteTokens = d.CacheWriteTokens + } else { + usage.CachedPromptTokens = u.PromptCacheHitTokens + } + // prompt_cache_miss_tokens is the rest of the prompt, so it is not a field of + // its own: deriving it from the hit count keeps one number per lane. + if d := u.CompletionTokensDetails; d != nil { + usage.ReasoningTokens = d.ReasoningTokens + } + return usage +} + +// toCost reports the charge a route stated for this request, or nil when it +// stated none. The upstream figure is a detail of the charge, not a second one, +// so it is only used when the route billed nothing itself (a BYOK request, where +// the account is charged nothing and the upstream cost is the informative one). +func (u *usageJSON) toCost(currency string) *types.Cost { + if u == nil { + return nil + } + if u.Cost != nil { + return &types.Cost{Amount: *u.Cost, Currency: currency} + } + if u.CostDetail != nil && u.CostDetail.UpstreamInferenceCost != nil { + return &types.Cost{Amount: *u.CostDetail.UpstreamInferenceCost, Currency: currency} + } + return nil } diff --git a/agent/usage_detail_test.go b/agent/usage_detail_test.go new file mode 100644 index 0000000..b7eca8e --- /dev/null +++ b/agent/usage_detail_test.go @@ -0,0 +1,123 @@ +package agent + +import ( + "context" + "io" + "strings" + "testing" + "time" + + "github.com/yusiwen/tinycode/types" +) + +// ─── S1: the cache-tier usage detail ─────────────────────────── + +// TestUsageDetailFromEitherShape pins the normalization: the same lane arrives +// under two wire names depending on the route, and both have to land in one +// field. A cache-hit call and a cache-miss call with identical totals must not +// be the same record, which is what the old Usage could not express. +func TestUsageDetailFromEitherShape(t *testing.T) { + cases := []struct { + name string + usage string + want types.Usage + }{ + { + name: "openai and openrouter nested shape", + usage: `{"prompt_tokens":100,"completion_tokens":20,"total_tokens":120,"prompt_tokens_details":{"cached_tokens":60,"cache_write_tokens":10},"completion_tokens_details":{"reasoning_tokens":8}}`, + want: types.Usage{ + PromptTokens: 100, CompletionTokens: 20, TotalTokens: 120, + CachedPromptTokens: 60, CacheWriteTokens: 10, ReasoningTokens: 8, + }, + }, + { + name: "deepseek flat shape", + usage: `{"prompt_tokens":100,"completion_tokens":20,"total_tokens":120,"prompt_cache_hit_tokens":60,"prompt_cache_miss_tokens":40}`, + want: types.Usage{ + PromptTokens: 100, CompletionTokens: 20, TotalTokens: 120, + CachedPromptTokens: 60, + }, + }, + { + name: "no detail at all", + usage: `{"prompt_tokens":100,"completion_tokens":20,"total_tokens":120}`, + want: types.Usage{PromptTokens: 100, CompletionTokens: 20, TotalTokens: 120}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body := `{"choices":[{"message":{"role":"assistant","content":"hi"}}],"usage":` + tc.usage + `}` + res, err := (&OpenAIProvider{model: "m"}).chatBatch(context.Background(), + io.NopCloser(strings.NewReader(body)), time.Now()) + if err != nil { + t.Fatalf("chatBatch: %v", err) + } + if res.Usage == nil { + t.Fatal("usage was dropped") + } + if *res.Usage != tc.want { + t.Errorf("usage = %+v, want %+v", *res.Usage, tc.want) + } + }) + } +} + +// TestUsageDetailPrefersTheNestedShape: a route that sends both must not have its +// cache tokens counted twice, so the nested object wins and the flat fields are +// ignored rather than added. +func TestUsageDetailPrefersTheNestedShape(t *testing.T) { + body := `{"choices":[{"message":{"content":"hi"}}],"usage":{` + + `"prompt_tokens":100,"completion_tokens":20,"total_tokens":120,` + + `"prompt_tokens_details":{"cached_tokens":60},"prompt_cache_hit_tokens":99}}` + res, err := (&OpenAIProvider{model: "m"}).chatBatch(context.Background(), + io.NopCloser(strings.NewReader(body)), time.Now()) + if err != nil { + t.Fatalf("chatBatch: %v", err) + } + if res.Usage.CachedPromptTokens != 60 { + t.Errorf("cached = %d, want the nested 60 (the flat 99 must not win or add)", + res.Usage.CachedPromptTokens) + } +} + +// TestUsageDetailFromTheStreamingTail covers the same parsing on the streaming +// path, where the usage arrives in its own chunk after finish_reason. +func TestUsageDetailFromTheStreamingTail(t *testing.T) { + input := "" + + "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\n" + + "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n" + + "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":194,\"completion_tokens\":2,\"total_tokens\":196,\"prompt_tokens_details\":{\"cached_tokens\":100,\"cache_write_tokens\":94},\"completion_tokens_details\":{\"reasoning_tokens\":1}}}\n\n" + + "data: [DONE]\n" + res, err := (&OpenAIProvider{model: "m"}).chatStream(context.Background(), + io.NopCloser(strings.NewReader(input)), time.Now(), &types.StreamCallbacks{}, nil) + if err != nil { + t.Fatalf("chatStream: %v", err) + } + want := types.Usage{ + PromptTokens: 194, CompletionTokens: 2, TotalTokens: 196, + CachedPromptTokens: 100, CacheWriteTokens: 94, ReasoningTokens: 1, + } + if res.Usage == nil || *res.Usage != want { + t.Fatalf("usage = %+v, want %+v", res.Usage, want) + } +} + +// TestUsageAddKeepsEveryLane guards the accumulator: a lane that Add forgot +// would silently vanish from a session total. +func TestUsageAddKeepsEveryLane(t *testing.T) { + a := types.Usage{ + PromptTokens: 1, CompletionTokens: 2, TotalTokens: 3, + CachedPromptTokens: 4, CacheWriteTokens: 5, ReasoningTokens: 6, + } + b := types.Usage{ + PromptTokens: 10, CompletionTokens: 20, TotalTokens: 30, + CachedPromptTokens: 40, CacheWriteTokens: 50, ReasoningTokens: 60, + } + want := types.Usage{ + PromptTokens: 11, CompletionTokens: 22, TotalTokens: 33, + CachedPromptTokens: 44, CacheWriteTokens: 55, ReasoningTokens: 66, + } + if got := a.Add(b); got != want { + t.Errorf("Add = %+v, want %+v", got, want) + } +} diff --git a/config.example.json b/config.example.json index be4e09b..5d004dd 100644 --- a/config.example.json +++ b/config.example.json @@ -23,7 +23,8 @@ "name": "openrouter", "type": "openai", "model": "anthropic/claude-sonnet-4", - "base_url": "https://openrouter.ai/api/v1" + "base_url": "https://openrouter.ai/api/v1", + "cost_currency": "credits" }, { "name": "groq", @@ -56,6 +57,22 @@ "output_dir": "/tmp/tinycode/truncated" }, + "pricing": { + "$comment": "Optional. What a route charges per million tokens, for the routes that do NOT report a charge themselves (OpenRouter does; see cost_currency above). Keys are '/' where is the provider name above; '*' is allowed for the route, the model or both. A zero detail rate falls back to the lane it is carved out of: cached and written input to the input rate, reasoning to the output rate. currency names the unit your bill is in and is deliberately not defaulted. A call with no declared rate and no reported charge is displayed as unknown, not as zero.", + "currency": "USD", + "prices": { + "deepseek/deepseek-v4-flash": { + "input_per_million": 0.30, + "output_per_million": 1.20, + "cache_read_per_million": 0.006 + }, + "ollama/*": { + "input_per_million": 0, + "output_per_million": 0 + } + } + }, + "budget": { "$comment": "Optional cumulative token budgets, as the provider reports them. Both default to 0 = unlimited. max_tokens_per_run bounds one prompt's ReAct loop; max_tokens_per_session bounds every run on this session together. A run stops before its next provider call once either is reached.", "max_tokens_per_run": 0, diff --git a/config/config.go b/config/config.go index a9d5d35..61219df 100644 --- a/config/config.go +++ b/config/config.go @@ -23,6 +23,11 @@ type ProviderRecordConfig struct { Model string `json:"model,omitempty"` BaseURL string `json:"base_url,omitempty"` APIKeyEnv string `json:"api_key_env,omitempty"` // env var name for API key + // CostCurrency names the unit this route bills a *reported* charge in, e.g. + // "credits" for an account that bills in its own credits. Empty means the + // route's unit is unknown, and an amount is then displayed without one + // rather than labelled with a unit nobody stated. + CostCurrency string `json:"cost_currency,omitempty"` } // TruncationConfig holds tool output truncation settings. @@ -32,6 +37,39 @@ type TruncationConfig struct { OutputDir string `json:"output_dir,omitempty"` } +// PricingConfig declares what a route charges, for the routes that do not report +// a charge themselves. +// +// The key of Prices is "/", where the route is the provider's name +// in this file — a price belongs to a billing endpoint, not to a model name, +// because the same model costs differently through a reseller, a gateway and the +// vendor's own API. "*" is allowed for the route, the model or both, so one rate +// can cover a whole route. +// +// Currency names the unit the rates are in (e.g. "USD"). It is deliberately not +// defaulted: a number with the wrong unit is worse than a number whose unit the +// user has to state. It is also independent of a provider's own +// cost_currency — an account billed in credits can still be priced from an +// invoice in dollars, and the two are accumulated apart rather than added. +type PricingConfig struct { + Currency string `json:"currency,omitempty"` + Prices map[string]PriceEntry `json:"prices,omitempty"` +} + +// PriceEntry is a rate per million tokens, per billing lane. +// +// A zero detail rate falls back to the lane it is carved out of: cached and +// written input to the input rate, reasoning to the output rate. That is how a +// route with one input price and one output price is declared, and it is why a +// zero here is "not separately priced" rather than "free". +type PriceEntry struct { + InputPerMillion float64 `json:"input_per_million"` + OutputPerMillion float64 `json:"output_per_million"` + CacheReadPerMillion float64 `json:"cache_read_per_million,omitempty"` + CacheWritePerMillion float64 `json:"cache_write_per_million,omitempty"` + ReasoningPerMillion float64 `json:"reasoning_per_million,omitempty"` +} + // BudgetConfig bounds what a run and a session may spend, in tokens, as the // provider reports them. // @@ -123,6 +161,7 @@ type Config struct { Providers []ProviderRecordConfig `json:"providers,omitempty"` Truncation *TruncationConfig `json:"truncation,omitempty"` Budget *BudgetConfig `json:"budget,omitempty"` + Pricing *PricingConfig `json:"pricing,omitempty"` Agents map[string]AgentOverride `json:"agents,omitempty"` Sandbox *SandboxConfig `json:"sandbox,omitempty"` Theme string `json:"theme,omitempty"` @@ -246,6 +285,25 @@ func merge(dst, src Config) Config { dst.Budget.MaxTokensPerSession = src.Budget.MaxTokensPerSession } } + if src.Pricing != nil { + if dst.Pricing == nil { + dst.Pricing = &PricingConfig{} + } + if src.Pricing.Currency != "" { + dst.Pricing.Currency = src.Pricing.Currency + } + // Rates merge per key, so a project file can price one route the user's + // global file does not mention. A later layer wins for the keys it names + // and leaves the rest alone. + if len(src.Pricing.Prices) > 0 { + if dst.Pricing.Prices == nil { + dst.Pricing.Prices = map[string]PriceEntry{} + } + for key, entry := range src.Pricing.Prices { + dst.Pricing.Prices[key] = entry + } + } + } if src.SessionDir != "" { dst.SessionDir = src.SessionDir } diff --git a/config/config_test.go b/config/config_test.go index 2e246ec..0eac00c 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -491,3 +491,69 @@ func TestBudgetRoundTripsThroughJSON(t *testing.T) { t.Errorf("TokenBudgets() = %d/%d, want 120000/2000000", run, session) } } + +// TestMergePricingRates covers the merge rule for declared rates: the currency is +// taken from the layer that sets it, and rates merge per key so a project file can +// price one route without dropping the ones the global file declared. +func TestMergePricingRates(t *testing.T) { + base := merge(DefaultConfig(), Config{Pricing: &PricingConfig{ + Currency: "USD", + Prices: map[string]PriceEntry{ + "deepseek/*": {InputPerMillion: 0.3, OutputPerMillion: 1.2}, + }, + }}) + if base.Pricing == nil || base.Pricing.Currency != "USD" { + t.Fatalf("pricing after the first layer = %+v, want USD", base.Pricing) + } + + got := merge(base, Config{Pricing: &PricingConfig{ + Prices: map[string]PriceEntry{ + "openrouter/*": {InputPerMillion: 3}, + }, + }}) + if len(got.Pricing.Prices) != 2 { + t.Fatalf("prices = %v, want both keys kept", got.Pricing.Prices) + } + if got.Pricing.Prices["deepseek/*"].InputPerMillion != 0.3 { + t.Errorf("deepseek/* = %v, want the earlier layer kept", got.Pricing.Prices["deepseek/*"]) + } + if got.Pricing.Prices["openrouter/*"].InputPerMillion != 3 { + t.Errorf("openrouter/* = %v, want the later layer added", got.Pricing.Prices["openrouter/*"]) + } + if got.Pricing.Currency != "USD" { + t.Errorf("currency = %q, want USD: a layer that sets none must not clear it", got.Pricing.Currency) + } + + // A later layer replaces the rate it names. + got = merge(got, Config{Pricing: &PricingConfig{ + Currency: "EUR", + Prices: map[string]PriceEntry{"deepseek/*": {InputPerMillion: 9}}, + }}) + if got.Pricing.Prices["deepseek/*"].InputPerMillion != 9 { + t.Errorf("deepseek/* = %v, want the later rate", got.Pricing.Prices["deepseek/*"]) + } + if got.Pricing.Currency != "EUR" { + t.Errorf("currency = %q, want EUR", got.Pricing.Currency) + } +} + +// TestPricingRoundTripsThroughJSON pins the key names a user types, including the +// route/model key with a model name that itself contains a separator. +func TestPricingRoundTripsThroughJSON(t *testing.T) { + raw := []byte(`{"providers":[{"name":"openrouter","type":"openai","model":"m","cost_currency":"credits"}],` + + `"pricing":{"currency":"USD","prices":{"openrouter/anthropic/claude-sonnet-4":{"input_per_million":3,"output_per_million":15,"cache_read_per_million":0.3}}}}`) + var cfg Config + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if cfg.Providers[0].CostCurrency != "credits" { + t.Errorf("cost_currency = %q, want credits", cfg.Providers[0].CostCurrency) + } + entry, ok := cfg.Pricing.Prices["openrouter/anthropic/claude-sonnet-4"] + if !ok { + t.Fatalf("prices = %v, want the route/model key", cfg.Pricing.Prices) + } + if entry.CacheReadPerMillion != 0.3 { + t.Errorf("cache_read_per_million = %v, want 0.3", entry.CacheReadPerMillion) + } +} diff --git a/main.go b/main.go index 2460cc3..bd3290e 100644 --- a/main.go +++ b/main.go @@ -142,6 +142,10 @@ func newRootCmd() *cobra.Command { } // Cumulative token budgets (0 = unlimited, the default). ag.BudgetTokensPerRun, ag.BudgetTokensPerSession = cfg.TokenBudgets() + // Declared prices price a call whose route reported no charge. An + // empty table prices nothing, which the agent reports as an unknown + // cost rather than as zero. + ag.PriceTable = priceTable(&cfg) if cfg.Truncation != nil { agent.SetTruncationConfig(cfg.Truncation.MaxLines, cfg.Truncation.MaxBytes, expandPath(cfg.Truncation.OutputDir)) } @@ -274,6 +278,7 @@ func newRootCmd() *cobra.Command { // Sub-agents enforce the same limits against their own spend. BudgetTokensPerRun: ag.BudgetTokensPerRun, BudgetTokensPerSession: ag.BudgetTokensPerSession, + PriceTable: ag.PriceTable, GetAgentConfig: func(name string) *agent.AgentConfig { cfg, err := reg.Get(name) if err != nil { @@ -735,6 +740,12 @@ func buildProviderRegistry(cfg *config.Config, apiKey, model, baseURL string) *a // "openai" or unknown — use an OpenAI-compatible provider prov = agent.NewOpenAIProvider(key, base, modelName) } + // A route is what a price is keyed on: the same model costs differently + // through different routes, so the provider has to carry the name the + // user gave it rather than only its type and model. + if ri, ok := prov.(interface{ SetRouteInfo(string, string, string) }); ok { + ri.SetRouteInfo(pc.Name, modelName, pc.CostCurrency) + } records = append(records, agent.ProviderRecord{ Name: pc.Name, Provider: prov, @@ -743,14 +754,36 @@ func buildProviderRegistry(cfg *config.Config, apiKey, model, baseURL string) *a // Fallback: if no providers are configured, create a default one. if len(records) == 0 { + prov := agent.NewOpenAIProvider(apiKey, baseURL, model) + prov.SetRouteInfo("default", model, "") records = append(records, agent.ProviderRecord{ Name: "default", - Provider: agent.NewOpenAIProvider(apiKey, baseURL, model), + Provider: prov, }) } return agent.NewProviderRegistry(records) } +// priceTable turns the declared rates in the configuration into the table the +// agent prices a call against. It returns nil when nothing is declared, which +// the agent reports as an unknown cost rather than as zero. +func priceTable(cfg *config.Config) *agent.PriceTable { + if cfg == nil || cfg.Pricing == nil || len(cfg.Pricing.Prices) == 0 { + return nil + } + table := agent.NewPriceTable(cfg.Pricing.Currency) + for key, entry := range cfg.Pricing.Prices { + table.Set(key, agent.Price{ + InputPerMillion: entry.InputPerMillion, + OutputPerMillion: entry.OutputPerMillion, + CacheReadPerMillion: entry.CacheReadPerMillion, + CacheWritePerMillion: entry.CacheWritePerMillion, + ReasoningPerMillion: entry.ReasoningPerMillion, + }) + } + return table +} + // applyAgentOverrides folds the config's per-agent overrides into the registry. // Unknown agent names are ignored so a stale config entry cannot break startup, // but an invalid permission effect is an error: silently dropping it would grant diff --git a/tool/bgtask.go b/tool/bgtask.go index 91fcbc1..a9971ce 100644 --- a/tool/bgtask.go +++ b/tool/bgtask.go @@ -98,6 +98,7 @@ func (mgr *BackgroundTaskManager) Start(deps *TaskToolDeps, name, goal string) s sub.SessionStore = nil sub.BudgetTokensPerRun = deps.BudgetTokensPerRun sub.BudgetTokensPerSession = deps.BudgetTokensPerSession + sub.PriceTable = deps.PriceTable tlog.Debug("task.bg", "start", "id", id, "agent", name, "goal", goal, "tools", len(subTools), "maxSteps", cfg.MaxSteps) diff --git a/tool/task.go b/tool/task.go index 3c69778..289f77f 100644 --- a/tool/task.go +++ b/tool/task.go @@ -35,6 +35,10 @@ type TaskToolDeps struct { // otherwise would report a number that cannot be computed. BudgetTokensPerRun int BudgetTokensPerSession int + // PriceTable is the declared rate table sub-agents price against, so a + // delegated call is accounted the same way as one the parent made. Nil + // prices nothing, which is reported as an unknown cost rather than as zero. + PriceTable *agent.PriceTable } // TaskTool creates the task tool for delegating to sub-agents. @@ -107,6 +111,7 @@ func TaskTool(deps *TaskToolDeps) agent.Tool { sub.SessionStore = nil sub.BudgetTokensPerRun = deps.BudgetTokensPerRun sub.BudgetTokensPerSession = deps.BudgetTokensPerSession + sub.PriceTable = deps.PriceTable tlog.Debug("task", "start", "agent", name, "goal", goal, "tools", len(subTools), "maxSteps", cfg.MaxSteps) diff --git a/tui/cost_test.go b/tui/cost_test.go new file mode 100644 index 0000000..850db07 --- /dev/null +++ b/tui/cost_test.go @@ -0,0 +1,108 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/yusiwen/tinycode/config" + "github.com/yusiwen/tinycode/types" +) + +// TestStatusBarShowsCostOnlyWhenThereIsSome is what keeps the committed frame +// goldens byte-identical: a session with no cost information renders exactly the +// bar it always has. +func TestStatusBarShowsCostOnlyWhenThereIsSome(t *testing.T) { + m := layoutModel(30) + m.config = &config.Config{} + m.sessionTokens = 1200 + + if bar := m.renderStatusBar(); strings.Contains(bar, "cost") { + t.Errorf("bar = %q, want no cost segment before any cost is known", bar) + } +} + +// TestStatusBarShowsCostPerUnit: money carries its unit, and the calls nobody +// could price are shown beside the total rather than folded into it. +func TestStatusBarShowsCostPerUnit(t *testing.T) { + m := layoutModel(30) + m.config = &config.Config{} + + m.Update(CostMsg{Event: types.CostEvent{ + Cost: types.Cost{Amount: 0.95, Currency: "credits"}, + Source: types.CostReported, + }}) + if bar := m.renderStatusBar(); !strings.Contains(bar, "cost: 0.95 credits") { + t.Errorf("bar = %q, want the reported cost with its unit", bar) + } + + // A second unit is accumulated apart, never added to the first. + m.Update(CostMsg{Event: types.CostEvent{ + Cost: types.Cost{Amount: 2, Currency: "USD"}, + Source: types.CostDeclared, + }}) + if bar := m.renderStatusBar(); !strings.Contains(bar, "cost: 0.95 credits, 2 USD") { + t.Errorf("bar = %q, want both units", bar) + } + + // Unknown costs are counted, not dropped and not zeroed. + m.Update(CostMsg{Event: types.CostEvent{Source: types.CostUnknown}}) + m.Update(CostMsg{Event: types.CostEvent{Source: types.CostUnknown}}) + bar := m.renderStatusBar() + if !strings.Contains(bar, "cost: 0.95 credits, 2 USD (+2 unpriced)") { + t.Errorf("bar = %q, want the unpriced count beside the total", bar) + } + if m.unpricedCalls != 2 { + t.Errorf("unpricedCalls = %d, want 2", m.unpricedCalls) + } +} + +// TestStatusBarSaysUnknownWhenNothingCouldBePriced: a session whose calls were +// all unpriced must say so, not show nothing and not show zero. +func TestStatusBarSaysUnknownWhenNothingCouldBePriced(t *testing.T) { + m := layoutModel(30) + m.config = &config.Config{} + + m.Update(CostMsg{Event: types.CostEvent{Source: types.CostUnknown}}) + + bar := m.renderStatusBar() + if !strings.Contains(bar, "cost: unknown (1 unpriced)") { + t.Errorf("bar = %q, want an explicit unknown", bar) + } + if strings.Contains(bar, "cost: 0") { + t.Errorf("bar = %q, want no zero: an unpriced call is not a free one", bar) + } +} + +// TestCostEventFromASupersededRunIsIgnored keeps a cancelled run's accounting out +// of the current session's money. +func TestCostEventFromASupersededRunIsIgnored(t *testing.T) { + m := layoutModel(30) + m.runActive = true + m.runID = 7 + + m.Update(CostMsg{RunID: 6, Event: types.CostEvent{ + Cost: types.Cost{Amount: 5, Currency: "USD"}, + Source: types.CostReported, + }}) + + if len(m.sessionCost) != 0 || m.unpricedCalls != 0 { + t.Errorf("cost=%v unpriced=%d, want a superseded run ignored", m.sessionCost, m.unpricedCalls) + } +} + +// TestResetSessionStatsClearsCost: switching the transcript must not carry +// another conversation's money into this one's bar. +func TestResetSessionStatsClearsCost(t *testing.T) { + m := layoutModel(30) + m.Update(CostMsg{Event: types.CostEvent{ + Cost: types.Cost{Amount: 1, Currency: "USD"}, + Source: types.CostReported, + }}) + m.Update(CostMsg{Event: types.CostEvent{Source: types.CostUnknown}}) + + m.resetSessionStats() + + if len(m.sessionCost) != 0 || m.unpricedCalls != 0 { + t.Errorf("cost=%v unpriced=%d, want both cleared", m.sessionCost, m.unpricedCalls) + } +} diff --git a/tui/messages.go b/tui/messages.go index aad9c3b..8fcb9e6 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -20,6 +20,15 @@ type UsageMsg struct { Usage types.Usage } +// CostMsg carries what one LLM call cost, and where that number came from. The +// agent emits it once per call, in stream order, right after that call's +// UsageMsg. An unknown source is an event like any other: a call nobody could +// price has to be visible as such rather than silently counted as free. +type CostMsg struct { + RunID uint64 // generation id of the producing run (0 in legacy tests) + Event types.CostEvent +} + // StreamDone is sent when the agent completes (final answer or error). type StreamDone struct { RunID uint64 // generation id of the producing run (0 in legacy tests) diff --git a/tui/model.go b/tui/model.go index af14231..f92633b 100644 --- a/tui/model.go +++ b/tui/model.go @@ -19,6 +19,7 @@ import ( "github.com/yusiwen/tinycode/skill" "github.com/yusiwen/tinycode/tlog" "github.com/yusiwen/tinycode/tool" + "github.com/yusiwen/tinycode/types" ) // Button represents a clickable region in the message area. @@ -150,6 +151,12 @@ type TuiModel struct { callTokens int sessionTitle string // auto-generated conversation title sessionToolCalls int + // sessionCost is the money this session is known to have cost, per unit, and + // unpricedCalls counts the calls whose cost is not known. The bar shows both, + // because a total without its unknowns is a different claim from a total + // alone. + sessionCost types.CostTotals + unpricedCalls int // LSP diagnostics tracking diagTotal int // total errors across all files @@ -576,6 +583,8 @@ func (m *TuiModel) resetSessionStats() { m.sessionTokens = 0 m.callTokens = 0 m.sessionToolCalls = 0 + m.sessionCost = nil + m.unpricedCalls = 0 } // ensureMsgTracking ensures msgDirty and msgRowCount arrays match m.messages length. diff --git a/tui/update.go b/tui/update.go index 700c455..7f6cd5c 100644 --- a/tui/update.go +++ b/tui/update.go @@ -521,6 +521,20 @@ func (m *TuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.autoScroll() return m, m.waitForStream() + case CostMsg: + if m.isStaleRun(msg.RunID) { + return m, m.waitForStream() + } + // One event per call, including the calls nobody could price: an unknown + // cost is counted as unknown, never as zero. + if msg.Event.Source == types.CostUnknown { + m.unpricedCalls++ + } else { + m.sessionCost = m.sessionCost.Add(msg.Event.Cost) + } + m.autoScroll() + return m, m.waitForStream() + case StreamDone: // Ignore output from a superseded run while a newer run is active. if m.isStaleRun(msg.RunID) { @@ -1419,6 +1433,9 @@ func (m *TuiModel) runAgent(ctx context.Context, runID uint64, prompt string) { OnUsage: func(usage types.Usage) { m.streamCh <- UsageMsg{RunID: runID, Usage: usage} }, + OnCost: func(event types.CostEvent) { + m.streamCh <- CostMsg{RunID: runID, Event: event} + }, } result, err := m.agent.Run(ctx, prompt) m.agent.StreamCallbacks = nil diff --git a/tui/view.go b/tui/view.go index 4669ef2..80121a5 100644 --- a/tui/view.go +++ b/tui/view.go @@ -436,10 +436,27 @@ func (m *TuiModel) renderStatusBar() string { } } - status := fmt.Sprintf("%s %s%s%s ■ %s %s tools: %d msgs: %d%s session: %s%s%s", + // Money appears only once there is something to say about it, so a session + // with no cost information renders exactly the bar it always has. A total is + // shown together with the count of calls it does not include: "0.40 USD" and + // "0.40 USD, and three calls nobody priced" are different statements, and the + // second is the one that is true. + costStr := "" + switch { + case len(m.sessionCost) == 0 && m.unpricedCalls == 0: + case len(m.sessionCost) == 0: + costStr = fmt.Sprintf(" cost: unknown (%d unpriced)", m.unpricedCalls) + default: + costStr = " cost: " + m.sessionCost.String() + if m.unpricedCalls > 0 { + costStr += fmt.Sprintf(" (+%d unpriced)", m.unpricedCalls) + } + } + + status := fmt.Sprintf("%s %s%s%s ■ %s %s%s tools: %d msgs: %d%s session: %s%s%s", modeIcon, modelName, spinnerStr, procStr, provName, - tokensStr, m.sessionToolCalls, len(m.messages), + tokensStr, costStr, m.sessionToolCalls, len(m.messages), diagStr, durStr, histStr, statusMsg) diff --git a/types/types.go b/types/types.go index e49ddcb..c4f5e1a 100644 --- a/types/types.go +++ b/types/types.go @@ -1,6 +1,11 @@ package types -import "context" +import ( + "context" + "sort" + "strconv" + "strings" +) // Role constants for messages. const ( @@ -51,6 +56,7 @@ type StreamCallbacks struct { OnToolResult func(name string) // called after each tool result OnStepDone func() // called after all tools complete for one step OnUsage func(usage Usage) // called once per LLM call that reported token usage + OnCost func(event CostEvent) // called once per LLM call, including one whose cost is unknown } // Usage is the token accounting a provider reported for one request. @@ -58,20 +64,126 @@ type StreamCallbacks struct { // It travels as a pointer on ChatResponse because "this endpoint reported no // usage" and "it reported zero tokens" are different facts: the first has to // fall back to an estimate, the second must not. +// +// The fields are one per *billing lane*, not one per wire name. A route that +// reports prompt_tokens_details.cached_tokens and one that reports DeepSeek's +// prompt_cache_hit_tokens mean the same thing, and both land in +// CachedPromptTokens, so nothing downstream has to know which route it is on. +// The detail fields are part of the counts beside them, not extra tokens: +// CachedPromptTokens and ReasoningTokens are subsets of PromptTokens and +// CompletionTokens respectively. type Usage struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` TotalTokens int `json:"total_tokens"` + + // CachedPromptTokens is the part of PromptTokens the provider served from a + // cache, billed below the standard input rate. + CachedPromptTokens int `json:"cached_prompt_tokens,omitempty"` + // CacheWriteTokens is the part written *into* an explicit cache, billed + // above the standard input rate by the providers that charge for it. + CacheWriteTokens int `json:"cache_write_tokens,omitempty"` + // ReasoningTokens is the part of CompletionTokens spent on reasoning, which + // some providers price separately from the answer. + ReasoningTokens int `json:"reasoning_tokens,omitempty"` } // Add returns the sum of two usage records, so a caller can accumulate a // session total without adding the fields itself. func (u Usage) Add(v Usage) Usage { return Usage{ - PromptTokens: u.PromptTokens + v.PromptTokens, - CompletionTokens: u.CompletionTokens + v.CompletionTokens, - TotalTokens: u.TotalTokens + v.TotalTokens, + PromptTokens: u.PromptTokens + v.PromptTokens, + CompletionTokens: u.CompletionTokens + v.CompletionTokens, + TotalTokens: u.TotalTokens + v.TotalTokens, + CachedPromptTokens: u.CachedPromptTokens + v.CachedPromptTokens, + CacheWriteTokens: u.CacheWriteTokens + v.CacheWriteTokens, + ReasoningTokens: u.ReasoningTokens + v.ReasoningTokens, + } +} + +// Cost is money, with the unit it is denominated in. +// +// The unit is not decoration: a provider may bill in its own credits while a +// user-declared price is in the currency of their invoice, and adding those +// together would produce a number with no meaning. Callers accumulate per +// currency rather than across it. +type Cost struct { + Amount float64 `json:"amount"` + Currency string `json:"currency"` +} + +// CostSource says where a cost number came from. It is the same distinction the +// usage path draws between a reported record and an estimate, and it exists so +// that "we do not know what this call cost" can never be displayed as zero. +type CostSource string + +const ( + // CostUnknown means no reported cost and no declared price: the call's cost + // is not known, which is not the same as its being free. + CostUnknown CostSource = "unknown" + // CostReported means the provider said what it charged. + CostReported CostSource = "reported" + // CostDeclared means it was computed from a price the user declared; it is + // an estimate of the bill, not a reading of it. + CostDeclared CostSource = "declared" +) + +// CostEvent is what one provider call cost, and where that number came from. +type CostEvent struct { + Cost Cost `json:"cost"` + Source CostSource `json:"source"` +} + +// CostTotals accumulates money per unit. +// +// The unit is part of the key because two routes can bill differently — an +// account's own credits beside the currency of the user's invoice — and summing +// those into one number would produce something no one can act on. An empty +// currency key collects amounts whose route did not name a unit. +type CostTotals map[string]float64 + +// Add returns the totals with one cost added. It returns a new map rather than +// mutating, so a caller can accumulate onto a struct field in one expression. +func (t CostTotals) Add(c Cost) CostTotals { + out := make(CostTotals, len(t)+1) + for k, v := range t { + out[k] = v + } + out[c.Currency] += c.Amount + return out +} + +// String renders the totals for display, ordered by unit so a caller can assert +// on the text. The order is case-insensitive — "credits" before "USD" reads the +// way a person would write it — with the raw string as a tiebreak so two units +// differing only in case still have a fixed order. An amount whose unit is +// unknown prints as a bare number: naming a unit the route did not give would be +// an invention. +func (t CostTotals) String() string { + if len(t) == 0 { + return "" + } + units := make([]string, 0, len(t)) + for k := range t { + units = append(units, k) + } + sort.Slice(units, func(i, j int) bool { + li, lj := strings.ToLower(units[i]), strings.ToLower(units[j]) + if li != lj { + return li < lj + } + return units[i] < units[j] + }) + parts := make([]string, 0, len(units)) + for _, unit := range units { + amount := strconv.FormatFloat(t[unit], 'f', -1, 64) + if unit == "" { + parts = append(parts, amount) + continue + } + parts = append(parts, amount+" "+unit) } + return strings.Join(parts, ", ") } // ChatResponse is the LLM's reply — either text or tool calls. @@ -80,6 +192,7 @@ type ChatResponse struct { ToolCalls []ToolCall ReasoningContent string // DeepSeek thinking mode Usage *Usage // provider-reported token usage; nil when the endpoint reported none + Cost *Cost // provider-reported charge; nil when the endpoint reported none } // ToolDef describes one tool to the LLM (function calling schema). From ed5c551efbde49c154f3b655c3e781d4b5bd22c8 Mon Sep 17 00:00:00 2001 From: Siwen Yu Date: Sat, 10 Oct 2026 17:42:49 +0800 Subject: [PATCH 2/3] docs(usage): record cost accounting and A3's delivered cost half The changelog entry explains the two halves a reader has to keep apart: what a route charges (reported, carried verbatim) and what the tokens were (lanes, so a rate can be applied). CODEBASE.md gains the new types, the price table and the unknown-cost rule. The A3 entry now says cost was delivered in #181, and keeps only the price-table question open in #182. --- CHANGELOG.md | 24 ++++++++++++++++++++++++ CODEBASE.md | 14 ++++++++++---- docs/roadmap.md | 13 +++++++------ 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a97c6c3..54d4e18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,30 @@ silent endpoint cannot switch the budget off; such a call never fires the usage report of what the provider actually said. Sub-agents enforce the same limits against their own spend — attributing a sub-agent's tokens to its parent is roadmap G1. Roadmap entry A3. +### Cost accounting: what a call charged, and what it cost + +Two things were missing to answer "what did this session cost", and they are separate problems. + +**What a route charges.** Some routes report it per request: OpenRouter returns a `cost` in its usage +object, and a `prompt_tokens_details` split of cached and cache-written input beside it. Those numbers +are now carried through unchanged — never recomputed, never added to a figure derived from a price +list — with `cost_currency` on the provider naming the unit that route bills in. + +**What the tokens were.** A route that does not report a charge has to be priced from rates, and rates +need the lanes: DeepSeek bills four input lanes (cache hit vs cache miss) times a peak/off-peak rule, +which a single input price cannot express. `Usage` now carries `cached_prompt_tokens`, +`cache_write_tokens` and `reasoning_tokens` — one field per billing lane, so OpenAI's nested +`prompt_tokens_details` and DeepSeek's flat `prompt_cache_hit_tokens` land in the same place. + +`pricing` in the configuration then declares rates per million tokens, keyed by `/` with +`*` wildcards, because a price belongs to a billing endpoint rather than to a model name. A call is +priced from a declaration only when its tokens were *reported*: multiplying a declared rate by a +guessed token count would dress a second estimate as a measurement. Everything else is counted as +**unknown**, which the status bar shows as an unknown rather than as zero — `Ollama` and subscription +plans have no per-token price at all, and "0" would read as free in exactly the decisions the number +is there to inform. Whether this project should ship a price table of its own is a separate, +undecided question (#182). + ## v0.0.7 — 2026-10-01 ### Language server integration diff --git a/CODEBASE.md b/CODEBASE.md index dd4ba6c..58322d9 100644 --- a/CODEBASE.md +++ b/CODEBASE.md @@ -44,7 +44,10 @@ - `StreamCallbacks *types.StreamCallbacks` - `UsageTotal types.Usage` — every token the agent has spent: provider-reported records summed, plus an estimate of the output for a call whose endpoint reported nothing (so a silent provider cannot switch a budget off) - `BudgetTokensPerRun`, `BudgetTokensPerSession` (int) — cumulative token limits, 0 = unlimited; `runUsage` is the same accounting restricted to the run in flight, reset by each `Run` - - `callSpend(resp)` → reported usage, or an output estimate marked as such; `recordUsage(cb, resp)` adds it to both totals and fires `OnUsage` only for the reported case + - `callSpend(resp)` → reported usage, or an output estimate marked as such; `recordCall(cb, resp)` adds it to both totals and fires `OnUsage` only for the reported case + - `PriceTable`/`Price` (pricing.go) — the user's declared rates per billing lane; `Lookup` resolves `"/"` with `*` wildcards, most specific first (exact → `route/*` → `*/model` → `*/*`), and `Amount` carves the detail lanes out of the totals, falling back to the lane they belong to and flooring every subtraction at zero + - `CostTotal types.CostTotals`, `CostUnknownCalls int` — money per unit, plus how many calls nobody could price; kept together because a total without its unknowns overstates what is known + - `priceKey()` asks the provider (RouteInfo) for its route and default model at call time, and prefers the agent's model override — a route can be switched mid-session, so a value captured at construction would price a call against another route's rates - `budgetReached()` / `budgetMessage(which, limit, spent)` — the check runs at the top of the loop, before the call that would spend, and its message names the budget, the spend and the knob (`budget.max_tokens_per_run` / `..._session`) - `ContentStreamed bool` - **`New(provider LLMProvider) *Agent`** — defaults: MaxSteps=20, MaxTokens=4096 @@ -110,7 +113,8 @@ - **`OllamaProvider`** struct (unexported: baseURL, model, http client) - **`NewOllamaProvider(baseURL, model string) *OllamaProvider`** — default baseURL: `http://localhost:11434` -- `Chat()` — line-delimited JSON (not SSE); tool results mapped to `role: "user"`; `thinking` field for reasoning; `prompt_eval_count`/`eval_count` are read into `ChatResponse.Usage` — from the stream's `{"done":true,…}` line (read before the loop breaks) and from the batch body's top level. Both are optional counters, so a server that reports neither leaves `Usage` nil instead of claiming a zero-token call +- `Chat()` — line-delimited JSON (not SSE); tool results mapped to `role: "user"`; `thinking` field for reasoning; `prompt_eval_count`/`eval_count` are read into `ChatResponse.Usage` — from the stream's `{"done":true,…}` line (read before the loop breaks) and from the batch body's top level. Both are optional counters, so a server that reports neither leaves `Usage` nil instead of claiming a zero-token call. A local route reports no charge, so `CostCurrency()` is empty and its calls are priced from the declared table or counted as unknown +- `SetRouteInfo(route, model, _)` names the configuration route, so a price lookup keys on the route rather than on a model name several routes share; the cost currency is ignored because this route bills in none - Bounded (issue #1): `ollamaRequestTimeout` (10 min) covers a whole non-streaming request, `ollamaIdleTimeout` (2 min) covers the silence between tokens on a stream and is reset by every line, so a long generation is not killed while a stalled one fails. Both are package variables (tests shrink them), and the cancel cause names the bound that fired instead of surfacing "context canceled". A stream that ends without its `{"done":true}` line is now an error rather than the partial text it managed to send. ### `compression.go` — Context Compression @@ -493,9 +497,11 @@ adding a scenario — frame, black-box scenario file, or tool release — is in - **`ToolCall`** struct: `ID`, `Name`, `Arguments string` (raw JSON) - **`ToolDef`** struct: `Name`, `Description`, `Parameters map[string]any` - **`ChatRequest`** struct: `Messages []Message`, `Tools []ToolDef`, `MaxTokens int`, `Model string`, `StreamCallbacks *StreamCallbacks` -- **`ChatResponse`** struct: `Content`, `ToolCalls []ToolCall`, `ReasoningContent`, `Usage *Usage` (nil when the endpoint reported none) +- **`ChatResponse`** struct: `Content`, `ToolCalls []ToolCall`, `ReasoningContent`, `Usage *Usage`, `Cost *Cost` (both nil when the endpoint reported none) - **`Usage`** struct: `PromptTokens`, `CompletionTokens`, `TotalTokens`; `Add(v Usage) Usage` sums two records -- **`StreamCallbacks`** struct: `OnReasoningDelta`, `OnTextDelta`, `OnToolCall`, `OnToolResult`, `OnStepDone`, `OnUsage` (fired once per LLM call that reported usage) +- **`StreamCallbacks`** struct: `OnReasoningDelta`, `OnTextDelta`, `OnToolCall`, `OnToolResult`, `OnStepDone`, `OnUsage` (fired once per LLM call that reported usage), `OnCost` (fired once per call, including a call whose cost is unknown) +- **`Usage`** detail fields: `CachedPromptTokens`, `CacheWriteTokens`, `ReasoningTokens` — one per *billing lane*, so OpenAI's `prompt_tokens_details` shape and DeepSeek's `prompt_cache_hit_tokens` land in the same field. They are subsets of the counts beside them, not extra tokens +- **`Cost`** struct: `Amount`, `Currency`; **`CostSource`** (`unknown`/`reported`/`declared`); **`CostEvent`**; **`CostTotals map[string]float64`** with `Add` and a `String()` ordered case-insensitively by unit — two routes billing in different units are accumulated apart, never added together - **`Memory`** struct: `Key`, `Value` - **`MemoryStore`** interface: `Remember`, `Recall`, `Forget`, `List` - **`WithPlanWriteRestriction(ctx, bool)` / `PlanWriteRestricted(ctx)`** — plan-mode write restriction travels on the run context, so concurrent sub-agents cannot flip it for each other diff --git a/docs/roadmap.md b/docs/roadmap.md index 5cd92a2..1772d5b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -80,12 +80,13 @@ current tree. - **Picked up**: [#179](https://github.com/yusiwen/TinyCode/issues/179) — S1 (the config surface), S2 (enforcement before every provider call, per run and per session), S3 (the status bar shows the - session limit when one is set), S4 (tests and docs). The **token** half is delivered; **cost** is - tracked separately in [#181](https://github.com/yusiwen/TinyCode/issues/181) — a provider-reported - cost, user-declared prices, and the cache-tier usage detail money has to be computed from — with its - price-table question in [#182](https://github.com/yusiwen/TinyCode/issues/182), a data-governance - decision whose deliverable is a written verdict rather than code. That split narrows this entry's - original wording: a price list is *not* the only way to know what a call cost, because a provider + session limit when one is set), S4 (tests and docs). The **token** half is delivered, and **cost** + followed in [#181](https://github.com/yusiwen/TinyCode/issues/181) — a provider-reported charge + carried verbatim, user-declared rates keyed by route and model, and the cache-tier usage detail that + money has to be computed from, with a call nobody could price counted as unknown rather than as + zero. Its price-table question stays open in + [#182](https://github.com/yusiwen/TinyCode/issues/182), a data-governance decision whose deliverable + is a written verdict rather than code. That split narrows this entry's original wording: a price list is *not* the only way to know what a call cost, because a provider may report the cost itself, and because a table carrying one input price and one output price is wrong for the provider this repository ships by default — DeepSeek prices four input lanes times a peak/off-peak rule. The evidence below is the `7e6b017` baseline, so its `rg` exit code describes From 8fc9e4ea474b86d8ca13d1f241c3a37ee111b298 Mon Sep 17 00:00:00 2001 From: Siwen Yu Date: Sun, 11 Oct 2026 10:11:49 +0800 Subject: [PATCH 3/3] fix(tui): show money only when cost accounting is in play MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cost segment was added whenever any call was unpriced, and a call is unpriced whenever a route reports neither usage nor a charge. That is the common case: the CI stubs report no usage at all, so every real run carried "cost: unknown (2 unpriced)" — 26 columns spent telling a user who never asked about money that we do not know what their calls cost. It also broke a real surface: the longer bar pushed "Press Ctrl+C again to quit" past the wrap in the permission-allow-always scenario, and the run timed out waiting for text that had been split across two lines. Money now appears when cost accounting is in play — a rate the user declared in pricing, or a route that reported a charge — and shows the unknown count beside the total from that point on. A session with neither keeps the bar it has always had, which is not the same as reporting zero. Verified with the real PTY runner (make test-tui-scenarios): 31 scenarios, 27 ok, 4 gated skips, including the permission-allow-always case that failed in CI. --- CHANGELOG.md | 8 +++++--- tui/cost_test.go | 37 ++++++++++++++++++++++++++++++++----- tui/view.go | 32 +++++++++++++++++++------------- 3 files changed, 56 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54d4e18..403b0a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,9 +81,11 @@ which a single input price cannot express. `Usage` now carries `cached_prompt_to `*` wildcards, because a price belongs to a billing endpoint rather than to a model name. A call is priced from a declaration only when its tokens were *reported*: multiplying a declared rate by a guessed token count would dress a second estimate as a measurement. Everything else is counted as -**unknown**, which the status bar shows as an unknown rather than as zero — `Ollama` and subscription -plans have no per-token price at all, and "0" would read as free in exactly the decisions the number -is there to inform. Whether this project should ship a price table of its own is a separate, +**unknown**, and never folded into a total as zero — `Ollama` and subscription plans have no per-token +price at all, and "0" would read as free in exactly the decisions the number is there to inform. The +status bar shows money only once cost accounting is in play (a rate declared, or a charge reported), +and then shows the count of calls the total does not include beside it; a session with neither keeps +the bar it has always had. Whether this project should ship a price table of its own is a separate, undecided question (#182). ## v0.0.7 — 2026-10-01 diff --git a/tui/cost_test.go b/tui/cost_test.go index 850db07..f9240ae 100644 --- a/tui/cost_test.go +++ b/tui/cost_test.go @@ -8,10 +8,17 @@ import ( "github.com/yusiwen/tinycode/types" ) -// TestStatusBarShowsCostOnlyWhenThereIsSome is what keeps the committed frame -// goldens byte-identical: a session with no cost information renders exactly the -// bar it always has. -func TestStatusBarShowsCostOnlyWhenThereIsSome(t *testing.T) { +// TestStatusBarShowsCostOnlyWhenItIsInPlay is what keeps the committed frame +// goldens byte-identical, and it is also why the status bar does not grow for a +// session that is not doing cost accounting: with no declared rate and no route +// reporting a charge, "unknown" would spend the bar's width on a statement the +// user never asked for. Silence there is not zero — the accounting still counts +// the call as unknown (see the agent's TestUnpricedCallIsUnknownNotZero). +// +// This is the case a real run hits: the CI stubs report no usage, so every call +// is unpriced, and a segment here pushed "Press Ctrl+C again to quit" past the +// wrap in the permission-allow-always scenario. +func TestStatusBarShowsCostOnlyWhenItIsInPlay(t *testing.T) { m := layoutModel(30) m.config = &config.Config{} m.sessionTokens = 1200 @@ -19,6 +26,21 @@ func TestStatusBarShowsCostOnlyWhenThereIsSome(t *testing.T) { if bar := m.renderStatusBar(); strings.Contains(bar, "cost") { t.Errorf("bar = %q, want no cost segment before any cost is known", bar) } + + // An unpriced call with nothing declared is still not cost accounting. + m.Update(CostMsg{Event: types.CostEvent{Source: types.CostUnknown}}) + if bar := m.renderStatusBar(); strings.Contains(bar, "cost") { + t.Errorf("bar = %q, want no cost segment when no rate is declared and no charge was reported", bar) + } + + // A route that reports a charge puts it in play on its own. + m.Update(CostMsg{Event: types.CostEvent{ + Cost: types.Cost{Amount: 0.5, Currency: "credits"}, + Source: types.CostReported, + }}) + if bar := m.renderStatusBar(); !strings.Contains(bar, "cost: 0.5 credits (+1 unpriced)") { + t.Errorf("bar = %q, want the reported charge and the call it does not include", bar) + } } // TestStatusBarShowsCostPerUnit: money carries its unit, and the calls nobody @@ -60,7 +82,12 @@ func TestStatusBarShowsCostPerUnit(t *testing.T) { // all unpriced must say so, not show nothing and not show zero. func TestStatusBarSaysUnknownWhenNothingCouldBePriced(t *testing.T) { m := layoutModel(30) - m.config = &config.Config{} + // A declared rate is what puts cost accounting in play: the user asked what + // their calls cost, so a call nobody could price has to be visible. + m.config = &config.Config{Pricing: &config.PricingConfig{ + Currency: "USD", + Prices: map[string]config.PriceEntry{"local/*": {InputPerMillion: 1}}, + }} m.Update(CostMsg{Event: types.CostEvent{Source: types.CostUnknown}}) diff --git a/tui/view.go b/tui/view.go index 80121a5..913a314 100644 --- a/tui/view.go +++ b/tui/view.go @@ -436,20 +436,26 @@ func (m *TuiModel) renderStatusBar() string { } } - // Money appears only once there is something to say about it, so a session - // with no cost information renders exactly the bar it always has. A total is - // shown together with the count of calls it does not include: "0.40 USD" and - // "0.40 USD, and three calls nobody priced" are different statements, and the - // second is the one that is true. + // Money appears only when cost accounting is in play — a rate the user + // declared, or a route that reported a charge. A session with neither is not + // doing cost accounting at all, and announcing "unknown" on every run would + // spend the bar's width on a statement nobody asked for; silence there is not + // the same as zero. Once it is in play, the calls nobody could price are shown + // beside the total, because "0.40 USD" and "0.40 USD, and three calls nobody + // priced" are different claims and the second is the true one. + costInPlay := len(m.sessionCost) > 0 + if !costInPlay && m.config != nil && m.config.Pricing != nil && len(m.config.Pricing.Prices) > 0 { + costInPlay = true + } costStr := "" - switch { - case len(m.sessionCost) == 0 && m.unpricedCalls == 0: - case len(m.sessionCost) == 0: - costStr = fmt.Sprintf(" cost: unknown (%d unpriced)", m.unpricedCalls) - default: - costStr = " cost: " + m.sessionCost.String() - if m.unpricedCalls > 0 { - costStr += fmt.Sprintf(" (+%d unpriced)", m.unpricedCalls) + if costInPlay { + if len(m.sessionCost) == 0 { + costStr = fmt.Sprintf(" cost: unknown (%d unpriced)", m.unpricedCalls) + } else { + costStr = " cost: " + m.sessionCost.String() + if m.unpricedCalls > 0 { + costStr += fmt.Sprintf(" (+%d unpriced)", m.unpricedCalls) + } } }