openai 0.106.0 → 0.108.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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -0
  3. data/README.md +49 -1
  4. data/examples/chat/tool_runner.rb +33 -0
  5. data/lib/openai/client.rb +2 -2
  6. data/lib/openai/helpers/chat/tool_runner.rb +259 -0
  7. data/lib/openai/helpers/live/websocket.rb +12 -141
  8. data/lib/openai/helpers/local_audio/process.rb +13 -3
  9. data/lib/openai/helpers/models/embedding.rb +42 -0
  10. data/lib/openai/helpers/realtime/connection.rb +22 -5
  11. data/lib/openai/helpers/realtime/recovery.rb +19 -7
  12. data/lib/openai/helpers/realtime/translation.rb +37 -169
  13. data/lib/openai/helpers/realtime/unknown_server_event.rb +5 -12
  14. data/lib/openai/helpers/responses_websocket/connection.rb +56 -35
  15. data/lib/openai/helpers/responses_websocket/incremental.rb +5 -1
  16. data/lib/openai/helpers/responses_websocket/session.rb +11 -4
  17. data/lib/openai/helpers/responses_websocket/unknown_server_event.rb +2 -14
  18. data/lib/openai/helpers/streaming/chat_completion_resource.rb +52 -0
  19. data/lib/openai/helpers/websocket/async_websocket_transport.rb +8 -6
  20. data/lib/openai/helpers/websocket/client_request.rb +1 -1
  21. data/lib/openai/helpers/websocket/connection.rb +21 -10
  22. data/lib/openai/helpers/websocket/connection_manager.rb +2 -1
  23. data/lib/openai/helpers/websocket/protocol.rb +160 -0
  24. data/lib/openai/helpers/websocket/typed_connection.rb +22 -16
  25. data/lib/openai/helpers/websocket/typed_resource.rb +15 -0
  26. data/lib/openai/internal/transport/base_client.rb +1 -1
  27. data/lib/openai/models/admin/organization/usage_audio_speeches_response.rb +16 -5
  28. data/lib/openai/models/admin/organization/usage_audio_transcriptions_response.rb +16 -5
  29. data/lib/openai/models/admin/organization/usage_code_interpreter_sessions_response.rb +16 -5
  30. data/lib/openai/models/admin/organization/usage_completions_response.rb +16 -5
  31. data/lib/openai/models/admin/organization/usage_costs_response.rb +16 -5
  32. data/lib/openai/models/admin/organization/usage_decisions_response.rb +14 -3
  33. data/lib/openai/models/admin/organization/usage_embeddings_response.rb +16 -5
  34. data/lib/openai/models/admin/organization/usage_file_search_calls_response.rb +16 -5
  35. data/lib/openai/models/admin/organization/usage_images_response.rb +16 -5
  36. data/lib/openai/models/admin/organization/usage_moderations_response.rb +16 -5
  37. data/lib/openai/models/admin/organization/usage_vector_stores_response.rb +16 -5
  38. data/lib/openai/models/admin/organization/usage_web_search_calls_response.rb +16 -5
  39. data/lib/openai/models/audio/voice_consent.rb +58 -0
  40. data/lib/openai/models/audio/voice_consent_create_params.rb +57 -0
  41. data/lib/openai/models/audio/voice_consent_delete_params.rb +22 -0
  42. data/lib/openai/models/audio/voice_consent_deleted.rb +34 -0
  43. data/lib/openai/models/audio/voice_consent_list_params.rb +42 -0
  44. data/lib/openai/models/audio/voice_consent_retrieve_params.rb +22 -0
  45. data/lib/openai/models/audio/voice_consent_update_params.rb +32 -0
  46. data/lib/openai/models/beta/beta_response_input_item.rb +10 -1
  47. data/lib/openai/models/responses/response_input_item.rb +10 -1
  48. data/lib/openai/resources/audio/voice_consents.rb +163 -0
  49. data/lib/openai/resources/audio.rb +5 -0
  50. data/lib/openai/version.rb +1 -1
  51. data/lib/openai.rb +9 -0
  52. data/rbi/openai/client.rbi +1 -8
  53. data/rbi/openai/helpers/chat/tool_runner.rbi +74 -0
  54. data/rbi/openai/helpers/realtime/translation.rbi +0 -5
  55. data/rbi/openai/helpers/realtime/unknown_server_event.rbi +6 -0
  56. data/rbi/openai/helpers/websocket.rbi +44 -6
  57. data/rbi/openai/internal/transport/base_client.rbi +2 -6
  58. data/rbi/openai/models/admin/organization/usage_audio_speeches_response.rbi +14 -2
  59. data/rbi/openai/models/admin/organization/usage_audio_transcriptions_response.rbi +14 -2
  60. data/rbi/openai/models/admin/organization/usage_code_interpreter_sessions_response.rbi +14 -2
  61. data/rbi/openai/models/admin/organization/usage_completions_response.rbi +14 -2
  62. data/rbi/openai/models/admin/organization/usage_costs_response.rbi +14 -2
  63. data/rbi/openai/models/admin/organization/usage_decisions_response.rbi +12 -0
  64. data/rbi/openai/models/admin/organization/usage_embeddings_response.rbi +14 -2
  65. data/rbi/openai/models/admin/organization/usage_file_search_calls_response.rbi +14 -2
  66. data/rbi/openai/models/admin/organization/usage_images_response.rbi +14 -2
  67. data/rbi/openai/models/admin/organization/usage_moderations_response.rbi +14 -2
  68. data/rbi/openai/models/admin/organization/usage_vector_stores_response.rbi +14 -2
  69. data/rbi/openai/models/admin/organization/usage_web_search_calls_response.rbi +14 -2
  70. data/rbi/openai/models/audio/voice_consent.rbi +86 -0
  71. data/rbi/openai/models/audio/voice_consent_create_params.rbi +92 -0
  72. data/rbi/openai/models/audio/voice_consent_delete_params.rbi +53 -0
  73. data/rbi/openai/models/audio/voice_consent_deleted.rbi +62 -0
  74. data/rbi/openai/models/audio/voice_consent_list_params.rbi +78 -0
  75. data/rbi/openai/models/audio/voice_consent_retrieve_params.rbi +53 -0
  76. data/rbi/openai/models/audio/voice_consent_update_params.rbi +62 -0
  77. data/rbi/openai/models/beta/beta_response_input_item.rbi +14 -1
  78. data/rbi/openai/models/response_format_json_schema.rbi +13 -11
  79. data/rbi/openai/models/responses/response_input_item.rbi +10 -1
  80. data/rbi/openai/resources/audio/voice_consents.rbi +108 -0
  81. data/rbi/openai/resources/audio.rbi +4 -0
  82. data/realtime.md +22 -3
  83. data/sig/openai/client.rbs +1 -1
  84. data/sig/openai/helpers/chat/tool_runner.rbs +48 -0
  85. data/sig/openai/helpers/realtime/translation.rbs +0 -2
  86. data/sig/openai/helpers/realtime/unknown_server_event.rbs +2 -0
  87. data/sig/openai/helpers/websocket.rbs +23 -6
  88. data/sig/openai/models/admin/organization/usage_audio_speeches_response.rbs +15 -5
  89. data/sig/openai/models/admin/organization/usage_audio_transcriptions_response.rbs +15 -5
  90. data/sig/openai/models/admin/organization/usage_code_interpreter_sessions_response.rbs +15 -5
  91. data/sig/openai/models/admin/organization/usage_completions_response.rbs +15 -5
  92. data/sig/openai/models/admin/organization/usage_costs_response.rbs +15 -5
  93. data/sig/openai/models/admin/organization/usage_decisions_response.rbs +11 -1
  94. data/sig/openai/models/admin/organization/usage_embeddings_response.rbs +15 -5
  95. data/sig/openai/models/admin/organization/usage_file_search_calls_response.rbs +15 -5
  96. data/sig/openai/models/admin/organization/usage_images_response.rbs +15 -5
  97. data/sig/openai/models/admin/organization/usage_moderations_response.rbs +15 -5
  98. data/sig/openai/models/admin/organization/usage_vector_stores_response.rbs +15 -5
  99. data/sig/openai/models/admin/organization/usage_web_search_calls_response.rbs +15 -5
  100. data/sig/openai/models/audio/voice_consent.rbs +57 -0
  101. data/sig/openai/models/audio/voice_consent_create_params.rbs +61 -0
  102. data/sig/openai/models/audio/voice_consent_delete_params.rbs +25 -0
  103. data/sig/openai/models/audio/voice_consent_deleted.rbs +31 -0
  104. data/sig/openai/models/audio/voice_consent_list_params.rbs +57 -0
  105. data/sig/openai/models/audio/voice_consent_retrieve_params.rbs +25 -0
  106. data/sig/openai/models/audio/voice_consent_update_params.rbs +36 -0
  107. data/sig/openai/models/beta/beta_response_input_item.rbs +8 -0
  108. data/sig/openai/models/responses/response_input_item.rbs +8 -3
  109. data/sig/openai/resources/audio/voice_consents.rbs +38 -0
  110. data/sig/openai/resources/audio.rbs +2 -0
  111. metadata +31 -1
