open_router_enhanced 2.1.0 → 2.2.1
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/Gemfile.lock +1 -1
- data/Rakefile +0 -1
- 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 +22 -578
- data/lib/open_router/completion_options.rb +15 -7
- data/lib/open_router/parameter_builder.rb +120 -0
- data/lib/open_router/request_handler.rb +99 -0
- data/lib/open_router/response.rb +15 -125
- data/lib/open_router/response_parsing.rb +107 -0
- data/lib/open_router/schema.rb +28 -12
- data/lib/open_router/tool_serializer.rb +152 -0
- data/lib/open_router/version.rb +1 -1
- data/lib/open_router.rb +7 -10
- metadata +7 -2
|
@@ -147,11 +147,18 @@ module OpenRouter
|
|
|
147
147
|
# Client-side options (not sent to API)
|
|
148
148
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
149
149
|
|
|
150
|
-
# @return [Boolean, nil]
|
|
151
|
-
# true:
|
|
152
|
-
#
|
|
153
|
-
# nil:
|
|
154
|
-
|
|
150
|
+
# @return [Boolean, nil] Request provider-side native json_schema enforcement.
|
|
151
|
+
# true: Send response_format: { type: "json_schema", ... } (grammar-constrained,
|
|
152
|
+
# only supported by some models/providers — can 400 on unsupported ones).
|
|
153
|
+
# false/nil (default): Send response_format: { type: "json_object" } and describe
|
|
154
|
+
# the schema in the prompt. Widely supported; validated/healed client-side.
|
|
155
|
+
attr_accessor :native
|
|
156
|
+
|
|
157
|
+
# @return [Boolean, nil] Validation strictness for structured outputs.
|
|
158
|
+
# true: Raise StructuredOutputError if the response doesn't match the schema.
|
|
159
|
+
# false/nil (default): Best-effort — return the parsed JSON as-is.
|
|
160
|
+
# When nil, falls back to configuration.structured_output_strict.
|
|
161
|
+
attr_accessor :strict
|
|
155
162
|
|
|
156
163
|
# All supported parameters with their defaults
|
|
157
164
|
DEFAULTS = {
|
|
@@ -193,11 +200,12 @@ module OpenRouter
|
|
|
193
200
|
# Responses API
|
|
194
201
|
reasoning: nil,
|
|
195
202
|
# Client-side
|
|
196
|
-
|
|
203
|
+
native: nil,
|
|
204
|
+
strict: nil
|
|
197
205
|
}.freeze
|
|
198
206
|
|
|
199
207
|
# Parameters that are client-side only (not sent to API)
|
|
200
|
-
CLIENT_SIDE_PARAMS = %i[
|
|
208
|
+
CLIENT_SIDE_PARAMS = %i[native strict extras].freeze
|
|
201
209
|
|
|
202
210
|
# Initialize with keyword arguments
|
|
203
211
|
#
|
|
@@ -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,99 @@
|
|
|
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, strict: false)
|
|
62
|
+
response = Response.new(raw_response, response_format: response_format, forced_extraction: forced_extraction,
|
|
63
|
+
strict: strict)
|
|
64
|
+
response.client = self
|
|
65
|
+
response
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def validate_vision_support(model, messages)
|
|
69
|
+
warn_if_unsupported(model, :vision, "vision/image processing") if messages_contain_images?(messages)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def warn_if_unsupported(model, capability, feature_name)
|
|
73
|
+
return if model.is_a?(Array) || model == "openrouter/auto"
|
|
74
|
+
return if ModelRegistry.has_capability?(model, capability)
|
|
75
|
+
|
|
76
|
+
if configuration.strict_mode
|
|
77
|
+
raise CapabilityError,
|
|
78
|
+
"Model '#{model}' does not support #{feature_name} (missing :#{capability} capability). Enable non-strict mode to allow this request."
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
warning_key = "#{model}:#{capability}"
|
|
82
|
+
return if @capability_warnings_shown.include?(warning_key)
|
|
83
|
+
|
|
84
|
+
warn "[OpenRouter Warning] Model '#{model}' may not support #{feature_name} (missing :#{capability} capability). The request will still be attempted."
|
|
85
|
+
@capability_warnings_shown << warning_key
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def messages_contain_images?(messages)
|
|
89
|
+
messages.any? do |msg|
|
|
90
|
+
content = msg[:content] || msg["content"]
|
|
91
|
+
if content.is_a?(Array)
|
|
92
|
+
content.any? { |part| part.is_a?(Hash) && (part[:type] == "image_url" || part["type"] == "image_url") }
|
|
93
|
+
else
|
|
94
|
+
false
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
data/lib/open_router/response.rb
CHANGED
|
@@ -2,18 +2,22 @@
|
|
|
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
|
|
10
|
-
|
|
11
|
+
include OpenRouter::ResponseParsing
|
|
12
|
+
|
|
13
|
+
attr_reader :raw_response, :response_format, :forced_extraction, :strict
|
|
11
14
|
attr_accessor :client
|
|
12
15
|
|
|
13
|
-
def initialize(raw_response, response_format: nil, forced_extraction: false)
|
|
16
|
+
def initialize(raw_response, response_format: nil, forced_extraction: false, strict: false)
|
|
14
17
|
@raw_response = raw_response.is_a?(Hash) ? raw_response.with_indifferent_access : {}
|
|
15
18
|
@response_format = response_format
|
|
16
19
|
@forced_extraction = forced_extraction
|
|
20
|
+
@strict = strict
|
|
17
21
|
@client = nil
|
|
18
22
|
end
|
|
19
23
|
|
|
@@ -77,14 +81,7 @@ module OpenRouter
|
|
|
77
81
|
|
|
78
82
|
# Structured output methods
|
|
79
83
|
def structured_output(mode: nil, auto_heal: nil)
|
|
80
|
-
|
|
81
|
-
if mode.nil?
|
|
82
|
-
mode = if @client&.configuration.respond_to?(:default_structured_output_mode)
|
|
83
|
-
@client.configuration.default_structured_output_mode || :strict
|
|
84
|
-
else
|
|
85
|
-
:strict
|
|
86
|
-
end
|
|
87
|
-
end
|
|
84
|
+
mode ||= default_structured_output_mode
|
|
88
85
|
# Validate mode parameter
|
|
89
86
|
raise ArgumentError, "Invalid mode: #{mode}. Must be :strict or :gentle." unless %i[strict gentle].include?(mode)
|
|
90
87
|
|
|
@@ -101,6 +98,10 @@ module OpenRouter
|
|
|
101
98
|
|
|
102
99
|
result = parse_and_heal_structured_output(auto_heal: should_heal)
|
|
103
100
|
|
|
101
|
+
# In the json_object path (lenient extraction) a parse failure yields nil rather
|
|
102
|
+
# than raising. Strict mode must surface that as an error rather than returning nil.
|
|
103
|
+
raise StructuredOutputError, "Failed to parse structured output from response" if result.nil?
|
|
104
|
+
|
|
104
105
|
# Only validate after parsing if healing is disabled (healing handles its own validation)
|
|
105
106
|
if result && !should_heal
|
|
106
107
|
schema_obj = extract_schema_from_response_format
|
|
@@ -256,121 +257,10 @@ module OpenRouter
|
|
|
256
257
|
|
|
257
258
|
private
|
|
258
259
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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)
|
|
260
|
+
# :strict raises on schema mismatch; :gentle returns best-effort. Driven by the
|
|
261
|
+
# `strict:` flag resolved at request time (per-call option or configured default).
|
|
262
|
+
def default_structured_output_mode
|
|
263
|
+
@strict ? :strict : :gentle
|
|
374
264
|
end
|
|
375
265
|
end
|
|
376
266
|
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
|
data/lib/open_router/schema.rb
CHANGED
|
@@ -30,24 +30,40 @@ module OpenRouter
|
|
|
30
30
|
|
|
31
31
|
# Convert to the format expected by OpenRouter API
|
|
32
32
|
def to_h
|
|
33
|
-
# Apply OpenRouter-specific transformations
|
|
34
|
-
openrouter_schema = @schema.dup
|
|
35
|
-
|
|
36
|
-
# OpenRouter/Azure requires ALL properties to be in the required array
|
|
37
|
-
# even if they are logically optional. This is a deviation from JSON Schema spec
|
|
38
|
-
# but necessary for compatibility.
|
|
39
|
-
if openrouter_schema[:properties]&.any?
|
|
40
|
-
all_properties = openrouter_schema[:properties].keys.map(&:to_s)
|
|
41
|
-
openrouter_schema[:required] = all_properties
|
|
42
|
-
end
|
|
43
|
-
|
|
44
33
|
{
|
|
45
34
|
name: @name,
|
|
46
35
|
strict: @strict,
|
|
47
|
-
schema:
|
|
36
|
+
schema: enforce_all_required(@schema)
|
|
48
37
|
}
|
|
49
38
|
end
|
|
50
39
|
|
|
40
|
+
# OpenRouter / OpenAI strict mode requires EVERY object — at every nesting
|
|
41
|
+
# level, including nested objects and array items — to list all of its
|
|
42
|
+
# properties in its `required` array. Forcing this only at the top level
|
|
43
|
+
# makes nested objects come back with `required: []`, which strict providers
|
|
44
|
+
# reject with a 400. Walk the schema and enforce it recursively.
|
|
45
|
+
#
|
|
46
|
+
# (Optional fields are expressed in strict mode by adding "null" to the
|
|
47
|
+
# property's type union, not by omitting them from `required`.)
|
|
48
|
+
def enforce_all_required(node)
|
|
49
|
+
case node
|
|
50
|
+
when Hash
|
|
51
|
+
transformed = node.each_with_object({}) { |(key, value), acc| acc[key] = enforce_all_required(value) }
|
|
52
|
+
|
|
53
|
+
props = transformed[:properties] || transformed["properties"]
|
|
54
|
+
if props.is_a?(Hash) && props.any?
|
|
55
|
+
key = transformed.key?(:properties) ? :required : "required"
|
|
56
|
+
transformed[key] = props.keys.map(&:to_s)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
transformed
|
|
60
|
+
when Array
|
|
61
|
+
node.map { |element| enforce_all_required(element) }
|
|
62
|
+
else
|
|
63
|
+
node
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
51
67
|
# Get the pure JSON Schema (respects required flags) for testing/validation
|
|
52
68
|
def pure_schema
|
|
53
69
|
@schema
|