llm.rb 2.1.0 → 3.1.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +125 -133
  3. data/lib/llm/bot.rb +4 -4
  4. data/lib/llm/buffer.rb +0 -9
  5. data/lib/llm/contract/completion.rb +65 -0
  6. data/lib/llm/contract.rb +48 -0
  7. data/lib/llm/error.rb +22 -14
  8. data/lib/llm/eventhandler.rb +6 -4
  9. data/lib/llm/eventstream/parser.rb +18 -13
  10. data/lib/llm/function.rb +1 -1
  11. data/lib/llm/json_adapter.rb +109 -0
  12. data/lib/llm/message.rb +9 -29
  13. data/lib/llm/multipart/enumerator_io.rb +86 -0
  14. data/lib/llm/multipart.rb +32 -51
  15. data/lib/llm/object/builder.rb +6 -6
  16. data/lib/llm/object/kernel.rb +4 -4
  17. data/lib/llm/object.rb +65 -10
  18. data/lib/llm/provider.rb +11 -3
  19. data/lib/llm/providers/anthropic/error_handler.rb +1 -1
  20. data/lib/llm/providers/anthropic/files.rb +4 -5
  21. data/lib/llm/providers/anthropic/models.rb +1 -2
  22. data/lib/llm/providers/anthropic/{format/completion_format.rb → request_adapter/completion.rb} +19 -19
  23. data/lib/llm/providers/anthropic/{format.rb → request_adapter.rb} +7 -7
  24. data/lib/llm/providers/anthropic/response_adapter/completion.rb +72 -0
  25. data/lib/llm/providers/anthropic/{response → response_adapter}/enumerable.rb +1 -1
  26. data/lib/llm/providers/anthropic/{response → response_adapter}/file.rb +1 -1
  27. data/lib/llm/providers/anthropic/{response → response_adapter}/web_search.rb +3 -3
  28. data/lib/llm/providers/anthropic/response_adapter.rb +36 -0
  29. data/lib/llm/providers/anthropic/stream_parser.rb +25 -11
  30. data/lib/llm/providers/anthropic.rb +26 -19
  31. data/lib/llm/providers/deepseek/{format/completion_format.rb → request_adapter/completion.rb} +15 -15
  32. data/lib/llm/providers/deepseek/{format.rb → request_adapter.rb} +7 -7
  33. data/lib/llm/providers/deepseek.rb +2 -2
  34. data/lib/llm/providers/gemini/audio.rb +2 -2
  35. data/lib/llm/providers/gemini/error_handler.rb +3 -3
  36. data/lib/llm/providers/gemini/files.rb +4 -7
  37. data/lib/llm/providers/gemini/images.rb +9 -14
  38. data/lib/llm/providers/gemini/models.rb +1 -2
  39. data/lib/llm/providers/gemini/{format/completion_format.rb → request_adapter/completion.rb} +14 -14
  40. data/lib/llm/providers/gemini/{format.rb → request_adapter.rb} +8 -8
  41. data/lib/llm/providers/gemini/response_adapter/completion.rb +73 -0
  42. data/lib/llm/providers/gemini/{response → response_adapter}/embedding.rb +1 -1
  43. data/lib/llm/providers/gemini/{response → response_adapter}/file.rb +1 -1
  44. data/lib/llm/providers/gemini/{response → response_adapter}/files.rb +1 -1
  45. data/lib/llm/providers/gemini/{response → response_adapter}/image.rb +3 -3
  46. data/lib/llm/providers/gemini/{response → response_adapter}/models.rb +1 -1
  47. data/lib/llm/providers/gemini/{response → response_adapter}/web_search.rb +3 -3
  48. data/lib/llm/providers/gemini/response_adapter.rb +42 -0
  49. data/lib/llm/providers/gemini/stream_parser.rb +41 -32
  50. data/lib/llm/providers/gemini.rb +30 -24
  51. data/lib/llm/providers/ollama/error_handler.rb +1 -1
  52. data/lib/llm/providers/ollama/{format/completion_format.rb → request_adapter/completion.rb} +19 -19
  53. data/lib/llm/providers/ollama/{format.rb → request_adapter.rb} +7 -7
  54. data/lib/llm/providers/ollama/response_adapter/completion.rb +67 -0
  55. data/lib/llm/providers/ollama/{response → response_adapter}/embedding.rb +1 -1
  56. data/lib/llm/providers/ollama/response_adapter.rb +32 -0
  57. data/lib/llm/providers/ollama/stream_parser.rb +2 -2
  58. data/lib/llm/providers/ollama.rb +26 -18
  59. data/lib/llm/providers/openai/audio.rb +1 -1
  60. data/lib/llm/providers/openai/error_handler.rb +12 -2
  61. data/lib/llm/providers/openai/files.rb +3 -6
  62. data/lib/llm/providers/openai/images.rb +4 -5
  63. data/lib/llm/providers/openai/models.rb +1 -3
  64. data/lib/llm/providers/openai/moderations.rb +3 -5
  65. data/lib/llm/providers/openai/{format/completion_format.rb → request_adapter/completion.rb} +22 -22
  66. data/lib/llm/providers/openai/{format/moderation_format.rb → request_adapter/moderation.rb} +5 -5
  67. data/lib/llm/providers/openai/{format/respond_format.rb → request_adapter/respond.rb} +16 -16
  68. data/lib/llm/providers/openai/{format.rb → request_adapter.rb} +12 -12
  69. data/lib/llm/providers/openai/{response → response_adapter}/audio.rb +1 -1
  70. data/lib/llm/providers/openai/response_adapter/completion.rb +71 -0
  71. data/lib/llm/providers/openai/{response → response_adapter}/embedding.rb +1 -1
  72. data/lib/llm/providers/openai/{response → response_adapter}/enumerable.rb +1 -1
  73. data/lib/llm/providers/openai/{response → response_adapter}/file.rb +1 -1
  74. data/lib/llm/providers/openai/{response → response_adapter}/image.rb +1 -1
  75. data/lib/llm/providers/openai/{response → response_adapter}/moderations.rb +1 -1
  76. data/lib/llm/providers/openai/{response → response_adapter}/responds.rb +6 -10
  77. data/lib/llm/providers/openai/{response → response_adapter}/web_search.rb +3 -3
  78. data/lib/llm/providers/openai/response_adapter.rb +47 -0
  79. data/lib/llm/providers/openai/responses/stream_parser.rb +22 -22
  80. data/lib/llm/providers/openai/responses.rb +6 -8
  81. data/lib/llm/providers/openai/stream_parser.rb +10 -7
  82. data/lib/llm/providers/openai/vector_stores.rb +8 -9
  83. data/lib/llm/providers/openai.rb +33 -23
  84. data/lib/llm/response.rb +14 -7
  85. data/lib/llm/usage.rb +11 -0
  86. data/lib/llm/version.rb +1 -1
  87. data/lib/llm.rb +36 -1
  88. metadata +44 -35
  89. data/lib/llm/providers/anthropic/response/completion.rb +0 -39
  90. data/lib/llm/providers/gemini/response/completion.rb +0 -35
  91. data/lib/llm/providers/ollama/response/completion.rb +0 -28
  92. data/lib/llm/providers/openai/response/completion.rb +0 -40
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8578f727c5a45b243d86f498cf1a3fcc594981f4056234d2376805744b7e7633
4
- data.tar.gz: 03aefaa4ebdf15200e0d6999a8b7015e101dcc0d9afff4c205672a6fa94532c4
3
+ metadata.gz: fa682f0c6793298daeaac88092cb52f03652cbbbf28adfd6b62f94b8a263f3f3
4
+ data.tar.gz: 1fb08983372becef70d866bdc4ee79ee8d8bba55ace5d4be4637a69e91341747
5
5
  SHA512:
6
- metadata.gz: 1b969f525f44192999bcb3ea45aec1e53283d6bb4347b6852bc0bfe9095ecf4d9a9d21a42f7b04e6c530329bf96deb0fa0508757b9e0c185b8944d1630da1648
7
- data.tar.gz: 239ff739c3f9bfdfbe8f574a1045acac9ace3bbb0ddca3a92958fee9319e4834c2f057a61c9b0c418979111050ade4968a01ec73862f70278b75cbe7752c9815
6
+ metadata.gz: 720e09be8b25a9fde7d92887636d572edcdbd39a1b3a23ae1f44baaddb9f881c95927f63f16248f3a3d22da1704973f69f51309c487e1c97195175b772499b0d
7
+ data.tar.gz: 8cf35f7829b4e66ef002652643779658cf9c8cf8726f8b563eb5ca59ebcfc3a71eeb9b4cc473dfc4556324448855b6733fe3d48a73fb6e70fb91102544eb7061
data/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ > **Minimal footprint** <br>
2
+ > Zero dependencies outside Ruby’s standard library. <br>
3
+ > Zero runtime dependencies.
4
+
1
5
  ## About
2
6
 
3
7
  llm.rb is a zero-dependency Ruby toolkit for Large Language Models that
@@ -15,32 +19,38 @@ A simple chatbot that maintains a conversation and streams responses in real-tim
15
19
  #!/usr/bin/env ruby
16
20
  require "llm"
17
21
 
18
- llm = LLM.openai(key: ENV["KEY"])
22
+ llm = LLM.openai(key: ENV.fetch("KEY"))
19
23
  bot = LLM::Bot.new(llm, stream: $stdout)
20
24
  loop do
21
25
  print "> "
22
- bot.chat(gets)
23
- print "\n"
26
+ bot.chat(STDIN.gets)
27
+ puts
24
28
  end
