ruby_llm-responses_api 0.4.1 → 0.5.1
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 +27 -0
- data/README.md +39 -0
- 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 +47 -19
- data/lib/ruby_llm/providers/openai_responses/tools.rb +1 -1
- data/lib/ruby_llm/providers/openai_responses/web_socket.rb +6 -6
- data/lib/ruby_llm/providers/openai_responses.rb +23 -5
- 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: '059b94ffc46c9a9505bec39f3c7e11dd55406304b159c9b3e95d37202a085c31'
|
|
4
|
+
data.tar.gz: 9371bcaa6539cd987d7688a3901ae855f090576f07f7e00ba0d8613157224e19
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1cfbb8c9fe68c29e7d32a07cd6f039dedb62eba362d9238ad877e5b10801b313ee8b821971d863dcf18d5d5affd1378e53c1bcbf77887811b41ef2daba834cc4
|
|
7
|
+
data.tar.gz: 62b356bac7017a796033d7ffd8a7e2ca35650898bbf56a59c18fc6530f00787f57b944932b67a5a5dcedc71922b9056f05057118f50ca6aceeea143eb740b9af
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ 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.1] - 2026-03-03
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `tool_choice` support (`auto`, `required`, `none`, or specific function name)
|
|
13
|
+
- `parallel_tool_calls` support (`:many` / `:one`) via RubyLLM's `tool_prefs`
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Compatibility with RubyLLM v1.13.0 (`tool_prefs:` parameter in `complete` and `render_payload`)
|
|
18
|
+
- Handle new pre-normalized schema format from `Chat.with_schema`
|
|
19
|
+
|
|
20
|
+
## [0.5.0] - 2026-02-25
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **Batch API** for processing many requests asynchronously at 50% lower cost
|
|
25
|
+
- `RubyLLM.batch(model:, provider:)` factory method
|
|
26
|
+
- `Batch#add` to queue requests with auto-generated or custom IDs
|
|
27
|
+
- `Batch#create!` to upload JSONL and create the batch in one call
|
|
28
|
+
- `Batch#wait!` to poll until completion with progress callbacks
|
|
29
|
+
- `Batch#results` returns a `Hash<custom_id, Message>` using the same parsing as `Chat`
|
|
30
|
+
- `Batch#errors`, `Batch#cancel!`, and status helpers (`completed?`, `in_progress?`, `failed?`)
|
|
31
|
+
- Resume from a previous session via `RubyLLM.batch(id: "batch_abc", provider: :openai_responses)`
|
|
32
|
+
- `RubyLLM.batches` to list existing batches
|
|
33
|
+
- `Batches` helper module with JSONL builder, URL helpers, and result parsing
|
|
34
|
+
|
|
8
35
|
## [0.4.1] - 2026-02-24
|
|
9
36
|
|
|
10
37
|
### 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.
|
|
@@ -314,6 +352,7 @@ ws.disconnect
|
|
|
314
352
|
- **Server-side compaction** - Run multi-hour agent sessions without hitting context limits
|
|
315
353
|
- **Containers** - Persistent execution environments with networking and file management
|
|
316
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
|
|
317
356
|
|
|
318
357
|
## License
|
|
319
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,10 +12,11 @@ module RubyLLM
|
|
|
12
12
|
|
|
13
13
|
module_function
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
# rubocop:disable Metrics/ParameterLists
|
|
16
|
+
def render_payload(messages, tools:, temperature:, model:, stream: false,
|
|
17
|
+
schema: nil, thinking: nil, tool_prefs: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
18
|
+
tool_prefs ||= {}
|
|
19
|
+
system_messages, non_system_messages = messages.partition { |m| m.role == :system }
|
|
19
20
|
|
|
20
21
|
instructions = system_messages.map { |m| extract_text_content(m.content) }.join("\n\n")
|
|
21
22
|
|
|
@@ -27,27 +28,54 @@ module RubyLLM
|
|
|
27
28
|
|
|
28
29
|
payload[:instructions] = instructions unless instructions.empty?
|
|
29
30
|
payload[:temperature] = temperature unless temperature.nil?
|
|
31
|
+
apply_tools(payload, tools, tool_prefs)
|
|
32
|
+
payload[:text] = build_schema_format(schema) if schema
|
|
30
33
|
|
|
31
|
-
payload[:tools] = tools.map { |_, tool| tool_for(tool) } if tools.any?
|
|
32
|
-
|
|
33
|
-
if schema
|
|
34
|
-
payload[:text] = {
|
|
35
|
-
format: {
|
|
36
|
-
type: 'json_schema',
|
|
37
|
-
name: 'response',
|
|
38
|
-
schema: schema,
|
|
39
|
-
strict: schema[:strict] != false
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
# Auto-chain conversations: find the last response_id from assistant messages
|
|
45
|
-
# This enables automatic stateful conversations without manual tracking
|
|
46
34
|
last_response_id = extract_last_response_id(messages)
|
|
47
35
|
payload[:previous_response_id] = last_response_id if last_response_id
|
|
48
36
|
|
|
49
37
|
payload
|
|
50
38
|
end
|
|
39
|
+
# rubocop:enable Metrics/ParameterLists
|
|
40
|
+
|
|
41
|
+
# Apply tools and tool preferences to the payload.
|
|
42
|
+
def apply_tools(payload, tools, tool_prefs)
|
|
43
|
+
return unless tools.any?
|
|
44
|
+
|
|
45
|
+
payload[:tools] = tools.map { |_, tool| Tools.tool_for(tool) }
|
|
46
|
+
payload[:tool_choice] = build_tool_choice(tool_prefs[:choice]) unless tool_prefs[:choice].nil?
|
|
47
|
+
payload[:parallel_tool_calls] = tool_prefs[:calls] == :many unless tool_prefs[:calls].nil?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Convert a RubyLLM tool choice symbol to the Responses API format.
|
|
51
|
+
# Responses API accepts "auto", "required", "none", or
|
|
52
|
+
# { type: "function", name: "fn_name" } for a specific function.
|
|
53
|
+
def build_tool_choice(choice)
|
|
54
|
+
case choice
|
|
55
|
+
when :auto, :none, :required
|
|
56
|
+
choice.to_s
|
|
57
|
+
else
|
|
58
|
+
{ type: 'function', name: choice.to_s }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Build the Responses API text format block from a schema.
|
|
63
|
+
# Schema arrives pre-normalized as { name:, schema:, strict: } from
|
|
64
|
+
# RubyLLM::Chat.with_schema (v1.13+), or as a raw hash (legacy).
|
|
65
|
+
def build_schema_format(schema)
|
|
66
|
+
schema_name = schema[:name] || 'response'
|
|
67
|
+
schema_def = schema[:schema] || schema
|
|
68
|
+
strict = schema.key?(:strict) ? schema[:strict] : true
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
format: {
|
|
72
|
+
type: 'json_schema',
|
|
73
|
+
name: schema_name,
|
|
74
|
+
schema: schema_def,
|
|
75
|
+
strict: strict
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
end
|
|
51
79
|
|
|
52
80
|
def extract_last_response_id(messages)
|
|
53
81
|
messages
|
|
@@ -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
|
|
@@ -21,7 +21,7 @@ module RubyLLM
|
|
|
21
21
|
# ws.connect
|
|
22
22
|
# ws.create_response(model: 'gpt-4o', input: [...]) { |chunk| ... }
|
|
23
23
|
# ws.disconnect
|
|
24
|
-
class WebSocket
|
|
24
|
+
class WebSocket # rubocop:disable Metrics/ClassLength
|
|
25
25
|
WEBSOCKET_PATH = '/v1/responses'
|
|
26
26
|
KNOWN_PARAMS = %i[store metadata compact_threshold context_management].freeze
|
|
27
27
|
|
|
@@ -96,7 +96,7 @@ module RubyLLM
|
|
|
96
96
|
# @param payload [Hash] Responses API payload (model, input, tools, etc.)
|
|
97
97
|
# @yield [RubyLLM::Chunk] each streamed chunk
|
|
98
98
|
# @return [RubyLLM::Message] the assembled final message
|
|
99
|
-
def call(payload, &
|
|
99
|
+
def call(payload, &)
|
|
100
100
|
ensure_connected!
|
|
101
101
|
acquire_flight!
|
|
102
102
|
|
|
@@ -105,7 +105,7 @@ module RubyLLM
|
|
|
105
105
|
|
|
106
106
|
envelope = { type: 'response.create', response: payload.except(:stream) }
|
|
107
107
|
send_json(envelope)
|
|
108
|
-
accumulate_response(queue, &
|
|
108
|
+
accumulate_response(queue, &)
|
|
109
109
|
ensure
|
|
110
110
|
@mutex.synchronize { @message_queue = nil }
|
|
111
111
|
release_flight!
|
|
@@ -122,7 +122,7 @@ module RubyLLM
|
|
|
122
122
|
# @param extra [Hash] additional fields forwarded to the API
|
|
123
123
|
# @yield [RubyLLM::Chunk] each streamed chunk
|
|
124
124
|
# @return [RubyLLM::Message] the assembled final message
|
|
125
|
-
def create_response(model:, input:, tools: nil, previous_response_id: nil, instructions: nil, **extra, &block)
|
|
125
|
+
def create_response(model:, input:, tools: nil, previous_response_id: nil, instructions: nil, **extra, &block) # rubocop:disable Metrics/ParameterLists
|
|
126
126
|
payload = build_standalone_payload(
|
|
127
127
|
model: model, input: input, tools: tools,
|
|
128
128
|
previous_response_id: previous_response_id,
|
|
@@ -221,7 +221,7 @@ module RubyLLM
|
|
|
221
221
|
headers
|
|
222
222
|
end
|
|
223
223
|
|
|
224
|
-
def build_standalone_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
|
|
225
225
|
prev_id = previous_response_id || @last_response_id
|
|
226
226
|
response = { model: model, input: input }
|
|
227
227
|
response[:tools] = tools.map { |t| Tools.tool_for(t) } if tools&.any?
|
|
@@ -231,7 +231,7 @@ module RubyLLM
|
|
|
231
231
|
State.apply_state_params(response, extra)
|
|
232
232
|
Compaction.apply_compaction(response, extra)
|
|
233
233
|
|
|
234
|
-
forwarded = extra.
|
|
234
|
+
forwarded = extra.except(*KNOWN_PARAMS)
|
|
235
235
|
response.merge(forwarded)
|
|
236
236
|
end
|
|
237
237
|
|
|
@@ -17,14 +17,18 @@ module RubyLLM
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
# Override to support WebSocket transport via with_params(transport: :websocket)
|
|
20
|
-
|
|
20
|
+
# rubocop:disable Metrics/ParameterLists
|
|
21
|
+
def complete(messages, tools:, temperature:, model:, params: {}, headers: {},
|
|
22
|
+
schema: nil, thinking: nil, tool_prefs: nil, &block)
|
|
21
23
|
if params[:transport]&.to_sym == :websocket
|
|
22
24
|
ws_complete(messages, tools: tools, temperature: temperature, model: model,
|
|
23
|
-
|
|
25
|
+
params: params.except(:transport), schema: schema,
|
|
26
|
+
thinking: thinking, &block)
|
|
24
27
|
else
|
|
25
28
|
super
|
|
26
29
|
end
|
|
27
30
|
end
|
|
31
|
+
# rubocop:enable Metrics/ParameterLists
|
|
28
32
|
|
|
29
33
|
def headers
|
|
30
34
|
{
|
|
@@ -145,9 +149,25 @@ module RubyLLM
|
|
|
145
149
|
response.body
|
|
146
150
|
end
|
|
147
151
|
|
|
152
|
+
# --- Batch API ---
|
|
153
|
+
|
|
154
|
+
# List batches
|
|
155
|
+
# @param limit [Integer] Number of batches to return (default: 20)
|
|
156
|
+
# @param after [String, nil] Cursor for pagination
|
|
157
|
+
# @return [Hash] Batch listing with 'data' array
|
|
158
|
+
def list_batches(limit: 20, after: nil)
|
|
159
|
+
url = Batches.batches_url
|
|
160
|
+
params = { limit: limit }
|
|
161
|
+
params[:after] = after if after
|
|
162
|
+
response = @connection.get(url) do |req|
|
|
163
|
+
req.params.merge!(params)
|
|
164
|
+
end
|
|
165
|
+
response.body
|
|
166
|
+
end
|
|
167
|
+
|
|
148
168
|
private
|
|
149
169
|
|
|
150
|
-
def ws_complete(messages, tools:, temperature:, model:, params:, schema:, thinking:, &block)
|
|
170
|
+
def ws_complete(messages, tools:, temperature:, model:, params:, schema:, thinking:, &block) # rubocop:disable Metrics/ParameterLists
|
|
151
171
|
normalized_temperature = maybe_normalize_temperature(temperature, model)
|
|
152
172
|
|
|
153
173
|
payload = Utils.deep_merge(
|
|
@@ -184,8 +204,6 @@ module RubyLLM
|
|
|
184
204
|
end
|
|
185
205
|
end
|
|
186
206
|
|
|
187
|
-
public
|
|
188
|
-
|
|
189
207
|
class << self
|
|
190
208
|
def capabilities
|
|
191
209
|
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.1'
|
|
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.1
|
|
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
|