ruby_llm 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (522) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +162 -120
  3. data/app/assets/javascripts/ruby_llm/chat_message_controller.js +163 -0
  4. data/app/assets/javascripts/ruby_llm/dompurify.js +9 -0
  5. data/app/assets/javascripts/ruby_llm/marked.js +71 -0
  6. data/config/importmap.rb +5 -0
  7. data/lib/generators/ruby_llm/agent/agent_generator.rb +11 -3
  8. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +6 -3
  9. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +201 -55
  10. data/lib/generators/ruby_llm/chat_ui/templates/agents/chat_agent.rb.tt +15 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/controllers/answers_controller.rb.tt +31 -0
  12. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +16 -5
  13. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +27 -9
  14. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +1 -1
  15. data/lib/generators/ruby_llm/chat_ui/templates/controllers/queued_messages_controller.rb.tt +14 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/javascript/chat_message_controller.js +1 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +5 -8
  18. data/lib/generators/ruby_llm/chat_ui/templates/jobs/resume_interrupted_chats_job.rb.tt +7 -0
  19. data/lib/generators/ruby_llm/chat_ui/templates/migrations/add_reply_error_migration.rb.tt +5 -0
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_files.html.erb.tt +15 -0
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +5 -7
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_model_select.html.erb.tt +7 -0
  23. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +32 -3
  24. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +17 -2
  25. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_attachments.html.erb.tt +14 -0
  26. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +1 -1
  27. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +28 -18
  28. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_queued_message.html.erb.tt +18 -0
  29. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +1 -1
  30. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -1
  31. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +4 -2
  32. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +1 -1
  33. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +4 -2
  34. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/questions/_question.html.erb.tt +101 -0
  35. data/lib/generators/ruby_llm/chat_ui/templates/test/agents/chat_agent_test.rb.tt +28 -0
  36. data/lib/generators/ruby_llm/chat_ui/templates/test/application_system_test_case.rb.tt +5 -0
  37. data/lib/generators/ruby_llm/chat_ui/templates/test/system/chats_test.rb.tt +240 -0
  38. data/lib/generators/ruby_llm/chat_ui/templates/views/answers/create.turbo_stream.erb.tt +6 -0
  39. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_files.html.erb.tt +15 -0
  40. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +5 -7
  41. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_model_select.html.erb.tt +7 -0
  42. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_questions.html.erb.tt +13 -0
  43. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +29 -3
  44. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +16 -2
  45. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_attachments.html.erb.tt +14 -0
  46. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_error.html.erb.tt +1 -1
  47. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +26 -16
  48. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_queued_message.html.erb.tt +8 -0
  49. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +1 -1
  50. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -1
  51. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +3 -2
  52. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +1 -0
  53. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +1 -1
  54. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +3 -2
  55. data/lib/generators/ruby_llm/chat_ui/templates/views/questions/_question.html.erb.tt +78 -0
  56. data/lib/generators/ruby_llm/evaluation/evaluation_generator.rb +43 -0
  57. data/lib/generators/ruby_llm/evaluation/templates/evaluation.rb.tt +9 -0
  58. data/lib/generators/ruby_llm/evaluation/templates/evaluation.yml.tt +4 -0
  59. data/lib/generators/ruby_llm/generator_helpers.rb +116 -3
  60. data/lib/generators/ruby_llm/install/install_generator.rb +5 -47
  61. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +4 -0
  62. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +5 -3
  63. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +41 -1
  64. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +2 -1
  65. data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +1 -2
  66. data/lib/generators/ruby_llm/judge/judge_generator.rb +35 -0
  67. data/lib/generators/ruby_llm/judge/templates/judge.rb.tt +2 -0
  68. data/lib/generators/ruby_llm/mcp/mcp_generator.rb +58 -0
  69. data/lib/generators/ruby_llm/mcp/templates/mcp.rb.tt +7 -0
  70. data/lib/generators/ruby_llm/provider/scaffold.rb +5 -12
  71. data/lib/generators/ruby_llm/provider/templates/core/provider.rb.erb +6 -0
  72. data/lib/generators/ruby_llm/provider/templates/gem/chat_spec.rb.erb +0 -1
  73. data/lib/generators/ruby_llm/provider/templates/gem/chat_streaming_spec.rb.erb +0 -1
  74. data/lib/generators/ruby_llm/provider/templates/gem/moderation_spec.rb.erb +1 -1
  75. data/lib/generators/ruby_llm/sandbox/sandbox_generator.rb +68 -0
  76. data/lib/generators/ruby_llm/sandbox/templates/sandbox.rb.tt +3 -0
  77. data/lib/generators/ruby_llm/skill/skill_generator.rb +48 -0
  78. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_call.html.erb.tt +1 -1
  79. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_result.html.erb.tt +1 -1
  80. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +0 -3
  81. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +1 -1
  82. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +1 -1
  83. data/lib/generators/ruby_llm/tool/tool_generator.rb +1 -5
  84. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_2.rb.tt +164 -0
  85. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +41 -3
  86. data/lib/ruby_llm/accounting/operation.rb +73 -0
  87. data/lib/ruby_llm/accounting/usage.rb +74 -24
  88. data/lib/ruby_llm/active_record/acts_as.rb +78 -11
  89. data/lib/ruby_llm/active_record/attachment_helpers.rb +10 -4
  90. data/lib/ruby_llm/active_record/batch.rb +67 -9
  91. data/lib/ruby_llm/active_record/chat_agent_hooks.rb +17 -0
  92. data/lib/ruby_llm/active_record/chat_approvals.rb +71 -0
  93. data/lib/ruby_llm/active_record/chat_cancellation.rb +106 -0
  94. data/lib/ruby_llm/active_record/chat_column_guard.rb +51 -0
  95. data/lib/ruby_llm/active_record/chat_file.rb +37 -0
  96. data/lib/ruby_llm/active_record/chat_files.rb +30 -0
  97. data/lib/ruby_llm/active_record/chat_helper.rb +73 -0
  98. data/lib/ruby_llm/active_record/chat_methods.rb +522 -277
  99. data/lib/ruby_llm/active_record/chat_placeholders.rb +67 -0
  100. data/lib/ruby_llm/active_record/chat_questions.rb +177 -0
  101. data/lib/ruby_llm/active_record/chat_queue.rb +120 -0
  102. data/lib/ruby_llm/active_record/chat_reply_error.rb +35 -0
  103. data/lib/ruby_llm/active_record/chat_run.rb +51 -0
  104. data/lib/ruby_llm/active_record/chat_tool_call_state.rb +77 -0
  105. data/lib/ruby_llm/active_record/chunk_methods.rb +49 -0
  106. data/lib/ruby_llm/active_record/claim_heartbeat.rb +149 -0
  107. data/lib/ruby_llm/active_record/file_store.rb +269 -0
  108. data/lib/ruby_llm/active_record/mcp_credential.rb +24 -9
  109. data/lib/ruby_llm/active_record/message_broadcasting.rb +85 -0
  110. data/lib/ruby_llm/active_record/message_methods.rb +83 -11
  111. data/lib/ruby_llm/active_record/model.rb +37 -9
  112. data/lib/ruby_llm/active_record/provider_file.rb +21 -4
  113. data/lib/ruby_llm/active_record/queued_message.rb +50 -0
  114. data/lib/ruby_llm/active_record/record.rb +3 -1
  115. data/lib/ruby_llm/active_record/run_claims.rb +608 -0
  116. data/lib/ruby_llm/active_record/solid_queue_shutdown.rb +33 -0
  117. data/lib/ruby_llm/active_record/system_instructions.rb +30 -0
  118. data/lib/ruby_llm/active_record/tool_call.rb +55 -4
  119. data/lib/ruby_llm/active_record/usage.rb +79 -9
  120. data/lib/ruby_llm/agent/callbacks.rb +98 -0
  121. data/lib/ruby_llm/agent/context_budget.rb +26 -0
  122. data/lib/ruby_llm/agent/join_callbacks.rb +28 -0
  123. data/lib/ruby_llm/agent/model_options.rb +56 -0
  124. data/lib/ruby_llm/agent/ownership.rb +22 -0
  125. data/lib/ruby_llm/agent/tool_conversion.rb +19 -0
  126. data/lib/ruby_llm/agent.rb +620 -236
  127. data/lib/ruby_llm/agent_tool.rb +91 -0
  128. data/lib/ruby_llm/aliases.json +84 -91
  129. data/lib/ruby_llm/attachment.rb +101 -20
  130. data/lib/ruby_llm/batch.rb +132 -46
  131. data/lib/ruby_llm/cached_content.rb +4 -4
  132. data/lib/ruby_llm/chat/agent_run.rb +22 -0
  133. data/lib/ruby_llm/chat/context_budget.rb +119 -0
  134. data/lib/ruby_llm/chat/file_references.rb +259 -0
  135. data/lib/ruby_llm/chat/message_queue.rb +56 -0
  136. data/lib/ruby_llm/chat/ownership.rb +18 -0
  137. data/lib/ruby_llm/chat/rescuable.rb +48 -0
  138. data/lib/ruby_llm/chat/run.rb +32 -0
  139. data/lib/ruby_llm/chat/tool_call_ids.rb +46 -0
  140. data/lib/ruby_llm/chat/tool_call_state.rb +115 -0
  141. data/lib/ruby_llm/chat/tool_concurrency.rb +90 -49
  142. data/lib/ruby_llm/chat/tool_search.rb +36 -1
  143. data/lib/ruby_llm/chat/tool_sequences.rb +84 -0
  144. data/lib/ruby_llm/chat.rb +1598 -332
  145. data/lib/ruby_llm/chunk.rb +27 -1
  146. data/lib/ruby_llm/citation.rb +2 -1
  147. data/lib/ruby_llm/code_execution/language.rb +28 -0
  148. data/lib/ruby_llm/code_execution/node_installation.rb +56 -0
  149. data/lib/ruby_llm/code_execution/packages.rb +241 -0
  150. data/lib/ruby_llm/code_execution/ruby_installation.rb +170 -0
  151. data/lib/ruby_llm/code_execution/workspace.rb +314 -0
  152. data/lib/ruby_llm/code_execution.rb +781 -0
  153. data/lib/ruby_llm/configuration.rb +151 -4
  154. data/lib/ruby_llm/content_filter.rb +68 -0
  155. data/lib/ruby_llm/cost.rb +68 -41
  156. data/lib/ruby_llm/downloaded_file.rb +10 -9
  157. data/lib/ruby_llm/embedding.rb +30 -45
  158. data/lib/ruby_llm/embedding_request.rb +1 -1
  159. data/lib/ruby_llm/error.rb +167 -14
  160. data/lib/ruby_llm/evaluation/dataset.rb +5 -4
  161. data/lib/ruby_llm/evaluation/evidence.rb +5 -3
  162. data/lib/ruby_llm/evaluation/report.rb +7 -3
  163. data/lib/ruby_llm/evaluation/result.rb +1 -1
  164. data/lib/ruby_llm/evaluation.rb +2 -1
  165. data/lib/ruby_llm/fallback.rb +32 -4
  166. data/lib/ruby_llm/file_store/converters/ocr.rb +28 -0
  167. data/lib/ruby_llm/file_store/converters/office/markup.rb +148 -0
  168. data/lib/ruby_llm/file_store/converters/office/outline.rb +193 -0
  169. data/lib/ruby_llm/file_store/converters/office/prose.rb +161 -0
  170. data/lib/ruby_llm/file_store/converters/office/workbook.rb +187 -0
  171. data/lib/ruby_llm/file_store/converters/office/zip.rb +149 -0
  172. data/lib/ruby_llm/file_store/converters/office.rb +106 -0
  173. data/lib/ruby_llm/file_store/converters/text.rb +27 -0
  174. data/lib/ruby_llm/file_store/converters.rb +84 -0
  175. data/lib/ruby_llm/file_store/directory.rb +190 -0
  176. data/lib/ruby_llm/file_store/memory.rb +75 -0
  177. data/lib/ruby_llm/file_store/tools/base.rb +99 -0
  178. data/lib/ruby_llm/file_store/tools/delete.rb +48 -0
  179. data/lib/ruby_llm/file_store/tools/edit.rb +155 -0
  180. data/lib/ruby_llm/file_store/tools/list.rb +57 -0
  181. data/lib/ruby_llm/file_store/tools/read.rb +120 -0
  182. data/lib/ruby_llm/file_store/tools/search.rb +208 -0
  183. data/lib/ruby_llm/file_store/tools/write.rb +40 -0
  184. data/lib/ruby_llm/file_store/tools.rb +39 -0
  185. data/lib/ruby_llm/file_store.rb +338 -0
  186. data/lib/ruby_llm/files/saving.rb +14 -0
  187. data/lib/ruby_llm/image.rb +67 -45
  188. data/lib/ruby_llm/input_required_error.rb +28 -0
  189. data/lib/ruby_llm/judge/builder.rb +6 -1
  190. data/lib/ruby_llm/judge.rb +1 -1
  191. data/lib/ruby_llm/judgment.rb +6 -24
  192. data/lib/ruby_llm/mcp/client.rb +67 -28
  193. data/lib/ruby_llm/mcp/collection.rb +39 -2
  194. data/lib/ruby_llm/mcp/content.rb +7 -2
  195. data/lib/ruby_llm/mcp/error.rb +8 -2
  196. data/lib/ruby_llm/mcp/http.rb +117 -37
  197. data/lib/ruby_llm/mcp/input_request.rb +3 -112
  198. data/lib/ruby_llm/mcp/input_required_error.rb +3 -21
  199. data/lib/ruby_llm/mcp/insufficient_scope_error.rb +27 -0
  200. data/lib/ruby_llm/mcp/listener.rb +7 -1
  201. data/lib/ruby_llm/mcp/oauth.rb +102 -39
  202. data/lib/ruby_llm/mcp/outages.rb +172 -0
  203. data/lib/ruby_llm/mcp/resource.rb +9 -6
  204. data/lib/ruby_llm/mcp/resource_template.rb +3 -2
  205. data/lib/ruby_llm/mcp/session_expired.rb +10 -2
  206. data/lib/ruby_llm/mcp/stdio.rb +66 -18
  207. data/lib/ruby_llm/mcp/task.rb +13 -40
  208. data/lib/ruby_llm/mcp/tool.rb +34 -11
  209. data/lib/ruby_llm/mcp.rb +123 -59
  210. data/lib/ruby_llm/message.rb +217 -23
  211. data/lib/ruby_llm/model/modalities.rb +1 -1
  212. data/lib/ruby_llm/model/pricing.rb +26 -4
  213. data/lib/ruby_llm/model/pricing_category.rb +38 -15
  214. data/lib/ruby_llm/model/pricing_tier.rb +3 -2
  215. data/lib/ruby_llm/model.rb +24 -6
  216. data/lib/ruby_llm/models/lookup.rb +37 -4
  217. data/lib/ruby_llm/models/registry.rb +4 -19
  218. data/lib/ruby_llm/models.json +36186 -27832
  219. data/lib/ruby_llm/models.rb +44 -39
  220. data/lib/ruby_llm/moderation.rb +10 -30
  221. data/lib/ruby_llm/ocr.rb +5 -22
  222. data/lib/ruby_llm/open_telemetry/attributes.rb +68 -19
  223. data/lib/ruby_llm/open_telemetry/content.rb +105 -0
  224. data/lib/ruby_llm/open_telemetry.rb +18 -4
  225. data/lib/ruby_llm/prompt/context.rb +2 -2
  226. data/lib/ruby_llm/prompt.rb +7 -5
  227. data/lib/ruby_llm/protocol/binary_streaming.rb +2 -8
  228. data/lib/ruby_llm/protocol/cache_lifetime.rb +22 -0
  229. data/lib/ruby_llm/protocol/native_content.rb +69 -0
  230. data/lib/ruby_llm/protocol/replayed_arguments.rb +28 -0
  231. data/lib/ruby_llm/protocol/stream_accumulator.rb +73 -33
  232. data/lib/ruby_llm/protocol/streaming.rb +105 -22
  233. data/lib/ruby_llm/protocol/tool_namespaces.rb +100 -0
  234. data/lib/ruby_llm/protocol/unsupported_operations.rb +42 -0
  235. data/lib/ruby_llm/protocol.rb +134 -109
  236. data/lib/ruby_llm/protocols/anthropic/batches.rb +41 -4
  237. data/lib/ruby_llm/protocols/anthropic/block_binding.rb +30 -0
  238. data/lib/ruby_llm/protocols/anthropic/chat.rb +135 -60
  239. data/lib/ruby_llm/protocols/anthropic/files.rb +9 -0
  240. data/lib/ruby_llm/protocols/anthropic/media.rb +14 -14
  241. data/lib/ruby_llm/protocols/anthropic/models.rb +16 -10
  242. data/lib/ruby_llm/protocols/anthropic/streaming.rb +43 -22
  243. data/lib/ruby_llm/protocols/anthropic/tools.rb +36 -24
  244. data/lib/ruby_llm/protocols/anthropic.rb +61 -6
  245. data/lib/ruby_llm/protocols/azure/speech/transcription.rb +125 -0
  246. data/lib/ruby_llm/protocols/azure/speech.rb +14 -0
  247. data/lib/ruby_llm/protocols/bedrock/batches.rb +26 -8
  248. data/lib/ruby_llm/protocols/bedrock/event_stream.rb +205 -0
  249. data/lib/ruby_llm/protocols/bedrock/files.rb +15 -6
  250. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +1 -1
  251. data/lib/ruby_llm/protocols/bedrock/rerank.rb +2 -0
  252. data/lib/ruby_llm/protocols/chat_completions/chat.rb +158 -58
  253. data/lib/ruby_llm/protocols/chat_completions/content_filters.rb +45 -0
  254. data/lib/ruby_llm/protocols/chat_completions/embedding_batches.rb +23 -2
  255. data/lib/ruby_llm/protocols/chat_completions/embeddings.rb +26 -3
  256. data/lib/ruby_llm/protocols/chat_completions/images.rb +7 -9
  257. data/lib/ruby_llm/protocols/chat_completions/media.rb +20 -20
  258. data/lib/ruby_llm/protocols/chat_completions/moderation.rb +8 -5
  259. data/lib/ruby_llm/protocols/chat_completions/speech.rb +8 -2
  260. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +38 -17
  261. data/lib/ruby_llm/protocols/chat_completions/think_tags.rb +148 -0
  262. data/lib/ruby_llm/protocols/chat_completions/tools.rb +15 -28
  263. data/lib/ruby_llm/protocols/chat_completions/transcription.rb +17 -2
  264. data/lib/ruby_llm/protocols/chat_completions.rb +4 -0
  265. data/lib/ruby_llm/protocols/cohere/batches.rb +15 -8
  266. data/lib/ruby_llm/protocols/cohere/chat.rb +47 -25
  267. data/lib/ruby_llm/protocols/cohere/datasets.rb +6 -2
  268. data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -5
  269. data/lib/ruby_llm/protocols/cohere/media.rb +7 -7
  270. data/lib/ruby_llm/protocols/cohere/rerank.rb +2 -1
  271. data/lib/ruby_llm/protocols/cohere/streaming.rb +26 -11
  272. data/lib/ruby_llm/protocols/cohere/tools.rb +17 -28
  273. data/lib/ruby_llm/protocols/cohere/transcription.rb +5 -4
  274. data/lib/ruby_llm/protocols/converse/batches.rb +2 -24
  275. data/lib/ruby_llm/protocols/converse/chat.rb +210 -75
  276. data/lib/ruby_llm/protocols/converse/media.rb +24 -22
  277. data/lib/ruby_llm/protocols/converse/reasoning.rb +23 -9
  278. data/lib/ruby_llm/protocols/converse/streaming.rb +68 -220
  279. data/lib/ruby_llm/protocols/converse.rb +17 -1
  280. data/lib/ruby_llm/protocols/deepgram/speech.rb +1 -2
  281. data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +1 -3
  282. data/lib/ruby_llm/protocols/deepgram/transcription.rb +28 -19
  283. data/lib/ruby_llm/protocols/deepgram.rb +3 -2
  284. data/lib/ruby_llm/protocols/deepseek/files.rb +1 -1
  285. data/lib/ruby_llm/protocols/elevenlabs/assets.rb +6 -4
  286. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +4 -0
  287. data/lib/ruby_llm/protocols/elevenlabs/speech.rb +3 -3
  288. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +5 -2
  289. data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +6 -3
  290. data/lib/ruby_llm/protocols/files.rb +27 -12
  291. data/lib/ruby_llm/protocols/gemini/caches.rb +4 -4
  292. data/lib/ruby_llm/protocols/gemini/chat.rb +118 -51
  293. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +9 -5
  294. data/lib/ruby_llm/protocols/gemini/embeddings.rb +3 -3
  295. data/lib/ruby_llm/protocols/gemini/file_transcription.rb +4 -0
  296. data/lib/ruby_llm/protocols/gemini/files.rb +5 -4
  297. data/lib/ruby_llm/protocols/gemini/images.rb +11 -9
  298. data/lib/ruby_llm/protocols/gemini/live_transcription.rb +1 -1
  299. data/lib/ruby_llm/protocols/gemini/long_decode.rb +104 -0
  300. data/lib/ruby_llm/protocols/gemini/media.rb +31 -28
  301. data/lib/ruby_llm/protocols/gemini/retry_info.rb +29 -0
  302. data/lib/ruby_llm/protocols/gemini/schemas.rb +126 -0
  303. data/lib/ruby_llm/protocols/gemini/speech.rb +9 -3
  304. data/lib/ruby_llm/protocols/gemini/streaming.rb +37 -13
  305. data/lib/ruby_llm/protocols/gemini/tools.rb +18 -10
  306. data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -9
  307. data/lib/ruby_llm/protocols/gemini/videos.rb +14 -3
  308. data/lib/ruby_llm/protocols/gemini.rb +16 -0
  309. data/lib/ruby_llm/protocols/gpustack/responses.rb +3 -3
  310. data/lib/ruby_llm/protocols/gpustack/tokenization.rb +14 -0
  311. data/lib/ruby_llm/protocols/interactions/chat.rb +29 -14
  312. data/lib/ruby_llm/protocols/interactions/content.rb +19 -14
  313. data/lib/ruby_llm/protocols/interactions/streaming.rb +1 -1
  314. data/lib/ruby_llm/protocols/interactions/tools.rb +10 -13
  315. data/lib/ruby_llm/protocols/interactions/videos.rb +99 -0
  316. data/lib/ruby_llm/protocols/interactions.rb +1 -0
  317. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +28 -10
  318. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +1 -1
  319. data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +45 -11
  320. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +5 -2
  321. data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +11 -3
  322. data/lib/ruby_llm/protocols/invoke_model.rb +21 -2
  323. data/lib/ruby_llm/protocols/meta/transcription/stream.rb +108 -0
  324. data/lib/ruby_llm/protocols/meta/transcription.rb +76 -0
  325. data/lib/ruby_llm/protocols/minimax/files.rb +58 -0
  326. data/lib/ruby_llm/protocols/minimax/images.rb +54 -0
  327. data/lib/ruby_llm/protocols/minimax/speech.rb +53 -0
  328. data/lib/ruby_llm/protocols/minimax/transcription.rb +82 -0
  329. data/lib/ruby_llm/protocols/minimax/videos.rb +93 -0
  330. data/lib/ruby_llm/protocols/minimax.rb +21 -0
  331. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +44 -21
  332. data/lib/ruby_llm/protocols/mistral/files.rb +6 -4
  333. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +15 -4
  334. data/lib/ruby_llm/protocols/moonshot/files.rb +48 -0
  335. data/lib/ruby_llm/protocols/openai/batches.rb +3 -2
  336. data/lib/ruby_llm/protocols/openai/decisions.rb +6 -4
  337. data/lib/ruby_llm/protocols/openai/files.rb +13 -6
  338. data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -17
  339. data/lib/ruby_llm/protocols/openrouter/responses.rb +6 -10
  340. data/lib/ruby_llm/protocols/perplexity/agent.rb +19 -35
  341. data/lib/ruby_llm/protocols/perplexity/batches.rb +37 -0
  342. data/lib/ruby_llm/protocols/perplexity/files.rb +19 -9
  343. data/lib/ruby_llm/protocols/responses/approvals.rb +12 -11
  344. data/lib/ruby_llm/protocols/responses/chat.rb +165 -75
  345. data/lib/ruby_llm/protocols/responses/citation_markers.rb +167 -0
  346. data/lib/ruby_llm/protocols/responses/compaction.rb +2 -2
  347. data/lib/ruby_llm/protocols/responses/media.rb +17 -9
  348. data/lib/ruby_llm/protocols/responses/streaming.rb +71 -12
  349. data/lib/ruby_llm/protocols/responses/token_counting.rb +1 -1
  350. data/lib/ruby_llm/protocols/responses/tools.rb +48 -1
  351. data/lib/ruby_llm/protocols/responses.rb +21 -0
  352. data/lib/ruby_llm/protocols/system_one/responses.rb +9 -2
  353. data/lib/ruby_llm/protocols/vertexai/files.rb +4 -3
  354. data/lib/ruby_llm/protocols/vertexai/text_to_speech.rb +116 -0
  355. data/lib/ruby_llm/protocols/xai/files.rb +13 -6
  356. data/lib/ruby_llm/protocols/zai/files.rb +37 -0
  357. data/lib/ruby_llm/provider.rb +128 -31
  358. data/lib/ruby_llm/providers/anthropic.rb +5 -1
  359. data/lib/ruby_llm/providers/azure/chat.rb +17 -3
  360. data/lib/ruby_llm/providers/azure/chat_completions.rb +1 -0
  361. data/lib/ruby_llm/providers/azure/decisions.rb +17 -0
  362. data/lib/ruby_llm/providers/azure/embeddings.rb +1 -2
  363. data/lib/ruby_llm/providers/azure/media.rb +6 -6
  364. data/lib/ruby_llm/providers/azure/mistral.rb +17 -0
  365. data/lib/ruby_llm/providers/azure/models.rb +16 -2
  366. data/lib/ruby_llm/providers/azure/moderation.rb +16 -0
  367. data/lib/ruby_llm/providers/azure/responses.rb +9 -0
  368. data/lib/ruby_llm/providers/azure/videos.rb +2 -2
  369. data/lib/ruby_llm/providers/azure.rb +43 -1
  370. data/lib/ruby_llm/providers/bedrock/anthropic/batches.rb +32 -0
  371. data/lib/ruby_llm/providers/bedrock/anthropic.rb +134 -0
  372. data/lib/ruby_llm/providers/bedrock/auth.rb +12 -3
  373. data/lib/ruby_llm/providers/bedrock/connection.rb +19 -0
  374. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +5 -1
  375. data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +2 -2
  376. data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +12 -2
  377. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -1
  378. data/lib/ruby_llm/providers/bedrock/mantle.rb +7 -0
  379. data/lib/ruby_llm/providers/bedrock/models.rb +2 -9
  380. data/lib/ruby_llm/providers/bedrock.rb +51 -8
  381. data/lib/ruby_llm/providers/cohere.rb +5 -1
  382. data/lib/ruby_llm/providers/deepgram/chat.rb +25 -0
  383. data/lib/ruby_llm/providers/deepgram.rb +21 -2
  384. data/lib/ruby_llm/providers/deepseek/chat.rb +7 -16
  385. data/lib/ruby_llm/providers/deepseek/responses.rb +25 -8
  386. data/lib/ruby_llm/providers/deepseek.rb +5 -1
  387. data/lib/ruby_llm/providers/elevenlabs.rb +2 -1
  388. data/lib/ruby_llm/providers/gemini.rb +13 -1
  389. data/lib/ruby_llm/providers/gpustack/chat.rb +23 -3
  390. data/lib/ruby_llm/providers/gpustack/embeddings.rb +2 -2
  391. data/lib/ruby_llm/providers/gpustack/media.rb +8 -8
  392. data/lib/ruby_llm/providers/gpustack/transcription.rb +14 -6
  393. data/lib/ruby_llm/providers/gpustack.rb +1 -1
  394. data/lib/ruby_llm/providers/hetzner/chat.rb +23 -6
  395. data/lib/ruby_llm/providers/hetzner.rb +5 -1
  396. data/lib/ruby_llm/providers/meta/responses.rb +81 -0
  397. data/lib/ruby_llm/providers/meta.rb +37 -0
  398. data/lib/ruby_llm/providers/minimax/anthropic.rb +95 -0
  399. data/lib/ruby_llm/providers/minimax/chat_completions.rb +48 -0
  400. data/lib/ruby_llm/providers/minimax/responses.rb +52 -0
  401. data/lib/ruby_llm/providers/minimax.rb +104 -0
  402. data/lib/ruby_llm/providers/mistral/chat.rb +34 -4
  403. data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +8 -3
  404. data/lib/ruby_llm/providers/mistral/embeddings.rb +1 -12
  405. data/lib/ruby_llm/providers/mistral/input_tokens.rb +35 -0
  406. data/lib/ruby_llm/providers/mistral/media.rb +8 -8
  407. data/lib/ruby_llm/providers/mistral/transcription.rb +8 -6
  408. data/lib/ruby_llm/providers/mistral.rb +13 -1
  409. data/lib/ruby_llm/providers/moonshot/capabilities.rb +16 -0
  410. data/lib/ruby_llm/providers/moonshot/chat.rb +100 -0
  411. data/lib/ruby_llm/providers/moonshot/documents.rb +79 -0
  412. data/lib/ruby_llm/providers/moonshot/responses.rb +55 -0
  413. data/lib/ruby_llm/providers/moonshot.rb +54 -0
  414. data/lib/ruby_llm/providers/ollama/chat.rb +5 -6
  415. data/lib/ruby_llm/providers/ollama/embeddings.rb +41 -0
  416. data/lib/ruby_llm/providers/ollama/media.rb +6 -6
  417. data/lib/ruby_llm/providers/ollama/speech.rb +24 -0
  418. data/lib/ruby_llm/providers/ollama.rb +13 -1
  419. data/lib/ruby_llm/providers/ollama_cloud.rb +22 -1
  420. data/lib/ruby_llm/providers/openai/responses.rb +8 -0
  421. data/lib/ruby_llm/providers/openai.rb +7 -3
  422. data/lib/ruby_llm/providers/openrouter/chat.rb +15 -9
  423. data/lib/ruby_llm/providers/openrouter/embeddings.rb +12 -8
  424. data/lib/ruby_llm/providers/openrouter/images.rb +16 -10
  425. data/lib/ruby_llm/providers/openrouter/judgments.rb +47 -0
  426. data/lib/ruby_llm/providers/openrouter/media.rb +5 -5
  427. data/lib/ruby_llm/providers/openrouter/models.rb +19 -7
  428. data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -4
  429. data/lib/ruby_llm/providers/openrouter/videos.rb +1 -1
  430. data/lib/ruby_llm/providers/openrouter.rb +32 -5
  431. data/lib/ruby_llm/providers/perplexity/chat.rb +3 -3
  432. data/lib/ruby_llm/providers/perplexity/embeddings.rb +1 -1
  433. data/lib/ruby_llm/providers/perplexity/media.rb +6 -6
  434. data/lib/ruby_llm/providers/perplexity.rb +13 -2
  435. data/lib/ruby_llm/providers/typesafe.rb +1 -1
  436. data/lib/ruby_llm/providers/vertexai/anthropic.rb +5 -1
  437. data/lib/ruby_llm/providers/vertexai/credentials.rb +27 -3
  438. data/lib/ruby_llm/providers/vertexai/embed_content.rb +1 -1
  439. data/lib/ruby_llm/providers/vertexai/embeddings.rb +2 -3
  440. data/lib/ruby_llm/providers/vertexai/gemini.rb +1 -1
  441. data/lib/ruby_llm/providers/vertexai/mistral.rb +5 -0
  442. data/lib/ruby_llm/providers/vertexai/models.rb +24 -2
  443. data/lib/ruby_llm/providers/vertexai/transcription.rb +2 -2
  444. data/lib/ruby_llm/providers/vertexai/videos.rb +1 -1
  445. data/lib/ruby_llm/providers/vertexai.rb +56 -9
  446. data/lib/ruby_llm/providers/xai/chat.rb +3 -3
  447. data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +8 -3
  448. data/lib/ruby_llm/providers/xai/images.rb +9 -4
  449. data/lib/ruby_llm/providers/xai/models.rb +2 -2
  450. data/lib/ruby_llm/providers/xai/responses.rb +10 -0
  451. data/lib/ruby_llm/providers/xai/speech.rb +2 -2
  452. data/lib/ruby_llm/providers/xai/transcription.rb +14 -9
  453. data/lib/ruby_llm/providers/xai/videos.rb +1 -1
  454. data/lib/ruby_llm/providers/xai.rb +9 -1
  455. data/lib/ruby_llm/providers/zai/chat.rb +117 -0
  456. data/lib/ruby_llm/providers/zai/ocr.rb +65 -0
  457. data/lib/ruby_llm/providers/zai/token_counting.rb +25 -0
  458. data/lib/ruby_llm/providers/zai/videos.rb +67 -0
  459. data/lib/ruby_llm/providers/zai.rb +66 -0
  460. data/lib/ruby_llm/question.rb +390 -0
  461. data/lib/ruby_llm/queued_message.rb +74 -0
  462. data/lib/ruby_llm/railtie.rb +43 -0
  463. data/lib/ruby_llm/request_shape/part.rb +2 -2
  464. data/lib/ruby_llm/request_shape/problem.rb +2 -2
  465. data/lib/ruby_llm/request_shape/tool_round.rb +2 -2
  466. data/lib/ruby_llm/request_shape/turn.rb +2 -2
  467. data/lib/ruby_llm/request_shape.rb +1 -1
  468. data/lib/ruby_llm/rerank.rb +10 -37
  469. data/lib/ruby_llm/research_job.rb +26 -13
  470. data/lib/ruby_llm/sandbox/landlock.rb +78 -0
  471. data/lib/ruby_llm/sandbox/mxc.rb +137 -0
  472. data/lib/ruby_llm/sandbox.rb +141 -0
  473. data/lib/ruby_llm/search_results.rb +9 -4
  474. data/lib/ruby_llm/server_tool_call.rb +1 -1
  475. data/lib/ruby_llm/skill/loader.rb +131 -0
  476. data/lib/ruby_llm/skill.rb +263 -0
  477. data/lib/ruby_llm/speech.rb +25 -53
  478. data/lib/ruby_llm/stored_file.rb +94 -0
  479. data/lib/ruby_llm/support/cancellation.rb +4 -4
  480. data/lib/ruby_llm/support/deprecated_enumerable.rb +13 -0
  481. data/lib/ruby_llm/support/deprecated_job_predicates.rb +24 -0
  482. data/lib/ruby_llm/support/inspectable.rb +1 -1
  483. data/lib/ruby_llm/support/instrumentation.rb +6 -5
  484. data/lib/ruby_llm/support/rescuable.rb +84 -0
  485. data/lib/ruby_llm/support/utils.rb +26 -3
  486. data/lib/ruby_llm/task.rb +126 -0
  487. data/lib/ruby_llm/test_helper.rb +129 -0
  488. data/lib/ruby_llm/thinking.rb +3 -3
  489. data/lib/ruby_llm/tokens.rb +52 -12
  490. data/lib/ruby_llm/tool.rb +339 -5
  491. data/lib/ruby_llm/tool_call/collection.rb +132 -0
  492. data/lib/ruby_llm/tool_call.rb +68 -6
  493. data/lib/ruby_llm/tools/provider_tools.rb +23 -0
  494. data/lib/ruby_llm/tools/search.rb +118 -0
  495. data/lib/ruby_llm/transcription/segment.rb +67 -0
  496. data/lib/ruby_llm/transcription/wav_audio.rb +2 -1
  497. data/lib/ruby_llm/transcription.rb +25 -49
  498. data/lib/ruby_llm/transcription_chunk.rb +9 -9
  499. data/lib/ruby_llm/transport/connection.rb +146 -33
  500. data/lib/ruby_llm/transport/error_middleware.rb +94 -49
  501. data/lib/ruby_llm/transport/event_stream_parser.rb +18 -4
  502. data/lib/ruby_llm/transport/json_response.rb +9 -3
  503. data/lib/ruby_llm/transport/log_formatter.rb +20 -0
  504. data/lib/ruby_llm/transport/release_request.rb +35 -0
  505. data/lib/ruby_llm/transport/websocket_connection.rb +26 -30
  506. data/lib/ruby_llm/uploaded_file.rb +15 -6
  507. data/lib/ruby_llm/version.rb +1 -1
  508. data/lib/ruby_llm/video.rb +34 -10
  509. data/lib/ruby_llm/video_job.rb +55 -42
  510. data/lib/ruby_llm/web_source.rb +38 -0
  511. data/lib/ruby_llm/workflow.rb +2 -2
  512. data/lib/ruby_llm.rb +63 -25
  513. data/lib/tasks/ruby_llm/evaluations.rb +1 -1
  514. data/skills/rubyllm/SKILL.md +128 -54
  515. data/skills/rubyllm/references/cost-and-tracing.md +8 -0
  516. data/skills/rubyllm/references/files-code-and-context.md +6 -0
  517. data/skills/rubyllm/references/operations.md +8 -0
  518. data/skills/rubyllm/references/rails-chats.md +22 -0
  519. data/skills/rubyllm/references/tools-and-agents.md +11 -0
  520. metadata +219 -14
  521. data/lib/generators/ruby_llm/chat_ui/templates/helpers/messages_helper.rb.tt +0 -25
  522. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +0 -78
