ruby_llm 2.0.0 → 2.1.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 (284) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -8
  3. data/lib/generators/ruby_llm/generator_helpers.rb +68 -6
  4. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +1 -0
  5. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +36 -3
  6. data/lib/generators/ruby_llm/provider/scaffold.rb +32 -22
  7. data/lib/generators/ruby_llm/provider/templates/gem/ci.yml.erb +1 -5
  8. data/lib/generators/ruby_llm/provider/templates/gem/gemfile.erb +1 -1
  9. data/lib/generators/ruby_llm/provider/templates/gem/gemspec.erb +1 -1
  10. data/lib/generators/ruby_llm/provider/templates/gem/rubocop.yml.erb +1 -1
  11. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +78 -0
  12. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +6 -163
  13. data/lib/ruby_llm/accounting/usage.rb +86 -49
  14. data/lib/ruby_llm/active_record/acts_as.rb +3 -0
  15. data/lib/ruby_llm/active_record/attachment_helpers.rb +50 -25
  16. data/lib/ruby_llm/active_record/batch.rb +1 -1
  17. data/lib/ruby_llm/active_record/chat_methods.rb +136 -40
  18. data/lib/ruby_llm/active_record/mcp_credential.rb +41 -0
  19. data/lib/ruby_llm/active_record/message_methods.rb +32 -7
  20. data/lib/ruby_llm/active_record/model.rb +1 -1
  21. data/lib/ruby_llm/active_record/provider_file.rb +64 -0
  22. data/lib/ruby_llm/active_record/record.rb +19 -0
  23. data/lib/ruby_llm/active_record/tool_call.rb +1 -1
  24. data/lib/ruby_llm/active_record/usage.rb +53 -3
  25. data/lib/ruby_llm/agent.rb +198 -33
  26. data/lib/ruby_llm/aliases.json +66 -8
  27. data/lib/ruby_llm/attachment.rb +54 -10
  28. data/lib/ruby_llm/batch.rb +26 -3
  29. data/lib/ruby_llm/chat/tool_concurrency.rb +47 -23
  30. data/lib/ruby_llm/chat/tool_search.rb +26 -0
  31. data/lib/ruby_llm/chat.rb +488 -82
  32. data/lib/ruby_llm/choice.rb +38 -0
  33. data/lib/ruby_llm/configuration.rb +36 -11
  34. data/lib/ruby_llm/context.rb +45 -32
  35. data/lib/ruby_llm/cost.rb +42 -1
  36. data/lib/ruby_llm/embedding.rb +9 -2
  37. data/lib/ruby_llm/error.rb +34 -2
  38. data/lib/ruby_llm/evaluation/assertions.rb +40 -0
  39. data/lib/ruby_llm/evaluation/case.rb +52 -0
  40. data/lib/ruby_llm/evaluation/dataset.rb +65 -0
  41. data/lib/ruby_llm/evaluation/evaluator.rb +126 -0
  42. data/lib/ruby_llm/evaluation/evidence.rb +125 -0
  43. data/lib/ruby_llm/evaluation/minitest.rb +26 -0
  44. data/lib/ruby_llm/evaluation/report.rb +103 -0
  45. data/lib/ruby_llm/evaluation/result.rb +75 -0
  46. data/lib/ruby_llm/evaluation/reviewer.rb +20 -0
  47. data/lib/ruby_llm/evaluation/rspec.rb +23 -0
  48. data/lib/ruby_llm/evaluation/runner.rb +93 -0
  49. data/lib/ruby_llm/evaluation/trial.rb +101 -0
  50. data/lib/ruby_llm/evaluation.rb +289 -0
  51. data/lib/ruby_llm/image.rb +12 -4
  52. data/lib/ruby_llm/judge/builder.rb +51 -0
  53. data/lib/ruby_llm/judge/data.rb +43 -0
  54. data/lib/ruby_llm/judge/question.rb +101 -0
  55. data/lib/ruby_llm/judge.rb +179 -0
  56. data/lib/ruby_llm/judgment.rb +125 -0
  57. data/lib/ruby_llm/mcp/apps.rb +25 -0
  58. data/lib/ruby_llm/mcp/client.rb +213 -0
  59. data/lib/ruby_llm/mcp/collection.rb +56 -0
  60. data/lib/ruby_llm/mcp/content.rb +44 -0
  61. data/lib/ruby_llm/mcp/error.rb +27 -0
  62. data/lib/ruby_llm/mcp/http.rb +379 -0
  63. data/lib/ruby_llm/mcp/input_request.rb +118 -0
  64. data/lib/ruby_llm/mcp/input_required_error.rb +29 -0
  65. data/lib/ruby_llm/mcp/listener.rb +168 -0
  66. data/lib/ruby_llm/mcp/oauth/key.rb +78 -0
  67. data/lib/ruby_llm/mcp/oauth/memory_store.rb +28 -0
  68. data/lib/ruby_llm/mcp/oauth/proofs.rb +39 -0
  69. data/lib/ruby_llm/mcp/oauth.rb +653 -0
  70. data/lib/ruby_llm/mcp/param_headers.rb +83 -0
  71. data/lib/ruby_llm/mcp/prompt.rb +71 -0
  72. data/lib/ruby_llm/mcp/resource.rb +84 -0
  73. data/lib/ruby_llm/mcp/resource_template.rb +99 -0
  74. data/lib/ruby_llm/mcp/result.rb +76 -0
  75. data/lib/ruby_llm/mcp/session_expired.rb +10 -0
  76. data/lib/ruby_llm/mcp/stdio.rb +250 -0
  77. data/lib/ruby_llm/mcp/task.rb +151 -0
  78. data/lib/ruby_llm/mcp/tool.rb +130 -0
  79. data/lib/ruby_llm/mcp.rb +1101 -0
  80. data/lib/ruby_llm/message.rb +41 -6
  81. data/lib/ruby_llm/model.rb +2 -1
  82. data/lib/ruby_llm/models/aliases.rb +5 -2
  83. data/lib/ruby_llm/models/lookup.rb +80 -0
  84. data/lib/ruby_llm/models/registry.rb +2 -2
  85. data/lib/ruby_llm/models/schema.rb +1 -1
  86. data/lib/ruby_llm/models.json +23082 -10158
  87. data/lib/ruby_llm/models.rb +20 -69
  88. data/lib/ruby_llm/moderation.rb +7 -2
  89. data/lib/ruby_llm/ocr.rb +7 -2
  90. data/lib/ruby_llm/open_telemetry/attributes.rb +111 -0
  91. data/lib/ruby_llm/open_telemetry.rb +82 -0
  92. data/lib/ruby_llm/probability.rb +30 -0
  93. data/lib/ruby_llm/progress.rb +44 -0
  94. data/lib/ruby_llm/prompt/context.rb +55 -0
  95. data/lib/ruby_llm/prompt.rb +1 -1
  96. data/lib/ruby_llm/protocol/binary_streaming.rb +1 -1
  97. data/lib/ruby_llm/protocol/request_shapes/analysis.rb +190 -0
  98. data/lib/ruby_llm/protocol/request_shapes.rb +183 -0
  99. data/lib/ruby_llm/protocol/stored_uploads.rb +62 -0
  100. data/lib/ruby_llm/protocol/stream_accumulator.rb +9 -25
  101. data/lib/ruby_llm/protocol/streaming.rb +38 -31
  102. data/lib/ruby_llm/protocol.rb +192 -40
  103. data/lib/ruby_llm/protocols/anthropic/chat.rb +39 -32
  104. data/lib/ruby_llm/protocols/anthropic/media.rb +6 -2
  105. data/lib/ruby_llm/protocols/anthropic/models.rb +16 -1
  106. data/lib/ruby_llm/protocols/anthropic/request_shapes.rb +70 -0
  107. data/lib/ruby_llm/protocols/anthropic/streaming.rb +23 -9
  108. data/lib/ruby_llm/protocols/anthropic/tools.rb +43 -0
  109. data/lib/ruby_llm/protocols/anthropic.rb +20 -1
  110. data/lib/ruby_llm/protocols/bedrock/async_videos.rb +9 -17
  111. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +8 -9
  112. data/lib/ruby_llm/protocols/bedrock/rerank.rb +1 -17
  113. data/lib/ruby_llm/protocols/chat_completions/chat.rb +5 -7
  114. data/lib/ruby_llm/protocols/chat_completions/media.rb +34 -8
  115. data/lib/ruby_llm/protocols/chat_completions/request_shapes.rb +121 -0
  116. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +3 -0
  117. data/lib/ruby_llm/protocols/chat_completions.rb +1 -0
  118. data/lib/ruby_llm/protocols/cohere/batch_requests.rb +0 -5
  119. data/lib/ruby_llm/protocols/cohere/batches.rb +3 -7
  120. data/lib/ruby_llm/protocols/cohere/chat.rb +8 -0
  121. data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -4
  122. data/lib/ruby_llm/protocols/cohere/media.rb +6 -2
  123. data/lib/ruby_llm/protocols/cohere/ocr.rb +1 -1
  124. data/lib/ruby_llm/protocols/cohere/streaming.rb +1 -0
  125. data/lib/ruby_llm/protocols/cohere/tools.rb +5 -1
  126. data/lib/ruby_llm/protocols/cohere.rb +1 -0
  127. data/lib/ruby_llm/protocols/converse/chat.rb +22 -131
  128. data/lib/ruby_llm/protocols/converse/media.rb +16 -2
  129. data/lib/ruby_llm/protocols/converse/reasoning.rb +194 -0
  130. data/lib/ruby_llm/protocols/converse/request_shapes.rb +99 -0
  131. data/lib/ruby_llm/protocols/converse/streaming.rb +2 -2
  132. data/lib/ruby_llm/protocols/converse/thinking_stream.rb +2 -1
  133. data/lib/ruby_llm/protocols/converse.rb +3 -5
  134. data/lib/ruby_llm/protocols/deepseek/files.rb +2 -9
  135. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +1 -7
  136. data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +11 -17
  137. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +1 -4
  138. data/lib/ruby_llm/protocols/gemini/chat.rb +50 -21
  139. data/lib/ruby_llm/protocols/gemini/embeddings.rb +2 -2
  140. data/lib/ruby_llm/protocols/gemini/files.rb +1 -1
  141. data/lib/ruby_llm/protocols/gemini/images.rb +5 -1
  142. data/lib/ruby_llm/protocols/gemini/media.rb +15 -3
  143. data/lib/ruby_llm/protocols/gemini/request_shapes.rb +111 -0
  144. data/lib/ruby_llm/protocols/gemini/streaming.rb +2 -1
  145. data/lib/ruby_llm/protocols/gemini/tools.rb +17 -11
  146. data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -0
  147. data/lib/ruby_llm/protocols/gemini/videos.rb +7 -2
  148. data/lib/ruby_llm/protocols/gemini.rb +1 -0
  149. data/lib/ruby_llm/protocols/gpustack/videos.rb +7 -9
  150. data/lib/ruby_llm/protocols/interactions/chat.rb +46 -18
  151. data/lib/ruby_llm/protocols/interactions/content.rb +9 -2
  152. data/lib/ruby_llm/protocols/interactions/request_shapes.rb +62 -0
  153. data/lib/ruby_llm/protocols/interactions/streaming.rb +15 -0
  154. data/lib/ruby_llm/protocols/interactions/tools.rb +28 -4
  155. data/lib/ruby_llm/protocols/interactions/transcription.rb +1 -11
  156. data/lib/ruby_llm/protocols/interactions.rb +1 -0
  157. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +2 -11
  158. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +2 -2
  159. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +2 -6
  160. data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +2 -8
  161. data/lib/ruby_llm/protocols/invoke_model.rb +1 -4
  162. data/lib/ruby_llm/protocols/mistral/content.rb +13 -0
  163. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +11 -1
  164. data/lib/ruby_llm/protocols/mistral/conversations/images.rb +5 -1
  165. data/lib/ruby_llm/protocols/mistral/conversations/request_shapes.rb +45 -0
  166. data/lib/ruby_llm/protocols/mistral/conversations.rb +1 -0
  167. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +11 -1
  168. data/lib/ruby_llm/protocols/openai/decisions.rb +135 -0
  169. data/lib/ruby_llm/protocols/openai/files.rb +0 -7
  170. data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -8
  171. data/lib/ruby_llm/protocols/openrouter/responses.rb +5 -1
  172. data/lib/ruby_llm/protocols/perplexity/agent.rb +116 -0
  173. data/lib/ruby_llm/protocols/perplexity/router.rb +2 -43
  174. data/lib/ruby_llm/protocols/responses/chat.rb +56 -22
  175. data/lib/ruby_llm/protocols/responses/media.rb +11 -7
  176. data/lib/ruby_llm/protocols/responses/request_shapes.rb +70 -0
  177. data/lib/ruby_llm/protocols/responses/streaming.rb +23 -7
  178. data/lib/ruby_llm/protocols/responses/token_counting.rb +2 -2
  179. data/lib/ruby_llm/protocols/responses/tools.rb +27 -0
  180. data/lib/ruby_llm/protocols/responses.rb +1 -0
  181. data/lib/ruby_llm/protocols/system_one/judgments.rb +42 -0
  182. data/lib/ruby_llm/protocols/system_one/models.rb +27 -0
  183. data/lib/ruby_llm/protocols/system_one/responses.rb +55 -0
  184. data/lib/ruby_llm/protocols/system_one.rb +27 -0
  185. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +2 -2
  186. data/lib/ruby_llm/protocols/vertexai/files.rb +34 -6
  187. data/lib/ruby_llm/protocols/vertexai/ranking.rb +9 -30
  188. data/lib/ruby_llm/protocols/vertexai/research.rb +9 -41
  189. data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +2 -5
  190. data/lib/ruby_llm/provider.rb +87 -15
  191. data/lib/ruby_llm/providers/anthropic.rb +23 -0
  192. data/lib/ruby_llm/providers/azure/chat.rb +4 -0
  193. data/lib/ruby_llm/providers/azure/images.rb +2 -2
  194. data/lib/ruby_llm/providers/azure/media.rb +8 -3
  195. data/lib/ruby_llm/providers/azure/videos.rb +7 -2
  196. data/lib/ruby_llm/providers/azure.rb +24 -4
  197. data/lib/ruby_llm/providers/bedrock/auth.rb +1 -1
  198. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +1 -1
  199. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -3
  200. data/lib/ruby_llm/providers/bedrock/mantle.rb +5 -9
  201. data/lib/ruby_llm/providers/bedrock.rb +38 -6
  202. data/lib/ruby_llm/providers/deepseek/chat.rb +6 -1
  203. data/lib/ruby_llm/providers/deepseek/responses.rb +6 -0
  204. data/lib/ruby_llm/providers/deepseek.rb +4 -0
  205. data/lib/ruby_llm/providers/gemini.rb +4 -0
  206. data/lib/ruby_llm/providers/gpustack/media.rb +8 -5
  207. data/lib/ruby_llm/providers/hetzner/chat.rb +39 -0
  208. data/lib/ruby_llm/providers/hetzner.rb +41 -0
  209. data/lib/ruby_llm/providers/mistral/chat.rb +16 -12
  210. data/lib/ruby_llm/providers/mistral/media.rb +8 -4
  211. data/lib/ruby_llm/providers/mistral/ocr.rb +1 -5
  212. data/lib/ruby_llm/providers/ollama/judgments.rb +26 -0
  213. data/lib/ruby_llm/providers/ollama/media.rb +6 -2
  214. data/lib/ruby_llm/providers/ollama/models.rb +2 -0
  215. data/lib/ruby_llm/providers/ollama.rb +11 -0
  216. data/lib/ruby_llm/providers/openai/capabilities.rb +3 -1
  217. data/lib/ruby_llm/providers/openai.rb +14 -0
  218. data/lib/ruby_llm/providers/openrouter/chat.rb +20 -24
  219. data/lib/ruby_llm/providers/openrouter/embeddings.rb +2 -0
  220. data/lib/ruby_llm/providers/openrouter/media.rb +5 -1
  221. data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -1
  222. data/lib/ruby_llm/providers/openrouter/videos.rb +10 -5
  223. data/lib/ruby_llm/providers/openrouter.rb +4 -0
  224. data/lib/ruby_llm/providers/perplexity/chat.rb +4 -0
  225. data/lib/ruby_llm/providers/perplexity/media.rb +5 -11
  226. data/lib/ruby_llm/providers/perplexity/models.rb +8 -4
  227. data/lib/ruby_llm/providers/perplexity.rb +11 -0
  228. data/lib/ruby_llm/providers/typesafe.rb +32 -0
  229. data/lib/ruby_llm/providers/vertexai/credentials.rb +63 -0
  230. data/lib/ruby_llm/providers/vertexai/gemini.rb +2 -2
  231. data/lib/ruby_llm/providers/vertexai/transcription.rb +1 -0
  232. data/lib/ruby_llm/providers/vertexai.rb +27 -28
  233. data/lib/ruby_llm/providers/xai/chat.rb +6 -0
  234. data/lib/ruby_llm/providers/xai/images.rb +9 -10
  235. data/lib/ruby_llm/providers/xai/responses.rb +10 -8
  236. data/lib/ruby_llm/providers/xai/videos.rb +11 -9
  237. data/lib/ruby_llm/providers/xai.rb +4 -0
  238. data/lib/ruby_llm/railtie.rb +11 -0
  239. data/lib/ruby_llm/request_shape/part.rb +117 -0
  240. data/lib/ruby_llm/request_shape/problem.rb +63 -0
  241. data/lib/ruby_llm/request_shape/tool_round.rb +64 -0
  242. data/lib/ruby_llm/request_shape/turn.rb +54 -0
  243. data/lib/ruby_llm/request_shape.rb +160 -0
  244. data/lib/ruby_llm/rerank.rb +9 -2
  245. data/lib/ruby_llm/research_job.rb +21 -5
  246. data/lib/ruby_llm/score.rb +42 -0
  247. data/lib/ruby_llm/search_results.rb +2 -2
  248. data/lib/ruby_llm/server_tool_call.rb +10 -2
  249. data/lib/ruby_llm/speech.rb +8 -1
  250. data/lib/ruby_llm/support/cancellation.rb +39 -0
  251. data/lib/ruby_llm/support/inspectable.rb +3 -2
  252. data/lib/ruby_llm/support/instrumentation.rb +38 -1
  253. data/lib/ruby_llm/support/process_cache.rb +60 -0
  254. data/lib/ruby_llm/support/progress_reporter.rb +33 -0
  255. data/lib/ruby_llm/thinking.rb +2 -1
  256. data/lib/ruby_llm/tokens.rb +57 -14
  257. data/lib/ruby_llm/tool.rb +50 -3
  258. data/lib/ruby_llm/tool_call.rb +17 -2
  259. data/lib/ruby_llm/transcription.rb +10 -3
  260. data/lib/ruby_llm/transport/connection.rb +127 -44
  261. data/lib/ruby_llm/transport/error_middleware.rb +90 -24
  262. data/lib/ruby_llm/transport/event_stream_parser.rb +121 -0
  263. data/lib/ruby_llm/transport/json_response.rb +33 -0
  264. data/lib/ruby_llm/transport/signing_middleware.rb +29 -0
  265. data/lib/ruby_llm/version.rb +1 -1
  266. data/lib/ruby_llm/video.rb +5 -3
  267. data/lib/ruby_llm/video_job.rb +48 -5
  268. data/lib/ruby_llm.rb +106 -5
  269. data/lib/tasks/ruby_llm/evaluations.rb +43 -0
  270. data/lib/tasks/ruby_llm.rake +6 -9
  271. data/skills/rubyllm/SKILL.md +33 -12
  272. metadata +95 -37
  273. data/lib/generators/ruby_llm/upgrade/legacy_content_sql.rb +0 -34
  274. data/lib/generators/ruby_llm/upgrade/online_copy_migration/data.rb +0 -341
  275. data/lib/generators/ruby_llm/upgrade/online_copy_migration/journal.rb +0 -171
  276. data/lib/generators/ruby_llm/upgrade/online_copy_migration/verification.rb +0 -197
  277. data/lib/generators/ruby_llm/upgrade/online_copy_migration.rb +0 -174
  278. data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +0 -468
  279. data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +0 -101
  280. data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +0 -247
  281. data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +0 -662
  282. data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +0 -251
  283. data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +0 -13
  284. data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +0 -359
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class Responses
6
+ # Describes Responses API requests, including token counting and
7
+ # compaction, as RequestShape objects. Each input item is a turn, and a
8
+ # call's output answers the call by id.
9
+ module RequestShapes
10
+ def parse_request_shape(payload)
11
+ input = payload['input']
12
+ return unless input.is_a?(Array)
13
+
14
+ turns = input.each_with_index.map do |item, index|
15
+ item = shape_hash(item)
16
+ shape_turn(index, item['role'] || item['type'], shape_input_speaker(item), shape_input_item(item))
17
+ end
18
+ shape_request(turns, payload:, instructions: shape_instructions(payload['instructions']),
19
+ tool_names: shape_list(payload['tools']).map { |tool| shape_tool_name(tool) },
20
+ thinking_settings: shape_settings(payload['reasoning'], 'effort', 'summary'))
21
+ end
22
+
23
+ private
24
+
25
+ def shape_input_speaker(item)
26
+ case item['type']
27
+ when nil, 'message' then ChatCompletions::RequestShapes::SPEAKERS.fetch(item['role'], :user)
28
+ when /_output\z|_response\z/ then :tool
29
+ else :model
30
+ end
31
+ end
32
+
33
+ def shape_input_item(item)
34
+ case item['type']
35
+ when nil, 'message' then shape_input_content(item['content'])
36
+ when 'function_call' then [shape_tool_call(item['name'], item['arguments'], id: item['call_id'])]
37
+ when 'function_call_output'
38
+ shape_tool_result(item['output'], id: item['call_id']) { |part| shape_input_part(part) }
39
+ when 'reasoning' then [shape_summary(item['summary'], signed: shape_signature?(item['encrypted_content']))]
40
+ else [shape_other(item['type'])]
41
+ end
42
+ end
43
+
44
+ def shape_input_content(content)
45
+ return [shape_text(content)] if content.is_a?(String)
46
+
47
+ shape_list(content).map { |part| shape_input_part(shape_hash(part)) }
48
+ end
49
+
50
+ def shape_input_part(part)
51
+ case part['type']
52
+ when 'input_text', 'output_text' then shape_text(part['text'])
53
+ when 'input_image'
54
+ part.key?('file_id') ? shape_file(kind: :image) : shape_url(part['image_url'], kind: :image)
55
+ when 'input_file' then shape_input_file(part)
56
+ when 'input_audio' then shape_inline(shape_hash(part['input_audio'])['data'], kind: :audio)
57
+ else shape_other(part['type'])
58
+ end
59
+ end
60
+
61
+ def shape_input_file(part)
62
+ return shape_file(kind: :document) if part.key?('file_id')
63
+ return shape_url(part['file_url'], kind: :document) if part.key?('file_url')
64
+
65
+ shape_url(part['file_data'], kind: :document)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -9,6 +9,7 @@ module RubyLLM
9
9
  ERROR_STATUSES = {
10
10
  'server_error' => 500,
11
11
  'rate_limit_exceeded' => 429,
12
+ 'too_many_requests' => 429,
12
13
  'insufficient_quota' => 429
13
14
  }.freeze
