ruby_llm 1.16.0 → 2.0.0.rc2
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 +85 -32
- 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 -8
- 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 +23 -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 +101 -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 -111
- data/lib/ruby_llm/active_record/attachment_helpers.rb +186 -0
- data/lib/ruby_llm/active_record/batch.rb +97 -0
- data/lib/ruby_llm/active_record/chat_methods.rb +828 -305
- data/lib/ruby_llm/active_record/message_methods.rb +113 -136
- 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 -151
- data/lib/ruby_llm/aliases.json +268 -100
- data/lib/ruby_llm/attachment.rb +187 -48
- 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 +1127 -198
- data/lib/ruby_llm/chunk.rb +10 -0
- data/lib/ruby_llm/citation.rb +105 -0
- data/lib/ruby_llm/configuration.rb +261 -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 +121 -17
- data/lib/ruby_llm/embedding_request.rb +53 -0
- data/lib/ruby_llm/error.rb +159 -23
- data/lib/ruby_llm/fallback.rb +133 -0
- data/lib/ruby_llm/files/mime_type.rb +97 -0
- data/lib/ruby_llm/image.rb +153 -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 +24 -5
- data/lib/ruby_llm/model/pricing_category.rb +103 -14
- data/lib/ruby_llm/model/pricing_tier.rb +55 -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 +73261 -33733
- data/lib/ruby_llm/models.rb +477 -215
- 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 +548 -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 +167 -0
- data/lib/ruby_llm/{providers → protocols}/anthropic/tools.rb +24 -41
- data/lib/ruby_llm/protocols/anthropic.rb +101 -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/{providers/openai → protocols/chat_completions}/media.rb +30 -17
- 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 +15 -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 +695 -0
- data/lib/ruby_llm/protocols/converse/media.rb +178 -0
- data/lib/ruby_llm/protocols/converse/streaming.rb +432 -0
- data/lib/ruby_llm/protocols/converse/thinking_stream.rb +56 -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 +17 -11
- 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 +560 -128
- 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 +5 -14
- 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 +60 -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 +193 -41
- data/lib/ruby_llm/providers/bedrock.rb +216 -45
- 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 -51
- data/lib/ruby_llm/providers/deepseek/chat.rb +49 -2
- 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 -16
- 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 -62
- 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 +6 -18
- data/lib/ruby_llm/providers/mistral/models.rb +57 -23
- 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 +16 -4
- data/lib/ruby_llm/providers/ollama/chat.rb +7 -13
- 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 -108
- 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 +2 -9
- data/lib/ruby_llm/providers/perplexity/embeddings.rb +32 -0
- data/lib/ruby_llm/providers/perplexity/media.rb +5 -21
- data/lib/ruby_llm/providers/perplexity/models.rb +80 -13
- data/lib/ruby_llm/providers/perplexity.rb +28 -20
- 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 +160 -17
- data/lib/ruby_llm/providers/xai/capabilities.rb +18 -0
- data/lib/ruby_llm/providers/xai/chat.rb +3 -2
- 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 -36
- 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 +15 -5
- data/lib/ruby_llm/railtie.rb +7 -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 +138 -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 +380 -6
- data/lib/tasks/ruby_llm.rake +21 -16
- data/skills/rubyllm/SKILL.md +81 -0
- data/skills/rubyllm/agents/openai.yaml +4 -0
- metadata +339 -97
- 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 -597
- 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 -41
- data/lib/ruby_llm/connection.rb +0 -159
- data/lib/ruby_llm/content.rb +0 -91
- data/lib/ruby_llm/deprecator.rb +0 -24
- data/lib/ruby_llm/error_middleware.rb +0 -81
- data/lib/ruby_llm/instrumentation.rb +0 -36
- data/lib/ruby_llm/mime_type.rb +0 -96
- data/lib/ruby_llm/model/info.rb +0 -164
- data/lib/ruby_llm/model_registry.rb +0 -39
- data/lib/ruby_llm/models_schema.json +0 -171
- data/lib/ruby_llm/providers/anthropic/chat.rb +0 -291
- 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 -59
- data/lib/ruby_llm/providers/anthropic/streaming.rb +0 -71
- data/lib/ruby_llm/providers/bedrock/chat.rb +0 -405
- data/lib/ruby_llm/providers/bedrock/media.rb +0 -108
- data/lib/ruby_llm/providers/bedrock/streaming.rb +0 -328
- data/lib/ruby_llm/providers/gemini/chat.rb +0 -542
- 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 -98
- data/lib/ruby_llm/providers/gemini/tools.rb +0 -234
- 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 -236
- 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/moderation.rb +0 -34
- data/lib/ruby_llm/providers/openai/streaming.rb +0 -55
- data/lib/ruby_llm/providers/openai/temperature.rb +0 -28
- data/lib/ruby_llm/providers/openai/transcription.rb +0 -71
- 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 -218
- data/lib/ruby_llm/streaming.rb +0 -179
- data/lib/ruby_llm/tool_concurrency.rb +0 -105
- data/lib/ruby_llm/utils.rb +0 -130
- data/lib/tasks/models.rake +0 -593
- data/lib/tasks/release.rake +0 -94
- data/lib/tasks/vcr.rake +0 -124
data/lib/ruby_llm/chat.rb
CHANGED
|
@@ -3,194 +3,936 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
|
|
5
5
|
module RubyLLM
|
|
6
|
-
#
|
|
7
|
-
|
|
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
|
|
8
25
|
include Enumerable
|
|
26
|
+
include Support::Inspectable
|
|
9
27
|
|
|
10
|
-
|
|
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
|
|
11
32
|
|
|
12
|
-
|
|
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
|
|
83
|
+
|
|
84
|
+
# Whether #with_citations asked the provider for citations.
|
|
85
|
+
attr_reader :citations
|
|
86
|
+
|
|
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)
|
|
13
110
|
if assume_model_exists && !provider
|
|
14
111
|
raise ArgumentError, 'Provider must be specified if assume_model_exists is true'
|
|
15
112
|
end
|
|
16
113
|
|
|
17
114
|
@context = context
|
|
18
115
|
@config = context&.config || RubyLLM.config
|
|
19
|
-
|
|
20
|
-
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)
|
|
21
117
|
@temperature = nil
|
|
118
|
+
@max_output_tokens = nil
|
|
22
119
|
@messages = []
|
|
120
|
+
@usage_entries = []
|
|
23
121
|
@tools = {}
|
|
122
|
+
@server_tools = []
|
|
24
123
|
@tool_prefs = { choice: nil, calls: nil }
|
|
25
124
|
@concurrency = normalize_tool_concurrency(@config.tool_concurrency)
|
|
26
|
-
@
|
|
125
|
+
@provider_options = {}
|
|
27
126
|
@headers = {}
|
|
28
127
|
@schema = nil
|
|
29
128
|
@thinking = nil
|
|
30
|
-
@
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
129
|
+
@citations = false
|
|
130
|
+
@caching = nil
|
|
131
|
+
@compaction = nil
|
|
132
|
+
@end_user = nil
|
|
133
|
+
@fallbacks = []
|
|
134
|
+
@fallback_errors = Fallback::DEFAULT_ERRORS
|
|
36
135
|
@callbacks = Hash.new { |callbacks, name| callbacks[name] = [] }
|
|
136
|
+
@cancelled = false
|
|
137
|
+
@cancellation_checker = nil
|
|
138
|
+
@tool_call_decisions = {}
|
|
139
|
+
@approval_checker = nil
|
|
37
140
|
end
|
|
38
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
|
+
#
|
|
39
153
|
def ask(message = nil, with: nil, &)
|
|
40
|
-
|
|
154
|
+
ask_later(message, with: with)
|
|
41
155
|
complete(&)
|
|
42
156
|
end
|
|
43
157
|
|
|
44
158
|
alias say ask
|
|
45
159
|
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
48
176
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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?
|
|
53
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
|
|
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
|
|
301
|
+
self
|
|
302
|
+
end
|
|
54
303
|
|
|
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
|
|
55
323
|
self
|
|
56
324
|
end
|
|
57
325
|
|
|
58
|
-
|
|
59
|
-
|
|
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|
|
|
60
340
|
tool_instance = tool.is_a?(Class) ? tool.new : tool
|
|
61
341
|
@tools[tool_instance.name.to_sym] = tool_instance
|
|
62
342
|
end
|
|
63
|
-
update_tool_options(choice:, calls:)
|
|
64
|
-
update_tool_concurrency(concurrency)
|
|
65
343
|
self
|
|
66
344
|
end
|
|
67
345
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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)
|
|
73
372
|
self
|
|
74
373
|
end
|
|
75
374
|
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
397
|
+
self
|
|
398
|
+
end
|
|
399
|
+
|
|
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)
|
|
78
420
|
@connection = @provider.connection
|
|
421
|
+
@protocol = protocol
|
|
79
422
|
self
|
|
80
423
|
end
|
|
81
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
|
|
437
|
+
self
|
|
438
|
+
end
|
|
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
|
+
#
|
|
82
445
|
def with_temperature(temperature)
|
|
83
446
|
@temperature = temperature
|
|
84
447
|
self
|
|
85
448
|
end
|
|
86
449
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
91
458
|
self
|
|
92
459
|
end
|
|
93
460
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
98
494
|
self
|
|
99
495
|
end
|
|
100
496
|
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
103
523
|
self
|
|
104
524
|
end
|
|
105
525
|
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
108
547
|
self
|
|
109
548
|
end
|
|
110
549
|
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
113
579
|
|
|
114
|
-
@
|
|
115
|
-
|
|
116
|
-
|
|
580
|
+
@compaction = options == false ? false : normalize_compaction(options)
|
|
581
|
+
self
|
|
582
|
+
end
|
|
117
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
|
|
118
595
|
self
|
|
119
596
|
end
|
|
120
597
|
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
123
606
|
end
|
|
124
607
|
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
127
617
|
end
|
|
128
618
|
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
131
627
|
end
|
|
132
628
|
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
135
652
|
end
|
|
136
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+.
|
|
137
657
|
def before_message(&)
|
|
138
658
|
add_callback(:before_message, &)
|
|
139
659
|
end
|
|
140
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
|
+
#
|
|
141
666
|
def after_message(&)
|
|
142
667
|
add_callback(:after_message, &)
|
|
143
668
|
end
|
|
144
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
|
+
#
|
|
145
675
|
def before_tool_call(&)
|
|
146
676
|
add_callback(:before_tool_call, &)
|
|
147
677
|
end
|
|
148
678
|
|
|
679
|
+
# Registers a callback that receives each local tool's result after
|
|
680
|
+
# execution. Returns +self+.
|
|
149
681
|
def after_tool_result(&)
|
|
150
682
|
add_callback(:after_tool_result, &)
|
|
151
683
|
end
|
|
152
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.
|
|
153
711
|
def each(&)
|
|
154
712
|
messages.each(&)
|
|
155
713
|
end
|
|
156
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
|
+
#
|
|
157
729
|
def cost
|
|
158
|
-
Cost.aggregate(
|
|
730
|
+
Cost.aggregate(usage_entries.map(&:cost), complete: usage_entries.all?(&:cost_available?))
|
|
159
731
|
end
|
|
160
732
|
|
|
161
|
-
|
|
162
|
-
|
|
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,
|
|
750
|
+
tools: @tools,
|
|
751
|
+
tool_prefs: @tool_prefs,
|
|
752
|
+
thinking: resolved_thinking,
|
|
753
|
+
schema: @schema,
|
|
754
|
+
citations: @citations,
|
|
755
|
+
caching: @caching,
|
|
756
|
+
protocol: @protocol
|
|
757
|
+
)
|
|
758
|
+
end
|
|
759
|
+
|
|
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
|
|
784
|
+
end
|
|
163
785
|
end
|
|
164
786
|
|
|
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
|
|
793
|
+
|
|
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)
|
|
798
|
+
end
|
|
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
|
+
#
|
|
165
809
|
def add_message(message_or_attributes)
|
|
166
|
-
message =
|
|
810
|
+
message = coerce_message(message_or_attributes)
|
|
167
811
|
messages << message
|
|
168
812
|
message
|
|
169
813
|
end
|
|
170
814
|
|
|
171
|
-
#
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
825
|
+
end
|
|
826
|
+
|
|
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
|
+
)
|
|
174
863
|
end
|
|
175
864
|
|
|
176
|
-
|
|
177
|
-
|
|
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.'
|
|
178
877
|
end
|
|
179
878
|
|
|
180
879
|
private
|
|
181
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
|
+
|
|
182
923
|
def normalize_schema_payload(raw_schema)
|
|
183
924
|
return nil if raw_schema.nil?
|
|
184
925
|
return raw_schema unless raw_schema.is_a?(Hash)
|
|
185
926
|
|
|
186
|
-
schema = RubyLLM::Utils.deep_symbolize_keys(raw_schema)
|
|
927
|
+
schema = RubyLLM::Support::Utils.deep_symbolize_keys(raw_schema)
|
|
187
928
|
schema_def = extract_schema_definition(schema)
|
|
188
929
|
strict = extract_schema_strict(schema, schema_def)
|
|
189
930
|
build_schema_payload(schema, schema_def, strict)
|
|
190
931
|
end
|
|
191
932
|
|
|
192
933
|
def extract_schema_definition(schema)
|
|
193
|
-
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)
|
|
194
936
|
end
|
|
195
937
|
|
|
196
938
|
def extract_schema_strict(schema, schema_def)
|
|
@@ -202,9 +944,9 @@ module RubyLLM
|
|
|
202
944
|
|
|
203
945
|
def build_schema_payload(schema, schema_def, strict)
|
|
204
946
|
{
|
|
205
|
-
name: sanitize_schema_name(schema[:name] || 'response'),
|
|
947
|
+
name: sanitize_schema_name(schema[:name] || schema[:title] || 'response'),
|
|
206
948
|
schema: schema_def,
|
|
207
|
-
strict: strict
|
|
949
|
+
strict: strict,
|
|
208
950
|
description: schema[:description]
|
|
209
951
|
}.compact
|
|
210
952
|
end
|
|
@@ -219,150 +961,351 @@ module RubyLLM
|
|
|
219
961
|
self
|
|
220
962
|
end
|
|
221
963
|
|
|
222
|
-
def
|
|
223
|
-
|
|
964
|
+
def raise_if_cancelled!
|
|
965
|
+
external_cancelled = @cancellation_checker&.call
|
|
966
|
+
return unless @cancelled || external_cancelled
|
|
224
967
|
|
|
225
|
-
|
|
968
|
+
@cancelled = false
|
|
969
|
+
raise CancelledError
|
|
970
|
+
end
|
|
226
971
|
|
|
227
|
-
|
|
972
|
+
def generate_once(stream_tracker: nil, usage_start: nil, &block)
|
|
973
|
+
raise_if_cancelled!
|
|
228
974
|
|
|
229
|
-
|
|
230
|
-
|
|
975
|
+
result = nil
|
|
976
|
+
entries_before = usage_entries.length
|
|
977
|
+
usage_start ||= entries_before
|
|
978
|
+
payload = instrumentation_payload(streaming: block_given?)
|
|
231
979
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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)
|
|
236
985
|
end
|
|
986
|
+
result
|
|
237
987
|
end
|
|
238
988
|
|
|
239
|
-
def
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
+
{
|
|
243
1000
|
chat: self,
|
|
244
1001
|
provider: @provider.slug,
|
|
245
|
-
provider_class: @provider.class.
|
|
1002
|
+
provider_class: @provider.class.display_name,
|
|
246
1003
|
model: @model.id,
|
|
247
1004
|
model_info: @model,
|
|
248
1005
|
input_messages: messages.dup,
|
|
249
1006
|
message_count: messages.size,
|
|
250
1007
|
tools: tools.keys,
|
|
1008
|
+
server_tools: server_tools,
|
|
251
1009
|
tool_choice: tool_prefs[:choice],
|
|
252
1010
|
tool_call_limit: tool_prefs[:calls],
|
|
253
1011
|
temperature: @temperature,
|
|
254
|
-
|
|
1012
|
+
max_output_tokens: @max_output_tokens,
|
|
1013
|
+
provider_options: provider_options,
|
|
255
1014
|
schema: schema,
|
|
256
|
-
thinking:
|
|
257
|
-
|
|
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)
|
|
258
1021
|
}
|
|
1022
|
+
end
|
|
259
1023
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
+
)
|
|
275
1062
|
end
|
|
276
1063
|
end
|
|
277
|
-
result
|
|
278
1064
|
end
|
|
279
1065
|
|
|
280
|
-
def
|
|
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
|
|
1096
|
+
self
|
|
1097
|
+
end
|
|
1098
|
+
|
|
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
|
+
|
|
281
1143
|
@provider.complete(
|
|
282
|
-
|
|
1144
|
+
preprocessed_messages,
|
|
283
1145
|
tools: @tools,
|
|
1146
|
+
server_tools: @server_tools,
|
|
284
1147
|
tool_prefs: @tool_prefs,
|
|
285
1148
|
temperature: @temperature,
|
|
1149
|
+
max_output_tokens: @max_output_tokens,
|
|
286
1150
|
model: @model,
|
|
287
|
-
|
|
1151
|
+
provider_options: Support::Utils.deep_dup(@provider_options),
|
|
288
1152
|
headers: @headers,
|
|
289
1153
|
schema: @schema,
|
|
290
|
-
thinking:
|
|
291
|
-
|
|
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:, &)
|
|
292
1163
|
)
|
|
293
1164
|
end
|
|
294
1165
|
|
|
295
|
-
def
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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)
|
|
1171
|
+
)
|
|
301
1172
|
end
|
|
302
1173
|
|
|
303
|
-
def
|
|
304
|
-
|
|
1174
|
+
def record_usage_entry(entry)
|
|
1175
|
+
usage_entries << entry
|
|
1176
|
+
@usage_recorder&.call(entry)
|
|
1177
|
+
entry
|
|
1178
|
+
end
|
|
305
1179
|
|
|
306
|
-
|
|
307
|
-
|
|
1180
|
+
def link_completion_usage(response, usage_start)
|
|
1181
|
+
response.ruby_llm_usage_entries = usage_entries.drop(usage_start)
|
|
308
1182
|
end
|
|
309
1183
|
|
|
310
|
-
def
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
314
1193
|
)
|
|
1194
|
+
response.ruby_llm_usage_entries = [entry]
|
|
1195
|
+
Accounting::Usage.instrument(entry, config: @config)
|
|
1196
|
+
record_usage_entry(entry)
|
|
315
1197
|
end
|
|
316
1198
|
|
|
317
|
-
def run_callbacks(name,
|
|
1199
|
+
def run_callbacks(name, *args)
|
|
318
1200
|
@callbacks[name].each { |callback| callback.call(*args) }
|
|
319
|
-
@on[legacy_name]&.call(*args)
|
|
320
1201
|
end
|
|
321
1202
|
|
|
322
|
-
def wrap_streaming_block(&block)
|
|
323
|
-
return nil unless
|
|
1203
|
+
def wrap_streaming_block(stream_tracker: nil, &block)
|
|
1204
|
+
return nil unless block
|
|
324
1205
|
|
|
325
|
-
run_callbacks(:before_message
|
|
1206
|
+
run_callbacks(:before_message)
|
|
326
1207
|
|
|
327
1208
|
proc do |chunk|
|
|
328
|
-
|
|
1209
|
+
raise_if_cancelled!
|
|
1210
|
+
stream_tracker&.call(chunk)
|
|
1211
|
+
block.call(chunk)
|
|
1212
|
+
raise_if_cancelled!
|
|
329
1213
|
end
|
|
330
1214
|
end
|
|
331
1215
|
|
|
332
|
-
def
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
1216
|
+
def execute_pending_tool_calls(response)
|
|
1217
|
+
raise_if_cancelled!
|
|
1218
|
+
|
|
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
|
|
338
1228
|
|
|
339
|
-
|
|
340
|
-
halt_result || complete(&)
|
|
1229
|
+
@tool_prefs[:choice] = nil if forced_tool_choice?
|
|
341
1230
|
end
|
|
342
1231
|
|
|
343
|
-
def
|
|
344
|
-
|
|
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." })
|
|
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
|
|
345
1277
|
|
|
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)
|
|
346
1295
|
tool_calls.each_value do |tool_call|
|
|
347
|
-
|
|
1296
|
+
raise_if_cancelled!
|
|
1297
|
+
run_callbacks(:before_message)
|
|
348
1298
|
result = execute_tool_with_callbacks(tool_call)
|
|
349
1299
|
add_tool_result_message(tool_call, result)
|
|
350
|
-
halt_result = result if result.is_a?(Tool::Halt)
|
|
351
1300
|
end
|
|
352
|
-
|
|
353
|
-
halt_result
|
|
354
1301
|
end
|
|
355
1302
|
|
|
356
1303
|
def handle_concurrent_tool_calls(tool_calls)
|
|
357
|
-
halt_result = nil
|
|
358
|
-
|
|
359
1304
|
execute_tools_concurrently(tool_calls) do |tool_call, result|
|
|
360
|
-
|
|
1305
|
+
raise_if_cancelled!
|
|
1306
|
+
run_callbacks(:before_message)
|
|
361
1307
|
add_tool_result_message(tool_call, result)
|
|
362
|
-
halt_result = result if result.is_a?(Tool::Halt)
|
|
363
1308
|
end
|
|
364
|
-
|
|
365
|
-
halt_result
|
|
366
1309
|
end
|
|
367
1310
|
|
|
368
1311
|
def execute_tools_concurrently(tool_calls, &on_result)
|
|
@@ -372,17 +1315,18 @@ module RubyLLM
|
|
|
372
1315
|
end
|
|
373
1316
|
|
|
374
1317
|
def execute_tool_with_callbacks(tool_call)
|
|
375
|
-
|
|
1318
|
+
raise_if_cancelled!
|
|
1319
|
+
run_callbacks(:before_tool_call, tool_call)
|
|
376
1320
|
result = execute_tool tool_call
|
|
377
|
-
|
|
1321
|
+
raise_if_cancelled!
|
|
1322
|
+
run_callbacks(:after_tool_result, result)
|
|
378
1323
|
result
|
|
379
1324
|
end
|
|
380
1325
|
|
|
381
1326
|
def add_tool_result_message(tool_call, result)
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
run_callbacks(:after_message, :end_message, message)
|
|
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)
|
|
386
1330
|
message
|
|
387
1331
|
end
|
|
388
1332
|
|
|
@@ -399,7 +1343,7 @@ module RubyLLM
|
|
|
399
1343
|
payload = {
|
|
400
1344
|
chat: self,
|
|
401
1345
|
provider: @provider.slug,
|
|
402
|
-
provider_class: @provider.class.
|
|
1346
|
+
provider_class: @provider.class.display_name,
|
|
403
1347
|
model: @model.id,
|
|
404
1348
|
model_info: @model,
|
|
405
1349
|
tool: tool,
|
|
@@ -410,16 +1354,18 @@ module RubyLLM
|
|
|
410
1354
|
}
|
|
411
1355
|
|
|
412
1356
|
RubyLLM.instrument('tool_call.ruby_llm', payload, config: @config) do |event|
|
|
413
|
-
result = tool.call(args)
|
|
1357
|
+
result = tool.call(**args, tool_call: tool_call)
|
|
414
1358
|
event[:result] = result
|
|
415
|
-
event[:result_content] = result
|
|
1359
|
+
event[:result_content] = result
|
|
416
1360
|
event[:result_class] = result.class.name
|
|
417
1361
|
result
|
|
418
1362
|
end
|
|
419
1363
|
end
|
|
420
1364
|
|
|
421
|
-
def
|
|
422
|
-
|
|
1365
|
+
def apply_tool_choice(choice)
|
|
1366
|
+
if choice.nil?
|
|
1367
|
+
@tool_prefs[:choice] = nil
|
|
1368
|
+
else
|
|
423
1369
|
normalized_choice = normalize_tool_choice(choice)
|
|
424
1370
|
valid_tool_choices = %i[auto none required] + tools.keys
|
|
425
1371
|
unless valid_tool_choices.include?(normalized_choice)
|
|
@@ -429,12 +1375,6 @@ module RubyLLM
|
|
|
429
1375
|
|
|
430
1376
|
@tool_prefs[:choice] = normalized_choice
|
|
431
1377
|
end
|
|
432
|
-
|
|
433
|
-
@tool_prefs[:calls] = normalize_calls(calls) unless calls.nil?
|
|
434
|
-
end
|
|
435
|
-
|
|
436
|
-
def update_tool_concurrency(concurrency)
|
|
437
|
-
@concurrency = normalize_tool_concurrency(concurrency)
|
|
438
1378
|
end
|
|
439
1379
|
|
|
440
1380
|
def normalize_tool_concurrency(concurrency)
|
|
@@ -442,11 +1382,11 @@ module RubyLLM
|
|
|
442
1382
|
return :threads if concurrency == true
|
|
443
1383
|
|
|
444
1384
|
normalized = concurrency.to_sym
|
|
445
|
-
return normalized if ToolConcurrency.
|
|
1385
|
+
return normalized if ToolConcurrency::MODES.include?(normalized)
|
|
446
1386
|
|
|
447
1387
|
raise ArgumentError,
|
|
448
1388
|
"Unknown tool concurrency: #{concurrency.inspect}. " \
|
|
449
|
-
"Available modes: #{ToolConcurrency.
|
|
1389
|
+
"Available modes: #{ToolConcurrency::MODES.join(', ')}"
|
|
450
1390
|
end
|
|
451
1391
|
|
|
452
1392
|
def normalize_calls(calls)
|
|
@@ -470,52 +1410,41 @@ module RubyLLM
|
|
|
470
1410
|
def tool_name_for_choice_class(tool_class)
|
|
471
1411
|
matched_tool_name = tools.find { |_name, tool| tool.is_a?(tool_class) }&.first
|
|
472
1412
|
return matched_tool_name if matched_tool_name
|
|
1413
|
+
return tool_class.tool_name.to_sym if tool_class.respond_to?(:tool_name)
|
|
473
1414
|
|
|
474
|
-
|
|
475
|
-
end
|
|
476
|
-
|
|
477
|
-
def classify_tool_name(class_name)
|
|
478
|
-
class_name.split('::').last
|
|
479
|
-
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
480
|
-
.downcase
|
|
481
|
-
.to_sym
|
|
1415
|
+
tool_class.name.to_s.to_sym
|
|
482
1416
|
end
|
|
483
1417
|
|
|
484
1418
|
def forced_tool_choice?
|
|
485
1419
|
@tool_prefs[:choice] && !%i[auto none].include?(@tool_prefs[:choice])
|
|
486
1420
|
end
|
|
487
1421
|
|
|
488
|
-
def
|
|
489
|
-
|
|
1422
|
+
def last_non_system_message
|
|
1423
|
+
messages.reverse.find { |message| message.role != :system }
|
|
490
1424
|
end
|
|
491
1425
|
|
|
492
|
-
def
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
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?
|
|
496
1429
|
end
|
|
497
1430
|
|
|
498
|
-
def
|
|
499
|
-
|
|
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)
|
|
500
1434
|
end
|
|
501
1435
|
|
|
502
|
-
def
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
+
}
|
|
506
1444
|
end
|
|
507
1445
|
|
|
508
|
-
def
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
if system_messages.empty?
|
|
512
|
-
system_messages = [Message.new(role: :system, content: instructions)]
|
|
513
|
-
else
|
|
514
|
-
system_messages.first.content = instructions
|
|
515
|
-
system_messages = [system_messages.first]
|
|
516
|
-
end
|
|
517
|
-
|
|
518
|
-
@messages = system_messages + non_system_messages
|
|
1446
|
+
def awaiting_approval_names
|
|
1447
|
+
pending_approvals.map(&:name).uniq
|
|
519
1448
|
end
|
|
520
1449
|
end
|
|
521
1450
|
end
|