openai 0.115.0 → 0.116.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.
@@ -5,30 +5,88 @@ module OpenAI
5
5
  # Optional, caller-fed provisional output for one WebSocket lane.
6
6
  # Feed the events returned by lane.receive; use a separate instance per lane.
7
7
  # This helper never reads, closes, or writes to a connection. Tools are data.
8
- #
9
- # Output is provisional and may be unavailable when the wire omitted its
10
- # scaffolding, or a known output update cannot be represented. Terminal events
11
- # are retained exactly as received, never filled from provisional output.
12
- # Call reset to discard all retained state.
8
+ # Terminal events stay exact; provisional facts never manufacture a Response.
13
9
  class IncrementalResponse
14
10
  include OpenAI::WebSocket::Protocol
15
11
 
12
+ STATUS_ITEMS = {
13
+ "response.code_interpreter_call.in_progress" => ["code_interpreter_call", "in_progress"],
14
+ "response.code_interpreter_call.interpreting" => ["code_interpreter_call", "interpreting"],
15
+ "response.code_interpreter_call.completed" => ["code_interpreter_call", "completed"],
16
+ "response.file_search_call.in_progress" => ["file_search_call", "in_progress"],
17
+ "response.file_search_call.searching" => ["file_search_call", "searching"],
18
+ "response.file_search_call.completed" => ["file_search_call", "completed"],
19
+ "response.web_search_call.in_progress" => ["web_search_call", "in_progress"],
20
+ "response.web_search_call.searching" => ["web_search_call", "searching"],
21
+ "response.web_search_call.completed" => ["web_search_call", "completed"],
22
+ "response.image_generation_call.in_progress" => ["image_generation_call", "in_progress"],
23
+ "response.image_generation_call.generating" => ["image_generation_call", "generating"],
24
+ "response.image_generation_call.completed" => ["image_generation_call", "completed"],
25
+ "response.mcp_call.in_progress" => ["mcp_call", "in_progress"],
26
+ "response.mcp_call.completed" => ["mcp_call", "completed"],
27
+ "response.mcp_call.failed" => ["mcp_call", "failed"]
28
+ }.freeze
29
+ private_constant :STATUS_ITEMS
30
+
16
31
  attr_reader :phase
17
32
 
18
33
  def initialize
19
34
  reset
20
35
  end
21
36
 
22
- # A detached provisional output snapshot, or nil when unavailable/terminal.
23
- #
24
- # This materializes the whole output and costs time proportional to its
25
- # size. Use the original event's delta for per-delta progress. Read output
26
- # at boundaries such as response.output_item.done when a full, corrected
27
- # view is needed; replace a displayed view instead of appending its prefix.
28
- # Repeated full snapshots of a growing response necessarily copy growing
29
- # amounts of data.
37
+ # Detached typed output: the truthful contiguous projectable prefix.
38
+ # Sparse or incomplete records stop this prefix; detailed_snapshot retains
39
+ # their actual indices. Full reads cost time proportional to retained data.
40
+ # Use original deltas for per-event progress and read snapshots at boundaries.
30
41
  def output
