llm.rb 4.7.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +32 -31
  3. data/lib/llm/eventstream/parser.rb +0 -5
  4. data/lib/llm/model.rb +115 -0
  5. data/lib/llm/provider.rb +36 -23
  6. data/lib/llm/providers/anthropic/error_handler.rb +1 -1
  7. data/lib/llm/providers/anthropic/models.rb +1 -1
  8. data/lib/llm/providers/anthropic/request_adapter.rb +20 -3
  9. data/lib/llm/providers/anthropic/response_adapter/models.rb +13 -0
  10. data/lib/llm/providers/anthropic/response_adapter.rb +2 -0
  11. data/lib/llm/providers/anthropic.rb +2 -1
  12. data/lib/llm/providers/gemini/error_handler.rb +18 -3
  13. data/lib/llm/providers/gemini/response_adapter/models.rb +4 -6
  14. data/lib/llm/providers/ollama/error_handler.rb +1 -1
  15. data/lib/llm/providers/ollama/models.rb +1 -1
  16. data/lib/llm/providers/ollama/response_adapter/models.rb +13 -0
  17. data/lib/llm/providers/ollama/response_adapter.rb +2 -0
  18. data/lib/llm/providers/openai/error_handler.rb +18 -3
  19. data/lib/llm/providers/openai/images.rb +17 -11
  20. data/lib/llm/providers/openai/models.rb +1 -1
  21. data/lib/llm/providers/openai/response_adapter/models.rb +13 -0
  22. data/lib/llm/providers/openai/response_adapter.rb +2 -0
  23. data/lib/llm/providers/openai/responses.rb +7 -0
  24. data/lib/llm/providers/openai.rb +9 -2
  25. data/lib/llm/providers/xai/images.rb +7 -6
  26. data/lib/llm/schema/enum.rb +16 -0
  27. data/lib/llm/schema.rb +1 -0
  28. data/lib/llm/tool/param.rb +1 -1
  29. data/lib/llm/tool.rb +1 -1
  30. data/lib/llm/tracer/langsmith.rb +144 -0
  31. data/lib/llm/tracer/logger.rb +8 -0
  32. data/lib/llm/tracer/null.rb +8 -0
  33. data/lib/llm/tracer/telemetry.rb +91 -71
  34. data/lib/llm/tracer.rb +108 -4
  35. data/lib/llm/version.rb +1 -1
  36. data/lib/llm.rb +1 -0
  37. metadata +7 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d4facfaa664a93ec948639191915c027613ed7bd407f38acb79182d4ca10c31
4
- data.tar.gz: 5974b17ea6f4c1317ee3eca9dd7ba9f6e342423cb954fdcf5a7474b53b660e88
3
+ metadata.gz: 696d0be686c58d66ce0904d0ed0aff879906511060704c61adc945b458ed1f37
4
+ data.tar.gz: 7d03d786ff1fdbaa24e470f87d6e0c3c6b5acfc8e6b73c1cda88fdb9c1a4db58
5
5
  SHA512:
6
- metadata.gz: e5b10736981b4bc2b2c0e06e45c6174ba78c869f7d4666da924e893d5582eb133ac6900d46cf0429cc4936d1f4090e58ba4b3f4cf862604e6f1bea3b86c95d2e
7
- data.tar.gz: 7a1ea1a6c30999f595139381c788aa4876d569ac03dde0cbfa9b1f302c7b5c6c0a7bc2c394ce754041d2aa417587b2d05f90092161cc974aa5fae5d332469002
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
@@ -181,18 +181,27 @@ 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
@@ -203,6 +212,7 @@ 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
218
  res.content!
@@ -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
@@ -675,68 +691,53 @@ puts res.text # => "Good morning."
675
691
  Some but not all LLM providers implement image generation capabilities that
676
692
  can create new images from a prompt, or edit an existing image with a
677
693
  prompt. The following example uses the OpenAI provider to create an
678
- 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
679
695
  `${HOME}/dogonrocket.png` as the final step:
680
696
 
681
697
  ```ruby
682
698
  #!/usr/bin/env ruby
683
699
  require "llm"
684
- require "open-uri"
685
- require "fileutils"
686
-
687
700
  llm = LLM.openai(key: ENV["KEY"])
688
701
  res = llm.images.create(prompt: "a dog on a rocket to the moon")
689
- res.urls.each do |url|
690
- FileUtils.mv OpenURI.open_uri(url).path,
691
- File.join(Dir.home, "dogonrocket.png")
692
- end
702
+ IO.copy_stream res.images[0], File.join(Dir.home, "dogonrocket.png")
693
703
  ```
694
704
 
695
705
  #### Edit
696
706
 
697
707
  The following example is focused on editing a local image with the aid
698
708
  of a prompt. The image (`/tmp/llm-logo.png`) is returned to us with a hat.
699
- 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
700
710
  the final step:
701
711
 
702
712
  ```ruby
703
713
  #!/usr/bin/env ruby
704
714
  require "llm"
705
- require "open-uri"
706
- require "fileutils"
707
-
708
715
  llm = LLM.openai(key: ENV["KEY"])
709
716
  res = llm.images.edit(
710
717
  image: "/tmp/llm-logo.png",
711
718
  prompt: "add a hat to the logo",
712
719
  )
713
- res.urls.each do |url|
714
- FileUtils.mv OpenURI.open_uri(url).path,
715
- File.join(Dir.home, "logo-with-hat.png")
716
- end
720
+ IO.copy_stream res.images[0], File.join(Dir.home, "logo-with-hat.png")
717
721
  ```
