open_router_enhanced 2.1.0 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/Rakefile +0 -1
- 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 +22 -578
- data/lib/open_router/completion_options.rb +15 -7
- data/lib/open_router/parameter_builder.rb +120 -0
- data/lib/open_router/request_handler.rb +99 -0
- data/lib/open_router/response.rb +15 -125
- data/lib/open_router/response_parsing.rb +107 -0
- data/lib/open_router/schema.rb +28 -12
- data/lib/open_router/tool_serializer.rb +152 -0
- data/lib/open_router/version.rb +1 -1
- data/lib/open_router.rb +7 -10
- metadata +7 -2
data/lib/open_router/client.rb
CHANGED
|
@@ -4,17 +4,23 @@ 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"
|
|
7
11
|
|
|
8
12
|
module OpenRouter
|
|
9
13
|
class ServerError < StandardError; end
|
|
10
14
|
|
|
11
|
-
# rubocop:disable Metrics/ClassLength
|
|
12
15
|
class Client
|
|
13
16
|
include OpenRouter::HTTP
|
|
17
|
+
include OpenRouter::Callbacks
|
|
18
|
+
include OpenRouter::ParameterBuilder
|
|
19
|
+
include OpenRouter::ToolSerializer
|
|
20
|
+
include OpenRouter::RequestHandler
|
|
14
21
|
|
|
15
22
|
attr_reader :callbacks, :usage_tracker, :configuration
|
|
16
23
|
|
|
17
|
-
# Initializes the client with optional configurations.
|
|
18
24
|
def initialize(access_token: nil, request_timeout: nil, uri_base: nil, extra_headers: {}, track_usage: true)
|
|
19
25
|
# Build a per-instance configuration to avoid mutating the global singleton,
|
|
20
26
|
# which would cause credential leakage across Client instances in concurrent use.
|
|
@@ -26,10 +32,8 @@ module OpenRouter
|
|
|
26
32
|
@configuration.extra_headers = @configuration.extra_headers.merge(extra_headers) if extra_headers.any?
|
|
27
33
|
yield(@configuration) if block_given?
|
|
28
34
|
|
|
29
|
-
# Instance-level tracking of capability warnings to avoid memory leaks
|
|
30
35
|
@capability_warnings_shown = Set.new
|
|
31
36
|
|
|
32
|
-
# Initialize callback system
|
|
33
37
|
@callbacks = {
|
|
34
38
|
before_request: [],
|
|
35
39
|
after_response: [],
|
|
@@ -39,57 +43,10 @@ module OpenRouter
|
|
|
39
43
|
on_healing: []
|
|
40
44
|
}
|
|
41
45
|
|
|
42
|
-
# Initialize usage tracking
|
|
43
46
|
@track_usage = track_usage
|
|
44
47
|
@usage_tracker = UsageTracker.new if @track_usage
|
|
45
48
|
end
|
|
46
49
|
|
|
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
50
|
# Performs a chat completion request to the OpenRouter API.
|
|
94
51
|
#
|
|
95
52
|
# @param messages [Array<Hash>] Array of message hashes with role and content
|
|
@@ -97,88 +54,52 @@ module OpenRouter
|
|
|
97
54
|
# @param stream [Proc, nil] Optional callable object for streaming
|
|
98
55
|
# @param kwargs [Hash] Additional options (merged with options parameter)
|
|
99
56
|
# @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
57
|
def complete(messages, options = nil, stream: nil, **kwargs)
|
|
114
58
|
opts = normalize_options(options, kwargs)
|
|
115
59
|
parameters = prepare_base_parameters(messages, opts, stream)
|
|
116
60
|
forced_extraction = configure_tools_and_structured_outputs!(parameters, opts)
|
|
117
|
-
|
|
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)
|
|
118
65
|
validate_vision_support(opts.model, messages)
|
|
119
66
|
|
|
120
|
-
# Trigger before_request callbacks
|
|
121
67
|
trigger_callbacks(:before_request, parameters)
|
|
122
68
|
|
|
123
69
|
raw_response = execute_request(parameters)
|
|
124
70
|
validate_response!(raw_response, stream)
|
|
125
71
|
|
|
126
|
-
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))
|
|
127
73
|
|
|
128
|
-
# Track usage if enabled
|
|
129
74
|
model_for_tracking = opts.model.is_a?(String) ? opts.model : opts.model.first
|
|
130
75
|
@usage_tracker&.track(response, model: model_for_tracking)
|
|
131
76
|
|
|
132
|
-
# Trigger after_response callbacks
|
|
133
77
|
trigger_callbacks(:after_response, response)
|
|
134
|
-
|
|
135
|
-
# Trigger on_tool_call callbacks if tool calls are present
|
|
136
78
|
trigger_callbacks(:on_tool_call, response.tool_calls) if response.has_tool_calls?
|
|
137
79
|
|
|
138
80
|
response
|
|
139
81
|
end
|
|
140
82
|
|
|
141
83
|
# Fetches the list of available models from the OpenRouter API.
|
|
142
|
-
# @return [Array<Hash>] The list of models.
|
|
143
84
|
def models
|
|
144
85
|
get(path: "/models")["data"]
|
|
145
86
|
end
|
|
146
87
|
|
|
147
88
|
# 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
89
|
def query_generation_stats(generation_id)
|
|
151
90
|
response = get(path: "/generation?id=#{generation_id}")
|
|
152
91
|
response["data"]
|
|
153
92
|
end
|
|
154
93
|
|
|
155
94
|
# Performs a request to the Responses API Beta (/api/v1/responses)
|
|
156
|
-
# This is an OpenAI-compatible stateless API with support for reasoning.
|
|
157
95
|
#
|
|
158
96
|
# @param input [String, Array] The input text or structured message array
|
|
159
97
|
# @param options [CompletionOptions, Hash, nil] Options object or hash with configuration
|
|
160
98
|
# @param kwargs [Hash] Additional options (merged with options parameter)
|
|
161
99
|
# @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
100
|
def responses(input, options = nil, **kwargs)
|
|
179
101
|
opts = normalize_options(options, kwargs)
|
|
180
102
|
|
|
181
|
-
# Model is required for Responses API
|
|
182
103
|
if opts.model == "openrouter/auto"
|
|
183
104
|
raise ArgumentError, "model is required for responses API (cannot use default 'openrouter/auto')"
|
|
184
105
|
end
|
|
@@ -187,7 +108,6 @@ module OpenRouter
|
|
|
187
108
|
parameters[:reasoning] = opts.reasoning if opts.reasoning
|
|
188
109
|
parameters[:tools] = serialize_tools_for_responses(opts.tools) if opts.tools?
|
|
189
110
|
parameters[:tool_choice] = opts.tool_choice if opts.tool_choice
|
|
190
|
-
# Prefer max_completion_tokens over max_tokens (consistent with complete() method)
|
|
191
111
|
parameters[:max_output_tokens] = opts.max_completion_tokens || opts.max_tokens if opts.max_completion_tokens || opts.max_tokens
|
|
192
112
|
parameters[:temperature] = opts.temperature if opts.temperature
|
|
193
113
|
parameters[:top_p] = opts.top_p if opts.top_p
|
|
@@ -198,34 +118,14 @@ module OpenRouter
|
|
|
198
118
|
end
|
|
199
119
|
|
|
200
120
|
# 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
121
|
def select_model
|
|
207
122
|
ModelSelector.new
|
|
208
123
|
end
|
|
209
124
|
|
|
210
125
|
# 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
126
|
def smart_complete(messages, requirements: {}, optimization: :cost, **extras)
|
|
226
127
|
selector = ModelSelector.new.optimize_for(optimization)
|
|
227
128
|
|
|
228
|
-
# Apply requirements using fluent interface
|
|
229
129
|
selector = selector.require(*requirements[:capabilities]) if requirements[:capabilities]
|
|
230
130
|
|
|
231
131
|
if requirements[:max_cost] || requirements[:max_input_cost]
|
|
@@ -241,43 +141,23 @@ module OpenRouter
|
|
|
241
141
|
case requirements[:providers]
|
|
242
142
|
when Hash
|
|
243
143
|
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
|
|
144
|
+
selector = selector.require_providers(*requirements[:providers][:require]) if requirements[:providers][:require]
|
|
247
145
|
selector = selector.avoid_providers(*requirements[:providers][:avoid]) if requirements[:providers][:avoid]
|
|
248
146
|
when Array
|
|
249
147
|
selector = selector.prefer_providers(*requirements[:providers])
|
|
250
148
|
end
|
|
251
149
|
end
|
|
252
150
|
|
|
253
|
-
# Select the best model
|
|
254
151
|
model = selector.choose
|
|
255
152
|
raise ModelSelectionError, "No model found matching requirements: #{requirements}" unless model
|
|
256
153
|
|
|
257
|
-
# Perform the completion with the selected model
|
|
258
154
|
complete(messages, model:, **extras)
|
|
259
155
|
end
|
|
260
156
|
|
|
261
157
|
# 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
158
|
def smart_complete_with_fallback(messages, requirements: {}, optimization: :cost, max_retries: 3, **extras)
|
|
278
159
|
selector = ModelSelector.new.optimize_for(optimization)
|
|
279
160
|
|
|
280
|
-
# Apply requirements (same logic as smart_complete)
|
|
281
161
|
selector = selector.require(*requirements[:capabilities]) if requirements[:capabilities]
|
|
282
162
|
|
|
283
163
|
if requirements[:max_cost] || requirements[:max_input_cost]
|
|
@@ -293,16 +173,13 @@ module OpenRouter
|
|
|
293
173
|
case requirements[:providers]
|
|
294
174
|
when Hash
|
|
295
175
|
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
|
|
176
|
+
selector = selector.require_providers(*requirements[:providers][:require]) if requirements[:providers][:require]
|
|
299
177
|
selector = selector.avoid_providers(*requirements[:providers][:avoid]) if requirements[:providers][:avoid]
|
|
300
178
|
when Array
|
|
301
179
|
selector = selector.prefer_providers(*requirements[:providers])
|
|
302
180
|
end
|
|
303
181
|
end
|
|
304
182
|
|
|
305
|
-
# Get fallback models
|
|
306
183
|
fallback_models = selector.choose_with_fallbacks(limit: max_retries + 1)
|
|
307
184
|
raise ModelSelectionError, "No models found matching requirements: #{requirements}" if fallback_models.empty?
|
|
308
185
|
|
|
@@ -312,26 +189,25 @@ module OpenRouter
|
|
|
312
189
|
return complete(messages, model:, **extras)
|
|
313
190
|
rescue StandardError => e
|
|
314
191
|
last_error = e
|
|
315
|
-
# Continue to next model in fallback list
|
|
316
192
|
end
|
|
317
193
|
|
|
318
|
-
# If we get here, all models failed
|
|
319
194
|
raise ModelSelectionError, "All fallback models failed. Last error: #{last_error&.message}"
|
|
320
195
|
end
|
|
321
196
|
|
|
322
197
|
private
|
|
323
198
|
|
|
324
|
-
#
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
+
|
|
329
206
|
def normalize_options(options, kwargs)
|
|
330
207
|
case options
|
|
331
208
|
when CompletionOptions
|
|
332
209
|
kwargs.empty? ? options : options.merge(**kwargs)
|
|
333
210
|
when Hash
|
|
334
|
-
# Symbolize keys to handle both string and symbol key hashes
|
|
335
211
|
symbolized = options.transform_keys(&:to_sym)
|
|
336
212
|
CompletionOptions.new(**symbolized.merge(kwargs))
|
|
337
213
|
when nil
|
|
@@ -340,437 +216,5 @@ module OpenRouter
|
|
|
340
216
|
raise ArgumentError, "options must be CompletionOptions, Hash, or nil"
|
|
341
217
|
end
|
|
342
218
|
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
219
|
end
|
|
775
|
-
# rubocop:enable Metrics/ClassLength
|
|
776
220
|
end
|