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
@@ -0,0 +1,444 @@
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
+ # tools normalization, and response_format conversion. This is the main entry point
28
+ # for parameter 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 tools from common format to Chat API format
45
+ params[:tools] = normalize_tools(params[:tools]) if params[:tools]
46
+
47
+ # Normalize tool_choice from common format
48
+ params[:tool_choice] = normalize_tool_choice(params[:tool_choice]) if params[:tool_choice]
49
+
50
+ # Normalize response_format if present
51
+ params[:response_format] = normalize_response_format(params[:response_format]) if params[:response_format]
52
+
53
+ params
54
+ end
55
+
56
+ # Normalizes messages to OpenAI Chat API format using gem message classes
57
+ #
58
+ # Handles various input formats:
59
+ # - `"text"` → UserMessageParam
60
+ # - `[{role: "user", content: "..."}]` → array of message params
61
+ # - Merges consecutive same-role messages into single message
62
+ #
63
+ # @param messages [Array, String, Hash, nil]
64
+ # @return [Array<OpenAI::Models::Chat::ChatCompletionMessageParam>, nil]
65
+ def normalize_messages(messages)
66
+ case messages
67
+ when String
68
+ [ create_message_param("user", messages) ]
69
+ when Hash
70
+ [ normalize_message(messages) ]
71
+ when Array
72
+ grouped = []
73
+
74
+ messages.each do |msg|
75
+ normalized = normalize_message(msg)
76
+
77
+ # Don't merge tool messages - each needs its own tool_call_id
78
+ if grouped.empty? || grouped.last.role != normalized.role || normalized.role.to_s == "tool"
79
+ grouped << normalized
80
+ else
81
+ # Merge consecutive same-role messages
82
+ merged_content = merge_content(grouped.last.content, normalized.content)
83
+ grouped[-1] = create_message_param(grouped.last.role, merged_content)
84
+ end
85
+ end
86
+
87
+ grouped
88
+ when nil
89
+ nil
90
+ else
91
+ raise ArgumentError, "Cannot normalize #{messages.class} to messages array"
92
+ end
93
+ end
94
+
95
+ # Normalizes a single message to proper gem message param class
96
+ #
97
+ # Handles shorthand formats:
98
+ # - `"text"` → user message
99
+ # - `{text: "..."}` → user message
100
+ # - `{role: "system", text: "..."}` → system message
101
+ # - `{image: "url"}` → user message with image content part
102
+ # - `{text: "...", image: "url"}` → user message with text and image parts
103
+ #
104
+ # @param message [String, Hash, OpenAI::Models::Chat::ChatCompletionMessageParam]
105
+ # @return [OpenAI::Models::Chat::ChatCompletionMessageParam]
106
+ def normalize_message(message)
107
+ case message
108
+ when String
109
+ create_message_param("user", message)
110
+ when ::OpenAI::Models::Chat::ChatCompletionMessageParam
111
+ # Already a gem message param - pass through
112
+ message
113
+ when Hash
114
+ msg_hash = message.deep_symbolize_keys
115
+ role = msg_hash[:role]&.to_s || "user"
116
+
117
+ # Handle shorthand formats
118
+ content = if msg_hash.key?(:content)
119
+ # Standard format with explicit content
120
+ msg_hash[:content]
121
+ elsif msg_hash.key?(:text) && msg_hash.key?(:image)
122
+ # Shorthand with both text and image: { text: "...", image: "url" }
123
+ [
124
+ { type: "text", text: msg_hash[:text] },
125
+ { type: "image_url", image_url: { url: msg_hash[:image] } }
126
+ ]
127
+ elsif msg_hash.key?(:image)
128
+ # Shorthand with only image: { image: "url" }
129
+ # Text comes from adjacent prompt arguments
130
+ [ { type: "image_url", image_url: { url: msg_hash[:image] } } ]
131
+ elsif msg_hash.key?(:text)
132
+ # Shorthand: { text: "..." } or { role: "...", text: "..." }
133
+ msg_hash[:text]
134
+ else
135
+ # No content specified
136
+ nil
137
+ end
138
+
139
+ # Create appropriate message param based on role and content
140
+ extra_params = msg_hash.except(:role, :content, :text, :image)
141
+ create_message_param(role, content, extra_params)
142
+ else
143
+ raise ArgumentError, "Cannot normalize #{message.class} to message"
144
+ end
145
+ end
146
+
147
+ # Creates the appropriate gem message param class for the given role
148
+ #
149
+ # @param role [String] message role (developer, system, user, assistant, tool, function)
150
+ # @param content [String, Array, Hash, nil]
151
+ # @param extra_params [Hash] additional parameters (tool_call_id, name, etc.)
152
+ # @return [OpenAI::Models::Chat::ChatCompletionMessageParam]
153
+ # @raise [ArgumentError] when role is unknown
154
+ def create_message_param(role, content, extra_params = {})
155
+ params = { role: role }
156
+ params[:content] = normalize_content(content) if content
157
+ params.merge!(extra_params)
158
+
159
+ case role.to_s
160
+ when "developer"
161
+ ::OpenAI::Models::Chat::ChatCompletionDeveloperMessageParam.new(**params)
162
+ when "system"
163
+ ::OpenAI::Models::Chat::ChatCompletionSystemMessageParam.new(**params)
164
+ when "user"
165
+ ::OpenAI::Models::Chat::ChatCompletionUserMessageParam.new(**params)
166
+ when "assistant"
167
+ ::OpenAI::Models::Chat::ChatCompletionAssistantMessageParam.new(**params)
168
+ when "tool"
169
+ ::OpenAI::Models::Chat::ChatCompletionToolMessageParam.new(**params)
170
+ when "function"
171
+ ::OpenAI::Models::Chat::ChatCompletionFunctionMessageParam.new(**params)
172
+ else
173
+ raise ArgumentError, "Unknown message role: #{role}"
174
+ end
175
+ end
176
+
177
+ # Normalizes message content to Chat API format
178
+ #
179
+ # @param content [String, Array, Hash, nil]
180
+ # @return [String, Array, nil]
181
+ # @raise [ArgumentError] when content type is invalid
182
+ def normalize_content(content)
183
+ case content
184
+ when String
185
+ content
186
+ when Array
187
+ content.map { |part| normalize_content_part(part) }
188
+ when Hash
189
+ # Single content part as hash - wrap in array
190
+ [ normalize_content_part(content) ]
191
+ when nil
192
+ nil
193
+ else
194
+ raise ArgumentError, "Cannot normalize #{content.class} to content"
195
+ end
196
+ end
197
+
198
+ # Normalizes a single content part
199
+ #
200
+ # Converts strings to proper content part format with type and text keys.
201
+ #
202
+ # @param part [Hash, String]
203
+ # @return [Hash] content part with symbolized keys
204
+ # @raise [ArgumentError] when part type is invalid
205
+ def normalize_content_part(part)
206
+ case part
207
+ when Hash
208
+ part.deep_symbolize_keys
209
+ when String
210
+ { type: "text", text: part }
211
+ else
212
+ raise ArgumentError, "Cannot normalize #{part.class} to content part"
213
+ end
214
+ end
215
+
216
+ # Merges two content values for consecutive same-role messages
217
+ #
218
+ # Preserves multiple text parts and mixed content as array structure
219
+ # rather than concatenating strings.
220
+ #
221
+ # @param content1 [String, Array, nil]
222
+ # @param content2 [String, Array, nil]
223
+ # @return [Array] merged content parts
224
+ def merge_content(content1, content2)
225
+ # Convert to arrays for consistent handling
226
+ arr1 = content_to_array(content1)
227
+ arr2 = content_to_array(content2)
228
+
229
+ merged = arr1 + arr2
230
+
231
+ # Keep as array of content parts - don't simplify to string
232
+ # This preserves multiple text parts and mixed content
233
+ merged
234
+ end
235
+
236
+ # Converts content to array format for merging
237
+ #
238
+ # @param content [String, Array, nil]
239
+ # @return [Array<Hash>] content parts with type and text keys
240
+ def content_to_array(content)
241
+ case content
242
+ when String
243
+ [ { type: "text", text: content } ]
244
+ when Array
245
+ content.map { |part| part.is_a?(String) ? { type: "text", text: part } : part }
246
+ when nil
247
+ []
248
+ else
249
+ [ content ]
250
+ end
251
+ end
252
+
253
+ # Simplifies messages for cleaner API requests
254
+ #
255
+ # Converts gem message objects to hashes and simplifies content:
256
+ # - Single text content arrays → strings
257
+ # - Empty content arrays → removed
258
+ #
259
+ # @param messages [Array]
260
+ # @return [Array<Hash>]
261
+ def simplify_messages(messages)
262
+ return messages unless messages.is_a?(Array)
263
+
264
+ messages.map do |msg|
265
+ # Convert to hash if it's a gem object
266
+ simplified = msg.is_a?(Hash) ? msg.dup : gem_to_hash(msg)
267
+
268
+ # Simplify content if it's a single text part
269
+ if simplified[:content].is_a?(Array) && simplified[:content].size == 1
270
+ part = simplified[:content][0]
271
+ if part.is_a?(Hash) && part[:type] == "text" && part.keys.sort == [ :text, :type ]
272
+ simplified[:content] = part[:text]
273
+ end
274
+ end
275
+
276
+ # Remove empty content arrays
277
+ simplified.delete(:content) if simplified[:content] == []
278
+
279
+ simplified
280
+ end
281
+ end
282
+
283
+ # Normalizes response_format to OpenAI Chat API format
284
+ #
285
+ # @param format [Hash, Symbol, String]
286
+ # @return [Hash] normalized response format
287
+ def normalize_response_format(format)
288
+ case format
289
+ when Hash
290
+ format_hash = format.deep_symbolize_keys
291
+
292
+ if format_hash[:type] == "json_schema" || format_hash[:type] == :json_schema
293
+ # json_schema format
294
+ {
295
+ type: "json_schema",
296
+ json_schema: {
297
+ name: format_hash[:name] || format_hash[:json_schema]&.dig(:name),
298
+ schema: format_hash[:schema] || format_hash[:json_schema]&.dig(:schema),
299
+ strict: format_hash[:strict] || format_hash[:json_schema]&.dig(:strict)
300
+ }.compact
301
+ }
302
+ elsif format_hash[:type]
303
+ # Other type formats (json_object, text, etc.)
304
+ { type: format_hash[:type].to_s }
305
+ else
306
+ # Pass through (already properly structured or complex)
307
+ format_hash
308
+ end
309
+ when Symbol, String
310
+ # Simple string type
311
+ { type: format.to_s }
312
+ else
313
+ format
314
+ end
315
+ end
316
+
317
+ # Normalizes tools from common format to OpenAI Chat API format.
318
+ #
319
+ # Accepts tools in multiple formats:
320
+ # - Common format: `{name: "...", description: "...", parameters: {...}}`
321
+ # - Common format alt: `{name: "...", description: "...", input_schema: {...}}`
322
+ # - Nested format: `{type: "function", function: {name: "...", parameters: {...}}}`
323
+ #
324
+ # Always outputs nested Chat API format: `{type: "function", function: {...}}`
325
+ #
326
+ # @param tools [Array<Hash>]
327
+ # @return [Array<Hash>]
328
+ def normalize_tools(tools)
329
+ return tools unless tools.is_a?(Array)
330
+
331
+ tools.map do |tool|
332
+ tool_hash = tool.is_a?(Hash) ? tool.deep_symbolize_keys : tool
333
+
334
+ # Already in nested format - return as is
335
+ if tool_hash[:type] == "function" && tool_hash[:function]
336
+ tool_hash
337
+ # Common format - convert to nested format
338
+ elsif tool_hash[:name]
339
+ {
340
+ type: "function",
341
+ function: {
342
+ name: tool_hash[:name],
343
+ description: tool_hash[:description],
344
+ parameters: tool_hash[:parameters] || tool_hash[:input_schema]
345
+ }.compact
346
+ }
347
+ else
348
+ tool_hash
349
+ end
350
+ end
351
+ end
352
+
353
+ # Normalizes tool_choice from common format to OpenAI Chat API format.
354
+ #
355
+ # Accepts:
356
+ # - "auto" (common) → "auto" (passthrough)
357
+ # - "required" (common) → "required" (passthrough)
358
+ # - `{name: "..."}` (common) → `{type: "function", function: {name: "..."}}`
359
+ # - Already nested format → passthrough
360
+ #
361
+ # @param tool_choice [String, Hash, Symbol]
362
+ # @return [String, Hash, Symbol]
363
+ def normalize_tool_choice(tool_choice)
364
+ case tool_choice
365
+ when "auto", :auto, "required", :required
366
+ # Passthrough - Chat API accepts these directly
367
+ tool_choice.to_s
368
+ when Hash
369
+ tool_choice_hash = tool_choice.deep_symbolize_keys
370
+
371
+ # Already in nested format with type and function keys
372
+ if tool_choice_hash[:type] == "function" && tool_choice_hash[:function]
373
+ tool_choice_hash
374
+ # Common format with just name - convert to nested format
375
+ elsif tool_choice_hash[:name]
376
+ {
377
+ type: "function",
378
+ function: {
379
+ name: tool_choice_hash[:name]
380
+ }
381
+ }
382
+ else
383
+ tool_choice_hash
384
+ end
385
+ else
386
+ tool_choice
387
+ end
388
+ end
389
+
390
+ # Normalizes instructions to developer message format
391
+ #
392
+ # Converts instructions into developer messages with proper content structure.
393
+ # Multiple instructions become content parts in a single developer message
394
+ # rather than separate messages.
395
+ #
396
+ # @param instructions [Array<String>, String]
397
+ # @return [Array<Hash>] developer messages
398
+ def normalize_instructions(instructions)
399
+ instructions_array = Array(instructions)
400
+
401
+ # Convert multiple instructions into content parts for a single developer message
402
+ if instructions_array.size > 1
403
+ content_parts = instructions_array.map do |instruction|
404
+ { type: "text", text: instruction }
405
+ end
406
+ [ { role: "developer", content: content_parts } ]
407
+ else
408
+ instructions_array.map do |instruction|
409
+ { role: "developer", content: instruction }
410
+ end
411
+ end
412
+ end
413
+
414
+ # Cleans up serialized hash for API request
415
+ #
416
+ # Removes default values, simplifies messages, and handles special cases
417
+ # like web_search_options (which requires empty hash to enable).
418
+ #
419
+ # @param hash [Hash] serialized request hash
420
+ # @param defaults [Hash] default values to remove
421
+ # @param gem_object [Object] original gem object for checking values
422
+ # @return [Hash] cleaned request hash
423
+ def cleanup_serialized_request(hash, defaults, gem_object)
424
+ # Remove default values that shouldn't be in the request body
425
+ defaults.each do |key, value|
426
+ hash.delete(key) if hash[key] == value
427
+ end
428
+
429
+ # Simplify messages for cleaner API requests
430
+ hash[:messages] = simplify_messages(hash[:messages]) if hash[:messages]
431
+
432
+ # Add web_search_options if present (defaults to empty hash to enable feature)
433
+ if gem_object.instance_variable_get(:@data)[:web_search_options]
434
+ hash[:web_search_options] ||= {}
435
+ end
436
+
437
+ hash
438
+ end
439
+ end
440
+ end
441
+ end
442
+ end
443
+ end
444
+ end
@@ -4,15 +4,16 @@ require_relative "chat/_types"
4
4
  module ActiveAgent
