ruby_llm 1.2.0 → 1.3.0rc1

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +80 -133
  3. data/lib/ruby_llm/active_record/acts_as.rb +212 -33
  4. data/lib/ruby_llm/aliases.json +48 -6
  5. data/lib/ruby_llm/attachments/audio.rb +12 -0
  6. data/lib/ruby_llm/attachments/image.rb +9 -0
  7. data/lib/ruby_llm/attachments/pdf.rb +9 -0
  8. data/lib/ruby_llm/attachments.rb +78 -0
  9. data/lib/ruby_llm/chat.rb +22 -19
  10. data/lib/ruby_llm/configuration.rb +30 -1
  11. data/lib/ruby_llm/connection.rb +95 -0
  12. data/lib/ruby_llm/content.rb +51 -72
  13. data/lib/ruby_llm/context.rb +30 -0
  14. data/lib/ruby_llm/embedding.rb +13 -5
  15. data/lib/ruby_llm/error.rb +1 -1
  16. data/lib/ruby_llm/image.rb +13 -5
  17. data/lib/ruby_llm/message.rb +12 -4
  18. data/lib/ruby_llm/mime_types.rb +713 -0
  19. data/lib/ruby_llm/model_info.rb +208 -27
  20. data/lib/ruby_llm/models.json +25766 -2154
  21. data/lib/ruby_llm/models.rb +95 -14
  22. data/lib/ruby_llm/provider.rb +48 -90
  23. data/lib/ruby_llm/providers/anthropic/capabilities.rb +76 -13
  24. data/lib/ruby_llm/providers/anthropic/chat.rb +7 -14
  25. data/lib/ruby_llm/providers/anthropic/media.rb +44 -34
  26. data/lib/ruby_llm/providers/anthropic/models.rb +15 -15
  27. data/lib/ruby_llm/providers/anthropic/tools.rb +2 -2
  28. data/lib/ruby_llm/providers/anthropic.rb +3 -3
  29. data/lib/ruby_llm/providers/bedrock/capabilities.rb +61 -2
  30. data/lib/ruby_llm/providers/bedrock/chat.rb +30 -73
  31. data/lib/ruby_llm/providers/bedrock/media.rb +56 -0
  32. data/lib/ruby_llm/providers/bedrock/models.rb +50 -58
  33. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +16 -0
  34. data/lib/ruby_llm/providers/bedrock.rb +14 -25
  35. data/lib/ruby_llm/providers/deepseek/capabilities.rb +35 -2
  36. data/lib/ruby_llm/providers/deepseek.rb +3 -3
  37. data/lib/ruby_llm/providers/gemini/capabilities.rb +84 -3
  38. data/lib/ruby_llm/providers/gemini/chat.rb +8 -37
  39. data/lib/ruby_llm/providers/gemini/embeddings.rb +18 -34
  40. data/lib/ruby_llm/providers/gemini/images.rb +2 -2
  41. data/lib/ruby_llm/providers/gemini/media.rb +39 -110
  42. data/lib/ruby_llm/providers/gemini/models.rb +16 -22
  43. data/lib/ruby_llm/providers/gemini/tools.rb +1 -1
  44. data/lib/ruby_llm/providers/gemini.rb +3 -3
  45. data/lib/ruby_llm/providers/ollama/chat.rb +28 -0
  46. data/lib/ruby_llm/providers/ollama/media.rb +44 -0
  47. data/lib/ruby_llm/providers/ollama.rb +34 -0
  48. data/lib/ruby_llm/providers/openai/capabilities.rb +78 -3
  49. data/lib/ruby_llm/providers/openai/chat.rb +6 -4
  50. data/lib/ruby_llm/providers/openai/embeddings.rb +8 -12
  51. data/lib/ruby_llm/providers/openai/media.rb +38 -21
  52. data/lib/ruby_llm/providers/openai/models.rb +16 -17
  53. data/lib/ruby_llm/providers/openai/tools.rb +9 -5
  54. data/lib/ruby_llm/providers/openai.rb +7 -5
  55. data/lib/ruby_llm/providers/openrouter/models.rb +88 -0
  56. data/lib/ruby_llm/providers/openrouter.rb +31 -0
  57. data/lib/ruby_llm/stream_accumulator.rb +4 -4
  58. data/lib/ruby_llm/streaming.rb +3 -3
  59. data/lib/ruby_llm/utils.rb +22 -0
  60. data/lib/ruby_llm/version.rb +1 -1
  61. data/lib/ruby_llm.rb +15 -5
  62. data/lib/tasks/models.rake +69 -33
  63. data/lib/tasks/models_docs.rake +164 -121
  64. data/lib/tasks/vcr.rake +4 -2
  65. metadata +23 -14
  66. data/lib/tasks/browser_helper.rb +0 -97
  67. data/lib/tasks/capability_generator.rb +0 -123
  68. data/lib/tasks/capability_scraper.rb +0 -224
  69. data/lib/tasks/cli_helper.rb +0 -22
  70. data/lib/tasks/code_validator.rb +0 -29
  71. data/lib/tasks/model_updater.rb +0 -66
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2f06ce431337dc189e6172b0c98ed897fdba930200f3f118c39c15f4527ec135
4
- data.tar.gz: 18f8ff36e7ee18cbee315e66db4b8f04619c98595a5de3b73d215bed248ca0d4
3
+ metadata.gz: ff16d82f5fc8836eba0943ee83bc3fe3b1558172ecc78cc6d4136fa504461e8f
4
+ data.tar.gz: 73cd15c8c05f53dae87740e0022cbb4b72ebd151918d7fe97d2f9a6047264069
5
5
  SHA512:
6
- metadata.gz: 42f7603cfec24fa6cc59b1186d2d6a90af9e9076eb79124ac5ce09d73000fbcdb931ab90cafe21bf95b39417a52ff000ca9d02ba51c8a78877d1a1f47b70866f
7
- data.tar.gz: 8513b6774ef3d745e7bbc8947f856608d13ade163ca658139c2992e923ad08d5ee00b99275cbfe762591182897b73db7bfc879fae8db7b8ce2f3ba1fea5ee235
6
+ metadata.gz: d9bff16dce3e4cef7f57d8afad8cf90fd995045a6da5412588fc019ac2e57fa4137628525c603ef4df259eec6bf40d748296619671713094ca7d8e7c824c0e78
7
+ data.tar.gz: 3f44c0c2783202e8f162d9d705f10061a7ca717f23a26026bbbcdb2cea214edd48c9a0458ec9a226a03eb823f3ec8f3d9a601526c01e277c24f4988bb0634521
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,55 @@ module RubyLLM
20
20
  class_name: @message_class,
21
21
  dependent: :destroy
22
22
 
23
- delegate :complete,
24
- :add_message,
23
+ delegate :add_message,
25
24
  to: :to_llm
26
25
  end
27
26
 
28
- def acts_as_message(chat_class: 'Chat', tool_call_class: 'ToolCall', touch_chat: false) # rubocop:disable Metrics/MethodLength
27
+ def acts_as_message(chat_class: 'Chat',
28
+ chat_foreign_key: nil,
29
+ tool_call_class: 'ToolCall',
30
+ tool_call_foreign_key: nil,
31
+ touch_chat: false)
29
32
  include MessageMethods
30
33
 
31
34
  @chat_class = chat_class.to_s
35
+ @chat_foreign_key = chat_foreign_key || ActiveSupport::Inflector.foreign_key(@chat_class)
36
+
32
37
  @tool_call_class = tool_call_class.to_s
38
+ @tool_call_foreign_key = tool_call_foreign_key || ActiveSupport::Inflector.foreign_key(@tool_call_class)
39
+
40
+ belongs_to :chat,
41
+ class_name: @chat_class,
42
+ foreign_key: @chat_foreign_key,
43
+ inverse_of: :messages,
44
+ touch: touch_chat
33
45
 