@@ -55,6 +55,8 @@ module OpenAI
55
55
 
56
56
  # @api private
57
57
  class SessionAccumulator
58
+ include OpenAI::WebSocket::Protocol
59
+
58
60
  attr_reader :final_response, :error
59
61
 
60
62
  def initialize(limit)
@@ -93,10 +95,10 @@ module OpenAI
93
95
  if type == "response.output_item.done"
94
96
  # Retain wire data independently of the mutable event returned by receive.
95
97
  completed_item = OpenAI::Internal::Type::BaseModel.recursively_to_h(event.item, convert: false)
96
- @items[event.output_index] = JSON.parse(JSON.generate(completed_item), symbolize_names: true)
98
+ @items[event.output_index] = parse_json(generate_json(completed_item))
97
99
  elsif %w[response.completed response.failed response.incomplete].include?(type)
98
100
  data = OpenAI::Internal::Type::BaseModel.recursively_to_h(event, convert: false)
99
- data = JSON.parse(JSON.generate(data), symbolize_names: true)
101
+ data = parse_json(generate_json(data))
100
102
  response = data.fetch(:response)
101
103
  if response[:output].nil?
102
104
  response = response.merge(output: @items.sort.map { |_index, item| item.to_h })
@@ -117,7 +119,7 @@ module OpenAI
117
119
  @state.handle_event(@decoder.decode(data))