@@ -5,10 +5,27 @@ module RubyLLM
5
5
  class Responses
6
6
  # Chat methods of the OpenAI Responses API
7
7
  module Chat
8
+ def native_tool_namespace?(_namespace) = true
9
+
10
+ # Tool search loads a deferred function declared outside any
11
+ # namespace as a namespace named after the function, and the model
12
+ # calls it under that namespace.
13
+ def searched_tool_namespace?(call, tools) # :nodoc:
14
+ function = tools[call.name.to_sym]
15
+ return false if function.nil? || function.namespace || call.namespace != call.name.to_s
16
+
17
+ tools.each_value.none? { |tool| tool.namespace == call.namespace }
18
+ end
19
+
8
20
  def completion_url
9
21
  'responses'
10
22
  end
11
23
 
24
+ def wire_parameters
25
+ super.merge('reasoning' => :thinking, 'reasoning.effort' => :thinking,
26
+ 'max_output_tokens' => :max_output_tokens)
27
+ end
28
+
12
29
  OPENAI_INLINE_FILE_LIMIT = 50 * 1024 * 1024
13
30
  PROMPT_CACHE_OPTIONS = %i[key ttl mode retention].freeze
14
31
 
@@ -23,8 +40,8 @@ module RubyLLM
23
40
  # to ride back in the response. xAI only encrypts it when asked.
