ruby_llm 1.16.0 → 2.0.0.rc2

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 (475) hide show
  1. checksums.yaml +4 -4
  2. data/.rdoc_options +25 -0
  3. data/README.md +85 -32
  4. data/exe/ruby_llm +8 -0
  5. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +0 -1
  6. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +3 -43
  7. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +11 -3
  8. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +8 -0
  9. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +4 -4
  10. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +1 -1
  11. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +1 -1
  12. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +1 -1
  13. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +2 -2
  14. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +1 -1
  15. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +1 -1
  16. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +1 -1
  17. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +6 -4
  18. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +1 -1
  19. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +2 -2
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +5 -6
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +2 -2
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +5 -5
  23. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +1 -1
  24. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +1 -1
  25. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +1 -1
  26. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +2 -2
  27. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +1 -1
  28. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +1 -1
  29. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +1 -1
  30. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +6 -4
  31. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +1 -1
  32. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +4 -6
  33. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +2 -2
  34. data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +5 -6
  35. data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +2 -2
  36. data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +3 -3
  37. data/lib/generators/ruby_llm/generator_helpers.rb +106 -62
  38. data/lib/generators/ruby_llm/install/install_generator.rb +3 -11
  39. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +2 -0
  40. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +14 -8
  41. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +117 -0
  42. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +0 -8
  43. data/lib/generators/ruby_llm/provider/cli.rb +175 -0
  44. data/lib/generators/ruby_llm/provider/scaffold.rb +323 -0
  45. data/lib/generators/ruby_llm/provider/templates/core/provider.rb.erb +37 -0
  46. data/lib/generators/ruby_llm/provider/templates/core/provider_spec.rb.erb +34 -0
  47. data/lib/generators/ruby_llm/provider/templates/gem/archspec.rb.erb +14 -0
  48. data/lib/generators/ruby_llm/provider/templates/gem/bin/console.erb +15 -0
  49. data/lib/generators/ruby_llm/provider/templates/gem/bin/setup.erb +5 -0
  50. data/lib/generators/ruby_llm/provider/templates/gem/chat_schema_spec.rb.erb +30 -0
  51. data/lib/generators/ruby_llm/provider/templates/gem/chat_spec.rb.erb +35 -0
  52. data/lib/generators/ruby_llm/provider/templates/gem/chat_streaming_spec.rb.erb +22 -0
  53. data/lib/generators/ruby_llm/provider/templates/gem/chat_tools_spec.rb.erb +30 -0
  54. data/lib/generators/ruby_llm/provider/templates/gem/ci.yml.erb +32 -0
  55. data/lib/generators/ruby_llm/provider/templates/gem/embedding_spec.rb.erb +49 -0
  56. data/lib/generators/ruby_llm/provider/templates/gem/env.erb +2 -0
  57. data/lib/generators/ruby_llm/provider/templates/gem/fixtures_gitkeep.erb +1 -0
  58. data/lib/generators/ruby_llm/provider/templates/gem/flayignore.erb +1 -0
  59. data/lib/generators/ruby_llm/provider/templates/gem/gemfile.erb +23 -0
  60. data/lib/generators/ruby_llm/provider/templates/gem/gemspec.erb +28 -0
  61. data/lib/generators/ruby_llm/provider/templates/gem/gitignore.erb +7 -0
  62. data/lib/generators/ruby_llm/provider/templates/gem/gitleaks.yml.erb +22 -0
  63. data/lib/generators/ruby_llm/provider/templates/gem/image_spec.rb.erb +23 -0
  64. data/lib/generators/ruby_llm/provider/templates/gem/license.erb +21 -0
  65. data/lib/generators/ruby_llm/provider/templates/gem/models.rb.erb +19 -0
  66. data/lib/generators/ruby_llm/provider/templates/gem/models_spec.rb.erb +17 -0
  67. data/lib/generators/ruby_llm/provider/templates/gem/moderation_spec.rb.erb +22 -0
  68. data/lib/generators/ruby_llm/provider/templates/gem/overcommit.yml.erb +31 -0
  69. data/lib/generators/ruby_llm/provider/templates/gem/provider.rb.erb +52 -0
  70. data/lib/generators/ruby_llm/provider/templates/gem/provider_spec.rb.erb +38 -0
  71. data/lib/generators/ruby_llm/provider/templates/gem/rakefile.erb +38 -0
  72. data/lib/generators/ruby_llm/provider/templates/gem/readme.md.erb +50 -0
  73. data/lib/generators/ruby_llm/provider/templates/gem/release.yml.erb +36 -0
  74. data/lib/generators/ruby_llm/provider/templates/gem/rerank_spec.rb.erb +23 -0
  75. data/lib/generators/ruby_llm/provider/templates/gem/rspec.erb +2 -0
  76. data/lib/generators/ruby_llm/provider/templates/gem/rubocop.yml.erb +29 -0
  77. data/lib/generators/ruby_llm/provider/templates/gem/rubyllm_configuration.rb.erb +14 -0
  78. data/lib/generators/ruby_llm/provider/templates/gem/spec_helper.rb.erb +26 -0
  79. data/lib/generators/ruby_llm/provider/templates/gem/speech_spec.rb.erb +25 -0
  80. data/lib/generators/ruby_llm/provider/templates/gem/vcr_configuration.rb.erb +16 -0
  81. data/lib/generators/ruby_llm/provider/templates/gem/video_spec.rb.erb +27 -0
  82. data/lib/generators/ruby_llm/schema/schema_generator.rb +5 -1
  83. data/lib/generators/ruby_llm/schema/templates/schema.rb.tt +1 -1
  84. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_call.html.erb.tt +13 -0
  85. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_result.html.erb.tt +21 -0
  86. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +3 -3
  87. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +7 -12
  88. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +5 -2
  89. data/lib/generators/ruby_llm/tool/tool_generator.rb +25 -59
  90. data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +461 -0
  91. data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +101 -0
  92. data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +215 -0
  93. data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +660 -0
  94. data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +222 -0
  95. data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +13 -0
  96. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +167 -0
  97. data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +344 -0
  98. data/lib/ruby_llm/accounting/usage.rb +245 -0
  99. data/lib/ruby_llm/active_record/acts_as.rb +94 -111
  100. data/lib/ruby_llm/active_record/attachment_helpers.rb +186 -0
  101. data/lib/ruby_llm/active_record/batch.rb +97 -0
  102. data/lib/ruby_llm/active_record/chat_methods.rb +828 -305
  103. data/lib/ruby_llm/active_record/message_methods.rb +113 -136
  104. data/lib/ruby_llm/active_record/model.rb +135 -0
  105. data/lib/ruby_llm/active_record/payload_helpers.rb +1 -2
  106. data/lib/ruby_llm/active_record/tool_call.rb +33 -0
  107. data/lib/ruby_llm/active_record/usage.rb +61 -0
  108. data/lib/ruby_llm/agent.rb +1065 -151
  109. data/lib/ruby_llm/aliases.json +268 -100
  110. data/lib/ruby_llm/attachment.rb +187 -48
  111. data/lib/ruby_llm/batch.rb +432 -0
  112. data/lib/ruby_llm/cached_content.rb +112 -0
  113. data/lib/ruby_llm/chat/tool_concurrency.rb +111 -0
  114. data/lib/ruby_llm/chat.rb +1127 -198
  115. data/lib/ruby_llm/chunk.rb +10 -0
  116. data/lib/ruby_llm/citation.rb +105 -0
  117. data/lib/ruby_llm/configuration.rb +261 -24
  118. data/lib/ruby_llm/context.rb +128 -6
  119. data/lib/ruby_llm/cost.rb +217 -80
  120. data/lib/ruby_llm/downloaded_file.rb +33 -0
  121. data/lib/ruby_llm/embedding.rb +121 -17
  122. data/lib/ruby_llm/embedding_request.rb +53 -0
  123. data/lib/ruby_llm/error.rb +159 -23
  124. data/lib/ruby_llm/fallback.rb +133 -0
  125. data/lib/ruby_llm/files/mime_type.rb +97 -0
  126. data/lib/ruby_llm/image.rb +153 -32
  127. data/lib/ruby_llm/message.rb +233 -54
  128. data/lib/ruby_llm/model/modalities.rb +17 -4
  129. data/lib/ruby_llm/model/pricing.rb +24 -5
  130. data/lib/ruby_llm/model/pricing_category.rb +103 -14
  131. data/lib/ruby_llm/model/pricing_tier.rb +55 -15
  132. data/lib/ruby_llm/model.rb +244 -2
  133. data/lib/ruby_llm/models/aliases.rb +41 -0
  134. data/lib/ruby_llm/models/registry.rb +165 -0
  135. data/lib/ruby_llm/models/schema.rb +99 -0
  136. data/lib/ruby_llm/models.json +73261 -33733
  137. data/lib/ruby_llm/models.rb +477 -215
  138. data/lib/ruby_llm/moderation.rb +139 -26
  139. data/lib/ruby_llm/ocr.rb +112 -0
  140. data/lib/ruby_llm/prompt.rb +79 -0
  141. data/lib/ruby_llm/protocol/binary_streaming.rb +65 -0
  142. data/lib/ruby_llm/protocol/stream_accumulator.rb +214 -0
  143. data/lib/ruby_llm/protocol/streaming.rb +230 -0
  144. data/lib/ruby_llm/protocol.rb +662 -0
  145. data/lib/ruby_llm/protocols/anthropic/batches.rb +73 -0
  146. data/lib/ruby_llm/protocols/anthropic/chat.rb +548 -0
  147. data/lib/ruby_llm/protocols/anthropic/embeddings.rb +14 -0
  148. data/lib/ruby_llm/protocols/anthropic/files.rb +38 -0
  149. data/lib/ruby_llm/protocols/anthropic/media.rb +141 -0
  150. data/lib/ruby_llm/protocols/anthropic/models.rb +129 -0
  151. data/lib/ruby_llm/protocols/anthropic/streaming.rb +167 -0
  152. data/lib/ruby_llm/{providers → protocols}/anthropic/tools.rb +24 -41
  153. data/lib/ruby_llm/protocols/anthropic.rb +101 -0
  154. data/lib/ruby_llm/protocols/azure/files.rb +16 -0
  155. data/lib/ruby_llm/protocols/bedrock/async_videos.rb +109 -0
  156. data/lib/ruby_llm/protocols/bedrock/batches.rb +129 -0
  157. data/lib/ruby_llm/protocols/bedrock/files.rb +110 -0
  158. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +122 -0
  159. data/lib/ruby_llm/protocols/bedrock/rerank.rb +95 -0
  160. data/lib/ruby_llm/protocols/chat_completions/batches.rb +32 -0
  161. data/lib/ruby_llm/protocols/chat_completions/chat.rb +493 -0
  162. data/lib/ruby_llm/protocols/chat_completions/embedding_batches.rb +35 -0
  163. data/lib/ruby_llm/protocols/chat_completions/embeddings.rb +60 -0
  164. data/lib/ruby_llm/protocols/chat_completions/images.rb +127 -0
  165. data/lib/ruby_llm/{providers/openai → protocols/chat_completions}/media.rb +30 -17
  166. data/lib/ruby_llm/protocols/chat_completions/models.rb +39 -0
  167. data/lib/ruby_llm/protocols/chat_completions/moderation.rb +52 -0
  168. data/lib/ruby_llm/protocols/chat_completions/rerank.rb +56 -0
  169. data/lib/ruby_llm/protocols/chat_completions/speech.rb +40 -0
  170. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +69 -0
  171. data/lib/ruby_llm/{providers/openai → protocols/chat_completions}/tools.rb +15 -17
  172. data/lib/ruby_llm/protocols/chat_completions/transcription.rb +150 -0
  173. data/lib/ruby_llm/protocols/chat_completions.rb +21 -0
  174. data/lib/ruby_llm/protocols/cohere/batch_requests.rb +75 -0
  175. data/lib/ruby_llm/protocols/cohere/batches.rb +98 -0
  176. data/lib/ruby_llm/protocols/cohere/chat.rb +227 -0
  177. data/lib/ruby_llm/protocols/cohere/datasets.rb +102 -0
  178. data/lib/ruby_llm/protocols/cohere/embeddings.rb +68 -0
  179. data/lib/ruby_llm/protocols/cohere/media.rb +77 -0
  180. data/lib/ruby_llm/protocols/cohere/models.rb +92 -0
  181. data/lib/ruby_llm/protocols/cohere/ocr.rb +63 -0
  182. data/lib/ruby_llm/protocols/cohere/rerank.rb +52 -0
  183. data/lib/ruby_llm/protocols/cohere/streaming.rb +105 -0
  184. data/lib/ruby_llm/protocols/cohere/tokenization.rb +22 -0
  185. data/lib/ruby_llm/protocols/cohere/tools.rb +132 -0
  186. data/lib/ruby_llm/protocols/cohere/transcription.rb +41 -0
  187. data/lib/ruby_llm/protocols/cohere.rb +21 -0
  188. data/lib/ruby_llm/protocols/converse/batches.rb +55 -0
  189. data/lib/ruby_llm/protocols/converse/chat.rb +695 -0
  190. data/lib/ruby_llm/protocols/converse/media.rb +178 -0
  191. data/lib/ruby_llm/protocols/converse/streaming.rb +432 -0
  192. data/lib/ruby_llm/protocols/converse/thinking_stream.rb +56 -0
  193. data/lib/ruby_llm/protocols/converse.rb +54 -0
  194. data/lib/ruby_llm/protocols/deepgram/models.rb +74 -0
  195. data/lib/ruby_llm/protocols/deepgram/speech.rb +93 -0
  196. data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +89 -0
  197. data/lib/ruby_llm/protocols/deepgram/transcription.rb +96 -0
  198. data/lib/ruby_llm/protocols/deepgram.rb +19 -0
  199. data/lib/ruby_llm/protocols/deepseek/files.rb +31 -0
  200. data/lib/ruby_llm/protocols/elevenlabs/assets.rb +36 -0
  201. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +74 -0
  202. data/lib/ruby_llm/protocols/elevenlabs/flows/media.rb +42 -0
  203. data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +93 -0
  204. data/lib/ruby_llm/protocols/elevenlabs/flows.rb +14 -0
  205. data/lib/ruby_llm/protocols/elevenlabs/models.rb +64 -0
  206. data/lib/ruby_llm/protocols/elevenlabs/speech.rb +67 -0
  207. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +127 -0
  208. data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +61 -0
  209. data/lib/ruby_llm/protocols/elevenlabs.rb +15 -0
  210. data/lib/ruby_llm/protocols/files.rb +119 -0
  211. data/lib/ruby_llm/protocols/gemini/batches.rb +162 -0
  212. data/lib/ruby_llm/protocols/gemini/caches.rb +59 -0
  213. data/lib/ruby_llm/protocols/gemini/chat.rb +453 -0
  214. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +86 -0
  215. data/lib/ruby_llm/protocols/gemini/embeddings.rb +70 -0
  216. data/lib/ruby_llm/protocols/gemini/file_transcription.rb +32 -0
  217. data/lib/ruby_llm/protocols/gemini/files.rb +115 -0
  218. data/lib/ruby_llm/protocols/gemini/images.rb +183 -0
  219. data/lib/ruby_llm/protocols/gemini/live_transcription.rb +140 -0
  220. data/lib/ruby_llm/{providers → protocols}/gemini/media.rb +17 -11
  221. data/lib/ruby_llm/protocols/gemini/models.rb +71 -0
  222. data/lib/ruby_llm/protocols/gemini/speech.rb +56 -0
  223. data/lib/ruby_llm/protocols/gemini/streaming.rb +96 -0
  224. data/lib/ruby_llm/protocols/gemini/tools.rb +157 -0
  225. data/lib/ruby_llm/{providers → protocols}/gemini/transcription.rb +22 -22
  226. data/lib/ruby_llm/protocols/gemini/videos.rb +103 -0
  227. data/lib/ruby_llm/protocols/gemini.rb +35 -0
  228. data/lib/ruby_llm/protocols/gpustack/responses.rb +111 -0
  229. data/lib/ruby_llm/protocols/gpustack/tokenization.rb +22 -0
  230. data/lib/ruby_llm/protocols/gpustack/videos.rb +96 -0
  231. data/lib/ruby_llm/protocols/interactions/chat.rb +145 -0
  232. data/lib/ruby_llm/protocols/interactions/content.rb +90 -0
  233. data/lib/ruby_llm/protocols/interactions/streaming.rb +91 -0
  234. data/lib/ruby_llm/protocols/interactions/tools.rb +51 -0
  235. data/lib/ruby_llm/protocols/interactions/transcription.rb +58 -0
  236. data/lib/ruby_llm/protocols/interactions.rb +29 -0
  237. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +51 -0
  238. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +111 -0
  239. data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +50 -0
  240. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +103 -0
  241. data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +33 -0
  242. data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +44 -0
  243. data/lib/ruby_llm/protocols/invoke_model.rb +57 -0
  244. data/lib/ruby_llm/protocols/mistral/content.rb +49 -0
  245. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +160 -0
  246. data/lib/ruby_llm/protocols/mistral/conversations/images.rb +43 -0
  247. data/lib/ruby_llm/protocols/mistral/conversations/streaming.rb +83 -0
  248. data/lib/ruby_llm/protocols/mistral/conversations.rb +30 -0
  249. data/lib/ruby_llm/protocols/mistral/files.rb +36 -0
  250. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +160 -0
  251. data/lib/ruby_llm/protocols/openai/batches.rb +126 -0
  252. data/lib/ruby_llm/protocols/openai/files.rb +42 -0
  253. data/lib/ruby_llm/protocols/openrouter/batches.rb +147 -0
  254. data/lib/ruby_llm/protocols/openrouter/files.rb +24 -0
  255. data/lib/ruby_llm/protocols/openrouter/responses.rb +53 -0
  256. data/lib/ruby_llm/protocols/openrouter/transcription.rb +51 -0
  257. data/lib/ruby_llm/protocols/perplexity/files.rb +48 -0
  258. data/lib/ruby_llm/protocols/perplexity/router.rb +59 -0
  259. data/lib/ruby_llm/protocols/responses/approvals.rb +32 -0
  260. data/lib/ruby_llm/protocols/responses/batches.rb +32 -0
  261. data/lib/ruby_llm/protocols/responses/chat.rb +476 -0
  262. data/lib/ruby_llm/protocols/responses/compaction.rb +29 -0
  263. data/lib/ruby_llm/protocols/responses/media.rb +61 -0
  264. data/lib/ruby_llm/protocols/responses/streaming.rb +117 -0
  265. data/lib/ruby_llm/protocols/responses/token_counting.rb +26 -0
  266. data/lib/ruby_llm/protocols/responses/tools.rb +39 -0
  267. data/lib/ruby_llm/protocols/responses.rb +35 -0
  268. data/lib/ruby_llm/protocols/vertexai/batch_prediction.rb +155 -0
  269. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +85 -0
  270. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/results.rb +74 -0
  271. data/lib/ruby_llm/protocols/vertexai/embedding_prediction.rb +56 -0
  272. data/lib/ruby_llm/protocols/vertexai/files.rb +101 -0
  273. data/lib/ruby_llm/protocols/vertexai/ranking.rb +69 -0
  274. data/lib/ruby_llm/protocols/vertexai/research.rb +193 -0
  275. data/lib/ruby_llm/protocols/xai/files.rb +30 -0
  276. data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +120 -0
  277. data/lib/ruby_llm/protocols/xai/tokenization.rb +23 -0
  278. data/lib/ruby_llm/provider.rb +560 -128
  279. data/lib/ruby_llm/providers/anthropic/capabilities.rb +5 -7
  280. data/lib/ruby_llm/providers/anthropic.rb +4 -6
  281. data/lib/ruby_llm/providers/azure/audio.rb +18 -0
  282. data/lib/ruby_llm/providers/azure/capabilities.rb +16 -0
  283. data/lib/ruby_llm/providers/azure/chat.rb +2 -9
  284. data/lib/ruby_llm/providers/azure/chat_completions/batches.rb +29 -0
  285. data/lib/ruby_llm/providers/azure/chat_completions.rb +80 -0
  286. data/lib/ruby_llm/providers/azure/cohere.rb +33 -0
  287. data/lib/ruby_llm/providers/azure/embeddings.rb +3 -2
  288. data/lib/ruby_llm/providers/azure/images.rb +22 -0
  289. data/lib/ruby_llm/providers/azure/media.rb +5 -14
  290. data/lib/ruby_llm/providers/azure/models.rb +35 -0
  291. data/lib/ruby_llm/providers/azure/responses.rb +26 -0
  292. data/lib/ruby_llm/providers/azure/videos.rb +64 -0
  293. data/lib/ruby_llm/providers/azure.rb +77 -78
  294. data/lib/ruby_llm/providers/bedrock/auth.rb +60 -41
  295. data/lib/ruby_llm/providers/bedrock/capabilities.rb +18 -0
  296. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +39 -0
  297. data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +23 -0
  298. data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +24 -0
  299. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +96 -0
  300. data/lib/ruby_llm/providers/bedrock/mantle.rb +57 -0
  301. data/lib/ruby_llm/providers/bedrock/models.rb +193 -41
  302. data/lib/ruby_llm/providers/bedrock.rb +216 -45
  303. data/lib/ruby_llm/providers/cohere.rb +31 -0
  304. data/lib/ruby_llm/providers/deepgram.rb +37 -0
  305. data/lib/ruby_llm/providers/deepseek/capabilities.rb +4 -51
  306. data/lib/ruby_llm/providers/deepseek/chat.rb +49 -2
  307. data/lib/ruby_llm/providers/deepseek/responses.rb +68 -0
  308. data/lib/ruby_llm/providers/deepseek.rb +9 -2
  309. data/lib/ruby_llm/providers/elevenlabs.rb +35 -0
  310. data/lib/ruby_llm/providers/gemini/capabilities.rb +8 -107
  311. data/lib/ruby_llm/providers/gemini.rb +15 -8
  312. data/lib/ruby_llm/providers/gpustack/chat.rb +2 -16
  313. data/lib/ruby_llm/providers/gpustack/embeddings.rb +28 -0
  314. data/lib/ruby_llm/providers/gpustack/media.rb +17 -16
  315. data/lib/ruby_llm/providers/gpustack/models.rb +72 -62
  316. data/lib/ruby_llm/providers/gpustack/speech.rb +15 -0
  317. data/lib/ruby_llm/providers/gpustack/transcription.rb +29 -0
  318. data/lib/ruby_llm/providers/gpustack.rb +35 -11
  319. data/lib/ruby_llm/providers/mistral/capabilities.rb +7 -155
  320. data/lib/ruby_llm/providers/mistral/chat.rb +37 -61
  321. data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +120 -0
  322. data/lib/ruby_llm/providers/mistral/chat_completions.rb +21 -0
  323. data/lib/ruby_llm/providers/mistral/conversations.rb +12 -0
  324. data/lib/ruby_llm/providers/mistral/embeddings.rb +6 -4
  325. data/lib/ruby_llm/providers/mistral/media.rb +6 -18
  326. data/lib/ruby_llm/providers/mistral/models.rb +57 -23
  327. data/lib/ruby_llm/providers/mistral/ocr.rb +47 -0
  328. data/lib/ruby_llm/providers/mistral/speech.rb +51 -0
  329. data/lib/ruby_llm/providers/mistral/transcription.rb +62 -0
  330. data/lib/ruby_llm/providers/mistral.rb +16 -4
  331. data/lib/ruby_llm/providers/ollama/chat.rb +7 -13
  332. data/lib/ruby_llm/providers/ollama/media.rb +6 -15
  333. data/lib/ruby_llm/providers/ollama/models.rb +50 -9
  334. data/lib/ruby_llm/providers/ollama.rb +9 -8
  335. data/lib/ruby_llm/providers/ollama_cloud/models.rb +14 -0
  336. data/lib/ruby_llm/providers/ollama_cloud.rb +40 -0
  337. data/lib/ruby_llm/providers/openai/capabilities.rb +54 -259
  338. data/lib/ruby_llm/providers/openai/models.rb +23 -23
  339. data/lib/ruby_llm/providers/openai/responses.rb +13 -0
  340. data/lib/ruby_llm/providers/openai.rb +92 -11
  341. data/lib/ruby_llm/providers/openrouter/chat.rb +130 -108
  342. data/lib/ruby_llm/providers/openrouter/embeddings.rb +51 -0
  343. data/lib/ruby_llm/providers/openrouter/images.rb +44 -43
  344. data/lib/ruby_llm/providers/openrouter/media.rb +34 -0
  345. data/lib/ruby_llm/providers/openrouter/models.rb +50 -11
  346. data/lib/ruby_llm/providers/openrouter/speech.rb +32 -0
  347. data/lib/ruby_llm/providers/openrouter/streaming.rb +31 -38
  348. data/lib/ruby_llm/providers/openrouter/videos.rb +81 -0
  349. data/lib/ruby_llm/providers/openrouter.rb +78 -20
  350. data/lib/ruby_llm/providers/perplexity/chat.rb +2 -9
  351. data/lib/ruby_llm/providers/perplexity/embeddings.rb +32 -0
  352. data/lib/ruby_llm/providers/perplexity/media.rb +5 -21
  353. data/lib/ruby_llm/providers/perplexity/models.rb +80 -13
  354. data/lib/ruby_llm/providers/perplexity.rb +28 -20
  355. data/lib/ruby_llm/providers/vertexai/anthropic/batches.rb +52 -0
  356. data/lib/ruby_llm/providers/vertexai/anthropic.rb +34 -0
  357. data/lib/ruby_llm/providers/vertexai/capabilities.rb +19 -0
  358. data/lib/ruby_llm/providers/vertexai/chat_completions/batches.rb +54 -0
  359. data/lib/ruby_llm/providers/vertexai/chat_completions.rb +15 -0
  360. data/lib/ruby_llm/providers/vertexai/embed_content.rb +42 -0
  361. data/lib/ruby_llm/providers/vertexai/embeddings.rb +22 -7
  362. data/lib/ruby_llm/providers/vertexai/gemini/batches.rb +42 -0
  363. data/lib/ruby_llm/providers/vertexai/gemini.rb +69 -0
  364. data/lib/ruby_llm/providers/vertexai/live_transcription.rb +24 -0
  365. data/lib/ruby_llm/providers/vertexai/mistral.rb +28 -0
  366. data/lib/ruby_llm/providers/vertexai/models.rb +145 -43
  367. data/lib/ruby_llm/providers/vertexai/transcription.rb +49 -4
  368. data/lib/ruby_llm/providers/vertexai/videos.rb +61 -0
  369. data/lib/ruby_llm/providers/vertexai.rb +160 -17
  370. data/lib/ruby_llm/providers/xai/capabilities.rb +18 -0
  371. data/lib/ruby_llm/providers/xai/chat.rb +3 -2
  372. data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +108 -0
  373. data/lib/ruby_llm/providers/xai/chat_completions.rb +19 -0
  374. data/lib/ruby_llm/providers/xai/images.rb +91 -0
  375. data/lib/ruby_llm/providers/xai/models.rb +32 -36
  376. data/lib/ruby_llm/providers/xai/reported_cost.rb +18 -0
  377. data/lib/ruby_llm/providers/xai/responses.rb +52 -0
  378. data/lib/ruby_llm/providers/xai/speech.rb +45 -0
  379. data/lib/ruby_llm/providers/xai/transcription.rb +48 -0
  380. data/lib/ruby_llm/providers/xai/videos.rb +87 -0
  381. data/lib/ruby_llm/providers/xai.rb +15 -5
  382. data/lib/ruby_llm/railtie.rb +7 -16
  383. data/lib/ruby_llm/rerank.rb +105 -0
  384. data/lib/ruby_llm/research_job.rb +241 -0
  385. data/lib/ruby_llm/search_results.rb +68 -0
  386. data/lib/ruby_llm/server_tool_call.rb +73 -0
  387. data/lib/ruby_llm/speech.rb +159 -0
  388. data/lib/ruby_llm/speech_chunk.rb +33 -0
  389. data/lib/ruby_llm/support/deprecator.rb +22 -0
  390. data/lib/ruby_llm/support/inspectable.rb +49 -0
  391. data/lib/ruby_llm/support/instrumentation.rb +41 -0
  392. data/lib/ruby_llm/support/utils.rb +147 -0
  393. data/lib/ruby_llm/thinking.rb +127 -20
  394. data/lib/ruby_llm/tokenization.rb +59 -0
  395. data/lib/ruby_llm/tokens.rb +103 -33
  396. data/lib/ruby_llm/tool.rb +266 -91
  397. data/lib/ruby_llm/tool_call.rb +36 -3
  398. data/lib/ruby_llm/tools/server_tools.rb +109 -0
  399. data/lib/ruby_llm/transcription/wav_audio.rb +62 -0
  400. data/lib/ruby_llm/transcription.rb +138 -13
  401. data/lib/ruby_llm/transcription_chunk.rb +68 -0
  402. data/lib/ruby_llm/transport/connection.rb +193 -0
  403. data/lib/ruby_llm/transport/error_middleware.rb +131 -0
  404. data/lib/ruby_llm/transport/usage_middleware.rb +28 -0
  405. data/lib/ruby_llm/transport/websocket_connection.rb +220 -0
  406. data/lib/ruby_llm/uploaded_file.rb +144 -0
  407. data/lib/ruby_llm/version.rb +2 -1
  408. data/lib/ruby_llm/video.rb +136 -0
  409. data/lib/ruby_llm/video_job.rb +150 -0
  410. data/lib/ruby_llm/workflow.rb +91 -0
  411. data/lib/ruby_llm.rb +380 -6
  412. data/lib/tasks/ruby_llm.rake +21 -16
  413. data/skills/rubyllm/SKILL.md +81 -0
  414. data/skills/rubyllm/agents/openai.yaml +4 -0
  415. metadata +339 -97
  416. data/lib/generators/ruby_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +0 -9
  417. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +0 -39
  418. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +0 -21
  419. data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +0 -3
  420. data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +0 -3
  421. data/lib/generators/ruby_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +0 -19
  422. data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +0 -50
  423. data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +0 -7
  424. data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +0 -49
  425. data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +0 -145
  426. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +0 -122
  427. data/lib/generators/ruby_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +0 -15
  428. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +0 -49
  429. data/lib/ruby_llm/active_record/acts_as_legacy.rb +0 -597
  430. data/lib/ruby_llm/active_record/model_methods.rb +0 -82
  431. data/lib/ruby_llm/active_record/tool_call_methods.rb +0 -18
  432. data/lib/ruby_llm/aliases.rb +0 -41
  433. data/lib/ruby_llm/connection.rb +0 -159
  434. data/lib/ruby_llm/content.rb +0 -91
  435. data/lib/ruby_llm/deprecator.rb +0 -24
  436. data/lib/ruby_llm/error_middleware.rb +0 -81
  437. data/lib/ruby_llm/instrumentation.rb +0 -36
  438. data/lib/ruby_llm/mime_type.rb +0 -96
  439. data/lib/ruby_llm/model/info.rb +0 -164
  440. data/lib/ruby_llm/model_registry.rb +0 -39
  441. data/lib/ruby_llm/models_schema.json +0 -171
  442. data/lib/ruby_llm/providers/anthropic/chat.rb +0 -291
  443. data/lib/ruby_llm/providers/anthropic/content.rb +0 -44
  444. data/lib/ruby_llm/providers/anthropic/embeddings.rb +0 -20
  445. data/lib/ruby_llm/providers/anthropic/media.rb +0 -92
  446. data/lib/ruby_llm/providers/anthropic/models.rb +0 -59
  447. data/lib/ruby_llm/providers/anthropic/streaming.rb +0 -71
  448. data/lib/ruby_llm/providers/bedrock/chat.rb +0 -405
  449. data/lib/ruby_llm/providers/bedrock/media.rb +0 -108
  450. data/lib/ruby_llm/providers/bedrock/streaming.rb +0 -328
  451. data/lib/ruby_llm/providers/gemini/chat.rb +0 -542
  452. data/lib/ruby_llm/providers/gemini/embeddings.rb +0 -37
  453. data/lib/ruby_llm/providers/gemini/images.rb +0 -47
  454. data/lib/ruby_llm/providers/gemini/models.rb +0 -38
  455. data/lib/ruby_llm/providers/gemini/streaming.rb +0 -98
  456. data/lib/ruby_llm/providers/gemini/tools.rb +0 -234
  457. data/lib/ruby_llm/providers/gpustack/capabilities.rb +0 -20
  458. data/lib/ruby_llm/providers/ollama/capabilities.rb +0 -20
  459. data/lib/ruby_llm/providers/openai/chat.rb +0 -236
  460. data/lib/ruby_llm/providers/openai/embeddings.rb +0 -33
  461. data/lib/ruby_llm/providers/openai/images.rb +0 -90
  462. data/lib/ruby_llm/providers/openai/moderation.rb +0 -34
  463. data/lib/ruby_llm/providers/openai/streaming.rb +0 -55
  464. data/lib/ruby_llm/providers/openai/temperature.rb +0 -28
  465. data/lib/ruby_llm/providers/openai/transcription.rb +0 -71
  466. data/lib/ruby_llm/providers/perplexity/capabilities.rb +0 -72
  467. data/lib/ruby_llm/providers/vertexai/chat.rb +0 -14
  468. data/lib/ruby_llm/providers/vertexai/streaming.rb +0 -14
  469. data/lib/ruby_llm/stream_accumulator.rb +0 -218
  470. data/lib/ruby_llm/streaming.rb +0 -179
  471. data/lib/ruby_llm/tool_concurrency.rb +0 -105
  472. data/lib/ruby_llm/utils.rb +0 -130
  473. data/lib/tasks/models.rake +0 -593
  474. data/lib/tasks/release.rake +0 -94
  475. data/lib/tasks/vcr.rake +0 -124