14
15
 
@@ -38,7 +39,7 @@ module RubyLLM
38
39
  when 'response.completed', 'response.incomplete'
39
40
  build_final_chunk(data)
40
41
  when 'response.failed'
41
- raise Error, data.dig('response', 'error', 'message')
42
+ raise_failed_response(data['response'] || {})
42
43
  else
43
44
  chunk
44
45
  end
@@ -95,17 +96,32 @@ module RubyLLM
95
96
  finish_reason: parse_finish_reason(response),
96
97
  citations: parse_citations(response, output, nil),
97
98
  server_tool_calls: server_tool_calls,
98
- raw_content: server_tool_calls.any? ? output : nil,
99
- **parse_usage(response['usage'] || {})
99
+ raw_content: keep_raw_output?(output, server_tool_calls) ? output : nil,
100
+ **parse_usage(response['usage'] || {}),
101
+ server_tool_use: parse_server_tool_use(response)
100
102
  end
101
103
 
102
- # Responses reports a stream error as a flat event carrying a code,
103
- # where Chat Completions nests type and message under an error object.
104
+ def raise_failed_response(response)
105
+ body = { 'error' => response['error'] }
106
+ raise_stream_error(JSON.generate(body), body, nil)
107
+ end
108
+
109
+ # OpenAI reports a stream error as a flat event carrying a code. Azure
110
+ # nests the code and type under an error object, as a failed response
111
+ # and Chat Completions do.
104
112
  def parse_streaming_error(data)
