llm.rb 0.2.0 → 0.3.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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +264 -110
  3. data/lib/llm/buffer.rb +83 -0
  4. data/lib/llm/chat.rb +131 -0
  5. data/lib/llm/file.rb +26 -40
  6. data/lib/llm/http_client.rb +10 -5
  7. data/lib/llm/message.rb +14 -8
  8. data/lib/llm/mime.rb +54 -0
  9. data/lib/llm/multipart.rb +98 -0
  10. data/lib/llm/provider.rb +116 -12
  11. data/lib/llm/providers/anthropic/error_handler.rb +2 -0
  12. data/lib/llm/providers/anthropic/format.rb +9 -1
  13. data/lib/llm/providers/anthropic/response_parser.rb +3 -1
  14. data/lib/llm/providers/anthropic.rb +14 -5
  15. data/lib/llm/providers/gemini/audio.rb +77 -0
  16. data/lib/llm/providers/gemini/error_handler.rb +2 -0
  17. data/lib/llm/providers/gemini/files.rb +160 -0
  18. data/lib/llm/providers/gemini/format.rb +19 -7
  19. data/lib/llm/providers/gemini/images.rb +99 -0
  20. data/lib/llm/providers/gemini/response_parser.rb +27 -1
  21. data/lib/llm/providers/gemini.rb +62 -6
  22. data/lib/llm/providers/ollama/error_handler.rb +2 -0
  23. data/lib/llm/providers/ollama/format.rb +18 -4
  24. data/lib/llm/providers/ollama/response_parser.rb +3 -1
  25. data/lib/llm/providers/ollama.rb +30 -7
  26. data/lib/llm/providers/openai/audio.rb +97 -0
  27. data/lib/llm/providers/openai/error_handler.rb +2 -0
  28. data/lib/llm/providers/openai/files.rb +148 -0
  29. data/lib/llm/providers/openai/format.rb +26 -7
  30. data/lib/llm/providers/openai/images.rb +109 -0
  31. data/lib/llm/providers/openai/response_parser.rb +58 -5
  32. data/lib/llm/providers/openai/responses.rb +78 -0
  33. data/lib/llm/providers/openai.rb +52 -6
  34. data/lib/llm/providers/voyageai.rb +2 -2
  35. data/lib/llm/response/audio.rb +13 -0
  36. data/lib/llm/response/audio_transcription.rb +14 -0
  37. data/lib/llm/response/audio_translation.rb +14 -0
  38. data/lib/llm/response/download_file.rb +15 -0
  39. data/lib/llm/response/file.rb +42 -0
  40. data/lib/llm/response/filelist.rb +18 -0
  41. data/lib/llm/response/image.rb +29 -0
  42. data/lib/llm/response/output.rb +56 -0
  43. data/lib/llm/response.rb +18 -6
  44. data/lib/llm/utils.rb +19 -0
  45. data/lib/llm/version.rb +1 -1
  46. data/lib/llm.rb +5 -2
  47. data/llm.gemspec +1 -6
  48. data/spec/anthropic/completion_spec.rb +1 -1
  49. data/spec/gemini/completion_spec.rb +22 -1
  50. data/spec/gemini/conversation_spec.rb +31 -0
  51. data/spec/gemini/files_spec.rb +124 -0
  52. data/spec/gemini/images_spec.rb +47 -0
  53. data/spec/llm/conversation_spec.rb +133 -1
  54. data/spec/ollama/completion_spec.rb +1 -1
  55. data/spec/ollama/conversation_spec.rb +31 -0
  56. data/spec/openai/audio_spec.rb +55 -0
  57. data/spec/openai/completion_spec.rb +22 -1
  58. data/spec/openai/files_spec.rb +150 -0
  59. data/spec/openai/images_spec.rb +95 -0
  60. data/spec/openai/responses_spec.rb +51 -0
  61. data/spec/setup.rb +8 -0
  62. metadata +31 -51
  63. data/LICENSE.txt +0 -21
  64. data/lib/llm/conversation.rb +0 -50
  65. data/lib/llm/lazy_conversation.rb +0 -51
  66. data/lib/llm/message_queue.rb +0 -47
  67. data/spec/llm/lazy_conversation_spec.rb +0 -92
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ ##
5
+ # The {LLM::Response::Audio LLM::Response::Audio} class represents an
6
+ # audio file that has been returned by a provider. It wraps an IO object
7
+ # that can be used to read the contents of an audio stream (as binary data).
8
+ class Response::Audio < Response
9
+ ##
10
+ # @return [StringIO]
11
+ attr_accessor :audio
12
+ end
13
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ ##
5
+ # The {LLM::Response::AudioTranscription LLM::Response::AudioTranscription}
6
+ # class represents an audio transcription that has been returned by
7
+ # a provider (eg OpenAI, Gemini, etc)
8
+ class Response::AudioTranscription < Response
9
+ ##
10
+ # Returns the text of the transcription
11
+ # @return [String]
12
+ attr_accessor :text
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ ##
5
+ # The {LLM::Response::AudioTranslation LLM::Response::AudioTranslation}
6
+ # class represents an audio translation that has been returned by
7
+ # a provider (eg OpenAI, Gemini, etc)
8
+ class Response::AudioTranslation < Response
9
+ ##
10
+ # Returns the text of the translation
11
+ # @return [String]
12
+ attr_accessor :text
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ ##
5
+ # The {LLM::Response::DownloadFile LLM::Response::DownloadFile} class
6
+ # represents the contents of a file that has been returned by a
7
+ # provider. It wraps an IO object that can be used to read the file
8
+ # contents.
9
+ class Response::DownloadFile < Response
10
+ ##
11
+ # Returns a StringIO object
12
+ # @return [StringIO]
13
+ attr_accessor :file
14
+ end
15
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ ##
5
+ # The {LLM::Response::File LLM::Response::File} class represents a file
6
+ # that has been uploaded to a provider. Its properties are delegated
7
+ # to the underlying response body, and vary by provider.
8
+ class Response::File < Response
9
+ ##
10
+ # Returns a normalized response body
11
+ # @return [Hash]
12
+ def body
13
+ @_body ||= if super["file"]
14
+ super["file"].transform_keys { snakecase(_1) }
15
+ else
16
+ super.transform_keys { snakecase(_1) }
17
+ end
18
+ end
19
+
20
+ ##
21
+ # @return [String]
22
+ def inspect
23
+ "#<#{self.class}:0x#{object_id.to_s(16)} body=#{body}>"
24
+ end
25
+
26
+ private
27
+
28
+ include LLM::Utils
29
+
30
+ def respond_to_missing?(m, _)
31
+ body.key?(m.to_s) || super
32
+ end
33
+
34
+ def method_missing(m, *args, &block)
35
+ if body.key?(m.to_s)
36
+ body[m.to_s]
37
+ else
38
+ super
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ ##
5
+ # The {LLM::Response::FileList LLM::Response::FileList} class represents a
6
+ # list of file objects that are returned by a provider. It is an Enumerable
7
+ # object, and can be used to iterate over the file objects in a way that is
8
+ # similar to an array. Each element is an instance of OpenStruct.
9
+ class Response::FileList < Response
10
+ include Enumerable
11
+
12
+ attr_accessor :files
13
+
14
+ def each(&)
15
+ @files.each(&)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ ##
5
+ # The {LLM::Response::Image LLM::Response::Image} class represents
6
+ # an image response. An image response might encapsulate one or more
7
+ # URLs, or a base64 encoded image -- depending on the provider.
8
+ class Response::Image < Response
9
+ ##
10
+ # Returns one or more image objects, or nil
11
+ # @return [Array<OpenStruct>, nil]
12
+ def images
13
+ parsed[:images].any? ? parsed[:images] : nil
14
+ end
15
+
16
+ ##
17
+ # Returns one or more image URLs, or nil
18
+ # @return [Array<String>, nil]
19
+ def urls
20
+ parsed[:urls].any? ? parsed[:urls] : nil
21
+ end
22
+
23
+ private
24
+
25
+ def parsed
26
+ @parsed ||= parse_image(body)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ class Response::Output < Response
5
+ ##
6
+ # @return [String]
7
+ # Returns the id of the response
8
+ def id
9
+ parsed[:id]
10
+ end
11
+
12
+ ##
13
+ # @return [String]
14
+ # Returns the model name
15
+ def model
16
+ parsed[:model]
17
+ end
18
+
19
+ ##
20
+ # @return [Array<LLM::Message>]
21
+ def outputs
22
+ parsed[:outputs]
23
+ end
24
+
25
+ ##
26
+ # @return [Integer]
27
+ # Returns the input token count
28
+ def input_tokens
29
+ parsed[:input_tokens]
30
+ end
31
+
32
+ ##
33
+ # @return [Integer]
34
+ # Returns the output token count
35
+ def output_tokens
36
+ parsed[:output_tokens]
37
+ end
38
+
39
+ ##
40
+ # @return [Integer]
41
+ # Returns the total count of tokens
42
+ def total_tokens
43
+ parsed[:total_tokens]
44
+ end
45
+
46
+ private
47
+
48
+ ##
49
+ # @private
50
+ # @return [Hash]
51
+ # Returns the parsed response from the provider
52
+ def parsed
53
+ @parsed ||= parse_output_response(body)
54
+ end
55
+ end
56
+ end
data/lib/llm/response.rb CHANGED
@@ -5,11 +5,14 @@ module LLM
5
5
  require "json"