24
41
  payload = {
25
42
  model: model.id,
26
- input: format_input(messages, caching:),
27
- instructions: format_instructions(messages, caching:),
43
+ input: render_input(messages, caching:),
44
+ instructions: render_instructions(messages, caching:),
28
45
  stream: stream,
29
46
  store: false,
30
47
  include: ['reasoning.encrypted_content']
@@ -34,8 +51,12 @@ module RubyLLM
34
51
  payload[:max_output_tokens] = max_output_tokens unless max_output_tokens.nil?
35
52
 
36
53
  if tools.any?
37
- payload[:tools] = tools.map { |_, tool| tool_for(tool) }
38
- payload[:tool_choice] = build_tool_choice(tool_prefs[:choice]) unless tool_prefs[:choice].nil?
54
+ payload[:tools] = render_tools(tools)
55
+ payload[:input] = namespace_searched_calls(payload[:input], tools)
56
+ unless tool_prefs[:choice].nil?
57
+ Protocol::ToolNamespaces.validate_tool_choice(tools, tool_prefs[:choice])
58
+ payload[:tool_choice] = build_tool_choice(tool_prefs[:choice])
59
+ end
39
60
  payload[:parallel_tool_calls] = tool_prefs[:calls] == :many unless tool_prefs[:calls].nil?
40
61
  end
41
62
 
@@ -50,7 +71,8 @@ module RubyLLM
50
71
  end
51
72
 
52
73
  def parse_completion_body(data, raw:)
53
- raise Error.new(data.dig('error', 'message'), response: raw) if data.dig('error', 'message')
74
+ raise_failed_response(data) if data.dig('error', 'message')
75
+ raise Error.new('The provider reported a failed response', response: raw) if data['status'] == 'failed'
54
76
 
55
77
  output = data['output'] || []
56
78
  content = parse_output_text(output)
@@ -69,14 +91,19 @@ module RubyLLM
69
91
  tool_calls: parse_pending_tool_calls(output, response: raw, finish_reason: finish_reason),
70
92
  server_tool_calls: server_tool_calls,
71
93
  raw_content: keep_raw_output?(output, server_tool_calls) ? output : nil,
94
+ phase: parse_phase(output),
72
95
  model: data['model'],
73
96
  raw: raw,
74
97
  finish_reason: finish_reason,
75
- **parse_usage(data['usage'] || {}),
76
- server_tool_use: parse_server_tool_use(data)
98
+ content_filters: ChatCompletions::ContentFilters.parse_content_filters(data),
99
+ **parse_completion_usage(data)
77
100
  )
