llm.rb 0.8.0 → 0.9.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +62 -48
  3. data/lib/llm/{chat → bot}/builder.rb +1 -1
  4. data/lib/llm/bot/conversable.rb +31 -0
  5. data/lib/llm/{chat → bot}/prompt/completion.rb +14 -4
  6. data/lib/llm/{chat → bot}/prompt/respond.rb +16 -5
  7. data/lib/llm/{chat.rb → bot.rb} +48 -66
  8. data/lib/llm/error.rb +22 -22
  9. data/lib/llm/event_handler.rb +44 -0
  10. data/lib/llm/eventstream/event.rb +69 -0
  11. data/lib/llm/eventstream/parser.rb +88 -0
  12. data/lib/llm/eventstream.rb +8 -0
  13. data/lib/llm/function.rb +9 -12
  14. data/lib/llm/object/builder.rb +8 -9
  15. data/lib/llm/object/kernel.rb +1 -1
  16. data/lib/llm/object.rb +7 -1
  17. data/lib/llm/provider.rb +61 -26
  18. data/lib/llm/providers/anthropic/error_handler.rb +3 -3
  19. data/lib/llm/providers/anthropic/models.rb +3 -7
  20. data/lib/llm/providers/anthropic/response_parser/completion_parser.rb +3 -3
  21. data/lib/llm/providers/anthropic/response_parser.rb +1 -0
  22. data/lib/llm/providers/anthropic/stream_parser.rb +66 -0
  23. data/lib/llm/providers/anthropic.rb +9 -4
  24. data/lib/llm/providers/gemini/error_handler.rb +4 -4
  25. data/lib/llm/providers/gemini/files.rb +12 -15
  26. data/lib/llm/providers/gemini/images.rb +4 -8
  27. data/lib/llm/providers/gemini/models.rb +3 -7
  28. data/lib/llm/providers/gemini/stream_parser.rb +69 -0
  29. data/lib/llm/providers/gemini.rb +19 -11
  30. data/lib/llm/providers/ollama/error_handler.rb +3 -3
  31. data/lib/llm/providers/ollama/format/completion_format.rb +1 -1
  32. data/lib/llm/providers/ollama/models.rb +3 -7
  33. data/lib/llm/providers/ollama/stream_parser.rb +44 -0
  34. data/lib/llm/providers/ollama.rb +13 -6
  35. data/lib/llm/providers/openai/audio.rb +5 -9
  36. data/lib/llm/providers/openai/error_handler.rb +3 -3
  37. data/lib/llm/providers/openai/files.rb +12 -15
  38. data/lib/llm/providers/openai/images.rb +8 -11
  39. data/lib/llm/providers/openai/models.rb +3 -7
  40. data/lib/llm/providers/openai/moderations.rb +3 -7
  41. data/lib/llm/providers/openai/response_parser/completion_parser.rb +3 -3
  42. data/lib/llm/providers/openai/response_parser.rb +3 -0
  43. data/lib/llm/providers/openai/responses.rb +10 -12
  44. data/lib/llm/providers/openai/stream_parser.rb +77 -0
  45. data/lib/llm/providers/openai.rb +11 -7
  46. data/lib/llm/providers/voyageai/error_handler.rb +3 -3
  47. data/lib/llm/providers/voyageai.rb +1 -1
  48. data/lib/llm/version.rb +1 -1
  49. data/lib/llm.rb +4 -2
  50. data/llm.gemspec +1 -1
  51. metadata +30 -25
  52. data/lib/llm/chat/conversable.rb +0 -53
  53. /data/lib/{json → llm/json}/schema/array.rb +0 -0
  54. /data/lib/{json → llm/json}/schema/boolean.rb +0 -0
  55. /data/lib/{json → llm/json}/schema/integer.rb +0 -0
  56. /data/lib/{json → llm/json}/schema/leaf.rb +0 -0
  57. /data/lib/{json → llm/json}/schema/null.rb +0 -0
  58. /data/lib/{json → llm/json}/schema/number.rb +0 -0
  59. /data/lib/{json → llm/json}/schema/object.rb +0 -0
  60. /data/lib/{json → llm/json}/schema/string.rb +0 -0
  61. /data/lib/{json → llm/json}/schema/version.rb +0 -0
  62. /data/lib/{json → llm/json}/schema.rb +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9b4e83ac151c51faaa4a1e275058091a9ce6f61c3dc10e879a6215b0f1498aad
