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 +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +57 -37
- data/lib/ruby_llm/providers/openai_responses/batch.rb +231 -0
- data/lib/ruby_llm/providers/openai_responses/batches.rb +131 -0
- data/lib/ruby_llm/providers/openai_responses/chat.rb +1 -1
- data/lib/ruby_llm/providers/openai_responses/tools.rb +1 -1
- data/lib/ruby_llm/providers/openai_responses/web_socket.rb +42 -38
- data/lib/ruby_llm/providers/openai_responses.rb +56 -2
- data/lib/rubyllm_responses_api.rb +19 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5eafd14a08ce95dc9637f022c3dcd0b88dc979314efd534ec3a3d5dbb2a6e396
|
|
4
|
+
data.tar.gz: 6b84beeec2204e791727bb969aac9b9e490e574204c78ab87ec6b69692f1ad7d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
###
|
|
310
|
+
### Usage
|
|
273
311
|
|
|
274
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
318
|
+
chat.ask("Hello!")
|
|
319
|
+
chat.ask("What's 2+2?") # reuses the same WebSocket connection
|
|
287
320
|
```
|
|
288
321
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
`previous_response_id` is tracked automatically across turns:
|
|
322
|
+
Streaming works the same way:
|
|
292
323
|
|
|
293
324
|
```ruby
|
|
294
|
-
|
|
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
|
-
###
|
|
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: '
|
|
310
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
# @param
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
223
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
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
|