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
data/lib/llm/multipart.rb CHANGED
@@ -5,6 +5,8 @@
5
5
  # @private
6
6
  class LLM::Multipart
7
7
  require "securerandom"
8
+ require_relative "multipart/enumerator_io"
9
+ CHUNK_SIZE = 16 * 1024
8
10
 
9
11
  ##
10
12
  # @return [String]
@@ -27,69 +29,36 @@ class LLM::Multipart
27
29
  end
28
30
 
29
31
  ##
30
- # Returns the multipart request body
31
- # @return [String]
32
+ # Returns the multipart request body as a stream
33
+ # @return [LLM::Multipart::EnumeratorIO]
32
34
  def body
33
- io = StringIO.new("".b)
34
- [*parts, StringIO.new("--#{@boundary}--\r\n".b)].each { IO.copy_stream(_1.tap(&:rewind), io) }
35
- io.tap(&:rewind)
35
+ LLM::Multipart::EnumeratorIO.new(enum_for(:each_part))
36
36
  end
37
37
 
38
38
  private
39
39
 
40
40
  attr_reader :params
41
41
 
42
- def file(locals, file)
43
- locals = locals.merge(attributes(file))
44
- build_file(locals) do |body|
45
- IO.copy_stream(file.path, body)
46
- body << "\r\n"
47
- end
48
- end
49
-
50
- def form(locals, value)
51
- locals = locals.merge(value:)
52
- build_form(locals) do |body|
53
- body << value.to_s
54
- body << "\r\n"
55
- end
56
- end
57
-
58
- def build_file(locals)
59
- StringIO.new("".b).tap do |io|
60
- io << "--#{locals[:boundary]}" \
61
- "\r\n" \
62
- "Content-Disposition: form-data; name=\"#{locals[:key]}\";" \
63
- "filename=\"#{locals[:filename]}\"" \
64
- "\r\n" \
65
- "Content-Type: #{locals[:content_type]}" \
66
- "\r\n\r\n"
67
- yield(io)
68
- end
69
- end
70
-
71
- def build_form(locals)
72
- StringIO.new("".b).tap do |io|
73
- io << "--#{locals[:boundary]}" \
74
- "\r\n" \
75
- "Content-Disposition: form-data; name=\"#{locals[:key]}\"" \
76
- "\r\n\r\n"
77
- yield(io)
78
- end
79
- end
80
-
81
- ##
82
- # Returns the multipart request body parts
83
- # @return [Array<String>]
84
- def parts
85
- params.map do |key, value|
42
+ def each_part
43
+ params.each do |key, value|
86
44
  locals = {key: key.to_s.b, boundary: boundary.to_s.b}
87
45
  if value.respond_to?(:path)
88
- file(locals, value)
46
+ locals = locals.merge(attributes(value))
47
+ yield file_header(locals)
48
+ File.open(value.path, "rb") do |io|
49
+ while (chunk = io.read(CHUNK_SIZE))
50
+ yield chunk
51
+ end
52
+ end
53
+ yield "\r\n".b
89
54
  else
90
- form(locals, value)
55
+ locals = locals.merge(value:)
56
+ yield form_header(locals)
57
+ yield value.to_s.b
58
+ yield "\r\n".b
91
59
  end
92
60
  end
61
+ yield "--#{@boundary}--\r\n".b
93
62
  end
94
63
 
95
64
  def attributes(file)
@@ -98,4 +67,16 @@ class LLM::Multipart
98
67
  content_type: LLM::Mime[file].b
99
68
  }
100
69
  end
70
+
71
+ def file_header(locals)
72
+ "--#{locals[:boundary]}\r\n" \
73
+ "Content-Disposition: form-data; name=\"#{locals[:key]}\";" \
74
+ "filename=\"#{locals[:filename]}\"\r\n" \
75
+ "Content-Type: #{locals[:content_type]}\r\n\r\n"
76
+ end
77
+
78
+ def form_header(locals)
79
+ "--#{locals[:boundary]}\r\n" \
80
+ "Content-Disposition: form-data; name=\"#{locals[:key]}\"\r\n\r\n"
81
+ end
101
82
  end
