activeagent 1.0.0.rc1 → 1.0.1

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 (191) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -1
  3. data/lib/active_agent/providers/_base_provider.rb +94 -82
  4. data/lib/active_agent/providers/anthropic/_types.rb +2 -2
  5. data/lib/active_agent/providers/anthropic/options.rb +4 -6
  6. data/lib/active_agent/providers/anthropic/request.rb +157 -78
  7. data/lib/active_agent/providers/anthropic/transforms.rb +482 -0
  8. data/lib/active_agent/providers/anthropic_provider.rb +159 -59
  9. data/lib/active_agent/providers/common/messages/_types.rb +46 -3
  10. data/lib/active_agent/providers/common/messages/assistant.rb +20 -4
  11. data/lib/active_agent/providers/common/responses/base.rb +118 -70
  12. data/lib/active_agent/providers/common/usage.rb +385 -0
  13. data/lib/active_agent/providers/concerns/instrumentation.rb +263 -0
  14. data/lib/active_agent/providers/concerns/previewable.rb +39 -5
  15. data/lib/active_agent/providers/concerns/tool_choice_clearing.rb +62 -0
  16. data/lib/active_agent/providers/log_subscriber.rb +64 -246
  17. data/lib/active_agent/providers/mock_provider.rb +23 -23
  18. data/lib/active_agent/providers/ollama/chat/request.rb +214 -35
  19. data/lib/active_agent/providers/ollama/chat/transforms.rb +135 -0
  20. data/lib/active_agent/providers/ollama/embedding/request.rb +160 -47
  21. data/lib/active_agent/providers/ollama/embedding/transforms.rb +160 -0
  22. data/lib/active_agent/providers/ollama_provider.rb +0 -1
  23. data/lib/active_agent/providers/open_ai/_base.rb +3 -2
  24. data/lib/active_agent/providers/open_ai/chat/_types.rb +13 -1
  25. data/lib/active_agent/providers/open_ai/chat/request.rb +132 -186
  26. data/lib/active_agent/providers/open_ai/chat/transforms.rb +444 -0
  27. data/lib/active_agent/providers/open_ai/chat_provider.rb +95 -36
  28. data/lib/active_agent/providers/open_ai/embedding/_types.rb +13 -2
  29. data/lib/active_agent/providers/open_ai/embedding/request.rb +38 -70
  30. data/lib/active_agent/providers/open_ai/embedding/transforms.rb +88 -0
  31. data/lib/active_agent/providers/open_ai/responses/_types.rb +1 -7
  32. data/lib/active_agent/providers/open_ai/responses/request.rb +116 -135
  33. data/lib/active_agent/providers/open_ai/responses/transforms.rb +363 -0
  34. data/lib/active_agent/providers/open_ai/responses_provider.rb +115 -30
  35. data/lib/active_agent/providers/open_ai_provider.rb +0 -3
  36. data/lib/active_agent/providers/open_router/_types.rb +27 -1
  37. data/lib/active_agent/providers/open_router/options.rb +49 -1
  38. data/lib/active_agent/providers/open_router/request.rb +252 -66
  39. data/lib/active_agent/providers/open_router/requests/_types.rb +0 -1
  40. data/lib/active_agent/providers/open_router/requests/messages/_types.rb +37 -40
  41. data/lib/active_agent/providers/open_router/requests/messages/content/file.rb +19 -3
  42. data/lib/active_agent/providers/open_router/requests/messages/content/files/details.rb +15 -4
  43. data/lib/active_agent/providers/open_router/requests/plugin.rb +19 -3
  44. data/lib/active_agent/providers/open_router/requests/plugins/pdf_config.rb +30 -8
  45. data/lib/active_agent/providers/open_router/requests/prediction.rb +17 -0
  46. data/lib/active_agent/providers/open_router/requests/provider_preferences/max_price.rb +41 -7
  47. data/lib/active_agent/providers/open_router/requests/provider_preferences.rb +60 -19
  48. data/lib/active_agent/providers/open_router/requests/response_format.rb +30 -2
  49. data/lib/active_agent/providers/open_router/transforms.rb +164 -0
  50. data/lib/active_agent/providers/open_router_provider.rb +23 -0
  51. data/lib/active_agent/version.rb +1 -1
  52. metadata +17 -160
  53. data/lib/active_agent/generation_provider/open_router/types.rb +0 -505
  54. data/lib/active_agent/generation_provider/xai_provider.rb +0 -144
  55. data/lib/active_agent/providers/anthropic/requests/_types.rb +0 -190
  56. data/lib/active_agent/providers/anthropic/requests/container_params.rb +0 -19
  57. data/lib/active_agent/providers/anthropic/requests/content/base.rb +0 -21
  58. data/lib/active_agent/providers/anthropic/requests/content/sources/base.rb +0 -22
  59. data/lib/active_agent/providers/anthropic/requests/context_management_config.rb +0 -18
  60. data/lib/active_agent/providers/anthropic/requests/messages/_types.rb +0 -189
  61. data/lib/active_agent/providers/anthropic/requests/messages/assistant.rb +0 -23
  62. data/lib/active_agent/providers/anthropic/requests/messages/base.rb +0 -63
  63. data/lib/active_agent/providers/anthropic/requests/messages/content/_types.rb +0 -143
  64. data/lib/active_agent/providers/anthropic/requests/messages/content/base.rb +0 -21
  65. data/lib/active_agent/providers/anthropic/requests/messages/content/document.rb +0 -26
  66. data/lib/active_agent/providers/anthropic/requests/messages/content/image.rb +0 -23
  67. data/lib/active_agent/providers/anthropic/requests/messages/content/redacted_thinking.rb +0 -21
  68. data/lib/active_agent/providers/anthropic/requests/messages/content/search_result.rb +0 -27
  69. data/lib/active_agent/providers/anthropic/requests/messages/content/sources/_types.rb +0 -171
  70. data/lib/active_agent/providers/anthropic/requests/messages/content/sources/base.rb +0 -22
  71. data/lib/active_agent/providers/anthropic/requests/messages/content/sources/document_base64.rb +0 -25
  72. data/lib/active_agent/providers/anthropic/requests/messages/content/sources/document_file.rb +0 -23
  73. data/lib/active_agent/providers/anthropic/requests/messages/content/sources/document_text.rb +0 -25
  74. data/lib/active_agent/providers/anthropic/requests/messages/content/sources/document_url.rb +0 -23
  75. data/lib/active_agent/providers/anthropic/requests/messages/content/sources/image_base64.rb +0 -27
  76. data/lib/active_agent/providers/anthropic/requests/messages/content/sources/image_file.rb +0 -23
  77. data/lib/active_agent/providers/anthropic/requests/messages/content/sources/image_url.rb +0 -23
  78. data/lib/active_agent/providers/anthropic/requests/messages/content/text.rb +0 -22
  79. data/lib/active_agent/providers/anthropic/requests/messages/content/thinking.rb +0 -23
  80. data/lib/active_agent/providers/anthropic/requests/messages/content/tool_result.rb +0 -24
  81. data/lib/active_agent/providers/anthropic/requests/messages/content/tool_use.rb +0 -28
  82. data/lib/active_agent/providers/anthropic/requests/messages/user.rb +0 -21
  83. data/lib/active_agent/providers/anthropic/requests/metadata.rb +0 -18
  84. data/lib/active_agent/providers/anthropic/requests/response_format.rb +0 -22
  85. data/lib/active_agent/providers/anthropic/requests/thinking_config/_types.rb +0 -60
  86. data/lib/active_agent/providers/anthropic/requests/thinking_config/base.rb +0 -20
  87. data/lib/active_agent/providers/anthropic/requests/thinking_config/disabled.rb +0 -16
  88. data/lib/active_agent/providers/anthropic/requests/thinking_config/enabled.rb +0 -20
  89. data/lib/active_agent/providers/anthropic/requests/tool_choice/_types.rb +0 -78
  90. data/lib/active_agent/providers/anthropic/requests/tool_choice/any.rb +0 -17
  91. data/lib/active_agent/providers/anthropic/requests/tool_choice/auto.rb +0 -17
  92. data/lib/active_agent/providers/anthropic/requests/tool_choice/base.rb +0 -20
  93. data/lib/active_agent/providers/anthropic/requests/tool_choice/none.rb +0 -16
  94. data/lib/active_agent/providers/anthropic/requests/tool_choice/tool.rb +0 -20
  95. data/lib/active_agent/providers/ollama/chat/requests/_types.rb +0 -3
  96. data/lib/active_agent/providers/ollama/chat/requests/messages/_types.rb +0 -116
  97. data/lib/active_agent/providers/ollama/chat/requests/messages/assistant.rb +0 -19
  98. data/lib/active_agent/providers/ollama/chat/requests/messages/user.rb +0 -19
  99. data/lib/active_agent/providers/ollama/embedding/requests/_types.rb +0 -83
  100. data/lib/active_agent/providers/ollama/embedding/requests/options.rb +0 -104
  101. data/lib/active_agent/providers/open_ai/chat/requests/_types.rb +0 -229
  102. data/lib/active_agent/providers/open_ai/chat/requests/audio.rb +0 -24
  103. data/lib/active_agent/providers/open_ai/chat/requests/messages/_types.rb +0 -123
  104. data/lib/active_agent/providers/open_ai/chat/requests/messages/assistant.rb +0 -42
  105. data/lib/active_agent/providers/open_ai/chat/requests/messages/base.rb +0 -78
  106. data/lib/active_agent/providers/open_ai/chat/requests/messages/content/_types.rb +0 -133
  107. data/lib/active_agent/providers/open_ai/chat/requests/messages/content/audio.rb +0 -35
  108. data/lib/active_agent/providers/open_ai/chat/requests/messages/content/base.rb +0 -24
  109. data/lib/active_agent/providers/open_ai/chat/requests/messages/content/file.rb +0 -26
  110. data/lib/active_agent/providers/open_ai/chat/requests/messages/content/files/_types.rb +0 -60
  111. data/lib/active_agent/providers/open_ai/chat/requests/messages/content/files/details.rb +0 -41
  112. data/lib/active_agent/providers/open_ai/chat/requests/messages/content/image.rb +0 -37
  113. data/lib/active_agent/providers/open_ai/chat/requests/messages/content/refusal.rb +0 -25
  114. data/lib/active_agent/providers/open_ai/chat/requests/messages/content/text.rb +0 -25
  115. data/lib/active_agent/providers/open_ai/chat/requests/messages/developer.rb +0 -25
  116. data/lib/active_agent/providers/open_ai/chat/requests/messages/function.rb +0 -25
  117. data/lib/active_agent/providers/open_ai/chat/requests/messages/system.rb +0 -25
  118. data/lib/active_agent/providers/open_ai/chat/requests/messages/tool.rb +0 -26
  119. data/lib/active_agent/providers/open_ai/chat/requests/messages/user.rb +0 -32
  120. data/lib/active_agent/providers/open_ai/chat/requests/prediction.rb +0 -46
  121. data/lib/active_agent/providers/open_ai/chat/requests/response_format.rb +0 -53
  122. data/lib/active_agent/providers/open_ai/chat/requests/stream_options.rb +0 -24
  123. data/lib/active_agent/providers/open_ai/chat/requests/tool_choice.rb +0 -26
  124. data/lib/active_agent/providers/open_ai/chat/requests/tools/_types.rb +0 -5
  125. data/lib/active_agent/providers/open_ai/chat/requests/tools/base.rb +0 -22
  126. data/lib/active_agent/providers/open_ai/chat/requests/tools/custom_tool.rb +0 -41
  127. data/lib/active_agent/providers/open_ai/chat/requests/tools/function_tool.rb +0 -51
  128. data/lib/active_agent/providers/open_ai/chat/requests/web_search_options.rb +0 -45
  129. data/lib/active_agent/providers/open_ai/embedding/requests/_types.rb +0 -49
  130. data/lib/active_agent/providers/open_ai/responses/requests/_types.rb +0 -231
  131. data/lib/active_agent/providers/open_ai/responses/requests/conversation.rb +0 -23
  132. data/lib/active_agent/providers/open_ai/responses/requests/inputs/_types.rb +0 -264
  133. data/lib/active_agent/providers/open_ai/responses/requests/inputs/assistant_message.rb +0 -22
  134. data/lib/active_agent/providers/open_ai/responses/requests/inputs/base.rb +0 -89
  135. data/lib/active_agent/providers/open_ai/responses/requests/inputs/code_interpreter_tool_call.rb +0 -30
  136. data/lib/active_agent/providers/open_ai/responses/requests/inputs/computer_tool_call.rb +0 -28
  137. data/lib/active_agent/providers/open_ai/responses/requests/inputs/computer_tool_call_output.rb +0 -33
  138. data/lib/active_agent/providers/open_ai/responses/requests/inputs/content/_types.rb +0 -207
  139. data/lib/active_agent/providers/open_ai/responses/requests/inputs/content/base.rb +0 -22
  140. data/lib/active_agent/providers/open_ai/responses/requests/inputs/content/input_audio.rb +0 -26
  141. data/lib/active_agent/providers/open_ai/responses/requests/inputs/content/input_file.rb +0 -28
  142. data/lib/active_agent/providers/open_ai/responses/requests/inputs/content/input_image.rb +0 -28
  143. data/lib/active_agent/providers/open_ai/responses/requests/inputs/content/input_text.rb +0 -25
  144. data/lib/active_agent/providers/open_ai/responses/requests/inputs/custom_tool_call.rb +0 -28
  145. data/lib/active_agent/providers/open_ai/responses/requests/inputs/custom_tool_call_output.rb +0 -27
  146. data/lib/active_agent/providers/open_ai/responses/requests/inputs/developer_message.rb +0 -20
  147. data/lib/active_agent/providers/open_ai/responses/requests/inputs/file_search_tool_call.rb +0 -25
  148. data/lib/active_agent/providers/open_ai/responses/requests/inputs/function_call_output.rb +0 -32
  149. data/lib/active_agent/providers/open_ai/responses/requests/inputs/function_tool_call.rb +0 -28
  150. data/lib/active_agent/providers/open_ai/responses/requests/inputs/image_gen_tool_call.rb +0 -27
  151. data/lib/active_agent/providers/open_ai/responses/requests/inputs/input_message.rb +0 -31
  152. data/lib/active_agent/providers/open_ai/responses/requests/inputs/item_reference.rb +0 -23
  153. data/lib/active_agent/providers/open_ai/responses/requests/inputs/local_shell_tool_call.rb +0 -26
  154. data/lib/active_agent/providers/open_ai/responses/requests/inputs/local_shell_tool_call_output.rb +0 -33
  155. data/lib/active_agent/providers/open_ai/responses/requests/inputs/mcp_approval_request.rb +0 -30
  156. data/lib/active_agent/providers/open_ai/responses/requests/inputs/mcp_approval_response.rb +0 -28
  157. data/lib/active_agent/providers/open_ai/responses/requests/inputs/mcp_list_tools.rb +0 -29
  158. data/lib/active_agent/providers/open_ai/responses/requests/inputs/mcp_tool_call.rb +0 -35
  159. data/lib/active_agent/providers/open_ai/responses/requests/inputs/output_message.rb +0 -35
  160. data/lib/active_agent/providers/open_ai/responses/requests/inputs/reasoning.rb +0 -33
  161. data/lib/active_agent/providers/open_ai/responses/requests/inputs/system_message.rb +0 -20
  162. data/lib/active_agent/providers/open_ai/responses/requests/inputs/tool_call_base.rb +0 -27
  163. data/lib/active_agent/providers/open_ai/responses/requests/inputs/tool_message.rb +0 -23
  164. data/lib/active_agent/providers/open_ai/responses/requests/inputs/user_message.rb +0 -20
  165. data/lib/active_agent/providers/open_ai/responses/requests/inputs/web_search_tool_call.rb +0 -24
  166. data/lib/active_agent/providers/open_ai/responses/requests/prompt_reference.rb +0 -23
  167. data/lib/active_agent/providers/open_ai/responses/requests/reasoning.rb +0 -23
  168. data/lib/active_agent/providers/open_ai/responses/requests/stream_options.rb +0 -20
  169. data/lib/active_agent/providers/open_ai/responses/requests/text/_types.rb +0 -89
  170. data/lib/active_agent/providers/open_ai/responses/requests/text/base.rb +0 -22
  171. data/lib/active_agent/providers/open_ai/responses/requests/text/json_object.rb +0 -20
  172. data/lib/active_agent/providers/open_ai/responses/requests/text/json_schema.rb +0 -48
  173. data/lib/active_agent/providers/open_ai/responses/requests/text/plain.rb +0 -20
  174. data/lib/active_agent/providers/open_ai/responses/requests/text.rb +0 -41
  175. data/lib/active_agent/providers/open_ai/responses/requests/tool_choice.rb +0 -26
  176. data/lib/active_agent/providers/open_ai/responses/requests/tools/_types.rb +0 -112
  177. data/lib/active_agent/providers/open_ai/responses/requests/tools/base.rb +0 -25
  178. data/lib/active_agent/providers/open_ai/responses/requests/tools/code_interpreter_tool.rb +0 -23
  179. data/lib/active_agent/providers/open_ai/responses/requests/tools/computer_tool.rb +0 -27
  180. data/lib/active_agent/providers/open_ai/responses/requests/tools/custom_tool.rb +0 -28
  181. data/lib/active_agent/providers/open_ai/responses/requests/tools/file_search_tool.rb +0 -27
  182. data/lib/active_agent/providers/open_ai/responses/requests/tools/function_tool.rb +0 -29
  183. data/lib/active_agent/providers/open_ai/responses/requests/tools/image_generation_tool.rb +0 -37
  184. data/lib/active_agent/providers/open_ai/responses/requests/tools/local_shell_tool.rb +0 -21
  185. data/lib/active_agent/providers/open_ai/responses/requests/tools/mcp_tool.rb +0 -41
  186. data/lib/active_agent/providers/open_ai/responses/requests/tools/web_search_preview_tool.rb +0 -24
  187. data/lib/active_agent/providers/open_ai/responses/requests/tools/web_search_tool.rb +0 -25
  188. data/lib/active_agent/providers/open_ai/schema.yml +0 -65937
  189. data/lib/active_agent/providers/open_router/requests/message.rb +0 -1
  190. data/lib/active_agent/providers/open_router/requests/messages/assistant.rb +0 -20
  191. data/lib/active_agent/providers/open_router/requests/messages/user.rb +0 -30
