activeagent 0.6.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +240 -2
  3. data/README.md +15 -24
  4. data/lib/active_agent/base.rb +389 -39
  5. data/lib/active_agent/concerns/callbacks.rb +251 -0
  6. data/lib/active_agent/concerns/observers.rb +147 -0
  7. data/lib/active_agent/concerns/parameterized.rb +292 -0
  8. data/lib/active_agent/concerns/provider.rb +120 -0
  9. data/lib/active_agent/concerns/queueing.rb +36 -0
  10. data/lib/active_agent/concerns/rescue.rb +64 -0
  11. data/lib/active_agent/concerns/streaming.rb +282 -0
  12. data/lib/active_agent/concerns/tooling.rb +23 -0
  13. data/lib/active_agent/concerns/view.rb +150 -0
  14. data/lib/active_agent/configuration.rb +442 -20
  15. data/lib/active_agent/generation.rb +141 -47
  16. data/lib/active_agent/providers/_base_provider.rb +420 -0
  17. data/lib/active_agent/providers/anthropic/_types.rb +63 -0
  18. data/lib/active_agent/providers/anthropic/options.rb +53 -0
  19. data/lib/active_agent/providers/anthropic/request.rb +163 -0
  20. data/lib/active_agent/providers/anthropic/transforms.rb +353 -0
  21. data/lib/active_agent/providers/anthropic_provider.rb +254 -0
  22. data/lib/active_agent/providers/common/messages/_types.rb +160 -0
  23. data/lib/active_agent/providers/common/messages/assistant.rb +57 -0
  24. data/lib/active_agent/providers/common/messages/base.rb +17 -0
  25. data/lib/active_agent/providers/common/messages/system.rb +20 -0
  26. data/lib/active_agent/providers/common/messages/tool.rb +21 -0
  27. data/lib/active_agent/providers/common/messages/user.rb +20 -0
  28. data/lib/active_agent/providers/common/model.rb +361 -0
  29. data/lib/active_agent/providers/common/response.rb +13 -0
  30. data/lib/active_agent/providers/common/responses/_types.rb +51 -0
  31. data/lib/active_agent/providers/common/responses/base.rb +199 -0
  32. data/lib/active_agent/providers/common/responses/embed.rb +33 -0
  33. data/lib/active_agent/providers/common/responses/format.rb +31 -0
  34. data/lib/active_agent/providers/common/responses/message.rb +3 -0
  35. data/lib/active_agent/providers/common/responses/prompt.rb +42 -0
  36. data/lib/active_agent/providers/common/usage.rb +385 -0
  37. data/lib/active_agent/providers/concerns/exception_handler.rb +72 -0
  38. data/lib/active_agent/providers/concerns/instrumentation.rb +263 -0
  39. data/lib/active_agent/providers/concerns/previewable.rb +150 -0
  40. data/lib/active_agent/providers/log_subscriber.rb +178 -0
  41. data/lib/active_agent/providers/mock/_types.rb +77 -0
  42. data/lib/active_agent/providers/mock/embedding_request.rb +17 -0
  43. data/lib/active_agent/providers/mock/messages/_types.rb +103 -0
  44. data/lib/active_agent/providers/mock/messages/assistant.rb +26 -0
  45. data/lib/active_agent/providers/mock/messages/base.rb +63 -0
  46. data/lib/active_agent/providers/mock/messages/user.rb +18 -0
  47. data/lib/active_agent/providers/mock/options.rb +30 -0
  48. data/lib/active_agent/providers/mock/request.rb +38 -0
  49. data/lib/active_agent/providers/mock_provider.rb +311 -0
  50. data/lib/active_agent/providers/ollama/_types.rb +5 -0
  51. data/lib/active_agent/providers/ollama/chat/_types.rb +44 -0
  52. data/lib/active_agent/providers/ollama/chat/request.rb +249 -0
  53. data/lib/active_agent/providers/ollama/chat/transforms.rb +135 -0
  54. data/lib/active_agent/providers/ollama/embedding/_types.rb +44 -0
  55. data/lib/active_agent/providers/ollama/embedding/request.rb +190 -0
  56. data/lib/active_agent/providers/ollama/embedding/transforms.rb +160 -0
  57. data/lib/active_agent/providers/ollama/options.rb +27 -0
  58. data/lib/active_agent/providers/ollama_provider.rb +94 -0
  59. data/lib/active_agent/providers/open_ai/_base.rb +59 -0
  60. data/lib/active_agent/providers/open_ai/_types.rb +5 -0
  61. data/lib/active_agent/providers/open_ai/chat/_types.rb +56 -0
  62. data/lib/active_agent/providers/open_ai/chat/request.rb +161 -0
  63. data/lib/active_agent/providers/open_ai/chat/transforms.rb +364 -0
  64. data/lib/active_agent/providers/open_ai/chat_provider.rb +219 -0
  65. data/lib/active_agent/providers/open_ai/embedding/_types.rb +56 -0
  66. data/lib/active_agent/providers/open_ai/embedding/request.rb +53 -0
  67. data/lib/active_agent/providers/open_ai/embedding/transforms.rb +88 -0
  68. data/lib/active_agent/providers/open_ai/options.rb +74 -0
  69. data/lib/active_agent/providers/open_ai/responses/_types.rb +44 -0
  70. data/lib/active_agent/providers/open_ai/responses/request.rb +129 -0
  71. data/lib/active_agent/providers/open_ai/responses/transforms.rb +228 -0
  72. data/lib/active_agent/providers/open_ai/responses_provider.rb +200 -0
  73. data/lib/active_agent/providers/open_ai_provider.rb +94 -0
  74. data/lib/active_agent/providers/open_router/_types.rb +71 -0
  75. data/lib/active_agent/providers/open_router/options.rb +141 -0
  76. data/lib/active_agent/providers/open_router/request.rb +249 -0
  77. data/lib/active_agent/providers/open_router/requests/_types.rb +197 -0
  78. data/lib/active_agent/providers/open_router/requests/messages/_types.rb +56 -0
  79. data/lib/active_agent/providers/open_router/requests/messages/content/_types.rb +97 -0
  80. data/lib/active_agent/providers/open_router/requests/messages/content/file.rb +43 -0
  81. data/lib/active_agent/providers/open_router/requests/messages/content/files/_types.rb +61 -0
  82. data/lib/active_agent/providers/open_router/requests/messages/content/files/details.rb +37 -0
  83. data/lib/active_agent/providers/open_router/requests/plugin.rb +41 -0
  84. data/lib/active_agent/providers/open_router/requests/plugins/_types.rb +46 -0
  85. data/lib/active_agent/providers/open_router/requests/plugins/pdf_config.rb +51 -0
  86. data/lib/active_agent/providers/open_router/requests/prediction.rb +34 -0
  87. data/lib/active_agent/providers/open_router/requests/provider_preferences/_types.rb +44 -0
  88. data/lib/active_agent/providers/open_router/requests/provider_preferences/max_price.rb +64 -0
  89. data/lib/active_agent/providers/open_router/requests/provider_preferences.rb +105 -0
  90. data/lib/active_agent/providers/open_router/requests/response_format.rb +77 -0
  91. data/lib/active_agent/providers/open_router/transforms.rb +134 -0
  92. data/lib/active_agent/providers/open_router_provider.rb +62 -0
  93. data/lib/active_agent/providers/openai_provider.rb +2 -0
  94. data/lib/active_agent/providers/openrouter_provider.rb +2 -0
  95. data/lib/active_agent/railtie.rb +8 -6
  96. data/lib/active_agent/schema_generator.rb +333 -166
  97. data/lib/active_agent/version.rb +1 -1
  98. data/lib/active_agent.rb +112 -36
  99. data/lib/generators/active_agent/agent/USAGE +78 -0
  100. data/lib/generators/active_agent/{agent_generator.rb → agent/agent_generator.rb} +14 -4
  101. data/lib/generators/active_agent/install/USAGE +25 -0
  102. data/lib/generators/active_agent/{install_generator.rb → install/install_generator.rb} +1 -19
  103. data/lib/generators/active_agent/templates/agent.rb.tt +7 -3
  104. data/lib/generators/active_agent/templates/application_agent.rb.tt +0 -2
  105. data/lib/generators/erb/agent_generator.rb +31 -16
  106. data/lib/generators/erb/templates/instructions.md.erb.tt +3 -0
  107. data/lib/generators/erb/templates/instructions.md.tt +3 -0
  108. data/lib/generators/erb/templates/instructions.text.tt +1 -0
  109. data/lib/generators/erb/templates/message.md.erb.tt +5 -0
  110. data/lib/generators/erb/templates/schema.json.tt +10 -0
  111. data/lib/generators/test_unit/agent_generator.rb +1 -1
  112. data/lib/generators/test_unit/templates/functional_test.rb.tt +4 -2
  113. metadata +182 -71
  114. data/lib/active_agent/action_prompt/action.rb +0 -13
  115. data/lib/active_agent/action_prompt/base.rb +0 -623
  116. data/lib/active_agent/action_prompt/message.rb +0 -126
  117. data/lib/active_agent/action_prompt/prompt.rb +0 -136
  118. data/lib/active_agent/action_prompt.rb +0 -19
  119. data/lib/active_agent/callbacks.rb +0 -33
  120. data/lib/active_agent/generation_provider/anthropic_provider.rb +0 -163
  121. data/lib/active_agent/generation_provider/base.rb +0 -55
  122. data/lib/active_agent/generation_provider/base_adapter.rb +0 -19
  123. data/lib/active_agent/generation_provider/error_handling.rb +0 -167
  124. data/lib/active_agent/generation_provider/log_subscriber.rb +0 -92
  125. data/lib/active_agent/generation_provider/message_formatting.rb +0 -107
  126. data/lib/active_agent/generation_provider/ollama_provider.rb +0 -66
  127. data/lib/active_agent/generation_provider/open_ai_provider.rb +0 -279
  128. data/lib/active_agent/generation_provider/open_router_provider.rb +0 -385
  129. data/lib/active_agent/generation_provider/parameter_builder.rb +0 -119
  130. data/lib/active_agent/generation_provider/response.rb +0 -75
  131. data/lib/active_agent/generation_provider/responses_adapter.rb +0 -44
  132. data/lib/active_agent/generation_provider/stream_processing.rb +0 -58
  133. data/lib/active_agent/generation_provider/tool_management.rb +0 -142
  134. data/lib/active_agent/generation_provider.rb +0 -67
  135. data/lib/active_agent/log_subscriber.rb +0 -44
  136. data/lib/active_agent/parameterized.rb +0 -75
  137. data/lib/active_agent/prompt_helper.rb +0 -19
  138. data/lib/active_agent/queued_generation.rb +0 -12
  139. data/lib/active_agent/rescuable.rb +0 -34
  140. data/lib/active_agent/sanitizers.rb +0 -40
  141. data/lib/active_agent/streaming.rb +0 -34
  142. data/lib/active_agent/test_case.rb +0 -125
  143. data/lib/generators/USAGE +0 -47
  144. data/lib/generators/active_agent/USAGE +0 -56
  145. data/lib/generators/erb/install_generator.rb +0 -44
  146. data/lib/generators/erb/templates/layout.html.erb.tt +0 -1
  147. data/lib/generators/erb/templates/layout.json.erb.tt +0 -1
  148. data/lib/generators/erb/templates/layout.text.erb.tt +0 -1
  149. data/lib/generators/erb/templates/view.html.erb.tt +0 -5
  150. data/lib/generators/erb/templates/view.json.erb.tt +0 -16
  151. /data/lib/active_agent/{preview.rb → concerns/preview.rb} +0 -0
  152. /data/lib/generators/erb/templates/{view.text.erb.tt → message.text.erb.tt} +0 -0
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "_types"
5
+ require_relative "message"
6
+
7
+ module ActiveAgent
8
+ module Providers
9
+ module Common
10
+ module Responses
11
+ # Response model for prompt/completion responses
12
+ #
13
+ # This class represents responses from conversational/completion endpoints.
14
+ # It includes the generated messages, the original context, raw API data,
15
+ # and usage statistics.
16
+ #
17
+ # == Example
18
+ #
19
+ # response = PromptResponse.new(
20
+ # context: context_hash,
21
+ # messages: [message_object],
22
+ # raw_response: { "usage" => { "prompt_tokens" => 10 } }
23
+ # )
24
+ #
25
+ # response.message #=> <Message>
26
+ # response.prompt_tokens #=> 10
27
+ # response.usage #=> { "prompt_tokens" => 10, ... }
28
+ class Prompt < Base
29
+ # The list of messages from this conversation
30
+ attribute :messages, Types::MessagesType.new, writable: false
31
+
32
+ attribute :format, Types::FormatType.new, writable: false, default: {}
33
+
34
+ # The most recent message in the conversational stack
35
+ def message
36
+ messages.last
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,385 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_agent/providers/common/model"
4
+
5
+ module ActiveAgent
6
+ module Providers
7
+ module Common
8
+ # Normalizes token usage statistics across AI providers.
9
+ #
10
+ # Providers return usage data in different formats with different field names.
11
+ # This model normalizes them into a consistent structure, automatically calculating
12
+ # +total_tokens+ if not provided.
13
+ #
14
+ # @example Accessing normalized usage data
15
+ # usage = response.normalized_usage
16
+ # usage.input_tokens #=> 100
17
+ # usage.output_tokens #=> 25
18
+ # usage.total_tokens #=> 125
19
+ # usage.cached_tokens #=> 20 (if available)
20
+ #
21
+ # @example Provider-specific details
22
+ # usage.provider_details #=> { "completion_tokens_details" => {...}, ... }
23
+ # usage.duration_ms #=> 5000 (for Ollama)
24
+ # usage.service_tier #=> "standard" (for Anthropic)
25
+ #
26
+ # @see https://platform.openai.com/docs/api-reference/chat/object OpenAI Chat Completion
27
+ # @see https://docs.anthropic.com/en/api/messages Anthropic Messages API
28
+ # @see https://github.com/ollama/ollama/blob/main/docs/api.md Ollama API
29
+ class Usage < BaseModel
30
+ # @!attribute [rw] input_tokens
31
+ # Normalized from:
32
+ # - OpenAI Chat/Embeddings: prompt_tokens
33
+ # - OpenAI Responses API: input_tokens
34
+ # - Anthropic: input_tokens
35
+ # - Ollama: prompt_eval_count
36
+ # - OpenRouter: prompt_tokens
37
+ #
38
+ # @return [Integer]
39
+ attribute :input_tokens, :integer, default: 0
40
+
41
+ # @!attribute [rw] output_tokens
42
+ # Normalized from:
43
+ # - OpenAI Chat: completion_tokens
44
+ # - OpenAI Responses API: output_tokens
45
+ # - Anthropic: output_tokens
46
+ # - Ollama: eval_count
47
+ # - OpenRouter: completion_tokens
48
+ # - OpenAI Embeddings: 0 (no output tokens)
49
+ #
50
+ # @return [Integer]
51
+ attribute :output_tokens, :integer, default: 0
52
+
53
+ # @!attribute [rw] total_tokens
54
+ # Automatically calculated as input_tokens + output_tokens if not provided by provider.
55
+ #
56
+ # @return [Integer]
57
+ attribute :total_tokens, :integer
58
+
59
+ # @!attribute [rw] cached_tokens
60
+ # Available from:
61
+ # - OpenAI: prompt_tokens_details.cached_tokens or input_tokens_details.cached_tokens
62
+ # - Anthropic: cache_read_input_tokens
63
+ #
64
+ # @return [Integer, nil]
65
+ attribute :cached_tokens, :integer
66
+
67
+ # @!attribute [rw] reasoning_tokens
68
+ # Available from:
69
+ # - OpenAI Chat: completion_tokens_details.reasoning_tokens
70
+ # - OpenAI Responses: output_tokens_details.reasoning_tokens
71
+ #
72
+ # @return [Integer, nil]
73
+ attribute :reasoning_tokens, :integer
74
+
75
+ # @!attribute [rw] audio_tokens
76
+ # Available from:
77
+ # - OpenAI: sum of prompt_tokens_details.audio_tokens and completion_tokens_details.audio_tokens
78
+ #
79
+ # @return [Integer, nil]
80
+ attribute :audio_tokens, :integer
81
+
82
+ # @!attribute [rw] cache_creation_tokens
83
+ # Available from:
84
+ # - Anthropic: cache_creation_input_tokens
85
+ #
86
+ # @return [Integer, nil]
87
+ attribute :cache_creation_tokens, :integer
88
+
89
+ # @!attribute [rw] service_tier
90
+ # Available from:
91
+ # - Anthropic: service_tier ("standard", "priority", "batch")
92
+ #
93
+ # @return [String, nil]
94
+ attribute :service_tier, :string
95
+
96
+ # @!attribute [rw] duration_ms
97
+ # Available from:
98
+ # - Ollama: total_duration (converted from nanoseconds)
99
+ #
100
+ # @return [Integer, nil]
101
+ attribute :duration_ms, :integer
102
+
103
+ # @!attribute [rw] provider_details
104
+ # Preserves provider-specific information that doesn't fit the normalized structure.
105
+ # Useful for debugging or provider-specific features.
106
+ #
107
+ # @return [Hash]
108
+ attribute :provider_details, default: -> { {} }
109
+
110
+ # Automatically calculates total_tokens if not provided.
111
+ #
112
+ # @param attributes [Hash]
113
+ # @option attributes [Integer] :input_tokens
114
+ # @option attributes [Integer] :output_tokens
115
+ # @option attributes [Integer] :total_tokens (calculated if not provided)
116
+ # @option attributes [Integer] :cached_tokens
117
+ # @option attributes [Integer] :reasoning_tokens
118
+ # @option attributes [Integer] :audio_tokens
119
+ # @option attributes [Integer] :cache_creation_tokens
120
+ # @option attributes [String] :service_tier
121
+ # @option attributes [Integer] :duration_ms
122
+ # @option attributes [Hash] :provider_details
123
+ def initialize(attributes = {})
124
+ super
125
+ # Calculate total_tokens if not provided
126
+ self.total_tokens ||= (input_tokens || 0) + (output_tokens || 0)
127
+ end
128
+
129
+ # Sums all token counts from two Usage objects.
130
+ #
131
+ # @param other [Usage]
132
+ # @return [Usage]
133
+ #
134
+ # @example
135
+ # usage1 = Usage.new(input_tokens: 100, output_tokens: 50)
136
+ # usage2 = Usage.new(input_tokens: 75, output_tokens: 25)
137
+ # combined = usage1 + usage2
138
+ # combined.input_tokens #=> 175
139
+ # combined.output_tokens #=> 75
140
+ # combined.total_tokens #=> 250
141
+ def +(other)
142
+ return self unless other
143
+
144
+ self.class.new(
145
+ input_tokens: self.input_tokens + other.input_tokens,
146
+ output_tokens: self.output_tokens + other.output_tokens,
147
+ total_tokens: self.total_tokens + other.total_tokens,
148
+ cached_tokens: sum_optional(self.cached_tokens, other.cached_tokens),
149
+ cache_creation_tokens: sum_optional(self.cache_creation_tokens, other.cache_creation_tokens),
150
+ reasoning_tokens: sum_optional(self.reasoning_tokens, other.reasoning_tokens),
151
+ audio_tokens: sum_optional(self.audio_tokens, other.audio_tokens)
152
+ )
153
+ end
154
+
155
+ # Creates a Usage object from OpenAI Chat Completion usage data.
156
+ #
157
+ # @param usage_hash [Hash]
158
+ # @return [Usage]
159
+ #
160
+ # @example
161
+ # Usage.from_openai_chat({
162
+ # "prompt_tokens" => 100,
163
+ # "completion_tokens" => 25,
164
+ # "total_tokens" => 125,
165
+ # "prompt_tokens_details" => { "cached_tokens" => 20 },
166
+ # "completion_tokens_details" => { "reasoning_tokens" => 3 }
167
+ # })
168
+ def self.from_openai_chat(usage_hash)
169
+ return nil unless usage_hash
170
+
171
+ usage = usage_hash.deep_symbolize_keys
172
+ prompt_details = usage[:prompt_tokens_details] || {}
173
+ completion_details = usage[:completion_tokens_details] || {}
174
+
175
+ audio_sum = [
176
+ prompt_details[:audio_tokens],
177
+ completion_details[:audio_tokens]
178
+ ].compact.sum
179
+
180
+ new(
181
+ **usage.slice(:total_tokens),
182
+ input_tokens: usage[:prompt_tokens] || 0,
183
+ output_tokens: usage[:completion_tokens] || 0,
184
+ cached_tokens: prompt_details[:cached_tokens],
185
+ reasoning_tokens: completion_details[:reasoning_tokens],
186
+ audio_tokens: audio_sum > 0 ? audio_sum : nil,
187
+ provider_details: usage.slice(:prompt_tokens_details, :completion_tokens_details).compact
188
+ )
189
+ end
190
+
191
+ # Creates a Usage object from OpenAI Embedding API usage data.
192
+ #
193
+ # @param usage_hash [Hash]
194
+ # @return [Usage]
195
+ #
196
+ # @example
197
+ # Usage.from_openai_embedding({
198
+ # "prompt_tokens" => 8,
199
+ # "total_tokens" => 8
200
+ # })
201
+ def self.from_openai_embedding(usage_hash)
202
+ return nil unless usage_hash
203
+
204
+ usage = usage_hash.deep_symbolize_keys
205
+
206
+ new(
207
+ **usage.slice(:total_tokens),
208
+ input_tokens: usage[:prompt_tokens] || 0,
209
+ output_tokens: 0, # Embeddings don't generate output tokens
210
+ provider_details: usage.except(:prompt_tokens, :total_tokens)
211
+ )
212
+ end
213
+
214
+ # Creates a Usage object from OpenAI Responses API usage data.
215
+ #
216
+ # @param usage_hash [Hash]
217
+ # @return [Usage]
218
+ #
219
+ # @example
220
+ # Usage.from_openai_responses({
221
+ # "input_tokens" => 150,
222
+ # "output_tokens" => 75,
223
+ # "total_tokens" => 225,
224
+ # "input_tokens_details" => { "cached_tokens" => 50 },
225
+ # "output_tokens_details" => { "reasoning_tokens" => 10 }
226
+ # })
227
+ def self.from_openai_responses(usage_hash)
228
+ return nil unless usage_hash
229
+
230
+ usage = usage_hash.deep_symbolize_keys
231
+ input_details = usage[:input_tokens_details] || {}
232
+ output_details = usage[:output_tokens_details] || {}
233
+
234
+ new(
235
+ **usage.slice(:input_tokens, :output_tokens, :total_tokens),
236
+ input_tokens: usage[:input_tokens] || 0,
237
+ output_tokens: usage[:output_tokens] || 0,
238
+ cached_tokens: input_details[:cached_tokens],
239
+ reasoning_tokens: output_details[:reasoning_tokens],
240
+ provider_details: usage.slice(:input_tokens_details, :output_tokens_details).compact
241
+ )
242
+ end
243
+
244
+ # Creates a Usage object from Anthropic usage data.
245
+ #
246
+ # @param usage_hash [Hash]
247
+ # @return [Usage]
248
+ #
249
+ # @example
250
+ # Usage.from_anthropic({
251
+ # "input_tokens" => 2095,
252
+ # "output_tokens" => 503,
253
+ # "cache_read_input_tokens" => 1500,
254
+ # "cache_creation_input_tokens" => 2051,
255
+ # "service_tier" => "standard"
256
+ # })
257
+ def self.from_anthropic(usage_hash)
258
+ return nil unless usage_hash
259
+
260
+ usage = usage_hash.deep_symbolize_keys
261
+
262
+ new(
263
+ **usage.slice(:input_tokens, :output_tokens, :service_tier),
264
+ input_tokens: usage[:input_tokens] || 0,
265
+ output_tokens: usage[:output_tokens] || 0,
266
+ cached_tokens: usage[:cache_read_input_tokens],
267
+ cache_creation_tokens: usage[:cache_creation_input_tokens],
268
+ provider_details: usage.slice(:cache_creation, :server_tool_use).compact
269
+ )
270
+ end
271
+
272
+ # Creates a Usage object from Ollama usage data.
273
+ #
274
+ # @param usage_hash [Hash]
275
+ # @return [Usage]
276
+ #
277
+ # @example
278
+ # Usage.from_ollama({
279
+ # "prompt_eval_count" => 50,
280
+ # "eval_count" => 25,
281
+ # "total_duration" => 5000000000,
282
+ # "load_duration" => 1000000000
283
+ # })
284
+ def self.from_ollama(usage_hash)
285
+ return nil unless usage_hash
286
+
287
+ usage = usage_hash.deep_symbolize_keys
288
+
289
+ new(
290
+ input_tokens: usage[:prompt_eval_count] || 0,
291
+ output_tokens: usage[:eval_count] || 0,
292
+ duration_ms: convert_nanoseconds_to_ms(usage[:total_duration]),
293
+ provider_details: {
294
+ load_duration_ms: convert_nanoseconds_to_ms(usage[:load_duration]),
295
+ prompt_eval_duration_ms: convert_nanoseconds_to_ms(usage[:prompt_eval_duration]),
296
+ eval_duration_ms: convert_nanoseconds_to_ms(usage[:eval_duration]),
297
+ tokens_per_second: calculate_tokens_per_second(usage[:eval_count], usage[:eval_duration])
298
+ }.compact
299
+ )
300
+ end
301
+
302
+ # Creates a Usage object from OpenRouter usage data.
303
+ #
304
+ # OpenRouter uses the same format as OpenAI Chat Completion.
305
+ #
306
+ # @param usage_hash [Hash]
307
+ # @return [Usage]
308
+ #
309
+ # @example
310
+ # Usage.from_openrouter({
311
+ # "prompt_tokens" => 14,
312
+ # "completion_tokens" => 4,
313
+ # "total_tokens" => 18
314
+ # })
315
+ def self.from_openrouter(usage_hash)
316
+ from_openai_chat(usage_hash)
317
+ end
318
+
319
+ # Auto-detects the provider format and creates a normalized Usage object.
320
+ #
321
+ # @note Detection is based on hash structure rather than native gem types
322
+ # because we cannot force-load all provider gems. This allows the framework
323
+ # to work with only the gems the user has installed.
324
+ #
325
+ # @param usage_hash [Hash]
326
+ # @return [Usage, nil]
327
+ #
328
+ # @example
329
+ # Usage.from_provider_usage(some_usage_hash)
330
+ def self.from_provider_usage(usage_hash)
331
+ return nil unless usage_hash.is_a?(Hash)
332
+
333
+ usage = usage_hash.deep_symbolize_keys
334
+
335
+ # Detect Ollama by presence of nanosecond duration fields
336
+ if usage.key?(:total_duration)
337
+ from_ollama(usage_hash)
338
+ # Detect Anthropic by presence of cache_creation or service_tier
339
+ elsif usage.key?(:cache_creation) || usage.key?(:service_tier)
340
+ from_anthropic(usage_hash)
341
+ # Detect OpenAI Responses API by input_tokens/output_tokens with details
342
+ elsif usage.key?(:input_tokens) && usage.key?(:input_tokens_details)
343
+ from_openai_responses(usage_hash)
344
+ # Detect OpenAI Chat/OpenRouter by prompt_tokens/completion_tokens
345
+ elsif usage.key?(:completion_tokens)
346
+ from_openai_chat(usage_hash)
347
+ # Detect OpenAI Embedding by prompt_tokens without completion_tokens
348
+ elsif usage.key?(:prompt_tokens)
349
+ from_openai_embedding(usage_hash)
350
+ # Default to raw initialization
351
+ else
352
+ new(usage_hash)
353
+ end
354
+ end
355
+
356
+ private
357
+
358
+ # @param a [Integer, nil]
359
+ # @param b [Integer, nil]
360
+ # @return [Integer, nil] nil if both inputs are nil
361
+ def sum_optional(a, b)
362
+ return nil if a.nil? && b.nil?
363
+ (a || 0) + (b || 0)
364
+ end
365
+
366
+ # @param nanoseconds [Integer, nil]
367
+ # @return [Integer, nil]
368
+ def self.convert_nanoseconds_to_ms(nanoseconds)
369
+ return nil unless nanoseconds
370
+
371
+ (nanoseconds / 1_000_000.0).round
372
+ end
373
+
374
+ # @param tokens [Integer, nil]
375
+ # @param duration_ns [Integer, nil]
376
+ # @return [Float, nil]
377
+ def self.calculate_tokens_per_second(tokens, duration_ns)
378
+ return nil unless tokens && duration_ns && duration_ns > 0
379
+
380
+ (tokens.to_f / (duration_ns / 1_000_000_000.0)).round(2)
381
+ end
382
+ end
383
+ end
384
+ end
385
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Providers
5
+ # Provides exception handling for provider operations.
6
+ #
7
+ # This concern implements basic exception handling that allows agents to
8
+ # define custom error handling logic via rescue_from callbacks. The actual
9
+ # retry logic is now handled by the underlying provider gems (ruby-openai,
10
+ # anthropic-rb, etc.) which provide their own retry mechanisms.
11
+ #
12
+ # @example Using exception handler
13
+ # class MyProvider
14
+ # include ActiveAgent::Providers::ExceptionHandler
15
+ #
16
+ # def call
17
+ # with_exception_handling do
18
+ # # API call that may fail
19
+ # end
20
+ # end
21
+ # end
22
+ #
23
+ # @example Agent-level error handling
24
+ # class MyAgent < ActiveAgent::Base
25
+ # rescue_from SomeError do |exception|
26
+ # # Handle the error
27
+ # end
28
+ # end
29
+ module ExceptionHandler
30
+ extend ActiveSupport::Concern
31
+
32
+ included do
33
+ # @!attribute [rw] exception_handler
34
+ # @return [Proc, nil] Callback for handling exceptions
35
+ attr_internal :exception_handler
36
+ end
37
+
38
+ # Configures instance-level exception handling.
39
+ #
40
+ # @param exception_handler [Proc, nil] callback for handling exceptions
41
+ # @return [void]
42
+ def configure_exception_handler(exception_handler: nil)
43
+ self.exception_handler = exception_handler
44
+ end
45
+
46
+ # Executes a block with exception handling.
47
+ #
48
+ # @yield Block to execute with exception protection
49
+ # @return [Object] The result of the block execution
50
+ # @raise [StandardError] Any unhandled exception from the block
51
+ #
52
+ # @example Basic usage
53
+ # with_exception_handling { api_call }
54
+ def with_exception_handling(&block)
55
+ yield
56
+ rescue => exception
57
+ rescue_with_handler(exception) || raise
58
+ end
59
+
60
+ # Bubbles up exceptions to the Agent's rescue_from if a handler is defined.
61
+ #
62
+ # This method delegates exception handling to the configured exception handler,
63
+ # allowing agents to define custom error handling logic.
64
+ #
65
+ # @param exception [StandardError] The exception to handle
66
+ # @return [Object, nil] Result from the exception handler, or nil if no handler
67
+ def rescue_with_handler(exception)
68
+ exception_handler&.call(exception)
69
+ end
70
+ end
71
+ end
72
+ end