ruby_llm 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +80 -133
  3. data/lib/ruby_llm/active_record/acts_as.rb +144 -47
  4. data/lib/ruby_llm/aliases.json +187 -17
  5. data/lib/ruby_llm/attachment.rb +164 -0
  6. data/lib/ruby_llm/chat.rb +31 -20
  7. data/lib/ruby_llm/configuration.rb +34 -1
  8. data/lib/ruby_llm/connection.rb +121 -0
  9. data/lib/ruby_llm/content.rb +27 -79
  10. data/lib/ruby_llm/context.rb +30 -0
  11. data/lib/ruby_llm/embedding.rb +13 -5
  12. data/lib/ruby_llm/error.rb +2 -1
  13. data/lib/ruby_llm/image.rb +15 -8
  14. data/lib/ruby_llm/message.rb +14 -6
  15. data/lib/ruby_llm/mime_type.rb +67 -0
  16. data/lib/ruby_llm/model/info.rb +101 -0
  17. data/lib/ruby_llm/model/modalities.rb +22 -0
  18. data/lib/ruby_llm/model/pricing.rb +51 -0
  19. data/lib/ruby_llm/model/pricing_category.rb +48 -0
  20. data/lib/ruby_llm/model/pricing_tier.rb +34 -0
  21. data/lib/ruby_llm/model.rb +7 -0
  22. data/lib/ruby_llm/models.json +26279 -2362
  23. data/lib/ruby_llm/models.rb +95 -14
  24. data/lib/ruby_llm/provider.rb +48 -90
  25. data/lib/ruby_llm/providers/anthropic/capabilities.rb +76 -13
  26. data/lib/ruby_llm/providers/anthropic/chat.rb +7 -14
  27. data/lib/ruby_llm/providers/anthropic/media.rb +49 -28
  28. data/lib/ruby_llm/providers/anthropic/models.rb +16 -16
  29. data/lib/ruby_llm/providers/anthropic/tools.rb +2 -2
  30. data/lib/ruby_llm/providers/anthropic.rb +3 -3
  31. data/lib/ruby_llm/providers/bedrock/capabilities.rb +61 -2
  32. data/lib/ruby_llm/providers/bedrock/chat.rb +30 -73
  33. data/lib/ruby_llm/providers/bedrock/media.rb +59 -0
  34. data/lib/ruby_llm/providers/bedrock/models.rb +50 -58
  35. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +16 -0
  36. data/lib/ruby_llm/providers/bedrock.rb +14 -25
  37. data/lib/ruby_llm/providers/deepseek/capabilities.rb +35 -2
  38. data/lib/ruby_llm/providers/deepseek.rb +3 -3
  39. data/lib/ruby_llm/providers/gemini/capabilities.rb +84 -3
  40. data/lib/ruby_llm/providers/gemini/chat.rb +8 -37
  41. data/lib/ruby_llm/providers/gemini/embeddings.rb +18 -34
  42. data/lib/ruby_llm/providers/gemini/images.rb +4 -3
  43. data/lib/ruby_llm/providers/gemini/media.rb +28 -111
  44. data/lib/ruby_llm/providers/gemini/models.rb +17 -23
  45. data/lib/ruby_llm/providers/gemini/tools.rb +1 -1
  46. data/lib/ruby_llm/providers/gemini.rb +3 -3
  47. data/lib/ruby_llm/providers/ollama/chat.rb +28 -0
  48. data/lib/ruby_llm/providers/ollama/media.rb +48 -0
  49. data/lib/ruby_llm/providers/ollama.rb +34 -0
  50. data/lib/ruby_llm/providers/openai/capabilities.rb +78 -3
  51. data/lib/ruby_llm/providers/openai/chat.rb +6 -4
  52. data/lib/ruby_llm/providers/openai/embeddings.rb +8 -12
  53. data/lib/ruby_llm/providers/openai/images.rb +3 -2
  54. data/lib/ruby_llm/providers/openai/media.rb +48 -21
  55. data/lib/ruby_llm/providers/openai/models.rb +17 -18
  56. data/lib/ruby_llm/providers/openai/tools.rb +9 -5
  57. data/lib/ruby_llm/providers/openai.rb +7 -5
  58. data/lib/ruby_llm/providers/openrouter/models.rb +88 -0
  59. data/lib/ruby_llm/providers/openrouter.rb +31 -0
  60. data/lib/ruby_llm/stream_accumulator.rb +4 -4
  61. data/lib/ruby_llm/streaming.rb +48 -13
  62. data/lib/ruby_llm/utils.rb +27 -0
  63. data/lib/ruby_llm/version.rb +1 -1
  64. data/lib/ruby_llm.rb +15 -5
  65. data/lib/tasks/aliases.rake +235 -0
  66. data/lib/tasks/models_docs.rake +164 -121
  67. data/lib/tasks/models_update.rake +79 -0
  68. data/lib/tasks/release.rake +32 -0
  69. data/lib/tasks/vcr.rake +4 -2
  70. metadata +56 -32
  71. data/lib/ruby_llm/model_info.rb +0 -56
  72. data/lib/tasks/browser_helper.rb +0 -97
  73. data/lib/tasks/capability_generator.rb +0 -123
  74. data/lib/tasks/capability_scraper.rb +0 -224
  75. data/lib/tasks/cli_helper.rb +0 -22
  76. data/lib/tasks/code_validator.rb +0 -29
  77. data/lib/tasks/model_updater.rb +0 -66
  78. data/lib/tasks/models.rake +0 -43
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2f06ce431337dc189e6172b0c98ed897fdba930200f3f118c39c15f4527ec135
4
- data.tar.gz: 18f8ff36e7ee18cbee315e66db4b8f04619c98595a5de3b73d215bed248ca0d4
3
+ metadata.gz: 8a1f6385e98e9396c7f9d8021bd2e574f5f8c8aa1cc05c7f10311e2059101017
4
+ data.tar.gz: e7617280adc17488f9dbc810b424b22ee21f36c0684f0a5f37762dd290d6c0de
5
5
  SHA512:
