activeagent 1.0.0.rc1 → 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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -1
  3. data/lib/active_agent/providers/_base_provider.rb +92 -82
  4. data/lib/active_agent/providers/anthropic/_types.rb +2 -2
  5. data/lib/active_agent/providers/anthropic/request.rb +135 -81
  6. data/lib/active_agent/providers/anthropic/transforms.rb +353 -0
  7. data/lib/active_agent/providers/anthropic_provider.rb +96 -53
  8. data/lib/active_agent/providers/common/messages/_types.rb +37 -1
  9. data/lib/active_agent/providers/common/responses/base.rb +118 -70
  10. data/lib/active_agent/providers/common/usage.rb +385 -0
  11. data/lib/active_agent/providers/concerns/instrumentation.rb +263 -0
  12. data/lib/active_agent/providers/log_subscriber.rb +64 -246
  13. data/lib/active_agent/providers/mock_provider.rb +23 -23
  14. data/lib/active_agent/providers/ollama/chat/request.rb +214 -35
  15. data/lib/active_agent/providers/ollama/chat/transforms.rb +135 -0
  16. data/lib/active_agent/providers/ollama/embedding/request.rb +160 -47
  17. data/lib/active_agent/providers/ollama/embedding/transforms.rb +160 -0
  18. data/lib/active_agent/providers/ollama_provider.rb +0 -1
  19. data/lib/active_agent/providers/open_ai/_base.rb +3 -2
  20. data/lib/active_agent/providers/open_ai/chat/_types.rb +13 -1
  21. data/lib/active_agent/providers/open_ai/chat/request.rb +132 -186
  22. data/lib/active_agent/providers/open_ai/chat/transforms.rb +364 -0
  23. data/lib/active_agent/providers/open_ai/chat_provider.rb +57 -36
  24. data/lib/active_agent/providers/open_ai/embedding/_types.rb +13 -2
  25. data/lib/active_agent/providers/open_ai/embedding/request.rb +38 -70
  26. data/lib/active_agent/providers/open_ai/embedding/transforms.rb +88 -0
  27. data/lib/active_agent/providers/open_ai/responses/_types.rb +1 -7
  28. data/lib/active_agent/providers/open_ai/responses/request.rb +100 -134
  29. data/lib/active_agent/providers/open_ai/responses/transforms.rb +228 -0
  30. data/lib/active_agent/providers/open_ai/responses_provider.rb +77 -30
  31. data/lib/active_agent/providers/open_ai_provider.rb +0 -3
  32. data/lib/active_agent/providers/open_router/_types.rb +27 -1
  33. data/lib/active_agent/providers/open_router/options.rb +49 -1
  34. data/lib/active_agent/providers/open_router/request.rb +232 -66
  35. data/lib/active_agent/providers/open_router/requests/_types.rb +0 -1
  36. data/lib/active_agent/providers/open_router/requests/messages/_types.rb +37 -40
  37. data/lib/active_agent/providers/open_router/requests/messages/content/file.rb +19 -3
  38. data/lib/active_agent/providers/open_router/requests/messages/content/files/details.rb +15 -4
  39. data/lib/active_agent/providers/open_router/requests/plugin.rb +19 -3
  40. data/lib/active_agent/providers/open_router/requests/plugins/pdf_config.rb +30 -8
  41. data/lib/active_agent/providers/open_router/requests/prediction.rb +17 -0
  42. data/lib/active_agent/providers/open_router/requests/provider_preferences/max_price.rb +41 -7
  43. data/lib/active_agent/providers/open_router/requests/provider_preferences.rb +60 -19
  44. data/lib/active_agent/providers/open_router/requests/response_format.rb +30 -2
  45. data/lib/active_agent/providers/open_router/transforms.rb +134 -0
  46. data/lib/active_agent/providers/open_router_provider.rb +9 -0
  47. data/lib/active_agent/version.rb +1 -1
  48. metadata +15 -159
  49. data/lib/active_agent/generation_provider/open_router/types.rb +0 -505
  50. data/lib/active_agent/generation_provider/xai_provider.rb +0 -144
  51. data/lib/active_agent/providers/anthropic/requests/_types.rb +0 -190
  52. data/lib/active_agent/providers/anthropic/requests/container_params.rb +0 -19
  53. data/lib/active_agent/providers/anthropic/requests/content/base.rb +0 -21
  54. data/lib/active_agent/providers/anthropic/requests/content/sources/base.rb +0 -22
  55. data/lib/active_agent/providers/anthropic/requests/context_management_config.rb +0 -18
  56. data/lib/active_agent/providers/anthropic/requests/messages/_types.rb +0 -189
  57. data/lib/active_agent/providers/anthropic/requests/messages/assistant.rb +0 -23
  58. data/lib/active_agent/providers/anthropic/requests/messages/base.rb +0 -63
  59. data/lib/active_agent/providers/anthropic/requests/messages/content/_types.rb +0 -143
  60. data/lib/active_agent/providers/anthropic/requests/messages/content/base.rb +0 -21
  61. data/lib/active_agent/providers/anthropic/requests/messages/content/document.rb +0 -26
  62. data/lib/active_agent/providers/anthropic/requests/messages/content/image.rb +0 -23
  63. data/lib/active_agent/providers/anthropic/requests/messages/content/redacted_thinking.rb +0 -21
  64. data/lib/active_agent/providers/anthropic/requests/messages/content/search_result.rb +0 -27
  65. data/lib/active_agent/providers/anthropic/requests/messages/content/sources/_types.rb +0 -171
  66. data/lib/active_agent/providers/anthropic/requests/messages/content/sources/base.rb +0 -22
  67. data/lib/active_agent/providers/anthropic/requests/messages/content/sources/document_base64.rb +0 -25
  68. data/lib/active_agent/providers/anthropic/requests/messages/content/sources/document_file.rb +0 -23
  69. data/lib/active_agent/providers/anthropic/requests/messages/content/sources/document_text.rb +0 -25
  70. data/lib/active_agent/providers/anthropic/requests/messages/content/sources/document_url.rb +0 -23
  71. data/lib/active_agent/providers/anthropic/requests/messages/content/sources/image_base64.rb +0 -27
  72. data/lib/active_agent/providers/anthropic/requests/messages/content/sources/image_file.rb +0 -23
  73. data/lib/active_agent/providers/anthropic/requests/messages/content/sources/image_url.rb +0 -23
  74. data/lib/active_agent/providers/anthropic/requests/messages/content/text.rb +0 -22
  75. data/lib/active_agent/providers/anthropic/requests/messages/content/thinking.rb +0 -23
  76. data/lib/active_agent/providers/anthropic/requests/messages/content/tool_result.rb +0 -24
  77. data/lib/active_agent/providers/anthropic/requests/messages/content/tool_use.rb +0 -28
  78. data/lib/active_agent/providers/anthropic/requests/messages/user.rb +0 -21
  79. data/lib/active_agent/providers/anthropic/requests/metadata.rb +0 -18
  80. data/lib/active_agent/providers/anthropic/requests/response_format.rb +0 -22
  81. data/lib/active_agent/providers/anthropic/requests/thinking_config/_types.rb +0 -60
  82. data/lib/active_agent/providers/anthropic/requests/thinking_config/base.rb +0 -20
  83. data/lib/active_agent/providers/anthropic/requests/thinking_config/disabled.rb +0 -16
  84. data/lib/active_agent/providers/anthropic/requests/thinking_config/enabled.rb +0 -20
  85. data/lib/active_agent/providers/anthropic/requests/tool_choice/_types.rb +0 -78
  86. data/lib/active_agent/providers/anthropic/requests/tool_choice/any.rb +0 -17
  87. data/lib/active_agent/providers/anthropic/requests/tool_choice/auto.rb +0 -17
  88. data/lib/active_agent/providers/anthropic/requests/tool_choice/base.rb +0 -20
  89. data/lib/active_agent/providers/anthropic/requests/tool_choice/none.rb +0 -16
  90. data/lib/active_agent/providers/anthropic/requests/tool_choice/tool.rb +0 -20
  91. data/lib/active_agent/providers/ollama/chat/requests/_types.rb +0 -3
  92. data/lib/active_agent/providers/ollama/chat/requests/messages/_types.rb +0 -116
  93. data/lib/active_agent/providers/ollama/chat/requests/messages/assistant.rb +0 -19
  94. data/lib/active_agent/providers/ollama/chat/requests/messages/user.rb +0 -19
  95. data/lib/active_agent/providers/ollama/embedding/requests/_types.rb +0 -83
  96. data/lib/active_agent/providers/ollama/embedding/requests/options.rb +0 -104
  97. data/lib/active_agent/providers/open_ai/chat/requests/_types.rb +0 -229
  98. data/lib/active_agent/providers/open_ai/chat/requests/audio.rb +0 -24
  99. data/lib/active_agent/providers/open_ai/chat/requests/messages/_types.rb +0 -123
  100. data/lib/active_agent/providers/open_ai/chat/requests/messages/assistant.rb +0 -42
  101. data/lib/active_agent/providers/open_ai/chat/requests/messages/base.rb +0 -78
  102. data/lib/active_agent/providers/open_ai/chat/requests/messages/content/_types.rb +0 -133
  103. data/lib/active_agent/providers/open_ai/chat/requests/messages/content/audio.rb +0 -35
  104. data/lib/active_agent/providers/open_ai/chat/requests/messages/content/base.rb +0 -24
  105. data/lib/active_agent/providers/open_ai/chat/requests/messages/content/file.rb +0 -26
  106. data/lib/active_agent/providers/open_ai/chat/requests/messages/content/files/_types.rb +0 -60
  107. data/lib/active_agent/providers/open_ai/chat/requests/messages/content/files/details.rb +0 -41
  108. data/lib/active_agent/providers/open_ai/chat/requests/messages/content/image.rb +0 -37
  109. data/lib/active_agent/providers/open_ai/chat/requests/messages/content/refusal.rb +0 -25
  110. data/lib/active_agent/providers/open_ai/chat/requests/messages/content/text.rb +0 -25
  111. data/lib/active_agent/providers/open_ai/chat/requests/messages/developer.rb +0 -25
  112. data/lib/active_agent/providers/open_ai/chat/requests/messages/function.rb +0 -25
  113. data/lib/active_agent/providers/open_ai/chat/requests/messages/system.rb +0 -25
  114. data/lib/active_agent/providers/open_ai/chat/requests/messages/tool.rb +0 -26
  115. data/lib/active_agent/providers/open_ai/chat/requests/messages/user.rb +0 -32
  116. data/lib/active_agent/providers/open_ai/chat/requests/prediction.rb +0 -46
  117. data/lib/active_agent/providers/open_ai/chat/requests/response_format.rb +0 -53
  118. data/lib/active_agent/providers/open_ai/chat/requests/stream_options.rb +0 -24
  119. data/lib/active_agent/providers/open_ai/chat/requests/tool_choice.rb +0 -26
  120. data/lib/active_agent/providers/open_ai/chat/requests/tools/_types.rb +0 -5
  121. data/lib/active_agent/providers/open_ai/chat/requests/tools/base.rb +0 -22
  122. data/lib/active_agent/providers/open_ai/chat/requests/tools/custom_tool.rb +0 -41
  123. data/lib/active_agent/providers/open_ai/chat/requests/tools/function_tool.rb +0 -51
  124. data/lib/active_agent/providers/open_ai/chat/requests/web_search_options.rb +0 -45
  125. data/lib/active_agent/providers/open_ai/embedding/requests/_types.rb +0 -49
  126. data/lib/active_agent/providers/open_ai/responses/requests/_types.rb +0 -231
  127. data/lib/active_agent/providers/open_ai/responses/requests/conversation.rb +0 -23
  128. data/lib/active_agent/providers/open_ai/responses/requests/inputs/_types.rb +0 -264
  129. data/lib/active_agent/providers/open_ai/responses/requests/inputs/assistant_message.rb +0 -22
  130. data/lib/active_agent/providers/open_ai/responses/requests/inputs/base.rb +0 -89
  131. data/lib/active_agent/providers/open_ai/responses/requests/inputs/code_interpreter_tool_call.rb +0 -30
  132. data/lib/active_agent/providers/open_ai/responses/requests/inputs/computer_tool_call.rb +0 -28
  133. data/lib/active_agent/providers/open_ai/responses/requests/inputs/computer_tool_call_output.rb +0 -33
  134. data/lib/active_agent/providers/open_ai/responses/requests/inputs/content/_types.rb +0 -207
  135. data/lib/active_agent/providers/open_ai/responses/requests/inputs/content/base.rb +0 -22
  136. data/lib/active_agent/providers/open_ai/responses/requests/inputs/content/input_audio.rb +0 -26
  137. data/lib/active_agent/providers/open_ai/responses/requests/inputs/content/input_file.rb +0 -28
  138. data/lib/active_agent/providers/open_ai/responses/requests/inputs/content/input_image.rb +0 -28
  139. data/lib/active_agent/providers/open_ai/responses/requests/inputs/content/input_text.rb +0 -25
  140. data/lib/active_agent/providers/open_ai/responses/requests/inputs/custom_tool_call.rb +0 -28
  141. data/lib/active_agent/providers/open_ai/responses/requests/inputs/custom_tool_call_output.rb +0 -27
  142. data/lib/active_agent/providers/open_ai/responses/requests/inputs/developer_message.rb +0 -20
  143. data/lib/active_agent/providers/open_ai/responses/requests/inputs/file_search_tool_call.rb +0 -25
  144. data/lib/active_agent/providers/open_ai/responses/requests/inputs/function_call_output.rb +0 -32
  145. data/lib/active_agent/providers/open_ai/responses/requests/inputs/function_tool_call.rb +0 -28
  146. data/lib/active_agent/providers/open_ai/responses/requests/inputs/image_gen_tool_call.rb +0 -27
  147. data/lib/active_agent/providers/open_ai/responses/requests/inputs/input_message.rb +0 -31
  148. data/lib/active_agent/providers/open_ai/responses/requests/inputs/item_reference.rb +0 -23
  149. data/lib/active_agent/providers/open_ai/responses/requests/inputs/local_shell_tool_call.rb +0 -26
  150. data/lib/active_agent/providers/open_ai/responses/requests/inputs/local_shell_tool_call_output.rb +0 -33
  151. data/lib/active_agent/providers/open_ai/responses/requests/inputs/mcp_approval_request.rb +0 -30
  152. data/lib/active_agent/providers/open_ai/responses/requests/inputs/mcp_approval_response.rb +0 -28
  153. data/lib/active_agent/providers/open_ai/responses/requests/inputs/mcp_list_tools.rb +0 -29
  154. data/lib/active_agent/providers/open_ai/responses/requests/inputs/mcp_tool_call.rb +0 -35
  155. data/lib/active_agent/providers/open_ai/responses/requests/inputs/output_message.rb +0 -35
  156. data/lib/active_agent/providers/open_ai/responses/requests/inputs/reasoning.rb +0 -33
  157. data/lib/active_agent/providers/open_ai/responses/requests/inputs/system_message.rb +0 -20
  158. data/lib/active_agent/providers/open_ai/responses/requests/inputs/tool_call_base.rb +0 -27
  159. data/lib/active_agent/providers/open_ai/responses/requests/inputs/tool_message.rb +0 -23
  160. data/lib/active_agent/providers/open_ai/responses/requests/inputs/user_message.rb +0 -20
  161. data/lib/active_agent/providers/open_ai/responses/requests/inputs/web_search_tool_call.rb +0 -24
  162. data/lib/active_agent/providers/open_ai/responses/requests/prompt_reference.rb +0 -23
  163. data/lib/active_agent/providers/open_ai/responses/requests/reasoning.rb +0 -23
  164. data/lib/active_agent/providers/open_ai/responses/requests/stream_options.rb +0 -20
  165. data/lib/active_agent/providers/open_ai/responses/requests/text/_types.rb +0 -89
  166. data/lib/active_agent/providers/open_ai/responses/requests/text/base.rb +0 -22
  167. data/lib/active_agent/providers/open_ai/responses/requests/text/json_object.rb +0 -20
  168. data/lib/active_agent/providers/open_ai/responses/requests/text/json_schema.rb +0 -48
  169. data/lib/active_agent/providers/open_ai/responses/requests/text/plain.rb +0 -20
  170. data/lib/active_agent/providers/open_ai/responses/requests/text.rb +0 -41
  171. data/lib/active_agent/providers/open_ai/responses/requests/tool_choice.rb +0 -26
  172. data/lib/active_agent/providers/open_ai/responses/requests/tools/_types.rb +0 -112
  173. data/lib/active_agent/providers/open_ai/responses/requests/tools/base.rb +0 -25
  174. data/lib/active_agent/providers/open_ai/responses/requests/tools/code_interpreter_tool.rb +0 -23
  175. data/lib/active_agent/providers/open_ai/responses/requests/tools/computer_tool.rb +0 -27
  176. data/lib/active_agent/providers/open_ai/responses/requests/tools/custom_tool.rb +0 -28
  177. data/lib/active_agent/providers/open_ai/responses/requests/tools/file_search_tool.rb +0 -27
  178. data/lib/active_agent/providers/open_ai/responses/requests/tools/function_tool.rb +0 -29
  179. data/lib/active_agent/providers/open_ai/responses/requests/tools/image_generation_tool.rb +0 -37
  180. data/lib/active_agent/providers/open_ai/responses/requests/tools/local_shell_tool.rb +0 -21
  181. data/lib/active_agent/providers/open_ai/responses/requests/tools/mcp_tool.rb +0 -41
  182. data/lib/active_agent/providers/open_ai/responses/requests/tools/web_search_preview_tool.rb +0 -24
  183. data/lib/active_agent/providers/open_ai/responses/requests/tools/web_search_tool.rb +0 -25
  184. data/lib/active_agent/providers/open_ai/schema.yml +0 -65937
  185. data/lib/active_agent/providers/open_router/requests/message.rb +0 -1
  186. data/lib/active_agent/providers/open_router/requests/messages/assistant.rb +0 -20
  187. data/lib/active_agent/providers/open_router/requests/messages/user.rb +0 -30
