open_router_enhanced 1.0.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 +7 -0
- data/.env.example +1 -0
- data/.rspec +3 -0
- data/.rubocop.yml +13 -0
- data/.rubocop_todo.yml +130 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +41 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/CONTRIBUTING.md +384 -0
- data/Gemfile +22 -0
- data/Gemfile.lock +138 -0
- data/LICENSE.txt +21 -0
- data/MIGRATION.md +556 -0
- data/README.md +1660 -0
- data/Rakefile +334 -0
- data/SECURITY.md +150 -0
- data/VCR_CONFIGURATION.md +80 -0
- data/docs/model_selection.md +637 -0
- data/docs/observability.md +430 -0
- data/docs/prompt_templates.md +422 -0
- data/docs/streaming.md +467 -0
- data/docs/structured_outputs.md +466 -0
- data/docs/tools.md +1016 -0
- data/examples/basic_completion.rb +122 -0
- data/examples/model_selection_example.rb +141 -0
- data/examples/observability_example.rb +199 -0
- data/examples/prompt_template_example.rb +184 -0
- data/examples/smart_completion_example.rb +89 -0
- data/examples/streaming_example.rb +176 -0
- data/examples/structured_outputs_example.rb +191 -0
- data/examples/tool_calling_example.rb +149 -0
- data/lib/open_router/client.rb +552 -0
- data/lib/open_router/http.rb +118 -0
- data/lib/open_router/json_healer.rb +263 -0
- data/lib/open_router/model_registry.rb +378 -0
- data/lib/open_router/model_selector.rb +462 -0
- data/lib/open_router/prompt_template.rb +290 -0
- data/lib/open_router/response.rb +371 -0
- data/lib/open_router/schema.rb +288 -0
- data/lib/open_router/streaming_client.rb +210 -0
- data/lib/open_router/tool.rb +221 -0
- data/lib/open_router/tool_call.rb +180 -0
- data/lib/open_router/usage_tracker.rb +277 -0
- data/lib/open_router/version.rb +5 -0
- data/lib/open_router.rb +123 -0
- data/sig/open_router.rbs +20 -0
- metadata +186 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/object/blank"
|
|
4
|
+
require "active_support/core_ext/hash/indifferent_access"
|
|
5
|
+
|
|
6
|
+
require_relative "http"
|
|
7
|
+
require "pry"
|
|
8
|
+
|
|
9
|
+
module OpenRouter
|
|
10
|
+
class ServerError < StandardError; end
|
|
11
|
+
|
|
12
|
+
class Client
|
|
13
|
+
include OpenRouter::HTTP
|
|
14
|
+
|
|
15
|
+
attr_reader :callbacks, :usage_tracker
|
|
16
|
+
|
|
17
|
+
# Initializes the client with optional configurations.
|
|
18
|
+
def initialize(access_token: nil, request_timeout: nil, uri_base: nil, extra_headers: {}, track_usage: true)
|
|
19
|
+
OpenRouter.configuration.access_token = access_token if access_token
|
|
20
|
+
OpenRouter.configuration.request_timeout = request_timeout if request_timeout
|
|
21
|
+
OpenRouter.configuration.uri_base = uri_base if uri_base
|
|
22
|
+
OpenRouter.configuration.extra_headers = extra_headers if extra_headers.any?
|
|
23
|
+
yield(OpenRouter.configuration) if block_given?
|
|
24
|
+
|
|
25
|
+
# Instance-level tracking of capability warnings to avoid memory leaks
|
|
26
|
+
@capability_warnings_shown = Set.new
|
|
27
|
+
|
|
28
|
+
# Initialize callback system
|
|
29
|
+
@callbacks = {
|
|
30
|
+
before_request: [],
|
|
31
|
+
after_response: [],
|
|
32
|
+
on_tool_call: [],
|
|
33
|
+
on_error: [],
|
|
34
|
+
on_stream_chunk: [],
|
|
35
|
+
on_healing: []
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Initialize usage tracking
|
|
39
|
+
@track_usage = track_usage
|
|
40
|
+
@usage_tracker = UsageTracker.new if @track_usage
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def configuration
|
|
44
|
+
OpenRouter.configuration
|
|
45
|
+
end
|
|
46
|
+
|
|
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
|
+
# Performs a chat completion request to the OpenRouter API.
|
|
94
|
+
# @param messages [Array<Hash>] Array of message hashes with role and content, like [{role: "user", content: "What is the meaning of life?"}]
|
|
95
|
+
# @param model [String|Array] Model identifier, or array of model identifiers if you want to fallback to the next model in case of failure
|
|
96
|
+
# @param providers [Array<String>] Optional array of provider identifiers, ordered by priority
|
|
97
|
+
# @param transforms [Array<String>] Optional array of strings that tell OpenRouter to apply a series of transformations to the prompt before sending it to the model. Transformations are applied in-order
|
|
98
|
+
# @param tools [Array<Tool>] Optional array of Tool objects or tool definition hashes for function calling
|
|
99
|
+
# @param tool_choice [String|Hash] Optional tool choice: "auto", "none", "required", or specific tool selection
|
|
100
|
+
# @param response_format [Hash] Optional response format for structured outputs
|
|
101
|
+
# @param extras [Hash] Optional hash of model-specific parameters to send to the OpenRouter API
|
|
102
|
+
# @param stream [Proc, nil] Optional callable object for streaming
|
|
103
|
+
# @return [Response] The completion response wrapped in a Response object.
|
|
104
|
+
def complete(messages, model: "openrouter/auto", providers: [], transforms: [], tools: [], tool_choice: nil,
|
|
105
|
+
response_format: nil, force_structured_output: nil, extras: {}, stream: nil)
|
|
106
|
+
parameters = prepare_base_parameters(messages, model, providers, transforms, stream, extras)
|
|
107
|
+
forced_extraction = configure_tools_and_structured_outputs!(parameters, model, tools, tool_choice,
|
|
108
|
+
response_format, force_structured_output)
|
|
109
|
+
validate_vision_support(model, messages)
|
|
110
|
+
|
|
111
|
+
# Trigger before_request callbacks
|
|
112
|
+
trigger_callbacks(:before_request, parameters)
|
|
113
|
+
|
|
114
|
+
raw_response = execute_request(parameters)
|
|
115
|
+
validate_response!(raw_response, stream)
|
|
116
|
+
|
|
117
|
+
response = build_response(raw_response, response_format, forced_extraction)
|
|
118
|
+
|
|
119
|
+
# Track usage if enabled
|
|
120
|
+
@usage_tracker&.track(response, model: model.is_a?(String) ? model : model.first)
|
|
121
|
+
|
|
122
|
+
# Trigger after_response callbacks
|
|
123
|
+
trigger_callbacks(:after_response, response)
|
|
124
|
+
|
|
125
|
+
# Trigger on_tool_call callbacks if tool calls are present
|
|
126
|
+
trigger_callbacks(:on_tool_call, response.tool_calls) if response.has_tool_calls?
|
|
127
|
+
|
|
128
|
+
response
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Fetches the list of available models from the OpenRouter API.
|
|
132
|
+
# @return [Array<Hash>] The list of models.
|
|
133
|
+
def models
|
|
134
|
+
get(path: "/models")["data"]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Queries the generation stats for a given id.
|
|
138
|
+
# @param generation_id [String] The generation id returned from a previous request.
|
|
139
|
+
# @return [Hash] The stats including token counts and cost.
|
|
140
|
+
def query_generation_stats(generation_id)
|
|
141
|
+
response = get(path: "/generation?id=#{generation_id}")
|
|
142
|
+
response["data"]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Create a new ModelSelector for intelligent model selection
|
|
146
|
+
#
|
|
147
|
+
# @return [ModelSelector] A new ModelSelector instance
|
|
148
|
+
# @example
|
|
149
|
+
# client = OpenRouter::Client.new
|
|
150
|
+
# model = client.select_model.optimize_for(:cost).require(:function_calling).choose
|
|
151
|
+
def select_model
|
|
152
|
+
ModelSelector.new
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Smart completion that automatically selects the best model based on requirements
|
|
156
|
+
#
|
|
157
|
+
# @param messages [Array<Hash>] Array of message hashes
|
|
158
|
+
# @param requirements [Hash] Model selection requirements
|
|
159
|
+
# @param optimization [Symbol] Optimization strategy (:cost, :performance, :latest, :context)
|
|
160
|
+
# @param extras [Hash] Additional parameters for the completion request
|
|
161
|
+
# @return [Response] The completion response
|
|
162
|
+
# @raise [ModelSelectionError] If no suitable model is found
|
|
163
|
+
#
|
|
164
|
+
# @example
|
|
165
|
+
# response = client.smart_complete(
|
|
166
|
+
# messages: [{ role: "user", content: "Analyze this data" }],
|
|
167
|
+
# requirements: { capabilities: [:function_calling], max_input_cost: 0.01 },
|
|
168
|
+
# optimization: :cost
|
|
169
|
+
# )
|
|
170
|
+
def smart_complete(messages, requirements: {}, optimization: :cost, **extras)
|
|
171
|
+
selector = ModelSelector.new.optimize_for(optimization)
|
|
172
|
+
|
|
173
|
+
# Apply requirements using fluent interface
|
|
174
|
+
selector = selector.require(*requirements[:capabilities]) if requirements[:capabilities]
|
|
175
|
+
|
|
176
|
+
if requirements[:max_cost] || requirements[:max_input_cost]
|
|
177
|
+
cost_opts = {}
|
|
178
|
+
cost_opts[:max_cost] = requirements[:max_cost] || requirements[:max_input_cost]
|
|
179
|
+
cost_opts[:max_output_cost] = requirements[:max_output_cost] if requirements[:max_output_cost]
|
|
180
|
+
selector = selector.within_budget(**cost_opts)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
selector = selector.min_context(requirements[:min_context_length]) if requirements[:min_context_length]
|
|
184
|
+
|
|
185
|
+
if requirements[:providers]
|
|
186
|
+
case requirements[:providers]
|
|
187
|
+
when Hash
|
|
188
|
+
selector = selector.prefer_providers(*requirements[:providers][:prefer]) if requirements[:providers][:prefer]
|
|
189
|
+
if requirements[:providers][:require]
|
|
190
|
+
selector = selector.require_providers(*requirements[:providers][:require])
|
|
191
|
+
end
|
|
192
|
+
selector = selector.avoid_providers(*requirements[:providers][:avoid]) if requirements[:providers][:avoid]
|
|
193
|
+
when Array
|
|
194
|
+
selector = selector.prefer_providers(*requirements[:providers])
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Select the best model
|
|
199
|
+
model = selector.choose
|
|
200
|
+
raise ModelSelectionError, "No model found matching requirements: #{requirements}" unless model
|
|
201
|
+
|
|
202
|
+
# Perform the completion with the selected model
|
|
203
|
+
complete(messages, model:, **extras)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Smart completion with automatic fallback to alternative models
|
|
207
|
+
#
|
|
208
|
+
# @param messages [Array<Hash>] Array of message hashes
|
|
209
|
+
# @param requirements [Hash] Model selection requirements
|
|
210
|
+
# @param optimization [Symbol] Optimization strategy
|
|
211
|
+
# @param max_retries [Integer] Maximum number of fallback attempts
|
|
212
|
+
# @param extras [Hash] Additional parameters for the completion request
|
|
213
|
+
# @return [Response] The completion response
|
|
214
|
+
# @raise [ModelSelectionError] If all fallback attempts fail
|
|
215
|
+
#
|
|
216
|
+
# @example
|
|
217
|
+
# response = client.smart_complete_with_fallback(
|
|
218
|
+
# messages: [{ role: "user", content: "Hello" }],
|
|
219
|
+
# requirements: { capabilities: [:function_calling] },
|
|
220
|
+
# max_retries: 3
|
|
221
|
+
# )
|
|
222
|
+
def smart_complete_with_fallback(messages, requirements: {}, optimization: :cost, max_retries: 3, **extras)
|
|
223
|
+
selector = ModelSelector.new.optimize_for(optimization)
|
|
224
|
+
|
|
225
|
+
# Apply requirements (same logic as smart_complete)
|
|
226
|
+
selector = selector.require(*requirements[:capabilities]) if requirements[:capabilities]
|
|
227
|
+
|
|
228
|
+
if requirements[:max_cost] || requirements[:max_input_cost]
|
|
229
|
+
cost_opts = {}
|
|
230
|
+
cost_opts[:max_cost] = requirements[:max_cost] || requirements[:max_input_cost]
|
|
231
|
+
cost_opts[:max_output_cost] = requirements[:max_output_cost] if requirements[:max_output_cost]
|
|
232
|
+
selector = selector.within_budget(**cost_opts)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
selector = selector.min_context(requirements[:min_context_length]) if requirements[:min_context_length]
|
|
236
|
+
|
|
237
|
+
if requirements[:providers]
|
|
238
|
+
case requirements[:providers]
|
|
239
|
+
when Hash
|
|
240
|
+
selector = selector.prefer_providers(*requirements[:providers][:prefer]) if requirements[:providers][:prefer]
|
|
241
|
+
if requirements[:providers][:require]
|
|
242
|
+
selector = selector.require_providers(*requirements[:providers][:require])
|
|
243
|
+
end
|
|
244
|
+
selector = selector.avoid_providers(*requirements[:providers][:avoid]) if requirements[:providers][:avoid]
|
|
245
|
+
when Array
|
|
246
|
+
selector = selector.prefer_providers(*requirements[:providers])
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Get fallback models
|
|
251
|
+
fallback_models = selector.choose_with_fallbacks(limit: max_retries + 1)
|
|
252
|
+
raise ModelSelectionError, "No models found matching requirements: #{requirements}" if fallback_models.empty?
|
|
253
|
+
|
|
254
|
+
last_error = nil
|
|
255
|
+
|
|
256
|
+
fallback_models.each do |model|
|
|
257
|
+
return complete(messages, model:, **extras)
|
|
258
|
+
rescue StandardError => e
|
|
259
|
+
last_error = e
|
|
260
|
+
# Continue to next model in fallback list
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# If we get here, all models failed
|
|
264
|
+
raise ModelSelectionError, "All fallback models failed. Last error: #{last_error&.message}"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
private
|
|
268
|
+
|
|
269
|
+
# Prepare the base parameters for the API request
|
|
270
|
+
def prepare_base_parameters(messages, model, providers, transforms, stream, extras)
|
|
271
|
+
parameters = { messages: messages.dup }
|
|
272
|
+
|
|
273
|
+
configure_model_parameter!(parameters, model)
|
|
274
|
+
configure_provider_parameter!(parameters, providers)
|
|
275
|
+
configure_transforms_parameter!(parameters, transforms)
|
|
276
|
+
configure_stream_parameter!(parameters, stream)
|
|
277
|
+
|
|
278
|
+
parameters.merge!(extras)
|
|
279
|
+
parameters
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Configure the model parameter (single model or fallback array)
|
|
283
|
+
def configure_model_parameter!(parameters, model)
|
|
284
|
+
if model.is_a?(String)
|
|
285
|
+
parameters[:model] = model
|
|
286
|
+
elsif model.is_a?(Array)
|
|
287
|
+
parameters[:models] = model
|
|
288
|
+
parameters[:route] = "fallback"
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Configure the provider parameter if providers are specified
|
|
293
|
+
def configure_provider_parameter!(parameters, providers)
|
|
294
|
+
parameters[:provider] = { order: providers } if providers.any?
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Configure the transforms parameter if transforms are specified
|
|
298
|
+
def configure_transforms_parameter!(parameters, transforms)
|
|
299
|
+
parameters[:transforms] = transforms if transforms.any?
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Configure the stream parameter if streaming is enabled
|
|
303
|
+
def configure_stream_parameter!(parameters, stream)
|
|
304
|
+
parameters[:stream] = stream if stream
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Configure tools and structured outputs, returning forced_extraction flag
|
|
308
|
+
def configure_tools_and_structured_outputs!(parameters, model, tools, tool_choice, response_format,
|
|
309
|
+
force_structured_output)
|
|
310
|
+
configure_tool_calling!(parameters, model, tools, tool_choice)
|
|
311
|
+
configure_structured_outputs!(parameters, model, response_format, force_structured_output)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Configure tool calling support
|
|
315
|
+
def configure_tool_calling!(parameters, model, tools, tool_choice)
|
|
316
|
+
return unless tools.any?
|
|
317
|
+
|
|
318
|
+
warn_if_unsupported(model, :function_calling, "tool calling")
|
|
319
|
+
parameters[:tools] = serialize_tools(tools)
|
|
320
|
+
parameters[:tool_choice] = tool_choice if tool_choice
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Configure structured output support and return forced_extraction flag
|
|
324
|
+
def configure_structured_outputs!(parameters, model, response_format, force_structured_output)
|
|
325
|
+
return false unless response_format
|
|
326
|
+
|
|
327
|
+
force_structured_output = determine_forced_extraction_mode(model, force_structured_output)
|
|
328
|
+
|
|
329
|
+
if force_structured_output
|
|
330
|
+
handle_forced_structured_output!(parameters, model, response_format)
|
|
331
|
+
true
|
|
332
|
+
else
|
|
333
|
+
handle_native_structured_output!(parameters, model, response_format)
|
|
334
|
+
false
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Determine whether to use forced extraction mode
|
|
339
|
+
def determine_forced_extraction_mode(model, force_structured_output)
|
|
340
|
+
return force_structured_output unless force_structured_output.nil?
|
|
341
|
+
|
|
342
|
+
if model.is_a?(String) &&
|
|
343
|
+
model != "openrouter/auto" &&
|
|
344
|
+
!ModelRegistry.has_capability?(model, :structured_outputs) &&
|
|
345
|
+
configuration.auto_force_on_unsupported_models
|
|
346
|
+
warn "[OpenRouter] Model '#{model}' doesn't support native structured outputs. Automatically using forced extraction mode."
|
|
347
|
+
true
|
|
348
|
+
else
|
|
349
|
+
false
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Handle forced structured output mode
|
|
354
|
+
def handle_forced_structured_output!(parameters, model, response_format)
|
|
355
|
+
# In strict mode, still validate to ensure user is aware of capability limits
|
|
356
|
+
warn_if_unsupported(model, :structured_outputs, "structured outputs") if configuration.strict_mode
|
|
357
|
+
inject_schema_instructions!(parameters[:messages], response_format)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Handle native structured output mode
|
|
361
|
+
def handle_native_structured_output!(parameters, model, response_format)
|
|
362
|
+
warn_if_unsupported(model, :structured_outputs, "structured outputs")
|
|
363
|
+
parameters[:response_format] = serialize_response_format(response_format)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Validate vision support if messages contain images
|
|
367
|
+
def validate_vision_support(model, messages)
|
|
368
|
+
warn_if_unsupported(model, :vision, "vision/image processing") if messages_contain_images?(messages)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Execute the HTTP request with comprehensive error handling
|
|
372
|
+
def execute_request(parameters)
|
|
373
|
+
post(path: "/chat/completions", parameters: parameters)
|
|
374
|
+
rescue ConfigurationError => e
|
|
375
|
+
trigger_callbacks(:on_error, e)
|
|
376
|
+
raise ServerError, e.message
|
|
377
|
+
rescue Faraday::Error => e
|
|
378
|
+
trigger_callbacks(:on_error, e)
|
|
379
|
+
handle_faraday_error(e)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Handle Faraday errors with specific error message extraction
|
|
383
|
+
def handle_faraday_error(error)
|
|
384
|
+
case error
|
|
385
|
+
when Faraday::UnauthorizedError
|
|
386
|
+
raise error
|
|
387
|
+
when Faraday::BadRequestError
|
|
388
|
+
error_message = extract_error_message(error)
|
|
389
|
+
raise ServerError, "Bad Request: #{error_message}"
|
|
390
|
+
when Faraday::ServerError
|
|
391
|
+
raise ServerError, "Server Error: #{error.message}"
|
|
392
|
+
else
|
|
393
|
+
raise ServerError, "Network Error: #{error.message}"
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Extract error message from Faraday error response
|
|
398
|
+
def extract_error_message(error)
|
|
399
|
+
return error.message unless error.response&.dig(:body)
|
|
400
|
+
|
|
401
|
+
body = error.response[:body]
|
|
402
|
+
|
|
403
|
+
if body.is_a?(Hash)
|
|
404
|
+
body.dig("error", "message") || error.message
|
|
405
|
+
elsif body.is_a?(String)
|
|
406
|
+
extract_error_from_json_string(body) || error.message
|
|
407
|
+
else
|
|
408
|
+
error.message
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Extract error message from JSON string response
|
|
413
|
+
def extract_error_from_json_string(json_string)
|
|
414
|
+
parsed_body = JSON.parse(json_string)
|
|
415
|
+
parsed_body.dig("error", "message")
|
|
416
|
+
rescue JSON::ParserError
|
|
417
|
+
nil
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Validate the API response for errors
|
|
421
|
+
def validate_response!(raw_response, stream)
|
|
422
|
+
raise ServerError, raw_response.dig("error", "message") if raw_response.presence&.dig("error", "message").present?
|
|
423
|
+
|
|
424
|
+
return unless stream.blank? && raw_response.blank?
|
|
425
|
+
|
|
426
|
+
raise ServerError, "Empty response from OpenRouter. Might be worth retrying once or twice."
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Build and configure the Response object
|
|
430
|
+
def build_response(raw_response, response_format, forced_extraction)
|
|
431
|
+
response = Response.new(raw_response, response_format: response_format, forced_extraction: forced_extraction)
|
|
432
|
+
response.client = self
|
|
433
|
+
response
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Warn if a model is being used with an unsupported capability
|
|
437
|
+
def warn_if_unsupported(model, capability, feature_name)
|
|
438
|
+
# Skip warnings for array models (fallbacks) or auto-selection
|
|
439
|
+
return if model.is_a?(Array) || model == "openrouter/auto"
|
|
440
|
+
|
|
441
|
+
return if ModelRegistry.has_capability?(model, capability)
|
|
442
|
+
|
|
443
|
+
if configuration.strict_mode
|
|
444
|
+
raise CapabilityError,
|
|
445
|
+
"Model '#{model}' does not support #{feature_name} (missing :#{capability} capability). Enable non-strict mode to allow this request."
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
warning_key = "#{model}:#{capability}"
|
|
449
|
+
return if @capability_warnings_shown.include?(warning_key)
|
|
450
|
+
|
|
451
|
+
warn "[OpenRouter Warning] Model '#{model}' may not support #{feature_name} (missing :#{capability} capability). The request will still be attempted."
|
|
452
|
+
@capability_warnings_shown << warning_key
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Check if messages contain image content
|
|
456
|
+
def messages_contain_images?(messages)
|
|
457
|
+
messages.any? do |msg|
|
|
458
|
+
content = msg[:content] || msg["content"]
|
|
459
|
+
if content.is_a?(Array)
|
|
460
|
+
content.any? { |part| part.is_a?(Hash) && (part[:type] == "image_url" || part["type"] == "image_url") }
|
|
461
|
+
else
|
|
462
|
+
false
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Serialize tools to the format expected by OpenRouter API
|
|
468
|
+
def serialize_tools(tools)
|
|
469
|
+
tools.map do |tool|
|
|
470
|
+
case tool
|
|
471
|
+
when Tool
|
|
472
|
+
tool.to_h
|
|
473
|
+
when Hash
|
|
474
|
+
tool
|
|
475
|
+
else
|
|
476
|
+
raise ArgumentError, "Tools must be Tool objects or hashes"
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Serialize response format to the format expected by OpenRouter API
|
|
482
|
+
def serialize_response_format(response_format)
|
|
483
|
+
case response_format
|
|
484
|
+
when Hash
|
|
485
|
+
if response_format[:json_schema].is_a?(Schema)
|
|
486
|
+
response_format.merge(json_schema: response_format[:json_schema].to_h)
|
|
487
|
+
else
|
|
488
|
+
response_format
|
|
489
|
+
end
|
|
490
|
+
when Schema
|
|
491
|
+
{
|
|
492
|
+
type: "json_schema",
|
|
493
|
+
json_schema: response_format.to_h
|
|
494
|
+
}
|
|
495
|
+
else
|
|
496
|
+
response_format
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Inject schema instructions into messages for forced structured output
|
|
501
|
+
def inject_schema_instructions!(messages, response_format)
|
|
502
|
+
schema = extract_schema(response_format)
|
|
503
|
+
return unless schema
|
|
504
|
+
|
|
505
|
+
instruction_content = if schema.respond_to?(:get_format_instructions)
|
|
506
|
+
schema.get_format_instructions
|
|
507
|
+
else
|
|
508
|
+
build_schema_instruction(schema)
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Add as system message
|
|
512
|
+
messages << { role: "system", content: instruction_content }
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# Extract schema from response_format
|
|
516
|
+
def extract_schema(response_format)
|
|
517
|
+
case response_format
|
|
518
|
+
when Schema
|
|
519
|
+
response_format
|
|
520
|
+
when Hash
|
|
521
|
+
# Handle both Schema objects and raw hash schemas
|
|
522
|
+
if response_format[:json_schema].is_a?(Schema)
|
|
523
|
+
response_format[:json_schema]
|
|
524
|
+
elsif response_format[:json_schema].is_a?(Hash)
|
|
525
|
+
response_format[:json_schema]
|
|
526
|
+
else
|
|
527
|
+
response_format
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# Build schema instruction when schema doesn't have get_format_instructions
|
|
533
|
+
def build_schema_instruction(schema)
|
|
534
|
+
schema_json = schema.respond_to?(:to_h) ? schema.to_h.to_json : schema.to_json
|
|
535
|
+
|
|
536
|
+
<<~INSTRUCTION
|
|
537
|
+
You must respond with valid JSON matching this exact schema:
|
|
538
|
+
|
|
539
|
+
```json
|
|
540
|
+
#{schema_json}
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
Rules:
|
|
544
|
+
- Return ONLY the JSON object, no other text
|
|
545
|
+
- Ensure all required fields are present
|
|
546
|
+
- Match the exact data types specified
|
|
547
|
+
- Follow any format constraints (email, date, etc.)
|
|
548
|
+
- Do not include trailing commas or comments
|
|
549
|
+
INSTRUCTION
|
|
550
|
+
end
|
|
551
|
+
end
|
|
552
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module OpenRouter
|
|
6
|
+
module HTTP
|
|
7
|
+
def get(path:)
|
|
8
|
+
response = conn.get(uri(path:)) do |req|
|
|
9
|
+
req.headers = headers
|
|
10
|
+
end
|
|
11
|
+
normalize_body(response&.body)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def post(path:, parameters:)
|
|
15
|
+
response = conn.post(uri(path:)) do |req|
|
|
16
|
+
if parameters[:stream].respond_to?(:call)
|
|
17
|
+
req.options.on_data = to_json_stream(user_proc: parameters[:stream])
|
|
18
|
+
parameters[:stream] = true # Necessary to tell OpenRouter to stream.
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
req.headers = headers
|
|
22
|
+
req.body = parameters.to_json
|
|
23
|
+
end
|
|
24
|
+
normalize_body(response&.body)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def multipart_post(path:, parameters: nil)
|
|
28
|
+
response = conn(multipart: true).post(uri(path:)) do |req|
|
|
29
|
+
req.headers = headers.merge({ "Content-Type" => "multipart/form-data" })
|
|
30
|
+
req.body = multipart_parameters(parameters)
|
|
31
|
+
end
|
|
32
|
+
normalize_body(response&.body)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def delete(path:)
|
|
36
|
+
response = conn.delete(uri(path:)) do |req|
|
|
37
|
+
req.headers = headers
|
|
38
|
+
end
|
|
39
|
+
normalize_body(response&.body)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# Normalize response body - parse JSON when middleware is not available
|
|
45
|
+
def normalize_body(body)
|
|
46
|
+
return body if OpenRouter::HAS_JSON_MW # Let middleware handle it
|
|
47
|
+
return body unless body.is_a?(String)
|
|
48
|
+
|
|
49
|
+
begin
|
|
50
|
+
JSON.parse(body)
|
|
51
|
+
rescue JSON::ParserError
|
|
52
|
+
body # Return original if not valid JSON
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Given a proc, returns an outer proc that can be used to iterate over a JSON stream of chunks.
|
|
57
|
+
# For each chunk, the inner user_proc is called giving it the JSON object. The JSON object could
|
|
58
|
+
# be a data object or an error object as described in the OpenRouter API documentation.
|
|
59
|
+
#
|
|
60
|
+
# If the JSON object for a given data or error message is invalid, it is ignored.
|
|
61
|
+
#
|
|
62
|
+
# @param user_proc [Proc] The inner proc to call for each JSON object in the chunk.
|
|
63
|
+
# @return [Proc] An outer proc that iterates over a raw stream, converting it to JSON.
|
|
64
|
+
def to_json_stream(user_proc:)
|
|
65
|
+
proc do |chunk, _|
|
|
66
|
+
chunk.scan(/(?:data|error): (\{.*\})/i).flatten.each do |data|
|
|
67
|
+
parsed_chunk = JSON.parse(data)
|
|
68
|
+
|
|
69
|
+
# Trigger on_stream_chunk callback if available
|
|
70
|
+
trigger_callbacks(:on_stream_chunk, parsed_chunk) if respond_to?(:trigger_callbacks)
|
|
71
|
+
|
|
72
|
+
user_proc.call(parsed_chunk)
|
|
73
|
+
rescue JSON::ParserError
|
|
74
|
+
# Ignore invalid JSON.
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def conn(multipart: false)
|
|
80
|
+
Faraday.new do |f|
|
|
81
|
+
f.options[:timeout] = OpenRouter.configuration.request_timeout
|
|
82
|
+
f.request(:multipart) if multipart
|
|
83
|
+
# NOTE: Removed MiddlewareErrors reference - was undefined and @log_errors was never set
|
|
84
|
+
f.response :raise_error
|
|
85
|
+
f.response :json if OpenRouter::HAS_JSON_MW
|
|
86
|
+
|
|
87
|
+
OpenRouter.configuration.faraday_config&.call(f)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def uri(path:)
|
|
92
|
+
base = OpenRouter.configuration.uri_base.sub(%r{/\z}, "")
|
|
93
|
+
ver = OpenRouter.configuration.api_version.to_s.sub(%r{^/}, "").sub(%r{/\z}, "")
|
|
94
|
+
p = path.to_s.sub(%r{^/}, "")
|
|
95
|
+
"#{base}/#{ver}/#{p}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def headers
|
|
99
|
+
{
|
|
100
|
+
"Authorization" => "Bearer #{OpenRouter.configuration.access_token}",
|
|
101
|
+
"Content-Type" => "application/json",
|
|
102
|
+
"X-Title" => "OpenRouter Ruby Client",
|
|
103
|
+
"HTTP-Referer" => "https://github.com/OlympiaAI/open_router"
|
|
104
|
+
}.merge(OpenRouter.configuration.extra_headers)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def multipart_parameters(parameters)
|
|
108
|
+
parameters&.transform_values do |value|
|
|
109
|
+
next value unless value.is_a?(File)
|
|
110
|
+
|
|
111
|
+
# Doesn't seem like OpenRouter needs mime_type yet, so not worth
|
|
112
|
+
# the library to figure this out. Hence the empty string
|
|
113
|
+
# as the second argument.
|
|
114
|
+
Faraday::UploadIO.new(value, "", value.path)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|