openai 0.97.0 → 0.98.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 (112) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +76 -1
  4. data/lib/openai/helpers/agents/session_stream.rb +60 -3
  5. data/lib/openai/helpers/beta/agents/artifacts.rb +72 -0
  6. data/lib/openai/helpers/beta/agents/creation_stream.rb +69 -0
  7. data/lib/openai/helpers/beta/agents/files.rb +236 -0
  8. data/lib/openai/helpers/beta/agents/function_tool.rb +65 -0
  9. data/lib/openai/helpers/beta/agents/model_adapter.rb +27 -0
  10. data/lib/openai/helpers/beta/agents/result.rb +170 -0
  11. data/lib/openai/helpers/beta/agents/typed_output.rb +191 -0
  12. data/lib/openai/helpers/live/websocket.rb +7 -1
  13. data/lib/openai/helpers/realtime/conversation_item_variant_resolver.rb +1 -1
  14. data/lib/openai/helpers/responses_websocket/connection.rb +7 -1
  15. data/lib/openai/helpers/responses_websocket/session.rb +5 -1
  16. data/lib/openai/helpers/responses_websocket/transports/async_websocket.rb +12 -2
  17. data/lib/openai/internal/transport/base_client.rb +18 -0
  18. data/lib/openai/internal/type/array_of.rb +1 -1
  19. data/lib/openai/internal/type/base_model.rb +6 -3
  20. data/lib/openai/internal/type/converter.rb +50 -3
  21. data/lib/openai/internal/type/hash_of.rb +1 -1
  22. data/lib/openai/internal/type/union.rb +6 -6
  23. data/lib/openai/models/beta/agents/sessions/session_trace.rb +64 -0
  24. data/lib/openai/models/beta/agents/sessions/trace_list_params.rb +68 -0
  25. data/lib/openai/models/beta/agents/vaults/credential.rb +8 -4
  26. data/lib/openai/models/beta/beta_response_output_text.rb +2 -2
  27. data/lib/openai/models/beta/beta_response_output_text_annotation_added_event.rb +2 -2
  28. data/lib/openai/models/beta/environment.rb +33 -1
  29. data/lib/openai/models/beta/environment_param.rb +24 -1
  30. data/lib/openai/models/beta/session_turn_error.rb +3 -0
  31. data/lib/openai/models/chat/chat_completion_content_part_image.rb +1 -0
  32. data/lib/openai/models/live/fork_server_event.rb +14 -3
  33. data/lib/openai/models/live/server_event.rb +14 -3
  34. data/lib/openai/models/realtime/translations/client_secret_create_params.rb +28 -0
  35. data/lib/openai/models/responses/response_output_text.rb +2 -2
  36. data/lib/openai/models/responses/response_output_text_annotation_added_event.rb +2 -2
  37. data/lib/openai/resources/beta/agents/environments/files.rb +31 -0
  38. data/lib/openai/resources/beta/agents/sessions/artifacts.rb +8 -0
  39. data/lib/openai/resources/beta/agents/sessions/events.rb +6 -5
  40. data/lib/openai/resources/beta/agents/sessions/traces.rb +58 -0
  41. data/lib/openai/resources/beta/agents/sessions.rb +18 -5
  42. data/lib/openai/resources/evals/runs.rb +1 -1
  43. data/lib/openai/resources/realtime/translations/client_secrets.rb +58 -0
  44. data/lib/openai/resources/realtime/translations.rb +20 -0
  45. data/lib/openai/resources/realtime.rb +4 -0
  46. data/lib/openai/version.rb +1 -1
  47. data/lib/openai.rb +9 -1
  48. data/rbi/openai/helpers/agents.rbi +20 -1
  49. data/rbi/openai/helpers/beta/agents/artifacts.rbi +29 -0
  50. data/rbi/openai/helpers/beta/agents/files.rbi +65 -0
  51. data/rbi/openai/helpers/beta/agents/function_tool.rbi +40 -0
  52. data/rbi/openai/helpers/beta/agents/model_adapter.rbi +22 -0
  53. data/rbi/openai/helpers/beta/agents.rbi +146 -0
  54. data/rbi/openai/internal/transport/base_client.rbi +12 -0
  55. data/rbi/openai/internal/type/base_model.rbi +2 -2
  56. data/rbi/openai/internal/type/converter.rbi +21 -3
  57. data/rbi/openai/internal/type/union.rbi +6 -2
  58. data/rbi/openai/models/beta/agents/sessions/session_trace.rbi +97 -0
  59. data/rbi/openai/models/beta/agents/sessions/trace_list_params.rbi +121 -0
  60. data/rbi/openai/models/beta/agents/vaults/credential.rbi +5 -5
  61. data/rbi/openai/models/beta/beta_response_output_text.rbi +2 -2
  62. data/rbi/openai/models/beta/beta_response_output_text_annotation_added_event.rbi +2 -2
  63. data/rbi/openai/models/beta/environment.rbi +29 -0
  64. data/rbi/openai/models/beta/environment_param.rbi +31 -0
  65. data/rbi/openai/models/beta/session_turn_error.rbi +3 -0
  66. data/rbi/openai/models/chat/chat_completion_content_part_image.rbi +1 -0
  67. data/rbi/openai/models/live/fork_server_event.rbi +14 -2
  68. data/rbi/openai/models/live/server_event.rbi +14 -2
  69. data/rbi/openai/models/realtime/translations/client_secret_create_params.rbi +68 -0
  70. data/rbi/openai/models/responses/response_output_text.rbi +2 -2
  71. data/rbi/openai/models/responses/response_output_text_annotation_added_event.rbi +2 -2
  72. data/rbi/openai/resources/beta/agents/environments/files.rbi +31 -0
  73. data/rbi/openai/resources/beta/agents/sessions/artifacts.rbi +8 -0
  74. data/rbi/openai/resources/beta/agents/sessions/events.rbi +6 -5
  75. data/rbi/openai/resources/beta/agents/sessions/traces.rbi +54 -0
  76. data/rbi/openai/resources/beta/agents/sessions.rbi +6 -1
  77. data/rbi/openai/resources/realtime/translations/client_secrets.rbi +54 -0
  78. data/rbi/openai/resources/realtime/translations.rbi +22 -0
  79. data/rbi/openai/resources/realtime.rbi +3 -0
  80. data/sig/openai/helpers/agents.rbs +7 -0
  81. data/sig/openai/helpers/beta/agents/artifacts.rbs +20 -0
  82. data/sig/openai/helpers/beta/agents/files.rbs +41 -0
  83. data/sig/openai/helpers/beta/agents/function_tool.rbs +24 -0
  84. data/sig/openai/helpers/beta/agents/model_adapter.rbs +16 -0
  85. data/sig/openai/helpers/beta/agents.rbs +71 -0
  86. data/sig/openai/helpers/structured_output.rbs +12 -0
  87. data/sig/openai/internal/transport/base_client.rbs +8 -0
  88. data/sig/openai/internal/type/base_model.rbs +3 -1
  89. data/sig/openai/internal/type/converter.rbs +16 -2
  90. data/sig/openai/internal/type/union.rbs +2 -1
  91. data/sig/openai/models/beta/agents/sessions/session_trace.rbs +65 -0
  92. data/sig/openai/models/beta/agents/sessions/trace_list_params.rbs +81 -0
  93. data/sig/openai/models/beta/agents/vaults/credential.rbs +5 -5
  94. data/sig/openai/models/beta/beta_response_output_text.rbs +2 -2
  95. data/sig/openai/models/beta/beta_response_output_text_annotation_added_event.rbs +2 -2
  96. data/sig/openai/models/beta/environment.rbs +22 -0
  97. data/sig/openai/models/beta/environment_param.rbs +25 -0
  98. data/sig/openai/models/beta/session_turn_error.rbs +4 -0
  99. data/sig/openai/models/chat/chat_completion_content_part_image.rbs +2 -1
  100. data/sig/openai/models/live/fork_server_event.rbs +17 -2
  101. data/sig/openai/models/live/server_event.rbs +17 -2
  102. data/sig/openai/models/realtime/translations/client_secret_create_params.rbs +39 -0
  103. data/sig/openai/models/responses/response_output_text.rbs +2 -2
  104. data/sig/openai/models/responses/response_output_text_annotation_added_event.rbs +2 -2
  105. data/sig/openai/resources/beta/agents/environments/files.rbs +17 -0
  106. data/sig/openai/resources/beta/agents/sessions/artifacts.rbs +4 -0
  107. data/sig/openai/resources/beta/agents/sessions/traces.rbs +21 -0
  108. data/sig/openai/resources/beta/agents/sessions.rbs +4 -1
  109. data/sig/openai/resources/realtime/translations/client_secrets.rbs +17 -0
  110. data/sig/openai/resources/realtime/translations.rbs +11 -0
  111. data/sig/openai/resources/realtime.rbs +2 -0
  112. metadata +37 -1
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenAI
4
+ module Helpers
5
+ module Beta
6
+ module Agents
7
+ # Shared native model checks; JSON and wire-schema policies stay with each caller.
8
+ # @api private
9
+ module ModelAdapter
10
+ def self.validate!(model, parameter:)
11
+ unless model.is_a?(Class) && model < OpenAI::BaseModel
12
+ raise ArgumentError, "#{parameter} must be an OpenAI::BaseModel subclass"
13
+ end
14
+ end
15
+
16
+ def self.coerce(model, value, memoize: false)
17
+ state = OpenAI::Internal::Type::Converter.new_coerce_state(memoize: memoize)
18
+ parsed = OpenAI::Internal::Type::Converter.coerce(model, value, state: state)
19
+ parsed if parsed.is_a?(model) && state[:exactness][:no].zero?
20
+ end
21
+ end
22
+
23
+ private_constant :ModelAdapter
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenAI
4
+ module Helpers
5
+ module Beta
6
+ module Agents
7
+ # The final output of one completed root turn (beta).
8
+ # Messages contain final assistant answers, not the session transcript.
9
+ class TurnResult
10
+ attr_reader :turn, :messages
11
+
12
+ # @api private
13
+ def initialize(turn:, messages:)
14
+ @turn = turn
15
+ @messages = messages.freeze
16
+ end
17
+
18
+ def session_id = turn.session_id
19
+ def turn_id = turn.id
20
+
21
+ # Joins text without adding separators or making requests.
22
+ def output_text = messages.map(&:output_text).join
23
+
24
+ # A raw result has no parser attached.
25
+ def output_parsed = nil
26
+
27
+ # Parse this completed answer without changing the hosted session.
28
+ def parse(output_type:)
29
+ OutputParser.new(output_type).parse(self)
30
+ end
31
+ end
32
+
33
+ # Collection failed, or the observed turn did not complete successfully.
34
+ # A transport failure describes observation, not failure of hosted work.
35
+ class ResultError < OpenAI::Errors::Error
36
+ attr_reader :reason, :session_id, :turn, :messages, :required_actions
37
+
38
+ # @api private
39
+ def initialize(reason:, session_id:, turn:, messages:, required_actions: [])
40
+ @reason = reason
41
+ @session_id = session_id
42
+ @turn = turn
43
+ @messages = messages.freeze
44
+ @required_actions = required_actions.freeze
45
+ super("Unable to collect the agent turn result: #{reason}")
46
+ end
47
+
48
+ def turn_id = turn&.id
49
+ end
50
+
51
+ # @api private
52
+ class ResultCollector
53
+ def initialize(session_id: nil, handler_names: [])
54
+ @session_id = session_id
55
+ @handler_names = handler_names
56
+ @messages = {}
57
+ @required_actions = []
58
+ end
59
+
60
+ def enable
61
+ return if @enabled
62
+ if @started
63
+ raise ArgumentError, "Call with_result_collection before consuming events to collect a final result"
64
+ end
65
+
66
+ @enabled = true
67
+ end
68
+
69
+ def observe(event)
70
+ @started = true
71
+ return unless @enabled
72
+ return if @finished || !event.is_a?(OpenAI::Internal::Type::BaseModel)
73
+
74
+ case event.type
75
+ when :"agent.session.created"
76
+ @session_id ||= event.session.id.dup
77
+ when :"agent.session.turn.created"
78
+ if @turn.nil? && event.turn.subagent_id.nil? && (@session_id.nil? || event.session_id == @session_id)
79
+ @turn = copy(event.turn)
80
+ @session_id = @turn.session_id
81
+ end
82
+
83
+ when :"agent.session.turn.completed", :"agent.session.turn.failed", :"agent.session.turn.cancelled"
84
+ if @turn && event.turn_id == @turn.id
85
+ @turn = copy(event.turn)
86
+ @terminal = true
87
+ @required_actions = []
88
+ end
89
+
90
+ when :"agent.session.turn.item.done"
91
+ item = event.item
92
+ if @turn &&
93
+ item.is_a?(OpenAI::Models::Beta::AgentSessionAssistantMessage) &&
94
+ item.turn_id == @turn.id &&
95
+ item.role == :assistant &&
96
+ item.status == :completed &&
97
+ item.phase != :commentary
98
+ @messages[item.id] = [event.output_index, copy(item, OpenAI::Models::Beta::AgentSessionMessage)]
99
+ end
100
+
101
+ when :"agent.session.requires_action"
102
+ @required_actions = event.session.required_actions.filter_map do |action|
103
+ if action.is_a?(OpenAI::Models::Beta::AgentSession::RequiredAction::FunctionCall) &&
104
+ @handler_names.include?(action.name)
105
+ next
106
+ end
107
+
108
+ copy(action)
109
+ end
110
+
111
+ when :"agent.session.in_progress"
112
+ @required_actions = [] if event.session.id == @session_id
113
+ when :"agent.session.idle"
114
+ @finished = true if @terminal && event.session.id == @session_id
115
+ when :"agent.session.failed"
116
+ @session_id ||= event.session.id.dup
117
+ @failure = :failed
118
+ @finished = true
119
+ end
120
+ end
121
+
122
+ def stopped?
123
+ @finished || !@required_actions.empty? || (@terminal && @turn.status != :completed)
124
+ end
125
+
126
+ def observe_error(error)
127
+ @cause ||= error if @enabled && !@finished
128
+ end
129
+
130
+ def result
131
+ return @result if @result
132
+ raise @result_error if @result_error
133
+ raise error(:observation_error), cause: @cause if @cause
134
+ raise error(@failure) if @failure
135
+ raise error(@turn.status) if @terminal && @turn.status != :completed
136
+ raise error(:requires_action) unless @required_actions.empty?
137
+ raise error(:incomplete) unless @finished && @turn&.status == :completed
138
+
139
+ @result = TurnResult.new(turn: @turn, messages: take_messages)
140
+ end
141
+
142
+ private
143
+
144
+ def take_messages
145
+ messages = @messages.values.sort_by(&:first).map(&:last)
146
+ @messages.clear
147
+ messages
148
+ end
149
+
150
+ def error(reason)
151
+ @result_error = ResultError.new(
152
+ reason: reason,
153
+ session_id: @session_id,
154
+ turn: @turn,
155
+ messages: take_messages,
156
+ required_actions: @required_actions
157
+ )
158
+ end
159
+
160
+ def copy(value, model = value.class)
161
+ raw = JSON.parse(JSON.generate(value), symbolize_names: true)
162
+ OpenAI::Internal::Type::Converter.coerce(model, raw)
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
169
+
170
+ require_relative "typed_output"
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "model_adapter"
4
+
5
+ module OpenAI
6
+ module Helpers
7
+ module Beta
8
+ module Agents
9
+ # A completed turn with output hydrated into the requested Ruby model (beta).
10
+ class ParsedTurnResult < TurnResult
11
+ attr_reader :raw_result, :output_parsed
12
+
13
+ # @api private
14
+ def initialize(raw_result:, output_parsed:)
15
+ @raw_result = raw_result
16
+ @output_parsed = output_parsed
17
+ super(turn: raw_result.turn, messages: raw_result.messages)
18
+ end
19
+ end
20
+
21
+ # Hosted work completed, but its answer did not match the requested model.
22
+ class OutputParseError < OpenAI::Errors::Error
23
+ attr_reader :raw_result
24
+
25
+ # @api private
26
+ def initialize(raw_result:)
27
+ @raw_result = raw_result
28
+ super("The completed agent output does not match output_type")
29
+ end
30
+ end
31
+
32
+ # @api private
33
+ class OutputParser
34
+ def initialize(model)
35
+ ModelAdapter.validate!(model, parameter: "output_type")
36
+
37
+ @model = model
38
+ end
39
+
40
+ def prepare_request(params)
41
+ agent = params.fetch(:agent, nil).to_h.transform_keys { _1.is_a?(String) ? _1.to_sym : _1 }
42
+ text = agent.fetch(:text, nil).to_h.transform_keys { _1.is_a?(String) ? _1.to_sym : _1 }
43
+ if text.key?(:format) || text.key?(:format_)
44
+ raise ArgumentError, "output_type cannot be combined with agent.text.format"
45
+ end
46
+
47
+ @schema = JSON.parse(JSON.generate(@model.to_json_schema, max_nesting: false), max_nesting: false)
48
+ normalize_references
49
+ params[:agent] = agent.merge(text: text.merge(format: {type: :json_schema, schema: @schema}))
50
+ end
51
+
52
+ def parse(result)
53
+ return @result if @result && @result.raw_result.equal?(result)
54
+ raise @error if @error && @error.raw_result.equal?(result)
55
+
56
+ first = nil
57
+ result.messages.each do |message|
58
+ message.content.each do |part|
59
+ next unless (part[:type] || (part["type"] if part.is_a?(Hash))).to_s == "output_text"
60
+ text = part[:text] || (part["text"] if part.is_a?(Hash))
61
+ value = JSON.parse(text, symbolize_names: true, max_nesting: false)
62
+ parsed = ModelAdapter.coerce(@model, value, memoize: true)
63
+ raise TypeError, "Output cannot be parsed into output_type" unless parsed
64
+ first ||= parsed
65
+ end
66
+ end
67
+
68
+ raise TypeError, "Output does not contain structured text" unless first
69
+
70
+ @result = ParsedTurnResult.new(raw_result: result, output_parsed: first)
71
+ rescue StandardError, SystemStackError => error
72
+ raise if error.equal?(@error)
73
+ @error = OutputParseError.new(raw_result: result)
74
+ raise @error, cause: nil
75
+ end
76
+
77
+ private
78
+
79
+ def resolve(ref)
80
+ return unless ref.is_a?(String) && ref.start_with?("#/")
81
+
82
+ URI::RFC2396_PARSER
83
+ .unescape(ref.delete_prefix("#/"))
84
+ .split("/", -1)
85
+ .reduce(@schema) { |node, token| node[token.gsub("~1", "/").gsub("~0", "~")] if node.is_a?(Hash) }
86
+ end
87
+
88
+ def normalize_references
89
+ return unless @schema.is_a?(Hash) && @schema["$defs"].is_a?(Hash)
90
+ return if @schema.key?("$ref") && !(@schema.keys - %w[$defs $ref]).empty?
91
+ definitions = @schema.fetch("$defs")
92
+ return if definitions.empty? || !definitions.values.all? { _1.is_a?(Hash) }
93
+
94
+ names = {}.compare_by_identity
95
+ definitions.each_value.with_index { |definition, index| names[definition] = "model_#{index}" }
96
+ nodes = schema_nodes
97
+ return unless references_covered?(nodes)
98
+ references = nodes.select { _1.key?("$ref") }
99
+ return if references.empty?
100
+ targets = references.map { resolve(_1["$ref"]) }
101
+ # Native Ruby pointers encode definition names; Agents looks up raw suffixes.
102
+ # Unfamiliar references keep their schema and definition names intact.
103
+ return unless targets.all? { names.key?(_1) }
104
+ root = resolve(@schema["$ref"])
105
+ expand_root = root.is_a?(Hash) && !root.key?("$defs") && (@schema.keys - %w[$defs $ref]).empty?
106
+
107
+ references.zip(targets).each do |node, target|
108
+ node["$ref"] = "#/$defs/#{names.fetch(target)}"
109
+ if (node.key?("description") || node.key?("title")) && !node.key?("anyOf")
110
+ node["anyOf"] = [{"$ref" => node.delete("$ref")}]
111
+ end
112
+ end
113
+
114
+ @schema["$defs"] = definitions.to_h { |_name, definition| [names.fetch(definition), definition] }
115
+ @schema = @schema.except("$ref").merge(root) if expand_root
116
+ end
117
+
118
+ def references_covered?(nodes)
119
+ known = nodes.each_with_object({}.compare_by_identity) { |node, seen| seen[node] = true }
120
+ pending = [@schema]
121
+ until pending.empty?
122
+ value = pending.pop
123
+ case value
124
+ when Hash
125
+ if known[value]
126
+ unless (value.keys &
127
+ %w[$id id $anchor $dynamicAnchor $dynamicRef $recursiveAnchor $recursiveRef discriminator])
128
+ .empty?
129
+ return false
130
+ end
131
+
132
+ pending.concat(
133
+ value.except("$ref", "title", "description", "default", "const", "enum", "examples").values
134
+ )
135
+ else
136
+ return false if %w[$ref $dynamicRef $recursiveRef].any? { value[_1].is_a?(String) }
137
+ pending.concat(value.values)
138
+ end
139
+
140
+ when Array
141
+ pending.concat(value)
142
+ when String
143
+ return false if value.start_with?("#/")
144
+ end
145
+ end
146
+
147
+ true
148
+ end
149
+
150
+ def schema_nodes
151
+ nodes = []
152
+ pending = [@schema]
153
+ until pending.empty?
154
+ node = pending.pop
155
+ next unless node.is_a?(Hash)
156
+ nodes << node
157
+ # Visit schema positions only: defaults, examples and enum values are data.
158
+ %w[properties patternProperties $defs definitions dependentSchemas dependencies].each do |key|
159
+ pending.concat(node[key].values.grep(Hash)) if node[key].is_a?(Hash)
160
+ end
161
+
162
+ %w[anyOf allOf oneOf prefixItems].each do |key|
163
+ pending.concat(node[key]) if node[key].is_a?(Array)
164
+ end
165
+
166
+ %w[
167
+ items
168
+ additionalProperties
169
+ additionalItems
170
+ contains
171
+ not
172
+ if
173
+ then
174
+ else
175
+ propertyNames
176
+ unevaluatedProperties
177
+ unevaluatedItems
178
+ ]
179
+ .each do |key|
180
+ pending.concat(node[key].is_a?(Array) ? node[key] : [node[key]]) if node.key?(key)
181
+ end
182
+ end
183
+
184
+ nodes
185
+ end
186
+
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -99,10 +99,16 @@ module OpenAI
99
99
  end
