llm.rb 4.6.0 → 4.8.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +40 -37
  3. data/lib/llm/contract/completion.rb +14 -0
  4. data/lib/llm/eventstream/parser.rb +0 -5
  5. data/lib/llm/model.rb +115 -0
  6. data/lib/llm/provider.rb +50 -23
  7. data/lib/llm/providers/anthropic/error_handler.rb +1 -1
  8. data/lib/llm/providers/anthropic/models.rb +1 -1
  9. data/lib/llm/providers/anthropic/request_adapter.rb +20 -3
  10. data/lib/llm/providers/anthropic/response_adapter/completion.rb +12 -0
  11. data/lib/llm/providers/anthropic/response_adapter/models.rb +13 -0
  12. data/lib/llm/providers/anthropic/response_adapter.rb +2 -0
  13. data/lib/llm/providers/anthropic.rb +2 -1
  14. data/lib/llm/providers/gemini/error_handler.rb +18 -3
  15. data/lib/llm/providers/gemini/response_adapter/completion.rb +12 -0
  16. data/lib/llm/providers/gemini/response_adapter/models.rb +4 -6
  17. data/lib/llm/providers/ollama/error_handler.rb +1 -1
  18. data/lib/llm/providers/ollama/models.rb +1 -1
  19. data/lib/llm/providers/ollama/response_adapter/completion.rb +12 -0
  20. data/lib/llm/providers/ollama/response_adapter/models.rb +13 -0
  21. data/lib/llm/providers/ollama/response_adapter.rb +2 -0
  22. data/lib/llm/providers/openai/error_handler.rb +18 -3
  23. data/lib/llm/providers/openai/images.rb +17 -11
  24. data/lib/llm/providers/openai/models.rb +1 -1
  25. data/lib/llm/providers/openai/response_adapter/completion.rb +12 -0
  26. data/lib/llm/providers/openai/response_adapter/models.rb +13 -0
  27. data/lib/llm/providers/openai/response_adapter.rb +2 -0
  28. data/lib/llm/providers/openai/responses.rb +7 -0
  29. data/lib/llm/providers/openai.rb +9 -2
  30. data/lib/llm/providers/xai/images.rb +7 -6
  31. data/lib/llm/schema/enum.rb +16 -0
  32. data/lib/llm/schema.rb +1 -0
  33. data/lib/llm/tool/param.rb +1 -1
  34. data/lib/llm/tool.rb +1 -1
  35. data/lib/llm/tracer/langsmith.rb +144 -0
  36. data/lib/llm/tracer/logger.rb +8 -0
  37. data/lib/llm/tracer/null.rb +8 -0
  38. data/lib/llm/tracer/telemetry.rb +107 -38
  39. data/lib/llm/tracer.rb +108 -4
  40. data/lib/llm/version.rb +1 -1
  41. data/lib/llm.rb +1 -0
  42. metadata +7 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c26db042940fc118505804bbec91d43dd39313b6625b4e3d7df51f43f01d58ae
4
- data.tar.gz: 5780f3e3d0db3579772d4f7ba141052314d34b7fd0db06ba029e4b4fd6fd84dc
3
+ metadata.gz: 696d0be686c58d66ce0904d0ed0aff879906511060704c61adc945b458ed1f37
4
+ data.tar.gz: 7d03d786ff1fdbaa24e470f87d6e0c3c6b5acfc8e6b73c1cda88fdb9c1a4db58
5
5
  SHA512:
