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.
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "tmpdir"
5
+
6
+ module RubyLLM
7
+ module Mongoid
8
+ # Optional concern for message models that want GridFS-backed file attachments
9
+ # instead of Active Storage.
10
+ #
11
+ # Usage:
12
+ # class Message
13
+ # include Mongoid::Document
14
+ # include RubyLLM::Mongoid::GridFsAttachment
15
+ # acts_as_message
16
+ # end
17
+ #
18
+ # Each uploaded file is stored in a MongoDB GridFS bucket (default: "attachments").
19
+ # Metadata is tracked in the +gridfs_file_ids+ array field on the document.
20
+ #
21
+ # To use a different bucket name:
22
+ # class Message
23
+ # include RubyLLM::Mongoid::GridFsAttachment
24
+ # use_gridfs_bucket :llm_files
25
+ # end
26
+ module GridFsAttachment
27
+ extend ActiveSupport::Concern
28
+
29
+ included do
30
+ # Array of { "id" => BSON::ObjectId, "filename" => String, "content_type" => String }
31
+ field :gridfs_file_ids, type: Array, default: []
32
+
33
+ before_destroy :delete_gridfs_files
34
+ after_find :cleanup_gridfs_tempfiles
35
+ after_save :cleanup_gridfs_tempfiles
36
+ end
37
+
38
+ # Cleanup tempfiles created during download
39
+ def cleanup_gridfs_tempfiles
40
+ return unless defined?(@_gridfs_tempfiles) && @_gridfs_tempfiles
41
+
42
+ @_gridfs_tempfiles.each do |f|
43
+ f.close
44
+ f.unlink
45
+ rescue StandardError => e
46
+ RubyLLM.logger.warn "RubyLLM: Failed to cleanup GridFS tempfile #{f.path}: #{e.message}"
47
+ end
48
+ @_gridfs_tempfiles = []
49
+ end
50
+
51
+ # Builds a RubyLLM::Content that streams each stored file back from GridFS.
52
+ # Called by message_methods#extract_content when gridfs_file_ids is non-empty.
53
+ def gridfs_content(text)
54
+ sources = gridfs_file_ids.filter_map { |meta| download_gridfs_file(meta) }
55
+ return text if sources.empty?
56
+
57
+ if text.present?
58
+ RubyLLM::Content.new(text).tap { |c| sources.each { |f, name| c.add_attachment(f, filename: name) } }
59
+ else
60
+ RubyLLM::Content.new(nil, sources.map(&:first))
61
+ end
62
+ end
63
+
64
+ module ClassMethods
65
+ def use_gridfs_bucket(name)
66
+ @gridfs_bucket_name = name.to_s
67
+ @_gridfs_bucket = nil
68
+ end
69
+
70
+ def gridfs_bucket_name
71
+ @gridfs_bucket_name ||= "attachments"
72
+ end
73
+
74
+ def gridfs_bucket
75
+ @gridfs_bucket ||= ::Mongo::Grid::FSBucket.new(
76
+ ::Mongoid.default_client.database,
77
+ fs_name: gridfs_bucket_name
78
+ )
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def delete_gridfs_files
85
+ gridfs_file_ids.each do |meta|
86
+ self.class.gridfs_bucket.delete(coerce_object_id(meta["id"]))
87
+ rescue ::Mongo::Error => e
88
+ RubyLLM.logger.warn "RubyLLM: GridFS delete failed for #{meta.inspect}: #{e.message}"
89
+ end
90
+ end
91
+
92
+ def download_gridfs_file(meta)
93
+ filename = meta["filename"].to_s
94
+ io = fetch_gridfs_stream(meta["id"])
95
+ tmpfile = stream_to_tempfile(io, filename)
96
+ (@_gridfs_tempfiles ||= []) << tmpfile
97
+ [tmpfile, filename]
98
+ rescue ::Mongo::Error => e
99
+ RubyLLM.logger.warn "RubyLLM: GridFS download failed for #{meta.inspect}: #{e.message}"
100
+ nil
101
+ end
102
+
103
+ def fetch_gridfs_stream(id)
104
+ io = StringIO.new("".b)
105
+ self.class.gridfs_bucket.download_to_stream(coerce_object_id(id), io)
106
+ io.tap(&:rewind)
107
+ end
108
+
109
+ def stream_to_tempfile(io, filename)
110
+ ext = File.extname(filename)
111
+ base = File.basename(filename, ext)
112
+ Tempfile.new([base, ext], Dir.tmpdir, encoding: "BINARY").tap do |f|
113
+ f.write(io.read)
114
+ f.rewind
115
+ end
116
+ end
117
+
118
+ def coerce_object_id(id)
119
+ return id if id.is_a?(BSON::ObjectId)
120
+
121
+ BSON::ObjectId.from_string(id.to_s)
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "ruby_llm/mongoid/payload_helpers"
5
+
6
+ module RubyLLM
7
+ module Mongoid
8
+ # Mixes into a Mongoid document that represents a persisted chat message.
9
+ # Mirrors RubyLLM::ActiveRecord::MessageMethods but replaces AR-specific
10
+ # introspection with Mongoid equivalents.
11
+ module MessageMethods
12
+ extend ActiveSupport::Concern
13
+ include PayloadHelpers
14
+
15
+ def to_llm
16
+ RubyLLM::Message.new(
17
+ role: role.to_sym,
18
+ content: extract_content,
19
+ thinking: thinking,
20
+ tokens: tokens,
21
+ tool_calls: extract_tool_calls,
22
+ tool_call_id: extract_tool_call_id,
23
+ model_id: model_association&.model_id
24
+ )
25
+ end
26
+
27
+ def thinking
28
+ RubyLLM::Thinking.build(
29
+ text: field_value(:thinking_text),
30
+ signature: field_value(:thinking_signature)
31
+ )
32
+ end
33
+
34
+ def tokens
35
+ RubyLLM::Tokens.build(
36
+ input: field_value(:input_tokens),
37
+ output: field_value(:output_tokens),
38
+ cached: field_value(:cached_tokens),
39
+ cache_creation: field_value(:cache_creation_tokens),
40
+ thinking: field_value(:thinking_tokens)
41
+ )
42
+ end
43
+
44
+ def cost
45
+ RubyLLM::Cost.new(tokens: tokens, model: model_association)
46
+ end
47
+
48
+ def cache_read_tokens
49
+ field_value(:cached_tokens)
50
+ end
51
+
52
+ def cache_write_tokens
53
+ field_value(:cache_creation_tokens)
54
+ end
55
+
56
+ def to_partial_path
57
+ partial_prefix = self.class.name.underscore.pluralize
58
+ role_partial = if to_llm.tool_call?
59
+ "tool_calls"
60
+ elsif role.to_s == "tool"
61
+ "tool"
62
+ else
63
+ role.to_s.presence || "assistant"
64
+ end
65
+ "#{partial_prefix}/#{role_partial}"
66
+ end
67
+
68
+ def tool_error_message
69
+ payload_error_message(content)
70
+ end
71
+
72
+ private
73
+
74
+ # Safely reads a field only if it is declared on this document class.
75
+ def field_value(name)
76
+ self.class.fields.key?(name.to_s) ? self[name] : nil
77
+ end
78
+
79
+ def extract_tool_calls
80
+ tool_calls_association.to_h do |tc|
81
+ [
82
+ tc.tool_call_id,
83
+ RubyLLM::ToolCall.new(
84
+ id: tc.tool_call_id,
85
+ name: tc.name,
86
+ arguments: tc.arguments,
87
+ thought_signature: tc.try(:thought_signature)
88
+ )
89
+ ]
90
+ end
91
+ end
92
+
93
+ def extract_tool_call_id
94
+ parent_tool_call&.tool_call_id
95
+ end
96
+
97
+ def extract_content
98
+ return RubyLLM::Content::Raw.new(self[:content_raw]) if field_value(:content_raw).present?
99
+ return gridfs_content(content) if respond_to?(:gridfs_file_ids) && gridfs_file_ids.present?
100
+
101
+ content
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/core_ext/module/delegation"
5
+
6
+ module RubyLLM
7
+ module Mongoid
8
+ # Mixes into a Mongoid document that represents a persisted LLM model record.
9
+ # Mirrors RubyLLM::ActiveRecord::ModelMethods.
10
+ module ModelMethods
11
+ extend ActiveSupport::Concern
12
+
13
+ class_methods do # rubocop:disable Metrics/BlockLength
14
+ def refresh!
15
+ RubyLLM.models.refresh!
16
+ save_to_database
17
+ end
18
+
19
+ def save_to_database
20
+ RubyLLM.models.all.each do |model_info|
21
+ model = find_or_initialize_by(
22
+ model_id: model_info.id,
23
+ provider: model_info.provider
24
+ )
25
+ model.assign_attributes(from_llm_attributes(model_info))
26
+ model.save!
27
+ end
28
+ end
29
+
30
+ def from_llm(model_info)
31
+ new(from_llm_attributes(model_info))
32
+ end
33
+
34
+ private
35
+
36
+ def from_llm_attributes(model_info)
37
+ {
38
+ model_id: model_info.id,
39
+ name: model_info.name,
40
+ provider: model_info.provider,
41
+ family: model_info.family,
42
+ model_created_at: model_info.created_at,
43
+ context_window: model_info.context_window,
44
+ max_output_tokens: model_info.max_output_tokens,
45
+ knowledge_cutoff: model_info.knowledge_cutoff,
46
+ modalities: model_info.modalities.to_h,
47
+ capabilities: model_info.capabilities,
48
+ pricing: model_info.pricing.to_h,
49
+ metadata: model_info.metadata
50
+ }
51
+ end
52
+ end
53
+
54
+ def to_llm
55
+ RubyLLM::Model::Info.new(
56
+ id: model_id,
57
+ name: name,
58
+ provider: provider,
59
+ family: family,
60
+ created_at: model_created_at,
61
+ context_window: context_window,
62
+ max_output_tokens: max_output_tokens,
63
+ knowledge_cutoff: knowledge_cutoff,
64
+ modalities: modalities&.deep_symbolize_keys || {},
65
+ capabilities: capabilities,
66
+ pricing: pricing&.deep_symbolize_keys || {},
67
+ metadata: metadata&.deep_symbolize_keys || {}
68
+ )
69
+ end
70
+
71
+ delegate :supports?, :supports_vision?, :supports_functions?, :type,
72
+ :input_price_per_million, :output_price_per_million,
73
+ :cache_read_input_price_per_million, :cache_write_input_price_per_million,
74
+ :cached_input_price_per_million, :cache_creation_input_price_per_million,
75
+ :function_calling?, :structured_output?, :batch?,
76
+ :reasoning?, :citations?, :streaming?, :provider_class, :label,
77
+ :cost_for,
78
+ to: :to_llm
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/blank"
4
+ require "json"
5
+
6
+ module RubyLLM
7
+ module Mongoid
8
+ module PayloadHelpers
9
+ private
10
+
11
+ def payload_error_message(value)
12
+ payload = parse_payload(value)
13
+ return unless payload.is_a?(Hash)
14
+
15
+ payload["error"] || payload[:error]
16
+ end
17
+
18
+ def parse_payload(value)
19
+ return value if value.is_a?(Hash) || value.is_a?(Array)
20
+ return if value.blank?
21
+
22
+ JSON.parse(value)
23
+ rescue JSON::ParserError
24
+ nil
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module RubyLLM
6
+ module Mongoid
7
+ class Railtie < Rails::Railtie
8
+ initializer "ruby_llm.mongoid.acts_as" do
9
+ ActiveSupport.on_load(:mongoid) do
10
+ ::Mongoid::Document::ClassMethods.include(RubyLLM::Mongoid::ActsAs::ClassMethods)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "ruby_llm/mongoid/payload_helpers"
5
+
6
+ module RubyLLM
7
+ module Mongoid
8
+ # Mixes into a Mongoid document that represents a persisted tool call.
9
+ # Mirrors RubyLLM::ActiveRecord::ToolCallMethods.
10
+ module ToolCallMethods
11
+ extend ActiveSupport::Concern
12
+ include PayloadHelpers
13
+
14
+ def tool_error_message
15
+ payload_error_message(arguments)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Mongoid
5
+ # Wraps a block in a MongoDB multi-document transaction when a replica set is
6
+ # available. Falls back to a plain yield on standalone mongod so tests and
7
+ # single-node dev setups still work without errors.
8
+ module Transaction
9
+ def with_transaction(&)
10
+ _run_transaction(&)
11
+ rescue ::Mongoid::Errors::TransactionsNotSupported, ::Mongo::Error::TransactionsNotSupported
12
+ yield
13
+ rescue ::Mongo::Error::OperationFailure => e
14
+ raise unless standalone_mongod_error?(e)
15
+
16
+ yield
17
+ rescue NotImplementedError, NoMethodError # rubocop:disable Lint/DuplicateBranch
18
+ yield
19
+ end
20
+
21
+ private
22
+
23
+ def _run_transaction(&block)
24
+ if ::Mongoid.respond_to?(:with_session)
25
+ ::Mongoid.with_session do |session|
26
+ session.with_transaction(&block)
27
+ end
28
+ else
29
+ yield
30
+ end
31
+ end
32
+
33
+ def standalone_mongod_error?(error)
34
+ # Error code 20 is "IllegalOperation" which is returned when transactions
35
+ # are used on a standalone mongod.
36
+ return true if error.respond_to?(:code) && error.code == 20
37
+
38
+ msg = error.message
39
+ msg.include?("Transaction numbers are only allowed") ||
40
+ msg.include?("no such command: 'startTransaction'")
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Mongoid
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+ require "mongoid"
5
+ require "active_support"
6
+ require_relative "mongoid/version"
7
+ require_relative "mongoid/payload_helpers"
8
+ require_relative "mongoid/transaction"
9
+ require_relative "mongoid/grid_fs_attachment"
10
+ require_relative "mongoid/model_methods"
11
+ require_relative "mongoid/tool_call_methods"
12
+ require_relative "mongoid/message_methods"
13
+ require_relative "mongoid/chat_methods"
14
+ require_relative "mongoid/acts_as"
15
+
16
+ module RubyLLM
17
+ module Mongoid
18
+ class Error < StandardError; end
19
+ end
20
+ end
21
+
22
+ if defined?(Rails)
23
+ require_relative "mongoid/railtie"
24
+ else
25
+ # In non-Rails environments (tests, scripts) auto-install the macros
26
+ # immediately after Mongoid is loaded.
27
+ RubyLLM::Mongoid::ActsAs.install!
28
+ end
metadata ADDED
@@ -0,0 +1,115 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby_llm-mongoid
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - washu
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: mongoid
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '8.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '8.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: ruby_llm
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '1.16'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '1.16'
54
+ description: Drop-in Mongoid replacement for ruby_llm's ActiveRecord integration.
55
+ Provides acts_as_chat, acts_as_message, acts_as_tool_call, and acts_as_model macros
56
+ backed by MongoDB via Mongoid.
57
+ email:
58
+ - sal.scotto@gmail.com
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - ".rspec"
64
+ - ".rubocop.yml"
65
+ - ".ruby-version"
66
+ - CHANGELOG.md
67
+ - CODE_OF_CONDUCT.md
68
+ - LICENSE.txt
69
+ - README.md
70
+ - Rakefile
71
+ - build_release.sh
72
+ - lib/generators/ruby_llm/mongoid/install/install_generator.rb
73
+ - lib/generators/ruby_llm/mongoid/install/templates/chat_model.rb.tt
74
+ - lib/generators/ruby_llm/mongoid/install/templates/initializer.rb.tt
75
+ - lib/generators/ruby_llm/mongoid/install/templates/message_model.rb.tt
76
+ - lib/generators/ruby_llm/mongoid/install/templates/model_model.rb.tt
77
+ - lib/generators/ruby_llm/mongoid/install/templates/tool_call_model.rb.tt
78
+ - lib/ruby_llm/mongoid.rb
79
+ - lib/ruby_llm/mongoid/acts_as.rb
80
+ - lib/ruby_llm/mongoid/chat_methods.rb
81
+ - lib/ruby_llm/mongoid/grid_fs_attachment.rb
82
+ - lib/ruby_llm/mongoid/message_methods.rb
83
+ - lib/ruby_llm/mongoid/model_methods.rb
84
+ - lib/ruby_llm/mongoid/payload_helpers.rb
85
+ - lib/ruby_llm/mongoid/railtie.rb
86
+ - lib/ruby_llm/mongoid/tool_call_methods.rb
87
+ - lib/ruby_llm/mongoid/transaction.rb
88
+ - lib/ruby_llm/mongoid/version.rb
89
+ homepage: https://github.com/washu/ruby_llm-mongoid
90
+ licenses:
91
+ - MIT
92
+ metadata:
93
+ homepage_uri: https://github.com/washu/ruby_llm-mongoid
94
+ source_code_uri: https://github.com/washu/ruby_llm-mongoid
95
+ changelog_uri: https://github.com/washu/ruby_llm-mongoid/blob/main/CHANGELOG.md
96
+ rubygems_mfa_required: 'true'
97
+ rdoc_options: []
98
+ require_paths:
99
+ - lib
100
+ required_ruby_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: 3.3.0
105
+ required_rubygems_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ requirements: []
111
+ rubygems_version: 3.6.9
112
+ specification_version: 4
113
+ summary: Mongoid persistence for ruby_llm (acts_as_chat, acts_as_message, acts_as_tool_call,
114
+ acts_as_model)
115
+ test_files: []