openai 0.106.0 → 0.108.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -0
  3. data/README.md +49 -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/live/websocket.rb +12 -141
  8. data/lib/openai/helpers/local_audio/process.rb +13 -3
  9. data/lib/openai/helpers/models/embedding.rb +42 -0
  10. data/lib/openai/helpers/realtime/connection.rb +22 -5
  11. data/lib/openai/helpers/realtime/recovery.rb +19 -7
  12. data/lib/openai/helpers/realtime/translation.rb +37 -169
  13. data/lib/openai/helpers/realtime/unknown_server_event.rb +5 -12
  14. data/lib/openai/helpers/responses_websocket/connection.rb +56 -35
  15. data/lib/openai/helpers/responses_websocket/incremental.rb +5 -1
  16. data/lib/openai/helpers/responses_websocket/session.rb +11 -4
  17. data/lib/openai/helpers/responses_websocket/unknown_server_event.rb +2 -14
  18. data/lib/openai/helpers/streaming/chat_completion_resource.rb +52 -0
  19. data/lib/openai/helpers/websocket/async_websocket_transport.rb +8 -6
  20. data/lib/openai/helpers/websocket/client_request.rb +1 -1
  21. data/lib/openai/helpers/websocket/connection.rb +21 -10
  22. data/lib/openai/helpers/websocket/connection_manager.rb +2 -1
  23. data/lib/openai/helpers/websocket/protocol.rb +160 -0
  24. data/lib/openai/helpers/websocket/typed_connection.rb +22 -16
  25. data/lib/openai/helpers/websocket/typed_resource.rb +15 -0
  26. data/lib/openai/internal/transport/base_client.rb +1 -1
  27. data/lib/openai/models/admin/organization/usage_audio_speeches_response.rb +16 -5
  28. data/lib/openai/models/admin/organization/usage_audio_transcriptions_response.rb +16 -5
  29. data/lib/openai/models/admin/organization/usage_code_interpreter_sessions_response.rb +16 -5
  30. data/lib/openai/models/admin/organization/usage_completions_response.rb +16 -5
  31. data/lib/openai/models/admin/organization/usage_costs_response.rb +16 -5
  32. data/lib/openai/models/admin/organization/usage_decisions_response.rb +14 -3
  33. data/lib/openai/models/admin/organization/usage_embeddings_response.rb +16 -5
  34. data/lib/openai/models/admin/organization/usage_file_search_calls_response.rb +16 -5
  35. data/lib/openai/models/admin/organization/usage_images_response.rb +16 -5
  36. data/lib/openai/models/admin/organization/usage_moderations_response.rb +16 -5
  37. data/lib/openai/models/admin/organization/usage_vector_stores_response.rb +16 -5
  38. data/lib/openai/models/admin/organization/usage_web_search_calls_response.rb +16 -5
  39. data/lib/openai/models/audio/voice_consent.rb +58 -0
  40. data/lib/openai/models/audio/voice_consent_create_params.rb +57 -0
  41. data/lib/openai/models/audio/voice_consent_delete_params.rb +22 -0
  42. data/lib/openai/models/audio/voice_consent_deleted.rb +34 -0
  43. data/lib/openai/models/audio/voice_consent_list_params.rb +42 -0
  44. data/lib/openai/models/audio/voice_consent_retrieve_params.rb +22 -0
  45. data/lib/openai/models/audio/voice_consent_update_params.rb +32 -0
  46. data/lib/openai/models/beta/beta_response_input_item.rb +10 -1
  47. data/lib/openai/models/responses/response_input_item.rb +10 -1
  48. data/lib/openai/resources/audio/voice_consents.rb +163 -0
  49. data/lib/openai/resources/audio.rb +5 -0
  50. data/lib/openai/version.rb +1 -1
  51. data/lib/openai.rb +9 -0
  52. data/rbi/openai/client.rbi +1 -8
  53. data/rbi/openai/helpers/chat/tool_runner.rbi +74 -0
  54. data/rbi/openai/helpers/realtime/translation.rbi +0 -5
  55. data/rbi/openai/helpers/realtime/unknown_server_event.rbi +6 -0
  56. data/rbi/openai/helpers/websocket.rbi +44 -6
  57. data/rbi/openai/internal/transport/base_client.rbi +2 -6
  58. data/rbi/openai/models/admin/organization/usage_audio_speeches_response.rbi +14 -2
  59. data/rbi/openai/models/admin/organization/usage_audio_transcriptions_response.rbi +14 -2
  60. data/rbi/openai/models/admin/organization/usage_code_interpreter_sessions_response.rbi +14 -2
  61. data/rbi/openai/models/admin/organization/usage_completions_response.rbi +14 -2
  62. data/rbi/openai/models/admin/organization/usage_costs_response.rbi +14 -2
  63. data/rbi/openai/models/admin/organization/usage_decisions_response.rbi +12 -0
  64. data/rbi/openai/models/admin/organization/usage_embeddings_response.rbi +14 -2
  65. data/rbi/openai/models/admin/organization/usage_file_search_calls_response.rbi +14 -2
  66. data/rbi/openai/models/admin/organization/usage_images_response.rbi +14 -2
  67. data/rbi/openai/models/admin/organization/usage_moderations_response.rbi +14 -2
  68. data/rbi/openai/models/admin/organization/usage_vector_stores_response.rbi +14 -2
  69. data/rbi/openai/models/admin/organization/usage_web_search_calls_response.rbi +14 -2
  70. data/rbi/openai/models/audio/voice_consent.rbi +86 -0
  71. data/rbi/openai/models/audio/voice_consent_create_params.rbi +92 -0
  72. data/rbi/openai/models/audio/voice_consent_delete_params.rbi +53 -0
  73. data/rbi/openai/models/audio/voice_consent_deleted.rbi +62 -0
  74. data/rbi/openai/models/audio/voice_consent_list_params.rbi +78 -0
  75. data/rbi/openai/models/audio/voice_consent_retrieve_params.rbi +53 -0
  76. data/rbi/openai/models/audio/voice_consent_update_params.rbi +62 -0
  77. data/rbi/openai/models/beta/beta_response_input_item.rbi +14 -1
  78. data/rbi/openai/models/response_format_json_schema.rbi +13 -11
  79. data/rbi/openai/models/responses/response_input_item.rbi +10 -1
  80. data/rbi/openai/resources/audio/voice_consents.rbi +108 -0
  81. data/rbi/openai/resources/audio.rbi +4 -0
  82. data/realtime.md +22 -3
  83. data/sig/openai/client.rbs +1 -1
  84. data/sig/openai/helpers/chat/tool_runner.rbs +48 -0
  85. data/sig/openai/helpers/realtime/translation.rbs +0 -2
  86. data/sig/openai/helpers/realtime/unknown_server_event.rbs +2 -0
  87. data/sig/openai/helpers/websocket.rbs +23 -6
  88. data/sig/openai/models/admin/organization/usage_audio_speeches_response.rbs +15 -5
  89. data/sig/openai/models/admin/organization/usage_audio_transcriptions_response.rbs +15 -5
  90. data/sig/openai/models/admin/organization/usage_code_interpreter_sessions_response.rbs +15 -5
  91. data/sig/openai/models/admin/organization/usage_completions_response.rbs +15 -5
  92. data/sig/openai/models/admin/organization/usage_costs_response.rbs +15 -5
  93. data/sig/openai/models/admin/organization/usage_decisions_response.rbs +11 -1
  94. data/sig/openai/models/admin/organization/usage_embeddings_response.rbs +15 -5
  95. data/sig/openai/models/admin/organization/usage_file_search_calls_response.rbs +15 -5
  96. data/sig/openai/models/admin/organization/usage_images_response.rbs +15 -5
  97. data/sig/openai/models/admin/organization/usage_moderations_response.rbs +15 -5
  98. data/sig/openai/models/admin/organization/usage_vector_stores_response.rbs +15 -5
  99. data/sig/openai/models/admin/organization/usage_web_search_calls_response.rbs +15 -5
  100. data/sig/openai/models/audio/voice_consent.rbs +57 -0
  101. data/sig/openai/models/audio/voice_consent_create_params.rbs +61 -0
  102. data/sig/openai/models/audio/voice_consent_delete_params.rbs +25 -0
  103. data/sig/openai/models/audio/voice_consent_deleted.rbs +31 -0
  104. data/sig/openai/models/audio/voice_consent_list_params.rbs +57 -0
  105. data/sig/openai/models/audio/voice_consent_retrieve_params.rbs +25 -0
  106. data/sig/openai/models/audio/voice_consent_update_params.rbs +36 -0
  107. data/sig/openai/models/beta/beta_response_input_item.rbs +8 -0
  108. data/sig/openai/models/responses/response_input_item.rbs +8 -3
  109. data/sig/openai/resources/audio/voice_consents.rbs +38 -0
  110. data/sig/openai/resources/audio.rbs +2 -0
  111. metadata +31 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c24e00e4168b6f3661b2eb29d8de97727d4bcd6a80e40f68512ab21c3c025b46
