llm.rb 3.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e60be1fa699baabf9a1df129d0263d0c2b6fecc3ce2b818128eed319aa7bb18
4
- data.tar.gz: a39d32cd9cfcfb7fa4152ba3e02960522b6ae4b5d6e22705b846f5b9dcd2972b
3
+ metadata.gz: fa682f0c6793298daeaac88092cb52f03652cbbbf28adfd6b62f94b8a263f3f3
4
+ data.tar.gz: 1fb08983372becef70d866bdc4ee79ee8d8bba55ace5d4be4637a69e91341747
5
5
  SHA512:
6
- metadata.gz: 4d434afe1a6acaeef6036178c5914500e047450ee7d92999dedd80f66a6be22c73f4d788f86739842ee283c579b68476c0a114f50e25e7db7bbfa6e6cdf1a5bc
7
- data.tar.gz: e818753b0b06cf4053652d11b2e3c79d7ef58de0b0acc5fb7fa147e55c693eee583f1c9e1f80b08096de95247c03644fad9e596f4511d57a99d2abd5339087c2
6
+ metadata.gz: 720e09be8b25a9fde7d92887636d572edcdbd39a1b3a23ae1f44baaddb9f881c95927f63f16248f3a3d22da1704973f69f51309c487e1c97195175b772499b0d
7
+ data.tar.gz: 8cf35f7829b4e66ef002652643779658cf9c8cf8726f8b563eb5ca59ebcfc3a71eeb9b4cc473dfc4556324448855b6733fe3d48a73fb6e70fb91102544eb7061
data/README.md CHANGED
@@ -19,32 +19,38 @@ A simple chatbot that maintains a conversation and streams responses in real-tim
19
19
  #!/usr/bin/env ruby
20
20
  require "llm"
21
21
 
22
- llm = LLM.openai(key: ENV["KEY"])
22
+ llm = LLM.openai(key: ENV.fetch("KEY"))
23
23
  bot = LLM::Bot.new(llm, stream: $stdout)
24
24
  loop do
25
25
  print "> "
26
- bot.chat(gets)
27
- print "\n"
26
+ bot.chat(STDIN.gets)
27
+ puts
28
28
  end
29
29
  ```
30
30
 
31
31
  #### Prompts
32
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
+
33
37
  A prompt builder that produces a chain of messages that can be sent in one request:
34
38
 
35
39
  ```ruby
36
40
  #!/usr/bin/env ruby
37
41
  require "llm"
38
42
 
39
- llm = LLM.openai(key: ENV["KEY"])
43
+ llm = LLM.openai(key: ENV.fetch("KEY"))
40
44
  bot = LLM::Bot.new(llm)
45
+
41
46
  prompt = bot.build_prompt do
42
- it.system "Your task is to answer all user queries"
47
+ it.user "Answer concisely."
43
48
  it.user "Was 2024 a leap year?"
44
- it.user "How many days in a year?"
49
+ it.user "How many days were in that year?"
45
50
  end
46
- bot.chat(prompt)
47
- 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}" }
48
54
  ```
49
55
 
50
56
  #### Schema
@@ -56,20 +62,20 @@ A bot that instructs the LLM to respond in JSON, and according to the given sche
56
62
  require "llm"
57
63
 
58
64
  class Estimation < LLM::Schema
59
- property :age, Integer, "The age of a person in a photo", required: true
60
- property :confidence, Number, "Model confidence (0.0 to 1.0)", required: true
61
- 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
62
68
  end
63
69
 
64
- llm = LLM.openai(key: ENV["KEY"])
70
+ llm = LLM.openai(key: ENV.fetch("KEY"))
65
71
  bot = LLM::Bot.new(llm, schema: Estimation)
66
72
  img = llm.images.create(prompt: "A man in his 30s")
67
- res = bot.chat bot.image_url(img.urls[0])
68
- estimation = res.choices.find(&:assistant?).content!
73
+ res = bot.chat bot.image_url(img.urls.first)
74
+ data = res.choices.find(&:assistant?).content!
69
75
 
70
- puts "age: #{estimation["age"]}"
71
- puts "confidence: #{estimation["confidence"]}"
72
- puts "notes: #{estimation["notes"]}"
76
+ puts "age: #{data["age"]}"
77
+ puts "confidence: #{data["confidence"]}"
78
+ puts "notes: #{data["notes"]}" if data["notes"]
73
79
  ```
74
80
 
75
81
  #### Tools
@@ -83,60 +89,57 @@ require "llm"
83
89
  class System < LLM::Tool
84
90
  name "system"
85
91
  description "Run a shell command"
86
- param :command, String, "The command to execute", required: true
92
+ param :command, String, "Command to execute", required: true
87
93
 
88
94
  def call(command:)
89
95
  {success: system(command)}
90
96
  end
91
97
  end
92
98
 
93
- llm = LLM.openai(key: ENV["KEY"])
99
+ llm = LLM.openai(key: ENV.fetch("KEY"))
94
100
  bot = LLM::Bot.new(llm, tools: [System])
101
+
95
102
  prompt = bot.build_prompt do
96
- it.system "Your task is to execute system commands"
97
- it.user "mkdir /home/robert/projects"
103
+ it.user "You can run safe shell commands."
104
+ it.user "Run `date`."
98
105
  end
