open_router_enhanced 1.0.0 → 1.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.
@@ -95,17 +95,20 @@ module OpenRouter
95
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
96
  # @param providers [Array<String>] Optional array of provider identifiers, ordered by priority
97
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 plugins [Array<Hash>] Optional array of plugin hashes like [{id: "response-healing"}]. Available plugins: response-healing, web-search, pdf-inputs
98
99
  # @param tools [Array<Tool>] Optional array of Tool objects or tool definition hashes for function calling
99
100
  # @param tool_choice [String|Hash] Optional tool choice: "auto", "none", "required", or specific tool selection
100
101
  # @param response_format [Hash] Optional response format for structured outputs
102
+ # @param prediction [Hash] Optional predicted output for latency reduction, e.g. {type: "content", content: "predicted text"}
101
103
  # @param extras [Hash] Optional hash of model-specific parameters to send to the OpenRouter API
102
104
  # @param stream [Proc, nil] Optional callable object for streaming
103
105
  # @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)
106
+ def complete(messages, model: "openrouter/auto", providers: [], transforms: [], plugins: [], tools: [], tool_choice: nil,
107
+ response_format: nil, force_structured_output: nil, prediction: nil, extras: {}, stream: nil)
108
+ parameters = prepare_base_parameters(messages, model, providers, transforms, plugins, prediction, stream, extras)
107
109
  forced_extraction = configure_tools_and_structured_outputs!(parameters, model, tools, tool_choice,
108
110
  response_format, force_structured_output)
111
+ configure_plugins!(parameters, response_format, stream)
109
112
  validate_vision_support(model, messages)
110
113
 
111
114
  # Trigger before_request callbacks
@@ -142,6 +145,48 @@ module OpenRouter
142
145
  response["data"]
143
146
  end
144
147
 
148
+ # Performs a request to the Responses API Beta (/api/v1/responses)
149
+ # This is an OpenAI-compatible stateless API with support for reasoning.
150
+ #
151
+ # @param input [String, Array] The input text or structured message array
152
+ # @param model [String] Model identifier (e.g., "openai/o4-mini")
153
+ # @param reasoning [Hash, nil] Optional reasoning config, e.g. {effort: "high"}
154
+ # Effort levels: "minimal", "low", "medium", "high"
155
+ # @param tools [Array<Tool, Hash>] Optional array of tool definitions
156
+ # @param tool_choice [String, Hash, nil] Optional: "auto", "none", "required", or specific tool
157
+ # @param max_output_tokens [Integer, nil] Maximum tokens to generate
158
+ # @param temperature [Float, nil] Sampling temperature (0-2)
159
+ # @param top_p [Float, nil] Nucleus sampling parameter (0-1)
160
+ # @param extras [Hash] Additional parameters to pass to the API
161
+ # @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
168
+ # response = client.responses(
169
+ # "Solve this step by step: What is 15% of 80?",
170
+ # model: "openai/o4-mini",
171
+ # reasoning: { effort: "high" }
172
+ # )
173
+ # puts response.reasoning_summary
174
+ # puts response.content
175
+ def responses(input, model:, reasoning: nil, tools: [], tool_choice: nil,
176
+ max_output_tokens: nil, temperature: nil, top_p: nil, extras: {})
177
+ parameters = { model: model, input: input }
178
+ parameters[:reasoning] = reasoning if reasoning
179
+ parameters[:tools] = serialize_tools_for_responses(tools) if tools.any?
180
+ parameters[:tool_choice] = tool_choice if tool_choice
181
+ parameters[:max_output_tokens] = max_output_tokens if max_output_tokens
182
+ parameters[:temperature] = temperature if temperature
183
+ parameters[:top_p] = top_p if top_p
184
+ parameters.merge!(extras)
185
+
186
+ raw = post(path: "/responses", parameters: parameters)
187
+ ResponsesResponse.new(raw)
188
+ end
189
+
145
190
  # Create a new ModelSelector for intelligent model selection