25
29
  ```
26
30
 
27
31
  #### Prompts
28
32
 
33
+ > ℹ️ **Tip:** Some providers (such as OpenAI) support `system` and `developer`
34
+ > roles, but the examples in this README stick to `user` roles since they are
35
+ > supported across all providers.
36
+
29
37
  A prompt builder that produces a chain of messages that can be sent in one request:
30
38
 
31
39
  ```ruby
32
40
  #!/usr/bin/env ruby
33
41
  require "llm"
34
42
 
35
- llm = LLM.openai(key: ENV["KEY"])
43
+ llm = LLM.openai(key: ENV.fetch("KEY"))
36
44
  bot = LLM::Bot.new(llm)
45
+
37
46
  prompt = bot.build_prompt do
38
- it.system "Your task is to answer all user queries"
47
+ it.user "Answer concisely."
39
48
  it.user "Was 2024 a leap year?"
40
- it.user "How many days in a year?"
49
+ it.user "How many days were in that year?"
41
50
  end
42
- bot.chat(prompt)
43
- bot.messages.each { print "[#{it.role}] ", it.content, "\n" }
51
+
52
+ res = bot.chat(prompt)
53
+ res.choices.each { |m| puts "[#{m.role}] #{m.content}" }
44
54
  ```
45
55
 
46
56
  #### Schema
@@ -52,20 +62,20 @@ A bot that instructs the LLM to respond in JSON, and according to the given sche
52
62
  require "llm"
53
63
 
54
64
  class Estimation < LLM::Schema
55
- property :age, Integer, "The age of a person in a photo", required: true
56
- property :confidence, Number, "Model confidence (0.0 to 1.0)", required: true
57
- property :notes, String, "Model notes or caveats", optional: true
65
+ property :age, Integer, "Estimated age", required: true
66
+ property :confidence, Number, "0.01.0", required: true
67
+ property :notes, String, "Short notes", optional: true
58
68
  end
59
69
 
60
- llm = LLM.openai(key: ENV["KEY"])
70
+ llm = LLM.openai(key: ENV.fetch("KEY"))
61
71
  bot = LLM::Bot.new(llm, schema: Estimation)
62
72
  img = llm.images.create(prompt: "A man in his 30s")
63
- res = bot.chat bot.image_url(img.urls[0])
64
- estimation = res.choices.find(&:assistant?).content!
73
+ res = bot.chat bot.image_url(img.urls.first)
74
+ data = res.choices.find(&:assistant?).content!
65
75
 
66
- puts "age: #{estimation["age"]}"
67
- puts "confidence: #{estimation["confidence"]}"
68
- puts "notes: #{estimation["notes"]}"
76
+ puts "age: #{data["age"]}"
77
+ puts "confidence: #{data["confidence"]}"
78
+ puts "notes: #{data["notes"]}" if data["notes"]
69
79
  ```
70
80
 
71
81
  #### Tools
@@ -79,58 +89,57 @@ require "llm"
79
89
  class System < LLM::Tool
80
90
  name "system"
81
91
  description "Run a shell command"
82
- param :command, String, "The command to execute", required: true
92
+ param :command, String, "Command to execute", required: true
83
93
 
84
94
  def call(command:)
85
95
  {success: system(command)}
86
96
  end
87
97
  end
88
98
 
89
- llm = LLM.openai(key: ENV["KEY"])
99
+ llm = LLM.openai(key: ENV.fetch("KEY"))
90
100
  bot = LLM::Bot.new(llm, tools: [System])
101
+
91
102
  prompt = bot.build_prompt do
92
- it.system "Your task is to execute system commands"
93
- it.user "mkdir /home/robert/projects"
103
+ it.user "You can run safe shell commands."
104
+ it.user "Run `date`."
94
105
  end
106
+
95
107
  bot.chat(prompt)
