llm.rb 0.6.2 → 0.7.1

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: 0abd0e522f099e1e53a3b4d7d3e4648a7c42d2ce23e9c17b9e09f97803e1e31d
4
- data.tar.gz: 2c7b8b38570b4cafb20c61e479f16bc9a7be3e81a651a189d6e15967da796fe3
3
+ metadata.gz: 7d5d93a645b666da3d6947c2076189063aec26e7bc3381cfb84d6a6aea4ce8fa
4
+ data.tar.gz: db764cd8e9180a3c21ca5bf2b35d8a5fa3f525cb63101381aca09f9e92cb5d37
5
5
  SHA512:
6
- metadata.gz: b309e59522e4f80a78d6a34fdf5b7aa0f06ab14ad79e845b92f5ad87537aba07532bb5b6d24bbb6d13386b32a0235ee6fc0308c7fe27496bb95da2016a27dc02
7
- data.tar.gz: 61e2ecd8a53d600977f8a97592b63ed978d2659ed49513508aea2e3ab66c7fe163790eec45dc2a58ccd8aa54fca6fcb89eeb58243861c3a3238e19d86a2a00c7
6
+ metadata.gz: c17999419e02e8c2d9d689299d149dd76077b1f52154557253dd9fe3876ff1109eaaa29d7d7aceda8147f38aa572261b455eaa253fbede212b874af42c8e03e0
7
+ data.tar.gz: 1061af3f752a1e8cbc37c8929a23f6bfa97328f66f3adf030e435f468785ffa6a3aae32fe5120936f872b60d8a48a31fcbf35cee0de66b8ee12ddedf17228beb
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  llm.rb is a zero-dependency Ruby toolkit for Large Language Models that
4
4
  includes OpenAI, Gemini, Anthropic, Ollama, and LlamaCpp. It’s fast, simple
5
- and composable – with full support for chat, tool calling, audio,
5
+ and composable – with full support for chat, tool calling, audio,
6
6
  images, files, and JSON Schema generation.
7
7
 
8
8
  ## Features
@@ -22,11 +22,28 @@ images, files, and JSON Schema generation.
22
22
  - 🗣️ Text-to-speech, transcription, and translation
23
23
  - 🖼️ Image generation, editing, and variation support
24
24
  - 📎 File uploads and prompt-aware file interaction
25
- - 💡 Multimodal prompts (text, URLs, files)
25
+ - 💡 Multimodal prompts (text, images, PDFs, URLs, files)
26
26
 
27
27
  #### Embeddings
28
28
  - 🧮 Text embeddings and vector support
29
29
 
30
+ ## Demos
31
+
32
+ <details>
33
+ <summary><b>1. Tools: "system" function</b></summary>
34
+ <img src="share/llm-shell/examples/toolcalls.gif">
35
+ </details>
36
+
37
+ <details>
38
+ <summary><b>2. Files: import at boot time</b></summary>
39
+ <img src="share/llm-shell/examples/files-boottime.gif">
40
+ </details>
41
+
42
+ <details>
43
+ <summary><b>3. Files: import at runtime</b></summary>
44
+ <img src="share/llm-shell/examples/files-runtime.gif">
45
+ </details>
46
+
30
47
  ## Examples
31
48
 
32
49
  ### Providers
@@ -54,10 +71,15 @@ llm = LLM.voyageai(key: "yourapikey")
54
71
 
55
72
  #### Completions
56
73
 
74
+ > This example uses the stateless chat completions API that all
75
+ > providers support. A similar example for OpenAI's stateful
76
+ > responses API is available in the [docs/](docs/OPENAI_RESPONSES.md)
77
+ > directory.
78
+
57
79
  The following example enables lazy mode for a