146
191
  #
147
192
  # @return [ModelSelector] A new ModelSelector instance
@@ -267,12 +312,14 @@ module OpenRouter
267
312
  private
268
313
 
269
314
  # Prepare the base parameters for the API request
270
- def prepare_base_parameters(messages, model, providers, transforms, stream, extras)
315
+ def prepare_base_parameters(messages, model, providers, transforms, plugins, prediction, stream, extras)
271
316
  parameters = { messages: messages.dup }
272
317
 
273
318
  configure_model_parameter!(parameters, model)
274
319
  configure_provider_parameter!(parameters, providers)
275
320
  configure_transforms_parameter!(parameters, transforms)
321
+ configure_plugins_parameter!(parameters, plugins)
322
+ configure_prediction_parameter!(parameters, prediction)
276
323
  configure_stream_parameter!(parameters, stream)
277
324
 
278
325
  parameters.merge!(extras)
@@ -299,11 +346,52 @@ module OpenRouter
299
346
  parameters[:transforms] = transforms if transforms.any?
300
347
  end
301
348
 
349
+ # Configure the plugins parameter if plugins are specified
350
+ def configure_plugins_parameter!(parameters, plugins)
351
+ parameters[:plugins] = plugins.dup if plugins.any?
352
+ end
353
+
354
+ # Configure the prediction parameter for latency optimization
355
+ def configure_prediction_parameter!(parameters, prediction)
356
+ parameters[:prediction] = prediction if prediction
357
+ end
358
+
302
359
  # Configure the stream parameter if streaming is enabled
303
360
  def configure_stream_parameter!(parameters, stream)
304
361
  parameters[:stream] = stream if stream
305
362
  end
306
363
 
364
+ # Auto-add response-healing plugin when using structured outputs (non-streaming only)
365
+ # This leverages OpenRouter's native JSON healing for better reliability
366
+ def configure_plugins!(parameters, response_format, stream)
367
+ return unless should_auto_add_healing?(response_format, stream)
368
+
369
+ parameters[:plugins] ||= []
370
+
371
+ # Don't duplicate if user already specified response-healing
372
+ return if parameters[:plugins].any? { |p| p[:id] == "response-healing" || p["id"] == "response-healing" }
373
+
374
+ parameters[:plugins] << { id: "response-healing" }
375
+ end
376
+
377
+ # Determine if we should auto-add the response-healing plugin
378
+ def should_auto_add_healing?(response_format, stream)
379
+ return false unless configuration.auto_native_healing
380
+ return false if stream # Response healing doesn't work with streaming
381
+ return false unless response_format
382
+
383
+ # Check if response_format is a structured output type
384
+ case response_format
385
+ when OpenRouter::Schema
386
+ true
387
+ when Hash
388
+ type = response_format[:type] || response_format["type"]
389
+ %w[json_schema json_object].include?(type.to_s)
390
+ else
391
+ false
392
+ end
393
+ end
394
+
307
395
  # Configure tools and structured outputs, returning forced_extraction flag
308
396
  def configure_tools_and_structured_outputs!(parameters, model, tools, tool_choice, response_format,
309
397
  force_structured_output)
@@ -464,7 +552,8 @@ module OpenRouter
464
552
  end
465
553
  end
466
554
 
467
- # Serialize tools to the format expected by OpenRouter API
555
+ # Serialize tools to the format expected by OpenRouter Chat Completions API
556
+ # Format: { type: "function", function: { name: ..., parameters: ... } }
468
557
  def serialize_tools(tools)
469
558
  tools.map do |tool|
470
559
  case tool
@@ -478,6 +567,34 @@ module OpenRouter
478
567
  end
479
568
  end
480
569
 
