openai 0.107.0 → 0.109.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/README.md +55 -1
  4. data/examples/chat/tool_runner.rb +33 -0
  5. data/lib/openai/client.rb +2 -2
  6. data/lib/openai/helpers/chat/tool_runner.rb +259 -0
  7. data/lib/openai/helpers/client_runtime.rb +11 -0
  8. data/lib/openai/helpers/realtime/recovery.rb +8 -2
  9. data/lib/openai/helpers/responses_websocket/incremental.rb +5 -1
  10. data/lib/openai/helpers/responses_websocket/session.rb +11 -4
  11. data/lib/openai/helpers/streaming/chat_completion_resource.rb +52 -0
  12. data/lib/openai/internal/provider.rb +1 -0
  13. data/lib/openai/models/admin/organization/usage_audio_speeches_response.rb +4 -4
  14. data/lib/openai/models/admin/organization/usage_audio_transcriptions_response.rb +4 -4
  15. data/lib/openai/models/admin/organization/usage_code_interpreter_sessions_response.rb +4 -4
  16. data/lib/openai/models/admin/organization/usage_completions_response.rb +4 -4
  17. data/lib/openai/models/admin/organization/usage_costs_response.rb +4 -4
  18. data/lib/openai/models/admin/organization/usage_decisions_response.rb +4 -4
  19. data/lib/openai/models/admin/organization/usage_embeddings_response.rb +4 -4
  20. data/lib/openai/models/admin/organization/usage_file_search_calls_response.rb +4 -4
  21. data/lib/openai/models/admin/organization/usage_images_response.rb +4 -4
  22. data/lib/openai/models/admin/organization/usage_moderations_response.rb +4 -4
  23. data/lib/openai/models/admin/organization/usage_vector_stores_response.rb +4 -4
  24. data/lib/openai/models/admin/organization/usage_web_search_calls_response.rb +4 -4
  25. data/lib/openai/models/audio/voice_consent.rb +58 -0
  26. data/lib/openai/models/audio/voice_consent_create_params.rb +57 -0
  27. data/lib/openai/models/audio/voice_consent_delete_params.rb +22 -0
  28. data/lib/openai/models/audio/voice_consent_deleted.rb +34 -0
  29. data/lib/openai/models/audio/voice_consent_list_params.rb +42 -0
  30. data/lib/openai/models/audio/voice_consent_retrieve_params.rb +22 -0
  31. data/lib/openai/models/audio/voice_consent_update_params.rb +32 -0
  32. data/lib/openai/providers/vertex.rb +229 -0
  33. data/lib/openai/resources/audio/voice_consents.rb +163 -0
  34. data/lib/openai/resources/audio.rb +5 -0
  35. data/lib/openai/version.rb +1 -1
  36. data/lib/openai.rb +9 -0
  37. data/rbi/openai/helpers/chat/tool_runner.rbi +74 -0
  38. data/rbi/openai/internal/provider.rbi +3 -0
  39. data/rbi/openai/models/admin/organization/usage_audio_speeches_response.rbi +4 -4
  40. data/rbi/openai/models/admin/organization/usage_audio_transcriptions_response.rbi +4 -4
  41. data/rbi/openai/models/admin/organization/usage_code_interpreter_sessions_response.rbi +4 -4
  42. data/rbi/openai/models/admin/organization/usage_completions_response.rbi +4 -4
  43. data/rbi/openai/models/admin/organization/usage_costs_response.rbi +4 -4
  44. data/rbi/openai/models/admin/organization/usage_decisions_response.rbi +4 -4
  45. data/rbi/openai/models/admin/organization/usage_embeddings_response.rbi +4 -4
  46. data/rbi/openai/models/admin/organization/usage_file_search_calls_response.rbi +4 -4
  47. data/rbi/openai/models/admin/organization/usage_images_response.rbi +4 -4
  48. data/rbi/openai/models/admin/organization/usage_moderations_response.rbi +4 -4
  49. data/rbi/openai/models/admin/organization/usage_vector_stores_response.rbi +4 -4
  50. data/rbi/openai/models/admin/organization/usage_web_search_calls_response.rbi +4 -4
  51. data/rbi/openai/models/audio/voice_consent.rbi +86 -0
  52. data/rbi/openai/models/audio/voice_consent_create_params.rbi +92 -0
  53. data/rbi/openai/models/audio/voice_consent_delete_params.rbi +53 -0
  54. data/rbi/openai/models/audio/voice_consent_deleted.rbi +62 -0
  55. data/rbi/openai/models/audio/voice_consent_list_params.rbi +78 -0
  56. data/rbi/openai/models/audio/voice_consent_retrieve_params.rbi +53 -0
  57. data/rbi/openai/models/audio/voice_consent_update_params.rbi +62 -0
  58. data/rbi/openai/models/response_format_json_schema.rbi +13 -11
  59. data/rbi/openai/providers.rbi +13 -0
  60. data/rbi/openai/resources/audio/voice_consents.rbi +108 -0
  61. data/rbi/openai/resources/audio.rbi +4 -0
  62. data/sig/openai/helpers/chat/tool_runner.rbs +48 -0
  63. data/sig/openai/internal/provider.rbs +3 -1
  64. data/sig/openai/models/admin/organization/usage_audio_speeches_response.rbs +6 -6
  65. data/sig/openai/models/admin/organization/usage_audio_transcriptions_response.rbs +6 -6
  66. data/sig/openai/models/admin/organization/usage_code_interpreter_sessions_response.rbs +6 -6
  67. data/sig/openai/models/admin/organization/usage_completions_response.rbs +6 -6
  68. data/sig/openai/models/admin/organization/usage_costs_response.rbs +6 -6
  69. data/sig/openai/models/admin/organization/usage_decisions_response.rbs +4 -4
  70. data/sig/openai/models/admin/organization/usage_embeddings_response.rbs +6 -6
  71. data/sig/openai/models/admin/organization/usage_file_search_calls_response.rbs +6 -6
  72. data/sig/openai/models/admin/organization/usage_images_response.rbs +6 -6
  73. data/sig/openai/models/admin/organization/usage_moderations_response.rbs +6 -6
  74. data/sig/openai/models/admin/organization/usage_vector_stores_response.rbs +6 -6
  75. data/sig/openai/models/admin/organization/usage_web_search_calls_response.rbs +6 -6
  76. data/sig/openai/models/audio/voice_consent.rbs +57 -0
  77. data/sig/openai/models/audio/voice_consent_create_params.rbs +61 -0
  78. data/sig/openai/models/audio/voice_consent_delete_params.rbs +25 -0
  79. data/sig/openai/models/audio/voice_consent_deleted.rbs +31 -0
  80. data/sig/openai/models/audio/voice_consent_list_params.rbs +57 -0
  81. data/sig/openai/models/audio/voice_consent_retrieve_params.rbs +25 -0
  82. data/sig/openai/models/audio/voice_consent_update_params.rbs +36 -0
  83. data/sig/openai/providers.rbs +8 -0
  84. data/sig/openai/resources/audio/voice_consents.rbs +38 -0
  85. data/sig/openai/resources/audio.rbs +2 -0
  86. data/vertex.md +29 -0
  87. metadata +32 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e70580b3709d068f537d9eed65ea7e9da96f89e0e2260b36ebf23a9c3590ecf1
