Skip to content

Commit 73106c7

Browse files
committed
Create Normalize Usage
1 parent c5a3186 commit 73106c7

File tree

22 files changed

+3658
-88
lines changed

22 files changed

+3658
-88
lines changed

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,21 @@ response = MyAgent.embed(inputs: ["Text 1", "Text 2"]).embed_now
148148
vectors = response.data.map { |d| d[:embedding] }
149149
```
150150

151+
**Normalized Usage Statistics**
152+
```ruby
153+
response = MyAgent.prompt("Hello").generate_now
154+
155+
# Works across all providers
156+
response.usage.input_tokens
157+
response.usage.output_tokens
158+
response.usage.total_tokens
159+
160+
# Provider-specific fields when available
161+
response.usage.cached_tokens # OpenAI, Anthropic
162+
response.usage.reasoning_tokens # OpenAI o1 models
163+
response.usage.service_tier # Anthropic
164+
```
165+
151166
**Provider Enhancements**
152167
- OpenAI Responses API: `api: :responses` or `api: :chat`
153168
- Anthropic JSON object mode with automatic extraction
@@ -195,6 +210,7 @@ vectors = response.data.map { |d| d[:embedding] }
195210
- Template rendering without blocks
196211
- Schema generator key symbolization
197212
- Rails 8.0 and 8.1 compatibility
213+
- Usage extraction across OpenAI/Anthropic response formats
198214

199215
### Removed
200216

docs/.vitepress/config.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export default defineConfig({
100100
{ text: 'Embeddings', link: '/actions/embeddings' },
101101
{ text: 'Tools', link: '/actions/tools' },
102102
{ text: 'Structured Output', link: '/actions/structured_output' },
103+
{ text: 'Usage', link: '/actions/usage' },
103104
]
104105
},
105106
{

docs/actions.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@ Generate vectors for semantic search:
4343

4444
<<< @/../test/docs/actions_examples_test.rb#embeddings_vectorize{ruby:line-numbers}
4545

46+
### [Usage Statistics](/actions/usage)
47+
48+
Track token consumption and costs:
49+
50+
```ruby
51+
response = agent.summarize.generate_now
52+
response.usage.total_tokens #=> 125
53+
```
54+
4655
## Common Patterns
4756

4857
### Multi-Capability Actions

docs/actions/usage.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
title: Usage Statistics
3+
description: Track token usage and performance metrics across all AI providers with normalized usage objects.
4+
---
5+
# {{ $frontmatter.title }}
6+
7+
Track token consumption and performance metrics from AI provider responses. All providers return normalized usage statistics for consistent cost tracking and monitoring.
8+
9+
## Accessing Usage
10+
11+
Get usage statistics from any response:
12+
13+
<<< @/../test/docs/actions/usage_examples_test.rb#accessing_usage{ruby:line-numbers}
14+
15+
## Common Fields
16+
17+
These fields work across all providers:
18+
19+
<<< @/../test/docs/actions/usage_examples_test.rb#common_fields{ruby:line-numbers}
20+
21+
## Provider-Specific Fields
22+
23+
Access advanced metrics when available:
24+
25+
::: code-group
26+
<<< @/../test/docs/actions/usage_examples_test.rb#provider_specific_openai{ruby:line-numbers} [OpenAI]
27+
<<< @/../test/docs/actions/usage_examples_test.rb#provider_specific_anthropic{ruby:line-numbers} [Anthropic]
28+
<<< @/../test/docs/actions/usage_examples_test.rb#provider_specific_ollama{ruby:line-numbers} [Ollama]
29+
:::
30+
31+
## Provider Details
32+
33+
Raw provider data preserved in `provider_details`:
34+
35+
::: code-group
36+
<<< @/../test/docs/actions/usage_examples_test.rb#provider_details_openai{ruby:line-numbers} [OpenAI]
37+
<<< @/../test/docs/actions/usage_examples_test.rb#provider_details_ollama{ruby:line-numbers} [Ollama]
38+
:::
39+
40+
## Cost Tracking
41+
42+
Calculate costs using token counts:
43+
44+
<<< @/../test/docs/actions/usage_examples_test.rb#cost_tracking{ruby:line-numbers}
45+
46+
## Embeddings Usage
47+
48+
Embedding responses have zero output tokens:
49+
50+
<<< @/../test/docs/actions/usage_examples_test.rb#embeddings_usage{ruby:line-numbers}
51+
52+
## Field Mapping
53+
54+
How provider fields map to normalized names:
55+
56+
| Provider | input_tokens | output_tokens | total_tokens |
57+
|----------|--------------|---------------|--------------|
58+
| OpenAI Chat | prompt_tokens | completion_tokens | total_tokens |
59+
| OpenAI Embed | prompt_tokens | 0 | total_tokens |
60+
| OpenAI Responses | input_tokens | output_tokens | total_tokens |
61+
| Anthropic | input_tokens | output_tokens | calculated |
62+
| Ollama | prompt_eval_count | eval_count | calculated |
63+
| OpenRouter | prompt_tokens | completion_tokens | total_tokens |
64+
65+
**Note:** `total_tokens` is automatically calculated as `input_tokens + output_tokens` when not provided by the provider.

docs/agents/generation.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,11 @@ response.raw_request # The most recent request in provider format
9393
response.raw_response # The most recent response in provider format
9494
response.context # The original context that was sent
9595

96-
# Usage statistics (when available from provider)
97-
response.prompt_tokens # Input tokens used
98-
response.completion_tokens # Output tokens used
99-
response.total_tokens # Total tokens used
96+
# Usage statistics (see /actions/usage for details)
97+
response.usage # Normalized usage object across all providers
98+
response.usage.input_tokens
99+
response.usage.output_tokens
100+
response.usage.total_tokens
100101
```
101102

