ruby_llm 2.1.1 → 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 +160 -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 +515 -274
  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 +1593 -331
  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 +50 -24
  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 +127 -106
  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 +15 -15
  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 +1 -7
  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 +5 -14
  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 +213 -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
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class FileStore
5
+ module Converters
6
+ module Office
7
+ # What a Word paragraph's style and numbering make of it: a Markdown
8
+ # heading, or a list marker with the count Word would show. A style
9
+ # is a heading by its outline level, the nearest one along +basedOn+,
10
+ # which also covers localized names, or else by being named
11
+ # <tt>heading 1</tt> to <tt>heading 6</tt>. Numbers are always
12
+ # decimal, without a level's own format or text. Missing or broken
13
+ # parts mark nothing.
14
+ class Outline # :nodoc:
15
+ HEADING_NAME = /\Aheading ([1-6])\z/i
16
+ HEADING_LEVELS = (0..5)
17
+ MAX_CHAIN = 32
18
+ MAX_LEVEL = 8
19
+ NO_LIST = 0
20
+ private_constant :HEADING_NAME, :HEADING_LEVELS, :MAX_CHAIN, :MAX_LEVEL, :NO_LIST
21
+
22
+ def self.read(zip)
23
+ new(part(zip, 'word/styles.xml'), part(zip, 'word/numbering.xml'))
24
+ end
25
+
26
+ def self.part(zip, name)
27
+ zip[name]
28
+ rescue Zip::Error, Zlib::Error
29
+ nil
30
+ end
31
+ private_class_method :part
32
+
33
+ def initialize(styles, numbering)
34
+ @styles = {}
35
+ @resolved = {}
36
+ @abstracts = {}
37
+ @lists = {}
38
+ @counts = Hash.new { |counts, abstract| counts[abstract] = {} }
39
+ @overridden = {}
40
+ read_styles(styles)
41
+ read_numbering(numbering)
42
+ end
43
+
44
+ # Returns the marker for a paragraph with these properties of its
45
+ # own, counting it if it is a list item.
46
+ def marker(style:, list:, level:, outline:)
47
+ inherited = resolve(style)
48
+ heading = outline ? heading_of(outline) : inherited[:heading]
49
+ return "#{'#' * heading} " if heading
50
+
51
+ item(list || inherited[:list], level || inherited[:level] || 0)
52
+ end
53
+
54
+ private
55
+
56
+ def heading_of(outline)
57
+ outline + 1 if HEADING_LEVELS.cover?(outline)
58
+ end
59
+
60
+ def resolve(id)
61
+ @resolved[id] ||= resolve_chain(id)
62
+ end
63
+
64
+ # Walks at most MAX_CHAIN styles, which also ends a basedOn cycle.
65
+ def resolve_chain(id)
66
+ found = {}
67
+ MAX_CHAIN.times do
68
+ style = @styles[id]
69
+ break unless style
70
+
71
+ found = style.merge(found)
72
+ id = style[:based_on]
73
+ end
74
+ heading = found.key?(:outline) ? heading_of(found[:outline]) : found[:named]
75
+ { heading:, list: found[:list], level: found[:level] }
76
+ end
77
+
78
+ def item(list_id, level)
79
+ return '' if list_id.nil? || list_id == NO_LIST
80
+
81
+ list = @lists[list_id]
82
+ definition = list && @abstracts.dig(list[:abstract], level)
83
+ return '' unless definition
84
+
85
+ count = next_count(list_id, list, level, definition)
86
+ render(definition[:format], count, level)
87
+ end
88
+
89
+ def render(format, count, level)
90
+ case format
91
+ when 'none' then ''
92
+ when 'bullet' then "#{' ' * level}- "
93
+ else "#{' ' * level}#{count}. "
94
+ end
95
+ end
96
+
97
+ def next_count(list_id, list, level, definition)
98
+ counts = @counts[list[:abstract]]
99
+ override = list[:overrides][level]
100
+ if override && !@overridden[[list_id, level]]
101
+ @overridden[[list_id, level]] = true
102
+ counts[level] = override - 1
103
+ end
104
+ counts[level] = (counts[level] || (definition[:start] - 1)) + 1
105
+ counts.delete_if { |deeper, _count| deeper > level }
106
+ counts[level]
107
+ end
108
+
109
+ def read_styles(xml)
110
+ style = nil
111
+ Markup.scan(xml) do |kind, name, tag|
112
+ next if kind == :text || name.nil?
113
+ next style = nil if kind == :close && name == 'style'
114
+ next if kind == :close
115
+
116
+ name == 'style' ? style = open_style(tag) : (style_property(style, name, tag) if style)
117
+ end
118
+ end
119
+
120
+ def open_style(tag)
121
+ return unless Markup.prefixed(tag, 'type') == 'paragraph'
122
+
123
+ id = Markup.prefixed(tag, 'styleId')
124
+ @styles[id] = {} if id
125
+ end
126
+
127
+ def style_property(style, name, tag)
128
+ case name
129
+ when 'name' then assign(style, :named, Markup.prefixed(tag, 'val').to_s[HEADING_NAME, 1]&.to_i)
130
+ when 'basedOn' then style[:based_on] = Markup.prefixed(tag, 'val')
131
+ when 'outlineLvl' then assign(style, :outline, integer(tag))
132
+ when 'numId' then assign(style, :list, integer(tag))
133
+ when 'ilvl' then assign(style, :level, level_of(tag))
134
+ end
135
+ end
136
+
137
+ def assign(style, key, value)
138
+ style[key] = value unless value.nil?
139
+ end
140
+
141
+ def read_numbering(xml)
142
+ state = {}
143
+ Markup.scan(xml) do |kind, name, tag|
144
+ next if kind == :text
145
+
146
+ kind == :close ? close_numbering(state, name) : open_numbering(state, name, tag)
147
+ end
148
+ end
149
+
150
+ def open_numbering(state, name, tag)
151
+ case name
152
+ when 'abstractNum' then state[:abstract] = @abstracts[integer(tag, 'abstractNumId')] = {}
153
+ when 'lvl' then open_level(state, tag)
154
+ when 'start' then state[:level][:start] = integer(tag) || 0 if state[:level]
155
+ when 'numFmt' then state[:level][:format] = Markup.prefixed(tag, 'val') if state[:level]
156
+ when 'num' then state[:list] = @lists[integer(tag, 'numId')] = { overrides: {} }
157
+ when 'abstractNumId' then state[:list][:abstract] = integer(tag) if state[:list]
158
+ when 'lvlOverride' then state[:override] = level_of(tag, 'ilvl')
159
+ when 'startOverride' then start_override(state, integer(tag))
160
+ end
161
+ end
162
+
163
+ def open_level(state, tag)
164
+ level = level_of(tag, 'ilvl')
165
+ state[:level] = (state[:abstract][level] = { start: 0 } if state[:abstract] && level)
166
+ end
167
+
168
+ def start_override(state, value)
169
+ state[:list][:overrides][state[:override]] = value if state[:list] && state[:override] && value
170
+ end
171
+
172
+ def close_numbering(state, name)
173
+ case name
174
+ when 'abstractNum' then state[:abstract] = nil
175
+ when 'lvl' then state[:level] = nil
176
+ when 'num' then state[:list] = nil
177
+ when 'lvlOverride' then state[:override] = nil
178
+ end
179
+ end
180
+
181
+ def integer(tag, attribute = 'val')
182
+ Markup.prefixed(tag, attribute).to_s[/\A\d{1,9}\z/]&.to_i
183
+ end
184
+
185
+ def level_of(tag, attribute = 'val')
186
+ level = integer(tag, attribute)
187
+ level if level && level <= MAX_LEVEL
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class FileStore
5
+ module Converters
6
+ module Office
7
+ # Reads the paragraphs and tables of a Word part or a slide: a line
8
+ # for each paragraph, and a line with tabs between cells for each
9
+ # table row. Word and PowerPoint share these element names.
10
+ class Prose # :nodoc:
11
+ SKIPPED = %w[tabs tabLst pPrChange rPr tblPr tcPr trPr sectPr Fallback].freeze
12
+ BREAKS = { 'tab' => "\t", 'br' => "\n", 'cr' => "\n" }.freeze
13
+ BLANK = /\A[[:space:]]*\z/
14
+ UNREAD_PLACEHOLDERS = %w[sldNum hdr ftr dt sldImg].freeze
15
+ Paragraph = Struct.new(:text, :style, :list, :level, :outline, :marked, :marker)
16
+ private_constant :SKIPPED, :BREAKS, :BLANK, :UNREAD_PLACEHOLDERS, :Paragraph
17
+
18
+ def self.lines(xml, outline: nil)
19
+ new(outline).read(xml)
20
+ end
21
+
22
+ def initialize(outline = nil)
23
+ @outline = outline
24
+ @sinks = [[]]
25
+ @paragraphs = []
26
+ @rows = []
27
+ @shapes = []
28
+ @capturing = false
29
+ end
30
+
31
+ def read(xml)
32
+ Markup.scan(xml, skip: SKIPPED) do |kind, name, content|
33
+ case kind
34
+ when :text then text(content)
35
+ when :open then opened(name, content)
36
+ when :empty then empty(name, content)
37
+ when :close then close(name)
38
+ end
39
+ end
40
+ @sinks.first
41
+ end
42
+
43
+ private
44
+
45
+ def text(content)
46
+ @paragraphs.last.text << Markup.unescape(content) if @capturing && @paragraphs.any?
47
+ end
48
+
49
+ def opened(name, tag)
50
+ case name
51
+ when 'p' then open_paragraph
52
+ when 't' then @capturing = true
53
+ when 'tr' then @rows << []
54
+ when 'tc' then @sinks << []
55
+ when 'sp' then open_shape
56
+ when 'ph' then placeholder(tag)
57
+ else leaf(name, tag)
58
+ end
59
+ end
60
+
61
+ def empty(name, tag)
62
+ case name
63
+ when 'tc' then @rows.last&.push('')
64
+ when 'ph' then placeholder(tag)
65
+ else leaf(name, tag)
66
+ end
67
+ end
68
+
69
+ def leaf(name, tag)
70
+ return unless @paragraphs.any?
71
+
72
+ paragraph = @paragraphs.last
73
+ case name
74
+ when 'tab', 'br', 'cr' then paragraph.text << BREAKS[name]
75
+ when 'pStyle' then paragraph.style = Markup.prefixed(tag, 'val')
76
+ when 'numId' then paragraph.list = number(tag)
77
+ when 'ilvl' then paragraph.level = number(tag)
78
+ when 'outlineLvl' then paragraph.outline = number(tag)
79
+ end
80
+ end
81
+
82
+ def number(tag)
83
+ Markup.prefixed(tag, 'val').to_s[/\A\d{1,9}\z/]&.to_i
84
+ end
85
+
86
+ def close(name)
87
+ case name
88
+ when 't' then @capturing = false
89
+ when 'p' then close_paragraph
90
+ when 'tc' then close_cell
91
+ when 'tr' then close_row
92
+ when 'sp' then close_shape
93
+ end
94
+ end
95
+
96
+ # A paragraph inside another, as in a text box, comes between the
97
+ # text around it, so what the outer one holds so far goes first.
98
+ def open_paragraph
99
+ outer = @paragraphs.last
100
+ emit(outer) if outer && !outer.text.empty?
101
+ @paragraphs << Paragraph.new(+'')
102
+ end
103
+
104
+ def close_paragraph
105
+ paragraph = @paragraphs.pop
106
+ emit(paragraph) if paragraph
107
+ end
108
+
109
+ def emit(paragraph)
110
+ marker = marker_of(paragraph)
111
+ text = paragraph.text
112
+ paragraph.text = +''
113
+ return if text.empty? || (!marker.to_s.empty? && text.match?(BLANK))
114
+
115
+ paragraph.marker = nil
116
+ @sinks.last << "#{marker}#{text}"
117
+ end
118
+
119
+ # A list item counts wherever it is, but a table row keeps to one
120
+ # line of plain cells. The marker waits for text that isn't blank.
121
+ def marker_of(paragraph)
122
+ return paragraph.marker if paragraph.marked || @outline.nil?
123
+
124
+ paragraph.marked = true
125
+ marker = @outline.marker(style: paragraph.style, list: paragraph.list, level: paragraph.level,
126
+ outline: paragraph.outline)
127
+ paragraph.marker = marker if @sinks.size == 1
128
+ end
129
+
130
+ def close_cell
131
+ return unless @sinks.size > 1
132
+
133
+ cell = @sinks.pop.join(' ').tr("\t\n", ' ')
134
+ @rows.last&.push(cell)
135
+ end
136
+
137
+ def close_row
138
+ row = @rows.pop
139
+ @sinks.last << row.join("\t") if row&.any? { |cell| !cell.empty? }
140
+ end
141
+
142
+ def open_shape
143
+ @sinks << []
144
+ @shapes << false
145
+ end
146
+
147
+ def placeholder(tag)
148
+ @shapes[-1] = true if !@shapes.empty? && UNREAD_PLACEHOLDERS.include?(Markup.attribute(tag, 'type'))
149
+ end
150
+
151
+ def close_shape
152
+ return if @sinks.size <= 1 || @shapes.empty?
153
+
154
+ lines = @sinks.pop
155
+ @sinks.last.concat(lines) unless @shapes.pop
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module RubyLLM
6
+ class FileStore
7
+ module Converters
8
+ module Office
9
+ # Reads the sheets of an Excel workbook: a heading for each, and a
10
+ # line with tabs between cells for each row that holds anything.
11
+ class Workbook # :nodoc:
12
+ MAX_COLUMN = 16_384
13
+ MAX_SERIAL = 2_958_465
14
+ SECONDS_A_DAY = 86_400
15
+ BUILT_IN_DATE_FORMATS = [*14..22, *27..36, *45..47, *50..58].freeze
16
+ TRUTHY = %w[1 true on].freeze
17
+ private_constant :MAX_COLUMN, :MAX_SERIAL, :SECONDS_A_DAY, :BUILT_IN_DATE_FORMATS, :TRUTHY
18
+
19
+ def initialize(zip, sheets)
20
+ @zip = zip
21
+ @sheets = sheets
22
+ @size = 0
23
+ end
24
+
25
+ def text
26
+ @strings = shared_strings
27
+ @date_styles = date_styles
28
+ @date1904 = date1904?
29
+ @sheets.each_with_index.map do |(part, tag), index|
30
+ ["## #{Markup.attribute(tag, 'name') || "Sheet #{index + 1}"}", *rows(@zip[part])].join("\n")
31
+ end.join("\n\n")
32
+ end
33
+
34
+ private
35
+
36
+ def shared_strings
37
+ strings = []
38
+ capturing = false
39
+ Markup.scan(@zip['xl/sharedStrings.xml'], skip: %w[rPh]) do |kind, name, content|
40
+ case kind
41
+ when :open
42
+ strings << +'' if name == 'si'
43
+ capturing = true if name == 't'
44
+ when :close then capturing = false if name == 't'
45
+ when :text then strings.last << Markup.unescape(content) if capturing && strings.any?
46
+ end
47
+ end
48
+ strings
49
+ end
50
+
51
+ def rows(xml)
52
+ @lines = []
53
+ @row = @cell = @value = nil
54
+ @capturing = false
55
+ Markup.scan(xml, skip: %w[rPh f]) do |kind, name, content|
56
+ case kind
57
+ when :open then opened(name, content)
58
+ when :empty then place(@row, content, '') if name == 'c' && @row
59
+ when :text then @value << content if @capturing && @value
60
+ when :close then closed(name)
61
+ end
62
+ end
63
+ @lines
64
+ end
65
+
66
+ def opened(name, tag)
67
+ case name
68
+ when 'row' then @row = []
69
+ when 'c'
70
+ @cell = tag
71
+ @value = +''.b
72
+ when 'v', 't' then @capturing = true
73
+ end
74
+ end
75
+
76
+ def closed(name)
77
+ case name
78
+ when 'v', 't' then @capturing = false
79
+ when 'c' then place(@row, @cell, render(@cell, @value)) if @row && @cell
80
+ when 'row'
81
+ add_row(@lines, @row) if @row
82
+ @row = nil
83
+ end
84
+ end
85
+
86
+ def add_row(lines, row)
87
+ return if row.all?(&:empty?)
88
+
89
+ line = row.join("\t")
90
+ @size += line.bytesize
91
+ raise Zip::Error, 'workbook too large' if @size > MAX_SIZE
92
+
93
+ lines << line
94
+ end
95
+
96
+ def place(row, tag, value)
97
+ column = column_of(tag)
98
+ row.fill('', row.size...column) if column
99
+ row << value
100
+ end
101
+
102
+ def column_of(tag)
103
+ letters = Markup.attribute(tag, 'r').to_s[/\A([A-Z]{1,3})\d+\z/, 1]
104
+ column = letters&.each_char&.inject(0) { |sum, letter| (sum * 26) + letter.ord - 64 }
105
+ column.pred if column && column <= MAX_COLUMN
106
+ end
107
+
108
+ def render(tag, value)
109
+ case Markup.attribute(tag, 't')
110
+ when 's' then @strings[value.to_i].to_s
111
+ when 'b' then value == '1' ? 'TRUE' : 'FALSE'
112
+ when nil, 'n' then number(tag, Markup.unescape(value))
113
+ else Markup.unescape(value)
114
+ end
115
+ end
116
+
117
+ def number(tag, value)
118
+ style = Markup.attribute(tag, 's')
119
+ return value unless style && @date_styles[style.to_i]
120
+
121
+ serial = Float(value, exception: false)
122
+ serial && serial >= 0 && serial <= MAX_SERIAL ? date(serial) || value : value
123
+ end
124
+
125
+ def date(serial)
126
+ day = serial.floor
127
+ seconds = ((serial - day) * SECONDS_A_DAY).round
128
+ if seconds == SECONDS_A_DAY
129
+ day += 1
130
+ seconds = 0
131
+ end
132
+ time = format('%<hours>02d:%<minutes>02d:%<seconds>02d', hours: seconds / 3600,
133
+ minutes: seconds % 3600 / 60,
134
+ seconds: seconds % 60)
135
+ return time if day.zero? && seconds.positive?
136
+
137
+ calendar = calendar_day(day)
138
+ return unless calendar
139
+
140
+ seconds.zero? ? calendar : "#{calendar} #{time}"
141
+ end
142
+
143
+ # Excel counts 1900 as a leap year, so serial 60 is a day that never
144
+ # was, and the serials after it are one day ahead of the calendar.
145
+ def calendar_day(day)
146
+ return (Date.new(1904, 1, 1) + day).iso8601 if @date1904
147
+ return if day.zero?
148
+ return '1900-02-29' if day == 60
149
+
150
+ (Date.new(1899, 12, day < 60 ? 31 : 30) + day).iso8601
151
+ end
152
+
153
+ def date_styles
154
+ formats = {}
155
+ styles = []
156
+ in_cell_formats = false
157
+ Markup.scan(@zip['xl/styles.xml']) do |kind, name, tag|
158
+ case name
159
+ when 'numFmt' then formats[Markup.attribute(tag, 'numFmtId').to_i] = Markup.attribute(tag, 'formatCode')
160
+ when 'cellXfs' then in_cell_formats = kind == :open
161
+ when 'xf' then if in_cell_formats && tag
162
+ styles << date_format?(Markup.attribute(tag, 'numFmtId').to_i,
163
+ formats)
164
+ end
165
+ end
166
+ end
167
+ styles
168
+ end
169
+
170
+ def date_format?(id, formats)
171
+ code = formats[id]
172
+ return BUILT_IN_DATE_FORMATS.include?(id) unless code
173
+ return false if code.match?(/\[(?:h+|m+|s+)\]/i)
174
+
175
+ code.gsub(/"[^"]*"|\\.|_.|\*.|\[[^\]]*\]/, '').match?(/[ymdhs]/i)
176
+ end
177
+
178
+ def date1904?
179
+ Markup.tags(@zip['xl/workbook.xml'], 'workbookPr').any? do |tag|
180
+ TRUTHY.include?(Markup.attribute(tag, 'date1904'))
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ module RubyLLM
6
+ class FileStore
7
+ module Converters
8
+ module Office
9
+ # The entries of a zip archive, read from its central directory,
10
+ # with Zip64 sizes when an entry or the directory needs them. It
11
+ # inflates at most MAX_SIZE in all, counting what comes out rather
12
+ # than what an entry declares, so a small archive cannot fill the
13
+ # memory.
14
+ class Zip # :nodoc:
15
+ class Error < StandardError; end # :nodoc:
16
+
17
+ END_OF_DIRECTORY = "PK\x05\x06".b
18
+ ZIP64_LOCATOR = "PK\x06\x07".b
19
+ ZIP64_END_OF_DIRECTORY = "PK\x06\x06".b
20
+ DIRECTORY_ENTRY = "PK\x01\x02".b
21
+ LOCAL_ENTRY = "PK\x03\x04".b
22
+ MASKED = 0xFFFFFFFF
23
+ MASKED_COUNT = 0xFFFF
24
+ ZIP64_EXTRA = 1
25
+ INFLATE_STEP = 4096
26
+ private_constant :END_OF_DIRECTORY, :ZIP64_LOCATOR, :ZIP64_END_OF_DIRECTORY, :DIRECTORY_ENTRY,
27
+ :LOCAL_ENTRY, :MASKED, :MASKED_COUNT, :ZIP64_EXTRA, :INFLATE_STEP
28
+
29
+ def initialize(bytes)
30
+ @bytes = bytes
31
+ @inflated = 0
32
+ @entries = read_directory
33
+ end
34
+
35
+ def names
36
+ @entries.keys
37
+ end
38
+
39
+ def include?(name)
40
+ @entries.key?(name)
41
+ end
42
+
43
+ def [](name)
44
+ entry = @entries[name]
45
+ entry && extract(**entry)
46
+ end
47
+
48
+ private
49
+
50
+ def slice(offset, length)
51
+ part = @bytes.byteslice(offset, length)
52
+ raise Error, 'truncated zip archive' unless part&.bytesize == length
53
+
54
+ part
55
+ end
56
+
57
+ def read_directory
58
+ finish = @bytes.rindex(END_OF_DIRECTORY) || raise(Error, 'not a zip archive')
59
+ count, _size, offset = slice(finish + 10, 10).unpack('vVV')
60
+ count, offset = zip64_directory(finish) if count == MASKED_COUNT || offset == MASKED
61
+ count.times.with_object({}) { |_index, entries| offset = read_entry(offset, entries) }
62
+ end
63
+
64
+ def zip64_directory(finish)
65
+ raise Error, 'bad Zip64 locator' unless finish >= 20 && slice(finish - 20, 4) == ZIP64_LOCATOR
66
+
67
+ record = slice(finish - 12, 8).unpack1('Q<')
68
+ raise Error, 'bad Zip64 directory' unless slice(record, 4) == ZIP64_END_OF_DIRECTORY
69
+
70
+ slice(record + 32, 24).unpack('Q<x8Q<')
71
+ end
72
+
73
+ def read_entry(offset, entries)
74
+ raise Error, 'bad central directory' unless slice(offset, 4) == DIRECTORY_ENTRY
75
+
76
+ method, compressed, size, name_length, extra_length, comment_length =
77
+ slice(offset + 10, 24).unpack('vx8VVvvv')
78
+ local = slice(offset + 42, 4).unpack1('V')
79
+ raw_name = slice(offset + 46, name_length)
80
+ name = raw_name.tr('\\', '/').force_encoding(Encoding::UTF_8)
81
+ extra = slice(offset + 46 + name_length, extra_length)
82
+ alias_of_entry = raw_name.include?('\\') && entries.key?(name)
83
+ entries[name] = { method:, **sizes(extra, size:, compressed:, local:) } unless alias_of_entry
84
+ offset + 46 + name_length + extra_length + comment_length
85
+ end
86
+
87
+ # Returns the three values, taking each masked one from the Zip64
88
+ # extra field, in the order the format gives them, or +nil+ when
89
+ # the field does not have it.
90
+ def sizes(extra, values)
91
+ return values unless values.value?(MASKED)
92
+
93
+ field = zip64_field(extra).unpack('Q<*')
94
+ values.transform_values { |value| value == MASKED ? field.shift : value }
95
+ end
96
+
97
+ def zip64_field(extra)
98
+ position = 0
99
+ while position + 4 <= extra.bytesize
100
+ id, length = extra.byteslice(position, 4).unpack('vv')
101
+ return extra.byteslice(position + 4, length).to_s if id == ZIP64_EXTRA
102
+
103
+ position += 4 + length
104
+ end
105
+ ''.b
106
+ end
107
+
108
+ def extract(method:, size:, compressed:, local:)
109
+ raise Error, 'bad local entry' unless local && slice(local, 4) == LOCAL_ENTRY
110
+
111
+ name_length, extra_length = slice(local + 26, 4).unpack('vv')
112
+ start = local + 30 + name_length + extra_length
113
+ data = compressed ? slice(start, compressed) : @bytes.byteslice(start..)
114
+ limit = [size, MAX_SIZE - @inflated].compact.min
115
+ output = contents(method, data, limit)
116
+ @inflated += output.bytesize
117
+ output
118
+ end
119
+
120
+ def contents(method, data, limit)
121
+ case method
122
+ when 0
123
+ raise Error, 'entry too large' if data.bytesize > limit
124
+
125
+ data
126
+ when 8 then inflate(data, limit)
127
+ else raise Error, "unsupported compression #{method}"
128
+ end
129
+ end
130
+
131
+ def inflate(data, limit)
132
+ inflater = Zlib::Inflate.new(-Zlib::MAX_WBITS)
133
+ output = +''.b
134
+ (0...data.bytesize).step(INFLATE_STEP) do |offset|
135
+ output << inflater.inflate(data.byteslice(offset, INFLATE_STEP))
136
+ raise Error, 'entry too large' if output.bytesize > limit
137
+ break if inflater.finished?
138
+ end
139
+ raise Error, 'truncated entry' unless inflater.finished?
140
+
141
+ output
142
+ ensure
143
+ inflater&.close
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end