31
- @output&.map { |item| copy(item) }
42
+ return unless @phase == :provisional
43
+
44
+ result = []
45
+ while (record = @items[result.length])
46
+ break unless record[:full]
47
+
48
+ result <<
49
+ OpenAI::Internal::Type::Converter.coerce(
50
+ OpenAI::Responses::ResponseOutputItem,
51
+ project_item(record)
52
+ )
53
+ end
54
+
55
+ result
56
+ end
57
+
58
+ # Detached observed facts, including sparse indices and response metadata.
59
+ # This is not a complete Response. No absent item/part type is inferred.
60
+ def detailed_snapshot
61
+ return if @phase.nil? || @phase == :unavailable
62
+
63
+ if @phase == :terminal
64
+ data = @terminal_event.deep_to_h
65
+ response = data[:response]
66
+ items = response.is_a?(Hash) && response[:output].is_a?(Array) ? response[:output] : []
67
+ records = items.map { |item| make_record(item, full: true) }
68
+ rows = records.each_with_index.map { |record, index| detailed_item(index, record) }
69
+ return copy(
70
+ phase: @phase,
71
+ stream_id: data[:stream_id],
72
+ response_id: response.is_a?(Hash) ? response[:id] : nil,
73
+ terminal_type: data[:type],
74
+ response: response.is_a?(Hash) ? response.reject { |key, _| key == :output } : nil,
75
+ output: rows,
76
+ output_text: observed_text(records)
77
+ )
78
+ end
79
+
80
+ indices = @items.keys.sort
81
+ copy(
82
+ phase: @phase,
83
+ stream_id: @stream_id,
84
+ response_id: @response && @response[:id],
85
+ terminal_type: nil,
86
+ response: @response,
87
+ output: indices.map { |index| detailed_item(index, @items.fetch(index)) },
88
+ output_text: observed_text(indices.map { |index| @items.fetch(index) })
89
+ )
32
90
  end
33
91
 
34
92
  # The exact consumed completion, failure, incomplete, or error event.
@@ -37,60 +95,65 @@ module OpenAI
37
95
  end
38
96
 
39
97
  def reset
40
- @state = OpenAI::Helpers::Streaming::ResponseStreamState.new(text_format: nil)
41
98
  @phase = nil
42
- @output = nil
99
+ @items = {}
100
+ @ids = {}
101
+ @command_bounds = {}
102
+ @response = nil
103
+ @stream_id = nil
43
104
  @terminal_event = nil
44
105
  nil
45
106
  end
46
107
 
47
108
  def add(event)
48
- case event
49
- when OpenAI::Responses::UnknownServerEvent
50
- return
51
- when
52
- OpenAI::Responses::ResponseCompletedEvent,
53
- OpenAI::Responses::ResponseFailedEvent,
54
- OpenAI::Responses::ResponseIncompleteEvent,
55
- OpenAI::Responses::ResponsesServerEvent::ResponseWsError
109
+ return if event.is_a?(OpenAI::Responses::UnknownServerEvent)
110
+
111
+ type = event[:type].to_s
112
+ if %w[response.completed response.failed response.incomplete error].include?(type)
56
113
  reset
57
114
  @terminal_event = copy(event)
58
115
  @phase = :terminal
59
- when OpenAI::Responses::ResponseCreatedEvent
116
+ return
117
+ end
118
+
119
+ return if @phase == :terminal && type != "response.created"
120
+
121
+ if %w[response.created response.queued response.in_progress].include?(type)
60
122
  response = copy(event)[:response]
61
123
  unless response.is_a?(OpenAI::Responses::Response) &&
62
124
  (response[:output].nil? || response[:output].is_a?(Array))
63
125
  raise SessionError, "Invalid Responses WebSocket created response."
64
126
  end
65
127
 
