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