6
- metadata.gz: 42f7603cfec24fa6cc59b1186d2d6a90af9e9076eb79124ac5ce09d73000fbcdb931ab90cafe21bf95b39417a52ff000ca9d02ba51c8a78877d1a1f47b70866f
7
- data.tar.gz: 8513b6774ef3d745e7bbc8947f856608d13ade163ca658139c2992e923ad08d5ee00b99275cbfe762591182897b73db7bfc879fae8db7b8ce2f3ba1fea5ee235
6
+ metadata.gz: 80bcef1cb440519b7eb146bd4064f8eb5a40cf1fd5e309ac582c6be8a3aee0a33b244e8c4961bc69ac763b2665adab918238a0d8787b41c2fb6e5e7fd6343ae3
7
+ data.tar.gz: 3d95b550d6d879ad29e20bb328cb09d7191b4055180cba95e237ddab2d1173845463d62e407c777d6b76425958531fb6e7abc664939478c9e89b41565971ad2b
data/README.md CHANGED
@@ -1,26 +1,37 @@
1
1
  <img src="/docs/assets/images/logotype.svg" alt="RubyLLM" height="120" width="250">
2
2
 
3
- A delightful Ruby way to work with AI. No configuration madness, no complex callbacks, no handler helljust beautiful, expressive Ruby code.
4
-
5
- <div style="display: flex; align-items: center; flex-wrap: wrap; margin-bottom: 1em">
6
- <img src="https://upload.wikimedia.org/wikipedia/commons/4/4d/OpenAI_Logo.svg" alt="OpenAI" height="40" width="120">
7
- &nbsp;&nbsp;
8
- <img src="https://upload.wikimedia.org/wikipedia/commons/7/78/Anthropic_logo.svg" alt="Anthropic" height="40" width="120">
9
- &nbsp;&nbsp;
10
- <img src="https://upload.wikimedia.org/wikipedia/commons/8/8a/Google_Gemini_logo.svg" alt="Google" height="40" width="120">
11
- &nbsp;&nbsp;
12
- <img src="https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/bedrock-color.svg" alt="Bedrock" height="40">
13
- <img src="https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/bedrock-text.svg" alt="Bedrock" height="40" width="120">
14
- &nbsp;&nbsp;
15
- <img src="https://upload.wikimedia.org/wikipedia/commons/e/ec/DeepSeek_logo.svg" alt="DeepSeek" height="40" width="120">
3
+ **A delightful Ruby way to work with AI.** RubyLLM provides **one** beautiful, Ruby-like interface to interact with modern AI models. Chat, generate images, create embeddings, and use tools all with clean, expressive code that feels like Ruby, not like patching together multiple services.
4
+
5
+ <div class="provider-icons">
6
+ <img src="https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/anthropic-text.svg" alt="Anthropic" class="logo-small">
7
+ &nbsp;
8
+ <img src="https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/bedrock-color.svg" alt="Bedrock" class="logo-medium">
9
+ <img src="https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/bedrock-text.svg" alt="Bedrock" class="logo-small">
10
+ &nbsp;
11
+ <img src="https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/deepseek-color.svg" alt="DeepSeek" class="logo-medium">
12
+ <img src="https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/deepseek-text.svg" alt="DeepSeek" class="logo-small">
13
+ &nbsp;
14
+ <img src="https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/gemini-brand-color.svg" alt="Gemini" class="logo-large">
15
+ &nbsp;
16
+ <img src="https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ollama.svg" alt="Ollama" class="logo-medium">
17
+ <img src="https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ollama-text.svg" alt="Ollama" class="logo-medium">
18
+ &nbsp;
19
+ <img src="https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openai.svg" alt="OpenAI" class="logo-medium">
20
+ <img src="https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openai-text.svg" alt="OpenAI" class="logo-medium">
21
+ &nbsp;
22
+ <img src="https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openrouter.svg" alt="OpenRouter" class="logo-medium">
23
+ <img src="https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openrouter-text.svg" alt="OpenRouter" class="logo-small">
24
+ &nbsp;
16
25
  </div>
