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
|
@@ -2,117 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module RubyLLM
|
|
4
4
|
class MCP
|
|
5
|
-
# A server's
|
|
6
|
-
#
|
|
7
|
-
|
|
8
|
-
# connect an account.
|
|
9
|
-
#
|
|
10
|
-
# before_input_request do |request|
|
|
11
|
-
# if request.url?
|
|
12
|
-
# system "open", request.url
|
|
13
|
-
# request.answer
|
|
14
|
-
# else
|
|
15
|
-
# request.answer(environment: "staging")
|
|
16
|
-
# end
|
|
17
|
-
# end
|
|
18
|
-
#
|
|
19
|
-
# In a chat, a request no callback answers pauses the tool call until
|
|
20
|
-
# Chat#answer or Chat#decline settles it; see Chat#pending_inputs.
|
|
21
|
-
class InputRequest
|
|
22
|
-
include Support::Inspectable
|
|
23
|
-
|
|
24
|
-
# A value a form request asks for.
|
|
25
|
-
Field = Struct.new(:name, :type, :title, :description, :required, :choices, :default, keyword_init: true) do
|
|
26
|
-
# Returns whether the server needs this value.
|
|
27
|
-
def required?
|
|
28
|
-
required == true
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# Why the server needs the input.
|
|
33
|
-
attr_reader :message
|
|
34
|
-
|
|
35
|
-
# The page a URL request asks the user to visit, or +nil+.
|
|
36
|
-
attr_reader :url
|
|
37
|
-
|
|
38
|
-
# The values a form request asks for, as Field objects.
|
|
39
|
-
attr_reader :fields
|
|
40
|
-
|
|
41
|
-
attr_reader :key, :response # :nodoc:
|
|
42
|
-
|
|
43
|
-
# The ToolCall paused on this request, when it came from a chat.
|
|
44
|
-
attr_reader :tool_call
|
|
45
|
-
|
|
46
|
-
def self.from_h(data, tool_call: nil) # :nodoc:
|
|
47
|
-
new(data['key'], data['params'], response: data['response'], tool_call:)
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def initialize(key, params, response: nil, tool_call: nil) # :nodoc:
|
|
51
|
-
@key = key
|
|
52
|
-
@params = params
|
|
53
|
-
@response = response
|
|
54
|
-
@tool_call = tool_call
|
|
55
|
-
@message = params['message']
|
|
56
|
-
@url = params['url'] if params['mode'] == 'url'
|
|
57
|
-
@fields = fields_from(params['requestedSchema'] || {})
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# Returns whether the server asks the user to visit #url.
|
|
61
|
-
def url?
|
|
62
|
-
!url.nil?
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# Returns whether the server asks for #fields.
|
|
66
|
-
def form?
|
|
67
|
-
!url?
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
# Accepts the request. A form request takes the values as keywords,
|
|
71
|
-
# and the fields you leave out take the defaults the server gave
|
|
72
|
-
# them; a URL request takes none, meaning the user agreed to visit
|
|
73
|
-
# the page.
|
|
74
|
-
def answer(**values)
|
|
75
|
-
@response = { action: 'accept', content: (with_defaults(values) unless url?) }.compact
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# Declines the request.
|
|
79
|
-
def decline
|
|
80
|
-
@response = { action: 'decline' }
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# Returns whether the request has been answered or declined.
|
|
84
|
-
def answered?
|
|
85
|
-
!response.nil?
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def to_h # :nodoc:
|
|
89
|
-
{ 'key' => key, 'params' => @params, 'response' => response&.transform_keys(&:to_s) }.compact
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
private
|
|
93
|
-
|
|
94
|
-
def with_defaults(values)
|
|
95
|
-
fields.reject { |field| field.default.nil? }.to_h { |field| [field.name, field.default] }
|
|
96
|
-
.merge(values).transform_keys(&:to_s)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def fields_from(schema)
|
|
100
|
-
required = Array(schema['required'])
|
|
101
|
-
schema.fetch('properties', {}).map do |name, property|
|
|
102
|
-
Field.new(name: name.to_sym, type: property['type'], title: property['title'],
|
|
103
|
-
description: property['description'], required: required.include?(name),
|
|
104
|
-
choices: choices(property), default: property['default'])
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def choices(property)
|
|
109
|
-
options = property['enum'] || Array(property['oneOf'] || property.dig('items', 'anyOf')).map { |o| o['const'] }
|
|
110
|
-
options unless options.empty?
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def inspect_attributes
|
|
114
|
-
{ message:, url:, fields: fields.map(&:name) }
|
|
115
|
-
end
|
|
116
|
-
end
|
|
5
|
+
# A server's question to the person, the same Question a tool asks
|
|
6
|
+
# with Tool#ask, under its 2.1 name.
|
|
7
|
+
InputRequest = RubyLLM::Question
|
|
117
8
|
end
|
|
118
9
|
end
|
|
@@ -3,27 +3,9 @@
|
|
|
3
3
|
module RubyLLM
|
|
4
4
|
class MCP
|
|
5
5
|
# Raised when a server needs input from the user and no
|
|
6
|
-
# MCP.before_input_request callback answered.
|
|
7
|
-
#
|
|
8
|
-
|
|
9
|
-
# returns the requests.
|
|
10
|
-
class InputRequiredError < Error
|
|
11
|
-
def initialize(server, input) # :nodoc:
|
|
12
|
-
@input = input
|
|
13
|
-
asks = requests.map { |request| [request.message, request.url].compact.join(' ') }
|
|
14
|
-
super("#{server} needs input from the user: #{asks.join('; ')}")
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
# The unanswered MCP::InputRequest objects.
|
|
18
|
-
def requests
|
|
19
|
-
@input['requests'].reject(&:answered?)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
# Returns everything needed to answer the requests later and resume
|
|
23
|
-
# the call, as a Hash that serializes to JSON.
|
|
24
|
-
def to_h # :nodoc:
|
|
25
|
-
@input.merge('requests' => @input['requests'].map(&:to_h)).compact
|
|
26
|
-
end
|
|
6
|
+
# MCP.before_input_request callback answered. Rescue
|
|
7
|
+
# RubyLLM::InputRequiredError to catch a tool's own questions too.
|
|
8
|
+
class InputRequiredError < RubyLLM::InputRequiredError
|
|
27
9
|
end
|
|
28
10
|
end
|
|
29
11
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class MCP
|
|
5
|
+
# Raised when an MCP server refuses a request because the token lacks a
|
|
6
|
+
# scope it needs. The message carries the server's reason, and #scopes
|
|
7
|
+
# the scopes it asked for. RubyLLM never widens a grant by itself: to
|
|
8
|
+
# grant them, send the user through MCP#authorization_url again.
|
|
9
|
+
#
|
|
10
|
+
# begin
|
|
11
|
+
# chat.ask "List the open issues in crmne/ruby_llm"
|
|
12
|
+
# rescue RubyLLM::MCP::InsufficientScopeError => e
|
|
13
|
+
# e.message # => "api.githubcopilot.com refused the request: Additional scopes required: repo"
|
|
14
|
+
# e.scopes # => ["repo"]
|
|
15
|
+
# end
|
|
16
|
+
class InsufficientScopeError < RubyLLM::ForbiddenError
|
|
17
|
+
# The scopes the server's challenge asked for, as Strings. Empty when
|
|
18
|
+
# it named none.
|
|
19
|
+
attr_reader :scopes
|
|
20
|
+
|
|
21
|
+
def initialize(message = nil, scopes: [], **) # :nodoc:
|
|
22
|
+
@scopes = scopes
|
|
23
|
+
super(message, **)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -58,7 +58,13 @@ module RubyLLM
|
|
|
58
58
|
@changes = changes
|
|
59
59
|
@pid = Process.pid
|
|
60
60
|
@state = nil
|
|
61
|
-
@thread = Thread.new
|
|
61
|
+
@thread = Thread.new do
|
|
62
|
+
Support::Cancellation.watch(nil) do
|
|
63
|
+
Support::Instrumentation.with_workflow(nil) do
|
|
64
|
+
Support::ProgressReporter.listen(nil) { Accounting::Usage.with_owner(nil) { run } }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
62
68
|
@thread.name = 'ruby_llm-mcp-listener'
|
|
63
69
|
@thread.report_on_exception = false
|
|
64
70
|
acknowledgment
|
data/lib/ruby_llm/mcp/oauth.rb
CHANGED
|
@@ -34,6 +34,9 @@ module RubyLLM
|
|
|
34
34
|
REFRESH_EARLY = 60
|
|
35
35
|
ASSERTION_FOR = 60
|
|
36
36
|
GRANTS = %i[authorization_code client_credentials jwt_bearer].freeze
|
|
37
|
+
RESERVED_AUTHORIZATION_PARAMS = %w[
|
|
38
|
+
client_id redirect_uri state code_challenge code_challenge_method response_type resource scope
|
|
39
|
+
].freeze
|
|
37
40
|
JWT_BEARER = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
|
|
38
41
|
TOKEN_EXCHANGE = 'urn:ietf:params:oauth:grant-type:token-exchange'
|
|
39
42
|
ID_JAG = 'urn:ietf:params:oauth:token-type:id-jag'
|
|
@@ -45,7 +48,7 @@ module RubyLLM
|
|
|
45
48
|
issuer token_endpoint token_endpoint_auth_methods_supported token_endpoint_auth_signing_alg_values_supported
|
|
46
49
|
authorization_response_iss_parameter_supported
|
|
47
50
|
].freeze
|
|
48
|
-
CLIENT_FIELDS = %w[client_id client_secret issuer server redirect_uri dpop_key].freeze
|
|
51
|
+
CLIENT_FIELDS = %w[client_id client_secret issuer server redirect_uri dpop_key resource].freeze
|
|
49
52
|
TOKEN = /[!\#$%&'*+.^_`|~0-9A-Za-z-]+/
|
|
50
53
|
PARAMETER = /#{TOKEN}\s*=/
|
|
51
54
|
QUOTED = /"((?:[^"\\]|\\.)*)"/
|
|
@@ -55,7 +58,7 @@ module RubyLLM
|
|
|
55
58
|
'%<path>s/.well-known/openid-configuration'
|
|
56
59
|
].freeze
|
|
57
60
|
|
|
58
|
-
@refreshes =
|
|
61
|
+
@refreshes = {}
|
|
59
62
|
@refreshes_lock = Mutex.new
|
|
60
63
|
|
|
61
64
|
def self.memory_store
|
|
@@ -75,9 +78,19 @@ module RubyLLM
|
|
|
75
78
|
end
|
|
76
79
|
|
|
77
80
|
# Runs the block while no other thread in this process refreshes the
|
|
78
|
-
# credentials stored under +key+.
|
|
81
|
+
# credentials stored under +key+. A key's lock lives only while a
|
|
82
|
+
# thread holds it or waits for it.
|
|
79
83
|
def self.refreshing(key, &)
|
|
80
|
-
@refreshes_lock.synchronize
|
|
84
|
+
lock = @refreshes_lock.synchronize do
|
|
85
|
+
entry = @refreshes[key] ||= [Mutex.new, 0]
|
|
86
|
+
entry[1] += 1
|
|
87
|
+
entry.first
|
|
88
|
+
end
|
|
89
|
+
begin
|
|
90
|
+
lock.synchronize(&)
|
|
91
|
+
ensure
|
|
92
|
+
@refreshes_lock.synchronize { @refreshes.delete(key) if (@refreshes[key][1] -= 1).zero? }
|
|
93
|
+
end
|
|
81
94
|
end
|
|
82
95
|
|
|
83
96
|
# Reads the challenges of a WWW-Authenticate header (RFC 9110
|
|
@@ -98,8 +111,9 @@ module RubyLLM
|
|
|
98
111
|
end
|
|
99
112
|
|
|
100
113
|
def initialize(server_url, owner:, scopes: nil, client_id: nil, client_secret: nil, grant: nil,
|
|
101
|
-
private_key: nil, assertion: nil, identity_provider: nil,
|
|
102
|
-
resolve: ->(value) { value })
|
|
114
|
+
private_key: nil, assertion: nil, identity_provider: nil, authorization_params: nil,
|
|
115
|
+
config: RubyLLM.config, resolve: ->(value) { value })
|
|
116
|
+
HTTP.parse(server_url)
|
|
103
117
|
@server_url = server_url.to_s
|
|
104
118
|
@owner = owner
|
|
105
119
|
@scopes = scopes
|
|
@@ -108,20 +122,22 @@ module RubyLLM
|
|
|
108
122
|
@private_key = resolve.call(private_key)
|
|
109
123
|
@assertion = assertion
|
|
110
124
|
@identity_provider = identity_provider
|
|
125
|
+
@authorization_params = authorization_params
|
|
111
126
|
@grant = grant || (assertion || identity_provider ? :jwt_bearer : :authorization_code)
|
|
112
127
|
@resolve = resolve
|
|
113
128
|
@config = config
|
|
114
129
|
end
|
|
115
130
|
|
|
116
131
|
def authorized?
|
|
117
|
-
credential&.key?('access_token')
|
|
132
|
+
@credential = store.read(key) unless @credential&.key?('access_token')
|
|
133
|
+
@credential.to_h.key?('access_token')
|
|
118
134
|
end
|
|
119
135
|
|
|
120
136
|
def access_token
|
|
121
137
|
return unless authorized?
|
|
122
138
|
|
|
123
139
|
renew if expiring?
|
|
124
|
-
credential['access_token']
|
|
140
|
+
credential.to_h['access_token']
|
|
125
141
|
end
|
|
126
142
|
|
|
127
143
|
# Returns the headers that authorize a +verb+ request to the server:
|
|
@@ -179,27 +195,44 @@ module RubyLLM
|
|
|
179
195
|
verifier = SecureRandom.urlsafe_base64(64)
|
|
180
196
|
state = SecureRandom.urlsafe_base64(32)
|
|
181
197
|
pending = client.merge('state' => state, 'verifier' => verifier, 'redirect_uri' => redirect_uri,
|
|
182
|
-
'issuer' => server['issuer'], 'scope' => scopes_for(server),
|
|
183
|
-
'dpop_key' => binding_key, 'expires_at' => Time.now.to_i + PENDING_FOR
|
|
184
|
-
|
|
198
|
+
'issuer' => server['issuer'], 'scope' => scopes_for(server), 'resource' => resource,
|
|
199
|
+
'dpop_key' => binding_key, 'expires_at' => Time.now.to_i + PENDING_FOR,
|
|
200
|
+
'server' => server.slice(*SERVER_FIELDS))
|
|
201
|
+
synchronize { write(store.read(key).to_h.merge('pending' => pending)) }
|
|
185
202
|
|
|
186
203
|
query = { response_type: 'code', client_id: client['client_id'], redirect_uri:, state:,
|
|
187
204
|
code_challenge: Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false),
|
|
188
205
|
code_challenge_method: 'S256', resource:, scope: pending['scope'] }.compact
|
|
189
|
-
|
|
206
|
+
uri = URI(endpoint(server['authorization_endpoint']))
|
|
207
|
+
uri.query = URI.encode_www_form(URI.decode_www_form(uri.query.to_s) + extra_authorization_params + query.to_a)
|
|
208
|
+
uri.to_s
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def self.check_authorization_params(params) # :nodoc:
|
|
212
|
+
reserved = params.keys.map(&:to_s) & RESERVED_AUTHORIZATION_PARAMS
|
|
213
|
+
return params if reserved.empty?
|
|
214
|
+
|
|
215
|
+
raise ArgumentError, "RubyLLM sets #{reserved.join(', ')} itself; remove it from authorization_params"
|
|
190
216
|
end
|
|
191
217
|
|
|
192
218
|
def authorize(params)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
219
|
+
synchronize do
|
|
220
|
+
@credential = store.read(key)
|
|
221
|
+
pending = credential&.fetch('pending', nil) or raise Error, 'No authorization in progress'
|
|
222
|
+
check_callback(pending, params)
|
|
223
|
+
tokens = token_request('authorization_code', code: value(params, :code),
|
|
224
|
+
redirect_uri: pending['redirect_uri'],
|
|
225
|
+
code_verifier: pending['verifier'], client: pending)
|
|
226
|
+
@credential = credential.except('pending')
|
|
227
|
+
store_tokens(tokens, client: pending.slice(*CLIENT_FIELDS, 'scope'))
|
|
228
|
+
end
|
|
198
229
|
end
|
|
199
230
|
|
|
200
231
|
def deauthorize
|
|
201
|
-
|
|
202
|
-
|
|
232
|
+
synchronize do
|
|
233
|
+
@credential = nil
|
|
234
|
+
store.delete(key)
|
|
235
|
+
end
|
|
203
236
|
end
|
|
204
237
|
|
|
205
238
|
private
|
|
@@ -208,6 +241,11 @@ module RubyLLM
|
|
|
208
241
|
@credential ||= store.read(key)
|
|
209
242
|
end
|
|
210
243
|
|
|
244
|
+
def extra_authorization_params
|
|
245
|
+
params = Hash(@resolve.call(@authorization_params))
|
|
246
|
+
self.class.check_authorization_params(params).map { |name, value| [name.to_s, @resolve.call(value).to_s] }
|
|
247
|
+
end
|
|
248
|
+
|
|
211
249
|
def write(data)
|
|
212
250
|
@credential = data
|
|
213
251
|
store.write(key, data, owner: @owner)
|
|
@@ -218,7 +256,7 @@ module RubyLLM
|
|
|
218
256
|
end
|
|
219
257
|
|
|
220
258
|
def store_tokens(tokens, client: nil)
|
|
221
|
-
data = credential.to_h
|
|
259
|
+
data = credential.to_h
|
|
222
260
|
data = data.except(*CLIENT_FIELDS).merge(client) if client
|
|
223
261
|
data = data.merge('access_token' => tokens['access_token'], 'scope' => tokens['scope'] || data['scope'],
|
|
224
262
|
'expires_at' => (Time.now.to_i + tokens['expires_in'].to_i if tokens['expires_in']),
|
|
@@ -333,7 +371,10 @@ module RubyLLM
|
|
|
333
371
|
check_issuer(pending, value(params, :iss))
|
|
334
372
|
check_state(pending, value(params, :state).to_s)
|
|
335
373
|
error = value(params, :error)
|
|
336
|
-
|
|
374
|
+
return unless error
|
|
375
|
+
|
|
376
|
+
raise Error.new('Authorization failed', provider_message: value(params, :error_description) || error,
|
|
377
|
+
error_code: error)
|
|
337
378
|
end
|
|
338
379
|
|
|
339
380
|
def check_issuer(pending, issuer)
|
|
@@ -366,17 +407,25 @@ module RubyLLM
|
|
|
366
407
|
# attempt signs a new proof and a new client assertion.
|
|
367
408
|
def send_token_request(server, client, key, params, retried: false)
|
|
368
409
|
url = server['token_endpoint']
|
|
369
|
-
form = params.merge(client_id: client['client_id'], resource:)
|
|
410
|
+
form = params.merge(client_id: client['client_id'], resource: resource(client))
|
|
370
411
|
headers = { 'Content-Type' => 'application/x-www-form-urlencoded', 'Accept' => 'application/json' }
|
|
371
412
|
authenticate(client, server, form, headers, key)
|
|
372
413
|
headers['DPoP'] = proofs.sign(client['dpop_key'], url) if client['dpop_key']
|
|
373
|
-
post(url, URI.encode_www_form(form.compact), headers)
|
|
414
|
+
tokens = post(url, URI.encode_www_form(form.compact), headers)
|
|
415
|
+
return tokens if granted?(tokens)
|
|
416
|
+
|
|
417
|
+
# Keep only the error fields: a partial response may still carry a refresh or ID token.
|
|
418
|
+
raise rejection(url, tokens.slice('error', 'error_description', 'error_uri'))
|
|
374
419
|
rescue Error => e
|
|
375
420
|
raise if retried || !client['dpop_key'] || oauth_error(e) != 'use_dpop_nonce'
|
|
376
421
|
|
|
377
422
|
send_token_request(server, client, key, params, retried: true)
|
|
378
423
|
end
|
|
379
424
|
|
|
425
|
+
def granted?(tokens)
|
|
426
|
+
tokens['access_token'] && !tokens['error']
|
|
427
|
+
end
|
|
428
|
+
|
|
380
429
|
def oauth_error(error)
|
|
381
430
|
error.data['error'] if error.data.is_a?(Hash)
|
|
382
431
|
end
|
|
@@ -394,6 +443,7 @@ module RubyLLM
|
|
|
394
443
|
elsif client['client_secret']
|
|
395
444
|
methods = server['token_endpoint_auth_methods_supported'] || ['client_secret_basic']
|
|
396
445
|
if methods.include?('client_secret_basic')
|
|
446
|
+
form.delete(:client_id)
|
|
397
447
|
headers['Authorization'] = "Basic #{basic_credentials(client['client_id'], client['client_secret'])}"
|
|
398
448
|
else
|
|
399
449
|
form[:client_secret] = client['client_secret']
|
|
@@ -483,19 +533,15 @@ module RubyLLM
|
|
|
483
533
|
server.path == path || server.path.start_with?("#{path}/")
|
|
484
534
|
end
|
|
485
535
|
|
|
536
|
+
# RFC 8414 section 3.3: metadata whose issuer is not the authorization
|
|
537
|
+
# server it was fetched for must not be used.
|
|
486
538
|
def discover_authorization_server(issuer)
|
|
487
539
|
server = authorization_server_metadata(issuer)
|
|
488
540
|
return server if same_issuer?(server['issuer'], issuer)
|
|
489
541
|
|
|
490
|
-
named = server['issuer']
|
|
491
|
-
raise Error, "#{issuer} metadata names a different issuer" unless named.is_a?(String) && URI(named).host
|
|
492
|
-
|
|
493
|
-
server = authorization_server_metadata(named)
|
|
494
|
-
raise Error, "#{issuer} metadata names a different issuer" unless same_issuer?(server['issuer'], named)
|
|
495
|
-
|
|
496
|
-
server
|
|
497
|
-
rescue URI::InvalidURIError
|
|
498
542
|
raise Error, "#{issuer} metadata names a different issuer"
|
|
543
|
+
rescue URI::InvalidURIError
|
|
544
|
+
raise Error, "#{issuer} is not a valid authorization server URL"
|
|
499
545
|
end
|
|
500
546
|
|
|
501
547
|
def authorization_server_metadata(issuer)
|
|
@@ -578,14 +624,14 @@ module RubyLLM
|
|
|
578
624
|
@challenge.to_h.each_value.filter_map { |parameters| parameters[name] }.first
|
|
579
625
|
end
|
|
580
626
|
|
|
581
|
-
def resource
|
|
582
|
-
@resource || @server_url.chomp('/')
|
|
627
|
+
def resource(client = nil)
|
|
628
|
+
client&.fetch('resource', nil) || @resource || @server_url.chomp('/')
|
|
583
629
|
end
|
|
584
630
|
|
|
585
631
|
def first_json(urls)
|
|
586
632
|
urls.each do |url|
|
|
587
633
|
return get_json(url)
|
|
588
|
-
rescue
|
|
634
|
+
rescue Error
|
|
589
635
|
next
|
|
590
636
|
end
|
|
591
637
|
nil
|
|
@@ -593,6 +639,8 @@ module RubyLLM
|
|
|
593
639
|
|
|
594
640
|
def get_json(url)
|
|
595
641
|
parse(connection(url).get(url, nil, 'Accept' => 'application/json'))
|
|
642
|
+
rescue Faraday::Error => e
|
|
643
|
+
raise failure(url, e)
|
|
596
644
|
end
|
|
597
645
|
|
|
598
646
|
def post(url, body, headers)
|
|
@@ -600,8 +648,15 @@ module RubyLLM
|
|
|
600
648
|
proofs.remember(url, response.headers['dpop-nonce'])
|
|
601
649
|
parse(response)
|
|
602
650
|
rescue Faraday::Error => e
|
|
603
|
-
raise
|
|
604
|
-
|
|
651
|
+
raise failure(url, e)
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def failure(url, error)
|
|
655
|
+
response = error.response
|
|
656
|
+
return ConnectionError.new(error.message, provider: URI(url).host) unless response
|
|
657
|
+
|
|
658
|
+
refusal(url, Faraday::Response.new(status: response[:status], body: response[:body],
|
|
659
|
+
response_headers: response[:headers]))
|
|
605
660
|
end
|
|
606
661
|
|
|
607
662
|
def refusal(url, response)
|
|
@@ -611,15 +666,23 @@ module RubyLLM
|
|
|
611
666
|
rescue JSON::ParserError
|
|
612
667
|
{}
|
|
613
668
|
end
|
|
669
|
+
rejection(url, details, response)
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def rejection(url, details, response = nil)
|
|
614
673
|
details = {} unless details.is_a?(Hash)
|
|
615
|
-
Error.new("#{URI(url).host} refused the request
|
|
674
|
+
Error.new("#{URI(url).host} refused the request",
|
|
675
|
+
provider_message: details['error_description'] || details['error'], error_code: details['error'],
|
|
616
676
|
data: details, response:)
|
|
617
677
|
end
|
|
618
678
|
|
|
619
679
|
def parse(response)
|
|
620
|
-
JSON.parse(response.body)
|
|
680
|
+
document = JSON.parse(response.body)
|
|
681
|
+
raise JSON::ParserError unless document.is_a?(Hash)
|
|
682
|
+
|
|
683
|
+
document
|
|
621
684
|
rescue JSON::ParserError
|
|
622
|
-
raise Error, 'The authorization server did not answer with JSON'
|
|
685
|
+
raise Error, 'The authorization server did not answer with a JSON object'
|
|
623
686
|
end
|
|
624
687
|
|
|
625
688
|
def connection(url)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class MCP
|
|
5
|
+
# The servers that could not be reached lately, for this whole process.
|
|
6
|
+
# A server waits this long, or as long as its Retry-After asks, before
|
|
7
|
+
# anyone tries it again, since a server that never answers would
|
|
8
|
+
# otherwise hold up every request, and each request in a job builds its
|
|
9
|
+
# MCP objects anew. A server that answers that it is unavailable, with
|
|
10
|
+
# HTTP 429 or a 5xx status, counts as one that can't be reached; one
|
|
11
|
+
# that refuses the credentials or the request does not, since trying
|
|
12
|
+
# again won't fix that.
|
|
13
|
+
module Outages # :nodoc:
|
|
14
|
+
BACKOFF = 60
|
|
15
|
+
PARTLY_RUN = 'The call may have partly run, so check before trying it again.'
|
|
16
|
+
|
|
17
|
+
# The tool error of a call the server failed, which a chat gives the
|
|
18
|
+
# model like any tool error.
|
|
19
|
+
class Failure < Hash # :nodoc:
|
|
20
|
+
end
|
|
21
|
+
UNAVAILABLE = [ConnectionError, RateLimitError, ServerError, ServiceUnavailableError, OverloadedError,
|
|
22
|
+
Stdio::Unavailable].freeze
|
|
23
|
+
|
|
24
|
+
@lock = Mutex.new
|
|
25
|
+
@retry_at = {}
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def tools(server)
|
|
29
|
+
return [] if down?(server)
|
|
30
|
+
|
|
31
|
+
server.tools
|
|
32
|
+
rescue RubyLLM::Error => e
|
|
33
|
+
raise unless unavailable?(e)
|
|
34
|
+
|
|
35
|
+
report(server, e) if record(server, backoff(e))
|
|
36
|
+
[]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Yields to call one of +server+'s tools, and returns the tool error
|
|
40
|
+
# when the server fails the call, or right away while the server
|
|
41
|
+
# sits out. A handler declared on the server's class or on MCP::Tool
|
|
42
|
+
# takes the error instead, but not one every Tool inherits, so a
|
|
43
|
+
# catch-all on RubyLLM::Tool keeps the outage handling. Checking on
|
|
44
|
+
# a task still raises, since the task may still be running.
|
|
45
|
+
def calling(server, tool, input)
|
|
46
|
+
return yield if input&.key?('task')
|
|
47
|
+
return Failure[error: "The #{server.name} MCP server can't be reached right now"] if down?(server)
|
|
48
|
+
|
|
49
|
+
begin
|
|
50
|
+
yield
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
raise if server.class.rescue_handler_for(e) || tool.class.rescue_handler_for(e, within: MCP::Tool)
|
|
53
|
+
|
|
54
|
+
failed_call(server, tool, e) || raise
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Returns the tool error for a call +server+ failed, or +nil+ for an
|
|
59
|
+
# error the call should raise: one that is not the server's, or a
|
|
60
|
+
# refusal such as missing credentials or an unknown endpoint. A
|
|
61
|
+
# server that is unavailable sits out until its backoff ends. The
|
|
62
|
+
# model reads what the server wrote, even with redacted error
|
|
63
|
+
# messages, since it is the one deciding what to do next.
|
|
64
|
+
def failed_call(server, tool, error)
|
|
65
|
+
return unless error.is_a?(Error) || (error.is_a?(RubyLLM::Error) && unavailable?(error))
|
|
66
|
+
return if refused_status?(error.response&.status)
|
|
67
|
+
|
|
68
|
+
if unavailable?(error)
|
|
69
|
+
report(server, error) if record(server, backoff(error))
|
|
70
|
+
server.send(:forget_tools)
|
|
71
|
+
end
|
|
72
|
+
Failure[error: failure_text(server, tool, error)]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def down?(server)
|
|
76
|
+
return false if server.tools_listed?
|
|
77
|
+
|
|
78
|
+
key = key(server)
|
|
79
|
+
@lock.synchronize { (@retry_at[key] || 0) > monotonic_now }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def clear
|
|
83
|
+
@lock.synchronize { @retry_at.clear }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# Names the error by its class and status, never its message, since
|
|
89
|
+
# a connection error's message can carry the server's host or URL.
|
|
90
|
+
def report(server, error)
|
|
91
|
+
status = error.response&.status
|
|
92
|
+
RubyLLM.logger.warn("MCP server #{server.name} is unreachable, so its tools sit out: " \
|
|
93
|
+
"#{[error.class, ("HTTP #{status}" if status)].compact.join(', ')}")
|
|
94
|
+
payload = { server: server.name, error: error.class.name, status: }.compact
|
|
95
|
+
RubyLLM.instrument('mcp_unreachable.ruby_llm', payload, config: server.send(:config))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# What the server wrote, or the error's own message when it wrote
|
|
99
|
+
# nothing, as an empty message would tell the model nothing.
|
|
100
|
+
def failure_text(server, tool, error)
|
|
101
|
+
detail = [error.provider_message, error.message].find { |candidate| candidate.to_s.strip != '' }
|
|
102
|
+
text = "The #{server.name} MCP server failed: #{detail}"
|
|
103
|
+
partly_run?(tool, error) ? "#{text.delete_suffix('.')}. #{PARTLY_RUN}" : text
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A call that broke off, or that the server failed with a 5xx
|
|
107
|
+
# status, may have done some of its work before it stopped, unless
|
|
108
|
+
# the tool says it has no effect or the same call again has none.
|
|
109
|
+
def partly_run?(tool, error)
|
|
110
|
+
return false if tool.read_only? || (tool.idempotent? && !tool.destructive?)
|
|
111
|
+
|
|
112
|
+
unavailable?(error) && error.response&.status != 429
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def unavailable?(error)
|
|
116
|
+
UNAVAILABLE.any? { |kind| error.is_a?(kind) } || unavailable_status?(error.response&.status)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def refused_status?(status)
|
|
120
|
+
(400..499).cover?(status) && !unavailable_status?(status)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def unavailable_status?(status)
|
|
124
|
+
status == 429 || (500..599).cover?(status)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def backoff(error)
|
|
128
|
+
value = error.response&.headers&.[]('Retry-After').to_s
|
|
129
|
+
seconds = Float(value, exception: false) || seconds_until(value)
|
|
130
|
+
seconds&.positive? ? seconds : BACKOFF
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def seconds_until(date)
|
|
134
|
+
Time.httpdate(date) - Time.now
|
|
135
|
+
rescue ArgumentError
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Returns whether this caller started the backoff.
|
|
140
|
+
def record(server, backoff)
|
|
141
|
+
key = key(server)
|
|
142
|
+
@lock.synchronize do
|
|
143
|
+
now = monotonic_now
|
|
144
|
+
@retry_at.delete_if { |_key, retry_at| retry_at <= now }
|
|
145
|
+
next false if @retry_at.key?(key)
|
|
146
|
+
|
|
147
|
+
@retry_at[key] = now + backoff
|
|
148
|
+
true
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Settings, not classes, since RubyLLM.mcp builds a class per call.
|
|
153
|
+
# Settings that depend on runtime state can differ per object, and so
|
|
154
|
+
# can the connection settings of an object's context, so one tenant's
|
|
155
|
+
# server never sidelines another's.
|
|
156
|
+
def key(server)
|
|
157
|
+
settings = server.class
|
|
158
|
+
endpoint = [settings.url, *settings.command]
|
|
159
|
+
return server if settings.transport || !endpoint.compact.all?(String)
|
|
160
|
+
|
|
161
|
+
config = server.send(:config)
|
|
162
|
+
ssl = Transport::Connection.ssl_snapshot(config.ssl) if config.ssl
|
|
163
|
+
[*endpoint, config.http_proxy, ssl, config.faraday_adapter, config.request_timeout]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def monotonic_now
|
|
167
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|