@@ -1,27 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_agent/providers/common/model"
4
+ require "active_agent/providers/common/usage"
4
5
 
5
6
  module ActiveAgent
6
7
  module Providers
7
8
  module Common
8
9
  module Responses
9
- # Base response model for provider responses.
10
- #
11
- # This class represents the standard response structure from AI providers
12
- # across different services (OpenAI, Anthropic, etc.). It provides a unified
13
- # interface for accessing response data, usage statistics, and request context.
10
+ # Provides unified interface for AI provider responses across OpenAI, Anthropic, etc.
14
11
  #
15
12
  # @abstract Subclass and override {#usage} if provider uses non-standard format
16
13
  #
17
- # @note This is a base class. Use specialized subclasses for specific response types:
18
- # - {Prompt} for conversational/completion responses with messages
19
- # - {Embed} for embedding responses with vector data
14
+ # @note Use specialized subclasses for specific response types:
15
+ # - {Prompt} for conversational/completion responses
16
+ # - {Embed} for embedding responses
20
17
  #
21
18
  # @example Accessing response data
22
19
  # response = agent.prompt.generate_now
23
20
  # response.success? #=> true
24
- # response.usage #=> { "prompt_tokens" => 10, "completion_tokens" => 20 }
21
+ # response.usage #=> Usage object with normalized fields
25
22
  # response.total_tokens #=> 30
