Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,32 @@ 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 `<route>/<model>` 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**, 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

### Language server integration
Expand Down
14 changes: 10 additions & 4 deletions CODEBASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `"<route>/<model>"` 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
70 changes: 57 additions & 13 deletions agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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
}

Expand Down
Loading
Loading