ruby_llm-responses_api 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ca7cab6681d016096c3c578e5cb0c74f21af60ec03cc4fd8667263a95cd97ce
4
- data.tar.gz: ed3d4931a835334aba4c351da61f4293464cfbeb3a3fb8399468b0a3665c962c
3
+ metadata.gz: 5eafd14a08ce95dc9637f022c3dcd0b88dc979314efd534ec3a3d5dbb2a6e396
4
+ data.tar.gz: 6b84beeec2204e791727bb969aac9b9e490e574204c78ab87ec6b69692f1ad7d
5
5
  SHA512:
6
- metadata.gz: 4ab75bc29fe723177cd82c988b89f298e367e363d9224998bf3cde0372eb94f153804b6ffc3f8ac75032a137c1ec7fe1d065ca7d7d8452dadabc0d27d24abfa9
7
- data.tar.gz: e4b6f9837af18c683392a3436942e4aed6e03d245ab1ae0070b95c20111ec0aae01c35f44fd929808fe75c926257b2e2a0218b527f9f12744e8003d7decc6df4
6
+ metadata.gz: 5216a047aa783ed7b221e91f0173fd5785ae003b3006a9dcbcdb07effef569e73cac28fab1129f9e157e15f03a5219d18fdcfcc84c17406f3913fe8cdc1ed761
7
+ data.tar.gz: 3aa365e82445b4deb9f3b2dcda4ec47f096728f05c3f7a89eb9076be41ba81e873e2f513b4057391b84d6f1e1b8fd630ade743a46b0c2a37f9c2187eb43efbd6
data/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.0] - 2026-02-25
9
+
10
+ ### Added
11
+
12
+ - **Batch API** for processing many requests asynchronously at 50% lower cost
13
+ - `RubyLLM.batch(model:, provider:)` factory method
14
+ - `Batch#add` to queue requests with auto-generated or custom IDs
15
+ - `Batch#create!` to upload JSONL and create the batch in one call
16
+ - `Batch#wait!` to poll until completion with progress callbacks
17
+ - `Batch#results` returns a `Hash<custom_id, Message>` using the same parsing as `Chat`
18
+ - `Batch#errors`, `Batch#cancel!`, and status helpers (`completed?`, `in_progress?`, `failed?`)
19
+ - Resume from a previous session via `RubyLLM.batch(id: "batch_abc", provider: :openai_responses)`
20
+ - `RubyLLM.batches` to list existing batches
21
+ - `Batches` helper module with JSONL builder, URL helpers, and result parsing
22
+
23
+ ## [0.4.1] - 2026-02-24
24
+
25
+ ### Added
26
+
27
+ - `chat.with_params(transport: :websocket)` integration with standard `chat.ask` interface
28
+ - `WebSocket#call` for accepting pre-built payloads from the provider
29
+
30
+ ### Fixed
31
+
32
+ - WebSocket responses now preserve token counts from `StreamAccumulator`
33
+
8
34
  ## [0.4.0] - 2026-02-24
9
35
 
10
36
  ### Added
data/README.md CHANGED
@@ -259,6 +259,44 @@ image_results = RubyLLM::ResponsesAPI::BuiltInTools.parse_image_generation_resu
259
259
  citations = RubyLLM::ResponsesAPI::BuiltInTools.extract_citations(message_content)
260
260
  ```
261
261
 
262
+ ## Batch API
263
+
264
+ Process many requests asynchronously at 50% lower cost with a 24-hour completion window:
265
+
266
+ ```ruby
267
+ # Create a batch
268
+ batch = RubyLLM.batch(model: 'gpt-4o', provider: :openai_responses)
269
+
270
+ # Add requests (auto-generates IDs or use your own)
271
+ batch.add("What is Ruby?")
272
+ batch.add("What is Python?", instructions: "Be brief", temperature: 0.5)
273
+ batch.add("Translate: hello", id: "translate_1")
274
+
275
+ # Submit (uploads JSONL file + creates batch)
276
+ batch.create!
277
+ batch.id # => "batch_abc123"
278
+
279
+ # Poll until done
280
+ batch.wait!(interval: 60) { |b| puts "#{b.completed_count}/#{b.total_count}" }
281
+
282
+ # Get results as Messages keyed by custom_id
283
+ results = batch.results
284
+ results["request_0"].content # => "Ruby is a dynamic..."
285
+ results["translate_1"].content # => "Hola"
286
+
287
+ # Resume from a previous session
288
+ batch = RubyLLM.batch(id: "batch_abc123", provider: :openai_responses)
289
+ batch.results
290
+
291
+ # Cancel a running batch
292
+ batch.cancel!
293
+
294
+ # List existing batches
295
+ RubyLLM.batches(provider: :openai_responses)
296
+ ```
297
+
298
+ **Constraints**: No `web_search`/`code_interpreter` tools, no `previous_response_id` chaining, max 50k requests per batch, 200MB file limit.
299
+
262
300
  ## WebSocket Mode
263
301
 
264
302
  For agentic workflows with many tool-call round trips, WebSocket mode provides lower latency by maintaining a persistent connection instead of HTTP requests per turn.
@@ -269,59 +307,40 @@ Requires the `websocket-client-simple` gem:
269
307
  gem 'websocket-client-simple'
270
308
  ```
