Skip to content

Commit ff34b49

Browse files
Alexey Panfilovclaude
andcommitted
fix(usage): capture OR cost, tag turn context, record logical role
Three corrections to Stage A.1 after seeing the first real usage_log row in prod: 1. Missing OpenRouter cost. OR's response carries `usage.cost` (the actual USD charged for this request, already accounting for which upstream was routed to and any volume discounts). Added cost_usd column + parse from both sync and streaming paths. Keeps our computed cost (tokens × model_capabilities prices) as a fallback for providers that don't report. 2. Role column held the slot name ("simple-or") instead of the logical role ("simple"). I'd been passing primaryKey (map key) to recordUsage. Fix: Router.pick() now returns (Provider, role) where role is the concept — simple/default/complex/multimodal/override/ tool-continuation/fallback. Every caller (Chat, ChatStream, sync + fallback streams) threads the role through, so usage_log.role is now analytics-friendly regardless of how slots are named locally. 3. user_message_id was always NULL — agent.Process never tagged ctx with TurnMeta. Reshape Store.AddMessage / AddMessageWithEmbedding to return int64 (Memory returns 0). storeUserMessage returns the new id; Process + ProcessStream call llm.WithTurnMeta on ctx before the router invocation so every LLM call for the turn links back to the user message that triggered it. Changed interface: callers that ignore the return value (most assistant/tool writes) don't need updates. Store migrations add cost_usd column to usage_log (SQLite + Postgres with ALTER IF NOT EXISTS for the already-deployed table). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e3427ff commit ff34b49

9 files changed

Lines changed: 99 additions & 62 deletions

File tree

