open_router_enhanced 2.1.0 → 2.2.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 +18 -0
- data/Gemfile.lock +1 -1
- data/README.md +90 -0
- data/Rakefile +0 -1
- data/docs/superpowers/plans/2026-06-27-openrouter-routing-features.md +913 -0
- data/docs/superpowers/specs/2026-06-27-openrouter-routing-features-design.md +179 -0
- data/examples/dynamic_model_switching_example.rb +0 -0
- data/examples/model_selection_example.rb +0 -0
- data/examples/prompt_template_example.rb +0 -0
- data/examples/real_world_schemas_example.rb +0 -0
- data/examples/responses_api_example.rb +0 -0
- data/examples/smart_completion_example.rb +0 -0
- data/examples/structured_outputs_example.rb +0 -0
- data/examples/tool_calling_example.rb +0 -0
- data/examples/tool_loop_example.rb +0 -0
- data/lib/open_router/callbacks.rb +50 -0
- data/lib/open_router/client.rb +12 -576
- data/lib/open_router/json_healer.rb +1 -1
- data/lib/open_router/parameter_builder.rb +120 -0
- data/lib/open_router/request_handler.rb +98 -0
- data/lib/open_router/response.rb +7 -119
- data/lib/open_router/response_parsing.rb +107 -0
- data/lib/open_router/routing.rb +80 -0
- data/lib/open_router/streaming_client.rb +1 -1
- data/lib/open_router/subagent_tool.rb +51 -0
- data/lib/open_router/tool_serializer.rb +164 -0
- data/lib/open_router/version.rb +1 -1
- data/lib/open_router.rb +14 -0
- metadata +11 -2
data/lib/open_router/client.rb
CHANGED
|
@@ -4,17 +4,25 @@ require "active_support/core_ext/object/blank"
|
|
|
4
4
|
require "active_support/core_ext/hash/indifferent_access"
|
|
5
5
|
|
|
6
6
|
require_relative "http"
|
|
7
|
+
require_relative "callbacks"
|
|
8
|
+
require_relative "parameter_builder"
|
|
9
|
+
require_relative "tool_serializer"
|
|
10
|
+
require_relative "request_handler"
|
|
11
|
+
require_relative "routing"
|
|
7
12
|
|
|
8
13
|
module OpenRouter
|
|
9
14
|
class ServerError < StandardError; end
|
|
10
15
|
|
|
11
|
-
# rubocop:disable Metrics/ClassLength
|
|
12
16
|
class Client
|
|
13
17
|
include OpenRouter::HTTP
|
|
18
|
+
include OpenRouter::Callbacks
|
|
19
|
+
include OpenRouter::ParameterBuilder
|
|
20
|
+
include OpenRouter::ToolSerializer
|
|
21
|
+
include OpenRouter::RequestHandler
|
|
22
|
+
include OpenRouter::Routing
|
|
14
23
|
|
|
15
24
|
attr_reader :callbacks, :usage_tracker, :configuration
|
|
16
25
|
|
|
17
|
-
# Initializes the client with optional configurations.
|
|
18
26
|
def initialize(access_token: nil, request_timeout: nil, uri_base: nil, extra_headers: {}, track_usage: true)
|
|
19
27
|
# Build a per-instance configuration to avoid mutating the global singleton,
|
|
20
28
|
# which would cause credential leakage across Client instances in concurrent use.
|
|
@@ -26,10 +34,8 @@ module OpenRouter
|
|
|
26
34
|
@configuration.extra_headers = @configuration.extra_headers.merge(extra_headers) if extra_headers.any?
|
|
27
35
|
yield(@configuration) if block_given?
|
|
28
36
|
|
|
29
|
-
# Instance-level tracking of capability warnings to avoid memory leaks
|
|
30
37
|
@capability_warnings_shown = Set.new
|
|
31
38
|
|
|
32
|
-
# Initialize callback system
|
|
33
39
|
@callbacks = {
|
|
34
40
|
before_request: [],
|
|
35
41
|
after_response: [],
|
|
@@ -39,57 +45,10 @@ module OpenRouter
|
|
|
39
45
|
on_healing: []
|
|
40
46
|
}
|
|
41
47
|
|
|
42
|
-
# Initialize usage tracking
|
|
43
48
|
@track_usage = track_usage
|
|
44
49
|
@usage_tracker = UsageTracker.new if @track_usage
|
|
45
50
|
end
|
|
46
51
|
|
|
47
|
-
# Register a callback for a specific event
|
|
48
|
-
#
|
|
49
|
-
# @param event [Symbol] The event to register for (:before_request, :after_response, :on_tool_call, :on_error, :on_stream_chunk, :on_healing)
|
|
50
|
-
# @param block [Proc] The callback to execute
|
|
51
|
-
# @return [self] Returns self for method chaining
|
|
52
|
-
#
|
|
53
|
-
# @example
|
|
54
|
-
# client.on(:after_response) do |response|
|
|
55
|
-
# puts "Used #{response.total_tokens} tokens"
|
|
56
|
-
# end
|
|
57
|
-
def on(event, &block)
|
|
58
|
-
unless @callbacks.key?(event)
|
|
59
|
-
raise ArgumentError, "Invalid event: #{event}. Valid events are: #{@callbacks.keys.join(", ")}"
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
@callbacks[event] << block
|
|
63
|
-
self
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# Remove all callbacks for a specific event
|
|
67
|
-
#
|
|
68
|
-
# @param event [Symbol] The event to clear callbacks for
|
|
69
|
-
# @return [self] Returns self for method chaining
|
|
70
|
-
def clear_callbacks(event = nil)
|
|
71
|
-
if event
|
|
72
|
-
@callbacks[event] = [] if @callbacks.key?(event)
|
|
73
|
-
else
|
|
74
|
-
@callbacks.each_key { |key| @callbacks[key] = [] }
|
|
75
|
-
end
|
|
76
|
-
self
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
# Trigger callbacks for a specific event
|
|
80
|
-
#
|
|
81
|
-
# @param event [Symbol] The event to trigger
|
|
82
|
-
# @param data [Object] Data to pass to the callbacks
|
|
83
|
-
def trigger_callbacks(event, data = nil)
|
|
84
|
-
return unless @callbacks[event]
|
|
85
|
-
|
|
86
|
-
@callbacks[event].each do |callback|
|
|
87
|
-
callback.call(data)
|
|
88
|
-
rescue StandardError => e
|
|
89
|
-
warn "[OpenRouter] Callback error for #{event}: #{e.message}"
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
|
|
93
52
|
# Performs a chat completion request to the OpenRouter API.
|
|
94
53
|
#
|
|
95
54
|
# @param messages [Array<Hash>] Array of message hashes with role and content
|
|
@@ -97,19 +56,6 @@ module OpenRouter
|
|
|
97
56
|
# @param stream [Proc, nil] Optional callable object for streaming
|
|
98
57
|
# @param kwargs [Hash] Additional options (merged with options parameter)
|
|
99
58
|
# @return [Response] The completion response wrapped in a Response object
|
|
100
|
-
#
|
|
101
|
-
# @example Simple usage (unchanged)
|
|
102
|
-
# client.complete(messages, model: "gpt-4")
|
|
103
|
-
#
|
|
104
|
-
# @example With CompletionOptions
|
|
105
|
-
# opts = CompletionOptions.new(model: "gpt-4", temperature: 0.7, tools: my_tools)
|
|
106
|
-
# client.complete(messages, opts)
|
|
107
|
-
#
|
|
108
|
-
# @example Hash options
|
|
109
|
-
# client.complete(messages, { model: "gpt-4", temperature: 0.7 })
|
|
110
|
-
#
|
|
111
|
-
# @example Options with override
|
|
112
|
-
# client.complete(messages, base_opts, temperature: 0.9)
|
|
113
59
|
def complete(messages, options = nil, stream: nil, **kwargs)
|
|
114
60
|
opts = normalize_options(options, kwargs)
|
|
115
61
|
parameters = prepare_base_parameters(messages, opts, stream)
|
|
@@ -117,7 +63,6 @@ module OpenRouter
|
|
|
117
63
|
configure_plugins!(parameters, opts.response_format, stream)
|
|
118
64
|
validate_vision_support(opts.model, messages)
|
|
119
65
|
|
|
120
|
-
# Trigger before_request callbacks
|
|
121
66
|
trigger_callbacks(:before_request, parameters)
|
|
122
67
|
|
|
123
68
|
raw_response = execute_request(parameters)
|
|
@@ -125,60 +70,35 @@ module OpenRouter
|
|
|
125
70
|
|
|
126
71
|
response = build_response(raw_response, opts.response_format, forced_extraction)
|
|
127
72
|
|
|
128
|
-
# Track usage if enabled
|
|
129
73
|
model_for_tracking = opts.model.is_a?(String) ? opts.model : opts.model.first
|
|
130
74
|
@usage_tracker&.track(response, model: model_for_tracking)
|
|
131
75
|
|
|
132
|
-
# Trigger after_response callbacks
|
|
133
76
|
trigger_callbacks(:after_response, response)
|
|
134
|
-
|
|
135
|
-
# Trigger on_tool_call callbacks if tool calls are present
|
|
136
77
|
trigger_callbacks(:on_tool_call, response.tool_calls) if response.has_tool_calls?
|
|
137
78
|
|
|
138
79
|
response
|
|
139
80
|
end
|
|
140
81
|
|
|
141
82
|
# Fetches the list of available models from the OpenRouter API.
|
|
142
|
-
# @return [Array<Hash>] The list of models.
|
|
143
83
|
def models
|
|
144
84
|
get(path: "/models")["data"]
|
|
145
85
|
end
|
|
146
86
|
|
|
147
87
|
# Queries the generation stats for a given id.
|
|
148
|
-
# @param generation_id [String] The generation id returned from a previous request.
|
|
149
|
-
# @return [Hash] The stats including token counts and cost.
|
|
150
88
|
def query_generation_stats(generation_id)
|
|
151
89
|
response = get(path: "/generation?id=#{generation_id}")
|
|
152
90
|
response["data"]
|
|
153
91
|
end
|
|
154
92
|
|
|
155
93
|
# Performs a request to the Responses API Beta (/api/v1/responses)
|
|
156
|
-
# This is an OpenAI-compatible stateless API with support for reasoning.
|
|
157
94
|
#
|
|
158
95
|
# @param input [String, Array] The input text or structured message array
|
|
159
96
|
# @param options [CompletionOptions, Hash, nil] Options object or hash with configuration
|
|
160
97
|
# @param kwargs [Hash] Additional options (merged with options parameter)
|
|
161
98
|
# @return [ResponsesResponse] The response wrapped in a ResponsesResponse object
|
|
162
|
-
#
|
|
163
|
-
# @example Basic usage
|
|
164
|
-
# response = client.responses("What is 2+2?", model: "openai/o4-mini")
|
|
165
|
-
# puts response.content
|
|
166
|
-
#
|
|
167
|
-
# @example With reasoning using CompletionOptions
|
|
168
|
-
# opts = CompletionOptions.new(
|
|
169
|
-
# model: "openai/o4-mini",
|
|
170
|
-
# reasoning: { effort: "high" }
|
|
171
|
-
# )
|
|
172
|
-
# response = client.responses("Solve this step by step: What is 15% of 80?", opts)
|
|
173
|
-
# puts response.reasoning_summary
|
|
174
|
-
# puts response.content
|
|
175
|
-
#
|
|
176
|
-
# @example With kwargs (still works)
|
|
177
|
-
# response = client.responses("Question", model: "openai/o4-mini", reasoning: { effort: "high" })
|
|
178
99
|
def responses(input, options = nil, **kwargs)
|
|
179
100
|
opts = normalize_options(options, kwargs)
|
|
180
101
|
|
|
181
|
-
# Model is required for Responses API
|
|
182
102
|
if opts.model == "openrouter/auto"
|
|
183
103
|
raise ArgumentError, "model is required for responses API (cannot use default 'openrouter/auto')"
|
|
184
104
|
end
|
|
@@ -187,7 +107,6 @@ module OpenRouter
|
|
|
187
107
|
parameters[:reasoning] = opts.reasoning if opts.reasoning
|
|
188
108
|
parameters[:tools] = serialize_tools_for_responses(opts.tools) if opts.tools?
|
|
189
109
|
parameters[:tool_choice] = opts.tool_choice if opts.tool_choice
|
|
190
|
-
# Prefer max_completion_tokens over max_tokens (consistent with complete() method)
|
|
191
110
|
parameters[:max_output_tokens] = opts.max_completion_tokens || opts.max_tokens if opts.max_completion_tokens || opts.max_tokens
|
|
192
111
|
parameters[:temperature] = opts.temperature if opts.temperature
|
|
193
112
|
parameters[:top_p] = opts.top_p if opts.top_p
|
|
@@ -198,34 +117,14 @@ module OpenRouter
|
|
|
198
117
|
end
|
|
199
118
|
|
|
200
119
|
# Create a new ModelSelector for intelligent model selection
|
|
201
|
-
#
|
|
202
|
-
# @return [ModelSelector] A new ModelSelector instance
|
|
203
|
-
# @example
|
|
204
|
-
# client = OpenRouter::Client.new
|
|
205
|
-
# model = client.select_model.optimize_for(:cost).require(:function_calling).choose
|
|
206
120
|
def select_model
|
|
207
121
|
ModelSelector.new
|
|
208
122
|
end
|
|
209
123
|
|
|
210
124
|
# Smart completion that automatically selects the best model based on requirements
|
|
211
|
-
#
|
|
212
|
-
# @param messages [Array<Hash>] Array of message hashes
|
|
213
|
-
# @param requirements [Hash] Model selection requirements
|
|
214
|
-
# @param optimization [Symbol] Optimization strategy (:cost, :performance, :latest, :context)
|
|
215
|
-
# @param extras [Hash] Additional parameters for the completion request
|
|
216
|
-
# @return [Response] The completion response
|
|
217
|
-
# @raise [ModelSelectionError] If no suitable model is found
|
|
218
|
-
#
|
|
219
|
-
# @example
|
|
220
|
-
# response = client.smart_complete(
|
|
221
|
-
# messages: [{ role: "user", content: "Analyze this data" }],
|
|
222
|
-
# requirements: { capabilities: [:function_calling], max_input_cost: 0.01 },
|
|
223
|
-
# optimization: :cost
|
|
224
|
-
# )
|
|
225
125
|
def smart_complete(messages, requirements: {}, optimization: :cost, **extras)
|
|
226
126
|
selector = ModelSelector.new.optimize_for(optimization)
|
|
227
127
|
|
|
228
|
-
# Apply requirements using fluent interface
|
|
229
128
|
selector = selector.require(*requirements[:capabilities]) if requirements[:capabilities]
|
|
230
129
|
|
|
231
130
|
if requirements[:max_cost] || requirements[:max_input_cost]
|
|
@@ -241,43 +140,23 @@ module OpenRouter
|
|
|
241
140
|
case requirements[:providers]
|
|
242
141
|
when Hash
|
|
243
142
|
selector = selector.prefer_providers(*requirements[:providers][:prefer]) if requirements[:providers][:prefer]
|
|
244
|
-
if requirements[:providers][:require]
|
|
245
|
-
selector = selector.require_providers(*requirements[:providers][:require])
|
|
246
|
-
end
|
|
143
|
+
selector = selector.require_providers(*requirements[:providers][:require]) if requirements[:providers][:require]
|
|
247
144
|
selector = selector.avoid_providers(*requirements[:providers][:avoid]) if requirements[:providers][:avoid]
|
|
248
145
|
when Array
|
|
249
146
|
selector = selector.prefer_providers(*requirements[:providers])
|
|
250
147
|
end
|
|
251
148
|
end
|
|
252
149
|
|
|
253
|
-
# Select the best model
|
|
254
150
|
model = selector.choose
|
|
255
151
|
raise ModelSelectionError, "No model found matching requirements: #{requirements}" unless model
|
|
256
152
|
|
|
257
|
-
# Perform the completion with the selected model
|
|
258
153
|
complete(messages, model:, **extras)
|
|
259
154
|
end
|
|
260
155
|
|
|
261
156
|
# Smart completion with automatic fallback to alternative models
|
|
262
|
-
#
|
|
263
|
-
# @param messages [Array<Hash>] Array of message hashes
|
|
264
|
-
# @param requirements [Hash] Model selection requirements
|
|
265
|
-
# @param optimization [Symbol] Optimization strategy
|
|
266
|
-
# @param max_retries [Integer] Maximum number of fallback attempts
|
|
267
|
-
# @param extras [Hash] Additional parameters for the completion request
|
|
268
|
-
# @return [Response] The completion response
|
|
269
|
-
# @raise [ModelSelectionError] If all fallback attempts fail
|
|
270
|
-
#
|
|
271
|
-
# @example
|
|
272
|
-
# response = client.smart_complete_with_fallback(
|
|
273
|
-
# messages: [{ role: "user", content: "Hello" }],
|
|
274
|
-
# requirements: { capabilities: [:function_calling] },
|
|
275
|
-
# max_retries: 3
|
|
276
|
-
# )
|
|
277
157
|
def smart_complete_with_fallback(messages, requirements: {}, optimization: :cost, max_retries: 3, **extras)
|
|
278
158
|
selector = ModelSelector.new.optimize_for(optimization)
|
|
279
159
|
|
|
280
|
-
# Apply requirements (same logic as smart_complete)
|
|
281
160
|
selector = selector.require(*requirements[:capabilities]) if requirements[:capabilities]
|
|
282
161
|
|
|
283
162
|
if requirements[:max_cost] || requirements[:max_input_cost]
|
|
@@ -293,16 +172,13 @@ module OpenRouter
|
|
|
293
172
|
case requirements[:providers]
|
|
294
173
|
when Hash
|
|
295
174
|
selector = selector.prefer_providers(*requirements[:providers][:prefer]) if requirements[:providers][:prefer]
|
|
296
|
-
if requirements[:providers][:require]
|
|
297
|
-
selector = selector.require_providers(*requirements[:providers][:require])
|
|
298
|
-
end
|
|
175
|
+
selector = selector.require_providers(*requirements[:providers][:require]) if requirements[:providers][:require]
|
|
299
176
|
selector = selector.avoid_providers(*requirements[:providers][:avoid]) if requirements[:providers][:avoid]
|
|
300
177
|
when Array
|
|
301
178
|
selector = selector.prefer_providers(*requirements[:providers])
|
|
302
179
|
end
|
|
303
180
|
end
|
|
304
181
|
|
|
305
|
-
# Get fallback models
|
|
306
182
|
fallback_models = selector.choose_with_fallbacks(limit: max_retries + 1)
|
|
307
183
|
raise ModelSelectionError, "No models found matching requirements: #{requirements}" if fallback_models.empty?
|
|
308
184
|
|
|
@@ -312,26 +188,18 @@ module OpenRouter
|
|
|
312
188
|
return complete(messages, model:, **extras)
|
|
313
189
|
rescue StandardError => e
|
|
314
190
|
last_error = e
|
|
315
|
-
# Continue to next model in fallback list
|
|
316
191
|
end
|
|
317
192
|
|
|
318
|
-
# If we get here, all models failed
|
|
319
193
|
raise ModelSelectionError, "All fallback models failed. Last error: #{last_error&.message}"
|
|
320
194
|
end
|
|
321
195
|
|
|
322
196
|
private
|
|
323
197
|
|
|
324
|
-
# Normalize options from various input formats into CompletionOptions
|
|
325
|
-
#
|
|
326
|
-
# @param options [CompletionOptions, Hash, nil] Options object or hash
|
|
327
|
-
# @param kwargs [Hash] Additional keyword arguments
|
|
328
|
-
# @return [CompletionOptions] Normalized options object
|
|
329
198
|
def normalize_options(options, kwargs)
|
|
330
199
|
case options
|
|
331
200
|
when CompletionOptions
|
|
332
201
|
kwargs.empty? ? options : options.merge(**kwargs)
|
|
333
202
|
when Hash
|
|
334
|
-
# Symbolize keys to handle both string and symbol key hashes
|
|
335
203
|
symbolized = options.transform_keys(&:to_sym)
|
|
336
204
|
CompletionOptions.new(**symbolized.merge(kwargs))
|
|
337
205
|
when nil
|
|
@@ -340,437 +208,5 @@ module OpenRouter
|
|
|
340
208
|
raise ArgumentError, "options must be CompletionOptions, Hash, or nil"
|
|
341
209
|
end
|
|
342
210
|
end
|
|
343
|
-
|
|
344
|
-
# Prepare the base parameters for the API request
|
|
345
|
-
#
|
|
346
|
-
# @param messages [Array<Hash>] Array of message hashes
|
|
347
|
-
# @param opts [CompletionOptions] Normalized options object
|
|
348
|
-
# @param stream [Proc, nil] Optional streaming handler
|
|
349
|
-
# @return [Hash] Parameters hash for the API request
|
|
350
|
-
def prepare_base_parameters(messages, opts, stream)
|
|
351
|
-
parameters = { messages: messages.dup }
|
|
352
|
-
|
|
353
|
-
configure_model_parameter!(parameters, opts.model)
|
|
354
|
-
configure_provider_parameter!(parameters, opts)
|
|
355
|
-
configure_transforms_parameter!(parameters, opts.transforms)
|
|
356
|
-
configure_plugins_parameter!(parameters, opts.plugins)
|
|
357
|
-
configure_prediction_parameter!(parameters, opts.prediction)
|
|
358
|
-
configure_stream_parameter!(parameters, stream)
|
|
359
|
-
configure_sampling_parameters!(parameters, opts)
|
|
360
|
-
configure_output_parameters!(parameters, opts)
|
|
361
|
-
configure_routing_parameters!(parameters, opts)
|
|
362
|
-
|
|
363
|
-
# Merge any extras last (allows overriding anything)
|
|
364
|
-
parameters.merge!(opts.extras || {})
|
|
365
|
-
parameters
|
|
366
|
-
end
|
|
367
|
-
|
|
368
|
-
# Configure the model parameter (single model or fallback array)
|
|
369
|
-
def configure_model_parameter!(parameters, model)
|
|
370
|
-
if model.is_a?(String)
|
|
371
|
-
parameters[:model] = model
|
|
372
|
-
elsif model.is_a?(Array)
|
|
373
|
-
parameters[:models] = model
|
|
374
|
-
parameters[:route] = "fallback"
|
|
375
|
-
end
|
|
376
|
-
end
|
|
377
|
-
|
|
378
|
-
# Configure the provider parameter from options
|
|
379
|
-
#
|
|
380
|
-
# @param parameters [Hash] Request parameters hash
|
|
381
|
-
# @param opts [CompletionOptions] Options object
|
|
382
|
-
def configure_provider_parameter!(parameters, opts)
|
|
383
|
-
# Full provider config takes precedence over simple providers array
|
|
384
|
-
if opts.provider && !opts.provider.empty?
|
|
385
|
-
parameters[:provider] = opts.provider
|
|
386
|
-
elsif opts.providers.any?
|
|
387
|
-
parameters[:provider] = { order: opts.providers }
|
|
388
|
-
end
|
|
389
|
-
|
|
390
|
-
# Route parameter for fallback models
|
|
391
|
-
parameters[:route] = opts.route if opts.route
|
|
392
|
-
end
|
|
393
|
-
|
|
394
|
-
# Configure sampling parameters (temperature, top_p, etc.)
|
|
395
|
-
#
|
|
396
|
-
# @param parameters [Hash] Request parameters hash
|
|
397
|
-
# @param opts [CompletionOptions] Options object
|
|
398
|
-
def configure_sampling_parameters!(parameters, opts)
|
|
399
|
-
parameters[:temperature] = opts.temperature if opts.temperature
|
|
400
|
-
parameters[:top_p] = opts.top_p if opts.top_p
|
|
401
|
-
parameters[:top_k] = opts.top_k if opts.top_k
|
|
402
|
-
parameters[:frequency_penalty] = opts.frequency_penalty if opts.frequency_penalty
|
|
403
|
-
parameters[:presence_penalty] = opts.presence_penalty if opts.presence_penalty
|
|
404
|
-
parameters[:repetition_penalty] = opts.repetition_penalty if opts.repetition_penalty
|
|
405
|
-
parameters[:min_p] = opts.min_p if opts.min_p
|
|
406
|
-
parameters[:top_a] = opts.top_a if opts.top_a
|
|
407
|
-
parameters[:seed] = opts.seed if opts.seed
|
|
408
|
-
end
|
|
409
|
-
|
|
410
|
-
# Configure output control parameters
|
|
411
|
-
#
|
|
412
|
-
# @param parameters [Hash] Request parameters hash
|
|
413
|
-
# @param opts [CompletionOptions] Options object
|
|
414
|
-
def configure_output_parameters!(parameters, opts)
|
|
415
|
-
# Prefer max_completion_tokens over max_tokens if both are set
|
|
416
|
-
if opts.max_completion_tokens
|
|
417
|
-
parameters[:max_completion_tokens] = opts.max_completion_tokens
|
|
418
|
-
elsif opts.max_tokens
|
|
419
|
-
parameters[:max_tokens] = opts.max_tokens
|
|
420
|
-
end
|
|
421
|
-
|
|
422
|
-
parameters[:stop] = opts.stop if opts.stop
|
|
423
|
-
parameters[:logprobs] = opts.logprobs unless opts.logprobs.nil?
|
|
424
|
-
parameters[:top_logprobs] = opts.top_logprobs if opts.top_logprobs
|
|
425
|
-
parameters[:logit_bias] = opts.logit_bias if opts.logit_bias && !opts.logit_bias.empty?
|
|
426
|
-
parameters[:parallel_tool_calls] = opts.parallel_tool_calls unless opts.parallel_tool_calls.nil?
|
|
427
|
-
parameters[:verbosity] = opts.verbosity if opts.verbosity
|
|
428
|
-
end
|
|
429
|
-
|
|
430
|
-
# Configure OpenRouter-specific routing parameters
|
|
431
|
-
#
|
|
432
|
-
# @param parameters [Hash] Request parameters hash
|
|
433
|
-
# @param opts [CompletionOptions] Options object
|
|
434
|
-
def configure_routing_parameters!(parameters, opts)
|
|
435
|
-
parameters[:metadata] = opts.metadata if opts.metadata && !opts.metadata.empty?
|
|
436
|
-
parameters[:user] = opts.user if opts.user
|
|
437
|
-
parameters[:session_id] = opts.session_id if opts.session_id
|
|
438
|
-
end
|
|
439
|
-
|
|
440
|
-
# Configure the transforms parameter if transforms are specified
|
|
441
|
-
def configure_transforms_parameter!(parameters, transforms)
|
|
442
|
-
parameters[:transforms] = transforms if transforms.any?
|
|
443
|
-
end
|
|
444
|
-
|
|
445
|
-
# Configure the plugins parameter if plugins are specified
|
|
446
|
-
def configure_plugins_parameter!(parameters, plugins)
|
|
447
|
-
parameters[:plugins] = plugins.dup if plugins.any?
|
|
448
|
-
end
|
|
449
|
-
|
|
450
|
-
# Configure the prediction parameter for latency optimization
|
|
451
|
-
def configure_prediction_parameter!(parameters, prediction)
|
|
452
|
-
parameters[:prediction] = prediction if prediction
|
|
453
|
-
end
|
|
454
|
-
|
|
455
|
-
# Configure the stream parameter if streaming is enabled
|
|
456
|
-
def configure_stream_parameter!(parameters, stream)
|
|
457
|
-
parameters[:stream] = stream if stream
|
|
458
|
-
end
|
|
459
|
-
|
|
460
|
-
# Auto-add response-healing plugin when using structured outputs (non-streaming only)
|
|
461
|
-
# This leverages OpenRouter's native JSON healing for better reliability
|
|
462
|
-
def configure_plugins!(parameters, response_format, stream)
|
|
463
|
-
return unless should_auto_add_healing?(response_format, stream)
|
|
464
|
-
|
|
465
|
-
parameters[:plugins] ||= []
|
|
466
|
-
|
|
467
|
-
# Don't duplicate if user already specified response-healing
|
|
468
|
-
return if parameters[:plugins].any? { |p| p[:id] == "response-healing" || p["id"] == "response-healing" }
|
|
469
|
-
|
|
470
|
-
parameters[:plugins] << { id: "response-healing" }
|
|
471
|
-
end
|
|
472
|
-
|
|
473
|
-
# Determine if we should auto-add the response-healing plugin
|
|
474
|
-
def should_auto_add_healing?(response_format, stream)
|
|
475
|
-
return false unless configuration.auto_native_healing
|
|
476
|
-
return false if stream # Response healing doesn't work with streaming
|
|
477
|
-
return false unless response_format
|
|
478
|
-
|
|
479
|
-
# Check if response_format is a structured output type
|
|
480
|
-
case response_format
|
|
481
|
-
when OpenRouter::Schema
|
|
482
|
-
true
|
|
483
|
-
when Hash
|
|
484
|
-
type = response_format[:type] || response_format["type"]
|
|
485
|
-
%w[json_schema json_object].include?(type.to_s)
|
|
486
|
-
else
|
|
487
|
-
false
|
|
488
|
-
end
|
|
489
|
-
end
|
|
490
|
-
|
|
491
|
-
# Configure tools and structured outputs, returning forced_extraction flag
|
|
492
|
-
#
|
|
493
|
-
# @param parameters [Hash] Request parameters hash
|
|
494
|
-
# @param opts [CompletionOptions] Options object
|
|
495
|
-
# @return [Boolean] Whether forced extraction mode is being used
|
|
496
|
-
def configure_tools_and_structured_outputs!(parameters, opts)
|
|
497
|
-
configure_tool_calling!(parameters, opts)
|
|
498
|
-
configure_structured_outputs!(parameters, opts)
|
|
499
|
-
end
|
|
500
|
-
|
|
501
|
-
# Configure tool calling support
|
|
502
|
-
#
|
|
503
|
-
# @param parameters [Hash] Request parameters hash
|
|
504
|
-
# @param opts [CompletionOptions] Options object
|
|
505
|
-
def configure_tool_calling!(parameters, opts)
|
|
506
|
-
return unless opts.tools?
|
|
507
|
-
|
|
508
|
-
warn_if_unsupported(opts.model, :function_calling, "tool calling")
|
|
509
|
-
parameters[:tools] = serialize_tools(opts.tools)
|
|
510
|
-
parameters[:tool_choice] = opts.tool_choice if opts.tool_choice
|
|
511
|
-
end
|
|
512
|
-
|
|
513
|
-
# Configure structured output support and return forced_extraction flag
|
|
514
|
-
#
|
|
515
|
-
# @param parameters [Hash] Request parameters hash
|
|
516
|
-
# @param opts [CompletionOptions] Options object
|
|
517
|
-
# @return [Boolean] Whether forced extraction mode is being used
|
|
518
|
-
def configure_structured_outputs!(parameters, opts)
|
|
519
|
-
return false unless opts.response_format?
|
|
520
|
-
|
|
521
|
-
force_extraction = determine_forced_extraction_mode(opts.model, opts.force_structured_output)
|
|
522
|
-
|
|
523
|
-
if force_extraction
|
|
524
|
-
handle_forced_structured_output!(parameters, opts.model, opts.response_format)
|
|
525
|
-
true
|
|
526
|
-
else
|
|
527
|
-
handle_native_structured_output!(parameters, opts.model, opts.response_format)
|
|
528
|
-
false
|
|
529
|
-
end
|
|
530
|
-
end
|
|
531
|
-
|
|
532
|
-
# Determine whether to use forced extraction mode
|
|
533
|
-
def determine_forced_extraction_mode(model, force_structured_output)
|
|
534
|
-
return force_structured_output unless force_structured_output.nil?
|
|
535
|
-
|
|
536
|
-
if model.is_a?(String) &&
|
|
537
|
-
model != "openrouter/auto" &&
|
|
538
|
-
!ModelRegistry.has_capability?(model, :structured_outputs) &&
|
|
539
|
-
configuration.auto_force_on_unsupported_models
|
|
540
|
-
warn "[OpenRouter] Model '#{model}' doesn't support native structured outputs. Automatically using forced extraction mode."
|
|
541
|
-
true
|
|
542
|
-
else
|
|
543
|
-
false
|
|
544
|
-
end
|
|
545
|
-
end
|
|
546
|
-
|
|
547
|
-
# Handle forced structured output mode
|
|
548
|
-
def handle_forced_structured_output!(parameters, model, response_format)
|
|
549
|
-
# In strict mode, still validate to ensure user is aware of capability limits
|
|
550
|
-
warn_if_unsupported(model, :structured_outputs, "structured outputs") if configuration.strict_mode
|
|
551
|
-
inject_schema_instructions!(parameters[:messages], response_format)
|
|
552
|
-
end
|
|
553
|
-
|
|
554
|
-
# Handle native structured output mode
|
|
555
|
-
def handle_native_structured_output!(parameters, model, response_format)
|
|
556
|
-
warn_if_unsupported(model, :structured_outputs, "structured outputs")
|
|
557
|
-
parameters[:response_format] = serialize_response_format(response_format)
|
|
558
|
-
end
|
|
559
|
-
|
|
560
|
-
# Validate vision support if messages contain images
|
|
561
|
-
def validate_vision_support(model, messages)
|
|
562
|
-
warn_if_unsupported(model, :vision, "vision/image processing") if messages_contain_images?(messages)
|
|
563
|
-
end
|
|
564
|
-
|
|
565
|
-
# Execute the HTTP request with comprehensive error handling
|
|
566
|
-
def execute_request(parameters)
|
|
567
|
-
post(path: "/chat/completions", parameters: parameters)
|
|
568
|
-
rescue ConfigurationError => e
|
|
569
|
-
trigger_callbacks(:on_error, e)
|
|
570
|
-
raise ServerError, e.message
|
|
571
|
-
rescue Faraday::Error => e
|
|
572
|
-
trigger_callbacks(:on_error, e)
|
|
573
|
-
handle_faraday_error(e)
|
|
574
|
-
end
|
|
575
|
-
|
|
576
|
-
# Handle Faraday errors with specific error message extraction
|
|
577
|
-
def handle_faraday_error(error)
|
|
578
|
-
case error
|
|
579
|
-
when Faraday::UnauthorizedError
|
|
580
|
-
raise error
|
|
581
|
-
when Faraday::BadRequestError
|
|
582
|
-
error_message = extract_error_message(error)
|
|
583
|
-
raise ServerError, "Bad Request: #{error_message}"
|
|
584
|
-
when Faraday::ServerError
|
|
585
|
-
raise ServerError, "Server Error: #{error.message}"
|
|
586
|
-
else
|
|
587
|
-
raise ServerError, "Network Error: #{error.message}"
|
|
588
|
-
end
|
|
589
|
-
end
|
|
590
|
-
|
|
591
|
-
# Extract error message from Faraday error response
|
|
592
|
-
def extract_error_message(error)
|
|
593
|
-
return error.message unless error.response&.dig(:body)
|
|
594
|
-
|
|
595
|
-
body = error.response[:body]
|
|
596
|
-
|
|
597
|
-
if body.is_a?(Hash)
|
|
598
|
-
body.dig("error", "message") || error.message
|
|
599
|
-
elsif body.is_a?(String)
|
|
600
|
-
extract_error_from_json_string(body) || error.message
|
|
601
|
-
else
|
|
602
|
-
error.message
|
|
603
|
-
end
|
|
604
|
-
end
|
|
605
|
-
|
|
606
|
-
# Extract error message from JSON string response
|
|
607
|
-
def extract_error_from_json_string(json_string)
|
|
608
|
-
parsed_body = JSON.parse(json_string)
|
|
609
|
-
parsed_body.dig("error", "message")
|
|
610
|
-
rescue JSON::ParserError
|
|
611
|
-
nil
|
|
612
|
-
end
|
|
613
|
-
|
|
614
|
-
# Validate the API response for errors
|
|
615
|
-
def validate_response!(raw_response, stream)
|
|
616
|
-
raise ServerError, raw_response.dig("error", "message") if raw_response.presence&.dig("error", "message").present?
|
|
617
|
-
|
|
618
|
-
return unless stream.blank? && raw_response.blank?
|
|
619
|
-
|
|
620
|
-
raise ServerError, "Empty response from OpenRouter. Might be worth retrying once or twice."
|
|
621
|
-
end
|
|
622
|
-
|
|
623
|
-
# Build and configure the Response object
|
|
624
|
-
def build_response(raw_response, response_format, forced_extraction)
|
|
625
|
-
response = Response.new(raw_response, response_format: response_format, forced_extraction: forced_extraction)
|
|
626
|
-
response.client = self
|
|
627
|
-
response
|
|
628
|
-
end
|
|
629
|
-
|
|
630
|
-
# Warn if a model is being used with an unsupported capability
|
|
631
|
-
def warn_if_unsupported(model, capability, feature_name)
|
|
632
|
-
# Skip warnings for array models (fallbacks) or auto-selection
|
|
633
|
-
return if model.is_a?(Array) || model == "openrouter/auto"
|
|
634
|
-
|
|
635
|
-
return if ModelRegistry.has_capability?(model, capability)
|
|
636
|
-
|
|
637
|
-
if configuration.strict_mode
|
|
638
|
-
raise CapabilityError,
|
|
639
|
-
"Model '#{model}' does not support #{feature_name} (missing :#{capability} capability). Enable non-strict mode to allow this request."
|
|
640
|
-
end
|
|
641
|
-
|
|
642
|
-
warning_key = "#{model}:#{capability}"
|
|
643
|
-
return if @capability_warnings_shown.include?(warning_key)
|
|
644
|
-
|
|
645
|
-
warn "[OpenRouter Warning] Model '#{model}' may not support #{feature_name} (missing :#{capability} capability). The request will still be attempted."
|
|
646
|
-
@capability_warnings_shown << warning_key
|
|
647
|
-
end
|
|
648
|
-
|
|
649
|
-
# Check if messages contain image content
|
|
650
|
-
def messages_contain_images?(messages)
|
|
651
|
-
messages.any? do |msg|
|
|
652
|
-
content = msg[:content] || msg["content"]
|
|
653
|
-
if content.is_a?(Array)
|
|
654
|
-
content.any? { |part| part.is_a?(Hash) && (part[:type] == "image_url" || part["type"] == "image_url") }
|
|
655
|
-
else
|
|
656
|
-
false
|
|
657
|
-
end
|
|
658
|
-
end
|
|
659
|
-
end
|
|
660
|
-
|
|
661
|
-
# Serialize tools to the format expected by OpenRouter Chat Completions API
|
|
662
|
-
# Format: { type: "function", function: { name: ..., parameters: ... } }
|
|
663
|
-
def serialize_tools(tools)
|
|
664
|
-
tools.map do |tool|
|
|
665
|
-
case tool
|
|
666
|
-
when Tool
|
|
667
|
-
tool.to_h
|
|
668
|
-
when Hash
|
|
669
|
-
tool
|
|
670
|
-
else
|
|
671
|
-
raise ArgumentError, "Tools must be Tool objects or hashes"
|
|
672
|
-
end
|
|
673
|
-
end
|
|
674
|
-
end
|
|
675
|
-
|
|
676
|
-
# Serialize tools to the flat format expected by Responses API
|
|
677
|
-
# Format: { type: "function", name: ..., parameters: ... }
|
|
678
|
-
def serialize_tools_for_responses(tools)
|
|
679
|
-
tools.map do |tool|
|
|
680
|
-
tool_hash = case tool
|
|
681
|
-
when Tool
|
|
682
|
-
tool.to_h
|
|
683
|
-
when Hash
|
|
684
|
-
tool.transform_keys(&:to_sym)
|
|
685
|
-
else
|
|
686
|
-
raise ArgumentError, "Tools must be Tool objects or hashes"
|
|
687
|
-
end
|
|
688
|
-
|
|
689
|
-
# Flatten the nested function structure if present
|
|
690
|
-
if tool_hash[:function]
|
|
691
|
-
{
|
|
692
|
-
type: "function",
|
|
693
|
-
name: tool_hash[:function][:name],
|
|
694
|
-
description: tool_hash[:function][:description],
|
|
695
|
-
parameters: tool_hash[:function][:parameters]
|
|
696
|
-
}.compact
|
|
697
|
-
else
|
|
698
|
-
# Already in flat format
|
|
699
|
-
tool_hash
|
|
700
|
-
end
|
|
701
|
-
end
|
|
702
|
-
end
|
|
703
|
-
|
|
704
|
-
# Serialize response format to the format expected by OpenRouter API
|
|
705
|
-
def serialize_response_format(response_format)
|
|
706
|
-
case response_format
|
|
707
|
-
when Hash
|
|
708
|
-
if response_format[:json_schema].is_a?(Schema)
|
|
709
|
-
response_format.merge(json_schema: response_format[:json_schema].to_h)
|
|
710
|
-
else
|
|
711
|
-
response_format
|
|
712
|
-
end
|
|
713
|
-
when Schema
|
|
714
|
-
{
|
|
715
|
-
type: "json_schema",
|
|
716
|
-
json_schema: response_format.to_h
|
|
717
|
-
}
|
|
718
|
-
else
|
|
719
|
-
response_format
|
|
720
|
-
end
|
|
721
|
-
end
|
|
722
|
-
|
|
723
|
-
# Inject schema instructions into messages for forced structured output
|
|
724
|
-
def inject_schema_instructions!(messages, response_format)
|
|
725
|
-
schema = extract_schema(response_format)
|
|
726
|
-
return unless schema
|
|
727
|
-
|
|
728
|
-
instruction_content = if schema.respond_to?(:get_format_instructions)
|
|
729
|
-
schema.get_format_instructions
|
|
730
|
-
else
|
|
731
|
-
build_schema_instruction(schema)
|
|
732
|
-
end
|
|
733
|
-
|
|
734
|
-
# Add as system message
|
|
735
|
-
messages << { role: "system", content: instruction_content }
|
|
736
|
-
end
|
|
737
|
-
|
|
738
|
-
# Extract schema from response_format
|
|
739
|
-
def extract_schema(response_format)
|
|
740
|
-
case response_format
|
|
741
|
-
when Schema
|
|
742
|
-
response_format
|
|
743
|
-
when Hash
|
|
744
|
-
# Handle both Schema objects and raw hash schemas
|
|
745
|
-
if response_format[:json_schema].is_a?(Schema)
|
|
746
|
-
response_format[:json_schema]
|
|
747
|
-
elsif response_format[:json_schema].is_a?(Hash)
|
|
748
|
-
response_format[:json_schema]
|
|
749
|
-
else
|
|
750
|
-
response_format
|
|
751
|
-
end
|
|
752
|
-
end
|
|
753
|
-
end
|
|
754
|
-
|
|
755
|
-
# Build schema instruction when schema doesn't have get_format_instructions
|
|
756
|
-
def build_schema_instruction(schema)
|
|
757
|
-
schema_json = schema.respond_to?(:to_h) ? schema.to_h.to_json : schema.to_json
|
|
758
|
-
|
|
759
|
-
<<~INSTRUCTION
|
|
760
|
-
You must respond with valid JSON matching this exact schema:
|
|
761
|
-
|
|
762
|
-
```json
|
|
763
|
-
#{schema_json}
|
|
764
|
-
```
|
|
765
|
-
|
|
766
|
-
Rules:
|
|
767
|
-
- Return ONLY the JSON object, no other text
|
|
768
|
-
- Ensure all required fields are present
|
|
769
|
-
- Match the exact data types specified
|
|
770
|
-
- Follow any format constraints (email, date, etc.)
|
|
771
|
-
- Do not include trailing commas or comments
|
|
772
|
-
INSTRUCTION
|
|
773
|
-
end
|
|
774
211
|
end
|
|
775
|
-
# rubocop:enable Metrics/ClassLength
|
|
776
212
|
end
|