271
309
 
272
- ### Basic usage
310
+ ### Usage
273
311
 
274
- ```ruby
275
- ws = RubyLLM::ResponsesAPI::WebSocket.new(api_key: ENV['OPENAI_API_KEY'])
276
- ws.connect
312
+ Just add `transport: :websocket` to your params -- the standard `chat.ask` API works as-is:
277
313
 
278
- # Stream a response
279
- message = ws.create_response(
280
- model: 'gpt-4o',
281
- input: [{ type: 'message', role: 'user', content: 'Hello!' }]
282
- ) do |chunk|
283
- print chunk.content if chunk.content
284
- end
314
+ ```ruby
315
+ chat = RubyLLM.chat(model: 'gpt-4o', provider: :openai_responses)
316
+ chat.with_params(transport: :websocket)
285
317
 
286
- puts "\n#{message.content}"
318
+ chat.ask("Hello!")
319
+ chat.ask("What's 2+2?") # reuses the same WebSocket connection
287
320
  ```
288
321
 
289
- ### Multi-turn conversations
290
-
291
- `previous_response_id` is tracked automatically across turns:
322
+ Streaming works the same way:
292
323
 
293
324
  ```ruby
294
- ws.create_response(model: 'gpt-4o', input: [
295
- { type: 'message', role: 'user', content: 'My name is Alice.' }
296
- ])
297
-
298
- ws.create_response(model: 'gpt-4o', input: [
299
- { type: 'message', role: 'user', content: "What's my name?" }
300
- ])
301
- # => "Alice" (auto-chained via previous_response_id)
325
+ chat.ask("Tell me a story") { |chunk| print chunk.content }
302
326
  ```
303
327
 
304
- ### With tools
328
+ ### Direct WebSocket access
329
+
330
+ For advanced use cases (raw Responses API format, warmup, explicit connection management):
305
331
 
306
332
  ```ruby
333
+ ws = RubyLLM::ResponsesAPI::WebSocket.new(api_key: ENV['OPENAI_API_KEY'])
334
+ ws.connect
335
+
307
336
  ws.create_response(
308
337
  model: 'gpt-4o',
309
- input: [{ type: 'message', role: 'user', content: 'Search for Ruby 3.4 release notes' }],
310
- tools: [{ type: 'web_search_preview' }]
311
- )
312
- ```
313
-
314
- ### Warmup
315
-
316
- Pre-cache model weights without generating output:
338
+ input: [{ type: 'message', role: 'user', content: 'Hello!' }]
339
+ ) { |chunk| print chunk.content }
317
340
 
318
- ```ruby
341
+ # Pre-cache model weights
319
342
  ws.warmup(model: 'gpt-4o')
320
- ```
321
343
 
322
- ### Cleanup
323
-
324
- ```ruby
325
344
  ws.disconnect