5
5
  module Providers
6
6
  module OpenAI
7
- # Provider implementation for OpenAI's Chat Completion API.
7
+ # Provider implementation for OpenAI's Chat Completions API
8
8
  #
9
9
  # Handles chat-based interactions including streaming responses,
10
- # function/tool calling, and message management. Uses OpenAI's
11
- # chat completions endpoint for generating responses.
10
+ # function/tool calling, and message management.
12
11
  #
13
12
  # @see Base
14
13
  # @see https://platform.openai.com/docs/api-reference/chat
15
14
  class ChatProvider < Base
15
+ include ToolChoiceClearing
16
+
16
17
  # @return [Class] the options class for this provider
17
18
  def self.options_klass
18
19
  Options
@@ -31,23 +32,68 @@ module ActiveAgent
31
32
  client.chat.completions
32
33
  end
33
34
 
34
- # Processes streaming response chunks from OpenAI's chat API.
35
+ # @see BaseProvider#prepare_prompt_request
36
+ # @return [Request]
37
+ def prepare_prompt_request
38
+ prepare_prompt_request_tools
39
+ super
40
+ end
41
+
42
+ # Extracts function names from Chat API tool_calls in assistant messages.
43
+ #
44
+ # @return [Array<String>]
45
+ def extract_used_function_names
46
+ message_stack
47
+ .select { |msg| msg[:role] == "assistant" && msg[:tool_calls] }
48
+ .flat_map { |msg| msg[:tool_calls] }
49
+ .map { |tc| tc.dig(:function, :name) }
50
+ .compact
51
+ end
52
+
53
+ # Returns true if tool_choice == "required".
54
+ #
55
+ # @return [Boolean]
56
+ def tool_choice_forces_required?
57
+ request.tool_choice == "required"
58
+ end
59
+
60
+ # Returns [true, name] if tool_choice is a hash with nested function name.
61
+ #
62
+ # @return [Array<Boolean, String|nil>]
63
+ def tool_choice_forces_specific?
64
+ if request.tool_choice.is_a?(Hash)
65
+ [ true, request.tool_choice.dig(:function, :name) ]
66
+ else
67
+ [ false, nil ]
68
+ end
69
+ end
70
+
71
+ # @see BaseProvider#api_response_normalize
72
+ # @param api_response [OpenAI::Models::ChatCompletion]
73
+ # @return [Hash] normalized response hash
74
+ def api_response_normalize(api_response)
75
+ return api_response unless api_response
76
+
77
+ Chat::Transforms.gem_to_hash(api_response)
78
+ end
79
+
80
+ # Processes streaming response chunks from OpenAI's chat API
35
81
  #
