open_router_enhanced 2.1.0 → 2.2.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.
@@ -133,7 +133,7 @@ module OpenRouter
133
133
  rescue StandardError => e
134
134
  # If the healing call itself fails, we can't proceed.
135
135
  # Return the original broken content to let the loop fail naturally.
136
- warn "[OpenRouter Warning] JSON healing request failed: #{e.message}"
136
+ OpenRouter.log_warning("[OpenRouter Warning] JSON healing request failed: #{e.message}")
137
137
 
138
138
  # Trigger callback for failed healing
139
139
  if @client.respond_to?(:trigger_callbacks)
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRouter
4
+ # Mixin providing request parameter construction for Client.
5
+ module ParameterBuilder
6
+ private
7
+
8
+ # Prepare the base parameters for the API request
9
+ def prepare_base_parameters(messages, opts, stream)
10
+ parameters = { messages: messages.dup }
11
+
12
+ configure_model_parameter!(parameters, opts.model)
13
+ configure_provider_parameter!(parameters, opts)
14
+ configure_transforms_parameter!(parameters, opts.transforms)
15
+ configure_plugins_parameter!(parameters, opts.plugins)
16
+ configure_prediction_parameter!(parameters, opts.prediction)
17
+ configure_stream_parameter!(parameters, stream)
18
+ configure_sampling_parameters!(parameters, opts)
19
+ configure_output_parameters!(parameters, opts)
20
+ configure_routing_parameters!(parameters, opts)
21
+
22
+ parameters.merge!(opts.extras || {})
23
+ parameters
24
+ end
25
+
26
+ def configure_model_parameter!(parameters, model)
27
+ if model.is_a?(String)
28
+ parameters[:model] = model
29
+ elsif model.is_a?(Array)
30
+ parameters[:models] = model
31
+ parameters[:route] = "fallback"
32
+ end
33
+ end
34
+
35
+ def configure_provider_parameter!(parameters, opts)
36
+ if opts.provider && !opts.provider.empty?
37
+ parameters[:provider] = opts.provider
38
+ elsif opts.providers.any?
39
+ parameters[:provider] = { order: opts.providers }
40
+ end
41
+
42
+ parameters[:route] = opts.route if opts.route
43
+ end
44
+
45
+ def configure_sampling_parameters!(parameters, opts)
46
+ parameters[:temperature] = opts.temperature if opts.temperature
47
+ parameters[:top_p] = opts.top_p if opts.top_p
48
+ parameters[:top_k] = opts.top_k if opts.top_k
49
+ parameters[:frequency_penalty] = opts.frequency_penalty if opts.frequency_penalty
50
+ parameters[:presence_penalty] = opts.presence_penalty if opts.presence_penalty
51
+ parameters[:repetition_penalty] = opts.repetition_penalty if opts.repetition_penalty
52
+ parameters[:min_p] = opts.min_p if opts.min_p
53
+ parameters[:top_a] = opts.top_a if opts.top_a
54
+ parameters[:seed] = opts.seed if opts.seed
55
+ end
56
+
57
+ def configure_output_parameters!(parameters, opts)
58
+ if opts.max_completion_tokens
59
+ parameters[:max_completion_tokens] = opts.max_completion_tokens
60
+ elsif opts.max_tokens
61
+ parameters[:max_tokens] = opts.max_tokens
62
+ end
63
+
64
+ parameters[:stop] = opts.stop if opts.stop
65
+ parameters[:logprobs] = opts.logprobs unless opts.logprobs.nil?
66
+ parameters[:top_logprobs] = opts.top_logprobs if opts.top_logprobs
67
+ parameters[:logit_bias] = opts.logit_bias if opts.logit_bias && !opts.logit_bias.empty?
68
+ parameters[:parallel_tool_calls] = opts.parallel_tool_calls unless opts.parallel_tool_calls.nil?
69
+ parameters[:verbosity] = opts.verbosity if opts.verbosity
70
+ end
71
+
72
+ def configure_routing_parameters!(parameters, opts)
73
+ parameters[:metadata] = opts.metadata if opts.metadata && !opts.metadata.empty?
74
+ parameters[:user] = opts.user if opts.user
75
+ parameters[:session_id] = opts.session_id if opts.session_id
76
+ end
77
+
78
+ def configure_transforms_parameter!(parameters, transforms)
79
+ parameters[:transforms] = transforms if transforms.any?
80
+ end
81
+
82
+ def configure_plugins_parameter!(parameters, plugins)
83
+ parameters[:plugins] = plugins.dup if plugins.any?
84
+ end
85
+
86
+ def configure_prediction_parameter!(parameters, prediction)
87
+ parameters[:prediction] = prediction if prediction
88
+ end
89
+
90
+ def configure_stream_parameter!(parameters, stream)
91
+ parameters[:stream] = stream if stream
92
+ end
93
+
94
+ # Auto-add response-healing plugin when using structured outputs (non-streaming only)
95
+ def configure_plugins!(parameters, response_format, stream)
96
+ return unless should_auto_add_healing?(response_format, stream)
97
+
98
+ parameters[:plugins] ||= []
99
+ return if parameters[:plugins].any? { |p| p[:id] == "response-healing" || p["id"] == "response-healing" }
100
+
101
+ parameters[:plugins] << { id: "response-healing" }
102
+ end
103
+
104
+ def should_auto_add_healing?(response_format, stream)
105
+ return false unless configuration.auto_native_healing
106
+ return false if stream
107
+ return false unless response_format
108
+
109
+ case response_format
110
+ when OpenRouter::Schema
111
+ true
112
+ when Hash
113
+ type = response_format[:type] || response_format["type"]
114
+ %w[json_schema json_object].include?(type.to_s)
115
+ else
116
+ false
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRouter
4
+ # Mixin providing HTTP execution, error handling, and capability validation for Client.
5
+ module RequestHandler
6
+ private
7
+
8
+ def execute_request(parameters)
9
+ post(path: "/chat/completions", parameters: parameters)
10
+ rescue ConfigurationError => e
11
+ trigger_callbacks(:on_error, e)
12
+ raise ServerError, e.message
13
+ rescue Faraday::Error => e
14
+ trigger_callbacks(:on_error, e)
15
+ handle_faraday_error(e)
16
+ end
17
+
18
+ def handle_faraday_error(error)
19
+ case error
20
+ when Faraday::UnauthorizedError
21
+ raise error
22
+ when Faraday::BadRequestError
23
+ error_message = extract_error_message(error)
24
+ raise ServerError, "Bad Request: #{error_message}"
25
+ when Faraday::ServerError
26
+ raise ServerError, "Server Error: #{error.message}"
27
+ else
28
+ raise ServerError, "Network Error: #{error.message}"
29
+ end
30
+ end
31
+
32
+ def extract_error_message(error)
33
+ return error.message unless error.response&.dig(:body)
34
+
35
+ body = error.response[:body]
36
+
37
+ if body.is_a?(Hash)
38
+ body.dig("error", "message") || error.message
39
+ elsif body.is_a?(String)
40
+ extract_error_from_json_string(body) || error.message
41
+ else
42
+ error.message
43
+ end
44
+ end
45
+
46
+ def extract_error_from_json_string(json_string)
47
+ parsed_body = JSON.parse(json_string)
48
+ parsed_body.dig("error", "message")
49
+ rescue JSON::ParserError
50
+ nil
51
+ end
52
+
53
+ def validate_response!(raw_response, stream)
54
+ raise ServerError, raw_response.dig("error", "message") if raw_response.presence&.dig("error", "message").present?
55
+
56
+ return unless stream.blank? && raw_response.blank?
57
+
58
+ raise ServerError, "Empty response from OpenRouter. Might be worth retrying once or twice."
59
+ end
60
+
61
+ def build_response(raw_response, response_format, forced_extraction)
62
+ response = Response.new(raw_response, response_format: response_format, forced_extraction: forced_extraction)
63
+ response.client = self
64
+ response
65
+ end
66
+
67
+ def validate_vision_support(model, messages)
68
+ warn_if_unsupported(model, :vision, "vision/image processing") if messages_contain_images?(messages)
69
+ end
70
+
71
+ def warn_if_unsupported(model, capability, feature_name)
72
+ return if model.is_a?(Array) || model.to_s.start_with?("openrouter/")
73
+ return if ModelRegistry.has_capability?(model, capability)
74
+
75
+ if configuration.strict_mode
76
+ raise CapabilityError,
77
+ "Model '#{model}' does not support #{feature_name} (missing :#{capability} capability). Enable non-strict mode to allow this request."
78
+ end
79
+
80
+ warning_key = "#{model}:#{capability}"
81
+ return if @capability_warnings_shown.include?(warning_key)
82
+
83
+ OpenRouter.log_warning("[OpenRouter Warning] Model '#{model}' may not support #{feature_name} (missing :#{capability} capability). The request will still be attempted.")
84
+ @capability_warnings_shown << warning_key
85
+ end
86
+
87
+ def messages_contain_images?(messages)
88
+ messages.any? do |msg|
89
+ content = msg[:content] || msg["content"]
90
+ if content.is_a?(Array)
91
+ content.any? { |part| part.is_a?(Hash) && (part[:type] == "image_url" || part["type"] == "image_url") }
92
+ else
93
+ false
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -2,11 +2,14 @@
2
2
 