26
23
  #
27
24
  # @example Inspecting raw provider data
@@ -33,117 +30,168 @@ module ActiveAgent
33
30
  # @see BaseModel
34
31
  class Base < BaseModel
35
32
  # @!attribute [r] context
36
- # The original context that was sent to the provider.
33
+ # Original request context sent to the provider.
37
34
  #
38
- # Contains structured information about the request including instructions,
39
- # messages, tools, and other configuration passed to the LLM.
35
+ # Includes instructions, messages, tools, and configuration.
40
36
  #
41
- # @return [Hash] the request context
37
+ # @return [Hash]
42
38
  attribute :context, writable: false
43
39
 
44
40
  # @!attribute [r] raw_request
45
- # The most recent request in provider-specific format.
41
+ # Most recent request in provider-specific format.
46
42
  #
47
- # Contains the actual API request payload sent to the provider,
48
- # useful for debugging and logging.
43
+ # Useful for debugging and logging.
49
44
  #
50
- # @return [Hash] the provider-formatted request
45
+ # @return [Hash]
51
46
  attribute :raw_request, writable: false
52
47
 
53
48
  # @!attribute [r] raw_response
54
- # The most recent response in provider-specific format.
49
+ # Most recent response in provider-specific format.
55
50
  #
56
- # Contains the raw API response from the provider, including all
57
- # metadata, usage stats, and provider-specific fields.
51
+ # Includes metadata, usage stats, and provider-specific fields.
52
+ # Hash keys are deep symbolized for consistent access.
58
53
  #
