activeagent 0.5.1 → 0.6.0rc1
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/lib/active_agent/action_prompt/base.rb +31 -13
- data/lib/active_agent/action_prompt/prompt.rb +4 -2
- data/lib/active_agent/base.rb +2 -1
- data/lib/active_agent/configuration.rb +36 -0
- data/lib/active_agent/generation_provider/anthropic_provider.rb +72 -65
- data/lib/active_agent/generation_provider/base.rb +18 -5
- data/lib/active_agent/generation_provider/error_handling.rb +166 -0
- data/lib/active_agent/generation_provider/log_subscriber.rb +92 -0
- data/lib/active_agent/generation_provider/message_formatting.rb +107 -0
- data/lib/active_agent/generation_provider/open_ai_provider.rb +47 -80
- data/lib/active_agent/generation_provider/open_router_provider.rb +330 -2
- data/lib/active_agent/generation_provider/parameter_builder.rb +119 -0
- data/lib/active_agent/generation_provider/response.rb +3 -1
- data/lib/active_agent/generation_provider/stream_processing.rb +58 -0
- data/lib/active_agent/generation_provider/tool_management.rb +142 -0
- data/lib/active_agent/generation_provider.rb +1 -1
- data/lib/active_agent/log_subscriber.rb +6 -6
- data/lib/active_agent/parameterized.rb +1 -0
- data/lib/active_agent/sanitizers.rb +40 -0
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +9 -6
- data/lib/generators/erb/agent_generator.rb +3 -0
- data/lib/generators/erb/templates/instructions.text.erb.tt +1 -0
- metadata +38 -29
@@ -4,11 +4,339 @@ require_relative "open_ai_provider"
|
|
4
4
|
module ActiveAgent
|
5
5
|
module GenerationProvider
|
6
6
|
class OpenRouterProvider < OpenAIProvider
|
7
|
+
# Vision-capable models on OpenRouter
|
8
|
+
VISION_MODELS = [
|
9
|
+
"openai/gpt-4-vision-preview",
|
10
|
+
"openai/gpt-4o",
|
11
|
+
"openai/gpt-4o-mini",
|
12
|
+
"anthropic/claude-3-5-sonnet",
|
13
|
+
"anthropic/claude-3-opus",
|
14
|
+
"anthropic/claude-3-sonnet",
|
15
|
+
"anthropic/claude-3-haiku",
|
16
|
+
"google/gemini-pro-1.5",
|
17
|
+
"google/gemini-pro-vision"
|
18
|
+
].freeze
|
19
|
+
|
20
|
+
# Models that support structured output
|
21
|
+
STRUCTURED_OUTPUT_MODELS = [
|
22
|
+
"openai/gpt-4o",
|
23
|
+
"openai/gpt-4o-2024-08-06",
|
24
|
+
"openai/gpt-4o-mini",
|
25
|
+
"openai/gpt-4o-mini-2024-07-18",
|
26
|
+
"openai/gpt-4-turbo",
|
27
|
+
"openai/gpt-4-turbo-2024-04-09",
|
28
|
+
"openai/gpt-3.5-turbo-0125",
|
29
|
+
"openai/gpt-3.5-turbo-1106"
|
30
|
+
].freeze
|
31
|
+
|
7
32
|
def initialize(config)
|
8
33
|
@config = config
|
9
|
-
@access_token
|
34
|
+
@access_token = config["api_key"] || config["access_token"] ||
|
35
|
+
ENV["OPENROUTER_API_KEY"] || ENV["OPENROUTER_ACCESS_TOKEN"]
|
10
36
|
@model_name = config["model"]
|
11
|
-
|
37
|
+
|
38
|
+
# OpenRouter-specific configuration
|
39
|
+
@app_name = config["app_name"] || default_app_name
|
40
|
+
@site_url = config["site_url"] || default_site_url
|
41
|
+
@enable_fallbacks = config["enable_fallbacks"] != false
|
42
|
+
@fallback_models = config["fallback_models"] || []
|
43
|
+
@transforms = config["transforms"] || []
|
44
|
+
@provider_preferences = config["provider"] || {}
|
45
|
+
@track_costs = config["track_costs"] != false
|
46
|
+
@route = config["route"] || "fallback"
|
47
|
+
|
48
|
+
# Data collection preference (allow, deny, or specific provider list)
|
49
|
+
@data_collection = config["data_collection"] || @provider_preferences["data_collection"] || "allow"
|
50
|
+
|
51
|
+
# Initialize OpenAI client with OpenRouter base URL
|
52
|
+
@client = OpenAI::Client.new(
|
53
|
+
uri_base: "https://openrouter.ai/api/v1",
|
54
|
+
access_token: @access_token,
|
55
|
+
log_errors: true,
|
56
|
+
default_headers: openrouter_headers
|
57
|
+
)
|
58
|
+
end
|
59
|
+
|
60
|
+
def generate(prompt)
|
61
|
+
@prompt = prompt
|
62
|
+
|
63
|
+
with_error_handling do
|
64
|
+
parameters = build_openrouter_parameters
|
65
|
+
response = execute_with_fallback(parameters)
|
66
|
+
process_openrouter_response(response)
|
67
|
+
end
|
68
|
+
rescue => e
|
69
|
+
handle_openrouter_error(e)
|
70
|
+
end
|
71
|
+
|
72
|
+
# Helper methods for checking model capabilities
|
73
|
+
def supports_vision?(model = @model_name)
|
74
|
+
VISION_MODELS.include?(model)
|
75
|
+
end
|
76
|
+
|
77
|
+
def supports_structured_output?(model = @model_name)
|
78
|
+
STRUCTURED_OUTPUT_MODELS.include?(model)
|
79
|
+
end
|
80
|
+
|
81
|
+
protected
|
82
|
+
|
83
|
+
def build_provider_parameters
|
84
|
+
# Start with base OpenAI parameters
|
85
|
+
params = super
|
86
|
+
|
87
|
+
# Add OpenRouter-specific parameters
|
88
|
+
add_openrouter_params(params)
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
def default_app_name
|
94
|
+
if defined?(Rails) && Rails.application
|
95
|
+
Rails.application.class.name.split("::").first
|
96
|
+
else
|
97
|
+
"ActiveAgent"
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def default_site_url
|
102
|
+
# First check ActiveAgent config
|
103
|
+
return config["default_url_options"]["host"] if config.dig("default_url_options", "host")
|
104
|
+
|
105
|
+
# Then check Rails routes default_url_options
|
106
|
+
if defined?(Rails) && Rails.application&.routes&.default_url_options&.any?
|
107
|
+
host = Rails.application.routes.default_url_options[:host]
|
108
|
+
port = Rails.application.routes.default_url_options[:port]
|
109
|
+
protocol = Rails.application.routes.default_url_options[:protocol] || "https"
|
110
|
+
|
111
|
+
if host
|
112
|
+
url = "#{protocol}://#{host}"
|
113
|
+
url += ":#{port}" if port && port != 80 && port != 443
|
114
|
+
return url
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
# Finally check ActionMailer config as fallback
|
119
|
+
if defined?(Rails) && Rails.application&.config&.action_mailer&.default_url_options
|
120
|
+
options = Rails.application.config.action_mailer.default_url_options
|
121
|
+
host = options[:host]
|
122
|
+
port = options[:port]
|
123
|
+
protocol = options[:protocol] || "https"
|
124
|
+
|
125
|
+
if host
|
126
|
+
url = "#{protocol}://#{host}"
|
127
|
+
url += ":#{port}" if port && port != 80 && port != 443
|
128
|
+
return url
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
nil
|
133
|
+
end
|
134
|
+
|
135
|
+
def openrouter_headers
|
136
|
+
headers = {}
|
137
|
+
headers["HTTP-Referer"] = @site_url if @site_url
|
138
|
+
headers["X-Title"] = @app_name if @app_name
|
139
|
+
headers
|
140
|
+
end
|
141
|
+
|
142
|
+
def build_openrouter_parameters
|
143
|
+
parameters = prompt_parameters
|
144
|
+
|
145
|
+
# Handle multiple models for fallback
|
146
|
+
if @fallback_models.present?
|
147
|
+
parameters[:models] = [ @model_name ] + @fallback_models
|
148
|
+
parameters[:route] = @route
|
149
|
+
end
|
150
|
+
|
151
|
+
# Add transforms if specified
|
152
|
+
parameters[:transforms] = @transforms if @transforms.present?
|
153
|
+
|
154
|
+
# Add provider preferences (always include if we have data_collection or other settings)
|
155
|
+
# Check both configured and runtime data_collection values
|
156
|
+
runtime_data_collection = prompt&.options&.key?(:data_collection)
|
157
|
+
if @provider_preferences.present? || @data_collection != "allow" || runtime_data_collection
|
158
|
+
parameters[:provider] = build_provider_preferences
|
159
|
+
end
|
160
|
+
|
161
|
+
parameters
|
162
|
+
end
|
163
|
+
|
164
|
+
def build_provider_preferences
|
165
|
+
prefs = {}
|
166
|
+
prefs[:order] = @provider_preferences["order"] if @provider_preferences["order"]
|
167
|
+
prefs[:require_parameters] = @provider_preferences["require_parameters"] if @provider_preferences.key?("require_parameters")
|
168
|
+
prefs[:allow_fallbacks] = @enable_fallbacks
|
169
|
+
|
170
|
+
# Data collection can be:
|
171
|
+
# - "allow" (default): Allow all providers to collect data
|
172
|
+
# - "deny": Deny all providers from collecting data
|
173
|
+
# - Array of provider names: Only allow these providers to collect data
|
174
|
+
# Check prompt options first (runtime override), then fall back to configured value
|
175
|
+
data_collection = prompt.options[:data_collection] if prompt&.options&.key?(:data_collection)
|
176
|
+
data_collection ||= @data_collection
|
177
|
+
prefs[:data_collection] = data_collection
|
178
|
+
|
179
|
+
prefs.compact
|
180
|
+
end
|
181
|
+
|
182
|
+
def add_openrouter_params(params)
|
183
|
+
# Add OpenRouter-specific routing parameters
|
184
|
+
if @enable_fallbacks && @fallback_models.present?
|
185
|
+
params[:models] = [ @model_name ] + @fallback_models
|
186
|
+
params[:route] = @route
|
187
|
+
end
|
188
|
+
|
189
|
+
# Add transforms
|
190
|
+
params[:transforms] = @transforms if @transforms.present?
|
191
|
+
|
192
|
+
# Add provider configuration (always include if we have data_collection or other settings)
|
193
|
+
# Check both configured and runtime data_collection values
|
194
|
+
runtime_data_collection = prompt&.options&.key?(:data_collection)
|
195
|
+
if @provider_preferences.present? || @data_collection != "allow" || runtime_data_collection
|
196
|
+
params[:provider] = build_provider_preferences
|
197
|
+
end
|
198
|
+
|
199
|
+
# Add plugins (e.g., for PDF processing)
|
200
|
+
if prompt.options[:plugins].present?
|
201
|
+
params[:plugins] = prompt.options[:plugins]
|
202
|
+
end
|
203
|
+
|
204
|
+
params
|
205
|
+
end
|
206
|
+
|
207
|
+
def execute_with_fallback(parameters)
|
208
|
+
parameters[:stream] = provider_stream if prompt.options[:stream] || config["stream"]
|
209
|
+
|
210
|
+
response = @client.chat(parameters: parameters)
|
211
|
+
|
212
|
+
# Log if fallback was used
|
213
|
+
if response.respond_to?(:headers) && response.headers["x-model"] != @model_name
|
214
|
+
Rails.logger.info "[OpenRouter] Fallback model used: #{response.headers['x-model']}" if defined?(Rails)
|
215
|
+
end
|
216
|
+
|
217
|
+
response
|
218
|
+
end
|
219
|
+
|
220
|
+
def process_openrouter_response(response)
|
221
|
+
# Process as normal OpenAI response first
|
222
|
+
if prompt.options[:stream]
|
223
|
+
return @response
|
224
|
+
end
|
225
|
+
|
226
|
+
# Extract standard response
|
227
|
+
message_json = response.dig("choices", 0, "message")
|
228
|
+
message_json["id"] = response.dig("id") if message_json && message_json["id"].blank?
|
229
|
+
message = handle_message(message_json) if message_json
|
230
|
+
|
231
|
+
update_context(prompt: prompt, message: message, response: response) if message
|
232
|
+
|
233
|
+
# Create response with OpenRouter metadata
|
234
|
+
@response = ActiveAgent::GenerationProvider::Response.new(
|
235
|
+
prompt: prompt,
|
236
|
+
message: message,
|
237
|
+
raw_response: response
|
238
|
+
)
|
239
|
+
|
240
|
+
# OpenRouter includes provider and model info directly in the response body
|
241
|
+
if response["provider"] || response["model"]
|
242
|
+
@response.metadata = {
|
243
|
+
provider: response["provider"],
|
244
|
+
model_used: response["model"],
|
245
|
+
fallback_used: response["model"] != @model_name
|
246
|
+
}.compact
|
247
|
+
end
|
248
|
+
|
249
|
+
# Track costs if enabled
|
250
|
+
track_usage(response) if @track_costs && response["usage"]
|
251
|
+
|
252
|
+
@response
|
253
|
+
end
|
254
|
+
|
255
|
+
def add_openrouter_metadata(response, headers)
|
256
|
+
return unless response.respond_to?(:metadata=)
|
257
|
+
|
258
|
+
response.metadata = {
|
259
|
+
provider: headers["x-provider"],
|
260
|
+
model_used: headers["x-model"],
|
261
|
+
trace_id: headers["x-trace-id"],
|
262
|
+
fallback_used: headers["x-model"] != @model_name,
|
263
|
+
ratelimit: {
|
264
|
+
requests_limit: headers["x-ratelimit-requests-limit"],
|
265
|
+
requests_remaining: headers["x-ratelimit-requests-remaining"],
|
266
|
+
requests_reset: headers["x-ratelimit-requests-reset"],
|
267
|
+
tokens_limit: headers["x-ratelimit-tokens-limit"],
|
268
|
+
tokens_remaining: headers["x-ratelimit-tokens-remaining"],
|
269
|
+
tokens_reset: headers["x-ratelimit-tokens-reset"]
|
270
|
+
}.compact
|
271
|
+
}.compact
|
272
|
+
end
|
273
|
+
|
274
|
+
def track_usage(response)
|
275
|
+
return nil unless @track_costs
|
276
|
+
return nil unless response["usage"]
|
277
|
+
|
278
|
+
usage = response["usage"]
|
279
|
+
model = response.dig("model") || @model_name
|
280
|
+
|
281
|
+
# Calculate costs (simplified - would need actual pricing data)
|
282
|
+
cost_info = {
|
283
|
+
model: model,
|
284
|
+
prompt_tokens: usage["prompt_tokens"],
|
285
|
+
completion_tokens: usage["completion_tokens"],
|
286
|
+
total_tokens: usage["total_tokens"]
|
287
|
+
}
|
288
|
+
|
289
|
+
# Log usage information
|
290
|
+
if defined?(Rails)
|
291
|
+
Rails.logger.info "[OpenRouter] Usage: #{cost_info.to_json}"
|
292
|
+
|
293
|
+
# Store in cache if available
|
294
|
+
if Rails.cache
|
295
|
+
cache_key = "openrouter:usage:#{Date.current}"
|
296
|
+
Rails.cache.increment("#{cache_key}:tokens", usage["total_tokens"])
|
297
|
+
Rails.cache.increment("#{cache_key}:requests")
|
298
|
+
end
|
299
|
+
end
|
300
|
+
|
301
|
+
cost_info
|
302
|
+
end
|
303
|
+
|
304
|
+
def handle_openrouter_error(error)
|
305
|
+
error_message = error.message || error.to_s
|
306
|
+
|
307
|
+
case error_message
|
308
|
+
when /rate limit/i
|
309
|
+
handle_rate_limit_error(error)
|
310
|
+
when /insufficient credits|payment required/i
|
311
|
+
handle_insufficient_credits(error)
|
312
|
+
when /no available provider/i
|
313
|
+
handle_no_provider_error(error)
|
314
|
+
when /timeout/i
|
315
|
+
handle_timeout_error(error)
|
316
|
+
else
|
317
|
+
# Fall back to parent error handling
|
318
|
+
super(error) if defined?(super)
|
319
|
+
end
|
320
|
+
end
|
321
|
+
|
322
|
+
def handle_rate_limit_error(error)
|
323
|
+
Rails.logger.error "[OpenRouter] Rate limit exceeded: #{error.message}" if defined?(Rails)
|
324
|
+
raise GenerationProviderError, "OpenRouter rate limit exceeded. Please retry later."
|
325
|
+
end
|
326
|
+
|
327
|
+
def handle_insufficient_credits(error)
|
328
|
+
Rails.logger.error "[OpenRouter] Insufficient credits: #{error.message}" if defined?(Rails)
|
329
|
+
raise GenerationProviderError, "OpenRouter account has insufficient credits."
|
330
|
+
end
|
331
|
+
|
332
|
+
def handle_no_provider_error(error)
|
333
|
+
Rails.logger.error "[OpenRouter] No available provider: #{error.message}" if defined?(Rails)
|
334
|
+
raise GenerationProviderError, "No available provider for the requested model."
|
335
|
+
end
|
336
|
+
|
337
|
+
def handle_timeout_error(error)
|
338
|
+
Rails.logger.error "[OpenRouter] Request timeout: #{error.message}" if defined?(Rails)
|
339
|
+
raise GenerationProviderError, "OpenRouter request timed out."
|
12
340
|
end
|
13
341
|
end
|
14
342
|
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveAgent
|
4
|
+
module GenerationProvider
|
5
|
+
module ParameterBuilder
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
def prompt_parameters(overrides = {})
|
9
|
+
base_params = build_base_parameters
|
10
|
+
provider_params = build_provider_parameters
|
11
|
+
|
12
|
+
# Merge parameters with proper precedence:
|
13
|
+
# 1. Overrides (highest priority)
|
14
|
+
# 2. Prompt options
|
15
|
+
# 3. Provider-specific parameters
|
16
|
+
# 4. Base parameters (lowest priority)
|
17
|
+
base_params
|
18
|
+
.merge(provider_params)
|
19
|
+
.merge(extract_prompt_options)
|
20
|
+
.merge(overrides)
|
21
|
+
.compact
|
22
|
+
end
|
23
|
+
|
24
|
+
protected
|
25
|
+
|
26
|
+
def build_base_parameters
|
27
|
+
{
|
28
|
+
model: determine_model,
|
29
|
+
messages: provider_messages(@prompt.messages),
|
30
|
+
temperature: determine_temperature
|
31
|
+
}.tap do |params|
|
32
|
+
# Add optional parameters if present
|
33
|
+
params[:max_tokens] = determine_max_tokens if determine_max_tokens
|
34
|
+
params[:tools] = format_tools(@prompt.actions) if @prompt.actions.present?
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def build_provider_parameters
|
39
|
+
# Override in provider for specific parameters
|
40
|
+
# For example, Anthropic needs 'system' parameter instead of system message
|
41
|
+
{}
|
42
|
+
end
|
43
|
+
|
44
|
+
def extract_prompt_options
|
45
|
+
# Extract relevant options from prompt
|
46
|
+
options = {}
|
47
|
+
|
48
|
+
# Common options that map directly
|
49
|
+
[ :stream, :top_p, :frequency_penalty, :presence_penalty, :seed, :stop, :user ].each do |key|
|
50
|
+
options[key] = @prompt.options[key] if @prompt.options.key?(key)
|
51
|
+
end
|
52
|
+
|
53
|
+
# Handle response format for structured output
|
54
|
+
if @prompt.output_schema.present?
|
55
|
+
options[:response_format] = build_response_format
|
56
|
+
end
|
57
|
+
|
58
|
+
options
|
59
|
+
end
|
60
|
+
|
61
|
+
def determine_model
|
62
|
+
@prompt.options[:model] || @model_name || @config["model"]
|
63
|
+
end
|
64
|
+
|
65
|
+
def determine_temperature
|
66
|
+
@prompt.options[:temperature] || @config["temperature"] || 0.7
|
67
|
+
end
|
68
|
+
|
69
|
+
def determine_max_tokens
|
70
|
+
@prompt.options[:max_tokens] || @config["max_tokens"]
|
71
|
+
end
|
72
|
+
|
73
|
+
def build_response_format
|
74
|
+
# Default OpenAI-style response format
|
75
|
+
# Override in provider for different formats
|
76
|
+
{
|
77
|
+
type: "json_schema",
|
78
|
+
json_schema: @prompt.output_schema
|
79
|
+
}
|
80
|
+
end
|
81
|
+
|
82
|
+
# Embedding-specific parameters
|
83
|
+
def embeddings_parameters(input: nil, model: nil, **options)
|
84
|
+
{
|
85
|
+
model: model || determine_embedding_model,
|
86
|
+
input: input || format_embedding_input,
|
87
|
+
dimensions: options[:dimensions] || @config["embedding_dimensions"],
|
88
|
+
encoding_format: options[:encoding_format] || "float"
|
89
|
+
}.compact
|
90
|
+
end
|
91
|
+
|
92
|
+
def determine_embedding_model
|
93
|
+
@prompt.options[:embedding_model] || @config["embedding_model"] || "text-embedding-3-small"
|
94
|
+
end
|
95
|
+
|
96
|
+
def format_embedding_input
|
97
|
+
# Handle single or batch embedding inputs
|
98
|
+
if @prompt.message
|
99
|
+
@prompt.message.content
|
100
|
+
elsif @prompt.messages
|
101
|
+
@prompt.messages.map(&:content)
|
102
|
+
else
|
103
|
+
nil
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
module ClassMethods
|
108
|
+
# Class-level configuration for default parameters
|
109
|
+
def default_parameters(params = {})
|
110
|
+
@default_parameters = params
|
111
|
+
end
|
112
|
+
|
113
|
+
def get_default_parameters
|
114
|
+
@default_parameters || {}
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
@@ -4,11 +4,13 @@ module ActiveAgent
|
|
4
4
|
module GenerationProvider
|
5
5
|
class Response
|
6
6
|
attr_reader :message, :prompt, :raw_response
|
7
|
+
attr_accessor :metadata
|
7
8
|
|
8
|
-
def initialize(prompt:, message: nil, raw_response: nil)
|
9
|
+
def initialize(prompt:, message: nil, raw_response: nil, metadata: nil)
|
9
10
|
@prompt = prompt
|
10
11
|
@message = message || prompt.message
|
11
12
|
@raw_response = raw_response
|
13
|
+
@metadata = metadata || {}
|
12
14
|
end
|
13
15
|
|
14
16
|
# Extract usage statistics from the raw response
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveAgent
|
4
|
+
module GenerationProvider
|
5
|
+
module StreamProcessing
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
included do
|
9
|
+
attr_accessor :stream_buffer, :stream_context
|
10
|
+
end
|
11
|
+
|
12
|
+
def provider_stream
|
13
|
+
agent_stream = prompt.options[:stream]
|
14
|
+
message = initialize_stream_message
|
15
|
+
|
16
|
+
@response = ActiveAgent::GenerationProvider::Response.new(prompt:, message:)
|
17
|
+
|
18
|
+
proc do |chunk|
|
19
|
+
process_stream_chunk(chunk, message, agent_stream)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
protected
|
24
|
+
|
25
|
+
def initialize_stream_message
|
26
|
+
ActiveAgent::ActionPrompt::Message.new(content: "", role: :assistant)
|
27
|
+
end
|
28
|
+
|
29
|
+
def process_stream_chunk(chunk, message, agent_stream)
|
30
|
+
# Default implementation - must be overridden in provider
|
31
|
+
raise NotImplementedError, "Providers must implement process_stream_chunk"
|
32
|
+
end
|
33
|
+
|
34
|
+
def handle_stream_delta(delta, message, agent_stream)
|
35
|
+
# Common delta handling logic
|
36
|
+
new_content = extract_content_from_delta(delta)
|
37
|
+
if new_content && !new_content.blank?
|
38
|
+
message.content += new_content
|
39
|
+
agent_stream&.call(message, new_content, false, prompt.action_name)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def extract_content_from_delta(delta)
|
44
|
+
# Default extraction - override if needed
|
45
|
+
delta if delta.is_a?(String)
|
46
|
+
end
|
47
|
+
|
48
|
+
def handle_tool_stream_chunk(chunk, message, agent_stream)
|
49
|
+
# Handle tool calls in streaming
|
50
|
+
# Override in provider for specific implementation
|
51
|
+
end
|
52
|
+
|
53
|
+
def finalize_stream(message, agent_stream)
|
54
|
+
agent_stream&.call(message, nil, true, prompt.action_name)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,142 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveAgent
|
4
|
+
module GenerationProvider
|
5
|
+
module ToolManagement
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
def format_tools(tools)
|
9
|
+
return nil if tools.blank?
|
10
|
+
|
11
|
+
tools.map do |tool|
|
12
|
+
format_single_tool(tool)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def handle_actions(tool_calls)
|
17
|
+
return [] if tool_calls.nil? || tool_calls.empty?
|
18
|
+
|
19
|
+
tool_calls.map do |tool_call|
|
20
|
+
parse_tool_call(tool_call)
|
21
|
+
end.compact
|
22
|
+
end
|
23
|
+
|
24
|
+
protected
|
25
|
+
|
26
|
+
def format_single_tool(tool)
|
27
|
+
# Default implementation for OpenAI-style tools
|
28
|
+
if tool["function"] || tool[:function]
|
29
|
+
# Tool already has the correct structure
|
30
|
+
tool
|
31
|
+
else
|
32
|
+
# Legacy format - wrap in function structure
|
33
|
+
wrap_tool_in_function(tool)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def wrap_tool_in_function(tool)
|
38
|
+
{
|
39
|
+
type: "function",
|
40
|
+
function: {
|
41
|
+
name: tool["name"] || tool[:name],
|
42
|
+
description: tool["description"] || tool[:description],
|
43
|
+
parameters: tool["parameters"] || tool[:parameters]
|
44
|
+
}
|
45
|
+
}
|
46
|
+
end
|
47
|
+
|
48
|
+
def parse_tool_call(tool_call)
|
49
|
+
# Skip if no function information
|
50
|
+
return nil if tool_call.nil?
|
51
|
+
|
52
|
+
# Extract tool information based on provider format
|
53
|
+
tool_id = extract_tool_id(tool_call)
|
54
|
+
tool_name = extract_tool_name(tool_call)
|
55
|
+
tool_params = extract_tool_params(tool_call)
|
56
|
+
|
57
|
+
# Skip if no name found
|
58
|
+
return nil if tool_name.blank?
|
59
|
+
|
60
|
+
ActiveAgent::ActionPrompt::Action.new(
|
61
|
+
id: tool_id,
|
62
|
+
name: tool_name,
|
63
|
+
params: tool_params
|
64
|
+
)
|
65
|
+
end
|
66
|
+
|
67
|
+
def extract_tool_id(tool_call)
|
68
|
+
tool_call["id"] || tool_call[:id]
|
69
|
+
end
|
70
|
+
|
71
|
+
def extract_tool_name(tool_call)
|
72
|
+
# Try different paths for tool name
|
73
|
+
tool_call.dig("function", "name") ||
|
74
|
+
tool_call.dig(:function, :name) ||
|
75
|
+
tool_call["name"] ||
|
76
|
+
tool_call[:name]
|
77
|
+
end
|
78
|
+
|
79
|
+
def extract_tool_params(tool_call)
|
80
|
+
# Try different paths for tool parameters/arguments
|
81
|
+
args = tool_call.dig("function", "arguments") ||
|
82
|
+
tool_call.dig(:function, :arguments) ||
|
83
|
+
tool_call["arguments"] ||
|
84
|
+
tool_call[:arguments] ||
|
85
|
+
tool_call["input"] ||
|
86
|
+
tool_call[:input]
|
87
|
+
|
88
|
+
return nil if args.blank?
|
89
|
+
|
90
|
+
# Parse JSON string if needed
|
91
|
+
if args.is_a?(String)
|
92
|
+
begin
|
93
|
+
JSON.parse(args, symbolize_names: true)
|
94
|
+
rescue JSON::ParserError
|
95
|
+
nil
|
96
|
+
end
|
97
|
+
else
|
98
|
+
args
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
# Provider-specific tool format methods
|
103
|
+
# Override these in specific providers
|
104
|
+
|
105
|
+
def format_tools_for_anthropic(tools)
|
106
|
+
# Anthropic-specific tool format
|
107
|
+
tools.map do |tool|
|
108
|
+
{
|
109
|
+
name: extract_tool_name_from_schema(tool),
|
110
|
+
description: extract_tool_description_from_schema(tool),
|
111
|
+
input_schema: extract_tool_parameters_from_schema(tool)
|
112
|
+
}
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def format_tools_for_openai(tools)
|
117
|
+
# OpenAI-specific tool format (default)
|
118
|
+
format_tools(tools)
|
119
|
+
end
|
120
|
+
|
121
|
+
private
|
122
|
+
|
123
|
+
def extract_tool_name_from_schema(tool)
|
124
|
+
tool["name"] || tool[:name] ||
|
125
|
+
tool.dig("function", "name") || tool.dig(:function, "name") ||
|
126
|
+
tool.dig("function", :name) || tool.dig(:function, :name)
|
127
|
+
end
|
128
|
+
|
129
|
+
def extract_tool_description_from_schema(tool)
|
130
|
+
tool["description"] || tool[:description] ||
|
131
|
+
tool.dig("function", "description") || tool.dig(:function, "description") ||
|
132
|
+
tool.dig("function", :description) || tool.dig(:function, :description)
|
133
|
+
end
|
134
|
+
|
135
|
+
def extract_tool_parameters_from_schema(tool)
|
136
|
+
tool["parameters"] || tool[:parameters] ||
|
137
|
+
tool.dig("function", "parameters") || tool.dig(:function, "parameters") ||
|
138
|
+
tool.dig("function", :parameters) || tool.dig(:function, :parameters)
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
@@ -14,7 +14,6 @@ module ActiveAgent
|
|
14
14
|
module ClassMethods
|
15
15
|
def configuration(name_or_provider, **options)
|
16
16
|
config = ActiveAgent.config[name_or_provider.to_s] || ActiveAgent.config.dig(ENV["RAILS_ENV"], name_or_provider.to_s) || {}
|
17
|
-
|
18
17
|
config = { "service" => "OpenAI" } if config.empty? && name_or_provider == :openai
|
19
18
|
config.merge!(options)
|
20
19
|
raise "Failed to load provider #{name_or_provider}: configuration not found for provider" if config["service"].nil?
|
@@ -26,6 +25,7 @@ module ActiveAgent
|
|
26
25
|
def configure_provider(config)
|
27
26
|
service_name = config["service"]
|
28
27
|
require "active_agent/generation_provider/#{service_name.underscore}_provider"
|
28
|
+
|
29
29
|
ActiveAgent::GenerationProvider.const_get("#{service_name.camelize}Provider").new(config)
|
30
30
|
end
|
31
31
|
|