3
3
  require "json"
4
4
  require "active_support/core_ext/hash/indifferent_access"
5
+ require_relative "response_parsing"
5
6
 
6
7
  module OpenRouter
7
8
  class StructuredOutputError < Error; end
8
9
 
9
10
  class Response
11
+ include OpenRouter::ResponseParsing
12
+
10
13
  attr_reader :raw_response, :response_format, :forced_extraction
11
14
  attr_accessor :client
12
15
 
@@ -182,6 +185,10 @@ module OpenRouter
182
185
  @raw_response["model"]
183
186
  end
184
187
 
188
+ # Alias for #model — returns the concrete model the API/router used.
189
+ # Useful for Pareto, Auto, and Fusion routing ("which model answered?").
190
+ alias selected_model model
191
+
185
192
  def created
186
193
  @raw_response["created"]
187
194
  end
@@ -253,124 +260,5 @@ module OpenRouter
253
260
  def error_message
254
261
  @raw_response.dig("error", "message")
255
262
  end
256
-
257
- private
258
-
259
- def parse_tool_calls
260
- tool_calls_data = choices.first&.dig("message", "tool_calls")
261
- return [] unless tool_calls_data.is_a?(Array)
262
-
263
- tool_calls_data.map { |tc| ToolCall.new(tc) }
264
- rescue StandardError => e
265
- raise ToolCallError, "Failed to parse tool calls: #{e.message}"
266
- end
267
-
268
- def raw_tool_calls
269
- choices.first&.dig("message", "tool_calls") || []
270
- end
271
-
272
- def parse_and_heal_structured_output(auto_heal: false)
273
- return nil unless structured_output_expected?
274
- return nil unless has_content?
275
-
276
- content_to_parse = @forced_extraction ? extract_json_from_text(content) : content
277
-
278
- if auto_heal && @client
279
- # For forced extraction: always send full content to provide context for healing
280
- # For normal responses: send the content as-is
281
- healing_content = if @forced_extraction
282
- content # Always send full response for better healing context
283
- else
284
- content_to_parse || content
285
- end
286
- heal_structured_response(healing_content, extract_schema_from_response_format)
287
- else
288
- return nil if content_to_parse.nil? # No JSON found in forced extraction
289
-
290
- begin
291
- JSON.parse(content_to_parse)
292
- rescue JSON::ParserError => e
293
- # For forced extraction, be more lenient and return nil on parse failures
294
- # For regular structured outputs, return nil if content looks like it contains markdown
295
- # (indicates it's not actually structured JSON output)
296
- if @forced_extraction
297
- nil
298
- elsif content_to_parse&.include?("```")
299
- # Content contains markdown blocks - this is not structured output
300
- nil
301
- else
302
- raise StructuredOutputError, "Failed to parse structured output: #{e.message}"
303
- end
304
- end
305
- end
306
- end
307
-
308
- # Extract JSON from text content (for forced structured output)
309
- def extract_json_from_text(text)
310
- return nil if text.nil? || text.empty?
311
-
312
- # First try to find JSON in code blocks
313
- if text.include?("```")
314
- # Look for ```json or ``` blocks
315
- json_match = text.match(/```(?:json)?\s*\n?(.*?)\n?```/m)
316
- if json_match
317
- candidate = json_match[1].strip
318
- return candidate unless candidate.empty?
319
- end
320
- end
321
-
322
- # Try to parse the entire text as JSON
323
- begin
324
- JSON.parse(text)
325
- return text
326
- rescue JSON::ParserError
327
- # Look for JSON-like content (starts with { or [)
328
- json_match = text.match(/(\{.*\}|\[.*\])/m)
329
- return json_match[1] if json_match
330
- end
331
-
332
- # No JSON found
333
- nil
334
- end
335
-
336
- def structured_output_expected?
337
- return false unless @response_format
338
-
339
- if @response_format.is_a?(Schema)
340
- true
341
- elsif @response_format.is_a?(Hash) && @response_format[:type] == "json_schema"
342
- true
343
- else
344
- false
345
- end
346
- end
347
-
348
- def extract_schema_from_response_format
349
- case @response_format
350
- when Schema
351
- @response_format
352
- when Hash
353
- schema_def = @response_format[:json_schema]
354
- if schema_def.is_a?(Schema)
355
- schema_def
356
- elsif schema_def.is_a?(Hash) && schema_def[:schema]
357
- # Create a temporary schema object for validation
358
- Schema.new(
359
- schema_def[:name] || "response",
360
- schema_def[:schema],
361
- strict: schema_def.key?(:strict) ? schema_def[:strict] : true
362
- )
363
- end
364
- end
365
- end
366
-
367
- # Backward compatibility method that delegates to JsonHealer
368
- def heal_structured_response(content, schema)
369
- return JSON.parse(content) unless schema
370
-
371
- healer = JsonHealer.new(@client)
372
- context = @forced_extraction ? :forced_extraction : :generic
373
- healer.heal(content, schema, context: context)
374
- end
375
263
  end