66
- reset
67
- @output = response[:output] || []
68
- @phase = :provisional
69
- when
70
- OpenAI::Responses::ResponseOutputItemAddedEvent,
71
- OpenAI::Responses::ResponseOutputItemDoneEvent,
72
- OpenAI::Responses::ResponseContentPartAddedEvent,
73
- OpenAI::Responses::ResponseContentPartDoneEvent,
74
- OpenAI::Responses::ResponseTextDeltaEvent,
75
- OpenAI::Responses::ResponseTextDoneEvent,
76
- OpenAI::Responses::ResponseFunctionCallArgumentsDeltaEvent,
77
- OpenAI::Responses::ResponseFunctionCallArgumentsDoneEvent,
78
- OpenAI::Responses::ResponseCustomToolCallInputDeltaEvent,
79
- OpenAI::Responses::ResponseCustomToolCallInputDoneEvent,
80
- OpenAI::Responses::ResponseRefusalDeltaEvent,
81
- OpenAI::Responses::ResponseRefusalDoneEvent,
82
- OpenAI::Responses::ResponseOutputTextAnnotationAddedEvent
83
- return if @phase == :unavailable || @phase == :terminal
84
-
85
- apply_output(copy(event))
86
- else
87
- # A full output snapshot must not leave stale fields after unsupported
88
- # indexed updates. Unknown raw events and progress events are not output.
89
- if @phase != :terminal &&
90
- event.class.known_fields.key?(:output_index) &&
91
- event[:type].to_s.end_with?(".added", ".delta", ".done", ".partial_image")
92
- unavailable
128
+ reset if type == "response.created"
129
+ return if @phase == :unavailable
130
+
131
+ data = response.deep_to_h
132
+ @response = data.reject { |key, _| key == :output }
133
+ @stream_id = copy(event[:stream_id])
134
+ if data[:output].is_a?(Array) || type == "response.created"
135
+ @items = {}
136
+ @ids = {}
137
+ @command_bounds = {}
138
+ (data[:output] || []).each_with_index do |item, index|
139
+ replace_item(index, item)
140
+ break if @phase == :unavailable
141
+ end
93
142
  end
143
+
144
+ @phase = :provisional unless @phase == :unavailable
145
+ return
146
+ end
147
+
148
+ return if @phase == :unavailable
149
+
150
+ data = copy(event).deep_to_h
151
+ @stream_id ||= data[:stream_id]
152
+ if (status = STATUS_ITEMS[type])
153
+ record = item_for(data, status.first)
154
+ record[:item][:status] = status.last if record
155
+ else
156
+ apply_output(type, data, event.class)
94
157
  end
95
158
 
96
159
  nil
@@ -98,134 +161,417 @@ module OpenAI
98
161
 
99
162
  private
100
163
 
101
- def apply_output(event)
102
- index = event[:output_index]
103
- unless @output && index.is_a?(Integer) && index >= 0 && index <= @output.length
104
- return unavailable
105
- end
106
-
107
- case event
108
- when OpenAI::Responses::ResponseOutputItemDoneEvent
109
- # Replace the entire item: done may reduce its content or change its ID.
110
- @output[index] = event.item
111
- @state = OpenAI::Helpers::Streaming::ResponseStreamState.new(text_format: nil)
112
- when OpenAI::Responses::ResponseOutputItemAddedEvent
113
- return unavailable unless index == @output.length
114
-
115
- @state.accumulate_output(event, @output)
116
- when OpenAI::Responses::ResponseFunctionCallArgumentsDoneEvent
117
- item = @output[index]
118
- unless item.is_a?(OpenAI::Responses::ResponseFunctionToolCall) &&
119
- item.id == event.item_id &&
120
- event[:arguments].is_a?(String)
121
- return unavailable
164
+ def apply_output(type, data, event_class)
165
+ case type
166
+ when "response.output_item.added", "response.output_item.done"
167
+ return unavailable unless valid_index?(data[:output_index]) && data[:item].is_a?(Hash)
168
+
169
+ replace_item(data[:output_index], data[:item])
170
+ when
171
+ "response.content_part.added",
172
+ "response.content_part.done",
173
+ "response.reasoning_summary_part.added",
174
+ "response.reasoning_summary_part.done"
175
+ summary = type.start_with?("response.reasoning_summary")
176
+ index = data[summary ? :summary_index : :content_index]
177
+ part = data[:part]
178
+ return unavailable unless valid_index?(index) && part.is_a?(Hash)
179
+
180
+ kind = summary || part[:type].to_s == "reasoning_text" ? "reasoning" : "message"
181
+ record = item_for(data, kind)
182
+ put_part(record, summary ? :summary : :content, index, part) if record
183
+ when
184
+ "response.function_call_arguments.delta",
185
+ "response.function_call_arguments.done",
186
+ "response.custom_tool_call_input.delta",
187
+ "response.custom_tool_call_input.done",
188
+ "response.mcp_call_arguments.delta",
189
+ "response.mcp_call_arguments.done",
190
+ "response.code_interpreter_call_code.delta",
191
+ "response.code_interpreter_call_code.done"
192
+ kind, field = case type
193
+ when /function_call_arguments/
194
+ ["function_call", :arguments]
195
+ when /custom_tool_call_input/
196
+ ["custom_tool_call", :input]
197
+ when /mcp_call_arguments/
198
+ ["mcp_call", :arguments]
199
+ else
200
+ ["code_interpreter_call", :code]
201
+ end
202
+
203
+ done = type.end_with?(".done")
204
+ value = data[done ? field : :delta]
205
+ return unavailable unless value.is_a?(String)
206
+
207
+ record = item_for(data, kind)
208
+ if record
209
+ update_string(record[:item], field, value, done: done, nullable: field == :code, required: record[:full])
122
210
  end