570
+ # Serialize tools to the flat format expected by Responses API
571
+ # Format: { type: "function", name: ..., parameters: ... }
572
+ def serialize_tools_for_responses(tools)
573
+ tools.map do |tool|
574
+ tool_hash = case tool
575
+ when Tool
576
+ tool.to_h
577
+ when Hash
578
+ tool.transform_keys(&:to_sym)
579
+ else
580
+ raise ArgumentError, "Tools must be Tool objects or hashes"
581
+ end
582
+
583
+ # Flatten the nested function structure if present
584
+ if tool_hash[:function]
585
+ {
586
+ type: "function",
587
+ name: tool_hash[:function][:name],
588
+ description: tool_hash[:function][:description],
589
+ parameters: tool_hash[:function][:parameters]
590
+ }.compact
591
+ else
592
+ # Already in flat format
593
+ tool_hash
594
+ end
595
+ end
596
+ end
597
+
481
598
  # Serialize response format to the format expected by OpenRouter API
482
599
  def serialize_response_format(response_format)
483
600
  case response_format
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRouter
4
+ # Response wrapper for the Responses API Beta (/api/v1/responses)
5
+ # This API differs from chat completions in its response structure,
6
+ # using an `output` array with typed items instead of `choices`.
7
+ class ResponsesResponse
8
+ attr_reader :raw
9
+
10
+ def initialize(raw)
11
+ @raw = raw || {}
12
+ end
13
+
14
+ # Core accessors
15
+ def id
16
+ raw["id"]
17
+ end
18
+
19
+ def status
20
+ # Status can be at top level or derived from message output
21
+ raw["status"] || message_output&.dig("status")
22
+ end
23
+
24
+ def model
25
+ raw["model"]
26
+ end
27
+
28
+ def created_at
29
+ raw["created_at"]
30
+ end
31
+
32
+ def output
33
+ raw["output"] || []
34
+ end
35
+
36
+ def usage
37
+ raw["usage"] || {}
38
+ end
39
+
40
+ # Convenience method to get the assistant's text content
41
+ def content
42
+ message_output&.dig("content", 0, "text")
43
+ end
44
+
45
+ # Get reasoning summary steps (array of strings)
46
+ def reasoning_summary
47
+ reasoning_output&.dig("summary") || []
48
+ end
49
+
50
+ # Check if reasoning was included in the response
51
+ def has_reasoning?
52
+ !reasoning_output.nil?
53
+ end
54
+
55
+ # Get tool/function calls from the response as ResponsesToolCall objects
56
+ #
57
+ # @return [Array<ResponsesToolCall>] Array of tool call objects
58
+ def tool_calls
59
+ @tool_calls ||= output
60
+ .select { |o| o["type"] == "function_call" }
61
+ .map { |tc| ResponsesToolCall.new(tc) }
62
+ end
63
+
64
+ # Get raw tool call data (hashes) from the response
65
+ #
66
+ # @return [Array<Hash>] Array of raw tool call hashes
67
+ def tool_calls_raw
68
+ output.select { |o| o["type"] == "function_call" }
69
+ end
70
+
71
+ def has_tool_calls?
72
+ tool_calls.any?
73
+ end
74
+
75
+ # Execute all tool calls and return results
76
+ #
77
+ # @yield [name, arguments] Block to execute each tool
78
+ # @return [Array<ResponsesToolResult>] Results from all tool executions
79
+ #
80
+ # @example
81
+ # results = response.execute_tool_calls do |name, args|
82
+ # case name
83
+ # when "get_weather" then fetch_weather(args["location"])
84
+ # when "search" then search_web(args["query"])
85
+ # end
86
+ # end
87
+ def execute_tool_calls(&block)
88
+ tool_calls.map { |tc| tc.execute(&block) }
89
+ end
90
+
91
+ # Build a follow-up input array that includes tool results
92
+ # Use this to continue the conversation after executing tools
93
+ #
94
+ # @param original_input [String, Array] The original input sent to the API
95
+ # @param tool_results [Array<ResponsesToolResult>] Results from execute_tool_calls
96
+ # @param follow_up_message [String, nil] Optional follow-up user message
97
+ # @return [Array] Input array for the next API call
98
+ #
99
+ # @example
100
+ # # First call with tools
101
+ # response = client.responses("What's the weather?", model: "...", tools: [...])
102
+ #
103
+ # # Execute tools
104
+ # results = response.execute_tool_calls { |name, args| ... }
105
+ #
106
+ # # Build follow-up input
107
+ # next_input = response.build_follow_up_input(
108
+ # original_input: "What's the weather?",
109
+ # tool_results: results,
110
+ # follow_up_message: "Is that good for a picnic?"
111
+ # )
112
+ #
113
+ # # Continue conversation
114
+ # next_response = client.responses(next_input, model: "...")
115
+ def build_follow_up_input(original_input:, tool_results:, follow_up_message: nil)
116
+ input_items = []
117
+
118
+ # Add original user message
119
+ if original_input.is_a?(String)
120
+ input_items << {
121
+ "type" => "message",
122
+ "role" => "user",
123
+ "content" => [{ "type" => "input_text", "text" => original_input }]
124
+ }
125
+ elsif original_input.is_a?(Array)
126
+ input_items.concat(original_input)
127
+ end
128
+
129
+ # Add function calls from this response
130
+ tool_calls_raw.each do |tc|
131
+ input_items << tc
132
+ end
133
+
134
+ # Add function call outputs
135
+ tool_results.each do |result|
136
+ input_items << result.to_input_item
137
+ end
138
+
139
+ # Add assistant message if present
140
+ if message_output
141
+ input_items << message_output
142
+ end
143
+
144
+ # Add follow-up user message if provided
145
+ if follow_up_message
146
+ input_items << {
147
+ "type" => "message",
148
+ "role" => "user",
149
+ "content" => [{ "type" => "input_text", "text" => follow_up_message }]
150
+ }
151
+ end
152
+
153
+ input_items
154
+ end
155
+
156
+ # Token counts
157
+ def input_tokens
158
+ usage.dig("input_tokens") || 0
159
+ end
160
+
161
+ def output_tokens
162
+ usage.dig("output_tokens") || 0
163
+ end
164
+
165
+ def total_tokens
166
+ usage.dig("total_tokens") || 0
167
+ end
168
+
169
+ def reasoning_tokens
170
+ usage.dig("output_tokens_details", "reasoning_tokens") || 0
171
+ end
172
+
173
+ # Hash-like access for raw data
174
+ def [](key)
175
+ raw[key]
176
+ end
177
+
178
+ def dig(*keys)
179
+ raw.dig(*keys)
180
+ end
181
+
182
+ private
183
+
184
+ def message_output
185
+ output.find { |o| o["type"] == "message" }
186
+ end
187
+
188
+ def reasoning_output
189
+ output.find { |o| o["type"] == "reasoning" }
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module OpenRouter
6
+ # Represents a tool/function call from the Responses API.
7
+ # Format: type="function_call" with name/arguments at top level (not nested)
8
+ class ResponsesToolCall
9
+ include ToolCallBase
10
+
11
+ attr_reader :id, :call_id, :arguments_string
12
+
13
+ def initialize(tool_call_data)
14
+ @id = tool_call_data["id"]
15
+ @call_id = tool_call_data["call_id"]
16
+ @name = tool_call_data["name"]
17
+ @arguments_string = tool_call_data["arguments"] || "{}"
18
+ end
19
+
20
+ # Get the function name
21
+ def name
22
+ @name
23
+ end
24
+
25
+ # Alias for consistency with ToolCall
26
+ def function_name
27
+ @name
28
+ end
29
+
30
+ # Build result for execute method (required by ToolCallBase)
31
+ def build_result(result, error = nil)
32
+ ResponsesToolResult.new(self, result, error)
33
+ end
34
+
35
+ # Convert to the function_call format for conversation continuation
36
+ def to_input_item
37
+ {
38
+ "type" => "function_call",
39
+ "id" => @id,
40
+ "call_id" => @call_id,
41
+ "name" => @name,
42
+ "arguments" => @arguments_string
43
+ }
44
+ end
45
+
46
+ def to_h
47
+ to_input_item
48
+ end
49
+
50
+ def to_json(*args)
51
+ to_h.to_json(*args)
52
+ end
53
+ end
54
+
55
+ # Represents the result of executing a Responses API tool call
56
+ class ResponsesToolResult
57
+ include ToolResultBase
58
+
59
+ attr_reader :tool_call, :result, :error
60
+
61
+ def initialize(tool_call, result = nil, error = nil)
62
+ @tool_call = tool_call
63
+ @result = result
64
+ @error = error
65
+ end
66
+
67
+ # Convert to function_call_output format for conversation continuation
68
+ #
69
+ # @return [Hash] The output item for the input array
70
+ def to_input_item
71
+ output_content = if @error
72
+ { error: @error }.to_json
73
+ elsif @result.is_a?(String)
74
+ @result
75
+ else
76
+ @result.to_json
77
+ end
78
+
79
+ {
80
+ "type" => "function_call_output",
81
+ "id" => "fc_output_#{SecureRandom.hex(8)}",
82
+ "call_id" => @tool_call.call_id,
83
+ "output" => output_content
84
+ }
85
+ end
86
+
87
+ def to_h
88
+ to_input_item
89
+ end
90
+
91
+ def to_json(*args)
92
+ to_h.to_json(*args)
93
+ end
94
+ end
95
+ end
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
-
5
3
  module OpenRouter
