llm.rb 1.0.1 → 2.0.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.
data/lib/llm/bot.rb CHANGED
@@ -13,25 +13,18 @@ module LLM
13
13
  #
14
14
  # llm = LLM.openai(key: ENV["KEY"])
15
15
  # bot = LLM::Bot.new(llm)
16
- # url = "https://en.wikipedia.org/wiki/Special:FilePath/Cognac_glass.jpg"
17
- # msgs = bot.chat do |prompt|
18
- # prompt.system "Your task is to answer all user queries"
19
- # prompt.user ["Tell me about this URL", URI(url)]
20
- # prompt.user ["Tell me about this PDF", File.open("handbook.pdf", "rb")]
21
- # prompt.user "Are the URL and PDF similar to each other?"
16
+ # url = "https://upload.wikimedia.org/wikipedia/commons/c/c7/Lisc_lipy.jpg"
17
+ #
18
+ # prompt = bot.build_prompt do
19
+ # it.system "Your task is to answer all user queries"
20
+ # it.user ["Tell me about this URL", bot.image_url(url)]
21
+ # it.user ["Tell me about this PDF", bot.local_file("handbook.pdf")]
22
22
  # end
23
+ # bot.chat(prompt)
23
24
  #
24
- # # At this point, we execute a single request
25
- # msgs.each { print "[#{_1.role}] ", _1.content, "\n" }
25
+ # # The full conversation history is in bot.messages
26
+ # bot.messages.each { print "[#{_1.role}] ", _1.content, "\n" }
26
27
  class Bot
27
- require_relative "bot/prompt/completion"
28
- require_relative "bot/prompt/respond"
29
- require_relative "bot/conversable"
30
- require_relative "bot/builder"
31
-
32
- include Conversable
33
- include Builder
34
-
35
28
  ##
36
29
  # Returns an Enumerable for the messages in a conversation
37
30
  # @return [LLM::Buffer<LLM::Message>]
@@ -45,7 +38,6 @@ module LLM
45
38
  # Any parameter the provider supports can be included and
46
39
  # not only those listed here.
47
40
  # @option params [String] :model Defaults to the provider's default model
48
- # @option params [#to_json, nil] :schema Defaults to nil
49
41
  # @option params [Array<LLM::Function>, nil] :tools Defaults to nil
50
42
  def initialize(provider, params = {})
51
43
  @provider = provider
@@ -54,56 +46,51 @@ module LLM
54
46
  end
55
47
 
56
48
  ##
57
- # Maintain a conversation via the chat completions API
58
- # @overload def chat(prompt, params = {})
59
- # @param prompt (see LLM::Provider#complete)
60
- # @param params The params
61
- # @return [LLM::Bot]
62
- # Returns self
63
- # @overload def chat(prompt, params, &block)
64
- # @param prompt (see LLM::Provider#complete)
65
- # @param params The params
66
- # @yield prompt Yields a prompt
67
- # @return [LLM::Buffer]
68
- # Returns messages
69
- def chat(prompt = nil, params = {})
70
- if block_given?
71
- params = prompt
72
- yield Prompt::Completion.new(self, params)
73
- messages
74
- elsif prompt.nil?
75
- raise ArgumentError, "wrong number of arguments (given 0, expected 1)"
76
- else
77
- params = {role: :user}.merge!(params)
78
- tap { async_completion(prompt, params) }
79
- end
49
+ # Maintain a conversation via the chat completions API.
50
+ # This method immediately sends a request to the LLM and returns the response.
51
+ #
52
+ # @param prompt (see LLM::Provider#complete)
53
+ # @param params The params, including optional :role (defaults to :user), :stream, :tools, :schema etc.
54
+ # @return [LLM::Response] Returns the LLM's response for this turn.
55
+ # @example
56
+ # llm = LLM.openai(key: ENV["KEY"])
57
+ # bot = LLM::Bot.new(llm)
58
+ # response = bot.chat("Hello, what is your name?")
59
+ # puts response.choices[0].content
60
+ def chat(prompt, params = {})
61
+ prompt, params, messages = fetch(prompt, params)
62
+ params = params.merge(messages: [*@messages.to_a, *messages])
63
+ params = @params.merge(params)
64
+ res = @provider.complete(prompt, params)
65
+ @messages.concat [LLM::Message.new(params[:role] || :user, prompt)]
66
+ @messages.concat messages
67
+ @messages.concat [res.choices[-1]]
68
+ res
80
69
  end
