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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.env.example +1 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +13 -0
  5. data/.rubocop_todo.yml +130 -0
  6. data/.ruby-version +1 -0
  7. data/CHANGELOG.md +41 -0
  8. data/CODE_OF_CONDUCT.md +84 -0
  9. data/CONTRIBUTING.md +384 -0
  10. data/Gemfile +22 -0
  11. data/Gemfile.lock +138 -0
  12. data/LICENSE.txt +21 -0
  13. data/MIGRATION.md +556 -0
  14. data/README.md +1660 -0
  15. data/Rakefile +334 -0
  16. data/SECURITY.md +150 -0
  17. data/VCR_CONFIGURATION.md +80 -0
  18. data/docs/model_selection.md +637 -0
  19. data/docs/observability.md +430 -0
  20. data/docs/prompt_templates.md +422 -0
  21. data/docs/streaming.md +467 -0
  22. data/docs/structured_outputs.md +466 -0
  23. data/docs/tools.md +1016 -0
  24. data/examples/basic_completion.rb +122 -0
  25. data/examples/model_selection_example.rb +141 -0
  26. data/examples/observability_example.rb +199 -0
  27. data/examples/prompt_template_example.rb +184 -0
  28. data/examples/smart_completion_example.rb +89 -0
  29. data/examples/streaming_example.rb +176 -0
  30. data/examples/structured_outputs_example.rb +191 -0
  31. data/examples/tool_calling_example.rb +149 -0
  32. data/lib/open_router/client.rb +552 -0
  33. data/lib/open_router/http.rb +118 -0
  34. data/lib/open_router/json_healer.rb +263 -0
  35. data/lib/open_router/model_registry.rb +378 -0
  36. data/lib/open_router/model_selector.rb +462 -0
  37. data/lib/open_router/prompt_template.rb +290 -0
  38. data/lib/open_router/response.rb +371 -0
  39. data/lib/open_router/schema.rb +288 -0
  40. data/lib/open_router/streaming_client.rb +210 -0
  41. data/lib/open_router/tool.rb +221 -0
  42. data/lib/open_router/tool_call.rb +180 -0
  43. data/lib/open_router/usage_tracker.rb +277 -0
  44. data/lib/open_router/version.rb +5 -0
  45. data/lib/open_router.rb +123 -0
  46. data/sig/open_router.rbs +20 -0
  47. 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