123
211
 
124
- item.arguments = event[:arguments]
125
212
  when
126
- OpenAI::Responses::ResponseCustomToolCallInputDeltaEvent,
127
- OpenAI::Responses::ResponseCustomToolCallInputDoneEvent
128
- item = @output[index]
129
- unless item.is_a?(OpenAI::Responses::ResponseCustomToolCall) && item.id == event.item_id
213
+ "response.output_text.delta",
214
+ "response.output_text.done",
215
+ "response.refusal.delta",
216
+ "response.refusal.done",
217
+ "response.reasoning_text.delta",
218
+ "response.reasoning_text.done",
219
+ "response.reasoning_summary_text.delta",
220
+ "response.reasoning_summary_text.done"
221
+ summary = type.start_with?("response.reasoning_summary")
222
+ reasoning = type.start_with?("response.reasoning")
223
+ refusal = type.start_with?("response.refusal")
224
+ field = refusal ? :refusal : :text
225
+ done = type.end_with?(".done")
226
+ value = data[done ? field : :delta]
227
+ index = data[summary ? :summary_index : :content_index]
228
+ return unavailable unless value.is_a?(String) && valid_index?(index)
229
+ return unavailable unless data[:logprobs].nil? || data[:logprobs].is_a?(Array)
230
+
231
+ record = item_for(data, reasoning ? "reasoning" : "message")
232
+ return unless record
233
+
234
+ kind = reasoning ? (summary ? "summary_text" : "reasoning_text") : (refusal ? "refusal" : "output_text")
235
+ part = part_for(record, summary ? :summary : :content, index, kind)
236
+ return unless part
237
+ return unless update_string(part[:part], field, value, done: done, required: part[:full])
238
+
239
+ if type.start_with?("response.output_text") && !data[:logprobs].nil?
240
+ if done
241
+ part[:part][:logprobs] = data[:logprobs]
242
+ else
243
+ old = part[:part][:logprobs]
244
+ return unavailable unless old.nil? || old.is_a?(Array)
245
+
246
+ (part[:part][:logprobs] ||= []).concat(data[:logprobs])
247
+ end
248
+ end
249
+
250
+ when "response.output_text.annotation.added"
251
+ index = data[:content_index]
252
+ annotation_index = data[:annotation_index]
253
+ unless valid_index?(index) &&
254
+ valid_index?(annotation_index) &&
255
+ data.key?(:annotation) &&
256
+ (data[:annotation].nil? || data[:annotation].is_a?(Hash))
130
257
  return unavailable
131
258
  end
132
259
 
133
- if event.is_a?(OpenAI::Responses::ResponseCustomToolCallInputDeltaEvent)
134
- return unavailable unless item[:input].is_a?(String) && event[:delta].is_a?(String)
260
+ record = item_for(data, "message")
261
+ return unless record
262
+
263
+ part = part_for(record, :content, index, "output_text")
264
+ return unless part
265
+ return unavailable if part[:part].key?(:annotations) && !part[:part][:annotations].is_a?(Array)
266
+
267
+ part[:annotations][annotation_index] = data[:annotation]
268
+ sync_array(part[:part][:annotations], part[:annotations], annotation_index)
269
+ when
270
+ "response.shell_call_command.added",
271
+ "response.shell_call_command.delta",
272
+ "response.shell_call_command.done"
273
+ done = !type.end_with?(".delta")
274
+ value = data[done ? :command : :delta]
275
+ index = data[:command_index]
276
+ return unavailable unless value.is_a?(String) && valid_index?(index)
277
+
278
+ record = item_for(data, "shell_call", identity: false)
279
+ return unless record
135
280
 