100
100
 
101
101
  private def write_text(text)
102
+ complete = false
102
103
  super
104
+ complete = true
105
+ nil
103
106
  rescue StandardError
104
- @poisoned = true
105
107
  raise OpenAI::Errors::LiveConnectionError.new(url: @url), cause: nil
108
+ ensure
109
+ # Also retire a possibly buffered write when cancellation bypasses
110
+ # StandardError, before another sender or normal cleanup can flush it.
111
+ @poisoned = true unless complete
106
112
  end
107
113
 
108
114
  private def read_raw_message
@@ -7,7 +7,7 @@ module OpenAI
7
7
  # exceptions local to Realtime instead of weakening SDK-wide union behavior.
8
8
  module ConversationItem
9
9
  class << self
10
- private def resolve_variant(value)
10
+ private def resolve_variant(value, state: nil)
11
11
  return super unless value.is_a?(Hash)
12
12
 
13
13
  type = value.fetch(:type) { value.fetch("type", OpenAI::Internal::OMIT) }
@@ -177,10 +177,16 @@ module OpenAI
177
177
  end
178
178
 
179
179
  private def write_text(text)
180
+ complete = false
180
181
  super
182
+ complete = true
183
+ nil
181
184
  rescue StandardError
182
- @poisoned = true
183
185
  raise OpenAI::Errors::ResponsesSendError.new(url: @url), cause: nil
