activeagent 0.5.0 → 0.6.0rc2

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.
@@ -6,9 +6,331 @@ module ActiveAgent
6
6
  class OpenRouterProvider < OpenAIProvider
7
7
  def initialize(config)
8
8
  @config = config
9
- @access_token ||= config["api_key"] || config["access_token"] || ENV["OPENROUTER_API_KEY"] || ENV["OPENROUTER_ACCESS_TOKEN"]
9
+ @access_token = config["api_key"] || config["access_token"] ||
10
+ ENV["OPENROUTER_API_KEY"] || ENV["OPENROUTER_ACCESS_TOKEN"]
10
11
  @model_name = config["model"]
11
- @client = OpenAI::Client.new(uri_base: "https://openrouter.ai/api/v1", access_token: @access_token, log_errors: true)
12
+
13
+ # OpenRouter-specific configuration
14
+ @app_name = config["app_name"] || default_app_name
15
+ @site_url = config["site_url"] || default_site_url
16
+ @enable_fallbacks = config["enable_fallbacks"] != false
17
+ @fallback_models = config["fallback_models"] || []
18
+ @transforms = config["transforms"] || []
19
+ @provider_preferences = config["provider"] || {}
20
+ @track_costs = config["track_costs"] != false
21
+ @route = config["route"] || "fallback"
22
+
23
+ # Data collection preference (allow, deny, or specific provider list)
24
+ @data_collection = config["data_collection"] || @provider_preferences["data_collection"] || "allow"
25
+
26
+ # Initialize OpenAI client with OpenRouter base URL
27
+ @client = OpenAI::Client.new(
28
+ uri_base: "https://openrouter.ai/api/v1",
29
+ access_token: @access_token,
30
+ log_errors: Rails.env.development?,
31
+ default_headers: openrouter_headers
32
+ )
33
+ end
34
+
35
+ def generate(prompt)
36
+ @prompt = prompt
37
+
38
+ with_error_handling do
39
+ parameters = build_openrouter_parameters
40
+ response = execute_with_fallback(parameters)
41
+ process_openrouter_response(response)
42
+ end
43
+ rescue => e
44
+ handle_openrouter_error(e)
45
+ end
46
+
47
+ protected
48
+
49
+ def build_provider_parameters
50
+ # Start with base OpenAI parameters
51
+ params = super
52
+
53
+ # Add OpenRouter-specific parameters
54
+ add_openrouter_params(params)
55
+ end
56
+
57
+ def format_content_item(item)
58
+ # Handle OpenRouter-specific content formats
59
+ if item.is_a?(Hash)
60
+ case item[:type] || item["type"]
61
+ when "file"
62
+ # Convert file type to image_url for OpenRouter PDF support
63
+ file_data = item.dig(:file, :file_data) || item.dig("file", "file_data")
64
+ if file_data
65
+ {
66
+ type: "image_url",
67
+ image_url: {
68
+ url: file_data
69
+ }
70
+ }
71
+ else
72
+ item
73
+ end
74
+ else
75
+ # Use default formatting for other types
76
+ super
77
+ end
78
+ else
79
+ super
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def default_app_name
86
+ if defined?(Rails) && Rails.application
87
+ Rails.application.class.name.split("::").first
88
+ else
89
+ "ActiveAgent"
90
+ end
91
+ end
92
+
93
+ def default_site_url
94
+ # First check ActiveAgent config
95
+ return config["default_url_options"]["host"] if config.dig("default_url_options", "host")
96
+
97
+ # Then check Rails routes default_url_options
98
+ if defined?(Rails) && Rails.application&.routes&.default_url_options&.any?
99
+ host = Rails.application.routes.default_url_options[:host]
100
+ port = Rails.application.routes.default_url_options[:port]
101
+ protocol = Rails.application.routes.default_url_options[:protocol] || "https"
102
+
103
+ if host
104
+ url = "#{protocol}://#{host}"
105
+ url += ":#{port}" if port && port != 80 && port != 443
106
+ return url
107
+ end
108
+ end
109
+
110
+ # Finally check ActionMailer config as fallback
111
+ if defined?(Rails) && Rails.application&.config&.action_mailer&.default_url_options
112
+ options = Rails.application.config.action_mailer.default_url_options
113
+ host = options[:host]
114
+ port = options[:port]
115
+ protocol = options[:protocol] || "https"
116
+
117
+ if host
118
+ url = "#{protocol}://#{host}"
119
+ url += ":#{port}" if port && port != 80 && port != 443
120
+ return url
121
+ end
122
+ end
123
+
124
+ nil
125
+ end
126
+
127
+ def openrouter_headers
128
+ headers = {}
129
+ headers["HTTP-Referer"] = @site_url if @site_url
130
+ headers["X-Title"] = @app_name if @app_name
131
+ headers
132
+ end
133
+
134
+ def build_openrouter_parameters
135
+ parameters = prompt_parameters
136
+
137
+ # Handle multiple models for fallback
138
+ if @fallback_models.present?
139
+ parameters[:models] = [ @model_name ] + @fallback_models
140
+ parameters[:route] = @route
141
+ end
142
+
143
+ # Add transforms if specified
144
+ parameters[:transforms] = @transforms if @transforms.present?
145
+
146
+ # Add provider preferences (always include if we have data_collection or other settings)
147
+ # Check both configured and runtime data_collection values
148
+ runtime_data_collection = prompt&.options&.key?(:data_collection)
149
+ if @provider_preferences.present? || @data_collection != "allow" || runtime_data_collection
150
+ parameters[:provider] = build_provider_preferences
151
+ end
152
+
153
+ # Add plugins (e.g., for PDF processing)
154
+
155
+ parameters[:plugins] = prompt.options[:plugins] if prompt.options[:plugins].present?
156
+ parameters[:models] = prompt.options[:fallback_models] if prompt.options[:enable_fallbacks] && prompt.options[:fallback_models].present?
157
+ parameters
158
+ end
159
+
160
+ def build_provider_preferences
161
+ prefs = {}
162
+ prefs[:order] = @provider_preferences["order"] if @provider_preferences["order"]
163
+ prefs[:require_parameters] = @provider_preferences["require_parameters"] if @provider_preferences.key?("require_parameters")
164
+ prefs[:allow_fallbacks] = @enable_fallbacks
165
+
166
+ # Data collection can be:
167
+ # - "allow" (default): Allow all providers to collect data
168
+ # - "deny": Deny all providers from collecting data
169
+ # - Array of provider names: Only allow these providers to collect data
170
+ # Check prompt options first (runtime override), then fall back to configured value
171
+ data_collection = prompt.options[:data_collection] if prompt&.options&.key?(:data_collection)
172
+ data_collection ||= @data_collection
173
+ prefs[:data_collection] = data_collection
174
+
175
+ prefs.compact
176
+ end
177
+
178
+ def add_openrouter_params(params)
179
+ # Add OpenRouter-specific routing parameters
180
+ if @enable_fallbacks && @fallback_models.present?
181
+ params[:models] = [ @model_name ] + @fallback_models
182
+ params[:route] = @route
183
+ end
184
+
185
+ # Add transforms
186
+ params[:transforms] = @transforms if @transforms.present?
187
+
188
+ # Add provider configuration (always include if we have data_collection or other settings)
189
+ # Check both configured and runtime data_collection values
190
+ runtime_data_collection = prompt&.options&.key?(:data_collection)
191
+ if @provider_preferences.present? || @data_collection != "allow" || runtime_data_collection
192
+ params[:provider] = build_provider_preferences
193
+ end
194
+
195
+ # Add plugins (e.g., for PDF processing)
196
+ if prompt.options[:plugins].present?
197
+ params[:plugins] = prompt.options[:plugins]
198
+ end
199
+
200
+ params
201
+ end
202
+
203
+ def execute_with_fallback(parameters)
204
+ parameters[:stream] = provider_stream if prompt.options[:stream] || config["stream"]
205
+
206
+ response = @client.chat(parameters: parameters)
207
+ # Log if fallback was used
208
+ if response.respond_to?(:headers) && response.headers["x-model"] != @model_name
209
+ Rails.logger.info "[OpenRouter] Fallback model used: #{response.headers['x-model']}" if defined?(Rails)
210
+ end
211
+
212
+ response
213
+ end
214
+
215
+ def process_openrouter_response(response)
216
+ # Process as normal OpenAI response first
217
+ if prompt.options[:stream]
218
+ return @response
219
+ end
220
+
221
+ # Extract standard response
222
+ message_json = response.dig("choices", 0, "message")
223
+ message_json["id"] = response.dig("id") if message_json && message_json["id"].blank?
224
+ message = handle_message(message_json) if message_json
225
+
226
+ update_context(prompt: prompt, message: message, response: response) if message
227
+ # Create response with OpenRouter metadata
228
+ @response = ActiveAgent::GenerationProvider::Response.new(
229
+ prompt: prompt,
230
+ message: message,
231
+ raw_response: response
232
+ )
233
+
234
+ # OpenRouter includes provider and model info directly in the response body
235
+ if response["provider"] || response["model"]
236
+ @response.metadata = {
237
+ provider: response["provider"],
238
+ model_used: response["model"],
239
+ fallback_used: response["model"] != @model_name
240
+ }.compact
241
+ end
242
+
243
+ # Track costs if enabled
244
+ track_usage(response) if @track_costs && response["usage"]
245
+
246
+ @response
247
+ end
248
+
249
+ def add_openrouter_metadata(response, headers)
250
+ return unless response.respond_to?(:metadata=)
251
+
252
+ response.metadata = {
253
+ provider: headers["x-provider"],
254
+ model_used: headers["x-model"],
255
+ trace_id: headers["x-trace-id"],
256
+ fallback_used: headers["x-model"] != @model_name,
257
+ ratelimit: {
258
+ requests_limit: headers["x-ratelimit-requests-limit"],
259
+ requests_remaining: headers["x-ratelimit-requests-remaining"],
260
+ requests_reset: headers["x-ratelimit-requests-reset"],
261
+ tokens_limit: headers["x-ratelimit-tokens-limit"],
262
+ tokens_remaining: headers["x-ratelimit-tokens-remaining"],
263
+ tokens_reset: headers["x-ratelimit-tokens-reset"]
264
+ }.compact
265
+ }.compact
266
+ end
267
+
268
+ def track_usage(response)
269
+ return nil unless @track_costs
270
+ return nil unless response["usage"]
271
+
272
+ usage = response["usage"]
273
+ model = response.dig("model") || @model_name
274
+
275
+ # Calculate costs (simplified - would need actual pricing data)
276
+ cost_info = {
277
+ model: model,
278
+ prompt_tokens: usage["prompt_tokens"],
279
+ completion_tokens: usage["completion_tokens"],
280
+ total_tokens: usage["total_tokens"]
281
+ }
282
+
283
+ # Log usage information
284
+ if defined?(Rails)
285
+ Rails.logger.info "[OpenRouter] Usage: #{cost_info.to_json}"
286
+
287
+ # Store in cache if available
288
+ if Rails.cache
289
+ cache_key = "openrouter:usage:#{Date.current}"
290
+ Rails.cache.increment("#{cache_key}:tokens", usage["total_tokens"])
291
+ Rails.cache.increment("#{cache_key}:requests")
292
+ end
293
+ end
294
+
295
+ cost_info
296
+ end
297
+
298
+ def handle_openrouter_error(error)
299
+ error_message = error.message || error.to_s
300
+
301
+ case error_message
302
+ when /rate limit/i
303
+ handle_rate_limit_error(error)
304
+ when /insufficient credits|payment required/i
305
+ handle_insufficient_credits(error)
306
+ when /no available provider/i
307
+ handle_no_provider_error(error)
308
+ when /timeout/i
309
+ handle_timeout_error(error)
310
+ else
311
+ # Fall back to parent error handling
312
+ raise GenerationProviderError, error, error.backtrace
313
+ end
314
+ end
315
+
316
+ def handle_rate_limit_error(error)
317
+ Rails.logger.error "[OpenRouter] Rate limit exceeded: #{error.message}" if defined?(Rails)
318
+ raise GenerationProviderError, "OpenRouter rate limit exceeded. Please retry later."
319
+ end
320
+
321
+ def handle_insufficient_credits(error)
322
+ Rails.logger.error "[OpenRouter] Insufficient credits: #{error.message}" if defined?(Rails)
323
+ raise GenerationProviderError, "OpenRouter account has insufficient credits."
324
+ end
325
+
326
+ def handle_no_provider_error(error)
327
+ Rails.logger.error "[OpenRouter] No available provider: #{error.message}" if defined?(Rails)
328
+ raise GenerationProviderError, "No available provider for the requested model."
329
+ end
330
+
331
+ def handle_timeout_error(error)
332
+ Rails.logger.error "[OpenRouter] Request timeout: #{error.message}" if defined?(Rails)
333
+ raise GenerationProviderError, "OpenRouter request timed out."
12
334
  end
13
335
  end
14
336
  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, :plugins ].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