81
70
 
82
71
  ##
83
- # Maintain a conversation via the responses API
84
- # @overload def respond(prompt, params = {})
85
- # @param prompt (see LLM::Provider#complete)
86
- # @param params The params
87
- # @return [LLM::Bot]
88
- # Returns self
89
- # @overload def respond(prompt, params, &block)
90
- # @note Not all LLM providers support this API
91
- # @param prompt (see LLM::Provider#complete)
92
- # @param params The params
93
- # @yield prompt Yields a prompt
94
- # @return [LLM::Buffer]
95
- # Returns messages
96
- def respond(prompt = nil, params = {})
97
- if block_given?
98
- params = prompt
99
- yield Prompt::Respond.new(self, params)
100
- messages
101
- elsif prompt.nil?
102
- raise ArgumentError, "wrong number of arguments (given 0, expected 1)"
103
- else
104
- params = {role: :user}.merge!(params)
105
- tap { async_response(prompt, params) }
106
- end
72
+ # Maintain a conversation via the responses API.
73
+ # This method immediately sends a request to the LLM and returns the response.
74
+ #
75
+ # @note Not all LLM providers support this API
76
+ # @param prompt (see LLM::Provider#complete)
77
+ # @param params The params, including optional :role (defaults to :user), :stream, :tools, :schema etc.
78
+ # @return [LLM::Response] Returns the LLM's response for this turn.
79
+ # @example
80
+ # llm = LLM.openai(key: ENV["KEY"])
81
+ # bot = LLM::Bot.new(llm)
82
+ # res = bot.respond("What is the capital of France?")
83
+ # puts res.output_text
84
+ def respond(prompt, params = {})
85
+ prompt, params, messages = fetch(prompt, params)
86
+ res_id = @messages.find(&:assistant?)&.response&.response_id
87
+ params = params.merge(previous_response_id: res_id, input: messages).compact
88
+ params = @params.merge(params)
89
+ res = @provider.responses.create(prompt, params)
90
+ @messages.concat [LLM::Message.new(params[:role] || :user, prompt)]
91
+ @messages.concat messages
92
+ @messages.concat [res.choices[-1]]
93
+ res
107
94
  end
108
95
 
109
96
  ##
@@ -118,24 +105,12 @@ module LLM
118
105
  # Returns an array of functions that can be called
119
106
  # @return [Array<LLM::Function>]
120
107
  def functions
121
- messages
108
+ @messages
122
109
  .select(&:assistant?)
123
110
  .flat_map(&:functions)
124
111
  .select(&:pending?)
125
112
  end
126
113
 
127
- ##
128
- # @example
129
- # llm = LLM.openai(key: ENV["KEY"])
130
- # bot = LLM::Bot.new(llm, stream: $stdout)
131
- # bot.chat("Hello", role: :user).flush
132
- # Drains the buffer and returns all messages as an array
133
- # @return [Array<LLM::Message>]
134
- def drain
135
- messages.drain
136
- end
137
- alias_method :flush, :drain
138
-
139
114
  ##
140
115
  # Returns token usage for the conversation
141
116
  # @note
@@ -144,7 +119,59 @@ module LLM
144
119
  # if there are no assistant messages
145
120
  # @return [LLM::Object]
146
121
  def usage