186
+ ensure
187
+ # Cancellation can bypass StandardError. Poison before another lane
188
+ # can send, so normal cleanup cannot flush an uncertain write.
189
+ @poisoned = true unless complete
184
190
  end
185
191
 
186
192
  private def with_read_lease
@@ -495,8 +495,12 @@ module OpenAI
495
495
  raise
496
496
  rescue OpenAI::Errors::ResponsesSendError => error
497
497
  @error = error
498
- stop_reader
499
498
  raise
499
+ ensure
500
+ if @connection.poisoned?
501
+ @error ||= OpenAI::Errors::ResponsesSendError.new(url: @connection.url)
502
+ stop_reader
503
+ end
500
504
  end
501
505
 
502
506
  # @api private
@@ -117,7 +117,10 @@ module OpenAI
117
117
  decoded = +"".b
118
118
  begin
119
119
  inflater.inflate(buffer + ::Protocol::WebSocket::Extension::Compression::Inflate::TRAILER) do |chunk|
120
- if inflater.total_out - start_out > @responses_decoded_limit
120
+ if inflater.total_out -
121
+ start_out > @responses_decoded_limit ||
122
+ decoded.bytesize +
123
+ chunk.bytesize > @responses_decoded_limit
121
124
  raise ::Protocol::WebSocket::ProtocolError, "Responses WebSocket message exceeds configured limit."