118
120
  end
119
121
 
120
- rescue StandardError => error
122
+ rescue StandardError, SystemStackError => error
121
123
  @error = error.is_a?(SessionError) ? error : SessionError.new("Could not collect the Responses final response.")
122
124
  clear
123
125
  end
@@ -313,6 +315,8 @@ module OpenAI
313
315
 
314
316
  # Owns one connection and one Async reader. Use only its lanes to receive.
315
317
  class Session
318
+ include OpenAI::WebSocket::Protocol
319
+
316
320
  attr_reader :limits, :default
317
321
 
318
322
  def self.open(
@@ -531,7 +535,7 @@ module OpenAI
531
535
  value = @lanes.fetch(data[:stream_id], @lanes[nil])
532
536
  return unless value
533
537
 
534
- bytes = JSON.generate(data).bytesize
538
+ bytes = generate_json(data).bytesize
535
539
  if @events >= @limits.max_events || @bytes + bytes > @limits.max_bytes
536
540
  raise BufferError, "The Responses session exceeded its buffered event budget."
537
541
  end
@@ -544,6 +548,9 @@ module OpenAI
544
548
  consumed(bytes)
545
549
  raise
546
550
  end
551
+
552
+ rescue SystemStackError
553
+ raise SessionError, "Could not buffer the Responses WebSocket event.", cause: nil
547
554
  end
548
555
 
549
556
  private def stop_reader
@@ -4,6 +4,8 @@ module OpenAI
4
4
  module Responses
5
5
  # A valid JSON event whose discriminator is newer than this SDK version.
6
6
  class UnknownServerEvent
7
+ include OpenAI::WebSocket::Protocol
8
+
7
9
  # @return [Symbol]
8
10
  attr_reader :type
9
11
 
@@ -35,20 +37,6 @@ module OpenAI
35
37
 
36
38
  alias to_s inspect
37
39
 
38
- private def freeze_json(value)
39
- case value
40
- when Hash
41
- value.each do |key, item|
42
- freeze_json(key)
43
- freeze_json(item)
44
- end
45
-
46
- when Array
47
- value.each { |item| freeze_json(item) }
48
- end
49
-
50
- value.freeze
51
- end
52
40
  end
53
41
  end
54
42
  end
@@ -1,9 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../chat/tool_runner"
4
+
3
5
  module OpenAI
4
6
  module Resources
5
7
  class Chat
6
8
  class Completions
9
+ # Run function tools until the model returns a final, non-streaming completion.
10
+ # Handlers receive a JSON object with string keys. String results are sent
11
+ # verbatim; other results are JSON encoded. Callback errors propagate without
12
+ # retrying callbacks or sending their exception text to the model.
13
+ # max_rounds bounds API calls (including the final answer), defaulting to 10.
14
+ # A forced tool choice applies only to the first round. Callbacks run serially.
15
+ #
16
+ # @param tool_handlers [Hash{String=>#call}]
17
+ # @param max_rounds [Integer]
18
+ # @param params [Hash] Other Chat Completions create parameters.
19
+ # @return [OpenAI::Models::Chat::ChatCompletion]
20
+ def run_tools(model:, messages:, tools:, tool_handlers:, max_rounds: 10, **params)
21
+ OpenAI::Helpers::Chat::ToolRunner
22
+ .new(
23
+ completions: self,
24
+ params: params.merge(model: model, messages: messages, tools: tools),
25
+ tool_handlers: tool_handlers,
26
+ max_rounds: max_rounds
27
+ )
28
+ .run
29
+ end
30
+
31
+ # @api private
32
+ # Buffer through the normal transport, then decode inside the runner's
33
+ # error boundary. Transport exceptions remain outside that boundary.
34
+ def create_for_tool_runner(params)
35
+ parsed, options = OpenAI::Chat::CompletionCreateParams.dump_request(params)
36
+ model, tool_models = get_structured_output_models(parsed)
37
+ parse = OpenAI::Helpers::StructuredOutput::ChatCompletionParser.build_unwrap(model, tool_models)
38
+ response = @client.request(
39
+ method: :post,
40
+ path: "chat/completions",
41
+ body: parsed,
42
+ model: StringIO,
43
+ security: {bearer_auth: true},
44
+ options: options
45
+ )
46
+
47
+ begin
48
+ raw = JSON.parse(response.string, symbolize_names: true)
49
+ completion = OpenAI::Internal::Type::Converter.coerce(OpenAI::Chat::ChatCompletion, parse.call(raw))
50
+ completion._set_last_response(response.last_response)
51
+ completion
52
+ rescue JSON::ParserError, KeyError, NoMethodError, TypeError, OpenAI::Errors::ConversionError
53
+ raise OpenAI::Helpers::Chat::ToolRunner::Error, "Malformed Chat Completion response", cause: nil
54
+ ensure
55
+ response.close
56
+ end
57
+ end
58
+
7
59
  def stream(params)
8
60
  parsed, options = OpenAI::Chat::CompletionCreateParams.dump_request(params)
9
61
 
@@ -171,7 +171,9 @@ module OpenAI
171
171
  @connection.read
172
172
  rescue StandardError => e
173
173
  error = @error_factory.call(url: @url, cause: e)
174
- raise error, cause: error.cause
174
+ # The factory retains explicit diagnostics; do not render them as a
175
+ # native exception chain, which can include payloads or credentials.
176
+ raise error, cause: nil
175
177
  end
176
178
 
177
179
  def write(message)
@@ -179,14 +181,14 @@ module OpenAI
179
181
  @connection.flush
180
182
  rescue StandardError => e
181
183
  error = @error_factory.call(url: @url, cause: e)
182
- raise error, cause: error.cause
184
+ raise error, cause: nil
183
185
  end
184
186
 
185
187
  def close(code: 1000, reason: "")
186
188
  @connection.close(code, reason)
187
189
  rescue StandardError => e
188
190
  error = @error_factory.call(url: @url, cause: e)
189
- raise error, cause: error.cause
191
+ raise error, cause: nil
190
192
  end
191
193
 
192
194
  # @api private
@@ -197,7 +199,7 @@ module OpenAI
197
199
  @aborted = true
198
200
  rescue StandardError => e
199
201
  error = @error_factory.call(url: @url, cause: e)
200
- raise error, cause: error.cause
202
+ raise error, cause: nil
201
203
  end
202
204
 
203
205
  # @api private
@@ -302,7 +304,7 @@ module OpenAI
302
304
  raise if e.equal?(block_error)
303
305
 
304
306
  error = @error_factory.call(url: url, cause: e, http_status: handshake_status(e))
305
- raise error, cause: error.cause
307
+ raise error, cause: nil
306
308
  end
307
309
 
308
310
  private def negotiation_options(_endpoint_options) = {}
@@ -373,7 +375,7 @@ module OpenAI
373
375
  "#{@product_name} WebSockets require the `async-websocket` gem. " \
374
376
  "Add `gem \"async-websocket\"` to your Gemfile."
375
377
  error = @error_factory.call(url: url, message: message, cause: e)
376
- raise error, cause: error.cause
378
+ raise error, cause: nil
377
379
  end
378
380
 
379
381
  private def authority(url, include_default_port: false)
@@ -140,7 +140,7 @@ module OpenAI
140
140
  [request, deadline]
141
141
  rescue Timeout::Error => e
142
142
  error = timeout_error.call(error_url, e)
143
- raise error, cause: error.cause
143
+ raise error, cause: nil
144
144
  end
145
145
 
146
146
  private def with_shared_websocket_base_url(request, path:, base_url:)
@@ -28,20 +28,13 @@ module OpenAI
28
28
  end
29
29
 
30
30
  private def write_text(text)
31
- complete = false
32
31
  super
33
- complete = true
34
- nil
35
32
  rescue StandardError
36
33
  raise transport_error, cause: nil
37
- ensure
38
- # Also retire a possibly buffered write when cancellation bypasses
39
- # StandardError, before another sender or normal cleanup can flush it.
40
- @poisoned = true unless complete
41
34
  end
42
35
 
43
36
  private def read_raw_message
44
- raise connection_error(failed_read_message) if @poisoned
37
+ raise connection_error(failed_read_message), cause: nil if @poisoned
45
38
  return nil if @closed
46
39
  text = super
47
40
  @closed = text.nil?
@@ -73,6 +66,7 @@ module OpenAI
73
66
  def initialize(socket:, url:)
74
67
  @socket = socket
75
68
  @url = url
69
+ @poisoned = false
76
70
  end
77
71
 
78
72
  # Yield server events until the remote peer closes the connection.
@@ -100,6 +94,8 @@ module OpenAI
100
94
  def receive_raw = read_raw_message
101
95
 
102
96
  private def read_raw_message
97
+ raise connection_error("Cannot read from a failed WebSocket."), cause: nil if @poisoned
98
+
103
99
  message = @socket.read
104
100
  message&.to_str
105
101
  end
@@ -108,7 +104,7 @@ module OpenAI
108
104
  #
109
105
  # @api private
110
106
  def send_raw(data)
111
- raise connection_error(closed_send_message) if closed?
107
+ raise connection_error(closed_send_message), cause: nil if closed?
112
108
 
113
109
  text = data.dup
114
110
  text.force_encoding(Encoding::UTF_8) if text.encoding == Encoding::BINARY
@@ -120,6 +116,7 @@ module OpenAI
120
116
 
121
117
  # Close the connection.
122
118
  def close(code: 1000, reason: "")
119
+ return abort if @poisoned
123
120
  return if socket_closed?
124
121
 
125
122
  @socket.close(code: code, reason: reason)
@@ -137,7 +134,7 @@ module OpenAI
137
134
  end
138
135
 
139
136
  # @return [Boolean]
140
- def closed? = socket_closed?
137
+ def closed? = @poisoned || socket_closed?
141
138
 
142
139
  private def parse_event(_data)
143
140
  raise NotImplementedError
@@ -154,8 +151,22 @@ module OpenAI
154
151
  private def socket_closed? = @socket.closed?
155
152
 
156
153
  private def write_text(text)
154
+ complete = false
157
155
  @socket.write(text)
156
+ complete = true
158
157
  nil
158
+ ensure
159
+ unless complete
160
+ # Retire the physical socket immediately: an already-running reader
161
+ # can otherwise flush buffered application bytes while replying to close.
162
+ @poisoned = true
163
+ begin
164
+ @socket.abort
165
+ rescue StandardError
166
+ # Preserve the original write failure or cancellation.
167
+ nil
168
+ end
169
+ end
159
170
  end
160
171
  end
161
172
  end
@@ -73,7 +73,8 @@ module OpenAI
73
73
 
74
74
  rescue StandardError => error
75
75
  raise if @transport_error_factory.nil?
76
- raise block_error, cause: block_error.cause if block_error
76
+ # Preserve the native chain, not an SDK accessor's explicit diagnostics.
77
+ raise block_error, cause: Exception.instance_method(:cause).bind_call(block_error) if block_error
77
78
  raise @transport_error_factory.call(url: request.fetch(:url), error: error), cause: nil
78
79
  end
79
80
  end
@@ -6,6 +6,166 @@ module OpenAI
6
6
  #
7
7
  # @api private
8
8
  module Protocol
9
+ # JSON schemas and extension data do not have a transport-specific depth limit.
10
+ private def parse_json(data) = JSON.parse(data, symbolize_names: true, max_nesting: false)
11
+
12
+ private def generate_json(value) = JSON.generate(value, max_nesting: false)
13
+
14
+ private def freeze_json(value)
15
+ pending = [value]
16
+ visited = {}.compare_by_identity
17
+ until pending.empty?
18
+ item = pending.pop
19
+ next if visited.key?(item)
20
+ visited[item] = true
21
+ case item
22
+ when Hash
23
+ item.each { |key, val| pending.push(key, val) }
24
+ when Array
25
+ pending.concat(item)
26
+ end
27
+
28
+ item.freeze
29
+ end
30
+
31
+ value
32
+ end
33
+
34
+ # Unknown wire keys must not replace a selected model's renamed Ruby fields.
35
+ private def validate_wire_field_names!(event, payload)
36
+ pending = [[event, payload]]
37
+ until pending.empty?
38
+ value, raw = pending.pop
39
+ case value
40
+ when OpenAI::Internal::Type::BaseModel
41
+ value.class.fields.each do |name, field|
42
+ raise ArgumentError if name != field.fetch(:api_name) && raw.is_a?(Hash) && raw.key?(name)
43
+ end
44
+
45
+ value.to_h.each do |name, child|
46
+ field = value.class.fields[name]
47
+ key = field ? field.fetch(:api_name) : name
48
+ pending << [child, raw.is_a?(Hash) ? raw[key] : nil]
49
+ end
50
+
51
+ when Hash
52
+ value.each { |name, child| pending << [child, raw.is_a?(Hash) ? raw[name] : nil] }
53
+ when Array
54
+ value.each_with_index { |child, index| pending << [child, raw.is_a?(Array) ? raw[index] : nil] }
55
+ end
56
+ end
57
+
58
+ event
59
+ end
60
+
61
+ private def coerce_event(union, payload, outbound: false)
62
+ state = OpenAI::Internal::Type::Converter.new_coerce_state(request_only: outbound)
63
+ event = OpenAI::Internal::Type::Converter.coerce(union, payload, state: state)
64
+ raise ArgumentError if state[:error] || !state.fetch(:exactness).fetch(:no).zero?
65
+ ancestors = {}.compare_by_identity
66
+ pending = [[event, false]]
67
+ until pending.empty?
68
+ value, exiting = pending.pop
69
+ if exiting
70
+ ancestors.delete(value)
71
+ next
72
+ end
73
+
74
+ children = case value
75
+ when OpenAI::Internal::Type::BaseModel
76
+ data = value.to_h
77
+ validate_model_fields!(value)
78
+ value.class.fields.each do |name, field|
79
+ const = field.fetch(:const)
80
+ if field.fetch(:required) &&
81
+ field.fetch(:mode) != (outbound ? :coerce : :dump) &&
82
+ (!outbound || const == OpenAI::Internal::OMIT) &&
83
+ !data.key?(name)
84
+ raise ArgumentError
85
+ end
86
+ end
87
+
88
+ data.values
89
+ when Hash
90
+ value.values
91
+ when Array
92
+ value
93
+ else
94
+ next
95
+ end
96
+
97
+ raise ArgumentError if ancestors.key?(value)
98
+ ancestors[value] = true
99
+ pending << [value, true]
100
+ children.each { |child| pending << [child, false] }
101
+ end
102
+
103
+ event
104
+ end
105
+
106
+ private def validate_event_tree!(event, json_only: false, strict_keys: true)
107
+ ancestors = {}.compare_by_identity
108
+ pending = [[event, false]]
109
+ until pending.empty?
110
+ value, exiting = pending.pop
111
+ if exiting
112
+ ancestors.delete(value)
113
+ next
114
+ end
115
+
116
+ children = case value
117
+ when OpenAI::Internal::Type::BaseModel
118
+ raise ArgumentError if json_only
119
+ data = value.to_h
120
+ validate_model_fields!(value)
121
+ [data]
122
+ when Hash
123
+ if strict_keys
124
+ keys = {}
125
+ value.each_key do |key|
126
+ raise ArgumentError unless key.is_a?(String) || key.is_a?(Symbol)
127
+ name = key.to_s
128
+ raise ArgumentError if keys.key?(name)
129
+ keys[name] = true
130
+ end
131
+ end
132
+
133
+ value.values
134
+ when Array
135
+ value
136
+ when NilClass, TrueClass, FalseClass, String, Integer, Float, Symbol
137
+ next
138
+ else
139
+ raise ArgumentError if json_only
140
+ next
141
+ end
142
+
143
+ raise ArgumentError if ancestors.key?(value)
144
+ ancestors[value] = true
145
+ pending << [value, true]
146
+ children.each { |child| pending << [child, false] }
147
+ end
148
+ end
149
+
150
+ private def validate_model_fields!(model)
151
+ names = {}
152
+ model.to_h.each do |name, actual|
153
+ key = name.is_a?(String) ? name.to_sym : name
154
+ field = model.class.known_fields[key]
155
+ serialized_name = field ? field.fetch(:api_name).to_s : key.to_s
156
+ raise ArgumentError if names.key?(serialized_name)
157
+ names[serialized_name] = true
158
+ next unless field
159
+ const = field.fetch(:const)
160
+ next if const == OpenAI::Internal::OMIT
161
+ if const.is_a?(Symbol)
162
+ raise ArgumentError unless (actual.is_a?(String) || actual.is_a?(Symbol)) && actual.to_s == const.to_s
163
+ elsif actual != const
164
+ raise ArgumentError
165
+ end
166
+ end
167
+ end
168
+
9
169
  private def discriminator_values(union)
10
170
  union.variants.to_h do |variant|
11
171
  value = variant.fields.fetch(:type).fetch(:const)
@@ -2,7 +2,7 @@
2
2
 
3
3
  module OpenAI
4
4
  module WebSocket
5
- # Generic event codec for APIs with generated bindings and no specialized helper.
5
+ # Shared event codec for generated bindings and specialized product facades.
6
6
  #
7
7
  # @api private
8
8
  class TypedConnection < Connection
@@ -30,36 +30,36 @@ module OpenAI
30
30
  end
31
31
 
32
32
  private def encode_client_event(event)
33
- event_type(event, message: "Invalid WebSocket client event.") if event.is_a?(Hash)
33
+ validate_event_tree!(event)
34
+ event_type(event, message: client_event_error_message) if event.is_a?(Hash)
34
35
  payload = @client_event ? OpenAI::Internal::Type::Converter.dump(@client_event, event) : event
35
- type = event_type(payload, message: "Invalid WebSocket client event.")
36
+ type = event_type(payload, message: client_event_error_message)
36
37
  if @client_event && !@client_event_types.any? { _1.nil? || _1 === type.to_sym || _1 === type.to_s }
37
- raise ArgumentError, "Invalid WebSocket client event."
38
+ raise ArgumentError, client_event_error_message
38
39
  end
39
40
 
40
- payload = coerce(@client_event, payload) if @client_event
41
+ payload = coerce_client_event(payload) if @client_event
41
42
  payload = OpenAI::Internal::Type::Converter.dump(@client_event, payload) if @client_event
42
- JSON.generate(payload, max_nesting: false)
43
+ validate_event_tree!(payload, json_only: true)
44
+ generate_json(payload)
43
45
  rescue StandardError, SystemStackError
44
- raise ArgumentError, "Invalid WebSocket client event.", cause: nil
46
+ raise ArgumentError, client_event_error_message, cause: nil
45
47
  end
46
48
 
47
49
  def parse_event(data)
48
- parsed = JSON.parse(data, symbolize_names: true, max_nesting: false)
50
+ parsed = parse_json(data)
49
51
  type = event_type(parsed, message: "WebSocket server event must be an object with a string type")
50
52
  _, model = @server_event_types.find { |event_type,| event_type === type.to_sym }
51
- return parsed unless model
53
+ return unknown_server_event(parsed) unless model
52
54
 
53
- # Unknown wire fields must not overwrite a known field's Ruby name.
54
- model.fields.each do |name, field|
55
- raise ArgumentError if name != field.fetch(:api_name) && parsed.key?(name)
56
- end
57
-
58
- coerce(@server_event, parsed)
55
+ event = coerce(@server_event, parsed)
56
+ validate_wire_field_names!(event, parsed)
59
57
  rescue StandardError, SystemStackError
60
- raise OpenAI::Errors::WebSocketProtocolError, "Invalid WebSocket server event.", cause: nil
58
+ raise protocol_error, cause: nil
61
59
  end
62
60
 
61
+ private def coerce_client_event(payload) = coerce(@client_event, payload)
62
+
63
63
  private def coerce(type, value)
64
64
  state = OpenAI::Internal::Type::Converter.new_coerce_state
65
65
  result = OpenAI::Internal::Type::Converter.coerce(type, value, state: state)
@@ -70,6 +70,12 @@ module OpenAI
70
70
  result
71
71
  end
72
72
 
73
+ private def client_event_error_message = "Invalid WebSocket client event."
74
+
75
+ private def unknown_server_event(payload) = payload
76
+
77
+ private def protocol_error = OpenAI::Errors::WebSocketProtocolError.new("Invalid WebSocket server event.")
78
+
73
79
  private def connection_error(message) = BindingConnectionError.new(url: @url, message: message)
74
80
 
75
81
  private def transport_error = connection_error("WebSocket transport failed.")
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+
5
+ module OpenAI
6
+ module WebSocket
7
+ # Supplies the generic signature contract for generated event bindings.
8
+ # Connection lifecycle and event handling are implemented by Resource.
9
+ #
10
+ # @api private
11
+ module TypedResource
12
+ include Resource
13
+ end
14
+ end
15
+ end
@@ -1012,7 +1012,7 @@ module OpenAI
1012
1012
  T::Class[OpenAI::Internal::Type::BaseStream[T.anything, OpenAI::Internal::Type::BaseModel]]
1013
1013
  ),