6
6
  require_relative "response/completion"
7
7
  require_relative "response/embedding"
8
-
9
- ##
10
- # @return [Hash]
11
- # Returns the response body
12
- attr_reader :body
8
+ require_relative "response/output"
9
+ require_relative "response/image"
10
+ require_relative "response/audio"
11
+ require_relative "response/audio_transcription"
12
+ require_relative "response/audio_translation"
13
+ require_relative "response/file"
14
+ require_relative "response/filelist"
15
+ require_relative "response/download_file"
13
16
 
14
17
  ##
15
18
  # @param [Net::HTTPResponse] res
@@ -18,7 +21,16 @@ module LLM
18
21
  # Returns an instance of LLM::Response
19
22
  def initialize(res)
20
23
  @res = res
21
- @body = JSON.parse(res.body)
24
+ end
25
+
26
+ ##
27
+ # Returns the response body
28
+ # @return [Hash, String]
29
+ def body
30
+ @body ||= case @res["content-type"]
31
+ when %r|\Aapplication/json\s*| then JSON.parse(@res.body)
32
+ else @res.body
33
+ end
22
34
  end
23
35
  end
24
36
  end
data/lib/llm/utils.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # @private
5
+ module LLM::Utils
6
+ def camelcase(key)
7
+ key.to_s
8
+ .split("_")
9
+ .map.with_index { (_2 > 0) ? _1.capitalize : _1 }
10
+ .join
11
+ end
12
+
13
+ def snakecase(key)
14
+ key
15
+ .split(/([A-Z])/)
16
+ .map { (_1.size == 1) ? "_#{_1.downcase}" : _1 }
17
+ .join
18
+ end
19
+ end
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.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/llm.rb CHANGED
@@ -2,14 +2,17 @@
2
2
 