122
125
  end
123
126
 
@@ -129,7 +132,14 @@ module OpenAI
129
132
  raise ::Protocol::WebSocket::ProtocolError, "Responses WebSocket message exceeds configured limit."
130
133
  end
131
134
 
132
- decoded << inflater.flush_next_out
135
+ # Finished streams can put unused input in this buffer without
136
+ # increasing total_out. Count every byte before collecting it.
137
+ tail = inflater.flush_next_out
138
+ if decoded.bytesize + tail.bytesize > @responses_decoded_limit
139
+ raise ::Protocol::WebSocket::ProtocolError, "Responses WebSocket message exceeds configured limit."
140
+ end
141
+
142
+ decoded << tail
133
143
  rescue ::Protocol::WebSocket::ProtocolError
134
144
  @responses_bounded_framer.abort
135
145
  inflater.close
@@ -823,6 +823,24 @@ module OpenAI
823
823
  end
824
824
  end
825
825
 
826
+ # @api private
827
+ # Consume binary response chunks without buffering a complete body.
828
+ def request_streaming_body(req, on_response = nil)
829
+ if req[:options].to_h[:include_raw_body]
830
+ raise ArgumentError, "include_raw_body is not supported for streaming downloads"
831
+ end
832
+
833
+ _url, response, log_context = perform_request(req)
834
+ finish_request(log_context, response) do
835
+ on_response&.call
836
+ response.body.each { |chunk| yield chunk }
837
+ nil
838
+ end
839
+
840
+ ensure
841
+ OpenAI::Internal::Util.close_fused!(response.body) if response
842
+ end
843
+
826
844
  # @api private
