open_router_enhanced 2.2.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/CHANGELOG.md +0 -18
- 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 +28 -12
- data/lib/open_router/streaming_client.rb +1 -1
- data/lib/open_router/tool_serializer.rb +27 -39
- 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: 9f4936ab4591643976d7cf5f63b92fec61fef1fda2900ce6ed022ce35671bd3a
|
|
4
|
+
data.tar.gz: 1ac1e6ea82c9eacc1bc28e4b99490507bcf1f335446f3ab1cbab2a2ca8d749b4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ef3a651dee0fec1b4d0508e88578c78ed1d259b5dc7c5fb5917d7af0690a2e69c12732f6fc5a0b423340da7afddbc4de80f08f8a27e616c9d7c15a1051508a39
|
|
7
|
+
data.tar.gz: 017ec8a29aeb205e396265409c8f9d53f3f7280d025543e89b2c95510d6b21fe78f230461dcc5291a433c1b60f850425ba754dcfc1ab32e98517b1f522688692
|
data/CHANGELOG.md
CHANGED
|
@@ -1,23 +1,5 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
-
## [2.2.0] - 2026-06-28
|
|
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
|
|
14
|
-
|
|
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.
|
|
16
|
-
|
|
17
|
-
### Notes
|
|
18
|
-
|
|
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.
|
|
20
|
-
|
|
21
3
|
## [2.0.0] - 2025-12-28
|
|
22
4
|
|
|
23
5
|
### Overview
|
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
|
@@ -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
|
|
@@ -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
|
|
35
|
-
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
|
|
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
|
|
49
44
|
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: } }
|
|
@@ -113,8 +105,7 @@ module OpenRouter
|
|
|
113
105
|
end
|
|
114
106
|
end
|
|
115
107
|
|
|
116
|
-
def inject_schema_instructions!(messages,
|
|
117
|
-
schema = extract_schema(response_format)
|
|
108
|
+
def inject_schema_instructions!(messages, schema)
|
|
118
109
|
return unless schema
|
|
119
110
|
|
|
120
111
|
instruction_content = if schema.respond_to?(:get_format_instructions)
|
|
@@ -126,18 +117,15 @@ module OpenRouter
|
|
|
126
117
|
messages << { role: "system", content: instruction_content }
|
|
127
118
|
end
|
|
128
119
|
|
|
120
|
+
# Pull the schema out of a response_format. Returns nil for a plain
|
|
121
|
+
# { type: "json_object" } directive (no schema to describe or validate).
|
|
129
122
|
def extract_schema(response_format)
|
|
130
123
|
case response_format
|
|
131
124
|
when Schema
|
|
132
125
|
response_format
|
|
133
126
|
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
|
|
127
|
+
json_schema = response_format[:json_schema] || response_format["json_schema"]
|
|
128
|
+
json_schema if json_schema.is_a?(Schema) || json_schema.is_a?(Hash)
|
|
141
129
|
end
|
|
142
130
|
end
|
|
143
131
|
|
data/lib/open_router/version.rb
CHANGED
data/lib/open_router.rb
CHANGED
|
@@ -19,7 +19,6 @@ end
|
|
|
19
19
|
require_relative "open_router/http"
|
|
20
20
|
require_relative "open_router/completion_options"
|
|
21
21
|
require_relative "open_router/tool"
|
|
22
|
-
require_relative "open_router/subagent_tool"
|
|
23
22
|
require_relative "open_router/tool_call_base"
|
|
24
23
|
require_relative "open_router/tool_call"
|
|
25
24
|
require_relative "open_router/schema"
|
|
@@ -31,7 +30,6 @@ require_relative "open_router/model_registry"
|
|
|
31
30
|
require_relative "open_router/model_selector"
|
|
32
31
|
require_relative "open_router/prompt_template"
|
|
33
32
|
require_relative "open_router/usage_tracker"
|
|
34
|
-
require_relative "open_router/routing"
|
|
35
33
|
require_relative "open_router/client"
|
|
36
34
|
require_relative "open_router/streaming_client"
|
|
37
35
|
require_relative "open_router/version"
|
|
@@ -56,15 +54,11 @@ module OpenRouter
|
|
|
56
54
|
# Capability validation configuration
|
|
57
55
|
attr_accessor :strict_mode
|
|
58
56
|
|
|
59
|
-
#
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
#
|
|
63
|
-
attr_accessor :
|
|
64
|
-
|
|
65
|
-
# Optional logger. When set, gem warnings are routed through it (e.g. Rails.logger).
|
|
66
|
-
# When nil (default), warnings go to Kernel.warn → $stderr.
|
|
67
|
-
attr_accessor :logger
|
|
57
|
+
# Default validation strictness for structured outputs.
|
|
58
|
+
# false (default): best-effort, return parsed JSON as-is.
|
|
59
|
+
# true: raise StructuredOutputError when the response doesn't match the schema.
|
|
60
|
+
# Overridden per-call by the `strict:` option.
|
|
61
|
+
attr_accessor :structured_output_strict
|
|
68
62
|
|
|
69
63
|
DEFAULT_API_VERSION = "v1"
|
|
70
64
|
DEFAULT_REQUEST_TIMEOUT = 120
|
|
@@ -99,11 +93,8 @@ module OpenRouter
|
|
|
99
93
|
# Capability validation defaults
|
|
100
94
|
self.strict_mode = ENV.fetch("OPENROUTER_STRICT_MODE", "false").downcase == "true"
|
|
101
95
|
|
|
102
|
-
#
|
|
103
|
-
self.
|
|
104
|
-
|
|
105
|
-
# Default structured output mode
|
|
106
|
-
self.default_structured_output_mode = ENV.fetch("OPENROUTER_DEFAULT_MODE", "strict").to_sym
|
|
96
|
+
# Default structured output validation strictness (loose/best-effort by default)
|
|
97
|
+
self.structured_output_strict = ENV.fetch("OPENROUTER_STRUCTURED_STRICT", "false").downcase == "true"
|
|
107
98
|
end
|
|
108
99
|
|
|
109
100
|
def access_token
|
|
@@ -136,12 +127,4 @@ module OpenRouter
|
|
|
136
127
|
def self.configure
|
|
137
128
|
yield(configuration)
|
|
138
129
|
end
|
|
139
|
-
|
|
140
|
-
def self.log_warning(message)
|
|
141
|
-
if configuration.logger
|
|
142
|
-
configuration.logger.warn(message)
|
|
143
|
-
else
|
|
144
|
-
Kernel.warn(message)
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
130
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: open_router_enhanced
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.2.
|
|
4
|
+
version: 2.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eric Stiens
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-07-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -134,8 +134,6 @@ files:
|
|
|
134
134
|
- docs/responses_api.md
|
|
135
135
|
- docs/streaming.md
|
|
136
136
|
- docs/structured_outputs.md
|
|
137
|
-
- docs/superpowers/plans/2026-06-27-openrouter-routing-features.md
|
|
138
|
-
- docs/superpowers/specs/2026-06-27-openrouter-routing-features-design.md
|
|
139
137
|
- docs/tools.md
|
|
140
138
|
- examples/basic_completion.rb
|
|
141
139
|
- examples/dynamic_model_switching_example.rb
|
|
@@ -164,10 +162,8 @@ files:
|
|
|
164
162
|
- lib/open_router/response_parsing.rb
|
|
165
163
|
- lib/open_router/responses_response.rb
|
|
166
164
|
- lib/open_router/responses_tool_call.rb
|
|
167
|
-
- lib/open_router/routing.rb
|
|
168
165
|
- lib/open_router/schema.rb
|
|
169
166
|
- lib/open_router/streaming_client.rb
|
|
170
|
-
- lib/open_router/subagent_tool.rb
|
|
171
167
|
- lib/open_router/tool.rb
|
|
172
168
|
- lib/open_router/tool_call.rb
|
|
173
169
|
- lib/open_router/tool_call_base.rb
|