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.
@@ -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
- configure_plugins!(parameters, opts.response_format, stream)
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
- # 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
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