3
3
  module LLM
4
4
  require_relative "llm/version"
5
+ require_relative "llm/utils"
5
6
  require_relative "llm/error"
6
7
  require_relative "llm/message"
7
8
  require_relative "llm/response"
9
+ require_relative "llm/mime"
10
+ require_relative "llm/multipart"
8
11
  require_relative "llm/file"
9
12
  require_relative "llm/model"
10
13
  require_relative "llm/provider"
11
- require_relative "llm/conversation"
12
- require_relative "llm/lazy_conversation"
14
+ require_relative "llm/chat"
15
+ require_relative "llm/buffer"
13
16
  require_relative "llm/core_ext/ostruct"
14
17
 
15
18
  module_function
data/llm.gemspec CHANGED
@@ -14,12 +14,11 @@ Gem::Specification.new do |spec|
14
14
  "flexible, and easy to use."
15
15
  spec.description = spec.summary
16
16
  spec.homepage = "https://github.com/llmrb/llm"
17
- spec.license = "MIT"
17
+ spec.license = "0BSDL"
18
18
  spec.required_ruby_version = ">= 3.0.0"
19
19
 
20
20
  spec.metadata["homepage_uri"] = spec.homepage
21
21
  spec.metadata["source_code_uri"] = "https://github.com/llmrb/llm"
22
- spec.metadata["changelog_uri"] = "https://github.com/llmrb/llm/blob/main/CHANGELOG.md"
23
22
 
24
23
  spec.files = Dir[
25
24
  "README.md", "LICENSE.txt",
@@ -29,10 +28,6 @@ Gem::Specification.new do |spec|
29
28
  ]
30
29
  spec.require_paths = ["lib"]
31
30
 
32
- spec.add_runtime_dependency "net-http", "~> 0.6.0"
33
- spec.add_runtime_dependency "json"
34
- spec.add_runtime_dependency "yaml"
35
-
36
31
  spec.add_development_dependency "webmock", "~> 3.24.0"
37
32
  spec.add_development_dependency "yard", "~> 0.9.37"
38
33
  spec.add_development_dependency "kramdown", "~> 2.4"
@@ -37,7 +37,7 @@ RSpec.describe "LLM::Anthropic: completions" do
37
37
  end
38
38
 
39
39
  it "includes the response" do