136
- item.input << event[:delta]
281
+ if done
282
+ record[:commands][index] = value
137
283
  else
138
- return unavailable unless event[:input].is_a?(String)
284
+ return unavailable if record[:commands].key?(index) && !record[:commands][index].is_a?(String)
139
285
 
140
- item.input = event[:input]
286
+ (record[:commands][index] ||= +"") << value
141
287
  end
142
- else
143
- item = @output[index]
144
- if event.is_a?(OpenAI::Responses::ResponseFunctionCallArgumentsDeltaEvent)
145
- unless item.is_a?(OpenAI::Responses::ResponseFunctionToolCall) &&
146
- item.id == event.item_id &&
147
- event.delta.is_a?(String)
288
+
289
+ commands = record[:item].dig(:action, :commands)
290
+ sync_array(commands, record[:commands], index)
291
+ update_bound(record, data[:output_index])
292
+ when "response.shell_call_output_content.delta", "response.shell_call_output_content.done"
293
+ index = data[:command_index]
294
+ done = type.end_with?(".done")
295
+ value = data[done ? :output : :delta]
296
+ return unavailable unless valid_index?(index)
297
+ if done
298
+ return unavailable unless value.is_a?(Array)
299
+ else
300
+ unless value.is_a?(Hash) &&
301
+ value.all? { |key, entry| !%i[stdout stderr].include?(key) || entry.is_a?(String) }
148
302
  return unavailable
149
303
  end
304
+ end
305
+
306
+ record = item_for(data, "shell_call_output")
307
+ return unless record
308
+
309
+ bound = @command_bounds[record[:item][:call_id]]
310
+ return unavailable if bound && index >= bound[:count]
311
+
312
+ if done
313
+ record[:content][index] = make_part({output: value}, full: false)
150
314
  else
151
- part_index = event[:content_index]
152
- unless item.is_a?(OpenAI::Responses::ResponseOutputMessage) &&
153
- item.id == event.item_id &&
154
- item.content.is_a?(Array) &&
155
- part_index.is_a?(Integer) &&
156
- part_index >= 0 &&
157
- part_index <= item.content.length
315
+ part = record[:content][index] ||= make_part({}, full: false)
316
+ value.each do |key, entry|
317
+ if %i[stdout stderr].include?(key)
318
+ update_string(part[:part], key, entry, done: false)
319
+ else
320
+ part[:part][key] = entry
321
+ end
322
+ end
323
+ end
324
+
325
+ when "response.image_generation_call.partial_image"
326
+ # Image bytes are deliberately not accumulated. Status remains useful.
327
+ nil
328
+ else
329
+ if event_class.known_fields.key?(:output_index) &&
330
+ type.end_with?(".added", ".delta", ".done", ".partial_image")
331
+ unavailable
332
+ end
333
+ end
334
+ end
335
+
336
+ def item_for(data, kind, identity: true)
337
+ index = data[:output_index]
338
+ return unavailable unless valid_index?(index)
339
+
340
+ record = @items[index]
341
+ return unavailable if record && record[:kind] && record[:kind] != kind
342
+ if identity
343
+ id = data[:item_id]
344
+ return unavailable unless id.is_a?(String)
345
+ return unavailable if @ids.key?(id) && @ids[id] != index
346
+ if record
347
+ item = record[:item]
348
+ if item.key?(:id)
349
+ return unavailable unless item[:id] == id
350
+ elsif record[:full] && !%w[function_call custom_tool_call].include?(kind)
158
351
  return unavailable