4
- data.tar.gz: 83910cf81a9410c93588e877cca7f88e3d70cbe07e8eed3cd0789b08e3299a0c
3
+ metadata.gz: 54cd64155cb1af2bb57a7c61bc15355979a7c0d9e50951ce7e893911fcfe473b
4
+ data.tar.gz: 29101b3385b4be69545f1bd5516c90329c34d1e6f0bf303f28b5ea379b36fde8
5
5
  SHA512:
6
- metadata.gz: 0e854d18548c800c72da1eb04b022befc61b36ccd3e1d24fa732db7b66a0d5c3fdca08a73dd0be579f97ad2c24fbf94f318c42e800eea5b9a8118b35b18b6cbe
7
- data.tar.gz: 8dd6d3894a1137351eea2bcdad51308b46cdf025de798e913bc1f64c198beb31a264140dcd1c2d901249a21f0bea4c22a6ea61ed274a54f9f031fef73a457cf3
6
+ metadata.gz: b3d15cf0ee35ec3f58df82ea2f7249b62e63c02c1ee5351d0d6e95f50d1929c707a458225ee71e8568f816149a7c8346788286b22ba9ec38faeb42d3e511746c
7
+ data.tar.gz: b0e8720cecf2f4f600f7ac05226a8cbbba2eae974a81e0312b9d8ac88bef0bc7e4883a591ec2ac0fa2e9b30de13a79607a23b4a904f03f945fc10a79bb4a3913
data/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.109.0](https://github.com/openai/openai-ruby/compare/v0.108.0...v0.109.0) (2026-10-21)
4
+
5
+
6
+ ### Features
7
+
8
+ * add Vertex AI support for the Responses API ([#952](https://github.com/openai/openai-ruby/issues/952)) ([98f5359](https://github.com/openai/openai-ruby/commit/98f5359e3ad2133abc5e914610fe7bbd2a058f8d))
9
+
10
+ ## [0.108.0](https://github.com/openai/openai-ruby/compare/v0.107.0...v0.108.0) (2026-10-21)
11
+
12
+
13
+ ### Features
14
+
15
+ * add a bounded Chat Completions tool runner ([#943](https://github.com/openai/openai-ruby/issues/943)) ([db9f85f](https://github.com/openai/openai-ruby/commit/db9f85f42ac3f3d76678db18941c46e0468267a9))
16
+ * **api:** add custom voice consent management ([#951](https://github.com/openai/openai-ruby/issues/951)) ([fc5256c](https://github.com/openai/openai-ruby/commit/fc5256c4332a1cf5b3d9567874c1d24085b574a1))
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * preserve accepted JSON through Responses session helpers ([#939](https://github.com/openai/openai-ruby/issues/939)) ([bdea98a](https://github.com/openai/openai-ruby/commit/bdea98a74c9ea093020c82375259baa6b23468d0))
22
+ * preserve Realtime restore errors if abort fails ([#940](https://github.com/openai/openai-ruby/issues/940)) ([3f09468](https://github.com/openai/openai-ruby/commit/3f094684e1618878a6d4936acf1c970ef426afac))
23
+
24
+
25
+ ### Chores
26
+
27
+ * **api:** clarify cache-write token totals ([#948](https://github.com/openai/openai-ruby/issues/948)) ([b0cb84d](https://github.com/openai/openai-ruby/commit/b0cb84db1759785b37a49b64b3f5398a63d81b1a))
28
+ * **api:** preserve client configuration compatibility ([#944](https://github.com/openai/openai-ruby/issues/944)) ([4d9bb59](https://github.com/openai/openai-ruby/commit/4d9bb5986fcd13b36634d4396a5745eb9ad1de77))
29
+ * **api:** preserve embedding helper loading ([#946](https://github.com/openai/openai-ruby/issues/946)) ([2965c7f](https://github.com/openai/openai-ruby/commit/2965c7fa3a66eeeb1ef77d37d67f6b73ca4d9701))
30
+ * **api:** preserve SDK compatibility ([#949](https://github.com/openai/openai-ruby/issues/949)) ([38cd9bd](https://github.com/openai/openai-ruby/commit/38cd9bdbdd653470c85a534a0026dac2f2f448f1))
31
+ * enforce zero customizations in generated files ([#950](https://github.com/openai/openai-ruby/issues/950)) ([b87c892](https://github.com/openai/openai-ruby/commit/b87c8928395a12c8e608c1b682e1ba9c1a984c75))
32
+
3
33
  ## [0.107.0](https://github.com/openai/openai-ruby/compare/v0.106.0...v0.107.0) (2026-10-20)
4
34
 
5
35
 
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.107.0"
18
+ gem "openai", "~> 0.109.0"
19
19
  ```
20
20
 
21
21
  <!-- x-release-please-end -->
@@ -50,6 +50,54 @@ stream.each do |event|
50
50
  end
51
51
  ```
52
52
 
53
+ ### Automatic Chat Completions tool runner
54
+
55
+ `client.chat.completions.run_tools` runs your function callbacks, sends their results
56
+ back to the model, and returns the final `ChatCompletion`. It accepts the same
57
+ messages, function-tool definitions (including `OpenAI::BaseModel` schemas), and
58
+ request options as `create`:
59
+
60
+ ```ruby
61
+ completion = client.chat.completions.run_tools(
62
+ model: "gpt-4o-mini",
63
+ messages: [{role: :user, content: "What is the weather in Seattle?"}],
64
+ tools: [WeatherArguments], # An OpenAI::BaseModel with a city field.
65
+ tool_handlers: {
66
+ "WeatherArguments" => -> (arguments) { {temperature_c: 18, city: arguments.fetch("city")} }
67
+ },
68
+ max_rounds: 10
69
+ )
70
+ puts(completion.choices.first.message.content)
71
+ ```
72
+
73
+ Handlers receive a parsed JSON object with **string keys**; validate application
74
+ requirements in your callback. String results are sent unchanged; other results
75
+ are JSON encoded. Callbacks execute sequentially, including when the model requests
76
+ multiple tools in one response. Setting `parallel_tool_calls: false` limits each
77
+ response to one tool call; the runner rejects larger batches before callbacks run.
78
+ Every advertised tool must have a matching handler.
79
+ The runner validates each batch before invoking callbacks, rejects repeated call
80
+ IDs, enforces the initial `tool_choice` before returning or invoking callbacks, and
81
+ enforces the `tool_choice.allowed_tools.tools` subset on every round. It
82
+ does not mutate your input messages or append the conversation to them.
83
+
84
+ This helper is non-streaming and supports one completion choice and function tools.
85
+ `max_rounds` limits API calls, including the final answer; it raises
86
+ `OpenAI::Helpers::Chat::ToolRunner::MaxRoundsError` before executing tools in the
87
+ last allowed round. A forced `tool_choice` applies to the first round, then switches
88
+ to `auto` while preserving any allowed-tools list. Other invalid or incomplete tool
89
+ responses raise `OpenAI::Helpers::Chat::ToolRunner::Error`.
90
+
91
+ Callback, transport, and API errors stop the run and propagate to your application.
92
+ Callback exception text is never submitted as a tool result, and HTTP retries do
93
+ not rerun callbacks. Earlier callback side effects are not rolled back; starting another run
94
+ can execute tools again. Use application-level idempotency where needed. Request
95
+ timeouts apply per API call, not to callback execution or the entire run.
96
+ Caller-supplied request idempotency keys are scoped to each round, so retries keep
97
+ the same key without colliding with the next round.
98
+
99
+ See [the complete example](examples/chat/tool_runner.rb).
100
+
53
101
  ### Agents final output (beta)
54
102
 
55
103
  Collect final output from an Agents creation stream:
@@ -508,6 +556,12 @@ client = OpenAI::Client.new(
508
556
 
509
557
  See [bedrock.md](bedrock.md) for authentication precedence, static and refreshable credentials, endpoint configuration, and SigV4 request constraints.
510
558
 
559
+ ## Google Vertex AI
560
+
561
+ Use `OpenAI::Providers.vertex` with the Responses API. See [vertex.md](vertex.md)
562
+ for setup with Google Application Default Credentials, a credentials object, or
563
+ an access-token callback.
564
+
511
565
  ## Workload Identity Authentication
512
566
 
513
567
  For secure, automated environments like cloud-managed Kubernetes, Azure, and GCP, you can use workload identity authentication with short-lived tokens from cloud identity providers instead of long-lived API keys.
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../../lib/openai"
5
+
6
+ class WeatherArguments < OpenAI::BaseModel
7
+ required :city, String
8
+ end
9
+
10
+ client = OpenAI::Client.new
11
+ calls = 0
12
+ completion = client.chat.completions.run_tools(
13
+ model: "gpt-4o-mini",
14
+ messages: [{role: :user, content: "What is the weather in Seattle? Use the provided weather tool."}],
15
+ tools: [WeatherArguments],
16
+ tool_choice: {type: :function, function: {name: "WeatherArguments"}},
17
+ tool_handlers: {
18
+ "WeatherArguments" => -> (arguments) do
19
+ calls += 1
20
+ # Replace this synthetic result with your weather service.
21
+ {city: arguments.fetch("city"), temperature_c: 18, condition: "rain"}
22
+ end
23
+ },
24
+ max_rounds: 5
25
+ )
26
+
27
+ unless calls.positive? &&
28
+ completion.choices.first.finish_reason == :stop &&
29
+ !completion.choices.first.message.content.to_s.empty?
30
+ abort("Expected a weather callback and a final answer")
31
+ end
32
+
33
+ puts("Tool runner completed.")
data/lib/openai/client.rb CHANGED
@@ -206,8 +206,8 @@ module OpenAI
206
206
  # @param base_url [String, nil] Override the default base URL for the API, e.g.,
207
207
  # `"https://api.example.com/v2/"`. Defaults to `ENV["OPENAI_BASE_URL"]`
208
208
  #
209
- # @param data_residency [Symbol, String, nil] Select `global`, `us`, `eu`, or
210
- # `ae`. Mutually exclusive with an explicit `base_url` or `provider`.
209
+ # @param data_residency [Symbol, String, nil] Select `global`, `us`, `eu`, `ae`.
210
+ # Mutually exclusive with an explicit `base_url` or `provider`.
211
211
  # This selects an endpoint; project and model eligibility still apply.
212
212
  #
213
213
  # @param default_headers [Hash{String=>String, nil}, nil] Extra headers to send
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../internal/request_options_scope"
4
+
5
+ module OpenAI
6
+ module Helpers
7
+ module Chat
8
+ # Executes a bounded, non-streaming Chat Completions function-tool loop.
9
+ class ToolRunner
10
+ class Error < StandardError
11
+ end
12
+
13
+ class MaxRoundsError < Error
14
+ end
15
+
16
+ # @api private
17
+ def initialize(completions:, params:, tool_handlers:, max_rounds:)
18
+ unless max_rounds.is_a?(Integer) && max_rounds.positive?
19
+ raise ArgumentError, "max_rounds must be a positive integer"
20
+ end
21
+
22
+ @completions = completions
23
+ @max_rounds = max_rounds
24
+ @params, options = OpenAI::Chat::CompletionCreateParams.dump_request(params)
25
+ @request_options = OpenAI::Internal::RequestOptionsScope.new(options)
26
+ @handlers = tool_handlers.to_h.dup
27
+ validate_options(options)
28
+
29
+ # Validate a separate copy: schema conversion must not replace the models
30
+ # used by create to parse the final completion's structured output.
31
+ definitions, = OpenAI::Chat::CompletionCreateParams.dump_request(params)
32
+ completions.get_structured_output_models(definitions)
33
+ names = definitions.fetch(:tools).map do |tool|
34
+ unless tool[:type] == :function || tool[:type] == "function"
35
+ raise ArgumentError, "run_tools supports function tools only"
36
+ end
37
+
38
+ name = tool.dig(:function, :name)
39
+ unless name.is_a?(String) && !name.empty? && @handlers[name].respond_to?(:call)
40
+ raise ArgumentError, "Each function tool must have a named callable in tool_handlers"
41
+ end
42
+
43
+ name
44
+ end
45
+
46
+ unless names.uniq.length == names.length && @handlers.keys.sort == names.sort
47
+ raise ArgumentError, "Tool definitions and tool_handlers must have matching, unique string names"
48
+ end
49
+
50
+ @allowed_names = allowed_tool_names(names)
51
+ @seen = {}
52
+ @params.fetch(:messages).each do |message|
53
+ Array(message[:tool_calls]).each { |call| @seen[call[:id]] = true }
54
+ end
55
+ end
56
+
57
+ # @api private
58
+ def run
59
+ @max_rounds.times do |round|
60
+ completion = create_completion(round)
61
+ assistant, prepared = prepare_completion(completion, round)
62
+ return completion unless assistant
63
+
64
+ @params[:messages] << assistant
65
+ prepared.each do |call, handler, arguments|
66
+ output = handler.call(arguments)
67
+ content = output.is_a?(String) ? output.dup : JSON.generate(output)
68
+ @params[:messages] << {role: :tool, tool_call_id: call[:id], content: content}
69
+ end
70
+
71
+ # A forced tool choice applies to the first round; allow a final answer.
72
+ # Preserve the caller's allowed-tools restriction, when supplied.
73
+ choice = @params[:tool_choice]
74
+ @params[:tool_choice] = if choice.is_a?(Hash) && choice[:type].to_s == "allowed_tools"
75
+ choice.merge(allowed_tools: choice.fetch(:allowed_tools).merge(mode: :auto))
76
+ else
77
+ :auto
78
+ end
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def create_completion(round)
85
+ @completions.create_for_tool_runner(
86
+ @params.merge(request_options: @request_options.child("chat.tool_runner.round.#{round}"))
87
+ )
88
+ end
89
+
90
+ # Keep response validation separate from transport and callback execution.
91
+ def prepare_completion(completion, round)
92
+ unless completion.choices.length == 1
93
+ raise Error, "run_tools requires exactly one completion choice"
94
+ end
95
+
96
+ choice = completion.choices.first
97
+ message = choice.message
98
+ unless message.to_h.key?(:role) && message.role == :assistant
99
+ raise Error, "Expected an assistant message"
100
+ end
101
+
102
+ [:content, :refusal].each do |field|
103
+ value = message[field]
104
+ unless message.to_h.key?(field) && (value.nil? || value.is_a?(String))
105
+ raise Error, "Expected assistant content and refusal to be strings or null"
106
+ end
107
+ end
108
+
109
+ audio = message.audio
110
+ if audio && (!audio[:id].is_a?(String) || audio[:id].empty?)
111
+ raise Error, "Expected an audio reference with a nonempty string ID"
112
+ end
113
+
114
+ calls = message.tool_calls || []
115
+ if @params[:parallel_tool_calls] == false && calls.length > 1
116
+ raise Error, "Model returned multiple tool calls with parallel_tool_calls disabled"
117
+ end
118
+
119
+ validate_tool_choice(calls)
120
+ if choice.finish_reason == :stop && calls.empty? && message.function_call.nil?
121
+ return [nil, nil]
122
+ end
123
+
124
+ unless [:stop, :tool_calls].include?(choice.finish_reason) && !calls.empty? && message.function_call.nil?
125
+ raise Error, "Completion did not finish normally or provide complete function tool calls"
126
+ end
127
+
128
+ if round + 1 == @max_rounds
129
+ raise MaxRoundsError, "Tool runner reached max_rounds before a final completion"
130
+ end
131
+
132
+ # Prepare the entire batch before allowing any callback side effects.
133
+ prepared = calls.map { |call| prepare(call) }
134
+ assistant = message.to_h.slice(:role, :content, :refusal)
135
+ assistant[:tool_calls] = prepared.map { |call, _handler, _arguments| call }
136
+ assistant[:audio] = {id: audio.id} if audio
137
+ [assistant, prepared]
138
+ rescue KeyError, NoMethodError, TypeError, OpenAI::Errors::ConversionError
139
+ raise Error, "Malformed Chat Completion response", cause: nil
140
+ end
141
+
142
+ def validate_tool_choice(calls)
143
+ choice = @params[:tool_choice]
144
+ if choice == :none || choice == "none"
145
+ raise Error, "Model requested tools when tool_choice is none" unless calls.empty?
146
+ return
147
+ end
148
+
149
+ required = choice == :required || choice == "required"
150
+ if choice.is_a?(Hash)
151
+ case (choice[:type] || choice["type"]).to_s
152
+ when "allowed_tools"
153
+ required = choice.fetch(:allowed_tools).fetch(:mode).to_s == "required"
154
+ when "function"
155
+ required = true
156
+ function = choice[:function] || choice["function"]
157
+ name = function[:name] || function["name"]
158
+ unless calls.all? { |call| call.function.name == name }
159
+ raise Error, "Model did not call the forced function tool"
160
+ end
161
+ end
162
+ end
163
+
164
+ raise Error, "Model returned no tool calls for a required tool choice" if required && calls.empty?
165
+ end
166
+
167
+ def allowed_tool_names(names)
168
+ choice = @params[:tool_choice]
169
+ return names unless choice.is_a?(Hash) && (choice[:type] || choice["type"]).to_s == "allowed_tools"
170
+
171
+ choice = OpenAI::Chat::ChatCompletionAllowedToolChoice.dump(choice, state: {can_retry: true})
172
+ @params[:tool_choice] = choice
173
+ allowed = choice[:allowed_tools]
174
+ unless allowed.is_a?(Hash) && allowed[:tools].is_a?(Array)
175
+ raise ArgumentError, "allowed_tools must include a tools array"
176
+ end
177
+
178
+ allowed[:tools].map do |tool|
179
+ type = nil
180
+ function = nil
181
+ name = nil
182
+ type = tool[:type] || tool["type"] if tool.is_a?(Hash)
183
+ function = tool[:function] || tool["function"] if tool.is_a?(Hash)
184
+ name = function[:name] || function["name"] if function.is_a?(Hash)
185
+ unless type.to_s == "function" && names.include?(name)
186
+ raise ArgumentError, "allowed_tools must reference registered function tools"
187
+ end
188
+
189
+ name
190
+ end
191
+ end
192
+
193
+ def validate_options(options)
194
+ unless @params[:n].nil? || @params[:n] == 1
195
+ raise ArgumentError, "run_tools supports n: 1 only"
196
+ end
197
+
198
+ if @params[:stream] || @params[:functions] || @params[:function_call]
199
+ raise ArgumentError, "run_tools requires non-streaming function tools"
200
+ end
201
+
202
+ controlled = %w[messages tools tool_choice parallel_tool_calls n stream functions function_call]
203
+ if options[:extra_body]&.keys&.any? { |key| controlled.include?(key.to_s) }
204
+ raise ArgumentError, "extra_body cannot override tool runner control parameters"
205
+ end
206
+
207
+ unless @params[:messages].is_a?(Array) && @params[:tools].is_a?(Array)
208
+ raise ArgumentError, "messages and tools must be arrays"
209
+ end
210
+
211
+ unless @handlers.keys.all? { |name| name.is_a?(String) }
212
+ raise ArgumentError, "tool_handlers keys must be strings"
213
+ end
214
+ end
215
+
216
+ def prepare(call)
217
+ unless call.is_a?(OpenAI::Chat::ChatCompletionMessageFunctionToolCall) &&
218
+ [:function, "function"].include?(call[:type]) &&
219
+ call[:id].is_a?(String) &&
220
+ !call.id.empty? &&
221
+ !@seen.key?(call.id)
222
+ raise Error, "Expected a function tool call with a new, nonempty ID"
223
+ end
224
+
225
+ function = call.function
226
+ unless function.is_a?(OpenAI::Chat::ChatCompletionMessageFunctionToolCall::Function) &&
227
+ function[:name].is_a?(String) &&
228
+ function[:arguments].is_a?(String)
229
+ raise Error, "Expected a function tool payload"
230
+ end
231
+
232
+ handler = @handlers[function.name]
233
+ raise Error, "Model requested an unregistered function tool" unless handler
234
+ unless @allowed_names.include?(function.name)
235
+ raise Error, "Model requested a function tool that is not allowed"
236
+ end
237
+
238
+ arguments = begin
239
+ JSON.parse(function.arguments)
240
+ rescue JSON::ParserError, TypeError
241
+ raise Error, "Function arguments must be a valid JSON object", cause: nil
242
+ end
243
+
244
+ raise Error, "Function arguments must be a JSON object" unless arguments.is_a?(Hash)
245
+
246
+ @seen[call.id] = true
247
+ wire_call = {
248
+ id: call.id,
249
+ type: :function,
250
+ function: {name: function.name, arguments: function.arguments}
251
+ }
252
+ [wire_call, handler, arguments]
253
+ rescue OpenAI::Errors::ConversionError
254
+ raise Error, "Malformed function tool payload", cause: nil
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
@@ -101,6 +101,17 @@ module OpenAI
101
101
 
102
102
  # @api private
103
103
  private def send_request(request, redirect_count:, retry_count:, send_retry_header:)
104
+ if (wrapper = @provider_runtime&.wrap_request)
105
+ return wrapper.call(request, request_replayable?(request), retry_count) do |attempt, attempt_retry_count|
106
+ super(
107
+ attempt,
108
+ redirect_count: redirect_count,
109
+ retry_count: attempt_retry_count,
110
+ send_retry_header: send_retry_header
111
+ )
112
+ end
113
+ end
114
+
104
115
  if @workload_identity_auth && x509_transport?(@requester)
105
116
  canonical_headers = @requester.validate_api_request!(
106
117
  url: request.fetch(:url),
@@ -306,8 +306,14 @@ module OpenAI
306
306
  # Shutdown must not mask the operation's result or prevent cleanup.
307
307
  nil
308
308
  ensure
309
- physical.abort
310
- @physical = nil
309
+ pending_error = $ERROR_INFO
310
+ begin
311
+ physical.abort
312
+ rescue StandardError
313
+ raise if pending_error.nil?
314
+ ensure
315
+ @physical = nil
316
+ end
311
317
  end
312
318
  end
313
319
  end
@@ -11,6 +11,8 @@ module OpenAI
11
11
  # are retained exactly as received, never filled from provisional output.
12
12
  # Call reset to discard all retained state.
13
13
  class IncrementalResponse
14
+ include OpenAI::WebSocket::Protocol
15
+
14
16
  attr_reader :phase
15
17
 
16
18
  def initialize
@@ -236,10 +238,12 @@ module OpenAI
236
238
  # references. Returned snapshots and incoming events must not share strings.
237
239
  def copy(value)
238
240
  data = OpenAI::Internal::Type::BaseModel.recursively_to_h(value, convert: false)
239
- data = JSON.parse(JSON.generate(data), symbolize_names: true)
241
+ data = parse_json(generate_json(data))
240
242
  return data unless value.is_a?(OpenAI::Internal::Type::BaseModel)
241
243
 
242
244
  OpenAI::Internal::Type::Converter.coerce(value.class, data)
245
+ rescue SystemStackError
246
+ raise SessionError, "Could not copy the Responses WebSocket event.", cause: nil
243
247
  end
244
248
  end
245
249
  end
@@ -55,6 +55,8 @@ module OpenAI
55
55
 
56
56
  # @api private
57
57
  class SessionAccumulator
58
+ include OpenAI::WebSocket::Protocol
59
+
58
60
  attr_reader :final_response, :error
59
61
 
60
62
  def initialize(limit)
@@ -93,10 +95,10 @@ module OpenAI
93
95
  if type == "response.output_item.done"
94
96
  # Retain wire data independently of the mutable event returned by receive.
95
97
  completed_item = OpenAI::Internal::Type::BaseModel.recursively_to_h(event.item, convert: false)
96
- @items[event.output_index] = JSON.parse(JSON.generate(completed_item), symbolize_names: true)
98
+ @items[event.output_index] = parse_json(generate_json(completed_item))
97
99
  elsif %w[response.completed response.failed response.incomplete].include?(type)
98
100
  data = OpenAI::Internal::Type::BaseModel.recursively_to_h(event, convert: false)
99
- data = JSON.parse(JSON.generate(data), symbolize_names: true)
101
+ data = parse_json(generate_json(data))
100
102
  response = data.fetch(:response)
101
103
  if response[:output].nil?
102
104
  response = response.merge(output: @items.sort.map { |_index, item| item.to_h })
@@ -117,7 +119,7 @@ module OpenAI
117
119
  @state.handle_event(@decoder.decode(data))
118
120
  end
119
121
 
120
- rescue StandardError => error
122
+ rescue StandardError, SystemStackError => error
121
123
  @error = error.is_a?(SessionError) ? error : SessionError.new("Could not collect the Responses final response.")
122
124
  clear
123
125
  end
@@ -313,6 +315,8 @@ module OpenAI
313
315
 
314
316
  # Owns one connection and one Async reader. Use only its lanes to receive.
315
317
  class Session
318
+ include OpenAI::WebSocket::Protocol
319
+
316
320
  attr_reader :limits, :default
317
321
 
318
322
  def self.open(
@@ -531,7 +535,7 @@ module OpenAI
531
535
  value = @lanes.fetch(data[:stream_id], @lanes[nil])
532
536
  return unless value
533
537
 
534
- bytes = JSON.generate(data).bytesize
538
+ bytes = generate_json(data).bytesize
535
539
  if @events >= @limits.max_events || @bytes + bytes > @limits.max_bytes
536
540
  raise BufferError, "The Responses session exceeded its buffered event budget."
537
541
  end
@@ -544,6 +548,9 @@ module OpenAI
544
548
  consumed(bytes)
545
549
  raise
546
550
  end
551
+
552
+ rescue SystemStackError
553
+ raise SessionError, "Could not buffer the Responses WebSocket event.", cause: nil
547
554
  end
548
555
 
549
556
  private def stop_reader
@@ -1,9 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../chat/tool_runner"
4
+
3
5
  module OpenAI
4
6
  module Resources
5
7
  class Chat
6
8
  class Completions
9
+ # Run function tools until the model returns a final, non-streaming completion.
10
+ # Handlers receive a JSON object with string keys. String results are sent
11
+ # verbatim; other results are JSON encoded. Callback errors propagate without
12
+ # retrying callbacks or sending their exception text to the model.
13
+ # max_rounds bounds API calls (including the final answer), defaulting to 10.
14
+ # A forced tool choice applies only to the first round. Callbacks run serially.
15
+ #
16
+ # @param tool_handlers [Hash{String=>#call}]
17
+ # @param max_rounds [Integer]
18
+ # @param params [Hash] Other Chat Completions create parameters.
19
+ # @return [OpenAI::Models::Chat::ChatCompletion]
20
+ def run_tools(model:, messages:, tools:, tool_handlers:, max_rounds: 10, **params)
21
+ OpenAI::Helpers::Chat::ToolRunner
22
+ .new(
23
+ completions: self,
24
+ params: params.merge(model: model, messages: messages, tools: tools),
25
+ tool_handlers: tool_handlers,
26
+ max_rounds: max_rounds
27
+ )
28
+ .run
29
+ end
30
+
31
+ # @api private
32
+ # Buffer through the normal transport, then decode inside the runner's
33
+ # error boundary. Transport exceptions remain outside that boundary.
34
+ def create_for_tool_runner(params)
35
+ parsed, options = OpenAI::Chat::CompletionCreateParams.dump_request(params)
36
+ model, tool_models = get_structured_output_models(parsed)
37
+ parse = OpenAI::Helpers::StructuredOutput::ChatCompletionParser.build_unwrap(model, tool_models)
38
+ response = @client.request(
39
+ method: :post,
40
+ path: "chat/completions",
41
+ body: parsed,
42
+ model: StringIO,
43
+ security: {bearer_auth: true},
44
+ options: options
45
+ )
46
+
47
+ begin
48
+ raw = JSON.parse(response.string, symbolize_names: true)
49
+ completion = OpenAI::Internal::Type::Converter.coerce(OpenAI::Chat::ChatCompletion, parse.call(raw))
50
+ completion._set_last_response(response.last_response)
51
+ completion
52
+ rescue JSON::ParserError, KeyError, NoMethodError, TypeError, OpenAI::Errors::ConversionError
53
+ raise OpenAI::Helpers::Chat::ToolRunner::Error, "Malformed Chat Completion response", cause: nil
54
+ ensure
55
+ response.close
56
+ end
57
+ end
58
+
7
59
  def stream(params)
8
60
  parsed, options = OpenAI::Chat::CompletionCreateParams.dump_request(params)
9
61
 
@@ -11,6 +11,7 @@ module OpenAI
11
11
  :name,
12
12
  :base_url,
13
13
  :prepare_request,
14
+ :wrap_request,
14
15
  :authentication_headers,
15
16
  keyword_init: true
16
17
  )
@@ -243,8 +243,8 @@ module OpenAI
243
243
  optional :input_cache_write_30m_tokens, Integer, nil?: true
244
244
 
245
245
  # @!attribute input_cache_write_tokens
246
- # The aggregated number of input tokens written to the cache with a 30-minute
247
- # retention period.
246
+ # The aggregated number of input tokens written to the cache across 30-minute and
247
+ # 12-hour retention periods.
248
248
  #
249
249
  # @return [Integer, nil]
250
250
  optional :input_cache_write_tokens, Integer
@@ -382,8 +382,8 @@ module OpenAI
382
382
  # retention period. May be absent or null for older responses.
383
383
  #
384
384
  # @param input_cache_write_tokens [Integer]
385
- # The aggregated number of input tokens written to the cache with a 30-minute
386
- # retention period.
385
+ # The aggregated number of input tokens written to the cache across 30-minute and
386
+ # 12-hour retention periods.
387
387
  #
388
388
  # @param input_cached_audio_tokens [Integer]
389
389
  # The aggregated number of cached audio input tokens used.
@@ -245,8 +245,8 @@ module OpenAI
245
245
  optional :input_cache_write_30m_tokens, Integer, nil?: true
246
246
 
247
247
  # @!attribute input_cache_write_tokens
248
- # The aggregated number of input tokens written to the cache with a 30-minute
249
- # retention period.
248
+ # The aggregated number of input tokens written to the cache across 30-minute and
249
+ # 12-hour retention periods.
250
250
  #
251
251
  # @return [Integer, nil]
252
252
  optional :input_cache_write_tokens, Integer
@@ -384,8 +384,8 @@ module OpenAI
384
384
  # retention period. May be absent or null for older responses.
385
385
  #
386
386
  # @param input_cache_write_tokens [Integer]
387
- # The aggregated number of input tokens written to the cache with a 30-minute
388
- # retention period.
387
+ # The aggregated number of input tokens written to the cache across 30-minute and
388
+ # 12-hour retention periods.
389
389
  #
390
390
  # @param input_cached_audio_tokens [Integer]
391
391
  # The aggregated number of cached audio input tokens used.