feat: comprehensive token usage tracking for V2
All checks were successful
CI / Lint (pull_request) Successful in 10m18s
CI / Root Module (pull_request) Successful in 11m4s
CI / V2 Module (pull_request) Successful in 11m5s

Add provider-specific usage details, fix streaming usage, and return
usage from all high-level APIs (Chat.Send, Generate[T], Agent.Run).

Breaking changes:
- Chat.Send/SendMessage/SendWithImages now return (string, *Usage, error)
- Generate[T]/GenerateWith[T] now return (T, *Usage, error)
- Agent.Run/RunMessages now return (string, *Usage, error)

New features:
- Usage.Details map for provider-specific token breakdowns
  (reasoning, cached, audio, thoughts tokens)
- OpenAI streaming now captures usage via StreamOptions.IncludeUsage
- Google streaming now captures UsageMetadata from final chunk
- UsageTracker.Details() for accumulated detail totals
- ModelPricing and PricingRegistry for cost computation

Closes #2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 04:33:18 +00:00
parent 7e1705c385
commit 5b687839b2
17 changed files with 684 additions and 61 deletions

View File

@@ -18,7 +18,7 @@
// coder.AsTool("code", "Write and run code"),
// )),
// )
// result, err := orchestrator.Run(ctx, "Build a fibonacci function in Go")
// result, _, err := orchestrator.Run(ctx, "Build a fibonacci function in Go")
package agent
import (
@@ -64,13 +64,15 @@ func New(model *llm.Model, system string, opts ...Option) *Agent {
// Run executes the agent with a user prompt. Each call is a fresh conversation.
// The agent loops tool calls automatically until it produces a text response.
func (a *Agent) Run(ctx context.Context, prompt string) (string, error) {
// Returns the text response, accumulated token usage, and any error.
func (a *Agent) Run(ctx context.Context, prompt string) (string, *llm.Usage, error) {
return a.RunMessages(ctx, []llm.Message{llm.UserMessage(prompt)})
}
// RunMessages executes the agent with full message control.
// Each call is a fresh conversation. The agent loops tool calls automatically.
func (a *Agent) RunMessages(ctx context.Context, messages []llm.Message) (string, error) {
// Returns the text response, accumulated token usage, and any error.
func (a *Agent) RunMessages(ctx context.Context, messages []llm.Message) (string, *llm.Usage, error) {
chat := llm.NewChat(a.model, a.reqOpts...)
if a.system != "" {
chat.SetSystem(a.system)
@@ -107,7 +109,8 @@ type delegateParams struct {
func (a *Agent) AsTool(name, description string) llm.Tool {
return llm.Define[delegateParams](name, description,
func(ctx context.Context, p delegateParams) (string, error) {
return a.Run(ctx, p.Input)
text, _, err := a.Run(ctx, p.Input)
return text, err
},
)
}

View File

@@ -29,15 +29,6 @@ func (m *mockProvider) Stream(ctx context.Context, req provider.Request, events
return nil
}
func (m *mockProvider) lastRequest() provider.Request {
m.mu.Lock()
defer m.mu.Unlock()
if len(m.requests) == 0 {
return provider.Request{}
}
return m.requests[len(m.requests)-1]
}
func newMockModel(fn func(ctx context.Context, req provider.Request) (provider.Response, error)) *llm.Model {
mp := &mockProvider{completeFunc: fn}
return llm.NewClient(mp).Model("mock-model")
@@ -53,7 +44,7 @@ func TestAgent_Run(t *testing.T) {
model := newSimpleMockModel("Hello from agent!")
a := New(model, "You are a helpful assistant.")
result, err := a.Run(context.Background(), "Say hello")
result, _, err := a.Run(context.Background(), "Say hello")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -83,7 +74,7 @@ func TestAgent_Run_WithTools(t *testing.T) {
})
a := New(model, "You are helpful.", WithTools(llm.NewToolBox(tool)))
result, err := a.Run(context.Background(), "Use the greet tool")
result, _, err := a.Run(context.Background(), "Use the greet tool")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -147,7 +138,7 @@ func TestAgent_AsTool_ParentChild(t *testing.T) {
)),
)
result, err := parent.Run(context.Background(), "Tell me about Go generics")
result, _, err := parent.Run(context.Background(), "Tell me about Go generics")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -169,7 +160,7 @@ func TestAgent_RunMessages(t *testing.T) {
llm.UserMessage("Follow up"),
}
result, err := a.RunMessages(context.Background(), messages)
result, _, err := a.RunMessages(context.Background(), messages)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -187,7 +178,7 @@ func TestAgent_ContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
_, err := a.Run(ctx, "This should fail")
_, _, err := a.Run(ctx, "This should fail")
if err == nil {
t.Fatal("expected error from cancelled context")
}
@@ -204,7 +195,7 @@ func TestAgent_WithRequestOptions(t *testing.T) {
WithRequestOptions(llm.WithTemperature(0.3), llm.WithMaxTokens(100)),
)
_, err := a.Run(context.Background(), "test")
_, _, err := a.Run(context.Background(), "test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -224,7 +215,7 @@ func TestAgent_Run_Error(t *testing.T) {
})
a := New(model, "You are helpful.")
_, err := a.Run(context.Background(), "test")
_, _, err := a.Run(context.Background(), "test")
if err == nil {
t.Fatal("expected error, got nil")
}
@@ -234,7 +225,7 @@ func TestAgent_EmptySystem(t *testing.T) {
model := newSimpleMockModel("no system prompt")
a := New(model, "") // Empty system prompt
result, err := a.Run(context.Background(), "test")
result, _, err := a.Run(context.Background(), "test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -242,3 +233,34 @@ func TestAgent_EmptySystem(t *testing.T) {
t.Errorf("unexpected result: %q", result)
}
}
func TestAgent_Run_ReturnsUsage(t *testing.T) {
model := newMockModel(func(ctx context.Context, req provider.Request) (provider.Response, error) {
return provider.Response{
Text: "result",
Usage: &provider.Usage{
InputTokens: 100,
OutputTokens: 50,
TotalTokens: 150,
},
}, nil
})
a := New(model, "You are helpful.")
result, usage, err := a.Run(context.Background(), "test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != "result" {
t.Errorf("expected 'result', got %q", result)
}
if usage == nil {
t.Fatal("expected usage, got nil")
}
if usage.InputTokens != 100 {
t.Errorf("expected input 100, got %d", usage.InputTokens)
}
if usage.OutputTokens != 50 {
t.Errorf("expected output 50, got %d", usage.OutputTokens)
}
}

View File

@@ -25,7 +25,7 @@ func Example_researcher() {
agent.WithRequestOptions(llm.WithTemperature(0.3)),
)
result, err := researcher.Run(context.Background(), "What are the latest developments in Go generics?")
result, _, err := researcher.Run(context.Background(), "What are the latest developments in Go generics?")
if err != nil {
fmt.Println("Error:", err)
return
@@ -50,7 +50,7 @@ func Example_coder() {
)),
)
result, err := coder.Run(context.Background(),
result, _, err := coder.Run(context.Background(),
"Create a Go program that prints the first 10 Fibonacci numbers. Save it and run it.")
if err != nil {
fmt.Println("Error:", err)
@@ -97,7 +97,7 @@ func Example_orchestrator() {
)),
)
result, err := orchestrator.Run(context.Background(),
result, _, err := orchestrator.Run(context.Background(),
"Research how to implement a binary search tree in Go, then create one with insert and search operations.")
if err != nil {
fmt.Println("Error:", err)