ruby_llm 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +162 -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 +522 -277
- 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 +1598 -332
- 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 +67 -28
- 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 +134 -109
- 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 +23 -2
- 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 +2 -9
- 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 +8 -3
- 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 +219 -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,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
# Keeps the assistant record a run writes its answer into. An answer
|
|
6
|
+
# the provider paused keeps its record, with the continuation that
|
|
7
|
+
# resumes it, so a later run continues it instead of starting again.
|
|
8
|
+
module ChatPlaceholders # :nodoc:
|
|
9
|
+
def persist_message_progress(message) # :nodoc:
|
|
10
|
+
return unless @message.has_attribute?(:continuation)
|
|
11
|
+
|
|
12
|
+
transaction do
|
|
13
|
+
@message.update!(message_attributes(message))
|
|
14
|
+
link_usage_entries(message)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def persist_new_message
|
|
21
|
+
unfinished = unfinished_message_record
|
|
22
|
+
return @message = unfinished if unfinished
|
|
23
|
+
|
|
24
|
+
@message.destroy if blank_placeholder?
|
|
25
|
+
remove_abandoned_placeholder
|
|
26
|
+
|
|
27
|
+
attrs = { role: :assistant, content: '' }
|
|
28
|
+
@message = messages_association.create!(attrs)
|
|
29
|
+
@last_message_id = @message.id
|
|
30
|
+
@message
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def blank_placeholder?
|
|
34
|
+
return false unless @message&.persisted? && @message.content.blank?
|
|
35
|
+
return false unless @message.role.to_s == 'assistant'
|
|
36
|
+
return false if unfinished_record?(@message)
|
|
37
|
+
return false if @message.respond_to?(:attachments) && @message.attachments.attached?
|
|
38
|
+
|
|
39
|
+
!@message.ruby_llm_tool_calls.exists?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A process killed before its reply arrived leaves its placeholder
|
|
43
|
+
# last, and the run that takes over the chat's claim writes a reply
|
|
44
|
+
# of its own, so it removes that one. A reply the model gave records
|
|
45
|
+
# usage, even a blank one. The claim is renewed first, so a run that
|
|
46
|
+
# lost it never removes the placeholder of the run that took over.
|
|
47
|
+
def remove_abandoned_placeholder
|
|
48
|
+
return unless @run_claim_token
|
|
49
|
+
|
|
50
|
+
transaction do
|
|
51
|
+
renew_run_claim(force: true)
|
|
52
|
+
record = messages_association.order(:id).last
|
|
53
|
+
record.destroy if to_llm.send(:placeholder?, record&.to_llm) && !record.ruby_llm_usages.exists?
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def unfinished_message_record
|
|
58
|
+
record = messages_association.order(:id).last
|
|
59
|
+
record if record&.role.to_s == 'assistant' && unfinished_record?(record)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def unfinished_record?(record)
|
|
63
|
+
record.has_attribute?(:continuation) && record.continuation.present?
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
# The questions a persisted chat waits on, kept on its tool call rows:
|
|
6
|
+
# approvals, and the questions tools and MCP servers ask (see
|
|
7
|
+
# RubyLLM::Question). Any process reads them from the database, without
|
|
8
|
+
# building the chat, and records answers on those rows under a row
|
|
9
|
+
# lock, so a web request can answer what a job asked and another job
|
|
10
|
+
# can carry the call on. ChatMethods includes it.
|
|
11
|
+
#
|
|
12
|
+
# chat.pending_questions.each { |question| render "questions/question", question: }
|
|
13
|
+
# chat.answer(params[:question_id], params[:answer])
|
|
14
|
+
# ChatResponseJob.perform_later(chat.id)
|
|
15
|
+
#
|
|
16
|
+
module ChatQuestions
|
|
17
|
+
# Returns the questions the chat waits on a person to answer, as
|
|
18
|
+
# RubyLLM::Question objects in the order the model made their calls:
|
|
19
|
+
# approvals, and the questions tools and MCP servers ask, as
|
|
20
|
+
# RubyLLM::Chat#pending_questions describes. It reads the database
|
|
21
|
+
# only, so it never builds the chat's tools or connects to its MCP
|
|
22
|
+
# servers, and it lists what any process recorded. Each question
|
|
23
|
+
# records its answer on this chat.
|
|
24
|
+
#
|
|
25
|
+
# Whether a call requires approval is decided when it is saved, by the
|
|
26
|
+
# process that received it, so a call whose tool decides with a
|
|
27
|
+
# Tool.requires_approval block stays listed until the chat runs again.
|
|
28
|
+
def pending_questions
|
|
29
|
+
approvals = persisted_pending_approvals.to_a
|
|
30
|
+
rows = RubyLLM::ActiveRecord::ToolCall.uncached { chat_tool_calls.where(result_id: nil).order(:created_at, :id).to_a }
|
|
31
|
+
rows.flat_map do |row|
|
|
32
|
+
tool_call = row.to_llm
|
|
33
|
+
next [RubyLLM::Question.approval(tool_call, chat: self)] if approvals.include?(row)
|
|
34
|
+
|
|
35
|
+
Array(row.mcp_state&.[]('requests')).filter_map do |data|
|
|
36
|
+
RubyLLM::Question.from_h(data, tool_call:, chat: self) unless data['response']
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Answers +question+, a RubyLLM::Question from #pending_questions or
|
|
42
|
+
# its id, on its tool call record, so the next +complete+ in any
|
|
43
|
+
# process carries the call on, as RubyLLM::Chat#answer describes.
|
|
44
|
+
# Answering an approval approves it, as +approve+ does, unless another
|
|
45
|
+
# decision came first, in this process or another. Takes the
|
|
46
|
+
# ActionController::Parameters a form sends for several fields, and
|
|
47
|
+
# keeps only those the question asks for. Returns +self+.
|
|
48
|
+
#
|
|
49
|
+
# chat.answer(params[:question_id], params[:answer])
|
|
50
|
+
#
|
|
51
|
+
# Raises ArgumentError for a question the chat never asked, and for an
|
|
52
|
+
# answer the question can't take.
|
|
53
|
+
def answer(question, value = nil, **values)
|
|
54
|
+
pending = persisted_pending_question(question)
|
|
55
|
+
response = pending&.response_to(value, values)
|
|
56
|
+
return self unless pending && settle_persisted_question(pending, response)
|
|
57
|
+
|
|
58
|
+
question.respond(response) if question.is_a?(RubyLLM::Question)
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Declines +question+, a RubyLLM::Question from #pending_questions,
|
|
63
|
+
# its id, or the call of an approval, on its tool call record, as
|
|
64
|
+
# RubyLLM::Chat#decline describes, unless another decision came
|
|
65
|
+
# first. The +note+ persists with it, so whichever process carries
|
|
66
|
+
# the call on gives the model the same note. A call saved waiting for
|
|
67
|
+
# approval before 2.2 is declined by its id, as +approve+ approves
|
|
68
|
+
# it, though #pending_questions leaves it out. Returns +self+.
|
|
69
|
+
#
|
|
70
|
+
# chat.decline(params[:question_id], params[:note])
|
|
71
|
+
#
|
|
72
|
+
# Raises ArgumentError for a question the chat never asked, and
|
|
73
|
+
# RubyLLM::ToolCallRunningError for an approval still pending whose
|
|
74
|
+
# call is already running.
|
|
75
|
+
def decline(question, note = nil)
|
|
76
|
+
pending = persisted_pending_question(question) || unmarked_approval(question)
|
|
77
|
+
response = RubyLLM::Question.declined(note)
|
|
78
|
+
return self unless pending && settle_persisted_question(pending, response)
|
|
79
|
+
|
|
80
|
+
question.respond(response) if question.is_a?(RubyLLM::Question)
|
|
81
|
+
self
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def settle_persisted_input(tool_call) # :nodoc:
|
|
85
|
+
transaction do
|
|
86
|
+
# Writing first takes the row's lock on every database, SQLite
|
|
87
|
+
# included, so the state read next is the latest.
|
|
88
|
+
scope = chat_tool_calls.where(tool_call_id: tool_call.id, result_id: nil)
|
|
89
|
+
next unless scope.update_all(updated_at: Time.current) == 1
|
|
90
|
+
|
|
91
|
+
locked = RubyLLM::ActiveRecord::ToolCall.uncached { scope.lock.take }
|
|
92
|
+
next if locked.mcp_state.nil?
|
|
93
|
+
|
|
94
|
+
settled = yield(locked.mcp_state)
|
|
95
|
+
next if settled == locked.mcp_state
|
|
96
|
+
|
|
97
|
+
forget_stale_cancellation
|
|
98
|
+
locked.update!(mcp_state: settled)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Returns the note +tool_call+ was declined with while it waits for
|
|
103
|
+
# its refusal. It is kept in +mcp_result+, which the call's result
|
|
104
|
+
# replaces, and never in +mcp_state+, where a note would look like
|
|
105
|
+
# a question the call waits on.
|
|
106
|
+
def persisted_decline_note(tool_call) # :nodoc:
|
|
107
|
+
RubyLLM::ActiveRecord::ToolCall.uncached { find_tool_call(tool_call.id) }&.mcp_result&.[]('note')
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def record_persisted_decline_note(id, note) # :nodoc:
|
|
111
|
+
chat_tool_calls.where(tool_call_id: id, result_id: nil)
|
|
112
|
+
.update_all(mcp_result: { 'note' => note }, updated_at: Time.current)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def persisted_pending_question(question)
|
|
118
|
+
id = persisted_question_id(question)
|
|
119
|
+
pending_questions.find { |pending| pending.id == id } ||
|
|
120
|
+
(persisted_question?(id) ? nil : raise(ArgumentError, "Unknown question: #{id.inspect}"))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def persisted_question_id(question)
|
|
124
|
+
id = question.respond_to?(:tool_call_id) ? question.tool_call_id : question
|
|
125
|
+
(id.respond_to?(:id) ? id.id : id).to_s
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# A call saved before 2.2 waits for approval with no +pending+ mark,
|
|
129
|
+
# so #pending_questions can't list it, but it can be declined as
|
|
130
|
+
# +approve+ approves it.
|
|
131
|
+
def unmarked_approval(question)
|
|
132
|
+
record = RubyLLM::ActiveRecord::ToolCall.uncached { find_tool_call(persisted_question_id(question)) }
|
|
133
|
+
return unless record && record.approval.nil? && record.result_id.nil?
|
|
134
|
+
|
|
135
|
+
RubyLLM::Question.approval(record.to_llm, chat: self)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def persisted_question?(id)
|
|
139
|
+
RubyLLM::ActiveRecord::ToolCall.uncached do
|
|
140
|
+
[id, id.rpartition('/').first].any? { |tool_call_id| find_tool_call(tool_call_id) }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def record_persisted_answer(question, response)
|
|
145
|
+
settle_persisted_input(question.tool_call) { |input| question.answered_input(input, response) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Records +response+ to +question+ and returns whether it did. An
|
|
149
|
+
# approval is approved or denied only while it still waits, so of
|
|
150
|
+
# two people deciding at once, in any processes, the first one's
|
|
151
|
+
# decision stands.
|
|
152
|
+
def settle_persisted_question(question, response)
|
|
153
|
+
unless question.approval?
|
|
154
|
+
record_persisted_answer(question, response)
|
|
155
|
+
return true
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
waiting = RubyLLM::ActiveRecord::ToolCall.where(id: tool_call_record(question.tool_call.id).id,
|
|
159
|
+
approval: ['pending', nil], result_id: nil)
|
|
160
|
+
transaction do
|
|
161
|
+
# Writing first takes the row's lock on every database, SQLite
|
|
162
|
+
# included, so a decision made at the same time waits for this
|
|
163
|
+
# one and then finds the call decided.
|
|
164
|
+
next false unless waiting.update_all(updated_at: Time.current) == 1
|
|
165
|
+
|
|
166
|
+
response == RubyLLM::Question::ACCEPTED ? approve(question) : refuse(question, response['note'])
|
|
167
|
+
true
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def refuse(question, note)
|
|
172
|
+
record_tool_call_decision(question.tool_call, 'denied')
|
|
173
|
+
record_persisted_decline_note(question.tool_call.id, note) if note
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ruby_llm/active_record/attachment_helpers'
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module ActiveRecord
|
|
7
|
+
# Keeps a persisted chat's queued messages in +ruby_llm_queued_messages+,
|
|
8
|
+
# so any process can queue one and the process running the chat takes
|
|
9
|
+
# it. Taking or withdrawing deletes the row, and only the process whose
|
|
10
|
+
# delete removes it goes on, so each message joins once or not at all.
|
|
11
|
+
# Joining runs Chat#around_join inside the transaction that deletes the
|
|
12
|
+
# row, so a callback that raises puts the row back. The message record
|
|
13
|
+
# gets the row's sender when its model has a +sender+ association.
|
|
14
|
+
class ChatQueue # :nodoc:
|
|
15
|
+
include AttachmentHelpers
|
|
16
|
+
|
|
17
|
+
def initialize(chat)
|
|
18
|
+
@chat = chat
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def push(content:, attachments:, steer:, sender:)
|
|
22
|
+
QueuedMessage.transaction do
|
|
23
|
+
@chat.forget_stale_cancellation
|
|
24
|
+
queued = rows.create!(content:, steer:, sender:)
|
|
25
|
+
persist_content(queued, attachments) unless attachments.nil?
|
|
26
|
+
queued
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def delete(queued)
|
|
31
|
+
id = queued.respond_to?(:id) ? queued.id : queued
|
|
32
|
+
record = read { rows.find_by(id:) }
|
|
33
|
+
record if record&.withdraw
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_a
|
|
37
|
+
read { rows.to_a }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def none?(steering_only: false)
|
|
41
|
+
!available? || read { !scope(steering_only).exists? }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def take(steering_only: false)
|
|
45
|
+
return unless available?
|
|
46
|
+
|
|
47
|
+
read { scope(steering_only).to_a }.each do |queued|
|
|
48
|
+
taken = take_one(queued)
|
|
49
|
+
yield taken.first, queued if taken
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def take_one(queued)
|
|
56
|
+
taken = nil
|
|
57
|
+
QueuedMessage.transaction do
|
|
58
|
+
next unless QueuedMessage.where(id: queued.id).delete_all == 1
|
|
59
|
+
|
|
60
|
+
taken = [@chat.to_llm.run_around_join(queued) { |**attributes| join(queued, attributes) }]
|
|
61
|
+
rescue StandardError
|
|
62
|
+
@chat.messages_association.reset
|
|
63
|
+
raise
|
|
64
|
+
end
|
|
65
|
+
queued.attachments.purge_later if taken == [nil] && attachments?(queued)
|
|
66
|
+
taken
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def join(queued, attributes)
|
|
70
|
+
blobs = queued.attachments.blobs.to_a if attachments?(queued)
|
|
71
|
+
message = @chat.persist_message({ role: :user, content: queued.content, attachments: blobs },
|
|
72
|
+
sender_attributes(queued).merge(attributes))
|
|
73
|
+
detach(queued) if blobs
|
|
74
|
+
message
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def sender_attributes(queued)
|
|
78
|
+
association = @chat.messages_association.klass.reflect_on_association(:sender)
|
|
79
|
+
sender = queued_sender(queued) if association
|
|
80
|
+
return {} unless sender && (association.polymorphic? || sender.is_a?(association.klass))
|
|
81
|
+
|
|
82
|
+
{ sender: }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Nil once the sender's class, or the subclass its row names, is
|
|
86
|
+
# gone, say renamed since the message was queued, so the message
|
|
87
|
+
# joins without its sender instead of holding up the queue.
|
|
88
|
+
def queued_sender(queued)
|
|
89
|
+
queued.sender
|
|
90
|
+
rescue NameError, ::ActiveRecord::SubclassNotFound
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def detach(queued)
|
|
95
|
+
ActiveStorage::Attachment.where(record_type: QueuedMessage.polymorphic_name, record_id: queued.id,
|
|
96
|
+
name: 'attachments').delete_all
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def scope(steering_only)
|
|
100
|
+
steering_only ? rows.where(steer: true) : rows
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def rows
|
|
104
|
+
@chat.ruby_llm_queued_messages.scope
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def read(&)
|
|
108
|
+
QueuedMessage.uncached(&)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def attachments?(queued)
|
|
112
|
+
queued.respond_to?(:attachments) && queued.attachments.attached?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def available?
|
|
116
|
+
@chat.persisted? && QueuedMessage.table_exists?
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
# Keeps a message for the person about the last failed reply in the
|
|
6
|
+
# chats table's +reply_error+ column, when it has one, so the chat's
|
|
7
|
+
# page shows it however its broadcasts arrive. A failed run writes it,
|
|
8
|
+
# and the next run clears it. A stop is not a failure.
|
|
9
|
+
module ChatReplyError # :nodoc:
|
|
10
|
+
MESSAGES = {
|
|
11
|
+
failed: 'The reply failed. Send your message again.',
|
|
12
|
+
too_long: 'This conversation is too long for the model. Start a new chat.'
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def clear_reply_error
|
|
18
|
+
update_columns(reply_error: nil) if reply_error_column? && self[:reply_error]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def record_reply_error(error)
|
|
22
|
+
return if error.is_a?(CancelledError) || !reply_error_column?
|
|
23
|
+
|
|
24
|
+
key = error.is_a?(ContextLengthExceededError) ? :too_long : :failed
|
|
25
|
+
update_columns(reply_error: I18n.t(key, scope: %i[ruby_llm reply_error], default: MESSAGES[key]))
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
RubyLLM.logger.warn("RubyLLM could not save the reply error: #{e.class}: #{e.message}")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def reply_error_column?
|
|
31
|
+
persisted? && has_attribute?(:reply_error)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
# Connects a persisted chat's conversation loop to its record: usage
|
|
6
|
+
# and progress land in the database, each cancellation checkpoint
|
|
7
|
+
# renews the run claim and polls for a request from another process,
|
|
8
|
+
# losing the claim ends the run, and the owner is the record that
|
|
9
|
+
# +acts_as_chat owner:+ names.
|
|
10
|
+
class ChatRun < Chat::Run # :nodoc:
|
|
11
|
+
def initialize(chat)
|
|
12
|
+
super()
|
|
13
|
+
@chat = chat
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def record_usage(entry)
|
|
17
|
+
@chat.persist_usage_entry(entry)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def record_progress(partial)
|
|
21
|
+
@chat.persist_message_progress(partial)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def chat_id
|
|
25
|
+
@chat.id
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Stable across processes and opaque: the provider sees neither the
|
|
29
|
+
# table nor the id.
|
|
30
|
+
def cache_key
|
|
31
|
+
id = @chat.id
|
|
32
|
+
return unless id
|
|
33
|
+
|
|
34
|
+
"ruby_llm-#{Digest::SHA256.hexdigest("#{@chat.class.base_class.name}/#{id}")[0, 32]}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def owner
|
|
38
|
+
name = @chat.class.owner_association_name
|
|
39
|
+
@chat.public_send(name) if name
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def cancellation_requested?
|
|
43
|
+
@chat.poll_run_cancellation == :cancelled
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def interrupted_by?(error)
|
|
47
|
+
@chat.run_claim_lost?(error)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
# Keeps a persisted chat's tool call state on its
|
|
6
|
+
# +ruby_llm_tool_calls+ rows, read outside the query cache, so every
|
|
7
|
+
# process sees the same state.
|
|
8
|
+
class ChatToolCallState < Chat::ToolCallState # :nodoc:
|
|
9
|
+
def initialize(chat)
|
|
10
|
+
super()
|
|
11
|
+
@chat = chat
|
|
12
|
+
@contested = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def input(tool_call)
|
|
16
|
+
record = RubyLLM::ActiveRecord::ToolCall.uncached { @chat.find_tool_call(tool_call.id) }
|
|
17
|
+
record.mcp_state if record&.has_attribute?(:mcp_state)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def record_input(tool_call, input)
|
|
21
|
+
record = RubyLLM::ActiveRecord::ToolCall.uncached { @chat.find_tool_call(tool_call.id) }
|
|
22
|
+
return if !record&.has_attribute?(:mcp_state) || record.mcp_state == input
|
|
23
|
+
|
|
24
|
+
@chat.transaction do
|
|
25
|
+
@chat.forget_stale_cancellation
|
|
26
|
+
record.update!(mcp_state: input)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def decline_note(tool_call)
|
|
31
|
+
@chat.persisted_decline_note(tool_call)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def record_decline_note(tool_call, note)
|
|
35
|
+
@chat.record_persisted_decline_note(tool_call.id, note)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def settle(tool_call, &)
|
|
39
|
+
@chat.settle_persisted_input(tool_call, &)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def decision(tool_call)
|
|
43
|
+
decided = super
|
|
44
|
+
decided.nil? ? @chat.persisted_tool_call_approval(tool_call) : decided
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def remembered?(call)
|
|
48
|
+
super || @chat.persisted_remembered_tool?(call)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def claim(requested, executing:)
|
|
52
|
+
@contested = []
|
|
53
|
+
return requested if requested.empty?
|
|
54
|
+
|
|
55
|
+
claimed = @chat.claim_persisted_tool_calls(yield, executing:)
|
|
56
|
+
@contested = requested - claimed
|
|
57
|
+
claimed
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def release(ids, stopped_by = nil, finished: false)
|
|
61
|
+
@chat.release_persisted_tool_calls(ids, stopped_by, finished:)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def started(tool_call)
|
|
65
|
+
@chat.persisted_tool_call_started(tool_call.id)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def interrupted?(tool_call)
|
|
69
|
+
@chat.persisted_tool_call_interrupted?(tool_call.id)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def running_elsewhere?(tool_call)
|
|
73
|
+
@contested.include?(tool_call.id) && @chat.tool_call_running_elsewhere?(tool_call)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
# Gives each RubyLLM::Chunk streamed from a persisted chat the message
|
|
6
|
+
# record it streams into.
|
|
7
|
+
#
|
|
8
|
+
# chat.ask("Tell me a story", &:broadcast)
|
|
9
|
+
#
|
|
10
|
+
module ChunkMethods
|
|
11
|
+
BROADCASTING_OFF = 'Messages do not broadcast. Set config.broadcast_messages = true in the ' \
|
|
12
|
+
'RubyLLM initializer, or pass broadcasts: true to acts_as_message, with ' \
|
|
13
|
+
'turbo-rails and Action Cable loaded.' # :nodoc:
|
|
14
|
+
|
|
15
|
+
# Returns a block that gives each chunk the record +message+ returns
|
|
16
|
+
# before passing it to +block+, or +nil+ without a block. Calls
|
|
17
|
+
# +on_error+ when +block+ raises, then re-raises the error.
|
|
18
|
+
def self.streaming(block, on_error: nil, &message) # :nodoc:
|
|
19
|
+
return unless block
|
|
20
|
+
|
|
21
|
+
lambda do |chunk|
|
|
22
|
+
block.call(attach(chunk, message.call))
|
|
23
|
+
rescue StandardError
|
|
24
|
+
on_error&.call
|
|
25
|
+
raise
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Gives +chunk+ the record +message+ it streams into. Returns +chunk+.
|
|
30
|
+
def self.attach(chunk, message) # :nodoc:
|
|
31
|
+
chunk.extend(ChunkMethods)
|
|
32
|
+
chunk.message = message
|
|
33
|
+
chunk
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns the persisted message record this chunk streams into.
|
|
37
|
+
attr_accessor :message
|
|
38
|
+
|
|
39
|
+
# Appends this chunk to its message on the chat's page. See
|
|
40
|
+
# MessageBroadcasting#broadcast_chunk. Raises
|
|
41
|
+
# RubyLLM::ConfigurationError when the message does not broadcast.
|
|
42
|
+
def broadcast
|
|
43
|
+
raise RubyLLM::ConfigurationError, BROADCASTING_OFF unless message.respond_to?(:broadcast_chunk)
|
|
44
|
+
|
|
45
|
+
message.broadcast_chunk(self)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|