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.
- checksums.yaml +4 -4
- data/README.md +160 -120
- data/app/assets/javascripts/ruby_llm/chat_message_controller.js +163 -0
- data/app/assets/javascripts/ruby_llm/dompurify.js +9 -0
- data/app/assets/javascripts/ruby_llm/marked.js +71 -0
- data/config/importmap.rb +5 -0
- data/lib/generators/ruby_llm/agent/agent_generator.rb +11 -3
- data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +6 -3
- data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +201 -55
- data/lib/generators/ruby_llm/chat_ui/templates/agents/chat_agent.rb.tt +15 -0
- data/lib/generators/ruby_llm/chat_ui/templates/controllers/answers_controller.rb.tt +31 -0
- data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +16 -5
- data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +27 -9
- data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/controllers/queued_messages_controller.rb.tt +14 -0
- data/lib/generators/ruby_llm/chat_ui/templates/javascript/chat_message_controller.js +1 -0
- data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +5 -8
- data/lib/generators/ruby_llm/chat_ui/templates/jobs/resume_interrupted_chats_job.rb.tt +7 -0
- data/lib/generators/ruby_llm/chat_ui/templates/migrations/add_reply_error_migration.rb.tt +5 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_files.html.erb.tt +15 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +5 -7
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_model_select.html.erb.tt +7 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +32 -3
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +17 -2
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_attachments.html.erb.tt +14 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +28 -18
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_queued_message.html.erb.tt +18 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -1
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +4 -2
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +4 -2
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/questions/_question.html.erb.tt +101 -0
- data/lib/generators/ruby_llm/chat_ui/templates/test/agents/chat_agent_test.rb.tt +28 -0
- data/lib/generators/ruby_llm/chat_ui/templates/test/application_system_test_case.rb.tt +5 -0
- data/lib/generators/ruby_llm/chat_ui/templates/test/system/chats_test.rb.tt +240 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/answers/create.turbo_stream.erb.tt +6 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_files.html.erb.tt +15 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +5 -7
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_model_select.html.erb.tt +7 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_questions.html.erb.tt +13 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +29 -3
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +16 -2
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_attachments.html.erb.tt +14 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_error.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +26 -16
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_queued_message.html.erb.tt +8 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -1
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +3 -2
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +1 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +3 -2
- data/lib/generators/ruby_llm/chat_ui/templates/views/questions/_question.html.erb.tt +78 -0
- data/lib/generators/ruby_llm/evaluation/evaluation_generator.rb +43 -0
- data/lib/generators/ruby_llm/evaluation/templates/evaluation.rb.tt +9 -0
- data/lib/generators/ruby_llm/evaluation/templates/evaluation.yml.tt +4 -0
- data/lib/generators/ruby_llm/generator_helpers.rb +116 -3
- data/lib/generators/ruby_llm/install/install_generator.rb +5 -47
- data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +4 -0
- data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +5 -3
- data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +41 -1
- data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +2 -1
- data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +1 -2
- data/lib/generators/ruby_llm/judge/judge_generator.rb +35 -0
- data/lib/generators/ruby_llm/judge/templates/judge.rb.tt +2 -0
- data/lib/generators/ruby_llm/mcp/mcp_generator.rb +58 -0
- data/lib/generators/ruby_llm/mcp/templates/mcp.rb.tt +7 -0
- data/lib/generators/ruby_llm/provider/scaffold.rb +5 -12
- data/lib/generators/ruby_llm/provider/templates/core/provider.rb.erb +6 -0
- data/lib/generators/ruby_llm/provider/templates/gem/chat_spec.rb.erb +0 -1
- data/lib/generators/ruby_llm/provider/templates/gem/chat_streaming_spec.rb.erb +0 -1
- data/lib/generators/ruby_llm/provider/templates/gem/moderation_spec.rb.erb +1 -1
- data/lib/generators/ruby_llm/sandbox/sandbox_generator.rb +68 -0
- data/lib/generators/ruby_llm/sandbox/templates/sandbox.rb.tt +3 -0
- data/lib/generators/ruby_llm/skill/skill_generator.rb +48 -0
- data/lib/generators/ruby_llm/tool/templates/tailwind/tool_call.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/tool/templates/tailwind/tool_result.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +0 -3
- data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/tool/tool_generator.rb +1 -5
- data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_2.rb.tt +164 -0
- data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +41 -3
- data/lib/ruby_llm/accounting/operation.rb +73 -0
- data/lib/ruby_llm/accounting/usage.rb +74 -24
- data/lib/ruby_llm/active_record/acts_as.rb +78 -11
- data/lib/ruby_llm/active_record/attachment_helpers.rb +10 -4
- data/lib/ruby_llm/active_record/batch.rb +67 -9
- data/lib/ruby_llm/active_record/chat_agent_hooks.rb +17 -0
- data/lib/ruby_llm/active_record/chat_approvals.rb +71 -0
- data/lib/ruby_llm/active_record/chat_cancellation.rb +106 -0
- data/lib/ruby_llm/active_record/chat_column_guard.rb +51 -0
- data/lib/ruby_llm/active_record/chat_file.rb +37 -0
- data/lib/ruby_llm/active_record/chat_files.rb +30 -0
- data/lib/ruby_llm/active_record/chat_helper.rb +73 -0
- data/lib/ruby_llm/active_record/chat_methods.rb +515 -274
- data/lib/ruby_llm/active_record/chat_placeholders.rb +67 -0
- data/lib/ruby_llm/active_record/chat_questions.rb +177 -0
- data/lib/ruby_llm/active_record/chat_queue.rb +120 -0
- data/lib/ruby_llm/active_record/chat_reply_error.rb +35 -0
- data/lib/ruby_llm/active_record/chat_run.rb +51 -0
- data/lib/ruby_llm/active_record/chat_tool_call_state.rb +77 -0
- data/lib/ruby_llm/active_record/chunk_methods.rb +49 -0
- data/lib/ruby_llm/active_record/claim_heartbeat.rb +149 -0
- data/lib/ruby_llm/active_record/file_store.rb +269 -0
- data/lib/ruby_llm/active_record/mcp_credential.rb +24 -9
- data/lib/ruby_llm/active_record/message_broadcasting.rb +85 -0
- data/lib/ruby_llm/active_record/message_methods.rb +83 -11
- data/lib/ruby_llm/active_record/model.rb +37 -9
- data/lib/ruby_llm/active_record/provider_file.rb +21 -4
- data/lib/ruby_llm/active_record/queued_message.rb +50 -0
- data/lib/ruby_llm/active_record/record.rb +3 -1
- data/lib/ruby_llm/active_record/run_claims.rb +608 -0
- data/lib/ruby_llm/active_record/solid_queue_shutdown.rb +33 -0
- data/lib/ruby_llm/active_record/system_instructions.rb +30 -0
- data/lib/ruby_llm/active_record/tool_call.rb +55 -4
- data/lib/ruby_llm/active_record/usage.rb +79 -9
- data/lib/ruby_llm/agent/callbacks.rb +98 -0
- data/lib/ruby_llm/agent/context_budget.rb +26 -0
- data/lib/ruby_llm/agent/join_callbacks.rb +28 -0
- data/lib/ruby_llm/agent/model_options.rb +56 -0
- data/lib/ruby_llm/agent/ownership.rb +22 -0
- data/lib/ruby_llm/agent/tool_conversion.rb +19 -0
- data/lib/ruby_llm/agent.rb +620 -236
- data/lib/ruby_llm/agent_tool.rb +91 -0
- data/lib/ruby_llm/aliases.json +84 -91
- data/lib/ruby_llm/attachment.rb +101 -20
- data/lib/ruby_llm/batch.rb +132 -46
- data/lib/ruby_llm/cached_content.rb +4 -4
- data/lib/ruby_llm/chat/agent_run.rb +22 -0
- data/lib/ruby_llm/chat/context_budget.rb +119 -0
- data/lib/ruby_llm/chat/file_references.rb +259 -0
- data/lib/ruby_llm/chat/message_queue.rb +56 -0
- data/lib/ruby_llm/chat/ownership.rb +18 -0
- data/lib/ruby_llm/chat/rescuable.rb +48 -0
- data/lib/ruby_llm/chat/run.rb +32 -0
- data/lib/ruby_llm/chat/tool_call_ids.rb +46 -0
- data/lib/ruby_llm/chat/tool_call_state.rb +115 -0
- data/lib/ruby_llm/chat/tool_concurrency.rb +90 -49
- data/lib/ruby_llm/chat/tool_search.rb +36 -1
- data/lib/ruby_llm/chat/tool_sequences.rb +84 -0
- data/lib/ruby_llm/chat.rb +1593 -331
- data/lib/ruby_llm/chunk.rb +27 -1
- data/lib/ruby_llm/citation.rb +2 -1
- data/lib/ruby_llm/code_execution/language.rb +28 -0
- data/lib/ruby_llm/code_execution/node_installation.rb +56 -0
- data/lib/ruby_llm/code_execution/packages.rb +241 -0
- data/lib/ruby_llm/code_execution/ruby_installation.rb +170 -0
- data/lib/ruby_llm/code_execution/workspace.rb +314 -0
- data/lib/ruby_llm/code_execution.rb +781 -0
- data/lib/ruby_llm/configuration.rb +151 -4
- data/lib/ruby_llm/content_filter.rb +68 -0
- data/lib/ruby_llm/cost.rb +68 -41
- data/lib/ruby_llm/downloaded_file.rb +10 -9
- data/lib/ruby_llm/embedding.rb +30 -45
- data/lib/ruby_llm/embedding_request.rb +1 -1
- data/lib/ruby_llm/error.rb +167 -14
- data/lib/ruby_llm/evaluation/dataset.rb +5 -4
- data/lib/ruby_llm/evaluation/evidence.rb +5 -3
- data/lib/ruby_llm/evaluation/report.rb +7 -3
- data/lib/ruby_llm/evaluation/result.rb +1 -1
- data/lib/ruby_llm/evaluation.rb +2 -1
- data/lib/ruby_llm/fallback.rb +32 -4
- data/lib/ruby_llm/file_store/converters/ocr.rb +28 -0
- data/lib/ruby_llm/file_store/converters/office/markup.rb +148 -0
- data/lib/ruby_llm/file_store/converters/office/outline.rb +193 -0
- data/lib/ruby_llm/file_store/converters/office/prose.rb +161 -0
- data/lib/ruby_llm/file_store/converters/office/workbook.rb +187 -0
- data/lib/ruby_llm/file_store/converters/office/zip.rb +149 -0
- data/lib/ruby_llm/file_store/converters/office.rb +106 -0
- data/lib/ruby_llm/file_store/converters/text.rb +27 -0
- data/lib/ruby_llm/file_store/converters.rb +84 -0
- data/lib/ruby_llm/file_store/directory.rb +190 -0
- data/lib/ruby_llm/file_store/memory.rb +75 -0
- data/lib/ruby_llm/file_store/tools/base.rb +99 -0
- data/lib/ruby_llm/file_store/tools/delete.rb +48 -0
- data/lib/ruby_llm/file_store/tools/edit.rb +155 -0
- data/lib/ruby_llm/file_store/tools/list.rb +57 -0
- data/lib/ruby_llm/file_store/tools/read.rb +120 -0
- data/lib/ruby_llm/file_store/tools/search.rb +208 -0
- data/lib/ruby_llm/file_store/tools/write.rb +40 -0
- data/lib/ruby_llm/file_store/tools.rb +39 -0
- data/lib/ruby_llm/file_store.rb +338 -0
- data/lib/ruby_llm/files/saving.rb +14 -0
- data/lib/ruby_llm/image.rb +67 -45
- data/lib/ruby_llm/input_required_error.rb +28 -0
- data/lib/ruby_llm/judge/builder.rb +6 -1
- data/lib/ruby_llm/judge.rb +1 -1
- data/lib/ruby_llm/judgment.rb +6 -24
- data/lib/ruby_llm/mcp/client.rb +50 -24
- data/lib/ruby_llm/mcp/collection.rb +39 -2
- data/lib/ruby_llm/mcp/content.rb +7 -2
- data/lib/ruby_llm/mcp/error.rb +8 -2
- data/lib/ruby_llm/mcp/http.rb +117 -37
- data/lib/ruby_llm/mcp/input_request.rb +3 -112
- data/lib/ruby_llm/mcp/input_required_error.rb +3 -21
- data/lib/ruby_llm/mcp/insufficient_scope_error.rb +27 -0
- data/lib/ruby_llm/mcp/listener.rb +7 -1
- data/lib/ruby_llm/mcp/oauth.rb +102 -39
- data/lib/ruby_llm/mcp/outages.rb +172 -0
- data/lib/ruby_llm/mcp/resource.rb +9 -6
- data/lib/ruby_llm/mcp/resource_template.rb +3 -2
- data/lib/ruby_llm/mcp/session_expired.rb +10 -2
- data/lib/ruby_llm/mcp/stdio.rb +66 -18
- data/lib/ruby_llm/mcp/task.rb +13 -40
- data/lib/ruby_llm/mcp/tool.rb +34 -11
- data/lib/ruby_llm/mcp.rb +123 -59
- data/lib/ruby_llm/message.rb +217 -23
- data/lib/ruby_llm/model/modalities.rb +1 -1
- data/lib/ruby_llm/model/pricing.rb +26 -4
- data/lib/ruby_llm/model/pricing_category.rb +38 -15
- data/lib/ruby_llm/model/pricing_tier.rb +3 -2
- data/lib/ruby_llm/model.rb +24 -6
- data/lib/ruby_llm/models/lookup.rb +37 -4
- data/lib/ruby_llm/models/registry.rb +4 -19
- data/lib/ruby_llm/models.json +36186 -27832
- data/lib/ruby_llm/models.rb +44 -39
- data/lib/ruby_llm/moderation.rb +10 -30
- data/lib/ruby_llm/ocr.rb +5 -22
- data/lib/ruby_llm/open_telemetry/attributes.rb +68 -19
- data/lib/ruby_llm/open_telemetry/content.rb +105 -0
- data/lib/ruby_llm/open_telemetry.rb +18 -4
- data/lib/ruby_llm/prompt/context.rb +2 -2
- data/lib/ruby_llm/prompt.rb +7 -5
- data/lib/ruby_llm/protocol/binary_streaming.rb +2 -8
- data/lib/ruby_llm/protocol/cache_lifetime.rb +22 -0
- data/lib/ruby_llm/protocol/native_content.rb +69 -0
- data/lib/ruby_llm/protocol/replayed_arguments.rb +28 -0
- data/lib/ruby_llm/protocol/stream_accumulator.rb +73 -33
- data/lib/ruby_llm/protocol/streaming.rb +105 -22
- data/lib/ruby_llm/protocol/tool_namespaces.rb +100 -0
- data/lib/ruby_llm/protocol/unsupported_operations.rb +42 -0
- data/lib/ruby_llm/protocol.rb +127 -106
- data/lib/ruby_llm/protocols/anthropic/batches.rb +41 -4
- data/lib/ruby_llm/protocols/anthropic/block_binding.rb +30 -0
- data/lib/ruby_llm/protocols/anthropic/chat.rb +135 -60
- data/lib/ruby_llm/protocols/anthropic/files.rb +9 -0
- data/lib/ruby_llm/protocols/anthropic/media.rb +14 -14
- data/lib/ruby_llm/protocols/anthropic/models.rb +16 -10
- data/lib/ruby_llm/protocols/anthropic/streaming.rb +43 -22
- data/lib/ruby_llm/protocols/anthropic/tools.rb +36 -24
- data/lib/ruby_llm/protocols/anthropic.rb +61 -6
- data/lib/ruby_llm/protocols/azure/speech/transcription.rb +125 -0
- data/lib/ruby_llm/protocols/azure/speech.rb +14 -0
- data/lib/ruby_llm/protocols/bedrock/batches.rb +26 -8
- data/lib/ruby_llm/protocols/bedrock/event_stream.rb +205 -0
- data/lib/ruby_llm/protocols/bedrock/files.rb +15 -6
- data/lib/ruby_llm/protocols/bedrock/guardrails.rb +1 -1
- data/lib/ruby_llm/protocols/bedrock/rerank.rb +2 -0
- data/lib/ruby_llm/protocols/chat_completions/chat.rb +158 -58
- data/lib/ruby_llm/protocols/chat_completions/content_filters.rb +45 -0
- data/lib/ruby_llm/protocols/chat_completions/embedding_batches.rb +15 -15
- data/lib/ruby_llm/protocols/chat_completions/embeddings.rb +26 -3
- data/lib/ruby_llm/protocols/chat_completions/images.rb +7 -9
- data/lib/ruby_llm/protocols/chat_completions/media.rb +20 -20
- data/lib/ruby_llm/protocols/chat_completions/moderation.rb +8 -5
- data/lib/ruby_llm/protocols/chat_completions/speech.rb +8 -2
- data/lib/ruby_llm/protocols/chat_completions/streaming.rb +38 -17
- data/lib/ruby_llm/protocols/chat_completions/think_tags.rb +148 -0
- data/lib/ruby_llm/protocols/chat_completions/tools.rb +15 -28
- data/lib/ruby_llm/protocols/chat_completions/transcription.rb +17 -2
- data/lib/ruby_llm/protocols/chat_completions.rb +4 -0
- data/lib/ruby_llm/protocols/cohere/batches.rb +15 -8
- data/lib/ruby_llm/protocols/cohere/chat.rb +47 -25
- data/lib/ruby_llm/protocols/cohere/datasets.rb +6 -2
- data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -5
- data/lib/ruby_llm/protocols/cohere/media.rb +7 -7
- data/lib/ruby_llm/protocols/cohere/rerank.rb +2 -1
- data/lib/ruby_llm/protocols/cohere/streaming.rb +26 -11
- data/lib/ruby_llm/protocols/cohere/tools.rb +17 -28
- data/lib/ruby_llm/protocols/cohere/transcription.rb +5 -4
- data/lib/ruby_llm/protocols/converse/batches.rb +2 -24
- data/lib/ruby_llm/protocols/converse/chat.rb +210 -75
- data/lib/ruby_llm/protocols/converse/media.rb +24 -22
- data/lib/ruby_llm/protocols/converse/reasoning.rb +23 -9
- data/lib/ruby_llm/protocols/converse/streaming.rb +68 -220
- data/lib/ruby_llm/protocols/converse.rb +17 -1
- data/lib/ruby_llm/protocols/deepgram/speech.rb +1 -2
- data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +1 -3
- data/lib/ruby_llm/protocols/deepgram/transcription.rb +28 -19
- data/lib/ruby_llm/protocols/deepgram.rb +3 -2
- data/lib/ruby_llm/protocols/deepseek/files.rb +1 -1
- data/lib/ruby_llm/protocols/elevenlabs/assets.rb +6 -4
- data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +4 -0
- data/lib/ruby_llm/protocols/elevenlabs/speech.rb +3 -3
- data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +5 -2
- data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +6 -3
- data/lib/ruby_llm/protocols/files.rb +27 -12
- data/lib/ruby_llm/protocols/gemini/caches.rb +4 -4
- data/lib/ruby_llm/protocols/gemini/chat.rb +118 -51
- data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +9 -5
- data/lib/ruby_llm/protocols/gemini/embeddings.rb +3 -3
- data/lib/ruby_llm/protocols/gemini/file_transcription.rb +4 -0
- data/lib/ruby_llm/protocols/gemini/files.rb +5 -4
- data/lib/ruby_llm/protocols/gemini/images.rb +11 -9
- data/lib/ruby_llm/protocols/gemini/live_transcription.rb +1 -1
- data/lib/ruby_llm/protocols/gemini/long_decode.rb +104 -0
- data/lib/ruby_llm/protocols/gemini/media.rb +31 -28
- data/lib/ruby_llm/protocols/gemini/retry_info.rb +29 -0
- data/lib/ruby_llm/protocols/gemini/schemas.rb +126 -0
- data/lib/ruby_llm/protocols/gemini/speech.rb +9 -3
- data/lib/ruby_llm/protocols/gemini/streaming.rb +37 -13
- data/lib/ruby_llm/protocols/gemini/tools.rb +18 -10
- data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -9
- data/lib/ruby_llm/protocols/gemini/videos.rb +14 -3
- data/lib/ruby_llm/protocols/gemini.rb +16 -0
- data/lib/ruby_llm/protocols/gpustack/responses.rb +3 -3
- data/lib/ruby_llm/protocols/gpustack/tokenization.rb +14 -0
- data/lib/ruby_llm/protocols/interactions/chat.rb +29 -14
- data/lib/ruby_llm/protocols/interactions/content.rb +19 -14
- data/lib/ruby_llm/protocols/interactions/streaming.rb +1 -1
- data/lib/ruby_llm/protocols/interactions/tools.rb +10 -13
- data/lib/ruby_llm/protocols/interactions/videos.rb +99 -0
- data/lib/ruby_llm/protocols/interactions.rb +1 -0
- data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +28 -10
- data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +1 -1
- data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +45 -11
- data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +5 -2
- data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +11 -3
- data/lib/ruby_llm/protocols/invoke_model.rb +21 -2
- data/lib/ruby_llm/protocols/meta/transcription/stream.rb +108 -0
- data/lib/ruby_llm/protocols/meta/transcription.rb +76 -0
- data/lib/ruby_llm/protocols/minimax/files.rb +58 -0
- data/lib/ruby_llm/protocols/minimax/images.rb +54 -0
- data/lib/ruby_llm/protocols/minimax/speech.rb +53 -0
- data/lib/ruby_llm/protocols/minimax/transcription.rb +82 -0
- data/lib/ruby_llm/protocols/minimax/videos.rb +93 -0
- data/lib/ruby_llm/protocols/minimax.rb +21 -0
- data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +44 -21
- data/lib/ruby_llm/protocols/mistral/files.rb +6 -4
- data/lib/ruby_llm/protocols/mistral/multi_completion.rb +15 -4
- data/lib/ruby_llm/protocols/moonshot/files.rb +48 -0
- data/lib/ruby_llm/protocols/openai/batches.rb +3 -2
- data/lib/ruby_llm/protocols/openai/decisions.rb +6 -4
- data/lib/ruby_llm/protocols/openai/files.rb +13 -6
- data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -17
- data/lib/ruby_llm/protocols/openrouter/responses.rb +6 -10
- data/lib/ruby_llm/protocols/perplexity/agent.rb +19 -35
- data/lib/ruby_llm/protocols/perplexity/batches.rb +37 -0
- data/lib/ruby_llm/protocols/perplexity/files.rb +19 -9
- data/lib/ruby_llm/protocols/responses/approvals.rb +12 -11
- data/lib/ruby_llm/protocols/responses/chat.rb +165 -75
- data/lib/ruby_llm/protocols/responses/citation_markers.rb +167 -0
- data/lib/ruby_llm/protocols/responses/compaction.rb +2 -2
- data/lib/ruby_llm/protocols/responses/media.rb +17 -9
- data/lib/ruby_llm/protocols/responses/streaming.rb +71 -12
- data/lib/ruby_llm/protocols/responses/token_counting.rb +1 -1
- data/lib/ruby_llm/protocols/responses/tools.rb +48 -1
- data/lib/ruby_llm/protocols/responses.rb +21 -0
- data/lib/ruby_llm/protocols/system_one/responses.rb +9 -2
- data/lib/ruby_llm/protocols/vertexai/files.rb +4 -3
- data/lib/ruby_llm/protocols/vertexai/text_to_speech.rb +116 -0
- data/lib/ruby_llm/protocols/xai/files.rb +13 -6
- data/lib/ruby_llm/protocols/zai/files.rb +37 -0
- data/lib/ruby_llm/provider.rb +128 -31
- data/lib/ruby_llm/providers/anthropic.rb +5 -1
- data/lib/ruby_llm/providers/azure/chat.rb +17 -3
- data/lib/ruby_llm/providers/azure/chat_completions.rb +1 -0
- data/lib/ruby_llm/providers/azure/decisions.rb +17 -0
- data/lib/ruby_llm/providers/azure/embeddings.rb +1 -2
- data/lib/ruby_llm/providers/azure/media.rb +6 -6
- data/lib/ruby_llm/providers/azure/mistral.rb +17 -0
- data/lib/ruby_llm/providers/azure/models.rb +16 -2
- data/lib/ruby_llm/providers/azure/moderation.rb +16 -0
- data/lib/ruby_llm/providers/azure/responses.rb +9 -0
- data/lib/ruby_llm/providers/azure/videos.rb +2 -2
- data/lib/ruby_llm/providers/azure.rb +43 -1
- data/lib/ruby_llm/providers/bedrock/anthropic/batches.rb +32 -0
- data/lib/ruby_llm/providers/bedrock/anthropic.rb +134 -0
- data/lib/ruby_llm/providers/bedrock/auth.rb +12 -3
- data/lib/ruby_llm/providers/bedrock/connection.rb +19 -0
- data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +5 -1
- data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +2 -2
- data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +12 -2
- data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -1
- data/lib/ruby_llm/providers/bedrock/mantle.rb +7 -0
- data/lib/ruby_llm/providers/bedrock/models.rb +1 -7
- data/lib/ruby_llm/providers/bedrock.rb +51 -8
- data/lib/ruby_llm/providers/cohere.rb +5 -1
- data/lib/ruby_llm/providers/deepgram/chat.rb +25 -0
- data/lib/ruby_llm/providers/deepgram.rb +21 -2
- data/lib/ruby_llm/providers/deepseek/chat.rb +7 -16
- data/lib/ruby_llm/providers/deepseek/responses.rb +25 -8
- data/lib/ruby_llm/providers/deepseek.rb +5 -1
- data/lib/ruby_llm/providers/elevenlabs.rb +2 -1
- data/lib/ruby_llm/providers/gemini.rb +13 -1
- data/lib/ruby_llm/providers/gpustack/chat.rb +23 -3
- data/lib/ruby_llm/providers/gpustack/embeddings.rb +2 -2
- data/lib/ruby_llm/providers/gpustack/media.rb +8 -8
- data/lib/ruby_llm/providers/gpustack/transcription.rb +14 -6
- data/lib/ruby_llm/providers/gpustack.rb +1 -1
- data/lib/ruby_llm/providers/hetzner/chat.rb +23 -6
- data/lib/ruby_llm/providers/hetzner.rb +5 -1
- data/lib/ruby_llm/providers/meta/responses.rb +81 -0
- data/lib/ruby_llm/providers/meta.rb +37 -0
- data/lib/ruby_llm/providers/minimax/anthropic.rb +95 -0
- data/lib/ruby_llm/providers/minimax/chat_completions.rb +48 -0
- data/lib/ruby_llm/providers/minimax/responses.rb +52 -0
- data/lib/ruby_llm/providers/minimax.rb +104 -0
- data/lib/ruby_llm/providers/mistral/chat.rb +34 -4
- data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +5 -14
- data/lib/ruby_llm/providers/mistral/embeddings.rb +1 -12
- data/lib/ruby_llm/providers/mistral/input_tokens.rb +35 -0
- data/lib/ruby_llm/providers/mistral/media.rb +8 -8
- data/lib/ruby_llm/providers/mistral/transcription.rb +8 -6
- data/lib/ruby_llm/providers/mistral.rb +13 -1
- data/lib/ruby_llm/providers/moonshot/capabilities.rb +16 -0
- data/lib/ruby_llm/providers/moonshot/chat.rb +100 -0
- data/lib/ruby_llm/providers/moonshot/documents.rb +79 -0
- data/lib/ruby_llm/providers/moonshot/responses.rb +55 -0
- data/lib/ruby_llm/providers/moonshot.rb +54 -0
- data/lib/ruby_llm/providers/ollama/chat.rb +5 -6
- data/lib/ruby_llm/providers/ollama/embeddings.rb +41 -0
- data/lib/ruby_llm/providers/ollama/media.rb +6 -6
- data/lib/ruby_llm/providers/ollama/speech.rb +24 -0
- data/lib/ruby_llm/providers/ollama.rb +13 -1
- data/lib/ruby_llm/providers/ollama_cloud.rb +22 -1
- data/lib/ruby_llm/providers/openai/responses.rb +8 -0
- data/lib/ruby_llm/providers/openai.rb +7 -3
- data/lib/ruby_llm/providers/openrouter/chat.rb +15 -9
- data/lib/ruby_llm/providers/openrouter/embeddings.rb +12 -8
- data/lib/ruby_llm/providers/openrouter/images.rb +16 -10
- data/lib/ruby_llm/providers/openrouter/judgments.rb +47 -0
- data/lib/ruby_llm/providers/openrouter/media.rb +5 -5
- data/lib/ruby_llm/providers/openrouter/models.rb +19 -7
- data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -4
- data/lib/ruby_llm/providers/openrouter/videos.rb +1 -1
- data/lib/ruby_llm/providers/openrouter.rb +32 -5
- data/lib/ruby_llm/providers/perplexity/chat.rb +3 -3
- data/lib/ruby_llm/providers/perplexity/embeddings.rb +1 -1
- data/lib/ruby_llm/providers/perplexity/media.rb +6 -6
- data/lib/ruby_llm/providers/perplexity.rb +13 -2
- data/lib/ruby_llm/providers/typesafe.rb +1 -1
- data/lib/ruby_llm/providers/vertexai/anthropic.rb +5 -1
- data/lib/ruby_llm/providers/vertexai/credentials.rb +27 -3
- data/lib/ruby_llm/providers/vertexai/embed_content.rb +1 -1
- data/lib/ruby_llm/providers/vertexai/embeddings.rb +2 -3
- data/lib/ruby_llm/providers/vertexai/gemini.rb +1 -1
- data/lib/ruby_llm/providers/vertexai/mistral.rb +5 -0
- data/lib/ruby_llm/providers/vertexai/models.rb +24 -2
- data/lib/ruby_llm/providers/vertexai/transcription.rb +2 -2
- data/lib/ruby_llm/providers/vertexai/videos.rb +1 -1
- data/lib/ruby_llm/providers/vertexai.rb +56 -9
- data/lib/ruby_llm/providers/xai/chat.rb +3 -3
- data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +8 -3
- data/lib/ruby_llm/providers/xai/images.rb +9 -4
- data/lib/ruby_llm/providers/xai/models.rb +2 -2
- data/lib/ruby_llm/providers/xai/responses.rb +10 -0
- data/lib/ruby_llm/providers/xai/speech.rb +2 -2
- data/lib/ruby_llm/providers/xai/transcription.rb +14 -9
- data/lib/ruby_llm/providers/xai/videos.rb +1 -1
- data/lib/ruby_llm/providers/xai.rb +9 -1
- data/lib/ruby_llm/providers/zai/chat.rb +117 -0
- data/lib/ruby_llm/providers/zai/ocr.rb +65 -0
- data/lib/ruby_llm/providers/zai/token_counting.rb +25 -0
- data/lib/ruby_llm/providers/zai/videos.rb +67 -0
- data/lib/ruby_llm/providers/zai.rb +66 -0
- data/lib/ruby_llm/question.rb +390 -0
- data/lib/ruby_llm/queued_message.rb +74 -0
- data/lib/ruby_llm/railtie.rb +43 -0
- data/lib/ruby_llm/request_shape/part.rb +2 -2
- data/lib/ruby_llm/request_shape/problem.rb +2 -2
- data/lib/ruby_llm/request_shape/tool_round.rb +2 -2
- data/lib/ruby_llm/request_shape/turn.rb +2 -2
- data/lib/ruby_llm/request_shape.rb +1 -1
- data/lib/ruby_llm/rerank.rb +10 -37
- data/lib/ruby_llm/research_job.rb +26 -13
- data/lib/ruby_llm/sandbox/landlock.rb +78 -0
- data/lib/ruby_llm/sandbox/mxc.rb +137 -0
- data/lib/ruby_llm/sandbox.rb +141 -0
- data/lib/ruby_llm/search_results.rb +9 -4
- data/lib/ruby_llm/server_tool_call.rb +1 -1
- data/lib/ruby_llm/skill/loader.rb +131 -0
- data/lib/ruby_llm/skill.rb +263 -0
- data/lib/ruby_llm/speech.rb +25 -53
- data/lib/ruby_llm/stored_file.rb +94 -0
- data/lib/ruby_llm/support/cancellation.rb +4 -4
- data/lib/ruby_llm/support/deprecated_enumerable.rb +13 -0
- data/lib/ruby_llm/support/deprecated_job_predicates.rb +24 -0
- data/lib/ruby_llm/support/inspectable.rb +1 -1
- data/lib/ruby_llm/support/instrumentation.rb +6 -5
- data/lib/ruby_llm/support/rescuable.rb +84 -0
- data/lib/ruby_llm/support/utils.rb +26 -3
- data/lib/ruby_llm/task.rb +126 -0
- data/lib/ruby_llm/test_helper.rb +129 -0
- data/lib/ruby_llm/thinking.rb +3 -3
- data/lib/ruby_llm/tokens.rb +52 -12
- data/lib/ruby_llm/tool.rb +339 -5
- data/lib/ruby_llm/tool_call/collection.rb +132 -0
- data/lib/ruby_llm/tool_call.rb +68 -6
- data/lib/ruby_llm/tools/provider_tools.rb +23 -0
- data/lib/ruby_llm/tools/search.rb +118 -0
- data/lib/ruby_llm/transcription/segment.rb +67 -0
- data/lib/ruby_llm/transcription/wav_audio.rb +2 -1
- data/lib/ruby_llm/transcription.rb +25 -49
- data/lib/ruby_llm/transcription_chunk.rb +9 -9
- data/lib/ruby_llm/transport/connection.rb +146 -33
- data/lib/ruby_llm/transport/error_middleware.rb +94 -49
- data/lib/ruby_llm/transport/event_stream_parser.rb +18 -4
- data/lib/ruby_llm/transport/json_response.rb +9 -3
- data/lib/ruby_llm/transport/log_formatter.rb +20 -0
- data/lib/ruby_llm/transport/release_request.rb +35 -0
- data/lib/ruby_llm/transport/websocket_connection.rb +26 -30
- data/lib/ruby_llm/uploaded_file.rb +15 -6
- data/lib/ruby_llm/version.rb +1 -1
- data/lib/ruby_llm/video.rb +34 -10
- data/lib/ruby_llm/video_job.rb +55 -42
- data/lib/ruby_llm/web_source.rb +38 -0
- data/lib/ruby_llm/workflow.rb +2 -2
- data/lib/ruby_llm.rb +63 -25
- data/lib/tasks/ruby_llm/evaluations.rb +1 -1
- data/skills/rubyllm/SKILL.md +128 -54
- data/skills/rubyllm/references/cost-and-tracing.md +8 -0
- data/skills/rubyllm/references/files-code-and-context.md +6 -0
- data/skills/rubyllm/references/operations.md +8 -0
- data/skills/rubyllm/references/rails-chats.md +22 -0
- data/skills/rubyllm/references/tools-and-agents.md +11 -0
- metadata +213 -14
- data/lib/generators/ruby_llm/chat_ui/templates/helpers/messages_helper.rb.tt +0 -25
- data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +0 -78
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
class FileStore
|
|
7
|
+
module Converters
|
|
8
|
+
# Reads the text of Word documents, Excel workbooks, and PowerPoint
|
|
9
|
+
# decks (.docx, .xlsx, .pptx) on this machine, with no service and no
|
|
10
|
+
# cost: a paragraph per line, a table or sheet row per line with tabs
|
|
11
|
+
# between cells, and a heading for each sheet and slide, with a slide's
|
|
12
|
+
# speaker notes under it. Excel dates read as dates, like 2026-03-31.
|
|
13
|
+
# Text comes back in document order, without layout or images.
|
|
14
|
+
module Office
|
|
15
|
+
TYPES = {
|
|
16
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => :document,
|
|
17
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => :workbook,
|
|
18
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation' => :deck
|
|
19
|
+
}.freeze # :nodoc:
|
|
20
|
+
MAX_SIZE = 50 * 1024 * 1024 # :nodoc:
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# Returns the text of +attachment+, or +nil+ when it is not a
|
|
24
|
+
# document, workbook, or deck it can read. A file it can't read
|
|
25
|
+
# never raises.
|
|
26
|
+
def call(attachment)
|
|
27
|
+
kind = TYPES[attachment.mime_type]
|
|
28
|
+
return unless kind
|
|
29
|
+
|
|
30
|
+
send(kind, Zip.new(attachment.content.to_s.b))
|
|
31
|
+
rescue Zip::Error, Zlib::Error
|
|
32
|
+
nil
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
RubyLLM.logger.warn { "Could not read an Office file: #{e.class}" }
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns +true+: it reads files on this machine, for free.
|
|
39
|
+
def local?
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def document(zip)
|
|
46
|
+
xml = zip['word/document.xml']
|
|
47
|
+
Prose.lines(xml, outline: Outline.read(zip)).join("\n")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def workbook(zip)
|
|
51
|
+
sheets = parts(zip, 'xl/workbook.xml', 'sheet', %r{\Axl/worksheets/[^/]+\.xml\z})
|
|
52
|
+
Workbook.new(zip, sheets).text
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def deck(zip)
|
|
56
|
+
slides = parts(zip, 'ppt/presentation.xml', 'sldId', %r{\Appt/slides/[^/]+\.xml\z})
|
|
57
|
+
slides.each_with_index.map do |(part, _tag), index|
|
|
58
|
+
notes = notes_of(zip, part)
|
|
59
|
+
sections = ["## Slide #{index + 1}", Prose.lines(zip[part]).join("\n")]
|
|
60
|
+
sections << "Speaker notes:\n#{notes.join("\n")}" if notes.any?
|
|
61
|
+
sections.reject(&:empty?).join("\n\n")
|
|
62
|
+
end.join("\n\n")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def notes_of(zip, slide)
|
|
66
|
+
notes = relationships(zip, slide).find { |relationship| relationship[:type]&.end_with?('/notesSlide') }
|
|
67
|
+
notes ? Prose.lines(zip[notes[:target]]) : []
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Returns each part the +owner+ lists with a +tag+, in its order,
|
|
71
|
+
# with that tag, or the parts matching +fallback+ in name order
|
|
72
|
+
# when the listing doesn't resolve.
|
|
73
|
+
def parts(zip, owner, tag, fallback)
|
|
74
|
+
targets = relationships(zip, owner).to_h { |relationship| relationship.values_at(:id, :target) }
|
|
75
|
+
listed = Markup.tags(zip[owner], tag).filter_map do |element|
|
|
76
|
+
part = targets[Markup.relationship_id(element)]
|
|
77
|
+
[part, element] if part && zip.include?(part)
|
|
78
|
+
end
|
|
79
|
+
return listed if listed.any?
|
|
80
|
+
|
|
81
|
+
zip.names.grep(fallback).sort_by { |name| name[/\d+/].to_i }.map { |name| [name, nil] }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def relationships(zip, part)
|
|
85
|
+
folder, _, file = part.rpartition('/')
|
|
86
|
+
Markup.tags(zip["#{folder}/_rels/#{file}.rels"], 'Relationship').map do |element|
|
|
87
|
+
{ id: Markup.attribute(element, 'Id'), type: Markup.attribute(element, 'Type'),
|
|
88
|
+
target: resolve(folder, Markup.attribute(element, 'Target').to_s) }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def resolve(folder, target)
|
|
93
|
+
target = target.tr('\\', '/')
|
|
94
|
+
base = target.start_with?('/') ? [] : folder.split('/')
|
|
95
|
+
segments = target.split('/').each_with_object(base) do |segment, path|
|
|
96
|
+
next if segment.empty? || segment == '.'
|
|
97
|
+
|
|
98
|
+
segment == '..' ? path.pop : path.push(segment)
|
|
99
|
+
end
|
|
100
|
+
segments.join('/').force_encoding(Encoding::UTF_8)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class FileStore
|
|
5
|
+
module Converters
|
|
6
|
+
# Reads a file as UTF-8 text, as it is. Text files are read without
|
|
7
|
+
# registering anything; register it for other types that hold plain
|
|
8
|
+
# text:
|
|
9
|
+
#
|
|
10
|
+
# config.file_converters["application/x-subrip"] = RubyLLM::FileStore::Converters::Text
|
|
11
|
+
#
|
|
12
|
+
module Text
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Returns the content of +attachment+ as UTF-8 text.
|
|
16
|
+
def call(attachment)
|
|
17
|
+
attachment.content.to_s.dup.force_encoding(Encoding::UTF_8).scrub
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns true: it reads files here, for free.
|
|
21
|
+
def local?
|
|
22
|
+
true
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class FileStore
|
|
5
|
+
# Converters turn files into text the model can read and search. A
|
|
6
|
+
# converter is any object that answers +call+ with an Attachment and
|
|
7
|
+
# returns the file's text, or +nil+ when it cannot read that file, so
|
|
8
|
+
# the next converter gets a turn. FileStore#text tries the converters
|
|
9
|
+
# in Configuration#file_converters first, then reads text files,
|
|
10
|
+
# including source code, CSV, JSON, and Markdown, as they are.
|
|
11
|
+
#
|
|
12
|
+
# Converters::Office reads Word, Excel, and PowerPoint files without
|
|
13
|
+
# any setup. The OCR converter reads PDFs, scans, and photos with
|
|
14
|
+
# RubyLLM.ocr. It costs a request per file, so it only runs where you
|
|
15
|
+
# register it. Converters::Text reads a file as it is, for other types
|
|
16
|
+
# that hold plain text.
|
|
17
|
+
#
|
|
18
|
+
# A converter that answers +local?+ with +true+ reads files on this
|
|
19
|
+
# machine for free, so a chat runs it as soon as it keeps an
|
|
20
|
+
# attachment, while it prepares the request, so keep it fast, and
|
|
21
|
+
# search_files finds the text without a read_file first. The others wait
|
|
22
|
+
# for read_file, or for a request that must send as text a file the
|
|
23
|
+
# provider cannot take.
|
|
24
|
+
#
|
|
25
|
+
# RubyLLM.configure do |config|
|
|
26
|
+
# config.file_converters["image/*"] = RubyLLM::FileStore::Converters::OCR.new
|
|
27
|
+
# config.file_converters["application/pdf"] = RubyLLM::FileStore::Converters::OCR.new
|
|
28
|
+
# config.file_converters["application/x-subrip"] = RubyLLM::FileStore::Converters::Text
|
|
29
|
+
# config.file_converters["application/vnd.oasis.opendocument.text"] = ->(file) { OdtText.call(file.content) }
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# Converters for other formats can ship as gems named
|
|
33
|
+
# <tt>ruby_llm-converters-NAME</tt>, like provider gems.
|
|
34
|
+
module Converters
|
|
35
|
+
class << self
|
|
36
|
+
# Returns the text the first converter for +attachment+ makes of it,
|
|
37
|
+
# or +nil+ when none can read it.
|
|
38
|
+
# With <tt>local: true</tt>, stops at the first converter that does
|
|
39
|
+
# not run on this machine for free, so one that may cost money, or
|
|
40
|
+
# that was registered to go first, is never passed over.
|
|
41
|
+
def convert(attachment, config: RubyLLM.config, local: false) # :nodoc:
|
|
42
|
+
converters = for_type(attachment.mime_type, config:)
|
|
43
|
+
converters = converters.take_while { |converter| local?(converter) } if local
|
|
44
|
+
converters.each do |converter|
|
|
45
|
+
text = converter.call(attachment)
|
|
46
|
+
next unless text.is_a?(String)
|
|
47
|
+
|
|
48
|
+
text = text.dup.force_encoding(Encoding::UTF_8).scrub
|
|
49
|
+
return text unless text.strip.empty?
|
|
50
|
+
end
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Returns the converters for files of +type+, in the order they are
|
|
55
|
+
# tried.
|
|
56
|
+
def for_type(type, config: RubyLLM.config) # :nodoc:
|
|
57
|
+
configured = config.file_converters.to_h.select { |pattern, _| matches?(pattern.to_s, type) }
|
|
58
|
+
configured.sort_by { |pattern, _| pattern.to_s.end_with?('*') ? 1 : 0 }
|
|
59
|
+
.flat_map { |_, converters| Array(converters) } + built_in(type)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Returns whether +converter+ reads files on this machine for free.
|
|
63
|
+
def local?(converter) # :nodoc:
|
|
64
|
+
converter.respond_to?(:local?) && converter.local? == true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def matches?(pattern, type)
|
|
70
|
+
return true if pattern == '*/*'
|
|
71
|
+
return type.start_with?(pattern.delete_suffix('*')) if pattern.end_with?('/*')
|
|
72
|
+
|
|
73
|
+
pattern == type
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def built_in(type)
|
|
77
|
+
return [Text] if Files::MimeType.text?(type)
|
|
78
|
+
|
|
79
|
+
Office::TYPES.key?(type) ? [Office] : []
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'find'
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
class FileStore
|
|
8
|
+
# Keeps files in a folder on disk, created when missing. Files someone
|
|
9
|
+
# puts in the folder are the chat's files too. Hidden files and
|
|
10
|
+
# folders, whose names start with a dot, are left out, and symbolic
|
|
11
|
+
# links are never followed. Code execution runs in the folder itself
|
|
12
|
+
# and can change anything in it, so give each store a folder of its
|
|
13
|
+
# own.
|
|
14
|
+
#
|
|
15
|
+
# chat.with_file_store("tmp/chats/42")
|
|
16
|
+
#
|
|
17
|
+
class Directory < FileStore
|
|
18
|
+
OPEN_FLAGS = File::RDONLY | File::BINARY | (defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0)
|
|
19
|
+
SNIFF_FLAGS = OPEN_FLAGS | (defined?(File::NONBLOCK) ? File::NONBLOCK : 0)
|
|
20
|
+
WRITE_FLAGS = File::WRONLY | File::CREAT | File::EXCL | File::BINARY
|
|
21
|
+
private_constant :OPEN_FLAGS, :SNIFF_FLAGS, :WRITE_FLAGS
|
|
22
|
+
|
|
23
|
+
# The folder holding the files, as an absolute path.
|
|
24
|
+
attr_reader :root
|
|
25
|
+
|
|
26
|
+
# Creates a store for the folder +root+.
|
|
27
|
+
def initialize(root)
|
|
28
|
+
super()
|
|
29
|
+
FileUtils.mkdir_p(root)
|
|
30
|
+
@root = File.realpath(root)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns #root: code execution runs in this folder.
|
|
34
|
+
def local_root # :nodoc:
|
|
35
|
+
root
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns the files inside the folder +prefix+, or every file.
|
|
39
|
+
def list(prefix = nil)
|
|
40
|
+
start = prefix.to_s.empty? ? root : full_path(FileStore.path(prefix))
|
|
41
|
+
return [self[prefix]].compact unless File.lstat(start).directory?
|
|
42
|
+
|
|
43
|
+
walk(start).sort_by(&:path)
|
|
44
|
+
rescue SystemCallError
|
|
45
|
+
[]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Returns the StoredFile at +path+, or +nil+.
|
|
49
|
+
def [](path)
|
|
50
|
+
full = full_path(FileStore.path(path))
|
|
51
|
+
stat = File.lstat(full)
|
|
52
|
+
stored_file(full, stat) if stat.file?
|
|
53
|
+
rescue SystemCallError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Returns the content of the file at +path+.
|
|
58
|
+
def read(path)
|
|
59
|
+
path = FileStore.path(path)
|
|
60
|
+
File.open(full_path(path), OPEN_FLAGS, &:read).b
|
|
61
|
+
rescue SystemCallError
|
|
62
|
+
raise FileNotFoundError, "No file at #{path}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Writes +content+ to +path+ and returns its StoredFile. Raises
|
|
66
|
+
# ArgumentError when +path+ is a folder.
|
|
67
|
+
def write(path, content, content_type: nil)
|
|
68
|
+
path = FileStore.path(path)
|
|
69
|
+
file = with_lock { write_file(path, content) }
|
|
70
|
+
content_type ? StoredFile.new(**file.to_h, content_type:) : file
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Deletes the file at +path+ and returns its StoredFile, or +nil+.
|
|
74
|
+
def delete(path)
|
|
75
|
+
path = FileStore.path(path)
|
|
76
|
+
with_lock do
|
|
77
|
+
refuse_folder(path) if folder?(path)
|
|
78
|
+
|
|
79
|
+
file = self[path]
|
|
80
|
+
File.delete(full_path(path)) if file
|
|
81
|
+
file
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Deletes the folder +path+ with everything in it, hidden files
|
|
86
|
+
# included, and returns the deleted StoredFile objects. Links are
|
|
87
|
+
# removed, never followed.
|
|
88
|
+
def delete_folder(path)
|
|
89
|
+
path = FileStore.path(path)
|
|
90
|
+
with_lock do
|
|
91
|
+
refuse_file(path) if self[path]
|
|
92
|
+
next [] unless folder?(path)
|
|
93
|
+
|
|
94
|
+
full = full_path(path)
|
|
95
|
+
walk(full).sort_by(&:path).tap { FileUtils.rm_r(full) }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Runs the block with the store locked, against other threads and,
|
|
100
|
+
# through a lock on #root, other processes, and returns its value.
|
|
101
|
+
def with_lock
|
|
102
|
+
super do
|
|
103
|
+
next yield if @locked
|
|
104
|
+
|
|
105
|
+
File.open(root, File::RDONLY) do |folder|
|
|
106
|
+
folder.flock(File::LOCK_EX)
|
|
107
|
+
@locked = true
|
|
108
|
+
yield
|
|
109
|
+
ensure
|
|
110
|
+
@locked = false
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def write_file(path, content)
|
|
118
|
+
raise ArgumentError, "#{path} is a folder, not a file" if folder?(path)
|
|
119
|
+
|
|
120
|
+
full = full_path(path)
|
|
121
|
+
make_room(full)
|
|
122
|
+
temporary = File.join(File.dirname(full), ".#{SecureRandom.hex(16)}.tmp")
|
|
123
|
+
File.open(temporary, WRITE_FLAGS, binmode: true) do |io|
|
|
124
|
+
content.respond_to?(:read) ? IO.copy_stream(content, io) : io.write(content)
|
|
125
|
+
end
|
|
126
|
+
File.rename(temporary, full)
|
|
127
|
+
stored_file(full, File.lstat(full))
|
|
128
|
+
ensure
|
|
129
|
+
FileUtils.rm_f(temporary) if temporary
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def full_path(path)
|
|
133
|
+
parent = root
|
|
134
|
+
path.split('/').each do |part|
|
|
135
|
+
raise ArgumentError, "#{path} is not inside the chat's files" if part.start_with?('.')
|
|
136
|
+
|
|
137
|
+
parent = File.join(parent, part)
|
|
138
|
+
end
|
|
139
|
+
folder = File.dirname(parent)
|
|
140
|
+
if File.directory?(folder) && !"#{File.realpath(folder)}/".start_with?("#{root}/")
|
|
141
|
+
raise ArgumentError, "#{path} is not inside the chat's files"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
parent
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def walk(start)
|
|
148
|
+
files = []
|
|
149
|
+
Find.find(start) do |path|
|
|
150
|
+
next if path == root
|
|
151
|
+
|
|
152
|
+
stat = File.lstat(path)
|
|
153
|
+
Find.prune if File.basename(path).start_with?('.')
|
|
154
|
+
files << stored_file(path, stat) if stat.file?
|
|
155
|
+
end
|
|
156
|
+
files
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def make_room(full)
|
|
160
|
+
parent = root
|
|
161
|
+
File.dirname(full).delete_prefix(root).split('/').reject(&:empty?).each do |part|
|
|
162
|
+
parent = File.join(parent, part)
|
|
163
|
+
File.delete(parent) if File.symlink?(parent) || File.file?(parent)
|
|
164
|
+
end
|
|
165
|
+
FileUtils.mkdir_p(File.dirname(full))
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def folder?(path)
|
|
169
|
+
File.lstat(full_path(path)).directory?
|
|
170
|
+
rescue SystemCallError
|
|
171
|
+
false
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def file_content_type(full, path)
|
|
175
|
+
type = FileStore.content_type_for(path, '')
|
|
176
|
+
return type unless type == 'application/octet-stream'
|
|
177
|
+
|
|
178
|
+
File.open(full, SNIFF_FLAGS) { |io| io.stat.file? ? Files::MimeType.for(io, name: path) : type }
|
|
179
|
+
rescue SystemCallError
|
|
180
|
+
type
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def stored_file(full, stat)
|
|
184
|
+
path = full.delete_prefix("#{root}/")
|
|
185
|
+
StoredFile.new(path:, byte_size: stat.size, content_type: file_content_type(full, path),
|
|
186
|
+
updated_at: stat.mtime)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class FileStore
|
|
5
|
+
# Keeps files in memory, for as long as the chat lives. It is the
|
|
6
|
+
# store Chat#with_file_store uses when given none.
|
|
7
|
+
#
|
|
8
|
+
# chat.with_file_store(:memory)
|
|
9
|
+
#
|
|
10
|
+
class Memory < FileStore
|
|
11
|
+
def initialize # :nodoc:
|
|
12
|
+
super
|
|
13
|
+
@files = {}
|
|
14
|
+
@lock = Mutex.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns the files inside the folder +prefix+, or every file.
|
|
18
|
+
def list(prefix = nil)
|
|
19
|
+
@lock.synchronize { @files.values.map(&:first) }
|
|
20
|
+
.select { |file| FileStore.inside?(file.path, prefix) }
|
|
21
|
+
.sort_by(&:path)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns the StoredFile at +path+, or +nil+.
|
|
25
|
+
def [](path)
|
|
26
|
+
@lock.synchronize { @files[FileStore.path(path)]&.first }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns the content of the file at +path+.
|
|
30
|
+
def read(path)
|
|
31
|
+
path = FileStore.path(path)
|
|
32
|
+
content = @lock.synchronize { @files[path]&.last }
|
|
33
|
+
raise FileNotFoundError, "No file at #{path}" unless content
|
|
34
|
+
|
|
35
|
+
content.dup
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Writes +content+ to +path+ and returns its StoredFile.
|
|
39
|
+
def write(path, content, content_type: nil)
|
|
40
|
+
path = FileStore.path(path)
|
|
41
|
+
content = FileStore.bytes(content).freeze
|
|
42
|
+
file = StoredFile.new(path:, byte_size: content.bytesize,
|
|
43
|
+
content_type: content_type || FileStore.content_type_for(path, content),
|
|
44
|
+
digest: FileStore.digest(content), updated_at: Time.now)
|
|
45
|
+
with_lock { @lock.synchronize { @files[path] = [file, content] } }
|
|
46
|
+
file
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Deletes the file at +path+ and returns its StoredFile, or +nil+.
|
|
50
|
+
def delete(path)
|
|
51
|
+
path = FileStore.path(path)
|
|
52
|
+
with_lock do
|
|
53
|
+
@lock.synchronize do
|
|
54
|
+
refuse_folder(path) if !@files.key?(path) && @files.keys.any? { |key| FileStore.inside?(key, path) }
|
|
55
|
+
@files.delete(path)&.first
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Deletes everything inside the folder +path+ and returns the deleted
|
|
61
|
+
# StoredFile objects.
|
|
62
|
+
def delete_folder(path)
|
|
63
|
+
path = FileStore.path(path)
|
|
64
|
+
with_lock do
|
|
65
|
+
@lock.synchronize do
|
|
66
|
+
refuse_file(path) if @files.key?(path)
|
|
67
|
+
|
|
68
|
+
paths = @files.keys.select { |key| FileStore.inside?(key, path) }
|
|
69
|
+
paths.map { |key| @files.delete(key).first }.sort_by(&:path)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class FileStore
|
|
5
|
+
module Tools
|
|
6
|
+
# What the file tools share: the chat's store, its configuration,
|
|
7
|
+
# the callback for each file a tool writes, and the check that the
|
|
8
|
+
# model read a file before it changes it.
|
|
9
|
+
class Base < Tool # :nodoc:
|
|
10
|
+
class ToolError < StandardError; end # :nodoc:
|
|
11
|
+
|
|
12
|
+
inline_limit false
|
|
13
|
+
|
|
14
|
+
def initialize(store:, config: -> { RubyLLM.config }, written: ->(_file) {})
|
|
15
|
+
super()
|
|
16
|
+
@store = store
|
|
17
|
+
@config = config
|
|
18
|
+
@written = written
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def store
|
|
24
|
+
@store.call
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def config
|
|
28
|
+
@config.call
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def path_for(name)
|
|
32
|
+
FileStore.path(name)
|
|
33
|
+
rescue ArgumentError => e
|
|
34
|
+
raise ToolError, e.message
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def folder_for(name)
|
|
38
|
+
path_for(name) unless name.to_s.strip.empty? || %w[. /].include?(name.to_s.strip)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def file_at(name)
|
|
42
|
+
path = path_for(name)
|
|
43
|
+
store[path] || raise(ToolError, "There is no file at #{path}. #{missing_hint(path)}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def missing_hint(path)
|
|
47
|
+
folder = store.list(path).first
|
|
48
|
+
return "#{path} is a folder: list it with list_files." if folder
|
|
49
|
+
|
|
50
|
+
'List the files with list_files.'
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def digest_of(file)
|
|
54
|
+
file.digest || FileStore.digest(store.read(file.path))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Yields the file at +path+, or +nil+ for a new one, with the store
|
|
58
|
+
# locked, once the model has read the file as it is now.
|
|
59
|
+
def changing(path)
|
|
60
|
+
store.with_lock do
|
|
61
|
+
file = store[path]
|
|
62
|
+
fresh!(file) if file
|
|
63
|
+
yield file
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def fresh!(file)
|
|
68
|
+
seen = store.read_digest(file.path)
|
|
69
|
+
raise ToolError, "Read #{file.path} before you change it." unless seen
|
|
70
|
+
return if seen == digest_of(file)
|
|
71
|
+
|
|
72
|
+
raise ToolError, "#{file.path} changed since you read it. Read it again."
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def guarded_write
|
|
76
|
+
written = []
|
|
77
|
+
result = guarded do
|
|
78
|
+
yield(lambda do |path, content|
|
|
79
|
+
file = store.write(path, content)
|
|
80
|
+
store.record_read(file.path, file.digest || FileStore.digest(content))
|
|
81
|
+
written << file
|
|
82
|
+
file
|
|
83
|
+
end)
|
|
84
|
+
end
|
|
85
|
+
written.each { |file| @written.call(file) }
|
|
86
|
+
result
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def guarded
|
|
90
|
+
yield
|
|
91
|
+
rescue ToolError, FileStoreFullError, ArgumentError => e
|
|
92
|
+
{ error: e.message }
|
|
93
|
+
rescue SystemCallError => e
|
|
94
|
+
{ error: SystemCallError.new(e.errno).message }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class FileStore
|
|
5
|
+
module Tools
|
|
6
|
+
# The +delete_file+ tool: deletes one file. Chats get it only when
|
|
7
|
+
# they ask for it.
|
|
8
|
+
class Delete < Base # :nodoc:
|
|
9
|
+
def self.tool_name
|
|
10
|
+
'delete_file'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def description
|
|
14
|
+
'Deletes one file.'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def parameters_schema
|
|
18
|
+
{
|
|
19
|
+
'type' => 'object',
|
|
20
|
+
'properties' => {
|
|
21
|
+
'path' => { 'type' => 'string', 'description' => 'The file to delete' }
|
|
22
|
+
},
|
|
23
|
+
'required' => %w[path],
|
|
24
|
+
'additionalProperties' => false
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def execute(path:)
|
|
29
|
+
guarded do
|
|
30
|
+
path = path_for(path)
|
|
31
|
+
store.with_lock { delete(path) }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def delete(path)
|
|
38
|
+
file = store.delete(path)
|
|
39
|
+
raise ToolError, "There is no file at #{path}. List the files with list_files." unless file
|
|
40
|
+
|
|
41
|
+
"Deleted #{file.path}."
|
|
42
|
+
rescue FolderError
|
|
43
|
+
raise ToolError, "#{path} is a folder. Delete its files one by one."
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|