internal/agent/agent.go

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,10 @@ func (a *Agent) Process(ctx context.Context, chatID int64, userMsg llm.Message,
122122

123123
// Store user message; embed it if semantic store + MCP embeddings are both available.
124124
endEmbed := tr.begin("embed")
125-
queryEmb := a.storeUserMessage(ctx, chatID, userMsg, queryText)
125+
queryEmb, userMsgID := a.storeUserMessage(ctx, chatID, userMsg, queryText)
126126
endEmbed()
127+
// Tag ctx so Router.recordUsage can link usage_log rows to this turn.
128+
ctx = llm.WithTurnMeta(ctx, llm.TurnMeta{ChatID: chatID, UserMessageID: userMsgID})
127129

128130
// Auto-compact if needed
129131
if a.compacter != nil && NeedsCompaction(a.store, chatID) {
@@ -226,8 +228,9 @@ func (a *Agent) ProcessStream(ctx context.Context, chatID int64, userMsg llm.Mes
226228
queryText := messageText(userMsg)
227229

228230
endEmbed := tr.begin("embed")
229-
queryEmb := a.storeUserMessage(ctx, chatID, userMsg, queryText)
231+
queryEmb, userMsgID := a.storeUserMessage(ctx, chatID, userMsg, queryText)
230232
endEmbed()
233+
ctx = llm.WithTurnMeta(ctx, llm.TurnMeta{ChatID: chatID, UserMessageID: userMsgID})
231234

232235
if a.compacter != nil && NeedsCompaction(a.store, chatID) {
233236
a.logger.Info("auto-compacting conversation", "chat_id", chatID)
@@ -549,19 +552,20 @@ func (a *Agent) callTool(ctx context.Context, name, argsJSON string) (string, er
549552

550553
// storeUserMessage saves the user message. If the store and MCP client both support
551554
// embeddings, it also computes and stores the embedding for semantic history retrieval.
552-
// Returns the embedding (may be nil if unavailable).
553-
func (a *Agent) storeUserMessage(ctx context.Context, chatID int64, msg llm.Message, text string) []float32 {
555+
// Returns the embedding (may be nil) and the inserted row's id (0 if the backend
556+
// does not expose IDs).
557+
func (a *Agent) storeUserMessage(ctx context.Context, chatID int64, msg llm.Message, text string) ([]float32, int64) {
554558
sem, hasSem := a.store.(store.SemanticStore)
555559
if hasSem && a.mcp != nil {
556560
emb, err := a.mcp.EmbedText(ctx, text)
557561
if err == nil {
558-
sem.AddMessageWithEmbedding(chatID, msg, emb)
559-
return emb
562+
id := sem.AddMessageWithEmbedding(chatID, msg, emb)
563+
return emb, id
560564
}
561565
a.logger.Info("embedding unavailable, storing without", "chat_id", chatID, "err", err)
562566
}
563-
a.store.AddMessage(chatID, msg)
564-
return nil
567+
id := a.store.AddMessage(chatID, msg)
568+
return nil, id
565569
}
566570

567571
// buildCrossSessionContext searches past sessions and returns a formatted block

internal/agent/compact_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ func newMockCompactableStore(charCount int) *mockCompactableStore {
9191
}
9292

9393
func (m *mockCompactableStore) GetHistory(_ int64) []llm.Message { return nil }
94-
func (m *mockCompactableStore) AddMessage(_ int64, _ llm.Message) {}
94+
func (m *mockCompactableStore) AddMessage(_ int64, _ llm.Message) int64 { return 0 }
9595
func (m *mockCompactableStore) ClearHistory(_ int64) {}
9696
func (m *mockCompactableStore) AddSummary(_ int64, _ string) {}
9797
func (m *mockCompactableStore) MarkCompacted(_ []int64) error { return nil }

internal/llm/openai_compat.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -214,11 +214,14 @@ type rawChatResponse struct {
214214

215215
// rawUsage covers OpenAI-compatible usage fields plus the nested *_details
216216
// blocks that OpenRouter / Anthropic / OpenAI populate for prompt caching
217-
// and reasoning tokens.
217+
// and reasoning tokens. `cost` is OpenRouter-specific and surfaces the
218+
// actual USD amount OR charged for this request (after routing to the
219+
// selected upstream + any volume discounts).
218220
type rawUsage struct {
219-
PromptTokens int `json:"prompt_tokens"`
220-
CompletionTokens int `json:"completion_tokens"`
221-
TotalTokens int `json:"total_tokens"`
221+
PromptTokens int `json:"prompt_tokens"`
222+
CompletionTokens int `json:"completion_tokens"`
223+
TotalTokens int `json:"total_tokens"`
224+
Cost float64 `json:"cost"`
222225
PromptTokensDetails *struct {
223226
CachedTokens int `json:"cached_tokens"`
224227
} `json:"prompt_tokens_details"`
@@ -295,6 +298,7 @@ func (p *openAICompatProvider) Chat(ctx context.Context, messages []Message, sys
295298
result.Usage = Usage{
296299
PromptTokens: u.PromptTokens,
297300
CompletionTokens: u.CompletionTokens,
301+
Cost: u.Cost,
298302
RequestID: chatResp.ID,
299303
}
300304
if u.PromptTokensDetails != nil {
@@ -485,6 +489,7 @@ func (p *openAICompatProvider) readSSE(resp *http.Response, ch chan<- StreamChun
485489
if delta.Usage != nil {
486490
usage.PromptTokens = delta.Usage.PromptTokens
487491
usage.CompletionTokens = delta.Usage.CompletionTokens
492+
usage.Cost = delta.Usage.Cost
488493
if delta.Usage.PromptTokensDetails != nil {
489494
usage.CachedPromptTokens = delta.Usage.PromptTokensDetails.CachedTokens
490495
}

internal/llm/router.go

Lines changed: 33 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -340,16 +340,15 @@ func (r *Router) SetClassifierMinLen(n int) {
340340
}
341341

342342
func (r *Router) Chat(ctx context.Context, messages []Message, systemPrompt string, tools []Tool) (Response, error) {
343-
provider := r.pick(ctx, messages)
344-
primaryKey := r.keyFor(provider)
343+
provider, role := r.pick(ctx, messages)
345344
r.mu.Lock()
346345
r.lastRouted = provider.Name()
347-
r.lastRoutedKey = primaryKey
346+
r.lastRoutedKey = r.keyFor(provider)
348347
r.mu.Unlock()
349348

350349
start := time.Now()
351350
resp, err := provider.Chat(ctx, messages, systemPrompt, tools)
352-
r.recordUsage(ctx, provider, primaryKey, resp, err, time.Since(start))
351+
r.recordUsage(ctx, provider, role, resp, err, time.Since(start))
353352

354353
if err != nil && isUnavailable(err) {
355354
// Build fallback chain: override → default → fallback.
@@ -403,6 +402,7 @@ func (r *Router) recordUsage(ctx context.Context, p Provider, role string, resp
403402
CompletionTokens: resp.Usage.CompletionTokens,
404403
CachedPromptTokens: resp.Usage.CachedPromptTokens,
405404
ReasoningTokens: resp.Usage.ReasoningTokens,
405+
Cost: resp.Usage.Cost,
406406
LatencyMs: int(latency / time.Millisecond),
407407
Success: callErr == nil,
408408
ErrorClass: ClassifyErrorClass(callErr),
@@ -435,27 +435,26 @@ func splitProviderName(name string) (provider, model string) {
435435
// ChatStream returns a streaming channel if the current provider supports it.
436436
// Falls back to wrapping a synchronous Chat() in a single-chunk channel.
437437
func (r *Router) ChatStream(ctx context.Context, messages []Message, systemPrompt string, tools []Tool) (<-chan StreamChunk, error) {
438-
provider := r.pick(ctx, messages)
439-
primaryKey := r.keyFor(provider)
438+
provider, role := r.pick(ctx, messages)
440439
r.mu.Lock()
441440
r.lastRouted = provider.Name()
442-
r.lastRoutedKey = primaryKey
441+
r.lastRoutedKey = r.keyFor(provider)
443442
r.mu.Unlock()
444443
if sp, ok := provider.(StreamProvider); ok {
445444
ch, err := sp.ChatStream(ctx, messages, systemPrompt, tools)
446445
if err != nil {
447446
// Even a start-of-stream failure deserves a usage record with
448447
// error_class set so dashboards see it.
449-
r.recordUsage(ctx, provider, primaryKey, Response{}, err, 0)
448+
r.recordUsage(ctx, provider, role, Response{}, err, 0)
450449
if isUnavailable(err) {
451450
return r.syncFallbackStream(ctx, provider, messages, systemPrompt, tools, err)
452451
}
453452
return nil, err
454453
}
455-
return r.instrumentedStream(ctx, provider, primaryKey, ch), nil
454+
return r.instrumentedStream(ctx, provider, role, ch), nil
456455
}
457456
// Provider does not stream — wrap synchronous call.
458-
return r.syncStream(ctx, provider, messages, systemPrompt, tools)
457+
return r.syncStream(ctx, provider, role, messages, systemPrompt, tools)
459458
}
460459

461460
// instrumentedStream tees the provider's stream to the caller and records a
@@ -484,8 +483,7 @@ func (r *Router) instrumentedStream(ctx context.Context, provider Provider, role
484483
// syncStream wraps a synchronous Chat() call in a channel. Usage is recorded
485484
// via the same path as Router.Chat so streaming callers that hit non-stream
486485
// providers still produce UsageLog rows.
487-
func (r *Router) syncStream(ctx context.Context, provider Provider, messages []Message, systemPrompt string, tools []Tool) (<-chan StreamChunk, error) {
488-
role := r.keyFor(provider)
486+
func (r *Router) syncStream(ctx context.Context, provider Provider, role string, messages []Message, systemPrompt string, tools []Tool) (<-chan StreamChunk, error) {
489487
start := time.Now()
490488
resp, err := provider.Chat(ctx, messages, systemPrompt, tools)
491489
r.recordUsage(ctx, provider, role, resp, err, time.Since(start))
@@ -522,21 +520,22 @@ func (r *Router) syncFallbackStream(ctx context.Context, failed Provider, messag
522520
r.recordUsage(ctx, next, "fallback", Response{}, err, 0)
523521
}
524522
// Otherwise sync.
525-
return r.syncStream(ctx, next, messages, systemPrompt, tools)
523+
return r.syncStream(ctx, next, "fallback", messages, systemPrompt, tools)
526524
}
527525
return nil, origErr
528526
}
529527

530528
// SupportsStreaming returns true if the currently active provider implements StreamProvider.
531529
func (r *Router) SupportsStreaming() bool {
532-
provider := r.pick(context.Background(), nil)
530+
provider, _ := r.pick(context.Background(), nil)
533531
_, ok := provider.(StreamProvider)
534532
return ok
535533
}
536534

537535
// Name returns the name of the currently active provider (respecting override).
538536
func (r *Router) Name() string {
539-
return r.pick(context.Background(), nil).Name()
537+
p, _ := r.pick(context.Background(), nil)
538+
return p.Name()
540539
}
541540

542541
// SetOverride sets a named model override. Pass "" to clear. Returns error if name is unknown.
@@ -590,10 +589,16 @@ func (r *Router) ProviderNames() []string {
590589
return names
591590
}
592591

593-
func (r *Router) pick(ctx context.Context, messages []Message) Provider {
592+
// pick selects the provider for this request AND returns the logical routing
593+
// role that drove the decision (simple/default/complex/multimodal/override/
594+
// tool-continuation). Role is what ends up in usage_log — conceptually
595+
// useful for analytics. Separate from the provider's slot key (e.g. the same
596+
// "simple" role may be backed by different slot names across deployments).
597+
func (r *Router) pick(ctx context.Context, messages []Message) (Provider, string) {
594598
r.mu.RLock()
595599
cfg := r.cfg
596600
override := r.override
601+
lastRoutedKey := r.lastRoutedKey
597602
r.mu.RUnlock()
598603

599604
multimodal := hasMultimodalContent(messages)
@@ -602,7 +607,7 @@ func (r *Router) pick(ctx context.Context, messages []Message) Provider {
602607
if p := r.get(override); p != nil {
603608
if !multimodal || supportsVision(p) {
604609
slog.Info("routing", "reason", "override", "provider", p.Name())
605-
return p
610+
return p, "override"
606611
}
607612
// Override doesn't support vision — fall through to multimodal
608613
}
@@ -612,23 +617,23 @@ func (r *Router) pick(ctx context.Context, messages []Message) Provider {
612617
if multimodal {
613618
if p := r.get(cfg.Simple); p != nil && supportsVision(p) {
614619
slog.Info("routing", "reason", "simple+vision", "provider", p.Name())
615-
return p
620+
return p, "simple"
616621
}
617622
if p := r.get(cfg.Default); p != nil && supportsVision(p) {
618623
slog.Info("routing", "reason", "default+vision", "provider", p.Name())
619-
return p
624+
return p, "default"
620625
}
621626
if p := r.get(cfg.Multimodal); p != nil {
622627
slog.Info("routing", "reason", "multimodal", "provider", p.Name())
623-
return p
628+
return p, "multimodal"
624629
}
625630
}
626631

627632
// Tool continuation — keep using the same provider that started the tool loop.
628-
if hasToolMessages(messages) && r.lastRoutedKey != "" {
629-
if last := r.get(r.lastRoutedKey); last != nil {
633+
if hasToolMessages(messages) && lastRoutedKey != "" {
634+
if last := r.get(lastRoutedKey); last != nil {
630635
slog.Info("routing", "reason", "tool-continuation", "provider", last.Name())
631-
return last
636+
return last, "tool-continuation"
632637
}
633638
}
634639

@@ -644,12 +649,12 @@ func (r *Router) pick(ctx context.Context, messages []Message) Provider {
644649
case 1:
645650
if p := r.get(cfg.Simple); p != nil {
646651
slog.Info("routing", "reason", "classifier→simple", "level", 1, "provider", p.Name())
647-
return p
652+
return p, "simple"
648653
}
649654
case 3:
650655
if p := r.get(cfg.Complex); p != nil {
651656
slog.Info("routing", "reason", "classifier→complex", "level", 3, "provider", p.Name())
652-
return p
657+
return p, "complex"
653658
}
654659
}
655660
// level 2 or fallback → default
@@ -658,13 +663,13 @@ func (r *Router) pick(ctx context.Context, messages []Message) Provider {
658663

659664
if p := r.get(cfg.Default); p != nil {
660665
slog.Info("routing", "reason", "default", "provider", p.Name())
661-
return p
666+
return p, "default"
662667
}
663668
// Should never happen if config is valid
664669
for _, p := range r.providers {
665-
return p
670+
return p, "default"
666671
}
667-
return nil
672+
return nil, ""
668673
}
669674

670675
func (r *Router) get(key string) Provider {

internal/llm/usage.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type UsageLog struct {
2121
CompletionTokens int
2222
CachedPromptTokens int // subset of PromptTokens that hit provider's prompt cache (Anthropic/OpenAI)
2323
ReasoningTokens int // for thinking-enabled models: tokens spent on internal reasoning (billed as completion but semantically distinct)
24+
Cost float64 // USD, authoritative per-request cost reported by the provider (OpenRouter's usage.cost). 0 when the provider doesn't surface this.
2425
LatencyMs int
2526
Success bool
2627
ErrorClass string // "" / "rate_limit" / "5xx" / "network" / "timeout" / "other"
@@ -45,6 +46,7 @@ type Usage struct {
4546
CompletionTokens int
4647
CachedPromptTokens int
4748
ReasoningTokens int
49+
Cost float64 // USD (OpenRouter `usage.cost`); 0 when provider doesn't report it
4850
RequestID string
4951
}
5052

internal/store/memory.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ func (m *Memory) GetHistory(chatID int64) []llm.Message {
3333
return result
3434
}
3535

36-
func (m *Memory) AddMessage(chatID int64, msg llm.Message) {
36+
func (m *Memory) AddMessage(chatID int64, msg llm.Message) int64 {
3737
m.mu.Lock()
3838
defer m.mu.Unlock()
3939

@@ -42,6 +42,7 @@ func (m *Memory) AddMessage(chatID int64, msg llm.Message) {
4242
if len(m.sessions[chatID]) > maxHistory {
4343
m.sessions[chatID] = m.sessions[chatID][len(m.sessions[chatID])-maxHistory:]
4444
}
45+
return 0 // memory backend has no stable IDs
4546
}
4647

4748
func (m *Memory) ClearHistory(chatID int64) {

internal/store/postgres.go

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ func (p *Postgres) migrate(ctx context.Context) error {
8686
completion_tokens INTEGER NOT NULL DEFAULT 0,
8787
cached_prompt_tokens INTEGER NOT NULL DEFAULT 0,
8888
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
89+
cost_usd DOUBLE PRECISION NOT NULL DEFAULT 0,
8990
latency_ms INTEGER NOT NULL DEFAULT 0,
9091
success BOOLEAN NOT NULL DEFAULT TRUE,
9192
error_class TEXT NOT NULL DEFAULT '',
@@ -94,6 +95,7 @@ func (p *Postgres) migrate(ctx context.Context) error {
9495
user_message_id BIGINT,
9596
assistant_message_id BIGINT
9697
)`,
98+
`ALTER TABLE usage_log ADD COLUMN IF NOT EXISTS cost_usd DOUBLE PRECISION NOT NULL DEFAULT 0`,
9799
`CREATE INDEX IF NOT EXISTS idx_usage_ts_model ON usage_log(ts, model_id)`,
98100
`CREATE INDEX IF NOT EXISTS idx_usage_chat_ts ON usage_log(chat_id, ts)`,
99101
`CREATE INDEX IF NOT EXISTS idx_usage_user_msg ON usage_log(user_message_id)`,
@@ -214,22 +216,26 @@ func (p *Postgres) GetHistory(chatID int64) []llm.Message {
214216
return reverseMessages(scanMessagesPg(rows))
215217
}
216218

217-
func (p *Postgres) AddMessage(chatID int64, msg llm.Message) {
219+
func (p *Postgres) AddMessage(chatID int64, msg llm.Message) int64 {
218220
ctx := context.Background()
219221
p.maybeSessionBreak(ctx, chatID, msg.Role)
220222
tcJSON, tcID := encodeToolFields(msg)
221223
partsJSON := encodePartsJSON(msg.Parts)
222224

223-
_, err := p.pool.Exec(ctx, `
225+
var id int64
226+
err := p.pool.QueryRow(ctx, `
224227
INSERT INTO messages (chat_id, role, content, parts, tool_calls, tool_call_id)
225-
VALUES ($1, $2, $3, $4, $5, $6)`,
226-
chatID, msg.Role, msg.Content, partsJSON, tcJSON, tcID)
228+
VALUES ($1, $2, $3, $4, $5, $6)
229+
RETURNING id`,
230+
chatID, msg.Role, msg.Content, partsJSON, tcJSON, tcID).Scan(&id)
227231
if err != nil {
228232
slog.Error("pg AddMessage failed", "chat_id", chatID, "role", msg.Role, "content_len", len(msg.Content), "err", err)
233+
return 0
229234
}
235+
return id
230236
}
231237

232-
func (p *Postgres) AddMessageWithEmbedding(chatID int64, msg llm.Message, emb []float32) {
238+
func (p *Postgres) AddMessageWithEmbedding(chatID int64, msg llm.Message, emb []float32) int64 {
233239
ctx := context.Background()
234240
p.maybeSessionBreak(ctx, chatID, msg.Role)
235241
tcJSON, tcID := encodeToolFields(msg)
@@ -240,13 +246,17 @@ func (p *Postgres) AddMessageWithEmbedding(chatID int64, msg llm.Message, emb []
240246
embBlob = floatsToBlob(emb)
241247
}
242248

243-
_, err := p.pool.Exec(ctx, `
249+
var id int64
250+
err := p.pool.QueryRow(ctx, `
244251
INSERT INTO messages (chat_id, role, content, parts, tool_calls, tool_call_id, embedding)
245-
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
246-
chatID, msg.Role, msg.Content, partsJSON, tcJSON, tcID, embBlob)
252+
VALUES ($1, $2, $3, $4, $5, $6, $7)
253+
RETURNING id`,
254+
chatID, msg.Role, msg.Content, partsJSON, tcJSON, tcID, embBlob).Scan(&id)
247255
if err != nil {
248256
slog.Error("pg AddMessageWithEmbedding failed", "chat_id", chatID, "role", msg.Role, "content_len", len(msg.Content), "err", err)
257+
return 0
249258
}
259+
return id
250260
}
251261

252262
func (p *Postgres) maybeSessionBreak(ctx context.Context, chatID int64, role string) {
@@ -642,13 +652,13 @@ func (p *Postgres) PutUsage(ctx context.Context, u llm.UsageLog) (int64, error)
642652
INSERT INTO usage_log (
643653
provider, model_id, role, chat_id,
644654
prompt_tokens, completion_tokens, cached_prompt_tokens, reasoning_tokens,
645-
latency_ms, success, error_class, request_id, tool_call_count,
655+
cost_usd, latency_ms, success, error_class, request_id, tool_call_count,
646656
user_message_id, assistant_message_id
647-
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
657+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
648658
RETURNING id`,
649659
u.Provider, u.ModelID, u.Role, u.ChatID,
650660
u.PromptTokens, u.CompletionTokens, u.CachedPromptTokens, u.ReasoningTokens,
651-
u.LatencyMs, u.Success, u.ErrorClass, u.RequestID, u.ToolCallCount,
661+
u.Cost, u.LatencyMs, u.Success, u.ErrorClass, u.RequestID, u.ToolCallCount,
652662
u.UserMessageID, u.AssistantMessageID,
653663
).Scan(&id)
654664
if err != nil {

0 commit comments

Comments
 (0)