@@ -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,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Providers
5
+ # Builds instrumentation event payloads for ActiveSupport::Notifications.
6
+ #
7
+ # Extracts request parameters and response metadata for monitoring, debugging,
8
+ # and APM integration (New Relic, DataDog, etc.).
9
+ #
10
+ # == Event Payloads
11
+ #
12
+ # Top-Level Events (overall request lifecycle):
13
+ #
14
+ # prompt.active_agent::
15
+ # Initial: `{ model:, temperature:, max_tokens:, message_count:, has_tools:, stream: }`
16
+ # Final: `{ usage: { input_tokens:, output_tokens:, total_tokens: }, finish_reason:, response_model:, response_id: }`
17
+ # Note: Usage is cumulative across all API calls in multi-turn conversations
18
+ #
19
+ # embed.active_agent::
20
+ # Initial: `{ model:, input_size:, encoding_format:, dimensions: }`
21
+ # Final: `{ usage: { input_tokens:, total_tokens: }, embedding_count:, response_model:, response_id: }`
22
+ #
23
+ # Provider-Level Events (per API call):
24
+ #
25
+ # prompt.provider.active_agent::
26
+ # Initial: `{ model:, temperature:, max_tokens:, message_count:, has_tools:, stream: }`
27
+ # Final: `{ usage: { input_tokens:, output_tokens:, total_tokens: }, finish_reason:, response_model:, response_id: }`
28
+ # Note: Usage is per individual API call
29
+ #
30
+ # embed.provider.active_agent::
31
+ # Initial: `{ model:, input_size:, encoding_format:, dimensions: }`
32
+ # Final: `{ usage: { input_tokens:, total_tokens: }, embedding_count:, response_model:, response_id: }`
33
+ module Instrumentation
34
+ extend ActiveSupport::Concern
35
+
36
+ # Builds and merges payload data for prompt instrumentation events.
37
+ #
38
+ # Populates both request parameters and response metadata for top-level and
39
+ # provider-level events. Usage data (tokens) is CRITICAL for APM cost tracking
40
+ # and performance monitoring.
41
+ #
42
+ # @param payload [Hash] instrumentation payload to merge into
43
+ # @param request [Request] request object with parameters
44
+ # @param response [Common::PromptResponse] completed response with normalized data
45
+ # @return [void]
46
+ def instrumentation_prompt_payload(payload, request, response)
47
+ # message_count: prefer the request/input messages (pre-call), fall back to
48
+ # response messages only if the request doesn't expose messages. New Relic
49
+ # expects parameters[:messages] to be the request messages and computes
50
+ # total message counts by adding response choices to that count.
51
+ message_count = safe_access(request, :messages)&.size
52
+ message_count = safe_access(response, :messages)&.size if message_count.nil?
53
+
54
+ payload.merge!(trace_id: trace_id, message_count: message_count || 0, stream: !!safe_access(request, :stream))
55
+
56
+ # Common parameters: prefer response-normalized values, then request
57
+ payload[:model] = safe_access(response, :model) || safe_access(request, :model)
58
+ payload[:temperature] = safe_access(request, :temperature)
59
+ payload[:max_tokens] = safe_access(request, :max_tokens)
60
+ payload[:top_p] = safe_access(request, :top_p)
61
+
62
+ # Tools / instructions
63
+ if (tools_val = safe_access(request, :tools))
64
+ payload[:has_tools] = tools_val.respond_to?(:present?) ? tools_val.present? : !!tools_val
65
+ payload[:tool_count] = tools_val&.size || 0
66
+ end
67
+
68
+ if (instr_val = safe_access(request, :instructions))
69
+ payload[:has_instructions] = instr_val.respond_to?(:present?) ? instr_val.present? : !!instr_val
70
+ end
71
+
72
+ # Usage (normalized)
73
+ if response.usage
74
+ usage = response.usage
75
+ payload[:usage] = {
76
+ input_tokens: usage.input_tokens,
77
+ output_tokens: usage.output_tokens,
78
+ total_tokens: usage.total_tokens
79
+ }
80
+
81
+ payload[:usage][:cached_tokens] = usage.cached_tokens if usage.cached_tokens
82
+ payload[:usage][:cache_creation_tokens] = usage.cache_creation_tokens if usage.cache_creation_tokens
83
+ payload[:usage][:reasoning_tokens] = usage.reasoning_tokens if usage.reasoning_tokens
84
+ payload[:usage][:audio_tokens] = usage.audio_tokens if usage.audio_tokens
85
+ end
86
+
87
+ # Response metadata
88
+ payload[:finish_reason] = safe_access(response, :finish_reason) || response.finish_reason
89
+ payload[:response_model] = safe_access(response, :model) || response.model
90
+ payload[:response_id] = safe_access(response, :id) || response.id
91
+
92
+ # Build messages list: prefer request messages; if unavailable use prior
93
+ # response messages (all but the final generated message).
94
+ if (req_msgs = safe_access(request, :messages)).is_a?(Array)
95
+ payload[:messages] = req_msgs.map { |m| extract_message_hash(m, false) }
96
+ else
97
+ prior = safe_access(response, :messages)
98
+ prior = prior[0...-1] if prior.is_a?(Array) && prior.size > 1
99
+ if prior.is_a?(Array) && prior.any?
100
+ payload[:messages] = prior.map { |m| extract_message_hash(m, false) }
101
+ end
102
+ end
103
+
104
+ # Build a parameters hash that mirrors what New Relic's OpenAI
105
+ # instrumentation expects. This makes it easy for APM adapters to
106
+ # map our provider payload to their LLM event constructors.
107
+ parameters = {}
108
+ parameters[:model] = payload[:model] if payload[:model]
109
+ parameters[:max_tokens] = payload[:max_tokens] if payload[:max_tokens]
110
+ parameters[:temperature] = payload[:temperature] if payload[:temperature]
111
+ parameters[:top_p] = payload[:top_p] if payload[:top_p]
112
+ parameters[:stream] = payload[:stream]
113
+ parameters[:messages] = payload[:messages] if payload[:messages]
114
+
115
+ # Include tools/instructions where available — New Relic ignores unknown keys,
116
+ # but having them here makes the parameter shape closer to OpenAI's.
117
+ parameters[:tools] = begin request.tools rescue nil end if begin request.tools rescue nil end
118
+ parameters[:instructions] = begin request.instructions rescue nil end if begin request.instructions rescue nil end
119
+
120
+ payload[:parameters] = parameters
121
+
122
+ # Attach raw response (provider-specific) so downstream APM integrations
123
+ # can inspect the provider response if needed. Use the normalized raw_response
124
+ # available on the Common::Response when possible.
125
+ begin
126
+ payload[:response_raw] = response.raw_response if response.respond_to?(:raw_response) && response.raw_response
127
+ rescue StandardError
128
+ # ignore
129
+ end
130
+ end
131
+
132
+ private
133
+
134
+ # Safely attempt to call a method or lookup a key on an object. We avoid
135
+ # probing with `respond_to?` to prevent ActiveModel attribute casting side
136
+ # effects; instead we attempt the call and rescue failures.
137
+ def safe_access(obj, name)
138
+ return nil if obj.nil?
139
+
140
+ begin
141
+ return obj.public_send(name)
142
+ rescue StandardError
143
+ end
144
+
145
+ begin
146
+ return obj[name]
147
+ rescue StandardError
148
+ end
149
+
150
+ begin
151
+ return obj[name.to_s]
152
+ rescue StandardError
153
+ end
154
+
155
+ nil
156
+ end
157
+
158
+ # NOTE: message access is handled via `safe_access(obj, :messages)` to
159
+ # avoid duplicating guarded lookup logic.
160
+
161
+ # Extract a simple hash from a provider message object or hash-like value.
162
+ def extract_message_hash(msg, is_response = false)
163
+ role = begin
164
+ if msg.respond_to?(:[])
165
+ begin msg[:role] rescue (begin msg["role"] rescue nil end) end
166
+ elsif msg.respond_to?(:role)
167
+ msg.role
168
+ elsif msg.respond_to?(:type)
169
+ msg.type
170
+ end
171
+ rescue StandardError
172
+ begin msg.role rescue msg.type rescue nil end
173
+ end
174
+
175
+ content = begin
176
+ if msg.respond_to?(:[])
177
+ begin msg[:content] rescue (begin msg["content"] rescue nil end) end
178
+ elsif msg.respond_to?(:content)
179
+ msg.content
180
+ elsif msg.respond_to?(:text)
181
+ msg.text
182
+ elsif msg.respond_to?(:to_h)
183
+ begin msg.to_h[:content] rescue (begin msg.to_h["content"] rescue nil end) end
184
+ elsif msg.respond_to?(:to_s)
185
+ msg.to_s
186
+ end
187
+ rescue StandardError
188
+ begin msg.to_s rescue nil end
189
+ end
190
+
191
+ { role: role, content: content, is_response: is_response }
192
+ end
193
+
194
+ # Builds and merges payload data for embed instrumentation events.
195
+ #
196
+ # Embeddings typically only report input tokens (no output tokens).
197
+ #
198
+ # @param payload [Hash] instrumentation payload to merge into
199
+ # @param request [Request] request object with parameters
200
+ # @param response [Common::EmbedResponse] completed response with normalized data
201
+ # @return [void]
202
+ def instrumentation_embed_payload(payload, request, response)
203
+ # Add request parameters
204
+ payload[:trace_id] = trace_id
205
+ payload[:model] = request.model if request.respond_to?(:model)
206
+
207
+ # Add input size if available
208
+ if request.respond_to?(:input)
209
+ begin
210
+ input = request.input
211
+ if input.is_a?(String)
212
+ payload[:input_size] = 1
213
+ elsif input.is_a?(Array)
214
+ payload[:input_size] = input.size
215
+ end
216
+ rescue # OpenAI throws errors this for some reason when you try to look at the input.
217
+ payload[:input_size] = request[:input].size
218
+ end
219
+ end
220
+
221
+ # Expose embedding input content similarly to message content.
222
+ # Use guarded access to avoid provider-specific errors.
223
+ begin
224
+ if (emb_input = safe_access(request, :input))
225
+ # Keep the raw input (string or array) in the payload so APM adapters
226
+ # can inspect it. This matches how we include message content.
227
+ payload[:input] = emb_input
228
+ end
229
+ rescue StandardError
230
+ # ignore
231
+ end
232
+
233
+ # Add encoding format if available (OpenAI)
234
+ payload[:encoding_format] = request.encoding_format if request.respond_to?(:encoding_format)
235
+
236
+ # Add dimensions if available (OpenAI)
237
+ payload[:dimensions] = request.dimensions if request.respond_to?(:dimensions)
238
+
239
+ # Add response data
240
+ payload[:embedding_count] = response.data&.size || 0
241
+
242
+ # Add usage data if available (CRITICAL for APM integration)
243
+ # Embeddings typically only have input tokens
244
+ if response.usage
245
+ payload[:usage] = {
246
+ input_tokens: response.usage.input_tokens,
247
+ total_tokens: response.usage.total_tokens
248
+ }
249
+ end
250
+
251
+ # Add response metadata directly from response object
252
+ payload[:response_model] = response.model
253
+ payload[:response_id] = response.id
254
+
255
+ # Build a parameters hash for embeddings to match New Relic's shape.
256
+ emb_params = {}
257
+ emb_params[:model] = payload[:model] if payload[:model]
258
+ emb_params[:input] = payload[:input] if payload.key?(:input)
259
+ payload[:parameters] = emb_params unless emb_params.empty?
260
+ end
261
+ end
262
+ end
263
+ end