827
845
  #
828
846
  # @param req [Hash{Symbol=>Object}]
@@ -94,7 +94,7 @@ module OpenAI
94
94
  return value
95
95
  end
96
96
 
97
- target = item_type
97
+ target = OpenAI::Internal::Type::Converter.resolve_type(@item_type_fn, state: state)
98
98
  exactness[:yes] += 1
99
99
  previous_error = state.fetch(:error)
100
100
  last_error = nil
@@ -28,9 +28,12 @@ module OpenAI
28
28
  # @api private
29
29
  #
30
30
  # @return [Hash{Symbol=>Hash{Symbol=>Object}}]
31
- def fields
31
+ def fields(state: nil)
32
32
  known_fields.transform_values do |field|
33
- {**field.except(:type_fn), type: field.fetch(:type_fn).call}
33
+ {
34
+ **field.except(:type_fn),
35
+ type: OpenAI::Internal::Type::Converter.resolve_type(field.fetch(:type_fn), state: state)
36
+ }
34
37
  end
35
38
  end
36
39
 
@@ -297,7 +300,7 @@ module OpenAI
297
300
  data = instance.to_h
298
301
  viability = instance.instance_variable_get(:@coerced)
299
302
 
300
- fields.each do |name, field|
303
+ fields(state: state).each do |name, field|
301
304
  mode, required, target = field.fetch_values(:mode, :required, :type)
