ruby_llm 1.15.0 → 2.0.0.rc1
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/.rdoc_options +25 -0
- data/README.md +87 -33
- data/exe/ruby_llm +8 -0
- data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +0 -1
- data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +3 -43
- data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +11 -3
- data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +8 -0
- data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +4 -4
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +2 -2
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +1 -1
- 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.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +6 -4
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +2 -2
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +5 -6
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +2 -2
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +5 -5
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +2 -2
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +1 -1
- 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.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +6 -4
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +4 -6
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +2 -2
- data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +5 -6
- data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +2 -2
- data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +3 -3
- data/lib/generators/ruby_llm/generator_helpers.rb +106 -62
- data/lib/generators/ruby_llm/install/install_generator.rb +3 -11
- data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +2 -0
- data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +14 -6
- data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +117 -0
- data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +0 -8
- data/lib/generators/ruby_llm/provider/cli.rb +175 -0
- data/lib/generators/ruby_llm/provider/scaffold.rb +323 -0
- data/lib/generators/ruby_llm/provider/templates/core/provider.rb.erb +37 -0
- data/lib/generators/ruby_llm/provider/templates/core/provider_spec.rb.erb +34 -0
- data/lib/generators/ruby_llm/provider/templates/gem/archspec.rb.erb +14 -0
- data/lib/generators/ruby_llm/provider/templates/gem/bin/console.erb +15 -0
- data/lib/generators/ruby_llm/provider/templates/gem/bin/setup.erb +5 -0
- data/lib/generators/ruby_llm/provider/templates/gem/chat_schema_spec.rb.erb +30 -0
- data/lib/generators/ruby_llm/provider/templates/gem/chat_spec.rb.erb +35 -0
- data/lib/generators/ruby_llm/provider/templates/gem/chat_streaming_spec.rb.erb +22 -0
- data/lib/generators/ruby_llm/provider/templates/gem/chat_tools_spec.rb.erb +30 -0
- data/lib/generators/ruby_llm/provider/templates/gem/ci.yml.erb +32 -0
- data/lib/generators/ruby_llm/provider/templates/gem/embedding_spec.rb.erb +49 -0
- data/lib/generators/ruby_llm/provider/templates/gem/env.erb +2 -0
- data/lib/generators/ruby_llm/provider/templates/gem/fixtures_gitkeep.erb +1 -0
- data/lib/generators/ruby_llm/provider/templates/gem/flayignore.erb +1 -0
- data/lib/generators/ruby_llm/provider/templates/gem/gemfile.erb +25 -0
- data/lib/generators/ruby_llm/provider/templates/gem/gemspec.erb +28 -0
- data/lib/generators/ruby_llm/provider/templates/gem/gitignore.erb +7 -0
- data/lib/generators/ruby_llm/provider/templates/gem/gitleaks.yml.erb +22 -0
- data/lib/generators/ruby_llm/provider/templates/gem/image_spec.rb.erb +23 -0
- data/lib/generators/ruby_llm/provider/templates/gem/license.erb +21 -0
- data/lib/generators/ruby_llm/provider/templates/gem/models.rb.erb +19 -0
- data/lib/generators/ruby_llm/provider/templates/gem/models_spec.rb.erb +17 -0
- data/lib/generators/ruby_llm/provider/templates/gem/moderation_spec.rb.erb +22 -0
- data/lib/generators/ruby_llm/provider/templates/gem/overcommit.yml.erb +31 -0
- data/lib/generators/ruby_llm/provider/templates/gem/provider.rb.erb +52 -0
- data/lib/generators/ruby_llm/provider/templates/gem/provider_spec.rb.erb +38 -0
- data/lib/generators/ruby_llm/provider/templates/gem/rakefile.erb +38 -0
- data/lib/generators/ruby_llm/provider/templates/gem/readme.md.erb +50 -0
- data/lib/generators/ruby_llm/provider/templates/gem/release.yml.erb +36 -0
- data/lib/generators/ruby_llm/provider/templates/gem/rerank_spec.rb.erb +23 -0
- data/lib/generators/ruby_llm/provider/templates/gem/rspec.erb +2 -0
- data/lib/generators/ruby_llm/provider/templates/gem/rubocop.yml.erb +29 -0
- data/lib/generators/ruby_llm/provider/templates/gem/rubyllm_configuration.rb.erb +14 -0
- data/lib/generators/ruby_llm/provider/templates/gem/spec_helper.rb.erb +26 -0
- data/lib/generators/ruby_llm/provider/templates/gem/speech_spec.rb.erb +25 -0
- data/lib/generators/ruby_llm/provider/templates/gem/vcr_configuration.rb.erb +16 -0
- data/lib/generators/ruby_llm/provider/templates/gem/video_spec.rb.erb +27 -0
- data/lib/generators/ruby_llm/schema/schema_generator.rb +5 -1
- data/lib/generators/ruby_llm/schema/templates/schema.rb.tt +1 -1
- data/lib/generators/ruby_llm/tool/templates/tailwind/tool_call.html.erb.tt +13 -0
- data/lib/generators/ruby_llm/tool/templates/tailwind/tool_result.html.erb.tt +21 -0
- data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +3 -3
- data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +7 -12
- data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +5 -2
- data/lib/generators/ruby_llm/tool/tool_generator.rb +25 -59
- data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +461 -0
- data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +100 -0
- data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +215 -0
- data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +660 -0
- data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +222 -0
- data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +13 -0
- data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +167 -0
- data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +344 -0
- data/lib/ruby_llm/accounting/usage.rb +245 -0
- data/lib/ruby_llm/active_record/acts_as.rb +94 -136
- data/lib/ruby_llm/active_record/attachment_helpers.rb +180 -0
- data/lib/ruby_llm/active_record/batch.rb +97 -0
- data/lib/ruby_llm/active_record/chat_methods.rb +823 -305
- data/lib/ruby_llm/active_record/message_methods.rb +119 -75
- data/lib/ruby_llm/active_record/model.rb +135 -0
- data/lib/ruby_llm/active_record/payload_helpers.rb +1 -2
- data/lib/ruby_llm/active_record/tool_call.rb +33 -0
- data/lib/ruby_llm/active_record/usage.rb +61 -0
- data/lib/ruby_llm/agent.rb +1065 -150
- data/lib/ruby_llm/aliases.json +338 -167
- data/lib/ruby_llm/attachment.rb +217 -61
- data/lib/ruby_llm/batch.rb +432 -0
- data/lib/ruby_llm/cached_content.rb +112 -0
- data/lib/ruby_llm/chat/tool_concurrency.rb +111 -0
- data/lib/ruby_llm/chat.rb +1208 -150
- data/lib/ruby_llm/chunk.rb +10 -0
- data/lib/ruby_llm/citation.rb +105 -0
- data/lib/ruby_llm/configuration.rb +274 -24
- data/lib/ruby_llm/context.rb +128 -6
- data/lib/ruby_llm/cost.rb +217 -80
- data/lib/ruby_llm/downloaded_file.rb +33 -0
- data/lib/ruby_llm/embedding.rb +141 -7
- data/lib/ruby_llm/embedding_request.rb +53 -0
- data/lib/ruby_llm/error.rb +161 -89
- data/lib/ruby_llm/fallback.rb +133 -0
- data/lib/ruby_llm/files/mime_type.rb +97 -0
- data/lib/ruby_llm/image.rb +155 -32
- data/lib/ruby_llm/message.rb +233 -54
- data/lib/ruby_llm/model/modalities.rb +17 -4
- data/lib/ruby_llm/model/pricing.rb +43 -14
- data/lib/ruby_llm/model/pricing_category.rb +103 -14
- data/lib/ruby_llm/model/pricing_tier.rb +66 -15
- data/lib/ruby_llm/model.rb +244 -2
- data/lib/ruby_llm/models/aliases.rb +41 -0
- data/lib/ruby_llm/models/registry.rb +165 -0
- data/lib/ruby_llm/models/schema.rb +99 -0
- data/lib/ruby_llm/models.json +70380 -33380
- data/lib/ruby_llm/models.rb +528 -201
- data/lib/ruby_llm/moderation.rb +139 -26
- data/lib/ruby_llm/ocr.rb +112 -0
- data/lib/ruby_llm/prompt.rb +79 -0
- data/lib/ruby_llm/protocol/binary_streaming.rb +65 -0
- data/lib/ruby_llm/protocol/stream_accumulator.rb +214 -0
- data/lib/ruby_llm/protocol/streaming.rb +230 -0
- data/lib/ruby_llm/protocol.rb +662 -0
- data/lib/ruby_llm/protocols/anthropic/batches.rb +73 -0
- data/lib/ruby_llm/protocols/anthropic/chat.rb +540 -0
- data/lib/ruby_llm/protocols/anthropic/embeddings.rb +14 -0
- data/lib/ruby_llm/protocols/anthropic/files.rb +38 -0
- data/lib/ruby_llm/protocols/anthropic/media.rb +141 -0
- data/lib/ruby_llm/protocols/anthropic/models.rb +129 -0
- data/lib/ruby_llm/protocols/anthropic/streaming.rb +166 -0
- data/lib/ruby_llm/{providers → protocols}/anthropic/tools.rb +43 -34
- data/lib/ruby_llm/protocols/anthropic.rb +100 -0
- data/lib/ruby_llm/protocols/azure/files.rb +16 -0
- data/lib/ruby_llm/protocols/bedrock/async_videos.rb +109 -0
- data/lib/ruby_llm/protocols/bedrock/batches.rb +129 -0
- data/lib/ruby_llm/protocols/bedrock/files.rb +110 -0
- data/lib/ruby_llm/protocols/bedrock/guardrails.rb +122 -0
- data/lib/ruby_llm/protocols/bedrock/rerank.rb +95 -0
- data/lib/ruby_llm/protocols/chat_completions/batches.rb +32 -0
- data/lib/ruby_llm/protocols/chat_completions/chat.rb +493 -0
- data/lib/ruby_llm/protocols/chat_completions/embedding_batches.rb +35 -0
- data/lib/ruby_llm/protocols/chat_completions/embeddings.rb +60 -0
- data/lib/ruby_llm/protocols/chat_completions/images.rb +127 -0
- data/lib/ruby_llm/protocols/chat_completions/media.rb +121 -0
- data/lib/ruby_llm/protocols/chat_completions/models.rb +39 -0
- data/lib/ruby_llm/protocols/chat_completions/moderation.rb +52 -0
- data/lib/ruby_llm/protocols/chat_completions/rerank.rb +56 -0
- data/lib/ruby_llm/protocols/chat_completions/speech.rb +40 -0
- data/lib/ruby_llm/protocols/chat_completions/streaming.rb +69 -0
- data/lib/ruby_llm/{providers/openai → protocols/chat_completions}/tools.rb +17 -17
- data/lib/ruby_llm/protocols/chat_completions/transcription.rb +150 -0
- data/lib/ruby_llm/protocols/chat_completions.rb +21 -0
- data/lib/ruby_llm/protocols/cohere/batch_requests.rb +75 -0
- data/lib/ruby_llm/protocols/cohere/batches.rb +98 -0
- data/lib/ruby_llm/protocols/cohere/chat.rb +227 -0
- data/lib/ruby_llm/protocols/cohere/datasets.rb +102 -0
- data/lib/ruby_llm/protocols/cohere/embeddings.rb +68 -0
- data/lib/ruby_llm/protocols/cohere/media.rb +77 -0
- data/lib/ruby_llm/protocols/cohere/models.rb +92 -0
- data/lib/ruby_llm/protocols/cohere/ocr.rb +63 -0
- data/lib/ruby_llm/protocols/cohere/rerank.rb +52 -0
- data/lib/ruby_llm/protocols/cohere/streaming.rb +105 -0
- data/lib/ruby_llm/protocols/cohere/tokenization.rb +22 -0
- data/lib/ruby_llm/protocols/cohere/tools.rb +132 -0
- data/lib/ruby_llm/protocols/cohere/transcription.rb +41 -0
- data/lib/ruby_llm/protocols/cohere.rb +21 -0
- data/lib/ruby_llm/protocols/converse/batches.rb +55 -0
- data/lib/ruby_llm/protocols/converse/chat.rb +685 -0
- data/lib/ruby_llm/protocols/converse/media.rb +178 -0
- data/lib/ruby_llm/protocols/converse/streaming.rb +424 -0
- data/lib/ruby_llm/protocols/converse.rb +54 -0
- data/lib/ruby_llm/protocols/deepgram/models.rb +74 -0
- data/lib/ruby_llm/protocols/deepgram/speech.rb +93 -0
- data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +89 -0
- data/lib/ruby_llm/protocols/deepgram/transcription.rb +96 -0
- data/lib/ruby_llm/protocols/deepgram.rb +19 -0
- data/lib/ruby_llm/protocols/deepseek/files.rb +31 -0
- data/lib/ruby_llm/protocols/elevenlabs/assets.rb +36 -0
- data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +74 -0
- data/lib/ruby_llm/protocols/elevenlabs/flows/media.rb +42 -0
- data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +93 -0
- data/lib/ruby_llm/protocols/elevenlabs/flows.rb +14 -0
- data/lib/ruby_llm/protocols/elevenlabs/models.rb +64 -0
- data/lib/ruby_llm/protocols/elevenlabs/speech.rb +67 -0
- data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +127 -0
- data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +61 -0
- data/lib/ruby_llm/protocols/elevenlabs.rb +15 -0
- data/lib/ruby_llm/protocols/files.rb +119 -0
- data/lib/ruby_llm/protocols/gemini/batches.rb +162 -0
- data/lib/ruby_llm/protocols/gemini/caches.rb +59 -0
- data/lib/ruby_llm/protocols/gemini/chat.rb +453 -0
- data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +86 -0
- data/lib/ruby_llm/protocols/gemini/embeddings.rb +70 -0
- data/lib/ruby_llm/protocols/gemini/file_transcription.rb +32 -0
- data/lib/ruby_llm/protocols/gemini/files.rb +115 -0
- data/lib/ruby_llm/protocols/gemini/images.rb +183 -0
- data/lib/ruby_llm/protocols/gemini/live_transcription.rb +140 -0
- data/lib/ruby_llm/{providers → protocols}/gemini/media.rb +33 -20
- data/lib/ruby_llm/protocols/gemini/models.rb +71 -0
- data/lib/ruby_llm/protocols/gemini/speech.rb +56 -0
- data/lib/ruby_llm/protocols/gemini/streaming.rb +96 -0
- data/lib/ruby_llm/protocols/gemini/tools.rb +157 -0
- data/lib/ruby_llm/{providers → protocols}/gemini/transcription.rb +22 -22
- data/lib/ruby_llm/protocols/gemini/videos.rb +103 -0
- data/lib/ruby_llm/protocols/gemini.rb +35 -0
- data/lib/ruby_llm/protocols/gpustack/responses.rb +111 -0
- data/lib/ruby_llm/protocols/gpustack/tokenization.rb +22 -0
- data/lib/ruby_llm/protocols/gpustack/videos.rb +96 -0
- data/lib/ruby_llm/protocols/interactions/chat.rb +145 -0
- data/lib/ruby_llm/protocols/interactions/content.rb +90 -0
- data/lib/ruby_llm/protocols/interactions/streaming.rb +91 -0
- data/lib/ruby_llm/protocols/interactions/tools.rb +51 -0
- data/lib/ruby_llm/protocols/interactions/transcription.rb +58 -0
- data/lib/ruby_llm/protocols/interactions.rb +29 -0
- data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +51 -0
- data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +111 -0
- data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +50 -0
- data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +103 -0
- data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +33 -0
- data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +44 -0
- data/lib/ruby_llm/protocols/invoke_model.rb +57 -0
- data/lib/ruby_llm/protocols/mistral/content.rb +49 -0
- data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +160 -0
- data/lib/ruby_llm/protocols/mistral/conversations/images.rb +43 -0
- data/lib/ruby_llm/protocols/mistral/conversations/streaming.rb +83 -0
- data/lib/ruby_llm/protocols/mistral/conversations.rb +30 -0
- data/lib/ruby_llm/protocols/mistral/files.rb +36 -0
- data/lib/ruby_llm/protocols/mistral/multi_completion.rb +160 -0
- data/lib/ruby_llm/protocols/openai/batches.rb +126 -0
- data/lib/ruby_llm/protocols/openai/files.rb +42 -0
- data/lib/ruby_llm/protocols/openrouter/batches.rb +147 -0
- data/lib/ruby_llm/protocols/openrouter/files.rb +24 -0
- data/lib/ruby_llm/protocols/openrouter/responses.rb +53 -0
- data/lib/ruby_llm/protocols/openrouter/transcription.rb +51 -0
- data/lib/ruby_llm/protocols/perplexity/files.rb +48 -0
- data/lib/ruby_llm/protocols/perplexity/router.rb +59 -0
- data/lib/ruby_llm/protocols/responses/approvals.rb +32 -0
- data/lib/ruby_llm/protocols/responses/batches.rb +32 -0
- data/lib/ruby_llm/protocols/responses/chat.rb +476 -0
- data/lib/ruby_llm/protocols/responses/compaction.rb +29 -0
- data/lib/ruby_llm/protocols/responses/media.rb +61 -0
- data/lib/ruby_llm/protocols/responses/streaming.rb +117 -0
- data/lib/ruby_llm/protocols/responses/token_counting.rb +26 -0
- data/lib/ruby_llm/protocols/responses/tools.rb +39 -0
- data/lib/ruby_llm/protocols/responses.rb +35 -0
- data/lib/ruby_llm/protocols/vertexai/batch_prediction.rb +155 -0
- data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +85 -0
- data/lib/ruby_llm/protocols/vertexai/embedding_prediction/results.rb +74 -0
- data/lib/ruby_llm/protocols/vertexai/embedding_prediction.rb +56 -0
- data/lib/ruby_llm/protocols/vertexai/files.rb +101 -0
- data/lib/ruby_llm/protocols/vertexai/ranking.rb +69 -0
- data/lib/ruby_llm/protocols/vertexai/research.rb +193 -0
- data/lib/ruby_llm/protocols/xai/files.rb +30 -0
- data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +120 -0
- data/lib/ruby_llm/protocols/xai/tokenization.rb +23 -0
- data/lib/ruby_llm/provider.rb +565 -124
- data/lib/ruby_llm/providers/anthropic/capabilities.rb +5 -7
- data/lib/ruby_llm/providers/anthropic.rb +4 -6
- data/lib/ruby_llm/providers/azure/audio.rb +18 -0
- data/lib/ruby_llm/providers/azure/capabilities.rb +16 -0
- data/lib/ruby_llm/providers/azure/chat.rb +2 -9
- data/lib/ruby_llm/providers/azure/chat_completions/batches.rb +29 -0
- data/lib/ruby_llm/providers/azure/chat_completions.rb +80 -0
- data/lib/ruby_llm/providers/azure/cohere.rb +33 -0
- data/lib/ruby_llm/providers/azure/embeddings.rb +3 -2
- data/lib/ruby_llm/providers/azure/images.rb +22 -0
- data/lib/ruby_llm/providers/azure/media.rb +6 -15
- data/lib/ruby_llm/providers/azure/models.rb +35 -0
- data/lib/ruby_llm/providers/azure/responses.rb +26 -0
- data/lib/ruby_llm/providers/azure/videos.rb +64 -0
- data/lib/ruby_llm/providers/azure.rb +77 -78
- data/lib/ruby_llm/providers/bedrock/auth.rb +61 -41
- data/lib/ruby_llm/providers/bedrock/capabilities.rb +18 -0
- data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +39 -0
- data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +23 -0
- data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +24 -0
- data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +96 -0
- data/lib/ruby_llm/providers/bedrock/mantle.rb +57 -0
- data/lib/ruby_llm/providers/bedrock/models.rb +194 -42
- data/lib/ruby_llm/providers/bedrock.rb +217 -46
- data/lib/ruby_llm/providers/cohere.rb +31 -0
- data/lib/ruby_llm/providers/deepgram.rb +37 -0
- data/lib/ruby_llm/providers/deepseek/capabilities.rb +4 -8
- data/lib/ruby_llm/providers/deepseek/chat.rb +56 -0
- data/lib/ruby_llm/providers/deepseek/responses.rb +68 -0
- data/lib/ruby_llm/providers/deepseek.rb +9 -2
- data/lib/ruby_llm/providers/elevenlabs.rb +35 -0
- data/lib/ruby_llm/providers/gemini/capabilities.rb +8 -107
- data/lib/ruby_llm/providers/gemini.rb +15 -8
- data/lib/ruby_llm/providers/gpustack/chat.rb +2 -9
- data/lib/ruby_llm/providers/gpustack/embeddings.rb +28 -0
- data/lib/ruby_llm/providers/gpustack/media.rb +17 -16
- data/lib/ruby_llm/providers/gpustack/models.rb +72 -60
- data/lib/ruby_llm/providers/gpustack/speech.rb +15 -0
- data/lib/ruby_llm/providers/gpustack/transcription.rb +29 -0
- data/lib/ruby_llm/providers/gpustack.rb +35 -11
- data/lib/ruby_llm/providers/mistral/capabilities.rb +7 -155
- data/lib/ruby_llm/providers/mistral/chat.rb +37 -61
- data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +120 -0
- data/lib/ruby_llm/providers/mistral/chat_completions.rb +21 -0
- data/lib/ruby_llm/providers/mistral/conversations.rb +12 -0
- data/lib/ruby_llm/providers/mistral/embeddings.rb +6 -4
- data/lib/ruby_llm/providers/mistral/media.rb +43 -0
- data/lib/ruby_llm/providers/mistral/models.rb +57 -21
- data/lib/ruby_llm/providers/mistral/ocr.rb +47 -0
- data/lib/ruby_llm/providers/mistral/speech.rb +51 -0
- data/lib/ruby_llm/providers/mistral/transcription.rb +62 -0
- data/lib/ruby_llm/providers/mistral.rb +18 -6
- data/lib/ruby_llm/providers/ollama/chat.rb +9 -8
- data/lib/ruby_llm/providers/ollama/media.rb +6 -15
- data/lib/ruby_llm/providers/ollama/models.rb +50 -9
- data/lib/ruby_llm/providers/ollama.rb +9 -8
- data/lib/ruby_llm/providers/ollama_cloud/models.rb +14 -0
- data/lib/ruby_llm/providers/ollama_cloud.rb +40 -0
- data/lib/ruby_llm/providers/openai/capabilities.rb +54 -259
- data/lib/ruby_llm/providers/openai/models.rb +23 -23
- data/lib/ruby_llm/providers/openai/responses.rb +13 -0
- data/lib/ruby_llm/providers/openai.rb +92 -11
- data/lib/ruby_llm/providers/openrouter/chat.rb +130 -104
- data/lib/ruby_llm/providers/openrouter/embeddings.rb +51 -0
- data/lib/ruby_llm/providers/openrouter/images.rb +44 -43
- data/lib/ruby_llm/providers/openrouter/media.rb +34 -0
- data/lib/ruby_llm/providers/openrouter/models.rb +50 -11
- data/lib/ruby_llm/providers/openrouter/speech.rb +32 -0
- data/lib/ruby_llm/providers/openrouter/streaming.rb +31 -38
- data/lib/ruby_llm/providers/openrouter/videos.rb +81 -0
- data/lib/ruby_llm/providers/openrouter.rb +78 -20
- data/lib/ruby_llm/providers/perplexity/chat.rb +4 -0
- data/lib/ruby_llm/providers/perplexity/embeddings.rb +32 -0
- data/lib/ruby_llm/providers/perplexity/media.rb +46 -0
- data/lib/ruby_llm/providers/perplexity/models.rb +80 -13
- data/lib/ruby_llm/providers/perplexity.rb +29 -21
- data/lib/ruby_llm/providers/vertexai/anthropic/batches.rb +52 -0
- data/lib/ruby_llm/providers/vertexai/anthropic.rb +34 -0
- data/lib/ruby_llm/providers/vertexai/capabilities.rb +19 -0
- data/lib/ruby_llm/providers/vertexai/chat_completions/batches.rb +54 -0
- data/lib/ruby_llm/providers/vertexai/chat_completions.rb +15 -0
- data/lib/ruby_llm/providers/vertexai/embed_content.rb +42 -0
- data/lib/ruby_llm/providers/vertexai/embeddings.rb +22 -7
- data/lib/ruby_llm/providers/vertexai/gemini/batches.rb +42 -0
- data/lib/ruby_llm/providers/vertexai/gemini.rb +69 -0
- data/lib/ruby_llm/providers/vertexai/live_transcription.rb +24 -0
- data/lib/ruby_llm/providers/vertexai/mistral.rb +28 -0
- data/lib/ruby_llm/providers/vertexai/models.rb +145 -43
- data/lib/ruby_llm/providers/vertexai/transcription.rb +49 -4
- data/lib/ruby_llm/providers/vertexai/videos.rb +61 -0
- data/lib/ruby_llm/providers/vertexai.rb +164 -17
- data/lib/ruby_llm/providers/xai/capabilities.rb +18 -0
- data/lib/ruby_llm/providers/xai/chat.rb +10 -0
- data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +108 -0
- data/lib/ruby_llm/providers/xai/chat_completions.rb +19 -0
- data/lib/ruby_llm/providers/xai/images.rb +91 -0
- data/lib/ruby_llm/providers/xai/models.rb +32 -48
- data/lib/ruby_llm/providers/xai/reported_cost.rb +18 -0
- data/lib/ruby_llm/providers/xai/responses.rb +52 -0
- data/lib/ruby_llm/providers/xai/speech.rb +45 -0
- data/lib/ruby_llm/providers/xai/transcription.rb +48 -0
- data/lib/ruby_llm/providers/xai/videos.rb +87 -0
- data/lib/ruby_llm/providers/xai.rb +17 -7
- data/lib/ruby_llm/railtie.rb +11 -16
- data/lib/ruby_llm/rerank.rb +105 -0
- data/lib/ruby_llm/research_job.rb +241 -0
- data/lib/ruby_llm/search_results.rb +68 -0
- data/lib/ruby_llm/server_tool_call.rb +73 -0
- data/lib/ruby_llm/speech.rb +159 -0
- data/lib/ruby_llm/speech_chunk.rb +33 -0
- data/lib/ruby_llm/support/deprecator.rb +22 -0
- data/lib/ruby_llm/support/inspectable.rb +49 -0
- data/lib/ruby_llm/support/instrumentation.rb +41 -0
- data/lib/ruby_llm/support/utils.rb +147 -0
- data/lib/ruby_llm/thinking.rb +127 -20
- data/lib/ruby_llm/tokenization.rb +59 -0
- data/lib/ruby_llm/tokens.rb +103 -33
- data/lib/ruby_llm/tool.rb +266 -91
- data/lib/ruby_llm/tool_call.rb +36 -3
- data/lib/ruby_llm/tools/server_tools.rb +109 -0
- data/lib/ruby_llm/transcription/wav_audio.rb +62 -0
- data/lib/ruby_llm/transcription.rb +139 -13
- data/lib/ruby_llm/transcription_chunk.rb +68 -0
- data/lib/ruby_llm/transport/connection.rb +193 -0
- data/lib/ruby_llm/transport/error_middleware.rb +131 -0
- data/lib/ruby_llm/transport/usage_middleware.rb +28 -0
- data/lib/ruby_llm/transport/websocket_connection.rb +220 -0
- data/lib/ruby_llm/uploaded_file.rb +144 -0
- data/lib/ruby_llm/version.rb +2 -1
- data/lib/ruby_llm/video.rb +136 -0
- data/lib/ruby_llm/video_job.rb +150 -0
- data/lib/ruby_llm/workflow.rb +91 -0
- data/lib/ruby_llm.rb +385 -4
- data/lib/tasks/ruby_llm.rake +21 -16
- data/skills/rubyllm/SKILL.md +81 -0
- data/skills/rubyllm/agents/openai.yaml +4 -0
- metadata +340 -92
- data/lib/generators/ruby_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +0 -9
- data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +0 -39
- data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +0 -21
- data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +0 -3
- data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +0 -3
- data/lib/generators/ruby_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +0 -19
- data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +0 -50
- data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +0 -7
- data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +0 -49
- data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +0 -145
- data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +0 -122
- data/lib/generators/ruby_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +0 -15
- data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +0 -49
- data/lib/ruby_llm/active_record/acts_as_legacy.rb +0 -530
- data/lib/ruby_llm/active_record/model_methods.rb +0 -82
- data/lib/ruby_llm/active_record/tool_call_methods.rb +0 -18
- data/lib/ruby_llm/aliases.rb +0 -38
- data/lib/ruby_llm/connection.rb +0 -130
- data/lib/ruby_llm/content.rb +0 -77
- data/lib/ruby_llm/mime_type.rb +0 -71
- data/lib/ruby_llm/model/info.rb +0 -130
- data/lib/ruby_llm/models_schema.json +0 -171
- data/lib/ruby_llm/providers/anthropic/chat.rb +0 -257
- data/lib/ruby_llm/providers/anthropic/content.rb +0 -44
- data/lib/ruby_llm/providers/anthropic/embeddings.rb +0 -20
- data/lib/ruby_llm/providers/anthropic/media.rb +0 -92
- data/lib/ruby_llm/providers/anthropic/models.rb +0 -57
- data/lib/ruby_llm/providers/anthropic/streaming.rb +0 -69
- data/lib/ruby_llm/providers/bedrock/chat.rb +0 -403
- data/lib/ruby_llm/providers/bedrock/media.rb +0 -90
- data/lib/ruby_llm/providers/bedrock/streaming.rb +0 -322
- data/lib/ruby_llm/providers/gemini/chat.rb +0 -543
- data/lib/ruby_llm/providers/gemini/embeddings.rb +0 -37
- data/lib/ruby_llm/providers/gemini/images.rb +0 -47
- data/lib/ruby_llm/providers/gemini/models.rb +0 -38
- data/lib/ruby_llm/providers/gemini/streaming.rb +0 -96
- data/lib/ruby_llm/providers/gemini/tools.rb +0 -232
- data/lib/ruby_llm/providers/gpustack/capabilities.rb +0 -20
- data/lib/ruby_llm/providers/ollama/capabilities.rb +0 -20
- data/lib/ruby_llm/providers/openai/chat.rb +0 -221
- data/lib/ruby_llm/providers/openai/embeddings.rb +0 -33
- data/lib/ruby_llm/providers/openai/images.rb +0 -90
- data/lib/ruby_llm/providers/openai/media.rb +0 -84
- data/lib/ruby_llm/providers/openai/moderation.rb +0 -34
- data/lib/ruby_llm/providers/openai/streaming.rb +0 -53
- data/lib/ruby_llm/providers/openai/temperature.rb +0 -28
- data/lib/ruby_llm/providers/openai/transcription.rb +0 -70
- data/lib/ruby_llm/providers/perplexity/capabilities.rb +0 -72
- data/lib/ruby_llm/providers/vertexai/chat.rb +0 -14
- data/lib/ruby_llm/providers/vertexai/streaming.rb +0 -14
- data/lib/ruby_llm/stream_accumulator.rb +0 -203
- data/lib/ruby_llm/streaming.rb +0 -175
- data/lib/ruby_llm/utils.rb +0 -91
- data/lib/tasks/models.rake +0 -565
- data/lib/tasks/release.rake +0 -67
- data/lib/tasks/vcr.rake +0 -124
data/lib/ruby_llm/chat.rb
CHANGED
|
@@ -1,220 +1,938 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
3
5
|
module RubyLLM
|
|
4
|
-
#
|
|
5
|
-
|
|
6
|
+
# A Chat is a conversation with an AI model. It holds the messages
|
|
7
|
+
# exchanged so far, the tools the model may call, and the settings applied
|
|
8
|
+
# to each request. RubyLLM.chat is the usual way to create one.
|
|
9
|
+
#
|
|
10
|
+
# chat = RubyLLM.chat
|
|
11
|
+
# chat.ask "What's the best way to learn Ruby?"
|
|
12
|
+
#
|
|
13
|
+
# Configuration methods return +self+, so calls chain:
|
|
14
|
+
#
|
|
15
|
+
# chat = RubyLLM.chat(model: 'claude-sonnet-5')
|
|
16
|
+
# chat.with_instructions("Be terse.").with_tools(Weather)
|
|
17
|
+
#
|
|
18
|
+
# #ask runs the conversation loop, executing tools until the model answers
|
|
19
|
+
# or a call needs approval. #ask_later, #generate, #run_tools, and #step
|
|
20
|
+
# expose individual operations. Resume an approval pause with #approve
|
|
21
|
+
# or #deny followed by #complete.
|
|
22
|
+
#
|
|
23
|
+
# A Chat is Enumerable over its messages.
|
|
24
|
+
class Chat # rubocop:disable Metrics/ClassLength
|
|
6
25
|
include Enumerable
|
|
26
|
+
include Support::Inspectable
|
|
27
|
+
|
|
28
|
+
# The provider-neutral options #with_compaction accepts.
|
|
29
|
+
COMPACTION_OPTIONS = %i[at instructions pause_after].freeze
|
|
30
|
+
THINKING_OPTIONS = %i[effort budget display].freeze
|
|
31
|
+
private_constant :THINKING_OPTIONS
|
|
32
|
+
|
|
33
|
+
# The Model the chat sends requests to.
|
|
34
|
+
attr_reader :model
|
|
35
|
+
|
|
36
|
+
# The Provider instance handling requests for the current model.
|
|
37
|
+
attr_reader :provider
|
|
38
|
+
|
|
39
|
+
# The Message objects exchanged so far, including system instructions.
|
|
40
|
+
attr_reader :messages
|
|
41
|
+
|
|
42
|
+
# The registered tools, as a Hash of tool name Symbols to Tool instances.
|
|
43
|
+
attr_reader :tools
|
|
44
|
+
|
|
45
|
+
# The server tools enabled with #with_server_tools, as an array of
|
|
46
|
+
# normalized entry Hashes.
|
|
47
|
+
attr_reader :server_tools
|
|
48
|
+
|
|
49
|
+
# Extra request options set with #with_provider_options, expressed in
|
|
50
|
+
# the provider's request vocabulary.
|
|
51
|
+
attr_reader :provider_options
|
|
52
|
+
|
|
53
|
+
# Extra HTTP headers set with #with_headers.
|
|
54
|
+
attr_reader :headers
|
|
55
|
+
|
|
56
|
+
# The sampling temperature set with #with_temperature, or +nil+ to let
|
|
57
|
+
# the model use its default.
|
|
58
|
+
attr_reader :temperature
|
|
59
|
+
|
|
60
|
+
# The output cap set with #with_max_output_tokens, or +nil+.
|
|
61
|
+
attr_reader :max_output_tokens
|
|
62
|
+
|
|
63
|
+
# The normalized structured output schema set with #with_schema, or +nil+.
|
|
64
|
+
attr_reader :schema
|
|
65
|
+
|
|
66
|
+
# The tool concurrency mode, or +nil+ when tools run sequentially.
|
|
67
|
+
attr_reader :concurrency
|
|
68
|
+
|
|
69
|
+
# The prompt caching options set with #with_caching, +false+ when
|
|
70
|
+
# explicitly disabled, or +nil+ when not configured.
|
|
71
|
+
attr_reader :caching
|
|
72
|
+
|
|
73
|
+
# The context compaction options set with #with_compaction, +false+ when
|
|
74
|
+
# explicitly disabled, or +nil+ when not configured.
|
|
75
|
+
attr_reader :compaction
|
|
76
|
+
|
|
77
|
+
# The opaque per-user identifier set with #with_end_user, or
|
|
78
|
+
# +nil+.
|
|
79
|
+
attr_reader :end_user
|
|
80
|
+
|
|
81
|
+
# The Fallback models tried in order when generation fails.
|
|
82
|
+
attr_reader :fallbacks
|
|
7
83
|
|
|
8
|
-
|
|
84
|
+
# Whether #with_citations asked the provider for citations.
|
|
85
|
+
attr_reader :citations
|
|
9
86
|
|
|
10
|
-
|
|
87
|
+
# The Context this chat sends requests through, or +nil+ for the global
|
|
88
|
+
# configuration.
|
|
89
|
+
attr_reader :context
|
|
90
|
+
|
|
91
|
+
attr_reader :tool_prefs, :fallback_errors, :usage_entries # :nodoc:
|
|
92
|
+
|
|
93
|
+
# Returns the +choice+, +calls+, and +concurrency+ set with
|
|
94
|
+
# #with_tool_options, with +nil+ for anything left at the default.
|
|
95
|
+
def tool_options
|
|
96
|
+
{ choice: tool_prefs[:choice], calls: tool_prefs[:calls], concurrency: concurrency }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Creates a chat with +model:+, or with the configured default model
|
|
100
|
+
# when +model:+ is +nil+. Most code calls RubyLLM.chat instead.
|
|
101
|
+
#
|
|
102
|
+
# A model is identified by its name, an optional +provider:+, and an
|
|
103
|
+
# optional +protocol:+. Pass +provider:+ to disambiguate models
|
|
104
|
+
# available from several providers, and +protocol:+ to override the wire
|
|
105
|
+
# protocol the provider would otherwise pick for the model. With
|
|
106
|
+
# <tt>assume_model_exists: true</tt> the registry lookup is skipped,
|
|
107
|
+
# which requires +provider:+. Pass a Context as +context:+ to use its
|
|
108
|
+
# configuration instead of the global one.
|
|
109
|
+
def initialize(model: nil, provider: nil, protocol: nil, assume_model_exists: false, context: nil)
|
|
11
110
|
if assume_model_exists && !provider
|
|
12
111
|
raise ArgumentError, 'Provider must be specified if assume_model_exists is true'
|
|
13
112
|
end
|
|
14
113
|
|
|
15
114
|
@context = context
|
|
16
115
|
@config = context&.config || RubyLLM.config
|
|
17
|
-
|
|
18
|
-
with_model(model_id, provider: provider, assume_exists: assume_model_exists)
|
|
116
|
+
with_model(model, provider: provider, protocol: protocol, assume_model_exists: assume_model_exists)
|
|
19
117
|
@temperature = nil
|
|
118
|
+
@max_output_tokens = nil
|
|
20
119
|
@messages = []
|
|
120
|
+
@usage_entries = []
|
|
21
121
|
@tools = {}
|
|
122
|
+
@server_tools = []
|
|
22
123
|
@tool_prefs = { choice: nil, calls: nil }
|
|
23
|
-
@
|
|
124
|
+
@concurrency = normalize_tool_concurrency(@config.tool_concurrency)
|
|
125
|
+
@provider_options = {}
|
|
24
126
|
@headers = {}
|
|
25
127
|
@schema = nil
|
|
26
128
|
@thinking = nil
|
|
27
|
-
@
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
129
|
+
@citations = false
|
|
130
|
+
@caching = nil
|
|
131
|
+
@compaction = nil
|
|
132
|
+
@end_user = nil
|
|
133
|
+
@fallbacks = []
|
|
134
|
+
@fallback_errors = Fallback::DEFAULT_ERRORS
|
|
33
135
|
@callbacks = Hash.new { |callbacks, name| callbacks[name] = [] }
|
|
136
|
+
@cancelled = false
|
|
137
|
+
@cancellation_checker = nil
|
|
138
|
+
@tool_call_decisions = {}
|
|
139
|
+
@approval_checker = nil
|
|
34
140
|
end
|
|
35
141
|
|
|
142
|
+
# Adds +message+ as a user message and runs the conversation loop,
|
|
143
|
+
# executing tools until the model answers or a call needs approval.
|
|
144
|
+
# Returns the latest assistant Message; check #awaiting_approval? before
|
|
145
|
+
# treating it as a final answer. Attach files with +with:+.
|
|
146
|
+
# A given block receives streamed Chunk objects as they arrive.
|
|
147
|
+
#
|
|
148
|
+
# chat.ask "What's the best way to learn Ruby?"
|
|
149
|
+
# chat.ask "What's in this image?", with: "ruby_conf.jpg"
|
|
150
|
+
# chat.ask "Analyze these files", with: ["diagram.png", "report.pdf"]
|
|
151
|
+
# chat.ask("Tell me a story") { |chunk| print chunk.content }
|
|
152
|
+
#
|
|
36
153
|
def ask(message = nil, with: nil, &)
|
|
37
|
-
|
|
154
|
+
ask_later(message, with: with)
|
|
38
155
|
complete(&)
|
|
39
156
|
end
|
|
40
157
|
|
|
41
158
|
alias say ask
|
|
42
159
|
|
|
43
|
-
|
|
44
|
-
|
|
160
|
+
# Stages +message+ as a user message without requesting a completion,
|
|
161
|
+
# leaving the chat ready for #complete, a single #step, or a
|
|
162
|
+
# provider-side batch via RubyLLM.batch. Accepts attachments with
|
|
163
|
+
# +with:+ like #ask. Returns +self+.
|
|
164
|
+
#
|
|
165
|
+
# chats = tickets.map { |t| RubyLLM.chat.ask_later(t.body) }
|
|
166
|
+
# RubyLLM.batch(chats)
|
|
167
|
+
#
|
|
168
|
+
# Raises PendingToolCallsError while the last response has unanswered
|
|
169
|
+
# tool calls: finish the round first, recording #approve or #deny
|
|
170
|
+
# decisions for calls that require approval.
|
|
171
|
+
def ask_later(message = nil, with: nil)
|
|
172
|
+
raise_if_pending_tool_calls!
|
|
173
|
+
add_message role: :user, content: message, attachments: with
|
|
174
|
+
self
|
|
175
|
+
end
|
|
45
176
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
177
|
+
# Requests one completion from the model, appends the response to the
|
|
178
|
+
# conversation, and returns it as a Message. Honors the fallbacks
|
|
179
|
+
# configured with #with_fallbacks. A given block receives streamed
|
|
180
|
+
# Chunk objects. Tool calls in the response are not executed; that is
|
|
181
|
+
# #run_tools.
|
|
182
|
+
def generate(&)
|
|
183
|
+
raise_if_cancelled!
|
|
184
|
+
|
|
185
|
+
return generate_once(&) if fallbacks.empty?
|
|
186
|
+
|
|
187
|
+
with_model_restored { generate_with_fallbacks(&) }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Executes the tool calls pending in the latest response and appends
|
|
191
|
+
# their result messages, without asking the model to respond. Tool
|
|
192
|
+
# calls that already have results are skipped, so a chat reloaded
|
|
193
|
+
# mid-round resumes with only the remaining tools. Calls whose tool
|
|
194
|
+
# was declared with Tool.requires_approval only execute once #approve
|
|
195
|
+
# records a decision; denied calls receive a structured denial result,
|
|
196
|
+
# and undecided calls stay pending. Does nothing when no tool calls
|
|
197
|
+
# are pending. The chat is then ready for the next #generate, or the
|
|
198
|
+
# next batch round. Returns +self+.
|
|
199
|
+
def run_tools
|
|
200
|
+
raise_if_cancelled!
|
|
201
|
+
|
|
202
|
+
message = pending_tool_response
|
|
203
|
+
execute_pending_tool_calls(message) if message
|
|
204
|
+
self
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Advances the conversation by one move: runs the pending tool calls
|
|
208
|
+
# if any are unanswered, otherwise generates the next response.
|
|
209
|
+
# Returns the Message that move produced, and +nil+ once there is
|
|
210
|
+
# nothing left to do or the loop is parked on an approval.
|
|
211
|
+
def step(&)
|
|
212
|
+
return if complete?
|
|
213
|
+
|
|
214
|
+
raise_if_cancelled!
|
|
215
|
+
return generate(&) unless pending_tool_response
|
|
216
|
+
|
|
217
|
+
before = messages.length
|
|
218
|
+
run_tools
|
|
219
|
+
messages.last if messages.length > before
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Runs the conversation loop until #complete? or #awaiting_approval?
|
|
223
|
+
# is +true+. Returns the last conversation Message, or +nil+ for an
|
|
224
|
+
# empty chat. Used after #ask_later; #ask calls #complete for you.
|
|
225
|
+
#
|
|
226
|
+
# When a pending tool call requires approval and no decision has been
|
|
227
|
+
# recorded, the loop pauses. Record #approve or #deny decisions, then
|
|
228
|
+
# call #complete again to continue.
|
|
229
|
+
def complete(&)
|
|
230
|
+
step(&) until complete? || awaiting_approval?
|
|
231
|
+
last_non_system_message || messages.last
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Returns whether the chat has no pending response or tool execution:
|
|
235
|
+
# nothing is staged, or the model answered without requesting tools.
|
|
236
|
+
def complete?
|
|
237
|
+
last = last_non_system_message
|
|
238
|
+
case last&.role
|
|
239
|
+
when nil then true
|
|
240
|
+
when :user, :tool then false
|
|
241
|
+
else !last.tool_call?
|
|
50
242
|
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Records approval for +tool_call+, a ToolCall or its id, so the next
|
|
246
|
+
# #complete or #run_tools executes a local tool or records permission for
|
|
247
|
+
# the provider to execute a remote tool on the next request. Returns +self+.
|
|
248
|
+
#
|
|
249
|
+
# chat.approve(tool_call)
|
|
250
|
+
# chat.complete
|
|
251
|
+
#
|
|
252
|
+
def approve(tool_call)
|
|
253
|
+
record_tool_call_decision(tool_call, true)
|
|
254
|
+
end
|
|
51
255
|
|
|
256
|
+
# Records denial for +tool_call+, a ToolCall or its id. The next
|
|
257
|
+
# #complete or #run_tools appends a structured denial result instead
|
|
258
|
+
# of executing a local tool, or sends a refusal for a remote tool on the
|
|
259
|
+
# next request. The model continues from there. Returns
|
|
260
|
+
# +self+.
|
|
261
|
+
def deny(tool_call)
|
|
262
|
+
record_tool_call_decision(tool_call, false)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Returns whether the conversation can make no progress without an
|
|
266
|
+
# approval decision: every remaining pending tool call requires
|
|
267
|
+
# approval and has none recorded. While +true+, #complete returns
|
|
268
|
+
# without executing them; record decisions with #approve or #deny,
|
|
269
|
+
# then call #complete again. Tool calls that need no approval still
|
|
270
|
+
# execute before the loop pauses.
|
|
271
|
+
#
|
|
272
|
+
# Consults each pending tool's approval resolver when one is declared,
|
|
273
|
+
# so resolvers must be idempotent reads.
|
|
274
|
+
def awaiting_approval?
|
|
275
|
+
response = pending_tool_response
|
|
276
|
+
return false unless response
|
|
277
|
+
|
|
278
|
+
pending = pending_tool_calls(response)
|
|
279
|
+
pending.any? && pending.all? { |_, tool_call| approval_pending?(tool_call) }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Returns the tool calls from the latest response that require approval
|
|
283
|
+
# and have no recorded decision, as an array of ToolCall objects. Pairs
|
|
284
|
+
# with #approve and #deny. ToolCall#remote? identifies provider-executed calls.
|
|
285
|
+
#
|
|
286
|
+
# chat.pending_approvals.each { |tool_call| puts tool_call.name }
|
|
287
|
+
# chat.approve(chat.pending_approvals.first)
|
|
288
|
+
#
|
|
289
|
+
def pending_approvals
|
|
290
|
+
response = pending_tool_response
|
|
291
|
+
return [] unless response
|
|
292
|
+
|
|
293
|
+
pending_tool_calls(response).values.select { |tool_call| approval_pending?(tool_call) }
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Cancels the current in-flight chat operation. The next cancellation
|
|
297
|
+
# checkpoint raises CancelledError and clears the flag so the chat can be
|
|
298
|
+
# reused.
|
|
299
|
+
def cancel
|
|
300
|
+
@cancelled = true
|
|
52
301
|
self
|
|
53
302
|
end
|
|
54
303
|
|
|
55
|
-
|
|
56
|
-
|
|
304
|
+
# Returns whether this in-memory chat has been marked for cancellation.
|
|
305
|
+
def cancelled?
|
|
306
|
+
@cancelled
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Sets the system instructions for the conversation, replacing any
|
|
310
|
+
# existing system messages. With <tt>append: true</tt> the instructions
|
|
311
|
+
# are added alongside the existing ones. With <tt>cache_until_here:
|
|
312
|
+
# true</tt> the instruction becomes an explicit prompt cache boundary.
|
|
313
|
+
# Pass +nil+ to remove all system instructions. Returns +self+.
|
|
314
|
+
#
|
|
315
|
+
# chat.with_instructions "You are a helpful Ruby tutor."
|
|
316
|
+
# chat.with_instructions "Use exactly one short paragraph.", append: true
|
|
317
|
+
# chat.with_instructions nil
|
|
318
|
+
#
|
|
319
|
+
def with_instructions(instructions, append: false, cache_until_here: false)
|
|
320
|
+
@messages.reject! { |message| message.role == :system } unless append
|
|
321
|
+
@messages << Message.new(role: :system, content: instructions) unless instructions.nil?
|
|
322
|
+
@messages.last.cache_until_here if instructions && cache_until_here
|
|
323
|
+
self
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Registers +tools+, each a Tool class or instance, for the model to
|
|
327
|
+
# call. Configure how the model uses them with #with_tool_options.
|
|
328
|
+
# Pass +nil+ to remove all registered tools. Returns +self+.
|
|
329
|
+
#
|
|
330
|
+
# chat.with_tools(Weather, Search)
|
|
331
|
+
# chat.with_tools(Weather).with_tool_options(choice: :required)
|
|
332
|
+
#
|
|
333
|
+
# To replace the registered tools, clear them first:
|
|
334
|
+
#
|
|
335
|
+
# chat.with_tools(nil).with_tools(NewTool)
|
|
336
|
+
#
|
|
337
|
+
def with_tools(*tools)
|
|
338
|
+
@tools.clear if tools == [nil]
|
|
339
|
+
tools.flatten.compact.each do |tool|
|
|
57
340
|
tool_instance = tool.is_a?(Class) ? tool.new : tool
|
|
58
341
|
@tools[tool_instance.name.to_sym] = tool_instance
|
|
59
342
|
end
|
|
60
|
-
update_tool_options(choice:, calls:)
|
|
61
343
|
self
|
|
62
344
|
end
|
|
63
345
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
346
|
+
# Enables tools that run on the provider's servers, such as web search
|
|
347
|
+
# or code execution. Accepts portable alias Symbols, alias-with-options
|
|
348
|
+
# keywords whose options use the provider's own vocabulary, and raw
|
|
349
|
+
# Hashes passed to the provider verbatim, so provider tools RubyLLM has
|
|
350
|
+
# no alias for yet work without a gem update. Entries add to any tools
|
|
351
|
+
# enabled earlier; pass +nil+ to clear them all. Returns +self+.
|
|
352
|
+
#
|
|
353
|
+
# chat.with_server_tools(:web_search)
|
|
354
|
+
# chat.with_server_tools(:web_search, :code_execution)
|
|
355
|
+
# chat.with_server_tools(web_search: { allowed_domains: ["ruby-lang.org"] })
|
|
356
|
+
# chat.with_server_tools({ type: "web_search_20260318", name: "web_search" })
|
|
357
|
+
#
|
|
358
|
+
# The tool steps the model ran come back on
|
|
359
|
+
# Message#server_tool_calls, citations from search tools on
|
|
360
|
+
# Message#citations, and per-use billing counters on
|
|
361
|
+
# <tt>message.tokens.server_tool_use</tt>.
|
|
362
|
+
#
|
|
363
|
+
# Raises UnsupportedServerToolError at request time when the provider
|
|
364
|
+
# has no server-tool support or does not define a requested alias.
|
|
365
|
+
def with_server_tools(*tools, **tools_with_options)
|
|
366
|
+
if tools == [nil] && tools_with_options.empty?
|
|
367
|
+
@server_tools = []
|
|
368
|
+
return self
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
@server_tools += RubyLLM::Tools::ServerTools.normalize(tools, tools_with_options)
|
|
372
|
+
self
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Configures how the model uses the registered tools. +choice:+
|
|
376
|
+
# constrains tool use to +:auto+, +:none+, +:required+, a tool name, or
|
|
377
|
+
# a Tool class. +calls:+ limits how many tool calls one response may
|
|
378
|
+
# contain (+:many+ or +:one+). +concurrency:+ runs tool calls
|
|
379
|
+
# concurrently: +true+ or +:threads+ for threads, +:fibers+ for fibers.
|
|
380
|
+
# An omitted option is left unchanged; passing +nil+ explicitly resets
|
|
381
|
+
# that option (+concurrency: nil+ returns to the configured default).
|
|
382
|
+
# Returns +self+.
|
|
383
|
+
#
|
|
384
|
+
# chat.with_tools(Weather, Search).with_tool_options(choice: :required)
|
|
385
|
+
# chat.with_tool_options(calls: :one, concurrency: :threads)
|
|
386
|
+
# chat.with_tool_options(choice: nil)
|
|
387
|
+
#
|
|
388
|
+
def with_tool_options(**options)
|
|
389
|
+
options.each do |option, value|
|
|
390
|
+
case option
|
|
391
|
+
when :choice then apply_tool_choice(value)
|
|
392
|
+
when :calls then @tool_prefs[:calls] = value.nil? ? nil : normalize_calls(value)
|
|
393
|
+
when :concurrency then @concurrency = normalize_tool_concurrency(value.nil? ? @config.tool_concurrency : value)
|
|
394
|
+
else raise ArgumentError, "Unknown tool option: #{option}. Valid options are: choice, calls, concurrency"
|
|
395
|
+
end
|
|
396
|
+
end
|
|
68
397
|
self
|
|
69
398
|
end
|
|
70
399
|
|
|
71
|
-
|
|
72
|
-
|
|
400
|
+
# Switches the chat to +model_id+ and its provider. Pass +provider:+ to
|
|
401
|
+
# disambiguate, and <tt>assume_model_exists: true</tt> to skip registry
|
|
402
|
+
# validation for custom or private models. Pass +nil+ to return to the
|
|
403
|
+
# configured default model. Returns +self+.
|
|
404
|
+
#
|
|
405
|
+
# +protocol:+ overrides the wire protocol the provider would pick for the
|
|
406
|
+
# model, such as +:responses+ or +:chat_completions+ for OpenAI. It stays
|
|
407
|
+
# +nil+ by default, meaning the provider chooses the protocol for each
|
|
408
|
+
# request. A bare #with_model resets the override to +nil+, just as it
|
|
409
|
+
# re-resolves the provider from the model.
|
|
410
|
+
#
|
|
411
|
+
# Raises ModelNotFoundError if +model_id+ is not in the registry and
|
|
412
|
+
# +assume_model_exists:+ is false.
|
|
413
|
+
#
|
|
414
|
+
# chat.with_model('claude-sonnet-5')
|
|
415
|
+
# chat.with_model('gpt-5.6', protocol: :chat_completions)
|
|
416
|
+
#
|
|
417
|
+
def with_model(model_id, provider: nil, protocol: nil, assume_model_exists: false)
|
|
418
|
+
model_id ||= @config.default_model
|
|
419
|
+
@model, @provider = Models.resolve(model_id, provider:, assume_model_exists:, config: @config)
|
|
73
420
|
@connection = @provider.connection
|
|
421
|
+
@protocol = protocol
|
|
422
|
+
self
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Sets fallback models to try, in order, when generation fails. +on:+
|
|
426
|
+
# selects the error classes that trigger a fallback; the default covers
|
|
427
|
+
# transient provider and network errors. Pass +nil+ to remove all
|
|
428
|
+
# fallbacks and restore the default error classes. Returns +self+.
|
|
429
|
+
#
|
|
430
|
+
# chat.with_fallbacks("gpt-4.1-mini", "claude-haiku-4-5")
|
|
431
|
+
# chat.with_fallbacks(nil)
|
|
432
|
+
#
|
|
433
|
+
def with_fallbacks(*models, on: Fallback::DEFAULT_ERRORS)
|
|
434
|
+
fallback_models = models.flatten.compact
|
|
435
|
+
@fallbacks = fallback_models.map { |model| Fallback.build(model) }
|
|
436
|
+
@fallback_errors = fallback_models.empty? ? Fallback::DEFAULT_ERRORS : Array(on).flatten.compact
|
|
74
437
|
self
|
|
75
438
|
end
|
|
76
439
|
|
|
440
|
+
# Sets the sampling temperature for subsequent requests. Pass +nil+ to
|
|
441
|
+
# return to the model's default sampling behavior. Returns +self+.
|
|
442
|
+
#
|
|
443
|
+
# chat.with_temperature(0.2)
|
|
444
|
+
#
|
|
77
445
|
def with_temperature(temperature)
|
|
78
446
|
@temperature = temperature
|
|
79
447
|
self
|
|
80
448
|
end
|
|
81
449
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
450
|
+
# Caps the number of tokens the model may generate.
|
|
451
|
+
# Pass +nil+ to remove the limit.
|
|
452
|
+
# Returns +self+.
|
|
453
|
+
#
|
|
454
|
+
# chat.with_max_output_tokens(1000)
|
|
455
|
+
#
|
|
456
|
+
def with_max_output_tokens(max_output_tokens)
|
|
457
|
+
@max_output_tokens = max_output_tokens
|
|
86
458
|
self
|
|
87
459
|
end
|
|
88
460
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
461
|
+
# Configures extended thinking for models that support it. With no
|
|
462
|
+
# arguments, RubyLLM uses the current model's registered default. Pass
|
|
463
|
+
# +false+ to disable thinking, or tune it with
|
|
464
|
+
# +effort:+ (+:low+, +:medium+, +:high+, +:none+, or a
|
|
465
|
+
# provider-specific tier such as +:minimal+, +:xhigh+, or +:max+,
|
|
466
|
+
# passed through as-is), +budget:+ (a token count), and +display:+
|
|
467
|
+
# (+:summarized+ or +:omitted+, controlling whether providers that
|
|
468
|
+
# support it return readable thinking text). Accepts keywords or an options
|
|
469
|
+
# Hash. Passing +nil+ raises ArgumentError. Returns +self+.
|
|
470
|
+
#
|
|
471
|
+
# chat.with_thinking
|
|
472
|
+
# chat.with_thinking(false)
|
|
473
|
+
# chat.with_thinking(effort: :high)
|
|
474
|
+
# chat.with_thinking(budget: 10_000)
|
|
475
|
+
# chat.with_thinking(display: :summarized)
|
|
476
|
+
#
|
|
477
|
+
def with_thinking(enabled = true, **options) # rubocop:disable Metrics/PerceivedComplexity
|
|
478
|
+
return with_thinking(**enabled.transform_keys(&:to_sym), **options) if enabled.is_a?(Hash)
|
|
479
|
+
|
|
480
|
+
raise ArgumentError, 'with_thinking accepts false or thinking options' unless [true, false].include?(enabled)
|
|
481
|
+
raise ArgumentError, 'with_thinking(false) does not accept options' if !enabled && options.any?
|
|
482
|
+
raise ArgumentError, 'thinking options cannot be nil; pass false to disable' if options.value?(nil)
|
|
483
|
+
if (unsupported = options.keys - THINKING_OPTIONS).any?
|
|
484
|
+
raise ArgumentError,
|
|
485
|
+
"with_thinking accepts #{format_option_keys(THINKING_OPTIONS)}, " \
|
|
486
|
+
"got #{format_option_keys(unsupported)}"
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
@thinking = if enabled
|
|
490
|
+
options.empty? ? Thinking::Config.default : Thinking::Config.new(**options)
|
|
491
|
+
else
|
|
492
|
+
Thinking::Config.disabled
|
|
493
|
+
end
|
|
93
494
|
self
|
|
94
495
|
end
|
|
95
496
|
|
|
96
|
-
|
|
97
|
-
|
|
497
|
+
# Returns the thinking options resolved for the current model, or +nil+
|
|
498
|
+
# when thinking was not configured or needs no provider control.
|
|
499
|
+
def thinking
|
|
500
|
+
config = resolved_thinking
|
|
501
|
+
return unless config
|
|
502
|
+
|
|
503
|
+
{
|
|
504
|
+
effort: config.effort,
|
|
505
|
+
budget: config.budget,
|
|
506
|
+
display: config.display,
|
|
507
|
+
enabled: config.enabled
|
|
508
|
+
}.compact
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Enables document citations, so the model backs its claims with quotes
|
|
512
|
+
# from attached files. Pass +false+ to disable. Passing +nil+ raises
|
|
513
|
+
# ArgumentError. Returns +self+.
|
|
514
|
+
#
|
|
515
|
+
# chat.with_citations
|
|
516
|
+
# response = chat.ask "Who created Ruby?", with: "facts.txt"
|
|
517
|
+
# response.citations.each { |citation| puts citation.cited_text }
|
|
518
|
+
#
|
|
519
|
+
def with_citations(enabled = true)
|
|
520
|
+
raise ArgumentError, 'with_citations accepts true or false' unless [true, false].include?(enabled)
|
|
521
|
+
|
|
522
|
+
@citations = enabled
|
|
98
523
|
self
|
|
99
524
|
end
|
|
100
525
|
|
|
101
|
-
|
|
102
|
-
|
|
526
|
+
# Enables provider prompt caching. With no arguments the provider's
|
|
527
|
+
# default behavior applies; options such as +ttl:+ apply where
|
|
528
|
+
# supported. Pass +id:+ with a CachedContent (or its name) from
|
|
529
|
+
# RubyLLM.cache to attach an explicit
|
|
530
|
+
# content cache. Pass +false+ to stop RubyLLM from sending cache
|
|
531
|
+
# controls or rendering explicit cache boundaries. A provider may still
|
|
532
|
+
# cache prompts implicitly. Passing +nil+ raises ArgumentError.
|
|
533
|
+
# Returns +self+.
|
|
534
|
+
#
|
|
535
|
+
# chat.with_caching
|
|
536
|
+
# chat.with_caching(ttl: "1h")
|
|
537
|
+
# chat.with_caching(id: cache)
|
|
538
|
+
# chat.with_caching(false)
|
|
539
|
+
#
|
|
540
|
+
def with_caching(options = {})
|
|
541
|
+
options = {} if options == true
|
|
542
|
+
unless options == false || options.is_a?(Hash)
|
|
543
|
+
raise ArgumentError, 'with_caching accepts true, false, or caching options'
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
@caching = options == false ? false : options.transform_keys(&:to_sym).freeze
|
|
103
547
|
self
|
|
104
548
|
end
|
|
105
549
|
|
|
106
|
-
|
|
107
|
-
|
|
550
|
+
# Enables provider-side context compaction, so a long conversation keeps
|
|
551
|
+
# going instead of overflowing the context window. The provider condenses
|
|
552
|
+
# the earlier turns itself and returns a block that RubyLLM replays on
|
|
553
|
+
# later requests. With no arguments the provider's own defaults apply.
|
|
554
|
+
# The options are provider-neutral:
|
|
555
|
+
#
|
|
556
|
+
# +at+:: the input-token count that triggers compaction.
|
|
557
|
+
# +instructions+:: a custom prompt for the summary the provider writes.
|
|
558
|
+
# +pause_after+:: end the turn once compaction runs, instead of
|
|
559
|
+
# continuing straight into the answer.
|
|
560
|
+
#
|
|
561
|
+
# Each provider applies the options it supports. Unsupported options
|
|
562
|
+
# are ignored with a debug log. Pass +false+ to disable; passing +nil+
|
|
563
|
+
# raises ArgumentError. Returns +self+.
|
|
564
|
+
#
|
|
565
|
+
# chat.with_compaction
|
|
566
|
+
# chat.with_compaction(at: 50_000)
|
|
567
|
+
# chat.with_compaction(at: 100_000, instructions: "Keep every decision.")
|
|
568
|
+
# chat.with_compaction(false)
|
|
569
|
+
#
|
|
570
|
+
# What a provider does when the threshold is crossed differs. Anthropic
|
|
571
|
+
# and OpenAI summarize the compacted span into an opaque block that
|
|
572
|
+
# replaces it; OpenRouter drops messages from the middle of the
|
|
573
|
+
# conversation instead, and has no threshold of its own.
|
|
574
|
+
def with_compaction(options = {})
|
|
575
|
+
options = {} if options == true
|
|
576
|
+
unless options == false || options.is_a?(Hash)
|
|
577
|
+
raise ArgumentError, 'with_compaction accepts true, false, or compaction options'
|
|
578
|
+
end
|
|
108
579
|
|
|
109
|
-
@
|
|
110
|
-
|
|
111
|
-
|
|
580
|
+
@compaction = options == false ? false : normalize_compaction(options)
|
|
581
|
+
self
|
|
582
|
+
end
|
|
112
583
|
|
|
584
|
+
# Identifies the end user behind the conversation for the provider's
|
|
585
|
+
# abuse monitoring. Providers without an equivalent field omit it.
|
|
586
|
+
# Pass +nil+ to remove it.
|
|
587
|
+
# Returns +self+.
|
|
588
|
+
#
|
|
589
|
+
# chat.with_end_user("user-123").ask "Hello"
|
|
590
|
+
#
|
|
591
|
+
# The value is sent as given, so use an opaque id such as a hash of
|
|
592
|
+
# your user id, never personal data.
|
|
593
|
+
def with_end_user(end_user)
|
|
594
|
+
@end_user = end_user
|
|
113
595
|
self
|
|
114
596
|
end
|
|
115
597
|
|
|
116
|
-
|
|
117
|
-
|
|
598
|
+
# Rebinds the chat to +context+, a Context built with RubyLLM.context,
|
|
599
|
+
# so subsequent requests use its configuration. Pass +nil+ to return to
|
|
600
|
+
# the global RubyLLM.config. Returns +self+.
|
|
601
|
+
def with_context(context)
|
|
602
|
+
@context = context
|
|
603
|
+
@config = context&.config || RubyLLM.config
|
|
604
|
+
with_model(@model.id, provider: @provider.slug, protocol: @protocol, assume_model_exists: true)
|
|
605
|
+
self
|
|
118
606
|
end
|
|
119
607
|
|
|
120
|
-
|
|
121
|
-
|
|
608
|
+
# Sets options in the provider's request vocabulary, merged into the
|
|
609
|
+
# request payload as-is and overriding RubyLLM's defaults. Replaces any
|
|
610
|
+
# previously set provider options; +nil+ clears them. Returns +self+.
|
|
611
|
+
#
|
|
612
|
+
# chat.with_provider_options(service_tier: "flex")
|
|
613
|
+
#
|
|
614
|
+
def with_provider_options(provider_options)
|
|
615
|
+
@provider_options = provider_options.to_h
|
|
616
|
+
self
|
|
122
617
|
end
|
|
123
618
|
|
|
124
|
-
|
|
125
|
-
|
|
619
|
+
# Sets extra HTTP headers sent with completion requests, replacing any
|
|
620
|
+
# previously set headers; +nil+ clears them. Returns +self+.
|
|
621
|
+
#
|
|
622
|
+
# chat.with_headers('anthropic-beta' => 'fine-grained-tool-streaming-2025-05-14')
|
|
623
|
+
#
|
|
624
|
+
def with_headers(headers)
|
|
625
|
+
@headers = headers.to_h
|
|
626
|
+
self
|
|
126
627
|
end
|
|
127
628
|
|
|
128
|
-
|
|
129
|
-
|
|
629
|
+
# Sets the schema for structured output. Accepts a JSON Schema Hash, a
|
|
630
|
+
# Schematist::Schema class or instance, or any object responding to
|
|
631
|
+
# +to_json_schema+. Returns +self+.
|
|
632
|
+
#
|
|
633
|
+
# class PersonSchema < Schematist::Schema
|
|
634
|
+
# string :name
|
|
635
|
+
# integer :age
|
|
636
|
+
# end
|
|
637
|
+
#
|
|
638
|
+
# chat.with_schema(PersonSchema)
|
|
639
|
+
# response = chat.ask("Generate a person named Alice who is 30 years old")
|
|
640
|
+
# response.parsed # => {"name" => "Alice", "age" => 30}
|
|
641
|
+
#
|
|
642
|
+
# Pass +nil+ to remove the schema, returning the chat to plain text
|
|
643
|
+
# responses.
|
|
644
|
+
def with_schema(schema)
|
|
645
|
+
schema_instance = schema.is_a?(Class) ? schema.new : schema
|
|
646
|
+
|
|
647
|
+
@schema = normalize_schema_payload(
|
|
648
|
+
schema_instance.respond_to?(:to_json_schema) ? schema_instance.to_json_schema : schema_instance
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
self
|
|
130
652
|
end
|
|
131
653
|
|
|
654
|
+
# Registers a callback that runs before each assistant response or tool
|
|
655
|
+
# result is appended to the conversation. Callbacks are additive: every
|
|
656
|
+
# registered block runs. Returns +self+.
|
|
132
657
|
def before_message(&)
|
|
133
658
|
add_callback(:before_message, &)
|
|
134
659
|
end
|
|
135
660
|
|
|
661
|
+
# Registers a callback that receives each assistant response and each
|
|
662
|
+
# tool result message once it has been appended. Returns +self+.
|
|
663
|
+
#
|
|
664
|
+
# chat.after_message { |message| puts message.content }
|
|
665
|
+
#
|
|
136
666
|
def after_message(&)
|
|
137
667
|
add_callback(:after_message, &)
|
|
138
668
|
end
|
|
139
669
|
|
|
670
|
+
# Registers a callback that receives each local ToolCall before the tool
|
|
671
|
+
# executes. Returns +self+.
|
|
672
|
+
#
|
|
673
|
+
# chat.before_tool_call { |tool_call| puts tool_call.name }
|
|
674
|
+
#
|
|
140
675
|
def before_tool_call(&)
|
|
141
676
|
add_callback(:before_tool_call, &)
|
|
142
677
|
end
|
|
143
678
|
|
|
679
|
+
# Registers a callback that receives each local tool's result after
|
|
680
|
+
# execution. Returns +self+.
|
|
144
681
|
def after_tool_result(&)
|
|
145
682
|
add_callback(:after_tool_result, &)
|
|
146
683
|
end
|
|
147
684
|
|
|
685
|
+
# Registers a callback that receives the Fallback attempt after the
|
|
686
|
+
# current model fails and before the fallback model is tried. Returns
|
|
687
|
+
# +self+.
|
|
688
|
+
def before_fallback(&)
|
|
689
|
+
add_callback(:before_fallback, &)
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
# Registers a callback that receives the Fallback attempt once it has
|
|
693
|
+
# succeeded or failed. Returns +self+.
|
|
694
|
+
def after_fallback(&)
|
|
695
|
+
add_callback(:after_fallback, &)
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
# Registers a callback that receives the fully rendered request payload
|
|
699
|
+
# before it is sent and may mutate it in place. Runs after all RubyLLM
|
|
700
|
+
# formatting and #with_provider_options merging. Returns +self+.
|
|
701
|
+
#
|
|
702
|
+
# chat.before_request { |payload| logger.debug payload }
|
|
703
|
+
#
|
|
704
|
+
def before_request(&)
|
|
705
|
+
add_callback(:before_request, &)
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
# Yields each Message in the conversation. Returns an Enumerator when
|
|
709
|
+
# no block is given. Chat includes Enumerable, so the usual collection
|
|
710
|
+
# methods are available.
|
|
148
711
|
def each(&)
|
|
149
712
|
messages.each(&)
|
|
150
713
|
end
|
|
151
714
|
|
|
715
|
+
# Returns token usage aggregated across every provider attempt this chat
|
|
716
|
+
# has made, including retries and attempts that produced no message.
|
|
717
|
+
#
|
|
718
|
+
# chat.tokens.input
|
|
719
|
+
#
|
|
720
|
+
def tokens
|
|
721
|
+
Tokens.aggregate(usage_entries.map(&:tokens))
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
# Returns a Cost aggregating every provider attempt this chat has made,
|
|
725
|
+
# including retries and attempts that produced no message.
|
|
726
|
+
#
|
|
727
|
+
# chat.cost.total
|
|
728
|
+
#
|
|
152
729
|
def cost
|
|
153
|
-
Cost.aggregate(
|
|
730
|
+
Cost.aggregate(usage_entries.map(&:cost), complete: usage_entries.all?(&:cost_available?))
|
|
154
731
|
end
|
|
155
732
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
733
|
+
# Counts input tokens for the conversation, including instructions,
|
|
734
|
+
# function tools, structured output, thinking, and attachments.
|
|
735
|
+
# Pass +message+ to include it as a staged user message without
|
|
736
|
+
# mutating the chat. Returns an Integer.
|
|
737
|
+
#
|
|
738
|
+
# chat.with_instructions("Be terse.").with_tools(Weather)
|
|
739
|
+
# chat.count_tokens("What's the weather in Berlin?")
|
|
740
|
+
#
|
|
741
|
+
# Server tools, provider_options, compaction, and before_request hooks
|
|
742
|
+
# are not included. Raises Error when the provider has no token counting
|
|
743
|
+
# endpoint.
|
|
744
|
+
def count_tokens(message = nil)
|
|
745
|
+
request_messages = messages.dup
|
|
746
|
+
request_messages << coerce_message(role: :user, content: message) unless message.nil?
|
|
747
|
+
@provider.count_tokens(
|
|
748
|
+
preprocessed_messages(request_messages),
|
|
749
|
+
model: @model,
|
|
159
750
|
tools: @tools,
|
|
160
751
|
tool_prefs: @tool_prefs,
|
|
161
|
-
|
|
162
|
-
model: @model,
|
|
163
|
-
params: @params,
|
|
164
|
-
headers: @headers,
|
|
752
|
+
thinking: resolved_thinking,
|
|
165
753
|
schema: @schema,
|
|
166
|
-
|
|
167
|
-
|
|
754
|
+
citations: @citations,
|
|
755
|
+
caching: @caching,
|
|
756
|
+
protocol: @protocol
|
|
168
757
|
)
|
|
758
|
+
end
|
|
169
759
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
760
|
+
# Compacts the conversation's model context and returns an assistant
|
|
761
|
+
# Message. The message can have empty text and carries the provider's
|
|
762
|
+
# compacted context internally. Every earlier message remains in
|
|
763
|
+
# #messages, including on persisted Rails chats.
|
|
764
|
+
#
|
|
765
|
+
# chat.ask "Remember these project requirements..."
|
|
766
|
+
# chat.compact
|
|
767
|
+
# chat.ask "Which requirement should we implement first?"
|
|
768
|
+
#
|
|
769
|
+
# Uses the current instructions, headers, and request hooks. Records
|
|
770
|
+
# reported usage and runs the normal message callbacks. Raises Error
|
|
771
|
+
# when the provider has no manual compaction endpoint, and
|
|
772
|
+
# PendingToolCallsError until pending tool calls have been answered.
|
|
773
|
+
def compact
|
|
774
|
+
raise_if_cancelled!
|
|
775
|
+
raise_if_pending_tool_calls!
|
|
776
|
+
usage_start = usage_entries.length
|
|
777
|
+
payload = instrumentation_payload(streaming: false)
|
|
778
|
+
RubyLLM.instrument('compaction.ruby_llm', payload, config: @config) do |event|
|
|
779
|
+
result = provider_compaction
|
|
780
|
+
record_out_of_band_usage(result) if usage_entries.length == usage_start
|
|
781
|
+
record_generated_message(result, usage_start)
|
|
782
|
+
record_completion_event(event, result)
|
|
783
|
+
result
|
|
178
784
|
end
|
|
785
|
+
end
|
|
179
786
|
|
|
180
|
-
|
|
181
|
-
|
|
787
|
+
# Replaces the conversation with +new_messages+, coercing each element
|
|
788
|
+
# into a Message. Accepts Message objects, attribute Hashes, and
|
|
789
|
+
# records responding to +to_llm+.
|
|
790
|
+
def messages=(new_messages)
|
|
791
|
+
@messages = message_list(new_messages).map { |message| coerce_message(message) }
|
|
792
|
+
end
|
|
182
793
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
end
|
|
794
|
+
# Replaces the usage ledger. Used by the Rails integration when
|
|
795
|
+
# rebuilding a persisted chat.
|
|
796
|
+
def usage_entries=(entries) # :nodoc:
|
|
797
|
+
@usage_entries = Array(entries)
|
|
188
798
|
end
|
|
189
799
|
|
|
800
|
+
# Hooks installed by the Rails integration.
|
|
801
|
+
attr_writer :cancellation_checker, :usage_recorder, :approval_checker # :nodoc:
|
|
802
|
+
|
|
803
|
+
# Appends a message to the conversation and returns it as a Message.
|
|
804
|
+
# Accepts a Message, an attribute Hash, or a record responding to
|
|
805
|
+
# +to_llm+.
|
|
806
|
+
#
|
|
807
|
+
# chat.add_message(role: :user, content: "What's the capital of France?")
|
|
808
|
+
#
|
|
190
809
|
def add_message(message_or_attributes)
|
|
191
|
-
message =
|
|
810
|
+
message = coerce_message(message_or_attributes)
|
|
192
811
|
messages << message
|
|
193
812
|
message
|
|
194
813
|
end
|
|
195
814
|
|
|
196
|
-
|
|
197
|
-
|
|
815
|
+
# Marks the latest message as an explicit prompt cache boundary, asking
|
|
816
|
+
# the provider to cache everything up to this point. Returns +self+.
|
|
817
|
+
#
|
|
818
|
+
# Raises ArgumentError if the chat has no messages.
|
|
819
|
+
def cache_until_here
|
|
820
|
+
message = messages.last
|
|
821
|
+
raise ArgumentError, 'No messages to cache' unless message
|
|
822
|
+
|
|
823
|
+
message.cache_until_here
|
|
824
|
+
self
|
|
198
825
|
end
|
|
199
826
|
|
|
200
|
-
|
|
201
|
-
|
|
827
|
+
# Receives a completion produced out-of-band (e.g. by a batch), running the
|
|
828
|
+
# same callbacks as a synchronous completion so persistence works unchanged.
|
|
829
|
+
def add_completion(response, record_usage: false) # :nodoc:
|
|
830
|
+
if response.ruby_llm_usage_entries.empty?
|
|
831
|
+
record_out_of_band_usage(response)
|
|
832
|
+
elsif record_usage
|
|
833
|
+
response.ruby_llm_usage_entries.each { |entry| record_usage_entry(entry) }
|
|
834
|
+
end
|
|
835
|
+
run_callbacks(:before_message)
|
|
836
|
+
add_message response
|
|
837
|
+
run_callbacks(:after_message, response)
|
|
838
|
+
response
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
# Returns the request payload this chat would send to the provider for
|
|
842
|
+
# its next completion, with #before_request hooks applied. Useful for
|
|
843
|
+
# inspecting and testing request output.
|
|
844
|
+
def render
|
|
845
|
+
@provider.render(
|
|
846
|
+
preprocessed_messages,
|
|
847
|
+
tools: @tools,
|
|
848
|
+
server_tools: @server_tools,
|
|
849
|
+
tool_prefs: @tool_prefs,
|
|
850
|
+
temperature: @temperature,
|
|
851
|
+
max_output_tokens: @max_output_tokens,
|
|
852
|
+
model: @model,
|
|
853
|
+
provider_options: Support::Utils.deep_dup(@provider_options),
|
|
854
|
+
schema: @schema,
|
|
855
|
+
thinking: resolved_thinking,
|
|
856
|
+
citations: @citations,
|
|
857
|
+
caching: @caching,
|
|
858
|
+
compaction: @compaction,
|
|
859
|
+
end_user: @end_user,
|
|
860
|
+
protocol: @protocol,
|
|
861
|
+
before_request: @callbacks[:before_request]
|
|
862
|
+
)
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
# Refuses to stage a user message onto an unfinished tool round, which
|
|
866
|
+
# providers reject. Called by #ask_later here and in the Rails
|
|
867
|
+
# integration before it persists anything.
|
|
868
|
+
def raise_if_pending_tool_calls! # :nodoc:
|
|
869
|
+
response = pending_tool_response
|
|
870
|
+
return unless response
|
|
871
|
+
|
|
872
|
+
names = pending_tool_calls(response).values.map(&:name).uniq
|
|
873
|
+
raise PendingToolCallsError,
|
|
874
|
+
"The last response has unanswered tool calls (#{names.join(', ')}). " \
|
|
875
|
+
'Run complete, recording approve or deny decisions for calls that ' \
|
|
876
|
+
'require approval, before asking again.'
|
|
202
877
|
end
|
|
203
878
|
|
|
204
879
|
private
|
|
205
880
|
|
|
881
|
+
def resolved_thinking
|
|
882
|
+
@thinking&.resolve(@model)
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
def normalize_compaction(options)
|
|
886
|
+
compaction = options.to_h.transform_keys(&:to_sym)
|
|
887
|
+
unsupported = compaction.keys - COMPACTION_OPTIONS
|
|
888
|
+
return compaction.freeze if unsupported.empty?
|
|
889
|
+
|
|
890
|
+
raise ArgumentError,
|
|
891
|
+
"with_compaction accepts #{format_option_keys(COMPACTION_OPTIONS)}, " \
|
|
892
|
+
"got #{format_option_keys(unsupported)}. Provider-specific settings " \
|
|
893
|
+
'go through with_provider_options.'
|
|
894
|
+
end
|
|
895
|
+
|
|
896
|
+
def format_option_keys(keys)
|
|
897
|
+
keys.map { |key| ":#{key}" }.join(', ')
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
def message_list(new_messages)
|
|
901
|
+
return [] if new_messages.nil?
|
|
902
|
+
if new_messages.is_a?(Hash) || new_messages.is_a?(Message) || new_messages.respond_to?(:to_llm)
|
|
903
|
+
return [new_messages]
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
new_messages.respond_to?(:to_a) ? new_messages.to_a : [new_messages]
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
def coerce_message(message_or_attributes)
|
|
910
|
+
raise ArgumentError, 'Message cannot be nil' if message_or_attributes.nil?
|
|
911
|
+
|
|
912
|
+
message = if message_or_attributes.respond_to?(:to_llm)
|
|
913
|
+
message_or_attributes.to_llm
|
|
914
|
+
else
|
|
915
|
+
message_or_attributes
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
message = Message.new(message.merge(config: @config)) unless message.is_a?(Message)
|
|
919
|
+
message.conversation = self
|
|
920
|
+
message
|
|
921
|
+
end
|
|
922
|
+
|
|
206
923
|
def normalize_schema_payload(raw_schema)
|
|
207
924
|
return nil if raw_schema.nil?
|
|
208
925
|
return raw_schema unless raw_schema.is_a?(Hash)
|
|
209
926
|
|
|
210
|
-
schema = RubyLLM::Utils.deep_symbolize_keys(raw_schema)
|
|
927
|
+
schema = RubyLLM::Support::Utils.deep_symbolize_keys(raw_schema)
|
|
211
928
|
schema_def = extract_schema_definition(schema)
|
|
212
929
|
strict = extract_schema_strict(schema, schema_def)
|
|
213
930
|
build_schema_payload(schema, schema_def, strict)
|
|
214
931
|
end
|
|
215
932
|
|
|
216
933
|
def extract_schema_definition(schema)
|
|
217
|
-
RubyLLM::Utils.deep_dup(schema[:schema] || schema)
|
|
934
|
+
definition = RubyLLM::Support::Utils.deep_dup(schema[:schema] || schema)
|
|
935
|
+
RubyLLM::Support::Utils.strip_schema_metadata(definition)
|
|
218
936
|
end
|
|
219
937
|
|
|
220
938
|
def extract_schema_strict(schema, schema_def)
|
|
@@ -226,9 +944,9 @@ module RubyLLM
|
|
|
226
944
|
|
|
227
945
|
def build_schema_payload(schema, schema_def, strict)
|
|
228
946
|
{
|
|
229
|
-
name: sanitize_schema_name(schema[:name] || 'response'),
|
|
947
|
+
name: sanitize_schema_name(schema[:name] || schema[:title] || 'response'),
|
|
230
948
|
schema: schema_def,
|
|
231
|
-
strict: strict
|
|
949
|
+
strict: strict,
|
|
232
950
|
description: schema[:description]
|
|
233
951
|
}.compact
|
|
234
952
|
end
|
|
@@ -243,53 +961,373 @@ module RubyLLM
|
|
|
243
961
|
self
|
|
244
962
|
end
|
|
245
963
|
|
|
246
|
-
def
|
|
247
|
-
|
|
964
|
+
def raise_if_cancelled!
|
|
965
|
+
external_cancelled = @cancellation_checker&.call
|
|
966
|
+
return unless @cancelled || external_cancelled
|
|
967
|
+
|
|
968
|
+
@cancelled = false
|
|
969
|
+
raise CancelledError
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
def generate_once(stream_tracker: nil, usage_start: nil, &block)
|
|
973
|
+
raise_if_cancelled!
|
|
974
|
+
|
|
975
|
+
result = nil
|
|
976
|
+
entries_before = usage_entries.length
|
|
977
|
+
usage_start ||= entries_before
|
|
978
|
+
payload = instrumentation_payload(streaming: block_given?)
|
|
979
|
+
|
|
980
|
+
RubyLLM.instrument('chat.ruby_llm', payload, config: @config) do |event|
|
|
981
|
+
result = provider_completion(usage_recorder: method(:record_usage_entry), stream_tracker:, &block)
|
|
982
|
+
record_out_of_band_usage(result) if usage_entries.length == entries_before
|
|
983
|
+
record_generated_message(result, usage_start, streaming: block_given?)
|
|
984
|
+
record_completion_event(event, result)
|
|
985
|
+
end
|
|
986
|
+
result
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
def record_generated_message(result, usage_start, streaming: false)
|
|
990
|
+
raise_if_cancelled!
|
|
991
|
+
link_completion_usage(result, usage_start)
|
|
992
|
+
run_callbacks(:before_message) unless streaming
|
|
993
|
+
add_message result
|
|
994
|
+
run_callbacks(:after_message, result)
|
|
995
|
+
end
|
|
996
|
+
|
|
997
|
+
def instrumentation_payload(streaming:)
|
|
998
|
+
empty_tokens = Tokens.new
|
|
999
|
+
{
|
|
1000
|
+
chat: self,
|
|
1001
|
+
provider: @provider.slug,
|
|
1002
|
+
provider_class: @provider.class.display_name,
|
|
1003
|
+
model: @model.id,
|
|
1004
|
+
model_info: @model,
|
|
1005
|
+
input_messages: messages.dup,
|
|
1006
|
+
message_count: messages.size,
|
|
1007
|
+
tools: tools.keys,
|
|
1008
|
+
server_tools: server_tools,
|
|
1009
|
+
tool_choice: tool_prefs[:choice],
|
|
1010
|
+
tool_call_limit: tool_prefs[:calls],
|
|
1011
|
+
temperature: @temperature,
|
|
1012
|
+
max_output_tokens: @max_output_tokens,
|
|
1013
|
+
provider_options: provider_options,
|
|
1014
|
+
schema: schema,
|
|
1015
|
+
thinking: resolved_thinking,
|
|
1016
|
+
citations: @citations,
|
|
1017
|
+
caching: @caching,
|
|
1018
|
+
streaming: streaming,
|
|
1019
|
+
tokens: empty_tokens,
|
|
1020
|
+
cost: Cost.new(tokens: empty_tokens, model: @model)
|
|
1021
|
+
}
|
|
1022
|
+
end
|
|
248
1023
|
|
|
249
|
-
|
|
1024
|
+
def record_completion_event(event, result)
|
|
1025
|
+
event[:response] = result
|
|
1026
|
+
event[:messages_after] = messages.dup
|
|
1027
|
+
event[:response_role] = result.role if result.respond_to?(:role)
|
|
1028
|
+
event[:tokens] = result.tokens
|
|
1029
|
+
event[:cost] = result.cost
|
|
1030
|
+
return unless result.respond_to?(:tool_call?)
|
|
1031
|
+
|
|
1032
|
+
event[:response_model] = result.model
|
|
1033
|
+
event[:tool_call] = result.tool_call?
|
|
1034
|
+
event[:tool_calls] = result.tool_calls
|
|
1035
|
+
end
|
|
1036
|
+
|
|
1037
|
+
def generate_with_fallbacks(&block)
|
|
1038
|
+
fallback_queue = fallbacks.dup
|
|
1039
|
+
attempt = 0
|
|
1040
|
+
active_fallback = nil
|
|
1041
|
+
streaming = block_given?
|
|
1042
|
+
usage_start = usage_entries.length
|
|
1043
|
+
|
|
1044
|
+
loop do
|
|
1045
|
+
chunks_yielded = false
|
|
1046
|
+
|
|
1047
|
+
begin
|
|
1048
|
+
result = generate_once(stream_tracker: proc { chunks_yielded = true }, usage_start:, &block)
|
|
1049
|
+
finish_fallback(active_fallback, response: result)
|
|
1050
|
+
return result
|
|
1051
|
+
rescue StandardError => e
|
|
1052
|
+
finish_fallback(active_fallback, fallback_error: e)
|
|
1053
|
+
raise e unless fallback_error?(e)
|
|
1054
|
+
|
|
1055
|
+
active_fallback, attempt = fallback_to_next_model!(
|
|
1056
|
+
fallback_queue,
|
|
1057
|
+
error: e,
|
|
1058
|
+
attempt: attempt,
|
|
1059
|
+
streaming: streaming,
|
|
1060
|
+
chunks_yielded: chunks_yielded
|
|
1061
|
+
)
|
|
1062
|
+
end
|
|
1063
|
+
end
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
def with_model_restored
|
|
1067
|
+
original_model = @model
|
|
1068
|
+
original_provider = @provider
|
|
1069
|
+
original_connection = @connection
|
|
1070
|
+
original_protocol = @protocol
|
|
1071
|
+
|
|
1072
|
+
yield
|
|
1073
|
+
ensure
|
|
1074
|
+
@model = original_model
|
|
1075
|
+
@provider = original_provider
|
|
1076
|
+
@connection = original_connection
|
|
1077
|
+
@protocol = original_protocol
|
|
1078
|
+
end
|
|
1079
|
+
|
|
1080
|
+
def switch_to_fallback_model(fallback)
|
|
1081
|
+
from_provider = @provider.slug
|
|
1082
|
+
if fallback.model
|
|
1083
|
+
with_resolved_model(fallback.model)
|
|
1084
|
+
else
|
|
1085
|
+
with_model(fallback.id, provider: fallback.provider, protocol: @protocol)
|
|
1086
|
+
end
|
|
1087
|
+
@protocol = nil unless @provider.slug == from_provider
|
|
1088
|
+
self
|
|
1089
|
+
end
|
|
1090
|
+
|
|
1091
|
+
def with_resolved_model(model)
|
|
1092
|
+
provider_class = Provider.resolve!(model.provider)
|
|
1093
|
+
@model = model
|
|
1094
|
+
@provider = provider_class.new(@config)
|
|
1095
|
+
@connection = @provider.connection
|
|
250
1096
|
self
|
|
251
1097
|
end
|
|
252
1098
|
|
|
253
|
-
def
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
1099
|
+
def fallback_to_next_model!(fallback_queue, error:, attempt:, streaming:, chunks_yielded:)
|
|
1100
|
+
fallback = fallback_queue.shift
|
|
1101
|
+
raise error unless fallback
|
|
1102
|
+
|
|
1103
|
+
attempt += 1
|
|
1104
|
+
from_model = @model
|
|
1105
|
+
switch_to_fallback_model(fallback)
|
|
1106
|
+
fallback = fallback.with_attempt(
|
|
1107
|
+
chat: self,
|
|
1108
|
+
error: error,
|
|
1109
|
+
from: from_model,
|
|
1110
|
+
to: @model,
|
|
1111
|
+
attempt: attempt,
|
|
1112
|
+
streaming: streaming,
|
|
1113
|
+
chunks_yielded: chunks_yielded
|
|
1114
|
+
)
|
|
1115
|
+
run_callbacks(:before_fallback, fallback)
|
|
1116
|
+
[fallback, attempt]
|
|
1117
|
+
end
|
|
1118
|
+
|
|
1119
|
+
def finish_fallback(fallback, response: nil, fallback_error: nil)
|
|
1120
|
+
return unless fallback
|
|
1121
|
+
|
|
1122
|
+
fallback.finish(response: response, fallback_error: fallback_error)
|
|
1123
|
+
run_callbacks(:after_fallback, fallback)
|
|
1124
|
+
end
|
|
1125
|
+
|
|
1126
|
+
def fallback_error?(error)
|
|
1127
|
+
fallback_errors.any? { |error_class| error.is_a?(error_class) }
|
|
1128
|
+
end
|
|
1129
|
+
|
|
1130
|
+
# Preprocessing builds a per-request view of the conversation: the
|
|
1131
|
+
# provider can change through fallbacks or with_model, so history keeps
|
|
1132
|
+
# the original attachments while each provider's upload is memoized on
|
|
1133
|
+
# them. Reloaded Rails chats rebuild history from rows and upload again.
|
|
1134
|
+
def preprocessed_messages(list = messages)
|
|
1135
|
+
return list unless @provider
|
|
1136
|
+
|
|
1137
|
+
list.map { |message| @provider.preprocess_message(message, model: @model, protocol: @protocol) }
|
|
1138
|
+
end
|
|
1139
|
+
|
|
1140
|
+
def provider_completion(usage_recorder:, stream_tracker: nil, &)
|
|
1141
|
+
raise_if_cancelled!
|
|
1142
|
+
|
|
1143
|
+
@provider.complete(
|
|
1144
|
+
preprocessed_messages,
|
|
1145
|
+
tools: @tools,
|
|
1146
|
+
server_tools: @server_tools,
|
|
1147
|
+
tool_prefs: @tool_prefs,
|
|
1148
|
+
temperature: @temperature,
|
|
1149
|
+
max_output_tokens: @max_output_tokens,
|
|
1150
|
+
model: @model,
|
|
1151
|
+
provider_options: Support::Utils.deep_dup(@provider_options),
|
|
1152
|
+
headers: @headers,
|
|
1153
|
+
schema: @schema,
|
|
1154
|
+
thinking: resolved_thinking,
|
|
1155
|
+
citations: @citations,
|
|
1156
|
+
caching: @caching,
|
|
1157
|
+
compaction: @compaction,
|
|
1158
|
+
end_user: @end_user,
|
|
1159
|
+
protocol: @protocol,
|
|
1160
|
+
before_request: @callbacks[:before_request],
|
|
1161
|
+
usage_recorder: usage_recorder,
|
|
1162
|
+
&wrap_streaming_block(stream_tracker:, &)
|
|
1163
|
+
)
|
|
1164
|
+
end
|
|
1165
|
+
|
|
1166
|
+
def provider_compaction
|
|
1167
|
+
@provider.compact(
|
|
1168
|
+
preprocessed_messages, model: @model, protocol: @protocol,
|
|
1169
|
+
headers: @headers, before_request: @callbacks[:before_request],
|
|
1170
|
+
usage_recorder: method(:record_usage_entry)
|
|
257
1171
|
)
|
|
258
1172
|
end
|
|
259
1173
|
|
|
260
|
-
def
|
|
1174
|
+
def record_usage_entry(entry)
|
|
1175
|
+
usage_entries << entry
|
|
1176
|
+
@usage_recorder&.call(entry)
|
|
1177
|
+
entry
|
|
1178
|
+
end
|
|
1179
|
+
|
|
1180
|
+
def link_completion_usage(response, usage_start)
|
|
1181
|
+
response.ruby_llm_usage_entries = usage_entries.drop(usage_start)
|
|
1182
|
+
end
|
|
1183
|
+
|
|
1184
|
+
def record_out_of_band_usage(response)
|
|
1185
|
+
entry = Accounting::Usage::Entry.new(
|
|
1186
|
+
operation: :chat,
|
|
1187
|
+
provider: @provider.slug,
|
|
1188
|
+
model: response.model || @model.id,
|
|
1189
|
+
status: :succeeded,
|
|
1190
|
+
tokens: response.tokens,
|
|
1191
|
+
cost: response.cost,
|
|
1192
|
+
message: response
|
|
1193
|
+
)
|
|
1194
|
+
response.ruby_llm_usage_entries = [entry]
|
|
1195
|
+
Accounting::Usage.instrument(entry, config: @config)
|
|
1196
|
+
record_usage_entry(entry)
|
|
1197
|
+
end
|
|
1198
|
+
|
|
1199
|
+
def run_callbacks(name, *args)
|
|
261
1200
|
@callbacks[name].each { |callback| callback.call(*args) }
|
|
262
|
-
@on[legacy_name]&.call(*args)
|
|
263
1201
|
end
|
|
264
1202
|
|
|
265
|
-
def wrap_streaming_block(&block)
|
|
266
|
-
return nil unless
|
|
1203
|
+
def wrap_streaming_block(stream_tracker: nil, &block)
|
|
1204
|
+
return nil unless block
|
|
267
1205
|
|
|
268
|
-
run_callbacks(:before_message
|
|
1206
|
+
run_callbacks(:before_message)
|
|
269
1207
|
|
|
270
1208
|
proc do |chunk|
|
|
271
|
-
|
|
1209
|
+
raise_if_cancelled!
|
|
1210
|
+
stream_tracker&.call(chunk)
|
|
1211
|
+
block.call(chunk)
|
|
1212
|
+
raise_if_cancelled!
|
|
272
1213
|
end
|
|
273
1214
|
end
|
|
274
1215
|
|
|
275
|
-
def
|
|
276
|
-
|
|
1216
|
+
def execute_pending_tool_calls(response)
|
|
1217
|
+
raise_if_cancelled!
|
|
277
1218
|
|
|
278
|
-
response.
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
1219
|
+
server_calls, local_calls = pending_tool_calls(response).partition { |_, call| call.remote? }.map(&:to_h)
|
|
1220
|
+
respond_to_tool_approvals(server_calls)
|
|
1221
|
+
executable, denied = partition_pending_tool_calls(local_calls)
|
|
1222
|
+
deny_tool_calls(denied)
|
|
1223
|
+
if concurrency
|
|
1224
|
+
handle_concurrent_tool_calls(executable)
|
|
1225
|
+
else
|
|
1226
|
+
handle_sequential_tool_calls(executable)
|
|
1227
|
+
end
|
|
1228
|
+
|
|
1229
|
+
@tool_prefs[:choice] = nil if forced_tool_choice?
|
|
1230
|
+
end
|
|
287
1231
|
|
|
288
|
-
|
|
1232
|
+
def respond_to_tool_approvals(tool_calls)
|
|
1233
|
+
tool_calls.each_value do |tool_call|
|
|
1234
|
+
decision = tool_call_approval(nil, tool_call)
|
|
1235
|
+
next if decision.nil?
|
|
1236
|
+
|
|
1237
|
+
raise_if_cancelled!
|
|
1238
|
+
run_callbacks(:before_message)
|
|
1239
|
+
response = @provider.tool_approval_response(tool_call, approved: decision, model: @model, protocol: @protocol)
|
|
1240
|
+
message = add_message(response)
|
|
1241
|
+
run_callbacks(:after_message, message)
|
|
1242
|
+
end
|
|
1243
|
+
end
|
|
1244
|
+
|
|
1245
|
+
def partition_pending_tool_calls(pending)
|
|
1246
|
+
executable = {}
|
|
1247
|
+
denied = {}
|
|
1248
|
+
pending.each do |id, tool_call|
|
|
1249
|
+
tool = tools[tool_call.name.to_sym]
|
|
1250
|
+
if tool&.requires_approval?
|
|
1251
|
+
decision = tool_call_approval(tool, tool_call)
|
|
1252
|
+
next if decision.nil?
|
|
1253
|
+
|
|
1254
|
+
(decision ? executable : denied)[id] = tool_call
|
|
1255
|
+
elsif @tool_call_decisions[tool_call.id] == false
|
|
1256
|
+
denied[id] = tool_call
|
|
1257
|
+
else
|
|
1258
|
+
executable[id] = tool_call
|
|
1259
|
+
end
|
|
1260
|
+
end
|
|
1261
|
+
[executable, denied]
|
|
1262
|
+
end
|
|
1263
|
+
|
|
1264
|
+
def deny_tool_calls(tool_calls)
|
|
1265
|
+
tool_calls.each_value do |tool_call|
|
|
1266
|
+
raise_if_cancelled!
|
|
1267
|
+
run_callbacks(:before_message)
|
|
1268
|
+
add_tool_result_message(tool_call, { error: "The user denied the #{tool_call.name} tool call." })
|
|
289
1269
|
end
|
|
1270
|
+
end
|
|
1271
|
+
|
|
1272
|
+
def record_tool_call_decision(tool_call, decision)
|
|
1273
|
+
id = tool_call.respond_to?(:id) ? tool_call.id : tool_call
|
|
1274
|
+
@tool_call_decisions[id] = decision
|
|
1275
|
+
self
|
|
1276
|
+
end
|
|
290
1277
|
|
|
291
|
-
|
|
292
|
-
|
|
1278
|
+
def approval_pending?(tool_call)
|
|
1279
|
+
return tool_call_approval(nil, tool_call).nil? if tool_call.remote?
|
|
1280
|
+
|
|
1281
|
+
tool = tools[tool_call.name.to_sym]
|
|
1282
|
+
return false unless tool&.requires_approval?
|
|
1283
|
+
|
|
1284
|
+
tool_call_approval(tool, tool_call).nil?
|
|
1285
|
+
end
|
|
1286
|
+
|
|
1287
|
+
def tool_call_approval(tool, tool_call)
|
|
1288
|
+
return tool.approval_resolver.call(tool_call) if tool&.approval_resolver
|
|
1289
|
+
return @tool_call_decisions[tool_call.id] if @tool_call_decisions.key?(tool_call.id)
|
|
1290
|
+
|
|
1291
|
+
@approval_checker&.call(tool_call)
|
|
1292
|
+
end
|
|
1293
|
+
|
|
1294
|
+
def handle_sequential_tool_calls(tool_calls)
|
|
1295
|
+
tool_calls.each_value do |tool_call|
|
|
1296
|
+
raise_if_cancelled!
|
|
1297
|
+
run_callbacks(:before_message)
|
|
1298
|
+
result = execute_tool_with_callbacks(tool_call)
|
|
1299
|
+
add_tool_result_message(tool_call, result)
|
|
1300
|
+
end
|
|
1301
|
+
end
|
|
1302
|
+
|
|
1303
|
+
def handle_concurrent_tool_calls(tool_calls)
|
|
1304
|
+
execute_tools_concurrently(tool_calls) do |tool_call, result|
|
|
1305
|
+
raise_if_cancelled!
|
|
1306
|
+
run_callbacks(:before_message)
|
|
1307
|
+
add_tool_result_message(tool_call, result)
|
|
1308
|
+
end
|
|
1309
|
+
end
|
|
1310
|
+
|
|
1311
|
+
def execute_tools_concurrently(tool_calls, &on_result)
|
|
1312
|
+
ToolConcurrency.run(concurrency, tool_calls, on_result:) do |tool_call|
|
|
1313
|
+
execute_tool_with_callbacks(tool_call)
|
|
1314
|
+
end
|
|
1315
|
+
end
|
|
1316
|
+
|
|
1317
|
+
def execute_tool_with_callbacks(tool_call)
|
|
1318
|
+
raise_if_cancelled!
|
|
1319
|
+
run_callbacks(:before_tool_call, tool_call)
|
|
1320
|
+
result = execute_tool tool_call
|
|
1321
|
+
raise_if_cancelled!
|
|
1322
|
+
run_callbacks(:after_tool_result, result)
|
|
1323
|
+
result
|
|
1324
|
+
end
|
|
1325
|
+
|
|
1326
|
+
def add_tool_result_message(tool_call, result)
|
|
1327
|
+
content, attachments = Tool.split_result(result)
|
|
1328
|
+
message = add_message role: :tool, content:, attachments:, tool_call_id: tool_call.id
|
|
1329
|
+
run_callbacks(:after_message, message)
|
|
1330
|
+
message
|
|
293
1331
|
end
|
|
294
1332
|
|
|
295
1333
|
def execute_tool(tool_call)
|
|
@@ -302,11 +1340,32 @@ module RubyLLM
|
|
|
302
1340
|
end
|
|
303
1341
|
|
|
304
1342
|
args = tool_call.arguments
|
|
305
|
-
|
|
1343
|
+
payload = {
|
|
1344
|
+
chat: self,
|
|
1345
|
+
provider: @provider.slug,
|
|
1346
|
+
provider_class: @provider.class.display_name,
|
|
1347
|
+
model: @model.id,
|
|
1348
|
+
model_info: @model,
|
|
1349
|
+
tool: tool,
|
|
1350
|
+
tool_call: tool_call,
|
|
1351
|
+
tool_name: tool.name,
|
|
1352
|
+
tool_arguments: args,
|
|
1353
|
+
tool_call_id: tool_call.id
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
RubyLLM.instrument('tool_call.ruby_llm', payload, config: @config) do |event|
|
|
1357
|
+
result = tool.call(**args, tool_call: tool_call)
|
|
1358
|
+
event[:result] = result
|
|
1359
|
+
event[:result_content] = result
|
|
1360
|
+
event[:result_class] = result.class.name
|
|
1361
|
+
result
|
|
1362
|
+
end
|
|
306
1363
|
end
|
|
307
1364
|
|
|
308
|
-
def
|
|
309
|
-
|
|
1365
|
+
def apply_tool_choice(choice)
|
|
1366
|
+
if choice.nil?
|
|
1367
|
+
@tool_prefs[:choice] = nil
|
|
1368
|
+
else
|
|
310
1369
|
normalized_choice = normalize_tool_choice(choice)
|
|
311
1370
|
valid_tool_choices = %i[auto none required] + tools.keys
|
|
312
1371
|
unless valid_tool_choices.include?(normalized_choice)
|
|
@@ -316,8 +1375,18 @@ module RubyLLM
|
|
|
316
1375
|
|
|
317
1376
|
@tool_prefs[:choice] = normalized_choice
|
|
318
1377
|
end
|
|
1378
|
+
end
|
|
1379
|
+
|
|
1380
|
+
def normalize_tool_concurrency(concurrency)
|
|
1381
|
+
return nil if concurrency.nil? || concurrency == false
|
|
1382
|
+
return :threads if concurrency == true
|
|
319
1383
|
|
|
320
|
-
|
|
1384
|
+
normalized = concurrency.to_sym
|
|
1385
|
+
return normalized if ToolConcurrency::MODES.include?(normalized)
|
|
1386
|
+
|
|
1387
|
+
raise ArgumentError,
|
|
1388
|
+
"Unknown tool concurrency: #{concurrency.inspect}. " \
|
|
1389
|
+
"Available modes: #{ToolConcurrency::MODES.join(', ')}"
|
|
321
1390
|
end
|
|
322
1391
|
|
|
323
1392
|
def normalize_calls(calls)
|
|
@@ -341,52 +1410,41 @@ module RubyLLM
|
|
|
341
1410
|
def tool_name_for_choice_class(tool_class)
|
|
342
1411
|
matched_tool_name = tools.find { |_name, tool| tool.is_a?(tool_class) }&.first
|
|
343
1412
|
return matched_tool_name if matched_tool_name
|
|
1413
|
+
return tool_class.tool_name.to_sym if tool_class.respond_to?(:tool_name)
|
|
344
1414
|
|
|
345
|
-
|
|
346
|
-
end
|
|
347
|
-
|
|
348
|
-
def classify_tool_name(class_name)
|
|
349
|
-
class_name.split('::').last
|
|
350
|
-
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
351
|
-
.downcase
|
|
352
|
-
.to_sym
|
|
1415
|
+
tool_class.name.to_s.to_sym
|
|
353
1416
|
end
|
|
354
1417
|
|
|
355
1418
|
def forced_tool_choice?
|
|
356
1419
|
@tool_prefs[:choice] && !%i[auto none].include?(@tool_prefs[:choice])
|
|
357
1420
|
end
|
|
358
1421
|
|
|
359
|
-
def
|
|
360
|
-
|
|
1422
|
+
def last_non_system_message
|
|
1423
|
+
messages.reverse.find { |message| message.role != :system }
|
|
361
1424
|
end
|
|
362
1425
|
|
|
363
|
-
def
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
Content.new(message, attachments)
|
|
1426
|
+
def pending_tool_response
|
|
1427
|
+
response = messages.reverse.find { |message| message.role != :system && !message.tool_result? }
|
|
1428
|
+
response if response&.tool_call? && pending_tool_calls(response).any?
|
|
367
1429
|
end
|
|
368
1430
|
|
|
369
|
-
def
|
|
370
|
-
|
|
1431
|
+
def pending_tool_calls(response)
|
|
1432
|
+
answered = messages.filter_map { |message| message.tool_call_id if message.tool_result? }
|
|
1433
|
+
response.tool_calls.except(*answered)
|
|
371
1434
|
end
|
|
372
1435
|
|
|
373
|
-
def
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
1436
|
+
def inspect_attributes # :nodoc:
|
|
1437
|
+
{
|
|
1438
|
+
model: model.id,
|
|
1439
|
+
provider: provider.slug,
|
|
1440
|
+
messages: messages.count,
|
|
1441
|
+
tools: tools.keys,
|
|
1442
|
+
awaiting_approval: awaiting_approval_names
|
|
1443
|
+
}
|
|
377
1444
|
end
|
|
378
1445
|
|
|
379
|
-
def
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
if system_messages.empty?
|
|
383
|
-
system_messages = [Message.new(role: :system, content: instructions)]
|
|
384
|
-
else
|
|
385
|
-
system_messages.first.content = instructions
|
|
386
|
-
system_messages = [system_messages.first]
|
|
387
|
-
end
|
|
388
|
-
|
|
389
|
-
@messages = system_messages + non_system_messages
|
|
1446
|
+
def awaiting_approval_names
|
|
1447
|
+
pending_approvals.map(&:name).uniq
|
|
390
1448
|
end
|
|
391
1449
|
end
|
|
392
1450
|
end
|