@@ -6,17 +6,17 @@ class LLM::Object
6
6
  module Builder
7
7
  ##
8
8
  # @example
9
- # obj = LLM::Object.from_hash(person: {name: 'John'})
9
+ # obj = LLM::Object.from(person: {name: 'John'})
10
10
  # obj.person.name # => 'John'
11
11
  # obj.person.class # => LLM::Object
12
12
  # @param [Hash, LLM::Object, Array] obj
13
13
  # A Hash object
14
14
  # @return [LLM::Object]
15
15
  # An LLM::Object object initialized by visiting `obj` with recursion
16
- def from_hash(obj)
16
+ def from(obj)
17
17
  case obj
18
- when self then from_hash(obj.to_h)
19
- when Array then obj.map { |v| from_hash(v) }
18
+ when self then from(obj.to_h)
19
+ when Array then obj.map { |v| from(v) }
20
20
  else
21
21
  visited = {}
22
22
  obj.each { visited[_1] = visit(_2) }
@@ -28,8 +28,8 @@ class LLM::Object
28
28
 
29
29
  def visit(value)
30
30
  case value
31
- when self then from_hash(value.to_h)
32
- when Hash then from_hash(value)
31
+ when self then from(value.to_h)
32
+ when Hash then from(value)
33
33
  when Array then value.map { |v| visit(v) }
34
34
  else value
35
35
  end
@@ -26,11 +26,11 @@ class LLM::Object
26
26
  alias_method :is_a?, :kind_of?
27
27
 
28
28
  def respond_to?(m, include_private = false)
29
- @h.key?(m.to_sym) || self.class.instance_methods.include?(m) || super
29
+ !!key(m) || self.class.method_defined?(m) || super
30
30
  end
31
31
 
32
32
  def respond_to_missing?(m, include_private = false)
33
- @h.key?(m.to_sym) || super
33
+ !!key(m) || super
34
34
  end
35
35
 
36
36
  def object_id
data/lib/llm/object.rb CHANGED
@@ -17,7 +17,7 @@ class LLM::Object < BasicObject
17
17
  # @param [Hash] h
18
18
  # @return [LLM::Object]
19
19
  def initialize(h = {})
20
- @h = h.transform_keys(&:to_sym) || h
20
+ @h = h || {}
21
21
  end
22
22
 
23
23
  ##
@@ -33,7 +33,7 @@ class LLM::Object < BasicObject
33
33
  # @param [Symbol, #to_sym] k
34
34
  # @return [Object]
35
35
  def [](k)
36
- @h[k.to_sym]
36
+ @h[key(k)]
37
37
  end
38
38
 
39
39
  ##
@@ -41,7 +41,7 @@ class LLM::Object < BasicObject
41
41
  # @param [Object] v
42
42
  # @return [void]
43
43
  def []=(k, v)
44
- @h[k.to_sym] = v
44
+ @h[k.to_s] = v
45
45
  end
46
46
 
47
47
  ##
@@ -59,9 +59,14 @@ class LLM::Object < BasicObject
59
59
  ##
60
60
  # @return [Hash]
61
61
  def to_h
62
- @h
62
+ @h.dup
63
+ end
64
+
65
+ ##
66
+ # @return [Hash]
67
+ def to_hash
68
+ @h.transform_keys(&:to_sym)
63
69
  end
64
- alias_method :to_hash, :to_h
65
70
 
66
71
  ##
67
72
  # @return [Object, nil]
@@ -79,9 +84,19 @@ class LLM::Object < BasicObject
79
84
 
80
85
  def method_missing(m, *args, &b)
81
86
  if m.to_s.end_with?("=")
82
- @h[m[0..-2].to_sym] = args.first
83
- elsif @h.key?(m)
84
- @h[m]
87
+ self[m[0..-2]] = args.first
88
+ elsif k = key(m)
89
+ @h[k]
90
+ else
91
+ nil
92
+ end
93
+ end
94
+
95
+ def key(k)
96
+ if @h.key?(k.to_s)
97
+ k.to_s
98
+ elsif @h.key?(k.to_sym)
99
+ k.to_sym
85
100
  else