106
+
99
107
  bot.chat(prompt)
100
- bot.chat bot.functions.map(&:call)
101
- 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}" }
102
110
  ```
103
111
 
104
112
  ## Features
105
113
 
106
114
  #### General
107
- - ✅ A single unified interface for multiple providers
108
- - 📦 Zero dependencies outside Ruby's standard library
109
- - 🧩 Choose your own JSON parser (JSON stdlib, Oj, Yajl, etc)
110
- - 🚀 Simple, composable API
111
- - ♻️ 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)
112
119
 
113
120
  #### Chat, Agents
114
- - 🧠 Stateless and stateful chat via completions and responses API
115
- - 🤖 Tool calling and function execution
116
- - 🗂️ JSON Schema support for structured, validated responses
117
- - 📡 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
118
125
 
119
126
  #### Media
120
- - 🗣️ Text-to-speech, transcription, and translation
121
- - 🖼️ Image generation, editing, and variation support
122
- - 📎 File uploads and prompt-aware file interaction
123
- - 📦 Streams multipart uploads and avoids buffering large files in memory
124
- - 💡 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)
125
132
 
126
133
  #### Embeddings
127
- - 🧮 Text embeddings and vector support
128
- - 🧱 Includes support for OpenAI's vector stores API
134
+ - 🧮 Embeddings
135
+ - 🧱 OpenAI vector stores (RAG)
129
136
 
130
137
  #### Miscellaneous
131
- - 📜 Model management and selection
132
- - 🔧 Includes support for OpenAI's responses, moderations, and vector stores APIs
138
+ - 📜 Models API
139
+ - 🔧 OpenAI responses + moderations
133
140
 
134
141
  ## Matrix
135
142
 
136
- While the Features section above gives you the high-level picture, the table below
137
- breaks things down by provider, so you can see exactly what’s supported where.
138
-
139
-
140
143
  | Feature / Provider | OpenAI | Anthropic | Gemini | DeepSeek | xAI (Grok) | zAI | Ollama | LlamaCpp |
141
144
  |--------------------------------------|:------:|:---------:|:------:|:--------:|:----------:|:------:|:------:|:--------:|
142
145
  | **Chat Completions** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -187,6 +190,27 @@ llm = LLM.ollama(key: nil)
187
190
  llm = LLM.llamacpp(key: nil)
188
191
  ```
189
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
+
190
214
  #### Persistence
191
215
 
192
216
  The llm.rb library can maintain a process-wide connection pool
@@ -203,7 +227,7 @@ llm = LLM.openai(key: ENV["KEY"], persistent: true)
203
227
  res1 = llm.responses.create "message 1"
204
228
  res2 = llm.responses.create "message 2", previous_response_id: res1.response_id
205
229
  res3 = llm.responses.create "message 3", previous_response_id: res2.response_id
206
- print res3.output_text, "\n"
230
+ puts res3.output_text
207
231
  ```
208
232
 
209
233
  #### Thread Safety
@@ -236,15 +260,17 @@ require "llm"
236
260
 
237
261
  llm = LLM.openai(key: ENV["KEY"])
238
262
  bot = LLM::Bot.new(llm)
239
- 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"
240
266
 
241
267
  prompt = bot.build_prompt do
242
- it.system "Your task is to answer all user queries"
243
- it.user ["Tell me about this URL", bot.image_url(url)]
244
- 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)]
245
271
  end
246
272
  bot.chat(prompt)
247
- bot.messages.each { print "[#{it.role}] ", it.content, "\n" }
273
+ bot.messages.each { |m| puts "[#{m.role}] #{m.content}" }
248
274
  ```
249
275
 
250
276
  #### Streaming
@@ -262,20 +288,20 @@ require "llm"
262
288
 
263
289
  llm = LLM.openai(key: ENV["KEY"])
264
290
  bot = LLM::Bot.new(llm, stream: $stdout)
265
- 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"
266
294
 
267
295
  prompt = bot.build_prompt do
268
- it.system "Your task is to answer all user queries"
269
- it.user ["Tell me about this URL", bot.image_url(url)]
270
- 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)]
271
299
  end
272
300
  bot.chat(prompt)
