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.
- checksums.yaml +4 -4
- data/README.md +80 -133
- data/lib/ruby_llm/active_record/acts_as.rb +212 -33
- data/lib/ruby_llm/aliases.json +48 -6
- data/lib/ruby_llm/attachments/audio.rb +12 -0
- data/lib/ruby_llm/attachments/image.rb +9 -0
- data/lib/ruby_llm/attachments/pdf.rb +9 -0
- data/lib/ruby_llm/attachments.rb +78 -0
- data/lib/ruby_llm/chat.rb +22 -19
- data/lib/ruby_llm/configuration.rb +30 -1
- data/lib/ruby_llm/connection.rb +95 -0
- data/lib/ruby_llm/content.rb +51 -72
- data/lib/ruby_llm/context.rb +30 -0
- data/lib/ruby_llm/embedding.rb +13 -5
- data/lib/ruby_llm/error.rb +1 -1
- data/lib/ruby_llm/image.rb +13 -5
- data/lib/ruby_llm/message.rb +12 -4
- data/lib/ruby_llm/mime_types.rb +713 -0
- data/lib/ruby_llm/model_info.rb +208 -27
- data/lib/ruby_llm/models.json +25766 -2154
- data/lib/ruby_llm/models.rb +95 -14
- data/lib/ruby_llm/provider.rb +48 -90
- data/lib/ruby_llm/providers/anthropic/capabilities.rb +76 -13
- data/lib/ruby_llm/providers/anthropic/chat.rb +7 -14
- data/lib/ruby_llm/providers/anthropic/media.rb +44 -34
- data/lib/ruby_llm/providers/anthropic/models.rb +15 -15
- data/lib/ruby_llm/providers/anthropic/tools.rb +2 -2
- data/lib/ruby_llm/providers/anthropic.rb +3 -3
- data/lib/ruby_llm/providers/bedrock/capabilities.rb +61 -2
- data/lib/ruby_llm/providers/bedrock/chat.rb +30 -73
- data/lib/ruby_llm/providers/bedrock/media.rb +56 -0
- data/lib/ruby_llm/providers/bedrock/models.rb +50 -58
- data/lib/ruby_llm/providers/bedrock/streaming/base.rb +16 -0
- data/lib/ruby_llm/providers/bedrock.rb +14 -25
- data/lib/ruby_llm/providers/deepseek/capabilities.rb +35 -2
- data/lib/ruby_llm/providers/deepseek.rb +3 -3
- data/lib/ruby_llm/providers/gemini/capabilities.rb +84 -3
- data/lib/ruby_llm/providers/gemini/chat.rb +8 -37
- data/lib/ruby_llm/providers/gemini/embeddings.rb +18 -34
- data/lib/ruby_llm/providers/gemini/images.rb +2 -2
- data/lib/ruby_llm/providers/gemini/media.rb +39 -110
- data/lib/ruby_llm/providers/gemini/models.rb +16 -22
- data/lib/ruby_llm/providers/gemini/tools.rb +1 -1
- data/lib/ruby_llm/providers/gemini.rb +3 -3
- data/lib/ruby_llm/providers/ollama/chat.rb +28 -0
- data/lib/ruby_llm/providers/ollama/media.rb +44 -0
- data/lib/ruby_llm/providers/ollama.rb +34 -0
- data/lib/ruby_llm/providers/openai/capabilities.rb +78 -3
- data/lib/ruby_llm/providers/openai/chat.rb +6 -4
- data/lib/ruby_llm/providers/openai/embeddings.rb +8 -12
- data/lib/ruby_llm/providers/openai/media.rb +38 -21
- data/lib/ruby_llm/providers/openai/models.rb +16 -17
- data/lib/ruby_llm/providers/openai/tools.rb +9 -5
- data/lib/ruby_llm/providers/openai.rb +7 -5
- data/lib/ruby_llm/providers/openrouter/models.rb +88 -0
- data/lib/ruby_llm/providers/openrouter.rb +31 -0
- data/lib/ruby_llm/stream_accumulator.rb +4 -4
- data/lib/ruby_llm/streaming.rb +3 -3
- data/lib/ruby_llm/utils.rb +22 -0
- data/lib/ruby_llm/version.rb +1 -1
- data/lib/ruby_llm.rb +15 -5
- data/lib/tasks/models.rake +69 -33
- data/lib/tasks/models_docs.rake +164 -121
- data/lib/tasks/vcr.rake +4 -2
- metadata +23 -14
- data/lib/tasks/browser_helper.rb +0 -97
- data/lib/tasks/capability_generator.rb +0 -123
- data/lib/tasks/capability_scraper.rb +0 -224
- data/lib/tasks/cli_helper.rb +0 -22
- data/lib/tasks/code_validator.rb +0 -29
- data/lib/tasks/model_updater.rb +0 -66
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ff16d82f5fc8836eba0943ee83bc3fe3b1558172ecc78cc6d4136fa504461e8f
|
4
|
+
data.tar.gz: 73cd15c8c05f53dae87740e0022cbb4b72ebd151918d7fe97d2f9a6047264069
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
4
|
-
|
5
|
-
<div
|
6
|
-
<img src="https://
|
7
|
-
 
|
8
|
-
<img src="https://
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
<img src="https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/
|
13
|
-
|
14
|
-
|
15
|
-
|
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
|
+
|
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
|
+
|
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
|
+
|
14
|
+
<img src="https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/gemini-brand-color.svg" alt="Gemini" class="logo-large">
|
15
|
+
|
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
|
+
|
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
|
+
|
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
|
+
|
16
25
|
</div>
|
17
26
|
|
18
|
-
<
|
19
|
-
<a href="https://
|
20
|
-
<a href="https://
|
21
|
-
<a href="https://
|
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 [💬
|
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
|
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
|
-
|
107
|
-
config.
|
108
|
-
|
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
|
-
##
|
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
|
-
|
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
|
-
#
|
172
|
-
|
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
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
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
|
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
|
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 :
|
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',
|
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
|
-
|
35
|
-
|
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:
|
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,
|
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:
|
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(
|
98
|
-
to_llm.with_tool(
|
116
|
+
def with_tool(...)
|
117
|
+
to_llm.with_tool(...)
|
99
118
|
self
|
100
119
|
end
|
101
120
|
|
102
|
-
def with_tools(
|
103
|
-
to_llm.with_tools(
|
121
|
+
def with_tools(...)
|
122
|
+
to_llm.with_tools(...)
|
104
123
|
self
|
105
124
|
end
|
106
125
|
|
107
|
-
def with_model(
|
108
|
-
to_llm.with_model(
|
126
|
+
def with_model(...)
|
127
|
+
to_llm.with_model(...)
|
109
128
|
self
|
110
129
|
end
|
111
130
|
|
112
|
-
def with_temperature(
|
113
|
-
to_llm.with_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
|
128
|
-
|
129
|
-
|
130
|
-
|
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)
|
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)
|
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
|