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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -0
- data/Gemfile.lock +1 -1
- data/README.md +90 -0
- data/Rakefile +0 -1
- 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/parameter_builder.rb +120 -0
- data/lib/open_router/request_handler.rb +98 -0
- data/lib/open_router/response.rb +7 -119
- 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)
|
|
@@ -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
|
|
|
@@ -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
|
-
|
|
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
|