86
101
  nil
87
102
  end
data/lib/llm/provider.rb CHANGED
@@ -280,7 +280,7 @@ class LLM::Provider
280
280
  # When authentication fails
281
281
  # @raise [LLM::Error::RateLimit]
282
282
  # When the rate limit is exceeded
283
- # @raise [LLM::Error::ResponseError]
283
+ # @raise [LLM::Error]
284
284
  # When any other unsuccessful status code is returned
285
285
  # @raise [SystemCallError]
286
286
  # When there is a network error at the operating system level
@@ -297,7 +297,7 @@ class LLM::Provider
297
297
  # response was most likely not streamed or
298
298
  # parsing has failed. In that case, we fallback
299
299
  # on the original response body.
300
- res.body = handler.body.empty? ? parser.body.dup : handler.body
300
+ res.body = LLM::Object.from(handler.body.empty? ? parser.body : handler.body)
301
301
  ensure
302
302
  parser&.free
303
303
  end
@@ -315,9 +315,17 @@ class LLM::Provider
315
315
  # @return [Net::HTTPResponse]
316
316
  def handle_response(res)
317
317
  case res
318
- when Net::HTTPOK then res
318
+ when Net::HTTPOK then res.body = parse_response(res)
319
319
  else error_handler.new(res).raise_error!
320
320
  end
321
+ res
322
+ end
323
+
324
+ def parse_response(res)
325
+ case res["content-type"]
326
+ when %r|\Aapplication/json\s*| then LLM::Object.from(LLM.json.load(res.body))
327
+ else res.body
328
+ end
321
329
  end
322
330
 
323
331
  ##
@@ -29,7 +29,7 @@ class LLM::Anthropic
29
29
  when Net::HTTPTooManyRequests
30
30
  raise LLM::RateLimitError.new { _1.response = res }, "Too many requests"
31
31
  else
32
- raise LLM::ResponseError.new { _1.response = res }, "Unexpected response"
32
+ raise LLM::Error.new { _1.response = res }, "Unexpected response"
33
33
  end
34
34
  end
35
35
  end
@@ -15,7 +15,6 @@ class LLM::Anthropic
15
15
  # bot.chat ["Tell me about this PDF", file]
16
16
  # bot.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
17
17
  class Files
18
- require_relative "response/file"
19
18
  ##
20
19
  # Returns a new Files object
21
20
  # @param provider [LLM::Provider]
@@ -40,7 +39,7 @@ class LLM::Anthropic
40
39
  query = URI.encode_www_form(params)
41
40
  req = Net::HTTP::Get.new("/v1/files?#{query}", headers)
42
41
  res = execute(request: req)
43
- LLM::Response.new(res).extend(LLM::Anthropic::Response::Enumerable)
42
+ ResponseAdapter.adapt(res, type: :enumerable)
44
43
  end
45
44
 
46
45
  ##
@@ -59,7 +58,7 @@ class LLM::Anthropic
59
58
  req["content-type"] = multi.content_type
60
59
  set_body_stream(req, multi.body)
61
60
  res = execute(request: req)
62
- LLM::Response.new(res).extend(LLM::Anthropic::Response::File)
61
+ ResponseAdapter.adapt(res, type: :file)
63
62
  end
64
63
 
65
64
  ##
@@ -78,7 +77,7 @@ class LLM::Anthropic
78
77
  query = URI.encode_www_form(params)
79
78
  req = Net::HTTP::Get.new("/v1/files/#{file_id}?#{query}", headers)
80
79
  res = execute(request: req)
81
- LLM::Response.new(res).extend(LLM::Anthropic::Response::File)
80
+ ResponseAdapter.adapt(res, type: :file)
82
81
  end
83
82
 
84
83
  ##
@@ -97,7 +96,7 @@ class LLM::Anthropic
97
96
  file_id = file.respond_to?(:id) ? file.id : file
98
97
  req = Net::HTTP::Get.new("/v1/files/#{file_id}?#{query}", headers)
99
98
  res = execute(request: req)
100
- LLM::Response.new(res).extend(LLM::Anthropic::Response::File)
99
+ ResponseAdapter.adapt(res, type: :file)
101
100
  end
