ruby_llm-mongoid 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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +55 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +18 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +253 -0
- data/Rakefile +12 -0
- data/build_release.sh +38 -0
- data/lib/generators/ruby_llm/mongoid/install/install_generator.rb +85 -0
- data/lib/generators/ruby_llm/mongoid/install/templates/chat_model.rb.tt +6 -0
- data/lib/generators/ruby_llm/mongoid/install/templates/initializer.rb.tt +6 -0
- data/lib/generators/ruby_llm/mongoid/install/templates/message_model.rb.tt +21 -0
- data/lib/generators/ruby_llm/mongoid/install/templates/model_model.rb.tt +23 -0
- data/lib/generators/ruby_llm/mongoid/install/templates/tool_call_model.rb.tt +14 -0
- data/lib/ruby_llm/mongoid/acts_as.rb +190 -0
- data/lib/ruby_llm/mongoid/chat_methods.rb +472 -0
- data/lib/ruby_llm/mongoid/grid_fs_attachment.rb +125 -0
- data/lib/ruby_llm/mongoid/message_methods.rb +105 -0
- data/lib/ruby_llm/mongoid/model_methods.rb +81 -0
- data/lib/ruby_llm/mongoid/payload_helpers.rb +28 -0
- data/lib/ruby_llm/mongoid/railtie.rb +15 -0
- data/lib/ruby_llm/mongoid/tool_call_methods.rb +19 -0
- data/lib/ruby_llm/mongoid/transaction.rb +44 -0
- data/lib/ruby_llm/mongoid/version.rb +7 -0
- data/lib/ruby_llm/mongoid.rb +28 -0
- metadata +115 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
class <%= model_model_name %>
|
|
2
|
+
include Mongoid::Document
|
|
3
|
+
include Mongoid::Timestamps
|
|
4
|
+
|
|
5
|
+
field :model_id, type: String
|
|
6
|
+
field :name, type: String
|
|
7
|
+
field :provider, type: String
|
|
8
|
+
field :family, type: String
|
|
9
|
+
field :model_created_at, type: Time
|
|
10
|
+
field :context_window, type: Integer
|
|
11
|
+
field :max_output_tokens, type: Integer
|
|
12
|
+
field :knowledge_cutoff, type: Date
|
|
13
|
+
field :modalities, type: Hash, default: {}
|
|
14
|
+
field :capabilities, type: Array, default: []
|
|
15
|
+
field :pricing, type: Hash, default: {}
|
|
16
|
+
field :metadata, type: Hash, default: {}
|
|
17
|
+
|
|
18
|
+
index({ provider: 1, model_id: 1 }, unique: true)
|
|
19
|
+
index({ provider: 1 })
|
|
20
|
+
index({ family: 1 })
|
|
21
|
+
|
|
22
|
+
acts_as_model
|
|
23
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class <%= tool_call_model_name %>
|
|
2
|
+
include Mongoid::Document
|
|
3
|
+
include Mongoid::Timestamps
|
|
4
|
+
|
|
5
|
+
field :tool_call_id, type: String
|
|
6
|
+
field :name, type: String
|
|
7
|
+
field :arguments, type: Hash, default: {}
|
|
8
|
+
field :thought_signature, type: String
|
|
9
|
+
|
|
10
|
+
index({ tool_call_id: 1 }, unique: true)
|
|
11
|
+
index({ name: 1 })
|
|
12
|
+
|
|
13
|
+
acts_as_tool_call
|
|
14
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "active_support/inflector"
|
|
5
|
+
require "ruby_llm/mongoid/chat_methods"
|
|
6
|
+
require "ruby_llm/mongoid/message_methods"
|
|
7
|
+
require "ruby_llm/mongoid/tool_call_methods"
|
|
8
|
+
require "ruby_llm/mongoid/model_methods"
|
|
9
|
+
|
|
10
|
+
module RubyLLM
|
|
11
|
+
module Mongoid
|
|
12
|
+
# Provides acts_as_chat, acts_as_message, acts_as_tool_call, and acts_as_model
|
|
13
|
+
# class macros for Mongoid documents. Include this module (or let the Railtie do it)
|
|
14
|
+
# and call the appropriate macro inside your document class.
|
|
15
|
+
module ActsAs
|
|
16
|
+
extend ActiveSupport::Concern
|
|
17
|
+
|
|
18
|
+
def self.included(base)
|
|
19
|
+
super
|
|
20
|
+
RubyLLM.config.model_registry_source ||= RubyLLM::Mongoid::MongoidSource.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
@@install_lock = Mutex.new # rubocop:disable Style/ClassVars
|
|
24
|
+
|
|
25
|
+
# Hook executed when a class does `include Mongoid::Document`.
|
|
26
|
+
# Injects our class-method macros onto every Mongoid document automatically
|
|
27
|
+
# so users don't have to `include RubyLLM::Mongoid::ActsAs` explicitly.
|
|
28
|
+
def self.install!
|
|
29
|
+
return unless defined?(::Mongoid::Document)
|
|
30
|
+
|
|
31
|
+
@@install_lock.synchronize do
|
|
32
|
+
return if ::Mongoid::Document.respond_to?(:acts_as_chat)
|
|
33
|
+
|
|
34
|
+
::Mongoid::Document.module_eval do
|
|
35
|
+
include RubyLLM::Mongoid::ActsAs
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class_methods do # rubocop:disable Metrics/BlockLength
|
|
41
|
+
# -----------------------------------------------------------------------
|
|
42
|
+
# acts_as_chat
|
|
43
|
+
# -----------------------------------------------------------------------
|
|
44
|
+
def acts_as_chat(messages: :messages, message_class: nil,
|
|
45
|
+
model: :model, model_class: nil)
|
|
46
|
+
include RubyLLM::Mongoid::ChatMethods
|
|
47
|
+
|
|
48
|
+
class_attribute :messages_association_name, :model_association_name,
|
|
49
|
+
:message_class, :model_class
|
|
50
|
+
|
|
51
|
+
self.messages_association_name = messages
|
|
52
|
+
self.model_association_name = model
|
|
53
|
+
self.message_class = (message_class || messages.to_s.classify).to_s
|
|
54
|
+
self.model_class = (model_class || model.to_s.classify).to_s
|
|
55
|
+
|
|
56
|
+
has_many messages,
|
|
57
|
+
class_name: self.message_class,
|
|
58
|
+
dependent: :destroy,
|
|
59
|
+
order: :created_at.asc
|
|
60
|
+
|
|
61
|
+
belongs_to model,
|
|
62
|
+
class_name: self.model_class,
|
|
63
|
+
optional: true
|
|
64
|
+
|
|
65
|
+
define_method(:messages_association) { send(messages_association_name) }
|
|
66
|
+
define_method(:model_association) { send(model_association_name) }
|
|
67
|
+
define_method(:"model_association=") { |v| send(:"#{model_association_name}=", v) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# -----------------------------------------------------------------------
|
|
71
|
+
# acts_as_model
|
|
72
|
+
# -----------------------------------------------------------------------
|
|
73
|
+
def acts_as_model(chats: :chats, chat_class: nil)
|
|
74
|
+
include RubyLLM::Mongoid::ModelMethods
|
|
75
|
+
|
|
76
|
+
class_attribute :chats_association_name, :chat_class
|
|
77
|
+
|
|
78
|
+
self.chats_association_name = chats
|
|
79
|
+
self.chat_class = (chat_class || chats.to_s.classify).to_s
|
|
80
|
+
|
|
81
|
+
validates :model_id, presence: true
|
|
82
|
+
validates :provider, presence: true
|
|
83
|
+
validates :name, presence: true
|
|
84
|
+
validates :model_id, uniqueness: { scope: :provider }
|
|
85
|
+
|
|
86
|
+
has_many chats, class_name: self.chat_class
|
|
87
|
+
|
|
88
|
+
define_method(:chats_association) { send(chats_association_name) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# -----------------------------------------------------------------------
|
|
92
|
+
# acts_as_message
|
|
93
|
+
# -----------------------------------------------------------------------
|
|
94
|
+
def acts_as_message(chat: :chat, chat_class: nil, touch_chat: false, # rubocop:disable Metrics/ParameterLists
|
|
95
|
+
tool_calls: :tool_calls, tool_call_class: nil,
|
|
96
|
+
model: :model, model_class: nil)
|
|
97
|
+
include RubyLLM::Mongoid::MessageMethods
|
|
98
|
+
|
|
99
|
+
class_attribute :chat_association_name, :tool_calls_association_name,
|
|
100
|
+
:model_association_name, :chat_class, :tool_call_class,
|
|
101
|
+
:model_class
|
|
102
|
+
|
|
103
|
+
self.chat_association_name = chat
|
|
104
|
+
self.tool_calls_association_name = tool_calls
|
|
105
|
+
self.model_association_name = model
|
|
106
|
+
self.chat_class = (chat_class || chat.to_s.classify).to_s
|
|
107
|
+
self.tool_call_class = (tool_call_class || tool_calls.to_s.classify).to_s
|
|
108
|
+
self.model_class = (model_class || model.to_s.classify).to_s
|
|
109
|
+
|
|
110
|
+
belongs_to chat,
|
|
111
|
+
class_name: self.chat_class,
|
|
112
|
+
touch: touch_chat
|
|
113
|
+
|
|
114
|
+
has_many tool_calls,
|
|
115
|
+
class_name: self.tool_call_class,
|
|
116
|
+
dependent: :destroy
|
|
117
|
+
|
|
118
|
+
# parent_tool_call links a tool-result message back to the ToolCall doc
|
|
119
|
+
# that produced the call. We use a named field `parent_tool_call_id`
|
|
120
|
+
# (BSON::ObjectId) rather than the string `tool_call_id` field to avoid
|
|
121
|
+
# a type collision.
|
|
122
|
+
belongs_to :parent_tool_call,
|
|
123
|
+
class_name: self.tool_call_class,
|
|
124
|
+
optional: true
|
|
125
|
+
|
|
126
|
+
belongs_to model,
|
|
127
|
+
class_name: self.model_class,
|
|
128
|
+
optional: true
|
|
129
|
+
|
|
130
|
+
delegate :tool_call?, :tool_result?, to: :to_llm
|
|
131
|
+
|
|
132
|
+
define_method(:chat_association) { send(chat_association_name) }
|
|
133
|
+
define_method(:tool_calls_association) { send(tool_calls_association_name) }
|
|
134
|
+
define_method(:model_association) { send(model_association_name) }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# -----------------------------------------------------------------------
|
|
138
|
+
# acts_as_tool_call
|
|
139
|
+
# -----------------------------------------------------------------------
|
|
140
|
+
def acts_as_tool_call(message: :message, message_class: nil,
|
|
141
|
+
result: :result, result_class: nil)
|
|
142
|
+
include RubyLLM::Mongoid::ToolCallMethods
|
|
143
|
+
|
|
144
|
+
class_attribute :message_association_name, :result_association_name,
|
|
145
|
+
:message_class, :result_class
|
|
146
|
+
|
|
147
|
+
self.message_association_name = message
|
|
148
|
+
self.result_association_name = result
|
|
149
|
+
self.message_class = (message_class || message.to_s.classify).to_s
|
|
150
|
+
self.result_class = (result_class || self.message_class).to_s
|
|
151
|
+
|
|
152
|
+
belongs_to message,
|
|
153
|
+
class_name: self.message_class
|
|
154
|
+
|
|
155
|
+
has_one result,
|
|
156
|
+
class_name: self.result_class,
|
|
157
|
+
foreign_key: :parent_tool_call_id,
|
|
158
|
+
dependent: :nullify
|
|
159
|
+
|
|
160
|
+
define_method(:message_association) { send(message_association_name) }
|
|
161
|
+
define_method(:result_association) { send(result_association_name) }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# Model registry source — plugs into RubyLLM.config.model_registry_source
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
class MongoidSource
|
|
170
|
+
def read
|
|
171
|
+
model_class = resolve_model_class
|
|
172
|
+
return [] unless model_class.respond_to?(:all)
|
|
173
|
+
|
|
174
|
+
model_class.all.map(&:to_llm)
|
|
175
|
+
rescue StandardError => e
|
|
176
|
+
RubyLLM.logger.debug { "Failed to load models from MongoDB: #{e.message}, falling back to JSON" }
|
|
177
|
+
[]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
def resolve_model_class
|
|
183
|
+
klass = RubyLLM.config.model_registry_class
|
|
184
|
+
return klass unless klass.is_a?(String)
|
|
185
|
+
|
|
186
|
+
klass.split("::").inject(Object) { |scope, name| scope.const_get(name) }
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "ruby_llm/mongoid/transaction"
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module Mongoid
|
|
8
|
+
# Mixes into a Mongoid document that represents a persisted chat session.
|
|
9
|
+
# Mirrors RubyLLM::ActiveRecord::ChatMethods, replacing AR-specific persistence
|
|
10
|
+
# and query calls with Mongoid equivalents.
|
|
11
|
+
module ChatMethods
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
include Transaction
|
|
14
|
+
|
|
15
|
+
included do
|
|
16
|
+
before_save :resolve_model_from_strings
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attr_accessor :assume_model_exists, :context
|
|
20
|
+
|
|
21
|
+
# -------------------------------------------------------------------------
|
|
22
|
+
# Model / provider assignment
|
|
23
|
+
# -------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
def model=(value)
|
|
26
|
+
@model_string = value if value.is_a?(String)
|
|
27
|
+
return if value.is_a?(String)
|
|
28
|
+
|
|
29
|
+
if self.class.model_association_name == :model
|
|
30
|
+
super
|
|
31
|
+
else
|
|
32
|
+
self.model_association = value
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def model_id=(value)
|
|
37
|
+
if value.is_a?(String)
|
|
38
|
+
@model_string = value
|
|
39
|
+
else
|
|
40
|
+
super
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def model_id
|
|
45
|
+
model_association&.model_id
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def provider=(value)
|
|
49
|
+
@provider_string = value
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def provider
|
|
53
|
+
model_association&.provider
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# -------------------------------------------------------------------------
|
|
57
|
+
# Chat interface — mirrors the AR version
|
|
58
|
+
# -------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def to_llm
|
|
61
|
+
model_record = model_association
|
|
62
|
+
@chat ||= (context || RubyLLM).chat(
|
|
63
|
+
model: model_record.model_id,
|
|
64
|
+
provider: model_record.provider.to_sym,
|
|
65
|
+
assume_model_exists: assume_model_exists || false
|
|
66
|
+
)
|
|
67
|
+
@chat.reset_messages!
|
|
68
|
+
|
|
69
|
+
ordered_messages = order_messages_for_llm(messages_association.to_a)
|
|
70
|
+
ordered_messages.each { |msg| @chat.add_message(msg.to_llm) }
|
|
71
|
+
reapply_runtime_instructions(@chat)
|
|
72
|
+
|
|
73
|
+
setup_persistence_callbacks
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def with_instructions(instructions, append: false, replace: nil)
|
|
77
|
+
append = append_instructions?(append: append, replace: replace)
|
|
78
|
+
persist_system_instruction(instructions, append: append)
|
|
79
|
+
to_llm.with_instructions(instructions, append: append, replace: replace)
|
|
80
|
+
self
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def with_runtime_instructions(instructions, append: false, replace: nil)
|
|
84
|
+
append = append_instructions?(append: append, replace: replace)
|
|
85
|
+
store_runtime_instruction(instructions, append: append)
|
|
86
|
+
to_llm.with_instructions(instructions, append: append, replace: replace)
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def with_tool(...)
|
|
91
|
+
to_llm.with_tool(...)
|
|
92
|
+
self
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def with_tools(...)
|
|
96
|
+
to_llm.with_tools(...)
|
|
97
|
+
self
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def with_model(model_name, provider: nil, assume_exists: false)
|
|
101
|
+
self.model = model_name
|
|
102
|
+
self.provider = provider if provider
|
|
103
|
+
self.assume_model_exists = assume_exists
|
|
104
|
+
resolve_model_from_strings
|
|
105
|
+
save!
|
|
106
|
+
to_llm.with_model(model_association.model_id, provider: model_association.provider.to_sym,
|
|
107
|
+
assume_exists: assume_exists)
|
|
108
|
+
self
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def with_temperature(...)
|
|
112
|
+
to_llm.with_temperature(...)
|
|
113
|
+
self
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def with_thinking(...)
|
|
117
|
+
to_llm.with_thinking(...)
|
|
118
|
+
self
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def with_params(...)
|
|
122
|
+
to_llm.with_params(...)
|
|
123
|
+
self
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def with_headers(...)
|
|
127
|
+
to_llm.with_headers(...)
|
|
128
|
+
self
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def with_schema(...)
|
|
132
|
+
to_llm.with_schema(...)
|
|
133
|
+
self
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def on_new_message(&)
|
|
137
|
+
to_llm.on_new_message(&)
|
|
138
|
+
self
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def on_end_message(&)
|
|
142
|
+
to_llm.on_end_message(&)
|
|
143
|
+
self
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def before_message(...)
|
|
147
|
+
to_llm.before_message(...)
|
|
148
|
+
self
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def after_message(...)
|
|
152
|
+
to_llm.after_message(...)
|
|
153
|
+
self
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def before_tool_call(...)
|
|
157
|
+
to_llm.before_tool_call(...)
|
|
158
|
+
self
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def after_tool_result(...)
|
|
162
|
+
to_llm.after_tool_result(...)
|
|
163
|
+
self
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def on_tool_call(...)
|
|
167
|
+
to_llm.on_tool_call(...)
|
|
168
|
+
self
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def on_tool_result(...)
|
|
172
|
+
to_llm.on_tool_result(...)
|
|
173
|
+
self
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def add_message(message_or_attributes)
|
|
177
|
+
llm_message = message_or_attributes.is_a?(RubyLLM::Message) ? message_or_attributes : RubyLLM::Message.new(message_or_attributes)
|
|
178
|
+
content_text, attachments, content_raw = prepare_content_for_storage(llm_message.content)
|
|
179
|
+
|
|
180
|
+
attrs = { role: llm_message.role, content: content_text }
|
|
181
|
+
|
|
182
|
+
if llm_message.tool_call_id
|
|
183
|
+
tc_db_id = find_tool_call_db_id(llm_message.tool_call_id)
|
|
184
|
+
attrs[:parent_tool_call_id] = tc_db_id if tc_db_id
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
message_record = messages_association.create!(attrs)
|
|
188
|
+
message_record.update!(content_raw: content_raw) if content_raw_field?(message_record)
|
|
189
|
+
|
|
190
|
+
persist_content(message_record, attachments) if attachments.present?
|
|
191
|
+
persist_tool_calls(llm_message.tool_calls, message_record: message_record) if llm_message.tool_calls.present?
|
|
192
|
+
|
|
193
|
+
message_record
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def cost
|
|
197
|
+
RubyLLM::Cost.aggregate(messages_association.map(&:cost))
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def create_user_message(content, with: nil)
|
|
201
|
+
add_message(role: :user, content: build_content(content, with))
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def ask(message = nil, with: nil, &)
|
|
205
|
+
add_message(role: :user, content: build_content(message, with))
|
|
206
|
+
complete(&)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
alias say ask
|
|
210
|
+
|
|
211
|
+
def complete(...)
|
|
212
|
+
to_llm.complete(...)
|
|
213
|
+
rescue RubyLLM::Error => e
|
|
214
|
+
cleanup_failed_messages if @message&.persisted? && @message.content.blank?
|
|
215
|
+
cleanup_orphaned_tool_results
|
|
216
|
+
raise e
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# -------------------------------------------------------------------------
|
|
220
|
+
# Private implementation
|
|
221
|
+
# -------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
private
|
|
224
|
+
|
|
225
|
+
def resolve_model_from_strings
|
|
226
|
+
config = context&.config || RubyLLM.config
|
|
227
|
+
@model_string ||= config.default_model unless model_association
|
|
228
|
+
return unless @model_string
|
|
229
|
+
|
|
230
|
+
model_info, _provider = RubyLLM::Models.resolve(
|
|
231
|
+
@model_string,
|
|
232
|
+
provider: @provider_string,
|
|
233
|
+
assume_exists: assume_model_exists || false,
|
|
234
|
+
config: config
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
model_klass = self.class.model_class.constantize
|
|
238
|
+
model_record = model_klass.find_or_create_by!(
|
|
239
|
+
model_id: model_info.id,
|
|
240
|
+
provider: model_info.provider
|
|
241
|
+
) do |m|
|
|
242
|
+
m.name = model_info.name || model_info.id
|
|
243
|
+
m.family = model_info.family
|
|
244
|
+
m.context_window = model_info.context_window
|
|
245
|
+
m.max_output_tokens = model_info.max_output_tokens
|
|
246
|
+
m.capabilities = model_info.capabilities || []
|
|
247
|
+
m.modalities = model_info.modalities.to_h
|
|
248
|
+
m.pricing = model_info.pricing.to_h
|
|
249
|
+
m.metadata = model_info.metadata || {}
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
self.model_association = model_record
|
|
253
|
+
@model_string = nil
|
|
254
|
+
@provider_string = nil
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def setup_persistence_callbacks
|
|
258
|
+
return @chat if @chat.instance_variable_get(:@_persistence_callbacks_setup)
|
|
259
|
+
|
|
260
|
+
@chat.before_message { persist_new_message }
|
|
261
|
+
@chat.after_message { |msg| persist_message_completion(msg) }
|
|
262
|
+
|
|
263
|
+
@chat.instance_variable_set(:@_persistence_callbacks_setup, true)
|
|
264
|
+
@chat
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def persist_new_message
|
|
268
|
+
@message = messages_association.create!(role: :assistant, content: "")
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def persist_message_completion(message)
|
|
272
|
+
return unless message
|
|
273
|
+
|
|
274
|
+
tool_call_db_id = find_tool_call_db_id(message.tool_call_id) if message.tool_call_id
|
|
275
|
+
|
|
276
|
+
with_transaction do
|
|
277
|
+
content_text, _attachments, content_raw = prepare_content_for_storage(message.content)
|
|
278
|
+
|
|
279
|
+
attrs = {
|
|
280
|
+
role: message.role,
|
|
281
|
+
content: content_text
|
|
282
|
+
}
|
|
283
|
+
attrs[:input_tokens] = message.input_tokens if field_declared?(@message, :input_tokens)
|
|
284
|
+
attrs[:output_tokens] = message.output_tokens if field_declared?(@message, :output_tokens)
|
|
285
|
+
attrs[:cached_tokens] = message.cached_tokens if field_declared?(@message, :cached_tokens)
|
|
286
|
+
attrs[:cache_creation_tokens] = message.cache_creation_tokens if field_declared?(@message,
|
|
287
|
+
:cache_creation_tokens)
|
|
288
|
+
attrs[:thinking_text] = message.thinking&.text if field_declared?(@message, :thinking_text)
|
|
289
|
+
attrs[:thinking_signature] = message.thinking&.signature if field_declared?(@message, :thinking_signature)
|
|
290
|
+
attrs[:thinking_tokens] = message.thinking_tokens if field_declared?(@message, :thinking_tokens)
|
|
291
|
+
|
|
292
|
+
attrs[self.class.model_association_name] = model_association
|
|
293
|
+
|
|
294
|
+
attrs[:parent_tool_call_id] = tool_call_db_id if tool_call_db_id
|
|
295
|
+
|
|
296
|
+
@message.assign_attributes(attrs)
|
|
297
|
+
@message.content_raw = content_raw if content_raw_field?(@message) && content_raw
|
|
298
|
+
@message.save!
|
|
299
|
+
|
|
300
|
+
persist_tool_calls(message.tool_calls) if message.tool_calls.present?
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def persist_tool_calls(tool_calls, message_record: @message)
|
|
305
|
+
return if tool_calls.blank?
|
|
306
|
+
|
|
307
|
+
supports_thought_signature = message_record.tool_calls_association.klass.fields.key?("thought_signature")
|
|
308
|
+
|
|
309
|
+
tool_calls.each_value do |tc|
|
|
310
|
+
attributes = tc.to_h
|
|
311
|
+
attributes.delete(:thought_signature) unless supports_thought_signature
|
|
312
|
+
attributes[:tool_call_id] = attributes.delete(:id)
|
|
313
|
+
message_record.tool_calls_association.create!(**attributes)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Resolves a provider-issued tool_call_id string to the Mongo _id of the
|
|
318
|
+
# ToolCall document. Done with two targeted queries instead of a SQL JOIN.
|
|
319
|
+
def find_tool_call_db_id(tool_call_id)
|
|
320
|
+
message_klass = messages_association.klass
|
|
321
|
+
tc_assoc_name = message_klass.tool_calls_association_name
|
|
322
|
+
tc_klass = message_klass.relations[tc_assoc_name.to_s].klass
|
|
323
|
+
|
|
324
|
+
tc = tc_klass.where(tool_call_id: tool_call_id).first
|
|
325
|
+
tc&.id
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def cleanup_failed_messages
|
|
329
|
+
RubyLLM.logger.warn "RubyLLM: API call failed, destroying message: #{@message.id}"
|
|
330
|
+
@message.destroy
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def cleanup_orphaned_tool_results
|
|
334
|
+
last = messages_association.order_by(_id: :asc).last
|
|
335
|
+
|
|
336
|
+
return unless last&.tool_call? || last&.tool_result?
|
|
337
|
+
|
|
338
|
+
if last.tool_call?
|
|
339
|
+
last.destroy
|
|
340
|
+
elsif last.tool_result?
|
|
341
|
+
tc_message = last.parent_tool_call.message_association
|
|
342
|
+
tc_ids = tc_message.tool_calls_association.distinct(:_id)
|
|
343
|
+
result_parent_ids = message_klass_for(last).where(
|
|
344
|
+
parent_tool_call_id: { "$in" => tc_ids }
|
|
345
|
+
).distinct(:parent_tool_call_id)
|
|
346
|
+
|
|
347
|
+
if tc_ids.sort != result_parent_ids.sort
|
|
348
|
+
message_klass_for(last).where(parent_tool_call_id: { "$in" => tc_ids }).destroy_all
|
|
349
|
+
tc_message.destroy
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def message_klass_for(msg)
|
|
355
|
+
msg.class
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def persist_system_instruction(instructions, append:)
|
|
359
|
+
with_transaction do
|
|
360
|
+
if append
|
|
361
|
+
messages_association.create!(role: :system, content: instructions)
|
|
362
|
+
else
|
|
363
|
+
replace_persisted_system_instructions(instructions)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def replace_persisted_system_instructions(instructions)
|
|
369
|
+
system_messages = messages_association.where(role: :system).order_by(_id: :asc).to_a
|
|
370
|
+
|
|
371
|
+
if system_messages.empty?
|
|
372
|
+
messages_association.create!(role: :system, content: instructions)
|
|
373
|
+
return
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
primary = system_messages.shift
|
|
377
|
+
primary.update!(content: instructions) if primary.content != instructions
|
|
378
|
+
system_messages.each(&:destroy)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def append_instructions?(append:, replace:)
|
|
382
|
+
return append if replace.nil?
|
|
383
|
+
|
|
384
|
+
append || (replace == false)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def order_messages_for_llm(messages)
|
|
388
|
+
system_msgs, other_msgs = messages.partition { |m| m.role.to_s == "system" }
|
|
389
|
+
system_msgs + other_msgs
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def runtime_instructions
|
|
393
|
+
@runtime_instructions ||= []
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def store_runtime_instruction(instructions, append:)
|
|
397
|
+
if append
|
|
398
|
+
runtime_instructions << instructions
|
|
399
|
+
else
|
|
400
|
+
@runtime_instructions = [instructions]
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def reapply_runtime_instructions(chat)
|
|
405
|
+
return if runtime_instructions.empty?
|
|
406
|
+
|
|
407
|
+
first, *rest = runtime_instructions
|
|
408
|
+
chat.with_instructions(first)
|
|
409
|
+
rest.each { |instr| chat.with_instructions(instr, append: true) }
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def build_content(message, attachments)
|
|
413
|
+
return message if content_like?(message)
|
|
414
|
+
|
|
415
|
+
RubyLLM::Content.new(message, attachments)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def content_like?(object)
|
|
419
|
+
object.is_a?(RubyLLM::Content) || object.is_a?(RubyLLM::Content::Raw)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def prepare_content_for_storage(content)
|
|
423
|
+
attachments = nil
|
|
424
|
+
content_raw = nil
|
|
425
|
+
content_text = content
|
|
426
|
+
|
|
427
|
+
case content
|
|
428
|
+
when RubyLLM::Content::Raw
|
|
429
|
+
content_raw = content.value
|
|
430
|
+
content_text = nil
|
|
431
|
+
when RubyLLM::Content
|
|
432
|
+
attachments = content.attachments.presence
|
|
433
|
+
content_text = content.text
|
|
434
|
+
when Hash, Array
|
|
435
|
+
content_raw = content
|
|
436
|
+
content_text = nil
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
[content_text, attachments, content_raw]
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def persist_content(message_record, attachments)
|
|
443
|
+
return unless message_record.respond_to?(:gridfs_file_ids)
|
|
444
|
+
|
|
445
|
+
ids = attachments.filter_map { |att| upload_to_gridfs(message_record, att) }
|
|
446
|
+
message_record.push(gridfs_file_ids: ids) if ids.any?
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def upload_to_gridfs(message_record, att)
|
|
450
|
+
att = RubyLLM::Attachment.new(att) unless att.is_a?(RubyLLM::Attachment)
|
|
451
|
+
io = StringIO.new(att.content.to_s.b)
|
|
452
|
+
file_id = message_record.class.gridfs_bucket.upload_from_stream(
|
|
453
|
+
att.filename,
|
|
454
|
+
io,
|
|
455
|
+
metadata: { content_type: att.mime_type }
|
|
456
|
+
)
|
|
457
|
+
{ "id" => file_id, "filename" => att.filename, "content_type" => att.mime_type }
|
|
458
|
+
rescue StandardError => e
|
|
459
|
+
RubyLLM.logger.warn "RubyLLM: GridFS upload failed for #{att.filename}: #{e.message}"
|
|
460
|
+
nil
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def content_raw_field?(record)
|
|
464
|
+
record.class.fields.key?("content_raw")
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def field_declared?(record, name)
|
|
468
|
+
record.class.fields.key?(name.to_s)
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
end
|