302
305
  api_name, nilable, const = field.fetch_values(:api_name, :nilable, :const)
303
306
  src_name = state.fetch(:translate_names) ? api_name : name
@@ -135,17 +135,26 @@ module OpenAI
135
135
  # @param request_only [Boolean] Exclude fields omitted by the request serializer.
136
136
  #
137
137
  # @return [Hash{Symbol=>Object}]
138
- def new_coerce_state(translate_names: true, request_only: false)
138
+ def new_coerce_state(translate_names: true, request_only: false, memoize: false)
139
139
  {
140
140
  translate_names: translate_names,
141
141
  request_only: request_only,
142
142
  strictness: true,
143
143
  exactness: {yes: 0, no: 0, maybe: 0},
144
144
  error: nil,
145
- branched: 0
145
+ branched: 0,
146
+ memo: memoize ? {}.compare_by_identity : nil
146
147
  }
147
148
  end
148
149
 
150
+ # @api private
151
+ # Resolve lazy schema types once within an opted-in parse.
152
+ def resolve_type(type_fn, state: nil)
153
+ return type_fn.call unless state && state[:memo]
154
+ cache = state[:memo][:resolved_types] ||= {}.compare_by_identity
155
+ cache.fetch(type_fn) { cache[type_fn] = type_fn.call }
156
+ end
157
+
149
158
  # @api private