273
301
  ```
274
302
 
275
303
  ### Schema
276
304
 
277
- #### Object
278
-
279
305
  All LLM providers except Anthropic and DeepSeek allow a client to describe
280
306
  the structure of a response that a LLM emits according to a schema that is
281
307
  described by JSON. The schema lets a client describe what JSON object
@@ -286,56 +312,21 @@ its ability:
286
312
  #!/usr/bin/env ruby
287
313
  require "llm"
288
314
 
289
- llm = LLM.openai(key: ENV["KEY"])
290
-
291
- ##
292
- # Objects
293
- schema = llm.schema.object(probability: llm.schema.number.required)
294
- bot = LLM::Bot.new(llm, schema:)
295
- bot.chat "Does the earth orbit the sun?", role: :user
296
- puts bot.messages.find(&:assistant?).content! # => {probability: 1.0}
297
-
298
- ##
299
- # Enums
300
- schema = llm.schema.object(fruit: llm.schema.string.enum("Apple", "Orange", "Pineapple"))
301
- bot = LLM::Bot.new(llm, schema:) :system
302
- bot.chat "What fruit is your favorite?", role: :user
303
- puts bot.messages.find(&:assistant?).content! # => {fruit: "Pineapple"}
304
-
305
- ##
306
- # Arrays
307
- schema = llm.schema.object(answers: llm.schema.array(llm.schema.integer.required))
308
- bot = LLM::Bot.new(llm, schema:)
309
- bot.chat "Tell me the answer to ((5 + 5) / 2) * 2 + 1", role: :user
310
- puts bot.messages.find(&:assistant?).content! # => {answers: [11]}
311
- ```
312
-
313
- #### Class
314
-
315
- Other than the object form we saw in the previous example, a class form
316
- is also supported. Under the hood, it is implemented with the object form
317
- and the class form primarily exists to provide structure and organization
318
- that the object form lacks:
319
-
320
- ```ruby
321
- #!/usr/bin/env ruby
322
- require "llm"
323
-
324
315
  class Player < LLM::Schema
325
316
  property :name, String, "The player's name", required: true
326
- property :numbers, Array[Integer], "The player's favorite numbers", required: true
317
+ property :position, Array[Number], "The player's [x, y] position", required: true
327
318
  end
328
319
 
329
320
  llm = LLM.openai(key: ENV["KEY"])
330
321
  bot = LLM::Bot.new(llm, schema: Player)
331
322
  prompt = bot.build_prompt do
332
- it.system "The user's name is Robert and their favorite numbers are 7 and 12"
333
- 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"
334
325
  end
335
326
 
336
327
  player = bot.chat(prompt).content!
337
- puts "name: #{player.name}"
338
- puts "numbers: #{player.numbers}"
328
+ puts "name: #{player['name']}"
329
+ puts "position: #{player['position'].join(', ')}"
339
330
  ```
340
331
 
341
332
  ### Tools
@@ -383,7 +374,7 @@ tool = LLM.function(:system) do |fn|
383
374
  end
384
375
 
385
376
  bot = LLM::Bot.new(llm, tools: [tool])
386
- 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
387
378
 
388
379
  bot.chat "What is the current date?", role: :user
389
380
  bot.chat bot.functions.map(&:call) # report return value to the LLM
@@ -430,7 +421,7 @@ end
430
421
 
431
422
  llm = LLM.openai(key: ENV["KEY"])
432
423
  bot = LLM::Bot.new(llm, tools: [System])
433
- 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
434
425
 
435
426
  bot.chat "What is the current date?", role: :user
436
427
  bot.chat bot.functions.map(&:call) # report return value to the LLM
@@ -460,9 +451,9 @@ require "llm"
460
451
 
461
452
  llm = LLM.openai(key: ENV["KEY"])
462
453
  bot = LLM::Bot.new(llm)
463
- file = llm.files.create(file: "/book.pdf")
454
+ file = llm.files.create(file: "/tmp/llm-book.pdf")
464
455
  res = bot.chat ["Tell me about this file", file]
465
- res.choices.each { print "[#{it.role}] ", it.content, "\n" }
456
+ res.choices.each { |m| puts "[#{m.role}] #{m.content}" }
466
457
  ```
467
458
 
468
459
  ### Prompts
@@ -493,17 +484,19 @@ require "llm"
493
484
 
494
485
  llm = LLM.openai(key: ENV["KEY"])
495
486
  bot = LLM::Bot.new(llm)
496
- 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"
497
490
 
498
- res1 = bot.chat ["Tell me about this URL", bot.image_url(url)]
499
- 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}" }
500
493
 
501
- file = llm.files.create(file: "/book.pdf")
494
+ file = llm.files.create(file: pdf_path)
502
495
  res2 = bot.chat ["Tell me about this PDF", bot.remote_file(file)]
503
- res2.choices.each { print "[#{it.role}] ", it.content, "\n" }
496
+ res2.choices.each { |m| puts "[#{m.role}] #{m.content}" }
504
497
 
505
- res3 = bot.chat ["Tell me about this image", bot.local_file("/puffy.png")]
506
- 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}" }
507
500
  ```
508
501
 
509
502
  ### Audio
@@ -540,7 +533,7 @@ llm = LLM.openai(key: ENV["KEY"])
540
533
  res = llm.audio.create_transcription(
541
534
  file: File.join(Dir.home, "hello.mp3")
542
535
  )
543
- print res.text, "\n" # => "Hello world."
536
+ puts res.text # => "Hello world."
544
537
  ```
545
538
 
546
539
  #### Translate
@@ -558,7 +551,7 @@ llm = LLM.openai(key: ENV["KEY"])
558
551
  res = llm.audio.create_translation(
559
552
  file: File.join(Dir.home, "bomdia.mp3")
560
553
  )
561
- print res.text, "\n" # => "Good morning."
554
+ puts res.text # => "Good morning."
562
555
  ```
563
556
 
564
557
  ### Images
@@ -588,8 +581,8 @@ end
588
581
  #### Edit
589
582
 
590
583
  The following example is focused on editing a local image with the aid
591
- of a prompt. The image (`/images/cat.png`) is returned to us with the cat
592
- 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
593
586
  the final step:
594
587
 