6
- metadata.gz: 864ba517e81d8fb4d248d754c5a9fc4bdcbf162f9ac4fb7691a54c015006ed40035300fd19c2057f755678676534a568204b0e0bc29b8e058cf2334b8625465e
7
- data.tar.gz: cd8b18c4e780a26c83201ea39f81077ad2535db966efcb5fe860f4af55808f96a8bd8a6bdd4c25f38a9012b3092eb2611bbfd31552969b00b15579af5e95354a
6
+ metadata.gz: aeb39e7a8b7a9826be90f3f4739ae0fae40df6bc6c7053e77110ccf1ebe9c5f44316b0edf0e2de3782d0fa93dce110ac8cc0328623056b6c71e55be3abf792c3
7
+ data.tar.gz: c665f150d6c12cbe27541ea743a0fbea4eb107eb5bc93d4e9af480460108430a3057852458f9c5bd1fa5d17ed67633462513ff098c02f0fef43de9dc5ab44224
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  <p align="center">
5
5
  <a href="https://0x1eef.github.io/x/llm.rb?rebuild=1"><img src="https://img.shields.io/badge/docs-0x1eef.github.io-blue.svg" alt="RubyDoc"></a>
6
6
  <a href="https://opensource.org/license/0bsd"><img src="https://img.shields.io/badge/License-0BSD-orange.svg?" alt="License"></a>
7
- <a href="https://github.com/llmrb/llm.rb/tags"><img src="https://img.shields.io/badge/version-4.6.0-green.svg?" alt="Version"></a>
7
+ <a href="https://github.com/llmrb/llm.rb/tags"><img src="https://img.shields.io/badge/version-4.8.0-green.svg?" alt="Version"></a>
8
8
  </p>
9
9
 
10
10
  ## About
@@ -66,7 +66,7 @@ end
66
66
  llm = LLM.openai(key: ENV["KEY"])
67
67
  ses = LLM::Session.new(llm, schema: Report)
68
68
  res = ses.talk("Structure this report: 'Database latency spiked at 10:42 UTC, causing 5% request timeouts for 12 minutes.'")
69
- pp res.messages.first(&:assistant?).content!
69
+ pp res.content!
70
70
 
71
71
  ##
72
72
  # {
@@ -181,31 +181,41 @@ ses.talk(prompt)
181
181
 
182
182
  llm.rb is designed for threaded environments with throughput in mind.
183
183
  Locks are used selectively, and localized state is preferred wherever
184
- possible. Blanket locking across every class would help guarantee
185
- correctness but it would also add contention, reduce throughput,
184
+ possible. Blanket locking across every class could help guarantee
185
+ correctness but it could also add contention, reduce throughput,
186
186
  and increase complexity.
187
187
 
188
188
  That's why we decided to optimize for both correctness and throughput
189
189
  instead. An important part of that design is guaranteeing that