150
159
  #
151
160
  # Based on `target`, transform `value` into `target`, to the extent possible:
@@ -191,6 +200,12 @@ module OpenAI
191
200
  #
192
201
  # @return [Object]
193
202
  def coerce(target, value, state: OpenAI::Internal::Type::Converter.new_coerce_state)
203
+ if state[:memo] &&
204
+ target.is_a?(OpenAI::Internal::Type::Converter) &&
205
+ (value.is_a?(Hash) || value.is_a?(Array))
206
+ return coerce_cached(target, value, state: state)
207
+ end
208
+
194
209
  exactness = state.fetch(:exactness)
195
210
 
196
211
  case target
@@ -248,6 +263,12 @@ module OpenAI
248
263
  state[:error] = TypeError.new("#{value.class} can't be coerced into #{String}")
249
264
  end
250
265
 
266
+ in -> { _1 <= Symbol }
267
+ if value.is_a?(String)
268
+ exactness[:yes] += 1
269
+ return value.to_sym
270
+ end
271
+
251
272
  in -> { _1 <= Date || _1 <= Time }
252
273
  Kernel.then do
253
274
  return target.parse(value).tap { exactness[:yes] += 1 }
@@ -282,6 +303,31 @@ module OpenAI
282
303
  value
283
304
  end