1014
1014
  model: T.nilable(OpenAI::Internal::Type::Converter::Input),
1015
- security: T.nilable({bearer_auth?: T::Boolean, admin_api_key_auth?: T::Boolean}),
1015
+ security: T.nilable(T::Hash[Symbol, T::Boolean]),
1016
1016
  options: T.nilable(OpenAI::RequestOptions::OrHash)
1017
1017
  }
1018
1018
  end
@@ -235,9 +235,16 @@ module OpenAI
235
235
  # @return [Integer, nil]
236
236
  optional :input_cache_write_12h_tokens, Integer
237
237
 
238
- # @!attribute input_cache_write_tokens
238
+ # @!attribute input_cache_write_30m_tokens
239
239
  # The aggregated number of input tokens written to the cache with a 30-minute
240
- # retention period.
240
+ # retention period. May be absent or null for older responses.
241
+ #
242
+ # @return [Integer, nil]
243
+ optional :input_cache_write_30m_tokens, Integer, nil?: true
244
+
245
+ # @!attribute input_cache_write_tokens
246
+ # The aggregated number of input tokens written to the cache across 30-minute and
247
+ # 12-hour retention periods.
241
248
  #
242
249
  # @return [Integer, nil]
243
250
  optional :input_cache_write_tokens, Integer
