open_router_enhanced 2.2.0 → 2.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b6c9c14171242103eaeab8219521180242f9e0ee0968c739f8db2148a17423a5
4
- data.tar.gz: '0906f33ab027e8cbf17ff60ab120ec65de39ec689c6e9a48025fb8df679f7d55'
3
+ metadata.gz: '08f395125182c8bc6fdedec8b3c1057750344bc56f5988373a5a4deaaccf6585'
4
+ data.tar.gz: c98f3b9e15d8838b0fcc160c55a7b9ae0a4ade6fe05495d3663e016666c48914
5
5
  SHA512:
6
- metadata.gz: 4cd127d4d6889e281e88e3f044f2444bd32e46f7ac4797d5c786b9a3fc5a8f792baf445c8dea481ab5018fae72646a221da05266bcb4134266735546e35a428d
7
- data.tar.gz: a5b80c88d5f2228f1891409d2edb335a1a2c0294b94ffc69fb2730d0a854f7010c5eece8ac026eb6ba99a60df583634f42c77e18aada4b0399886b6a744a3488
6
+ metadata.gz: 5526e571d28a4c3d93726d67f4441c90890980c929b36938e2ed0f8137c29e0ac8773a7d438de4422119e0660522334447bab2585837333c2ae1651f79ef0cd9
7
+ data.tar.gz: 6977214b59795346e76855586eb04ff50f173e45ea6189ad1b514ad80ff5edabb1a68f433b8535977789516685eb321db6d820ca7fe18564821c9cd392a9ecf7
data/CHANGELOG.md CHANGED
@@ -1,22 +1,15 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [2.2.0] - 2026-06-28
3
+ ## [2.3.0] - 2026-07-08
4
4
 
5
- ### Added
6
-
7
- - **`Routing` mixin** (`OpenRouter::Routing`) included in `Client`, providing two new meta-routing methods:
8
- - `pareto_complete(messages, min_coding_score: nil, **opts)` — routes to the cheapest model meeting a configurable quality bar via OpenRouter's Pareto Code Router (`openrouter/pareto-code`). `min_coding_score` is validated to `0.0–1.0`.
9
- - `fuse(messages, analysis_models: nil, judge: nil, preset: nil, max_tool_calls: nil, **opts)` — fans a prompt out to a panel of models and synthesises one answer via OpenRouter's Fusion router (`openrouter/fusion`). `analysis_models` (1–8) and `max_tool_calls` (1–16) are validated.
10
- - **`SubagentTool`** (`OpenRouter::SubagentTool`) — wraps OpenRouter's `openrouter:subagent` server tool so an orchestrator model can delegate self-contained subtasks to a cheaper worker model mid-generation. Constructor: `model:` (required worker model) plus optional `instructions:`, `max_completion_tokens:`, `temperature:`, and `reasoning:`. Pass it via the normal `tools:` array to `complete`.
11
- - **`Response#selected_model`** — alias for `#model`; returns the concrete model OpenRouter resolved for routing responses (e.g. Pareto, Auto, Fusion).
12
-
13
- ### Changed
5
+ ### Fixed
14
6
 
15
- - Capability warning / strict-mode guards now exempt all `openrouter/`-prefixed meta-models (previously only `openrouter/auto` was exempt); this prevents spurious warnings or `CapabilityError` when using `pareto_complete` or `fuse` with tools or structured outputs.
7
+ - **Structured output schemas now honor optional keys.** `Schema#to_h` previously forced *every* property — at every nesting level — into the `required` array, so fields declared with `required: false` (the DSL default) were silently made mandatory in the prompt, the native `json_schema` payload, and the healer. `to_h` now emits the schema honestly, respecting the `required` arrays exactly as declared. Optional fields stay optional and the model may omit them.
8
+ - `to_h` and local validation now agree, so what is described to the model matches what is accepted.
16
9
 
17
- ### Notes
10
+ ### Added
18
11
 