6
- class ToolCallError < Error; end
7
-
4
+ # Represents a tool/function call from the Chat Completions API.
5
+ # Format: tool_calls[].function.name/arguments (nested under function key)
8
6
  class ToolCall
7
+ include ToolCallBase
8
+
9
9
  attr_reader :id, :type, :function_name, :arguments_string
10
10
 
11
11
  def initialize(tool_call_data)
@@ -15,34 +15,17 @@ module OpenRouter
15
15
  raise ToolCallError, "Invalid tool call data: missing function" unless tool_call_data["function"]
16
16
 
17
17
  @function_name = tool_call_data["function"]["name"]
18
- @arguments_string = tool_call_data["function"]["arguments"]
19
- end
20
-
21
- # Parse the arguments JSON string into a Ruby hash
22
- def arguments
23
- @arguments ||= begin
24
- JSON.parse(@arguments_string)
25
- rescue JSON::ParserError => e
26
- raise ToolCallError, "Failed to parse tool call arguments: #{e.message}"
27
- end
18
+ @arguments_string = tool_call_data["function"]["arguments"] || "{}"
28
19
  end
29
20
 
30
- # Get the function name (alias for consistency)
21
+ # Get the function name
31
22
  def name
32
23
  @function_name
33
24
  end
34
25
 