147
- messages.find(&:assistant?)&.usage || LLM::Object.from_hash({})
122
+ @messages.find(&:assistant?)&.usage || LLM::Object.from_hash({})
123
+ end
124
+
125
+ ##
126
+ # Build a prompt
127
+ # @example
128
+ # prompt = bot.build_prompt do
129
+ # it.system "Your task is to assist the user"
130
+ # it.user "Hello, can you assist me?"
131
+ # end
132
+ # bot.chat(prompt)
133
+ def build_prompt(&)
134
+ LLM::Builder.new(&).tap(&:call)
135
+ end
136
+
137
+ ##
138
+ # Recongize an object as a URL to an image
139
+ # @param [String] url
140
+ # The URL
141
+ # @return [LLM::Object]
142
+ # Returns a tagged object
143
+ def image_url(url)
144
+ LLM::Object.from_hash(value: url, kind: :image_url)
145
+ end
146
+
147
+ ##
148
+ # Recongize an object as a local file
149
+ # @param [String] path
150
+ # The path
151
+ # @return [LLM::Object]
152
+ # Returns a tagged object
153
+ def local_file(path)
154
+ LLM::Object.from_hash(value: LLM.File(path), kind: :local_file)
155
+ end
156
+
157
+ ##
158
+ # Reconginize an object as a remote file
159
+ # @param [LLM::Response] res
160
+ # The response
161
+ # @return [LLM::Object]
162
+ # Returns a tagged object
163
+ def remote_file(res)
164
+ LLM::Object.from_hash(value: res, kind: :remote_file)
165
+ end
166
+
167
+ private
168
+
169
+ def fetch(prompt, params)
170
+ return [prompt, params, []] unless LLM::Builder === prompt
171
+ messages = prompt.to_a
172
+ prompt = messages.shift
173
+ params.merge!(role: prompt.role)
174
+ [prompt.content, params, messages]
148
175
  end
149
176
  end
150
177
  end
data/lib/llm/buffer.rb CHANGED
@@ -3,8 +3,7 @@
3
3
  module LLM
4
4
  ##
5
5
  # {LLM::Buffer LLM::Buffer} provides an Enumerable object that
6
- # yields each message in a conversation on-demand, and only sends
7
- # a request to the LLM when a response is needed.
6
+ # tracks messages in a conversation thread.
8
7
  class Buffer
9
8
  include Enumerable
10
9
 
@@ -13,19 +12,24 @@ module LLM
13
12
  # @return [LLM::Buffer]
14
13
  def initialize(provider)
15
14
  @provider = provider
16
- @pending = []
17
- @completed = []
15
+ @messages = []
16
+ end
17
+
18
+ ##
19
+ # Append an array
20
+ # @param [Array<LLM::Message>] ary
21
+ # The array to append
22
+ def concat(ary)
23
+ @messages.concat(ary)
18
24
  end
19
25
 
20
26
  ##
21
27
  # @yield [LLM::Message]
22
28
  # Yields each message in the conversation thread
23
- # @raise (see LLM::Provider#complete)
24
29
  # @return [void]
25
30
  def each(...)
26
31
  if block_given?
27
- empty! unless @pending.empty?
28
- @completed.each { yield(_1) }
32
+ @messages.each { yield(_1) }
29
33
  else
30
34
  enum_for(:each, ...)
31
35
  end
@@ -53,19 +57,15 @@ module LLM
53
57
  # The number of messages to return
54
58
  # @return [LLM::Message, Array<LLM::Message>, nil]
55
59
  def last(n = nil)
56
- if @pending.empty?
57
- n.nil? ? @completed.last : @completed.last(n)
58
- else
59
- n.nil? ? to_a.last : to_a.last(n)
60
- end
60
+ n.nil? ? @messages.last : @messages.last(n)
61
61
  end
62
62
 
63
63
  ##
64
- # @param [[LLM::Message, Hash, Symbol]] item
65
- # A message and its parameters
64
+ # @param [[LLM::Message]] item
65
+ # A message to add to the buffer
66
66
  # @return [void]
67
67
  def <<(item)
68
- @pending << item
68
+ @messages << item
69
69
  self
70
70
  end
71
71
  alias_method :push, :<<
@@ -76,87 +76,21 @@ module LLM
76
76
  # @return [LLM::Message, nil]
77
77
  # Returns a message, or nil
78
78
  def [](index)
79
- if @pending.empty?
80
- if Range === index
81
- slice = @completed[index]
82
- (slice.nil? || slice.size < index.size) ? to_a[index] : slice
83
- else
84
- @completed[index]
85
- end
86
- else
87
- to_a[index]
88
- end
79
+ @messages[index]
89
80
  end
90
81
 
91
82
  ##
92
83
  # @return [String]
93
84
  def inspect
94
85
  "#<#{self.class.name}:0x#{object_id.to_s(16)} " \
95
- "completed_count=#{@completed.size} pending_count=#{@pending.size}>"
86
+ "message_count=#{@messages.size}>"
96
87
  end
