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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +240 -2
- data/README.md +15 -24
- data/lib/active_agent/base.rb +389 -39
- data/lib/active_agent/concerns/callbacks.rb +251 -0
- data/lib/active_agent/concerns/observers.rb +147 -0
- data/lib/active_agent/concerns/parameterized.rb +292 -0
- data/lib/active_agent/concerns/provider.rb +120 -0
- data/lib/active_agent/concerns/queueing.rb +36 -0
- data/lib/active_agent/concerns/rescue.rb +64 -0
- data/lib/active_agent/concerns/streaming.rb +282 -0
- data/lib/active_agent/concerns/tooling.rb +23 -0
- data/lib/active_agent/concerns/view.rb +150 -0
- data/lib/active_agent/configuration.rb +442 -20
- data/lib/active_agent/generation.rb +141 -47
- data/lib/active_agent/providers/_base_provider.rb +420 -0
- data/lib/active_agent/providers/anthropic/_types.rb +63 -0
- data/lib/active_agent/providers/anthropic/options.rb +53 -0
- data/lib/active_agent/providers/anthropic/request.rb +163 -0
- data/lib/active_agent/providers/anthropic/transforms.rb +353 -0
- data/lib/active_agent/providers/anthropic_provider.rb +254 -0
- data/lib/active_agent/providers/common/messages/_types.rb +160 -0
- data/lib/active_agent/providers/common/messages/assistant.rb +57 -0
- data/lib/active_agent/providers/common/messages/base.rb +17 -0
- data/lib/active_agent/providers/common/messages/system.rb +20 -0
- data/lib/active_agent/providers/common/messages/tool.rb +21 -0
- data/lib/active_agent/providers/common/messages/user.rb +20 -0
- data/lib/active_agent/providers/common/model.rb +361 -0
- data/lib/active_agent/providers/common/response.rb +13 -0
- data/lib/active_agent/providers/common/responses/_types.rb +51 -0
- data/lib/active_agent/providers/common/responses/base.rb +199 -0
- data/lib/active_agent/providers/common/responses/embed.rb +33 -0
- data/lib/active_agent/providers/common/responses/format.rb +31 -0
- data/lib/active_agent/providers/common/responses/message.rb +3 -0
- data/lib/active_agent/providers/common/responses/prompt.rb +42 -0
- data/lib/active_agent/providers/common/usage.rb +385 -0
- data/lib/active_agent/providers/concerns/exception_handler.rb +72 -0
- data/lib/active_agent/providers/concerns/instrumentation.rb +263 -0
- data/lib/active_agent/providers/concerns/previewable.rb +150 -0
- data/lib/active_agent/providers/log_subscriber.rb +178 -0
- data/lib/active_agent/providers/mock/_types.rb +77 -0
- data/lib/active_agent/providers/mock/embedding_request.rb +17 -0
- data/lib/active_agent/providers/mock/messages/_types.rb +103 -0
- data/lib/active_agent/providers/mock/messages/assistant.rb +26 -0
- data/lib/active_agent/providers/mock/messages/base.rb +63 -0
- data/lib/active_agent/providers/mock/messages/user.rb +18 -0
- data/lib/active_agent/providers/mock/options.rb +30 -0
- data/lib/active_agent/providers/mock/request.rb +38 -0
- data/lib/active_agent/providers/mock_provider.rb +311 -0
- data/lib/active_agent/providers/ollama/_types.rb +5 -0
- data/lib/active_agent/providers/ollama/chat/_types.rb +44 -0
- data/lib/active_agent/providers/ollama/chat/request.rb +249 -0
- data/lib/active_agent/providers/ollama/chat/transforms.rb +135 -0
- data/lib/active_agent/providers/ollama/embedding/_types.rb +44 -0
- data/lib/active_agent/providers/ollama/embedding/request.rb +190 -0
- data/lib/active_agent/providers/ollama/embedding/transforms.rb +160 -0
- data/lib/active_agent/providers/ollama/options.rb +27 -0
- data/lib/active_agent/providers/ollama_provider.rb +94 -0
- data/lib/active_agent/providers/open_ai/_base.rb +59 -0
- data/lib/active_agent/providers/open_ai/_types.rb +5 -0
- data/lib/active_agent/providers/open_ai/chat/_types.rb +56 -0
- data/lib/active_agent/providers/open_ai/chat/request.rb +161 -0
- data/lib/active_agent/providers/open_ai/chat/transforms.rb +364 -0
- data/lib/active_agent/providers/open_ai/chat_provider.rb +219 -0
- data/lib/active_agent/providers/open_ai/embedding/_types.rb +56 -0
- data/lib/active_agent/providers/open_ai/embedding/request.rb +53 -0
- data/lib/active_agent/providers/open_ai/embedding/transforms.rb +88 -0
- data/lib/active_agent/providers/open_ai/options.rb +74 -0
- data/lib/active_agent/providers/open_ai/responses/_types.rb +44 -0
- data/lib/active_agent/providers/open_ai/responses/request.rb +129 -0
- data/lib/active_agent/providers/open_ai/responses/transforms.rb +228 -0
- data/lib/active_agent/providers/open_ai/responses_provider.rb +200 -0
- data/lib/active_agent/providers/open_ai_provider.rb +94 -0
- data/lib/active_agent/providers/open_router/_types.rb +71 -0
- data/lib/active_agent/providers/open_router/options.rb +141 -0
- data/lib/active_agent/providers/open_router/request.rb +249 -0
- data/lib/active_agent/providers/open_router/requests/_types.rb +197 -0
- data/lib/active_agent/providers/open_router/requests/messages/_types.rb +56 -0
- data/lib/active_agent/providers/open_router/requests/messages/content/_types.rb +97 -0
- data/lib/active_agent/providers/open_router/requests/messages/content/file.rb +43 -0
- data/lib/active_agent/providers/open_router/requests/messages/content/files/_types.rb +61 -0
- data/lib/active_agent/providers/open_router/requests/messages/content/files/details.rb +37 -0
- data/lib/active_agent/providers/open_router/requests/plugin.rb +41 -0
- data/lib/active_agent/providers/open_router/requests/plugins/_types.rb +46 -0
- data/lib/active_agent/providers/open_router/requests/plugins/pdf_config.rb +51 -0
- data/lib/active_agent/providers/open_router/requests/prediction.rb +34 -0
- data/lib/active_agent/providers/open_router/requests/provider_preferences/_types.rb +44 -0
- data/lib/active_agent/providers/open_router/requests/provider_preferences/max_price.rb +64 -0
- data/lib/active_agent/providers/open_router/requests/provider_preferences.rb +105 -0
- data/lib/active_agent/providers/open_router/requests/response_format.rb +77 -0
- data/lib/active_agent/providers/open_router/transforms.rb +134 -0
- data/lib/active_agent/providers/open_router_provider.rb +62 -0
- data/lib/active_agent/providers/openai_provider.rb +2 -0
- data/lib/active_agent/providers/openrouter_provider.rb +2 -0
- data/lib/active_agent/railtie.rb +8 -6
- data/lib/active_agent/schema_generator.rb +333 -166
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +112 -36
- data/lib/generators/active_agent/agent/USAGE +78 -0
- data/lib/generators/active_agent/{agent_generator.rb → agent/agent_generator.rb} +14 -4
- data/lib/generators/active_agent/install/USAGE +25 -0
- data/lib/generators/active_agent/{install_generator.rb → install/install_generator.rb} +1 -19
- data/lib/generators/active_agent/templates/agent.rb.tt +7 -3
- data/lib/generators/active_agent/templates/application_agent.rb.tt +0 -2
- data/lib/generators/erb/agent_generator.rb +31 -16
- data/lib/generators/erb/templates/instructions.md.erb.tt +3 -0
- data/lib/generators/erb/templates/instructions.md.tt +3 -0
- data/lib/generators/erb/templates/instructions.text.tt +1 -0
- data/lib/generators/erb/templates/message.md.erb.tt +5 -0
- data/lib/generators/erb/templates/schema.json.tt +10 -0
- data/lib/generators/test_unit/agent_generator.rb +1 -1
- data/lib/generators/test_unit/templates/functional_test.rb.tt +4 -2
- metadata +182 -71
- data/lib/active_agent/action_prompt/action.rb +0 -13
- data/lib/active_agent/action_prompt/base.rb +0 -623
- data/lib/active_agent/action_prompt/message.rb +0 -126
- data/lib/active_agent/action_prompt/prompt.rb +0 -136
- data/lib/active_agent/action_prompt.rb +0 -19
- data/lib/active_agent/callbacks.rb +0 -33
- data/lib/active_agent/generation_provider/anthropic_provider.rb +0 -163
- data/lib/active_agent/generation_provider/base.rb +0 -55
- data/lib/active_agent/generation_provider/base_adapter.rb +0 -19
- data/lib/active_agent/generation_provider/error_handling.rb +0 -167
- data/lib/active_agent/generation_provider/log_subscriber.rb +0 -92
- data/lib/active_agent/generation_provider/message_formatting.rb +0 -107
- data/lib/active_agent/generation_provider/ollama_provider.rb +0 -66
- data/lib/active_agent/generation_provider/open_ai_provider.rb +0 -279
- data/lib/active_agent/generation_provider/open_router_provider.rb +0 -385
- data/lib/active_agent/generation_provider/parameter_builder.rb +0 -119
- data/lib/active_agent/generation_provider/response.rb +0 -75
- data/lib/active_agent/generation_provider/responses_adapter.rb +0 -44
- data/lib/active_agent/generation_provider/stream_processing.rb +0 -58
- data/lib/active_agent/generation_provider/tool_management.rb +0 -142
- data/lib/active_agent/generation_provider.rb +0 -67
- data/lib/active_agent/log_subscriber.rb +0 -44
- data/lib/active_agent/parameterized.rb +0 -75
- data/lib/active_agent/prompt_helper.rb +0 -19
- data/lib/active_agent/queued_generation.rb +0 -12
- data/lib/active_agent/rescuable.rb +0 -34
- data/lib/active_agent/sanitizers.rb +0 -40
- data/lib/active_agent/streaming.rb +0 -34
- data/lib/active_agent/test_case.rb +0 -125
- data/lib/generators/USAGE +0 -47
- data/lib/generators/active_agent/USAGE +0 -56
- data/lib/generators/erb/install_generator.rb +0 -44
- data/lib/generators/erb/templates/layout.html.erb.tt +0 -1
- data/lib/generators/erb/templates/layout.json.erb.tt +0 -1
- data/lib/generators/erb/templates/layout.text.erb.tt +0 -1
- data/lib/generators/erb/templates/view.html.erb.tt +0 -5
- data/lib/generators/erb/templates/view.json.erb.tt +0 -16
- /data/lib/active_agent/{preview.rb → concerns/preview.rb} +0 -0
- /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
|