@@ -333,7 +340,7 @@ module OpenAI
333
340
  # @return [String, nil]
334
341
  optional :user_id, String, nil?: true
335
342
 
336
- # @!method initialize(input_tokens:, num_model_requests:, output_tokens:, api_key_id: nil, api_source: nil, batch: nil, input_audio_tokens: nil, input_cache_write_12h_tokens: nil, input_cache_write_tokens: nil, input_cached_audio_tokens: nil, input_cached_image_tokens: nil, input_cached_text_tokens: nil, input_cached_tokens: nil, input_image_tokens: nil, input_text_tokens: nil, input_uncached_tokens: nil, model: nil, output_audio_tokens: nil, output_image_tokens: nil, output_text_tokens: nil, project_id: nil, service_tier: nil, user_id: nil, object: :"organization.usage.completions.result")
343
+ # @!method initialize(input_tokens:, num_model_requests:, output_tokens:, api_key_id: nil, api_source: nil, batch: nil, input_audio_tokens: nil, input_cache_write_12h_tokens: nil, input_cache_write_30m_tokens: nil, input_cache_write_tokens: nil, input_cached_audio_tokens: nil, input_cached_image_tokens: nil, input_cached_text_tokens: nil, input_cached_tokens: nil, input_image_tokens: nil, input_text_tokens: nil, input_uncached_tokens: nil, model: nil, output_audio_tokens: nil, output_image_tokens: nil, output_text_tokens: nil, project_id: nil, service_tier: nil, user_id: nil, object: :"organization.usage.completions.result")
337
344
  # The aggregated completions usage details of the specific time bucket.