96
- bot.chat bot.functions.map(&:call)
97
- bot.messages.select(&:assistant?).each { print "[#{it.role}] ", it.content, "\n" }
108
+ bot.chat(bot.functions.map(&:call))
109
+ bot.messages.select(&:assistant?).each { |m| puts "[#{m.role}] #{m.content}" }
98
110
  ```
99
111
 
100
112
  ## Features
101
113
 
102
114
  #### General
103
- - ✅ A single unified interface for multiple providers
104
- - 📦 Zero dependencies outside Ruby's standard library
105
- - 🚀 Simple, composable API
106
- - ♻️ Optional: per-provider, process-wide connection pool via net-http-persistent
115
+ - ✅ Unified API across providers
116
+ - 📦 Zero runtime deps (stdlib-only)
117
+ - 🧩 Pluggable JSON adapters (JSON, Oj, Yajl, etc)
118
+ - ♻️ Optional persistent HTTP pool (net-http-persistent)
107
119
 
108
120
  #### Chat, Agents
109
- - 🧠 Stateless and stateful chat via completions and responses API
110
- - 🤖 Tool calling and function execution
111
- - 🗂️ JSON Schema support for structured, validated responses
112
- - 📡 Streaming support for real-time response updates
121
+ - 🧠 Stateless + stateful chat (completions + responses)
122
+ - 🤖 Tool calling / function execution
123
+ - 🗂️ JSON Schema structured output
124
+ - 📡 Streaming responses
113
125
 
114
126
  #### Media
115
- - 🗣️ Text-to-speech, transcription, and translation
116
- - 🖼️ Image generation, editing, and variation support
117
- - 📎 File uploads and prompt-aware file interaction
118
- - 💡 Multimodal prompts (text, documents, audio, images, videos, URLs, etc)
127
+ - 🗣️ TTS, transcription, translation
128
+ - 🖼️ Image generation + editing
129
+ - 📎 Files API + prompt-aware file inputs
130
+ - 📦 Streaming multipart uploads (no full buffering)
131
+ - 💡 Multimodal prompts (text, documents, audio, images, video, URLs)
119
132
 
120
133
  #### Embeddings
121
- - 🧮 Text embeddings and vector support
122
- - 🧱 Includes support for OpenAI's vector stores API
134
+ - 🧮 Embeddings
135
+ - 🧱 OpenAI vector stores (RAG)
123
136
 
124
137
  #### Miscellaneous
125
- - 📜 Model management and selection
126
- - 🔧 Includes support for OpenAI's responses, moderations, and vector stores APIs
138
+ - 📜 Models API
139
+ - 🔧 OpenAI responses + moderations
127
140
 
128
141
  ## Matrix
129
142
 
130
- While the Features section above gives you the high-level picture, the table below
131
- breaks things down by provider, so you can see exactly what’s supported where.
132
-
133
-
134
143
  | Feature / Provider | OpenAI | Anthropic | Gemini | DeepSeek | xAI (Grok) | zAI | Ollama | LlamaCpp |
135
144
  |--------------------------------------|:------:|:---------:|:------:|:--------:|:----------:|:------:|:------:|:--------:|
136
145
  | **Chat Completions** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -181,6 +190,27 @@ llm = LLM.ollama(key: nil)
181
190
  llm = LLM.llamacpp(key: nil)
182
191
  ```
183
192
 
