openai 0.81.0 → 0.83.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +53 -1
  4. data/lib/openai/helpers/realtime/client_extension.rb +107 -0
  5. data/lib/openai/helpers/sorbet.rb +324 -0
  6. data/lib/openai/helpers/streaming/response_event_decoder.rb +44 -0
  7. data/lib/openai/helpers/streaming/response_stream.rb +10 -3
  8. data/lib/openai/helpers/streaming/unknown_stream_event.rb +51 -0
  9. data/lib/openai/http_client.rb +40 -1
  10. data/lib/openai/internal/logging.rb +7 -1
  11. data/lib/openai/internal/stream.rb +4 -1
  12. data/lib/openai/internal/transport/base_client.rb +35 -4
  13. data/lib/openai/internal/util.rb +6 -2
  14. data/lib/openai/models/admin/organization/audit_log_list_params.rb +2 -0
  15. data/lib/openai/models/admin/organization/audit_log_list_response.rb +2 -0
  16. data/lib/openai/models/beta/beta_response_function_tool_call_output_item.rb +9 -9
  17. data/lib/openai/models/beta/beta_response_input_item.rb +9 -9
  18. data/lib/openai/models/beta/beta_response_usage.rb +12 -1
  19. data/lib/openai/models/completion_usage.rb +12 -1
  20. data/lib/openai/models/responses/response_function_tool_call_output_item.rb +9 -9
  21. data/lib/openai/models/responses/response_input_item.rb +9 -9
  22. data/lib/openai/models/responses/response_usage.rb +12 -1
  23. data/lib/openai/request_options.rb +8 -0
  24. data/lib/openai/resources/beta/responses.rb +2 -2
  25. data/lib/openai/resources/responses.rb +3 -3
  26. data/lib/openai/version.rb +1 -1
  27. data/rbi/openai/helpers/sorbet.rbi +55 -0
  28. data/rbi/openai/helpers/streaming/response_event_decoder.rbi +28 -0
  29. data/rbi/openai/helpers/streaming/response_stream.rbi +5 -0
  30. data/rbi/openai/helpers/streaming/unknown_stream_event.rbi +29 -0
  31. data/rbi/openai/http_client.rbi +25 -2
  32. data/rbi/openai/internal/logging.rbi +12 -2
  33. data/rbi/openai/internal/transport/base_client.rbi +12 -2
  34. data/rbi/openai/internal/util.rbi +3 -2
  35. data/rbi/openai/models/admin/organization/audit_log_list_params.rbi +8 -0
  36. data/rbi/openai/models/admin/organization/audit_log_list_response.rbi +8 -0
  37. data/rbi/openai/models/beta/beta_response_function_tool_call_output_item.rbi +13 -10
  38. data/rbi/openai/models/beta/beta_response_input_item.rbi +10 -10
  39. data/rbi/openai/models/beta/beta_response_usage.rbi +13 -3
  40. data/rbi/openai/models/completion_usage.rbi +10 -0
  41. data/rbi/openai/models/responses/response_function_tool_call_output_item.rbi +13 -10
  42. data/rbi/openai/models/responses/response_input_item.rbi +10 -10
  43. data/rbi/openai/models/responses/response_usage.rbi +13 -3
  44. data/rbi/openai/request_options.rbi +4 -0
  45. data/rbi/openai/resources/beta/responses.rbi +10 -2
  46. data/rbi/openai/resources/responses.rbi +2 -2
  47. data/rbi/openai/streaming.rbi +1 -0
  48. data/sig/openai/helpers/sorbet.rbs +54 -0
  49. data/sig/openai/helpers/streaming/response_event_decoder.rbs +16 -0
  50. data/sig/openai/helpers/streaming/unknown_stream_event.rbs +15 -0
  51. data/sig/openai/http_client.rbs +14 -1
  52. data/sig/openai/internal/logging.rbs +6 -1
  53. data/sig/openai/internal/transport/base_client.rbs +7 -1
  54. data/sig/openai/internal/util.rbs +3 -1
  55. data/sig/openai/models/admin/organization/audit_log_list_params.rbs +4 -0
  56. data/sig/openai/models/admin/organization/audit_log_list_response.rbs +4 -0
  57. data/sig/openai/models/beta/beta_response_function_tool_call_output_item.rbs +7 -5
  58. data/sig/openai/models/beta/beta_response_input_item.rbs +5 -5
  59. data/sig/openai/models/beta/beta_response_usage.rbs +8 -3
  60. data/sig/openai/models/completion_usage.rbs +5 -0
  61. data/sig/openai/models/responses/response_function_tool_call_output_item.rbs +7 -5
  62. data/sig/openai/models/responses/response_input_item.rbs +5 -5
  63. data/sig/openai/models/responses/response_usage.rbs +8 -3
  64. data/sig/openai/request_options.rbs +4 -1
  65. data/sig/openai/resources/beta/responses.rbs +4 -2
  66. data/sig/openai/resources/responses.rbs +6 -3
  67. data/sig/openai/streaming.rbs +5 -0
  68. metadata +11 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97ce6ca6902b7dc85c97989c0555036a7fe45783129a2139186d0cee87f36cb0