102103
For embeddings:
@@ -110,14 +111,16 @@ response.raw_request # The most recent request in provider format
110111
response.raw_response # The most recent response in provider format
111112
response.context # The original context that was sent
112113

113-
# Usage statistics (when available from provider)
114-
response.prompt_tokens
114+
# Usage statistics
115+
response.usage # Normalized usage object
116+
response.usage.input_tokens
115117
```
116118

117119
## Next Steps
118120

119121
- [Agents](/agents) - Understanding the full agent lifecycle
120122
- [Actions](/actions) - Define what your agents can do
123+
- [Usage Statistics](/actions/usage) - Track token consumption and costs
121124
- [Messages](/actions/messages) - Work with multimodal content
122125
- [Tools](/actions/tools) - Enable function calling capabilities
123126
- [Streaming](/agents/streaming) - Stream responses in real-time

docs/framework.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ When you define an agent, you create a specialized participant that interacts wi
6060

6161
- **Agent** (Controller) - Manages lifecycle, defines actions, configures providers
6262
- **Generation** (Request Proxy) - Coordinates execution, holds configuration, provides synchronous/async methods. Created by invocation, it's lazy—execution doesn't start until you call `.prompt_now`, `.embed_now`, or `.prompt_later`.
63-
- **Response** (Result) - Contains messages, metadata, token usage, and parsed output. Returned after Generation executes.
63+
- **Response** (Result) - Contains messages, metadata, and normalized usage statistics (see **[Usage Statistics](/actions/usage)**). Returned after Generation executes.
6464

6565
**Request-Response Lifecycle:**
6666

docs/providers.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ All providers return standardized response objects:
9292

9393
**Common attributes:**
9494
- `message` / `messages` - Response content and conversation history
95-
- `prompt_tokens` / `completion_tokens` - Token usage for cost tracking
95+
- `usage` - Normalized token usage statistics (see **[Usage Statistics](/actions/usage)**)
9696
- `raw_request` / `raw_response` - Provider-specific data for debugging
9797
- `context` - Original request sent to provider
9898

lib/active_agent/providers/anthropic_provider.rb

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -192,25 +192,33 @@ def process_tool_call_function(api_function_call)
192192
end
193193

194194
# Converts API response message to hash for message_stack.
195+
# Converts Anthropic gem response object to hash for storage.
196+
#
197+
# @param api_response [Anthropic::Models::Message]
198+
# @return [Common::PromptResponse, nil]
199+
def process_prompt_finished(api_response = nil)
200+
# Convert gem object to hash so that raw_response[:usage] works
201+
api_response_hash = api_response ? Anthropic::Transforms.gem_to_hash(api_response) : nil
202+
super(api_response_hash)
203+
end
204+
195205
#
196206
# Handles JSON response format simulation by prepending `{` to the response
197207
# content after removing the assistant lead-in message.
198208
#
199209
# @see BaseProvider#process_prompt_finished_extract_messages
200-
# @param api_response [Anthropic::Models::Message]
210+
# @param api_response [Hash] converted response hash
201211
# @return [Array<Hash>, nil]
202212
def process_prompt_finished_extract_messages(api_response)
203213
return unless api_response
204214

205215
# Handle JSON response format simulation
206216
if request.response_format&.dig(:type) == "json_object"
207217
request.pop_message!
208-
api_response.content[0].text = "{#{api_response.content[0].text}"
218+
api_response[:content][0][:text] = "{#{api_response[:content][0][:text]}"
209219
end
210220

211-
message = Anthropic::Transforms.gem_to_hash(api_response)
212-
213-
[ message ]
221+
[ api_response ]
214222
end
215223

216224
# Extracts tool_use blocks from message_stack and parses JSON inputs.

lib/active_agent/providers/common/responses/base.rb

Lines changed: 23 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require "active_agent/providers/common/model"
4+
require "active_agent/providers/common/usage"
45

56
module ActiveAgent
67
module Providers
@@ -21,7 +22,7 @@ module Responses
2122
# @example Accessing response data
2223
# response = agent.prompt.generate_now
2324
# response.success? #=> true
24-
# response.usage #=> { "prompt_tokens" => 10, "completion_tokens" => 20 }
25+
# response.usage #=> Usage object with normalized fields
2526
# response.total_tokens #=> 30
2627
#
2728
# @example Inspecting raw provider data
@@ -91,58 +92,33 @@ def success?
9192
true
9293
end
9394

94-
# Extracts usage statistics from the raw response.
95+
# Returns normalized usage statistics across all providers.
9596
#
96-
# Most providers (OpenAI, Anthropic, etc.) return usage data in a
97-
# standardized format within the response. This method extracts that
98-
# information for token counting and billing purposes.
97+
# This method provides a consistent interface for accessing token usage
98+
# regardless of the underlying provider. It automatically detects the
99+
# provider format and returns a {Usage} object with normalized fields.
99100
#
100-
# @return [Hash, nil] usage statistics hash with keys like "prompt_tokens",
101-
# "completion_tokens", and "total_tokens", or nil if not available
101+
# @return [Usage, nil] normalized usage object, or nil if not available
102102
#
103-
# @example Usage data structure
104-
# {
105-
# "prompt_tokens" => 10,
106-
# "completion_tokens" => 20,
107-
# "total_tokens" => 30
108-
# }
109-
def usage
110-
return nil unless raw_response
111-
112-
# Most providers store usage in the same format
113-
if raw_response.is_a?(Hash) && raw_response["usage"]
114-
raw_response["usage"]
115-
end
116-
end
117-
118-
# Extracts the number of tokens used in the prompt/input.
119-
#
120-
# @return [Integer, nil] number of prompt tokens used, or nil if unavailable
103+
# @example Accessing normalized usage
104+
# response.usage.input_tokens #=> 100
105+
# response.usage.output_tokens #=> 25
106+
# response.usage.total_tokens #=> 125
107+
# response.usage.cached_tokens #=> 20 (if available)
121108
#
122-
# @example
123-
# response.prompt_tokens #=> 10
124-
def prompt_tokens
125-
usage&.dig("prompt_tokens")
126-
end
109+
# @see Usage
110+
def usage
111+
@usage ||= begin
112+
return nil unless raw_response
127113

128-
# Extracts the number of tokens used in the completion/output.
129-
#
130-
# @return [Integer, nil] number of completion tokens used, or nil if unavailable
131-
#
132-
# @example
133-
# response.completion_tokens #=> 20
134-
def completion_tokens
135-
usage&.dig("completion_tokens")
136-
end
114+
# Extract raw usage hash from provider response
115+
# Support both string and symbol keys for compatibility
116+
raw_usage = if raw_response.is_a?(Hash)
117+
raw_response["usage"] || raw_response[:usage]
118+
end
137119

138-
# Extracts the total number of tokens used (prompt + completion).
139-
#
140-
# @return [Integer, nil] total number of tokens used, or nil if unavailable
141-
#
142-
# @example
143-
# response.total_tokens #=> 30
144-
def total_tokens
145-
usage&.dig("total_tokens")
120+
Usage.from_provider_usage(raw_usage)
121+
end
146122
end
147123
end
148124
end

0 commit comments

Comments
 (0)