78
101
  end
79
102
 
103
+ def parse_completion_usage(data)
104
+ parse_usage(data['usage'] || {}).merge(server_tool_use: parse_server_tool_use(data))
105
+ end
106
+
80
107
  # context_management is an OpenAI parameter that only OpenAI's own
81
108
  # endpoints serve; the other services on this wire format reject it.
82
109
  COMPACTION_PROVIDERS = %w[openai azure].freeze
@@ -109,8 +136,35 @@ module RubyLLM
109
136
 
110
137
  PORTABLE_OUTPUT_ITEM_TYPES = (%w[message function_call] + TOOL_SEARCH_ITEM_TYPES).freeze
111
138
 
139
+ # A message carries one thinking signature and one phase, so output
140
+ # with more reasoning items or phases than that only replays whole
141
+ # from its raw items.
112
142
  def keep_raw_output?(output, server_tool_calls)
113
- server_tool_calls.any? || output.any? { |item| namespaced_call?(item) }
143
+ server_tool_calls.any? || output.any? { |item| namespaced_call?(item) } ||
144
+ output.count { |item| item['type'] == 'reasoning' } > 1 || message_phases(output).uniq.size > 1
145
+ end
146
+
147
+ def parse_phase(output)
148
+ message_phases(output).compact.last
149
+ end
150
+
151
+ # Structured output lives in the final_answer items, and a turn is
152
+ # read whole only when none of its items carries a phase, as in
153
+ # OpenAI's SDKs. Commentary, unphased text beside a final answer,
154
+ # and phases OpenAI adds later are not the answer. A turn whose
155
+ # items carry different phases keeps them in raw_content; otherwise
156
+ # its one phase tells.
157
+ def parse_answer_text(message)
158
+ output = message.raw_content
159
+ if output.is_a?(Array) && message_phases(output).any?
160
+ parse_output_text(output.select { |item| item['phase'] == 'final_answer' }) || ''
161
+ elsif !message.phase.nil? && message.phase != 'final_answer'
162
+ ''
163
+ end
164
+ end
165
+
166
+ def message_phases(output)
167
+ output.select { |item| item['type'] == 'message' }.map { |item| item['phase'] }
114
168
  end