4
- data.tar.gz: 15132d79dd284edb8fa80e3f6d0a72a7b0345285770182b93e6d3e376218c9ae
3
+ metadata.gz: 06b53caabbe2b1546ef4ec0eb7f677b8de5cb6d193a65e6a7e37b937459e84b1
4
+ data.tar.gz: af8a795be6bc7ee391b58b35a48e7a6eb49562c67a98d548437fad6f85871a48
5
5
  SHA512:
6
- metadata.gz: 21a457fa9fcbf0a59f7ed109acee5a0d55b47a757d9d1a0dfbc12fee70a07cf3766d0369c512a923d117f6a1f6c2179810d8f81c5edf48ba7e7de18c611c083f
7
- data.tar.gz: 1d0bc3c3de8bd6180b293e14ae6b4b15cead9feb6a8fe6ae49bf65fc140c3d7f6a473fa75d7f85a1436be47ab3ec7fdaaf38b9164746a7b293c19a861482d107
6
+ metadata.gz: 0a4a975d009308c39829498c31a41cd7ff1e4d6d34302251a2ad1a96858892a6ca52dfb7bb2791dfae729cc000c9c191ced27ae413f96b4a15b1f93f3d729c80
7
+ data.tar.gz: af0e5f84ca22955f73cecafe5a06bd9180c414c410cd1d8a88f36ae4566bfcdf43cdbba3300f24648e5cfbea993bd9093f0edc57304b183af6328a0826ad026e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.83.0](https://github.com/openai/openai-ruby/compare/v0.82.0...v0.83.0) (2026-08-27)
4
+
5
+
6
+ ### Features
7
+
8
+ * **api:** add compute_units to Responses and Chat Completions usage ([#543](https://github.com/openai/openai-ruby/issues/543)) ([b128f89](https://github.com/openai/openai-ruby/commit/b128f89672416a083addeb0e2bbc337f8ddb7f9f))
9
+ * expose opt-in raw response bodies on response metadata ([#544](https://github.com/openai/openai-ruby/issues/544)) ([1529e96](https://github.com/openai/openai-ruby/commit/1529e96781c217828915ed3f69faa61bf6605129))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * **realtime:** clean up interrupted WebRTC call allocations ([#534](https://github.com/openai/openai-ruby/issues/534)) ([2057039](https://github.com/openai/openai-ruby/commit/205703935bafe5e78b90b5424cc43d3b7b978772))
15
+
16
+
17
+ ### Chores
18
+
19
+ * **deps:** bump the codeql group with 2 updates ([#541](https://github.com/openai/openai-ruby/issues/541)) ([b15451c](https://github.com/openai/openai-ruby/commit/b15451c0df8ba890f136c86dc7f5299876bb9b5d))
20
+
21
+ ## [0.82.0](https://github.com/openai/openai-ruby/compare/v0.81.0...v0.82.0) (2026-08-27)
22
+
23
+
24
+ ### Features
25
+
26
+ * add optional Sorbet structured-output models ([#537](https://github.com/openai/openai-ruby/issues/537)) ([53ccf39](https://github.com/openai/openai-ruby/commit/53ccf39b464d8b2052456fa78aaea4dcdee8158c))
27
+ * **api:** make function call output call IDs optional ([#539](https://github.com/openai/openai-ruby/issues/539)) ([81d385b](https://github.com/openai/openai-ruby/commit/81d385be58ece0c5db798ed79d01c0fd4c458f60))
28
+
29
+
30
+ ### Bug Fixes
31
+
32
+ * **streaming:** preserve unknown Responses stream events ([#538](https://github.com/openai/openai-ruby/issues/538)) ([1d728f7](https://github.com/openai/openai-ruby/commit/1d728f74649c039ad29ca55986f9f5f48436d9f0))
33
+
3
34
  ## [0.81.0](https://github.com/openai/openai-ruby/compare/v0.80.0...v0.81.0) (2026-08-26)
4
35
 
5
36
 
data/README.md CHANGED
@@ -15,7 +15,7 @@ To use this gem, install via Bundler by adding the following to your application
15
15
  <!-- x-release-please-start-version -->
16
16
 
17
17
  ```ruby
18
- gem "openai", "~> 0.81.0"
18
+ gem "openai", "~> 0.83.0"
19
19
  ```
20
20
 
21
21
  <!-- x-release-please-end -->
@@ -764,6 +764,58 @@ response = client.responses.create(
764
764
 
765
765
  </details>
766
766
 
767
+ #### Sorbet structured-output models
768
+
769
+ Applications that use Sorbet can provide their own `T::Struct` models instead of
770
+ subclassing `OpenAI::BaseModel`. Add `sorbet-runtime` to your application's
771
+ dependencies and explicitly load the optional integration:
772
+
773
+ ```ruby
774
+ require "openai"
775
+ require "openai/helpers/sorbet"
776
+
777
+ class Participant < T::Struct
778
+ const :name, String
779
+ const :email, T.nilable(String)
780
+ end
781
+
782
+ class CalendarEvent < T::Struct
783
+ const :title, String
784
+ const :participants, T::Array[Participant]
785
+ end
786
+
787
+ schema = OpenAI::StructuredOutput.from_sorbet(CalendarEvent)
788
+ client = OpenAI::Client.new
789
+
790
+ response = client.responses.create(
791
+ model: "gpt-5.2",
792
+ input: "Extract the event information.",
793
+ text: schema
794
+ )
795
+
796
+ message = response.output.grep(OpenAI::Responses::ResponseOutputMessage).fetch(0)
797
+ output_text = message.content.grep(OpenAI::Responses::ResponseOutputText).fetch(0)
798
+ event = T.cast(output_text.parsed, CalendarEvent)
799
+ event.participants.fetch(0).name
800
+
801
+ completion = client.chat.completions.create(
802
+ model: "gpt-5.2",
803
+ messages: [{role: :user, content: "Extract the event information."}],
804
+ response_format: schema
805
+ )
806
+
807
+ T.cast(completion.choices.fetch(0).message.parsed, CalendarEvent)
808
+ ```
809
+
810
+ The integration supports nested structs, arrays, nullable values, boolean and
811
+ numeric fields, application-defined `T::Enum` values, and Sorbet's `name:` field
812
+ aliases. All structured-output fields are required, including nullable fields.
813
+ Unsupported unions, hashes, recursive models, and enum values that do not
814
+ serialize to strings are rejected. `sorbet-runtime` remains optional and is not
815
+ loaded by ordinary SDK usage. This integration supports non-streaming
816
+ `responses.create` and `chat.completions.create` requests only; streaming
817
+ structured outputs and function-tool schemas are not supported.
818
+
767
819
  ### Handling errors
768
820
 
769
821
  When the library is unable to connect to the API, or if the API returns a non-success status code (i.e., 4xx or 5xx response), a subclass of `OpenAI::Errors::APIError` will be thrown:
@@ -5,6 +5,113 @@ module OpenAI
5
5
  module Realtime
6
6
  # Realtime request integration kept outside the generated client implementation.
7
7
  module ClientExtension
8
+ MAX_CALL_CLEANUP_SECONDS = 5.0
9
+ private_constant :MAX_CALL_CLEANUP_SECONDS
10
+
11
+ # A generated WebRTC allocation belongs to the SDK until its complete raw
12
+ # response can be returned. Keep this lifecycle outside generated resources.
13
+ def request(req)
14
+ unless req.is_a?(Hash) &&
15
+ req[:method] == :post &&
16
+ req[:path] == "realtime/calls" &&
17
+ req[:model] == OpenAI::HTTPClient::Response
18
+ return super
19
+ end
20
+
21
+ delivered = false
22
+ allocated_response = nil
23
+ call_id = nil
24
+ begin
25
+ url, response, log_context = perform_request(req) do |received_response, request_url, response_url|
26
+ if received_response.status == 201
27
+ allocated_response = received_response
28
+ if response_url.path == request_url.path &&
29
+ realtime_call_origin(response_url).casecmp?(realtime_call_origin(request_url))
30
+ call_id = realtime_call_id_from_location(
31
+ received_response.headers["location"],
32
+ request_url: request_url
33
+ )
34
+ end
35
+ end
36
+ end
37
+
38
+ result = finish_request(log_context, response) do
39
+ parse_response(req, url: url, response: response)
40
+ end
41
+
42
+ delivered = true
43
+ result
44
+ ensure
45
+ unless delivered
46
+ begin
47
+ OpenAI::Internal::Util.close_fused!(allocated_response.body) if allocated_response
48
+ rescue StandardError, *(defined?(::Async::Stop) ? [::Async::Stop] : [])
49
+ nil
50
+ ensure
51
+ cleanup_created_realtime_call(call_id, options: req[:options]) if call_id
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ private def realtime_call_id_from_location(location, request_url:)
58
+ return if location.nil? || location.empty?
59
+
60
+ call_url = URI.join(request_url.to_s, location)
61
+ unless call_url.is_a?(URI::HTTP) &&
62
+ !call_url.host.to_s.empty? &&
63
+ call_url.userinfo.nil? &&
64
+ call_url.fragment.nil?
65
+ return
66
+ end
67
+
68
+ return unless realtime_call_origin(request_url).casecmp?(realtime_call_origin(call_url))
69
+
70
+ prefix = "#{request_url.path}/"
71
+ return unless call_url.path.start_with?(prefix)
72
+
73
+ call_id = call_url.path.delete_prefix(prefix)
74
+ call_id if /\A[A-Za-z0-9_-]+\z/.match?(call_id)
75
+ rescue URI::InvalidURIError, ArgumentError
76
+ nil
77
+ end
78
+
79
+ private def realtime_call_origin(url)
80
+ origin = OpenAI::Internal::Util.uri_origin(url)
81
+ if url.host.start_with?("[") && !url.hostname.start_with?("v", "V")
82
+ origin = origin.sub(url.host, "[#{IPAddr.new(url.hostname)}]")
83
+ end
84
+
85
+ origin
86
+ end
87
+
88
+ private def cleanup_created_realtime_call(call_id, options:)
89
+ cleanup_options = options.to_h.slice(:extra_headers, :extra_query, :timeout)
90
+ timeout = cleanup_options.fetch(:timeout, @timeout)
91
+ timeout = MAX_CALL_CLEANUP_SECONDS unless timeout&.positive?
92
+ cleanup_options[:timeout] = [timeout, MAX_CALL_CLEANUP_SECONDS].min
93
+ cleanup_options[:max_retries] = 0
94
+ task = ::Async::Task.current? if defined?(::Async::Task)
95
+ cleanup = lambda do
96
+ Timeout.timeout(cleanup_options.fetch(:timeout)) do
97
+ realtime.calls.hangup(call_id, request_options: cleanup_options)
98
+ end
99
+ end
100
+
101
+ if task
102
+ if task.respond_to?(:defer_cancel)
103
+ task.defer_cancel(&cleanup)
104
+ else
105
+ task.defer_stop(&cleanup)
106
+ end
107
+ else
108
+ cleanup.call
109
+ end
110
+
111
+ rescue StandardError, *(defined?(::Async::Stop) ? [::Async::Stop] : [])
112
+ nil
113
+ end
114
+
8
115
  # Build a fully authenticated Realtime WebSocket handshake request. Realtime
9
116
  # transports use this boundary so provider authentication and request options stay
10
117
  # consistent with ordinary SDK requests.
@@ -0,0 +1,324 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "sorbet-runtime"
5
+
6
+ rescue LoadError => e
7
+ raise unless e.path == "sorbet-runtime"
8
+
9
+ raise LoadError, "Sorbet structured outputs require the optional sorbet-runtime gem"
10
+ end
11
+
12
+ require "openai"
13
+
14
+ module OpenAI
15
+ module Helpers
16
+ module StructuredOutput
17
+ # Adapts application-owned Sorbet models to structured output requests.
18
+ #
19
+ # Load explicitly with `require "openai/helpers/sorbet"`.
20
+ class SorbetAdapter
21
+ include OpenAI::Internal::Type::Converter
22
+ include OpenAI::Helpers::StructuredOutput::JsonSchemaConverter
23
+
24
+ class HydrationError < TypeError
25
+ end
26
+
27
+ Field = Data.define(:name, :wire_name, :node)
28
+ Node = Data.define(:kind, :value, :nullable)
29
+ private_constant :Field, :Node
30
+
31
+ # @return [String]
32
+ attr_reader :name
33
+
34
+ # @param model [Class<T::Struct>]
35
+ def initialize(model)
36
+ unless model.is_a?(Class) && model < T::Struct && !model.name.to_s.empty?
37
+ raise ArgumentError, "#{model} must be a named T::Struct subclass"
38
+ end
39
+
40
+ @name = model.name
41
+ @compiled_structs = {}
42
+ @node = compile_struct(model, path: @name, ancestors: [])
43
+ end
44
+
45
+ # @return [Hash{Symbol=>Object}]
46
+ def to_json_schema = OpenAI::Helpers::StructuredOutput::JsonSchemaConverter.to_json_schema(self)
47
+
48
+ # @api private
49
+ def to_json_schema_inner(state:) = schema_for(@node, state: state)
50
+
51
+ # @api private
52
+ def coerce(value, state:)
53
+ return value if value.nil? || value.is_a?(JSON::ParserError)
54
+
55
+ hydrated = hydrate(@node, value, path: @name.split("::").last)
56
+ state.fetch(:exactness)[:yes] += 1
57
+ hydrated
58
+ end
59
+
60
+ private def compile_struct(model, path:, ancestors:)
61
+ if ancestors.include?(model)
62
+ raise ArgumentError, "#{path}: recursive Sorbet models are not supported"
63
+ end
64
+
65
+ return @compiled_structs.fetch(model) if @compiled_structs.key?(model)
66
+
67
+ fields = model.props.map do |field_name, rules|
68
+ wire_name = rules.fetch(:serialized_form)
69
+ type = rules.fetch(:type_object)
70
+ child = compile_type(type, path: "#{path}.#{field_name}", ancestors: [*ancestors, model])
71
+
72
+ Field.new(name: field_name, wire_name: wire_name, node: child)
73
+ end
74
+
75
+ if (duplicate = fields.map(&:wire_name).tally.find { _2 > 1 })
76
+ raise ArgumentError, "#{path}: duplicate serialized field name #{duplicate.first.inspect}"
77
+ end
78
+
79
+ @compiled_structs.store(model, Node.new(kind: :object, value: [model, fields], nullable: false))
80
+ end
81
+
82
+ private def compile_type(type, path:, ancestors:)
83
+ case type
84
+ in T::Types::TypedArray
85
+ item = compile_type(type.type, path: "#{path}[]", ancestors: ancestors)
86
+ Node.new(kind: :array, value: item, nullable: false)
87
+ in T::Types::Union
88
+ compile_union(type, path: path, ancestors: ancestors)
89
+ in T::Types::Simple
90
+ compile_class(type.raw_type, path: path, ancestors: ancestors)
91
+ else
92
+ raise ArgumentError, "#{path}: unsupported Sorbet type #{type}"
93
+ end
94
+ end
95
+
96
+ private def compile_union(type, path:, ancestors:)
97
+ members = type.types
98
+ nullable, remaining = members.partition do |member|
99
+ member.is_a?(T::Types::Simple) && member.raw_type == NilClass
100
+ end
101
+
102
+ node = if boolean_members?(remaining)
103
+ Node.new(kind: :boolean, value: nil, nullable: false)
104
+ elsif remaining.length == 1 && nullable.length == 1
105
+ compile_type(remaining.first, path: path, ancestors: ancestors)
106
+ else
107
+ raise ArgumentError, "#{path}: unsupported Sorbet union #{type}"
108
+ end
109
+
110
+ node.with(nullable: !nullable.empty?)
111
+ end
112
+
113
+ private def boolean_members?(members)
114
+ members.length == 2 &&
115
+ members.all? { _1.is_a?(T::Types::Simple) } &&
116
+ members.map(&:raw_type).to_set == [TrueClass, FalseClass].to_set
117
+ end
118
+
119
+ private def compile_class(type, path:, ancestors:)
120
+ if [String, Integer, Float].include?(type)
121
+ Node.new(kind: :scalar, value: type, nullable: false)
122
+ elsif type < T::Enum
123
+ unless type.values.all? { _1.serialize.is_a?(String) }
124
+ raise ArgumentError, "#{path}: Sorbet enum values must serialize to strings"
125
+ end
126
+
127
+ Node.new(kind: :enum, value: type, nullable: false)
128
+ elsif type < T::Struct
129
+ compile_struct(type, path: path, ancestors: ancestors)
130
+ else
131
+ raise ArgumentError, "#{path}: unsupported Sorbet class #{type}"
132
+ end
133
+ end
134
+
135
+ private def schema_for(node, state:)
136
+ schema = case node.kind
137
+ in :object
138
+ model, fields = node.value
139
+ OpenAI::Helpers::StructuredOutput::JsonSchemaConverter.cache_def!(state, type: model) do
140
+ properties = fields.to_h do |field|
141
+ child_state = {**state, path: [*state.fetch(:path), ".#{field.wire_name}"]}
142
+
143
+ [field.wire_name.to_sym, schema_for(field.node, state: child_state)]
144
+ end
145
+
146
+ {
147
+ type: "object",
148
+ properties: properties,
149
+ required: fields.map(&:wire_name),
150
+ additionalProperties: false
151
+ }
152
+ end
153
+
154
+ in :array
155
+ {type: "array", items: schema_for(node.value, state: state)}
156
+ in :boolean
157
+ {type: "boolean"}
158
+ in :enum
159
+ {type: "string", enum: node.value.values.map(&:serialize)}
160
+ in :scalar
161
+ {type: {String => "string", Integer => "integer", Float => "number"}.fetch(node.value)}
162
+ end
163
+
164
+ if node.nullable
165
+ if [:object, :enum].include?(node.kind)
166
+ {anyOf: [schema, {type: "null"}]}
167
+ else
168
+ OpenAI::Helpers::StructuredOutput::JsonSchemaConverter.to_nilable(schema)
169
+ end
170
+ else
171
+ schema
172
+ end
173
+ end
174
+
175
+ private def hydrate(node, value, path:)
176
+ return nil if value.nil? && node.nullable
177
+
178
+ case node.kind
179
+ in :object
180
+ hydrate_struct(node.value, value, path: path)
181
+ in :array
182
+ unless value.is_a?(Array)
183
+ fail_hydration(path, expected: "Array", actual: value)
184
+ end
185
+
186
+ value.map.with_index { |item, index| hydrate(node.value, item, path: "#{path}[#{index}]") }
187
+ in :enum
188
+ unless value.is_a?(String)
189
+ fail_hydration(path, expected: node.value.name, actual: value)
190
+ end
191
+
192
+ begin
193
+ node.value.deserialize(value)
194
+ rescue StandardError
195
+ fail_hydration(path, expected: node.value.name, actual: value)
196
+ end
197
+
198
+ in :boolean
199
+ unless value.equal?(true) || value.equal?(false)
200
+ fail_hydration(path, expected: "Boolean", actual: value)
201
+ end
202
+
203
+ value
204
+ in :scalar
205
+ value = value.to_f if node.value == Float && value.is_a?(Integer)
206
+
207
+ unless value.is_a?(node.value) && (node.value != Float || value.finite?)
208
+ fail_hydration(path, expected: node.value.name, actual: value)
209
+ end
210
+
211
+ value
212
+ end
213
+ end
214
+
215
+ private def hydrate_struct(shape, value, path:)
216
+ model, fields = shape
217
+ unless value.is_a?(Hash)
218
+ fail_hydration(path, expected: model.name, actual: value)
219
+ end
220
+
221
+ expected_names = fields.map(&:wire_name).to_set
222
+ unless value.keys.all? { (_1.is_a?(Symbol) || _1.is_a?(String)) && expected_names.include?(_1.to_s) }
223
+ raise HydrationError, "#{path}: unexpected structured-output field", cause: nil
224
+ end
225
+
226
+ attributes = fields.to_h do |field|
227
+ keys = [field.wire_name.to_sym, field.wire_name].select { value.key?(_1) }
228
+ field_path = "#{path}.#{field.name}"
229
+ unless keys.one?
230
+ reason = keys.empty? ? "missing required field" : "duplicate field"
231
+ raise HydrationError, "#{field_path}: #{reason}", cause: nil
232
+ end
233
+
234
+ [field.name, hydrate(field.node, value.fetch(keys.first), path: field_path)]
235
+ end
236
+
237
+ begin
238
+ model.new(**attributes)
239
+ rescue StandardError
240
+ raise HydrationError, "#{path}: invalid #{model.name} structured output", cause: nil
241
+ end
242
+ end
243
+
244
+ private def fail_hydration(path, expected:, actual:)
245
+ raise HydrationError, "#{path}: expected #{expected}, got #{actual.class}", cause: nil
246
+ end
247
+ end
248
+
249
+ # @param model [Class<T::Struct>]
250
+ # @return [OpenAI::Helpers::StructuredOutput::SorbetAdapter]
251
+ def self.from_sorbet(model) = OpenAI::Helpers::StructuredOutput::SorbetAdapter.new(model)
252
+
253
+ # Reject unsupported Sorbet request shapes before generated resources dispatch requests.
254
+ #
255
+ # @api private
256
+ module SorbetRequestGuard
257
+ def create(...)
258
+ reject_sorbet_function_tools!(...)
259
+ super
260
+ end
261
+
262
+ def stream(params)
263
+ reject_sorbet_streaming_formats!(params)
264
+ super
265
+ end
266
+
267
+ def stream_raw(...)
268
+ reject_sorbet_streaming_formats!(...)
269
+ super
270
+ end
271
+
272
+ private def reject_sorbet_streaming_formats!(params = {})
273
+ formats = [params[:text], params[:response_format], *Array(params[:tools])]
274
+ if params.is_a?(Hash)
275
+ formats.concat([params["text"], params["response_format"], *Array(params["tools"])])
276
+ end
277
+
278
+ if formats.any? { sorbet_adapter?(_1) }
279
+ raise ArgumentError, "Sorbet structured-output models are not supported for streaming"
280
+ end
281
+ end
282
+
283
+ private def reject_sorbet_function_tools!(params = {})
284
+ tools = Array(params[:tools])
285
+ tools.concat(Array(params["tools"])) if params.is_a?(Hash)
286
+
287
+ if tools.any? { sorbet_adapter?(_1) }
288
+ raise ArgumentError, "Sorbet structured-output function tools are not supported"
289
+ end
290
+ end
291
+
292
+ private def sorbet_adapter?(format)
293
+ return true if format.is_a?(SorbetAdapter)
294
+ format = format.to_h if format.is_a?(OpenAI::Internal::Type::BaseModel)
295
+ return false unless format.is_a?(Hash)
296
+
297
+ keys = [
298
+ :format,
299
+ "format",
300
+ :format_,
301
+ "format_",
302
+ :json_schema,
303
+ "json_schema",
304
+ :schema,
305
+ "schema",
306
+ :parameters,
307
+ "parameters",
308
+ :function,
309
+ "function"
310
+ ]
311
+ format.values_at(*keys).any? do |nested|
312
+ nested = nested.to_h if nested.is_a?(OpenAI::Internal::Type::BaseModel)
313
+ nested.is_a?(SorbetAdapter) ||
314
+ (nested.is_a?(Hash) && nested.values_at(:schema, "schema", :parameters, "parameters").any?(SorbetAdapter))
315
+ end
316
+ end
317
+ end
318
+
319
+ private_constant :SorbetRequestGuard
320
+ OpenAI::Resources::Responses.prepend(SorbetRequestGuard)
321
+ OpenAI::Resources::Chat::Completions.prepend(SorbetRequestGuard)
322
+ end
323
+ end
324
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "unknown_stream_event"
4
+
5
+ module OpenAI
6
+ module Helpers
7
+ module Streaming
8
+ # @api private
9
+ class ResponseEventDecoder
10
+ def initialize(model:)
11
+ @model = model
12
+ return unless response_stream_model?
13
+
14
+ @known_types = model.variants.to_h do |variant|
15
+ type = variant.known_fields.fetch(:type).fetch(:const)
16
+ [type.to_s, true]
17
+ end
18
+ end
19
+
20
+ def decode(data)
21
+ if unknown_response_event?(data)
22
+ UnknownStreamEvent.new(data: data)
23
+ else
24
+ OpenAI::Internal::Type::Converter.coerce(@model, data)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def response_stream_model?
31
+ @model.equal?(OpenAI::Models::Responses::ResponseStreamEvent) ||
32
+ @model.equal?(OpenAI::Models::Beta::BetaResponseStreamEvent)
33
+ end
34
+
35
+ def unknown_response_event?(data)
36
+ return false unless @known_types && data.is_a?(Hash)
37
+
38
+ type = data[:type]
39
+ (type.is_a?(String) || type.is_a?(Symbol)) && !@known_types.key?(type.to_s)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -63,13 +63,18 @@ module OpenAI
63
63
  @raw_stream.each do |raw_event|
64
64
  events_to_yield = @state.handle_event(raw_event)
65
65
  events_to_yield.each do |event|
66
- if @starting_after.nil? || event.sequence_number > @starting_after
67
- y << event
68
- end
66
+ y << event if after_starting_event?(event)
69
67
  end
70
68
  end
71
69
  end
72
70
  end
71
+
72
+ def after_starting_event?(event)
73
+ return true if @starting_after.nil?
74
+ return true if event.is_a?(UnknownStreamEvent) && event.sequence_number.nil?
75
+
76
+ event.sequence_number > @starting_after
77
+ end
73
78
  end
74
79
 
75
80
  class ResponseStreamState
@@ -82,6 +87,8 @@ module OpenAI
82
87
  end
83
88
 
84
89
  def handle_event(event)
90
+ return [event] if event.is_a?(UnknownStreamEvent)
91
+
85
92
  @current_snapshot = accumulate_event(
86
93
  event: event,
87
94
  current_snapshot: @current_snapshot
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenAI
4
+ module Helpers
5
+ module Streaming
6
+ # A Responses event whose discriminator is newer than this SDK version.
7
+ class UnknownStreamEvent
8
+ # @return [Symbol]
9
+ attr_reader :type
10
+
11
+ # @return [Integer, nil]
12
+ attr_reader :sequence_number
13
+
14
+ # @return [Hash{Symbol=>Object}]
15
+ attr_reader :data
16
+
17
+ # @api private
18
+ def initialize(data:)
19
+ value = data.fetch(:type)
20
+ unless value.is_a?(String) || value.is_a?(Symbol)
21
+ raise ArgumentError, "Responses stream event type must be a string or symbol"
22
+ end
23
+
24
+ @type = value.to_sym
25
+ sequence_number = data[:sequence_number]
26
+ @sequence_number = sequence_number if sequence_number.is_a?(Integer)
27
+ @data = freeze_json(data)
28
+ freeze
29
+ end
30
+
31
+ # @return [Hash{Symbol=>Object}]
32
+ def to_h = @data
33
+
34
+ private def freeze_json(value)
35
+ case value
36
+ when Hash
37
+ value.each do |key, item|
38
+ freeze_json(key)
39
+ freeze_json(item)
40
+ end
41
+
42
+ when Array
43
+ value.each { |item| freeze_json(item) }
44
+ end
45
+
46
+ value.freeze
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end