338
345
  #
339
346
  # @param input_tokens [Integer]
@@ -370,9 +377,13 @@ module OpenAI
370
377
  # The aggregated number of input tokens written to the cache with a 12-hour
371
378
  # retention period.
372
379
  #
373
- # @param input_cache_write_tokens [Integer]
380
+ # @param input_cache_write_30m_tokens [Integer, nil]
374
381
  # The aggregated number of input tokens written to the cache with a 30-minute
375
- # retention period.
382
+ # retention period. May be absent or null for older responses.
383
+ #
384
+ # @param input_cache_write_tokens [Integer]
385
+ # The aggregated number of input tokens written to the cache across 30-minute and
386
+ # 12-hour retention periods.
376
387
  #
377
388
  # @param input_cached_audio_tokens [Integer]
378
389
  # The aggregated number of cached audio input tokens used.
@@ -237,9 +237,16 @@ module OpenAI
237
237
  # @return [Integer, nil]
238
238
  optional :input_cache_write_12h_tokens, Integer
239
239
 
240
- # @!attribute input_cache_write_tokens
240
+ # @!attribute input_cache_write_30m_tokens
241
241
  # The aggregated number of input tokens written to the cache with a 30-minute
242
- # retention period.
242
+ # retention period. May be absent or null for older responses.
243
+ #
244
+ # @return [Integer, nil]
245
+ optional :input_cache_write_30m_tokens, Integer, nil?: true
246
+
247
+ # @!attribute input_cache_write_tokens
248
+ # The aggregated number of input tokens written to the cache across 30-minute and
249
+ # 12-hour retention periods.
243
250
  #
