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.
@@ -147,11 +147,18 @@ module OpenRouter
147
147
  # Client-side options (not sent to API)
148
148
  # ═══════════════════════════════════════════════════════════════════════════
149
149
 
150
- # @return [Boolean, nil] Override forced extraction mode for structured outputs
151
- # true: Force extraction via system message injection
152
- # false: Use native structured output
153
- # nil: Auto-determine based on model capability
154
- attr_accessor :force_structured_output
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
- force_structured_output: nil
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[force_structured_output extras].freeze
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
@@ -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
- attr_reader :raw_response, :response_format, :forced_extraction
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
- # Use global default mode if not specified
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
- 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)
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
@@ -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: openrouter_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