19
- - These three OpenRouter platform features are still evolving server-side. The gem builds and validates the requests; routing/synthesis/delegation behaviour is performed by OpenRouter. Fusion fans out to every panel model plus a judge, so it costs roughly 4–5× a single completion. `pareto_complete` may resolve to a reasoning model that consumes a small `max_tokens` budget entirely on reasoning (returning `nil` content with `finish_reason: "length"`) budget `max_tokens` accordingly.
12
+ - `Schema#to_strict_h` the provider-strict form for native `json_schema` decoding. It still lists every property in `required` (so strict providers don't `400` on nested `required: []`), but expresses optionality the correct way: fields not declared required are made **nullable** (their type gains `"null"`) rather than mandatory. The native serialization path uses this form automatically for strict schemas.
20
13
 
21
14
  ## [2.0.0] - 2025-12-28
22
15
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- open_router_enhanced (2.2.0)
4
+ open_router_enhanced (2.3.0)
5
5
  activesupport (>= 6.0, < 9.0)
6
6
  dotenv (>= 2.0, < 4.0)
7
7
  faraday (>= 1.0, < 3.0)
data/README.md CHANGED
@@ -45,7 +45,6 @@ The [OpenRouter API](https://openrouter.ai/docs) is a single unified interface f
45
45
  - [Tool Calling](#tool-calling)
46
46
  - [Structured Outputs](#structured-outputs)
47
47
  - [Smart Model Selection](#smart-model-selection)
48
- - [Routing (Pareto & Fusion)](#routing-pareto--fusion)
49
48
  - [Prompt Templates](#prompt-templates)
50
49
  - [Streaming](#streaming)
51
50
  - [Usage Tracking](#usage-tracking)
@@ -384,95 +383,6 @@ models = OpenRouter::ModelSelector.new
384
383
 
385
384
  **[Complete Model Selection Documentation](docs/model_selection.md)**
386
385
 
387
- ### Routing (Pareto & Fusion)
388
-
389
- OpenRouter offers two meta-routing modes that automatically pick or synthesize answers across models.
390
-
391
- #### Pareto Code Router
392
-
393
- Routes each request to the cheapest model that meets a configurable quality bar — useful when you want cost-optimised code completions without picking a specific model.
394
-
395
- ```ruby
396
- # Cheapest model meeting default quality threshold
397
- response = client.pareto_complete([
398
- { role: "user", content: "Write a binary search in Ruby" }
399
- ])
400
-
401
- # Require a higher quality bar (0.0–1.0, higher = better)
402
- response = client.pareto_complete(
403
- [{ role: "user", content: "Implement a red-black tree" }],
404
- min_coding_score: 0.8,
405
- max_tokens: 1000
406
- )
407
-
408
- # Which model actually answered?
409
- puts response.selected_model # => "anthropic/claude-3.5-haiku"
410
- puts response.content
411
- ```
412
-
413
- #### Fusion Router
414
-
415
- Fans a prompt out to a panel of models in parallel, then synthesises one answer with a judge model. Costs roughly 4–5× a single completion but can outperform any individual model.
416
-
417
- ```ruby
418
- # Default panel (OpenRouter chooses)
419
- response = client.fuse([
420
- { role: "user", content: "What is the best approach to distributed consensus?" }
421
- ])
422
-
423
- # Custom panel + explicit judge
424
- response = client.fuse(
425
- [{ role: "user", content: "Review this architecture" }],
426
- analysis_models: [
427
- "anthropic/claude-3.5-sonnet",
428
- "openai/gpt-4o",
429
- "google/gemini-2.0-flash-001"
430
- ],
431
- judge: "anthropic/claude-opus-4-5",
432
- max_tokens: 2000
433
- )
434
-
435
- # Curated preset panels
436
- response = client.fuse(messages, preset: "general-budget")
437
-
438
- # selected_model reports the synthesis/judge model that produced the answer,
439
- # e.g. "anthropic/claude-opus-4-5" — not the "openrouter/fusion" router alias.
440
- puts response.selected_model
441
- puts response.content
442
- ```
443
-
444
- > **Note:** Fusion fans out to every panel model plus a judge, so it costs roughly 4–5× a single completion. `min_coding_score` for Pareto is validated to `0.0–1.0`; `analysis_models` (1–8) and `max_tool_calls` (1–16) for Fusion are validated client-side.
445
-
446
- #### `SubagentTool`
447
-
448
- Wraps OpenRouter's built-in `openrouter:subagent` server tool so an LLM can spawn its own sub-completions during a tool-calling loop.
449
-
450
- ```ruby
451
- subagent = OpenRouter::SubagentTool.new(
452
- model: "anthropic/claude-3.5-haiku", # required: the cheaper worker model
453
- instructions: "Complete the task exactly as described. Be concise.", # optional
454
- max_completion_tokens: 512 # optional (also: temperature:, reasoning:)
455
- )
456
-
457
- response = client.complete(
458
- [{ role: "user", content: "Summarize the attached changelog into release notes." }],
459
- model: "openai/gpt-4o",
460
- tools: [subagent],
461
- tool_choice: "auto"
462
- )
463
- ```
464
-
465
- > The orchestrator decides whether to delegate. The gem's job is to build and send a valid `openrouter:subagent` tool; OpenRouter runs the worker server-side and feeds its result back into the orchestrator's generation.
466
-
467
- #### `Response#selected_model`
468
-
469
- All routing methods (`complete`, `pareto_complete`, `fuse`) return a `Response` object. Use `#selected_model` (alias for `#model`) to see which model OpenRouter ultimately used:
470
-
471
- ```ruby
472
- response = client.pareto_complete(messages)
473
- puts response.selected_model # e.g. "mistralai/codestral-2501"
474
- ```
475
-
476
386
  ### Prompt Templates
477
387
 
478
388
  Create reusable, parameterized prompts with variable interpolation.
@@ -43,7 +43,7 @@ module OpenRouter
43
43
  @callbacks[event].each do |callback|
44
44
  callback.call(data)
45
45
  rescue StandardError => e
46
- OpenRouter.log_warning("[OpenRouter] Callback error for #{event}: #{e.message}")
46
+ warn "[OpenRouter] Callback error for #{event}: #{e.message}"
47
47
  end
48
48
  end
49
49
  end
@@ -8,7 +8,6 @@ require_relative "callbacks"
8
8
  require_relative "parameter_builder"
9
9
  require_relative "tool_serializer"
10
10
  require_relative "request_handler"
11
- require_relative "routing"
12
11
 
13
12
  module OpenRouter
14
13
  class ServerError < StandardError; end
@@ -19,7 +18,6 @@ module OpenRouter
19
18
  include OpenRouter::ParameterBuilder
20
19
  include OpenRouter::ToolSerializer
21
20
  include OpenRouter::RequestHandler
22
- include OpenRouter::Routing
23
21
 
24
22
  attr_reader :callbacks, :usage_tracker, :configuration
25
23
 
@@ -60,7 +58,10 @@ module OpenRouter
60
58
  opts = normalize_options(options, kwargs)
61
59
  parameters = prepare_base_parameters(messages, opts, stream)
62
60
  forced_extraction = configure_tools_and_structured_outputs!(parameters, opts)
63
- configure_plugins!(parameters, opts.response_format, stream)
61
+ # Gate the response-healing plugin on what's actually on the wire — it requires
62
+ # response_format to be present, so keying off opts.response_format (intent) would
63
+ # attach it even when no response_format was sent and produce a 400.
64
+ configure_plugins!(parameters, parameters[:response_format], stream)
64
65
  validate_vision_support(opts.model, messages)
65
66
 
66
67
  trigger_callbacks(:before_request, parameters)
@@ -68,7 +69,7 @@ module OpenRouter
68
69
  raw_response = execute_request(parameters)
69
70
  validate_response!(raw_response, stream)
70
71
 
71
- response = build_response(raw_response, opts.response_format, forced_extraction)
72
+ response = build_response(raw_response, opts.response_format, forced_extraction, strict: resolve_strict(opts.strict))
72
73
 
73
74
  model_for_tracking = opts.model.is_a?(String) ? opts.model : opts.model.first
74
75
  @usage_tracker&.track(response, model: model_for_tracking)
@@ -195,6 +196,13 @@ module OpenRouter
195
196
 
196
197
  private
197
198
 
199
+ # Per-call `strict:` wins; otherwise fall back to the configured default.
200
+ def resolve_strict(strict)
201
+ return strict unless strict.nil?
202
+
203
+ configuration.structured_output_strict
204
+ end
205
+
198
206
  def normalize_options(options, kwargs)
199
207
  case options
200
208
  when CompletionOptions
@@ -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
  #
@@ -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
- OpenRouter.log_warning("[OpenRouter Warning] JSON healing request failed: #{e.message}")
136
+ warn "[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)
@@ -58,8 +58,9 @@ module OpenRouter
58
58
  raise ServerError, "Empty response from OpenRouter. Might be worth retrying once or twice."
59
59
  end
60
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)
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)
63
64
  response.client = self
64
65
  response
65
66
  end
@@ -69,7 +70,7 @@ module OpenRouter
69
70
  end
70
71
 
71
72
  def warn_if_unsupported(model, capability, feature_name)
72
- return if model.is_a?(Array) || model.to_s.start_with?("openrouter/")
73
+ return if model.is_a?(Array) || model == "openrouter/auto"
73
74
  return if ModelRegistry.has_capability?(model, capability)
74
75
 
75
76
  if configuration.strict_mode
@@ -80,7 +81,7 @@ module OpenRouter
80
81
  warning_key = "#{model}:#{capability}"
81
82
  return if @capability_warnings_shown.include?(warning_key)
82
83
 
83
- OpenRouter.log_warning("[OpenRouter Warning] Model '#{model}' may not support #{feature_name} (missing :#{capability} capability). The request will still be attempted.")
84
+ warn "[OpenRouter Warning] Model '#{model}' may not support #{feature_name} (missing :#{capability} capability). The request will still be attempted."
84
85
  @capability_warnings_shown << warning_key
85
86
  end
86
87
 
@@ -10,13 +10,14 @@ module OpenRouter
10
10
  class Response
11
11
  include OpenRouter::ResponseParsing
12
12
 
13
- attr_reader :raw_response, :response_format, :forced_extraction
13
+ attr_reader :raw_response, :response_format, :forced_extraction, :strict
14
14
  attr_accessor :client
15
15
 
16
- def initialize(raw_response, response_format: nil, forced_extraction: false)
16
+ def initialize(raw_response, response_format: nil, forced_extraction: false, strict: false)
17
17
  @raw_response = raw_response.is_a?(Hash) ? raw_response.with_indifferent_access : {}
18
18
  @response_format = response_format
19
19
  @forced_extraction = forced_extraction
20
+ @strict = strict
20
21
  @client = nil
21
22
  end
22
23
 
@@ -80,14 +81,7 @@ module OpenRouter
80
81
 
81
82
  # Structured output methods
82
83
  def structured_output(mode: nil, auto_heal: nil)
83
- # Use global default mode if not specified
84
- if mode.nil?
85
- mode = if @client&.configuration.respond_to?(:default_structured_output_mode)
86
- @client.configuration.default_structured_output_mode || :strict
87
- else
88
- :strict
89
- end
90
- end
84
+ mode ||= default_structured_output_mode
91
85
  # Validate mode parameter
92
86
  raise ArgumentError, "Invalid mode: #{mode}. Must be :strict or :gentle." unless %i[strict gentle].include?(mode)
93
87
 
@@ -104,6 +98,10 @@ module OpenRouter
104
98
 
105
99
  result = parse_and_heal_structured_output(auto_heal: should_heal)
106
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
+
107
105
  # Only validate after parsing if healing is disabled (healing handles its own validation)
108
106
  if result && !should_heal
109
107
  schema_obj = extract_schema_from_response_format
@@ -185,10 +183,6 @@ module OpenRouter
185
183
  @raw_response["model"]
186
184
  end
187
185
 
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
-
192
186
  def created
193
187
  @raw_response["created"]
194
188
  end
@@ -260,5 +254,13 @@ module OpenRouter
260
254
  def error_message
261
255
  @raw_response.dig("error", "message")
262
256
  end
257
+
258
+ private
259
+
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
264
+ end
263
265
  end
264
266
  end
@@ -28,23 +28,36 @@ module OpenRouter
28
28
  new(name, builder.to_h, strict:)
29
29
  end
30
30
 
31
- # Convert to the format expected by OpenRouter API
31
+ # Convert to the format expected by OpenRouter API.
32
+ #
33
+ # This is the *honest* representation: it respects the `required` arrays
34
+ # exactly as declared, so fields you left optional stay optional. It is what
35
+ # we describe to the model (prompt injection), what we validate against, and
36
+ # what we hand the healer — keeping "what we ask for" and "what we accept" in
37
+ # agreement. The provider-strict, all-required form lives in #to_strict_h and
38
+ # is used only when serializing a native `json_schema` request.
32
39
  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
40
+ {
41
+ name: @name,
42
+ strict: @strict,
43
+ schema: @schema
44
+ }
45
+ end
43
46
 
47
+ # Provider-strict form for native `json_schema` decoding.
48
+ #
49
+ # OpenRouter / OpenAI strict mode requires EVERY object — at every nesting
50
+ # level, including nested objects and array items — to list all of its
51
+ # properties in its `required` array; a nested `required: []` gets a 400.
52
+ # We satisfy that WITHOUT silently making optional fields mandatory: any
53
+ # property that was not declared required is made nullable (its type gains
54
+ # "null"), which is exactly how strict mode expresses optionality. The model
55
+ # may then return `null` for it instead of being forced to invent a value.
56
+ def to_strict_h
44
57
  {
45
58
  name: @name,
46
59
  strict: @strict,
47
- schema: openrouter_schema
60
+ schema: enforce_all_required(@schema)
48
61
  }
49
62
  end
50
63
 
@@ -121,6 +134,51 @@ module OpenRouter
121
134
 
122
135
  private
123
136
 
137
+ # Recursively force every object to list all its properties in `required`,
138
+ # keeping declared-optional properties optional by making them nullable.
139
+ # Reads each node's OWN existing `required` before overwriting, so nested
140
+ # optionality is preserved at every level.
141
+ def enforce_all_required(node)
142
+ case node
143
+ when Hash
144
+ transformed = node.each_with_object({}) { |(key, value), acc| acc[key] = enforce_all_required(value) }
145
+
146
+ props = transformed[:properties] || transformed["properties"]
147
+ if props.is_a?(Hash) && props.any?
148
+ key = transformed.key?(:properties) ? :required : "required"
149
+ already_required = Array(transformed[key]).map(&:to_s)
150
+ props.each do |prop_name, prop_def|
151
+ next if already_required.include?(prop_name.to_s)
152
+
153
+ props[prop_name] = make_nullable(prop_def)
154
+ end
155
+ transformed[key] = props.keys.map(&:to_s)
156
+ end
157
+
158
+ transformed
159
+ when Array
160
+ node.map { |element| enforce_all_required(element) }
161
+ else
162
+ node
163
+ end
164
+ end
165
+
166
+ # Add "null" to a property's type union so it can be omitted (as null) under
167
+ # strict mode. Leaves the property untouched if it has no determinable type.
168
+ def make_nullable(prop_def)
169
+ return prop_def unless prop_def.is_a?(Hash)
170
+
171
+ type_key = if prop_def.key?(:type) then :type
172
+ elsif prop_def.key?("type") then "type"
173
+ end
174
+ return prop_def unless type_key
175
+
176
+ types = Array(prop_def[type_key]).map(&:to_s)
177
+ return prop_def if types.include?("null")
178
+
179
+ prop_def.merge(type_key => types + ["null"])
180
+ end
181
+
124
182
  def validate_schema!
125
183
  raise ArgumentError, "Schema name is required" if @name.nil? || @name.empty?
126
184
  raise ArgumentError, "Schema must be a hash" unless @schema.is_a?(Hash)
@@ -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
- OpenRouter.log_warning("[OpenRouter] Streaming callback error for #{event}: #{e.message}")
138
+ warn "[OpenRouter] Streaming callback error for #{event}: #{e.message}"
139
139
  end
140
140
  end
141
141
 
@@ -20,43 +20,35 @@ module OpenRouter
20
20
  parameters[:tool_choice] = opts.tool_choice if opts.tool_choice
21
21
  end
22
22
 
23
- # Returns forced_extraction boolean
23
+ # Configure the structured-output request.
24
+ #
25
+ # Default (native: false): ask the provider for a plain JSON object — the most
26
+ # widely supported response_format across models/providers — and describe the
27
+ # schema in the prompt. The model's capability registry never gates the request.
28
+ #
29
+ # Opt-in (native: true): send response_format: { type: "json_schema", ... } for
30
+ # grammar-constrained decoding. Only some models/providers support this; it can
31
+ # 400 on the rest, which is why it is explicit rather than auto-detected.
32
+ #
33
+ # Returns the lenient-extraction flag (true when the schema was injected into the
34
+ # prompt, so the response may need JSON extracted from surrounding text).
24
35
  def configure_structured_outputs!(parameters, opts)
25
36
  return false unless opts.response_format?
26
37
 
27
- force_extraction = determine_forced_extraction_mode(opts.model, opts.force_structured_output)
38
+ schema = extract_schema(opts.response_format)
28
39
 
29
- if force_extraction
30
- handle_forced_structured_output!(parameters, opts.model, opts.response_format)
31
- true
32
- else
33
- handle_native_structured_output!(parameters, opts.model, opts.response_format)
34
- false
40
+ if opts.native && schema
41
+ warn_if_unsupported(opts.model, :structured_outputs, "structured outputs")
42
+ parameters[:response_format] = serialize_response_format(opts.response_format)
43
+ return false
35
44
  end
36
- end
37
-
38
- def determine_forced_extraction_mode(model, force_structured_output)
39
- return force_structured_output unless force_structured_output.nil?
40
-
41
- if model.is_a?(String) &&
42
- !model.start_with?("openrouter/") &&
43
- !ModelRegistry.has_capability?(model, :structured_outputs) &&
44
- configuration.auto_force_on_unsupported_models
45
- OpenRouter.log_warning("[OpenRouter] Model '#{model}' doesn't support native structured outputs. Automatically using forced extraction mode.")
46
- true
47
- else
48
- false
49
- end
50
- end
51
45
 
52
- def handle_forced_structured_output!(parameters, model, response_format)
53
- warn_if_unsupported(model, :structured_outputs, "structured outputs") if configuration.strict_mode
54
- inject_schema_instructions!(parameters[:messages], response_format)
55
- end
46
+ # Default json_object path.
47
+ parameters[:response_format] = { type: "json_object" }
48
+ return false unless schema
56
49
 
57
- def handle_native_structured_output!(parameters, model, response_format)
58
- warn_if_unsupported(model, :structured_outputs, "structured outputs")
59
- parameters[:response_format] = serialize_response_format(response_format)
50
+ inject_schema_instructions!(parameters[:messages], schema)
51
+ true
60
52
  end
61
53
 
62
54
  # Serialize tools to Chat Completions API format: { type: "function", function: { name:, parameters: } }
@@ -102,19 +94,25 @@ module OpenRouter
102
94
  case response_format
103
95
  when Hash
104
96
  if response_format[:json_schema].is_a?(Schema)
105
- response_format.merge(json_schema: response_format[:json_schema].to_h)
97
+ response_format.merge(json_schema: native_schema_hash(response_format[:json_schema]))
106
98
  else
107
99
  response_format
108
100
  end
109
101
  when Schema
110
- { type: "json_schema", json_schema: response_format.to_h }
102
+ { type: "json_schema", json_schema: native_schema_hash(response_format) }
111
103
  else
112
104
  response_format
113
105
  end
114
106
  end
115
107
 
116
- def inject_schema_instructions!(messages, response_format)
117
- schema = extract_schema(response_format)
108
+ # Provider-side json_schema decoding requires every property in `required`.
109
+ # For strict schemas we emit the all-required-with-nullable-optionals form so
110
+ # the request doesn't 400; non-strict schemas are sent honestly.
111
+ def native_schema_hash(schema)
112
+ schema.strict ? schema.to_strict_h : schema.to_h
113
+ end
114
+
115
+ def inject_schema_instructions!(messages, schema)
118
116
  return unless schema
119
117
 
120
118
  instruction_content = if schema.respond_to?(:get_format_instructions)
@@ -126,18 +124,15 @@ module OpenRouter
126
124
  messages << { role: "system", content: instruction_content }
127
125
  end
128
126
 
127
+ # Pull the schema out of a response_format. Returns nil for a plain
128
+ # { type: "json_object" } directive (no schema to describe or validate).
129
129
  def extract_schema(response_format)
130
130
  case response_format
131
131
  when Schema
132
132
  response_format
133
133
  when Hash
134
- if response_format[:json_schema].is_a?(Schema)
135
- response_format[:json_schema]
136
- elsif response_format[:json_schema].is_a?(Hash)
137
- response_format[:json_schema]
138
- else
139
- response_format
140
- end
134
+ json_schema = response_format[:json_schema] || response_format["json_schema"]
135
+ json_schema if json_schema.is_a?(Schema) || json_schema.is_a?(Hash)
141
136
  end
142
137
  end
143
138
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenRouter
4
- VERSION = "2.2.0"
4
+ VERSION = "2.3.0"
5
5
  end