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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad027591f42a7f1aed3f0ac973b22b81f8d4a1171e30d311845b8ece12b0cc30
4
- data.tar.gz: 70db67b389fe4053242e587dad3ea8a7fb9477f160278e213c4206cbe03d6289
3
+ metadata.gz: 490515bf12013414fe683641c2357dc16e15a26354bbc9436fef0dc7b901589f
4
+ data.tar.gz: db7eeb1fb9efa3745ac7d8bc37789450aac6212eee254d5a25d6d63c1f59fcfa
5
5
  SHA512:
6
- metadata.gz: e14a2a0287715824fe9271eed406677661ac595d8b3851f2e24480b3c6d365362f067ad8e207e99cff2246cedd247f6d82a7007a487a37bd8c381dce3b8479d3
7
- data.tar.gz: 647c42ecf4d655d2148e23c42253d057ffc569189541fd58333144b73663c3b4cf3adf9d9cfb51b0d98123cdabcd9c8458c2d3f8c169347e3e70a0bcc1978571
6
+ metadata.gz: b09e97bdd9d0589478bb3d68a31212e431e0ff1b691de4c54849fd993222ed14b9116417ddf31c1e7b9050478422305c86e314b2f28b8b587ddc81be13481250
7
+ data.tar.gz: 78664445a99113c083afbc48295d7e6a11bfcbdef798b69eb97e9283c8f77fc4477367895bf583a9c390af53925dd1fcb323c860e79c127b12edcc13b442c323
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.98.0](https://github.com/openai/openai-ruby/compare/v0.97.0...v0.98.0) (2026-10-01)
4
+
5
+
6
+ ### Features
7
+
8
+ * **agents:** [1/n] parse typed output into Ruby models ([#796](https://github.com/openai/openai-ruby/issues/796)) ([49b8b70](https://github.com/openai/openai-ruby/commit/49b8b70763db318134f0eedd82bd91a7bd269943))
9
+ * **agents:** prepare hosted files and download result artifacts ([#798](https://github.com/openai/openai-ruby/issues/798)) ([9853389](https://github.com/openai/openai-ruby/commit/9853389e6c5a428c836d8ac2e4af991519e9630d))
10
+ * **api:** Add session traces and translation secrets ([#789](https://github.com/openai/openai-ruby/issues/789)) ([79e3e0c](https://github.com/openai/openai-ruby/commit/79e3e0c550e3630253bd79423a04fb108cbaa642))
11
+ * bind typed application actions to beta agent tools ([#795](https://github.com/openai/openai-ruby/issues/795)) ([c8cab4a](https://github.com/openai/openai-ruby/commit/c8cab4aa699f8563946991f6b949f4ae73b212d7))
12
+ * collect final output from beta Agents streams ([#793](https://github.com/openai/openai-ruby/issues/793)) ([35aaa3a](https://github.com/openai/openai-ruby/commit/35aaa3a881a13583bc28e25240bc86e2312ae647))
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * **api:** allow original image detail in Chat Completions ([#794](https://github.com/openai/openai-ruby/issues/794)) ([8bb025d](https://github.com/openai/openai-ruby/commit/8bb025dac3f0336309a6e12764d7520959fda0fd))
18
+ * **api:** correct the eval run cancellation endpoint ([#791](https://github.com/openai/openai-ruby/issues/791)) ([6a99f63](https://github.com/openai/openai-ruby/commit/6a99f632f0c3a9009b90b671380a8840a4c68cd1))
19
+ * enforce decoded WebSocket budget on inflater tails ([#792](https://github.com/openai/openai-ruby/issues/792)) ([2acfaf8](https://github.com/openai/openai-ruby/commit/2acfaf87c702b4ddc61ff7506f857c98ef8aa27d))
20
+ * retire canceled Responses and Live websocket writes ([#787](https://github.com/openai/openai-ruby/issues/787)) ([de62191](https://github.com/openai/openai-ruby/commit/de621915ed28f5dcc8434a61e83a976a103f2fa6))
21
+
22
+
23
+ ### Chores
24
+
25
+ * **api:** retain WebRTC Live session transport types ([#790](https://github.com/openai/openai-ruby/issues/790)) ([1f15936](https://github.com/openai/openai-ruby/commit/1f159369caa98861eb45ba6c614635b9cf39a16f))
26
+
3
27
  ## [0.97.0](https://github.com/openai/openai-ruby/compare/v0.96.0...v0.97.0) (2026-09-29)
4
28
 
5
29
 
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.97.0"
18
+ gem "openai", "~> 0.98.0"
19
19
  ```
20
20
 
21
21
  <!-- x-release-please-end -->
@@ -50,6 +50,81 @@ stream.each do |event|
50
50
  end
51
51
  ```
52
52
 
53
+ ### Agents final output (beta)
54
+
55
+ Collect final output from an Agents creation stream:
56
+
57
+ ```ruby
58
+ stream = openai.beta.agents.sessions.create_streaming(
59
+ agent: {model: "gpt-5.2", instructions: "Explain the supplied policy clearly."},
60
+ environment: {type: :none},
61
+ input: "Explain this policy: ..."
62
+ )
63
+ result = stream.get_final_result
64
+ puts(result.output_text)
65
+ ```
66
+
67
+ Use the same getter for follow-ups; to display progress first, call
68
+ `stream.with_result_collection` before iterating:
69
+
70
+ ```ruby
71
+ openai.beta.agents.sessions.stream(result.session_id, input: "Give an example.") do |stream|
72
+ puts(stream.get_final_result.output_text)
73
+ end
74
+ ```
75
+
76
+ The beta result includes `turn` metadata and final `messages`; unsuccessful or
77
+ incomplete collection raises `OpenAI::Helpers::Beta::Agents::ResultError`.
78
+
79
+ ### Typed Agents output (beta)
80
+
81
+ Use an `OpenAI::BaseModel` with `output_type` to configure and parse a structured answer:
82
+
83
+ ```ruby
84
+ class Report < OpenAI::BaseModel
85
+ required :summary, String
86
+ required :findings, OpenAI::ArrayOf[String]
87
+ end
88
+
89
+ result = openai.beta.agents.sessions.create_streaming(
90
+ agent: {model: "gpt-5.2"}, environment: {type: :none},
91
+ input: "Summarize these notes: ...", output_type: Report
92
+ ).get_final_result
93
+ puts(result.output_parsed.summary)
94
+
95
+ openai.beta.agents.sessions.stream(result.session_id, input: "Update the report.", output_type: Report) do |stream|
96
+ puts(stream.get_final_result.output_parsed.summary)
97
+ end
98
+ ```
99
+
100
+ Parsing follows the SDK’s existing `BaseModel` conventions. Each output-text item is parsed separately; `output_parsed` returns the first. Follow-up `output_type` only parses the answer; it does not change the session's schema.
101
+ `OutputParseError#raw_result` preserves the completed answer if parsing fails.
102
+
103
+ ### Hosted files and artifacts
104
+
105
+ Prepare selected local files before creating a hosted session, or use `upload` to stage one in an existing environment. Keep `upload_ids` for explicit cleanup through the Files API.
106
+
107
+ ```ruby
108
+ prepared = openai.beta.agents.environments.files.prepare({
109
+ "/workspace/source.pdf" => Pathname("source.pdf")
110
+ })
111
+ stream = openai.beta.agents.sessions.create_streaming(
112
+ agent: {model: "gpt-6-astra"},
113
+ environment: {type: :openai_hosted, files: prepared.files},
114
+ input: "Read source.pdf and write /workspace/outputs/report.md"
115
+ )
116
+ result = stream.get_final_result
117
+ artifacts = openai.beta.agents.sessions.artifacts.for_result(result)
118
+ artifacts.download(path: "/workspace/outputs/report.md", to: Pathname("report.md"))
119
+
120
+ # Or keep the downloaded bytes in memory:
121
+ buffer = StringIO.new
122
+ artifacts.download(path: "/workspace/outputs/report.md", to: buffer)
123
+ puts(buffer.string)
124
+ ```
125
+
126
+ `prepare_directory(directory, destination:, include:)` stages an explicitly selected snapshot. Local path and directory helpers assume stable, application-owned source paths; they are not a filesystem sandbox for arbitrary user-supplied paths or hostile local writers. Downloads select the exact result turn and stream to an application-owned local path or the writer you supply. An interrupted transfer may leave partial contents.
127
+
53
128
  ### Local audio
54
129
 
55
130
  Record a clip and play generated speech with optional FFmpeg/FFplay helpers.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "tools"
4
+ require_relative "../beta/agents/result"
4
5
 
5
6
  module OpenAI
6
7
  module Helpers
@@ -13,13 +14,26 @@ module OpenAI
13
14
  include Enumerable
14
15
 
15
16
  # @api private
16
- def initialize(sessions:, session_id:, input:, tool_handlers: {}, idempotency_key: nil, request_options: {})
17
+ def initialize(
18
+ sessions:,
19
+ session_id:,
20
+ input:,
21
+ tool_handlers: {},
22
+ idempotency_key: nil,
23
+ output_type: nil,
24
+ request_options: {}
25
+ )
17
26
  messages = input.is_a?(String) ? [{role: :user, content: [{type: :input_text, text: input}]}] : input.to_a
18
27
  raise ArgumentError, "input must not be empty" if input == "" || messages.empty?
19
28
 
29
+ @output_parser = OpenAI::Helpers::Beta::Agents::OutputParser.new(output_type) unless output_type.nil?
20
30
  @sessions = sessions
21
31
  @session_id = session_id
22
32
  @handlers = tool_handlers.to_h.dup
33
+ @collector = OpenAI::Helpers::Beta::Agents::ResultCollector.new(
34
+ session_id: session_id,
35
+ handler_names: @handlers.keys
36
+ )
23
37
  @options = request_options.to_h.dup
24
38
  headers = {"OpenAI-Beta" => "agents=v1"}.merge(@options[:extra_headers].to_h)
25
39
  options_key = @options.delete(:idempotency_key)
@@ -84,6 +98,36 @@ module OpenAI
84
98
  self
85
99
  end
86
100
 
101
+ # @beta
102
+ # Consume the remaining events and handlers, then return this turn's final
103
+ # answer. Failed, blocked, or incomplete observation raises ResultError.
104
+ # @return [OpenAI::Helpers::Beta::Agents::TurnResult]
105
+ def get_final_result
106
+ with_result_collection
107
+ raise @result_error if @result_error
108
+ begin
109
+ each { |_event| break if @collector.stopped? } unless @collector.stopped?
110
+ rescue StandardError => error
111
+ @collector.observe_error(error)
112
+ end
113
+
114
+ result = @collector.result
115
+ @output_parser ? @output_parser.parse(result) : result
116
+ rescue OpenAI::Helpers::Beta::Agents::ResultError => error
117
+ @result_error = error
118
+ raise
119
+ ensure
120
+ close
121
+ end
122
+
123
+ # @beta
124
+ # Enable result collection before iterating to display progress.
125
+ # @return [self]
126
+ def with_result_collection
127
+ @collector.enable
128
+ self
129
+ end
130
+
87
131
  # Close the event connection without cancelling the backend turn.
88
132
  # @return [void]
89
133
  def close
@@ -99,7 +143,14 @@ module OpenAI
99
143
  Enumerator.new do |yielder|
100
144
  begin
101
145
  @raw_stream.each do |event|
102
- next unless accept?(event)
146
+ known = event.is_a?(OpenAI::Internal::Type::BaseModel)
147
+ next if known && !accept?(event)
148
+
149
+ @collector.observe(event)
150
+ unless known
151
+ yielder << event
152
+ next
153
+ end
103
154
 
104
155
  terminal = event.type == :"agent.session.failed" ||
105
156
  (event.type == :"agent.session.idle" && @turn_ended)
@@ -112,6 +163,9 @@ module OpenAI
112
163
  end
113
164
 
114
165
  raise RuntimeError, "Session event stream ended before the turn reached idle or failed" unless @closed
166
+ rescue StandardError => error
167
+ @collector.observe_error(error)
168
+ raise
115
169
  ensure
116
170
  close
117
171
  end
@@ -134,7 +188,10 @@ module OpenAI
134
188
  end
135
189
 
136
190
  def prepare_call(event)
137
- return unless event.type == :"agent.session.turn.item.added" && event.item.type == :function_call
191
+ unless event.type == :"agent.session.turn.item.added" &&
192
+ event.item.is_a?(OpenAI::Models::Beta::AgentFunctionCallItem)
193
+ return
194
+ end
138
195
 
139
196
  call = event.item
140
197
  key = [call.turn_id.dup, call.call_id.dup]
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenAI
4
+ module Helpers
5
+ module Beta
6
+ module Agents
7
+ # Artifact downloads scoped to one completed result (beta).
8
+ class ResultArtifacts
9
+ # @api private
10
+ def initialize(artifacts:, client:, result:)
11
+ @artifacts = artifacts
12
+ @client = client
13
+ @session_id = result.session_id.dup.freeze
14
+ @turn_id = result.turn_id.dup.freeze
15
+ end
16
+
17
+ # Download the exact turn/path to a caller-selected local path or writer.
18
+ # Returns the immutable artifact record that supplied the bytes.
19
+ def download(path:, to:, request_options: {})
20
+ request_options = request_options
21
+ .to_h
22
+ .merge(extra_headers: {"OpenAI-Beta" => "agents=v1"}.merge(request_options.to_h[:extra_headers].to_h))
23
+ match = nil
24
+ @artifacts
25
+ .list(@session_id, order: :asc, limit: 100, request_options: request_options)
26
+ .auto_paging_each do |artifact|
27
+ next unless artifact.turn_id == @turn_id && artifact.path == path
28
+ raise ArgumentError, "More than one artifact matches the result turn and path" if match
29
+ match = artifact
30
+ end
31
+
32
+ raise ArgumentError, "No artifact matches the result turn and path" unless match
33
+
34
+ transfer(match, to, request_options)
35
+
36
+ match
37
+ end
38
+
39
+ private
40
+
41
+ def transfer(artifact, destination, options)
42
+ writer = nil
43
+ opened = lambda do
44
+ writer = if destination.is_a?(String) || destination.is_a?(Pathname)
45
+ File.open(destination, "wb")
46
+ else
47
+ destination
48
+ end
49
+ end
50
+
51
+ request = {
52
+ method: :get,
53
+ path: ["agents/sessions/%1$s/artifacts/%2$s/content", @session_id, artifact.id],
54
+ headers: {"accept" => "application/octet-stream"},
55
+ security: {bearer_auth: true},
56
+ options: options
57
+ }
58
+ received = 0
59
+ @client.request_streaming_body(request, opened) do |chunk|
60
+ writer.write(chunk)
61
+ received += chunk.bytesize
62
+ end
63
+
64
+ raise IOError, "Artifact content size does not match its metadata" unless received == artifact.size_bytes
65
+ ensure
66
+ writer.close if writer && !writer.equal?(destination)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "result"
4
+
5
+ module OpenAI
6
+ module Helpers
7
+ module Beta
8
+ module Agents
9
+ # A typed creation event stream with beta turn-result collection.
10
+ # Existing event iteration and HTTP metadata access remain available.
11
+ class CreationStream < OpenAI::Internal::Stream
12
+ # Requires initial input in create_streaming.
13
+ # Consume events through the initial root turn's terminal/idle boundary.
14
+ # Calling this again returns the same result without making requests.
15
+ # @return [OpenAI::Helpers::Beta::Agents::TurnResult]
16
+ def get_final_result
17
+ with_result_collection
18
+ raise @result_error if @result_error
19
+ begin
20
+ @iterator.next until @collector.stopped?
21
+ rescue StopIteration
22
+ # EOF alone does not establish a completed turn.
23
+ rescue StandardError => error
24
+ @collector.observe_error(error)
25
+ end
26
+
27
+ result = @collector.result
28
+ @output_parser ? @output_parser.parse(result) : result
29
+ rescue ResultError => error
30
+ @result_error = error
31
+ raise
32
+ ensure
33
+ close
34
+ end
35
+
36
+ # Enable result collection before iterating to display progress.
37
+ # @return [self]
38
+ def with_result_collection
39
+ @collector.enable
40
+ self
41
+ end
42
+
43
+ # @api private
44
+ def configure_output_parser(parser)
45
+ @output_parser = parser
46
+ self
47
+ end
48
+
49
+ private
50
+
51
+ def iterator
52
+ @collector = ResultCollector.new
53
+ source = super
54
+ @iterator = OpenAI::Internal::Util.chain_fused(source) do |yielder|
55
+ source.each do |event|
56
+ @collector.observe(event)
57
+ yielder << event
58
+ end
59
+
60
+ rescue StandardError => error
61
+ @collector.observe_error(error)
62
+ raise
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+
5
+ module OpenAI
6
+ module Helpers
7
+ module Beta
8
+ module Agents
9
+ # Uploaded file references ready for a hosted environment (beta).
10
+ # Delete upload_ids explicitly through the Files API when no longer needed.
11
+ class PreparedFiles
12
+ attr_reader :files, :upload_ids
13
+
14
+ # @api private
15
+ def initialize(files)
16
+ @files = files
17
+ .map do |file|
18
+ OpenAI::Models::Beta::HostedEnvironmentFileParam::FileID
19
+ .new(
20
+ file_id: file.fetch(:file_id).dup.freeze,
21
+ path: file.fetch(:path).dup.freeze
22
+ )
23
+ .freeze
24
+ end
25
+ .freeze
26
+ @upload_ids = @files.map(&:file_id).freeze
27
+ end
28
+ end
29
+
30
+ # A preparation failed after uploads may have been created (beta).
31
+ class FilePreparationError < OpenAI::Errors::Error
32
+ attr_reader :prepared
33
+
34
+ # @api private
35
+ def initialize(prepared:)
36
+ @prepared = prepared
37
+ super("Agent file preparation failed; prepared contains the uploaded file references")
38
+ end
39
+ end
40
+
41
+ # @api private
42
+ class FilePreparation
43
+ def initialize(client:, environment_files:)
44
+ @client = client
45
+ @environment_files = environment_files
46
+ end
47
+
48
+ def prepare(files, request_options: {})
49
+ prepare_sources(files, request_options: request_options)
50
+ end
51
+
52
+ private def prepare_sources(files, request_options:, directory_root: nil)
53
+ entries = files.map do |destination, source|
54
+ path = local_file(source)
55
+ stat = path.lstat
56
+ check_directory_source(path, directory_root) if directory_root
57
+ [destination_path(destination), path, stat]
58
+ end
59
+
60
+ if entries.length > 1 && explicit_idempotency_key?(request_options)
61
+ raise ArgumentError, "Use separate uploads when supplying an Idempotency-Key for multiple files"
62
+ end
63
+
64
+ paths = entries.map(&:first).sort_by { _1.split("/") }
65
+ if paths.each_cons(2).any? { |a, b| a == b || b.start_with?("#{a}/") }
66
+ raise ArgumentError, "Destination file paths must not collide"
67
+ end
68
+
69
+ upload_options = entries.one? ? request_options_scope(request_options).child("file-upload") : request_options
70
+ uploaded = []
71
+ begin
72
+ Dir.mktmpdir("openai-agent-files") do |directory|
73
+ snapshots = entries.each_with_index.map do |(destination, source, stat), index|
74
+ target = Pathname(directory).join(index.to_s)
75
+ snapshot(source, target, stat, directory_root)
76
+ [destination, OpenAI::FilePart.new(target, filename: source.basename.to_s)]
77
+ end
78
+
79
+ snapshots.each do |destination, source|
80
+ file = @client.files.create(file: source, purpose: :user_data, request_options: upload_options)
81
+ uploaded << {type: :file_id, file_id: file.id, path: destination}
82
+ end
83
+ end
84
+
85
+ PreparedFiles.new(uploaded)
86
+ rescue StandardError
87
+ raise FilePreparationError.new(prepared: PreparedFiles.new(uploaded))
88
+ end
89
+ end
90
+
91
+ def prepare_directory(directory, destination:, include:, request_options: {})
92
+ root = Pathname(directory).expand_path
93
+ identity = root.lstat
94
+ unless identity.directory?
95
+ raise ArgumentError, "directory must be an existing directory, not a symlink"
96
+ end
97
+
98
+ canonical = root.realpath
99
+ unless [root.lstat, canonical.lstat].all? {
100
+ _1.directory? && _1.dev == identity.dev && _1.ino == identity.ino
101
+ }
102
+ raise ArgumentError, "Selected directory changed while preparing files"
103
+ end
104
+
105
+ root = canonical
106
+ directory_root = [root, identity.dev, identity.ino]
107
+ patterns = Array(include)
108
+ if patterns.empty? ||
109
+ patterns.any? { !_1.is_a?(String) || _1.start_with?("/") || _1.split("/").include?("..") }
110
+ raise ArgumentError, "include must contain relative selection patterns"
111
+ end
112
+
113
+ base = destination_path(destination, directory: true)
114
+ selected = Dir
115
+ .glob(patterns, base: root)
116
+ .map { Pathname(_1).cleanpath.to_s }
117
+ .uniq
118
+ .reject { root.join(_1).directory? && !root.join(_1).symlink? }
119
+
120
+ prepare_sources(
121
+ selected.to_h { |relative| ["#{base}/#{relative}", root.join(relative)] },
122
+ request_options: request_options,
123
+ directory_root: directory_root
124
+ )
125
+ end
126
+
127
+ def upload(environment_id, file:, path:, request_options: {})
128
+ scope = request_options_scope(request_options)
129
+ prepared = prepare({path => file}, request_options: request_options)
130
+ stage_options = scope.child("environment-file")
131
+ begin
132
+ @environment_files.create(
133
+ environment_id,
134
+ hosted_environment_file_param: prepared.files.first,
135
+ request_options: stage_options.merge(
136
+ extra_headers: {"OpenAI-Beta" => "agents=v1"}.merge(stage_options[:extra_headers].to_h)
137
+ )
138
+ )
139
+ prepared
140
+ rescue StandardError
141
+ raise FilePreparationError.new(prepared: prepared)
142
+ end
143
+ end
144
+
145
+ private
146
+
147
+ def request_options_scope(request_options)
148
+ options = request_options.to_h
149
+ headers = OpenAI::Internal::Util.normalized_headers(@client.headers, options[:extra_headers].to_h)
150
+ if headers.key?("idempotency-key")
151
+ options = options.merge(
152
+ extra_headers: options[:extra_headers].to_h.merge("idempotency-key" => headers["idempotency-key"])
153
+ )
154
+ end
155
+
156
+ OpenAI::Internal::RequestOptionsScope.new(options)
157
+ end
158
+
159
+ def snapshot(source, target, expected, directory_root)
160
+ flags = File::RDONLY | File::NONBLOCK | File::BINARY
161
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
162
+ File.open(source, flags) do |input|
163
+ unless input.stat.file? && file_identity(input.stat) == file_identity(expected)
164
+ raise ArgumentError, "Source changed after file preflight"
165
+ end
166
+
167
+ check_directory_source(source, directory_root) if directory_root
168
+ copied = File.open(target, "wb") { |output| IO.copy_stream(input, output, expected.size + 1) }
169
+ unless copied == expected.size && file_identity(input.stat) == file_identity(expected)
170
+ raise ArgumentError, "Source changed while preparing files"
171
+ end
172
+ end
173
+ end
174
+
175
+ def check_directory_source(source, boundary)
176
+ root, device, inode = boundary
177
+ current = root.lstat
178
+ unless current.directory? &&
179
+ current.dev == device &&
180
+ current.ino == inode &&
181
+ source.to_s.start_with?(File.join(root, "")) &&
182
+ source.realpath == source
183
+ raise ArgumentError, "Selected directory changed or contains a symlink"
184
+ end
185
+ end
186
+
187
+ def file_identity(stat) = [stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime]
188
+
189
+ def destination_path(path, directory: false)
190
+ if path.is_a?(String)
191
+ path = path.encoding == Encoding::BINARY ? path.dup.force_encoding(Encoding::UTF_8) : path.encode(
192
+ Encoding::UTF_8
193
+ )
194
+ raise ArgumentError, "Destination must be valid UTF-8 text" unless path.valid_encoding?
195
+ end
196
+
197
+ parts = path.is_a?(String) ? path.split("/", -1) : []
198
+ relative = parts.drop(2)
199
+ root = relative.first
200
+ unless path.is_a?(String) &&
201
+ !path.include?("\0") &&
202
+ !path.include?("\\") &&
203
+ parts.first == "" &&
204
+ parts[1] == "workspace" &&
205
+ relative.none? { ["", ".", ".."].include?(_1) } &&
206
+ (directory || !relative.empty?) &&
207
+ ![".codex", ".managed-agents"].include?(root) &&
208
+ !root.to_s.start_with?(".managed-agents-") &&
209
+ (directory || relative != ["outputs"])
210
+ raise ArgumentError, "Destination must be a normalized file path under /workspace"
211
+ end
212
+
213
+ path
214
+ rescue EncodingError
215
+ raise ArgumentError, "Destination must be valid UTF-8 text"
216
+ end
217
+
218
+ def local_file(source)
219
+ path = Pathname(source).expand_path
220
+ unless path.file? && !path.symlink?
221
+ raise ArgumentError, "Sources must be regular files, not symlinks"
222
+ end
223
+
224
+ path
225
+ end
226
+
227
+ def explicit_idempotency_key?(options)
228
+ opts = options.to_h
229
+ headers = OpenAI::Internal::Util.normalized_headers(@client.headers, opts[:extra_headers].to_h)
230
+ headers.key?("idempotency-key") ? headers["idempotency-key"] : opts[:idempotency_key]
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,65 @@
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
+ # Beta binding between an application's argument model and local callback.
10
+ class FunctionTool
11
+ # @return [String]
12
+ attr_reader :name
13
+
14
+ # @param name [String]
15
+ # @param arguments [Class<OpenAI::BaseModel>]
16
+ # @param description [String]
17
+ # @yieldparam arguments [OpenAI::BaseModel]
18
+ def initialize(name:, arguments:, description: "", &handler)
19
+ ModelAdapter.validate!(arguments, parameter: "arguments")
20
+
21
+ raise ArgumentError, "a callback is required" unless handler
22
+ raise ArgumentError, "name must be a nonempty string" unless name.is_a?(String) && !name.empty?
23
+
24
+ @name = name.dup.freeze
25
+ @arguments = arguments
26
+ @handler = handler
27
+ @definition = JSON
28
+ .generate(
29
+ type: :function,
30
+ name: @name,
31
+ parameters: arguments.to_json_schema,
32
+ description: description
33
+ )
34
+ .freeze
35
+ end
36
+
37
+ # Supply this definition in the agent's tools at creation.
38
+ # @return [Hash{Symbol=>Object}]
39
+ def definition = JSON.parse(@definition, symbolize_names: true)
40
+
41
+ # Pass these local bindings to sessions.stream(tool_handlers: ...).
42
+ # @return [Hash{String=>Proc}]
43
+ def handlers = {name => method(:call).to_proc}
44
+
45
+ # Parse using the SDK's BaseModel conventions, then invoke the callback.
46
+ # This is not JSON Schema validation; validate application constraints
47
+ # and authorization in the callback before performing side effects.
48
+ # @param arguments [Hash{String=>Object}, String]
49
+ # @return [Object]
50
+ def call(arguments)
51
+ values = JSON.parse(arguments.is_a?(String) ? arguments : JSON.generate(arguments), symbolize_names: true)
52
+ raise ArgumentError, "Tool arguments must be a JSON object" unless values.is_a?(Hash)
53
+ parsed = ModelAdapter.coerce(@arguments, values)
54
+ unless parsed
55
+ raise ArgumentError, "Tool arguments cannot be parsed into the argument model"
56
+ end
57
+
58
+ @handler.call(parsed)
59
+ end
60
+
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end