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,364 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/hash/keys"
4
+
5
+ module ActiveAgent
6
+ module Providers
7
+ module OpenAI
8
+ module Chat
9
+ # Provides transformation methods for normalizing chat parameters
10
+ # to OpenAI gem's native format
11
+ #
12
+ # Handles message normalization, shorthand formats, instructions mapping,
13
+ # and response format conversion for the Chat Completions API.
14
+ module Transforms
15
+ class << self
16
+ # Converts gem model object to hash via JSON round-trip
17
+ #
18
+ # @param gem_object [Object]
19
+ # @return [Hash] with symbolized keys
20
+ def gem_to_hash(gem_object)
21
+ JSON.parse(gem_object.to_json, symbolize_names: true)
22
+ end
23
+
24
+ # Normalizes all request parameters for OpenAI Chat API
25
+ #
26
+ # Handles instructions mapping to developer messages, message normalization,
27
+ # and response_format conversion. This is the main entry point for parameter
28
+ # transformation.
29
+ #
30
+ # @param params [Hash]
31
+ # @return [Hash] normalized parameters
32
+ def normalize_params(params)
33
+ params = params.dup
34
+
35
+ # Map common format 'instructions' to developer messages
36
+ if params.key?(:instructions)
37
+ instructions_messages = normalize_instructions(params.delete(:instructions))
38
+ params[:messages] = instructions_messages + Array(params[:messages] || [])
39
+ end
40
+
41
+ # Normalize messages for gem compatibility
42
+ params[:messages] = normalize_messages(params[:messages]) if params[:messages]
43
+
44
+ # Normalize response_format if present
45
+ params[:response_format] = normalize_response_format(params[:response_format]) if params[:response_format]
46
+
47
+ params
48
+ end
49
+
50
+ # Normalizes messages to OpenAI Chat API format using gem message classes
51
+ #
52
+ # Handles various input formats:
53
+ # - `"text"` → UserMessageParam
54
+ # - `[{role: "user", content: "..."}]` → array of message params
55
+ # - Merges consecutive same-role messages into single message
56
+ #
57
+ # @param messages [Array, String, Hash, nil]
58
+ # @return [Array<OpenAI::Models::Chat::ChatCompletionMessageParam>, nil]
59
+ def normalize_messages(messages)
60
+ case messages
61
+ when String
62
+ [ create_message_param("user", messages) ]
63
+ when Hash
64
+ [ normalize_message(messages) ]
65
+ when Array
66
+ grouped = []
67
+
68
+ messages.each do |msg|
69
+ normalized = normalize_message(msg)
70
+
71
+ if grouped.empty? || grouped.last.role != normalized.role
72
+ grouped << normalized
73
+ else
74
+ # Merge consecutive same-role messages
75
+ merged_content = merge_content(grouped.last.content, normalized.content)
76
+ grouped[-1] = create_message_param(grouped.last.role, merged_content)
77
+ end
78
+ end
79
+
80
+ grouped
81
+ when nil
82
+ nil
83
+ else
84
+ raise ArgumentError, "Cannot normalize #{messages.class} to messages array"
85
+ end
86
+ end
87
+
88
+ # Normalizes a single message to proper gem message param class
89
+ #
90
+ # Handles shorthand formats:
91
+ # - `"text"` → user message
92
+ # - `{text: "..."}` → user message
93
+ # - `{role: "system", text: "..."}` → system message
94
+ # - `{image: "url"}` → user message with image content part
95
+ # - `{text: "...", image: "url"}` → user message with text and image parts
96
+ #
97
+ # @param message [String, Hash, OpenAI::Models::Chat::ChatCompletionMessageParam]
98
+ # @return [OpenAI::Models::Chat::ChatCompletionMessageParam]
99
+ def normalize_message(message)
100
+ case message
101
+ when String
102
+ create_message_param("user", message)
103
+ when ::OpenAI::Models::Chat::ChatCompletionMessageParam
104
+ # Already a gem message param - pass through
105
+ message
106
+ when Hash
107
+ msg_hash = message.deep_symbolize_keys
108
+ role = msg_hash[:role]&.to_s || "user"
109
+
110
+ # Handle shorthand formats
111
+ content = if msg_hash.key?(:content)
112
+ # Standard format with explicit content
113
+ msg_hash[:content]
114
+ elsif msg_hash.key?(:text) && msg_hash.key?(:image)
115
+ # Shorthand with both text and image: { text: "...", image: "url" }
116
+ [
117
+ { type: "text", text: msg_hash[:text] },
118
+ { type: "image_url", image_url: { url: msg_hash[:image] } }
119
+ ]
120
+ elsif msg_hash.key?(:image)
121
+ # Shorthand with only image: { image: "url" }
122
+ # Text comes from adjacent prompt arguments
123
+ [ { type: "image_url", image_url: { url: msg_hash[:image] } } ]
124
+ elsif msg_hash.key?(:text)
125
+ # Shorthand: { text: "..." } or { role: "...", text: "..." }
126
+ msg_hash[:text]
127
+ else
128
+ # No content specified
129
+ nil
130
+ end
131
+
132
+ # Create appropriate message param based on role and content
133
+ extra_params = msg_hash.except(:role, :content, :text, :image)
134
+ create_message_param(role, content, extra_params)
135
+ else
136
+ raise ArgumentError, "Cannot normalize #{message.class} to message"
137
+ end
138
+ end
139
+
140
+ # Creates the appropriate gem message param class for the given role
141
+ #
142
+ # @param role [String] message role (developer, system, user, assistant, tool, function)
143
+ # @param content [String, Array, Hash, nil]
144
+ # @param extra_params [Hash] additional parameters (tool_call_id, name, etc.)
145
+ # @return [OpenAI::Models::Chat::ChatCompletionMessageParam]
146
+ # @raise [ArgumentError] when role is unknown
147
+ def create_message_param(role, content, extra_params = {})
148
+ params = { role: role }
149
+ params[:content] = normalize_content(content) if content
150
+ params.merge!(extra_params)
151
+
152
+ case role.to_s
153
+ when "developer"
154
+ ::OpenAI::Models::Chat::ChatCompletionDeveloperMessageParam.new(**params)
155
+ when "system"
156
+ ::OpenAI::Models::Chat::ChatCompletionSystemMessageParam.new(**params)
157
+ when "user"
158
+ ::OpenAI::Models::Chat::ChatCompletionUserMessageParam.new(**params)
159
+ when "assistant"
160
+ ::OpenAI::Models::Chat::ChatCompletionAssistantMessageParam.new(**params)
161
+ when "tool"
162
+ ::OpenAI::Models::Chat::ChatCompletionToolMessageParam.new(**params)
163
+ when "function"
164
+ ::OpenAI::Models::Chat::ChatCompletionFunctionMessageParam.new(**params)
165
+ else
166
+ raise ArgumentError, "Unknown message role: #{role}"
167
+ end
168
+ end
169
+
170
+ # Normalizes message content to Chat API format
171
+ #
172
+ # @param content [String, Array, Hash, nil]
173
+ # @return [String, Array, nil]
174
+ # @raise [ArgumentError] when content type is invalid
175
+ def normalize_content(content)
176
+ case content
177
+ when String
178
+ content
179
+ when Array
180
+ content.map { |part| normalize_content_part(part) }
181
+ when Hash
182
+ # Single content part as hash - wrap in array
183
+ [ normalize_content_part(content) ]
184
+ when nil
185
+ nil
186
+ else
187
+ raise ArgumentError, "Cannot normalize #{content.class} to content"
188
+ end
189
+ end
190
+
191
+ # Normalizes a single content part
192
+ #
193
+ # Converts strings to proper content part format with type and text keys.
194
+ #
195
+ # @param part [Hash, String]
196
+ # @return [Hash] content part with symbolized keys
197
+ # @raise [ArgumentError] when part type is invalid
198
+ def normalize_content_part(part)
199
+ case part
200
+ when Hash
201
+ part.deep_symbolize_keys
202
+ when String
203
+ { type: "text", text: part }
204
+ else
205
+ raise ArgumentError, "Cannot normalize #{part.class} to content part"
206
+ end
207
+ end
208
+
209
+ # Merges two content values for consecutive same-role messages
210
+ #
211
+ # Preserves multiple text parts and mixed content as array structure
212
+ # rather than concatenating strings.
213
+ #
214
+ # @param content1 [String, Array, nil]
215
+ # @param content2 [String, Array, nil]
216
+ # @return [Array] merged content parts
217
+ def merge_content(content1, content2)
218
+ # Convert to arrays for consistent handling
219
+ arr1 = content_to_array(content1)
220
+ arr2 = content_to_array(content2)
221
+
222
+ merged = arr1 + arr2
223
+
224
+ # Keep as array of content parts - don't simplify to string
225
+ # This preserves multiple text parts and mixed content
226
+ merged
227
+ end
228
+
229
+ # Converts content to array format for merging
230
+ #
231
+ # @param content [String, Array, nil]
232
+ # @return [Array<Hash>] content parts with type and text keys
233
+ def content_to_array(content)
234
+ case content
235
+ when String
236
+ [ { type: "text", text: content } ]
237
+ when Array
238
+ content.map { |part| part.is_a?(String) ? { type: "text", text: part } : part }
239
+ when nil
240
+ []
241
+ else
242
+ [ content ]
243
+ end
244
+ end
245
+
246
+ # Simplifies messages for cleaner API requests
247
+ #
248
+ # Converts gem message objects to hashes and simplifies content:
249
+ # - Single text content arrays → strings
250
+ # - Empty content arrays → removed
251
+ #
252
+ # @param messages [Array]
253
+ # @return [Array<Hash>]
254
+ def simplify_messages(messages)
255
+ return messages unless messages.is_a?(Array)
256
+
257
+ messages.map do |msg|
258
+ # Convert to hash if it's a gem object
259
+ simplified = msg.is_a?(Hash) ? msg.dup : gem_to_hash(msg)
260
+
261
+ # Simplify content if it's a single text part
262
+ if simplified[:content].is_a?(Array) && simplified[:content].size == 1
263
+ part = simplified[:content][0]
264
+ if part.is_a?(Hash) && part[:type] == "text" && part.keys.sort == [ :text, :type ]
265
+ simplified[:content] = part[:text]
266
+ end
267
+ end
268
+
269
+ # Remove empty content arrays
270
+ simplified.delete(:content) if simplified[:content] == []
271
+
272
+ simplified
273
+ end
274
+ end
275
+
276
+ # Normalizes response_format to OpenAI Chat API format
277
+ #
278
+ # @param format [Hash, Symbol, String]
279
+ # @return [Hash] normalized response format
280
+ def normalize_response_format(format)
281
+ case format
282
+ when Hash
283
+ format_hash = format.deep_symbolize_keys
284
+
285
+ if format_hash[:type] == "json_schema" || format_hash[:type] == :json_schema
286
+ # json_schema format
287
+ {
288
+ type: "json_schema",
289
+ json_schema: {
290
+ name: format_hash[:name] || format_hash[:json_schema]&.dig(:name),
291
+ schema: format_hash[:schema] || format_hash[:json_schema]&.dig(:schema),
292
+ strict: format_hash[:strict] || format_hash[:json_schema]&.dig(:strict)
293
+ }.compact
294
+ }
295
+ elsif format_hash[:type]
296
+ # Other type formats (json_object, text, etc.)
297
+ { type: format_hash[:type].to_s }
298
+ else
299
+ # Pass through (already properly structured or complex)
300
+ format_hash
301
+ end
302
+ when Symbol, String
303
+ # Simple string type
304
+ { type: format.to_s }
305
+ else
306
+ format
307
+ end
308
+ end
309
+
310
+ # Normalizes instructions to developer message format
311
+ #
312
+ # Converts instructions into developer messages with proper content structure.
313
+ # Multiple instructions become content parts in a single developer message
314
+ # rather than separate messages.
315
+ #
316
+ # @param instructions [Array<String>, String]
317
+ # @return [Array<Hash>] developer messages
318
+ def normalize_instructions(instructions)
319
+ instructions_array = Array(instructions)
320
+
321
+ # Convert multiple instructions into content parts for a single developer message
322
+ if instructions_array.size > 1
323
+ content_parts = instructions_array.map do |instruction|
324
+ { type: "text", text: instruction }
325
+ end
326
+ [ { role: "developer", content: content_parts } ]
327
+ else
328
+ instructions_array.map do |instruction|
329
+ { role: "developer", content: instruction }
330
+ end
331
+ end
332
+ end
333
+
334
+ # Cleans up serialized hash for API request
335
+ #
336
+ # Removes default values, simplifies messages, and handles special cases
337
+ # like web_search_options (which requires empty hash to enable).
338
+ #
339
+ # @param hash [Hash] serialized request hash
340
+ # @param defaults [Hash] default values to remove
341
+ # @param gem_object [Object] original gem object for checking values
342
+ # @return [Hash] cleaned request hash
343
+ def cleanup_serialized_request(hash, defaults, gem_object)
344
+ # Remove default values that shouldn't be in the request body
345
+ defaults.each do |key, value|
346
+ hash.delete(key) if hash[key] == value
347
+ end
348
+
349
+ # Simplify messages for cleaner API requests
350
+ hash[:messages] = simplify_messages(hash[:messages]) if hash[:messages]
351
+
352
+ # Add web_search_options if present (defaults to empty hash to enable feature)
353
+ if gem_object.instance_variable_get(:@data)[:web_search_options]
354
+ hash[:web_search_options] ||= {}
355
+ end
356
+
357
+ hash
358
+ end
359
+ end
360
+ end
361
+ end
362
+ end
363
+ end
364
+ end
@@ -0,0 +1,219 @@
1
+ require_relative "_base"
2
+ require_relative "chat/_types"
3
+
4
+ module ActiveAgent
5
+ module Providers
6
+ module OpenAI
7
+ # Provider implementation for OpenAI's Chat Completions API
8
+ #
9
+ # Handles chat-based interactions including streaming responses,
10
+ # function/tool calling, and message management.
11
+ #
12
+ # @see Base
13
+ # @see https://platform.openai.com/docs/api-reference/chat
14
+ class ChatProvider < Base
15
+ # @return [Class] the options class for this provider
16
+ def self.options_klass
17
+ Options
18
+ end
19
+
20
+ # @return [Chat::RequestType] request type instance for chat completions
21
+ def self.prompt_request_type
22
+ Chat::RequestType.new
23
+ end
24
+
25
+ protected
26
+
27
+ # @return [OpenAI::Client::Completions] the API client for chat completions
28
+ # @see Base#api_prompt_executer
29
+ def api_prompt_executer
30
+ client.chat.completions
31
+ end
32
+
33
+ # @see BaseProvider#api_response_normalize
34
+ # @param api_response [OpenAI::Models::ChatCompletion]
35
+ # @return [Hash] normalized response hash
36
+ def api_response_normalize(api_response)
37
+ return api_response unless api_response
38
+
39
+ Chat::Transforms.gem_to_hash(api_response)
40
+ end
41
+
42
+ # Processes streaming response chunks from OpenAI's chat API
43
+ #
44
+ # Handles message deltas, content updates, and completion detection.
45
+ # Manages the message stack and broadcasts streaming updates.
46
+ #
47
+ # Event types handled:
48
+ # - `:chunk` - message content and tool call deltas
49
+ # - `:"content.delta"` - incremental content updates
50
+ # - `:"content.done"` - complete content delivery
51
+ # - `:"tool_calls.function.arguments.delta"` - tool argument deltas
52
+ # - `:"tool_calls.function.arguments.done"` - complete tool arguments
53
+ #
54
+ # @param api_response_event [OpenAI::Helpers::Streaming::ChatChunkEvent]
55
+ # @return [void]
56
+ # @see Base#process_stream_chunk
57
+ def process_stream_chunk(api_response_event)
58
+ instrument("stream_chunk.active_agent")
59
+
60
+ # Called Multiple Times: [Chunk<T>, T]<Content, ToolsCall>
61
+ case api_response_event.type
62
+ when :chunk
63
+ api_message = api_response_event.chunk.choices.first
64
+
65
+ # If we have a delta, we need to update a message in the stack
66
+ message = find_or_create_message(api_message.index)
67
+ message = message_merge_delta(message, api_message.delta.as_json.deep_symbolize_keys)
68
+
69
+ # Stream back content changes as they come in
70
+ if api_message.delta.content
71
+ broadcast_stream_update(message_stack.last, api_message.delta.content)
72
+ end
73
+ when :"content.delta"
74
+ # Returns the deltas, without context
75
+ # => {type: :"content.delta", delta: "", snapshot: "", parsed: nil}
76
+ # => {type: :"content.delta", delta: "Hi", snapshot: "Hi", parsed: nil}
77
+ when :"content.done"
78
+ # Returns the full content when complete
79
+ # => {type: :"content.done", content: "Hi there! How can I help you today?", parsed: nil}
80
+
81
+ # Once we are finished, close out and run tooling callbacks (Recursive)
82
+ process_prompt_finished
83
+ when :"tool_calls.function.arguments.delta"
84
+ # => {type: :"tool_calls.function.arguments.delta", name: "get_current_weather", index: 0, arguments: "", parsed: nil, arguments_delta: ""}
85
+ when :"tool_calls.function.arguments.done"
86
+ # => => {type: :"tool_calls.function.arguments.done", index: 0, name: "get_current_weather", arguments: "{\"location\":\"Boston, MA\"}", parsed: nil}
87
+ else
88
+ fail "Unexpected Response Event Type: #{api_response_event.type}"
89
+ end
90
+ end
91
+
92
+ # Processes function/tool calls from the API response
93
+ #
94
+ # Executes each tool call and creates tool response messages
95
+ # for the next iteration of the conversation.
96
+ #
97
+ # @param api_function_calls [Array<Hash>] function calls with :type, :id, and :function keys
98
+ # @return [void]
99
+ # @see Base#process_function_calls
100
+ def process_function_calls(api_function_calls)
101
+ api_function_calls.each do |api_function_call|
102
+ content = instrument("tool_call.active_agent", tool_name: api_function_call.dig(:function, :name)) do
103
+ case api_function_call[:type]
104
+ when "function"
105
+ process_tool_call_function(api_function_call[:function])
106
+ else
107
+ fail "Unexpected Tool Call Type: #{api_function_call[:type]}"
108
+ end
109
+ end
110
+
111
+ # Create tool message using gem's message param class
112
+ message = ::OpenAI::Models::Chat::ChatCompletionToolMessageParam.new(
113
+ role: "tool",
114
+ tool_call_id: api_function_call[:id],
115
+ content: content.to_json
116
+ )
117
+
118
+ # Serialize and push to message stack
119
+ message_hash = Chat::Transforms.gem_to_hash(message)
120
+ message_stack.push(message_hash)
121
+ end
122
+ end
123
+
124
+ # Extracts messages from the completed API response.
125
+ # Converts OpenAI gem response object to hash for storage.
126
+ #
127
+ # @param api_response [OpenAI::Models::Chat::ChatCompletion]
128
+ # @return [Common::PromptResponse, nil]
129
+ def process_prompt_finished(api_response = nil)
130
+ # Convert gem object to hash so that raw_response["usage"] works
131
+ api_response_hash = api_response ? Chat::Transforms.gem_to_hash(api_response) : nil
132
+ super(api_response_hash)
133
+ end
134
+
135
+ # Extracts messages from completed API response.
136
+ #
137
+ # @param api_response [Hash] converted response hash
138
+ # @return [Array<Hash>, nil] single-element array with message or nil if no message
139
+ # @see Base#process_prompt_finished_extract_messages
140
+ def process_prompt_finished_extract_messages(api_response)
141
+ return unless api_response
142
+
143
+ api_message = api_response[:choices][0][:message]
144
+
145
+ [ api_message ]
146
+ end
147
+
148
+ # Extracts function calls from the last message in the stack.
149
+ #
150
+ # @return [Array<Hash>, nil] tool call objects or nil if no tool calls
151
+ # @see Base#process_prompt_finished_extract_function_calls
152
+ def process_prompt_finished_extract_function_calls
153
+ message_stack.last[:tool_calls]
154
+ end
155
+
156
+ # Merges streaming delta into a message
157
+ #
158
+ # Separated from hash_merge_delta to allow Ollama to override role handling.
159
+ #
160
+ # @param message [Hash]
161
+ # @param delta [Hash]
162
+ # @return [Hash] merged message
163
+ def message_merge_delta(message, delta)
164
+ hash_merge_delta(message, delta)
165
+ end
166
+
167
+ private
168
+
169
+ # Finds an existing message by index or creates a new one
170
+ #
171
+ # @param id [Integer]
172
+ # @return [Hash] found or newly created message
173
+ def find_or_create_message(id)
174
+ message = message_stack.find { _1[:index] == id }
175
+ return message if message
176
+
177
+ message_stack << { index: id }
178
+ message_stack.last
179
+ end
180
+
181
+ # Recursively merges delta changes into a hash structure
182
+ #
183
+ # Handles complex delta merging for OpenAI's streaming API, including
184
+ # arrays with indexed items and string concatenation.
185
+ #
186
+ # @param hash [Hash] target hash to merge into
187
+ # @param delta [Hash] delta changes to apply
188
+ # @return [Hash] merged hash
189
+ def hash_merge_delta(hash, delta)
190
+ delta.each do |key, value|
191
+ case hash[key]
192
+ when Hash
193
+ hash[key] = hash_merge_delta(hash[key], value)
194
+ when Array
195
+ value.each do |delta_item|
196
+ if delta_item.is_a?(Hash) && delta_item[:index]
197
+ hash_item = hash[key].find { _1[:index] == delta_item[:index] }
198
+ if hash_item
199
+ hash_merge_delta(hash_item, delta_item)
200
+ else
201
+ hash[key] << delta_item
202
+ end
203
+ else
204
+ hash[key] << delta_item
205
+ end
206
+ end
207
+ when String
208
+ hash[key] += value
209
+ else
210
+ hash[key] = value
211
+ end
212
+ end
213
+
214
+ hash
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "request"
4
+
5
+ module ActiveAgent
6
+ module Providers
7
+ module OpenAI
8
+ module Embedding
9
+ # ActiveModel type for casting and serializing embedding requests
10
+ class RequestType < ActiveModel::Type::Value
11
+ # Casts value to Request object
12
+ #
13
+ # @param value [Request, Hash, nil]
14
+ # @return [Request, nil]
15
+ # @raise [ArgumentError] when value cannot be cast
16
+ def cast(value)
17
+ case value
18
+ when Request
19
+ value
20
+ when Hash
21
+ Request.new(**value.deep_symbolize_keys)
22
+ when nil
23
+ nil
24
+ else
25
+ raise ArgumentError, "Cannot cast #{value.class} to Request"
26
+ end
27
+ end
28
+
29
+ # Serializes Request to hash for API submission
30
+ #
31
+ # @param value [Request, Hash, nil]
32
+ # @return [Hash, nil]
33
+ # @raise [ArgumentError] when value cannot be serialized
34
+ def serialize(value)
35
+ case value
36
+ when Request
37
+ value.serialize
38
+ when Hash
39
+ value
40
+ when nil
41
+ nil
42
+ else
43
+ raise ArgumentError, "Cannot serialize #{value.class}"
44
+ end
45
+ end
46
+
47
+ # @param value [Object]
48
+ # @return [Request, nil]
49
+ def deserialize(value)
50
+ cast(value)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "transforms"
5
+
6
+ module ActiveAgent
7
+ module Providers
8
+ module OpenAI
9
+ module Embedding
10
+ # Wraps OpenAI gem's EmbeddingCreateParams with normalization
11
+ #
12
+ # Delegates to OpenAI::Models::EmbeddingCreateParams while providing
13
+ # parameter normalization via the Transforms module
14
+ class Request < SimpleDelegator
15
+ # Default parameter values applied during initialization
16
+ DEFAULTS = {}.freeze
17
+
18
+ # Creates a new embedding request
19
+ #
20
+ # @param params [Hash] embedding parameters
21
+ # @option params [String, Array<String>, Array<Integer>, Array<Array<Integer>>] :input
22
+ # text or token array(s) to embed
23
+ # @option params [String] :model embedding model identifier
24
+ # @option params [Integer, nil] :dimensions number of dimensions for output (text-embedding-3 only)
25
+ # @option params [String, nil] :encoding_format "float" or "base64"
26
+ # @option params [String, nil] :user unique user identifier
27
+ # @raise [ArgumentError] when parameters are invalid
28
+ def initialize(**params)
29
+ # Step 1: Normalize parameters
30
+ params = Transforms.normalize_params(params)
31
+
32
+ # Step 2: Create gem model - this validates all parameters!
33
+ gem_model = ::OpenAI::Models::EmbeddingCreateParams.new(**params)
34
+
35
+ # Step 3: Delegate all method calls to gem model
36
+ super(gem_model)
37
+ rescue ArgumentError => e
38
+ # Re-raise with more context
39
+ raise ArgumentError, "Invalid OpenAI Embedding request parameters: #{e.message}"
40
+ end
41
+
42
+ # Serializes request for API submission
43
+ #
44
+ # @return [Hash] cleaned request hash without nil values
45
+ def serialize
46
+ serialized = Transforms.gem_to_hash(__getobj__)
47
+ Transforms.cleanup_serialized_request(serialized, DEFAULTS, __getobj__)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end