openai 0.26.0 → 0.27.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 +24 -0
- data/README.md +1 -1
- data/lib/openai/helpers/streaming/chat_completion_stream.rb +683 -0
- data/lib/openai/helpers/streaming/chat_events.rb +181 -0
- data/lib/openai/helpers/streaming/exceptions.rb +29 -0
- data/lib/openai/helpers/streaming/response_stream.rb +0 -2
- data/lib/openai/internal/util.rb +2 -1
- data/lib/openai/models/chat/parsed_chat_completion.rb +15 -0
- data/lib/openai/resources/chat/completions.rb +78 -37
- data/lib/openai/resources/responses.rb +1 -1
- data/lib/openai/version.rb +1 -1
- data/lib/openai.rb +5 -1
- data/rbi/openai/helpers/streaming/events.rbi +120 -0
- data/rbi/openai/streaming.rbi +28 -1
- metadata +7 -3
- /data/lib/openai/helpers/streaming/{events.rb → response_events.rb} +0 -0
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: bae9059a58e735637f77e8f67206ae5461c0a9b1644dae426b5ecdc783f9856b
|
4
|
+
data.tar.gz: 2b0724ad0cc6348a15db3d6d305cc451bb289ec7d8af9f8375d97fa884be4acc
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b93a9f4249988394f4d90dd36c1f161eea365978ad6b83d3e9e9ecd8579d10f5f0ac26320a5f698dc2174755553ab0048e33602992c3d213c26e5f97d7b2adf9
|
7
|
+
data.tar.gz: dbe13a678f0617a93002ab5f14ca13b2f1305083ac2b5ecc3982d1afa925698dd41673b1bade182d6287e4558177b26e75b465af3d382111d0a640687c30dbb4
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,29 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
+
## 0.27.0 (2025-09-26)
|
4
|
+
|
5
|
+
Full Changelog: [v0.26.0...v0.27.0](https://github.com/openai/openai-ruby/compare/v0.26.0...v0.27.0)
|
6
|
+
|
7
|
+
### Features
|
8
|
+
|
9
|
+
* chat completion streaming helpers ([#828](https://github.com/openai/openai-ruby/issues/828)) ([6e98424](https://github.com/openai/openai-ruby/commit/6e9842485e819876dd6b78107fa45f1a5da67e4f))
|
10
|
+
|
11
|
+
|
12
|
+
### Bug Fixes
|
13
|
+
|
14
|
+
* **internal:** use null byte as file separator in the fast formatting script ([151ffe1](https://github.com/openai/openai-ruby/commit/151ffe10c9dc8d5edaf46de2a1c6b6e6fda80034))
|
15
|
+
* shorten multipart boundary sep to less than RFC specificed max length ([d7770d1](https://github.com/openai/openai-ruby/commit/d7770d10ee3b093d8e2464b79e0e12be3a9d2beb))
|
16
|
+
|
17
|
+
|
18
|
+
### Performance Improvements
|
19
|
+
|
20
|
+
* faster code formatting ([67da711](https://github.com/openai/openai-ruby/commit/67da71139e5b572c97539299c39bae04c1d569fd))
|
21
|
+
|
22
|
+
|
23
|
+
### Chores
|
24
|
+
|
25
|
+
* allow fast-format to use bsd sed as well ([66ac913](https://github.com/openai/openai-ruby/commit/66ac913d195d8b5a5c4474ded88a5f9dad13b7b6))
|
26
|
+
|
3
27
|
## 0.26.0 (2025-09-23)
|
4
28
|
|
5
29
|
Full Changelog: [v0.25.1...v0.26.0](https://github.com/openai/openai-ruby/compare/v0.25.1...v0.26.0)
|
data/README.md
CHANGED
@@ -0,0 +1,683 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OpenAI
|
4
|
+
module Helpers
|
5
|
+
module Streaming
|
6
|
+
class ChatCompletionStream
|
7
|
+
include OpenAI::Internal::Type::BaseStream
|
8
|
+
|
9
|
+
def initialize(raw_stream:, response_format: nil, input_tools: nil)
|
10
|
+
@raw_stream = raw_stream
|
11
|
+
@state = ChatCompletionStreamState.new(
|
12
|
+
response_format: response_format,
|
13
|
+
input_tools: input_tools
|
14
|
+
)
|
15
|
+
@iterator = iterator
|
16
|
+
end
|
17
|
+
|
18
|
+
def get_final_completion
|
19
|
+
until_done
|
20
|
+
@state.get_final_completion
|
21
|
+
end
|
22
|
+
|
23
|
+
def get_output_text
|
24
|
+
completion = get_final_completion
|
25
|
+
text_parts = []
|
26
|
+
|
27
|
+
completion.choices.each do |choice|
|
28
|
+
next unless choice.message.content
|
29
|
+
text_parts << choice.message.content
|
30
|
+
end
|
31
|
+
|
32
|
+
text_parts.join
|
33
|
+
end
|
34
|
+
|
35
|
+
def until_done
|
36
|
+
each {} # rubocop:disable Lint/EmptyBlock
|
37
|
+
self
|
38
|
+
end
|
39
|
+
|
40
|
+
def current_completion_snapshot
|
41
|
+
@state.current_completion_snapshot
|
42
|
+
end
|
43
|
+
|
44
|
+
def text
|
45
|
+
OpenAI::Internal::Util.chain_fused(@iterator) do |yielder|
|
46
|
+
@iterator.each do |event|
|
47
|
+
yielder << event.delta if event.is_a?(ChatContentDeltaEvent)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def iterator
|
55
|
+
@iterator ||= OpenAI::Internal::Util.chain_fused(@raw_stream) do |y|
|
56
|
+
@raw_stream.each do |raw_event|
|
57
|
+
next unless valid_chat_completion_chunk?(raw_event)
|
58
|
+
@state.handle_chunk(raw_event).each do |event|
|
59
|
+
y << event
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def valid_chat_completion_chunk?(sse_event)
|
66
|
+
# Although the _raw_stream is always supposed to contain only objects adhering to ChatCompletionChunk schema,
|
67
|
+
# this is broken by the Azure OpenAI in case of Asynchronous Filter enabled.
|
68
|
+
# An easy filter is to check for the "object" property:
|
69
|
+
# - should be "chat.completion.chunk" for a ChatCompletionChunk;
|
70
|
+
# - is an empty string for Asynchronous Filter events.
|
71
|
+
sse_event.object == :"chat.completion.chunk"
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
class ChatCompletionStreamState
|
76
|
+
attr_reader :current_completion_snapshot
|
77
|
+
|
78
|
+
def initialize(response_format: nil, input_tools: nil)
|
79
|
+
@current_completion_snapshot = nil
|
80
|
+
@choice_event_states = []
|
81
|
+
@input_tools = Array(input_tools)
|
82
|
+
@response_format = response_format
|
83
|
+
@rich_response_format = response_format.is_a?(Class) ? response_format : nil
|
84
|
+
end
|
85
|
+
|
86
|
+
def get_final_completion
|
87
|
+
parse_chat_completion(
|
88
|
+
chat_completion: current_completion_snapshot,
|
89
|
+
response_format: @rich_response_format
|
90
|
+
)
|
91
|
+
end
|
92
|
+
|
93
|
+
# Transforms raw streaming chunks into higher-level events that represent content changes,
|
94
|
+
# tool calls, and completion states. It maintains a running snapshot of the complete
|
95
|
+
# response by accumulating data from each chunk.
|
96
|
+
#
|
97
|
+
# The method performs the following steps:
|
98
|
+
# 1. Unwraps the chunk if it's wrapped in a ChatChunkEvent
|
99
|
+
# 2. Filters out non-ChatCompletionChunk objects
|
100
|
+
# 3. Accumulates the chunk data into the current completion snapshot
|
101
|
+
# 4. Generates appropriate events based on the chunk's content
|
102
|
+
def handle_chunk(chunk)
|
103
|
+
chunk = chunk.chunk if chunk.is_a?(ChatChunkEvent)
|
104
|
+
|
105
|
+
return [] unless chunk.is_a?(OpenAI::Chat::ChatCompletionChunk)
|
106
|
+
|
107
|
+
@current_completion_snapshot = accumulate_chunk(chunk)
|
108
|
+
build_events(chunk: chunk, completion_snapshot: @current_completion_snapshot)
|
109
|
+
end
|
110
|
+
|
111
|
+
private
|
112
|
+
|
113
|
+
def get_choice_state(choice)
|
114
|
+
index = choice.index
|
115
|
+
@choice_event_states[index] ||= ChoiceEventState.new(input_tools: @input_tools)
|
116
|
+
end
|
117
|
+
|
118
|
+
def accumulate_chunk(chunk)
|
119
|
+
if @current_completion_snapshot.nil?
|
120
|
+
return convert_initial_chunk_into_snapshot(chunk)
|
121
|
+
end
|
122
|
+
|
123
|
+
completion_snapshot = @current_completion_snapshot
|
124
|
+
|
125
|
+
chunk.choices.each do |choice|
|
126
|
+
accumulate_choice!(choice, completion_snapshot)
|
127
|
+
end
|
128
|
+
|
129
|
+
completion_snapshot.usage = chunk.usage if chunk.usage
|
130
|
+
completion_snapshot.system_fingerprint = chunk.system_fingerprint if chunk.system_fingerprint
|
131
|
+
|
132
|
+
completion_snapshot
|
133
|
+
end
|
134
|
+
|
135
|
+
def accumulate_choice!(choice, completion_snapshot)
|
136
|
+
choice_snapshot = completion_snapshot.choices[choice.index]
|
137
|
+
|
138
|
+
if choice_snapshot.nil?
|
139
|
+
choice_snapshot = create_new_choice_snapshot(choice)
|
140
|
+
completion_snapshot.choices[choice.index] = choice_snapshot
|
141
|
+
else
|
142
|
+
update_existing_choice_snapshot(choice, choice_snapshot)
|
143
|
+
end
|
144
|
+
|
145
|
+
if choice.finish_reason
|
146
|
+
choice_snapshot.finish_reason = choice.finish_reason
|
147
|
+
handle_finish_reason(choice.finish_reason)
|
148
|
+
end
|
149
|
+
|
150
|
+
parse_tool_calls!(choice.delta.tool_calls, choice_snapshot.message.tool_calls)
|
151
|
+
|
152
|
+
accumulate_logprobs!(choice.logprobs, choice_snapshot)
|
153
|
+
end
|
154
|
+
|
155
|
+
def create_new_choice_snapshot(choice)
|
156
|
+
OpenAI::Internal::Type::Converter.coerce(
|
157
|
+
OpenAI::Models::Chat::ParsedChoice,
|
158
|
+
choice.to_h.except(:delta).merge(message: choice.delta.to_h)
|
159
|
+
)
|
160
|
+
end
|
161
|
+
|
162
|
+
def update_existing_choice_snapshot(choice, choice_snapshot)
|
163
|
+
delta_data = model_dump(choice.delta)
|
164
|
+
message_hash = model_dump(choice_snapshot.message)
|
165
|
+
|
166
|
+
accumulated_data = accumulate_delta(message_hash, delta_data)
|
167
|
+
|
168
|
+
choice_snapshot.message = OpenAI::Internal::Type::Converter.coerce(
|
169
|
+
OpenAI::Chat::ChatCompletionMessage,
|
170
|
+
accumulated_data
|
171
|
+
)
|
172
|
+
end
|
173
|
+
|
174
|
+
def build_events(chunk:, completion_snapshot:)
|
175
|
+
chunk_event = ChatChunkEvent.new(
|
176
|
+
type: :chunk,
|
177
|
+
chunk: chunk,
|
178
|
+
snapshot: completion_snapshot
|
179
|
+
)
|
180
|
+
|
181
|
+
choice_events = chunk.choices.flat_map do |choice|
|
182
|
+
build_choice_events(choice, completion_snapshot)
|
183
|
+
end
|
184
|
+
|
185
|
+
[chunk_event] + choice_events
|
186
|
+
end
|
187
|
+
|
188
|
+
def build_choice_events(choice, completion_snapshot)
|
189
|
+
choice_state = get_choice_state(choice)
|
190
|
+
choice_snapshot = completion_snapshot.choices[choice.index]
|
191
|
+
|
192
|
+
content_delta_events(choice, choice_snapshot) +
|
193
|
+
tool_call_delta_events(choice, choice_snapshot) +
|
194
|
+
logprobs_delta_events(choice, choice_snapshot) +
|
195
|
+
choice_state.get_done_events(
|
196
|
+
choice_chunk: choice,
|
197
|
+
choice_snapshot: choice_snapshot,
|
198
|
+
response_format: @response_format
|
199
|
+
)
|
200
|
+
end
|
201
|
+
|
202
|
+
def content_delta_events(choice, choice_snapshot)
|
203
|
+
events = []
|
204
|
+
|
205
|
+
if choice.delta.content && choice_snapshot.message.content
|
206
|
+
events << ChatContentDeltaEvent.new(
|
207
|
+
type: :"content.delta",
|
208
|
+
delta: choice.delta.content,
|
209
|
+
snapshot: choice_snapshot.message.content,
|
210
|
+
parsed: choice_snapshot.message.parsed
|
211
|
+
)
|
212
|
+
end
|
213
|
+
|
214
|
+
if choice.delta.refusal && choice_snapshot.message.refusal
|
215
|
+
events << ChatRefusalDeltaEvent.new(
|
216
|
+
type: :"refusal.delta",
|
217
|
+
delta: choice.delta.refusal,
|
218
|
+
snapshot: choice_snapshot.message.refusal
|
219
|
+
)
|
220
|
+
end
|
221
|
+
|
222
|
+
events
|
223
|
+
end
|
224
|
+
|
225
|
+
def tool_call_delta_events(choice, choice_snapshot)
|
226
|
+
events = []
|
227
|
+
return events unless choice.delta.tool_calls
|
228
|
+
|
229
|
+
tool_calls = choice_snapshot.message.tool_calls
|
230
|
+
return events unless tool_calls
|
231
|
+
|
232
|
+
choice.delta.tool_calls.each do |tool_call_delta|
|
233
|
+
tool_call = tool_calls[tool_call_delta.index]
|
234
|
+
next unless tool_call.type == :function && tool_call_delta.function
|
235
|
+
|
236
|
+
parsed_args = if tool_call.function.respond_to?(:parsed)
|
237
|
+
tool_call.function.parsed
|
238
|
+
end
|
239
|
+
events << ChatFunctionToolCallArgumentsDeltaEvent.new(
|
240
|
+
type: :"tool_calls.function.arguments.delta",
|
241
|
+
name: tool_call.function.name,
|
242
|
+
index: tool_call_delta.index,
|
243
|
+
arguments: tool_call.function.arguments,
|
244
|
+
parsed: parsed_args,
|
245
|
+
arguments_delta: tool_call_delta.function.arguments || ""
|
246
|
+
)
|
247
|
+
end
|
248
|
+
|
249
|
+
events
|
250
|
+
end
|
251
|
+
|
252
|
+
def logprobs_delta_events(choice, choice_snapshot)
|
253
|
+
events = []
|
254
|
+
return events unless choice.logprobs && choice_snapshot.logprobs
|
255
|
+
|
256
|
+
if choice.logprobs.content && choice_snapshot.logprobs.content
|
257
|
+
events << ChatLogprobsContentDeltaEvent.new(
|
258
|
+
type: :"logprobs.content.delta",
|
259
|
+
content: choice.logprobs.content,
|
260
|
+
snapshot: choice_snapshot.logprobs.content
|
261
|
+
)
|
262
|
+
end
|
263
|
+
|
264
|
+
if choice.logprobs.refusal && choice_snapshot.logprobs.refusal
|
265
|
+
events << ChatLogprobsRefusalDeltaEvent.new(
|
266
|
+
type: :"logprobs.refusal.delta",
|
267
|
+
refusal: choice.logprobs.refusal,
|
268
|
+
snapshot: choice_snapshot.logprobs.refusal
|
269
|
+
)
|
270
|
+
end
|
271
|
+
|
272
|
+
events
|
273
|
+
end
|
274
|
+
|
275
|
+
def handle_finish_reason(finish_reason)
|
276
|
+
return unless parseable_input?
|
277
|
+
|
278
|
+
case finish_reason
|
279
|
+
when :length
|
280
|
+
raise LengthFinishReasonError.new(completion: @chat_completion)
|
281
|
+
when :content_filter
|
282
|
+
raise ContentFilterFinishReasonError.new
|
283
|
+
end
|
284
|
+
end
|
285
|
+
|
286
|
+
def parse_tool_calls!(delta_tool_calls, snapshot_tool_calls)
|
287
|
+
return unless delta_tool_calls && snapshot_tool_calls
|
288
|
+
|
289
|
+
delta_tool_calls.each do |tool_call_chunk|
|
290
|
+
tool_call_snapshot = snapshot_tool_calls[tool_call_chunk.index]
|
291
|
+
next unless tool_call_snapshot&.type == :function
|
292
|
+
|
293
|
+
input_tool = find_input_tool(tool_call_snapshot.function.name)
|
294
|
+
next unless input_tool&.dig(:function, :strict)
|
295
|
+
next unless tool_call_snapshot.function.arguments
|
296
|
+
|
297
|
+
begin
|
298
|
+
tool_call_snapshot.function.parsed = JSON.parse(
|
299
|
+
tool_call_snapshot.function.arguments,
|
300
|
+
symbolize_names: true
|
301
|
+
)
|
302
|
+
rescue JSON::ParserError
|
303
|
+
nil
|
304
|
+
end
|
305
|
+
end
|
306
|
+
end
|
307
|
+
|
308
|
+
def accumulate_logprobs!(choice_logprobs, choice_snapshot)
|
309
|
+
return unless choice_logprobs
|
310
|
+
|
311
|
+
if choice_snapshot.logprobs.nil?
|
312
|
+
choice_snapshot.logprobs = OpenAI::Chat::ChatCompletionChunk::Choice::Logprobs.new(
|
313
|
+
content: choice_logprobs.content,
|
314
|
+
refusal: choice_logprobs.refusal
|
315
|
+
)
|
316
|
+
else
|
317
|
+
if choice_logprobs.content
|
318
|
+
choice_snapshot.logprobs.content ||= []
|
319
|
+
choice_snapshot.logprobs.content.concat(choice_logprobs.content)
|
320
|
+
end
|
321
|
+
|
322
|
+
if choice_logprobs.refusal
|
323
|
+
choice_snapshot.logprobs.refusal ||= []
|
324
|
+
choice_snapshot.logprobs.refusal.concat(choice_logprobs.refusal)
|
325
|
+
end
|
326
|
+
end
|
327
|
+
end
|
328
|
+
|
329
|
+
def parse_chat_completion(chat_completion:, response_format:)
|
330
|
+
choices = chat_completion.choices.map do |choice|
|
331
|
+
if parseable_input?
|
332
|
+
case choice.finish_reason
|
333
|
+
when :length
|
334
|
+
raise LengthFinishReasonError.new(completion: chat_completion)
|
335
|
+
when :content_filter
|
336
|
+
raise ContentFilterFinishReasonError.new
|
337
|
+
end
|
338
|
+
end
|
339
|
+
|
340
|
+
build_parsed_choice(choice, response_format)
|
341
|
+
end
|
342
|
+
|
343
|
+
OpenAI::Internal::Type::Converter.coerce(
|
344
|
+
OpenAI::Chat::ParsedChatCompletion,
|
345
|
+
chat_completion.to_h.merge(choices: choices)
|
346
|
+
)
|
347
|
+
end
|
348
|
+
|
349
|
+
def build_parsed_choice(choice, response_format)
|
350
|
+
message = choice.message
|
351
|
+
|
352
|
+
tool_calls = parse_choice_tool_calls(message.tool_calls)
|
353
|
+
|
354
|
+
choice_data = model_dump(choice)
|
355
|
+
choice_data[:message] = model_dump(message)
|
356
|
+
choice_data[:message][:tool_calls] = tool_calls && !tool_calls.empty? ? tool_calls : nil
|
357
|
+
|
358
|
+
if response_format && message.content && !message.refusal
|
359
|
+
choice_data[:message][:parsed] = parse_content(response_format, message)
|
360
|
+
end
|
361
|
+
|
362
|
+
choice_data
|
363
|
+
end
|
364
|
+
|
365
|
+
def parse_choice_tool_calls(tool_calls)
|
366
|
+
return unless tool_calls
|
367
|
+
|
368
|
+
tool_calls.map do |tool_call|
|
369
|
+
tool_call_hash = model_dump(tool_call)
|
370
|
+
next tool_call_hash unless tool_call_hash[:type] == :function && tool_call_hash[:function]
|
371
|
+
|
372
|
+
function = tool_call_hash[:function]
|
373
|
+
parsed_args = parse_function_tool_arguments(function)
|
374
|
+
function[:parsed] = parsed_args if parsed_args
|
375
|
+
|
376
|
+
tool_call_hash
|
377
|
+
end
|
378
|
+
end
|
379
|
+
|
380
|
+
def parseable_input?
|
381
|
+
@response_format || @input_tools.any?
|
382
|
+
end
|
383
|
+
|
384
|
+
def model_dump(obj)
|
385
|
+
if obj.is_a?(OpenAI::Internal::Type::BaseModel)
|
386
|
+
obj.deep_to_h
|
387
|
+
elsif obj.respond_to?(:to_h)
|
388
|
+
obj.to_h
|
389
|
+
else
|
390
|
+
obj
|
391
|
+
end
|
392
|
+
end
|
393
|
+
|
394
|
+
def find_input_tool(name)
|
395
|
+
@input_tools.find { |tool| tool.dig(:function, :name) == name }
|
396
|
+
end
|
397
|
+
|
398
|
+
def parse_function_tool_arguments(function)
|
399
|
+
return nil unless function[:arguments]
|
400
|
+
|
401
|
+
input_tool = find_input_tool(function[:name])
|
402
|
+
return nil unless input_tool&.dig(:function, :strict)
|
403
|
+
|
404
|
+
parsed = JSON.parse(function[:arguments], symbolize_names: true)
|
405
|
+
return nil unless parsed
|
406
|
+
|
407
|
+
model_class = input_tool[:model] || input_tool.dig(:function, :parameters)
|
408
|
+
if model_class.is_a?(Class)
|
409
|
+
OpenAI::Internal::Type::Converter.coerce(model_class, parsed)
|
410
|
+
else
|
411
|
+
parsed
|
412
|
+
end
|
413
|
+
rescue JSON::ParserError
|
414
|
+
nil
|
415
|
+
end
|
416
|
+
|
417
|
+
def parse_content(response_format, message)
|
418
|
+
return nil unless message.content && !message.refusal
|
419
|
+
|
420
|
+
parsed = JSON.parse(message.content, symbolize_names: true)
|
421
|
+
return nil unless parsed
|
422
|
+
|
423
|
+
if response_format.is_a?(Class)
|
424
|
+
OpenAI::Internal::Type::Converter.coerce(response_format, parsed)
|
425
|
+
else
|
426
|
+
parsed
|
427
|
+
end
|
428
|
+
rescue JSON::ParserError
|
429
|
+
nil
|
430
|
+
end
|
431
|
+
|
432
|
+
def convert_initial_chunk_into_snapshot(chunk)
|
433
|
+
data = chunk.to_h
|
434
|
+
|
435
|
+
choices = []
|
436
|
+
chunk.choices.each do |choice|
|
437
|
+
choice_hash = choice.to_h
|
438
|
+
delta_hash = choice.delta.to_h
|
439
|
+
|
440
|
+
message_data = delta_hash.dup
|
441
|
+
message_data[:role] ||= :assistant
|
442
|
+
|
443
|
+
choice_data = {
|
444
|
+
index: choice_hash[:index],
|
445
|
+
message: message_data,
|
446
|
+
finish_reason: choice_hash[:finish_reason],
|
447
|
+
logprobs: choice_hash[:logprobs]
|
448
|
+
}
|
449
|
+
choices << choice_data
|
450
|
+
end
|
451
|
+
|
452
|
+
OpenAI::Internal::Type::Converter.coerce(
|
453
|
+
OpenAI::Chat::ParsedChatCompletion,
|
454
|
+
{
|
455
|
+
id: data[:id],
|
456
|
+
object: :"chat.completion",
|
457
|
+
created: data[:created],
|
458
|
+
model: data[:model],
|
459
|
+
choices: choices,
|
460
|
+
usage: data[:usage],
|
461
|
+
system_fingerprint: nil,
|
462
|
+
service_tier: data[:service_tier]
|
463
|
+
}
|
464
|
+
)
|
465
|
+
end
|
466
|
+
|
467
|
+
def accumulate_delta(acc, delta)
|
468
|
+
return acc if delta.nil?
|
469
|
+
|
470
|
+
delta.each do |key, delta_value| # rubocop:disable Metrics/BlockLength
|
471
|
+
key = key.to_sym if key.is_a?(String)
|
472
|
+
|
473
|
+
unless acc.key?(key)
|
474
|
+
acc[key] = delta_value
|
475
|
+
next
|
476
|
+
end
|
477
|
+
|
478
|
+
acc_value = acc[key]
|
479
|
+
if acc_value.nil?
|
480
|
+
acc[key] = delta_value
|
481
|
+
next
|
482
|
+
end
|
483
|
+
|
484
|
+
# Special properties that should be replaced, not accumulated.
|
485
|
+
if [:index, :type, :parsed].include?(key)
|
486
|
+
acc[key] = delta_value
|
487
|
+
next
|
488
|
+
end
|
489
|
+
|
490
|
+
if acc_value.is_a?(String) && delta_value.is_a?(String)
|
491
|
+
acc[key] = acc_value + delta_value
|
492
|
+
elsif acc_value.is_a?(Numeric) && delta_value.is_a?(Numeric) # rubocop:disable Lint/DuplicateBranch
|
493
|
+
acc[key] = acc_value + delta_value
|
494
|
+
elsif acc_value.is_a?(Hash) && delta_value.is_a?(Hash)
|
495
|
+
acc[key] = accumulate_delta(acc_value, delta_value)
|
496
|
+
elsif acc_value.is_a?(Array) && delta_value.is_a?(Array)
|
497
|
+
if acc_value.all? { |x| x.is_a?(String) || x.is_a?(Numeric) }
|
498
|
+
acc_value.concat(delta_value)
|
499
|
+
next
|
500
|
+
end
|
501
|
+
|
502
|
+
delta_value.each do |delta_entry|
|
503
|
+
unless delta_entry.is_a?(Hash)
|
504
|
+
raise TypeError,
|
505
|
+
"Unexpected list delta entry is not a hash: #{delta_entry}"
|
506
|
+
end
|
507
|
+
|
508
|
+
index = delta_entry[:index] || delta_entry["index"]
|
509
|
+
if index.nil?
|
510
|
+
raise RuntimeError,
|
511
|
+
"Expected list delta entry to have an `index` key; #{delta_entry}"
|
512
|
+
end
|
513
|
+
unless index.is_a?(Integer)
|
514
|
+
raise TypeError,
|
515
|
+
"Unexpected, list delta entry `index` value is not an integer; #{index}"
|
516
|
+
end
|
517
|
+
|
518
|
+
if acc_value[index].nil?
|
519
|
+
acc_value[index] = delta_entry
|
520
|
+
elsif acc_value[index].is_a?(Hash)
|
521
|
+
acc_value[index] = accumulate_delta(acc_value[index], delta_entry)
|
522
|
+
end
|
523
|
+
end
|
524
|
+
else
|
525
|
+
acc[key] = acc_value
|
526
|
+
end
|
527
|
+
end
|
528
|
+
|
529
|
+
acc
|
530
|
+
end
|
531
|
+
end
|
532
|
+
|
533
|
+
class ChoiceEventState
|
534
|
+
def initialize(input_tools:)
|
535
|
+
@input_tools = Array(input_tools)
|
536
|
+
@content_done = false
|
537
|
+
@refusal_done = false
|
538
|
+
@logprobs_content_done = false
|
539
|
+
@logprobs_refusal_done = false
|
540
|
+
@done_tool_calls = Set.new
|
541
|
+
@current_tool_call_index = nil
|
542
|
+
end
|
543
|
+
|
544
|
+
def get_done_events(choice_chunk:, choice_snapshot:, response_format:)
|
545
|
+
events = []
|
546
|
+
|
547
|
+
if choice_snapshot.finish_reason
|
548
|
+
events.concat(content_done_events(choice_snapshot, response_format))
|
549
|
+
|
550
|
+
if @current_tool_call_index && !@done_tool_calls.include?(@current_tool_call_index)
|
551
|
+
event = tool_done_event(choice_snapshot, @current_tool_call_index)
|
552
|
+
events << event if event
|
553
|
+
end
|
554
|
+
end
|
555
|
+
|
556
|
+
Array(choice_chunk.delta.tool_calls).each do |tool_call|
|
557
|
+
if @current_tool_call_index != tool_call.index
|
558
|
+
events.concat(content_done_events(choice_snapshot, response_format))
|
559
|
+
|
560
|
+
if @current_tool_call_index
|
561
|
+
event = tool_done_event(choice_snapshot, @current_tool_call_index)
|
562
|
+
events << event if event
|
563
|
+
end
|
564
|
+
end
|
565
|
+
|
566
|
+
@current_tool_call_index = tool_call.index
|
567
|
+
end
|
568
|
+
|
569
|
+
events
|
570
|
+
end
|
571
|
+
|
572
|
+
private
|
573
|
+
|
574
|
+
def content_done_events(choice_snapshot, response_format)
|
575
|
+
events = []
|
576
|
+
|
577
|
+
if choice_snapshot.message.content && !@content_done
|
578
|
+
@content_done = true
|
579
|
+
parsed = parse_content(choice_snapshot.message, response_format)
|
580
|
+
choice_snapshot.message.parsed = parsed
|
581
|
+
|
582
|
+
events << ChatContentDoneEvent.new(
|
583
|
+
type: :"content.done",
|
584
|
+
content: choice_snapshot.message.content,
|
585
|
+
parsed: parsed
|
586
|
+
)
|
587
|
+
end
|
588
|
+
|
589
|
+
if choice_snapshot.message.refusal && !@refusal_done
|
590
|
+
@refusal_done = true
|
591
|
+
events << ChatRefusalDoneEvent.new(
|
592
|
+
type: :"refusal.done",
|
593
|
+
refusal: choice_snapshot.message.refusal
|
594
|
+
)
|
595
|
+
end
|
596
|
+
|
597
|
+
events + logprobs_done_events(choice_snapshot)
|
598
|
+
end
|
599
|
+
|
600
|
+
def logprobs_done_events(choice_snapshot)
|
601
|
+
events = []
|
602
|
+
logprobs = choice_snapshot.logprobs
|
603
|
+
return events unless logprobs
|
604
|
+
|
605
|
+
if logprobs.content&.any? && !@logprobs_content_done
|
606
|
+
@logprobs_content_done = true
|
607
|
+
events << ChatLogprobsContentDoneEvent.new(
|
608
|
+
type: :"logprobs.content.done",
|
609
|
+
content: logprobs.content
|
610
|
+
)
|
611
|
+
end
|
612
|
+
|
613
|
+
if logprobs.refusal&.any? && !@logprobs_refusal_done
|
614
|
+
@logprobs_refusal_done = true
|
615
|
+
events << ChatLogprobsRefusalDoneEvent.new(
|
616
|
+
type: :"logprobs.refusal.done",
|
617
|
+
refusal: logprobs.refusal
|
618
|
+
)
|
619
|
+
end
|
620
|
+
|
621
|
+
events
|
622
|
+
end
|
623
|
+
|
624
|
+
def tool_done_event(choice_snapshot, tool_index)
|
625
|
+
return nil if @done_tool_calls.include?(tool_index)
|
626
|
+
|
627
|
+
@done_tool_calls.add(tool_index)
|
628
|
+
|
629
|
+
tool_call = choice_snapshot.message.tool_calls&.[](tool_index)
|
630
|
+
return nil unless tool_call&.type == :function
|
631
|
+
|
632
|
+
parsed_args = parse_function_tool_arguments(tool_call.function)
|
633
|
+
|
634
|
+
if tool_call.function.respond_to?(:parsed=)
|
635
|
+
tool_call.function.parsed = parsed_args
|
636
|
+
end
|
637
|
+
|
638
|
+
ChatFunctionToolCallArgumentsDoneEvent.new(
|
639
|
+
type: :"tool_calls.function.arguments.done",
|
640
|
+
index: tool_index,
|
641
|
+
name: tool_call.function.name,
|
642
|
+
arguments: tool_call.function.arguments,
|
643
|
+
parsed: parsed_args
|
644
|
+
)
|
645
|
+
end
|
646
|
+
|
647
|
+
def parse_content(message, response_format)
|
648
|
+
return nil unless response_format && message.content
|
649
|
+
|
650
|
+
parsed = JSON.parse(message.content, symbolize_names: true)
|
651
|
+
if response_format.is_a?(Class)
|
652
|
+
OpenAI::Internal::Type::Converter.coerce(response_format, parsed)
|
653
|
+
else
|
654
|
+
parsed
|
655
|
+
end
|
656
|
+
rescue JSON::ParserError
|
657
|
+
nil
|
658
|
+
end
|
659
|
+
|
660
|
+
def parse_function_tool_arguments(function)
|
661
|
+
return nil unless function.arguments
|
662
|
+
|
663
|
+
tool = find_input_tool(function.name)
|
664
|
+
return nil unless tool&.dig(:function, :strict)
|
665
|
+
|
666
|
+
parsed = JSON.parse(function.arguments, symbolize_names: true)
|
667
|
+
|
668
|
+
if tool[:model]
|
669
|
+
OpenAI::Internal::Type::Converter.coerce(tool[:model], parsed)
|
670
|
+
else
|
671
|
+
parsed
|
672
|
+
end
|
673
|
+
rescue JSON::ParserError
|
674
|
+
nil
|
675
|
+
end
|
676
|
+
|
677
|
+
def find_input_tool(name)
|
678
|
+
@input_tools.find { |tool| tool.dig(:function, :name) == name }
|
679
|
+
end
|
680
|
+
end
|
681
|
+
end
|
682
|
+
end
|
683
|
+
end
|
@@ -0,0 +1,181 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OpenAI
|
4
|
+
module Helpers
|
5
|
+
module Streaming
|
6
|
+
# Raw streaming chunk event with accumulated completion snapshot.
|
7
|
+
#
|
8
|
+
# This is the fundamental event that wraps each raw chunk from the API
|
9
|
+
# along with the accumulated state up to that point. All other events
|
10
|
+
# are derived from processing these chunks.
|
11
|
+
#
|
12
|
+
# @example
|
13
|
+
# event.chunk # => ChatCompletionChunk (raw API response)
|
14
|
+
# event.snapshot # => ParsedChatCompletion (accumulated state)
|
15
|
+
class ChatChunkEvent < OpenAI::Internal::Type::BaseModel
|
16
|
+
required :type, const: :chunk
|
17
|
+
required :chunk, -> { OpenAI::Chat::ChatCompletionChunk }
|
18
|
+
required :snapshot, -> { OpenAI::Chat::ParsedChatCompletion }
|
19
|
+
end
|
20
|
+
|
21
|
+
# Incremental text content update event.
|
22
|
+
#
|
23
|
+
# Emitted as the assistant's text response is being generated. Each event
|
24
|
+
# contains the new text fragment (delta) and the complete accumulated
|
25
|
+
# text so far (snapshot).
|
26
|
+
#
|
27
|
+
# @example
|
28
|
+
# event.delta # => "Hello" (new fragment)
|
29
|
+
# event.snapshot # => "Hello world" (accumulated text)
|
30
|
+
# event.parsed # => {name: "John"} (if using structured outputs)
|
31
|
+
class ChatContentDeltaEvent < OpenAI::Internal::Type::BaseModel
|
32
|
+
required :type, const: :"content.delta"
|
33
|
+
required :delta, String
|
34
|
+
required :snapshot, String
|
35
|
+
optional :parsed, Object # Partially parsed structured output
|
36
|
+
end
|
37
|
+
|
38
|
+
# Text content completion event.
|
39
|
+
#
|
40
|
+
# Emitted when the assistant has finished generating text content.
|
41
|
+
# Contains the complete text and, if applicable, the fully parsed
|
42
|
+
# structured output.
|
43
|
+
#
|
44
|
+
# @example
|
45
|
+
# event.content # => "Hello world! How can I help?"
|
46
|
+
# event.parsed # => {name: "John", age: 30} (if using structured outputs)
|
47
|
+
class ChatContentDoneEvent < OpenAI::Internal::Type::BaseModel
|
48
|
+
required :type, const: :"content.done"
|
49
|
+
required :content, String
|
50
|
+
optional :parsed, Object # Fully parsed structured output
|
51
|
+
end
|
52
|
+
|
53
|
+
# Incremental refusal update event.
|
54
|
+
#
|
55
|
+
# Emitted when the assistant is refusing to fulfill a request.
|
56
|
+
# Contains the new refusal text fragment and accumulated refusal message.
|
57
|
+
#
|
58
|
+
# @example
|
59
|
+
# event.delta # => "I cannot"
|
60
|
+
# event.snapshot # => "I cannot help with that request"
|
61
|
+
class ChatRefusalDeltaEvent < OpenAI::Internal::Type::BaseModel
|
62
|
+
required :type, const: :"refusal.delta"
|
63
|
+
required :delta, String
|
64
|
+
required :snapshot, String
|
65
|
+
end
|
66
|
+
|
67
|
+
# Refusal completion event.
|
68
|
+
#
|
69
|
+
# Emitted when the assistant has finished generating a refusal message.
|
70
|
+
# Contains the complete refusal text.
|
71
|
+
#
|
72
|
+
# @example
|
73
|
+
# event.refusal # => "I cannot help with that request as it violates..."
|
74
|
+
class ChatRefusalDoneEvent < OpenAI::Internal::Type::BaseModel
|
75
|
+
required :type, const: :"refusal.done"
|
76
|
+
required :refusal, String
|
77
|
+
end
|
78
|
+
|
79
|
+
# Incremental function tool call arguments update.
|
80
|
+
#
|
81
|
+
# Emitted as function arguments are being streamed. Provides both the
|
82
|
+
# raw JSON fragments and incrementally parsed arguments for strict tools.
|
83
|
+
#
|
84
|
+
# @example
|
85
|
+
# event.name # => "get_weather"
|
86
|
+
# event.index # => 0 (tool call index in array)
|
87
|
+
# event.arguments_delta # => '{"location": "San' (new fragment)
|
88
|
+
# event.arguments # => '{"location": "San Francisco"' (accumulated JSON)
|
89
|
+
# event.parsed # => {location: "San Francisco"} (if strict: true)
|
90
|
+
class ChatFunctionToolCallArgumentsDeltaEvent < OpenAI::Internal::Type::BaseModel
|
91
|
+
required :type, const: :"tool_calls.function.arguments.delta"
|
92
|
+
required :name, String
|
93
|
+
required :index, Integer
|
94
|
+
required :arguments_delta, String
|
95
|
+
required :arguments, String
|
96
|
+
required :parsed, Object
|
97
|
+
end
|
98
|
+
|
99
|
+
# Function tool call arguments completion event.
|
100
|
+
#
|
101
|
+
# Emitted when a function tool call's arguments are complete.
|
102
|
+
# For tools defined with `strict: true`, the arguments will be fully
|
103
|
+
# parsed and validated. For non-strict tools, only raw JSON is available.
|
104
|
+
#
|
105
|
+
# @example With strict tool
|
106
|
+
# event.name # => "get_weather"
|
107
|
+
# event.arguments # => '{"location": "San Francisco", "unit": "celsius"}'
|
108
|
+
# event.parsed # => {location: "San Francisco", unit: "celsius"}
|
109
|
+
#
|
110
|
+
# @example Without strict tool
|
111
|
+
# event.parsed # => nil (parse JSON from event.arguments manually)
|
112
|
+
class ChatFunctionToolCallArgumentsDoneEvent < OpenAI::Internal::Type::BaseModel
|
113
|
+
required :type, const: :"tool_calls.function.arguments.done"
|
114
|
+
required :name, String
|
115
|
+
required :index, Integer
|
116
|
+
required :arguments, String
|
117
|
+
required :parsed, Object # (only for strict: true tools)
|
118
|
+
end
|
119
|
+
|
120
|
+
# Incremental logprobs update for content tokens.
|
121
|
+
#
|
122
|
+
# Emitted when logprobs are requested and content tokens are being generated.
|
123
|
+
# Contains log probability information for the new tokens and accumulated
|
124
|
+
# logprobs for all content tokens so far.
|
125
|
+
#
|
126
|
+
# @example
|
127
|
+
# event.content[0].token # => "Hello"
|
128
|
+
# event.content[0].logprob # => -0.31725305
|
129
|
+
# event.content[0].top_logprobs # => [{token: "Hello", logprob: -0.31725305}, ...]
|
130
|
+
# event.snapshot # => [all logprobs accumulated so far]
|
131
|
+
class ChatLogprobsContentDeltaEvent < OpenAI::Internal::Type::BaseModel
|
132
|
+
required :type, const: :"logprobs.content.delta"
|
133
|
+
required :content, -> { OpenAI::Internal::Type::ArrayOf[OpenAI::Chat::ChatCompletionTokenLogprob] }
|
134
|
+
required :snapshot, -> { OpenAI::Internal::Type::ArrayOf[OpenAI::Chat::ChatCompletionTokenLogprob] }
|
135
|
+
end
|
136
|
+
|
137
|
+
# Logprobs completion event for content tokens.
|
138
|
+
#
|
139
|
+
# Emitted when content generation is complete and logprobs were requested.
|
140
|
+
# Contains the complete array of log probabilities for all content tokens.
|
141
|
+
#
|
142
|
+
# @example
|
143
|
+
# event.content.each do |logprob|
|
144
|
+
# puts "Token: #{logprob.token}, Logprob: #{logprob.logprob}"
|
145
|
+
# end
|
146
|
+
class ChatLogprobsContentDoneEvent < OpenAI::Internal::Type::BaseModel
|
147
|
+
required :type, const: :"logprobs.content.done"
|
148
|
+
required :content, -> { OpenAI::Internal::Type::ArrayOf[OpenAI::Chat::ChatCompletionTokenLogprob] }
|
149
|
+
end
|
150
|
+
|
151
|
+
# Incremental logprobs update for refusal tokens.
|
152
|
+
#
|
153
|
+
# Emitted when logprobs are requested and refusal tokens are being generated.
|
154
|
+
# Contains log probability information for refusal message tokens.
|
155
|
+
#
|
156
|
+
# @example
|
157
|
+
# event.refusal[0].token # => "I"
|
158
|
+
# event.refusal[0].logprob # => -0.12345
|
159
|
+
# event.snapshot # => [all refusal logprobs accumulated so far]
|
160
|
+
class ChatLogprobsRefusalDeltaEvent < OpenAI::Internal::Type::BaseModel
|
161
|
+
required :type, const: :"logprobs.refusal.delta"
|
162
|
+
required :refusal, -> { OpenAI::Internal::Type::ArrayOf[OpenAI::Chat::ChatCompletionTokenLogprob] }
|
163
|
+
required :snapshot, -> { OpenAI::Internal::Type::ArrayOf[OpenAI::Chat::ChatCompletionTokenLogprob] }
|
164
|
+
end
|
165
|
+
|
166
|
+
# Logprobs completion event for refusal tokens.
|
167
|
+
#
|
168
|
+
# Emitted when refusal generation is complete and logprobs were requested.
|
169
|
+
# Contains the complete array of log probabilities for all refusal tokens.
|
170
|
+
#
|
171
|
+
# @example
|
172
|
+
# event.refusal.each do |logprob|
|
173
|
+
# puts "Refusal token: #{logprob.token}, Logprob: #{logprob.logprob}"
|
174
|
+
# end
|
175
|
+
class ChatLogprobsRefusalDoneEvent < OpenAI::Internal::Type::BaseModel
|
176
|
+
required :type, const: :"logprobs.refusal.done"
|
177
|
+
required :refusal, -> { OpenAI::Internal::Type::ArrayOf[OpenAI::Chat::ChatCompletionTokenLogprob] }
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OpenAI
|
4
|
+
module Helpers
|
5
|
+
module Streaming
|
6
|
+
class StreamError < StandardError; end
|
7
|
+
|
8
|
+
class LengthFinishReasonError < StreamError
|
9
|
+
attr_reader :completion
|
10
|
+
|
11
|
+
def initialize(completion:)
|
12
|
+
@completion = completion
|
13
|
+
super("Stream finished due to length limit")
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
class ContentFilterFinishReasonError < StreamError
|
18
|
+
def initialize
|
19
|
+
super("Stream finished due to content filter")
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
module OpenAI
|
27
|
+
LengthFinishReasonError = Helpers::Streaming::LengthFinishReasonError
|
28
|
+
ContentFilterFinishReasonError = Helpers::Streaming::ContentFilterFinishReasonError
|
29
|
+
end
|
data/lib/openai/internal/util.rb
CHANGED
@@ -566,7 +566,8 @@ module OpenAI
|
|
566
566
|
#
|
567
567
|
# @return [Array(String, Enumerable<String>)]
|
568
568
|
private def encode_multipart_streaming(body)
|
569
|
-
|
569
|
+
# RFC 1521 Section 7.2.1 says we should have 70 char maximum for boundary length
|
570
|
+
boundary = SecureRandom.urlsafe_base64(46)
|
570
571
|
|
571
572
|
closing = []
|
572
573
|
strio = writable_enum do |y|
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OpenAI
|
4
|
+
module Models
|
5
|
+
module Chat
|
6
|
+
class ParsedChoice < OpenAI::Models::Chat::ChatCompletion::Choice
|
7
|
+
optional :finish_reason, enum: -> { OpenAI::Chat::ChatCompletion::Choice::FinishReason }, nil?: true
|
8
|
+
end
|
9
|
+
|
10
|
+
class ParsedChatCompletion < ChatCompletion
|
11
|
+
required :choices, -> { OpenAI::Internal::Type::ArrayOf[ParsedChoice] }
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -110,6 +110,54 @@ module OpenAI
|
|
110
110
|
raise ArgumentError.new(message)
|
111
111
|
end
|
112
112
|
|
113
|
+
model, tool_models = get_structured_output_models(parsed)
|
114
|
+
|
115
|
+
# rubocop:disable Metrics/BlockLength
|
116
|
+
unwrap = ->(raw) do
|
117
|
+
if model.is_a?(OpenAI::StructuredOutput::JsonSchemaConverter)
|
118
|
+
raw[:choices]&.each do |choice|
|
119
|
+
message = choice.fetch(:message)
|
120
|
+
begin
|
121
|
+
content = message.fetch(:content)
|
122
|
+
parsed = content.nil? ? nil : JSON.parse(content, symbolize_names: true)
|
123
|
+
rescue JSON::ParserError => e
|
124
|
+
parsed = e
|
125
|
+
end
|
126
|
+
coerced = OpenAI::Internal::Type::Converter.coerce(model, parsed)
|
127
|
+
message.store(:parsed, coerced)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
raw[:choices]&.each do |choice|
|
131
|
+
choice.dig(:message, :tool_calls)&.each do |tool_call|
|
132
|
+
func = tool_call.fetch(:function)
|
133
|
+
next if (model = tool_models[func.fetch(:name)]).nil?
|
134
|
+
|
135
|
+
begin
|
136
|
+
arguments = func.fetch(:arguments)
|
137
|
+
parsed = arguments.nil? ? nil : JSON.parse(arguments, symbolize_names: true)
|
138
|
+
rescue JSON::ParserError => e
|
139
|
+
parsed = e
|
140
|
+
end
|
141
|
+
coerced = OpenAI::Internal::Type::Converter.coerce(model, parsed)
|
142
|
+
func.store(:parsed, coerced)
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
raw
|
147
|
+
end
|
148
|
+
# rubocop:enable Metrics/BlockLength
|
149
|
+
|
150
|
+
@client.request(
|
151
|
+
method: :post,
|
152
|
+
path: "chat/completions",
|
153
|
+
body: parsed,
|
154
|
+
unwrap: unwrap,
|
155
|
+
model: OpenAI::Chat::ChatCompletion,
|
156
|
+
options: options
|
157
|
+
)
|
158
|
+
end
|
159
|
+
|
160
|
+
def get_structured_output_models(parsed)
|
113
161
|
model = nil
|
114
162
|
tool_models = {}
|
115
163
|
case parsed
|
@@ -162,53 +210,46 @@ module OpenAI
|
|
162
210
|
else
|
163
211
|
end
|
164
212
|
|
165
|
-
|
166
|
-
|
167
|
-
if model.is_a?(OpenAI::StructuredOutput::JsonSchemaConverter)
|
168
|
-
raw[:choices]&.each do |choice|
|
169
|
-
message = choice.fetch(:message)
|
170
|
-
begin
|
171
|
-
content = message.fetch(:content)
|
172
|
-
parsed = content.nil? ? nil : JSON.parse(content, symbolize_names: true)
|
173
|
-
rescue JSON::ParserError => e
|
174
|
-
parsed = e
|
175
|
-
end
|
176
|
-
coerced = OpenAI::Internal::Type::Converter.coerce(model, parsed)
|
177
|
-
message.store(:parsed, coerced)
|
178
|
-
end
|
179
|
-
end
|
180
|
-
raw[:choices]&.each do |choice|
|
181
|
-
choice.dig(:message, :tool_calls)&.each do |tool_call|
|
182
|
-
func = tool_call.fetch(:function)
|
183
|
-
next if (model = tool_models[func.fetch(:name)]).nil?
|
213
|
+
[model, tool_models]
|
214
|
+
end
|
184
215
|
|
185
|
-
|
186
|
-
|
187
|
-
parsed = arguments.nil? ? nil : JSON.parse(arguments, symbolize_names: true)
|
188
|
-
rescue JSON::ParserError => e
|
189
|
-
parsed = e
|
190
|
-
end
|
191
|
-
coerced = OpenAI::Internal::Type::Converter.coerce(model, parsed)
|
192
|
-
func.store(:parsed, coerced)
|
193
|
-
end
|
194
|
-
end
|
216
|
+
def build_tools_with_models(tools, tool_models)
|
217
|
+
return [] if tools.nil?
|
195
218
|
|
196
|
-
|
219
|
+
tools.map do |tool|
|
220
|
+
next tool unless tool[:type] == :function
|
221
|
+
|
222
|
+
function_name = tool.dig(:function, :name)
|
223
|
+
model = tool_models[function_name]
|
224
|
+
|
225
|
+
model ? tool.merge(model: model) : tool
|
197
226
|
end
|
198
|
-
|
227
|
+
end
|
199
228
|
|
200
|
-
|
229
|
+
def stream(params)
|
230
|
+
parsed, options = OpenAI::Chat::CompletionCreateParams.dump_request(params)
|
231
|
+
|
232
|
+
parsed.store(:stream, true)
|
233
|
+
|
234
|
+
response_format, tool_models = get_structured_output_models(parsed)
|
235
|
+
|
236
|
+
input_tools = build_tools_with_models(parsed[:tools], tool_models)
|
237
|
+
|
238
|
+
raw_stream = @client.request(
|
201
239
|
method: :post,
|
202
240
|
path: "chat/completions",
|
241
|
+
headers: {"accept" => "text/event-stream"},
|
203
242
|
body: parsed,
|
204
|
-
|
205
|
-
model: OpenAI::Chat::
|
243
|
+
stream: OpenAI::Internal::Stream,
|
244
|
+
model: OpenAI::Chat::ChatCompletionChunk,
|
206
245
|
options: options
|
207
246
|
)
|
208
|
-
end
|
209
247
|
|
210
|
-
|
211
|
-
|
248
|
+
OpenAI::Helpers::Streaming::ChatCompletionStream.new(
|
249
|
+
raw_stream: raw_stream,
|
250
|
+
response_format: response_format,
|
251
|
+
input_tools: input_tools
|
252
|
+
)
|
212
253
|
end
|
213
254
|
|
214
255
|
# See {OpenAI::Resources::Chat::Completions#create} for non-streaming counterpart.
|
@@ -85,7 +85,7 @@ module OpenAI
|
|
85
85
|
def create(params = {})
|
86
86
|
parsed, options = OpenAI::Responses::ResponseCreateParams.dump_request(params)
|
87
87
|
if parsed[:stream]
|
88
|
-
message = "Please use `#
|
88
|
+
message = "Please use `#stream` for the streaming use case."
|
89
89
|
raise ArgumentError.new(message)
|
90
90
|
end
|
91
91
|
|
data/lib/openai/version.rb
CHANGED
data/lib/openai.rb
CHANGED
@@ -195,6 +195,7 @@ require_relative "openai/models/chat/chat_completion_assistant_message_param"
|
|
195
195
|
require_relative "openai/models/chat/chat_completion_audio"
|
196
196
|
require_relative "openai/models/chat/chat_completion_audio_param"
|
197
197
|
require_relative "openai/models/chat/chat_completion_chunk"
|
198
|
+
require_relative "openai/models/chat/parsed_chat_completion"
|
198
199
|
require_relative "openai/models/chat/chat_completion_content_part"
|
199
200
|
require_relative "openai/models/chat/chat_completion_content_part_image"
|
200
201
|
require_relative "openai/models/chat/chat_completion_content_part_input_audio"
|
@@ -697,6 +698,9 @@ require_relative "openai/resources/vector_stores"
|
|
697
698
|
require_relative "openai/resources/vector_stores/file_batches"
|
698
699
|
require_relative "openai/resources/vector_stores/files"
|
699
700
|
require_relative "openai/resources/webhooks"
|
700
|
-
require_relative "openai/helpers/streaming/
|
701
|
+
require_relative "openai/helpers/streaming/response_events"
|
701
702
|
require_relative "openai/helpers/streaming/response_stream"
|
703
|
+
require_relative "openai/helpers/streaming/exceptions"
|
704
|
+
require_relative "openai/helpers/streaming/chat_events"
|
705
|
+
require_relative "openai/helpers/streaming/chat_completion_stream"
|
702
706
|
require_relative "openai/streaming"
|
@@ -26,6 +26,126 @@ module OpenAI
|
|
26
26
|
def response
|
27
27
|
end
|
28
28
|
end
|
29
|
+
|
30
|
+
class ChatChunkEvent < OpenAI::Internal::Type::BaseModel
|
31
|
+
sig { returns(T.untyped) }
|
32
|
+
def chunk
|
33
|
+
end
|
34
|
+
|
35
|
+
sig { returns(T.untyped) }
|
36
|
+
def snapshot
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
class ChatContentDeltaEvent < OpenAI::Internal::Type::BaseModel
|
41
|
+
sig { returns(String) }
|
42
|
+
def delta
|
43
|
+
end
|
44
|
+
|
45
|
+
sig { returns(String) }
|
46
|
+
def snapshot
|
47
|
+
end
|
48
|
+
|
49
|
+
sig { returns(T.untyped) }
|
50
|
+
def parsed
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
class ChatContentDoneEvent < OpenAI::Internal::Type::BaseModel
|
55
|
+
sig { returns(String) }
|
56
|
+
def content
|
57
|
+
end
|
58
|
+
|
59
|
+
sig { returns(T.untyped) }
|
60
|
+
def parsed
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
class ChatRefusalDeltaEvent < OpenAI::Internal::Type::BaseModel
|
65
|
+
sig { returns(String) }
|
66
|
+
def delta
|
67
|
+
end
|
68
|
+
|
69
|
+
sig { returns(String) }
|
70
|
+
def snapshot
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
class ChatRefusalDoneEvent < OpenAI::Internal::Type::BaseModel
|
75
|
+
sig { returns(String) }
|
76
|
+
def refusal
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
class ChatFunctionToolCallArgumentsDeltaEvent < OpenAI::Internal::Type::BaseModel
|
81
|
+
sig { returns(String) }
|
82
|
+
def name
|
83
|
+
end
|
84
|
+
|
85
|
+
sig { returns(Integer) }
|
86
|
+
def index
|
87
|
+
end
|
88
|
+
|
89
|
+
sig { returns(String) }
|
90
|
+
def arguments_delta
|
91
|
+
end
|
92
|
+
|
93
|
+
sig { returns(String) }
|
94
|
+
def arguments
|
95
|
+
end
|
96
|
+
|
97
|
+
sig { returns(T.untyped) }
|
98
|
+
def parsed_arguments
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
class ChatFunctionToolCallArgumentsDoneEvent < OpenAI::Internal::Type::BaseModel
|
103
|
+
sig { returns(String) }
|
104
|
+
def name
|
105
|
+
end
|
106
|
+
|
107
|
+
sig { returns(Integer) }
|
108
|
+
def index
|
109
|
+
end
|
110
|
+
|
111
|
+
sig { returns(String) }
|
112
|
+
def arguments
|
113
|
+
end
|
114
|
+
|
115
|
+
sig { returns(T.untyped) }
|
116
|
+
def parsed_arguments
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
class ChatLogprobsContentDeltaEvent < OpenAI::Internal::Type::BaseModel
|
121
|
+
sig { returns(T.untyped) }
|
122
|
+
def content
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
class ChatLogprobsContentDoneEvent < OpenAI::Internal::Type::BaseModel
|
127
|
+
sig { returns(T.untyped) }
|
128
|
+
def content
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
class ChatLogprobsRefusalDeltaEvent < OpenAI::Internal::Type::BaseModel
|
133
|
+
sig { returns(T.untyped) }
|
134
|
+
def refusal
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
class ChatLogprobsRefusalDoneEvent < OpenAI::Internal::Type::BaseModel
|
139
|
+
sig { returns(T.untyped) }
|
140
|
+
def refusal
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
class ChatCompletionStream
|
145
|
+
sig { returns(T.untyped) }
|
146
|
+
def each
|
147
|
+
end
|
148
|
+
end
|
29
149
|
end
|
30
150
|
end
|
31
151
|
end
|
data/rbi/openai/streaming.rbi
CHANGED
@@ -1,5 +1,32 @@
|
|
1
1
|
# typed: strong
|
2
2
|
|
3
3
|
module OpenAI
|
4
|
-
|
4
|
+
module Streaming
|
5
|
+
ResponseTextDeltaEvent = OpenAI::Helpers::Streaming::ResponseTextDeltaEvent
|
6
|
+
ResponseTextDoneEvent = OpenAI::Helpers::Streaming::ResponseTextDoneEvent
|
7
|
+
ResponseFunctionCallArgumentsDeltaEvent =
|
8
|
+
OpenAI::Helpers::Streaming::ResponseFunctionCallArgumentsDeltaEvent
|
9
|
+
ResponseCompletedEvent = OpenAI::Helpers::Streaming::ResponseCompletedEvent
|
10
|
+
|
11
|
+
ChatChunkEvent = OpenAI::Helpers::Streaming::ChatChunkEvent
|
12
|
+
ChatContentDeltaEvent = OpenAI::Helpers::Streaming::ChatContentDeltaEvent
|
13
|
+
ChatContentDoneEvent = OpenAI::Helpers::Streaming::ChatContentDoneEvent
|
14
|
+
ChatRefusalDeltaEvent = OpenAI::Helpers::Streaming::ChatRefusalDeltaEvent
|
15
|
+
ChatRefusalDoneEvent = OpenAI::Helpers::Streaming::ChatRefusalDoneEvent
|
16
|
+
ChatFunctionToolCallArgumentsDeltaEvent =
|
17
|
+
OpenAI::Helpers::Streaming::ChatFunctionToolCallArgumentsDeltaEvent
|
18
|
+
ChatFunctionToolCallArgumentsDoneEvent =
|
19
|
+
OpenAI::Helpers::Streaming::ChatFunctionToolCallArgumentsDoneEvent
|
20
|
+
ChatLogprobsContentDeltaEvent =
|
21
|
+
OpenAI::Helpers::Streaming::ChatLogprobsContentDeltaEvent
|
22
|
+
ChatLogprobsContentDoneEvent =
|
23
|
+
OpenAI::Helpers::Streaming::ChatLogprobsContentDoneEvent
|
24
|
+
ChatLogprobsRefusalDeltaEvent =
|
25
|
+
OpenAI::Helpers::Streaming::ChatLogprobsRefusalDeltaEvent
|
26
|
+
ChatLogprobsRefusalDoneEvent =
|
27
|
+
OpenAI::Helpers::Streaming::ChatLogprobsRefusalDoneEvent
|
28
|
+
|
29
|
+
ResponseStream = OpenAI::Helpers::Streaming::ResponseStream
|
30
|
+
ChatCompletionStream = OpenAI::Helpers::Streaming::ChatCompletionStream
|
31
|
+
end
|
5
32
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: openai
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.27.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- OpenAI
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-09-
|
11
|
+
date: 2025-09-27 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: connection_pool
|
@@ -39,7 +39,10 @@ files:
|
|
39
39
|
- lib/openai/client.rb
|
40
40
|
- lib/openai/errors.rb
|
41
41
|
- lib/openai/file_part.rb
|
42
|
-
- lib/openai/helpers/streaming/
|
42
|
+
- lib/openai/helpers/streaming/chat_completion_stream.rb
|
43
|
+
- lib/openai/helpers/streaming/chat_events.rb
|
44
|
+
- lib/openai/helpers/streaming/exceptions.rb
|
45
|
+
- lib/openai/helpers/streaming/response_events.rb
|
43
46
|
- lib/openai/helpers/streaming/response_stream.rb
|
44
47
|
- lib/openai/helpers/structured_output.rb
|
45
48
|
- lib/openai/helpers/structured_output/array_of.rb
|
@@ -229,6 +232,7 @@ files:
|
|
229
232
|
- lib/openai/models/chat/completion_retrieve_params.rb
|
230
233
|
- lib/openai/models/chat/completion_update_params.rb
|
231
234
|
- lib/openai/models/chat/completions/message_list_params.rb
|
235
|
+
- lib/openai/models/chat/parsed_chat_completion.rb
|
232
236
|
- lib/openai/models/chat_model.rb
|
233
237
|
- lib/openai/models/comparison_filter.rb
|
234
238
|
- lib/openai/models/completion.rb
|
File without changes
|