59
- # @return [Hash] the provider-formatted response
54
+ # @return [Hash]
60
55
  attribute :raw_response, writable: false
61
56
 
62
- # Initializes a new response object with deep-duplicated attributes.
57
+ # @!attribute [r] usages
58
+ # Usage objects from each API call in multi-turn conversations.
59
+ #
60
+ # Each call (e.g., for tool calling) tracks usage separately. These are
61
+ # summed to provide cumulative statistics via {#usage}.
63
62
  #
64
- # Deep duplication ensures that the response object maintains its own
65
- # independent copy of the data, preventing external modifications from
66
- # affecting the response's internal state.
63
+ # @return [Array<Usage>]
64
+ attribute :usages, default: -> { [] }, writable: false
65
+
66
+ # Initializes response with deep-duplicated attributes.
67
67
  #
68
- # @param kwargs [Hash] response attributes
69
- # @option kwargs [Hash] :context the original request context
70
- # @option kwargs [Hash] :raw_request the provider-formatted request
71
- # @option kwargs [Hash] :raw_response the provider-formatted response
68
+ # Deep duplication prevents external modifications from affecting internal state.
69
+ # The raw_response is deep symbolized for consistent key access across providers.
72
70
  #
73
- # @return [Base] the initialized response object
71
+ # @param kwargs [Hash]
72
+ # @option kwargs [Hash] :context
73
+ # @option kwargs [Hash] :raw_request
74
+ # @option kwargs [Hash] :raw_response
74
75
  def initialize(kwargs = {})