58
80
  [LLM::Chat](https://0x1eef.github.io/x/llm.rb/LLM/Chat.html)
59
- object by entering into a "lazy" conversation where messages are buffered and
60
- sent to the provider only when necessary. Both lazy and non-lazy conversations
81
+ object by entering into a conversation where messages are buffered and
82
+ sent to the provider only when necessary. Both lazy and non-lazy conversations
61
83
  maintain a message thread that can be reused as context throughout a conversation.
62
84
  The example captures the spirit of llm.rb by demonstrating how objects cooperate
63
85
  together through composition, and it uses the stateless chat completions API that
@@ -67,13 +89,17 @@ all LLM providers support:
67
89
  #!/usr/bin/env ruby
68
90
  require "llm"
69
91
 
70
- llm = LLM.openai(key: ENV["KEY"])
71
- bot = LLM::Chat.new(llm).lazy
72
- bot.chat File.read("./share/llm/prompts/system.txt"), role: :system
73
- bot.chat "Tell me the answer to 5 + 15", role: :user
74
- bot.chat "Tell me the answer to (5 + 15) * 2", role: :user
75
- bot.chat "Tell me the answer to ((5 + 15) * 2) / 10", role: :user
76
- bot.messages.each { print "[#{_1.role}] ", _1.content, "\n" }
92
+ llm = LLM.openai(key: ENV["KEY"])
93
+ bot = LLM::Chat.new(llm).lazy
94
+ msgs = bot.chat do |prompt|
95
+ prompt.system File.read("./share/llm/prompts/system.txt")
96
+ prompt.user "Tell me the answer to 5 + 15"
97
+ prompt.user "Tell me the answer to (5 + 15) * 2"
98
+ prompt.user "Tell me the answer to ((5 + 15) * 2) / 10"
99
+ end
100
+
101
+ # At this point, we execute a single request
102
+ msgs.each { print "[#{_1.role}] ", _1.content, "\n" }
77
103
 
78
104
  ##
79
105
  # [system] You are my math assistant.
@@ -91,46 +117,6 @@ bot.messages.each { print "[#{_1.role}] ", _1.content, "\n" }
91
117
  # The answer to ((5 + 15) * 2) / 10 is 4.
92
118
  ```
93
119
 
94
- #### Responses
95
-
96
- The responses API is a recent addition
97
- [provided by OpenAI](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses)
98
- that lets a client store message state on their servers &ndash; and in turn
99
- a client can avoid maintaining state manually as well as avoid sending
100
- the entire conversation with each request that is made. Although it is
101
- primarily supported by OpenAI at the moment, we might see other providers
102
- support it in the future. For now
103
- [llm.rb supports the responses API](https://0x1eef.github.io/x/llm.rb/LLM/OpenAI/Responses.html)
104
- for the OpenAI provider:
105
-
106
- ```ruby
107
- #!/usr/bin/env ruby
108
- require "llm"
109
-
110
- llm = LLM.openai(key: ENV["KEY"])
111
- bot = LLM::Chat.new(llm).lazy
112
- bot.respond File.read("./share/llm/prompts/system.txt"), role: :developer
113
- bot.respond "Tell me the answer to 5 + 15", role: :user
114
- bot.respond "Tell me the answer to (5 + 15) * 2", role: :user
115
- bot.respond "Tell me the answer to ((5 + 15) * 2) / 10", role: :user
116
- bot.messages.each { print "[#{_1.role}] ", _1.content, "\n" }
117
-
118
- ##
119
- # [developer] You are my math assistant.
120
- # I will provide you with (simple) equations.
121
- # You will provide answers in the format "The answer to <equation> is <answer>".
122
- # I will provide you a set of messages. Reply to all of them.
123
- # A message is considered unanswered if there is no corresponding assistant response.
124
- #
125
- # [user] Tell me the answer to 5 + 15
126
- # [user] Tell me the answer to (5 + 15) * 2
127
- # [user] Tell me the answer to ((5 + 15) * 2) / 10
128
- #
129
- # [assistant] The answer to 5 + 15 is 20.
130
- # The answer to (5 + 15) * 2 is 40.
131
- # The answer to ((5 + 15) * 2) / 10 is 4.
132
- ```
133
-
134
120
  ### Schema
135
121
 
136
122
  #### Structured
@@ -139,13 +125,9 @@ All LLM providers except Anthropic allow a client to describe the structure
139
125
  of a response that a LLM emits according to a schema that is described by JSON.
140
126
  The schema lets a client describe what JSON object (or value) an LLM should emit,
141
127
  and the LLM will abide by the schema. See also: [JSON Schema website](https://json-schema.org/overview/what-is-jsonschema).
142
-
143
- True to the llm.rb spirit of doing one thing well, and solving problems through the
144
- composition of objects, the generation of a schema is delegated to another object
145
- who is responsible for and an expert in the generation of JSON schemas. We will use
146
- the
128
+ We will use the
147
129
  [llmrb/json-schema](https://github.com/llmrb/json-schema)
148
- library for the sake of the examples &ndash; the interface is designed so you
130
+ library for the sake of the examples &ndash; the interface is designed so you
149
131
  could drop in any other library in its place:
150
132
 
151
133
  ```ruby
@@ -153,19 +135,19 @@ could drop in any other library in its place:
153
135
  require "llm"
154
136
 
155
137
  llm = LLM.openai(key: ENV["KEY"])
156
- schema = llm.schema.object({os: llm.schema.string.enum("OpenBSD", "FreeBSD", "NetBSD")})
157
- bot = LLM::Chat.new(llm, schema:)
158
- bot.chat "You secretly love NetBSD", role: :system
159
- bot.chat "What operating system is the best?", role: :user
160
- bot.messages.find(&:assistant?).content! # => {os: "NetBSD"}
138
+ schema = llm.schema.object({fruit: llm.schema.string.enum("Apple", "Orange", "Pineapple")})
139
+ bot = LLM::Chat.new(llm, schema:).lazy
140
+ bot.chat "Your favorite fruit is Pineapple", role: :system
141
+ bot.chat "What fruit is your favorite?", role: :user
142
+ bot.messages.find(&:assistant?).content! # => {fruit: "Pineapple"}
161
143
 
162
144
  schema = llm.schema.object({answer: llm.schema.integer.required})
163
- bot = LLM::Chat.new(llm, schema:)
145
+ bot = LLM::Chat.new(llm, schema:).lazy
164
146
  bot.chat "Tell me the answer to ((5 + 5) / 2)", role: :user
165
147
  bot.messages.find(&:assistant?).content! # => {answer: 5}
166
148
 
167
149
  schema = llm.schema.object({probability: llm.schema.number.required})
168
- bot = LLM::Chat.new(llm, schema:)
150
+ bot = LLM::Chat.new(llm, schema:).lazy
169
151
  bot.chat "Does the earth orbit the sun?", role: :user
170
152
  bot.messages.find(&:assistant?).content! # => {probability: 1}
171
153
  ```
@@ -195,14 +177,18 @@ arbitrary commands from a LLM without sanitizing the input first :) Without furt
195
177
  #!/usr/bin/env ruby
196
178
  require "llm"
197
179
 
198
- llm = LLM.openai(key: ENV["KEY"])
180
+ llm = LLM.openai(key: ENV["KEY"])
199
181
  tool = LLM.function(:system) do |fn|
200
182
  fn.description "Run a shell command"
201
183
  fn.params do |schema|
202
184
  schema.object(command: schema.string.required)
203
185
  end
204
186
  fn.define do |params|
205
- system(params.command)
187
+ ro, wo = IO.pipe
188
+ re, we = IO.pipe
189
+ Process.wait Process.spawn(params.command, out: wo, err: we)
190
+ [wo,we].each(&:close)
191
+ {stderr: re.read, stdout: ro.read}
206
192
  end
207
193
  end
208
194
 
@@ -216,8 +202,8 @@ bot.chat "What operating system am I running? (short version please!)", role: :u
216
202
  bot.chat bot.functions.map(&:call) # report return value to the LLM
217
203
 
218
204
  ##
219
- # Thu May 1 10:01:02 UTC 2025
220
- # FreeBSD
205
+ # {stderr: "", stdout: "Thu May 1 10:01:02 UTC 2025"}
206
+ # {stderr: "", stdout: "FreeBSD"}
221
207
  ```
222
208
 
223
209
  ### Audio
@@ -228,8 +214,7 @@ Some but not all providers implement audio generation capabilities that
228
214
  can create speech from text, transcribe audio to text, or translate
229
215
  audio to text (usually English). The following example uses the OpenAI provider
230
216
  to create an audio file from a text prompt. The audio is then moved to
231
- `${HOME}/hello.mp3` as the final step. As always, consult the provider's
232
- documentation for more information on how to use the audio generation API:
217
+ `${HOME}/hello.mp3` as the final step:
233
218
 
234
219
  ```ruby
235
220
  #!/usr/bin/env ruby
@@ -245,8 +230,7 @@ IO.copy_stream res.audio, File.join(Dir.home, "hello.mp3")
245
230
  The following example transcribes an audio file to text. The audio file
246
231
  (`${HOME}/hello.mp3`) was theoretically created in the previous example,
247
232
  and the result is printed to the console. The example uses the OpenAI
248
- provider to transcribe the audio file. As always, consult the provider's
249
- documentation for more information on how to use the audio transcription API:
233
+ provider to transcribe the audio file:
250
234
 
251
235
  ```ruby
252
236
  #!/usr/bin/env ruby
@@ -264,9 +248,7 @@ print res.text, "\n" # => "Hello world."
264
248
  The following example translates an audio file to text. In this example
265
249
  the audio file (`${HOME}/bomdia.mp3`) is theoretically in Portuguese,
266
250
  and it is translated to English. The example uses the OpenAI provider,
267
- and at the time of writing, it can only translate to English. As always,
268
- consult the provider's documentation for more information on how to use
269
- the audio translation API:
251
+ and at the time of writing, it can only translate to English:
270
252
 
271
253
  ```ruby
272
254
  #!/usr/bin/env ruby
@@ -308,11 +290,7 @@ end
308
290
  The following example is focused on editing a local image with the aid
309
291
  of a prompt. The image (`/images/cat.png`) is returned to us with the cat
310
292
  now wearing a hat. The image is then moved to `${HOME}/catwithhat.png` as
311
- the final step.
312
-
313
- Results and quality may vary, consider prompt adjustments if the results
314
- are not as expected, and consult the provider's documentation
315
- for more information on how to use the image editing API:
293
+ the final step:
316
294
 
317
295
  ```ruby
318
296
  #!/usr/bin/env ruby
@@ -336,8 +314,7 @@ end
336
314
  The following example is focused on creating variations of a local image.
337
315
  The image (`/images/cat.png`) is returned to us with five different variations.
338
316
  The images are then moved to `${HOME}/catvariation0.png`, `${HOME}/catvariation1.png`
339
- and so on as the final step. Consult the provider's documentation for more information
340
- on how to use the image variations API:
317
+ and so on as the final step:
341
318
 
342
319
  ```ruby
343
320
  #!/usr/bin/env ruby
@@ -458,10 +435,8 @@ print res.embeddings[0].size, "\n"
458
435
  Almost all LLM providers provide a models endpoint that allows a client to
459
436
  query the list of models that are available to use. The list is dynamic,
460
437
  maintained by LLM providers, and it is independent of a specific llm.rb release.
461
- True to the llm.rb spirit of small, composable objects that cooperate with
462
- each other, a
463
438
  [LLM::Model](https://0x1eef.github.io/x/llm.rb/LLM/Model.html)
464
- object can be used instead of a string that describes a model name (although
439
+ objects can be used instead of a string that describes a model name (although
465
440
  either works). Let's take a look at an example:
466
441
 
467
442
  ```ruby
@@ -497,7 +472,8 @@ over or doesn't cover at all. The API reference is available at
497
472
 
498
473
  The [docs/](docs/) directory contains some additional documentation that
499
474
  didn't quite make it into the README. It covers the design guidelines that
500
- the library follows, and some strategies for memory management.
475
+ the library follows, some strategies for memory management, and other
476
+ provider-specific features.
501
477
 
502
478
  ## See also
503
479
 
@@ -506,7 +482,7 @@ the library follows, and some strategies for memory management.
506
482
  An extensible, developer-oriented command line utility that is powered by
507
483
  llm.rb and serves as a demonstration of the library's capabilities. The
508
484
  [demo](https://github.com/llmrb/llm-shell#demos) section has a number of GIF
509
- previews might be especially interesting!
485
+ previews might be especially interesting.
510
486
 
511
487
  ## Install
512
488
 
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Chat
4
+ ##
5
+ # @private
6
+ module Builder
7
+ private
8
+
9
+ ##
10
+ # @param [String] prompt The prompt
11
+ # @param [Hash] params
12
+ # @return [LLM::Response::Respond]
13
+ def create_response!(prompt, params)
14
+ @provider.responses.create(
15
+ prompt,
16
+ @params.merge(params.merge(@response ? {previous_response_id: @response.id} : {}))
17
+ )
18
+ end
19
+
20
+ ##
21
+ # @param [String] prompt The prompt
22
+ # @param [Hash] params
23
+ # @return [LLM::Response::Completion]
24
+ def create_completion!(prompt, params)
25
+ @provider.complete(
26
+ prompt,
27
+ @params.merge(params.merge(messages:))
28
+ )
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Chat
4
+ ##
5
+ # @private
6
+ module Conversable
7
+ private
8
+
9
+ ##
10
+ # Queues a response to be sent to the provider.
11
+ # @param [String] prompt The prompt
12
+ # @param [Hash] params
13
+ # @return [void]
14
+ def async_response(prompt, params = {})
15
+ role = params.delete(:role)
16
+ @messages << [LLM::Message.new(role, prompt), @params.merge(params), :respond]
17
+ end
18
+
19
+ ##
20
+ # Sends a response to the provider and returns the response.
21
+ # @param [String] prompt The prompt
22
+ # @param [Hash] params
23
+ # @return [LLM::Response::Respond]
24
+ def sync_response(prompt, params = {})
25
+ role = params[:role]
26
+ @response = create_response!(prompt, params)
27
+ @messages.concat [Message.new(role, prompt), @response.outputs[0]]
28
+ end
29
+
30
+ ##
31
+ # Queues a completion to be sent to the provider.
32
+ # @param [String] prompt The prompt
33
+ # @param [Hash] params
34
+ # @return [void]
35
+ def async_completion(prompt, params = {})
36
+ role = params.delete(:role)
37
+ @messages.push [LLM::Message.new(role, prompt), @params.merge(params), :complete]
38
+ end
39
+
40
+ ##
41
+ # Sends a completion to the provider and returns the completion.
42
+ # @param [String] prompt The prompt
43
+ # @param [Hash] params
44
+ # @return [LLM::Response::Completion]
45
+ def sync_completion(prompt, params = {})
46
+ role = params[:role]
47
+ completion = create_completion!(prompt, params)
48
+ @messages.concat [Message.new(role, prompt), completion.choices[0]]
49
+ end
50
+
51
+ include LLM
52
+ end
53
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Chat::Prompt
4
+ class Completion < Struct.new(:bot)
5
+ ##
6
+ # @param [String] prompt
7
+ # @param [Hash] params (see LLM::Provider#complete)
8
+ # @return [LLM::Chat]
9
+ def system(prompt, params = {})
10
+ bot.chat prompt, params.merge(role: :system)
11
+ end
12
+
13
+ ##
14
+ # @param [String] prompt
15
+ # @param [Hash] params (see LLM::Provider#complete)
16
+ # @return [LLM::Chat]
17
+ def user(prompt, params = {})
18
+ bot.chat prompt, params.merge(role: :user)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Chat::Prompt
4
+ class Respond < Struct.new(:bot)
5
+ ##
6
+ # @param [String] prompt
7
+ # @param [Hash] params (see LLM::Provider#complete)
8
+ # @return [LLM::Chat]
9
+ def system(prompt, params = {})
10
+ bot.respond prompt, params.merge(role: :system)
11
+ end
12
+
13
+ ##
14
+ # @param [String] prompt
15
+ # @param [Hash] params (see LLM::Provider#complete)
16
+ # @return [LLM::Chat]
17
+ def developer(prompt, params = {})
18
+ bot.respond prompt, params.merge(role: :developer)
19
+ end
20
+
21
+ ##
22
+ # @param [String] prompt
23
+ # @param [Hash] params (see LLM::Provider#complete)
24
+ # @return [LLM::Chat]
25
+ def user(prompt, params = {})
26
+ bot.respond prompt, params.merge(role: :user)
27
+ end
28
+ end
29
+ end
data/lib/llm/chat.rb CHANGED
@@ -11,14 +11,36 @@ module LLM
11
11
  # #!/usr/bin/env ruby
12
12
  # require "llm"
13
13
  #
14
+ # llm = LLM.openai(ENV["KEY"])
15
+ # bot = LLM::Chat.new(llm).lazy
16
+ # msgs = bot.chat do |prompt|
17
+ # prompt.system "Answer the following questions."
18
+ # prompt.user "What is 5 + 7 ?"
19
+ # prompt.user "Why is the sky blue ?"
20
+ # prompt.user "Why did the chicken cross the road ?"
21
+ # end
22
+ # msgs.map { print "[#{_1.role}]", _1.content, "\n" }
23
+ #
24
+ # @example
25
+ # #!/usr/bin/env ruby
26
+ # require "llm"
27
+ #
14
28
  # llm = LLM.openai(ENV["KEY"])
15
29
  # bot = LLM::Chat.new(llm).lazy
16
- # bot.chat("Provide short and concise answers", role: :system)
17
- # bot.chat("What is 5 + 7 ?", role: :user)
18
- # bot.chat("Why is the sky blue ?", role: :user)
19
- # bot.chat("Why did the chicken cross the road ?", role: :user)
30
+ # bot.chat "Answer the following questions.", role: :system
31
+ # bot.chat "What is 5 + 7 ?", role: :user
32
+ # bot.chat "Why is the sky blue ?", role: :user
33
+ # bot.chat "Why did the chicken cross the road ?", role: :user
20
34
  # bot.messages.map { print "[#{_1.role}]", _1.content, "\n" }
21
35
  class Chat
36
+ require_relative "chat/prompt/completion"
37
+ require_relative "chat/prompt/respond"
38
+ require_relative "chat/conversable"
39
+ require_relative "chat/builder"
40
+
41
+ include Conversable
42
+ include Builder
43
+
22
44
  ##
23
45
  # @return [Array<LLM::Message>]
24
46
  attr_reader :messages
@@ -44,18 +66,18 @@ module LLM
44
66
  # Maintain a conversation via the chat completions API
45
67
  # @param prompt (see LLM::Provider#complete)
46
68
  # @param params (see LLM::Provider#complete)
47
- # @return [LLM::Chat]
48
- def chat(prompt, params = {})
49
- params = {role: :user}.merge!(params)
50
- if lazy?
51
- role = params.delete(:role)
52
- @messages << [LLM::Message.new(role, prompt), @params.merge(params), :complete]
53
- self
69
+ # @yieldparam [LLM::Chat::CompletionPrompt] prompt Yields a prompt
70
+ # @return [LLM::Chat, Array<LLM::Message>, LLM::Buffer]
71
+ # Returns self unless given a block, otherwise returns messages
72
+ def chat(prompt = nil, params = {})
73
+ if block_given?
74
+ yield Prompt::Completion.new(self)
75
+ messages
76
+ elsif prompt.nil?
77
+ raise ArgumentError, "wrong number of arguments (given 0, expected 1)"
54
78
  else
55
- role = params[:role]
56
- completion = complete!(prompt, params)
57
- @messages.concat [Message.new(role, prompt), completion.choices[0]]
58
- self
79
+ params = {role: :user}.merge!(params)
80
+ tap { lazy? ? async_completion(prompt, params) : sync_completion(prompt, params) }
59
81
  end
60
82
  end
61
83
 
@@ -64,36 +86,20 @@ module LLM
64
86
  # @note Not all LLM providers support this API
65
87
  # @param prompt (see LLM::Provider#complete)
66
88
  # @param params (see LLM::Provider#complete)
67
- # @return [LLM::Chat]
68
- def respond(prompt, params = {})
69
- params = {role: :user}.merge!(params)
70
- if lazy?
71
- role = params.delete(:role)
72
- @messages << [LLM::Message.new(role, prompt), @params.merge(params), :respond]
73
- self
89
+ # @return [LLM::Chat, Array<LLM::Message>, LLM::Buffer]
90
+ # Returns self unless given a block, otherwise returns messages
91
+ def respond(prompt = nil, params = {})
92
+ if block_given?
93
+ yield Prompt::Respond.new(self)
94
+ messages
95
+ elsif prompt.nil?
96
+ raise ArgumentError, "wrong number of arguments (given 0, expected 1)"
74
97
  else
75
- role = params[:role]
76
- @response = respond!(prompt, params)
77
- @messages.concat [Message.new(role, prompt), @response.outputs[0]]
78
- self
98
+ params = {role: :user}.merge!(params)
99
+ tap { lazy? ? async_response(prompt, params) : sync_response(prompt, params) }
79
100
  end
80
101
  end
81
102
 
82
- ##
83
- # The last message in the conversation.
84
- # @note
85
- # The `read_response` and `recent_message` methods are aliases of
86
- # the `last_message` method, and you can choose the name that best
87
- # fits your context or code style.
88
- # @param [#to_s] role
89
- # The role of the last message.
90
- # @return [LLM::Message]
91
- def last_message(role: @provider.assistant_role)
92
- messages.reverse_each.find { _1.role == role.to_s }
93
- end
94
- alias_method :recent_message, :last_message
95
- alias_method :read_response, :last_message
96
-
97
103
  ##
98
104
  # Enables lazy mode for the conversation.
99
105
  # @return [LLM::Chat]
@@ -121,13 +127,13 @@ module LLM
121
127
  end
122
128
 
123
129
  ##
124
- # Returns an array of functions that have yet to be called
130
+ # Returns an array of functions that can be called
125
131
  # @return [Array<LLM::Function>]
126
132
  def functions
127
133
  messages
128
134
  .select(&:assistant?)
129
135
  .flat_map(&:functions)
130
- .reject(&:called?)
136
+ .select(&:pending?)
131
137
  end
132
138
 
133
139
  private
@@ -144,19 +150,5 @@ module LLM
144
150
  end
145
151
  end
146
152
  private_constant :Array
147
-
148
- def respond!(prompt, params)
149
- @provider.responses.create(
150
- prompt,
151
- @params.merge(params.merge(@response ? {previous_response_id: @response.id} : {}))
152
- )
153
- end
154
-
155
- def complete!(prompt, params)
156
- @provider.complete(
157
- prompt,
158
- @params.merge(params.merge(messages:))
159
- )
160
- end
161
153
  end
162
154
  end
data/lib/llm/function.rb CHANGED
@@ -1,5 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ ##
4
+ # The {LLM::Function LLM::Function} class represents a function that can
5
+ # be called by an LLM. It comes in two forms: a Proc-based function,
6
+ # or a Class-based function.
7
+ #
8
+ # @example
9
+ # # Proc-based
10
+ # LLM.function(:system) do |fn|
11
+ # fn.description "Runs system commands, emits their output"
12
+ # fn.params do |schema|
13
+ # schema.object(command: schema.string.required)
14
+ # end
15
+ # fn.define do |params|
16
+ # Kernel.system(params.command)
17
+ # end
18
+ # end
19
+ #
20
+ # @example
21
+ # # Class-based
22
+ # class System
23
+ # def call(params)
24
+ # Kernel.system(params.command)
25
+ # end
26
+ # end
27
+ #
28
+ # LLM.function(:system) do |fn|
29
+ # fn.description "Runs system commands, emits their output"
30
+ # fn.params do |schema|
31
+ # schema.object(command: schema.string.required)
32
+ # end
33
+ # fn.register(System)
34
+ # end
3
35
  class LLM::Function
4
36
  class Return < Struct.new(:id, :value)
5
37
  end
@@ -25,6 +57,8 @@ class LLM::Function
25
57
  def initialize(name, &b)
26
58
  @name = name
27
59
  @schema = JSON::Schema.new
60
+ @called = false
61
+ @cancelled = false
28
62
  yield(self)
29
63
  end
30
64
 
@@ -45,21 +79,36 @@ class LLM::Function
45
79
 
46
80
  ##
47
81
  # Set the function implementation
48
- # @param [Proc] b The function implementation
82
+ # @param [Proc, Class] b The function implementation
49
83
  # @return [void]
50
- def define(&b)
51
- @runner = b
84
+ def define(klass = nil, &b)
85
+ @runner = klass || b
52
86
  end
87
+ alias_method :register, :define
53
88
 
54
89
  ##
55
90
  # Call the function
56
- # @return [Object] The result of the function call
91
+ # @return [LLM::Function::Return] The result of the function call
57
92
  def call
58
- Return.new id, @runner.call(arguments)
93
+ Return.new id, (Class === @runner) ? @runner.new.call(arguments) : @runner.call(arguments)
59
94
  ensure
60
95
  @called = true
61
96
  end
62
97
 
98
+ ##
99
+ # Returns a value that communicates that the function call was cancelled
100
+ # @example
101
+ # llm = LLM.openai(key: ENV["KEY"])
102
+ # bot = LLM::Chat.new(llm, tools: [fn1, fn2])
103
+ # bot.chat "I want to run the functions"
104
+ # bot.chat bot.functions.map(&:cancel)
105
+ # @return [LLM::Function::Return]
106
+ def cancel(reason: "function call cancelled")
107
+ Return.new(id, {cancelled: true, reason:})
108
+ ensure
109
+ @cancelled = true
110
+ end
111
+
63
112
  ##
64
113
  # Returns true when a function has been called
65
114
  # @return [Boolean]
@@ -67,6 +116,20 @@ class LLM::Function
67
116
  @called
68
117
  end
69
118
 
119
+ ##
120
+ # Returns true when a function has been cancelled
121
+ # @return [Boolean]
122
+ def cancelled?
123
+ @cancelled
124
+ end
125
+
126
+ ##
127
+ # Returns true when a function has neither been called nor cancelled
128
+ # @return [Boolean]
129
+ def pending?
130
+ !@called && !@cancelled
131
+ end
132
+
70
133
  ##
71
134
  # @return [Hash]
72
135
  def format(provider)
data/lib/llm/multipart.rb CHANGED
@@ -27,20 +27,6 @@ class LLM::Multipart
27
27
  "multipart/form-data; boundary=#{@boundary}"
28
28
  end
29
29
 
30
- ##
31
- # Returns the multipart request body parts
32
- # @return [Array<String>]
33
- def parts
34
- params.map do |key, value|
35
- locals = {key: key.to_s.b, boundary: boundary.to_s.b}
36
- if value.respond_to?(:path)
37
- file_part(key, value, locals)
38
- else
39
- data_part(key, value, locals)
40
- end
41
- end
42
- end
43
-
44
30
  ##
45
31
  # Returns the multipart request body
46
32
  # @return [String]
@@ -54,47 +40,63 @@ class LLM::Multipart
54
40
 
55
41
  attr_reader :params
56
42
 
57
- def attributes(file)
58
- {
59
- filename: File.basename(file.path).b,
60
- content_type: LLM::Mime[file].b
61
- }
43
+ def file(locals, file)
44
+ locals = locals.merge(attributes(file))
45
+ build_file(locals) do |body|
46
+ IO.copy_stream(file.path, body)
47
+ body << "\r\n"
48
+ end
62
49
  end
63
50
 
64
- def multipart_header(type:, locals:)
65
- if type == :file
66
- str = StringIO.new("".b)
67
- str << "--#{locals[:boundary]}" \
51
+ def form(locals, value)
52
+ locals = locals.merge(value:)
53
+ build_form(locals) do |body|
54
+ body << value.to_s
55
+ body << "\r\n"
56
+ end
57
+ end
58
+
59
+ def build_file(locals)
60
+ StringIO.new("".b).tap do |io|
61
+ io << "--#{locals[:boundary]}" \
68
62
  "\r\n" \
69
63
  "Content-Disposition: form-data; name=\"#{locals[:key]}\";" \
70
64
  "filename=\"#{locals[:filename]}\"" \
71
65
  "\r\n" \
72
66
  "Content-Type: #{locals[:content_type]}" \
73
67
  "\r\n\r\n"
74
- elsif type == :data
75
- str = StringIO.new("".b)
76
- str << "--#{locals[:boundary]}" \
68
+ yield(io)
69
+ end
70
+ end
71
+
72
+ def build_form(locals)
73
+ StringIO.new("".b).tap do |io|
74
+ io << "--#{locals[:boundary]}" \
77
75
  "\r\n" \
78
76
  "Content-Disposition: form-data; name=\"#{locals[:key]}\"" \
79
77
  "\r\n\r\n"
80
- else
81
- raise "unknown type: #{type}"
78
+ yield(io)
82
79
  end
83
80
  end
84
81
 
85
- def file_part(key, file, locals)
86
- locals = locals.merge(attributes(file))
87
- multipart_header(type: :file, locals:).tap do |io|
88
- IO.copy_stream(file.path, io)
89
- io << "\r\n"
82
+ ##
83
+ # Returns the multipart request body parts
84
+ # @return [Array<String>]
85
+ def parts
86
+ params.map do |key, value|
87
+ locals = {key: key.to_s.b, boundary: boundary.to_s.b}
88
+ if value.respond_to?(:path)
89
+ file(locals, value)
90
+ else
91
+ form(locals, value)
92
+ end
90
93
  end
91
94
  end
92
95
 
93
- def data_part(key, value, locals)
94
- locals = locals.merge(value:)
95
- multipart_header(type: :data, locals:).tap do |io|
96
- io << value.to_s
97
- io << "\r\n"
98
- end
96
+ def attributes(file)
97
+ {
98
+ filename: File.basename(file.path).b,
99
+ content_type: LLM::Mime[file].b
100
+ }
99
101
  end
100
102
  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.6.2"
4
+ VERSION = "0.7.1"
5
5
  end
data/llm.gemspec CHANGED
@@ -8,14 +8,15 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ["Antar Azri", "0x1eef"]
9
9
  spec.email = ["azantar@proton.me", "0x1eef@proton.me"]
10
10
 
11
- spec.summary = "llm.rb is a lightweight Ruby library that provides a " \
12
- "common interface and set of functionality for multple " \
13
- "Large Language Models (LLMs). It is designed to be simple, " \
14
- "flexible, and easy to use."
11
+ spec.summary = "llm.rb is a zero-dependency Ruby toolkit for " \
12
+ "Large Language Models that includes OpenAI, Gemini, " \
13
+ "Anthropic, Ollama, and LlamaCpp. It’s fast, simple " \
14
+ "and composable – with full support for chat, tool calling, audio, " \
15
+ "images, files, and JSON Schema generation."
15
16
  spec.description = spec.summary
16
17
  spec.homepage = "https://github.com/llmrb/llm"
17
18
  spec.license = "0BSDL"
18
- spec.required_ruby_version = ">= 3.0.0"
19
+ spec.required_ruby_version = ">= 3.2.0"
19
20
 
20
21
  spec.metadata["homepage_uri"] = spec.homepage
21
22
  spec.metadata["source_code_uri"] = "https://github.com/llmrb/llm"
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.6.2
4
+ version: 0.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Antar Azri
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2025-05-07 00:00:00.000000000 Z
12
+ date: 2025-05-11 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: webmock
@@ -151,9 +151,10 @@ dependencies:
151
151
  - - "~>"
152
152
  - !ruby/object:Gem::Version
153
153
  version: '2.8'
154
- description: llm.rb is a lightweight Ruby library that provides a common interface
155
- and set of functionality for multple Large Language Models (LLMs). It is designed
156
- to be simple, flexible, and easy to use.
154
+ description: llm.rb is a zero-dependency Ruby toolkit for Large Language Models that
155
+ includes OpenAI, Gemini, Anthropic, Ollama, and LlamaCpp. It’s fast, simple and
156
+ composable with full support for chat, tool calling, audio, images, files, and
157
+ JSON Schema generation.
157
158
  email:
158
159
  - azantar@proton.me
159
160
  - 0x1eef@proton.me
@@ -176,6 +177,10 @@ files:
176
177
  - lib/llm.rb
177
178
  - lib/llm/buffer.rb
178
179
  - lib/llm/chat.rb
180
+ - lib/llm/chat/builder.rb
181
+ - lib/llm/chat/conversable.rb
182
+ - lib/llm/chat/prompt/completion.rb
183
+ - lib/llm/chat/prompt/respond.rb
179
184
  - lib/llm/core_ext/ostruct.rb
180
185
  - lib/llm/error.rb
181
186
  - lib/llm/file.rb
@@ -255,7 +260,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
255
260
  requirements:
256
261
  - - ">="
257
262
  - !ruby/object:Gem::Version
258
- version: 3.0.0
263
+ version: 3.2.0
259
264
  required_rubygems_version: !ruby/object:Gem::Requirement
260
265
  requirements:
261
266
  - - ">="
@@ -265,7 +270,8 @@ requirements: []
265
270
  rubygems_version: 3.5.23
266
271
  signing_key:
267
272
  specification_version: 4
268
- summary: llm.rb is a lightweight Ruby library that provides a common interface and
269
- set of functionality for multple Large Language Models (LLMs). It is designed to
270
- be simple, flexible, and easy to use.
273
+ summary: llm.rb is a zero-dependency Ruby toolkit for Large Language Models that includes
274
+ OpenAI, Gemini, Anthropic, Ollama, and LlamaCpp. It’s fast, simple and composable
275
+ with full support for chat, tool calling, audio, images, files, and JSON Schema
276
+ generation.
271
277
  test_files: []