159
352
  end
160
353
 
161
- case event
162
- when OpenAI::Responses::ResponseContentPartDoneEvent
163
- item.content[part_index] = event.part
164
- return
165
- when OpenAI::Responses::ResponseContentPartAddedEvent
166
- return unavailable unless part_index == item.content.length
167
- when OpenAI::Responses::ResponseTextDoneEvent
168
- part = item.content[part_index]
169
- unless part.is_a?(OpenAI::Responses::ResponseOutputText) &&
170
- event[:text].is_a?(String) &&
171
- (event[:logprobs].nil? || event[:logprobs].is_a?(Array))
172
- return unavailable
173
- end
354
+ return unavailable if record[:bound_id] && record[:bound_id] != id
355
+ end
356
+ end
174
357
 
175
- part.text = event[:text]
176
- part.logprobs = event[:logprobs] unless event[:logprobs].nil?
177
- return
178
- when OpenAI::Responses::ResponseTextDeltaEvent
179
- part = item.content[part_index]
180
- unless part.is_a?(OpenAI::Responses::ResponseOutputText) &&
181
- part.text.is_a?(String) &&
182
- event.delta.is_a?(String) &&
183
- (event[:logprobs].nil? || event[:logprobs].is_a?(Array)) &&
184
- (part[:logprobs].nil? || part[:logprobs].is_a?(Array))
185
- return unavailable
186
- end
358
+ record ||= (@items[index] = make_record({}, full: false))
359
+ record[:kind] ||= kind
360
+ if identity
361
+ record[:bound_id] = data[:item_id]
362
+ record[:item][:id] = data[:item_id] unless record[:full]
363
+ @ids[data[:item_id]] = index
364
+ end
187
365
 
188
- unless event[:logprobs].nil?
189
- part.logprobs = [] if part[:logprobs].nil?
190
- part[:logprobs].concat(event[:logprobs])
191
- end
366
+ @phase = :provisional
367
+ record
368
+ end
192
369
 
193
- when OpenAI::Responses::ResponseRefusalDeltaEvent, OpenAI::Responses::ResponseRefusalDoneEvent
194
- part = item.content[part_index]
195
- return unavailable unless part.is_a?(OpenAI::Responses::ResponseOutputRefusal)
370
+ def replace_item(index, item)
371
+ id = item[:id]
372
+ return unavailable if id.is_a?(String) && @ids.key?(id) && @ids[id] != index
196
373
 
197
- if event.is_a?(OpenAI::Responses::ResponseRefusalDeltaEvent)
198
- return unavailable unless part[:refusal].is_a?(String) && event[:delta].is_a?(String)
374
+ if (old = @items[index])
375
+ @ids.delete(old[:bound_id]) if old[:bound_id]
376
+ call_id = old[:item][:call_id]
377
+ @command_bounds.delete(call_id) if @command_bounds[call_id]&.fetch(:index) == index
378
+ end
199
379
 
200
- part.refusal << event[:delta]
201
- else
202
- return unavailable unless event[:refusal].is_a?(String)
380
+ record = @items[index] = make_record(item, full: true)
381
+ @ids[id] = index if id.is_a?(String)
382
+ update_bound(record, index)
383
+ @phase = :provisional
384
+ end
203
385
 
204
- part.refusal = event[:refusal]
205
- end
386
+ def make_record(item, full:)
387
+ kind = item[:type]&.to_s
388
+ {
389
+ item: item,
390
+ full: full,
391
+ kind: kind,
392
+ bound_id: item[:id],
393
+ content: part_collection?(kind, :content) ? indexed_parts(item[:content]) : {},
394
+ summary: part_collection?(kind, :summary) ? indexed_parts(item[:summary]) : {},
395
+ commands: kind == "shell_call" && item[:action].is_a?(Hash) && item[:action][:commands].is_a?(Array) ? item[
396
+ :action
397
+ ][:commands]
398
+ .each_with_index
399
+ .to_h { |value, index| [index, value] } : {}
400
+ }
401
+ end
206
402
 
