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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -0
- data/Gemfile.lock +1 -1
- data/README.md +90 -0
- data/Rakefile +24 -14
- data/docs/superpowers/plans/2026-06-27-openrouter-routing-features.md +913 -0
- data/docs/superpowers/specs/2026-06-27-openrouter-routing-features-design.md +179 -0
- data/examples/dynamic_model_switching_example.rb +0 -0
- data/examples/model_selection_example.rb +0 -0
- data/examples/prompt_template_example.rb +0 -0
- data/examples/real_world_schemas_example.rb +0 -0
- data/examples/responses_api_example.rb +0 -0
- data/examples/smart_completion_example.rb +0 -0
- data/examples/structured_outputs_example.rb +0 -0
- data/examples/tool_calling_example.rb +0 -0
- data/examples/tool_loop_example.rb +0 -0
- data/lib/open_router/callbacks.rb +50 -0
- data/lib/open_router/client.rb +12 -576
- data/lib/open_router/json_healer.rb +1 -1
- data/lib/open_router/model_registry.rb +24 -6
- data/lib/open_router/model_selector.rb +7 -7
- data/lib/open_router/parameter_builder.rb +120 -0
- data/lib/open_router/request_handler.rb +98 -0
- data/lib/open_router/response.rb +13 -120
- data/lib/open_router/response_parsing.rb +107 -0
- data/lib/open_router/routing.rb +80 -0
- data/lib/open_router/streaming_client.rb +1 -1
- data/lib/open_router/subagent_tool.rb +51 -0
- data/lib/open_router/tool_serializer.rb +164 -0
- data/lib/open_router/version.rb +1 -1
- data/lib/open_router.rb +14 -0
- metadata +11 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
266
|
-
output_cost =
|
|
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[:
|
|
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[:
|
|
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[:
|
|
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[:
|
|
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[:
|
|
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[:
|
|
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[:
|
|
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[:
|
|
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[:
|
|
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[:
|
|
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
|
data/lib/open_router/response.rb
CHANGED
|
@@ -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
|
-
|
|
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
|