4
- data.tar.gz: f78b7bbeaece69384d6b38014e9d1d99816195d8536a310a25d2a23479dda122
3
+ metadata.gz: eb2885c3c77d0ac7555b59fd57ccbf15ed5c72e2b385f5f4d83f8ea906b34171
4
+ data.tar.gz: 0cf0fa38bec61167de57441f11c389f13a1f86ea9dd04caf597698363fa53c71
5
5
  SHA512:
6
- metadata.gz: e117602fae5643713a159d633201cd88e94a339763710bbb788b3b1439e39bbbff9f2c221975fc58e1b57aabdf8d0d935d69dbc6acbece84e98701e129cf3c3d
7
- data.tar.gz: 79f2ef053bf500ba9e5ab76c62abdb69ab93ba43b2f11fce867d008e64fbe09e154c1a30fbfdeb08ae30a5442b5d7e5876aa42c788dce8d0778c786f0a69adee
6
+ metadata.gz: 280bccde2d4d730485845440986b27ce7b753cdde7f080bc3c7e2f1381a3e924fa1aebe48fe734d9d6d58c5d0d5ff7e182a5d7bba5b3e390c79c4c2738bbd8c2
7
+ data.tar.gz: dba89769f1fe6f35ac98e7ad6fce9e90775e8b61e47ee3215e8fb8df555d9787f9d289206bd3cdc60b3b1bdee4a5217e8e69b1d3aa33f29e0e193ba90f0e33f8
data/README.md CHANGED
@@ -3,7 +3,8 @@
3
3
  llm.rb is a zero-dependency Ruby toolkit for Large Language Models that
4
4
  includes OpenAI, Gemini, Anthropic, DeepSeek, Ollama, and LlamaCpp.
5
5
  It's fast, simple and composable – with full support for chat,
6
- tool calling, audio, images, files, and JSON Schema generation.
6
+ streaming, tool calling, audio, images, files, and JSON Schema
7
+ generation.
7
8
 
8
9
  ## Features
9
10
 
@@ -16,6 +17,7 @@ tool calling, audio, images, files, and JSON Schema generation.
16
17
  - 🧠 Stateless and stateful chat via completions and responses API
17
18
  - 🤖 Tool calling and function execution
18
19
  - 🗂️ JSON Schema support for structured, validated responses
20
+ - 📡 Streaming support for real-time response updates
19
21
 
20
22
  #### Media
21
23
  - 🗣️ Text-to-speech, transcription, and translation
@@ -31,17 +33,17 @@ tool calling, audio, images, files, and JSON Schema generation.
31
33
 
32
34
  <details>
33
35
  <summary><b>1. Tools: "system" function</b></summary>
34
- <img src="share/llm-shell/examples/toolcalls.gif">
36
+ <img src="https://github.com/llmrb/llm/raw/main/share/llm-shell/examples/toolcalls.gif">
35
37
  </details>
36
38
 
37
39
  <details>
38
40
  <summary><b>2. Files: import at runtime</b></summary>
39
- <img src="share/llm-shell/examples/files-runtime.gif">
41
+ <img src="https://github.com/llmrb/llm/raw/main/share/llm-shell/examples/files-runtime.gif">
40
42
  </details>
41
43
 
42
44
  <details>
43
45
  <summary><b>3. Files: import at boot time</b></summary>
44
- <img src="share/llm-shell/examples/files-boottime.gif">
46
+ <img src="https://github.com/llmrb/llm/raw/main/share/llm-shell/examples/files-boottime.gif">
45
47
  </details>
46
48
 
47
49
  ## Examples
@@ -60,7 +62,7 @@ using an API key (if required) and an optional set of configuration options via
60
62
  require "llm"
61
63
 
