ruby_llm 1.16.0 → 2.0.0.rc4

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