115
169
 
116
170
  def namespaced_call?(item)
@@ -143,12 +197,26 @@ module RubyLLM
143
197
  end
144
198
 
145
199
  # Result payloads differ by item type; compaction items carry an
146
- # opaque encrypted_content instead of a readable result.
200
+ # opaque encrypted_content instead of a readable result, and a web
201
+ # search keeps the sources it found in its action.
147
202
  SERVER_TOOL_RESULT_KEYS = %w[result results outputs output encrypted_content].freeze
148
203
 
149
204
  def server_tool_result(item)
150
205
  key = SERVER_TOOL_RESULT_KEYS.find { |candidate| item[candidate] }
151
- item[key] if key
206
+ return item[key] if key
207
+
208
+ action = item['action']
209
+ action['sources'] if action.is_a?(Hash)
210
+ end
211
+
212
+ # Only what a search returned. The address in an open_page or
213
+ # find_in_page action is the model's own choice and need not be a
214
+ # result, so text the model read could steer it there.
215
+ def parse_web_sources(message)
216
+ searches = message.server_tool_calls.select { |call| call.type == 'web_search_call' }
217
+ searches.flat_map { |call| Array(call.raw.dig('action', 'sources')) }.filter_map do |source|
218
+ WebSource.new(url: source['url'], title: source['title']) if source['url']
219
+ end
152
220
  end
