llm.rb 0.3.1 → 0.3.2

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: 3939075c064b4abfd8853c3f67b6db7df6111d340d658d4d8ad0c4d1bccc96bc
4
- data.tar.gz: 0ca274d3e4b032c25730aef896df903681c28033ebb0907c965339a33aff56d1
3
+ metadata.gz: 3c55653b476d2fe6fe9457c89bc430c698668312ce89660a1d69abd8adf338eb
4
+ data.tar.gz: fe7d456bbb739eb091e82351839baef4c64d1d108a2c4cd7de3eb1b478982631
5
5
  SHA512:
6
- metadata.gz: feaf87457b8fa5b4f756a5fe8cc1f670c8b0286a730fe00273bc99678092fe7f704d58f01ba0a0baf4072a0dcee063bc87cf88bc7cdf53125334476adbce41f6
7
- data.tar.gz: 3be8b460d9b483c0e172d9159b2394ea39da7a1475aee3ab47b224303e2a251f3b04f0543402494485040998225f84342be986db8c7b8ea80df92f561d4d6d92
6
+ metadata.gz: 8cd55bb28eb92fea745d8b11062b2442bf4b2de88ecfb0b7dc99cfefd293bd45113088dd13ccfe7e251d2e369459da700f15725bae51c3d31d4bf68e19953138
7
+ data.tar.gz: dab47021b94d00e51e7d0ca3f92e2966170b9fd8ce7138e0728d2be7fb83da03104ff93cd7c54b760acca62dd03adf16462069db9eb5c30185743c25259105aa
data/README.md CHANGED
@@ -26,6 +26,7 @@ llm = LLM.openai("yourapikey")
26
26
  llm = LLM.gemini("yourapikey")
27
27
  llm = LLM.anthropic("yourapikey")
28
28
  llm = LLM.ollama(nil)
29
+ llm = LLM.voyageai("yourapikey")
29
30
  ```
30
31
 
31
32
  ### Conversations
@@ -122,8 +123,6 @@ for more information on how to use the audio generation API:
122
123
  ```ruby
123
124
  #!/usr/bin/env ruby
124
125
  require "llm"
125
- require "open-uri"
126
- require "fileutils"
127
126
 
128
127
  llm = LLM.openai(ENV["KEY"])
129
128
  res = llm.audio.create_speech(input: "Hello world")
@@ -151,8 +150,6 @@ examples and documentation
151
150
  ```ruby
152
151
  #!/usr/bin/env ruby
153
152
  require "llm"
154
- require "open-uri"
155
- require "fileutils"
156
153
 
157
154
  llm = LLM.openai(ENV["KEY"])
158
155
  res = llm.audio.create_transcription(
@@ -180,9 +177,8 @@ examples and documentation
180
177
 
181
178
 
182
179
  ```ruby
180
+ #!/usr/bin/env ruby
183
181
  require "llm"
184
- require "open-uri"
185
- require "fileutils"
186
182
 
187
183
  llm = LLM.openai(ENV["KEY"])
