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,259 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
class Chat
|
|
8
|
+
# What Chat#with_file_store changes in a conversation. Every attachment is
|
|
9
|
+
# kept in the chat's FileStore, and each request shows it in full only
|
|
10
|
+
# while it is new: on the latest question, and on read_file results
|
|
11
|
+
# since then. Before that, and on other tools' results, the model sees
|
|
12
|
+
# one line naming the file, and reads it again with the file tools. A
|
|
13
|
+
# tool result longer than the inline limit is saved as a file, and
|
|
14
|
+
# the model gets its start and the path. An attachment the provider
|
|
15
|
+
# cannot take, such as a Word file, goes as the text the store's
|
|
16
|
+
# converters read from it, or as that line when none can. An attachment
|
|
17
|
+
# a converter on this machine reads for free has its text extracted
|
|
18
|
+
# when it is kept, so search_files finds it at once.
|
|
19
|
+
class FileReferences # :nodoc:
|
|
20
|
+
INLINE_LIMIT = 10_000
|
|
21
|
+
PREVIEW = 2_000
|
|
22
|
+
SPILLED = Regexp.new('\n\n\[This result has \d+ characters, so only the start is shown\. All of it is in ' \
|
|
23
|
+
'(\S+): read more with read_file, or search it with search_files\.\]\z')
|
|
24
|
+
READ_TOOL = 'read_file'
|
|
25
|
+
|
|
26
|
+
# Stands for the store a chat picks when given none: a new
|
|
27
|
+
# FileStore::Memory, or a persisted chat's files in Active Storage.
|
|
28
|
+
DEFAULT_STORE = Object.new.tap { |default| def default.inspect = 'the default store' }.freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :inline_limit, :tools, :store
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
def validate(store, inline_limit:, tools: FileStore::Tools::DEFAULT, store_lambda: false)
|
|
34
|
+
unless store?(store) || (store_lambda && store.is_a?(Proc))
|
|
35
|
+
raise ArgumentError, 'with_file_store takes a folder path, :memory, a RubyLLM::FileStore, or nil, ' \
|
|
36
|
+
"got #{store.inspect}"
|
|
37
|
+
end
|
|
38
|
+
FileStore::Tools.names(tools)
|
|
39
|
+
return if inline_limit == false || (inline_limit.is_a?(Integer) && inline_limit.positive?)
|
|
40
|
+
|
|
41
|
+
raise ArgumentError, 'inline_limit: takes a positive number of characters or false'
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns the path of the file holding the whole result whose start
|
|
45
|
+
# is +content+, or +nil+ when the result was sent whole.
|
|
46
|
+
def spilled_path(content)
|
|
47
|
+
content.to_s[SPILLED, 1]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def resolve(store)
|
|
51
|
+
return FileStore::Memory.new if store == :memory
|
|
52
|
+
return FileStore::Directory.new(store) if path?(store)
|
|
53
|
+
|
|
54
|
+
store
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def store?(store)
|
|
60
|
+
store.is_a?(FileStore) || store.equal?(DEFAULT_STORE) || store == :memory || path?(store)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def path?(store)
|
|
64
|
+
store.is_a?(String) || store.respond_to?(:to_path)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def initialize(store:, inline_limit:, written:, tools: FileStore::Tools::DEFAULT, config: -> { RubyLLM.config })
|
|
69
|
+
@store = store
|
|
70
|
+
@config = config
|
|
71
|
+
@inline_limit = inline_limit
|
|
72
|
+
@tools = FileStore::Tools.names(tools)
|
|
73
|
+
@written = written
|
|
74
|
+
@stored = {}.compare_by_identity
|
|
75
|
+
@described = {}.compare_by_identity
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def view(list)
|
|
79
|
+
current = list.rindex { |message| message.role == :user } || 0
|
|
80
|
+
reads = read_paths(list)
|
|
81
|
+
list.each_with_index.map do |message, index|
|
|
82
|
+
message.attachments.empty? ? message : view_message(message, current: index >= current, reads:)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def spill(tool, tool_call, content)
|
|
87
|
+
limit = tool.respond_to?(:inline_limit) ? tool.inline_limit : nil
|
|
88
|
+
limit = @inline_limit if limit.nil?
|
|
89
|
+
return content unless limit && content.is_a?(String) && content.length > limit
|
|
90
|
+
|
|
91
|
+
text, extension = readable(content)
|
|
92
|
+
file = write("results/#{safe_name(tool_call.name)}-#{safe_id(tool_call.id)}.#{extension}", text)
|
|
93
|
+
"#{text[0, PREVIEW]}\n\n[This result has #{text.length} characters, so only the start is shown. " \
|
|
94
|
+
"All of it is in #{file.path}: read more with read_file, or search it with search_files.]"
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
RubyLLM.logger.warn { "Could not save a long tool result in the chat's files: #{e.class}" }
|
|
97
|
+
content
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def store_attachment(attachment)
|
|
101
|
+
@stored[attachment] ||= store.store_attachment(attachment) do |file|
|
|
102
|
+
@written.call(file)
|
|
103
|
+
extract(file)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def describe(attachment, config: RubyLLM.config)
|
|
108
|
+
file = store_attachment(attachment)
|
|
109
|
+
(as_text(file, config) || reference(file.path, file)).tap { |replacement| @described[replacement] = true }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Records a read for each text attachment in +list+, the messages a
|
|
113
|
+
# request sent, that went as itself and matches the file in the store.
|
|
114
|
+
# Anything else the provider got for it, a reference, converted text,
|
|
115
|
+
# or an upload, is a new object that was never stored here.
|
|
116
|
+
def record_shown(list)
|
|
117
|
+
list.flat_map(&:attachments).each do |attachment|
|
|
118
|
+
file = @stored[attachment]
|
|
119
|
+
record_read(attachment, file) if file && attachment.text?
|
|
120
|
+
end
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
RubyLLM.logger.warn { "Could not record the files a request showed: #{e.class}" }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Returns the StoredFiles of the attachments on +list+, the chat's
|
|
126
|
+
# messages, as they are now, keeping any not kept yet. Leaves out
|
|
127
|
+
# read_file results: they show a file to whoever read it, and a store
|
|
128
|
+
# with an owner may not show that file to this one.
|
|
129
|
+
def attached_files(list)
|
|
130
|
+
reads = read_paths(list)
|
|
131
|
+
list.reject { |message| message.role == :tool && reads.key?(message.tool_call_id) }.flat_map(&:attachments)
|
|
132
|
+
.filter_map { |attachment| attached_file(attachment) }.uniq(&:path)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Removes the replacements #describe made from +replacements+, a
|
|
136
|
+
# chat's memo, so another store or limit describes them again.
|
|
137
|
+
def forget(replacements)
|
|
138
|
+
replacements.delete_if { |_attachment, replacement| @described.key?(replacement) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def attached_file(attachment)
|
|
144
|
+
store[store_attachment(attachment).path] unless attachment.provider_file?
|
|
145
|
+
rescue StandardError => e
|
|
146
|
+
RubyLLM.logger.warn { "Could not keep an attachment in the chat's files: #{e.class}" }
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def record_read(attachment, file)
|
|
151
|
+
seen = FileStore.digest(attachment.content.to_s.b)
|
|
152
|
+
current = store[file.path]
|
|
153
|
+
return unless current && seen == (current.digest || FileStore.digest(store.read(current.path)))
|
|
154
|
+
|
|
155
|
+
store.record_read(current.path, seen) unless store.read_digest(current.path) == seen
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def extract(file)
|
|
159
|
+
return if file.text? || store.cached_text(file)
|
|
160
|
+
|
|
161
|
+
text = FileStore::Converters.convert(store.attachment(file.path, file:), config: @config.call, local: true)
|
|
162
|
+
store.cache_text(file, text) if text
|
|
163
|
+
rescue StandardError => e
|
|
164
|
+
RubyLLM.logger.warn { "Could not extract the text of #{file.path}: #{e.class}" }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def view_message(message, current:, reads:)
|
|
168
|
+
read = (reads[message.tool_call_id] if message.tool_result?)
|
|
169
|
+
shown = current && (!message.tool_result? || read)
|
|
170
|
+
attachments = message.attachments.map { |attachment| view_of(attachment, shown:, read:) }
|
|
171
|
+
attachments == message.attachments ? message : message.with_attachments(attachments)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def view_of(attachment, shown:, read:)
|
|
175
|
+
return attachment if attachment.provider_file?
|
|
176
|
+
return (shown ? attachment : reference(read, nil)) if read
|
|
177
|
+
|
|
178
|
+
file = store_attachment(attachment)
|
|
179
|
+
return reference(file.path, file) unless shown
|
|
180
|
+
|
|
181
|
+
too_long?(attachment) ? start_of(attachment, file) : attachment
|
|
182
|
+
rescue StandardError => e
|
|
183
|
+
RubyLLM.logger.warn { "Could not keep an attachment in the chat's files: #{e.class}" }
|
|
184
|
+
attachment
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def too_long?(attachment)
|
|
188
|
+
@inline_limit && attachment.text? && attachment.content.to_s.length > @inline_limit
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def start_of(attachment, file)
|
|
192
|
+
text_attachment(file, attachment.content.to_s)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def as_text(file, config)
|
|
196
|
+
text = store.text(file.path, config:)
|
|
197
|
+
return if text.nil? || text.strip.empty?
|
|
198
|
+
|
|
199
|
+
text_attachment(file, text)
|
|
200
|
+
rescue StandardError => e
|
|
201
|
+
RubyLLM.logger.warn { "Could not read #{file.path} as text: #{e.class}" }
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def text_attachment(file, text)
|
|
206
|
+
partial = @inline_limit && text.length > @inline_limit
|
|
207
|
+
if partial
|
|
208
|
+
text = "#{text[0, @inline_limit]}\n\n[#{file.path} has #{text.length} characters, so only the start " \
|
|
209
|
+
'is shown, and you have not seen the rest. Read more with read_file, or search it with search_files.]'
|
|
210
|
+
end
|
|
211
|
+
attachment = Attachment.new(StringIO.new("#{file.path}, read as text:\n\n#{text}"),
|
|
212
|
+
filename: "#{File.basename(file.path)}.txt")
|
|
213
|
+
partial ? attachment.mark_partial : attachment
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def reference(path, file)
|
|
217
|
+
text = if file
|
|
218
|
+
"#{path} (#{file.content_type}, #{file.human_size}) is in this chat's files and not shown " \
|
|
219
|
+
'here. Open it with read_file, or search it with search_files.'
|
|
220
|
+
else
|
|
221
|
+
"#{path} was shown here. Open it again with read_file to see it."
|
|
222
|
+
end
|
|
223
|
+
Attachment.new(StringIO.new(text), filename: "#{File.basename(path)}.txt")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Returns the path each read_file call asked for, by call id.
|
|
227
|
+
def read_paths(list)
|
|
228
|
+
list.flat_map { |message| message.tool_calls.to_a }
|
|
229
|
+
.select { |call| call.name == READ_TOOL }
|
|
230
|
+
.to_h { |call| [call.id, read_path(call)] }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def read_path(call)
|
|
234
|
+
path = call.arguments.to_h.transform_keys(&:to_s)['path'].to_s
|
|
235
|
+
path.empty? ? 'the file' : path
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def write(path, content)
|
|
239
|
+
store.write(path, content).tap { |file| @written.call(file) }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def readable(content)
|
|
243
|
+
return [content, 'txt'] unless content.lstrip.start_with?('{', '[')
|
|
244
|
+
|
|
245
|
+
[JSON.pretty_generate(JSON.parse(content)), 'json']
|
|
246
|
+
rescue JSON::ParserError
|
|
247
|
+
[content, 'txt']
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def safe_name(name)
|
|
251
|
+
name.to_s.gsub(/[^A-Za-z0-9_-]/, '_')[0, 64]
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def safe_id(id)
|
|
255
|
+
id.to_s.gsub(/[^A-Za-z0-9]/, '').chars.last(8).join
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class Chat
|
|
5
|
+
# Holds a chat's queued messages in memory. Any thread may queue or
|
|
6
|
+
# withdraw while the conversation loop takes them, and each message is
|
|
7
|
+
# taken or withdrawn once. A message that fails to join stays queued,
|
|
8
|
+
# with the ones taken after it, and those that joined are already
|
|
9
|
+
# yielded.
|
|
10
|
+
class MessageQueue # :nodoc:
|
|
11
|
+
def initialize(messages = [], &add)
|
|
12
|
+
@add = add
|
|
13
|
+
@messages = messages.map { |message| message.requeue(self) }
|
|
14
|
+
@lock = Mutex.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def push(content:, attachments:, steer:, sender:)
|
|
18
|
+
queued = QueuedMessage.new(content:, attachments:, steer:, sender:, queue: self)
|
|
19
|
+
@lock.synchronize { @messages << queued }
|
|
20
|
+
queued
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def delete(queued)
|
|
24
|
+
id = queued.respond_to?(:id) ? queued.id : queued
|
|
25
|
+
@lock.synchronize do
|
|
26
|
+
index = @messages.index { |message| message.id == id }
|
|
27
|
+
@messages.delete_at(index) if index
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_a
|
|
32
|
+
@lock.synchronize { @messages.dup }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def none?(steering_only: false)
|
|
36
|
+
@lock.synchronize { @messages.none? { |message| message.steer? || !steering_only } }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def take(steering_only: false)
|
|
40
|
+
taken = @lock.synchronize do
|
|
41
|
+
ready, @messages = @messages.partition { |message| message.steer? || !steering_only }
|
|
42
|
+
ready
|
|
43
|
+
end
|
|
44
|
+
taken.each_with_index do |queued, index|
|
|
45
|
+
joined = index
|
|
46
|
+
message = @add.call(queued)
|
|
47
|
+
joined = index + 1
|
|
48
|
+
yield message, queued
|
|
49
|
+
rescue StandardError
|
|
50
|
+
@lock.synchronize { @messages.unshift(*taken.drop(joined)) }
|
|
51
|
+
raise
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class Chat
|
|
5
|
+
# Attributes every provider attempt a chat's operations make, including
|
|
6
|
+
# the tools they run, the converters a render runs, and the callbacks
|
|
7
|
+
# of a completion a batch delivers, to the owner set with
|
|
8
|
+
# Chat#with_owner, through the same fiber storage RubyLLM.with_owner
|
|
9
|
+
# uses. A chat without an owner leaves the surrounding one in place.
|
|
10
|
+
module Ownership # :nodoc:
|
|
11
|
+
[*Rescuable::OPERATIONS, :render, :add_completion].each do |operation|
|
|
12
|
+
define_method(operation) do |*args, **kwargs, &block|
|
|
13
|
+
Accounting::Usage.owned_by(owner) { super(*args, **kwargs, &block) }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class Chat
|
|
5
|
+
# Runs a chat's operations through the Agent::rescue_from handlers of the
|
|
6
|
+
# agent that configured it, however the chat was reached. Only the
|
|
7
|
+
# outermost operation rescues: #ask runs #complete, and a handler's value
|
|
8
|
+
# must not stand in for a step of a loop that is still running.
|
|
9
|
+
module Rescuable # :nodoc:
|
|
10
|
+
OPERATIONS = %i[ask say ask_later complete generate run_tools step count_tokens compact].freeze
|
|
11
|
+
|
|
12
|
+
attr_writer :rescuer
|
|
13
|
+
|
|
14
|
+
# The agents whose callback macros run on this chat, by agent class.
|
|
15
|
+
def agent_callback_receivers
|
|
16
|
+
@agent_callback_receivers ||= {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
OPERATIONS.each do |operation|
|
|
20
|
+
define_method(operation) do |*args, **kwargs, &block|
|
|
21
|
+
return super(*args, **kwargs, &block) unless @rescuer
|
|
22
|
+
|
|
23
|
+
rescuing_with(@rescuer) { super(*args, **kwargs, &block) }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def rescuing_with(agent)
|
|
28
|
+
return yield if @rescuing
|
|
29
|
+
|
|
30
|
+
begin
|
|
31
|
+
@rescuing = true
|
|
32
|
+
yield
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
@rescuing = false
|
|
35
|
+
agent.rescue_with_handler(e)
|
|
36
|
+
ensure
|
|
37
|
+
@rescuing = false
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize_copy(source)
|
|
42
|
+
super
|
|
43
|
+
@rescuing = false
|
|
44
|
+
@agent_callback_receivers = @agent_callback_receivers&.dup
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class Chat
|
|
5
|
+
# What the conversation loop tells the world around it, and asks of it.
|
|
6
|
+
# A plain chat reports usage through +usage.ruby_llm+ and has nothing
|
|
7
|
+
# else to consult. The Rails integration swaps in ActiveRecord::ChatRun,
|
|
8
|
+
# which reaches the record that owns the run. A copy of the chat shares
|
|
9
|
+
# it.
|
|
10
|
+
class Run # :nodoc:
|
|
11
|
+
def record_usage(entry)
|
|
12
|
+
Accounting::Usage.record(entry)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def record_progress(_partial); end
|
|
16
|
+
|
|
17
|
+
def chat_id; end
|
|
18
|
+
|
|
19
|
+
def cache_key; end
|
|
20
|
+
|
|
21
|
+
def owner; end
|
|
22
|
+
|
|
23
|
+
def cancellation_requested?
|
|
24
|
+
false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def interrupted_by?(_error)
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
module RubyLLM
|
|
8
|
+
class Chat
|
|
9
|
+
class ToolCallIds # :nodoc:
|
|
10
|
+
def self.answer(result, messages)
|
|
11
|
+
calls = unanswered(messages)
|
|
12
|
+
call = calls.find { |candidate| candidate.id == result.tool_call_id } ||
|
|
13
|
+
calls.reverse.find { |candidate| candidate.provider_call_id == result.provider_call_id }
|
|
14
|
+
call ? result.with_tool_call_id(call.id) : result
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.unanswered(messages)
|
|
18
|
+
answered = messages.select(&:tool_result?).to_set(&:tool_call_id)
|
|
19
|
+
messages.select(&:tool_call?).flat_map { |message| message.tool_calls.to_a }
|
|
20
|
+
.reject { |call| answered.include?(call.id) }
|
|
21
|
+
end
|
|
22
|
+
private_class_method :unanswered
|
|
23
|
+
|
|
24
|
+
def initialize(namespace: nil)
|
|
25
|
+
@ids = {}
|
|
26
|
+
@namespace = namespace
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def assign(message)
|
|
30
|
+
return message unless message.tool_call?
|
|
31
|
+
|
|
32
|
+
calls = message.tool_calls.transform_values do |call|
|
|
33
|
+
call.provider_call_id ? call.with_id(id_for(call.provider_call_id)) : call
|
|
34
|
+
end
|
|
35
|
+
calls = ToolCall.index(calls.values) unless message.is_a?(Chunk)
|
|
36
|
+
message.with_tool_calls(calls)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def id_for(id)
|
|
42
|
+
@ids[id] ||= @namespace ? Digest::SHA256.hexdigest(JSON.generate([*@namespace, id])) : SecureRandom.uuid
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class Chat
|
|
5
|
+
# Holds what a chat knows about its tool calls beyond the transcript, in
|
|
6
|
+
# memory. A copy of the chat gets a copy of the state. The Rails
|
|
7
|
+
# integration swaps in ActiveRecord::ChatToolCallState, which keeps it
|
|
8
|
+
# on the tool call records so any process can read it.
|
|
9
|
+
class ToolCallState # :nodoc:
|
|
10
|
+
def initialize
|
|
11
|
+
@inputs = {}
|
|
12
|
+
@decisions = {}
|
|
13
|
+
@notes = {}
|
|
14
|
+
@remembered = []
|
|
15
|
+
@spent_escalations = []
|
|
16
|
+
@lock = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize_copy(source)
|
|
20
|
+
super
|
|
21
|
+
@inputs = @inputs.dup
|
|
22
|
+
@decisions = @decisions.dup
|
|
23
|
+
@notes = @notes.dup
|
|
24
|
+
@remembered = @remembered.dup
|
|
25
|
+
@spent_escalations = @spent_escalations.dup
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def input(tool_call)
|
|
29
|
+
@inputs[tool_call.id]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def record_input(tool_call, input)
|
|
33
|
+
@inputs[tool_call.id] = input
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Replaces the input +tool_call+ is paused on with what the block
|
|
37
|
+
# returns for it, while the call is still paused. Answers recorded
|
|
38
|
+
# from several threads at once take turns, each on the input the one
|
|
39
|
+
# before left.
|
|
40
|
+
def settle(tool_call)
|
|
41
|
+
exclusively do
|
|
42
|
+
input = @inputs[tool_call.id]
|
|
43
|
+
@inputs[tool_call.id] = yield(input) if input
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Runs the block while no other thread settles or decides through
|
|
48
|
+
# this state, so a check and the write it leads to stay together.
|
|
49
|
+
def exclusively(&)
|
|
50
|
+
@lock.synchronize(&)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def record_decision(id, decision)
|
|
54
|
+
@decisions[id] = decision
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def decision(tool_call)
|
|
58
|
+
@decisions[tool_call.id]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The note a person gave when declining +tool_call+, kept apart from
|
|
62
|
+
# its input so a declined call never looks paused on a question.
|
|
63
|
+
def decline_note(tool_call)
|
|
64
|
+
@notes[tool_call.id]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def record_decline_note(tool_call, note)
|
|
68
|
+
@notes[tool_call.id] = note
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def denied?(tool_call)
|
|
72
|
+
decision(tool_call) == false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def remember(identity)
|
|
76
|
+
@remembered |= [identity]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def remembered?(call)
|
|
80
|
+
@remembered.include?([call.namespace, call.name.to_s])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def escalation_spent?(tool_call)
|
|
84
|
+
@spent_escalations.include?(tool_call.id)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def spend_escalation(tool_call)
|
|
88
|
+
@spent_escalations << tool_call.id
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def claim(requested, **)
|
|
92
|
+
requested
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Frees the claims on +ids+ once their round ends, whether it ended
|
|
96
|
+
# normally or by +stopped_by+, the signal or exit that stopped it.
|
|
97
|
+
# A round that +finished+ left only calls that paused unanswered.
|
|
98
|
+
def release(_ids, _stopped_by = nil, finished: false); end
|
|
99
|
+
|
|
100
|
+
# Notes that +tool_call+, claimed with its round, starts running now.
|
|
101
|
+
def started(_tool_call); end
|
|
102
|
+
|
|
103
|
+
# Whether +tool_call+, claimed with its round, had started in a
|
|
104
|
+
# process that stopped before saving its result, so it is answered
|
|
105
|
+
# as interrupted instead of running again.
|
|
106
|
+
def interrupted?(_tool_call)
|
|
107
|
+
false
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def running_elsewhere?(_tool_call)
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|