ruby_llm-sequel 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b0cdecd16447df344e6e618c6e0883890348068cfa185085304e6bd26bffbb9a
4
+ data.tar.gz: 2984799b793177bf89fef310270444aa3728c7ad4f9fee0558df599065cebc4a
5
+ SHA512:
6
+ metadata.gz: 91a0ad1ef357c27909b064abcbbd6b5cc83b256d1de8b280f8b0d60e6d7fa71c9b9bda191a8d80ded993eb100cb3ad9b68f2483fd9fe9dd15ace65e7d17bc834
7
+ data.tar.gz: 85d122fcad93f5c14af4e5897c9173a69b0c1725a231f60bd8d0b606da31f696cf8f837d9653e2973540230ae8273079fbdde15fe92f132a759aa90c509b8976
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Julian Pasquale
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,266 @@
1
+ # RubyLLM::Sequel
2
+
3
+ Sequel ORM integration for [RubyLLM](https://github.com/contextco/ruby_llm), providing the same ActiveRecord-style `acts_as_*` methods for Sequel models to interact with LLM providers.
4
+
5
+ This gem enables your Sequel models to serve as:
6
+ - **Chat interfaces** - Direct LLM conversations with persistent message history
7
+ - **Message storage** - User, assistant, system, and tool messages
8
+ - **Tool call tracking** - Function/tool invocations and results
9
+ - **Model registry** - Track available models, pricing, and capabilities
10
+
11
+ ## Installation
12
+
13
+ Add this line to your application's Gemfile:
14
+
15
+ ```ruby
16
+ gem 'ruby_llm-sequel'
17
+ ```
18
+
19
+ And then execute:
20
+
21
+ ```bash
22
+ bundle install
23
+ ```
24
+
25
+ Or install it yourself as:
26
+
27
+ ```bash
28
+ gem install ruby_llm-sequel
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ### Setup
34
+
35
+ First, require the gem in your application:
36
+
37
+ ```ruby
38
+ require 'ruby_llm/sequel'
39
+ ```
40
+
41
+ ### Database Schema
42
+
43
+ Your database needs tables for models, chats, messages, and tool calls. Here's an example schema:
44
+
45
+ ```ruby
46
+ DB.create_table :models do
47
+ primary_key :id
48
+ String :model_id, null: false
49
+ String :name, null: false
50
+ String :provider, null: false
51
+ String :family
52
+ Integer :context_window
53
+ Integer :max_output_tokens
54
+
55
+ String :capabilities, text: true
56
+ String :pricing, text: true
57
+ # or use jsonb for Postgres
58
+ jsonb :capabilities, text: true
59
+ jsonb :pricing, text: true
60
+
61
+ DateTime :created_at
62
+ DateTime :updated_at
63
+
64
+ index [:provider, :model_id], unique: true
65
+ end
66
+
67
+ DB.create_table :chats do
68
+ primary_key :id
69
+ foreign_key :model_id, :models
70
+ TrueClass :active, default: true
71
+ DateTime :created_at
72
+ DateTime :updated_at
73
+ end
74
+
75
+ DB.create_table :messages do
76
+ primary_key :id
77
+ foreign_key :chat_id, :chats, null: false
78
+ String :role, null: false
79
+ String :content, text: true
80
+ Integer :input_tokens
81
+ Integer :output_tokens
82
+
83
+ String :content_raw, text: true
84
+ # or use jsonb for Postgres
85
+ jsonb :content_raw
86
+
87
+ foreign_key :model_id, :models
88
+ foreign_key :tool_call_id, :tool_calls
89
+ DateTime :created_at
90
+ DateTime :updated_at
91
+ end
92
+
93
+ DB.create_table :tool_calls do
94
+ primary_key :id
95
+ foreign_key :message_id, :messages, null: false
96
+ String :tool_call_id, null: false, unique: true
97
+ String :name, null: false
98
+ String :arguments, text: true
99
+ DateTime :created_at
100
+ DateTime :updated_at
101
+ end
102
+ ```
103
+
104
+ ### Model Setup
105
+
106
+ Define your Sequel models with the RubyLLM plugin and appropriate `acts_as_*` methods:
107
+
108
+ ```ruby
109
+ class Model < Sequel::Model
110
+ plugin ::Sequel::Plugins::RubyLLM
111
+
112
+ acts_as_model
113
+ end
114
+
115
+ class Chat < Sequel::Model
116
+ plugin ::Sequel::Plugins::RubyLLM
117
+
118
+ acts_as_chat(model: :llm_model,model_class: 'ApiFr::Agents::Model')
119
+ end
120
+
121
+ class Message < Sequel::Model
122
+ plugin ::Sequel::Plugins::RubyLLM
123
+
124
+ acts_as_message(model: :llm_model, model_class: 'ApiFr::Agents::Model',)
125
+ end
126
+
127
+ class ToolCall < Sequel::Model
128
+ plugin ::Sequel::Plugins::RubyLLM
129
+
130
+ acts_as_tool_call
131
+ end
132
+ ```
133
+
134
+ ### Basic Chat Usage
135
+
136
+ ```ruby
137
+ # Configure RubyLLM
138
+ RubyLLM.configure do |config|
139
+ config.openai_api_key = ENV['OPENAI_API_KEY']
140
+ end
141
+
142
+ # Create a chat
143
+ chat = Chat.create
144
+
145
+ # Set instructions
146
+ chat.with_instructions("You are a helpful assistant")
147
+
148
+ # Send a message and get a response
149
+ chat.create_user_message("Hello!")
150
+ response = chat.ask # Calls the LLM and stores the response
151
+
152
+ puts response.content
153
+ # => "Hello! How can I help you today?"
154
+
155
+ # Access message history
156
+ chat.messages.each do |message|
157
+ puts "#{message.role}: #{message.content}"
158
+ end
159
+ ```
160
+
161
+ ### Tool Calls
162
+
163
+ ```ruby
164
+ # Define a tool
165
+ class Weather < RubyLLM::Tool
166
+ description "Gets current weather for a location"
167
+
168
+ params do # the params DSL is only available in v1.9+. older versions should use the param helper instead
169
+ string :latitude, description: "Latitude (e.g., 52.5200)"
170
+ string :longitude, description: "Longitude (e.g., 13.4050)"
171
+ end
172
+
173
+ def execute(latitude:, longitude:)
174
+ url = "https://api.open-meteo.com/v1/forecast?latitude=#{latitude}&longitude=#{longitude}&current=temperature_2m,wind_speed_10m"
175
+
176
+ response = Faraday.get(url)
177
+ data = JSON.parse(response.body)
178
+ rescue => e
179
+ { error: e.message }
180
+ end
181
+ end
182
+
183
+ # Use the tool in a chat
184
+ chat.with_tool(Weather.new)
185
+ response = chat.ask("What's the weather in San Francisco?")
186
+
187
+ # Tool calls are automatically tracked
188
+ response.tool_calls.each do |tool_call|
189
+ puts "Called: #{tool_call.name}"
190
+ puts "Arguments: #{tool_call.arguments}"
191
+ end
192
+ ```
193
+
194
+ ### Model Registry
195
+
196
+ ```ruby
197
+ # Refresh model information from providers
198
+ Model.refresh! # Fetches latest models from all configured providers
199
+
200
+ # Query models
201
+ gpt4 = Model.first(provider: 'openai', model_id: 'gpt-4')
202
+
203
+ # Check capabilities
204
+ gpt4.function_calling? # => true
205
+ gpt4.streaming? # => true
206
+ gpt4.supports?('vision') # => false
207
+
208
+ # Get pricing
209
+ gpt4.input_price_per_million # => 30.0
210
+ gpt4.output_price_per_million # => 60.0
211
+ ```
212
+
213
+ ## Important Notes
214
+
215
+ ### Database Compatibility
216
+
217
+ The gem supports both PostgreSQL (with jsonb) and other databases (JSON as text):
218
+ - PostgreSQL: Uses `jsonb` columns automatically
219
+ - SQLite/MySQL: Stores JSON as text strings
220
+ - JSON parsing/serialization is handled transparently
221
+
222
+ ### The model association
223
+
224
+ The Sequel gem already defines the `model` instance method for subclasses of `Sequel::Model` so it's recommended to use a different association name like `llm_model` to avoid name conflicts
225
+
226
+ ## Development
227
+
228
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
229
+
230
+ ### Running Tests
231
+
232
+ The test suite uses Minitest and runs against an in-memory SQLite database. To run the tests:
233
+
234
+ ```bash
235
+ bundle exec rake test
236
+ ```
237
+
238
+ To run a specific test file:
239
+
240
+ ```bash
241
+ ruby -Ilib:spec spec/ruby_llm/sequel/chat_methods_spec.rb
242
+ ```
243
+
244
+ ### Test Configuration
245
+
246
+ The tests use:
247
+ - **Minitest** for the test framework
248
+ - **SQLite** in-memory database for fast test execution
249
+ - **VCR + WebMock** for HTTP interaction recording (when testing with real API calls)
250
+ - **Transaction rollback** to isolate tests and maintain a clean database state
251
+
252
+ All test configuration is in `spec/spec_helper.rb`.
253
+
254
+ ### Contributing to Tests
255
+
256
+ When adding new features, please include corresponding tests. Test files are located in `spec/ruby_llm/sequel/` and follow the naming convention `*_methods_spec.rb`.
257
+
258
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
259
+
260
+ ## Contributing
261
+
262
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/ruby_llm-sequel.
263
+
264
+ ## License
265
+
266
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Sequel
5
+ module ChatMethods
6
+ attr_accessor :assume_model_exists, :context
7
+
8
+ def before_save
9
+ super
10
+ resolve_model_from_strings
11
+ end
12
+
13
+ def model=(value)
14
+ @model_string = value if value.is_a?(String)
15
+ return if value.is_a?(String)
16
+
17
+ send("#{self.class.model_association_name}=", value)
18
+ end
19
+
20
+ def model_id=(value)
21
+ if value.is_a?(Integer) || value.nil?
22
+ super
23
+ else
24
+ @model_string = value
25
+ end
26
+ end
27
+
28
+ def provider=(value)
29
+ @provider_string = value
30
+ end
31
+
32
+ def provider
33
+ model_record = send(self.class.model_association_name)
34
+ model_record&.provider
35
+ end
36
+
37
+ def to_llm
38
+ model_record = send(self.class.model_association_name)
39
+ @chat ||= (context || ::RubyLLM).chat(
40
+ model: model_record.model_id,
41
+ provider: model_record.provider.to_sym,
42
+ assume_model_exists: assume_model_exists || false
43
+ )
44
+ @chat.reset_messages!
45
+
46
+ message_class = self.class.message_class.constantize
47
+ tool_calls_assoc = message_class.tool_calls_association_name
48
+
49
+ messages_association_dataset.eager(
50
+ tool_calls_assoc, :parent_tool_call, self.class.model_association_name
51
+ ).all.each do |msg|
52
+ @chat.add_message(msg.to_llm)
53
+ end
54
+
55
+ setup_persistence_callbacks
56
+ end
57
+
58
+ def with_instructions(instructions, replace: false)
59
+ db.transaction do
60
+ messages_association_dataset.where(role: 'system').destroy if replace
61
+ send("add_#{self.class.messages_association_name.to_s.singularize}", role: 'system', content: instructions)
62
+ end
63
+ to_llm.with_instructions(instructions)
64
+ self
65
+ end
66
+
67
+ def with_tool(...)
68
+ to_llm.with_tool(...)
69
+ self
70
+ end
71
+
72
+ def with_tools(...)
73
+ to_llm.with_tools(...)
74
+ self
75
+ end
76
+
77
+ def with_model(model_name, provider: nil, assume_exists: false)
78
+ self.model = model_name
79
+ self.provider = provider if provider
80
+ self.assume_model_exists = assume_exists
81
+ resolve_model_from_strings
82
+ save
83
+ model_record = send(self.class.model_association_name)
84
+ to_llm.with_model(model_record.model_id, provider: model_record.provider.to_sym, assume_exists: assume_exists)
85
+ self
86
+ end
87
+
88
+ def with_temperature(...)
89
+ to_llm.with_temperature(...)
90
+ self
91
+ end
92
+
93
+ def with_params(...)
94
+ to_llm.with_params(...)
95
+ self
96
+ end
97
+
98
+ def with_headers(...)
99
+ to_llm.with_headers(...)
100
+ self
101
+ end
102
+
103
+ def with_schema(...)
104
+ to_llm.with_schema(...)
105
+ self
106
+ end
107
+
108
+ def on_new_message(&block)
109
+ to_llm
110
+
111
+ existing_callback = @chat.instance_variable_get(:@on)[:new_message]
112
+
113
+ @chat.on_new_message do
114
+ existing_callback&.call
115
+ block&.call
116
+ end
117
+ self
118
+ end
119
+
120
+ def on_end_message(&block)
121
+ to_llm
122
+
123
+ existing_callback = @chat.instance_variable_get(:@on)[:end_message]
124
+
125
+ @chat.on_end_message do |msg|
126
+ existing_callback&.call(msg)
127
+ block&.call(msg)
128
+ end
129
+ self
130
+ end
131
+
132
+ def on_tool_call(...)
133
+ to_llm.on_tool_call(...)
134
+ self
135
+ end
136
+
137
+ def on_tool_result(...)
138
+ to_llm.on_tool_result(...)
139
+ self
140
+ end
141
+
142
+ def create_user_message(content, with: nil)
143
+ raise UnsupportedFeatureError, "Cannot use 'with' parameter with Sequel integration" if with
144
+
145
+ content_text, _, content_raw = prepare_content_for_storage(content)
146
+
147
+ message_record = send("add_#{self.class.messages_association_name.to_s.singularize}", role: 'user',
148
+ content: content_text)
149
+ message_record.update(content_raw: content_raw) if message_record.columns.include?(:content_raw) && content_raw
150
+
151
+ message_record
152
+ end
153
+
154
+ def ask(message, with: nil, &)
155
+ create_user_message(message, with: with)
156
+ complete(&)
157
+ end
158
+
159
+ alias say ask
160
+
161
+ def complete(...)
162
+ to_llm.complete(...)
163
+ rescue ::RubyLLM::Error => e
164
+ cleanup_failed_messages if !@message.id.nil? && @message.content && @message.content != ''
165
+ cleanup_orphaned_tool_results
166
+ raise e
167
+ end
168
+
169
+ private
170
+
171
+ # Helper to handle JSON data for both PostgreSQL (jsonb) and other databases (text)
172
+ def jsonb_or_string(value)
173
+ return nil if value.nil?
174
+
175
+ # If the database supports pg_jsonb (PostgreSQL), use it
176
+ if defined?(::Sequel::Postgres) && db.database_type == :postgres
177
+ ::Sequel.pg_jsonb(value)
178
+ else
179
+ # For other databases (like SQLite), store as JSON string
180
+ value.is_a?(String) ? value : JSON.generate(value)
181
+ end
182
+ end
183
+
184
+ def resolve_model_from_strings
185
+ config = context&.config || ::RubyLLM.config
186
+ @model_string ||= config.default_model unless send(self.class.model_association_name)
187
+ return unless @model_string
188
+
189
+ model_info, _provider = ::RubyLLM::Models.resolve(
190
+ @model_string,
191
+ provider: @provider_string,
192
+ assume_exists: assume_model_exists || false,
193
+ config: config
194
+ )
195
+
196
+ model_record = self.class.model_class.constantize.find_or_create(
197
+ model_id: model_info.id,
198
+ provider: model_info.provider
199
+ ) do |m|
200
+ m.name = model_info.name || model_info.id
201
+ m.family = model_info.family
202
+ m.context_window = model_info.context_window
203
+ m.max_output_tokens = model_info.max_output_tokens
204
+ m.capabilities = jsonb_or_string(model_info.capabilities || [])
205
+ m.modalities = jsonb_or_string(model_info.modalities.to_h)
206
+ m.pricing = jsonb_or_string(model_info.pricing.to_h)
207
+ m.metadata = jsonb_or_string(model_info.metadata || {})
208
+ end
209
+
210
+ send("#{self.class.model_association_name}=", model_record)
211
+ @model_string = nil
212
+ @provider_string = nil
213
+ end
214
+
215
+ def setup_persistence_callbacks
216
+ return @chat if @chat.instance_variable_get(:@_persistence_callbacks_setup)
217
+
218
+ @chat.on_new_message { persist_new_message }
219
+ @chat.on_end_message { |msg| persist_message_completion(msg) }
220
+
221
+ @chat.instance_variable_set(:@_persistence_callbacks_setup, true)
222
+ @chat
223
+ end
224
+
225
+ def persist_new_message
226
+ @message = send("add_#{self.class.messages_association_name.to_s.singularize}", role: 'assistant', content: '')
227
+ end
228
+
229
+ def persist_message_completion(message)
230
+ return unless message
231
+
232
+ tool_call_id = find_tool_call_id(message.tool_call_id) if message.tool_call_id
233
+
234
+ db.transaction do
235
+ content_text, _attachments_to_persist, content_raw = prepare_content_for_storage(message.content)
236
+
237
+ attrs = {
238
+ role: message.role,
239
+ content: content_text,
240
+ input_tokens: message.input_tokens,
241
+ output_tokens: message.output_tokens
242
+ }
243
+ attrs[:cached_tokens] = message.cached_tokens if @message.columns.include?(:cached_tokens)
244
+ if @message.columns.include?(:cache_creation_tokens)
245
+ attrs[:cache_creation_tokens] = message.cache_creation_tokens
246
+ end
247
+
248
+ attrs[self.class.model_association_name] = send(self.class.model_association_name)
249
+
250
+ if tool_call_id
251
+ parent_tool_call_assoc = @message.class.association_reflection(:parent_tool_call)
252
+ attrs[parent_tool_call_assoc[:key]] = tool_call_id if parent_tool_call_assoc
253
+ end
254
+
255
+ @message.update(attrs)
256
+ if @message.columns.include?(:content_raw) && content_raw
257
+ @message.update(content_raw: ::Sequel.pg_jsonb(content_raw))
258
+ end
259
+
260
+ persist_tool_calls(message.tool_calls) unless message.tool_calls.nil? || message.tool_calls.empty?
261
+ end
262
+ end
263
+
264
+ def persist_tool_calls(tool_calls)
265
+ tool_calls.each_value do |tool_call|
266
+ attributes = tool_call.to_h
267
+ attributes[:tool_call_id] = attributes.delete(:id)
268
+ @message.send("add_#{@message.class.tool_calls_association_name.to_s.singularize}", **attributes)
269
+ end
270
+ end
271
+
272
+ def find_tool_call_id(tool_call_id)
273
+ message_class = self.class.message_class.constantize
274
+ tool_call_class = message_class.tool_call_class.constantize
275
+
276
+ # Find the tool_call record by its string tool_call_id within this chat's messages
277
+ tool_call = tool_call_class
278
+ .where(tool_call_id: tool_call_id)
279
+ .where(message_id: messages_association_dataset.select(:id))
280
+ .first
281
+
282
+ tool_call&.id
283
+ end
284
+
285
+ def prepare_content_for_storage(content)
286
+ attachments = nil
287
+ content_raw = nil
288
+ content_text = content
289
+
290
+ case content
291
+ when ::RubyLLM::Content::Raw
292
+ content_raw = content.value
293
+ content_text = nil
294
+ when ::RubyLLM::Content
295
+ attachments = content.attachments if content.attachments.any?
296
+ content_text = content.text
297
+ when Hash, Array
298
+ content_raw = content
299
+ content_text = nil
300
+ end
301
+
302
+ [content_text, attachments, content_raw]
303
+ end
304
+
305
+ def cleanup_failed_messages
306
+ ::RubyLLM.logger.warn "RubyLLM: API call failed, destroying message: #{@message.id}"
307
+ @message.destroy
308
+ end
309
+
310
+ def cleanup_orphaned_tool_results
311
+ reload
312
+ last = messages_association_dataset.order(:id).last
313
+
314
+ return unless last&.tool_call? || last&.tool_result?
315
+
316
+ if last.tool_call?
317
+ last.destroy
318
+ elsif last.tool_result?
319
+ tool_call_message = last.parent_tool_call.message_association
320
+ expected_results = tool_call_message.tool_calls_association.map(&:id)
321
+ actual_results = tool_call_message.tool_calls_association.select(&:result_association).map(&:id)
322
+
323
+ if expected_results.sort != actual_results.sort
324
+ tool_call_message.tool_calls_association.each do |tc|
325
+ tc.result_association&.destroy
326
+ end
327
+ tool_call_message.destroy
328
+ end
329
+ end
330
+ end
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Sequel
5
+ module MessageMethods
6
+ def to_llm
7
+ cached = columns.include?(:cached_tokens) ? self[:cached_tokens] : nil
8
+ cache_creation = columns.include?(:cache_creation_tokens) ? self[:cache_creation_tokens] : nil
9
+
10
+ ::RubyLLM::Message.new(
11
+ role: role.to_sym,
12
+ content: extract_content,
13
+ tool_calls: extract_tool_calls,
14
+ tool_call_id: extract_tool_call_id,
15
+ input_tokens: input_tokens,
16
+ output_tokens: output_tokens,
17
+ cached_tokens: cached,
18
+ cache_creation_tokens: cache_creation,
19
+ model_id: model_association&.model_id
20
+ )
21
+ end
22
+
23
+ def tool_call?
24
+ role == 'assistant' && tool_calls_association.any?
25
+ end
26
+
27
+ def tool_result?
28
+ role == 'tool' && !tool_call_id.nil?
29
+ end
30
+
31
+ private
32
+
33
+ def extract_content
34
+ return ::RubyLLM::Content::Raw.new(content_raw) if columns.include?(:content_raw) && !content_raw.nil?
35
+
36
+ content
37
+ end
38
+
39
+ def extract_tool_calls
40
+ tool_calls_association.to_h do |tool_call|
41
+ [
42
+ tool_call.tool_call_id,
43
+ tool_call.to_llm
44
+ ]
45
+ end
46
+ end
47
+
48
+ def extract_tool_call_id
49
+ parent_tool_call&.tool_call_id
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Sequel
5
+ module ModelMethods
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods
11
+ # Helper to handle JSON data for both PostgreSQL (jsonb) and other databases (text)
12
+ def jsonb_or_string(value)
13
+ return nil if value.nil?
14
+
15
+ # If the database supports pg_jsonb (PostgreSQL), use it
16
+ if defined?(::Sequel::Postgres) && db.database_type == :postgres
17
+ ::Sequel.pg_jsonb(value)
18
+ else
19
+ # For other databases (like SQLite), store as JSON string
20
+ value.is_a?(String) ? value : JSON.generate(value)
21
+ end
22
+ end
23
+
24
+ def refresh!
25
+ ::RubyLLM.models.refresh!
26
+ save_to_database
27
+ end
28
+
29
+ def save_to_database
30
+ ::Sequel::Model.db.transaction do
31
+ ::RubyLLM.models.all.each do |model_info|
32
+ from_llm(model_info)
33
+ end
34
+ end
35
+ end
36
+
37
+ def from_llm(model_info)
38
+ modalities_hash =
39
+ if model_info.modalities.respond_to?(:to_h)
40
+ model_info.modalities.to_h
41
+ else
42
+ model_info.modalities || {}
43
+ end
44
+ capabilities_array = model_info.capabilities || []
45
+ pricing_hash = model_info.pricing.respond_to?(:to_h) ? model_info.pricing.to_h : (model_info.pricing || {})
46
+ metadata_hash =
47
+ if model_info.metadata.respond_to?(:to_h)
48
+ model_info.metadata.to_h
49
+ else
50
+ model_info.metadata || {}
51
+ end
52
+
53
+ model_data = {
54
+ model_id: model_info.id, # RubyLLM::Model::Info uses 'id' not 'model_id'
55
+ name: model_info.name,
56
+ provider: model_info.provider,
57
+ family: model_info.family,
58
+ model_created_at: model_info.created_at,
59
+ context_window: model_info.context_window,
60
+ max_output_tokens: model_info.max_output_tokens,
61
+ knowledge_cutoff: model_info.knowledge_cutoff,
62
+ modalities: jsonb_or_string(modalities_hash),
63
+ capabilities: jsonb_or_string(capabilities_array),
64
+ pricing: jsonb_or_string(pricing_hash),
65
+ metadata: jsonb_or_string(metadata_hash)
66
+ }
67
+
68
+ existing = first(provider: model_info.provider, model_id: model_info.id)
69
+ if existing
70
+ existing.update(model_data)
71
+ existing
72
+ else
73
+ create(model_data)
74
+ end
75
+ end
76
+ end
77
+
78
+ def to_llm
79
+ parse_jsonb = lambda do |value|
80
+ return nil if value.nil?
81
+
82
+ value.is_a?(String) ? ::JSON.parse(value) : value
83
+ end
84
+
85
+ deep_symbolize = lambda do |obj|
86
+ case obj
87
+ when Hash
88
+ obj.transform_keys(&:to_sym).transform_values { |v| deep_symbolize.call(v) }
89
+ when Array
90
+ obj.map { |item| deep_symbolize.call(item) }
91
+ else
92
+ obj
93
+ end
94
+ end
95
+
96
+ parsed_pricing = parse_jsonb.call(pricing)
97
+ symbolized_pricing = parsed_pricing ? deep_symbolize.call(parsed_pricing) : nil
98
+
99
+ ::RubyLLM::Model::Info.new(
100
+ id: model_id, # RubyLLM::Model::Info expects 'id' not 'model_id'
101
+ name: name,
102
+ provider: provider,
103
+ family: family,
104
+ created_at: model_created_at,
105
+ context_window: context_window,
106
+ max_output_tokens: max_output_tokens,
107
+ knowledge_cutoff: knowledge_cutoff,
108
+ modalities: parse_jsonb.call(modalities),
109
+ capabilities: parse_jsonb.call(capabilities),
110
+ pricing: symbolized_pricing,
111
+ metadata: parse_jsonb.call(metadata)
112
+ )
113
+ end
114
+
115
+ def supports?(capability)
116
+ return false unless capabilities
117
+
118
+ caps_array = capabilities.is_a?(String) ? ::JSON.parse(capabilities) : capabilities
119
+
120
+ caps_array.include?(capability.to_s)
121
+ end
122
+
123
+ def supports_vision?
124
+ supports?('vision')
125
+ end
126
+
127
+ def supports_functions?
128
+ supports?('function_calling') || supports?('tools')
129
+ end
130
+
131
+ def input_price_per_million
132
+ return nil unless pricing
133
+
134
+ price_hash = pricing.is_a?(String) ? JSON.parse(pricing) : pricing
135
+
136
+ if price_hash.dig('text_tokens', 'standard', 'input_per_million')
137
+ price_hash.dig('text_tokens', 'standard', 'input_per_million').to_f
138
+ elsif price_hash.dig('text_tokens', 'input')
139
+ price_hash.dig('text_tokens', 'input').to_f
140
+ elsif price_hash['input'] || price_hash[:input]
141
+ (price_hash['input'] || price_hash[:input]).to_f
142
+ end
143
+ end
144
+
145
+ def output_price_per_million
146
+ return nil unless pricing
147
+
148
+ price_hash = pricing.is_a?(String) ? JSON.parse(pricing) : pricing
149
+
150
+ if price_hash.dig('text_tokens', 'standard', 'output_per_million')
151
+ price_hash.dig('text_tokens', 'standard', 'output_per_million').to_f
152
+ elsif price_hash.dig('text_tokens', 'output')
153
+ price_hash.dig('text_tokens', 'output').to_f
154
+ elsif price_hash['output'] || price_hash[:output]
155
+ (price_hash['output'] || price_hash[:output]).to_f
156
+ end
157
+ end
158
+
159
+ def type
160
+ to_llm.type
161
+ end
162
+
163
+ def function_calling?
164
+ to_llm.function_calling?
165
+ end
166
+
167
+ def structured_output?
168
+ to_llm.structured_output?
169
+ end
170
+
171
+ def batch?
172
+ to_llm.batch?
173
+ end
174
+
175
+ def reasoning?
176
+ to_llm.reasoning?
177
+ end
178
+
179
+ def citations?
180
+ to_llm.citations?
181
+ end
182
+
183
+ def streaming?
184
+ to_llm.streaming?
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Sequel
5
+ module ToolCallMethods
6
+ def to_llm
7
+ ::RubyLLM::ToolCall.new(
8
+ id: tool_call_id,
9
+ name: name,
10
+ arguments: parse_arguments
11
+ )
12
+ end
13
+
14
+ def result_message
15
+ message_association.chat_association.messages_association_dataset.where(tool_call_id: id).first
16
+ end
17
+
18
+ private
19
+
20
+ def parse_arguments
21
+ if defined?(::Sequel::Postgres::JSONBHash) && arguments.is_a?(::Sequel::Postgres::JSONBHash)
22
+ return arguments.to_h
23
+ end
24
+ return arguments if arguments.is_a?(Hash)
25
+ return {} if arguments.nil? || arguments.empty?
26
+
27
+ JSON.parse(arguments)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Sequel
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sequel'
4
+
5
+ Sequel.extension :inflector
6
+ Sequel::Model.plugin :validation_helpers
7
+
8
+ require_relative 'sequel/version'
9
+ require_relative 'sequel/model_methods'
10
+ require_relative 'sequel/chat_methods'
11
+ require_relative 'sequel/message_methods'
12
+ require_relative 'sequel/tool_call_methods'
13
+ require_relative '../sequel/plugins/ruby_llm'
14
+
15
+ module RubyLLM
16
+ module Sequel
17
+ class UnsupportedFeatureError < StandardError; end
18
+ end
19
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sequel
4
+ module Plugins
5
+ # RubyLLM plugin for Sequel models
6
+ # Provides acts_as_model, acts_as_chat, acts_as_message, and acts_as_tool_call methods
7
+ module RubyLLM
8
+ module ClassMethods
9
+ def acts_as_model(chats: :chats, chat_class: nil)
10
+ Plugins.inherited_instance_variables(singleton_class,
11
+ :@chats_association_name => nil,
12
+ :@chat_class => nil)
13
+
14
+ class << self
15
+ attr_accessor :chats_association_name, :chat_class
16
+ end
17
+ include ::RubyLLM::Sequel::ModelMethods
18
+
19
+ chat_class ||= chats.to_s.classify
20
+
21
+ self.chats_association_name = chats
22
+ self.chat_class = chat_class
23
+
24
+ one_to_many chats, class: chat_class, key: :model_id
25
+
26
+ define_method(:chats_association) do
27
+ send(self.class.chats_association_name)
28
+ end
29
+ define_method(:chats_association_dataset) do
30
+ public_send(:"#{self.class.chats_association_name}_dataset")
31
+ end
32
+
33
+ define_method(:validate) do
34
+ super()
35
+ validates_presence %i[model_id provider name]
36
+ validates_unique %i[model_id provider]
37
+ end
38
+ end
39
+
40
+ def acts_as_chat(messages: :messages, message_class: nil, model: :llm_model, model_class: 'Model',
41
+ model_key: :model_id)
42
+ Plugins.inherited_instance_variables(singleton_class,
43
+ :@messages_association_name => nil,
44
+ :@message_class => nil,
45
+ :@model_association_name => nil,
46
+ :@model_class => nil)
47
+
48
+ class << self
49
+ attr_accessor :messages_association_name, :message_class,
50
+ :model_association_name, :model_class
51
+ end
52
+
53
+ include ::RubyLLM::Sequel::ChatMethods
54
+
55
+ message_class ||= messages.to_s
56
+ model_class ||= model.to_s
57
+
58
+ self.messages_association_name = messages
59
+ self.message_class = message_class.classify
60
+ self.model_association_name = model
61
+ self.model_class = model_class.classify
62
+
63
+ define_method(:messages_association) { public_send(self.class.messages_association_name) }
64
+ define_method(:messages_association_dataset) do
65
+ public_send(:"#{self.class.messages_association_name}_dataset")
66
+ end
67
+
68
+ define_method(:model_association) { public_send(self.class.model_association_name) }
69
+ define_method(:model_association_dataset) do
70
+ public_send(:"#{self.class.model_association_name}_dataset")
71
+ end
72
+
73
+ association_options = { class: model_class }
74
+ association_options[:key] = model_key if model_key
75
+ many_to_one model, **association_options
76
+
77
+ one_to_many messages, class: message_class, key: :"#{table_name.to_s.singularize}_id",
78
+ order: ::Sequel.qualify(message_class.constantize.table_name.to_sym, :created_at).asc
79
+ end
80
+
81
+ def acts_as_message(chat: :chat, chat_class: nil, tool_calls: :tool_calls,
82
+ tool_call_class: nil, model: :llm_model, model_class: 'Model',
83
+ model_key: :model_id, touch_chat: false)
84
+ Plugins.inherited_instance_variables(singleton_class,
85
+ :@chat_association_name => nil,
86
+ :@chat_class => nil,
87
+ :@tool_calls_association_name => nil,
88
+ :@tool_call_class => nil,
89
+ :@model_association_name => nil,
90
+ :@model_class => nil)
91
+
92
+ class << self
93
+ attr_accessor :chat_association_name, :chat_class,
94
+ :tool_calls_association_name, :tool_call_class,
95
+ :model_association_name, :model_class
96
+ end
97
+
98
+ include ::RubyLLM::Sequel::MessageMethods
99
+
100
+ chat_class ||= chat.to_s.classify
101
+ tool_call_class ||= tool_calls.to_s.singularize.classify
102
+ model_class ||= model.to_s.classify
103
+
104
+ self.chat_association_name = chat
105
+ self.chat_class = chat_class
106
+ self.tool_calls_association_name = tool_calls
107
+ self.tool_call_class = tool_call_class
108
+ self.model_association_name = model
109
+ self.model_class = model_class
110
+
111
+ many_to_one chat, class: chat_class
112
+
113
+ model_association_options = { class: model_class }
114
+ model_association_options[:key] = model_key if model_key
115
+ many_to_one model, **model_association_options
116
+
117
+ many_to_one :parent_tool_call, class: tool_call_class, key: :tool_call_id
118
+ one_to_many tool_calls, class: tool_call_class, key: :"#{table_name.to_s.singularize}_id"
119
+
120
+ plugin :touch, column: :updated_at if touch_chat
121
+
122
+ define_method(:validate) do
123
+ super()
124
+ validates_presence [:role]
125
+ end
126
+
127
+ define_method(:chat_association) do
128
+ public_send(self.class.chat_association_name)
129
+ end
130
+ define_method(:chat_association_dataset) do
131
+ public_send(:"#{self.class.chat_association_name}_dataset")
132
+ end
133
+
134
+ define_method(:tool_calls_association) do
135
+ public_send(self.class.tool_calls_association_name)
136
+ end
137
+ define_method(:tool_calls_association_dataset) do
138
+ public_send(:"#{self.class.tool_calls_association_name}_dataset")
139
+ end
140
+
141
+ define_method(:model_association) do
142
+ public_send(self.class.model_association_name)
143
+ end
144
+ define_method(:model_association_dataset) do
145
+ public_send(:"#{self.class.model_association_name}_dataset")
146
+ end
147
+
148
+ return if chat == :chat
149
+
150
+ alias_method :chat, chat
151
+ end
152
+
153
+ def acts_as_tool_call(message: :message, message_class: nil, result: :result, result_class: nil)
154
+ Plugins.inherited_instance_variables(singleton_class,
155
+ :@message_association_name => nil,
156
+ :@message_class => nil,
157
+ :@result_association_name => nil,
158
+ :@result_class => nil)
159
+
160
+ class << self
161
+ attr_accessor :message_association_name, :message_class,
162
+ :result_association_name, :result_class
163
+ end
164
+
165
+ include ::RubyLLM::Sequel::ToolCallMethods
166
+
167
+ message_class ||= message.to_s.classify
168
+ result_class ||= message_class
169
+
170
+ self.message_association_name = message
171
+ self.message_class = message_class
172
+ self.result_association_name = result
173
+ self.result_class = result_class
174
+
175
+ plugin :association_dependencies
176
+
177
+ many_to_one message, class: message_class, key: :"#{message}_id", eager_loader_key: nil
178
+ one_to_one result, class: result_class, key: :tool_call_id, eager_loader_key: nil
179
+
180
+ add_association_dependencies result => :nullify
181
+
182
+ define_method(:validate) do
183
+ super()
184
+ validates_presence %i[tool_call_id name]
185
+ validates_unique [:tool_call_id]
186
+ end
187
+
188
+ define_method(:message_association) do
189
+ send(self.class.message_association_name)
190
+ end
191
+ define_method(:messages_association_dataset) do
192
+ send("#{self.class.message_association_name}_dataset")
193
+ end
194
+ define_method(:result_association) do
195
+ send(self.class.result_association_name)
196
+ end
197
+ define_method(:result_association_dataset) do
198
+ send("#{self.class.result_association_name}_dataset")
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby_llm-sequel
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Julian Pasquale
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ruby_llm
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.9'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.9'
26
+ - !ruby/object:Gem::Dependency
27
+ name: sequel
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.0'
40
+ description: Provides an adapter to integrate RubyLLM with Sequel ORM.
41
+ email:
42
+ - jpasquale@fu.do
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - LICENSE
48
+ - README.md
49
+ - lib/ruby_llm/sequel.rb
50
+ - lib/ruby_llm/sequel/chat_methods.rb
51
+ - lib/ruby_llm/sequel/message_methods.rb
52
+ - lib/ruby_llm/sequel/model_methods.rb
53
+ - lib/ruby_llm/sequel/tool_call_methods.rb
54
+ - lib/ruby_llm/sequel/version.rb
55
+ - lib/sequel/plugins/ruby_llm.rb
56
+ homepage: https://gitlab.com/fudo/poc/ruby_llm-sequel
57
+ licenses:
58
+ - MIT
59
+ metadata:
60
+ homepage_uri: https://gitlab.com/fudo/poc/ruby_llm-sequel
61
+ source_code_uri: https://gitlab.com/fudo/poc/ruby_llm-sequel
62
+ rubygems_mfa_required: 'true'
63
+ rdoc_options: []
64
+ require_paths:
65
+ - lib
66
+ required_ruby_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: 3.1.0
71
+ required_rubygems_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ requirements: []
77
+ rubygems_version: 3.7.2
78
+ specification_version: 4
79
+ summary: Sequel adapter for RubyLLM models.
80
+ test_files: []