376
264
  end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRouter
4
+ # Private parsing helpers for Response — structured output extraction and tool call parsing.
5
+ module ResponseParsing
6
+ private
7
+
8
+ def parse_tool_calls
9
+ tool_calls_data = choices.first&.dig("message", "tool_calls")
10
+ return [] unless tool_calls_data.is_a?(Array)
11
+
12
+ tool_calls_data.map { |tc| ToolCall.new(tc) }
13
+ rescue StandardError => e
14
+ raise ToolCallError, "Failed to parse tool calls: #{e.message}"
15
+ end
16
+
17
+ def raw_tool_calls
18
+ choices.first&.dig("message", "tool_calls") || []
19
+ end
20
+
21
+ def parse_and_heal_structured_output(auto_heal: false)
22
+ return nil unless structured_output_expected?
23
+ return nil unless has_content?
24
+
25
+ content_to_parse = @forced_extraction ? extract_json_from_text(content) : content
26
+
27
+ if auto_heal && @client
28
+ healing_content = @forced_extraction ? content : (content_to_parse || content)
29
+ heal_structured_response(healing_content, extract_schema_from_response_format)
30
+ else
31
+ return nil if content_to_parse.nil?
32
+
33
+ begin
34
+ JSON.parse(content_to_parse)
35
+ rescue JSON::ParserError => e
36
+ if @forced_extraction
37
+ nil
38
+ elsif content_to_parse&.include?("```")
39
+ nil
40
+ else
41
+ raise StructuredOutputError, "Failed to parse structured output: #{e.message}"
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ def extract_json_from_text(text)
48
+ return nil if text.nil? || text.empty?
49
+
50
+ if text.include?("```")
51
+ json_match = text.match(/```(?:json)?\s*\n?(.*?)\n?```/m)
52
+ if json_match
53
+ candidate = json_match[1].strip
54
+ return candidate unless candidate.empty?
55
+ end
56
+ end
57
+
58
+ begin
59
+ JSON.parse(text)
60
+ return text
61
+ rescue JSON::ParserError
62
+ json_match = text.match(/(\{.*\}|\[.*\])/m)
63
+ return json_match[1] if json_match
64
+ end
65
+
66
+ nil
67
+ end
68
+
69
+ def structured_output_expected?
70
+ return false unless @response_format
71
+
72
+ if @response_format.is_a?(Schema)
73
+ true
74
+ elsif @response_format.is_a?(Hash) && @response_format[:type] == "json_schema"
75
+ true
76
+ else
77
+ false
78
+ end
79
+ end
80
+
81
+ def extract_schema_from_response_format
82
+ case @response_format
83
+ when Schema
84
+ @response_format
85
+ when Hash
86
+ schema_def = @response_format[:json_schema]
87
+ if schema_def.is_a?(Schema)
88
+ schema_def
89
+ elsif schema_def.is_a?(Hash) && schema_def[:schema]
90
+ Schema.new(
91
+ schema_def[:name] || "response",
92
+ schema_def[:schema],
93
+ strict: schema_def.key?(:strict) ? schema_def[:strict] : true
94
+ )
95
+ end
96
+ end
97
+ end
98
+
99
+ def heal_structured_response(content, schema)
100
+ return JSON.parse(content) unless schema
101
+
102
+ healer = JsonHealer.new(@client)
103
+ context = @forced_extraction ? :forced_extraction : :generic
104
+ healer.heal(content, schema, context: context)
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRouter
4
+ # Mixin providing ergonomic access to OpenRouter router/meta-model features
5
+ # (Fusion, Pareto Code Router). Builds the right model alias + plugin config
6
+ # and delegates to Client#complete.
7
+ module Routing
8
+ FUSION_MODEL = "openrouter/fusion"
9
+ PARETO_CODE_MODEL = "openrouter/pareto-code"
10
+
11
+ # Route to the cheapest code-capable model meeting a quality bar.
12
+ #
13
+ # @param min_coding_score [Float, nil] 0.0–1.0 (1.0 = best). Optional.
14
+ def pareto_complete(messages, min_coding_score: nil, **opts)
15
+ validate_min_coding_score!(min_coding_score)
16
+
17
+ plugin = { id: "pareto-router" }
18
+ plugin[:min_coding_score] = min_coding_score unless min_coding_score.nil?
19
+
20
+ kwargs = merge_plugin(opts, plugin)
21
+ complete(messages, model: PARETO_CODE_MODEL, **kwargs)
22
+ end
23
+
24
+ # Fan a prompt out to a panel of models and synthesize one answer.
25
+ # NOTE: Fusion costs ~4–5x a single completion (panel calls + judge).
26
+ #
27
+ # @param analysis_models [Array<String>, nil] 1–8 panel model ids.
28
+ # @param judge [String, nil] synthesis model id (defaults to the fusion model server-side).
29
+ # @param preset [String, Symbol, nil] curated panel slug (e.g. "general-budget").
30
+ # @param max_tool_calls [Integer, nil] 1–16.
31
+ def fuse(messages, analysis_models: nil, judge: nil, preset: nil, max_tool_calls: nil, **opts)
32
+ validate_analysis_models!(analysis_models)
33
+ validate_max_tool_calls!(max_tool_calls)
34
+
35
+ plugin = {
36
+ id: "fusion",
37
+ analysis_models: analysis_models,
38
+ model: judge, # OpenRouter Fusion plugin field is 'model', not 'judge'
39
+ preset: preset&.to_s,
40
+ max_tool_calls: max_tool_calls
41
+ }.compact
42
+
43
+ kwargs = merge_plugin(opts, plugin)
44
+ complete(messages, model: FUSION_MODEL, **kwargs)
45
+ end
46
+
47
+ private
48
+
49
+ def validate_min_coding_score!(score)
50
+ return if score.nil?
51
+
52
+ return if score.is_a?(Numeric) && score >= 0.0 && score <= 1.0
53
+
54
+ raise ArgumentError, "min_coding_score must be a number between 0.0 and 1.0 (got #{score.inspect})"
55
+ end
56
+
57
+ def validate_analysis_models!(models)
58
+ return if models.nil?
59
+
60
+ return if models.is_a?(Array) && (1..8).cover?(models.size) && models.all? { |m| m.is_a?(String) && !m.strip.empty? }
61
+
62
+ raise ArgumentError, "analysis_models must be an array of 1–8 model id strings (got #{models.inspect})"
63
+ end
64
+
65
+ def validate_max_tool_calls!(value)
66
+ return if value.nil?
67
+
68
+ return if value.is_a?(Integer) && (1..16).cover?(value)
69
+
70
+ raise ArgumentError, "max_tool_calls must be an integer between 1 and 16 (got #{value.inspect})"
71
+ end
72
+
73
+ # Merge a router plugin into any caller-supplied plugins, de-duped by :id.
74
+ def merge_plugin(opts, plugin)
75
+ existing = Array(opts[:plugins]).map { |p| p.transform_keys(&:to_sym) }
76
+ existing = existing.reject { |p| p[:id].to_s == plugin[:id].to_s }
77
+ opts.merge(plugins: existing + [plugin])
78
+ end
79
+ end
80
+ end
@@ -135,7 +135,7 @@ module OpenRouter
135
135
  @streaming_callbacks[event].each do |callback|