102
101
  alias_method :retrieve_metadata, :get_metadata
103
102
 
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class LLM::Anthropic
4
- require_relative "response/enumerable"
5
4
  ##
6
5
  # The {LLM::Anthropic::Models LLM::Anthropic::Models} class provides a model
7
6
  # object for interacting with [Anthropic's models API](https://platform.anthropic.com/docs/api-reference/models/list).
@@ -42,7 +41,7 @@ class LLM::Anthropic
42
41
  query = URI.encode_www_form(params)
43
42
  req = Net::HTTP::Get.new("/v1/models?#{query}", headers)
44
43
  res = execute(request: req)
45
- LLM::Response.new(res).extend(LLM::Anthropic::Response::Enumerable)
44
+ ResponseAdapter.adapt(res, type: :enumerable)
46
45
  end
47
46
 
48
47
  private
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module LLM::Anthropic::Format
3
+ module LLM::Anthropic::RequestAdapter
4
4
  ##
5
5
  # @private
6
- class CompletionFormat
6
+ class Completion
7
7
  ##
8
8
  # @param [LLM::Message, Hash] message
9
9
  # The message to format
@@ -12,25 +12,25 @@ module LLM::Anthropic::Format
12
12
  end
13
13
 
14
14
  ##
15
- # Formats the message for the Anthropic chat completions API
15
+ # Adapts the message for the Anthropic chat completions API
16
16
  # @return [Hash]
17
- def format
17
+ def adapt
18
18
  catch(:abort) do
19
19
  if Hash === message
20
- {role: message[:role], content: format_content(message[:content])}
20
+ {role: message[:role], content: adapt_content(message[:content])}
21
21
  else
22
- format_message
22
+ adapt_message
23
23
  end
24
24
  end
25
25
  end
26
26
 
27
27
  private
28
28
 
29
- def format_message
29
+ def adapt_message
30
30
  if message.tool_call?
31
31
  {role: message.role, content: message.extra[:original_tool_calls]}
32
32
  else
33
- {role: message.role, content: format_content(content)}
33
+ {role: message.role, content: adapt_content(content)}
34
34
  end
35
35
  end
36
36
 
@@ -39,41 +39,41 @@ module LLM::Anthropic::Format
39
39
  # The content to format
40
40
  # @return [String, Hash]
41
41
  # The formatted content
42
- def format_content(content)
42
+ def adapt_content(content)
43
43
  case content
44
44
  when Hash
45
45
  content.empty? ? throw(:abort, nil) : [content]
46
46
  when Array
47
- content.empty? ? throw(:abort, nil) : content.flat_map { format_content(_1) }
47
+ content.empty? ? throw(:abort, nil) : content.flat_map { adapt_content(_1) }
48
48
  when LLM::Object
49
- format_object(content)
49
+ adapt_object(content)
50
50
  when String
51
51
  [{type: :text, text: content}]
52
52
  when LLM::Response
53
- format_remote_file(content)
53
+ adapt_remote_file(content)
54
54
  when LLM::Message
55
- format_content(content.content)
55
+ adapt_content(content.content)
56
56
  when LLM::Function::Return
57
- [{type: "tool_result", tool_use_id: content.id, content: [{type: :text, text: JSON.dump(content.value)}]}]
57
+ [{type: "tool_result", tool_use_id: content.id, content: [{type: :text, text: LLM.json.dump(content.value)}]}]
58
58
  else
59
59
  prompt_error!(content)
60
60
  end
61
61
  end
62
62
 
63
- def format_object(object)
63
+ def adapt_object(object)
64
64
  case object.kind
65
65
  when :image_url
66
66
  [{type: :image, source: {type: "url", url: object.value.to_s}}]
67
67
  when :local_file
68
- format_local_file(object.value)
68
+ adapt_local_file(object.value)
69
69
  when :remote_file
70
- format_remote_file(object.value)
70
+ adapt_remote_file(object.value)
71
71
  else
72
72
  prompt_error!(content)
73
73
  end
74
74
  end
75
75
 
76
- def format_local_file(file)
76
+ def adapt_local_file(file)
77
77
  if file.image?
