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,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
# Calls its block every +interval+ seconds from a thread of its own
|
|
6
|
+
# until stopped. The thread connects as the starting thread did, to the
|
|
7
|
+
# same roles and shards, and each beat checks out a connection from the
|
|
8
|
+
# pool of each of +models+ and returns it, so the thread holds none
|
|
9
|
+
# between beats.
|
|
10
|
+
#
|
|
11
|
+
# A beat that fails is skipped, and the next one tries again. A lease
|
|
12
|
+
# spans at least four intervals, so a claim outlasts a skipped beat,
|
|
13
|
+
# but after two in a row it may lapse before the next one lands, so
|
|
14
|
+
# the second logs a warning.
|
|
15
|
+
#
|
|
16
|
+
# Once its process is about to exit, ::hurry_all makes every live
|
|
17
|
+
# heartbeat beat at once and then every HURRIED_INTERVAL seconds, so a
|
|
18
|
+
# claim a run wrote just before learning of the exit is cut back too.
|
|
19
|
+
class ClaimHeartbeat # :nodoc:
|
|
20
|
+
HURRIED_INTERVAL = 1
|
|
21
|
+
|
|
22
|
+
LIVE_LOCK = Mutex.new
|
|
23
|
+
private_constant :LIVE_LOCK
|
|
24
|
+
|
|
25
|
+
@live = Set.new
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def hurry_all
|
|
29
|
+
LIVE_LOCK.synchronize { @live.to_a }.each(&:hurry)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def add(heartbeat)
|
|
33
|
+
LIVE_LOCK.synchronize { @live << heartbeat }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def delete(heartbeat)
|
|
37
|
+
LIVE_LOCK.synchronize { @live.delete(heartbeat) }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(models, interval, &beat)
|
|
42
|
+
@models = models
|
|
43
|
+
@interval = interval
|
|
44
|
+
@beat = beat
|
|
45
|
+
@failed_beats = 0
|
|
46
|
+
@lock = Mutex.new
|
|
47
|
+
@wake = ConditionVariable.new
|
|
48
|
+
@stopped = false
|
|
49
|
+
@hurried = false
|
|
50
|
+
@thread = start(::ActiveRecord::Base.connected_to_stack.to_a)
|
|
51
|
+
self.class.add(self)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def stop
|
|
55
|
+
self.class.delete(self)
|
|
56
|
+
@lock.synchronize do
|
|
57
|
+
@stopped = true
|
|
58
|
+
@wake.signal
|
|
59
|
+
end
|
|
60
|
+
@thread.join
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def hurry
|
|
64
|
+
@lock.synchronize do
|
|
65
|
+
@interval = [@interval, HURRIED_INTERVAL].min
|
|
66
|
+
@hurried = true
|
|
67
|
+
@wake.signal
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def start(connected_to)
|
|
74
|
+
thread = Thread.new do
|
|
75
|
+
::ActiveRecord::Base.connected_to_stack.concat(connected_to)
|
|
76
|
+
beat until stopped_after_interval?
|
|
77
|
+
end
|
|
78
|
+
thread.name = 'ruby_llm claim heartbeat'
|
|
79
|
+
thread.report_on_exception = false
|
|
80
|
+
thread
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def stopped_after_interval?
|
|
84
|
+
@lock.synchronize do
|
|
85
|
+
@wake.wait(@lock, @interval) unless @stopped || @hurried
|
|
86
|
+
@hurried = false
|
|
87
|
+
@stopped
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def beat
|
|
92
|
+
deadline = monotonic_now + (@interval / 4.0)
|
|
93
|
+
connected(@models.map(&:connection_pool).uniq, deadline) { @beat.call }
|
|
94
|
+
@failed_beats = 0
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
beat_failed(e)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def beat_failed(error)
|
|
100
|
+
@failed_beats += 1
|
|
101
|
+
if @failed_beats == 1 && busy?(error)
|
|
102
|
+
RubyLLM.logger.debug("RubyLLM skipped renewing a chat's claims, the database was busy")
|
|
103
|
+
else
|
|
104
|
+
RubyLLM.logger.warn("RubyLLM could not renew a chat's claims (#{@failed_beats} in a row): " \
|
|
105
|
+
"#{error.class}: #{error.message}")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def busy?(error)
|
|
110
|
+
[error, error.cause].any?(::SQLite3::BusyException) if defined?(::SQLite3::BusyException)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def connected(pools, deadline, &)
|
|
114
|
+
return yield if pools.empty?
|
|
115
|
+
|
|
116
|
+
pools.first.with_connection do |connection|
|
|
117
|
+
waiting_until(connection, deadline) { connected(pools.drop(1), deadline, &) }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Before Rails 8, SQLite waits for a lock through busy_timeout, which
|
|
122
|
+
# holds the GVL and stops every thread in the process, including the
|
|
123
|
+
# run whose transaction holds the lock. The beat waits with the GVL
|
|
124
|
+
# released instead, until its deadline, and then disconnects, so the
|
|
125
|
+
# next query on that connection reconnects with the application's own
|
|
126
|
+
# busy settings. An in-memory database has no other connection to wait
|
|
127
|
+
# for, and disconnecting would drop it.
|
|
128
|
+
def waiting_until(connection, deadline)
|
|
129
|
+
return yield unless gvl_held_while_busy?(connection)
|
|
130
|
+
|
|
131
|
+
begin
|
|
132
|
+
connection.raw_connection.busy_handler { monotonic_now < deadline && sleep(0.001) }
|
|
133
|
+
yield
|
|
134
|
+
ensure
|
|
135
|
+
connection.disconnect!
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def gvl_held_while_busy?(connection)
|
|
140
|
+
connection.adapter_name == 'SQLite' && ::ActiveRecord::VERSION::MAJOR < 8 &&
|
|
141
|
+
connection.supports_concurrent_connections?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def monotonic_now
|
|
145
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'stringio'
|
|
4
|
+
require 'tempfile'
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module ActiveRecord
|
|
8
|
+
# Keeps a persisted chat's files in +ruby_llm_chat_files+, with their
|
|
9
|
+
# content in Active Storage, so they live on whatever service Active
|
|
10
|
+
# Storage uses: a disk now, a bucket later. A persisted chat uses one
|
|
11
|
+
# when ChatFiles#with_file_store is given no store.
|
|
12
|
+
#
|
|
13
|
+
# chat.with_file_store
|
|
14
|
+
# chat.file_store.list
|
|
15
|
+
#
|
|
16
|
+
# The store writes files for the chat's owner, the record
|
|
17
|
+
# <tt>acts_as_chat owner:</tt> names or one set with
|
|
18
|
+
# ChatMethods#with_owner, and sees only their files and the files
|
|
19
|
+
# without an owner, so in a chat several people share, one person's
|
|
20
|
+
# tool results stay theirs:
|
|
21
|
+
#
|
|
22
|
+
# chat.with_owner(Current.user).with_file_store
|
|
23
|
+
#
|
|
24
|
+
# What the model last read of a file is kept in the row's
|
|
25
|
+
# +read_digest+ column, so it holds across processes, and #with_lock
|
|
26
|
+
# locks the chat's row. Converted text is kept in the row's +text+
|
|
27
|
+
# column. Encrypt it, like
|
|
28
|
+
# anything else the model reads, with
|
|
29
|
+
# <tt>RubyLLM::ActiveRecord::ChatFile.encrypts :text</tt>.
|
|
30
|
+
class FileStore < RubyLLM::FileStore
|
|
31
|
+
# The chat record the files belong to.
|
|
32
|
+
attr_reader :chat
|
|
33
|
+
|
|
34
|
+
# Creates a store for the files of the +chat+ record, writing them for
|
|
35
|
+
# +owner+ when given, and otherwise for the chat's owner.
|
|
36
|
+
#
|
|
37
|
+
# Raises ArgumentError when +chat+ is not an +acts_as_chat+ record,
|
|
38
|
+
# and ConfigurationError when Active Storage is not loaded.
|
|
39
|
+
def initialize(chat, owner: nil)
|
|
40
|
+
unless chat.is_a?(ChatFiles)
|
|
41
|
+
raise ArgumentError, "#{self.class} needs an acts_as_chat record, not #{chat.class}"
|
|
42
|
+
end
|
|
43
|
+
unless ChatFile.respond_to?(:reflect_on_attachment) && ChatFile.reflect_on_attachment(:file)
|
|
44
|
+
raise ConfigurationError, "#{self.class} needs Active Storage. " \
|
|
45
|
+
'Load it with require "active_storage/engine" in config/application.rb, ' \
|
|
46
|
+
'then run bin/rails active_storage:install and bin/rails db:migrate.'
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
super()
|
|
50
|
+
@chat = chat
|
|
51
|
+
@owner = owner
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Returns the owner the store writes files for: the one it was
|
|
55
|
+
# created with, or else the chat's owner when that is a record.
|
|
56
|
+
# Returns +nil+ when there is neither.
|
|
57
|
+
def owner
|
|
58
|
+
return @owner unless @owner.nil?
|
|
59
|
+
|
|
60
|
+
owner = @chat.to_llm.owner
|
|
61
|
+
owner if owner.is_a?(::ActiveRecord::Base)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns the files inside the folder +prefix+, or every file.
|
|
65
|
+
def list(prefix = nil)
|
|
66
|
+
scope = prefix.present? ? inside(path(prefix)) : visible
|
|
67
|
+
preferred(scope.includes(:owner).order(:path, :id).to_a).map(&:to_stored_file)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Returns the StoredFile at +path+, or +nil+.
|
|
71
|
+
def [](path)
|
|
72
|
+
row(path)&.to_stored_file
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Returns the content of the file at +path+.
|
|
76
|
+
def read(path)
|
|
77
|
+
record = row(path) || raise(FileNotFoundError, "No file at #{path(path)}")
|
|
78
|
+
record.file.download.b
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Writes +content+ to +path+ and returns its StoredFile.
|
|
82
|
+
def write(path, content, content_type: nil)
|
|
83
|
+
path = path(path)
|
|
84
|
+
rewindable(content) do |io|
|
|
85
|
+
type = content_type || RubyLLM::FileStore.content_type_for(path, io)
|
|
86
|
+
digest = RubyLLM::FileStore.digest(io)
|
|
87
|
+
io.rewind
|
|
88
|
+
blob = ::ActiveStorage::Blob.create_and_upload!(io:, filename: File.basename(path),
|
|
89
|
+
content_type: type, identify: false)
|
|
90
|
+
save(path, blob.byte_size, type, digest) { |record| record.file.attach(blob) }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Deletes the file at +path+ and returns its StoredFile, or +nil+. A
|
|
95
|
+
# store with an owner deletes only that owner's files.
|
|
96
|
+
def delete(path)
|
|
97
|
+
path = path(path)
|
|
98
|
+
with_lock do
|
|
99
|
+
record = owned.find_by(path:)
|
|
100
|
+
refuse_folder(path) if record.nil? && rows_in_folder(path).any? { |row| row.path != path }
|
|
101
|
+
record&.destroy
|
|
102
|
+
record&.to_stored_file
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Deletes everything inside the folder +path+ and returns the deleted
|
|
107
|
+
# StoredFile objects. A store with an owner deletes only that
|
|
108
|
+
# owner's files.
|
|
109
|
+
def delete_folder(path)
|
|
110
|
+
path = path(path)
|
|
111
|
+
with_lock do
|
|
112
|
+
refuse_file(path) if row(path)
|
|
113
|
+
|
|
114
|
+
rows_in_folder(path, owned).each(&:destroy).map(&:to_stored_file)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Returns the digest of what the model last read at +path+, from the
|
|
119
|
+
# file's row, or +nil+.
|
|
120
|
+
def read_digest(path) # :nodoc:
|
|
121
|
+
return super unless read_digests?
|
|
122
|
+
|
|
123
|
+
row(path)&.read_digest
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Keeps +digest+ as what the model read at +path+, in the file's row.
|
|
127
|
+
def record_read(path, digest) # :nodoc:
|
|
128
|
+
return super unless read_digests?
|
|
129
|
+
|
|
130
|
+
row(path)&.update_columns(read_digest: digest)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Runs the block in a transaction that locks the chat's row, past
|
|
134
|
+
# the query cache, so other processes wait for it.
|
|
135
|
+
def with_lock
|
|
136
|
+
super do
|
|
137
|
+
ChatFile.uncached do
|
|
138
|
+
ChatFile.transaction do
|
|
139
|
+
lock_chat
|
|
140
|
+
yield
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Returns the text kept for +file+, or +nil+.
|
|
147
|
+
def cached_text(file) # :nodoc:
|
|
148
|
+
record = row(file.path)
|
|
149
|
+
record.text if record&.digest == file.digest && record.has_attribute?(:text)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Keeps +text+ for +file+ in its row.
|
|
153
|
+
def cache_text(file, text) # :nodoc:
|
|
154
|
+
record = row(file.path)
|
|
155
|
+
record.update_columns(text:) if record&.digest == file.digest && record.has_attribute?(:text)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Keeps an attachment from Active Storage by its blob, without
|
|
159
|
+
# copying it: the file and the message share the blob.
|
|
160
|
+
def store_attachment(attachment) # :nodoc:
|
|
161
|
+
blob = blob_of(attachment)
|
|
162
|
+
return super unless blob
|
|
163
|
+
|
|
164
|
+
existing = visible.joins(:file_blob).find_by(active_storage_blobs: { id: blob.id })
|
|
165
|
+
existing ? existing.to_stored_file : super
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
# Active Storage reads an IO from its start, so one that cannot
|
|
171
|
+
# rewind, or stands past its start, is copied to a tempfile first.
|
|
172
|
+
def rewindable(content)
|
|
173
|
+
return yield StringIO.new(content.to_s.b) unless content.respond_to?(:read)
|
|
174
|
+
return yield content if at_start?(content)
|
|
175
|
+
|
|
176
|
+
Tempfile.create('ruby_llm-file-', binmode: true) do |copy|
|
|
177
|
+
IO.copy_stream(content, copy)
|
|
178
|
+
copy.rewind
|
|
179
|
+
yield copy
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def at_start?(io)
|
|
184
|
+
%i[rewind pos].all? { |method| io.respond_to?(method) } && io.pos.zero?
|
|
185
|
+
rescue Errno::ESPIPE
|
|
186
|
+
false
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def read_digests?
|
|
190
|
+
ChatFile.column_names.include?('read_digest')
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def rows_in_folder(folder, scope = visible)
|
|
194
|
+
inside(folder, scope).includes(:owner).order(:path, :id)
|
|
195
|
+
.select { |row| RubyLLM::FileStore.inside?(row.path, folder) }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def write_attachment(path, attachment, content)
|
|
199
|
+
blob = blob_of(attachment)
|
|
200
|
+
return super unless blob
|
|
201
|
+
|
|
202
|
+
save(path, blob.byte_size, attachment.mime_type, RubyLLM::FileStore.digest(content)) do |record|
|
|
203
|
+
record.file.attach(blob)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def save(path, byte_size, content_type, digest)
|
|
208
|
+
ChatFile.transaction do
|
|
209
|
+
lock_chat
|
|
210
|
+
record = owned.lock.find_or_initialize_by(path:)
|
|
211
|
+
record.assign_attributes(byte_size:, content_type:, digest:)
|
|
212
|
+
record.text = nil if record.has_attribute?(:text)
|
|
213
|
+
yield record
|
|
214
|
+
record.save!
|
|
215
|
+
record.to_stored_file
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# SQLite has no row locks, and a deferred transaction that reads
|
|
220
|
+
# first fails when it later writes beside another writer, so it takes
|
|
221
|
+
# the write lock at once with a no-op update.
|
|
222
|
+
def lock_chat
|
|
223
|
+
key = @chat.class.primary_key
|
|
224
|
+
scope = @chat.class.unscoped.where(key => @chat.id)
|
|
225
|
+
return scope.lock.pick(key) unless @chat.class.connection.adapter_name.match?(/sqlite/i)
|
|
226
|
+
|
|
227
|
+
column = @chat.class.connection.quote_column_name(key)
|
|
228
|
+
scope.update_all("#{column} = #{column}")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def row(path)
|
|
232
|
+
preferred(visible.where(path: path(path)).order(:id).to_a).first
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def preferred(records)
|
|
236
|
+
records.group_by(&:path).values.map { |same| same.find(&:owner_id) || same.first }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def rows
|
|
240
|
+
ChatFile.where(chat: @chat)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def visible
|
|
244
|
+
shared = rows.where(owner_type: nil, owner_id: nil)
|
|
245
|
+
owner ? shared.or(rows.where(owner:)) : shared
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def owned
|
|
249
|
+
owner ? rows.where(owner:) : rows.where(owner_type: nil, owner_id: nil)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def inside(folder, scope = visible)
|
|
253
|
+
inside = "#{ChatFile.sanitize_sql_like(folder, '!')}/%"
|
|
254
|
+
scope.where("path = :folder OR path LIKE :inside ESCAPE '!'", folder:, inside:)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def path(name)
|
|
258
|
+
RubyLLM::FileStore.path(name)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def blob_of(attachment)
|
|
262
|
+
source = attachment.source
|
|
263
|
+
return source if defined?(::ActiveStorage::Blob) && source.is_a?(::ActiveStorage::Blob)
|
|
264
|
+
|
|
265
|
+
source.blob if source.respond_to?(:blob) && source.blob.is_a?(::ActiveStorage::Blob)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
@@ -2,9 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
module RubyLLM
|
|
4
4
|
module ActiveRecord
|
|
5
|
-
# RubyLLM
|
|
6
|
-
#
|
|
7
|
-
|
|
5
|
+
# The OAuth state RubyLLM::MCP keeps for a server, in RubyLLM's
|
|
6
|
+
# +ruby_llm_mcp_credentials+ table. +data+ is encrypted with Active
|
|
7
|
+
# Record encryption, so the application needs encryption keys.
|
|
8
|
+
#
|
|
9
|
+
# RubyLLM::ActiveRecord::MCPCredential.where(owner: current_user).pluck(:updated_at)
|
|
10
|
+
#
|
|
11
|
+
# Each row has an optional polymorphic +owner+, the record the
|
|
12
|
+
# credentials were authorized for. Its columns:
|
|
13
|
+
#
|
|
14
|
+
# [+key+] The key RubyLLM stores the state under, unique across the table.
|
|
15
|
+
# [+data+] Tokens, client registrations, and signing keys, as a Hash.
|
|
16
|
+
# Treat it as a secret.
|
|
17
|
+
#
|
|
18
|
+
# RubyLLM writes these rows. Read and query them, and add scopes or
|
|
19
|
+
# columns of your own, but do not create, update, or delete them: call
|
|
20
|
+
# +deauthorize+ on the server to forget a user's credentials.
|
|
21
|
+
class MCPCredential < Record
|
|
8
22
|
self.table_name = 'ruby_llm_mcp_credentials'
|
|
9
23
|
|
|
10
24
|
belongs_to :owner, polymorphic: true, optional: true
|
|
@@ -15,21 +29,22 @@ module RubyLLM
|
|
|
15
29
|
validates :key, presence: true
|
|
16
30
|
|
|
17
31
|
class << self
|
|
18
|
-
def read(key)
|
|
32
|
+
def read(key) # :nodoc:
|
|
19
33
|
find_by(key:)&.data
|
|
20
34
|
end
|
|
21
35
|
|
|
22
|
-
def write(key, data, owner: nil)
|
|
23
|
-
|
|
24
|
-
record
|
|
36
|
+
def write(key, data, owner: nil) # :nodoc:
|
|
37
|
+
attributes = { data:, owner: owner.is_a?(::ActiveRecord::Base) ? owner : nil }
|
|
38
|
+
record = find_by(key:) || create_or_find_by!(key:) { |created| created.assign_attributes(attributes) }
|
|
39
|
+
record.update!(attributes)
|
|
25
40
|
end
|
|
26
41
|
|
|
27
|
-
def delete(key)
|
|
42
|
+
def delete(key) # :nodoc:
|
|
28
43
|
where(key:).delete_all
|
|
29
44
|
end
|
|
30
45
|
|
|
31
46
|
# Updating the row locks it on every adapter; SQLite ignores FOR UPDATE.
|
|
32
|
-
def synchronize(key)
|
|
47
|
+
def synchronize(key) # :nodoc:
|
|
33
48
|
transaction do
|
|
34
49
|
where(key:).touch_all
|
|
35
50
|
yield
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module ActiveRecord
|
|
7
|
+
# Shows a chat's messages live with Turbo when
|
|
8
|
+
# Configuration#broadcast_messages, or the model's +broadcasts:+
|
|
9
|
+
# option, is on. An empty assistant reply is
|
|
10
|
+
# appended when it is created, so its chunks have a place to land, and
|
|
11
|
+
# each chunk is appended as a numbered span that the chat UI's Stimulus
|
|
12
|
+
# controller puts back in order. A saved message replaces itself, a
|
|
13
|
+
# destroyed one is removed, and every change refreshes the chat page.
|
|
14
|
+
# A refresh carries no request id, so it reaches the page whose request
|
|
15
|
+
# made the change too, and Turbo never skips a burst of refreshes as
|
|
16
|
+
# that page's own. Broadcasts go to the chat record's stream:
|
|
17
|
+
#
|
|
18
|
+
# <%= turbo_stream_from @chat %>
|
|
19
|
+
#
|
|
20
|
+
module MessageBroadcasting
|
|
21
|
+
extend ActiveSupport::Concern
|
|
22
|
+
|
|
23
|
+
# Returns whether Turbo broadcasting can run: turbo-rails and Action
|
|
24
|
+
# Cable are loaded.
|
|
25
|
+
def self.available?
|
|
26
|
+
defined?(::Turbo::Broadcastable) && defined?(::ActionCable) ? true : false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
included do
|
|
30
|
+
after_create_commit -> { broadcast_append_to chat_association }, if: :streaming_placeholder?
|
|
31
|
+
after_update_commit -> { broadcast_replace_to chat_association }, if: :broadcasting?
|
|
32
|
+
after_destroy_commit -> { broadcast_remove_to chat_association }, if: :broadcasting?
|
|
33
|
+
after_commit -> { broadcast_refresh_to chat_association, request_id: nil }, if: :broadcasting?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class_methods do
|
|
37
|
+
# Returns whether this model's messages broadcast: its
|
|
38
|
+
# +broadcasts:+ option when given, Configuration#broadcast_messages
|
|
39
|
+
# otherwise.
|
|
40
|
+
def broadcasting?
|
|
41
|
+
ruby_llm_broadcasts.nil? ? RubyLLM.config.broadcast_messages == true : ruby_llm_broadcasts
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns whether this message broadcasts. See ::broadcasting?.
|
|
46
|
+
def broadcasting?
|
|
47
|
+
self.class.broadcasting?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Appends the content of +chunk+, a RubyLLM::Chunk streamed into this
|
|
51
|
+
# message, to the message's <tt><dom_id>_stream</tt> element as
|
|
52
|
+
# <tt><span data-seq="index"></tt>, escaped. Each span names the
|
|
53
|
+
# index of the span before it in <tt>data-after</tt>, so the page can
|
|
54
|
+
# order them. A chunk can reach the page before the message does, and
|
|
55
|
+
# Turbo drops an append with no target, so a page of your own must hold
|
|
56
|
+
# it, as the chat UI's Stimulus controller does. Returns +nil+ for a
|
|
57
|
+
# chunk without content. Raises
|
|
58
|
+
# RubyLLM::ConfigurationError when the message does not broadcast.
|
|
59
|
+
#
|
|
60
|
+
# chat.ask("Tell me a story") { |chunk| chunk.message.broadcast_chunk(chunk) }
|
|
61
|
+
#
|
|
62
|
+
def broadcast_chunk(chunk)
|
|
63
|
+
raise RubyLLM::ConfigurationError, ChunkMethods::BROADCASTING_OFF unless broadcasting?
|
|
64
|
+
return if chunk.content.to_s.empty?
|
|
65
|
+
|
|
66
|
+
after = @last_broadcast_chunk_index
|
|
67
|
+
@last_broadcast_chunk_index = chunk.index
|
|
68
|
+
broadcast_append_to chat_association,
|
|
69
|
+
target: "#{ActionView::RecordIdentifier.dom_id(self)}_stream",
|
|
70
|
+
content: chunk_span(chunk, after)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def streaming_placeholder?
|
|
76
|
+
broadcasting? && role.to_s == 'assistant' && content.blank?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def chunk_span(chunk, after)
|
|
80
|
+
after_attribute = %( data-after="#{after}") if after
|
|
81
|
+
%(<span data-seq="#{chunk.index}"#{after_attribute}>#{ERB::Util.html_escape(chunk.content)}</span>)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|