75
- super(kwargs.deep_dup) # Ensure that userland can't fuck with our memory space
76
+ kwargs = kwargs.deep_dup # Ensure that userland can't fuck with our memory space
77
+
78
+ # Deep symbolize raw_response for consistent access across all extraction methods
79
+ if kwargs[:raw_response].is_a?(Hash)
80
+ kwargs[:raw_response] = kwargs[:raw_response].deep_symbolize_keys
81
+ end
82
+
83
+ super(kwargs)
76
84
  end
77
85
 
78
- # Extracts instructions from the context.
79
- #
80
- # @return [String, Array<Hash>, nil] the instructions that were sent to the provider
86
+ # @return [String, Array<Hash>, nil]
81
87
  def instructions
82
88
  context[:instructions]
83
89
  end
84
90
 
85
- # Indicates whether the generation request was successful.
86
- #
87
91
  # @todo Better handling of failure flows
88
- #
89
- # @return [Boolean] true if successful, false otherwise
92
+ # @return [Boolean]
90
93
  def success?
91
94
  true
92
95
  end
93
96
 
94
- # Extracts usage statistics from the raw response.
97
+ # Normalized usage statistics across all providers.
98
+ #
99
+ # For multi-turn conversations with tool calling, returns cumulative
100
+ # usage across all API calls (sum of {#usages}).
95
101
  #
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.
102
+ # @return [Usage, nil]
99
103
  #