97
88
 
98
89
  ##
99
90
  # Returns true when the buffer is empty
100
91
  # @return [Boolean]
101
92
  def empty?
102
- @pending.empty? and @completed.empty?
103
- end
104
-
105
- ##
106
- # @example
107
- # llm = LLM.openai(key: ENV["KEY"])
108
- # bot = LLM::Bot.new(llm, stream: $stdout)
109
- # bot.chat "Hello", role: :user
110
- # bot.messages.flush
111
- # @see LLM::Bot#drain
112
- # @note
113
- # This method is especially useful when using the streaming API.
114
- # Drains the buffer and returns all messages as an array
115
- # @return [Array<LLM::Message>]
116
- def drain
117
- to_a
118
- end
119
- alias_method :flush, :drain
120
-
121
- private
122
-
123
- def empty!
124
- message, params, method = @pending.pop
125
- if method == :complete
126
- complete!(message, params)
127
- elsif method == :respond
128
- respond!(message, params)
129
- else
130
- raise LLM::Error, "Unknown method: #{method}"
131
- end
132
- end
133
-
134
- def complete!(message, params)
135
- oldparams = @pending.map { _1[1] }
136
- pendings = @pending.map { _1[0] }
137
- messages = [*@completed, *pendings]
138
- role = message.role
139
- completion = @provider.complete(
140
- message.content,
141
- [*oldparams, params.merge(role:, messages:)].inject({}, &:merge!)
142
- )
143
- @completed.concat([*pendings, message, *completion.choices[0]])
144
- @pending.clear
145
- end
146
-
147
- def respond!(message, params)
148
- oldparams = @pending.map { _1[1] }
149
- pendings = @pending.map { _1[0] }
150
- messages = [*pendings]
151
- role = message.role
152
- params = [
153
- *oldparams,
154
- params.merge(input: messages),
155
- @response ? {previous_response_id: @response.response_id} : {}
156
- ].inject({}, &:merge!)
157
- @response = @provider.responses.create(message.content, params.merge(role:))
158
- @completed.concat([*pendings, message, *@response.choices[0]])
159
- @pending.clear
93
+ @messages.empty?
160
94
  end
161
95
  end
162
96
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # The {LLM::Builder LLM::Builder} class can build a collection
5
+ # of messages that can be sent in a single request.
6
+ #
7
+ # @example
8
+ # llm = LLM.openai(key: ENV["KEY"])
9
+ # bot = LLM::Bot.new(llm)
10
+ # prompt = bot.build_prompt do
11
+ # it.system "Your task is to assist the user"
12
+ # it.user "Hello. Can you assist me?"
13
+ # end
14
+ # res = bot.chat(prompt)
15
+ class LLM::Builder
16
+ ##
17
+ # @param [Proc] evaluator
18
+ # The evaluator
19
+ def initialize(&evaluator)
20
+ @buffer = []
21
+ @evaluator = evaluator
22
+ end
23
+
24
+ ##
25
+ # @return [void]
26
+ def call
27
+ @evaluator.call(self)
28
+ end
29
+
30
+ ##
31
+ # @param [String] content
32
+ # The message
33
+ # @param [Symbol] role
34
+ # The role (eg user, system)
35
+ # @return [void]
36
+ def chat(content, role: :user)
37
+ @buffer << LLM::Message.new(role, content)
38
+ end
39
+
40
+ ##
41
+ # @param [String] content
42
+ # The message content
43
+ # @return [void]
44
+ def user(content)
45
+ chat(content, role: :user)
46
+ end
47
+
48
+ ##
49
+ # @param [String] content
50
+ # The message content
51
+ # @return [void]
52
+ def system(content)
53
+ chat(content, role: :system)
54
+ end
55
+
56
+ ##
57
+ # @return [Array]
58
+ def to_a
59
+ @buffer.dup
60
+ end
61
+ end
data/lib/llm/function.rb CHANGED
@@ -83,7 +83,11 @@ class LLM::Function
83
83
  # @return [void]
84
84
  def params
85
85
  if block_given?
86
- @params = yield(@schema)
86
+ if @params
87
+ @params.merge!(yield(@schema))
88
+ else
89
+ @params = yield(@schema)
90
+ end
87
91
  else
88
92
  @params
