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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +53 -1
- data/lib/openai/helpers/realtime/client_extension.rb +107 -0
- data/lib/openai/helpers/sorbet.rb +324 -0
- data/lib/openai/helpers/streaming/response_event_decoder.rb +44 -0
- data/lib/openai/helpers/streaming/response_stream.rb +10 -3
- data/lib/openai/helpers/streaming/unknown_stream_event.rb +51 -0
- data/lib/openai/http_client.rb +40 -1
- data/lib/openai/internal/logging.rb +7 -1
- data/lib/openai/internal/stream.rb +4 -1
- data/lib/openai/internal/transport/base_client.rb +35 -4
- data/lib/openai/internal/util.rb +6 -2
- data/lib/openai/models/admin/organization/audit_log_list_params.rb +2 -0
- data/lib/openai/models/admin/organization/audit_log_list_response.rb +2 -0
- data/lib/openai/models/beta/beta_response_function_tool_call_output_item.rb +9 -9
- data/lib/openai/models/beta/beta_response_input_item.rb +9 -9
- data/lib/openai/models/beta/beta_response_usage.rb +12 -1
- data/lib/openai/models/completion_usage.rb +12 -1
- data/lib/openai/models/responses/response_function_tool_call_output_item.rb +9 -9
- data/lib/openai/models/responses/response_input_item.rb +9 -9
- data/lib/openai/models/responses/response_usage.rb +12 -1
- data/lib/openai/request_options.rb +8 -0
- data/lib/openai/resources/beta/responses.rb +2 -2
- data/lib/openai/resources/responses.rb +3 -3
- data/lib/openai/version.rb +1 -1
- data/rbi/openai/helpers/sorbet.rbi +55 -0
- data/rbi/openai/helpers/streaming/response_event_decoder.rbi +28 -0
- data/rbi/openai/helpers/streaming/response_stream.rbi +5 -0
- data/rbi/openai/helpers/streaming/unknown_stream_event.rbi +29 -0
- data/rbi/openai/http_client.rbi +25 -2
- data/rbi/openai/internal/logging.rbi +12 -2
- data/rbi/openai/internal/transport/base_client.rbi +12 -2
- data/rbi/openai/internal/util.rbi +3 -2
- data/rbi/openai/models/admin/organization/audit_log_list_params.rbi +8 -0
- data/rbi/openai/models/admin/organization/audit_log_list_response.rbi +8 -0
- data/rbi/openai/models/beta/beta_response_function_tool_call_output_item.rbi +13 -10
- data/rbi/openai/models/beta/beta_response_input_item.rbi +10 -10
- data/rbi/openai/models/beta/beta_response_usage.rbi +13 -3
- data/rbi/openai/models/completion_usage.rbi +10 -0
- data/rbi/openai/models/responses/response_function_tool_call_output_item.rbi +13 -10
- data/rbi/openai/models/responses/response_input_item.rbi +10 -10
- data/rbi/openai/models/responses/response_usage.rbi +13 -3
- data/rbi/openai/request_options.rbi +4 -0
- data/rbi/openai/resources/beta/responses.rbi +10 -2
- data/rbi/openai/resources/responses.rbi +2 -2
- data/rbi/openai/streaming.rbi +1 -0
- data/sig/openai/helpers/sorbet.rbs +54 -0
- data/sig/openai/helpers/streaming/response_event_decoder.rbs +16 -0
- data/sig/openai/helpers/streaming/unknown_stream_event.rbs +15 -0
- data/sig/openai/http_client.rbs +14 -1
- data/sig/openai/internal/logging.rbs +6 -1
- data/sig/openai/internal/transport/base_client.rbs +7 -1
- data/sig/openai/internal/util.rbs +3 -1
- data/sig/openai/models/admin/organization/audit_log_list_params.rbs +4 -0
- data/sig/openai/models/admin/organization/audit_log_list_response.rbs +4 -0
- data/sig/openai/models/beta/beta_response_function_tool_call_output_item.rbs +7 -5
- data/sig/openai/models/beta/beta_response_input_item.rbs +5 -5
- data/sig/openai/models/beta/beta_response_usage.rbs +8 -3
- data/sig/openai/models/completion_usage.rbs +5 -0
- data/sig/openai/models/responses/response_function_tool_call_output_item.rbs +7 -5
- data/sig/openai/models/responses/response_input_item.rbs +5 -5
- data/sig/openai/models/responses/response_usage.rbs +8 -3
- data/sig/openai/request_options.rbs +4 -1
- data/sig/openai/resources/beta/responses.rbs +4 -2
- data/sig/openai/resources/responses.rbs +6 -3
- data/sig/openai/streaming.rbs +5 -0
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 06b53caabbe2b1546ef4ec0eb7f677b8de5cb6d193a65e6a7e37b937459e84b1
|
|
4
|
+
data.tar.gz: af8a795be6bc7ee391b58b35a48e7a6eb49562c67a98d548437fad6f85871a48
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
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
|