105
113
  event = JSON.parse(data)
106
- return super unless event.is_a?(Hash) && event['type'] == 'error'
114
+ error = stream_error(event)
115
+ return super unless error
116
+
117
+ [ERROR_STATUSES[error['code']] || ERROR_STATUSES.fetch(error['type'], 400), error['message']]
118
+ end
119
+
120
+ def stream_error(event)
121
+ return unless event.is_a?(Hash)
122
+ return event['error'] if event['error'].is_a?(Hash)
107
123
 
108
- [ERROR_STATUSES.fetch(event['code'], 400), event['message']]
124
+ event if event['type'] == 'error'
109
125
  end
110
126
 
111
127
  def chunk(content: nil, **attributes)
@@ -13,8 +13,8 @@ module RubyLLM
13
13
  "#{completion_url}/input_tokens"
14
14
  end
15
15
 
16
- def render_count_tokens_payload(messages, model:, **options)
17
- render_payload(messages, model: model, temperature: nil, **options).slice(*COUNT_TOKENS_KEYS)
16
+ def render_count_tokens_payload(messages, model:, **)
17
+ render_payload(messages, model: model, temperature: nil, **).slice(*COUNT_TOKENS_KEYS)
18
18
  end
19
19
 
20
20
  def parse_count_tokens_response(response)