136
136
  callback.call(data)
137
137
  rescue StandardError => e
138
- warn "[OpenRouter] Streaming callback error for #{event}: #{e.message}"
138
+ OpenRouter.log_warning("[OpenRouter] Streaming callback error for #{event}: #{e.message}")
139
139
  end
140
140
  end
141
141
 
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tool"
4
+
5
+ module OpenRouter
6
+ # Represents the `openrouter:subagent` server tool, which lets an orchestrator
7
+ # model delegate self-contained subtasks to a cheaper worker model mid-generation.
8
+ #
9
+ # Unlike a function Tool, it serializes to the server-tool shape:
10
+ # { type: "openrouter:subagent", parameters: { model:, instructions:, ... } }
11
+ #
12
+ # @example
13
+ # sub = OpenRouter::SubagentTool.new(model: "z-ai/glm-5.2", instructions: "Be concise.")
14
+ # client.complete(messages, model: "anthropic/claude-3.5-sonnet", tools: [sub])
15
+ class SubagentTool < Tool
16
+ SERVER_TOOL_TYPE = "openrouter:subagent"
17
+
18
+ # We deliberately do not call super: Tool#initialize expects a function
19
+ # definition with a name/description and validates it, neither of which a
20
+ # server tool has. The server-tool shape is built directly here instead.
21
+ def initialize(model:, instructions: nil, max_completion_tokens: nil, # rubocop:disable Lint/MissingSuper
22
+ temperature: nil, reasoning: nil)
23
+ raise ArgumentError, "model is required for SubagentTool" if model.nil? || model.to_s.strip.empty?
24
+
25
+ @type = SERVER_TOOL_TYPE
26
+ @parameters_config = {
27
+ model: model,
28
+ instructions: instructions,
29
+ max_completion_tokens: max_completion_tokens,
30
+ temperature: temperature,
31
+ reasoning: reasoning
32
+ }.compact
33
+ end
34
+
35
+ def to_h
36
+ { type: @type, parameters: @parameters_config }
37
+ end
38
+
39
+ def name
40
+ @type
41
+ end
42
+
43
+ def description
44
+ "OpenRouter subagent server tool (worker: #{@parameters_config[:model]})"
45
+ end
46
+
47
+ def parameters
48
+ nil
49
+ end
50
+ end
51
+ end