35
- # Execute the tool call with a provided block
36
- # The block should accept (name, arguments) and return the result
37
- def execute(&block)
38
- raise ArgumentError, "Block required for tool execution" unless block_given?
39
-
40
- begin
41
- result = block.call(@function_name, arguments)
42
- ToolResult.new(self, result)
43
- rescue StandardError => e
44
- ToolResult.new(self, nil, e.message)
45
- end
26
+ # Build result for execute method (required by ToolCallBase)
27
+ def build_result(result, error = nil)
28
+ ToolResult.new(self, result, error)
46
29
  end
47
30
 
48
31
  # Convert this tool call to a message format for conversation continuation
@@ -50,16 +33,7 @@ module OpenRouter
50
33
  {
51
34
  role: "assistant",
52
35
  content: nil,
53
- tool_calls: [
54
- {
55
- id: @id,
56
- type: @type,
57
- function: {
58
- name: @function_name,
59
- arguments: @arguments_string
60
- }
61
- }
62
- ]
36
+ tool_calls: [to_h]
63
37
  }
64
38
  end
65
39
 
@@ -95,8 +69,6 @@ module OpenRouter
95
69
 
96
70
  # Validate against a provided array of tools (Tool instances or hashes)
97
71
  def valid?(tools:)
98
- # tools is now a required keyword argument
99
-
100
72
  schema = find_schema_for_call(tools)