207
- return
208
- when OpenAI::Responses::ResponseOutputTextAnnotationAddedEvent
209
- part = item.content[part_index]
210
- annotation_index = event[:annotation_index]
211
- unless part.is_a?(OpenAI::Responses::ResponseOutputText) &&
212
- part[:annotations].is_a?(Array) &&
213
- annotation_index.is_a?(Integer) &&
214
- annotation_index >= 0 &&
215
- annotation_index <= part[:annotations].length &&
216
- (event[:annotation].is_a?(OpenAI::Internal::Type::BaseModel) || event[:annotation].is_a?(Hash))
217
- return unavailable
218
- end
403
+ def indexed_parts(parts)
404
+ return {} unless parts.is_a?(Array)
405
+
406
+ parts.each_with_index.to_h { |part, index| [index, make_part(part, full: true)] }
407
+ end
408
+
409
+ def make_part(part, full:)
410
+ {
411
+ part: part,
412
+ full: full,
413
+ kind: part[:type]&.to_s,
414
+ annotations: part[:type].to_s == "output_text" && part[:annotations].is_a?(Array) ? part[:annotations]
415
+ .each_with_index
416
+ .to_h { |value, index|
417
+ [index, value]
418
+ } : {}
419
+ }
420
+ end
421
+
422
+ def part_for(record, collection, index, kind)
423
+ part = record[collection][index]
424
+ return unavailable if part && part[:kind] && part[:kind] != kind
425
+
426
+ part ||= (record[collection][index] = make_part({}, full: false))
427
+ part[:kind] ||= kind
428
+ part
429
+ end
430
+
431
+ def put_part(record, collection, index, part)
432
+ record[collection][index] = make_part(part, full: true)
433
+ sync_array(record[:item][collection], record[collection], index) { |value| value[:part] }
434
+ end
435
+
436
+ # Grow only through actual contiguous observations, never through a hole.
437
+ def sync_array(array, values, index)
438
+ return unless array.is_a?(Array)
439
+
440
+ if index < array.length
441
+ value = values[index]
442
+ array[index] = block_given? ? yield(value) : value
443
+ end
444
+
445
+ while values.key?(array.length)
446
+ value = values[array.length]
447
+ array << (block_given? ? yield(value) : value)
448
+ end
449
+ end
450
+
451
+ def update_bound(record, index)
452
+ return unless record[:kind] == "shell_call"
453
+
454
+ call_id = record[:item][:call_id]
455
+ commands = record[:item].dig(:action, :commands)
456
+ return unless call_id.is_a?(String)
457
+
458
+ if commands.is_a?(Array) && commands.length == record[:commands].length
459
+ @command_bounds[call_id] = {index: index, count: commands.length}
460
+ else
461
+ @command_bounds.delete(call_id)
462
+ end
463
+ end
464
+
465
+ def update_string(fields, field, value, done:, nullable: false, required: false)
466
+ if done
467
+ fields[field] = value
468
+ else
469
+ return unavailable if required && !fields.key?(field)
470
+
471
+ old = fields[field]
472
+ return unavailable if fields.key?(field) && !old.is_a?(String) && !(nullable && old.nil?)
219
473
 
220
- # The event and output use distinct annotation union types.
221
- # Snapshot copying reconstructs this value as an output annotation.
222
- part[:annotations][annotation_index] = event[:annotation]
223
- return
474
+ (fields[field] ||= +"") << value
475
+ end
476
+
477
+ true
478
+ end
479
+
480
+ def project_item(record)
481
+ item = record[:item].dup
482
+ %i[content summary].each do |collection|
483
+ parts = record[collection]
484
+ unless part_collection?(record[:kind], collection) &&
485
+ (item[collection].is_a?(Array) || parts[0]&.fetch(:full))
486
+ next
487
+ end
488
+
489
+ item[collection] = []
490
+ while (part = parts[item[collection].length]) && part[:full]
491
+ value = part[:part].dup
492
+ if part[:kind] == "output_text" && (value[:annotations].is_a?(Array) || part[:annotations].key?(0))
493
+ value[:annotations] = prefix(part[:annotations])
224
494
  end