62
64
  ##
63
- # cloud providers
65
+ # remote providers
64
66
  llm = LLM.openai(key: "yourapikey")
65
67
  llm = LLM.gemini(key: "yourapikey")
66
68
  llm = LLM.anthropic(key: "yourapikey")
@@ -79,24 +81,24 @@ llm = LLM.llamacpp(key: nil)
79
81
 
80
82
  > This example uses the stateless chat completions API that all
81
83
  > providers support. A similar example for OpenAI's stateful
82
- > responses API is available in the [docs/](docs/OPENAI.md)
84
+ > responses API is available in the [docs/](docs/OPENAI.md#responses)
83
85
  > directory.
84
86
 
85
- The following example enables lazy mode for a
86
- [LLM::Chat](https://0x1eef.github.io/x/llm.rb/LLM/Chat.html)
87
- object by entering into a conversation where messages are buffered and
88
- sent to the provider only when necessary. Both lazy and non-lazy conversations
89
- maintain a message thread that can be reused as context throughout a conversation.
90
- The example captures the spirit of llm.rb by demonstrating how objects cooperate
91
- together through composition, and it uses the stateless chat completions API that
92
- all LLM providers support:
87
+ The following example creates an instance of
88
+ [LLM::Bot](https://0x1eef.github.io/x/llm.rb/LLM/Bot.html)
89
+ by entering into a conversation where messages are buffered and
90
+ sent to the provider on-demand. This is the default behavior
91
+ because it can reduce the number of requests sent to a provider,
92
+ and avoids unneccessary requests until an attempt to iterate over
93
+ [LLM::Bot#messages](https://0x1eef.github.io/x/llm.rb/LLM/Bot.html#messages-instance_method)
94
+ is made:
93
95
 
94
96
  ```ruby
95
97
  #!/usr/bin/env ruby
96
98
  require "llm"
97
99
 
98
100
  llm = LLM.openai(key: ENV["KEY"])
99
- bot = LLM::Chat.new(llm).lazy
101
+ bot = LLM::Bot.new(llm)
100
102
  msgs = bot.chat do |prompt|
101
103
  prompt.system File.read("./share/llm/prompts/system.txt")
102
104
  prompt.user "Tell me the answer to 5 + 15"
@@ -106,21 +108,38 @@ end
106
108
 
107
109
  # At this point, we execute a single request
108
110
  msgs.each { print "[#{_1.role}] ", _1.content, "\n" }
111
+ ```
109
112
 
110
- ##
111
- # [system] You are my math assistant.
112
- # I will provide you with (simple) equations.
113
- # You will provide answers in the format "The answer to <equation> is <answer>".
114
- # I will provide you a set of messages. Reply to all of them.
115
- # A message is considered unanswered if there is no corresponding assistant response.
116
- #
117
- # [user] Tell me the answer to 5 + 15
118
- # [user] Tell me the answer to (5 + 15) * 2
119
- # [user] Tell me the answer to ((5 + 15) * 2) / 10
120
- #
121
- # [assistant] The answer to 5 + 15 is 20.
122
- # The answer to (5 + 15) * 2 is 40.
123
- # The answer to ((5 + 15) * 2) / 10 is 4.
113
+ #### Streaming
114
+
115
+ > There Is More Than One Way To Do It (TIMTOWTDI) when you are
116
+ > using llm.rb &ndash; and this is especially true when it
117
+ > comes to streaming. See the streaming documentation in
118
+ > [docs/](docs/STREAMING.md#flexibility) for more details.
119
+
120
+ The following example streams the messages in a conversation
121
+ as they are generated in real-time. This feature can be useful
122
+ in case you want to see the contents of a message as it is
123
+ generated, or in case you want to avoid potential read timeouts
124
+ during the generation of a response.
125
+
126
+ The `stream` option can be set to an IO object, or the value `true`
127
+ to enable streaming &ndash; and at the end of the request, `bot.chat`
128
+ returns the same response as the non-streaming version which allows
129
+ you to process a response in the same way:
130
+
131
+ ```ruby
132
+ #!/usr/bin/env ruby
133
+ require "llm"
134
+
135
+ llm = LLM.openai(key: ENV["KEY"])
136
+ bot = LLM::Bot.new(llm)
137
+ bot.chat(stream: $stdout) do |prompt|
138
+ prompt.system "You are my math assistant."
139
+ prompt.user "Tell me the answer to 5 + 15"
140
+ prompt.user "Tell me the answer to (5 + 15) * 2"
141
+ prompt.user "Tell me the answer to ((5 + 15) * 2) / 10"
142
+ end.to_a
124
143
  ```
125
144
 
126
145
  ### Schema
@@ -130,12 +149,7 @@ msgs.each { print "[#{_1.role}] ", _1.content, "\n" }
130
149
  All LLM providers except Anthropic and DeepSeek allow a client to describe
131
150
  the structure of a response that a LLM emits according to a schema that is
132
151
  described by JSON. The schema lets a client describe what JSON object (or value)
133
- an LLM should emit, and the LLM will abide by the schema.
134
- See also: [JSON Schema website](https://json-schema.org/overview/what-is-jsonschema).
135
- We will use the
136
- [llmrb/json-schema](https://github.com/llmrb/json-schema)
137
- library for the sake of the examples &ndash; the interface is designed so you
138
- could drop in any other library in its place:
152
+ an LLM should emit, and the LLM will abide by the schema:
139
153
 
140
154
  ```ruby
141
155
  #!/usr/bin/env ruby
@@ -145,14 +159,14 @@ require "llm"
145
159
  # Objects
146
160
  llm = LLM.openai(key: ENV["KEY"])
147
161
  schema = llm.schema.object(answer: llm.schema.integer.required)
148
- bot = LLM::Chat.new(llm, schema:).lazy
162
+ bot = LLM::Bot.new(llm, schema:)
149
163
  bot.chat "Does the earth orbit the sun?", role: :user
150
164
  bot.messages.find(&:assistant?).content! # => {probability: 1}
151
165
 
152
166
  ##
153
167
  # Enums
154
168
  schema = llm.schema.object(fruit: llm.schema.string.enum("Apple", "Orange", "Pineapple"))
155
- bot = LLM::Chat.new(llm, schema:).lazy
169
+ bot = LLM::Bot.new(llm, schema:)
156
170
  bot.chat "Your favorite fruit is Pineapple", role: :system
157
171
  bot.chat "What fruit is your favorite?", role: :user
158
172
  bot.messages.find(&:assistant?).content! # => {fruit: "Pineapple"}
@@ -160,7 +174,7 @@ bot.messages.find(&:assistant?).content! # => {fruit: "Pineapple"}
160
174
  ##
161
175
  # Arrays
162
176
  schema = llm.schema.object(answers: llm.schema.array(llm.schema.integer.required))
163
- bot = LLM::Chat.new(llm, schema:).lazy
177
+ bot = LLM::Bot.new(llm, schema:)
164
178
  bot.chat "Answer all of my questions", role: :system
165
179
  bot.chat "Tell me the answer to ((5 + 5) / 2)", role: :user
166
180
  bot.chat "Tell me the answer to ((5 + 5) / 2) * 2", role: :user
@@ -172,14 +186,14 @@ bot.messages.find(&:assistant?).content! # => {answers: [5, 10, 11]}
172
186
 
173
187
  #### Functions
174
188
 
175
- The OpenAI, Anthropic, Gemini and Ollama providers support a powerful feature known as
176
- tool calling, and although it is a little complex to understand at first,
177
- it can be powerful for building agents. The following example demonstrates how we
178
- can define a local function (which happens to be a tool), and OpenAI can
179
- then detect when we should call the function.
189
+ All providers support a powerful feature known as tool calling, and although
190
+ it is a little complex to understand at first, it can be powerful for building
191
+ agents. The following example demonstrates how we can define a local function
192
+ (which happens to be a tool), and a provider (such as OpenAI) can then detect
193
+ when we should call the function.
180
194
 
181
195
  The
182
- [LLM::Chat#functions](https://0x1eef.github.io/x/llm.rb/LLM/Chat.html#functions-instance_method)
196
+ [LLM::Bot#functions](https://0x1eef.github.io/x/llm.rb/LLM/Bot.html#functions-instance_method)
183
197
  method returns an array of functions that can be called after sending a message and
184
198
  it will only be populated if the LLM detects a function should be called. Each function
185
199
  corresponds to an element in the "tools" array. The array is emptied after a function call,
@@ -208,7 +222,7 @@ tool = LLM.function(:system) do |fn|
208
222
  end
209
223
  end
210
224
 
211
- bot = LLM::Chat.new(llm, tools: [tool]).lazy
225
+ bot = LLM::Bot.new(llm, tools: [tool])
212
226
  bot.chat "Your task is to run shell commands via a tool.", role: :system
213
227
 
214
228
  bot.chat "What is the current date?", role: :user
@@ -367,7 +381,7 @@ can be given to the chat method:
367
381
  require "llm"
368
382
 
369
383
  llm = LLM.openai(key: ENV["KEY"])
370
- bot = LLM::Chat.new(llm).lazy
384
+ bot = LLM::Bot.new(llm)
371
385
  file = llm.files.create(file: "/documents/openbsd_is_awesome.pdf")
372
386
  bot.chat(file)
373
387
  bot.chat("What is this file about?")
@@ -398,7 +412,7 @@ to a prompt:
398
412
  require "llm"
399
413
 
400
414
  llm = LLM.openai(key: ENV["KEY"])
401
- bot = LLM::Chat.new(llm).lazy
415
+ bot = LLM::Bot.new(llm)
402
416
 
403
417
  bot.chat [URI("https://example.com/path/to/image.png"), "Describe the image in the link"]
404
418
  bot.messages.select(&:assistant?).each { print "[#{_1.role}] ", _1.content, "\n" }
@@ -469,7 +483,7 @@ end
469
483
  ##
470
484
  # Select a model
471
485
  model = llm.models.all.find { |m| m.id == "gpt-3.5-turbo" }
472
- bot = LLM::Chat.new(llm, model:)
486
+ bot = LLM::Bot.new(llm, model:)
473
487
  bot.chat "Hello #{model.id} :)"
474
488
  bot.messages.select(&:assistant?).each { print "[#{_1.role}] ", _1.content, "\n" }
475
489
  ```
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class LLM::Chat
3
+ class LLM::Bot
4
4
  ##
5
5
  # @private
6
6
  module Builder
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Bot
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
+ # Queues a completion to be sent to the provider.
21
+ # @param [String] prompt The prompt
22
+ # @param [Hash] params
23
+ # @return [void]
24
+ def async_completion(prompt, params = {})
25
+ role = params.delete(:role)
26
+ @messages.push [LLM::Message.new(role, prompt), @params.merge(params), :complete]
27
+ end
28
+
29
+ include LLM
30
+ end
31
+ end
@@ -1,20 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module LLM::Chat::Prompt
4
- class Completion < Struct.new(:bot)
3
+ module LLM::Bot::Prompt
4
+ class Completion < Struct.new(:bot, :defaults)
5
+ ##
6
+ # @param [LLM::Bot] bot
7
+ # @param [Hash] defaults
8
+ # @return [LLM::Bot::Prompt::Completion]
9
+ def initialize(bot, defaults)
10
+ super(bot, defaults || {})
11
+ end
12
+
5
13
  ##
6
14
  # @param [String] prompt
7
15
  # @param [Hash] params (see LLM::Provider#complete)
8
- # @return [LLM::Chat]
16
+ # @return [LLM::Bot]
9
17
  def system(prompt, params = {})
18
+ params = defaults.merge(params)
10
19
  bot.chat prompt, params.merge(role: :system)
11
20
  end
12
21
 
13
22
  ##
14
23
  # @param [String] prompt
15
24
  # @param [Hash] params (see LLM::Provider#complete)
16
- # @return [LLM::Chat]
25
+ # @return [LLM::Bot]
17
26
  def user(prompt, params = {})
27
+ params = defaults.merge(params)
18
28
  bot.chat prompt, params.merge(role: :user)
19
29
  end
20
30
  end
@@ -1,28 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module LLM::Chat::Prompt
4
- class Respond < Struct.new(:bot)
3
+ module LLM::Bot::Prompt
4
+ class Respond < Struct.new(:bot, :defaults)
5
+ ##
6
+ # @param [LLM::Bot] bot
7
+ # @param [Hash] defaults
8
+ # @return [LLM::Bot::Prompt::Completion]
9
+ def initialize(bot, defaults)
10
+ super(bot, defaults || {})
11
+ end
12
+
5
13
  ##
6
14
  # @param [String] prompt
7
15
  # @param [Hash] params (see LLM::Provider#complete)
8
- # @return [LLM::Chat]
16
+ # @return [LLM::Bot]
9
17
  def system(prompt, params = {})
18
+ params = defaults.merge(params)
10
19
  bot.respond prompt, params.merge(role: :system)
11
20
  end
12
21
 
13
22
  ##
14
23
  # @param [String] prompt
15
24
  # @param [Hash] params (see LLM::Provider#complete)
16
- # @return [LLM::Chat]
25
+ # @return [LLM::Bot]
17
26
  def developer(prompt, params = {})
27
+ params = defaults.merge(params)
18
28
  bot.respond prompt, params.merge(role: :developer)
19
29
  end
20
30
 
21
31
  ##
22
32
  # @param [String] prompt
23
33
  # @param [Hash] params (see LLM::Provider#complete)
24
- # @return [LLM::Chat]
34
+ # @return [LLM::Bot]
25
35
  def user(prompt, params = {})
36
+ params = defaults.merge(params)
26
37
  bot.respond prompt, params.merge(role: :user)
27
38
  end
28
39
  end
@@ -2,47 +2,48 @@
2
2
 
3
3
  module LLM
4
4
  ##
5
- # {LLM::Chat LLM::Chat} provides a chat object that maintains a
6
- # thread of messages that acts as context throughout a conversation.
7
- # A conversation can use the chat completions API that most LLM providers
8
- # support or the responses API that a select few LLM providers support.
5
+ # {LLM::Bot LLM::Bot} provides a bot object that can maintain a
6
+ # a conversation. A conversation can use the chat completions API
7
+ # that all LLM providers support or the responses API that a select
8
+ # few LLM providers support.
9
9
  #
10
- # @example
10
+ # @example example #1
11
11
  # #!/usr/bin/env ruby
12
12
  # require "llm"
13
13
  #
14
14
  # llm = LLM.openai(ENV["KEY"])
15
- # bot = LLM::Chat.new(llm).lazy
15
+ # bot = LLM::Bot.new(llm)
16
16
  # msgs = bot.chat do |prompt|
17
17
  # prompt.system "Answer the following questions."
18
18
  # prompt.user "What is 5 + 7 ?"
19
19
  # prompt.user "Why is the sky blue ?"
20
20
  # prompt.user "Why did the chicken cross the road ?"
21
21
  # end
22
- # msgs.map { print "[#{_1.role}]", _1.content, "\n" }
22
+ # msgs.each { print "[#{_1.role}]", _1.content, "\n" }
23
23
  #
24
- # @example
24
+ # @example example #2
25
25
  # #!/usr/bin/env ruby
26
26
  # require "llm"
27
27
  #
28
28
  # llm = LLM.openai(ENV["KEY"])
29
- # bot = LLM::Chat.new(llm).lazy
29
+ # bot = LLM::Bot.new(llm)
30
30
  # bot.chat "Answer the following questions.", role: :system
31
31
  # bot.chat "What is 5 + 7 ?", role: :user
32
32
  # bot.chat "Why is the sky blue ?", role: :user
33
33
  # bot.chat "Why did the chicken cross the road ?", role: :user
34
- # bot.messages.map { print "[#{_1.role}]", _1.content, "\n" }
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"
34
+ # bot.messages.each { print "[#{_1.role}]", _1.content, "\n" }
35
+ class Bot
36
+ require_relative "bot/prompt/completion"
37
+ require_relative "bot/prompt/respond"
38
+ require_relative "bot/conversable"
39
+ require_relative "bot/builder"
40
40
 
41
41
  include Conversable
42
42
  include Builder
43
43
 
44
44
  ##
45
- # @return [Array<LLM::Message>]
45
+ # Returns an Enumerable for the messages in a conversation
46
+ # @return [LLM::Buffer<LLM::Message>]
46
47
  attr_reader :messages
47
48
 
48
49
  ##
@@ -58,72 +59,68 @@ module LLM
58
59
  def initialize(provider, params = {})
59
60
  @provider = provider
60
61
  @params = {model: provider.default_model, schema: nil}.compact.merge!(params)
61
- @lazy = false
62
- @messages = [].extend(Array)
62
+ @messages = LLM::Buffer.new(provider)
63
63
  end
64
64
 
65
65
  ##
66
66
  # Maintain a conversation via the chat completions API
67
- # @param prompt (see LLM::Provider#complete)
68
- # @param params (see LLM::Provider#complete)
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
67
+ # @overload def chat(prompt, params = {})
68
+ # @param prompt (see LLM::Provider#complete)
69
+ # @param params The params
70
+ # @return [LLM::Bot]
71
+ # Returns self
72
+ # @overload def chat(prompt, params, &block)
73
+ # @param prompt (see LLM::Provider#complete)
74
+ # @param params The params
75
+ # @yield prompt Yields a prompt
76
+ # @return [LLM::Buffer]
77
+ # Returns messages
72
78
  def chat(prompt = nil, params = {})
73
79
  if block_given?
74
- yield Prompt::Completion.new(self)
80
+ params = prompt
81
+ yield Prompt::Completion.new(self, params)
75
82
  messages
76
83
  elsif prompt.nil?
77
84
  raise ArgumentError, "wrong number of arguments (given 0, expected 1)"
78
85
  else
79
86
  params = {role: :user}.merge!(params)
80
- tap { lazy? ? async_completion(prompt, params) : sync_completion(prompt, params) }
87
+ tap { async_completion(prompt, params) }
81
88
  end
82
89
  end
83
90
 
84
91
  ##
85
92
  # Maintain a conversation via the responses API
86
- # @note Not all LLM providers support this API
87
- # @param prompt (see LLM::Provider#complete)
88
- # @param params (see LLM::Provider#complete)
89
- # @return [LLM::Chat, Array<LLM::Message>, LLM::Buffer]
90
- # Returns self unless given a block, otherwise returns messages
93
+ # @overload def respond(prompt, params = {})
94
+ # @param prompt (see LLM::Provider#complete)
95
+ # @param params The params
96
+ # @return [LLM::Bot]
97
+ # Returns self
98
+ # @overload def respond(prompt, params, &block)
99
+ # @note Not all LLM providers support this API
100
+ # @param prompt (see LLM::Provider#complete)
101
+ # @param params The params
102
+ # @yield prompt Yields a prompt
103
+ # @return [LLM::Buffer]
104
+ # Returns messages
91
105
  def respond(prompt = nil, params = {})
92
106
  if block_given?
93
- yield Prompt::Respond.new(self)
107
+ params = prompt
108
+ yield Prompt::Respond.new(self, params)
94
109
  messages
95
110
  elsif prompt.nil?
96
111
  raise ArgumentError, "wrong number of arguments (given 0, expected 1)"
97
112
  else
98
113
  params = {role: :user}.merge!(params)
99
- tap { lazy? ? async_response(prompt, params) : sync_response(prompt, params) }
114
+ tap { async_response(prompt, params) }
100
115
  end
101
116
  end
102
117
 
103
- ##
104
- # Enables lazy mode for the conversation.
105
- # @return [LLM::Chat]
106
- def lazy
107
- tap do
108
- next if lazy?
109
- @lazy = true
110
- @messages = LLM::Buffer.new(@provider)
111
- end
112
- end
113
-
114
- ##
115
- # @return [Boolean]
116
- # Returns true if the conversation is lazy
117
- def lazy?
118
- @lazy
119
- end
120
-
121
118
  ##
122
119
  # @return [String]
123
120
  def inspect
124
121
  "#<#{self.class.name}:0x#{object_id.to_s(16)} " \
125
122
  "@provider=#{@provider.class}, @params=#{@params.inspect}, " \
126
- "@messages=#{@messages.inspect}, @lazy=#{@lazy.inspect}>"
123
+ "@messages=#{@messages.inspect}>"
127
124
  end
128
125
 
129
126
  ##
@@ -135,20 +132,5 @@ module LLM
135
132
  .flat_map(&:functions)
136
133
  .select(&:pending?)
137
134
  end
138
-
139
- private
140
-
141
- ##
142
- # @private
143
- module Array
144
- def find(...)
145
- reverse_each.find(...)
146
- end
147
-
148
- def unread
149
- reject(&:read?)
150
- end
151
- end
152
- private_constant :Array
153
135
  end
154
136
  end
data/lib/llm/error.rb CHANGED
@@ -8,34 +8,34 @@ module LLM
8
8
  block_given? ? yield(self) : nil
9
9
  super
10
10
  end
11
+ end
11
12
 
13
+ ##
14
+ # The superclass of all HTTP protocol errors
15
+ class ResponseError < Error
12
16
  ##
13
- # The superclass of all HTTP protocol errors
14
- class ResponseError < Error
15
- ##
16
- # @return [Net::HTTPResponse]
17
- # Returns the response associated with an error
18
- attr_accessor :response
17
+ # @return [Net::HTTPResponse]
18
+ # Returns the response associated with an error
19
+ attr_accessor :response
19
20
 
20
- def message
21
- [super, response.body].join("\n")
22
- end
21
+ def message
22
+ [super, response.body].join("\n")
23
23
  end
24
+ end
24
25
 
25
- ##
26
- # HTTPUnauthorized
27
- Unauthorized = Class.new(ResponseError)
26
+ ##
27
+ # HTTPUnauthorized
28
+ UnauthorizedError = Class.new(ResponseError)
28
29
 
29
- ##
30
- # HTTPTooManyRequests
31
- RateLimit = Class.new(ResponseError)
30
+ ##
31
+ # HTTPTooManyRequests
32
+ RateLimitError = Class.new(ResponseError)
32
33
 
33
- ##
34
- # When an given an input that is not understood
35
- FormatError = Class.new(Error)
34
+ ##
35
+ # When an given an input object that is not understood
36
+ FormatError = Class.new(Error)
36
37
 
37
- ##
38
- # When given a prompt that is not understood
39
- PromptError = Class.new(FormatError)
40
- end
38
+ ##
39
+ # When given a prompt object that is not understood
40
+ PromptError = Class.new(FormatError)
41
41
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ ##
5
+ # @private
6
+ class EventHandler
7
+ ##
8
+ # @param [#parse!] parser
9
+ # @return [LLM::EventHandler]
10
+ def initialize(parser)
11
+ @parser = parser
12
+ end
13
+
14
+ ##
15
+ # "data:" event callback
16
+ # @param [LLM::EventStream::Event] event
17
+ # @return [void]
18
+ def on_data(event)
19
+ return if event.end?
20
+ chunk = JSON.parse(event.value)
21
+ @parser.parse!(chunk)
22
+ rescue JSON::ParserError
23
+ end
24
+
25
+ ##
26
+ # Callback for when *any* of chunk of data
27
+ # is received, regardless of whether it has
28
+ # a field name or not. Primarily for ollama,
29
+ # which does emit Server-Sent Events (SSE).
30
+ # @param [LLM::EventStream::Event] event
31
+ # @return [void]
32
+ def on_chunk(event)
33
+ return if event.end?
34
+ chunk = JSON.parse(event.chunk)
35
+ @parser.parse!(chunk)
36
+ rescue JSON::ParserError
37
+ end
38
+
39
+ ##
40
+ # Returns a fully constructed response body
41
+ # @return [LLM::Object]
42
+ def body = @parser.body
43
+ end
44
+ end