188
184
  res = llm.audio.create_translation(
@@ -320,6 +316,48 @@ bot.messages.select(&:assistant?).each { print "[#{_1.role}] ", _1.content, "\n"
320
316
  # contains information about the features, installation, and usage of OpenBSD.
321
317
  ```
322
318
 
319
+ ### Prompts
320
+
321
+ #### Multimodal
322
+
323
+ Generally all providers accept text prompts but some providers can
324
+ also understand URLs, and various file types (eg images, audio, video,
325
+ etc). The llm.rb approach to multimodal prompts is to let you pass `URI`
326
+ objects to describe links, `LLM::File` / `LLM::Response::File` objects
327
+ to describe files, `String` objects to describe text blobs, or an array
328
+ of the forementioned objects to describe multiple objects in a single
329
+ prompt. Each object is a first class citizen that can be passed directly
330
+ to a prompt.
331
+
332
+ For more depth and examples on how to use the multimodal API, please see
333
+ the [provider-specific documentation](https://0x1eef.github.io/x/llm.rb/)
334
+ for more provider-specific examples – there can be subtle differences
335
+ between providers and even between APIs from the same provider that are
336
+ not covered in the README:
337
+
338
+ ```ruby
339
+ #!/usr/bin/env ruby
340
+ require "llm"
341
+
342
+ llm = LLM.openai(ENV["KEY"])
343
+ bot = LLM::Chat.new(llm).lazy
344
+
345
+ bot.chat URI("https://example.com/path/to/image.png")
346
+ bot.chat "Describe the above image"
347
+ bot.messages.select(&:assistant?).each { print "[#{_1.role}] ", _1.content, "\n" }
348
+
349
+ file = bot.files.create(file: LLM::File("/documents/openbsd_is_awesome.pdf"))
350
+ bot.chat file
351
+ bot.chat "What is this file about?"
352
+ bot.messages.select(&:assistant?).each { print "[#{_1.role}] ", _1.content, "\n" }
353
+
354
+ bot.chat [LLM::File("/images/puffy.png"), "What is this image about?"]
355
+ bot.messages.select(&:assistant?).each { print "[#{_1.role}] ", _1.content, "\n" }
356
+
357
+ bot.chat [LLM::File("/images/beastie.png"), "What is this image about?"]
358
+ bot.messages.select(&:assistant?).each { print "[#{_1.role}] ", _1.content, "\n" }
359
+ ```
360
+
323
361
  ### Embeddings
324
362
 
325
363
  #### Text
@@ -394,11 +432,11 @@ llm.rb can be installed via rubygems.org:
394
432
 
395
433
  ## Philosophy
396
434
 
397
- llm.rb was built for developers who believe that simplicity is strength.
398
- It provides a clean, dependency-free interface to Large Language Models,
399
- treating Ruby itself as the primary platform – not Rails or any other
400
- specific framework or library. There is no hidden magic or extreme
401
- metaprogramming.
435
+ llm.rb was built for developers who believe that simplicity can be challenging
436
+ but it is always worth it. It provides a clean, dependency-free interface to
437
+ Large Language Models, treating Ruby itself as the primary platform –
438
+ not Rails or any other specific framework or library. There is no hidden
439
+ magic or complex metaprogramming.
402
440
 
403
441
  Every part of llm.rb is designed to be explicit, composable, memory-safe,
404
442
  and production-ready without compromise. No unnecessary abstractions,
@@ -406,8 +444,6 @@ no global configuration, and no dependencies that aren't part of standard
406
444
  Ruby. It has been inspired in part by other languages such as Python, but
407
445
  it is not a port of any other library.
408
446
 
409
- Good software doesn’t need marketing. It just needs to work. :)
410
-
411
447
  ## License
412
448
 
413
449
  [BSD Zero Clause](https://choosealicense.com/licenses/0bsd/)
data/lib/llm/error.rb CHANGED
@@ -4,8 +4,9 @@ module LLM
4
4
  ##
5
5
  # The superclass of all LLM errors
6
6
  class Error < RuntimeError
7
- def initialize
7
+ def initialize(...)
8
8
  block_given? ? yield(self) : nil
9
+ super
9
10
  end
10
11
 
11
12
  ##
@@ -17,6 +18,10 @@ module LLM
17
18
  attr_accessor :response
18
19
  end
19
20
 
21
+ ##
22
+ # When a prompt is given an object that's not understood
23
+ PromptError = Class.new(Error)
24
+
20
25
  ##
21
26
  # HTTPUnauthorized
22
27
  Unauthorized = Class.new(ResponseError)
data/lib/llm/file.rb CHANGED
@@ -42,6 +42,13 @@ class LLM::File
42
42
  [File.binread(path)].pack("m0")
43
43
  end
44
44
 
45
+ ##
46
+ # @return [String]
47
+ # Returns the file contents in base64 URL format
48
+ def to_data_uri
49
+ "data:#{mime_type};base64,#{to_b64}"
50
+ end
51
+
45
52
  ##
46
53
  # @return [File]
47
54
  # Yields an IO object suitable to be streamed
data/lib/llm/provider.rb CHANGED
@@ -4,16 +4,7 @@
4
4
  # The Provider class represents an abstract class for
5
5
  # LLM (Language Model) providers.
6
6
  #
7
- # @note
8
- # This class is not meant to be instantiated directly.
9
- # Instead, use one of the subclasses that implement
10
- # the methods defined here.
11
- #
12
7
  # @abstract
13
- # @see LLM::Provider::OpenAI
14
- # @see LLM::Provider::Anthropic
15
- # @see LLM::Provider::Gemini
16
- # @see LLM::Provider::Ollama
17
8
  class LLM::Provider
18
9
  require "net/http"
19
10
 
@@ -78,7 +69,7 @@ class LLM::Provider
78
69
  # @raise [NotImplementedError]
79
70
  # When the method is not implemented by a subclass
80
71
  # @return [LLM::Response::Completion]
81
- def complete(prompt, role = :user, model:, **params)
72
+ def complete(prompt, role = :user, model: nil, **params)
82
73
  raise NotImplementedError
83
74
  end
84
75
 
@@ -237,6 +228,8 @@ class LLM::Provider
237
228
  # When the rate limit is exceeded
238
229
  # @raise [LLM::Error::ResponseError]
239
230
  # When any other unsuccessful status code is returned
231
+ # @raise [LLM::Error::PromptError]
232
+ # When given an object a provider does not understand
240
233
  # @raise [SystemCallError]
241
234
  # When there is a network error at the operating system level
242
235
  def request(http, req, &b)
@@ -247,6 +240,17 @@ class LLM::Provider
247
240
  end
248
241
  end
249
242
 
243
+ ##
244
+ # @param [Net::HTTPRequest] req
245
+ # The request to set the body stream for
246
+ # @param [IO] io
247
+ # The IO object to set as the body stream
248
+ # @return [void]
249
+ def set_body_stream(req, io)
250
+ req.body_stream = io
251
+ req["transfer-encoding"] = "chunked" unless req["content-length"]
252
+ end
253
+
250
254
  ##
251
255
  # @param [String] provider
252
256
  # The name of the provider
@@ -83,7 +83,7 @@ class LLM::Gemini
83
83
  req["X-Goog-Upload-Offset"] = 0
84
84
  req["X-Goog-Upload-Command"] = "upload, finalize"
85
85
  file.with_io do |io|
86
- req.body_stream = io
86
+ set_body_stream(req, io)
87
87
  res = request(http, req)
88
88
  LLM::Response::File.new(res)
89
89
  end
@@ -155,7 +155,7 @@ class LLM::Gemini
155
155
  @provider.instance_variable_get(:@secret)
156
156
  end
157
157
 
158
- [:headers, :request].each do |m|
158
+ [:headers, :request, :set_body_stream].each do |m|
159
159
  define_method(m) { |*args, &b| @provider.send(m, *args, &b) }
160
160
  end
161
161
  end
@@ -70,7 +70,7 @@ class LLM::Gemini
70
70
  contents: [{parts: [{text: prompt}, format_content(image)]}],
71
71
  generationConfig: {responseModalities: ["TEXT", "IMAGE"]}
72
72
  }.merge!(params)).b
73
- req.body_stream = StringIO.new(body)
73
+ set_body_stream(req, StringIO.new(body))
74
74
  res = request(http, req)
75
75
  LLM::Response::Image.new(res).extend(response_parser)
76
76
  end
@@ -92,7 +92,7 @@ class LLM::Gemini
92
92
  @provider.instance_variable_get(:@http)
93
93
  end
94
94
 
95
- [:response_parser, :headers, :request].each do |m|
95
+ [:response_parser, :headers, :request, :set_body_stream].each do |m|
96
96
  define_method(m) { |*args, &b| @provider.send(m, *args, &b) }
97
97
  end
98
98
  end
@@ -70,11 +70,12 @@ module LLM
70
70
  # @raise (see LLM::Provider#request)
71
71
  # @return (see LLM::Provider#complete)
72
72
  def complete(prompt, role = :user, model: "gemini-1.5-flash", **params)
73
- path = ["/v1beta/models/#{model}", "generateContent?key=#{@secret}"].join(":")
74
- req = Net::HTTP::Post.new(path, headers)
73
+ path = ["/v1beta/models/#{model}", "generateContent?key=#{@secret}"].join(":")
74
+ req = Net::HTTP::Post.new(path, headers)
75
75
  messages = [*(params.delete(:messages) || []), LLM::Message.new(role, prompt)]
76
- req.body = JSON.dump({contents: format(messages)})
77
- res = request(@http, req)
76
+ body = JSON.dump({contents: format(messages)}).b
77
+ set_body_stream(req, StringIO.new(body))
78
+ res = request(@http, req)
78
79
  Response::Completion.new(res).extend(response_parser)
79
80
  end
80
81
 
@@ -57,7 +57,7 @@ class LLM::OpenAI
57
57
  multi = LLM::Multipart.new(params.merge!(file:, model:))
58
58
  req = Net::HTTP::Post.new("/v1/audio/transcriptions", headers)
59
59
  req["content-type"] = multi.content_type
60
- req.body_stream = multi.body
60
+ set_body_stream(req, multi.body)
61
61
  res = request(http, req)
62
62
  LLM::Response::AudioTranscription.new(res).tap { _1.text = _1.body["text"] }
63
63
  end
@@ -79,7 +79,7 @@ class LLM::OpenAI
79
79
  multi = LLM::Multipart.new(params.merge!(file:, model:))
80
80
  req = Net::HTTP::Post.new("/v1/audio/translations", headers)
81
81
  req["content-type"] = multi.content_type
82
- req.body_stream = multi.body
82
+ set_body_stream(req, multi.body)
83
83
  res = request(http, req)
84
84
  LLM::Response::AudioTranslation.new(res).tap { _1.text = _1.body["text"] }
85
85
  end
@@ -90,7 +90,7 @@ class LLM::OpenAI
90
90
  @provider.instance_variable_get(:@http)
91
91
  end
92
92
 
93
- [:headers, :request].each do |m|
93
+ [:headers, :request, :set_body_stream].each do |m|
94
94
  define_method(m) { |*args, &b| @provider.send(m, *args, &b) }
95
95
  end
96
96
  end
@@ -73,7 +73,7 @@ class LLM::OpenAI
73
73
  multi = LLM::Multipart.new(params.merge!(file:, purpose:))
74
74
  req = Net::HTTP::Post.new("/v1/files", headers)
75
75
  req["content-type"] = multi.content_type
76
- req.body_stream = multi.body
76
+ set_body_stream(req, multi.body)
77
77
  res = request(http, req)
78
78
  LLM::Response::File.new(res)
79
79
  end
@@ -141,7 +141,7 @@ class LLM::OpenAI
141
141
  @provider.instance_variable_get(:@http)
142
142
  end
143
143
 
144
- [:headers, :request].each do |m|
144
+ [:headers, :request, :set_body_stream].each do |m|
145
145
  define_method(m) { |*args, &b| @provider.send(m, *args, &b) }
146
146
  end
147
147
  end
@@ -29,19 +29,50 @@ class LLM::OpenAI
29
29
  # The formatted content
30
30
  def format_content(content, mode)
31
31
  if mode == :complete
32
- case content
33
- when Array then content.flat_map { format_content(_1, mode) }
34
- when URI then [{type: :image_url, image_url: {url: content.to_s}}]
35
- when LLM::Response::File then [{type: :file, file: {file_id: content.id}}]
36
- else [{type: :text, text: content.to_s}]
37
- end
32
+ format_complete(content)
38
33
  elsif mode == :response
39
- case content
40
- when Array then content.flat_map { format_content(_1, mode) }
41
- when URI then [{type: :image_url, image_url: {url: content.to_s}}]
42
- when LLM::Response::File then [{type: :input_file, file_id: content.id}]
43
- else [{type: :input_text, text: content.to_s}]
34
+ format_response(content)
35
+ end
36
+ end
37
+
38
+ def format_complete(content)
39
+ case content
40
+ when Array
41
+ content.flat_map { format_complete(_1) }
42
+ when URI
43
+ [{type: :image_url, image_url: {url: content.to_s}}]
44
+ when LLM::File
45
+ [{type: :image_url, image_url: {url: content.to_data_uri}}]
46
+ when LLM::Response::File
47
+ [{type: :file, file: {file_id: content.id}}]
48
+ when String
49
+ [{type: :text, text: content.to_s}]
50
+ when LLM::Message
51
+ format_complete(content.content)
52
+ else
53
+ raise LLM::Error::PromptError, "The given object (an instance of #{content.class}) " \
54
+ "is not supported by the OpenAI chat completions API"
55
+ end
56
+ end
57
+
58
+ def format_response(content)
59
+ case content
60
+ when Array
61
+ content.flat_map { format_response(_1) }
62
+ when LLM::Response::File
63
+ file = LLM::File(content.filename)
64
+ if file.image?
65
+ [{type: :input_image, file_id: content.id}]
66
+ else
67
+ [{type: :input_file, file_id: content.id}]
44
68
  end
69
+ when String
70
+ [{type: :input_text, text: content.to_s}]
71
+ when LLM::Message
72
+ format_response(content.content)
73
+ else
74
+ raise LLM::Error::PromptError, "The given object (an instance of #{content.class}) " \
75
+ "is not supported by the OpenAI responses API"
45
76
  end
46
77
  end
47
78
  end
@@ -69,7 +69,7 @@ class LLM::OpenAI
69
69
  multi = LLM::Multipart.new(params.merge!(image:, model:))
70
70
  req = Net::HTTP::Post.new("/v1/images/variations", headers)
71
71
  req["content-type"] = multi.content_type
72
- req.body_stream = multi.body
72
+ set_body_stream(req, multi.body)
73
73
  res = request(http, req)
74
74
  LLM::Response::Image.new(res).extend(response_parser)
75
75
  end
@@ -91,7 +91,7 @@ class LLM::OpenAI
91
91
  multi = LLM::Multipart.new(params.merge!(image:, prompt:, model:))
92
92
  req = Net::HTTP::Post.new("/v1/images/edits", headers)
93
93
  req["content-type"] = multi.content_type
94
- req.body_stream = multi.body
94
+ set_body_stream(req, multi.body)
95
95
  res = request(http, req)
96
96
  LLM::Response::Image.new(res).extend(response_parser)
97
97
  end
@@ -102,7 +102,7 @@ class LLM::OpenAI
102
102
  @provider.instance_variable_get(:@http)
103
103
  end
104
104
 
105
- [:response_parser, :headers, :request].each do |m|
105
+ [:response_parser, :headers, :request, :set_body_stream].each do |m|
106
106
  define_method(m) { |*args, &b| @provider.send(m, *args, &b) }
107
107
  end
108
108
  end
@@ -16,6 +16,20 @@ class LLM::OpenAI
16
16
  # res1 = llm.responses.create "Your task is to help me with math", :developer
17
17
  # res2 = llm.responses.create "5 + 5 = ?", :user, previous_response_id: res1.id
18
18
  # [res1,res2].each { llm.responses.delete(_1) }
19
+ # @example
20
+ # #!/usr/bin/env ruby
21
+ # require "llm"
22
+ #
23
+ # llm = LLM.openai(ENV["KEY"])
24
+ # file = llm.files.create file: LLM::File("/images/hat.png")
25
+ # res = llm.responses.create ["Describe the image", file]
26
+ # @example
27
+ # #!/usr/bin/env ruby
28
+ # require "llm"
29
+ #
30
+ # llm = LLM.openai(ENV["KEY"])
31
+ # file = llm.files.create file: LLM::File("/documents/freebsd.pdf")
32
+ # res = llm.responses.create ["Describe the document, file]
19
33
  class Responses
20
34
  include Format
21
35
 
data/lib/llm/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LLM
4
- VERSION = "0.3.1"
4
+ VERSION = "0.3.2"
5
5
  end
@@ -48,7 +48,7 @@ RSpec.describe "LLM::OpenAI: completions" do
48
48
  openai.complete "What is your name? What age are you?", :user, messages: [
49
49
  {role: "system", content: "Answer all of my questions"},
50
50
  {role: "system", content: "Answer in the format: My name is <name> and I am <age> years old"},
51
- {role: "system", content: "Your name is Pablo and you are 25 years old"},
51
+ {role: "system", content: "Your name is Pablo and you are 25 years old"}
52
52
  ]
53
53
  end
54
54
 
@@ -81,7 +81,7 @@ RSpec.describe "LLM::OpenAI: completions" do
81
81
 
82
82
  context "when given an unauthorized response",
83
83
  vcr: {cassette_name: "openai/completions/unauthorized_response"} do
84
- subject(:response) { openai.complete(LLM::Message.new("Hello!", :user)) }
84
+ subject(:response) { openai.complete(LLM::Message.new(:user, "Hello!")) }
85
85
  let(:token) { "BADTOKEN" }
86
86
 
87
87
  it "raises an error" do
@@ -94,16 +94,16 @@ RSpec.describe "LLM::OpenAI::Files" do
94
94
  provider.files.create(file: LLM::File("spec/fixtures/documents/haiku2.txt"))
95
95
  ]
96
96
  end
97
- subject(:file) { provider.files.all }
97
+ subject(:filelist) { provider.files.all }
98
98
 
99
99
  it "is successful" do
100
- expect(file).to be_instance_of(LLM::Response::FileList)
100
+ expect(filelist).to be_instance_of(LLM::Response::FileList)
101
101
  ensure
102
102
  files.each { |file| provider.files.delete(file:) }
103
103
  end
104
104
 
105
105
  it "returns an array of file objects" do
106
- expect(file).to match_array(
106
+ expect(filelist.files[0..1]).to match_array(
107
107
  [
108
108
  have_attributes(
109
109
  id: instance_of(String),
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm.rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Antar Azri