4
- data.tar.gz: 16f33bff54375b5f3fb5174af63026f95e79ab2ca1cdfe6dd07cedfacac31568
3
+ metadata.gz: bbaffc5b480678a26851b73b6b49f9f63419801d8bbec633d6ed4f3765fe4717
4
+ data.tar.gz: 127b19570f47781df5644f2217d9405e1ea0e82279b319a7683c59b567cbbd72
5
5
  SHA512:
6
- metadata.gz: 0fe9484b436e02d3b50981251596b57195cef1a604207b7b496049a2214b44eb3953447568b3dcfb3e1e0827279785587e66dfc1d2427c1fa858b7bcb87c54c1
7
- data.tar.gz: 2cabbb26e7382803fbc4aa5ddcc47e3be1bad88c2d05510fb0a44f537653761a0c888157d407764ebeaab8de174cf5f8a44338ae868b16c016914b31cdd1978c
6
+ metadata.gz: ffe2a7748064e0cb03c1eb2482670a4f581323b02a0275fd9654e1f97ecaf85f9128e064f22b5075af7d615d0e9d4fee73144d57f07ceaa9dcde25a419f7b066
7
+ data.tar.gz: c531be809fb0b7dd69d4cb089eea59680a01dbcc258265e47ef6b16052ffd78eeba8f2ccf094ffaa694cdd2dca602b9ebceffa65e362de1427f51e04219a6192
data/CHANGELOG.md CHANGED
@@ -1,5 +1,79 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.108.0](https://github.com/openai/openai-ruby/compare/v0.107.0...v0.108.0) (2026-10-21)
4
+
5
+
6
+ ### Features
7
+
8
+ * 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))
9
+ * **api:** add custom voice consent management ([#951](https://github.com/openai/openai-ruby/issues/951)) ([fc5256c](https://github.com/openai/openai-ruby/commit/fc5256c4332a1cf5b3d9567874c1d24085b574a1))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * 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))
15
+ * 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))
16
+
17
+
18
+ ### Chores
19
+
20
+ * **api:** clarify cache-write token totals ([#948](https://github.com/openai/openai-ruby/issues/948)) ([b0cb84d](https://github.com/openai/openai-ruby/commit/b0cb84db1759785b37a49b64b3f5398a63d81b1a))
21
+ * **api:** preserve client configuration compatibility ([#944](https://github.com/openai/openai-ruby/issues/944)) ([4d9bb59](https://github.com/openai/openai-ruby/commit/4d9bb5986fcd13b36634d4396a5745eb9ad1de77))
22
+ * **api:** preserve embedding helper loading ([#946](https://github.com/openai/openai-ruby/issues/946)) ([2965c7f](https://github.com/openai/openai-ruby/commit/2965c7fa3a66eeeb1ef77d37d67f6b73ca4d9701))
23
+ * **api:** preserve SDK compatibility ([#949](https://github.com/openai/openai-ruby/issues/949)) ([38cd9bd](https://github.com/openai/openai-ruby/commit/38cd9bdbdd653470c85a534a0026dac2f2f448f1))
24
+ * enforce zero customizations in generated files ([#950](https://github.com/openai/openai-ruby/issues/950)) ([b87c892](https://github.com/openai/openai-ruby/commit/b87c8928395a12c8e608c1b682e1ba9c1a984c75))
25
+
26
+ ## [0.107.0](https://github.com/openai/openai-ruby/compare/v0.106.0...v0.107.0) (2026-10-20)
27
+
28
+
29
+ ### Features
30
+
31
+ * **api:** add 30-minute cache-write usage counts ([#915](https://github.com/openai/openai-ruby/issues/915)) ([ea2dd66](https://github.com/openai/openai-ruby/commit/ea2dd66ed0c16e544490332fc2ea8ec4f5abd1bb))
32
+ * **api:** add compaction trigger IDs ([#934](https://github.com/openai/openai-ruby/issues/934)) ([224e04a](https://github.com/openai/openai-ruby/commit/224e04ac2f610c793d772efafe7244d6c88039fc))
33
+
34
+
35
+ ### Bug Fixes
36
+
37
+ * **api:** correct authentication security key types ([#938](https://github.com/openai/openai-ruby/issues/938)) ([4a5cc74](https://github.com/openai/openai-ruby/commit/4a5cc744b38dc39042d98ae2ff5a32fdd0247bc9))
38
+ * decode base64 embedding vectors ([#942](https://github.com/openai/openai-ruby/issues/942)) ([d684df4](https://github.com/openai/openai-ruby/commit/d684df4dc2a633e34836a346bbbe1e7cbb3d2707))
39
+ * drain audio diagnostics while waiting on input ([#920](https://github.com/openai/openai-ruby/issues/920)) ([8a0b876](https://github.com/openai/openai-ruby/commit/8a0b87606e55f26e86e289577cdfd2533c329ad6))
40
+ * drain ready audio diagnostics without delay ([#918](https://github.com/openai/openai-ruby/issues/918)) ([3e87ce3](https://github.com/openai/openai-ruby/commit/3e87ce380fafe08bb4348b9b867d4006bb939e49))
41
+ * keep Realtime diagnostic rendering payload-free ([#924](https://github.com/openai/openai-ruby/issues/924)) ([3a29026](https://github.com/openai/openai-ruby/commit/3a29026ab177704dbe30d688fddd085e737dfc6d))
42
+ * preserve deep JSON in WebSocket event codecs ([#928](https://github.com/openai/openai-ruby/issues/928)) ([57462c9](https://github.com/openai/openai-ruby/commit/57462c938e3b7602c456b0816e8873bd67fad241))
43
+ * preserve typed Realtime event identity ([#931](https://github.com/openai/openai-ruby/issues/931)) ([9bb5618](https://github.com/openai/openai-ruby/commit/9bb56187c7eb0d72f992c76fed68369d34972d81))
44
+ * **realtime:** abort connections after failed or canceled WebSocket writes so buffered application data cannot be flushed later. On a poisoned connection, `close(code:, reason:)` aborts without a close handshake; the supplied code and reason are not sent. ([#914](https://github.com/openai/openai-ruby/issues/914)) ([d219f3d](https://github.com/openai/openai-ruby/commit/d219f3d0461dd96911429f1b89369eadd382f234))
45
+ * report terminal Realtime failures before transport cleanup ([#923](https://github.com/openai/openai-ruby/issues/923)) ([0d21d75](https://github.com/openai/openai-ruby/commit/0d21d751cd89621c6a32c0048c45ef6e5b7c1af4))
46
+ * retain Translation completion across callback exits ([#921](https://github.com/openai/openai-ruby/issues/921)) ([e6fb44e](https://github.com/openai/openai-ruby/commit/e6fb44e57d233e657d982eee4cf27c6a5d7977c7))
47
+ * share strict WebSocket model validation ([#929](https://github.com/openai/openai-ruby/issues/929)) ([51c6f0b](https://github.com/openai/openai-ruby/commit/51c6f0b73081f0f02f981d2beb4a8b129b2ec1ba))
48
+ * validate nested WebSocket wire field names ([#922](https://github.com/openai/openai-ruby/issues/922)) ([ae551ef](https://github.com/openai/openai-ruby/commit/ae551ef48d52d2c2f415f63c39e0c643fcfb1d75))
49
+ * validate typed WebSocket event constants before conversion ([#925](https://github.com/openai/openai-ruby/issues/925)) ([3ae0cd3](https://github.com/openai/openai-ruby/commit/3ae0cd3070b4fcf046f8823f4cca1b7b8829effe))
50
+
51
+
52
+ ### Chores
53
+
54
+ * **api:** clarify project API key update requirements ([#936](https://github.com/openai/openai-ruby/issues/936)) ([34606cc](https://github.com/openai/openai-ruby/commit/34606cc46087dae5a643256e9cae93966a106182))
55
+ * **api:** Keep SDK maintenance checks aligned ([#917](https://github.com/openai/openai-ruby/issues/917)) ([ebe5bf4](https://github.com/openai/openai-ruby/commit/ebe5bf4dd74d87acf86e30f5c7a78350ce3f8e8d))
56
+ * **api:** preserve audio transcription response compatibility ([#911](https://github.com/openai/openai-ruby/issues/911)) ([c93fb88](https://github.com/openai/openai-ruby/commit/c93fb88b350c80d724912351cf02d7ff51e3073a))
57
+ * **api:** preserve JSON Schema adapter types ([#930](https://github.com/openai/openai-ruby/issues/930)) ([f1430b8](https://github.com/openai/openai-ruby/commit/f1430b8038c7f01123fbf9f2703f6eb5b7fa8c14))
58
+ * **api:** preserve legacy Threads stream behavior ([#910](https://github.com/openai/openai-ruby/issues/910)) ([436887f](https://github.com/openai/openai-ruby/commit/436887fbe6bc3d0874cb75dca501ba1da0a9d9b4))
59
+ * **api:** preserve optional Decisions usage filters ([#909](https://github.com/openai/openai-ruby/issues/909)) ([34f61df](https://github.com/openai/openai-ruby/commit/34f61dfc23bd1ade311e09a036ef6696af37c138))
60
+ * **api:** preserve optional image edit safety identifiers ([#913](https://github.com/openai/openai-ruby/issues/913)) ([6bbfad1](https://github.com/openai/openai-ruby/commit/6bbfad1a6305b5efc71dbb41dce8560d2865bda2))
61
+ * **api:** preserve standard library loading ([#935](https://github.com/openai/openai-ruby/issues/935)) ([a742640](https://github.com/openai/openai-ruby/commit/a7426400cf499f0d88c9e88e694d86369f8d9cb5))
62
+ * **deps-dev:** bump the development-dependencies group with 3 updates ([#912](https://github.com/openai/openai-ruby/issues/912)) ([1227860](https://github.com/openai/openai-ruby/commit/1227860daec638117ce1f14dcd570cffc92c9647))
63
+ * **deps:** bump ruby/setup-ruby from 1.329.0 to 1.331.0 ([#937](https://github.com/openai/openai-ruby/issues/937)) ([8b3ab95](https://github.com/openai/openai-ruby/commit/8b3ab95e7332a7c5325b4718d4e01246c5a54054))
64
+ * prepare generic WebSocket binding signatures ([#941](https://github.com/openai/openai-ruby/issues/941)) ([9206c8c](https://github.com/openai/openai-ruby/commit/9206c8ceb99cc18df859d06c251fa5336c59ecdf))
65
+
66
+
67
+ ### Documentation
68
+
69
+ * clarify Realtime guide scope and Translation support ([#905](https://github.com/openai/openai-ruby/issues/905)) ([efb4004](https://github.com/openai/openai-ruby/commit/efb4004aa49a8a4374833f4b07fbd373fdebb685))
70
+
71
+
72
+ ### Refactors
73
+
74
+ * share Live codecs and use generated sideband commands ([#933](https://github.com/openai/openai-ruby/issues/933)) ([02861d0](https://github.com/openai/openai-ruby/commit/02861d0780eb18f493f881cfa9ed7cdf9a68abe3))
75
+ * share Translation and binding event codecs ([#932](https://github.com/openai/openai-ruby/issues/932)) ([7dc0088](https://github.com/openai/openai-ruby/commit/7dc00884ba30a54a965fc41e8b19e1840e9faec9))
76
+
3
77
  ## [0.106.0](https://github.com/openai/openai-ruby/compare/v0.105.0...v0.106.0) (2026-10-17)
4
78
 
5
79
 
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.106.0"
18
+ gem "openai", "~> 0.108.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:
@@ -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
@@ -26,6 +26,8 @@ module OpenAI
26
26
  # A future server event retains its raw data, but routine diagnostics do not
27
27
  # render potentially sensitive audio, transcript, or session fields.
28
28
  class UnknownServerEvent
29
+ include OpenAI::WebSocket::Protocol
30
+
29
31
  attr_reader :type, :data
30
32
 
31
33
  def initialize(data:)
@@ -38,160 +40,33 @@ module OpenAI
38
40
  def inspect = "#<#{self.class} type=#{@type.inspect}>"
39
41
  alias to_s inspect
40
42
 
41
- private def freeze_json(value)
42
- pending = [value]
43
- visited = {}.compare_by_identity
44
- until pending.empty?
45
- item = pending.pop
46
- next if visited.key?(item)
47
- visited[item] = true
48
- case item
49
- when Hash
50
- item.each { |key, val| pending.push(key, val) }
51
- when Array
52
- pending.concat(item)
53
- end
54
-
55
- item.freeze
56
- end
57
-
58
- value
59
- end
60
43
  end
61
44
 
62
45
  # A transport-open primary connection. Startup is owned by the caller:
63
46
  # send session.start and observe session.started before further commands.
64
- class Connection < OpenAI::WebSocket::Connection
65
- include OpenAI::WebSocket::Protocol
66
- include OpenAI::WebSocket::GuardedConnection
67
-
47
+ class Connection < OpenAI::WebSocket::TypedConnection
68
48
  # @api private
69
49
  def initialize(socket:, url:)
70
- super
50
+ super(socket: socket, url: url, client_event: client_event_type, server_event: server_event_type)
71
51
  @poisoned = false
72
52
  @closed = false
73
- @server_event_names = discriminator_values(server_event_type)
74
- @client_event_names = discriminator_values(client_event_type)
75
- end
76
-
77
- def send_event(event)
78
- send_raw(encode_client_event(event))
79
53
  end
80
54
 
81
- private def encode_client_event(event)
82
- validate_event_tree!(event)
83
- payload = OpenAI::Internal::Type::Converter.dump(client_event_type, event)
84
- type = payload[:type] || payload["type"] if payload.is_a?(Hash)
85
- raise ArgumentError unless (type.is_a?(String) || type.is_a?(Symbol)) && @client_event_names.key?(type.to_s)
86
-
87
- coerced = coerce_event(client_event_type, payload, outbound: true)
88
- serialized = OpenAI::Internal::Type::Converter.dump(client_event_type, coerced)
89
- validate_event_tree!(serialized, json_only: true)
90
- JSON.generate(serialized, max_nesting: false)
91
- rescue StandardError, SystemStackError
92
- raise ArgumentError, "Invalid Live client event.", cause: nil
93
- end
55
+ private :parse_event
94
56
 
95
- private def parse_event(data)
96
- parsed = JSON.parse(data, symbolize_names: true, max_nesting: false)
97
- type = event_type(parsed, message: "Live server event must be an object with a string type")
98
- unless @server_event_names.key?(type.to_s)
99
- return OpenAI::Live::UnknownServerEvent.new(data: parsed)
100
- end
57
+ private def coerce_client_event(payload) = coerce_event(@client_event, payload, outbound: true)
101
58
 
102
- event = coerce_event(server_event_type, parsed)
59
+ private def coerce(type, payload)
60
+ event = coerce_event(type, payload)
103
61
  validate_event_tree!(event)
104
62
  event
105
- rescue StandardError, SystemStackError
106
- raise OpenAI::Errors::LiveProtocolError.new, cause: nil
107
63
  end
108
64
 
109
- private def coerce_event(union, payload, outbound: false)
110
- state = OpenAI::Internal::Type::Converter.new_coerce_state(request_only: outbound)
111
- event = OpenAI::Internal::Type::Converter.coerce(union, payload, state: state)
112
- raise ArgumentError if state[:error] || !state.fetch(:exactness).fetch(:no).zero?
113
- pending = [event]
114
- until pending.empty?
115
- value = pending.pop
116
- case value
117
- when OpenAI::Internal::Type::BaseModel
118
- value.class.fields.each do |name, field|
119
- if field.fetch(:required) &&
120
- field.fetch(:mode) != (outbound ? :coerce : :dump) &&
121
- (!outbound || field.fetch(:const) == OpenAI::Internal::OMIT) &&
122
- !value.to_h.key?(name)
123
- raise ArgumentError
124
- end
125
- end
126
-
127
- pending.concat(value.to_h.values)
128
- when Hash
129
- pending.concat(value.values)
130
- when Array
131
- pending.concat(value)
132
- end
133
- end
134
-
135
- event
136
- end
65
+ private def client_event_error_message = "Invalid Live client event."
137
66
 
138
- private def validate_event_tree!(event, json_only: false)
139
- ancestors = {}.compare_by_identity
140
- pending = [[event, false]]
141
- until pending.empty?
142
- value, exiting = pending.pop
143
- if exiting
144
- ancestors.delete(value)
145
- next
146
- end
67
+ private def unknown_server_event(payload) = OpenAI::Live::UnknownServerEvent.new(data: payload)
147
68
 
148
- children = case value
149
- when OpenAI::Internal::Type::BaseModel
150
- raise ArgumentError if json_only
151
- data = value.to_h
152
- keys = {}
153
- data.each do |key, val|
154
- name = key.is_a?(String) ? key.to_sym : key
155
- field = value.class.known_fields[name]
156
- serialized_name = field ? field.fetch(:api_name).to_s : name.to_s
157
- raise ArgumentError if keys.key?(serialized_name)
158
- keys[serialized_name] = true
159
- next unless field
160
- const = field.fetch(:const)
161
- next if const == OpenAI::Internal::OMIT
162
- if const.is_a?(Symbol)
163
- raise ArgumentError unless (val.is_a?(String) || val.is_a?(Symbol)) && val.to_s == const.to_s
164
- elsif val != const
165
- raise ArgumentError
166
- end
167
- end
168
-
169
- [data]
170
- when Hash
171
- keys = {}
172
- value.each_key do |key|
173
- raise ArgumentError unless key.is_a?(String) || key.is_a?(Symbol)
174
- name = key.to_s
175
- raise ArgumentError if keys.key?(name)
176
- keys[name] = true
177
- end
178
-
179
- value.values
180
- when Array
181
- value
182
- when NilClass, TrueClass, FalseClass, String, Integer, Float, Symbol
183
- next
184
- else
185
- raise ArgumentError if json_only
186
- next
187
- end
188
-
189
- raise ArgumentError if ancestors.key?(value)
190
- ancestors[value] = true
191
- pending << [value, true]
192
- children.each { |child| pending << [child, false] }
193
- end
194
- end
69
+ private def protocol_error = OpenAI::Errors::LiveProtocolError.new
195
70
 
196
71
  private def transport_error = OpenAI::Errors::LiveConnectionError.new(url: @url)
197
72
  private def failed_read_message = "Cannot read from a failed Live WebSocket."
@@ -206,11 +81,7 @@ module OpenAI
206
81
 
207
82
  # Attached to an existing session; no new session.start handshake is sent.
208
83
  class SidebandConnection < Connection
209
- # @api private
210
- def initialize(socket:, url:)
211
- super
212
- @client_event_names = @client_event_names.except("session.start", "session.input_audio.append")
213
- end
84
+ private def client_event_type = OpenAI::Live::SidebandClientEvent
214
85
  end
215
86
 
216
87
  # Caller-driven startup using overrides for an eligible stored session.