rubycanusellm 0.3.1 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb98a91b1d857f47d5ed3905d0fc520175297366268125d382fe928a91a993a3
4
- data.tar.gz: '0284438324a2fc028176c0405a3af011a0457617ca930abc7cbcc4012f204fa1'
3
+ metadata.gz: 95279ea6440be4ca1db0588578072aeec9ceb7ed60b3491a9a8a7e2e9f1cd354
4
+ data.tar.gz: d584565619149e8e23a50997345da518c65d1646daee38b5dafecabd76b467cd
5
5
  SHA512:
6
- metadata.gz: 9b7d8455fca4b21d60597e8d49a765a5a210d6c314b855cceb9ca21c46924555ea2841a7bda802fb55cdfd7ad49721d9a49d2a67edafd37f8427da98ba1fe522
7
- data.tar.gz: d7c5818f91bc9a7973b20df9a465deaa4c4878e59e48c0859ef18bc4f0a5c6e4e261ac96458d9a05af90a901cf2b6614fb2fe4588339e1598c6a9e9b403aad11
6
+ metadata.gz: f390e611e876ebbe93e4f9a80a2f48126156d620477489fb4de1786db739bfddbc029bbcd3ddfcfc3f06947956ffbec2d942a3dc10430ada08c6a8c754f92673
7
+ data.tar.gz: 95b04a2d9c73571ed57c97ad8f2b752866329ff9f8914eb8324e0b1eeb3000ca963a56b4e31206f7fbec17b5f5ba56874380e98ecc658f4ab43efd3b1f215c0d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0] - 2026-04-03
4
+
5
+ ### Added
6
+
7
+ - Mistral provider (chat + embeddings)
8
+ - Ollama provider (chat + embeddings, local, no API key required)
9
+ - `config.base_url` for pointing to custom Ollama instances
10
+ - Mistral and Ollama added to `EMBEDDING_PROVIDERS`
11
+
3
12
  ## [0.3.1] - 2026-04-03
4
13
 
5
14
  ### Added
data/README.md CHANGED
@@ -67,6 +67,8 @@ That's it. Same code, different provider.
67
67
  |----------|--------|--------|-------|
68
68
  | OpenAI | gpt-4o-mini, gpt-4o, etc. | ✅ | Chat + Embeddings |
69
69
  | Anthropic | claude-sonnet-4-20250514, etc. | ✅ | Chat only |
70
+ | Mistral | mistral-small-latest, mistral-large-latest, etc. | ✅ | Chat + Embeddings |
71
+ | Ollama | llama3.2, mistral, etc. | ✅ | Chat + Embeddings (local) |
70
72
  | Voyage AI | voyage-3.5, voyage-4, etc. | ✅ | Embeddings only |
71
73
 
72
74
  ## API Reference
@@ -74,15 +76,24 @@ That's it. Same code, different provider.
74
76
  ### Configuration
75
77
  ```ruby
76
78
  RubyCanUseLLM.configure do |config|
77
- config.provider = :openai # :openai or :anthropic
78
- config.api_key = "your-key" # required
79
+ config.provider = :openai # :openai, :anthropic, :mistral, or :ollama
80
+ config.api_key = "your-key" # required (not needed for Ollama)
79
81
  config.model = "gpt-4o-mini" # optional, has sensible defaults
80
82
  config.timeout = 30 # optional, default 30s
83
+ config.base_url = "http://localhost:11434" # optional, for Ollama (default shown)
81
84
  config.embedding_provider = :voyage # optional, for separate embedding provider
82
85
  config.embedding_api_key = "key" # required when embedding_provider is set
83
86
  end
84
87
  ```
85
88
 
89
+ **Ollama (local, no API key needed):**
90
+ ```ruby
91
+ RubyCanUseLLM.configure do |config|
92
+ config.provider = :ollama
93
+ # config.base_url = "http://localhost:11434" # default, change if needed
94
+ end
95
+ ```
96
+
86
97
  ### Chat
87
98
  ```ruby
88
99
  response = RubyCanUseLLM.chat(messages, **options)
@@ -109,7 +120,7 @@ RubyCanUseLLM.chat(messages, stream: true) do |chunk|
109
120
  end
110
121
  ```