40
- expect(choice.extra[:completion]).to eq(response)
40
+ expect(choice.extra[:response]).to eq(response)
41
41
  end
42
42
  end
43
43
  end
@@ -41,11 +41,32 @@ RSpec.describe "LLM::Gemini: completions" do
41
41
  end
42
42
 
43
43
  it "includes the response" do
44
- expect(choice.extra[:completion]).to eq(response)
44
+ expect(choice.extra[:response]).to eq(response)
45
45
  end
46
46
  end
47
47
  end
48
48
 
49
+ context "when given a thread of messages",
50
+ vcr: {cassette_name: "gemini/completions/successful_response_thread"} do
51
+ subject(:response) do
52
+ gemini.complete "What is your name? What age are you?", :user, messages: [
53
+ {role: "user", content: "Answer all of my questions"},
54
+ {role: "user", content: "Your name is Pablo, you are 25 years old and you are my amigo"}
55
+ ]
56
+ end
57
+
58
+ it "has choices" do
59
+ expect(response).to have_attributes(
60
+ choices: [
61
+ have_attributes(
62
+ role: "model",
63
+ content: "My name is Pablo, and I am 25 years old. ¡Amigo!\n"
64
+ )
65
+ ]
66
+ )
67
+ end
68
+ end
69
+
49
70
  context "when given an unauthorized response",
50
71
  vcr: {cassette_name: "gemini/completions/unauthorized_response"} do