100
- # @return [Hash, nil] usage statistics hash with keys like "prompt_tokens",
101
- # "completion_tokens", and "total_tokens", or nil if not available
104
+ # @example Single-turn usage
105
+ # response.usage.input_tokens #=> 100
106
+ # response.usage.output_tokens #=> 25
107
+ # response.usage.total_tokens #=> 125
102
108
  #
103
- # @example Usage data structure
104
- # {
105
- # "prompt_tokens" => 10,
106
- # "completion_tokens" => 20,
107
- # "total_tokens" => 30
108
- # }
109
+ # @example Multi-turn usage (cumulative)
110
+ # # After 3 API calls due to tool usage:
111
+ # response.usage.input_tokens #=> 350 (sum of all calls)
112
+ # response.usage.output_tokens #=> 120 (sum of all calls)
113
+ #
114
+ # @see Usage
109
115
  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"]
116
+ @usage ||= begin
117
+ if usages.any?
118
+ usages.reduce(:+)
119
+ elsif raw_response
120
+ Usage.from_provider_usage(
121
+ raw_response.is_a?(Hash) ? raw_response[:usage] : raw_response.usage
122
+ )
123
+ end
115
124
  end
116
125
  end
117
126
 
118
- # Extracts the number of tokens used in the prompt/input.
127
+ # Response ID from provider, useful for tracking and debugging.
119
128
  #