@@ -8,6 +8,8 @@ module RubyLLM
8
8
  module Tools
9
9
  module_function
10
10
 
11
+ NATIVE_TOOL_SEARCH = { type: 'tool_search' }.freeze
12
+
11
13
  def tool_for(tool)
12
14
  definition = {
13
15
  type: 'function',
@@ -16,12 +18,37 @@ module RubyLLM
16
18
  parameters: ChatCompletions::Tools.parameters_schema_for(tool),
17
19
  strict: false
18
20
  }
21
+ definition[:defer_loading] = true if deferred?(tool)
19
22
 
20
23
  return definition if tool.provider_options.empty?
21
24
 
22
25
  RubyLLM::Support::Utils.deep_merge(definition, tool.provider_options)
23
26
  end
24
27
 
28
+ def deferred?(tool)
29
+ tool.is_a?(RubyLLM::Tool::Deferred)
30
+ end
31
+
32
+ # The API requires a search tool next to deferred tools, and rejects
33
+ # search items in the input of a request that declares none.
34
+ def apply_tool_search(payload)
35
+ tools = Array(payload[:tools])
36
+ return payload if tools.any? { |tool| search_tool?(tool) }
37
+ return payload.merge(tools: tools + [NATIVE_TOOL_SEARCH.dup]) if defers_loading?(tools)
38
+
39
+ payload.merge(input: payload[:input].reject do |item|
40
+ Chat::TOOL_SEARCH_ITEM_TYPES.include?(item[:type] || item['type'])
41
+ end)
42
+ end
43
+
44
+ def defers_loading?(tools)
45
+ tools.any? { |tool| tool[:defer_loading] || tool['defer_loading'] }
46
+ end
47
+
48
+ def search_tool?(tool)
49
+ (tool[:type] || tool['type']) == NATIVE_TOOL_SEARCH[:type]
50
+ end
51
+
25
52
  def build_tool_choice(tool_choice)