34
- belongs_to :chat, class_name: @chat_class, touch: touch_chat
35
- has_many :tool_calls, class_name: @tool_call_class, dependent: :destroy
46
+ has_many :tool_calls,
47
+ class_name: @tool_call_class,
48
+ dependent: :destroy
36
49
 
37
50
  belongs_to :parent_tool_call,
38
51
  class_name: @tool_call_class,
39
- foreign_key: 'tool_call_id',
52
+ foreign_key: @tool_call_foreign_key,
40
53
  optional: true,
41
54
  inverse_of: :result
42
55
 
43
56
  delegate :tool_call?, :tool_result?, :tool_results, to: :to_llm
44
57
  end
45
58
 
46
- def acts_as_tool_call(message_class: 'Message')
59
+ def acts_as_tool_call(message_class: 'Message', message_foreign_key: nil, result_foreign_key: nil)
47
60
  @message_class = message_class.to_s
61
+ @message_foreign_key = message_foreign_key || ActiveSupport::Inflector.foreign_key(@message_class)
62
+ @result_foreign_key = result_foreign_key || ActiveSupport::Inflector.foreign_key(name)
48
63
 
49
- belongs_to :message, class_name: @message_class
64
+ belongs_to :message,
65
+ class_name: @message_class,
66
+ foreign_key: @message_foreign_key,
67
+ inverse_of: :tool_calls
50
68
 
51
69
  has_one :result,
52
70
  class_name: @message_class,
53
- foreign_key: 'tool_call_id',
71
+ foreign_key: @result_foreign_key,
54
72
  inverse_of: :parent_tool_call,
55
73
  dependent: :nullify
56
74
  end
@@ -68,6 +86,7 @@ module RubyLLM
68
86
 
69
87
  def to_llm
70
88
  @chat ||= RubyLLM.chat(model: model_id)
89
+ @chat.reset_messages!
71
90
 
72
91
  # Load existing messages into chat
73
92
  messages.each do |msg|
@@ -94,44 +113,72 @@ module RubyLLM
94
113
  self
95
114
  end
96
115
 
97
- def with_tool(tool)
98
- to_llm.with_tool(tool)
116
+ def with_tool(...)
117
+ to_llm.with_tool(...)
99
118
  self
100
119
  end
101
120
 
102
- def with_tools(*tools)
103
- to_llm.with_tools(*tools)
121
+ def with_tools(...)
122
+ to_llm.with_tools(...)
104
123
  self
105
124
  end
106
125
 
107
- def with_model(model_id, provider: nil)
108
- to_llm.with_model(model_id, provider: provider)
126
+ def with_model(...)
127
+ to_llm.with_model(...)
109
128
  self
110
129
  end
111
130
 
112
- def with_temperature(temperature)
113
- to_llm.with_temperature(temperature)
131
+ def with_temperature(...)
132
+ to_llm.with_temperature(...)
114
133
  self
115
134
  end
116
135
 
117
- def on_new_message(&)
118
- to_llm.on_new_message(&)
136
+ def on_new_message(...)
137
+ to_llm.on_new_message(...)
119
138
  self
120
139
  end
121
140
 
122
- def on_end_message(&)
123
- to_llm.on_end_message(&)
141
+ def on_end_message(...)
142
+ to_llm.on_end_message(...)
124
143
  self
125
144
  end
126
145
 
127
- def ask(message, &)
128
- message = { role: :user, content: message }
129
- messages.create!(**message)
130
- to_llm.complete(&)
146
+ def create_user_message(content, with: nil)
147
+ message_record = messages.create!(
148
+ role: :user,
149
+ content: content
150
+ )
151
+
152
+ if with.present?
153
+ files = Array(with).reject(&:blank?)
154
+
155
+ if files.any? && files.first.is_a?(ActionDispatch::Http::UploadedFile)
156
+ message_record.attachments.attach(files)
157
+ else
158
+ attach_files(message_record, process_attachments(with))
159
+ end
160
+ end
161
+
162
+ message_record
163
+ end
164
+
165
+ def ask(message, with: nil, &)
166
+ create_user_message(message, with:)
167
+ complete(&)
131
168
  end