153
221
 
154
222
  def parse_citations(data, output, content)
@@ -158,13 +226,15 @@ module RubyLLM
158
226
 
159
227
  def parse_output_citations(output, content)
160
228
  offset = 0
161
- output.select { |item| item['type'] == 'message' }.flat_map do |message|
229
+ text_messages(output).each_with_index.flat_map do |message, index|
230
+ offset += MESSAGE_SEPARATOR.length if index.positive?
162
231
  Array(message['content']).flat_map do |part|
163
232
  key = MESSAGE_TEXT_KEYS[part['type']]
164
233
  next [] unless key
165
234
 
166
- citations = offset_citations(parse_annotations(part['annotations'], nil), offset, content)
167
- offset += part[key].to_s.length
235
+ text, markers = CitationMarkers.strip(part[key].to_s)
236
+ citations = offset_citations(parse_annotations(part['annotations'], nil), offset, content, markers)
237
+ offset += text.length
168
238
  citations
169
239
  end
170
240
  end
@@ -195,10 +265,10 @@ module RubyLLM
195
265
  )
196
266
  end
197
267
 
198
- def offset_citations(citations, offset, content)
268
+ def offset_citations(citations, offset, content, markers = CitationMarkers.new)
199
269
  citations.map do |citation|
200
- start_index = citation.start_index && (citation.start_index + offset)
201
- end_index = citation.end_index && (citation.end_index + offset)
270
+ start_index = citation.start_index && (markers.shift(citation.start_index) + offset)
271
+ end_index = citation.end_index && (markers.shift(citation.end_index) + offset)
202
272
 
