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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +76 -1
- data/lib/openai/helpers/agents/session_stream.rb +60 -3
- data/lib/openai/helpers/beta/agents/artifacts.rb +72 -0
- data/lib/openai/helpers/beta/agents/creation_stream.rb +69 -0
- data/lib/openai/helpers/beta/agents/files.rb +236 -0
- data/lib/openai/helpers/beta/agents/function_tool.rb +65 -0
- data/lib/openai/helpers/beta/agents/model_adapter.rb +27 -0
- data/lib/openai/helpers/beta/agents/result.rb +170 -0
- data/lib/openai/helpers/beta/agents/typed_output.rb +191 -0
- data/lib/openai/helpers/live/websocket.rb +7 -1
- data/lib/openai/helpers/realtime/conversation_item_variant_resolver.rb +1 -1
- data/lib/openai/helpers/responses_websocket/connection.rb +7 -1
- data/lib/openai/helpers/responses_websocket/session.rb +5 -1
- data/lib/openai/helpers/responses_websocket/transports/async_websocket.rb +12 -2
- data/lib/openai/internal/transport/base_client.rb +18 -0
- data/lib/openai/internal/type/array_of.rb +1 -1
- data/lib/openai/internal/type/base_model.rb +6 -3
- data/lib/openai/internal/type/converter.rb +50 -3
- data/lib/openai/internal/type/hash_of.rb +1 -1
- data/lib/openai/internal/type/union.rb +6 -6
- data/lib/openai/models/beta/agents/sessions/session_trace.rb +64 -0
- data/lib/openai/models/beta/agents/sessions/trace_list_params.rb +68 -0
- data/lib/openai/models/beta/agents/vaults/credential.rb +8 -4
- data/lib/openai/models/beta/beta_response_output_text.rb +2 -2
- data/lib/openai/models/beta/beta_response_output_text_annotation_added_event.rb +2 -2
- data/lib/openai/models/beta/environment.rb +33 -1
- data/lib/openai/models/beta/environment_param.rb +24 -1
- data/lib/openai/models/beta/session_turn_error.rb +3 -0
- data/lib/openai/models/chat/chat_completion_content_part_image.rb +1 -0
- data/lib/openai/models/live/fork_server_event.rb +14 -3
- data/lib/openai/models/live/server_event.rb +14 -3
- data/lib/openai/models/realtime/translations/client_secret_create_params.rb +28 -0
- data/lib/openai/models/responses/response_output_text.rb +2 -2
- data/lib/openai/models/responses/response_output_text_annotation_added_event.rb +2 -2
- data/lib/openai/resources/beta/agents/environments/files.rb +31 -0
- data/lib/openai/resources/beta/agents/sessions/artifacts.rb +8 -0
- data/lib/openai/resources/beta/agents/sessions/events.rb +6 -5
- data/lib/openai/resources/beta/agents/sessions/traces.rb +58 -0
- data/lib/openai/resources/beta/agents/sessions.rb +18 -5
- data/lib/openai/resources/evals/runs.rb +1 -1
- data/lib/openai/resources/realtime/translations/client_secrets.rb +58 -0
- data/lib/openai/resources/realtime/translations.rb +20 -0
- data/lib/openai/resources/realtime.rb +4 -0
- data/lib/openai/version.rb +1 -1
- data/lib/openai.rb +9 -1
- data/rbi/openai/helpers/agents.rbi +20 -1
- data/rbi/openai/helpers/beta/agents/artifacts.rbi +29 -0
- data/rbi/openai/helpers/beta/agents/files.rbi +65 -0
- data/rbi/openai/helpers/beta/agents/function_tool.rbi +40 -0
- data/rbi/openai/helpers/beta/agents/model_adapter.rbi +22 -0
- data/rbi/openai/helpers/beta/agents.rbi +146 -0
- data/rbi/openai/internal/transport/base_client.rbi +12 -0
- data/rbi/openai/internal/type/base_model.rbi +2 -2
- data/rbi/openai/internal/type/converter.rbi +21 -3
- data/rbi/openai/internal/type/union.rbi +6 -2
- data/rbi/openai/models/beta/agents/sessions/session_trace.rbi +97 -0
- data/rbi/openai/models/beta/agents/sessions/trace_list_params.rbi +121 -0
- data/rbi/openai/models/beta/agents/vaults/credential.rbi +5 -5
- data/rbi/openai/models/beta/beta_response_output_text.rbi +2 -2
- data/rbi/openai/models/beta/beta_response_output_text_annotation_added_event.rbi +2 -2
- data/rbi/openai/models/beta/environment.rbi +29 -0
- data/rbi/openai/models/beta/environment_param.rbi +31 -0
- data/rbi/openai/models/beta/session_turn_error.rbi +3 -0
- data/rbi/openai/models/chat/chat_completion_content_part_image.rbi +1 -0
- data/rbi/openai/models/live/fork_server_event.rbi +14 -2
- data/rbi/openai/models/live/server_event.rbi +14 -2
- data/rbi/openai/models/realtime/translations/client_secret_create_params.rbi +68 -0
- data/rbi/openai/models/responses/response_output_text.rbi +2 -2
- data/rbi/openai/models/responses/response_output_text_annotation_added_event.rbi +2 -2
- data/rbi/openai/resources/beta/agents/environments/files.rbi +31 -0
- data/rbi/openai/resources/beta/agents/sessions/artifacts.rbi +8 -0
- data/rbi/openai/resources/beta/agents/sessions/events.rbi +6 -5
- data/rbi/openai/resources/beta/agents/sessions/traces.rbi +54 -0
- data/rbi/openai/resources/beta/agents/sessions.rbi +6 -1
- data/rbi/openai/resources/realtime/translations/client_secrets.rbi +54 -0
- data/rbi/openai/resources/realtime/translations.rbi +22 -0
- data/rbi/openai/resources/realtime.rbi +3 -0
- data/sig/openai/helpers/agents.rbs +7 -0
- data/sig/openai/helpers/beta/agents/artifacts.rbs +20 -0
- data/sig/openai/helpers/beta/agents/files.rbs +41 -0
- data/sig/openai/helpers/beta/agents/function_tool.rbs +24 -0
- data/sig/openai/helpers/beta/agents/model_adapter.rbs +16 -0
- data/sig/openai/helpers/beta/agents.rbs +71 -0
- data/sig/openai/helpers/structured_output.rbs +12 -0
- data/sig/openai/internal/transport/base_client.rbs +8 -0
- data/sig/openai/internal/type/base_model.rbs +3 -1
- data/sig/openai/internal/type/converter.rbs +16 -2
- data/sig/openai/internal/type/union.rbs +2 -1
- data/sig/openai/models/beta/agents/sessions/session_trace.rbs +65 -0
- data/sig/openai/models/beta/agents/sessions/trace_list_params.rbs +81 -0
- data/sig/openai/models/beta/agents/vaults/credential.rbs +5 -5
- data/sig/openai/models/beta/beta_response_output_text.rbs +2 -2
- data/sig/openai/models/beta/beta_response_output_text_annotation_added_event.rbs +2 -2
- data/sig/openai/models/beta/environment.rbs +22 -0
- data/sig/openai/models/beta/environment_param.rbs +25 -0
- data/sig/openai/models/beta/session_turn_error.rbs +4 -0
- data/sig/openai/models/chat/chat_completion_content_part_image.rbs +2 -1
- data/sig/openai/models/live/fork_server_event.rbs +17 -2
- data/sig/openai/models/live/server_event.rbs +17 -2
- data/sig/openai/models/realtime/translations/client_secret_create_params.rbs +39 -0
- data/sig/openai/models/responses/response_output_text.rbs +2 -2
- data/sig/openai/models/responses/response_output_text_annotation_added_event.rbs +2 -2
- data/sig/openai/resources/beta/agents/environments/files.rbs +17 -0
- data/sig/openai/resources/beta/agents/sessions/artifacts.rbs +4 -0
- data/sig/openai/resources/beta/agents/sessions/traces.rbs +21 -0
- data/sig/openai/resources/beta/agents/sessions.rbs +4 -1
- data/sig/openai/resources/realtime/translations/client_secrets.rbs +17 -0
- data/sig/openai/resources/realtime/translations.rbs +11 -0
- data/sig/openai/resources/realtime.rbs +2 -0
- metadata +37 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 490515bf12013414fe683641c2357dc16e15a26354bbc9436fef0dc7b901589f
|
|
4
|
+
data.tar.gz: db7eeb1fb9efa3745ac7d8bc37789450aac6212eee254d5a25d6d63c1f59fcfa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|