495
+
496
+ item[collection] << value
225
497
  end
498
+ end
499
+
500
+ if record[:kind] == "shell_call" && item.dig(:action, :commands).is_a?(Array)
501
+ item[:action] = item[:action].dup
502
+ item[:action][:commands] = prefix(record[:commands])
503
+ end
504
+
505
+ copy(item)
506
+ end
226
507
 
227
- @state.accumulate_output(event, @output)
508
+ def prefix(values)
509
+ result = []
510
+ while values.key?(result.length)
511
+ value = values[result.length]
512
+ break if value.nil?
513
+
514
+ result << value
228
515
  end
516
+
517
+ result
518
+ end
519
+
520
+ def detailed_item(index, record)
521
+ item = record[:item].dup
522
+ result = {output_index: index, item: item}
523
+ %i[content summary].each do |collection|
524
+ parts = record[collection]
525
+ full_array = part_collection?(record[:kind], collection) && item[collection].is_a?(Array)
526
+ next unless full_array || !parts.empty?
527
+
528
+ item.delete(collection) if full_array
529
+ key = record[:kind] == "shell_call_output" ? :command_index : (collection == :summary ? :summary_index : :content_index)
530
+ result[collection] = parts.keys.sort.map do |part_index|
531
+ part = parts.fetch(part_index)
532
+ value = part[:part].dup
533
+ if part[:kind] == "output_text" && (value[:annotations].is_a?(Array) || !part[:annotations].empty?)
534
+ value.delete(:annotations)
535
+ value[:annotations] = part[:annotations].keys.sort.map do |annotation_index|
536
+ {annotation_index: annotation_index, annotation: part[:annotations].fetch(annotation_index)}
537
+ end
538
+ end
539
+
540
+ {key => part_index, :part => value}
541
+ end
542
+ end
543
+
544
+ if record[:kind] == "shell_call" && (item.dig(:action, :commands).is_a?(Array) || !record[:commands].empty?)
545
+ item[:action] = item[:action].dup if item[:action].is_a?(Hash)
546
+ item[:action]&.delete(:commands)
547
+ result[:commands] = record[:commands].keys.sort.map do |command_index|
548
+ {command_index: command_index, command: record[:commands].fetch(command_index)}
549
+ end
550
+ end
551
+
552
+ result
553
+ end
554
+
555
+ def observed_text(records)
556
+ records
557
+ .flat_map do |record|
558
+ next [] unless record[:kind] == "message"
559
+
560
+ record[:content].keys.sort.filter_map do |index|
561
+ part = record[:content].fetch(index)
562
+ text = part[:part][:text]
563
+ text if part[:kind] == "output_text" && text.is_a?(String)
564
+ end
565
+ end
566
+ .join
567
+ end
568
+
569
+ def part_collection?(kind, collection)
570
+ kind == "reasoning" || (kind == "message" && collection == :content)
571
+ end
572
+
573
+ def valid_index?(index)
574
+ index.is_a?(Integer) && index >= 0
229
575
  end
230
576
 
231
577
  def unavailable
@@ -234,8 +580,8 @@ module OpenAI
234
580
  nil
235
581
  end
236
582
 
237
- # BaseModel's recursive representation copies containers but retains string
238
- # references. Returned snapshots and incoming events must not share strings.
583
+ # BaseModel's recursive representation retains strings. Both incoming data
584
+ # and read views must cross this copy boundary before their owner can mutate.
239
585
  def copy(value)
240
586
  data = OpenAI::Internal::Type::BaseModel.recursively_to_h(value, convert: false)
241
587
  data = parse_json(generate_json(data))