101
73
  return true unless schema # No validation if tool not found
102
74
 
@@ -110,8 +82,6 @@ module OpenRouter
110
82
  end
111
83
 
112
84
  def validation_errors(tools:)
113
- # tools is now a required keyword argument
114
-
115
85
  schema = find_schema_for_call(tools)
116
86
  return [] unless schema # No errors if tool not found
117
87
 
@@ -144,8 +114,10 @@ module OpenRouter
144
114
  end
145
115
  end
146
116
 
147
- # Represents the result of executing a tool call
117
+ # Represents the result of executing a Chat Completions tool call
148
118
  class ToolResult
119
+ include ToolResultBase
120
+
149
121
  attr_reader :tool_call, :result, :error
150
122
 
151
123
  def initialize(tool_call, result = nil, error = nil)
@@ -154,27 +126,9 @@ module OpenRouter
154
126
  @error = error
155
127
  end
156
128
 
157
- def success?
158
- @error.nil?
159
- end
160
-
161
- def failure?
162
- !success?
163
- end
164
-
165
129
  # Convert to message format for conversation continuation
166
130
  def to_message
167
131
  @tool_call.to_result_message(@error || @result)
168
132
  end
169
-
170
- # Create a failed result
171
- def self.failure(tool_call, error)
172
- new(tool_call, nil, error)
173
- end
174
-
175
- # Create a successful result
176
- def self.success(tool_call, result)
177
- new(tool_call, result, nil)
178
- end
179
133
  end
180
134
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OpenRouter
6
+ class ToolCallError < Error; end
7
+
8
+ # Shared behavior for tool call parsing across different API formats.
9
+ # Include this module and define `name` and `arguments_string` accessors.
10
+ module ToolCallBase
11
+ # Parse the arguments JSON string into a Ruby hash
12
+ def arguments
13
+ @arguments ||= begin
14
+ JSON.parse(arguments_string)
15
+ rescue JSON::ParserError => e
16
+ raise ToolCallError, "Failed to parse tool call arguments: #{e.message}"
17
+ end
18
+ end
19
+
20
+ # Execute the tool call with a provided block
21
+ # The block receives (name, arguments) and should return the result
22
+ #
23
+ # @yield [name, arguments] Block to execute the tool
24
+ # @return [ToolResultBase] The result of execution
25
+ def execute(&block)
26
+ raise ArgumentError, "Block required for tool execution" unless block_given?
27
+
28
+ begin
29
+ result = block.call(name, arguments)
30
+ build_result(result)
31
+ rescue StandardError => e
32
+ build_result(nil, e.message)
33
+ end
34
+ end
35
+
36
+ # Subclasses must implement this to return the appropriate result type
37
+ def build_result(_result, _error = nil)
38
+ raise NotImplementedError, "Subclasses must implement build_result"
39
+ end
40
+ end
41
+
42
+ # Shared behavior for tool execution results.
43
+ # Include this module and define `tool_call`, `result`, and `error` accessors.
44
+ module ToolResultBase
45
+ def success?
46
+ error.nil?
47
+ end
48
+
49
+ def failure?
50
+ !success?
51
+ end
52
+
53
+ module ClassMethods
54
+ # Create a failed result
55
+ def failure(tool_call, error)
56
+ new(tool_call, nil, error)
57
+ end
58
+
59
+ # Create a successful result
60
+ def success(tool_call, result)
61
+ new(tool_call, result, nil)
62
+ end
63
+ end
64
+
65
+ def self.included(base)
66
+ base.extend(ClassMethods)
67
+ end
68
+ end
69
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenRouter
4
- VERSION = "1.0.0"
4
+ VERSION = "1.2.0"
5
5
  end