36
82
  # Handles message deltas, content updates, and completion detection.
37
83
  # Manages the message stack and broadcasts streaming updates.
38
84
  #
39
- # Handles multiple event types:
40
- # - `:chunk` - Message content and tool call deltas
41
- # - `:"content.delta"` - Incremental content updates
42
- # - `:"content.done"` - Complete content delivery
43
- # - `:"tool_calls.function.arguments.delta"` - Tool argument deltas
44
- # - `:"tool_calls.function.arguments.done"` - Complete tool arguments
85
+ # Event types handled:
86
+ # - `:chunk` - message content and tool call deltas
87
+ # - `:"content.delta"` - incremental content updates
88
+ # - `:"content.done"` - complete content delivery
89
+ # - `:"tool_calls.function.arguments.delta"` - tool argument deltas
90
+ # - `:"tool_calls.function.arguments.done"` - complete tool arguments
45
91
  #
46
92
  # @param api_response_event [OpenAI::Helpers::Streaming::ChatChunkEvent]
47
93
  # @return [void]
48
94
  # @see Base#process_stream_chunk
49
95
  def process_stream_chunk(api_response_event)
50
- instrument("stream_chunk_processing.provider.active_agent")
96
+ instrument("stream_chunk.active_agent")
51
97
 
52
98
  # Called Multiple Times: [Chunk<T>, T]<Content, ToolsCall>
