open_router_enhanced 2.0.1 → 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)
@@ -139,7 +139,7 @@ module OpenRouter
139
139
 
140
140
  models[model_id] = {
141
141
  name: model_data["name"],
142
- cost_per_1k_tokens: {
142
+ cost_per_token: {
143
143
  input: model_data.dig("pricing", "prompt").to_f,
144
144
  output: model_data.dig("pricing", "completion").to_f
145
145
  },
@@ -262,12 +262,30 @@ module OpenRouter
262
262
  model_info = get_model_info(model)
263
263
  return 0 unless model_info
264
264
 
265
- input_cost = (input_tokens / 1000.0) * model_info[:cost_per_1k_tokens][:input]
266
- output_cost = (output_tokens / 1000.0) * model_info[:cost_per_1k_tokens][:output]
265
+ input_cost = input_tokens * model_info[:cost_per_token][:input]
266
+ output_cost = output_tokens * model_info[:cost_per_token][:output]
267
267
 
268
268
  input_cost + output_cost
269
269
  end
270
270
 
271
+ # Cost per 1,000 tokens — { input: Float, output: Float } or nil
272
+ def cost_per_thousand(model)
273
+ info = get_model_info(model)
274
+ return nil unless info
275
+
276
+ { input: info[:cost_per_token][:input] * 1_000,
277
+ output: info[:cost_per_token][:output] * 1_000 }
278
+ end
279
+
280
+ # Cost per 1,000,000 tokens — { input: Float, output: Float } or nil
281
+ def cost_per_million(model)
282
+ info = get_model_info(model)
283
+ return nil unless info
284
+
285
+ { input: info[:cost_per_token][:input] * 1_000_000,
286
+ output: info[:cost_per_token][:output] * 1_000_000 }
287
+ end
288
+
271
289
  private
272
290
 
273
291
  # Check if model specs meet the given requirements
@@ -279,11 +297,11 @@ module OpenRouter
279
297
  end
280
298
 
281
299
  # Check cost requirements
282
- if requirements[:max_input_cost] && (specs[:cost_per_1k_tokens][:input] > requirements[:max_input_cost])
300
+ if requirements[:max_input_cost] && (specs[:cost_per_token][:input] > requirements[:max_input_cost])
283
301
  return false
284
302
  end
285
303
 
286
- if requirements[:max_output_cost] && (specs[:cost_per_1k_tokens][:output] > requirements[:max_output_cost])
304
+ if requirements[:max_output_cost] && (specs[:cost_per_token][:output] > requirements[:max_output_cost])
287
305
  return false
288
306
  end
289
307
 
@@ -334,7 +352,7 @@ module OpenRouter
334
352
  def calculate_model_cost(specs, _requirements)
335
353
  # Simple cost calculation for sorting - could be made more sophisticated
336
354
  # For now, just use input token cost as the primary metric
337
- specs[:cost_per_1k_tokens][:input]
355
+ specs[:cost_per_token][:input]
338
356
  end
339
357
 
340
358
  # Set up cleanup hook to manage cache size
@@ -343,7 +343,7 @@ module OpenRouter
343
343
  all_candidates = filter_by_providers(ModelRegistry.all_models)
344
344
  return nil if all_candidates.empty?
345
345
 
346
- all_candidates.min_by { |_, specs| specs[:cost_per_1k_tokens][:input] }&.first
346
+ all_candidates.min_by { |_, specs| specs[:cost_per_token][:input] }&.first
347
347
  end
348
348
 
349
349
  # Get detailed information about the current selection criteria
@@ -426,18 +426,18 @@ module OpenRouter
426
426
  def apply_strategy_sorting(candidates)
427
427
  case @strategy
428
428
  when :cost
429
- candidates.min_by { |_, specs| specs[:cost_per_1k_tokens][:input] }
429
+ candidates.min_by { |_, specs| specs[:cost_per_token][:input] }
430
430
  when :performance
431
431
  # Prefer premium tier, then by cost within tier
432
432
  candidates.min_by do |_, specs|
433
- [specs[:performance_tier] == :premium ? 0 : 1, specs[:cost_per_1k_tokens][:input]]
433
+ [specs[:performance_tier] == :premium ? 0 : 1, specs[:cost_per_token][:input]]
434
434
  end
435
435
  when :latest
436
436
  candidates.max_by { |_, specs| (specs[:created_at] || 0).to_i }
437
437
  when :context
438
438
  candidates.max_by { |_, specs| (specs[:context_length] || 0).to_i }
439
439
  else
440
- candidates.min_by { |_, specs| specs[:cost_per_1k_tokens][:input] }
440
+ candidates.min_by { |_, specs| specs[:cost_per_token][:input] }
441
441
  end
442
442
  end
443
443
 
@@ -445,17 +445,17 @@ module OpenRouter
445
445
  def apply_strategy_sorting_all(candidates)
446
446
  case @strategy
447
447
  when :cost
448
- candidates.sort_by { |_, specs| specs[:cost_per_1k_tokens][:input] }
448
+ candidates.sort_by { |_, specs| specs[:cost_per_token][:input] }
449
449
  when :performance
450
450
  candidates.sort_by do |_, specs|
451
- [specs[:performance_tier] == :premium ? 0 : 1, specs[:cost_per_1k_tokens][:input]]
451
+ [specs[:performance_tier] == :premium ? 0 : 1, specs[:cost_per_token][:input]]
452
452
  end
453
453
  when :latest
454
454
  candidates.sort_by { |_, specs| -(specs[:created_at] || 0).to_i }
455
455
  when :context
456
456
  candidates.sort_by { |_, specs| -(specs[:context_length] || 0).to_i }
457
457
  else
458
- candidates.sort_by { |_, specs| specs[:cost_per_1k_tokens][:input] }
458
+ candidates.sort_by { |_, specs| specs[:cost_per_token][:input] }
459
459
  end
460
460
  end
461
461
  end
@@ -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
 
@@ -110,7 +113,12 @@ module OpenRouter
110
113
  end
111
114
  end
112
115
 
113
- @structured_output ||= result
116
+ # Use a flag rather than ||= so nil results don't trigger re-parsing on every call
117
+ unless @structured_output_computed
118
+ @structured_output = result
119
+ @structured_output_computed = true
120
+ end
121
+ @structured_output
114
122
  when :gentle
115
123
  # New gentle mode: best-effort parsing, no healing, no validation
116
124
  content_to_parse = @forced_extraction ? extract_json_from_text(content) : content
@@ -177,6 +185,10 @@ module OpenRouter
177
185
  @raw_response["model"]
178
186
  end
179
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
+
180
192
  def created
181
193
  @raw_response["created"]
182
194
  end
@@ -248,124 +260,5 @@ module OpenRouter
248
260
  def error_message
249
261
  @raw_response.dig("error", "message")
250
262
  end
251
-
252
- private
253
-
254
- def parse_tool_calls
255
- tool_calls_data = choices.first&.dig("message", "tool_calls")
256
- return [] unless tool_calls_data.is_a?(Array)
257
-
258
- tool_calls_data.map { |tc| ToolCall.new(tc) }
259
- rescue StandardError => e
260
- raise ToolCallError, "Failed to parse tool calls: #{e.message}"
261
- end
262
-
263
- def raw_tool_calls
264
- choices.first&.dig("message", "tool_calls") || []
265
- end
266
-
267
- def parse_and_heal_structured_output(auto_heal: false)
268
- return nil unless structured_output_expected?
269
- return nil unless has_content?
270
-
271
- content_to_parse = @forced_extraction ? extract_json_from_text(content) : content
272
-
273
- if auto_heal && @client
274
- # For forced extraction: always send full content to provide context for healing
275
- # For normal responses: send the content as-is
276
- healing_content = if @forced_extraction
277
- content # Always send full response for better healing context
278
- else
279
- content_to_parse || content
280
- end
281
- heal_structured_response(healing_content, extract_schema_from_response_format)
282
- else
283
- return nil if content_to_parse.nil? # No JSON found in forced extraction
284
-
285
- begin
286
- JSON.parse(content_to_parse)
287
- rescue JSON::ParserError => e
288
- # For forced extraction, be more lenient and return nil on parse failures
289
- # For regular structured outputs, return nil if content looks like it contains markdown
290
- # (indicates it's not actually structured JSON output)
291
- if @forced_extraction
292
- nil
293
- elsif content_to_parse&.include?("```")
294
- # Content contains markdown blocks - this is not structured output
295
- nil
296
- else
297
- raise StructuredOutputError, "Failed to parse structured output: #{e.message}"
298
- end
299
- end
300
- end
301
- end
302
-
303
- # Extract JSON from text content (for forced structured output)
304
- def extract_json_from_text(text)
305
- return nil if text.nil? || text.empty?
306
-
307
- # First try to find JSON in code blocks
308
- if text.include?("```")
309
- # Look for ```json or ``` blocks
310
- json_match = text.match(/```(?:json)?\s*\n?(.*?)\n?```/m)
311
- if json_match
312
- candidate = json_match[1].strip
313
- return candidate unless candidate.empty?
314
- end
315
- end
316
-
317
- # Try to parse the entire text as JSON
318
- begin
319
- JSON.parse(text)
320
- return text
321
- rescue JSON::ParserError
322
- # Look for JSON-like content (starts with { or [)
323
- json_match = text.match(/(\{.*\}|\[.*\])/m)
324
- return json_match[1] if json_match
325
- end
326
-
327
- # No JSON found
328
- nil
329
- end
330
-
331
- def structured_output_expected?
332
- return false unless @response_format
333
-
334
- if @response_format.is_a?(Schema)
335
- true
336
- elsif @response_format.is_a?(Hash) && @response_format[:type] == "json_schema"
337
- true
338
- else
339
- false
340
- end
341
- end
342
-
343
- def extract_schema_from_response_format
344
- case @response_format
345
- when Schema
346
- @response_format
347
- when Hash
348
- schema_def = @response_format[:json_schema]
349
- if schema_def.is_a?(Schema)
350
- schema_def
351
- elsif schema_def.is_a?(Hash) && schema_def[:schema]
352
- # Create a temporary schema object for validation
353
- Schema.new(
354
- schema_def[:name] || "response",
355
- schema_def[:schema],
356
- strict: schema_def.key?(:strict) ? schema_def[:strict] : true
357
- )
358
- end
359
- end
360
- end
361
-
362
- # Backward compatibility method that delegates to JsonHealer
363
- def heal_structured_response(content, schema)
364
- return JSON.parse(content) unless schema
365
-
366
- healer = JsonHealer.new(@client)
367
- context = @forced_extraction ? :forced_extraction : :generic
368
- healer.heal(content, schema, context: context)
369
- end
370
263
  end
371
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