78
78
  [{type: :image, source: {type: "base64", media_type: file.mime_type, data: file.to_b64}}]
79
79
  elsif file.pdf?
@@ -85,7 +85,7 @@ module LLM::Anthropic::Format
85
85
  end
86
86
  end
87
87
 
88
- def format_remote_file(file)
88
+ def adapt_remote_file(file)
89
89
  prompt_error!(file) unless file.file?
90
90
  [{type: file.file_type, source: {type: :file, file_id: file.id}}]
91
91
  end
@@ -3,16 +3,16 @@
3
3
  class LLM::Anthropic
4
4
  ##
5
5
  # @private
6
- module Format
7
- require_relative "format/completion_format"
6
+ module RequestAdapter
7
+ require_relative "request_adapter/completion"
8
8
 
9
9
  ##
10
10
  # @param [Array<LLM::Message>] messages
11
- # The messages to format
11
+ # The messages to adapt
12
12
  # @return [Array<Hash>]
13
- def format(messages)
13
+ def adapt(messages, mode: nil)
14
14
  messages.filter_map do
15
- CompletionFormat.new(_1).format
15
+ Completion.new(_1).adapt
16
16
  end
17
17
  end
18
18
 
@@ -21,9 +21,9 @@ class LLM::Anthropic
21
21
  ##
22
22
  # @param [Hash] params
23
23
  # @return [Hash]
24
- def format_tools(tools)
24
+ def adapt_tools(tools)
25
25
  return {} unless tools&.any?
26
- {tools: tools.map { _1.respond_to?(:format) ? _1.format(self) : _1 }}
26
+ {tools: tools.map { _1.respond_to?(:adapt) ? _1.adapt(self) : _1 }}
27
27
  end
28
28
  end
29
29
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Anthropic::ResponseAdapter
4
+ module Completion
5
+ ##
6
+ # (see LLM::Contract::Completion#messages)
7
+ def messages
8
+ adapt_choices
9
+ end
10
+ alias_method :choices, :messages
11
+
12
+ ##
13
+ # (see LLM::Contract::Completion#input_tokens)
14
+ def input_tokens
15
+ body.usage["input_tokens"] || 0
16
+ end
17
+
18
+ ##
19
+ # (see LLM::Contract::Completion#output_tokens)
20
+ def output_tokens
21
+ body.usage["output_tokens"] || 0
22
+ end
23
+
24
+ ##
25
+ # (see LLM::Contract::Completion#total_tokens)
26
+ def total_tokens
27
+ input_tokens + output_tokens
28
+ end
29
+
30
+ ##
31
+ # (see LLM::Contract::Completion#usage)
32
+ def usage
33
+ super
34
+ end
35
+
36
+ ##
37
+ # (see LLM::Contract::Completion#model)
38
+ def model
39
+ body.model
40
+ end
41
+
42
+ private
43
+
44
+ def adapt_choices
45
+ texts.map.with_index do |choice, index|
46
+ extra = {
47
+ index:, response: self,
48
+ tool_calls: adapt_tool_calls(tools), original_tool_calls: tools
49
+ }
50
+ LLM::Message.new(role, choice["text"], extra)
51
+ end
52
+ end
53
+
54
+ def adapt_tool_calls(tools)
55
+ (tools || []).filter_map do |tool|
56
+ {id: tool.id, name: tool.name, arguments: tool.input}
57
+ end
58
+ end
59
+
60
+ def parts = body.content
61
+ def texts = @texts ||= parts.select { _1["type"] == "text" }
62
+ def tools = @tools ||= parts.select { _1["type"] == "tool_use" }
63
+
64
+ include LLM::Contract::Completion
65
+ end
66
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module LLM::Anthropic::Response
3
+ module LLM::Anthropic::ResponseAdapter
4
4
  module Enumerable
5
5
  include ::Enumerable
6
6
 
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module LLM::Anthropic::Response
3
+ module LLM::Anthropic::ResponseAdapter
4
4
  module File
5
5
  ##
6
6
  # Always return true
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module LLM::Anthropic::Response
3
+ module LLM::Anthropic::ResponseAdapter
4
4
  ##