132
169
 
133
170
  alias say ask
134
171
 
172
+ def complete(...)
173
+ to_llm.complete(...)
174
+ rescue RubyLLM::Error => e
175
+ if @message&.persisted? && @message.content.blank?
176
+ RubyLLM.logger.debug "RubyLLM: API call failed, destroying message: #{@message.id}"
177
+ @message.destroy
178
+ end
179
+ raise e
180
+ end
181
+
135
182
  private
136
183
 
137
184
  def persist_new_message
@@ -141,22 +188,23 @@ module RubyLLM
141
188
  )
142
189
  end
143
190
 
144
- def persist_message_completion(message) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
191
+ def persist_message_completion(message)
145
192
  return unless message
146
193
 
147
194
  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
195
+ tool_call_id = self.class.tool_call_class.constantize.find_by(tool_call_id: message.tool_call_id)&.id
149
196
  end
150
197
 
151
198
  transaction do
152
- @message.update!(
199
+ @message.update(
153
200
  role: message.role,
154
201
  content: message.content,
155
202
  model_id: message.model_id,
156
- tool_call_id: tool_call_id,
157
203
  input_tokens: message.input_tokens,
158
204
  output_tokens: message.output_tokens
159
205
  )
206
+ @message.write_attribute(@message.class.tool_call_foreign_key, tool_call_id) if tool_call_id
207
+ @message.save!
160
208
  persist_tool_calls(message.tool_calls) if message.tool_calls.present?
161
209
  end
162
210
  end
@@ -168,6 +216,86 @@ module RubyLLM
168
216
  @message.tool_calls.create!(**attributes)
169
217
  end
170
218
  end
219
+
220
+ def process_attachments(attachments) # rubocop:disable Metrics/PerceivedComplexity
221
+ return {} if attachments.nil?
222
+
223
+ result = {}
224
+ files = Array(attachments)
225
+
226
+ files.each do |file|
227
+ content_type = if file.respond_to?(:content_type)
228
+ file.content_type
229
+ elsif file.is_a?(ActiveStorage::Attachment)
230
+ file.blob.content_type
231
+ else
232
+ RubyLLM::MimeTypes.detect_from_path(file.to_s)
233
+ end
234
+
235
+ if RubyLLM::MimeTypes.image?(content_type)
236
+ result[:image] ||= []
237
+ result[:image] << file
238
+ elsif RubyLLM::MimeTypes.audio?(content_type)
239
+ result[:audio] ||= []
240
+ result[:audio] << file
241
+ else
242
+ # Default to PDF for unknown types
243
+ result[:pdf] ||= []
244
+ result[:pdf] << file
245
+ end
246
+ end
247
+
248
+ result
249
+ end
250
+
251
+ def attach_files(message, attachments_hash)
252
+ return unless message.respond_to?(:attachments)
253
+
254
+ %i[image audio pdf].each do |type|
255
+ Array(attachments_hash[type]).each do |file_source|
256
+ attach_file(message, file_source)
257
+ end
258
+ end
259
+ end
260
+
261
+ def attach_file(message, file_source)
262
+ if file_source.to_s.match?(%r{^https?://})
263
+ # For URLs, create a special attachment that just stores the URL
264
+ content_type = RubyLLM::MimeTypes.detect_from_path(file_source.to_s)
265
+
266
+ # Create a minimal blob that just stores the URL
267
+ blob = ActiveStorage::Blob.create_and_upload!(
268
+ io: StringIO.new('URL Reference'),
269
+ filename: File.basename(file_source),
270
+ content_type: content_type,
271
+ metadata: { original_url: file_source.to_s }
272
+ )
273
+ message.attachments.attach(blob)
274
+ elsif file_source.respond_to?(:read)
275
+ # Handle various file source types
276
+ message.attachments.attach(
277
+ io: file_source,
278
+ filename: extract_filename(file_source),
279
+ content_type: RubyLLM::MimeTypes.detect_from_path(extract_filename(file_source))
280
+ ) # Already a file-like object
281
+ elsif file_source.is_a?(::ActiveStorage::Attachment)
282
+ # Copy from existing ActiveStorage attachment
283
+ message.attachments.attach(file_source.blob)
284
+ elsif file_source.is_a?(::ActiveStorage::Blob)
285
+ message.attachments.attach(file_source)
286
+ else
287
+ # Local file path
288
+ message.attachments.attach(
289
+ io: File.open(file_source),
290
+ filename: File.basename(file_source),
291
+ content_type: RubyLLM::MimeTypes.detect_from_path(file_source)
292
+ )
293
+ end
294
+ end
295
+
296
+ def extract_filename(file)
297
+ file.respond_to?(:original_filename) ? file.original_filename : 'attachment'
298
+ end
171
299
  end
172
300
 
173
301
  # Methods mixed into message models to handle serialization and
@@ -175,6 +303,10 @@ module RubyLLM
175
303
  module MessageMethods
176
304
  extend ActiveSupport::Concern
177
305
 
306
+ class_methods do
307
+ attr_reader :chat_class, :tool_call_class, :chat_foreign_key, :tool_call_foreign_key
308
+ end
309
+
178
310
  def to_llm
179
311
  RubyLLM::Message.new(
180
312
  role: role.to_sym,
@@ -204,8 +336,55 @@ module RubyLLM
204
336
  parent_tool_call&.tool_call_id
205
337
  end
206
338
 
207
- def extract_content
208
- content
339
+ def extract_content # rubocop:disable Metrics/PerceivedComplexity
340
+ return content unless respond_to?(:attachments) && attachments.attached?
341
+
342
+ content_obj = RubyLLM::Content.new(content)
343
+
344
+ # We need to keep tempfiles alive for the duration of the API call
345
+ @_tempfiles = []
346
+
347
+ attachments.each do |attachment|
348
+ attachment_data = if attachment.metadata&.key?('original_url')
349
+ attachment.metadata['original_url']
350
+ elsif defined?(ActiveJob) && caller.any? { |c| c.include?('active_job') }
351
+ # We're in a background job - need to download the data
352
+ temp_file = Tempfile.new([File.basename(attachment.filename.to_s, '.*'),
353
+ File.extname(attachment.filename.to_s)])
354
+ temp_file.binmode
355
+ temp_file.write(attachment.download)
356
+ temp_file.flush
357
+ temp_file.rewind
358
+
359
+ # Store the tempfile reference in the instance variable to prevent GC
360
+ @_tempfiles << temp_file
361
+
362
+ # Return the file object itself, not just the path
363
+ temp_file
364
+ else
365
+ blob_path_for(attachment)
366
+ end
367
+
368
+ if RubyLLM::MimeTypes.image?(attachment.content_type)
369
+ content_obj.add_image(attachment_data)
370
+ elsif RubyLLM::MimeTypes.audio?(attachment.content_type)
371
+ content_obj.add_audio(attachment_data)
372
+ elsif RubyLLM::MimeTypes.pdf?(attachment.content_type)
373
+ content_obj.add_pdf(attachment_data)
374
+ end
375
+ end
376
+
377
+ content_obj
378
+ end
379
+
380
+ private
381
+
382
+ def blob_path_for(attachment)
383
+ if Rails.application.routes.url_helpers.respond_to?(:rails_blob_path)
384
+ Rails.application.routes.url_helpers.rails_blob_path(attachment, only_path: true)
385
+ else
386
+ attachment.service_url
387
+ end
209
388
  end
210
389
  end
211
390
  end