llm.rb 2.1.0 → 3.0.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 +6 -0
  3. data/lib/llm/bot.rb +4 -4
  4. data/lib/llm/buffer.rb +0 -9
  5. data/lib/llm/contract/completion.rb +57 -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 +7 -28
  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 +2 -2
  17. data/lib/llm/object.rb +23 -8
  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 +66 -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 +6 -6
  30. data/lib/llm/providers/anthropic.rb +8 -11
  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 +67 -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 +37 -32
  50. data/lib/llm/providers/gemini.rb +10 -14
  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 +61 -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 +8 -10
  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 +62 -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 +6 -5
  82. data/lib/llm/providers/openai/vector_stores.rb +8 -9
  83. data/lib/llm/providers/openai.rb +12 -14
  84. data/lib/llm/response.rb +2 -5
  85. data/lib/llm/usage.rb +10 -0
  86. data/lib/llm/version.rb +1 -1
  87. data/lib/llm.rb +33 -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: 2e60be1fa699baabf9a1df129d0263d0c2b6fecc3ce2b818128eed319aa7bb18
4
+ data.tar.gz: a39d32cd9cfcfb7fa4152ba3e02960522b6ae4b5d6e22705b846f5b9dcd2972b
5
5
  SHA512:
6
- metadata.gz: 1b969f525f44192999bcb3ea45aec1e53283d6bb4347b6852bc0bfe9095ecf4d9a9d21a42f7b04e6c530329bf96deb0fa0508757b9e0c185b8944d1630da1648
7
- data.tar.gz: 239ff739c3f9bfdfbe8f574a1045acac9ace3bbb0ddca3a92958fee9319e4834c2f057a61c9b0c418979111050ade4968a01ec73862f70278b75cbe7752c9815
6
+ metadata.gz: 4d434afe1a6acaeef6036178c5914500e047450ee7d92999dedd80f66a6be22c73f4d788f86739842ee283c579b68476c0a114f50e25e7db7bbfa6e6cdf1a5bc
7
+ data.tar.gz: e818753b0b06cf4053652d11b2e3c79d7ef58de0b0acc5fb7fa147e55c693eee583f1c9e1f80b08096de95247c03644fad9e596f4511d57a99d2abd5339087c2
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
@@ -102,6 +106,7 @@ bot.messages.select(&:assistant?).each { print "[#{it.role}] ", it.content, "\n"
102
106
  #### General
103
107
  - ✅ A single unified interface for multiple providers
104
108
  - 📦 Zero dependencies outside Ruby's standard library
109
+ - 🧩 Choose your own JSON parser (JSON stdlib, Oj, Yajl, etc)
105
110
  - 🚀 Simple, composable API
106
111
  - ♻️ Optional: per-provider, process-wide connection pool via net-http-persistent
107
112
 
@@ -115,6 +120,7 @@ bot.messages.select(&:assistant?).each { print "[#{it.role}] ", it.content, "\n"
115
120
  - 🗣️ Text-to-speech, transcription, and translation
116
121
  - 🖼️ Image generation, editing, and variation support
117
122
  - 📎 File uploads and prompt-aware file interaction
123
+ - 📦 Streams multipart uploads and avoids buffering large files in memory
118
124
  - 💡 Multimodal prompts (text, documents, audio, images, videos, URLs, etc)
119
125
 
120
126
  #### Embeddings
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,57 @@
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 total number of tokens
35
+ def total_tokens
36
+ raise NotImplementedError, "#{self.class} does not implement '#{__method__}'"
37
+ end
38
+
39
+ ##
40
+ # @return [LLM::Usage]
41
+ # Returns usage information
42
+ def usage
43
+ LLM::Usage.new(
44
+ input_tokens:,
45
+ output_tokens:,
46
+ total_tokens:
47
+ )
48
+ end
49
+
50
+ ##
51
+ # @return [String]
52
+ # Returns the model name
53
+ def model
54
+ raise NotImplementedError, "#{self.class} does not implement '#{__method__}'"
55
+ end
56
+ end
57
+ 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
data/lib/llm/error.rb CHANGED
@@ -4,40 +4,40 @@ module LLM
4
4
  ##
5
5
  # The superclass of all LLM errors
6
6
  class Error < RuntimeError
7
+ ##
8
+ # @return [Net::HTTPResponse, nil]
9
+ # Returns the response associated with an error, or nil
10
+ attr_accessor :response
11
+
7
12
  def initialize(...)
8
13
  block_given? ? yield(self) : nil
9
14
  super
10
15
  end
11
- end
12
-
13
- ##
14
- # The superclass of all HTTP protocol errors
15
- class ResponseError < Error
16
- ##
17
- # @return [Net::HTTPResponse]
18
- # Returns the response associated with an error
19
- attr_accessor :response
20
16
 
21
17
  def message
22
- [super, response.body].join("\n")
18
+ if response
19
+ [super, response.body].join("\n")
20
+ else
21
+ super
22
+ end
23
23
  end
24
24
  end
25
25
 
26
26
  ##
27
27
  # HTTPUnauthorized
28
- UnauthorizedError = Class.new(ResponseError)
28
+ UnauthorizedError = Class.new(Error)
29
29
 
30
30
  ##
31
31
  # HTTPTooManyRequests
32
- RateLimitError = Class.new(ResponseError)
32
+ RateLimitError = Class.new(Error)
33
33
 
34
34
  ##
35
35
  # HTTPServerError
36
- ServerError = Class.new(ResponseError)
36
+ ServerError = Class.new(Error)
37
37
 
38
38
  ##
39
39
  # When no images are found in a response
40
- NoImageError = Class.new(ResponseError)
40
+ NoImageError = Class.new(Error)
41
41
 
42
42
  ##
43
43
  # When an given an input object that is not understood
@@ -46,4 +46,12 @@ module LLM
46
46
  ##
47
47
  # When given a prompt object that is not understood
48
48
  PromptError = Class.new(FormatError)
49
+
50
+ ##
51
+ # When given an invalid request
52
+ InvalidRequestError = Class.new(Error)
53
+
54
+ ##
55
+ # When the context window is exceeded
56
+ ContextWindowError = Class.new(InvalidRequestError)
49
57
  end
@@ -17,9 +17,10 @@ module LLM
17
17
  # @return [void]
18
18
  def on_data(event)
19
19
  return if event.end?
20
- chunk = JSON.parse(event.value)
20
+ chunk = LLM.json.load(event.value)
21
+ return unless chunk
21
22
  @parser.parse!(chunk)
22
- rescue JSON::ParserError
23
+ rescue *LLM.json.parser_error
23
24
  end
24
25
 
25
26
  ##
@@ -31,9 +32,10 @@ module LLM
31
32
  # @return [void]
32
33
  def on_chunk(event)
33
34
  return if event.end?
34
- chunk = JSON.parse(event.chunk)
35
+ chunk = LLM.json.load(event.chunk)
36
+ return unless chunk
35
37
  @parser.parse!(chunk)
36
- rescue JSON::ParserError
38
+ rescue *LLM.json.parser_error
37
39
  end
38
40
 
39
41
  ##
@@ -7,9 +7,9 @@ module LLM::EventStream
7
7
  ##
8
8
  # @return [LLM::EventStream::Parser]
9
9
  def initialize
10
- @buffer = StringIO.new
10
+ @buffer = +""
11
11
  @events = Hash.new { |h, k| h[k] = [] }
12
- @offset = 0
12
+ @cursor = 0
13
13
  @visitors = []
14
14
  end
15
15
 
@@ -34,8 +34,7 @@ module LLM::EventStream
34
34
  # Append an event to the internal buffer
35
35
  # @return [void]
36
36
  def <<(event)
37
- io = StringIO.new(event)
38
- IO.copy_stream io, @buffer
37
+ @buffer << event
39
38
  each_line { parse!(_1) }
40
39
  end
41
40
 
@@ -43,15 +42,15 @@ module LLM::EventStream
43
42
  # Returns the internal buffer
44
43
  # @return [String]
45
44
  def body
46
- @buffer.string
45
+ @buffer.dup
47
46
  end
48
47
 
49
48
  ##
50
49
  # Free the internal buffer
51
50
  # @return [void]
52
51
  def free
53
- @buffer.truncate(0)
54
- @buffer.rewind
52
+ @buffer.clear
53
+ @cursor = 0
55
54
  end
56
55
 
57
56
  private
@@ -76,13 +75,19 @@ module LLM::EventStream
76
75
  end
77
76
 
78
77
  def each_line
79
- string.each_line.with_index do
80
- next if _2 < @offset
81
- yield(_1)
82
- @offset += 1
78
+ while (newline = @buffer.index("\n", @cursor))
79
+ line = @buffer[@cursor..newline]
80
+ @cursor = newline + 1
81
+ yield(line)
83
82
  end
83
+ if @cursor < @buffer.length
84
+ line = @buffer[@cursor..]
85
+ @cursor = @buffer.length
86
+ yield(line)
87
+ end
88
+ return if @cursor.zero?
89
+ @buffer = @buffer[@cursor..] || +""
90
+ @cursor = 0
84
91
  end
85
-
86
- def string = @buffer.string
87
92
  end
88
93
  end
data/lib/llm/function.rb CHANGED
@@ -149,7 +149,7 @@ class LLM::Function
149
149
 
150
150
  ##
151
151
  # @return [Hash]
152
- def format(provider)
152
+ def adapt(provider)
153
153
  case provider.class.to_s
154
154
  when "LLM::Gemini"
155
155
  {name: @name, description: @description, parameters: @params}.compact
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ ##
5
+ # The JSONAdapter class defines the interface for JSON parsers
6
+ # that can be used by the library when dealing with JSON. The
7
+ # following parsers are supported:
8
+ # * {LLM::JSONAdapter::JSON LLM::JSONAdapter::JSON} (default)
9
+ # * {LLM::JSONAdapter::Oj LLM::JSONAdapter::Oj}
10
+ # * {LLM::JSONAdapter::Yajl LLM::JSONAdapter::Yajl}
11
+ #
12
+ # @example Change parser
13
+ # LLM.json = LLM::JSONAdapter::Oj
14
+ class JSONAdapter
15
+ ##
16
+ # @return [String]
17
+ # Returns a JSON string representation of the given object
18
+ def self.dump(*) = raise NotImplementedError
19
+
20
+ ##
21
+ # @return [Object]
22
+ # Returns a Ruby object parsed from the given JSON string
23
+ def self.load(*) = raise NotImplementedError
24
+
25
+ ##
26
+ # @return [Exception]
27
+ # Returns the error raised when parsing fails
28
+ def self.parser_error = [StandardError]
29
+ end
30
+
31
+ ##
32
+ # The {LLM::JSONAdapter::JSON LLM::JSONAdapter::JSON} class
33
+ # provides a JSON adapter backed by the standard library
34
+ # JSON module.
35
+ class JSONAdapter::JSON < JSONAdapter
36
+ ##
37
+ # @return (see JSONAdapter#dump)
38
+ def self.dump(obj)
39
+ require "json" unless defined?(::JSON)
40
+ ::JSON.dump(obj)
41
+ end
42
+
43
+ ##
44
+ # @return (see JSONAdapter#load)
45
+ def self.load(string)
46
+ require "json" unless defined?(::JSON)
47
+ ::JSON.parse(string)
48
+ end
49
+
50
+ ##
51
+ # @return (see JSONAdapter#parser_error)
52
+ def self.parser_error
53
+ require "json" unless defined?(::JSON)
54
+ [::JSON::ParserError]
55
+ end
56
+ end
57
+
58
+ ##
59
+ # The {LLM::JSONAdapter::Oj LLM::JSONAdapter::Oj} class
60
+ # provides a JSON adapter backed by the Oj gem.
61
+ class JSONAdapter::Oj < JSONAdapter
62
+ ##
63
+ # @return (see JSONAdapter#dump)
64
+ def self.dump(obj)
65
+ require "oj" unless defined?(::Oj)
66
+ ::Oj.dump(obj)
67
+ end
68
+
69
+ ##
70
+ # @return (see JSONAdapter#load)
71
+ def self.load(string)
72
+ require "oj" unless defined?(::Oj)
73
+ ::Oj.load(string, mode: :compat, symbol_keys: false, symbolize_names: false)
74
+ end
75
+
76
+ ##
77
+ # @return (see JSONAdapter#parser_error)
78
+ def self.parser_error
79
+ require "oj" unless defined?(::Oj)
80
+ [::Oj::ParseError, ::EncodingError]
81
+ end
82
+ end
83
+
84
+ ##
85
+ # The {LLM::JSONAdapter::Yajl LLM::JSONAdapter::Yajl} class
86
+ # provides a JSON adapter backed by the Yajl gem.
87
+ class JSONAdapter::Yajl < JSONAdapter
88
+ ##
89
+ # @return (see JSONAdapter#dump)
90
+ def self.dump(obj)
91
+ require "yajl" unless defined?(::Yajl)
92
+ ::Yajl::Encoder.encode(obj)
93
+ end
94
+
95
+ ##
96
+ # @return (see JSONAdapter#load)
97
+ def self.load(string)
98
+ require "yajl" unless defined?(::Yajl)
99
+ ::Yajl::Parser.parse(string)
100
+ end
101
+
102
+ ##
103
+ # @return (see JSONAdapter#parser_error)
104
+ def self.parser_error
105
+ require "yajl" unless defined?(::Yajl)
106
+ [::Yajl::ParseError]
107
+ end
108
+ end
109
+ end
data/lib/llm/message.rb CHANGED
@@ -54,7 +54,7 @@ module LLM
54
54
  # Try to parse the content as JSON
55
55
  # @return [Hash]
56
56
  def content!
57
- JSON.parse(content)
57
+ LLM.json.load(content)
58
58
  end
59
59
 
60
60
  ##
@@ -67,20 +67,6 @@ module LLM
67
67
  end
68
68
  end
69
69
 
70
- ##
71
- # Marks the message as read
72
- # @return [void]
73
- def read!
74
- @read = true
75
- end
76
-
77
- ##
78
- # Returns true when the message has been read
79
- # @return [Boolean]
80
- def read?
81
- @read
82
- end
83
-
84
70
  ##
85
71
  # Returns true when the message is an assistant message
86
72
  # @return [Boolean]
@@ -134,25 +120,18 @@ module LLM
134
120
  # Returns annotations associated with the message
135
121
  # @return [Array<LLM::Object>]
136
122
  def annotations
137
- @annotations ||= LLM::Object.from_hash(extra["annotations"] || [])
123
+ @annotations ||= LLM::Object.from(extra["annotations"] || [])
138
124
  end
139
125
 
140
126
  ##
141
127
  # @note
142
128
  # This method returns token usage for assistant messages,
143
- # and it returns an empty object for non-assistant messages
129
+ # and it returns nil for non-assistant messages
144
130
  # Returns token usage statistics
145
- # @return [LLM::Object]
131
+ # @return [LLM::Object, nil]
146
132
  def usage
147
- @usage ||= if response
148
- LLM::Object.from_hash({
149
- input_tokens: response.prompt_tokens || 0,
150
- output_tokens: response.completion_tokens || 0,
151
- total_tokens: response.total_tokens || 0
152
- })
153
- else
154
- LLM::Object.from_hash({})
155
- end
133
+ return nil unless response
134
+ @usage ||= response.usage
156
135
  end
157
136
  alias_method :token_usage, :usage
158
137
 
@@ -168,7 +147,7 @@ module LLM
168
147
  private
169
148
 
170
149
  def tool_calls
171
- @tool_calls ||= LLM::Object.from_hash(@extra[:tool_calls] || [])
150
+ @tool_calls ||= LLM::Object.from(@extra[:tool_calls] || [])
172
151
  end
173
152
 
174
153
  def tools
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Multipart
4
+ ##
5
+ # @private
6
+ # Wraps an Enumerator as an IO-like object for streaming bodies.
7
+ class EnumeratorIO
8
+ ##
9
+ # @param [Enumerator] enum
10
+ # The enumerator yielding body chunks
11
+ def initialize(enum)
12
+ @enum = enum
13
+ @buffer = +""
14
+ @eof = false
15
+ end
16
+
17
+ ##
18
+ # Reads bytes from the stream
19
+ # @param [Integer, nil] length
20
+ # The number of bytes to read (all when nil)
21
+ # @param [String] outbuf
22
+ # The buffer to fill
23
+ # @return [String, nil]
24
+ # Returns the data read, or nil on EOF
25
+ def read(length = nil, outbuf = +"")
26
+ outbuf.clear
27
+ if length.nil?
28
+ read_all(outbuf)
29
+ else
30
+ read_chunk(length, outbuf)
31
+ end
32
+ end
33
+
34
+ ##
35
+ # Returns true when no more data is available
36
+ # @return [Boolean]
37
+ def eof?
38
+ @eof && @buffer.empty?
39
+ end
40
+
41
+ ##
42
+ # Raises when called, the stream is not rewindable
43
+ # @raise [IOError]
44
+ def rewind
45
+ raise IOError, "stream is not rewindable"
46
+ end
47
+
48
+ private
49
+
50
+ def read_all(outbuf)
51
+ fill_buffer
52
+ return nil if eof?
53
+ outbuf << @buffer
54
+ @buffer.clear
55
+ while (chunk = next_chunk)
56
+ outbuf << chunk
57
+ end
58
+ @eof = true
59
+ outbuf
60
+ end
61
+
62
+ def read_chunk(length, outbuf)
63
+ fill_buffer while @buffer.bytesize < length && !@eof
64
+ return nil if eof?
65
+ outbuf << @buffer.byteslice(0, length)
66
+ @buffer = @buffer.byteslice(length..-1) || +""
67
+ outbuf
68
+ end
69
+
70
+ def fill_buffer
71
+ return if @eof
72
+ chunk = next_chunk
73
+ if chunk
74
+ @buffer << chunk
75
+ else
76
+ @eof = true
77
+ end
78
+ end
79
+
80
+ def next_chunk
81
+ @enum.next
82
+ rescue StopIteration
83
+ nil
84
+ end
85
+ end
86
+ end