111
122
 
112
- Each `chunk` is a `RubyCanUseLLM::Chunk` with `content` (the token text) and `role` (`"assistant"`). Works with both OpenAI and Anthropic.
123
+ Each `chunk` is a `RubyCanUseLLM::Chunk` with `content` (the token text) and `role` (`"assistant"`). Works with OpenAI, Anthropic, Mistral, and Ollama.
113
124
 
114
125
  ### Response
115
126
  ```ruby
@@ -205,8 +216,9 @@ end
205
216
  - [x] Streaming support
206
217
  - [x] Embeddings + configurable embedding provider
207
218
  - [x] Voyage AI provider (embeddings)
219
+ - [x] Mistral provider (chat + embeddings)
220
+ - [x] Ollama provider (chat + embeddings, local)
208
221
  - [ ] `generate:embedding` command
209
- - [ ] Mistral and Ollama providers
210
222
  - [ ] Tool calling
211
223
 
212
224
  ## Development
@@ -2,10 +2,10 @@
2
2
 
3
3
  module RubyCanUseLLM
4
4
  class Configuration
5
- SUPPORTED_PROVIDERS = %i[openai anthropic voyage].freeze
6
- EMBEDDING_PROVIDERS = %i[openai voyage].freeze
5
+ SUPPORTED_PROVIDERS = %i[openai anthropic voyage mistral ollama].freeze
6
+ EMBEDDING_PROVIDERS = %i[openai voyage mistral ollama].freeze
7
7
 
8
- attr_accessor :provider, :api_key, :model, :timeout, :embedding_provider, :embedding_api_key
8
+ attr_accessor :provider, :api_key, :model, :timeout, :embedding_provider, :embedding_api_key, :base_url
9
9
 
10
10
  def initialize
11
11
  @provider = nil
@@ -14,11 +14,14 @@ module RubyCanUseLLM
14
14
  @timeout = 30
15
15
  @embedding_provider = nil
16
16
  @embedding_api_key = nil
17
+ @base_url = nil
17
18
  end
18
19
 
19
20
  def validate!
20
- raise Error, "provider is required. Use :openai or :anthropic" if provider.nil?
21
- raise Error, "api_key is required" if api_key.nil? || api_key.empty?
21
+ raise Error, "provider is required. Use :openai, :anthropic, :mistral, or :ollama" if provider.nil?
22
+ unless provider == :ollama
23
+ raise Error, "api_key is required" if api_key.nil? || api_key.empty?
24
+ end
22
25
  raise Error, "Unknown provider: #{provider}. Supported: #{SUPPORTED_PROVIDERS.join(", ")}" unless SUPPORTED_PROVIDERS.include?(provider)
23
26
  end
24
27
 
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module RubyCanUseLLM
8
+ module Providers
9
+ class Mistral < Base
10
+ CHAT_URL = "https://api.mistral.ai/v1/chat/completions"
11
+ EMBED_URL = "https://api.mistral.ai/v1/embeddings"
12
+
13
+ def chat(messages, **options, &block)
14
+ if options[:stream] && block
15
+ body = build_body(messages, options.except(:stream)).merge(stream: true)
16
+ stream_request(body, &block)
17
+ else
18
+ body = build_body(messages, options)
19
+ response = request(body)
20
+ parse_response(response)
21
+ end
22
+ end
23
+
24
+ def embed(text, **options)
25
+ body = {
26
+ model: options[:model] || "mistral-embed",
27
+ input: text
28
+ }
29
+ response = embedding_request(body)
30
+ parse_embedding(response)
31
+ end
32
+
33
+ private
34
+
35
+ def build_body(messages, options)
36
+ {
37
+ model: options[:model] || config.model || "mistral-small-latest",
38
+ messages: format_messages(messages),
39
+ temperature: options[:temperature] || 0.7
40
+ }
41
+ end
42
+
43
+ def format_messages(messages)
44
+ messages.map do |msg|
45
+ { role: msg[:role].to_s, content: msg[:content] }
46
+ end
47
+ end
48
+
49
+ def request(body)
50
+ uri = URI(CHAT_URL)
51
+ http = Net::HTTP.new(uri.host, uri.port)
52
+ http.use_ssl = true
53
+ http.read_timeout = config.timeout
54
+
55
+ req = Net::HTTP::Post.new(uri)
56
+ req["Authorization"] = "Bearer #{config.api_key}"
57
+ req["Content-Type"] = "application/json"
58
+ req.body = body.to_json
59
+
60
+ handle_response(http.request(req))
61
+ rescue Net::ReadTimeout, Net::OpenTimeout
62
+ raise TimeoutError, "Request to Mistral timed out after #{config.timeout}s"
63
+ end
64
+
65
+ def stream_request(body, &block)
66
+ uri = URI(CHAT_URL)
67
+ http = Net::HTTP.new(uri.host, uri.port)
68
+ http.use_ssl = true
69
+ http.read_timeout = config.timeout
70
+
71
+ req = Net::HTTP::Post.new(uri)
72
+ req["Authorization"] = "Bearer #{config.api_key}"
73
+ req["Content-Type"] = "application/json"
74
+ req["Accept-Encoding"] = "identity"
75
+ req.body = body.to_json
76
+
77
+ http.request(req) do |response|
78
+ case response.code.to_i
79
+ when 401 then raise AuthenticationError, "Invalid Mistral API key"
80
+ when 429 then raise RateLimitError, "Mistral rate limit exceeded"
81
+ end
82
+ raise ProviderError, "Mistral error (#{response.code})" unless response.code.to_i == 200
83
+
84
+ buffer = ""
85
+ response.read_body do |raw_chunk|
86
+ buffer += raw_chunk
87
+ lines = buffer.split("\n", -1)
88
+ buffer = lines.pop || ""
89
+ lines.each do |line|
90
+ line.chomp!
91
+ next unless line.start_with?("data: ")
92
+
93
+ data = line[6..]
94
+ next if data == "[DONE]"
95
+
96
+ parsed = JSON.parse(data)
97
+ content = parsed.dig("choices", 0, "delta", "content")
98
+ block.call(Chunk.new(content: content)) if content && !content.empty?
99
+ end
100
+ end
101
+ end
102
+ rescue Net::ReadTimeout, Net::OpenTimeout
103
+ raise TimeoutError, "Request to Mistral timed out after #{config.timeout}s"
104
+ end
105
+
106
+ def handle_response(response)
107
+ case response.code.to_i
108
+ when 200
109
+ JSON.parse(response.body)
110
+ when 401
111
+ raise AuthenticationError, "Invalid Mistral API key"
112
+ when 429
113
+ raise RateLimitError, "Mistral rate limit exceeded"
114
+ else
115
+ raise ProviderError, "Mistral error (#{response.code}): #{response.body}"
116
+ end
117
+ end
118
+
119
+ def parse_response(data)
120
+ choice = data.dig("choices", 0, "message")
121
+ usage = data["usage"]
122
+
123
+ Response.new(
124
+ content: choice["content"],
125
+ model: data["model"],
126
+ input_tokens: usage["prompt_tokens"],
127
+ output_tokens: usage["completion_tokens"],
128
+ raw: data
129
+ )
130
+ end
131
+
132
+ def embedding_request(body)
133
+ uri = URI(EMBED_URL)
134
+ http = Net::HTTP.new(uri.host, uri.port)
135
+ http.use_ssl = true
136
+ http.read_timeout = config.timeout
137
+
138
+ req = Net::HTTP::Post.new(uri)
139
+ req["Authorization"] = "Bearer #{config.api_key}"
140
+ req["Content-Type"] = "application/json"
141
+ req.body = body.to_json
142
+
143
+ handle_response(http.request(req))
144
+ rescue Net::ReadTimeout, Net::OpenTimeout
145
+ raise TimeoutError, "Request to Mistral timed out after #{config.timeout}s"
146
+ end
147
+
148
+ def parse_embedding(data)
149
+ embedding = data.dig("data", 0, "embedding")
150
+ usage = data["usage"]
151
+
152
+ EmbeddingResponse.new(
153
+ embedding: embedding,
154
+ model: data["model"],
155
+ tokens: usage["total_tokens"],
156
+ raw: data
157
+ )
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module RubyCanUseLLM
8
+ module Providers
9
+ class Ollama < Base
10
+ DEFAULT_BASE_URL = "http://localhost:11434"
11
+
12
+ def chat(messages, **options, &block)
13
+ if options[:stream] && block
14
+ body = build_body(messages, options.except(:stream)).merge(stream: true)
15
+ stream_request(body, &block)
16
+ else
17
+ body = build_body(messages, options).merge(stream: false)
18
+ response = request(body)
19
+ parse_response(response)
20
+ end
21
+ end
22
+
23
+ def embed(text, **options)
24
+ body = {
25
+ model: options[:model] || "nomic-embed-text",
26
+ input: text
27
+ }
28
+ response = embedding_request(body)
29
+ parse_embedding(response)
30
+ end
31
+
32
+ private
33
+
34
+ def base_url
35
+ config.base_url || DEFAULT_BASE_URL
36
+ end
37
+
38
+ def build_body(messages, options)
39
+ {
40
+ model: options[:model] || config.model || "llama3.2",
41
+ messages: format_messages(messages),
42
+ temperature: options[:temperature] || 0.7
43
+ }
44
+ end
45
+
46
+ def format_messages(messages)
47
+ messages.map do |msg|
48
+ { role: msg[:role].to_s, content: msg[:content] }
49
+ end
50
+ end
51
+
52
+ def request(body)
53
+ uri = URI("#{base_url}/api/chat")
54
+ http = Net::HTTP.new(uri.host, uri.port)
55
+ http.use_ssl = uri.scheme == "https"
56
+ http.read_timeout = config.timeout
57
+
58
+ req = Net::HTTP::Post.new(uri)
59
+ req["Content-Type"] = "application/json"
60
+ req.body = body.to_json
61
+
62
+ handle_response(http.request(req))
63
+ rescue Net::ReadTimeout, Net::OpenTimeout
64
+ raise TimeoutError, "Request to Ollama timed out after #{config.timeout}s"
65
+ end
66
+
67
+ def stream_request(body, &block)
68
+ uri = URI("#{base_url}/api/chat")
69
+ http = Net::HTTP.new(uri.host, uri.port)
70
+ http.use_ssl = uri.scheme == "https"
71
+ http.read_timeout = config.timeout
72
+
73
+ req = Net::HTTP::Post.new(uri)
74
+ req["Content-Type"] = "application/json"
75
+ req["Accept-Encoding"] = "identity"
76
+ req.body = body.to_json
77
+
78
+ http.request(req) do |response|
79
+ raise ProviderError, "Ollama error (#{response.code})" unless response.code.to_i == 200
80
+
81
+ response.read_body do |raw_chunk|
82
+ raw_chunk.split("\n").each do |line|
83
+ line.strip!
84
+ next if line.empty?
85
+
86
+ parsed = JSON.parse(line)
87
+ content = parsed.dig("message", "content")
88
+ block.call(Chunk.new(content: content)) if content && !content.empty? && !parsed["done"]
89
+ end
90
+ end
91
+ end
92
+ rescue Net::ReadTimeout, Net::OpenTimeout
93
+ raise TimeoutError, "Request to Ollama timed out after #{config.timeout}s"
94
+ end
95
+
96
+ def handle_response(response)
97
+ case response.code.to_i
98
+ when 200
99
+ JSON.parse(response.body)
100
+ else
101
+ raise ProviderError, "Ollama error (#{response.code}): #{response.body}"
102
+ end
103
+ end
104
+
105
+ def parse_response(data)
106
+ message = data["message"]
107
+ Response.new(
108
+ content: message["content"],
109
+ model: data["model"],
110
+ input_tokens: data["prompt_eval_count"] || 0,
111
+ output_tokens: data["eval_count"] || 0,
112
+ raw: data
113
+ )
114
+ end
115
+
116
+ def embedding_request(body)
117
+ uri = URI("#{base_url}/api/embed")
118
+ http = Net::HTTP.new(uri.host, uri.port)
119
+ http.use_ssl = uri.scheme == "https"
120
+ http.read_timeout = config.timeout
121
+
122
+ req = Net::HTTP::Post.new(uri)
123
+ req["Content-Type"] = "application/json"
124
+ req.body = body.to_json
125
+
126
+ handle_response(http.request(req))
127
+ rescue Net::ReadTimeout, Net::OpenTimeout
128
+ raise TimeoutError, "Request to Ollama timed out after #{config.timeout}s"
129
+ end
130
+
131
+ def parse_embedding(data)
132
+ embedding = data.dig("embeddings", 0)
133
+ EmbeddingResponse.new(
134
+ embedding: embedding,
135
+ model: data["model"],
136
+ tokens: data["prompt_eval_count"] || 0,
137
+ raw: data
138
+ )
139
+ end
140
+ end
141
+ end
142
+ end
@@ -1,22 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  RubyCanUseLLM.configure do |config|
4
- # Choose your provider: :openai or :anthropic
4
+ # Choose your provider: :openai, :anthropic, :mistral, or :ollama
5
5
  config.provider = :openai
6
6
 
7
7
  # Your API key (use environment variables in production)
8
+ # Not required for Ollama (runs locally)
8
9
  config.api_key = ENV["LLM_API_KEY"]
9
10
 
10
11
  # Default model (optional, each provider has a sensible default)
11
12
  # OpenAI: "gpt-4o-mini", Anthropic: "claude-sonnet-4-20250514"
13
+ # Mistral: "mistral-small-latest", Ollama: "llama3.2"
12
14
  # config.model = "gpt-4o-mini"
13
15
 
14
16
  # Request timeout in seconds (default: 30)
15
17
  # config.timeout = 30
16
18
 
19
+ # Ollama base URL (optional, default: http://localhost:11434)
20
+ # config.base_url = "http://localhost:11434"
21
+
17
22
  # Embedding provider (optional, defaults to main provider)
18
23
  # Anthropic doesn't support embeddings natively.
19
- # Use :voyage (recommended by Anthropic) or :openai for embeddings.
24
+ # Use :voyage (recommended by Anthropic), :openai, :mistral, or :ollama for embeddings.
20
25
  # config.embedding_provider = :voyage
21
26
  # config.embedding_api_key = ENV["VOYAGE_API_KEY"]
22
27
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rubycanusellm
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/rubycanusellm.rb CHANGED
@@ -9,13 +9,17 @@ require_relative "rubycanusellm/providers/base"
9
9
  require_relative "rubycanusellm/providers/openai"
10
10
  require_relative "rubycanusellm/providers/anthropic"
11
11
  require_relative "rubycanusellm/providers/voyage"
12
+ require_relative "rubycanusellm/providers/mistral"
13
+ require_relative "rubycanusellm/providers/ollama"
12
14
  require_relative "rubycanusellm/embedding_response"
13
15
 
14
16
  module RubyCanUseLLM
15
17
  PROVIDERS = {
16
18
  openai: Providers::OpenAI,
17
19
  anthropic: Providers::Anthropic,
18
- voyage: Providers::Voyage
20
+ voyage: Providers::Voyage,
21
+ mistral: Providers::Mistral,
22
+ ollama: Providers::Ollama
19
23
  }.freeze
20
24
 
21
25
  class << self
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubycanusellm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Juan Manuel Guzman Nava
@@ -35,6 +35,8 @@ files:
35
35
  - lib/rubycanusellm/errors.rb
36
36
  - lib/rubycanusellm/providers/anthropic.rb
37
37
  - lib/rubycanusellm/providers/base.rb
38
+ - lib/rubycanusellm/providers/mistral.rb
39
+ - lib/rubycanusellm/providers/ollama.rb
38
40
  - lib/rubycanusellm/providers/openai.rb
39
41
  - lib/rubycanusellm/providers/voyage.rb
40
42
  - lib/rubycanusellm/response.rb