326
345
  ```
327
346
 
@@ -333,6 +352,7 @@ ws.disconnect
333
352
  - **Server-side compaction** - Run multi-hour agent sessions without hitting context limits
334
353
  - **Containers** - Persistent execution environments with networking and file management
335
354
  - **WebSocket mode** - Lower-latency persistent connections for agentic tool-call loops
355
+ - **Batch API** - Process bulk requests at 50% lower cost with 24-hour turnaround
336
356
 
337
357
  ## License
338
358
 
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+
5
+ module RubyLLM
6
+ module Providers
7
+ class OpenAIResponses
8
+ # High-level interface for OpenAI's Batch API.
9
+ # Hides JSONL serialization, file upload, polling, and result parsing
10
+ # behind a clean Ruby API that mirrors RubyLLM::Chat.
11
+ #
12
+ # @example
13
+ # batch = RubyLLM.batch(model: 'gpt-4o', provider: :openai_responses)
14
+ # batch.add("What is Ruby?")
15
+ # batch.add("What is Python?", instructions: "Be brief")
16
+ # batch.create!
17
+ # batch.wait! { |b| puts "#{b.completed_count}/#{b.total_count}" }
18
+ # batch.results # => { "request_0" => Message, ... }
19
+ class Batch
20
+ attr_reader :id, :requests
21
+
22
+ # @param model [String] Model ID (e.g. 'gpt-4o')
23
+ # @param provider [Symbol, RubyLLM::Providers::OpenAIResponses] Provider slug or instance
24
+ # @param id [String, nil] Existing batch ID to resume
25
+ def initialize(model: nil, provider: :openai_responses, id: nil)
26
+ @model = model
27
+ @provider = resolve_provider(provider)
28
+ @requests = []
29
+ @request_counter = 0
30
+ @data = {}
31
+
32
+ return unless id
33
+
34
+ @id = id
35
+ refresh!
36
+ end
37
+
38
+ # Queue a request for inclusion in the batch.
39
+ # @param input [String, Array] User message or Responses API input array
40
+ # @param id [String, nil] Custom ID for this request (auto-generated if omitted)
41
+ # @param instructions [String, nil] System/developer instructions
42
+ # @param temperature [Float, nil] Sampling temperature
43
+ # @param tools [Array, nil] Tools configuration
44
+ # @return [self]
45
+ def add(input, id: nil, instructions: nil, temperature: nil, tools: nil, **extra) # rubocop:disable Metrics/ParameterLists
46
+ custom_id = id || "request_#{@request_counter}"
47
+ @request_counter += 1
48
+
49
+ body = { model: @model, input: Batches.normalize_input(input) }
50
+ body[:instructions] = instructions if instructions
51
+ body[:temperature] = temperature if temperature
52
+ body[:tools] = tools if tools
53
+ body.merge!(extra) unless extra.empty?
54
+
55
+ @requests << { custom_id: custom_id, body: body }
56
+ self
57
+ end
58
+
59
+ # Build JSONL, upload the file, and create the batch.
60
+ # @param metadata [Hash, nil] Optional metadata for the batch
61
+ # @return [self]
62
+ def create!(metadata: nil)
63
+ raise Error.new(nil, 'No requests added') if @requests.empty?
64
+ raise Error.new(nil, 'Batch already created') if @id
65
+
66
+ jsonl = Batches.build_jsonl(@requests)
67
+ file_id = upload_file(jsonl)
68
+
69
+ payload = {
70
+ input_file_id: file_id,
71
+ endpoint: '/v1/responses',
72
+ completion_window: '24h'
73
+ }
74
+ payload[:metadata] = metadata if metadata
75
+
76
+ response = @provider.instance_variable_get(:@connection).post(Batches.batches_url, payload)
77
+ @data = response.body
78
+ @id = @data['id']
79
+ self
80
+ end
81
+
82
+ # Fetch the latest batch status from the API.
83
+ # @return [self]
84
+ def refresh!
85
+ raise Error.new(nil, 'Batch not yet created') unless @id
86
+
87
+ response = @provider.instance_variable_get(:@connection).get(Batches.batch_url(@id))
88
+ @data = response.body
89
+ self
90
+ end
91
+
92
+ # @return [String, nil] Batch status
93
+ def status
94
+ @data['status']
95
+ end
96
+
97
+ # @return [Integer, nil] Number of completed requests
98
+ def completed_count
99
+ @data.dig('request_counts', 'completed')
100
+ end
101
+
102
+ # @return [Integer, nil] Total number of requests
103
+ def total_count
104
+ @data.dig('request_counts', 'total')
105
+ end
106
+
107
+ # @return [Integer, nil] Number of failed requests
108
+ def failed_count
109
+ @data.dig('request_counts', 'failed')
110
+ end
111
+
112
+ # @return [Boolean]
113
+ def completed?
114
+ status == Batches::COMPLETED
115
+ end
116
+
117
+ # @return [Boolean]
118
+ def in_progress?
119
+ Batches.pending?(status)
120
+ end
121
+
122
+ # @return [Boolean]
123
+ def failed?
124
+ status == Batches::FAILED
125
+ end
126
+
127
+ # @return [Boolean]
128
+ def expired?
129
+ status == Batches::EXPIRED
130
+ end
131
+
132
+ # @return [Boolean]
133
+ def cancelled?
134
+ status == Batches::CANCELLED
135
+ end
136
+
137
+ # Block until the batch reaches a terminal status.
138
+ # @param interval [Numeric] Seconds between polls (default: 30)
139
+ # @param timeout [Numeric, nil] Maximum seconds to wait
140
+ # @yield [Batch] Called after each poll
141
+ # @return [self]
142
+ def wait!(interval: 30, timeout: nil)
143
+ start_time = Time.now
144
+
145
+ loop do
146
+ refresh!
147
+ yield self if block_given?
148
+
149
+ break if Batches.terminal?(status)
150
+
151
+ if timeout && (Time.now - start_time) > timeout
152
+ raise Error.new(nil, "Batch polling timeout after #{timeout} seconds")
153
+ end
154
+
155
+ sleep interval
156
+ end
157
+
158
+ self
159
+ end
160
+
161
+ # Download and parse the output file into a Hash of Messages.
162
+ # @return [Hash<String, Message>] Results keyed by custom_id
163
+ def results
164
+ output_file_id = @data['output_file_id']
165
+ raise Error.new(nil, 'No output file available yet') unless output_file_id
166
+
167
+ jsonl = fetch_file_content(output_file_id)
168
+ Batches.parse_results_to_messages(jsonl)
169
+ end
170
+
171
+ # Download and parse the error file.
172
+ # @return [Array<Hash>] Error entries
173
+ def errors
174
+ error_file_id = @data['error_file_id']
175
+ return [] unless error_file_id
176
+
177
+ jsonl = fetch_file_content(error_file_id)
178
+ Batches.parse_errors(jsonl)
179
+ end
180
+
181
+ # Cancel the batch.
182
+ # @return [self]
183
+ def cancel!
184
+ raise Error.new(nil, 'Batch not yet created') unless @id
185
+
186
+ response = @provider.instance_variable_get(:@connection).post(Batches.cancel_batch_url(@id), {})
187
+ @data = response.body
188
+ self
189
+ end
190
+
191
+ private
192
+
193
+ def resolve_provider(provider)
194
+ case provider
195
+ when Symbol, String
196
+ slug = provider.to_sym
197
+ provider_class = RubyLLM::Provider.providers[slug]
198
+ raise Error.new(nil, "Unknown provider: #{slug}") unless provider_class
199
+
200
+ provider_class.new(RubyLLM.config)
201
+ else
202
+ provider
203
+ end
204
+ end
205
+
206
+ # Upload a JSONL string as a file to the Files API.
207
+ # @return [String] The uploaded file ID
208
+ def upload_file(jsonl)
209
+ io = StringIO.new(jsonl)
210
+ file_part = Faraday::Multipart::FilePart.new(io, 'application/jsonl', 'batch_requests.jsonl')
211
+
212
+ response = @provider.instance_variable_get(:@connection).post(Batches.files_url, {
213
+ file: file_part,
214
+ purpose: 'batch'
215
+ })
216
+ response.body['id']
217
+ end
218
+
219
+ # Download raw file content, bypassing JSON response middleware.
220
+ # @return [String] Raw file content
221
+ def fetch_file_content(file_id)
222
+ conn = @provider.instance_variable_get(:@connection)
223
+ response = conn.connection.get(Batches.file_content_url(file_id)) do |req|
224
+ req.headers.merge!(@provider.headers)
225
+ end
226
+ response.body
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RubyLLM
6
+ module Providers
7
+ class OpenAIResponses
8
+ # Stateless helpers for the Batch API.
9
+ # Provides URL builders, JSONL serialization, status constants, and result parsing.
10
+ module Batches
11
+ module_function
12
+
13
+ # Status constants
14
+ VALIDATING = 'validating'
15
+ IN_PROGRESS = 'in_progress'
16
+ COMPLETED = 'completed'
17
+ FAILED = 'failed'
18
+ CANCELLED = 'cancelled'
19
+ CANCELLING = 'cancelling'
20
+ EXPIRED = 'expired'
21
+
22
+ TERMINAL_STATUSES = [COMPLETED, FAILED, CANCELLED, EXPIRED].freeze
23
+ PENDING_STATUSES = [VALIDATING, IN_PROGRESS, CANCELLING].freeze
24
+
25
+ # --- URL helpers ---
26
+
27
+ def files_url
28
+ 'files'
29
+ end
30
+
31
+ def batches_url
32
+ 'batches'
33
+ end
34
+
35
+ def batch_url(batch_id)
36
+ "batches/#{batch_id}"
37
+ end
38
+
39
+ def cancel_batch_url(batch_id)
40
+ "batches/#{batch_id}/cancel"
41
+ end
42
+
43
+ def file_content_url(file_id)
44
+ "files/#{file_id}/content"
45
+ end
46
+
47
+ # --- Status helpers ---
48
+
49
+ def terminal?(status)
50
+ TERMINAL_STATUSES.include?(status)
51
+ end
52
+
53
+ def pending?(status)
54
+ PENDING_STATUSES.include?(status)
55
+ end
56
+
57
+ # --- JSONL builder ---
58
+
59
+ # Build a JSONL string from an array of request hashes.
60
+ # Each request has: custom_id, body (the Responses API payload)
61
+ def build_jsonl(requests)
62
+ requests.map do |req|
63
+ JSON.generate({
64
+ custom_id: req[:custom_id],
65
+ method: 'POST',
66
+ url: '/v1/responses',
67
+ body: req[:body]
68
+ })
69
+ end.join("\n")
70
+ end
71
+
72
+ # --- Input normalization ---
73
+
74
+ # Wraps a plain string into the Responses API input format.
75
+ def normalize_input(input)
76
+ case input
77
+ when String
78
+ [{ type: 'message', role: 'user', content: input }]
79
+ when Array
80
+ input
81
+ else
82
+ input
83
+ end
84
+ end
85
+
86
+ # --- Result parsing ---
87
+
88
+ # Parse JSONL output into an array of raw result hashes.
89
+ def parse_results(jsonl_string)
90
+ jsonl_string.each_line.filter_map do |line|
91
+ line = line.strip
92
+ next if line.empty?
93
+
94
+ JSON.parse(line)
95
+ end
96
+ end
97
+
98
+ # Parse JSONL output into a Hash of { custom_id => Message }.
99
+ # Reuses Chat.extract_output_text and Chat.extract_tool_calls to avoid duplication.
100
+ def parse_results_to_messages(jsonl_string)
101
+ results = parse_results(jsonl_string)
102
+ results.each_with_object({}) do |result, hash|
103
+ custom_id = result['custom_id']
104
+ response_body = result.dig('response', 'body')
105
+ next unless response_body
106
+
107
+ output = response_body['output'] || []
108
+ content = Chat.extract_output_text(output)
109
+ tool_calls = Chat.extract_tool_calls(output)
110
+ usage = response_body['usage'] || {}
111
+
112
+ hash[custom_id] = Message.new(
113
+ role: :assistant,
114
+ content: content,
115
+ tool_calls: tool_calls,
116
+ input_tokens: usage['input_tokens'],
117
+ output_tokens: usage['output_tokens'],
118
+ model_id: response_body['model']
119
+ )
120
+ end
121
+ end
122
+
123
+ # Parse JSONL error file into an array of error hashes.
124
+ def parse_errors(jsonl_string)
125
+ results = parse_results(jsonl_string)
126
+ results.select { |r| r.dig('response', 'status_code')&.>= 400 }
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -12,7 +12,7 @@ module RubyLLM
12
12
 
13
13
  module_function
14
14
 
15
- def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil, thinking: nil) # rubocop:disable Metrics/ParameterLists
15
+ def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil, thinking: nil) # rubocop:disable Metrics/ParameterLists,Lint/UnusedMethodArgument
16
16
  # Extract system messages for instructions
17
17
  system_messages = messages.select { |m| m.role == :system }
18
18
  non_system_messages = messages.reject { |m| m.role == :system }
@@ -191,7 +191,7 @@ module RubyLLM
191
191
  end
192
192
 
193
193
  def shell_tool(environment_type: 'container_auto', container_id: nil,
194
- network_policy: nil, memory_limit: nil)
194
+ network_policy: nil, memory_limit: nil)
195
195
  env = if container_id
196
196
  { type: 'container_reference', container_id: container_id }
197
197
  else
@@ -11,16 +11,17 @@ module RubyLLM
11
11
  #
12
12
  # Requires the `websocket-client-simple` gem (soft dependency).
13
13
  #
14
- # Usage:
14
+ # Integrated usage (recommended):
15
+ # chat = RubyLLM.chat(model: 'gpt-4o', provider: :openai_responses)
16
+ # chat.with_params(transport: :websocket)
17
+ # chat.ask("Hello!")
18
+ #
19
+ # Standalone usage (advanced):
15
20
  # ws = RubyLLM::ResponsesAPI::WebSocket.new(api_key: ENV['OPENAI_API_KEY'])
16
21
  # ws.connect
17
- #
18
- # ws.create_response(model: 'gpt-4o', input: [{ type: 'message', role: 'user', content: 'Hi' }]) do |chunk|
19
- # print chunk.content if chunk.content
20
- # end
21
- #
22
+ # ws.create_response(model: 'gpt-4o', input: [...]) { |chunk| ... }
22
23
  # ws.disconnect
23
- class WebSocket
24
+ class WebSocket # rubocop:disable Metrics/ClassLength
24
25
  WEBSOCKET_PATH = '/v1/responses'
25
26
  KNOWN_PARAMS = %i[store metadata compact_threshold context_management].freeze
26
27
 
@@ -73,7 +74,6 @@ module RubyLLM
73
74
  end
74
75
  end
75
76
 
76
- # Route all messages to the current queue (swapped per request)
77
77
  @ws.on(:message) do |msg|
78
78
  q = @mutex.synchronize { @message_queue }
79
79
  q&.push(msg.data)
@@ -89,35 +89,47 @@ module RubyLLM
89
89
  self
90
90
  end
91
91
 
92
- # Send a response.create request and stream chunks via block.
93
- # @param model [String] model ID
94
- # @param input [Array<Hash>] input items in Responses API format
95
- # @param tools [Array<Hash>, nil] tool definitions
96
- # @param previous_response_id [String, nil] chain to a prior response
97
- # @param instructions [String, nil] system/developer instructions
98
- # @param extra [Hash] additional top-level fields forwarded to the API
92
+ # Send a pre-built payload over WebSocket, streaming chunks via block.
93
+ # This is the integration point for Provider#complete -- it accepts the
94
+ # same payload hash that render_payload returns.
95
+ #
96
+ # @param payload [Hash] Responses API payload (model, input, tools, etc.)
99
97
  # @yield [RubyLLM::Chunk] each streamed chunk
100
98
  # @return [RubyLLM::Message] the assembled final message
101
- # @raise [ConcurrencyError] if another response is already in flight
102
- # @raise [ConnectionError] if not connected
103
- def create_response(model:, input:, tools: nil, previous_response_id: nil, instructions: nil, **extra, &block)
99
+ def call(payload, &)
104
100
  ensure_connected!
105
101
  acquire_flight!
106
102
 
107
103
  queue = Queue.new
108
104
  @mutex.synchronize { @message_queue = queue }
109
105
 
110
- payload = build_payload(
106
+ envelope = { type: 'response.create', response: payload.except(:stream) }
107
+ send_json(envelope)
108
+ accumulate_response(queue, &)
109
+ ensure
110
+ @mutex.synchronize { @message_queue = nil }
111
+ release_flight!
112
+ end
113
+
114
+ # Send a response.create request using raw Responses API format.
115
+ # Useful for standalone usage outside the RubyLLM chat interface.
116
+ #
117
+ # @param model [String] model ID
118
+ # @param input [Array<Hash>] input items in Responses API format
119
+ # @param tools [Array<Hash>, nil] tool definitions
120
+ # @param previous_response_id [String, nil] chain to a prior response
121
+ # @param instructions [String, nil] system/developer instructions
122
+ # @param extra [Hash] additional fields forwarded to the API
123
+ # @yield [RubyLLM::Chunk] each streamed chunk
124
+ # @return [RubyLLM::Message] the assembled final message
125
+ def create_response(model:, input:, tools: nil, previous_response_id: nil, instructions: nil, **extra, &block) # rubocop:disable Metrics/ParameterLists
126
+ payload = build_standalone_payload(
111
127
  model: model, input: input, tools: tools,
112
128
  previous_response_id: previous_response_id,
113
129
  instructions: instructions, **extra
114
130
  )
115
131
 
116
- send_json(payload)
117
- accumulate_response(queue, &block)
118
- ensure
119
- @mutex.synchronize { @message_queue = nil }
120
- release_flight!
132
+ call(payload, &block)
121
133
  end
122
134
 
123
135
  # Warm up the connection by sending a response.create with generate: false.
@@ -209,7 +221,7 @@ module RubyLLM
209
221
  headers
210
222
  end
211
223
 
212
- def build_payload(model:, input:, tools: nil, previous_response_id: nil, instructions: nil, **extra)
224
+ def build_standalone_payload(model:, input:, tools: nil, previous_response_id: nil, instructions: nil, **extra) # rubocop:disable Metrics/ParameterLists
213
225
  prev_id = previous_response_id || @last_response_id
214
226
  response = { model: model, input: input }
215
227
  response[:tools] = tools.map { |t| Tools.tool_for(t) } if tools&.any?
@@ -219,8 +231,8 @@ module RubyLLM
219
231
  State.apply_state_params(response, extra)
220
232
  Compaction.apply_compaction(response, extra)
221
233
 
222
- forwarded = extra.reject { |k, _| KNOWN_PARAMS.include?(k) }
223
- { type: 'response.create', response: response.merge(forwarded) }
234
+ forwarded = extra.except(*KNOWN_PARAMS)
235
+ response.merge(forwarded)
224
236
  end
225
237
 
226
238
  def send_json(payload)
@@ -247,7 +259,9 @@ module RubyLLM
247
259
  end
248
260
  end
249
261
 
250
- build_final_message(accumulator)
262
+ message = accumulator.to_message(nil)
263
+ message.response_id = @last_response_id
264
+ message
251
265
  end
252
266
 
253
267
  def track_response_id(data)
@@ -255,16 +269,6 @@ module RubyLLM
255
269
  @mutex.synchronize { @last_response_id = resp_id } if resp_id
256
270
  end
257
271
 
258
- def build_final_message(accumulator)
259
- Message.new(
260
- role: :assistant,
261
- content: accumulator.content,
262
- tool_calls: accumulator.tool_calls.empty? ? nil : accumulator.tool_calls,
263
- model_id: accumulator.model_id,
264
- response_id: @last_response_id
265
- )
266
- end
267
-
268
272
  def ensure_connected!
269
273
  raise ConnectionError, 'WebSocket is not connected. Call #connect first.' unless connected?
270
274
  end
@@ -16,6 +16,17 @@ module RubyLLM
16
16
  @config.openai_api_base || 'https://api.openai.com/v1'
17
17
  end
18
18
 
19
+ # Override to support WebSocket transport via with_params(transport: :websocket)
20
+ def complete(messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, thinking: nil, &block) # rubocop:disable Metrics/ParameterLists
21
+ if params[:transport]&.to_sym == :websocket
22
+ ws_complete(messages, tools: tools, temperature: temperature, model: model,
23
+ params: params.except(:transport), schema: schema,
24
+ thinking: thinking, &block)
25
+ else
26
+ super
27
+ end
28
+ end
29
+
19
30
  def headers
20
31
  {
21
32
  'Authorization' => "Bearer #{@config.openai_api_key}",
@@ -135,8 +146,53 @@ module RubyLLM
135
146
  response.body
136
147
  end
137
148
 
149
+ # --- Batch API ---
150
+
151
+ # List batches
152
+ # @param limit [Integer] Number of batches to return (default: 20)
153
+ # @param after [String, nil] Cursor for pagination
154
+ # @return [Hash] Batch listing with 'data' array
155
+ def list_batches(limit: 20, after: nil)
156
+ url = Batches.batches_url
157
+ params = { limit: limit }
158
+ params[:after] = after if after
159
+ response = @connection.get(url) do |req|
160
+ req.params.merge!(params)
161
+ end
162
+ response.body
163
+ end
164
+
138
165
  private
139
166
 
167
+ def ws_complete(messages, tools:, temperature:, model:, params:, schema:, thinking:, &block) # rubocop:disable Metrics/ParameterLists
168
+ normalized_temperature = maybe_normalize_temperature(temperature, model)
169
+
170
+ payload = Utils.deep_merge(
171
+ render_payload(
172
+ messages,
173
+ tools: tools,
174
+ temperature: normalized_temperature,
175
+ model: model,
176
+ stream: true,
177
+ schema: schema,
178
+ thinking: thinking
179
+ ),
180
+ params
181
+ )
182
+
183
+ ws_connection.connect unless ws_connection.connected?
184
+ ws_connection.call(payload, &block)
185
+ end
186
+
187
+ def ws_connection
188
+ @ws_connection ||= WebSocket.new(
189
+ api_key: @config.openai_api_key,
190
+ api_base: api_base,
191
+ organization_id: @config.openai_organization_id,
192
+ project_id: @config.openai_project_id
193
+ )
194
+ end
195
+
140
196
  # DELETE request via the underlying Faraday connection
141
197
  # RubyLLM::Connection only exposes get/post, so we use Faraday directly
142
198
  def delete_request(url)
@@ -145,8 +201,6 @@ module RubyLLM
145
201
  end
146
202
  end
147
203
 
148
- public
149
-
150
204
  class << self
151
205
  def capabilities
152
206
  OpenAIResponses::Capabilities
@@ -19,6 +19,8 @@ require_relative 'ruby_llm/providers/openai_responses/state'
19
19
  require_relative 'ruby_llm/providers/openai_responses/background'
20
20
  require_relative 'ruby_llm/providers/openai_responses/compaction'
21
21
  require_relative 'ruby_llm/providers/openai_responses/containers'
22
+ require_relative 'ruby_llm/providers/openai_responses/batches'
23
+ require_relative 'ruby_llm/providers/openai_responses/batch'
22
24
  require_relative 'ruby_llm/providers/openai_responses/message_extension'
23
25
  require_relative 'ruby_llm/providers/openai_responses/model_registry'
24
26
  require_relative 'ruby_llm/providers/openai_responses/active_record_extension'
@@ -37,7 +39,7 @@ RubyLLM::Providers::OpenAIResponses::ModelRegistry.register_all!
37
39
  module RubyLLM
38
40
  # ResponsesAPI namespace for direct access to helpers and version
39
41
  module ResponsesAPI
40
- VERSION = '0.4.0'
42
+ VERSION = '0.5.0'
41
43
 
42
44
  # Shorthand access to built-in tool helpers
43
45
  BuiltInTools = Providers::OpenAIResponses::BuiltInTools
@@ -45,6 +47,22 @@ module RubyLLM
45
47
  Background = Providers::OpenAIResponses::Background
46
48
  Compaction = Providers::OpenAIResponses::Compaction
47
49
  Containers = Providers::OpenAIResponses::Containers
50
+ Batches = Providers::OpenAIResponses::Batches
51
+ Batch = Providers::OpenAIResponses::Batch
48
52
  WebSocket = Providers::OpenAIResponses::WebSocket
49
53
  end
54
+
55
+ # Create a new Batch for bulk request processing
56
+ def self.batch(...)
57
+ Providers::OpenAIResponses::Batch.new(...)
58
+ end
59
+
60
+ # List existing batches
61
+ def self.batches(provider: :openai_responses, **kwargs)
62
+ slug = provider.to_sym
63
+ provider_class = Provider.providers[slug]
64
+ raise Error.new(nil, "Unknown provider: #{slug}") unless provider_class
65
+
66
+ provider_class.new(config).list_batches(**kwargs)
67
+ end
50
68
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm-responses_api
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Hasinski
@@ -153,6 +153,8 @@ files:
153
153
  - lib/ruby_llm/providers/openai_responses/active_record_extension.rb
154
154
  - lib/ruby_llm/providers/openai_responses/background.rb
155
155
  - lib/ruby_llm/providers/openai_responses/base.rb
156
+ - lib/ruby_llm/providers/openai_responses/batch.rb
157
+ - lib/ruby_llm/providers/openai_responses/batches.rb
156
158
  - lib/ruby_llm/providers/openai_responses/built_in_tools.rb
157
159
  - lib/ruby_llm/providers/openai_responses/capabilities.rb
158
160
  - lib/ruby_llm/providers/openai_responses/chat.rb