595
588
  ```ruby
@@ -600,20 +593,20 @@ require "fileutils"
600
593
 
601
594
  llm = LLM.openai(key: ENV["KEY"])
602
595
  res = llm.images.edit(
603
- image: "/images/cat.png",
604
- prompt: "a cat with a hat",
596
+ image: "/tmp/llm-logo.png",
597
+ prompt: "add a hat to the logo",
605
598
  )
606
599
  res.urls.each do |url|
607
600
  FileUtils.mv OpenURI.open_uri(url).path,
608
- File.join(Dir.home, "catwithhat.png")
601
+ File.join(Dir.home, "logo-with-hat.png")
609
602
  end
610
603
  ```
611
604
 
612
605
  #### Variations
613
606
 
614
607
  The following example is focused on creating variations of a local image.
615
- The image (`/images/cat.png`) is returned to us with five different variations.
616
- 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`
617
610
  and so on as the final step:
618
611
 
619
612
  ```ruby
@@ -624,12 +617,12 @@ require "fileutils"
624
617
 
625
618
  llm = LLM.openai(key: ENV["KEY"])
626
619
  res = llm.images.create_variation(
627
- image: "/images/cat.png",
620
+ image: "/tmp/llm-logo.png",
628
621
  n: 5
629
622
  )
630
623
  res.urls.each.with_index do |url, index|
631
624
  FileUtils.mv OpenURI.open_uri(url).path,
632
- File.join(Dir.home, "catvariation#{index}.png")
625
+ File.join(Dir.home, "logo-variation#{index}.png")
633
626
  end
634
627
  ```
635
628
 
@@ -639,13 +632,8 @@ end
639
632
 
640
633
  The
641
634
  [`LLM::Provider#embed`](https://0x1eef.github.io/x/llm.rb/LLM/Provider.html#embed-instance_method)
642
- method generates a vector representation of one or more chunks
643
- of text. Embeddings capture the semantic meaning of text &ndash;
644
- a common use-case for them is to store chunks of text in a
645
- vector database, and then to query the database for *semantically
646
- similar* text. These chunks of similar text can then support the
647
- generation of a prompt that is used to query a large language model,
648
- 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):
649
637
 
650
638
  ```ruby
651
639
  #!/usr/bin/env ruby
@@ -653,9 +641,9 @@ require "llm"
653
641
 
654
642
  llm = LLM.openai(key: ENV["KEY"])
655
643
  res = llm.embed(["programming is fun", "ruby is a programming language", "sushi is art"])
656
- print res.class, "\n"
657
- print res.embeddings.size, "\n"
658
- print res.embeddings[0].size, "\n"
644
+ puts res.class
645
+ puts res.embeddings.size
646
+ puts res.embeddings[0].size
659
647
 
660
648
  ##
661
649
  # LLM::Response
@@ -669,10 +657,8 @@ print res.embeddings[0].size, "\n"
669
657
 
670
658
  Almost all LLM providers provide a models endpoint that allows a client to
671
659
  query the list of models that are available to use. The list is dynamic,
672
- maintained by LLM providers, and it is independent of a specific llm.rb release.
673
- [LLM::Model](https://0x1eef.github.io/x/llm.rb/LLM/Model.html)
674
- objects can be used instead of a string that describes a model name (although
675
- 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:
676
662
 
677
663
  ```ruby
678
664
  #!/usr/bin/env ruby
@@ -682,7 +668,7 @@ require "llm"
682
668
  # List all models
683
669
  llm = LLM.openai(key: ENV["KEY"])
684
670
  llm.models.all.each do |model|
685
- print "model: ", model.id, "\n"
671
+ puts "model: #{model.id}"
686
672
  end
687
673
 
688
674
  ##
@@ -690,7 +676,7 @@ end
690
676
  model = llm.models.all.find { |m| m.id == "gpt-3.5-turbo" }
691
677
  bot = LLM::Bot.new(llm, model: model.id)
692
678
  res = bot.chat "Hello #{model.id} :)"