26
53
  case tool_choice
27
54
  when :auto, :none, :required
@@ -10,6 +10,7 @@ module RubyLLM
10
10
  include Responses::Approvals
11
11
  include Responses::Chat
12
12
  include Responses::Media
13
+ include Responses::RequestShapes
13
14
  include Responses::Streaming
14
15
  include Responses::Tools
15
16
 
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class SystemOne
6
+ module Judgments # :nodoc:
7
+ TYPES = { probability: 'noul', choice: 'choice', score: 'score' }.freeze
8
+ BOOLEAN_KEYS = { 'yes' => 'true', 'true' => 'true', 'no' => 'false', 'false' => 'false' }.freeze
9
+
10
+ module_function
11
+
12
+ def judgment_url
13
+ 'v1/systemone'
14
+ end
15
+
16
+ def render_judgment_payload(input, questions:, model:, with: [], provider_options: {})
17
+ raise UnsupportedAttachmentError, with.first.mime_type unless with.empty?
18
+
19
+ reserved = provider_options.keys.map(&:to_s) & %w[model state questions]
20
+ unless reserved.empty?
21
+ raise ArgumentError, "Use the judgment arguments instead of provider_options for #{reserved.join(', ')}"
22
+ end
23
+
24
+ {
25
+ model:,
26
+ state: input,
27
+ questions: questions.transform_values { |question| render_question(question) }
28
+ }.merge(provider_options)
29
+ end
30
+
31
+ def render_question(question)
32
+ criteria = question.criteria
33
+ if question.type == :probability && criteria
34
+ criteria = criteria.transform_keys { |key| BOOLEAN_KEYS.fetch(key.to_s) }
35
+ end
36
+
37
+ { type: TYPES.fetch(question.type), instructions: question.instructions, criteria: }.compact
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class SystemOne
6
+ module Models # :nodoc:
7
+ module_function
8
+
9
+ def models_url
10
+ 'v1/models'
11
+ end
12
+
13
+ def parse_list_models_response(response, slug)
14
+ Array(response.body['models']).map do |entry|
15
+ Model.new(
16
+ id: entry['name'], name: entry['name'], provider: slug,
17
+ created_at: entry['release_date'],
18
+ modalities: { input: ['text'], output: ['judgment'] },
19
+ capabilities: ['judgment'],
20
+ metadata: { description: entry['description'] }.compact
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class SystemOne
6
+ module Responses # :nodoc:
7
+ module_function
8
+
9
+ def parse_judgment_response(response, questions:)
10
+ body = response.body
11
+ answers = questions.values.to_h do |question|
12
+ [question.name, parse_answer(body.fetch('answers').fetch(question.name.to_s), question)]
13
+ end
14
+ RubyLLM::Judgment.new(
15
+ answers:, model: body['model'], raw: response, model_info: @model,
16
+ tokens: Tokens.new(input: body.dig('usage', 'input_tokens'), output: body.dig('usage', 'output_tokens'))
17
+ )
18
+ rescue KeyError, NoMethodError, TypeError => e
19
+ raise Error.new("System One returned an invalid judgment: #{e.message}", response:)
20
+ end
21
+
22
+ def parse_answer(answer, question)
23
+ case question.type
24
+ when :probability then RubyLLM::Probability.new(probability: answer.fetch('noul'))
25
+ when :choice then parse_choice(answer, question)
26
+ when :score then parse_score(answer, question)
27
+ end
28
+ end
29
+
30
+ def parse_choice(answer, question)
31
+ options = question.criteria.keys.to_h { |key| [key.to_s, key] }
32
+ RubyLLM::Choice.new(
33
+ choice: options.fetch(answer.fetch('choice')),
34
+ probabilities: parse_probabilities(answer.fetch('probabilities'), options),
35
+ confidence: answer.fetch('confidence')
36
+ )
37
+ end
38
+
39
+ def parse_score(answer, question)
40
+ indexes = question.criteria.each_index.to_h { |index| [index.to_s, index] }
41
+ legend = answer.fetch('legend')
42
+ RubyLLM::Score.new(
43
+ score: answer.fetch('score'), levels: indexes.keys.map { |key| legend.fetch(key) },
44
+ probabilities: parse_probabilities(answer.fetch('probabilities'), indexes),
45
+ confidence: answer.fetch('confidence')
46
+ )
47
+ end
48
+
49
+ def parse_probabilities(values, keys)
50
+ keys.to_h { |wire, key| [key, values.fetch(wire)] }
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ # The System One protocol for typed questions and probabilistic answers.
6
+ class SystemOne < Protocol
7
+ include SystemOne::Models
8
+ include SystemOne::Judgments
9
+ include SystemOne::Responses
10
+
11
+ def parse_error_response(response)
12
+ body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
13
+ return unless body.is_a?(Hash)
14
+
15
+ detail = body['detail']
16
+ case detail
17
+ when String
18
+ detail
19
+ when Hash
20
+ detail['message']
21
+ end
22
+ rescue JSON::ParserError
23
+ nil
24
+ end
25
+ end
26
+ end
27
+ end
@@ -11,8 +11,8 @@ module RubyLLM
11
11
  def validate_embedding_request!(request)
12
12
  text = request.fetch(:text)
13
13
  values = text.is_a?(Array) ? text : [text]
14
- unless values.any? && values.all? { |value| value.is_a?(String) && !value.empty? }
15
- raise ArgumentError, 'Vertex AI embedding batches require nonempty text strings'
14
+ unless values.any? && values.all?(String)
15
+ raise ArgumentError, 'Vertex AI embedding batches require a nonempty list of text strings'
16
16
  end
17
17
 
18
18
  payload = request.fetch(:payload)
@@ -8,6 +8,12 @@ module RubyLLM
8
8
  # GCS object names allow spaces and other characters URI() rejects.
9
9
  GCS_URI = %r{\Ags://([^/]+)/?(.*)\z}m
10
10
 
11
+ STORAGE_CLIENTS = Support::ProcessCache.new
12
+
13
+ def self.storage_clients
14
+ STORAGE_CLIENTS
15
+ end
16
+
11
17
  # rubocop:disable-next Lint/UnusedMethodArgument
12
18
  def upload(file, filename: nil, purpose: nil, expires_in: nil, uri: nil, content_type: nil,
13
19
  provider_options: {})
@@ -74,17 +80,39 @@ module RubyLLM
74
80
  def storage
75
81
  require 'google/cloud/storage'
76
82
 
77
- options = { project_id: @config.vertexai_project_id }
78
- if @config.vertexai_service_account_key
79
- options[:credentials] =
80
- JSON.parse(@config.vertexai_service_account_key)
81
- end
82
- ::Google::Cloud::Storage.new(**options)
83
+ STORAGE_CLIENTS.fetch(storage_key) { ::Google::Cloud::Storage.new(**storage_options) }
83
84
  rescue LoadError
84
85
  raise Error, 'The google-cloud-storage gem is required for Vertex AI file uploads. ' \
85
86
  'Please add it to your Gemfile: gem "google-cloud-storage"'
86
87
  end
87
88
 
89
+ # Without a key, Cloud Storage resolves its own credentials, including
90
+ # settings of its own such as STORAGE_KEYFILE.
91
+ def storage_options
92
+ options = { project_id: @config.vertexai_project_id }
93
+ options[:credentials] = @provider.google_credentials if @config.vertexai_service_account_key
94
+ options
95
+ end
96
+
97
+ # A client keeps the credentials, scope, endpoint, and settings it was
98
+ # built with, so only callers that would build the same one share it.
99
+ def storage_key
100
+ settings = ::Google::Cloud::Storage.configure
101
+ [
102
+ storage_options,
103
+ settings.fields!.to_h { |field| [field, settings[field]] },
104
+ ::Google::Cloud.configure.credentials,
105
+ ENV.values_at(*credential_variables)
106
+ ]
107
+ end
108
+
109
+ def credential_variables
110
+ storage = ::Google::Cloud::Storage::Credentials
111
+ loader = ::Google::Auth::CredentialsLoader
112
+ storage::PATH_ENV_VARS + storage::JSON_ENV_VARS +
113
+ loader.constants.grep(/_VAR\z/).map { |name| loader.const_get(name) }
114
+ end
115
+
88
116
  def bucket(name)
89
117
  storage.bucket(name) || raise(Error, "GCS bucket not found: #{name}")
90
118
  end
@@ -15,50 +15,29 @@ module RubyLLM
15
15
  end
16
16
 
17
17
  def render_rerank_payload(query, documents, model:, top_n: nil, provider_options: {})
18
- validate_ranking_input(query, documents, top_n)
18
+ raise ArgumentError, 'Vertex AI Search reranking documents must be text' unless documents.all?(String)
19
+
19
20
  records = documents.each_with_index.map { |document, index| { id: index.to_s, content: document } }
20
21
  { model: model, query: query, records: records, topN: top_n }.compact.merge(provider_options)
21
22
  end
22
23
 
23
24
  def parse_rerank_response(response, model:, documents: [])
24
- records = response.body['records']
25
- raise Error.new('Vertex AI Search returned no ranking records', response:) unless records.is_a?(Array)
26
-
27
- seen = []
28
- results = records.map do |record|
25
+ seen = Set.new
26
+ results = Array(response.body['records']).map do |record|
29
27
  index = ranking_index(record, documents)
30
- raise Error.new('Vertex AI Search returned a duplicate document id', response:) if seen.include?(index)
28
+ raise Error.new('Vertex AI Search returned a duplicate document id', response:) unless seen.add?(index)
31
29
 
32
- seen << index
33
- Rerank::Result.new(index: index, document: documents[index], score: record.fetch('score'))
30
+ Rerank::Result.new(index: index, document: documents[index], score: record['score'])
34
31
  end
35
32
  Rerank.new(results: results, model: model, raw: response.body)
36
33
  end
37
34
 
38
35
  private
39
36
 
40
- def validate_ranking_input(query, documents, top_n)
41
- unless query.is_a?(String) && !query.empty?
42
- raise ArgumentError, 'Vertex AI Search reranking requires a nonempty query'
43
- end
44
- unless valid_ranking_documents?(documents)
45
- raise ArgumentError, 'Vertex AI Search reranking accepts between 1 and 1000 nonempty text documents'
46
- end
47
- return if top_n.nil? || (top_n.is_a?(Integer) && top_n.positive?)
48
-
49
- raise ArgumentError, 'top_n must be a positive integer'
50
- end
51
-
52
- def valid_ranking_documents?(documents)
53
- documents.is_a?(Array) && (1..1000).cover?(documents.length) &&
54
- documents.all? { |document| document.is_a?(String) && !document.empty? }
55
- end
56
-
57
37
  def ranking_index(record, documents)
58
- id = record['id'] if record.is_a?(Hash)
59
- unless id.is_a?(String) && id.match?(/\A(?:0|[1-9]\d*)\z/) && id.to_i < documents.length &&
60
- record['score'].is_a?(Numeric)
61
- raise Error, 'Vertex AI Search returned an invalid document id or score'
38
+ id = record['id']
39
+ unless id.is_a?(String) && id.match?(/\A(?:0|[1-9]\d*)\z/) && id.to_i < documents.length
40
+ raise Error, 'Vertex AI Search returned an invalid document id'
62
41
  end
63
42
 
64
43
  id.to_i
@@ -22,7 +22,6 @@ module RubyLLM
22
22
  def server_tool_aliases = SERVER_TOOL_ALIASES
23
23
 
24
24
  def create_research_job(prompt, agent:, with: nil, provider_tools: nil, provider_options: {})
25
- validate_research_agent(agent)
26
25
  payload = render_research_payload(prompt, agent:, with:, provider_tools:, provider_options:)
27
26
  response = @connection.post(research_url, payload, idempotent: false)
28
27
  parse_research_job(response, agent:)
@@ -37,14 +36,14 @@ module RubyLLM
37
36
  response = @connection.get(research_url(job.id)) do |request|
38
37
  request.options.timeout = request_timeout(timeout)
39
38
  end
40
- parse_research_state(response, id: job.id, agent: job.agent)
39
+ parse_research_state(response)
41
40
  end
42
41
 
43
42
  def cancel_research_job(job, timeout: nil)
44
43
  response = @connection.post("#{research_url(job.id)}/cancel", {}, idempotent: false) do |request|
45
44
  request.options.timeout = request_timeout(timeout)
46
45
  end
47
- parse_research_state(response, id: job.id, agent: job.agent)
46
+ parse_research_state(response)
48
47
  end
49
48
 
50
49
  private
@@ -65,17 +64,10 @@ module RubyLLM
65
64
  [timeout, @config.request_timeout].compact.min
66
65
  end
67
66
 
68
- def validate_research_agent(agent)
69
- return if agent == 'deep-research-preview-04-2026'
70
-
71
- raise ArgumentError, 'Vertex AI research requires the supported Deep Research agent ID'
72
- end
73
-
74
67
  def render_research_payload(prompt, agent:, with:, provider_tools:, provider_options:)
75
68
  raise ArgumentError, 'Research requires a nonempty prompt' unless prompt.is_a?(String) && !prompt.empty?
76
69
 
77
70
  attachments = Attachment.wrap(with, config: @config)
78
- validate_research_attachments(attachments)
79
71
  payload = { agent:, input: render_interaction_content(prompt, attachments), background: true, stream: false }
80
72
  payload[:tools] = [] unless provider_tools.nil?
81
73
  options = render_research_options(provider_options)
@@ -87,14 +79,6 @@ module RubyLLM
87
79
  apply_provider_tools(payload, entries).merge(options)
88
80
  end
89
81
 
90
- def validate_research_attachments(attachments)
91
- attachments.each do |attachment|
92
- next if attachment.image? || attachment.pdf? || attachment.text?
93
-
94
- raise UnsupportedAttachmentError, attachment.mime_type
95
- end
96
- end
97
-
98
82
  def render_research_options(provider_options)
99
83
  options = Support::Utils.deep_symbolize_keys(provider_options)
100
84
  unsupported = options.keys - %i[agent_config service_tier]
@@ -121,7 +105,7 @@ module RubyLLM
121
105
 
122
106
  def parse_research_job(response, agent: nil, id: nil)
123
107
  data = response.body
124
- state = parse_research_state(response, agent:, id:)
108
+ state = parse_research_state(response)
125
109
  ResearchJob.new(id: data['id'], provider: @provider.slug, agent: data['agent'] || agent, protocol: self,
126
110
  **state)
127
111
  rescue RubyLLM::Error, ArgumentError => e
@@ -135,9 +119,9 @@ module RubyLLM
135
119
  job:, response:), cause: e
136
120
  end
137
121
 
138
- def parse_research_state(response, id: nil, agent: nil)
122
+ def parse_research_state(response)
139
123
  data = response.body
140
- validate_research_response(data, response, id:, agent:)
124
+ validate_research_response(data, response)
141
125
  status = STATUSES.fetch(data['status'])
142
126
  message = parse_research_message(data, response) if %i[completed incomplete].include?(status)
143
127
  {
@@ -146,21 +130,10 @@ module RubyLLM
146
130
  }
147
131
  end
148
132
 
149
- def validate_research_response(data, response, id:, agent:)
150
- unless data.is_a?(Hash) && !data['id'].to_s.empty? && STATUSES.key?(data['status'])
151
- raise RubyLLM::Error.new('Vertex AI research returned no recognized job state', response:)
152
- end
153
-
154
- validate_research_identity(data, response, id:, agent:)
155
- validate_research_agent(data['agent'] || agent)
156
- rescue ArgumentError => e
157
- raise RubyLLM::Error.new(e.message, response:), cause: e
158
- end
133
+ def validate_research_response(data, response)
134
+ return if data.is_a?(Hash) && !data['id'].to_s.empty? && STATUSES.key?(data['status'])
159
135
 
160
- def validate_research_identity(data, response, id:, agent:)
161
- return unless (id && id != data['id']) || (agent && data['agent'] && agent != data['agent'])
162
-
163
- raise RubyLLM::Error.new('Vertex AI research returned a different job identity', response:)
136
+ raise RubyLLM::Error.new('Vertex AI research returned no recognized job state', response:)
164
137
  end
165
138
 
166
139
  def parse_research_tokens(data)
@@ -174,12 +147,7 @@ module RubyLLM
174
147
  state = data.merge('status' => data['status'] == 'completed' ? 'completed' : 'incomplete')
175
148
  tokens = parse_research_tokens(data)
176
149
  cost = Cost.from_h({ total: tokens.reported_cost }.compact, tokens:)
177
- message = parse_completion_body(state, raw: response, model: nil, cost:)
178
- if message.content.to_s.empty? && message.attachments.empty?
179
- raise RubyLLM::Error.new('Vertex AI research finished without a report', response:)
180
- end
181
-
182
- message
150
+ parse_completion_body(state, raw: response, model: nil, cost:)
183
151
  end
184
152
 
185
153
  def parse_research_error(data)
@@ -4,7 +4,6 @@ module RubyLLM
4
4
  module Protocols
5
5
  module XAI
6
6
  module StreamingTranscription # :nodoc: all
7
- SAMPLE_RATES = [8000, 16_000, 22_050, 24_000, 44_100, 48_000].freeze
8
7
  ENCODINGS = { [1, 16] => 'pcm', [6, 8] => 'alaw', [7, 8] => 'mulaw' }.freeze
9
8
 
10
9
  def stream_transcription(payload, model:, &block)
@@ -53,9 +52,7 @@ module RubyLLM
53
52
 
54
53
  def streaming_transcription_url(payload, audio:)
55
54
  encoding = ENCODINGS[[audio.encoding, audio.bits_per_sample]]
56
- unless encoding && SAMPLE_RATES.include?(audio.sample_rate) && (1..8).cover?(audio.channels)
57
- raise ArgumentError, 'xAI streaming requires 16-bit PCM or 8-bit G.711 WAV audio at a supported sample rate'
58
- end
55
+ raise ArgumentError, 'xAI streaming requires 16-bit PCM or 8-bit G.711 WAV audio' unless encoding
59
56
 
60
57
  params = payload.except(:file).merge(encoding:, sample_rate: audio.sample_rate, interim_results: true)
61
58
  params[:channels] = audio.channels
@@ -68,7 +65,7 @@ module RubyLLM
68
65
 
69
66
  def send_transcription_audio(socket, audio)
70
67
  offset = 0
71
- bytes = audio.sample_rate * audio.channels * audio.bits_per_sample / 8 / 10
68
+ bytes = [audio.sample_rate * audio.channels * audio.bits_per_sample / 8 / 10, 1].max
72
69
  while offset < audio.data.bytesize
73
70
  socket.send_binary(audio.data.byteslice(offset, bytes))
74
71
  offset += bytes