203
273
  Citation.new(citation.to_h.merge(
204
274
  start_index: start_index,
@@ -229,10 +299,14 @@ module RubyLLM
229
299
 
230
300
  {}.tap do |cache_options|
231
301
  cache_options[:mode] = options[:mode] if options[:mode]
232
- cache_options[:ttl] = ttl if ttl
302
+ cache_options[:ttl] = render_cache_lifetime(ttl) if ttl
233
303
  end
234
304
  end
235
305
 
306
+ def render_cache_lifetime(ttl)
307
+ Protocol::CacheLifetime.render(ttl)
308
+ end
309
+
236
310
  def retention_ttl(retention)
237
311
  return unless retention
238
312
 
@@ -252,6 +326,8 @@ module RubyLLM
252
326
  "Responses prompt caching accepts :key, :ttl, and :mode, got #{format_cache_option_keys(unsupported)}"
253
327
  end
254
328
 
329
+ def cache_option_names = PROMPT_CACHE_OPTIONS
330
+
255
331
  def format_cache_option_keys(keys)
256
332
  keys.map { |key| ":#{key}" }.join(', ')
257
333
  end
@@ -291,31 +367,31 @@ module RubyLLM
291
367
  # The +instructions+ parameter is a plain string and cannot carry a
292
368
  # breakpoint marker or a file. When any system message needs one, every
293
369
  # system message rides along as an input item, so their order holds.
294
- def format_instructions(messages, caching: nil)
370
+ def render_instructions(messages, caching: nil)
295
371
  system = messages.select { |msg| msg.role == :system }
296
372
  return if system.empty? || system_input_items?(system, caching:)
297
373
 
298
374
  system.map { |msg| msg.content.to_s }.join("\n\n")
299
375
  end
300
376
 
301
- def format_input(messages, caching: nil)
377
+ def render_input(messages, caching: nil)
302
378
  system, conversation = messages.partition { |msg| msg.role == :system }
303
379
  system_items = if system_input_items?(system, caching:)
304
- system.flat_map { |msg| [format_item(msg, caching:)].flatten(1) }
380
+ system.flat_map { |msg| [render_item(msg, caching:)].flatten(1) }
305
381
  else
306
382
  []
307
383
  end
308
384
 
309
- system_items + format_conversation(conversation, caching:)
385
+ system_items + render_conversation(conversation, caching:)
310
386
  end
311
387
 
312
- def format_conversation(messages, caching: nil)
388
+ def render_conversation(messages, caching: nil)
313
389
  messages.each_with_object([]) do |message, input|
314
390
  raw = message.raw_content
315
391
  if raw.is_a?(Hash) && raw['object'] == 'response.compaction'
316
392
  input.replace(raw.fetch('output'))
317
393
  else
318
- input.concat([format_item(message, caching:)].flatten(1))
394
+ input.concat([render_item(message, caching:)].flatten(1))
319
395
  end
320
396
  end
321
397
  end
@@ -328,17 +404,17 @@ module RubyLLM
328
404
  msg.role == :system && ((caching != false && msg.cache_until_here?) || msg.attachments.any?)
329
405
  end
330
406
 
331
- def format_item(msg, caching: nil)
407
+ def render_item(msg, caching: nil)
332
408
  case msg.role
333
409
  when :system
334
- item = { role: 'system', content: format_content(msg.content, msg.attachments) }
410
+ item = { role: 'system', content: render_content(msg.content, msg.attachments) }
335
411
  caching != false && msg.cache_until_here? ? inject_cache_breakpoint(item) : item
336
412
  when :tool
337
- format_tool_items(msg)
413
+ render_tool_items(msg)
338
414
  when :assistant
339
- format_assistant_items(msg)
415
+ render_assistant_items(msg)
340
416
  else
341
- item = { role: 'user', content: format_content(msg.content, msg.attachments) }
417
+ item = { role: 'user', content: render_content(msg.content, msg.attachments) }
342
418
  caching != false && msg.cache_until_here? ? inject_cache_breakpoint(item) : item
343
419
  end
344
420
  end
@@ -360,37 +436,44 @@ module RubyLLM
360
436
 
361
437
  # Function call outputs are text-only on the wire, so tool attachments
362
438
  # ride a user item spliced in right after the result.
363
- def format_tool_items(msg)
439
+ def render_tool_items(msg)
364
440
  return msg.raw_content if msg.raw_content
365
441
 
366
442
  items = [{
367
443
  type: 'function_call_output',
368
- call_id: msg.tool_call_id,
369
- output: format_content(msg.content)
444
+ call_id: msg.provider_call_id,
445
+ output: render_content(msg.content)
370
446
  }]
371
447
 
372
448
  if msg.attachments.any?
373
- parts = [{ type: 'input_text', text: "Attachments from tool call #{msg.tool_call_id}:" }]
374
- parts.concat(format_content(nil, msg.attachments))
375
- items << { role: 'user', content: parts }
449
+ items << render_attachment_item("Attachments from tool call #{msg.provider_call_id}:", msg.attachments)
376
450
  end
377
451
 
378
452
  items
379
453
  end
380
454
 
381
- def format_assistant_items(msg)
455
+ def render_attachment_item(label, attachments)
456
+ parts = [{ type: 'input_text', text: label }]
457
+ parts.concat(render_content(nil, attachments))
458
+ { role: 'user', content: parts }
459
+ end
460
+
461
+ # Assistant output is text-only on the wire, so the attachments of a
462
+ # replayed assistant turn ride a user item spliced in after it.
463
+ def render_assistant_items(msg)
382
464
  # Turns that used server tools replay their output items verbatim,
383
465
  # reasoning and tool results included, as stateless chaining expects.
384
- return msg.raw_content if msg.raw_content
466
+ return replayed_output(msg) if msg.raw_content
385
467
 
386
468
  items = []
387
- items << format_reasoning_item(msg.thinking) if own_signature(msg)
388
- items << { role: 'assistant', content: format_output_content(msg) } unless empty_content?(msg.content)
389
- items.concat(format_function_call_items(msg.tool_calls)) if msg.tool_call?
469
+ items << render_reasoning_item(msg.thinking) if own_signature(msg)
470
+ items << render_output_item(msg) unless empty_content?(msg.content)
471
+ items.concat(render_function_call_items(msg.tool_calls)) if msg.tool_call?
472
+ items << render_attachment_item('Attachments from the assistant:', msg.attachments) if msg.attachments.any?
390
473
  items
391
474
  end
392
475
 
393
- def format_reasoning_item(thinking)
476
+ def render_reasoning_item(thinking)
394
477
  {
395
478
  type: 'reasoning',
396
479
  summary: thinking.text ? [{ type: 'summary_text', text: thinking.text }] : [],
@@ -398,19 +481,25 @@ module RubyLLM
398
481
  }
399
482
  end
400
483
 
401
- def format_function_call_items(tool_calls)
402
- tool_calls.map do |_, tc|
484
+ def replayed_output(msg)
485
+ Protocol::ReplayedArguments.blank(msg.raw_content, msg,
486
+ id: %w[call_id id], arguments: 'arguments', empty: '{}')
487
+ end
488
+
489
+ def render_function_call_items(tool_calls)
490
+ tool_calls.map do |tc|
403
491
  {
404
492
  type: 'function_call',
405
- call_id: tc.id,
493
+ call_id: tc.provider_call_id,
406
494
  name: tc.name,
495
+ namespace: tc.namespace,
407
496
  arguments: JSON.generate(tc.arguments)
408
- }
497
+ }.compact
409
498
  end
410
499
  end
411
500
 
412
- def format_output_content(msg)
413
- [{ type: 'output_text', text: msg.content }]
501
+ def render_output_item(msg)
502
+ { role: 'assistant', content: [{ type: 'output_text', text: msg.content }], phase: msg.phase }.compact
414
503
  end
415
504
 
416
505
  def empty_content?(content)
@@ -418,53 +507,54 @@ module RubyLLM
418
507
  end
419
508
 
420
509
  MESSAGE_TEXT_KEYS = { 'output_text' => 'text', 'refusal' => 'refusal' }.freeze
510
+ MESSAGE_SEPARATOR = "\n\n"
421
511
 
422
512
  def parse_output_text(output)
423
- texts = output.select { |item| item['type'] == 'message' }.flat_map do |message|
513
+ texts = text_messages(output).map do |message|
424
514
  Array(message['content']).filter_map do |part|
425
515
  key = MESSAGE_TEXT_KEYS[part['type']]
426
- part[key] if key
427
- end
516
+ CitationMarkers.strip(part[key]).first if key && part[key]
517
+ end.join
428
518
  end
429
519
 
430
- texts.empty? ? nil : texts.join
520
+ texts.empty? ? nil : texts.join(MESSAGE_SEPARATOR)
521
+ end
522
+
523
+ def text_messages(output)
524
+ output.select do |item|
525
+ item['type'] == 'message' && Array(item['content']).any? { |part| MESSAGE_TEXT_KEYS.key?(part['type']) }
526
+ end
431
527
  end
432
528
 
433
529
  FINISH_REASONS = {
434
- 'completed' => :stop, 'max_output_tokens' => :max_tokens, 'content_filter' => :content_filter
530
+ 'completed' => :stop, 'function_call' => :tool_calls,
531
+ 'max_output_tokens' => :max_tokens, 'content_filter' => :content_filter
435
532
  }.freeze
436
533
 
437
534
  def finish_reasons = FINISH_REASONS
438
535
 
439
536
  def parse_finish_reason(data)
440
- reason = data.dig('incomplete_details', 'reason') || (data['status'] if data['status'] == 'completed')
441
- normalize_finish_reason(reason)
537
+ normalize_finish_reason(data.dig('incomplete_details', 'reason') || parse_completed_reason(data))
442
538
  end
443
539
 
444
- def parse_function_calls(output, response: nil, finish_reason: nil)
445
- calls = output.select { |item| item['type'] == 'function_call' }
446
- return nil if calls.empty?
540
+ def parse_completed_reason(data)
541
+ return unless data['status'] == 'completed'
447
542
 
448
- calls.to_h do |call|
449
- arguments = call['arguments']
450
-
451
- [
452
- call['call_id'],
453
- ToolCall.new(
454
- id: call['call_id'],
455
- name: call['name'],
456
- arguments: parse_function_call_arguments(arguments, response: response, finish_reason: finish_reason)
457
- )
458
- ]
459
- end
543
+ Array(data['output']).any? { |item| item['type'] == 'function_call' } ? 'function_call' : 'completed'
460
544
  end
461
545
 
462
- def parse_function_call_arguments(arguments, response: nil, finish_reason: nil)
463
- return {} if arguments.nil? || arguments.empty?
546
+ def parse_function_calls(output, response: nil, finish_reason: nil)
547
+ calls = output.select { |item| item['type'] == 'function_call' }
548
+ return nil if calls.empty?
464
549
 
465
- JSON.parse(arguments)
466
- rescue JSON::ParserError => e
467
- raise ToolCallParseError.new(response: response, finish_reason: finish_reason), cause: e
550
+ ToolCall.index(calls.map do |call|
551
+ ToolCall.new(
552
+ provider_call_id: call['call_id'],
553
+ name: call['name'],
554
+ namespace: call['namespace'],
555
+ **ToolCall.parse_arguments(call['arguments'], response:, finish_reason:)
556
+ )
557
+ end, by: :provider_call_id)
468
558
  end
469
559
 
470
560
  def parse_reasoning_summary(output)
@@ -472,14 +562,14 @@ module RubyLLM
472
562
  Array(item['summary']).filter_map { |part| part['text'] }
473
563
  end
474
564
 
475
- texts.empty? ? nil : texts.join("\n")
565
+ texts.empty? ? nil : texts.join("\n\n")
476
566
  end
477
567
 
478
568
  def parse_reasoning_signature(output)
479
569
  output.find { |item| item['type'] == 'reasoning' }&.dig('encrypted_content')
480
570
  end
481
571
 
482
- def supports_provider_file_references?
572
+ def provider_file_references?
483
573
  true
484
574
  end
485
575
 
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class Responses
6
+ # Removes the citation markers OpenAI's models write into their text:
7
+ # U+E200, then "cite" and the sources and line locators it cites, each
8
+ # after a U+E202, then U+E201. Only OpenAI's own tools turn them into annotations, so
9
+ # a model that writes them without those tools leaves them in the
10
+ # answer. Model output is untrusted: each character is read once, and
11
+ # an unclosed marker holds back at most MAX_LENGTH characters. A space
12
+ # before a removed marker goes too when punctuation, a space, or the end
13
+ # of the text follows. Only a marker holds a space past the end of a
14
+ # stream chunk, so text keeps its order across content parts. #shift
15
+ # maps an index into the text as sent onto the text without markers.
16
+ class CitationMarkers # :nodoc:
17
+ OPEN = "\u{E200}"
18
+ CLOSE = "\u{E201}"
19
+ SEPARATOR = "\u{E202}"
20
+ MARKS = [OPEN, CLOSE, SEPARATOR].freeze
21
+ CITE = 'cite'
22
+ REFERENCE = /\A[\w-]*\z/
23
+ SPACE = ' '
24
+ BOUNDARY = /[[:space:].,;:!?)\]}]/
25
+ MAX_LENGTH = 256
26
+
27
+ def self.strip(text)
28
+ markers = new
29
+ ["#{markers.feed(text)}#{markers.finish}", markers]
30
+ end
31
+
32
+ def initialize
33
+ @held = nil
34
+ @seen = 0
35
+ @removed = []
36
+ @space_at = nil
37
+ @after_marker = false
38
+ end
39
+
40
+ def feed(text)
41
+ return text if text.nil? || text.empty?
42
+
43
+ output = +''
44
+ text.each_char { |char| read(char, output) }
45
+ settle_space(output, boundary: false) unless @held || @after_marker
46
+ output.empty? ? nil : output
47
+ end
48
+
49
+ def finish
50
+ output = +''
51
+ held = take_held
52
+ if held && !citation_prefix?(held)
53
+ release(held, output)
54
+ else
55
+ drop(held) if held
56
+ settle_space(output, boundary: true)
57
+ end
58
+ output.empty? ? nil : output
59
+ end
60
+
61
+ def shift(index)
62
+ return index if index.nil?
63
+
64
+ index - @removed.sum { |position, length| position < index ? length : 0 }
65
+ end
66
+
67
+ private
68
+
69
+ def read(char, output)
70
+ if @held
71
+ hold(char, output)
72
+ elsif char == OPEN
73
+ open_marker
74
+ elsif MARKS.include?(char)
75
+ @removed << [@seen, 1]
76
+ else
77
+ write(char, output)
78
+ end
79
+ @seen += 1
80
+ end
81
+
82
+ def write(char, output)
83
+ settle_space(output, boundary: char.match?(BOUNDARY))
84
+ if char == SPACE
85
+ @space_at = @seen
86
+ else
87
+ output << char
88
+ end
89
+ end
90
+
91
+ def settle_space(output, boundary:)
92
+ if @space_at && @after_marker && boundary
93
+ @removed << [@space_at, 1]
94
+ elsif @space_at
95
+ output << SPACE
96
+ end
97
+ @space_at = nil
98
+ @after_marker = false
99
+ end
100
+
101
+ def open_marker
102
+ @held = +OPEN
103
+ @held_at = @seen
104
+ end
105
+
106
+ def hold(char, output)
107
+ if char == OPEN
108
+ release(@held, output)
109
+ open_marker
110
+ else
111
+ @held << char
112
+ return close_marker(output) if char == CLOSE
113
+
114
+ release(take_held, output) if @held.length > MAX_LENGTH
115
+ end
116
+ end
117
+
118
+ def close_marker(output)
119
+ held = take_held
120
+ complete_marker?(held.delete_suffix(CLOSE)) ? drop(held) : release(held, output)
121
+ end
122
+
123
+ def citation_prefix?(held)
124
+ CITE.start_with?(held.delete_prefix(OPEN)) || marker?(held)
125
+ end
126
+
127
+ def complete_marker?(held)
128
+ marker?(held) && held.split(SEPARATOR, -1).drop(1).none?(&:empty?)
129
+ end
130
+
131
+ def marker?(held)
132
+ word, *references = held.delete_prefix(OPEN).split(SEPARATOR, -1)
133
+ return false unless word.to_s.end_with?(CITE) && word.each_char.all? { |char| alphanumeric?(char) }
134
+
135
+ references.all? { |reference| reference.match?(REFERENCE) }
136
+ end
137
+
138
+ def alphanumeric?(char)
139
+ char.match?(/[[:alnum:]]/)
140
+ end
141
+
142
+ def take_held
143
+ held = @held
144
+ @held = nil
145
+ held
146
+ end
147
+
148
+ def drop(held)
149
+ @removed << [@held_at, held.length]
150
+ @after_marker = true
151
+ nil
152
+ end
153
+
154
+ def release(held, output)
155
+ settle_space(output, boundary: false)
156
+ held.each_char.with_index do |char, index|
157
+ if MARKS.include?(char)
158
+ @removed << [@held_at + index, 1]
159
+ else
160
+ output << char
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -10,8 +10,8 @@ module RubyLLM
10
10
  end
11
11
 
12
12
  def render_compaction_payload(messages)
13
- { model: model.id, input: format_input(messages, caching: false),
14
- instructions: format_instructions(messages, caching: false) }.compact
13
+ { model: model.id, input: render_input(messages, caching: false),
14
+ instructions: render_instructions(messages, caching: false) }.compact
15
15
  end
16
16
 
17
17
  def parse_compaction_response(response)