193
+ #### LLM::Response
194
+
195
+ All provider methods that perform requests return an
196
+ [LLM::Response](https://0x1eef.github.io/x/llm.rb/LLM/Response.html).
197
+ If the HTTP response is JSON (`content-type: application/json`),
198
+ `response.body` is parsed into an
199
+ [LLM::Object](https://0x1eef.github.io/x/llm.rb/LLM/Object.html) for
200
+ dot-access. For non-JSON responses, `response.body` is a raw string.
201
+ It is also possible to access top-level keys directly on the response
202
+ (eg: `res.object` instead of `res.body.object`):
203
+
204
+ ```ruby
205
+ #!/usr/bin/env ruby
206
+ require "llm"
207
+
208
+ llm = LLM.openai(key: ENV["KEY"])
209
+ res = llm.models.all
210
+ puts res.object
211
+ puts res.data.first.id
212
+ ```
213
+
184
214
  #### Persistence
185
215
 
186
216
  The llm.rb library can maintain a process-wide connection pool
@@ -197,7 +227,7 @@ llm = LLM.openai(key: ENV["KEY"], persistent: true)
197
227
  res1 = llm.responses.create "message 1"
198
228
  res2 = llm.responses.create "message 2", previous_response_id: res1.response_id
199
229
  res3 = llm.responses.create "message 3", previous_response_id: res2.response_id
200
- print res3.output_text, "\n"
230
+ puts res3.output_text
201
231
  ```
202
232
 
203
233
  #### Thread Safety
@@ -230,15 +260,17 @@ require "llm"
230
260
 
231
261
  llm = LLM.openai(key: ENV["KEY"])
232
262
  bot = LLM::Bot.new(llm)
233
- url = "https://upload.wikimedia.org/wikipedia/commons/c/c7/Lisc_lipy.jpg"
263
+ image_url = "https://upload.wikimedia.org/wikipedia/commons/9/97/The_Earth_seen_from_Apollo_17.jpg"
264
+ image_path = "/tmp/llm-logo.png"
265
+ pdf_path = "/tmp/llm-handbook.pdf"
234
266
 
235
267
  prompt = bot.build_prompt do
236
- it.system "Your task is to answer all user queries"
237
- it.user ["Tell me about this URL", bot.image_url(url)]
238
- it.user ["Tell me about this PDF", bot.local_file("handbook.pdf")]
268
+ it.user ["Tell me about this image", bot.image_url(image_url)]
269
+ it.user ["Tell me about this image", bot.local_file(image_path)]
270
+ it.user ["Tell me about this PDF", bot.local_file(pdf_path)]
239
271
  end
240
272
  bot.chat(prompt)
241
- bot.messages.each { print "[#{it.role}] ", it.content, "\n" }
273
+ bot.messages.each { |m| puts "[#{m.role}] #{m.content}" }
242
274
  ```
243
275
 
244
276
  #### Streaming
@@ -256,20 +288,20 @@ require "llm"
256
288
 
257
289
  llm = LLM.openai(key: ENV["KEY"])
258
290
  bot = LLM::Bot.new(llm, stream: $stdout)
259
- url = "https://upload.wikimedia.org/wikipedia/commons/c/c7/Lisc_lipy.jpg"
291
+ image_url = "https://upload.wikimedia.org/wikipedia/commons/9/97/The_Earth_seen_from_Apollo_17.jpg"
292
+ image_path = "/tmp/llm-logo.png"
293
+ pdf_path = "/tmp/llm-handbook.pdf"
260
294
 
261
295
  prompt = bot.build_prompt do
262
- it.system "Your task is to answer all user queries"
263
- it.user ["Tell me about this URL", bot.image_url(url)]
264
- it.user ["Tell me about the PDF", bot.local_file("handbook.pdf")]
296
+ it.user ["Tell me about this image", bot.image_url(image_url)]
297
+ it.user ["Tell me about this image", bot.local_file(image_path)]
298
+ it.user ["Tell me about the PDF", bot.local_file(pdf_path)]
265
299
  end
266
300
  bot.chat(prompt)
267
301
  ```
268
302
 
269
303
  ### Schema
270
304
 
271
- #### Object
272
-
273
305
  All LLM providers except Anthropic and DeepSeek allow a client to describe
274
306
  the structure of a response that a LLM emits according to a schema that is
275
307
  described by JSON. The schema lets a client describe what JSON object
@@ -280,56 +312,21 @@ its ability:
280
312
  #!/usr/bin/env ruby
281
313
  require "llm"
282
314
 
283
- llm = LLM.openai(key: ENV["KEY"])
284
-
285
- ##
286
- # Objects
287
- schema = llm.schema.object(probability: llm.schema.number.required)
288
- bot = LLM::Bot.new(llm, schema:)
289
- bot.chat "Does the earth orbit the sun?", role: :user
290
- puts bot.messages.find(&:assistant?).content! # => {probability: 1.0}
291
-
292
- ##
293
- # Enums
294
- schema = llm.schema.object(fruit: llm.schema.string.enum("Apple", "Orange", "Pineapple"))
295
- bot = LLM::Bot.new(llm, schema:) :system
296
- bot.chat "What fruit is your favorite?", role: :user
297
- puts bot.messages.find(&:assistant?).content! # => {fruit: "Pineapple"}
298
-
299
- ##
300
- # Arrays
301
- schema = llm.schema.object(answers: llm.schema.array(llm.schema.integer.required))
302
- bot = LLM::Bot.new(llm, schema:)
303
- bot.chat "Tell me the answer to ((5 + 5) / 2) * 2 + 1", role: :user
304
- puts bot.messages.find(&:assistant?).content! # => {answers: [11]}
305
- ```
306
-
307
- #### Class
308
-
309
- Other than the object form we saw in the previous example, a class form
310
- is also supported. Under the hood, it is implemented with the object form
311
- and the class form primarily exists to provide structure and organization
312
- that the object form lacks:
313
-
314
- ```ruby
315
- #!/usr/bin/env ruby
316
- require "llm"
317
-
318
315
  class Player < LLM::Schema
319
316
  property :name, String, "The player's name", required: true
320
- property :numbers, Array[Integer], "The player's favorite numbers", required: true
317
+ property :position, Array[Number], "The player's [x, y] position", required: true
321
318
  end
322
319
 
323
320
  llm = LLM.openai(key: ENV["KEY"])
324
321
  bot = LLM::Bot.new(llm, schema: Player)
325
322
  prompt = bot.build_prompt do
326
- it.system "The user's name is Robert and their favorite numbers are 7 and 12"
327
- it.user "Tell me about myself"
323
+ it.user "The player's name is Sam and their position is (7, 12)."
324
+ it.user "Return the player's name and position"
328
325
  end
329
326
 
330
327
  player = bot.chat(prompt).content!
331
- puts "name: #{player.name}"
332
- puts "numbers: #{player.numbers}"
328
+ puts "name: #{player['name']}"
329
+ puts "position: #{player['position'].join(', ')}"
333
330
  ```
334
331
 
335
332
  ### Tools
@@ -377,7 +374,7 @@ tool = LLM.function(:system) do |fn|
377
374
  end
378
375
 
379
376
  bot = LLM::Bot.new(llm, tools: [tool])
380
- bot.chat "Your task is to run shell commands via a tool.", role: :system
377
+ bot.chat "Your task is to run shell commands via a tool.", role: :user
381
378
 
382
379
  bot.chat "What is the current date?", role: :user
383
380
  bot.chat bot.functions.map(&:call) # report return value to the LLM
@@ -424,7 +421,7 @@ end
424
421
 
425
422
  llm = LLM.openai(key: ENV["KEY"])
426
423
  bot = LLM::Bot.new(llm, tools: [System])
427
- bot.chat "Your task is to run shell commands via a tool.", role: :system
424
+ bot.chat "Your task is to run shell commands via a tool.", role: :user
428
425
 
429
426
  bot.chat "What is the current date?", role: :user
430
427
  bot.chat bot.functions.map(&:call) # report return value to the LLM
@@ -454,9 +451,9 @@ require "llm"
454
451
 
455
452
  llm = LLM.openai(key: ENV["KEY"])
456
453
  bot = LLM::Bot.new(llm)
457
- file = llm.files.create(file: "/book.pdf")
454
+ file = llm.files.create(file: "/tmp/llm-book.pdf")
458
455
  res = bot.chat ["Tell me about this file", file]
459
- res.choices.each { print "[#{it.role}] ", it.content, "\n" }
456
+ res.choices.each { |m| puts "[#{m.role}] #{m.content}" }
460
457
  ```
461
458
 
462
459
  ### Prompts
@@ -487,17 +484,19 @@ require "llm"
487
484
 
488
485
  llm = LLM.openai(key: ENV["KEY"])
489
486
  bot = LLM::Bot.new(llm)
490
- url = "https://upload.wikimedia.org/wikipedia/commons/c/c7/Lisc_lipy.jpg"
487
+ image_url = "https://upload.wikimedia.org/wikipedia/commons/9/97/The_Earth_seen_from_Apollo_17.jpg"
488
+ image_path = "/tmp/llm-logo.png"
489
+ pdf_path = "/tmp/llm-book.pdf"
491
490
 
492
- res1 = bot.chat ["Tell me about this URL", bot.image_url(url)]
493
- res1.choices.each { print "[#{it.role}] ", it.content, "\n" }
491
+ res1 = bot.chat ["Tell me about this image URL", bot.image_url(image_url)]
492
+ res1.choices.each { |m| puts "[#{m.role}] #{m.content}" }
494
493
 
495
- file = llm.files.create(file: "/book.pdf")
494
+ file = llm.files.create(file: pdf_path)
496
495
  res2 = bot.chat ["Tell me about this PDF", bot.remote_file(file)]
497
- res2.choices.each { print "[#{it.role}] ", it.content, "\n" }
496
+ res2.choices.each { |m| puts "[#{m.role}] #{m.content}" }
498
497
 
499
- res3 = bot.chat ["Tell me about this image", bot.local_file("/puffy.png")]
500
- res3.choices.each { print "[#{it.role}] ", it.content, "\n" }
498
+ res3 = bot.chat ["Tell me about this image", bot.local_file(image_path)]
499
+ res3.choices.each { |m| puts "[#{m.role}] #{m.content}" }
501
500
  ```
502
501
 
503
502
  ### Audio
@@ -534,7 +533,7 @@ llm = LLM.openai(key: ENV["KEY"])
534
533
  res = llm.audio.create_transcription(
535
534
  file: File.join(Dir.home, "hello.mp3")
536
535
  )
537
- print res.text, "\n" # => "Hello world."
536
+ puts res.text # => "Hello world."
538
537
  ```
539
538
 
540
539
  #### Translate
@@ -552,7 +551,7 @@ llm = LLM.openai(key: ENV["KEY"])
552
551
  res = llm.audio.create_translation(
553
552
  file: File.join(Dir.home, "bomdia.mp3")
554
553
  )
555
- print res.text, "\n" # => "Good morning."
554
+ puts res.text # => "Good morning."
556
555
  ```
557
556
 
558
557
  ### Images
@@ -582,8 +581,8 @@ end
582
581
  #### Edit
583
582
 
584
583
  The following example is focused on editing a local image with the aid
585
- of a prompt. The image (`/images/cat.png`) is returned to us with the cat
586
- now wearing a hat. The image is then moved to `${HOME}/catwithhat.png` as
584
+ of a prompt. The image (`/tmp/llm-logo.png`) is returned to us with a hat.
585
+ The image is then moved to `${HOME}/logo-with-hat.png` as
587
586
  the final step:
588
587
 
589
588
  ```ruby
@@ -594,20 +593,20 @@ require "fileutils"
594
593
 
595
594
  llm = LLM.openai(key: ENV["KEY"])
596
595
  res = llm.images.edit(
597
- image: "/images/cat.png",
598
- prompt: "a cat with a hat",
596
+ image: "/tmp/llm-logo.png",
597
+ prompt: "add a hat to the logo",
599
598
  )
600
599
  res.urls.each do |url|
601
600
  FileUtils.mv OpenURI.open_uri(url).path,
602
- File.join(Dir.home, "catwithhat.png")
601
+ File.join(Dir.home, "logo-with-hat.png")
603
602
  end
604
603
  ```
605
604
 
606
605
  #### Variations
607
606
 
608
607
  The following example is focused on creating variations of a local image.
609
- The image (`/images/cat.png`) is returned to us with five different variations.
610
- The images are then moved to `${HOME}/catvariation0.png`, `${HOME}/catvariation1.png`
608
+ The image (`/tmp/llm-logo.png`) is returned to us with five different variations.
609
+ The images are then moved to `${HOME}/logo-variation0.png`, `${HOME}/logo-variation1.png`
611
610
  and so on as the final step:
612
611
 
613
612
  ```ruby
@@ -618,12 +617,12 @@ require "fileutils"
618
617
 
619
618
  llm = LLM.openai(key: ENV["KEY"])
620
619
  res = llm.images.create_variation(
621
- image: "/images/cat.png",
620
+ image: "/tmp/llm-logo.png",
622
621
  n: 5
623
622
  )
624
623
  res.urls.each.with_index do |url, index|
625
624
  FileUtils.mv OpenURI.open_uri(url).path,
626
- File.join(Dir.home, "catvariation#{index}.png")
625
+ File.join(Dir.home, "logo-variation#{index}.png")
627
626
  end
628
627
  ```
629
628
 
@@ -633,13 +632,8 @@ end
633
632
 
634
633
  The
635
634
  [`LLM::Provider#embed`](https://0x1eef.github.io/x/llm.rb/LLM/Provider.html#embed-instance_method)
636
- method generates a vector representation of one or more chunks
637
- of text. Embeddings capture the semantic meaning of text &ndash;
638
- a common use-case for them is to store chunks of text in a
639
- vector database, and then to query the database for *semantically
640
- similar* text. These chunks of similar text can then support the
641
- generation of a prompt that is used to query a large language model,
642
- which will go on to generate a response:
635
+ method returns vector embeddings for one or more text inputs. A common
636
+ use is semantic search (store vectors, then query for similar text):
643
637
 
644
638
  ```ruby
645
639
  #!/usr/bin/env ruby
@@ -647,9 +641,9 @@ require "llm"
647
641
 
648
642
  llm = LLM.openai(key: ENV["KEY"])
649
643
  res = llm.embed(["programming is fun", "ruby is a programming language", "sushi is art"])
650
- print res.class, "\n"
651
- print res.embeddings.size, "\n"
652
- print res.embeddings[0].size, "\n"
644
+ puts res.class
645
+ puts res.embeddings.size
646
+ puts res.embeddings[0].size
653
647
 
654
648
  ##
655
649
  # LLM::Response
@@ -663,10 +657,8 @@ print res.embeddings[0].size, "\n"
663
657
 
664
658
  Almost all LLM providers provide a models endpoint that allows a client to
665
659
  query the list of models that are available to use. The list is dynamic,
666
- maintained by LLM providers, and it is independent of a specific llm.rb release.
667
- [LLM::Model](https://0x1eef.github.io/x/llm.rb/LLM/Model.html)
668
- objects can be used instead of a string that describes a model name (although
669
- either works). Let's take a look at an example:
660
+ maintained by LLM providers, and it is independent of a specific llm.rb
661
+ release:
670
662
 
671
663
  ```ruby
672
664
  #!/usr/bin/env ruby
@@ -676,7 +668,7 @@ require "llm"
676
668
  # List all models
677
669
  llm = LLM.openai(key: ENV["KEY"])
678
670
  llm.models.all.each do |model|
679
- print "model: ", model.id, "\n"
671
+ puts "model: #{model.id}"
680
672
  end
681
673
 
682
674
  ##
@@ -684,7 +676,7 @@ end
684
676
  model = llm.models.all.find { |m| m.id == "gpt-3.5-turbo" }
685
677
  bot = LLM::Bot.new(llm, model: model.id)
686
678
  res = bot.chat "Hello #{model.id} :)"
687
- res.choices.each { print "[#{it.role}] ", it.content, "\n" }
679
+ res.choices.each { |m| puts "[#{m.role}] #{m.content}" }
688
680
  ```
689
681
 
690
682
  ## Install
data/lib/llm/bot.rb CHANGED
@@ -119,7 +119,7 @@ module LLM
119
119
  # if there are no assistant messages
120
120
  # @return [LLM::Object]
121
121
  def usage
122
- @messages.find(&:assistant?)&.usage || LLM::Object.from_hash({})
122
+ @messages.find(&:assistant?)&.usage || LLM::Object.from({})
123
123
  end
124
124
 
125
125
  ##
@@ -141,7 +141,7 @@ module LLM
141
141
  # @return [LLM::Object]
142
142
  # Returns a tagged object
143
143
  def image_url(url)
144
- LLM::Object.from_hash(value: url, kind: :image_url)
144
+ LLM::Object.from(value: url, kind: :image_url)
145
145
  end
146
146
 
147
147
  ##
@@ -151,7 +151,7 @@ module LLM
151
151
  # @return [LLM::Object]
152
152
  # Returns a tagged object
153
153
  def local_file(path)
154
- LLM::Object.from_hash(value: LLM.File(path), kind: :local_file)
154
+ LLM::Object.from(value: LLM.File(path), kind: :local_file)
155
155
  end
156
156
 
157
157
  ##
@@ -161,7 +161,7 @@ module LLM
161
161
  # @return [LLM::Object]
162
162
  # Returns a tagged object
163
163
  def remote_file(res)
164
- LLM::Object.from_hash(value: res, kind: :remote_file)
164
+ LLM::Object.from(value: res, kind: :remote_file)
165
165
  end
166
166
 
167
167
  private
data/lib/llm/buffer.rb CHANGED
@@ -35,15 +35,6 @@ module LLM
35
35
  end
36
36
  end
37
37
 
38
- ##
39
- # Returns an array of unread messages
40
- # @see LLM::Message#read?
41
- # @see LLM::Message#read!
42
- # @return [Array<LLM::Message>]
43
- def unread
44
- reject(&:read?)
45
- end
46
-
47
38
  ##
48
39
  # Find a message (in descending order)
49
40
  # @return [LLM::Message, nil]
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Contract
4
+ ##
5
+ # Defines the interface all completion responses must implement
6
+ # @abstract
7
+ module Completion
8
+ extend LLM::Contract
9
+
10
+ ##
11
+ # @return [Array<LLM::Messsage>]
12
+ # Returns one or more messages
13
+ def messages
14
+ raise NotImplementedError, "#{self.class} does not implement '#{__method__}'"
15
+ end
16
+ alias_method :choices, :messages
17
+
18
+ ##
19
+ # @return [Integer]
20
+ # Returns the number of input tokens
21
+ def input_tokens
22
+ raise NotImplementedError, "#{self.class} does not implement '#{__method__}'"
23
+ end
24
+
25
+ ##
26
+ # @return [Integer]
27
+ # Returns the number of output tokens
28
+ def output_tokens
29
+ raise NotImplementedError, "#{self.class} does not implement '#{__method__}'"
30
+ end
31
+
32
+ ##
33
+ # @return [Integer]
34
+ # Returns the number of reasoning tokens
35
+ def reasoning_tokens
36
+ raise NotImplementedError, "#{self.class} does not implement '#{__method__}'"
37
+ end
38
+
39
+ ##
40
+ # @return [Integer]
41
+ # Returns the total number of tokens
42
+ def total_tokens
43
+ raise NotImplementedError, "#{self.class} does not implement '#{__method__}'"
44
+ end
45
+
46
+ ##
47
+ # @return [LLM::Usage]
48
+ # Returns usage information
49
+ def usage
50
+ LLM::Usage.new(
51
+ input_tokens:,
52
+ output_tokens:,
53
+ reasoning_tokens:,
54
+ total_tokens:
55
+ )
56
+ end
57
+
58
+ ##
59
+ # @return [String]
60
+ # Returns the model name
61
+ def model
62
+ raise NotImplementedError, "#{self.class} does not implement '#{__method__}'"
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ ##
5
+ # The `LLM::Contract` module provides the ability for modules
6
+ # who are extended by it to implement contracts which must be
7
+ # implemented by other modules who include a given contract.
8
+ #
9
+ # @example
10
+ # module LLM::Contract
11
+ # # ..
12
+ # end
13
+ #
14
+ # module LLM::Contract
15
+ # module Completion
16
+ # extend LLM::Contract
17
+ # # inheriting modules must implement these methods
18
+ # # otherwise an error is raised on include
19
+ # def foo = nil
20
+ # def bar = nil
21
+ # end
22
+ # end
23
+ #
24
+ # module LLM::OpenAI::ResponseAdapter
25
+ # module Completion
26
+ # def foo = nil
27
+ # def bar = nil
28
+ # include LLM::Contract::Completion
29
+ # end
30
+ # end
31
+ module Contract
32
+ ContractError = Class.new(LLM::Error)
33
+ require_relative "contract/completion"
34
+
35
+ ##
36
+ # @api private
37
+ def included(mod)
38
+ meths = mod.instance_methods(false)
39
+ if meths.empty?
40
+ raise ContractError, "#{mod} does not implement any methods required by #{self}"
41
+ end
42
+ missing = instance_methods - meths
43
+ if missing.any?
44
+ raise ContractError, "#{mod} does not implement methods (#{missing.join(", ")}) required by #{self}"
45
+ end
46
+ end
47
+ end
48
+ end