190
190
  [LLM::Provider](https://0x1eef.github.io/x/llm.rb/LLM/Provider.html)
191
- is safe to share across threads. [LLM::Session](https://0x1eef.github.io/x/llm.rb/LLM/Session.html) and
191
+ is safe to share and use across threads. [LLM::Session](https://0x1eef.github.io/x/llm.rb/LLM/Session.html) and
192
192
  [LLM::Agent](https://0x1eef.github.io/x/llm.rb/LLM/Agent.html) are
193
- stateful objects that should be kept local to a single thread. So the
194
- recommended pattern is to keep one session or agent per thread,
195
- and share a provider across multiple threads:
193
+ stateful objects that should be kept local to a single thread.
194
+
195
+ [LLM::Tracer](https://0x1eef.github.io/x/llm.rb/LLM/Tracer.html) and its
196
+ subclasses are also designed to be thread-local, which means that
197
+ `llm.tracer = ...` only impacts the current thread and must be set
198
+ again in each thread where a tracer is desired. This avoids contention
199
+ on tracer state, keeps tracing isolated per thread, and allows different
200
+ tracers to be used in different threads simultaneously.
201
+
202
+ So the recommended pattern is to keep one session, tracer or agent per
203
+ thread, and share a provider across multiple threads:
204
+
196
205
 
197
206
  ```ruby
198
207
  #!/usr/bin/env ruby
199
208
  require "llm"
200
209
 
201
- llm = LLM.openai(key: ENV["KEY"], persistent: true)
210
+ llm = LLM.openai(key: ENV["KEY"]).persist!
202
211
  schema = llm.schema.object(answer: llm.schema.integer.required)
203
212
 
204
213
  vals = 10.times.map do |x|
205
214
  Thread.new do
215
+ llm.tracer = LLM::Tracer::Logger.new(llm, path: "thread#{x}.log")
206
216
  ses = LLM::Session.new(llm, schema:)
207
217
  res = ses.talk "#{x} + 5 = ?"
208
- res.messages.find(&:assistant?).content!
218
+ res.content!
209
219
  end
210
220
  end.map(&:value)
211
221
 
@@ -335,7 +345,7 @@ and the gem should be installed separately:
335
345
  #!/usr/bin/env ruby
336
346
  require "llm"
337
347
 
338
- llm = LLM.openai(key: ENV["KEY"], persistent: true)
348
+ llm = LLM.openai(key: ENV["KEY"]).persist!
339
349
  res1 = llm.responses.create "message 1"
340
350
  res2 = llm.responses.create "message 2", previous_response_id: res1.response_id
341
351
  res3 = llm.responses.create "message 3", previous_response_id: res2.response_id
@@ -349,6 +359,11 @@ can be used to trace LLM requests. It can be useful for debugging, monitoring,
349
359
  and observability. The primary use case in mind is integration with tools like
350
360
  [LangSmith](https://www.langsmith.com/).
351
361
 
362
+ It is worth mentioning that tracers are local to a thread, and they
363
+ should be configured per thread. That means that `llm.tracer = LLM::Tracer::Telemetry.new(llm)`
364
+ only impacts the current thread, and it should be repeated in each thread where
365
+ tracing is required.
366
+
352
367
  The telemetry implementation uses the [opentelemetry-sdk](https://github.com/open-telemetry/opentelemetry-ruby)
353
368
  and is based on the [gen-ai telemetry spec(s)](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/).
354
369
  This feature is optional, disabled by default, and the [opentelemetry-sdk](https://github.com/open-telemetry/opentelemetry-ruby)
@@ -406,7 +421,8 @@ The llm.rb library includes simple logging support through its
406
421
  tracer API, and Ruby's standard library ([ruby/logger](https://github.com/ruby/logger)).
407
422
  This feature is optional, disabled by default, and it can be useful for debugging and/or
408
423
  monitoring requests to LLM providers. The `path` or `io` options can be used to choose
409
- where logs are written to, and by default it is set to `$stdout`:
424
+ where logs are written, and by default it is set to `$stdout`. Like other tracers,
425
+ the logger tracer is local to a thread:
410
426
 
411
427
  ```ruby
412
428
  #!/usr/bin/env ruby
@@ -578,12 +594,13 @@ it has been uploaded. The file (a specialized instance of
578
594
  ```ruby
579
595
  #!/usr/bin/env ruby
580
596
  require "llm"
597
+ require "pp"
581
598
 
582
599
  llm = LLM.openai(key: ENV["KEY"])
583
600
  ses = LLM::Session.new(llm)
584
601
  file = llm.files.create(file: "/tmp/llm-book.pdf")
585
602
  res = ses.talk ["Tell me about this file", file]
586
- res.messages.each { |m| puts "[#{m.role}] #{m.content}" }
603
+ pp res.content
587
604
  ```
588
605
 
589
606
  ### Prompts
@@ -674,68 +691,53 @@ puts res.text # => "Good morning."
674
691
  Some but not all LLM providers implement image generation capabilities that
675
692
  can create new images from a prompt, or edit an existing image with a
676
693
  prompt. The following example uses the OpenAI provider to create an
677
- image of a dog on a rocket to the moon. The image is then moved to
694
+ image of a dog on a rocket to the moon. The image is then written to
678
695
  `${HOME}/dogonrocket.png` as the final step:
679
696
 
680
697
  ```ruby
681
698
  #!/usr/bin/env ruby
682
699
  require "llm"
683
- require "open-uri"
684
- require "fileutils"
685
-
686
700
  llm = LLM.openai(key: ENV["KEY"])
687
701
  res = llm.images.create(prompt: "a dog on a rocket to the moon")
688
- res.urls.each do |url|
689
- FileUtils.mv OpenURI.open_uri(url).path,
690
- File.join(Dir.home, "dogonrocket.png")
691
- end
702
+ IO.copy_stream res.images[0], File.join(Dir.home, "dogonrocket.png")
692
703
  ```
693
704
 
694
705
  #### Edit
695
706
 
696
707
  The following example is focused on editing a local image with the aid
697
708
  of a prompt. The image (`/tmp/llm-logo.png`) is returned to us with a hat.
698
- The image is then moved to `${HOME}/logo-with-hat.png` as
709
+ The image is then written to `${HOME}/logo-with-hat.png` as
699
710
  the final step:
700
711
 
701
712
  ```ruby
702
713
  #!/usr/bin/env ruby
703
714
  require "llm"
704
- require "open-uri"
705
- require "fileutils"
706
-
707
715
  llm = LLM.openai(key: ENV["KEY"])
708
716
  res = llm.images.edit(
709
717
  image: "/tmp/llm-logo.png",
710
718
  prompt: "add a hat to the logo",
711
719
  )
712
- res.urls.each do |url|
713
- FileUtils.mv OpenURI.open_uri(url).path,
714
- File.join(Dir.home, "logo-with-hat.png")
715
- end
720
+ IO.copy_stream res.images[0], File.join(Dir.home, "logo-with-hat.png")
716
721
  ```
717
722
 
718
723
  #### Variations
719
724
 
720
725
  The following example is focused on creating variations of a local image.
721
726
  The image (`/tmp/llm-logo.png`) is returned to us with five different variations.
722
- The images are then moved to `${HOME}/logo-variation0.png`, `${HOME}/logo-variation1.png`
727
+ The images are then written to `${HOME}/logo-variation0.png`, `${HOME}/logo-variation1.png`
723
728
  and so on as the final step:
724
729
 
725
730
  ```ruby
726
731
  #!/usr/bin/env ruby
727
732
  require "llm"
728
- require "open-uri"
729
- require "fileutils"
730
-
731
733
  llm = LLM.openai(key: ENV["KEY"])
732
734
  res = llm.images.create_variation(
733
735
  image: "/tmp/llm-logo.png",
734
736
  n: 5
735
737
  )
736
- res.urls.each.with_index do |url, index|
737
- FileUtils.mv OpenURI.open_uri(url).path,
738
- File.join(Dir.home, "logo-variation#{index}.png")
738
+ res.images.each.with_index do |image, index|
739
+ IO.copy_stream image,
740
+ File.join(Dir.home, "logo-variation#{index}.png")
739
741
  end
740
742
  ```
741
743
 
@@ -776,6 +778,7 @@ release:
776
778
  ```ruby
777
779
  #!/usr/bin/env ruby
778
780
  require "llm"
781
+ require "pp"
779
782
 
780
783
  ##
781
784
  # List all models
@@ -789,7 +792,7 @@ end
789
792
  model = llm.models.all.find { |m| m.id == "gpt-3.5-turbo" }
790
793
  ses = LLM::Session.new(llm, model: model.id)
791
794
  res = ses.talk "Hello #{model.id} :)"
792
- res.messages.each { |m| puts "[#{m.role}] #{m.content}" }
795
+ pp res.content
793
796
  ```
794
797
 
795
798
  ## Install
@@ -43,6 +43,20 @@ module LLM::Contract
43
43
  raise NotImplementedError, "#{self.class} does not implement '#{__method__}'"
44
44
  end
45
45
 
46
+ ##
47
+ # @return [String]
48
+ # Returns the LLM response
49
+ def content
50
+ messages.find(&:assistant?).content
51
+ end
52
+
53
+ ##
54
+ # @return [Hash]
55
+ # Returns the LLM response after parsing it as JSON
56
+ def content!
57
+ LLM.json.load(content)
58
+ end
59
+
46
60
  ##
47
61
  # @return [LLM::Usage]
48
62
  # Returns usage information
@@ -80,11 +80,6 @@ module LLM::EventStream
80
80
  @cursor = newline + 1
81
81
  yield(line)
82
82
  end
83
- if @cursor < @buffer.length
84
- line = @buffer[@cursor..]
85
- @cursor = @buffer.length
86
- yield(line)
87
- end
88
83
  return if @cursor.zero?
89
84
  @buffer = @buffer[@cursor..] || +""
90
85
  @cursor = 0
data/lib/llm/model.rb ADDED
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # The {LLM::Model LLM::Model} class provides a normalized view of
5
+ # a provider model record returned by the models API.
6
+ class LLM::Model
7
+ ##
8
+ # The provider-specific model payload.
9
+ # @return [LLM::Object]
10
+ attr_reader :raw
11
+
12
+ ##
13
+ # @param [LLM::Object, Hash] raw
14
+ def initialize(raw)
15
+ @raw = raw
16
+ end
17
+
18
+ ##
19
+ # Returns a normalized identifier suitable for API calls.
20
+ # @return [String, nil]
21
+ def id
22
+ normalize_id(raw.id || raw.model || raw.name)
23
+ end
24
+
25
+ ##
26
+ # Returns a display-friendly model name.
27
+ # @return [String, nil]
28
+ def name
29
+ raw.display_name || raw.displayName || id
30
+ end
31
+
32
+ ##
33
+ # Best-effort predicate for chat support.
34
+ # @return [Boolean]
35
+ def chat?
36
+ return true if anthropic?
37
+ return [*(raw.supportedGenerationMethods || [])].include?("generateContent") if gemini?
38
+ openai_compatible_chat?
39
+ end
40
+
41
+ ##
42
+ # Returns a Hash representation of the normalized model.
43
+ # @return [Hash]
44
+ def to_h
45
+ {id:, name:, chat?: chat?}.compact
46
+ end
47
+
48
+ ##
49
+ # @private
50
+ module Collection
51
+ include ::Enumerable
52
+
53
+ ##
54
+ # @yield [model]
55
+ # @yieldparam [LLM::Model] model
56
+ # @return [Enumerator, void]
57
+ def each(&)
58
+ return enum_for(:each) unless block_given?
59
+ models.each(&)
60
+ end
61
+
62
+ ##
63
+ # Returns an element, or a slice, or nil.
64
+ # @return [Object, Array<Object>, nil]
65
+ def [](*pos, **kw)
66
+ models[*pos, **kw]
67
+ end
68
+
69
+ ##
70
+ # @return [Boolean]
71
+ def empty?
72
+ models.empty?
73
+ end
74
+
75
+ ##
76
+ # @return [Integer]
77
+ def size
78
+ models.size
79
+ end
80
+
81
+ ##
82
+ # Returns normalized models.
83
+ # @return [Array<LLM::Model>]
84
+ def models
85
+ @models ||= raw_models.map { LLM::Model.new(_1) }
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def normalize_id(value)
92
+ value&.sub(%r{\Amodels/}, "")
93
+ end
94
+
95
+ def anthropic?
96
+ raw.type == "model" && raw.key?(:display_name) && raw.key?(:created_at)
97
+ end
98
+
99
+ def gemini?
100
+ raw.key?(:supportedGenerationMethods)
101
+ end
102
+
103
+ def openai_compatible_chat?
104
+ value = [id, raw.name, raw.model].compact.join(" ").downcase
105
+ return false if value.include?("embedding")
106
+ return false if value.include?("moderation")
107
+ return false if value.include?("tts")
108
+ return false if value.include?("transcrib")
109
+ return false if value.include?("image")
110
+ return false if value.include?("whisper")
111
+ return false if value.include?("dall")
112
+ return false if value.include?("omni-moderation")
113
+ true
114
+ end
115
+ end
data/lib/llm/provider.rb CHANGED
@@ -37,7 +37,6 @@ class LLM::Provider
37
37
  @timeout = timeout
38
38
  @ssl = ssl
39
39
  @client = persistent ? persistent_client : nil
40
- @tracer = LLM::Tracer::Null.new(self)
41
40
  @base_uri = URI("#{ssl ? "https" : "http"}://#{host}:#{port}/")
42
41
  @headers = {"User-Agent" => "llm.rb v#{LLM::VERSION}"}
43
42
  @monitor = Monitor.new
@@ -48,7 +47,7 @@ class LLM::Provider
48
47
  # @return [String]
49
48
  # @note The secret key is redacted in inspect for security reasons
50
49
  def inspect
51
- "#<#{self.class.name}:0x#{object_id.to_s(16)} @key=[REDACTED] @client=#{@client.inspect} @tracer=#{@tracer.inspect}>"
50
+ "#<#{self.class.name}:0x#{object_id.to_s(16)} @key=[REDACTED] @client=#{@client.inspect} @tracer=#{tracer.inspect}>"
52
51
  end
53
52
 
54
53
  ##
@@ -265,27 +264,48 @@ class LLM::Provider
265
264
 
266
265
  ##
267
266
  # @return [LLM::Tracer]
268
- # Returns an LLM tracer
267
+ # Returns a thread-local tracer
269
268
  def tracer
270
- @tracer
269
+ weakmap[self] || LLM::Tracer::Null.new(self)
271
270
  end
272
271
 
273
272
  ##
274
- # Set the tracer
273
+ # Set a thread-local tracer
275
274
  # @example
276
275
  # llm = LLM.openai(key: ENV["KEY"])
277
- # llm.tracer = LLM::Tracer::Logger.new(llm, path: "/path/to/log.txt")
276
+ # Thread.new do
277
+ # llm.tracer = LLM::Tracer::Logger.new(llm, path: "/path/to/log/1.txt")
278
+ # end
279
+ # Thread.new do
280
+ # llm.tracer = LLM::Tracer::Logger.new(llm, path: "/path/to/log/2.txt")
281
+ # end
278
282
  # # ...
279
283
  # @param [LLM::Tracer] tracer
280
284
  # A tracer
281
285
  # @return [void]
282
286
  def tracer=(tracer)
283
- lock do
284
- @tracer = if tracer.nil?
285
- LLM::Tracer::Null.new(self)
287
+ if tracer.nil?
288
+ if weakmap.respond_to?(:delete)
289
+ weakmap.delete(self)
286
290
  else
287
- tracer
291
+ weakmap[self] = nil
288
292
  end
293
+ else
294
+ weakmap[self] = tracer
295
+ end
296
+ end
297
+
298
+ ##
299
+ # This method configures a provider to use a persistent connection pool
300
+ # via the optional dependency [Net::HTTP::Persistent](https://github.com/drbrain/net-http-persistent)
301
+ # @example
302
+ # llm = LLM.openai(key: ENV["KEY"]).persist!
303
+ # # do something with 'llm'
304
+ # @return [LLM::Provider]
305
+ def persist!
306
+ client = persistent_client
307
+ lock do
308
+ tap { @client = client }
289
309
  end
290
310
  end
291
311
 
@@ -340,9 +360,9 @@ class LLM::Provider
340
360
  # @raise [SystemCallError]
341
361
  # When there is a network error at the operating system level
342
362
  # @return [Net::HTTPResponse]
343
- def execute(request:, operation:, stream: nil, stream_parser: self.stream_parser, model: nil, &b)
344
- tracer = @tracer
345
- span = tracer.on_request_start(operation:, model:)
363
+ def execute(request:, operation:, stream: nil, stream_parser: self.stream_parser, model: nil, inputs: nil, &b)
364
+ tracer = self.tracer
365
+ span = tracer.on_request_start(operation:, model:, inputs:)
346
366
  http = client || transient_client
347
367
  args = (Net::HTTP === http) ? [request] : [URI.join(base_uri, request.path), request]
348
368
  res = if stream
@@ -351,11 +371,12 @@ class LLM::Provider
351
371
  parser = LLM::EventStream::Parser.new
352
372
  parser.register(handler)
353
373
  res.read_body(parser)
354
- # If the handler body is empty, it means the
355
- # response was most likely not streamed or
356
- # parsing has failed. In that case, we fallback
357
- # on the original response body.
358
- res.body = LLM::Object.from(handler.body.empty? ? parser.body : handler.body)
374
+ # If the handler body is empty, the response was
375
+ # most likely not streamed or parsing failed.
376
+ # Preserve the raw body in that case so standard
377
+ # JSON/error handling can parse it later.
378
+ body = handler.body.empty? ? parser.body : handler.body
379
+ res.body = Hash === body || Array === body ? LLM::Object.from(body) : body
359
380
  ensure
360
381
  parser&.free
361
382
  end
@@ -423,14 +444,20 @@ class LLM::Provider
423
444
  end
424
445
 
425
446
  ##
426
- # @return [Hash<Symbol, LLM::Tracer>]
427
- def tracers
428
- self.class.tracers
447
+ # @api private
448
+ def lock(&)
449
+ @monitor.synchronize(&)
429
450
  end
430
451
 
431
452
  ##
432
453
  # @api private
433
- def lock(&)
434
- @monitor.synchronize(&)
454
+ def thread
455
+ Thread.current
456
+ end
457
+
458
+ ##
459
+ # @api private
460
+ def weakmap
461
+ thread[:"llm.provider.weakmap"] ||= ObjectSpace::WeakMap.new
435
462
  end
436
463
  end
@@ -35,7 +35,7 @@ class LLM::Anthropic
35
35
  ex = error
36
36
  @tracer.on_request_error(ex:, span:)
37
37
  ensure
38
- raise(ex)
38
+ raise(ex) if ex
39
39
  end
40
40
 
41
41
  private
@@ -41,7 +41,7 @@ class LLM::Anthropic
41
41
  query = URI.encode_www_form(params)
42
42
  req = Net::HTTP::Get.new("/v1/models?#{query}", headers)
43
43
  res, span, tracer = execute(request: req, operation: "request")
44
- res = ResponseAdapter.adapt(res, type: :enumerable)
44
+ res = ResponseAdapter.adapt(res, type: :models)
45
45
  tracer.on_request_finish(operation: "request", res:, span:)
46
46
  res
47
47
  end
@@ -9,11 +9,20 @@ class LLM::Anthropic
9
9
  ##
10
10
  # @param [Array<LLM::Message>] messages
11
11
  # The messages to adapt
12
- # @return [Array<Hash>]
12
+ # @return [Hash]
13
13
  def adapt(messages, mode: nil)
14
- messages.filter_map do
15
- Completion.new(_1).adapt
14
+ payload = {messages: [], system: []}
15
+ messages.each do |message|
16
+ adapted = Completion.new(message).adapt
17
+ next if adapted.nil?
18
+ if system?(message)
19
+ payload[:system].concat Array(adapted[:content])
20
+ else
21
+ payload[:messages] << adapted
22
+ end
16
23
  end
24
+ payload.delete(:system) if payload[:system].empty?
25
+ payload
17
26
  end
18
27
 
19
28
  private
@@ -25,5 +34,13 @@ class LLM::Anthropic
25
34
  return {} unless tools&.any?
26
35
  {tools: tools.map { _1.respond_to?(:adapt) ? _1.adapt(self) : _1 }}
27
36
  end
37
+
38
+ def system?(message)
39
+ if message.respond_to?(:system?)
40
+ message.system?
41
+ else
42
+ Hash === message and message[:role].to_s == "system"
43
+ end
44
+ end
28
45
  end
29
46
  end
@@ -45,6 +45,18 @@ module LLM::Anthropic::ResponseAdapter
45
45
  body.model
46
46
  end
47
47
 
48
+ ##
49
+ # (see LLM::Contract::Completion#content)
50
+ def content
51
+ super
52
+ end
53
+
54
+ ##
55
+ # (see LLM::Contract::Completion#content!)
56
+ def content!
57
+ super
58
+ end
59
+
48
60
  private
49
61
 
50
62
  def adapt_choices
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Anthropic::ResponseAdapter
4
+ module Models
5
+ include LLM::Model::Collection
6
+
7
+ private
8
+
9
+ def raw_models
10
+ data || []
11
+ end
12
+ end
13
+ end
@@ -7,6 +7,7 @@ class LLM::Anthropic
7
7
  require_relative "response_adapter/completion"
8
8
  require_relative "response_adapter/enumerable"
9
9
  require_relative "response_adapter/file"
10
+ require_relative "response_adapter/models"
10
11
  require_relative "response_adapter/web_search"
11
12
 
12
13
  module_function
@@ -27,6 +28,7 @@ class LLM::Anthropic
27
28
  when :completion then LLM::Anthropic::ResponseAdapter::Completion
28
29
  when :enumerable then LLM::Anthropic::ResponseAdapter::Enumerable
29
30
  when :file then LLM::Anthropic::ResponseAdapter::File
31
+ when :models then LLM::Anthropic::ResponseAdapter::Models
30
32
  when :web_search then LLM::Anthropic::ResponseAdapter::WebSearch
31
33
  else
32
34
  raise ArgumentError, "Unknown response adapter type: #{type.inspect}"
@@ -140,7 +140,8 @@ module LLM
140
140
 
141
141
  def build_complete_request(prompt, params, role)
142
142
  messages = [*(params.delete(:messages) || []), Message.new(role, prompt)]
143
- body = LLM.json.dump({messages: [adapt(messages)].flatten}.merge!(params))
143
+ payload = adapt(messages)
144
+ body = LLM.json.dump(payload.merge!(params))
144
145
  req = Net::HTTP::Post.new("/v1/messages", headers)
145
146
  set_body_stream(req, StringIO.new(body))
146
147
  req
@@ -35,15 +35,15 @@ class LLM::Gemini
35
35
  ex = error
36
36
  @tracer.on_request_error(ex:, span:)
37
37
  ensure
38
- raise(ex)
38
+ raise(ex) if ex
39
39
  end
40
40
 
41
41
  private
42
42
 
43
43
  ##
44
- # @return [LLM::Object]
44
+ # @return [String, LLM::Object]
45
45
  def body
46
- @body ||= LLM.json.load(res.body)
46
+ @body ||= parse_body!
47
47
  end
48
48
 
49
49
  ##
@@ -65,5 +65,20 @@ class LLM::Gemini
65
65
  LLM::Error.new("Unexpected response").tap { _1.response = res }
66
66
  end
67
67
  end
68
+
69
+ ##
70
+ # Tries to parse the response body as a LLM::Object
71
+ # @return [String, LLM::Object]
72
+ def parse_body!
73
+ if String === res.body
74
+ LLM::Object.from LLM.json.load(res.body)
75
+ elsif Hash === res.body
76
+ LLM::Object.from(res.body)
77
+ else
78
+ res.body
79
+ end
80
+ rescue
81
+ res.body
82
+ end
68
83
  end
69
84
  end
@@ -45,6 +45,18 @@ module LLM::Gemini::ResponseAdapter
45
45
  body.modelVersion
46
46
  end
47
47
 
48
+ ##
49
+ # (see LLM::Contract::Completion#content)
50
+ def content
51
+ super
52
+ end
53
+
54
+ ##
55
+ # (see LLM::Contract::Completion#content!)
56
+ def content!
57
+ super
58
+ end
59
+
48
60
  private
49
61
 
50
62
  def adapt_choices