120
- # @return [Integer, nil] number of prompt tokens used, or nil if unavailable
129
+ # @return [String, nil]
121
130
  #
122
131
  # @example
123
- # response.prompt_tokens #=> 10
124
- def prompt_tokens
125
- usage&.dig("prompt_tokens")
132
+ # response.id #=> "chatcmpl-CbDx1nXoNSBrNIMhiuy5fk7jXQjmT" (OpenAI)
133
+ # response.id #=> "msg_01RotDmSnYpKQjrTpaHUaEBz" (Anthropic)
134
+ # response.id #=> "gen-1761505659-yxgaVsqVABMQqw6oA7QF" (OpenRouter)
135
+ def id
136
+ @id ||= begin
137
+ return nil unless raw_response
138
+
139
+ if raw_response.is_a?(Hash)
140
+ raw_response[:id]
141
+ elsif raw_response.respond_to?(:id)
142
+ raw_response.id
143
+ end
144
+ end
126
145
  end
127
146
 
128
- # Extracts the number of tokens used in the completion/output.
147
+ # Model name from provider response.
148
+ #
149
+ # Useful for confirming which model was actually used, as providers may
150
+ # use different versions than requested.
129
151
  #
130
- # @return [Integer, nil] number of completion tokens used, or nil if unavailable
152
+ # @return [String, nil]
131
153
  #
132
154
  # @example
133
- # response.completion_tokens #=> 20
134
- def completion_tokens
135
- usage&.dig("completion_tokens")
155
+ # response.model #=> "gpt-4o-mini-2024-07-18"
156
+ # response.model #=> "claude-3-5-haiku-20241022"
157
+ def model
158
+ @model ||= begin
159
+ return nil unless raw_response
160
+
161
+ if raw_response.is_a?(Hash)
162
+ raw_response[:model]
163
+ elsif raw_response.respond_to?(:model)
164
+ raw_response.model
165
+ end
166
+ end
136
167
  end
137
168
 
138
- # Extracts the total number of tokens used (prompt + completion).
169
+ # Finish reason from provider response.
170
+ #
171
+ # Indicates why generation stopped (e.g., "stop", "length", "tool_calls").
172
+ # Normalizes access across providers that use different field names.
139
173
  #
140
- # @return [Integer, nil] total number of tokens used, or nil if unavailable
174
+ # @return [String, nil]
141
175
  #
142
176
  # @example
143
- # response.total_tokens #=> 30
144
- def total_tokens
145
- usage&.dig("total_tokens")
177
+ # response.finish_reason #=> "stop"
178
+ # response.finish_reason #=> "length"
179
+ # response.finish_reason #=> "tool_calls"
180
+ # response.stop_reason #=> "stop" (alias)
181
+ def finish_reason
182
+ @finish_reason ||= begin
183
+ return nil unless raw_response
184
+
185
+ if raw_response.is_a?(Hash)
186
+ # OpenAI format: choices[0].finish_reason or choices[0].message.finish_reason
187
+ raw_response.dig(:choices, 0, :finish_reason) ||
188
+ raw_response.dig(:choices, 0, :message, :finish_reason) ||
189
+ # Anthropic format: stop_reason
190
+ raw_response[:stop_reason]
191
+ end
192
+ end
146
193
  end
194
+ alias_method :stop_reason, :finish_reason
147
195
  end
148
196
  end
149
197
  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