5
- # The {LLM::Anthropic::Response::WebSearch LLM::Anthropic::Response::WebSearch}
5
+ # The {LLM::Anthropic::ResponseAdapter::WebSearch LLM::Anthropic::ResponseAdapter::WebSearch}
6
6
  # module provides methods for accessing web search results from a web search
7
7
  # tool call made via the {LLM::Provider#web_search LLM::Provider#web_search}
8
8
  # method.
@@ -11,7 +11,7 @@ module LLM::Anthropic::Response
11
11
  # Returns one or more search results
12
12
  # @return [Array<LLM::Object>]
13
13
  def search_results
14
- LLM::Object.from_hash(
14
+ LLM::Object.from(
15
15
  content
16
16
  .select { _1["type"] == "web_search_tool_result" }
17
17
  .flat_map { |n| n.content.map { _1.slice(:title, :url) } }
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Anthropic
4
+ ##
5
+ # @private
6
+ module ResponseAdapter
7
+ require_relative "response_adapter/completion"
8
+ require_relative "response_adapter/enumerable"
9
+ require_relative "response_adapter/file"
10
+ require_relative "response_adapter/web_search"
11
+
12
+ module_function
13
+
14
+ ##
15
+ # @param [LLM::Response, Net::HTTPResponse] res
16
+ # @param [Symbol] type
17
+ # @return [LLM::Response]
18
+ def adapt(res, type:)
19
+ response = (LLM::Response === res) ? res : LLM::Response.new(res)
20
+ response.extend(select(type))
21
+ end
22
+
23
+ ##
24
+ # @api private
25
+ def select(type)
26
+ case type
27
+ when :completion then LLM::Anthropic::ResponseAdapter::Completion
28
+ when :enumerable then LLM::Anthropic::ResponseAdapter::Enumerable
29
+ when :file then LLM::Anthropic::ResponseAdapter::File
30
+ when :web_search then LLM::Anthropic::ResponseAdapter::WebSearch
31
+ else
32
+ raise ArgumentError, "Unknown response adapter type: #{type.inspect}"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -6,14 +6,14 @@ class LLM::Anthropic
6
6
  class StreamParser
7
7
  ##
8
8
  # Returns the fully constructed response body
9
- # @return [LLM::Object]
9
+ # @return [Hash]
10
10
  attr_reader :body
11
11
 
12
12
  ##
13
13
  # @param [#<<] io An IO-like object
14
14
  # @return [LLM::Anthropic::StreamParser]
15
15
  def initialize(io)
16
- @body = LLM::Object.new(role: "assistant", content: [])
16
+ @body = {"role" => "assistant", "content" => []}
17
17
  @io = io
18
18
  end
19
19
 
@@ -33,10 +33,10 @@ class LLM::Anthropic
33
33
  @body["content"][chunk["index"]] = chunk["content_block"]
34
34
  elsif chunk["type"] == "content_block_delta"
35
35
  if chunk["delta"]["type"] == "text_delta"
36
- @body.content[chunk["index"]]["text"] << chunk["delta"]["text"]
36
+ @body["content"][chunk["index"]]["text"] << chunk["delta"]["text"]
37
37
  @io << chunk["delta"]["text"] if @io.respond_to?(:<<)
38
38
  elsif chunk["delta"]["type"] == "input_json_delta"
39
- content = @body.content[chunk["index"]]
39
+ content = @body["content"][chunk["index"]]
40
40
  if Hash === content["input"]
41
41
  content["input"] = chunk["delta"]["partial_json"]
42
42
  else
@@ -46,9 +46,9 @@ class LLM::Anthropic
46
46
  elsif chunk["type"] == "message_delta"
47
47
  merge_message!(chunk["delta"])
48
48
  elsif chunk["type"] == "content_block_stop"
49
- content = @body.content[chunk["index"]]
49
+ content = @body["content"][chunk["index"]]
50
50
  if content["input"]
51
- content["input"] = JSON.parse(content["input"])
51
+ content["input"] = LLM.json.load(content["input"])
52
52
  end
53
53
  end
54
54
  end