89
93
  end
@@ -45,29 +45,12 @@ module LLM::Anthropic::Format
45
45
  content.empty? ? throw(:abort, nil) : [content]
46
46
  when Array
47
47
  content.empty? ? throw(:abort, nil) : content.flat_map { format_content(_1) }
48
- when URI
49
- [{type: :image, source: {type: "url", url: content.to_s}}]
50
- when File
51
- content.close unless content.closed?
52
- format_content(LLM.File(content.path))
53
- when LLM::File
54
- if content.image?
55
- [{type: :image, source: {type: "base64", media_type: content.mime_type, data: content.to_b64}}]
56
- elsif content.pdf?
57
- [{type: :document, source: {type: "base64", media_type: content.mime_type, data: content.to_b64}}]
58
- else
59
- raise LLM::PromptError, "The given object (an instance of #{content.class}) " \
60
- "is not an image or PDF, and therefore not supported by the " \
61
- "Anthropic API"
62
- end
63
- when LLM::Response
64
- if content.file?
65
- [{type: content.file_type, source: {type: :file, file_id: content.id}}]
66
- else
67
- prompt_error!(content)
68
- end
48
+ when LLM::Object
49
+ format_object(content)
69
50
  when String
70
51
  [{type: :text, text: content}]
52
+ when LLM::Response
53
+ format_remote_file(content)
71
54
  when LLM::Message
72
55
  format_content(content.content)
73
56
  when LLM::Function::Return
@@ -77,9 +60,42 @@ module LLM::Anthropic::Format
77
60
  end
78
61
  end
79
62
 
63
+ def format_object(object)
64
+ case object.kind
65
+ when :image_url
66
+ [{type: :image, source: {type: "url", url: object.value.to_s}}]
67
+ when :local_file
68
+ format_local_file(object.value)
69
+ when :remote_file
70
+ format_remote_file(object.value)
71
+ else
72
+ prompt_error!(content)
73
+ end
74
+ end
75
+
76
+ def format_local_file(file)
77
+ if file.image?
78
+ [{type: :image, source: {type: "base64", media_type: file.mime_type, data: file.to_b64}}]
79
+ elsif file.pdf?
80
+ [{type: :document, source: {type: "base64", media_type: file.mime_type, data: file.to_b64}}]
81
+ else
82
+ raise LLM::PromptError, "The given object (an instance of #{file.class}) " \
83
+ "is not an image or PDF, and therefore not supported by the " \
84
+ "Anthropic API"
85
+ end
86
+ end
87
+
88
+ def format_remote_file(file)
89
+ prompt_error!(file) unless file.file?
90
+ [{type: file.file_type, source: {type: :file, file_id: file.id}}]
91
+ end
92
+
80
93
  def prompt_error!(content)
81
- raise LLM::PromptError, "The given object (an instance of #{content.class}) " \
82
- "is not supported by the Anthropic API"
94
+ if LLM::Object === content
95
+ else
96
+ raise LLM::PromptError, "The given object (an instance of #{content.class}) " \
97
+ "is not supported by the Anthropic API."
98
+ end
83
99
  end
84
100
 
85
101
  def message = @message
@@ -36,9 +36,10 @@ module LLM::DeepSeek::Format
36
36
  format_content(content.content)
37
37
  when LLM::Function::Return
38
38
  throw(:abort, {role: "tool", tool_call_id: content.id, content: JSON.dump(content.value)})
39
+ when LLM::Object
40
+ prompt_error!(content)
39
41
  else
40
- raise LLM::PromptError, "The given object (an instance of #{content.class}) " \
41
- "is not supported by the DeepSeek chat completions API"
42
+ prompt_error!(content)
42
43
  end
43
44
  end
44
45
 
@@ -61,6 +62,16 @@ module LLM::DeepSeek::Format
61
62
  end
62
63
  end
63
64
 
65
+ def prompt_error!(object)
66
+ if LLM::Object === object
67
+ raise LLM::PromptError, "The given LLM::Object with kind '#{content.kind}' is not " \
68
+ "supported by the DeepSeek API"
69
+ else
70
+ raise LLM::PromptError, "The given object (an instance of #{object.class}) " \
71
+ "is not supported by the DeepSeek API"
72
+ end
73
+ end
74
+
64
75
  def message = @message