53
99
  case api_response_event.type
@@ -62,11 +108,6 @@ module ActiveAgent
62
108
  if api_message.delta.content
63
109
  broadcast_stream_update(message_stack.last, api_message.delta.content)
64
110
  end
65
-
66
- # If this is the last api_chunk to be processed
67
- return unless api_message.finish_reason
68
-
69
- instrument("stream_finished.provider.active_agent", finish_reason: api_message.finish_reason)
70
111
  when :"content.delta"
71
112
  # Returns the deltas, without context
72
113
  # => {type: :"content.delta", delta: "", snapshot: "", parsed: nil}
@@ -86,42 +127,60 @@ module ActiveAgent
86
127
  end
87
128
  end
88
129
 
89
- # Processes function/tool calls from the API response.
130
+ # Processes function/tool calls from the API response
90
131
  #
91
132
  # Executes each tool call and creates tool response messages
92
133
  # for the next iteration of the conversation.
93
134
  #
94
- # @param api_function_calls [Array<Hash>] function call objects with :type, :id, and :function keys
135
+ # @param api_function_calls [Array<Hash>] function calls with :type, :id, and :function keys
95
136
  # @return [void]
96
137
  # @see Base#process_function_calls
97
138
  def process_function_calls(api_function_calls)
98
139
  api_function_calls.each do |api_function_call|