718
722
 
719
723
  #### Variations
720
724
 
721
725
  The following example is focused on creating variations of a local image.
722
726
  The image (`/tmp/llm-logo.png`) is returned to us with five different variations.
723
- 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`
724
728
  and so on as the final step:
725
729
 
726
730
  ```ruby
727
731
  #!/usr/bin/env ruby
728
732
  require "llm"
729
- require "open-uri"
730
- require "fileutils"
731
-
732
733
  llm = LLM.openai(key: ENV["KEY"])
733
734
  res = llm.images.create_variation(
734
735
  image: "/tmp/llm-logo.png",
735
736
  n: 5
736
737
  )
737
- res.urls.each.with_index do |url, index|
738
- FileUtils.mv OpenURI.open_uri(url).path,
739
- 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")
740
741
  end
741
742
  ```
742
743
 
@@ -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,34 @@ 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
289
295
  end
290
296
  end
291
297
 
@@ -354,9 +360,9 @@ class LLM::Provider
354
360
  # @raise [SystemCallError]
355
361
  # When there is a network error at the operating system level
356
362
  # @return [Net::HTTPResponse]
357
- def execute(request:, operation:, stream: nil, stream_parser: self.stream_parser, model: nil, &b)
358
- tracer = @tracer
359
- 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:)
360
366
  http = client || transient_client
361
367
  args = (Net::HTTP === http) ? [request] : [URI.join(base_uri, request.path), request]
362
368
  res = if stream
@@ -365,11 +371,12 @@ class LLM::Provider
365
371
  parser = LLM::EventStream::Parser.new
366
372
  parser.register(handler)
367
373
  res.read_body(parser)
368
- # If the handler body is empty, it means the
369
- # response was most likely not streamed or
370
- # parsing has failed. In that case, we fallback
371
- # on the original response body.
372
- 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
373
380
  ensure
374
381
  parser&.free
375
382
  end
@@ -437,14 +444,20 @@ class LLM::Provider
437
444
  end
438
445
 
439
446
  ##
440
- # @return [Hash<Symbol, LLM::Tracer>]
441
- def tracers
442
- self.class.tracers
447
+ # @api private
448
+ def lock(&)
449
+ @monitor.synchronize(&)
443
450
  end
444
451
 
445
452
  ##
446
453
  # @api private
447
- def lock(&)
448
- @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
449
462
  end
450
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
@@ -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
@@ -2,13 +2,11 @@
2
2
 
3
3
  module LLM::Gemini::ResponseAdapter
4
4
  module Models
5
- include ::Enumerable
6
- def each(&)
7
- return enum_for(:each) unless block_given?
8
- models.each { yield(_1) }
9
- end
5
+ include LLM::Model::Collection
6
+
7
+ private
10
8
 
11
- def models
9
+ def raw_models
12
10
  body.models || []
13
11
  end
14
12
  end
@@ -35,7 +35,7 @@ class LLM::Ollama
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
@@ -44,7 +44,7 @@ class LLM::Ollama
44
44
  query = URI.encode_www_form(params)
45
45
  req = Net::HTTP::Get.new("/api/tags?#{query}", headers)
46
46
  res, span, tracer = execute(request: req, operation: "request")
47
- res = LLM::Response.new(res)
47
+ res = ResponseAdapter.adapt(res, type: :models)
48
48
  tracer.on_request_finish(operation: "request", res:, span:)
49
49
  res
50
50
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Ollama::ResponseAdapter
4
+ module Models
5
+ include LLM::Model::Collection
6
+
7
+ private
8
+
9
+ def raw_models
10
+ body.models || []
11
+ end
12
+ end
13
+ end
@@ -6,6 +6,7 @@ class LLM::Ollama
6
6
  module ResponseAdapter
7
7
  require_relative "response_adapter/completion"
8
8
  require_relative "response_adapter/embedding"
9
+ require_relative "response_adapter/models"
9
10
 
10
11
  module_function
11
12
 
@@ -24,6 +25,7 @@ class LLM::Ollama
24
25
  case type
25
26
  when :completion then LLM::Ollama::ResponseAdapter::Completion
26
27
  when :embedding then LLM::Ollama::ResponseAdapter::Embedding
28
+ when :models then LLM::Ollama::ResponseAdapter::Models
27
29
  else
28
30
  raise ArgumentError, "Unknown response adapter type: #{type.inspect}"
29
31
  end
@@ -35,15 +35,15 @@ class LLM::OpenAI
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
  ##
@@ -79,5 +79,20 @@ class LLM::OpenAI
79
79
  LLM::InvalidRequestError.new(error["message"]).tap { _1.response = res }
80
80
  end
81
81
  end
82
+
83
+ ##
84
+ # Tries to parse the response body as a LLM::Object
85
+ # @return [String, LLM::Object]
86
+ def parse_body!
87
+ if String === res.body
88
+ LLM::Object.from LLM.json.load(res.body)
89
+ elsif Hash === res.body
90
+ LLM::Object.from(res.body)
91
+ else
92
+ res.body
93
+ end
94
+ rescue
95
+ res.body
96
+ end
82
97
  end
83
98
  end