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 +4 -4
- data/CHANGELOG.md +6 -13
- data/Gemfile.lock +1 -1
- data/README.md +0 -90
- data/lib/open_router/callbacks.rb +1 -1
- data/lib/open_router/client.rb +12 -4
- data/lib/open_router/completion_options.rb +15 -7
- data/lib/open_router/json_healer.rb +1 -1
- data/lib/open_router/request_handler.rb +5 -4
- data/lib/open_router/response.rb +16 -14
- data/lib/open_router/schema.rb +70 -12
- data/lib/open_router/streaming_client.rb +1 -1
- data/lib/open_router/tool_serializer.rb +36 -41
- data/lib/open_router/version.rb +1 -1
- data/lib/open_router.rb +7 -24
- metadata +2 -6
- data/docs/superpowers/plans/2026-06-27-openrouter-routing-features.md +0 -913
- data/docs/superpowers/specs/2026-06-27-openrouter-routing-features-design.md +0 -179
- data/lib/open_router/routing.rb +0 -80
- data/lib/open_router/subagent_tool.rb +0 -51
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '08f395125182c8bc6fdedec8b3c1057750344bc56f5988373a5a4deaaccf6585'
|
|
4
|
+
data.tar.gz: c98f3b9e15d8838b0fcc160c55a7b9ae0a4ade6fe05495d3663e016666c48914
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5526e571d28a4c3d93726d67f4441c90890980c929b36938e2ed0f8137c29e0ac8773a7d438de4422119e0660522334447bab2585837333c2ae1651f79ef0cd9
|
|
7
|
+
data.tar.gz: 6977214b59795346e76855586eb04ff50f173e45ea6189ad1b514ad80ff5edabb1a68f433b8535977789516685eb321db6d820ca7fe18564821c9cd392a9ecf7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,22 +1,15 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
-
## [2.
|
|
3
|
+
## [2.3.0] - 2026-07-08
|
|
4
4
|
|
|
5
|
-
###
|
|
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
|
-
-
|
|
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
|
-
###
|
|
10
|
+
### Added
|
|
18
11
|
|
|
19
|
-
-
|
|
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
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
|
-
|
|
46
|
+
warn "[OpenRouter] Callback error for #{event}: #{e.message}"
|
|
47
47
|
end
|
|
48
48
|
end
|
|
49
49
|
end
|
data/lib/open_router/client.rb
CHANGED
|
@@ -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
|
-
|
|
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]
|
|
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
|
#
|
|
@@ -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
|
+
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
|
|
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
|
-
|
|
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
|
|
data/lib/open_router/response.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/open_router/schema.rb
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
38
|
+
schema = extract_schema(opts.response_format)
|
|
28
39
|
|
|
29
|
-
if
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
end
|
|
46
|
+
# Default json_object path.
|
|
47
|
+
parameters[:response_format] = { type: "json_object" }
|
|
48
|
+
return false unless schema
|
|
56
49
|
|
|
57
|
-
|
|
58
|
-
|
|
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]
|
|
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
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
|
data/lib/open_router/version.rb
CHANGED