284
305
 
306
+ # @api private
307
+ # Reuse converter/value pairs within one parse, including recursive collections.
308
+ # Identity keys avoid recursively hashing model and converter definitions.
309
+ def coerce_cached(target, value, state:)
310
+ converters = state.fetch(:memo)
311
+ values = converters[target] ||= {}.compare_by_identity
312
+ outcomes = values[value] ||= {}
313
+ key = state.values_at(:translate_names, :request_only, :strictness, :error)
314
+ exactness = state.fetch(:exactness)
315
+ if (cached = outcomes[key])
316
+ parsed, delta, error, branched = cached
317
+ delta.each { exactness[_1] += _2 }
318
+ state[:error] = error
319
+ state[:branched] += branched
320
+ return parsed
321
+ end
322
+
323
+ before = exactness.dup
324
+ branched = state.fetch(:branched)
325
+ parsed = target.coerce(value, state: state)
326
+ delta = exactness.to_h { |name, count| [name, count - before.fetch(name)] }
327
+ outcomes[key] = [parsed, delta, state[:error], state[:branched] - branched]
328
+ parsed
329
+ end
330
+
285
331
  # @api private
286
332
  #
287
333
  # Coerces a value while isolating its error from sibling coercions.
@@ -350,7 +396,8 @@ module OpenAI
350
396
  strictness: T::Boolean,
351
397
  exactness: {yes: Integer, no: Integer, maybe: Integer},
352
398
  error: T.nilable(StandardError),
353
- branched: Integer
399
+ branched: Integer,
400
+ memo: T.nilable(T::Hash[T.untyped, T.untyped])
354
401
  }
355
402
  end
356
403
  end
@@ -97,7 +97,7 @@ module OpenAI
97
97
  return value
98
98
  end
99
99
 
100
- target = item_type
100
+ target = OpenAI::Internal::Type::Converter.resolve_type(@item_type_fn, state: state)
101
101
  exactness[:yes] += 1
102
102
  previous_error = state.fetch(:error)
103
103
  last_error = nil