693
- res.choices.each { print "[#{it.role}] ", it.content, "\n" }
679
+ res.choices.each { |m| puts "[#{m.role}] #{m.content}" }
694
680
  ```
695
681
 
696
682
  ## Install
@@ -29,6 +29,13 @@ module LLM::Contract
29
29
  raise NotImplementedError, "#{self.class} does not implement '#{__method__}'"
30
30
  end
31
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
+
32
39
  ##
33
40
  # @return [Integer]
34
41
  # Returns the total number of tokens
@@ -43,6 +50,7 @@ module LLM::Contract
43
50
  LLM::Usage.new(
44
51
  input_tokens:,
45
52
  output_tokens:,
53
+ reasoning_tokens:,
46
54
  total_tokens:
47
55
  )
48
56
  end
data/lib/llm/message.rb CHANGED
@@ -51,8 +51,9 @@ module LLM
51
51
  alias_method :eql?, :==
52
52
 
53
53
  ##
54
- # Try to parse the content as JSON
54
+ # Try to parse JSON content
55
55
  # @return [Hash]
56
+ # Returns the parsed content as a Hash
56
57
  def content!
57
58
  LLM.json.load(content)
58
59
  end
@@ -20,17 +20,17 @@ class LLM::Object
20
20
  ::Kernel.instance_method(:method).bind(self).call(...)
21
21
  end
22
22
 
23
- def kind_of?(...)
24
- ::Kernel.instance_method(:kind_of?).bind(self).call(...)
23
+ def kind_of?(klass)
24
+ ::Kernel.instance_method(:kind_of?).bind(self).call(klass)
25
25
  end
26
26
  alias_method :is_a?, :kind_of?
27
27
 
28
28
  def respond_to?(m, include_private = false)
29
- !!key(m) || self.class.method_defined?(m) || super
29
+ !!key(m) || self.class.method_defined?(m)
30
30
  end
31
31
 
32
32
  def respond_to_missing?(m, include_private = false)
33
- !!key(m) || super
33
+ !!key(m)
34
34
  end
35
35
 
36
36
  def object_id
data/lib/llm/object.rb CHANGED
@@ -68,16 +68,56 @@ class LLM::Object < BasicObject
68
68
  @h.transform_keys(&:to_sym)
69
69
  end
70
70
 
71
+ ##
72
+ # @return [Array<String>]
73
+ def keys
74
+ @h.keys
75
+ end
76
+
77
+ ##
78
+ # @return [Array]
79
+ def values
80
+ @h.values
81
+ end
82
+
83
+ ##
84
+ # @param [String, Symbol] k
85
+ # @return [Boolean]
86
+ def key?(k)
87
+ @h.key?(key(k))
88
+ end
89
+ alias_method :has_key?, :key?
90
+
91
+ ##
92
+ # @param [String, Symbol] k
93
+ # @return [Object]
94
+ def fetch(k, *args, &b)
95
+ @h.fetch(key(k), *args, &b)
96
+ end
97
+
98
+ ##
99
+ # @return [Integer]
100
+ def size
101
+ @h.size
102
+ end
103
+ alias_method :length, :size
104
+
105
+ ##
106
+ # @yieldparam [String, Object]
107
+ def each_pair(&)
108
+ @h.each(&)
109
+ end
110
+
71
111
  ##
72
112
  # @return [Object, nil]
73
113
  def dig(...)
74
- to_h.dig(...)
114
+ @h.dig(...)
75
115
  end
76
116
 
77
117
  ##
78
118
  # @return [Hash]
79
119
  def slice(...)
80
- to_h.slice(...)
120
+ @h.slice(...)
81
121
  end
82
122
 
83
123
  private
@@ -12,13 +12,19 @@ module LLM::Anthropic::ResponseAdapter
12
12
  ##
13
13
  # (see LLM::Contract::Completion#input_tokens)
14
14
  def input_tokens
15
- body.usage["input_tokens"] || 0
15
+ body.usage&.input_tokens || 0
16
16
  end
17
17
 
18
18
  ##
19
19
  # (see LLM::Contract::Completion#output_tokens)
20
20
  def output_tokens
21
- body.usage["output_tokens"] || 0
21
+ body.usage&.output_tokens || 0
22
+ end
23
+
24
+ ##
25
+ # (see LLM::Contract::Completion#reasoning_tokens)
26
+ def reasoning_tokens
27
+ 0
22
28
  end
23
29
 
24
30
  ##
@@ -40,11 +40,14 @@ class LLM::Anthropic
40
40
  if Hash === content["input"]
41
41
  content["input"] = chunk["delta"]["partial_json"]
42
42
  else
43
+ content["input"] ||= +""
43
44
  content["input"] << chunk["delta"]["partial_json"]
44
45
  end
45
46
  end
46
47
  elsif chunk["type"] == "message_delta"
47
- merge_message!(chunk["delta"])
48
+ merge_message!(chunk["delta"]) if chunk["delta"]
49
+ extras = chunk.reject { |k, _| k == "type" || k == "delta" }
50
+ merge_message!(extras) unless extras.empty?
48
51
  elsif chunk["type"] == "content_block_stop"
49
52
  content = @body["content"][chunk["index"]]
50
53
  if content["input"]
@@ -54,11 +57,22 @@ class LLM::Anthropic
54
57
  end
55
58
 
56
59
  def merge_message!(message)
57
- message.each do |key, value|
58
- @body[key] = if value.respond_to?(:each_pair)
59
- merge_message!(value)
60
+ message.each_pair do |key, value|
61
+ if value.respond_to?(:each_pair)
62
+ @body[key] ||= {}
63
+ deep_merge!(@body[key], value)
60
64
  else
61
- value
65
+ @body[key] = value
66
+ end
67
+ end
68
+ end
69
+
70
+ def deep_merge!(target, source)
71
+ source.each_pair do |key, value|
72
+ if value.respond_to?(:each_pair) && target[key].respond_to?(:each_pair)
73
+ deep_merge!(target[key], value)
74
+ else
75
+ target[key] = value
62
76
  end
63
77
  end
64
78
  end
@@ -41,16 +41,9 @@ module LLM
41
41
  # When given an object a provider does not understand
42
42
  # @return (see LLM::Provider#complete)
43
43
  def complete(prompt, params = {})
44
- params = {role: :user, model: default_model, max_tokens: 1024}.merge!(params)
45
- tools = resolve_tools(params.delete(:tools))
46
- params = [params, adapt_tools(tools)].inject({}, &:merge!).compact
47
- role, stream = params.delete(:role), params.delete(:stream)
48
- params[:stream] = true if stream.respond_to?(:<<) || stream == true
49
- req = Net::HTTP::Post.new("/v1/messages", headers)
50
- messages = [*(params.delete(:messages) || []), Message.new(role, prompt)]
51
- body = LLM.json.dump({messages: [adapt(messages)].flatten}.merge!(params))
52
- set_body_stream(req, StringIO.new(body))
53
- res = execute(request: req, stream:)
44
+ params, stream, tools, role = normalize_complete_params(params)
45
+ req = build_complete_request(prompt, params, role)
46
+ res = execute(request: req, stream: stream)
54
47
  ResponseAdapter.adapt(res, type: :completion)
55
48
  .extend(Module.new { define_method(:__tools__) { tools } })
56
49
  end
@@ -131,5 +124,22 @@ module LLM
131
124
  def error_handler
132
125
  LLM::Anthropic::ErrorHandler
133
126
  end
127
+
128
+ def normalize_complete_params(params)
129
+ params = {role: :user, model: default_model, max_tokens: 1024}.merge!(params)
130
+ tools = resolve_tools(params.delete(:tools))
131
+ params = [params, adapt_tools(tools)].inject({}, &:merge!).compact
132
+ role, stream = params.delete(:role), params.delete(:stream)
133
+ params[:stream] = true if stream.respond_to?(:<<) || stream == true
134
+ [params, stream, tools, role]
135
+ end
136
+
137
+ def build_complete_request(prompt, params, role)
138
+ messages = [*(params.delete(:messages) || []), Message.new(role, prompt)]
139
+ body = LLM.json.dump({messages: [adapt(messages)].flatten}.merge!(params))
140
+ req = Net::HTTP::Post.new("/v1/messages", headers)
141
+ set_body_stream(req, StringIO.new(body))
142
+ req
143
+ end
134
144
  end
135
145
  end
@@ -21,6 +21,12 @@ module LLM::Gemini::ResponseAdapter
21
21
  body.usageMetadata.candidatesTokenCount || 0
22
22
  end
23
23
 
24
+ ##
25
+ # (see LLM::Contract::Completion#reasoning_tokens)
26
+ def reasoning_tokens
27
+ body.usageMetadata.thoughtsTokenCount || 0
28
+ end
29
+
24
30
  ##
25
31
  # (see LLM::Contract::Completion#total_tokens)
26
32
  def total_tokens
@@ -104,6 +104,10 @@ class LLM::Gemini
104
104
  delta_call = delta["functionCall"]
105
105
  if last_call.is_a?(Hash) && delta_call.is_a?(Hash)
106
106
  last_existing_part["functionCall"] = last_call.merge(delta_call)
107
+ delta.each do |key, value|
108
+ next if key == "functionCall"
109
+ last_existing_part[key] = value
110
+ end
107
111
  else
108
112
  parts << delta
109
113
  end
@@ -64,18 +64,9 @@ module LLM
64
64
  # When given an object a provider does not understand
65
65
  # @return [LLM::Response]
66
66
  def complete(prompt, params = {})
67
- params = {role: :user, model: default_model}.merge!(params)
68
- tools = resolve_tools(params.delete(:tools))
69
- params = [params, adapt_schema(params), adapt_tools(tools)].inject({}, &:merge!).compact
70
- role, model, stream = [:role, :model, :stream].map { params.delete(_1) }
71
- action = stream ? "streamGenerateContent?key=#{@key}&alt=sse" : "generateContent?key=#{@key}"
72
- model.respond_to?(:id) ? model.id : model
73
- path = ["/v1beta/models/#{model}", action].join(":")
74
- req = Net::HTTP::Post.new(path, headers)
75
- messages = [*(params.delete(:messages) || []), LLM::Message.new(role, prompt)]
76
- body = LLM.json.dump({contents: adapt(messages)}.merge!(params))
77
- set_body_stream(req, StringIO.new(body))
78
- res = execute(request: req, stream:)
67
+ params, stream, tools, role, model = normalize_complete_params(params)
68
+ req = build_complete_request(prompt, params, role, model, stream)
69
+ res = execute(request: req, stream: stream)
79
70
  ResponseAdapter.adapt(res, type: :completion)
80
71
  .extend(Module.new { define_method(:__tools__) { tools } })
81
72
  end
@@ -165,5 +156,24 @@ module LLM
165
156
  def error_handler
166
157
  LLM::Gemini::ErrorHandler
167
158
  end
159
+
160
+ def normalize_complete_params(params)
161
+ params = {role: :user, model: default_model}.merge!(params)
162
+ tools = resolve_tools(params.delete(:tools))
163
+ params = [params, adapt_schema(params), adapt_tools(tools)].inject({}, &:merge!).compact
164
+ role, model, stream = [:role, :model, :stream].map { params.delete(_1) }
165
+ [params, stream, tools, role, model]
166
+ end
167
+
168
+ def build_complete_request(prompt, params, role, model, stream)
169
+ action = stream ? "streamGenerateContent?key=#{@key}&alt=sse" : "generateContent?key=#{@key}"
170
+ model.respond_to?(:id) ? model.id : model
171
+ path = ["/v1beta/models/#{model}", action].join(":")
172
+ req = Net::HTTP::Post.new(path, headers)
173
+ messages = [*(params.delete(:messages) || []), LLM::Message.new(role, prompt)]
174
+ body = LLM.json.dump({contents: adapt(messages)}.merge!(params))
175
+ set_body_stream(req, StringIO.new(body))
176
+ req
177
+ end
168
178
  end
169
179
  end
@@ -21,6 +21,12 @@ module LLM::Ollama::ResponseAdapter
21
21
  body.eval_count || 0
22
22
  end
23
23
 
24
+ ##
25
+ # (see LLM::Contract::Completion#reasoning_tokens)
26
+ def reasoning_tokens
27
+ 0
28
+ end
29
+
24
30
  ##
25
31
  # (see LLM::Contract::Completion#total_tokens)
26
32
  def total_tokens
@@ -58,16 +58,9 @@ module LLM
58
58
  # When given an object a provider does not understand
59
59
  # @return [LLM::Response]
60
60
  def complete(prompt, params = {})
61
- params = {role: :user, model: default_model, stream: true}.merge!(params)
62
- tools = resolve_tools(params.delete(:tools))
63
- params = [params, {format: params[:schema]}, adapt_tools(tools)].inject({}, &:merge!).compact
64
- role, stream = params.delete(:role), params.delete(:stream)
65
- params[:stream] = true if stream.respond_to?(:<<) || stream == true
66
- req = Net::HTTP::Post.new("/api/chat", headers)
67
- messages = [*(params.delete(:messages) || []), LLM::Message.new(role, prompt)]
68
- body = LLM.json.dump({messages: [adapt(messages)].flatten}.merge!(params))
69
- set_body_stream(req, StringIO.new(body))
70
- res = execute(request: req, stream:)
61
+ params, stream, tools, role = normalize_complete_params(params)
62
+ req = build_complete_request(prompt, params, role)
63
+ res = execute(request: req, stream: stream)
71
64
  ResponseAdapter.adapt(res, type: :completion)
72
65
  .extend(Module.new { define_method(:__tools__) { tools } })
73
66
  end
@@ -110,5 +103,22 @@ module LLM
110
103
  def error_handler
111
104
  LLM::Ollama::ErrorHandler
112
105
  end
106
+
107
+ def normalize_complete_params(params)
108
+ params = {role: :user, model: default_model, stream: true}.merge!(params)
109
+ tools = resolve_tools(params.delete(:tools))
110
+ params = [params, {format: params[:schema]}, adapt_tools(tools)].inject({}, &:merge!).compact
111
+ role, stream = params.delete(:role), params.delete(:stream)
112
+ params[:stream] = true if stream.respond_to?(:<<) || stream == true
113
+ [params, stream, tools, role]
114
+ end
115
+
116
+ def build_complete_request(prompt, params, role)
117
+ messages = [*(params.delete(:messages) || []), LLM::Message.new(role, prompt)]
118
+ body = LLM.json.dump({messages: [adapt(messages)].flatten}.merge!(params))
119
+ req = Net::HTTP::Post.new("/api/chat", headers)
120
+ set_body_stream(req, StringIO.new(body))
121
+ req
122
+ end
113
123
  end
114
124
  end
@@ -21,19 +21,28 @@ module LLM::OpenAI::ResponseAdapter
21
21
  ##
22
22
  # (see LLM::Contract::Completion#input_tokens)
23
23
  def input_tokens
24
- body.usage["prompt_tokens"] || 0
24
+ body.usage&.prompt_tokens || 0
25
25
  end
26
26
 
27
27
  ##
28
28
  # (see LLM::Contract::Completion#output_tokens)
29
29
  def output_tokens
30
- body.usage["completion_tokens"] || 0
30
+ body.usage&.completion_tokens || 0
31
+ end
32
+
33
+ ##
34
+ # (see LLM::Contract::Completion#reasoning_tokens)
35
+ def reasoning_tokens
36
+ body
37
+ .usage
38
+ &.completion_tokens_details
39
+ &.reasoning_tokens || 0
31
40
  end
32
41
 
33
42
  ##
34
43
  # (see LLM::Contract::Completion#total_tokens)
35
44
  def total_tokens
36
- body.usage["total_tokens"] || 0
45
+ body.usage&.total_tokens || 0
37
46
  end
38
47
 
39
48
  ##
@@ -71,7 +71,9 @@ class LLM::OpenAI
71
71
  def merge_tools!(target, tools)
72
72
  target["tool_calls"] ||= []
73
73
  tools.each.with_index do |toola, index|
74
- toolb = target["tool_calls"][index]
74
+ tindex = toola["index"]
75
+ tindex = index unless Integer === tindex && tindex >= 0
76
+ toolb = target["tool_calls"][tindex]
75
77
  if toolb && toola["function"] && toolb["function"]
76
78
  # Append to existing function arguments
77
79
  toola["function"].each do |func_key, func_value|
@@ -79,7 +81,7 @@ class LLM::OpenAI
79
81
  toolb["function"][func_key] << func_value
80
82
  end
81
83
  else
82
- target["tool_calls"][index] = toola
84
+ target["tool_calls"][tindex] = toola
83
85
  end
84
86
  end
85
87
  end
@@ -62,17 +62,9 @@ module LLM
62
62
  # When given an object a provider does not understand
63
63
  # @return (see LLM::Provider#complete)
64
64
  def complete(prompt, params = {})
65
- params = {role: :user, model: default_model}.merge!(params)
66
- tools = resolve_tools(params.delete(:tools))
67
- params = [params, adapt_schema(params), adapt_tools(tools)].inject({}, &:merge!).compact
68
- role, stream = params.delete(:role), params.delete(:stream)
69
- params[:stream] = true if stream.respond_to?(:<<) || stream == true
70
- params[:stream_options] = {include_usage: true}.merge!(params[:stream_options] || {}) if params[:stream]
71
- req = Net::HTTP::Post.new(completions_path, headers)
72
- messages = [*(params.delete(:messages) || []), Message.new(role, prompt)]
73
- body = LLM.json.dump({messages: adapt(messages, mode: :complete).flatten}.merge!(params))
74
- set_body_stream(req, StringIO.new(body))
75
- res = execute(request: req, stream:)
65
+ params, stream, tools, role = normalize_complete_params(params)
66
+ req = build_complete_request(prompt, params, role)
67
+ res = execute(request: req, stream: stream)
76
68
  ResponseAdapter.adapt(res, type: :completion)
77
69
  .extend(Module.new { define_method(:__tools__) { tools } })
78
70
  end
@@ -200,5 +192,25 @@ module LLM
200
192
  def error_handler
201
193
  LLM::OpenAI::ErrorHandler
202
194
  end
195
+
196
+ def normalize_complete_params(params)
197
+ params = {role: :user, model: default_model}.merge!(params)
198
+ tools = resolve_tools(params.delete(:tools))
199
+ params = [params, adapt_schema(params), adapt_tools(tools)].inject({}, &:merge!).compact
200
+ role, stream = params.delete(:role), params.delete(:stream)
201
+ params[:stream] = true if stream.respond_to?(:<<) || stream == true
202
+ if params[:stream]
203
+ params[:stream_options] = {include_usage: true}.merge!(params[:stream_options] || {})
204
+ end
205
+ [params, stream, tools, role]
206
+ end
207
+
208
+ def build_complete_request(prompt, params, role)
209
+ messages = [*(params.delete(:messages) || []), Message.new(role, prompt)]
210
+ body = LLM.json.dump({messages: adapt(messages, mode: :complete).flatten}.merge!(params))
211
+ req = Net::HTTP::Post.new(completions_path, headers)
212
+ set_body_stream(req, StringIO.new(body))
213
+ req
214
+ end
203
215
  end
204
216
  end
data/lib/llm/response.rb CHANGED
@@ -26,6 +26,8 @@ module LLM
26
26
  ##
27
27
  # Returns the response body
28
28
  # @return [LLM::Object, String]
29
+ # Returns an LLM::Object when the response body is JSON,
30
+ # otherwise returns a raw string.
29
31
  def body
30
32
  @res.body
31
33
  end
@@ -54,11 +56,19 @@ module LLM
54
56
  private
55
57
 
56
58
  def method_missing(m, *args, **kwargs, &b)
57
- body.respond_to?(m) ? body[m.to_s] : super
59
+ if LLM::Object === body
60
+ body.respond_to?(m) ? body[m.to_s] : super
61
+ else
62
+ super
63
+ end
58
64
  end
59
65
 
60
66
  def respond_to_missing?(m, include_private = false)
61
- body.respond_to?(m) || super
67
+ if LLM::Object === body
68
+ body.respond_to?(m)
69
+ else
70
+ false
71
+ end
62
72
  end
63
73
  end
64
74
  end
data/lib/llm/usage.rb CHANGED
@@ -4,7 +4,8 @@
4
4
  # The {LLM::Usage LLM::Usage} class represents token usage for
5
5
  # a given conversation or completion. As a conversation grows,
6
6
  # so does the number of tokens used. This class helps track
7
- # the number of input, output, and total tokens. It can also help
8
- # track usage of the context window (which may vary by model).
9
- class LLM::Usage < Struct.new(:input_tokens, :output_tokens, :total_tokens, keyword_init: true)
7
+ # the number of input, output, reasoning and overall token count.
8
+ # It can also help track usage of the context window (which may
9
+ # vary by model).
10
+ class LLM::Usage < Struct.new(:input_tokens, :output_tokens, :reasoning_tokens, :total_tokens, keyword_init: true)
10
11
  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 = "3.0.0"
4
+ VERSION = "3.1.0"
5
5
  end
data/lib/llm.rb CHANGED
@@ -41,6 +41,9 @@ module LLM
41
41
 
42
42
  ##
43
43
  # Sets the JSON adapter used by the library
44
+ # @note
45
+ # This should be set once from the main thread when your program starts.
46
+ # Defaults to {LLM::JSONAdapter::JSON LLM::JSONAdapter::JSON}.
44
47
  # @param [Class, String, Symbol] adapter
45
48
  # A JSON adapter class or its name
46
49
  # @return [void]
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: 3.0.0
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Antar Azri