244
251
  # @return [Integer, nil]
245
252
  optional :input_cache_write_tokens, Integer
@@ -335,7 +342,7 @@ module OpenAI
335
342
  # @return [String, nil]
336
343
  optional :user_id, String, nil?: true
337
344
 
338
- # @!method initialize(input_tokens:, num_model_requests:, output_tokens:, api_key_id: nil, api_source: nil, batch: nil, input_audio_tokens: nil, input_cache_write_12h_tokens: nil, input_cache_write_tokens: nil, input_cached_audio_tokens: nil, input_cached_image_tokens: nil, input_cached_text_tokens: nil, input_cached_tokens: nil, input_image_tokens: nil, input_text_tokens: nil, input_uncached_tokens: nil, model: nil, output_audio_tokens: nil, output_image_tokens: nil, output_text_tokens: nil, project_id: nil, service_tier: nil, user_id: nil, object: :"organization.usage.completions.result")
345
+ # @!method initialize(input_tokens:, num_model_requests:, output_tokens:, api_key_id: nil, api_source: nil, batch: nil, input_audio_tokens: nil, input_cache_write_12h_tokens: nil, input_cache_write_30m_tokens: nil, input_cache_write_tokens: nil, input_cached_audio_tokens: nil, input_cached_image_tokens: nil, input_cached_text_tokens: nil, input_cached_tokens: nil, input_image_tokens: nil, input_text_tokens: nil, input_uncached_tokens: nil, model: nil, output_audio_tokens: nil, output_image_tokens: nil, output_text_tokens: nil, project_id: nil, service_tier: nil, user_id: nil, object: :"organization.usage.completions.result")
339
346
  # The aggregated completions usage details of the specific time bucket.
340
347
  #
341
348
  # @param input_tokens [Integer]
@@ -372,9 +379,13 @@ module OpenAI
372
379
  # The aggregated number of input tokens written to the cache with a 12-hour
373
380
  # retention period.
374
381
  #
375
- # @param input_cache_write_tokens [Integer]
382
+ # @param input_cache_write_30m_tokens [Integer, nil]
376
383
  # The aggregated number of input tokens written to the cache with a 30-minute
377
- # retention period.
384
+ # retention period. May be absent or null for older responses.
385
+ #
386
+ # @param input_cache_write_tokens [Integer]
387
+ # The aggregated number of input tokens written to the cache across 30-minute and
388
+ # 12-hour retention periods.
378
389
  #
379
390
  # @param input_cached_audio_tokens [Integer]
380
391
  # The aggregated number of cached audio input tokens used.