@@ -0,0 +1,476 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class Responses
6
+ # Chat methods of the OpenAI Responses API
7
+ module Chat
8
+ def completion_url
9
+ 'responses'
10
+ end
11
+
12
+ OPENAI_INLINE_FILE_LIMIT = 50 * 1024 * 1024
13
+ OPENAI_FILE_UPLOAD_LIMIT = 512 * 1024 * 1024
14
+ PROMPT_CACHE_OPTIONS = %i[key ttl mode retention].freeze
15
+ CACHE_BREAKPOINT_ROLES = %i[user system].freeze
16
+
17
+ module_function
18
+
19
+ # rubocop:disable-next Metrics/PerceivedComplexity
20
+ def render_payload(messages, tools:, temperature:, model:, stream: false, max_output_tokens: nil, schema: nil,
21
+ thinking: nil, citations: false, caching: nil, tool_prefs: nil)
22
+ warn_unsupported_citations(model) if citations && !model.supports?(:citations)
23
+ tool_prefs ||= {}
24
+ # store: false leaves the provider holding no state, so reasoning has
25
+ # to ride back in the response. xAI only encrypts it when asked.
26
+ payload = {
27
+ model: model.id,
28
+ input: format_input(messages, caching:),
29
+ instructions: format_instructions(messages, caching:),
30
+ stream: stream,
31
+ store: false,
32
+ include: ['reasoning.encrypted_content']
33
+ }.compact
34
+
35
+ payload[:temperature] = temperature unless temperature.nil?
36
+ payload[:max_output_tokens] = max_output_tokens unless max_output_tokens.nil?
37
+
38
+ if tools.any?
39
+ payload[:tools] = tools.map { |_, tool| tool_for(tool) }
40
+ payload[:tool_choice] = build_tool_choice(tool_prefs[:choice]) unless tool_prefs[:choice].nil?
41
+ payload[:parallel_tool_calls] = tool_prefs[:calls] == :many unless tool_prefs[:calls].nil?
42
+ end
43
+
44
+ payload[:text] = { format: schema_format(schema) } if schema
45
+
46
+ effort = resolve_effort(thinking)
47
+ payload[:reasoning] = { effort: effort } if effort
48
+ payload[:reasoning] = (payload[:reasoning] || {}).merge(summary: 'auto') if thinking&.display == :summarized
49
+ payload.merge!(prompt_cache_params(caching)) if caching
50
+ force_explicit_cache_mode(payload) if caching != false && cache_boundaries?(messages)
51
+
52
+ payload
53
+ end
54
+
55
+ def parse_completion_body(data, raw:)
56
+ raise Error.new(data.dig('error', 'message'), response: raw) if data.dig('error', 'message')
57
+
58
+ output = data['output'] || []
59
+ content = parse_output_text(output)
60
+ server_tool_calls = parse_server_tool_items(output)
61
+
62
+ finish_reason = parse_finish_reason(data)
63
+
64
+ Message.new(
65
+ role: :assistant,
66
+ content: content,
67
+ citations: parse_citations(data, output, content),
68
+ thinking: Thinking.build(
69
+ text: parse_reasoning_summary(output),
70
+ signature: parse_reasoning_signature(output)
71
+ ),
72
+ tool_calls: parse_pending_tool_calls(output, response: raw, finish_reason: finish_reason),
73
+ server_tool_calls: server_tool_calls,
74
+ raw_content: server_tool_calls.any? ? output : nil,
75
+ model: data['model'],
76
+ raw: raw,
77
+ finish_reason: finish_reason,
78
+ **parse_usage(data['usage'] || {})
79
+ )
80
+ end
81
+
82
+ # context_management is an OpenAI parameter that only OpenAI's own
83
+ # endpoints serve; the other services on this wire format reject it.
84
+ COMPACTION_PROVIDERS = %w[openai azure].freeze
85
+ COMPACTION_IGNORED_OPTIONS = %i[instructions pause_after].freeze
86
+
87
+ # OpenAI names the threshold compact_threshold and takes the entries
88
+ # as a flat array, where Anthropic nests a trigger object under
89
+ # context_management.edits. It writes the summary itself, so there is
90
+ # nothing to steer with instructions and no pausing to opt into.
91
+ def apply_compaction(payload, compaction)
92
+ return super unless COMPACTION_PROVIDERS.include?(@provider.slug)
93
+
94
+ warn_ignored_compaction_options(compaction)
95
+ entry = { type: 'compaction' }
96
+ entry[:compact_threshold] = compaction[:at] if compaction[:at]
97
+ payload.merge(context_management: [entry])
98
+ end
99
+
100
+ def warn_ignored_compaction_options(compaction)
101
+ ignored = compaction.keys & COMPACTION_IGNORED_OPTIONS
102
+ return if ignored.empty?
103
+
104
+ RubyLLM.logger.debug do
105
+ "#{@provider.name} compaction takes no #{ignored.join(', ')}, dropping"
106
+ end
107
+ end
108
+
109
+ CLIENT_OUTPUT_ITEM_TYPES = %w[message reasoning function_call].freeze
110
+
111
+ # Output items beyond text, reasoning, and function calls record
112
+ # provider-executed tool steps (web_search_call, code_interpreter_call,
113
+ # and whatever OpenAI ships next). They are kept raw and replayed.
114
+ def parse_server_tool_items(output)
115
+ output.reject { |item| CLIENT_OUTPUT_ITEM_TYPES.include?(item['type']) }.map do |item|
116
+ ServerToolCall.new(
117
+ type: item['type'],
118
+ name: item['name'],
119
+ id: item['id'],
120
+ input: item['action'] || item['arguments'] || item['code'],
121
+ result: server_tool_result(item),
122
+ raw: item
123
+ )
124
+ end
125
+ end
126
+
127
+ # Result payloads differ by item type; compaction items carry an
128
+ # opaque encrypted_content instead of a readable result.
129
+ SERVER_TOOL_RESULT_KEYS = %w[result results outputs output encrypted_content].freeze
130
+
131
+ def server_tool_result(item)
132
+ key = SERVER_TOOL_RESULT_KEYS.find { |candidate| item[candidate] }
133
+ item[key] if key
134
+ end
135
+
136
+ def parse_citations(data, output, content)
137
+ citations = parse_output_citations(output, content)
138
+ citations.any? ? citations : parse_root_citations(data)
139
+ end
140
+
141
+ def parse_output_citations(output, content)
142
+ offset = 0
143
+ output.select { |item| item['type'] == 'message' }.flat_map do |message|
144
+ Array(message['content']).flat_map do |part|
145
+ key = MESSAGE_TEXT_KEYS[part['type']]
146
+ next [] unless key
147
+
148
+ citations = offset_citations(parse_annotations(part['annotations'], nil), offset, content)
149
+ offset += part[key].to_s.length
150
+ citations
151
+ end
152
+ end
153
+ end
154
+
155
+ def parse_annotations(annotations, content)
156
+ Array(annotations).filter_map do |annotation|
157
+ case annotation['type']
158
+ when 'file_citation', 'container_file_citation'
159
+ parse_file_citation(annotation, content)
160
+ else
161
+ super([normalize_annotation(annotation)], content).first
162
+ end
163
+ end
164
+ end
165
+
166
+ def parse_file_citation(annotation, content)
167
+ start_index = annotation['start_index']
168
+ end_index = annotation['end_index']
169
+
170
+ Citation.new(
171
+ source_id: annotation['file_id'],
172
+ title: annotation['filename'],
173
+ source_index: annotation['index'],
174
+ text: annotated_text(content, start_index, end_index),
175
+ start_index: start_index,
176
+ end_index: end_index
177
+ )
178
+ end
179
+
180
+ def offset_citations(citations, offset, content)
181
+ citations.map do |citation|
182
+ start_index = citation.start_index && (citation.start_index + offset)
183
+ end_index = citation.end_index && (citation.end_index + offset)
184
+
185
+ Citation.new(citation.to_h.merge(
186
+ start_index: start_index,
187
+ end_index: end_index,
188
+ text: annotated_text(content, start_index, end_index)
189
+ ))
190
+ end
191
+ end
192
+
193
+ def normalize_annotation(annotation)
194
+ return annotation if annotation.key?('url_citation') || annotation['type'] != 'url_citation'
195
+
196
+ { 'url_citation' => annotation }
197
+ end
198
+
199
+ def prompt_cache_params(caching)
200
+ options = prompt_cache_options(caching)
201
+ cache_options = build_prompt_cache_options(options)
202
+
203
+ {}.tap do |params|
204
+ params[:prompt_cache_key] = options[:key] if options[:key]
205
+ params[:prompt_cache_options] = cache_options unless cache_options.empty?
206
+ end
207
+ end
208
+
209
+ def build_prompt_cache_options(options)
210
+ ttl = options[:ttl] || retention_ttl(options[:retention])
211
+
212
+ {}.tap do |cache_options|
213
+ cache_options[:mode] = options[:mode] if options[:mode]
214
+ cache_options[:ttl] = ttl if ttl
215
+ end
216
+ end
217
+
218
+ def retention_ttl(retention)
219
+ return unless retention
220
+
221
+ RubyLLM.logger.warn(
222
+ 'with_caching retention: is deprecated; OpenAI replaced prompt_cache_retention ' \
223
+ 'with prompt_cache_options. Use ttl: instead.'
224
+ )
225
+ retention
226
+ end
227
+
228
+ def force_explicit_cache_mode(payload)
229
+ payload[:prompt_cache_options] = { mode: 'explicit' }.merge(payload[:prompt_cache_options] || {})
230
+ end
231
+
232
+ def cache_boundaries?(messages)
233
+ messages.any? { |msg| msg.cache_until_here? && CACHE_BREAKPOINT_ROLES.include?(msg.role) }
234
+ end
235
+
236
+ def prompt_cache_options(caching)
237
+ options = caching.to_h.transform_keys(&:to_sym)
238
+ unsupported = options.keys - PROMPT_CACHE_OPTIONS
239
+ return options if unsupported.empty?
240
+
241
+ raise ArgumentError,
242
+ "Responses prompt caching accepts :key, :ttl, and :mode, got #{format_cache_option_keys(unsupported)}"
243
+ end
244
+
245
+ def format_cache_option_keys(keys)
246
+ keys.map { |key| ":#{key}" }.join(', ')
247
+ end
248
+
249
+ def parse_usage(usage)
250
+ details = usage['input_tokens_details'] || usage['prompt_tokens_details'] || {}
251
+ cached = details['cached_tokens']
252
+ cache_writes = details['cache_write_tokens']
253
+ input = usage['input_tokens']
254
+
255
+ {
256
+ input_tokens: input && [input.to_i - cached.to_i - cache_writes.to_i, 0].max,
257
+ output_tokens: usage['output_tokens'],
258
+ cache_read_tokens: cached,
259
+ cache_write_tokens: cache_writes,
260
+ thinking_tokens: usage.dig('output_tokens_details', 'reasoning_tokens')
261
+ }
262
+ end
263
+
264
+ def schema_format(schema)
265
+ {
266
+ type: 'json_schema',
267
+ name: schema[:name],
268
+ schema: schema[:schema],
269
+ strict: schema_strict(schema)
270
+ }
271
+ end
272
+
273
+ # System messages marked as cache boundaries, or carrying attachments,
274
+ # ride along as input items, because the +instructions+ parameter is a
275
+ # plain string and cannot carry a breakpoint marker or a file.
276
+ def format_instructions(messages, caching: nil)
277
+ instructions = messages.select { |msg| msg.role == :system && !system_input_item?(msg, caching:) }
278
+ .map { |msg| msg.content.to_s }
279
+
280
+ instructions.empty? ? nil : instructions.join("\n\n")
281
+ end
282
+
283
+ def format_input(messages, caching: nil)
284
+ system_items = []
285
+ messages.each_with_object([]) do |message, input|
286
+ next if message.role == :system && !system_input_item?(message, caching:)
287
+
288
+ raw = message.raw_content
289
+ if raw.is_a?(Hash) && raw['object'] == 'response.compaction'
290
+ input.replace(system_items + raw.fetch('output'))
291
+ else
292
+ items = [format_item(message, caching:)].flatten(1)
293
+ system_items.concat(items) if message.role == :system
294
+ input.concat(items)
295
+ end
296
+ end
297
+ end
298
+
299
+ def system_input_item?(msg, caching: nil)
300
+ msg.role == :system && ((caching != false && msg.cache_until_here?) || msg.attachments.any?)
301
+ end
302
+
303
+ def format_item(msg, caching: nil)
304
+ case msg.role
305
+ when :system
306
+ item = { role: 'system', content: format_content(msg.content, msg.attachments) }
307
+ caching != false && msg.cache_until_here? ? inject_cache_breakpoint(item) : item
308
+ when :tool
309
+ format_tool_items(msg)
310
+ when :assistant
311
+ format_assistant_items(msg)
312
+ else
313
+ item = { role: 'user', content: format_content(msg.content, msg.attachments) }
314
+ caching != false && msg.cache_until_here? ? inject_cache_breakpoint(item) : item
315
+ end
316
+ end
317
+
318
+ def inject_cache_breakpoint(item)
319
+ parts = cache_breakpoint_parts(item[:content])
320
+ return item unless parts&.last.is_a?(Hash)
321
+
322
+ parts[-1] = parts.last.merge(prompt_cache_breakpoint: { mode: 'explicit' })
323
+ item.merge(content: parts)
324
+ end
325
+
326
+ def cache_breakpoint_parts(content)
327
+ case content
328
+ when Array then content.dup
329
+ when String then [{ type: 'input_text', text: content }] unless content.empty?
330
+ end
331
+ end
332
+
333
+ # Function call outputs are text-only on the wire, so tool attachments
334
+ # ride a user item spliced in right after the result.
335
+ def format_tool_items(msg)
336
+ return msg.raw_content if msg.raw_content
337
+
338
+ items = [{
339
+ type: 'function_call_output',
340
+ call_id: msg.tool_call_id,
341
+ output: format_content(msg.content)
342
+ }]
343
+
344
+ if msg.attachments.any?
345
+ parts = [{ type: 'input_text', text: "Attachments from tool call #{msg.tool_call_id}:" }]
346
+ parts.concat(Media.format_content(nil, msg.attachments))
347
+ items << { role: 'user', content: parts }
348
+ end
349
+
350
+ items
351
+ end
352
+
353
+ def format_assistant_items(msg)
354
+ # Turns that used server tools replay their output items verbatim,
355
+ # reasoning and tool results included, as stateless chaining expects.
356
+ return msg.raw_content if msg.raw_content
357
+
358
+ items = []
359
+ items << format_reasoning_item(msg.thinking) if msg.thinking&.signature
360
+ items << { role: 'assistant', content: format_output_content(msg) } unless empty_content?(msg.content)
361
+ items.concat(format_function_call_items(msg.tool_calls)) if msg.tool_call?
362
+ items
363
+ end
364
+
365
+ def format_reasoning_item(thinking)
366
+ {
367
+ type: 'reasoning',
368
+ summary: thinking.text ? [{ type: 'summary_text', text: thinking.text }] : [],
369
+ encrypted_content: thinking.signature
370
+ }
371
+ end
372
+
373
+ def format_function_call_items(tool_calls)
374
+ tool_calls.map do |_, tc|
375
+ {
376
+ type: 'function_call',
377
+ call_id: tc.id,
378
+ name: tc.name,
379
+ arguments: JSON.generate(tc.arguments)
380
+ }
381
+ end
382
+ end
383
+
384
+ def format_output_content(msg)
385
+ [{ type: 'output_text', text: msg.content }]
386
+ end
387
+
388
+ def empty_content?(content)
389
+ content.nil? || content.strip.empty?
390
+ end
391
+
392
+ MESSAGE_TEXT_KEYS = { 'output_text' => 'text', 'refusal' => 'refusal' }.freeze
393
+
394
+ def parse_output_text(output)
395
+ texts = output.select { |item| item['type'] == 'message' }.flat_map do |message|
396
+ Array(message['content']).filter_map do |part|
397
+ key = MESSAGE_TEXT_KEYS[part['type']]
398
+ part[key] if key
399
+ end
400
+ end
401
+
402
+ texts.empty? ? nil : texts.join
403
+ end
404
+
405
+ FINISH_REASONS = {
406
+ 'completed' => :stop, 'max_output_tokens' => :max_tokens, 'content_filter' => :content_filter
407
+ }.freeze
408
+
409
+ def finish_reasons = FINISH_REASONS
410
+
411
+ def parse_finish_reason(data)
412
+ reason = data.dig('incomplete_details', 'reason') || (data['status'] if data['status'] == 'completed')
413
+ normalize_finish_reason(reason)
414
+ end
415
+
416
+ def parse_function_calls(output, response: nil, finish_reason: nil)
417
+ calls = output.select { |item| item['type'] == 'function_call' }
418
+ return nil if calls.empty?
419
+
420
+ calls.to_h do |call|
421
+ arguments = call['arguments']
422
+
423
+ [
424
+ call['call_id'],
425
+ ToolCall.new(
426
+ id: call['call_id'],
427
+ name: call['name'],
428
+ arguments: parse_function_call_arguments(arguments, response: response, finish_reason: finish_reason)
429
+ )
430
+ ]
431
+ end
432
+ end
433
+
434
+ def parse_function_call_arguments(arguments, response: nil, finish_reason: nil)
435
+ return {} if arguments.nil? || arguments.empty?
436
+
437
+ JSON.parse(arguments)
438
+ rescue JSON::ParserError => e
439
+ raise ToolCallParseError.new(response: response, finish_reason: finish_reason), cause: e
440
+ end
441
+
442
+ def parse_reasoning_summary(output)
443
+ texts = output.select { |item| item['type'] == 'reasoning' }.flat_map do |item|
444
+ Array(item['summary']).filter_map { |part| part['text'] }
445
+ end
446
+
447
+ texts.empty? ? nil : texts.join("\n")
448
+ end
449
+
450
+ def parse_reasoning_signature(output)
451
+ output.find { |item| item['type'] == 'reasoning' }&.dig('encrypted_content')
452
+ end
453
+
454
+ def supports_provider_file_references?
455
+ true
456
+ end
457
+
458
+ def default_large_file_upload_threshold
459
+ OPENAI_INLINE_FILE_LIMIT
460
+ end
461
+
462
+ def provider_file_upload_limit
463
+ OPENAI_FILE_UPLOAD_LIMIT
464
+ end
465
+
466
+ def provider_file_attachable?(attachment)
467
+ attachment.pdf? || attachment.document?
468
+ end
469
+
470
+ def provider_file_upload_options(_attachment)
471
+ { purpose: 'user_data' }
472
+ end
473
+ end
474
+ end
475
+ end
476
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class Responses
6
+ # Manual context compaction through the Responses API.
7
+ module Compaction
8
+ def compaction_url
9
+ "#{completion_url}/compact"
10
+ end
11
+
12
+ def render_compaction_payload(messages)
13
+ { model: model.id, input: format_input(messages, caching: false),
14
+ instructions: format_instructions(messages, caching: false) }.compact
15
+ end
16
+
17
+ def parse_compaction_response(response)
18
+ body = response.body
19
+ unless body['object'] == 'response.compaction' && body['output'].is_a?(Array)
20
+ raise Error.new('The provider returned an invalid compaction response', response:)
21
+ end
22
+
23
+ Message.new(role: :assistant, content: '', model: model.id, finish_reason: :stop,
24
+ raw_content: body, raw: response, **parse_usage(body['usage'] || {}))
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class Responses
6
+ # Handles formatting of media content for the OpenAI Responses API
7
+ module Media
8
+ module_function
9
+
10
+ def format_content(content, attachments = [])
11
+ return content if attachments.empty?
12
+
13
+ parts = []
14
+ parts << { type: 'input_text', text: content } if content
15
+ attachments.each { |attachment| parts << format_attachment(attachment) }
16
+ parts
17
+ end
18
+
19
+ def format_attachment(attachment)
20
+ return format_provider_file(attachment) if attachment.provider_file?
21
+
22
+ case attachment.type
23
+ when :image
24
+ format_image(attachment)
25
+ when :pdf, :document
26
+ format_document(attachment)
27
+ when :text
28
+ { type: 'input_text', text: attachment.for_llm }
29
+ else
30
+ raise UnsupportedAttachmentError, attachment.mime_type
31
+ end
32
+ end
33
+
34
+ def format_image(image)
35
+ {
36
+ type: 'input_image',
37
+ image_url: image.url? ? image.source.to_s : image.for_llm
38
+ }
39
+ end
40
+
41
+ # The Responses API extracts text from documents, presentations, and
42
+ # spreadsheets as well as PDFs, so every document attachment rides
43
+ # along as a native input_file.
44
+ def format_document(document)
45
+ {
46
+ type: 'input_file',
47
+ filename: document.filename,
48
+ file_data: document.for_llm
49
+ }
50
+ end
51
+
52
+ def format_provider_file(file)
53
+ {
54
+ type: 'input_file',
55
+ file_id: file.provider_file_id
56
+ }
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class Responses
6
+ # Streaming methods of the OpenAI Responses API. Events are semantic:
7
+ # each SSE data frame carries a `type` describing what changed.
8
+ module Streaming
9
+ ERROR_STATUSES = {
10
+ 'server_error' => 500,
11
+ 'rate_limit_exceeded' => 429,
12
+ 'insufficient_quota' => 429
13
+ }.freeze
14
+
15
+ module_function
16
+
17
+ def stream_response(...)
18
+ @citation_content_lengths = Hash.new(0)
19
+ super
20
+ end
21
+
22
+ def build_chunk(data)
23
+ case data['type']
24
+ when 'response.output_text.delta', 'response.refusal.delta'
25
+ build_text_chunk(data)
26
+ when 'response.reasoning_summary_text.delta'
27
+ chunk thinking: Thinking.build(text: data['delta'])
28
+ when 'response.reasoning_summary_part.added'
29
+ build_reasoning_summary_part_chunk(data)
30
+ when 'response.output_text.annotation.added'
31
+ build_annotation_chunk(data)
32
+ when 'response.output_item.added'
33
+ build_item_added_chunk(data)
34
+ when 'response.function_call_arguments.delta'
35
+ chunk tool_calls: { data['output_index'] => ToolCall.new(id: nil, name: nil, arguments: data['delta']) }
36
+ when 'response.output_item.done'
37
+ build_item_done_chunk(data)
38
+ when 'response.completed', 'response.incomplete'
39
+ build_final_chunk(data)
40
+ when 'response.failed'
41
+ raise Error, data.dig('response', 'error', 'message')
42
+ else
43
+ chunk
44
+ end
45
+ end
46
+
47
+ def build_text_chunk(data)
48
+ @citation_content_lengths ||= Hash.new(0)
49
+ @citation_content_lengths[citation_content_position(data)] += data['delta'].to_s.length
50
+ chunk content: data['delta']
51
+ end
52
+
53
+ def build_annotation_chunk(data)
54
+ position = citation_content_position(data)
55
+ offset = (@citation_content_lengths || {}).sum do |key, length|
56
+ (key <=> position).negative? ? length : 0
57
+ end
58
+
59
+ chunk citations: offset_citations(parse_annotations([data['annotation']], nil), offset, nil)
60
+ end
61
+
62
+ def citation_content_position(data)
63
+ [data.fetch('output_index', 0), data.fetch('content_index', 0)]
64
+ end
65
+
66
+ def build_reasoning_summary_part_chunk(data)
67
+ return chunk unless data['summary_index'].positive?
68
+
69
+ chunk thinking: Thinking.build(text: "\n\n")
70
+ end
71
+
72
+ def build_item_added_chunk(data)
73
+ item = data['item']
74
+ return chunk unless item['type'] == 'function_call'
75
+
76
+ chunk tool_calls: {
77
+ data['output_index'] => ToolCall.new(id: item['call_id'], name: item['name'], arguments: +'')
78
+ }
79
+ end
80
+
81
+ def build_item_done_chunk(data)
82
+ item = data['item']
83
+ return chunk unless item['type'] == 'reasoning' && item['encrypted_content']
84
+
85
+ chunk thinking: Thinking.build(text: nil, signature: item['encrypted_content'])
86
+ end
87
+
88
+ def build_final_chunk(data)
89
+ response = data['response'] || {}
90
+ output = response['output'] || []
91
+ server_tool_calls = parse_server_tool_items(output)
92
+
93
+ chunk model: response['model'],
94
+ tool_calls: parse_tool_approvals(output, finish_reason: parse_finish_reason(response)),
95
+ finish_reason: parse_finish_reason(response),
96
+ citations: parse_citations(response, output, nil),
97
+ server_tool_calls: server_tool_calls,
98
+ raw_content: server_tool_calls.any? ? output : nil,
99
+ **parse_usage(response['usage'] || {})
100
+ end
101
+
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 parse_streaming_error(data)
105
+ event = JSON.parse(data)
106
+ return super unless event.is_a?(Hash) && event['type'] == 'error'
107
+
108
+ [ERROR_STATUSES.fetch(event['code'], 400), event['message']]
109
+ end
110
+
111
+ def chunk(content: nil, **attributes)
112
+ Chunk.new(role: :assistant, content: content, **attributes)
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end