65
76
  def content = message.content
66
77
  def returns = content.grep(LLM::Function::Return)
@@ -43,7 +43,7 @@ class LLM::Gemini
43
43
  res = @provider.complete [
44
44
  "Your task is to transcribe the contents of an audio file",
45
45
  "Your response should include the transcription, and nothing else",
46
- LLM.File(file)
46
+ LLM::Object.from_hash(value: LLM.File(file), kind: :local_file)
47
47
  ], params.merge(role: :user, model:)
48
48
  res.tap { _1.define_singleton_method(:text) { choices[0].content } }
49
49
  end
@@ -65,7 +65,7 @@ class LLM::Gemini
65
65
  res = @provider.complete [
66
66
  "Your task is to translate the contents of an audio file into English",
67
67
  "Your response should include the translation, and nothing else",
68
- LLM.File(file)
68
+ LLM::Object.from_hash(value: LLM.File(file), kind: :local_file)
69
69
  ], params.merge(role: :user, model:)
70
70
  res.tap { _1.define_singleton_method(:text) { choices[0].content } }
71
71
  end
@@ -30,37 +30,48 @@ module LLM::Gemini::Format
30
30
  case content
31
31
  when Array
32
32
  content.empty? ? throw(:abort, nil) : content.flat_map { format_content(_1) }
33
- when LLM::Response
34
- format_response(content)
35
- when File
36
- content.close unless content.closed?
37
- format_content(LLM.File(content.path))
38
- when LLM::File
39
- file = content
40
- [{inline_data: {mime_type: file.mime_type, data: file.to_b64}}]
41
33
  when String
42
34
  [{text: content}]
35
+ when LLM::Response
36
+ format_remote_file(content)
43
37
  when LLM::Message
44
38
  format_content(content.content)
45
39
  when LLM::Function::Return
46
40
  [{functionResponse: {name: content.name, response: content.value}}]
41
+ when LLM::Object
42
+ format_object(content)
47
43
  else
48
44
  prompt_error!(content)
49
45
  end
50
46
  end
51
47
 
52
- def format_response(response)
53
- if response.file?
54
- file = response
55
- [{file_data: {mime_type: file.mime_type, file_uri: file.uri}}]
48
+ def format_object(object)
49
+ case object.kind
50
+ when :image_url
51
+ [{file_data: {mime_type: "image/*", file_uri: object.value.to_s}}]
52
+ when :local_file
53
+ file = object.value
54
+ [{inline_data: {mime_type: file.mime_type, data: file.to_b64}}]
55
+ when :remote_file
56
+ format_remote_file(object.value)
56
57
  else
57
- prompt_error!(content)
58
+ prompt_error!(object)
58
59
  end
59
60
  end
60
61
 
62
+ def format_remote_file(file)
63
+ return prompt_error!(file) unless file.file?
64
+ [{file_data: {mime_type: file.mime_type, file_uri: file.uri}}]
65
+ end
66
+
61
67
  def prompt_error!(object)
62
- raise LLM::PromptError, "The given object (an instance of #{object.class}) " \
63
- "is not supported by the Gemini API"
68
+ if LLM::Object === object
69
+ raise LLM::PromptError, "The given LLM::Object with kind '#{content.kind}' is not " \
70
+ "supported by the Gemini API"
71
+ else
72
+ raise LLM::PromptError, "The given object (an instance of #{object.class}) " \
73
+ "is not supported by the Gemini API"
74
+ end
64
75
  end
65
76
 
66
77
  def message = @message
@@ -69,7 +69,7 @@ class LLM::Gemini
69
69
  # @return [LLM::Response]
70
70
  def edit(image:, prompt:, model: "gemini-2.5-flash-image-preview", **params)
71
71
  req = Net::HTTP::Post.new("/v1beta/models/#{model}:generateContent?key=#{key}", headers)
72
- image = LLM.File(image)
72
+ image = LLM::Object.from_hash(value: LLM.File(image), kind: :local_file)
73
73
  body = JSON.dump({
74
74
  contents: [{parts: [{text: edit_prompt}, {text: prompt}, format.format_content(image)]}],
75
75
  generationConfig: {responseModalities: ["TEXT", "IMAGE"]}