open_router_enhanced 2.0.1 → 2.2.0

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