17
26
 
18
- <a href="https://badge.fury.io/rb/ruby_llm"><img src="https://badge.fury.io/rb/ruby_llm.svg" alt="Gem Version" /></a>
19
- <a href="https://github.com/testdouble/standard"><img src="https://img.shields.io/badge/code_style-standard-brightgreen.svg" alt="Ruby Style Guide" /></a>
20
- <a href="https://rubygems.org/gems/ruby_llm"><img alt="Gem Downloads" src="https://img.shields.io/gem/dt/ruby_llm"></a>
21
- <a href="https://codecov.io/gh/crmne/ruby_llm"><img src="https://codecov.io/gh/crmne/ruby_llm/branch/main/graph/badge.svg" alt="codecov" /></a>
27
+ <div class="badge-container">
28
+ <a href="https://badge.fury.io/rb/ruby_llm"><img src="https://badge.fury.io/rb/ruby_llm.svg" alt="Gem Version" /></a>
29
+ <a href="https://github.com/testdouble/standard"><img src="https://img.shields.io/badge/code_style-standard-brightgreen.svg" alt="Ruby Style Guide" /></a>
30
+ <a href="https://rubygems.org/gems/ruby_llm"><img alt="Gem Downloads" src="https://img.shields.io/gem/dt/ruby_llm"></a>
31
+ <a href="https://codecov.io/gh/crmne/ruby_llm"><img src="https://codecov.io/gh/crmne/ruby_llm/branch/main/graph/badge.svg" alt="codecov" /></a>
32
+ </div>
22
33
 