99
- content = case api_function_call[:type]
100
- when "function"
101
- instrument("tool_execution.provider.active_agent", tool_name: api_function_call.dig(:function, :name))
102
- process_tool_call_function(api_function_call[:function])
103
- else
104
- fail "Unexpected Tool Call Type: #{api_function_call[:type]}"
140
+ content = instrument("tool_call.active_agent", tool_name: api_function_call.dig(:function, :name)) do
141
+ case api_function_call[:type]
142
+ when "function"
143
+ process_tool_call_function(api_function_call[:function])
144
+ else
145
+ fail "Unexpected Tool Call Type: #{api_function_call[:type]}"
146
+ end
105
147
  end
106
148
 
107
- message = Chat::Requests::Messages::Tool.new(
149
+ # Create tool message using gem's message param class
150
+ message = ::OpenAI::Models::Chat::ChatCompletionToolMessageParam.new(
151
+ role: "tool",
108
152
  tool_call_id: api_function_call[:id],
109
153
  content: content.to_json
110
154
  )
111
155
 
112
- message_stack.push(message.serialize)
156
+ # Serialize and push to message stack
157
+ message_hash = Chat::Transforms.gem_to_hash(message)
158
+ message_stack.push(message_hash)
113
159
  end
114
160
  end
115
161
 
116
162
  # Extracts messages from the completed API response.
163
+ # Converts OpenAI gem response object to hash for storage.
164
+ #
165
+ # @param api_response [OpenAI::Models::Chat::ChatCompletion]
166
+ # @return [Common::PromptResponse, nil]
167
+ def process_prompt_finished(api_response = nil)
168
+ # Convert gem object to hash so that raw_response["usage"] works
169
+ api_response_hash = api_response ? Chat::Transforms.gem_to_hash(api_response) : nil
170
+ super(api_response_hash)
171
+ end
172
+
173
+ # Extracts messages from completed API response.
117
174
  #
118
- # @param api_response [Hash]
175
+ # @param api_response [Hash] converted response hash
119
176
  # @return [Array<Hash>, nil] single-element array with message or nil if no message
120
177
  # @see Base#process_prompt_finished_extract_messages
121
178
  def process_prompt_finished_extract_messages(api_response)
122
- api_message = api_response&.dig(:choices, 0, :message)
179
+ return unless api_response
180
+
181
+ api_message = api_response[:choices][0][:message]
123
182
 
124
- [ api_message ] if api_message
183
+ [ api_message ]
125
184
  end
126
185
 
127
186
  # Extracts function calls from the last message in the stack.
@@ -132,20 +191,20 @@ module ActiveAgent
132
191
  message_stack.last[:tool_calls]
133
192
  end
134
193
 
135
- # Merges streaming delta into a message.
194
+ # Merges streaming delta into a message
136
195
  #
137
196
  # Separated from hash_merge_delta to allow Ollama to override role handling.
138
197
  #
139
198
  # @param message [Hash]
140
199
  # @param delta [Hash]
141
- # @return [Hash] the merged message
200
+ # @return [Hash] merged message
142
201
  def message_merge_delta(message, delta)
143
202
  hash_merge_delta(message, delta)
144
203
  end
145
204
 
146
205
  private
147
206
 
148
- # Finds an existing message by ID or creates a new one.
207
+ # Finds an existing message by index or creates a new one
149
208
  #
150
209
  # @param id [Integer]
151
210
  # @return [Hash] found or newly created message
@@ -157,14 +216,14 @@ module ActiveAgent
157
216
  message_stack.last
158
217
  end
159
218
 
160
- # Recursively merges delta changes into a hash structure.
219
+ # Recursively merges delta changes into a hash structure
161
220
  #
162
- # Handles the complex delta merging needed for OpenAI's streaming API,
163
- # including arrays with indexed items and string concatenation.
221
+ # Handles complex delta merging for OpenAI's streaming API, including
222
+ # arrays with indexed items and string concatenation.
164
223
  #
165
224
  # @param hash [Hash] target hash to merge into
166
225
  # @param delta [Hash] delta changes to apply
167
- # @return [Hash] the merged hash
226
+ # @return [Hash] merged hash
168
227
  def hash_merge_delta(hash, delta)
169
228
  delta.each do |key, value|
170
229
  case hash[key]