51
72
  subject(:response) { gemini.complete("Hello!", :user) }
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "setup"
4
+
5
+ RSpec.describe "LLM::Chat: gemini" do
6
+ let(:described_class) { LLM::Chat }
7
+ let(:provider) { LLM.gemini(token) }
8
+ let(:token) { ENV["LLM_SECRET"] || "TOKEN" }
9
+ let(:conversation) { described_class.new(provider, **params).lazy }
10
+
11
+ context "when asked to describe an image",
12
+ vcr: {cassette_name: "gemini/conversations/multimodal_response"} do
13
+ subject { conversation.last_message }
14
+
15
+ let(:params) { {} }
16
+ let(:image) { LLM::File("spec/fixtures/images/bluebook.png") }
17
+
18
+ before do
19
+ conversation.chat(image, :user)
20
+ conversation.chat("Describe the image with a short sentance", :user)
21
+ end
22
+
23
+ it "describes the image" do
24
+ is_expected.to have_attributes(
25
+ role: "model",
26
+ content: "That's a simple illustration of a book " \
27
+ "resting on a blue, X-shaped book stand.\n"
28
+ )
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "setup"
4
+
5
+ RSpec.describe "LLM::Gemini::Files" do
6
+ let(:token) { ENV["LLM_SECRET"] || "TOKEN" }
7
+ let(:provider) { LLM.gemini(token) }
8
+
9
+ context "when given a successful create operation (bismillah.mp3)",
10
+ vcr: {cassette_name: "gemini/files/successful_create_bismillah"} do
11
+ subject(:file) { provider.files.create(file: LLM::File("spec/fixtures/audio/bismillah.mp3")) }
12
+ after { provider.files.delete(file:) }
13
+
14
+ it "is successful" do
15
+ expect(file).to be_instance_of(LLM::Response::File)
16
+ end
17
+
18
+ it "returns a file object" do
19
+ expect(file).to have_attributes(
20
+ name: instance_of(String),
21
+ display_name: "bismillah.mp3"
22
+ )
23
+ end
24
+ end
25
+
26
+ context "when given a successful delete operation (bismillah.mp3)",
27
+ vcr: {cassette_name: "gemini/files/successful_delete_bismillah"} do
28
+ let(:file) { provider.files.create(file: LLM::File("spec/fixtures/audio/bismillah.mp3")) }
29
+ subject { provider.files.delete(file:) }
30
+
31
+ it "is successful" do
32
+ is_expected.to be_instance_of(Net::HTTPOK)
33
+ end
34
+ end
35
+
36
+ context "when given a successful get operation (bismillah.mp3)",
37
+ vcr: {cassette_name: "gemini/files/successful_get_bismillah"} do
38
+ let(:file) { provider.files.create(file: LLM::File("spec/fixtures/audio/bismillah.mp3")) }
39
+ subject { provider.files.get(file:) }
40
+ after { provider.files.delete(file:) }
41
+
42
+ it "is successful" do
43
+ is_expected.to be_instance_of(LLM::Response::File)
44
+ end
45
+
46
+ it "returns a file object" do
47
+ is_expected.to have_attributes(
48
+ name: instance_of(String),
49
+ display_name: "bismillah.mp3"
50
+ )
51
+ end
52
+ end
53
+
54
+ context "when given a successful translation operation (bismillah.mp3)",
55
+ vcr: {cassette_name: "gemini/files/successful_translation_bismillah"} do
56
+ subject { bot.last_message.content }
57
+ let(:file) { provider.files.create(file: LLM::File("spec/fixtures/audio/bismillah.mp3")) }
58
+ let(:bot) { LLM::Chat.new(provider).lazy }
59
+ after { provider.files.delete(file:) }
60
+
61
+ before do
62
+ bot.chat file
63
+ bot.chat "Translate the contents of the audio file into English"
64
+ bot.chat "The audio is referenced in the first message I sent to you"
65
+ bot.chat "Provide no other content except the translation"
66
+ end
67
+
68
+ it "translates the audio clip" do
69
+ is_expected.to eq("In the name of God, the Most Gracious, the Most Merciful.\n")
70
+ end
71
+ end
72
+
73
+ context "when given a successful translation operation (alhamdullilah.mp3)",
74
+ vcr: {cassette_name: "gemini/files/successful_translation_alhamdullilah"} do
75
+ subject { bot.last_message.content }
76
+ let(:file) { provider.files.create(file: LLM::File("spec/fixtures/audio/alhamdullilah.mp3")) }
77
+ let(:bot) { LLM::Chat.new(provider).lazy }
78
+ after { provider.files.delete(file:) }
79
+
80
+ before do
81
+ bot.chat [
82
+ "Translate the contents of the audio file into English",
83
+ "Provide no other content except the translation",
84
+ file
85
+ ]
86
+ end
87
+
88
+ it "translates the audio clip" do
89
+ is_expected.to eq("All praise is due to Allah, Lord of the Worlds.\n")
90
+ end
91
+ end
92
+
93
+ context "when given a successful all operation",
94
+ vcr: {cassette_name: "gemini/files/successful_all"} do
95
+ let!(:files) do
96
+ [
97
+ provider.files.create(file: LLM::File("spec/fixtures/audio/bismillah.mp3")),
98
+ provider.files.create(file: LLM::File("spec/fixtures/audio/alhamdullilah.mp3"))
99
+ ]
100
+ end
101
+
102
+ subject(:response) { provider.files.all }
103
+ after { files.each { |file| provider.files.delete(file:) } }
104
+
105
+ it "is successful" do
106
+ expect(response).to be_instance_of(LLM::Response::FileList)
107
+ end
108
+
109
+ it "returns an array of file objects" do
110
+ expect(response).to match_array(
111
+ [
112
+ have_attributes(
113
+ name: instance_of(String),
114
+ display_name: "bismillah.mp3"
115
+ ),
116
+ have_attributes(
117
+ name: instance_of(String),
118
+ display_name: "alhamdullilah.mp3"
119
+ )
120
+ ]
121
+ )
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "setup"
4
+
5
+ RSpec.describe "LLM::Gemini::Images" do
6
+ let(:token) { ENV["LLM_SECRET"] || "TOKEN" }
7
+ let(:provider) { LLM.gemini(token) }
8
+
9
+ context "when given a successful create operation",
10
+ vcr: {cassette_name: "gemini/images/successful_create"} do
11
+ subject(:response) { provider.images.create(prompt: "A dog on a rocket to the moon") }
12
+
13
+ it "is successful" do
14
+ expect(response).to be_instance_of(LLM::Response::Image)
15
+ end
16
+
17
+ it "returns an encoded string" do
18
+ expect(response.images[0].encoded).to be_instance_of(String)
19
+ end
20
+
21
+ it "returns a binary string" do
22
+ expect(response.images[0].binary).to be_instance_of(String)
23
+ end
24
+ end
25
+
26
+ context "when given a successful edit operation",
27
+ vcr: {cassette_name: "gemini/images/successful_edit"} do
28
+ subject(:response) do
29
+ provider.images.edit(
30
+ image: LLM::File("spec/fixtures/images/bluebook.png"),
31
+ prompt: "Book is floating in the clouds"
32
+ )
33
+ end
34
+
35
+ it "is successful" do
36
+ expect(response).to be_instance_of(LLM::Response::Image)
37
+ end
38
+
39
+ it "returns data" do
40
+ expect(response.images[0].encoded).to be_instance_of(String)
41
+ end
42
+
43
+ it "returns a url" do
44
+ expect(response.images[0].binary).to be_instance_of(String)
45
+ end
46
+ end
47
+ end