23
- 🤺 Battle tested at [💬 Chat with Work](https://chatwithwork.com)
34
+ 🤺 Battle tested at [💬 Chat with Work](https://chatwithwork.com)
24
35
 
25
36
  ## The problem with AI libraries
26
37
 
@@ -28,17 +39,6 @@ Every AI provider comes with its own client library, its own response format, it
28
39
 
29
40
  RubyLLM fixes all that. One beautiful API for everything. One consistent format. Minimal dependencies — just Faraday and Zeitwerk. Because working with AI should be a joy, not a chore.
30
41
 
31
- ## Features
32
-
33
- - 💬 **Chat** with OpenAI, Anthropic, Gemini, AWS Bedrock Anthropic, and DeepSeek models
34
- - 👁️ **Vision and Audio** understanding
35
- - 📄 **PDF Analysis** for analyzing documents
36
- - 🖼️ **Image generation** with DALL-E and other providers
37
- - 📊 **Embeddings** for vector search and semantic analysis
38
- - 🔧 **Tools** that let AI use your Ruby code
39
- - 🚂 **Rails integration** to persist chats and messages with ActiveRecord
40
- - 🌊 **Streaming** responses with proper Ruby patterns
41
-
42
42
  ## What makes it great
43
43
 
44
44
  ```ruby
@@ -85,142 +85,89 @@ end
85
85
  chat.with_tool(Weather).ask "What's the weather in Berlin? (52.5200, 13.4050)"
86
86
  ```
87
87
 
88
+ ## Core Capabilities
89
+
90
+ * 💬 **Unified Chat:** Converse with models from OpenAI, Anthropic, Gemini, Bedrock, OpenRouter, DeepSeek, Ollama, or any OpenAI-compatible API using `RubyLLM.chat`.
91
+ * 👁️ **Vision:** Analyze images within chats.
92
+ * 🔊 **Audio:** Transcribe and understand audio content.
93
+ * 📄 **PDF Analysis:** Extract information and summarize PDF documents.
94
+ * 🖼️ **Image Generation:** Create images with `RubyLLM.paint`.
95
+ * 📊 **Embeddings:** Generate text embeddings for vector search with `RubyLLM.embed`.
96
+ * 🔧 **Tools (Function Calling):** Let AI models call your Ruby code using `RubyLLM::Tool`.
97
+ * 🚂 **Rails Integration:** Easily persist chats, messages, and tool calls using `acts_as_chat` and `acts_as_message`.
98
+ * 🌊 **Streaming:** Process responses in real-time with idiomatic Ruby blocks.
99
+
88
100
  ## Installation
89
101
 
102
+ Add to your Gemfile:
90
103
  ```ruby
91
- # In your Gemfile
92
104
  gem 'ruby_llm'
93
-
94
- # Then run
95
- bundle install
96
-
97
- # Or install it yourself
98
- gem install ruby_llm
99
105
  ```
106
+ Then `bundle install`.
100
107
 
101
- Configure with your API keys:
102
-
108
+ Configure your API keys (using environment variables is recommended):
103
109
  ```ruby
110
+ # config/initializers/ruby_llm.rb or similar
104
111
  RubyLLM.configure do |config|
105
112
  config.openai_api_key = ENV.fetch('OPENAI_API_KEY', nil)
106
- config.anthropic_api_key = ENV.fetch('ANTHROPIC_API_KEY', nil)
107
- config.gemini_api_key = ENV.fetch('GEMINI_API_KEY', nil)
108
- config.deepseek_api_key = ENV.fetch('DEEPSEEK_API_KEY', nil)
109
-
110
- # Bedrock
111
- config.bedrock_api_key = ENV.fetch('AWS_ACCESS_KEY_ID', nil)
112
- config.bedrock_secret_key = ENV.fetch('AWS_SECRET_ACCESS_KEY', nil)
113
- config.bedrock_region = ENV.fetch('AWS_REGION', nil)
114
- config.bedrock_session_token = ENV.fetch('AWS_SESSION_TOKEN', nil)
113
+ # Add keys ONLY for providers you intend to use
114
+ # config.anthropic_api_key = ENV.fetch('ANTHROPIC_API_KEY', nil)
115
+ # ... see Configuration guide for all options ...
115
116
  end
116
117
  ```
118
+ See the [Installation Guide](https://rubyllm.com/installation) for full details.
117
119
 
118
- ## Have great conversations
119
-
120
- ```ruby
121
- # Start a chat with the default model (gpt-4.1-nano)
122
- chat = RubyLLM.chat
123
-
124
- # Or specify what you want
125
- chat = RubyLLM.chat(model: 'claude-3-7-sonnet-20250219')
126
-
127
- # Simple questions just work
128
- chat.ask "What's the difference between attr_reader and attr_accessor?"
129
-
130
- # Multi-turn conversations are seamless
131
- chat.ask "Could you give me an example?"
132
-
133
- # Stream responses in real-time
134
- chat.ask "Tell me a story about a Ruby programmer" do |chunk|
135
- print chunk.content
136
- end
137
-
138
- # Set personality or behavior with instructions (aka system prompts)
139
- chat.with_instructions "You are a friendly Ruby expert who loves to help beginners"
140
-
141
- # Understand content in multiple forms
142
- chat.ask "Compare these diagrams", with: { image: ["diagram1.png", "diagram2.png"] }
143
- chat.ask "Summarize this document", with: { pdf: "contract.pdf" }
144
- chat.ask "What's being said?", with: { audio: "meeting.wav" }
145
-
146
- # Need a different model mid-conversation? No problem
147
- chat.with_model('gemini-2.0-flash').ask "What's your favorite algorithm?"
148
- ```
120
+ ## Rails Integration
149
121
 
150
- ## Rails integration that makes sense
122
+ Add persistence to your chat models effortlessly:
151
123
 
152
124
  ```ruby
153
125
  # app/models/chat.rb
154
126
  class Chat < ApplicationRecord
155
- acts_as_chat
156
-
157
- # Works great with Turbo
158
- broadcasts_to ->(chat) { "chat_#{chat.id}" }
127
+ acts_as_chat # Automatically saves messages & tool calls
128
+ # ... your other model logic ...
159
129
  end
160
130
 
161
131
  # app/models/message.rb
162
132
  class Message < ApplicationRecord
163
133
  acts_as_message
134
+ # ...
164
135
  end
165
136
 
166
- # app/models/tool_call.rb
137
+ # app/models/tool_call.rb (if using tools)
167
138
  class ToolCall < ApplicationRecord
168
139
  acts_as_tool_call
140
+ # ...
169
141
  end
170
142
 
171
- # In a background job
172
- chat = Chat.create! model_id: "gpt-4.1-nano"
173
-
174
- # Set personality or behavior with instructions (aka system prompts) - they're persisted too!
175
- chat.with_instructions "You are a friendly Ruby expert who loves to help beginners"
176
-
177
- chat.ask("What's your favorite Ruby gem?") do |chunk|
178
- Turbo::StreamsChannel.broadcast_append_to(
179
- chat,
180
- target: "response",
181
- partial: "messages/chunk",
182
- locals: { chunk: chunk }
183
- )
184
- end
185
-
186
- # That's it - chat history is automatically saved
143
+ # Now interacting with a Chat record persists the conversation:
144
+ chat_record = Chat.create!(model_id: "gpt-4.1-nano")
145
+ chat_record.ask("Explain Active Record callbacks.") # User & Assistant messages saved
187
146
  ```
188
-
189
- ## Creating tools is a breeze
190
-
191
- ```ruby
192
- class Search < RubyLLM::Tool
193
- description "Searches a knowledge base"
194
-
195
- param :query, desc: "The search query"
196
- param :limit, type: :integer, desc: "Max results", required: false
197
-
198
- def execute(query:, limit: 5)
199
- # Your search logic here
200
- Document.search(query).limit(limit).map(&:title)
201
- end
202
- end
203
-
204
- # Let the AI use it
205
- chat.with_tool(Search).ask "Find documents about Ruby 3.3 features"
206
- ```
207
-
208
- ## Learn more
209
-
210
- Check out the guides at https://rubyllm.com for deeper dives into conversations with tools, streaming responses, embedding generations, and more.
147
+ Check the [Rails Integration Guide](https://rubyllm.com/guides/rails) for more.
148
+
149
+ ## Learn More
150
+
151
+ Dive deeper with the official documentation:
152
+
153
+ - [Installation](https://rubyllm.com/installation)
154
+ - [Configuration](https://rubyllm.com/configuration)
155
+ - **Guides:**
156
+ - [Getting Started](https://rubyllm.com/guides/getting-started)
157
+ - [Chatting with AI Models](https://rubyllm.com/guides/chat)
158
+ - [Using Tools](https://rubyllm.com/guides/tools)
159
+ - [Streaming Responses](https://rubyllm.com/guides/streaming)
160
+ - [Rails Integration](https://rubyllm.com/guides/rails)
161
+ - [Image Generation](https://rubyllm.com/guides/image-generation)
162
+ - [Embeddings](https://rubyllm.com/guides/embeddings)
163
+ - [Working with Models](https://rubyllm.com/guides/models)
164
+ - [Error Handling](https://rubyllm.com/guides/error-handling)
165
+ - [Available Models](https://rubyllm.com/guides/available-models)
211
166
 
212
167
  ## Contributing
213
168
 
214
- We welcome contributions to RubyLLM!
215
-
216
- See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed instructions on how to:
217
- - Run the test suite
218
- - Add new features
219
- - Update documentation
220
- - Re-record VCR cassettes when needed
221
-
222
- We appreciate your help making RubyLLM better!
169
+ We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on setup, testing, and contribution guidelines.
223
170
 
224
171
  ## License
225
172
 
226
- Released under the MIT License.
173
+ Released under the MIT License.
@@ -3,8 +3,8 @@
3
3
  module RubyLLM
4
4
  module ActiveRecord
5
5
  # Adds chat and message persistence capabilities to ActiveRecord models.
6
- # Provides a clean interface for storing chat history and message metadata
7
- # in your database.
6
+ # Provides a clean interface for storing chat history, message metadata,
7
+ # and attachments in your database.
8
8
  module ActsAs
9
9
  extend ActiveSupport::Concern
10
10
 
@@ -20,37 +20,54 @@ module RubyLLM
20
20
  class_name: @message_class,
21
21
  dependent: :destroy
22
22
 
23
- delegate :complete,
24
- :add_message,
25
- to: :to_llm
23
+ delegate :add_message, to: :to_llm
26
24
  end
27
25
 
28
- def acts_as_message(chat_class: 'Chat', tool_call_class: 'ToolCall', touch_chat: false) # rubocop:disable Metrics/MethodLength
26
+ def acts_as_message(chat_class: 'Chat',
27
+ chat_foreign_key: nil,
28
+ tool_call_class: 'ToolCall',
29
+ tool_call_foreign_key: nil,
30
+ touch_chat: false)
29
31
  include MessageMethods
30
32
 
31
33
  @chat_class = chat_class.to_s
34
+ @chat_foreign_key = chat_foreign_key || ActiveSupport::Inflector.foreign_key(@chat_class)
35
+
32
36
  @tool_call_class = tool_call_class.to_s
37
+ @tool_call_foreign_key = tool_call_foreign_key || ActiveSupport::Inflector.foreign_key(@tool_call_class)
38
+
39
+ belongs_to :chat,
40
+ class_name: @chat_class,
41
+ foreign_key: @chat_foreign_key,
42
+ inverse_of: :messages,
43
+ touch: touch_chat
33
44
 
34
- belongs_to :chat, class_name: @chat_class, touch: touch_chat
35
- has_many :tool_calls, class_name: @tool_call_class, dependent: :destroy
45
+ has_many :tool_calls,
46
+ class_name: @tool_call_class,
47
+ dependent: :destroy
36
48
 
37
49
  belongs_to :parent_tool_call,
38
50
  class_name: @tool_call_class,
39
- foreign_key: 'tool_call_id',
51
+ foreign_key: @tool_call_foreign_key,
40
52
  optional: true,
41
53
  inverse_of: :result
42
54
 
43
55
  delegate :tool_call?, :tool_result?, :tool_results, to: :to_llm
44
56
  end
45
57
 
46
- def acts_as_tool_call(message_class: 'Message')
58
+ def acts_as_tool_call(message_class: 'Message', message_foreign_key: nil, result_foreign_key: nil)
47
59
  @message_class = message_class.to_s
60
+ @message_foreign_key = message_foreign_key || ActiveSupport::Inflector.foreign_key(@message_class)
61
+ @result_foreign_key = result_foreign_key || ActiveSupport::Inflector.foreign_key(name)
48
62
 
49
- belongs_to :message, class_name: @message_class
63
+ belongs_to :message,
64
+ class_name: @message_class,
65
+ foreign_key: @message_foreign_key,
66
+ inverse_of: :tool_calls
50
67
 
51
68
  has_one :result,
52
69
  class_name: @message_class,
53
- foreign_key: 'tool_call_id',
70
+ foreign_key: @result_foreign_key,
54
71
  inverse_of: :parent_tool_call,
55
72
  dependent: :nullify
56
73
  end
@@ -68,95 +85,104 @@ module RubyLLM
68
85
 
69
86
  def to_llm
70
87
  @chat ||= RubyLLM.chat(model: model_id)
88
+ @chat.reset_messages!
71
89
 
72
- # Load existing messages into chat
73
90
  messages.each do |msg|
74
91
  @chat.add_message(msg.to_llm)
75
92
  end
76
93
 
77
- # Set up message persistence
78
94
  @chat.on_new_message { persist_new_message }
79
95
  .on_end_message { |msg| persist_message_completion(msg) }
80
96
  end
81
97
 
82
98
  def with_instructions(instructions, replace: false)
83
99
  transaction do
84
- # If replace is true, remove existing system messages
85
100
  messages.where(role: :system).destroy_all if replace
86
-
87
- # Create the new system message
88
- messages.create!(
89
- role: :system,
90
- content: instructions
91
- )
101
+ messages.create!(role: :system, content: instructions)
92
102
  end
93
103
  to_llm.with_instructions(instructions)
94
104
  self
95
105
  end
96
106
 
97
- def with_tool(tool)
98
- to_llm.with_tool(tool)
107
+ def with_tool(...)
108
+ to_llm.with_tool(...)
99
109
  self
100
110
  end
101
111
 
102
- def with_tools(*tools)
103
- to_llm.with_tools(*tools)
112
+ def with_tools(...)
113
+ to_llm.with_tools(...)
104
114
  self
105
115
  end
106
116
 
107
- def with_model(model_id, provider: nil)
108
- to_llm.with_model(model_id, provider: provider)
117
+ def with_model(...)
118
+ update(model_id: to_llm.with_model(...).model.id)
109
119
  self
110
120
  end
111
121
 
112
- def with_temperature(temperature)
113
- to_llm.with_temperature(temperature)
122
+ def with_temperature(...)
123
+ to_llm.with_temperature(...)
114
124
  self
115
125
  end
116
126
 
117
- def on_new_message(&)
118
- to_llm.on_new_message(&)
127
+ def with_context(...)
128
+ to_llm.with_context(...)
119
129
  self
120
130
  end
121
131
 
122
- def on_end_message(&)
123
- to_llm.on_end_message(&)
132
+ def on_new_message(...)
133
+ to_llm.on_new_message(...)
124
134
  self
125
135
  end
126
136
 
127
- def ask(message, &)
128
- message = { role: :user, content: message }
129
- messages.create!(**message)
130
- to_llm.complete(&)
137
+ def on_end_message(...)
138
+ to_llm.on_end_message(...)
139
+ self
140
+ end
141
+
142
+ def create_user_message(content, with: nil)
143
+ message_record = messages.create!(role: :user, content: content)
144
+ persist_content(message_record, with) if with.present?
145
+ message_record
146
+ end
147
+
148
+ def ask(message, with: nil, &)
149
+ create_user_message(message, with:)
150
+ complete(&)
131
151
  end
132
152
 
133
153
  alias say ask
134
154
 
155
+ def complete(...)
156
+ to_llm.complete(...)
157
+ rescue RubyLLM::Error => e
158
+ if @message&.persisted? && @message.content.blank?
159
+ RubyLLM.logger.debug "RubyLLM: API call failed, destroying message: #{@message.id}"
160
+ @message.destroy
161
+ end
162
+ raise e
163
+ end
164
+
135
165
  private
136
166
 
137
167
  def persist_new_message
138
- @message = messages.create!(
139
- role: :assistant,
140
- content: String.new
141
- )
168
+ @message = messages.create!(role: :assistant, content: String.new)
142
169
  end
143
170
 
144
- def persist_message_completion(message) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
171
+ def persist_message_completion(message)
145
172
  return unless message
146
173
 
147
- if message.tool_call_id
148
- tool_call_id = self.class.tool_call_class.constantize.find_by(tool_call_id: message.tool_call_id).id
149
- end
174
+ tool_call_id = find_tool_call_id(message.tool_call_id) if message.tool_call_id
150
175
 
151
176
  transaction do
152
177
  @message.update!(
153
178
  role: message.role,
154
179
  content: message.content,
155
180
  model_id: message.model_id,
156
- tool_call_id: tool_call_id,
157
181
  input_tokens: message.input_tokens,
158
182
  output_tokens: message.output_tokens
159
183
  )
184
+ @message.write_attribute(@message.class.tool_call_foreign_key, tool_call_id) if tool_call_id
185
+ @message.save!
160
186
  persist_tool_calls(message.tool_calls) if message.tool_calls.present?
161
187
  end
162
188
  end
@@ -168,6 +194,48 @@ module RubyLLM
168
194
  @message.tool_calls.create!(**attributes)
169
195
  end
170
196
  end
197
+
198
+ def find_tool_call_id(tool_call_id)
199
+ self.class.tool_call_class.constantize.find_by(tool_call_id: tool_call_id)&.id
200
+ end
201
+
202
+ def persist_content(message_record, attachments)
203
+ return unless message_record.respond_to?(:attachments)
204
+
205
+ attachables = prepare_for_active_storage(attachments)
206
+ message_record.attachments.attach(attachables) if attachables.any?
207
+ end
208
+
209
+ def prepare_for_active_storage(attachments)
210
+ Utils.to_safe_array(attachments).filter_map do |attachment|
211
+ case attachment
212
+ when ActionDispatch::Http::UploadedFile, ActiveStorage::Blob
213
+ attachment
214
+ when ActiveStorage::Attached::One, ActiveStorage::Attached::Many
215
+ attachment.blobs
216
+ when Hash
217
+ attachment.values.map { |v| prepare_for_active_storage(v) }
218
+ else
219
+ convert_to_active_storage_format(attachment)
220
+ end
221
+ end.flatten.compact
222
+ end
223
+
224
+ def convert_to_active_storage_format(source)
225
+ return if source.blank?
226
+
227
+ # Let RubyLLM::Attachment handle the heavy lifting
228
+ attachment = RubyLLM::Attachment.new(source)
229
+
230
+ {
231
+ io: StringIO.new(attachment.content),
232
+ filename: attachment.filename,
233
+ content_type: attachment.mime_type
234
+ }
235
+ rescue StandardError => e
236
+ RubyLLM.logger.warn "Failed to process attachment #{source}: #{e.message}"
237
+ nil
238
+ end
171
239
  end
172
240
 
173
241
  # Methods mixed into message models to handle serialization and
@@ -175,6 +243,10 @@ module RubyLLM
175
243
  module MessageMethods
176
244
  extend ActiveSupport::Concern
177
245
 
246
+ class_methods do
247
+ attr_reader :chat_class, :tool_call_class, :chat_foreign_key, :tool_call_foreign_key
248
+ end
249
+
178
250
  def to_llm
179
251
  RubyLLM::Message.new(
180
252
  role: role.to_sym,
@@ -187,6 +259,8 @@ module RubyLLM
187
259
  )
188
260
  end
189
261
 
262
+ private
263
+
190
264
  def extract_tool_calls
191
265
  tool_calls.to_h do |tool_call|
192
266
  [
@@ -205,7 +279,30 @@ module RubyLLM
205
279
  end
206
280
 
207
281
  def extract_content
208
- content
282
+ return content unless respond_to?(:attachments) && attachments.attached?
283
+
284
+ RubyLLM::Content.new(content).tap do |content_obj|
285
+ @_tempfiles = []
286
+
287
+ attachments.each do |attachment|
288
+ tempfile = download_attachment(attachment)
289
+ content_obj.add_attachment(tempfile, filename: attachment.filename.to_s)
290
+ end
291
+ end
292
+ end
293
+
294
+ def download_attachment(attachment)
295
+ ext = File.extname(attachment.filename.to_s)
296
+ basename = File.basename(attachment.filename.to_s, ext)
297
+ tempfile = Tempfile.new([basename, ext])
298
+ tempfile.binmode
299
+
300
+ attachment.download { |chunk| tempfile.write(chunk) }
301
+
302
+ tempfile.flush
303
+ tempfile.rewind
304
+ @_tempfiles << tempfile
305
+ tempfile
209
306
  end
210
307
  end
211
308
  end