llm.rb 0.7.2 → 0.9.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +93 -63
  3. data/lib/llm/{chat → bot}/builder.rb +1 -1
  4. data/lib/llm/bot/conversable.rb +31 -0
  5. data/lib/llm/{chat → bot}/prompt/completion.rb +14 -4
  6. data/lib/llm/{chat → bot}/prompt/respond.rb +16 -5
  7. data/lib/llm/{chat.rb → bot.rb} +48 -66
  8. data/lib/llm/buffer.rb +2 -2
  9. data/lib/llm/error.rb +24 -16
  10. data/lib/llm/event_handler.rb +44 -0
  11. data/lib/llm/eventstream/event.rb +69 -0
  12. data/lib/llm/eventstream/parser.rb +88 -0
  13. data/lib/llm/eventstream.rb +8 -0
  14. data/lib/llm/function.rb +9 -12
  15. data/lib/{json → llm/json}/schema/array.rb +1 -1
  16. data/lib/llm/message.rb +1 -1
  17. data/lib/llm/model.rb +1 -1
  18. data/lib/llm/object/builder.rb +38 -0
  19. data/lib/llm/object/kernel.rb +45 -0
  20. data/lib/llm/object.rb +77 -0
  21. data/lib/llm/provider.rb +68 -26
  22. data/lib/llm/providers/anthropic/error_handler.rb +3 -3
  23. data/lib/llm/providers/anthropic/models.rb +3 -7
  24. data/lib/llm/providers/anthropic/response_parser/completion_parser.rb +5 -5
  25. data/lib/llm/providers/anthropic/response_parser.rb +1 -0
  26. data/lib/llm/providers/anthropic/stream_parser.rb +66 -0
  27. data/lib/llm/providers/anthropic.rb +9 -4
  28. data/lib/llm/providers/deepseek/format/completion_format.rb +68 -0
  29. data/lib/llm/providers/deepseek/format.rb +28 -0
  30. data/lib/llm/providers/deepseek.rb +60 -0
  31. data/lib/llm/providers/gemini/error_handler.rb +4 -4
  32. data/lib/llm/providers/gemini/files.rb +13 -16
  33. data/lib/llm/providers/gemini/images.rb +4 -8
  34. data/lib/llm/providers/gemini/models.rb +3 -7
  35. data/lib/llm/providers/gemini/response_parser/completion_parser.rb +2 -2
  36. data/lib/llm/providers/gemini/stream_parser.rb +69 -0
  37. data/lib/llm/providers/gemini.rb +19 -11
  38. data/lib/llm/providers/llamacpp.rb +16 -2
  39. data/lib/llm/providers/ollama/error_handler.rb +3 -3
  40. data/lib/llm/providers/ollama/format/completion_format.rb +1 -1
  41. data/lib/llm/providers/ollama/models.rb +3 -7
  42. data/lib/llm/providers/ollama/response_parser/completion_parser.rb +2 -2
  43. data/lib/llm/providers/ollama/stream_parser.rb +44 -0
  44. data/lib/llm/providers/ollama.rb +16 -9
  45. data/lib/llm/providers/openai/audio.rb +5 -9
  46. data/lib/llm/providers/openai/error_handler.rb +3 -3
  47. data/lib/llm/providers/openai/files.rb +15 -18
  48. data/lib/llm/providers/openai/format/moderation_format.rb +35 -0
  49. data/lib/llm/providers/openai/format.rb +3 -3
  50. data/lib/llm/providers/openai/images.rb +8 -11
  51. data/lib/llm/providers/openai/models.rb +3 -7
  52. data/lib/llm/providers/openai/moderations.rb +67 -0
  53. data/lib/llm/providers/openai/response_parser/completion_parser.rb +5 -5
  54. data/lib/llm/providers/openai/response_parser/respond_parser.rb +2 -2
  55. data/lib/llm/providers/openai/response_parser.rb +15 -0
  56. data/lib/llm/providers/openai/responses.rb +14 -16
  57. data/lib/llm/providers/openai/stream_parser.rb +77 -0
  58. data/lib/llm/providers/openai.rb +22 -7
  59. data/lib/llm/providers/voyageai/error_handler.rb +3 -3
  60. data/lib/llm/providers/voyageai.rb +1 -1
  61. data/lib/llm/response/filelist.rb +1 -1
  62. data/lib/llm/response/image.rb +1 -1
  63. data/lib/llm/response/modellist.rb +1 -1
  64. data/lib/llm/response/moderationlist/moderation.rb +47 -0
  65. data/lib/llm/response/moderationlist.rb +51 -0
  66. data/lib/llm/response.rb +1 -0
  67. data/lib/llm/version.rb +1 -1
  68. data/lib/llm.rb +13 -4
  69. data/llm.gemspec +2 -2
  70. metadata +42 -28
  71. data/lib/llm/chat/conversable.rb +0 -53
  72. data/lib/llm/core_ext/ostruct.rb +0 -43
  73. /data/lib/{json → llm/json}/schema/boolean.rb +0 -0
  74. /data/lib/{json → llm/json}/schema/integer.rb +0 -0
  75. /data/lib/{json → llm/json}/schema/leaf.rb +0 -0
  76. /data/lib/{json → llm/json}/schema/null.rb +0 -0
  77. /data/lib/{json → llm/json}/schema/number.rb +0 -0
  78. /data/lib/{json → llm/json}/schema/object.rb +0 -0
  79. /data/lib/{json → llm/json}/schema/string.rb +0 -0
  80. /data/lib/{json → llm/json}/schema/version.rb +0 -0
  81. /data/lib/{json → llm/json}/schema.rb +0 -0
@@ -40,7 +40,7 @@ class LLM::Anthropic
40
40
  def all(**params)
41
41
  query = URI.encode_www_form(params)
42
42
  req = Net::HTTP::Get.new("/v1/models?#{query}", headers)
43
- res = request(http, req)
43
+ res = execute(request: req)
44
44
  LLM::Response::ModelList.new(res).tap { |modellist|
45
45
  models = modellist.body["data"].map do |model|
46
46
  LLM::Model.from_hash(model).tap { _1.provider = @provider }
@@ -51,12 +51,8 @@ class LLM::Anthropic
51
51
 
52
52
  private
53
53
 
54
- def http
55
- @provider.instance_variable_get(:@http)
56
- end
57
-
58
- [:headers, :request].each do |m|
59
- define_method(m) { |*args, &b| @provider.send(m, *args, &b) }
54
+ [:headers, :execute].each do |m|
55
+ define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
60
56
  end
61
57
  end
62
58
  end
@@ -5,7 +5,7 @@ module LLM::Anthropic::ResponseParser
5
5
  # @private
6
6
  class CompletionParser
7
7
  def initialize(body)
8
- @body = OpenStruct.from_hash(body)
8
+ @body = LLM::Object.from_hash(body)
9
9
  end
10
10
 
11
11
  def format(response)
@@ -34,16 +34,16 @@ module LLM::Anthropic::ResponseParser
34
34
  name: tool.name,
35
35
  arguments: tool.input
36
36
  }
37
- OpenStruct.new(tool)
37
+ LLM::Object.new(tool)
38
38
  end
39
39
  end
40
40
 
41
41
  def body = @body
42
42
  def role = body.role
43
43
  def model = body.model
44
- def prompt_tokens = body.usage.input_tokens
45
- def completion_tokens = body.usage.output_tokens
46
- def total_tokens = body.usage.total_tokens
44
+ def prompt_tokens = body.usage&.input_tokens
45
+ def completion_tokens = body.usage&.output_tokens
46
+ def total_tokens = body.usage&.total_tokens
47
47
  def parts = body.content
48
48
  def texts = parts.select { _1["type"] == "text" }
49
49
  def tools = parts.select { _1["type"] == "tool_use" }
@@ -4,6 +4,7 @@ class LLM::Anthropic
4
4
  ##
5
5
  # @private
6
6
  module ResponseParser
7
+ require_relative "response_parser/completion_parser"
7
8
  def parse_embedding(body)
8
9
  {
9
10
  model: body["model"],
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Anthropic
4
+ ##
5
+ # @private
6
+ class StreamParser
7
+ ##
8
+ # Returns the fully constructed response body
9
+ # @return [LLM::Object]
10
+ attr_reader :body
11
+
12
+ ##
13
+ # @param [#<<] io An IO-like object
14
+ # @return [LLM::Anthropic::StreamParser]
15
+ def initialize(io)
16
+ @body = LLM::Object.new(role: "assistant", content: [])
17
+ @io = io
18
+ end
19
+
20
+ ##
21
+ # @param [Hash] chunk
22
+ # @return [LLM::Anthropic::StreamParser]
23
+ def parse!(chunk)
24
+ tap { merge!(chunk) }
25
+ end
26
+
27
+ private
28
+
29
+ def merge!(chunk)
30
+ if chunk["type"] == "message_start"
31
+ merge_message!(chunk["message"])
32
+ elsif chunk["type"] == "content_block_start"
33
+ @body["content"][chunk["index"]] = chunk["content_block"]
34
+ elsif chunk["type"] == "content_block_delta"
35
+ if chunk["delta"]["type"] == "text_delta"
36
+ @body.content[chunk["index"]]["text"] << chunk["delta"]["text"]
37
+ @io << chunk["delta"]["text"] if @io.respond_to?(:<<)
38
+ elsif chunk["delta"]["type"] == "input_json_delta"
39
+ content = @body.content[chunk["index"]]
40
+ if Hash === content["input"]
41
+ content["input"] = chunk["delta"]["partial_json"]
42
+ else
43
+ content["input"] << chunk["delta"]["partial_json"]
44
+ end
45
+ end
46
+ elsif chunk["type"] == "message_delta"
47
+ merge_message!(chunk["delta"])
48
+ elsif chunk["type"] == "content_block_stop"
49
+ content = @body.content[chunk["index"]]
50
+ if content["input"]
51
+ content["input"] = JSON.parse(content["input"])
52
+ end
53
+ end
54
+ end
55
+
56
+ def merge_message!(message)
57
+ message.each do |key, value|
58
+ @body[key] = if value.respond_to?(:each_pair)
59
+ merge_message!(value)
60
+ else
61
+ value
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -5,10 +5,10 @@ module LLM
5
5
  # The Anthropic class implements a provider for
6
6
  # [Anthropic](https://www.anthropic.com)
7
7
  class Anthropic < Provider
8
+ require_relative "anthropic/format"
8
9
  require_relative "anthropic/error_handler"
10
+ require_relative "anthropic/stream_parser"
9
11
  require_relative "anthropic/response_parser"
10
- require_relative "anthropic/response_parser/completion_parser"
11
- require_relative "anthropic/format"
12
12
  require_relative "anthropic/models"
13
13
  include Format
14
14
 
@@ -50,12 +50,13 @@ module LLM
50
50
  def complete(prompt, params = {})
51
51
  params = {role: :user, model: default_model, max_tokens: 1024}.merge!(params)
52
52
  params = [params, format_tools(params)].inject({}, &:merge!).compact
53
- role = params.delete(:role)
53
+ role, stream = params.delete(:role), params.delete(:stream)
54
+ params[:stream] = true if stream.respond_to?(:<<) || stream == true
54
55
  req = Net::HTTP::Post.new("/v1/messages", headers)
55
56
  messages = [*(params.delete(:messages) || []), Message.new(role, prompt)]
56
57
  body = JSON.dump({messages: [format(messages)].flatten}.merge!(params))
57
58
  set_body_stream(req, StringIO.new(body))
58
- res = request(@http, req)
59
+ res = execute(request: req, stream:)
59
60
  Response::Completion.new(res).extend(response_parser)
60
61
  end
61
62
 
@@ -95,6 +96,10 @@ module LLM
95
96
  LLM::Anthropic::ResponseParser
96
97
  end
97
98
 
99
+ def stream_parser
100
+ LLM::Anthropic::StreamParser
101
+ end
102
+
98
103
  def error_handler
99
104
  LLM::Anthropic::ErrorHandler
100
105
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::DeepSeek::Format
4
+ ##
5
+ # @private
6
+ class CompletionFormat
7
+ ##
8
+ # @param [LLM::Message, Hash] message
9
+ # The message to format
10
+ def initialize(message)
11
+ @message = message
12
+ end
13
+
14
+ ##
15
+ # Formats the message for the OpenAI chat completions API
16
+ # @return [Hash]
17
+ def format
18
+ catch(:abort) do
19
+ if Hash === message
20
+ {role: message[:role], content: format_content(message[:content])}
21
+ elsif message.tool_call?
22
+ {role: message.role, content: nil, tool_calls: message.extra[:original_tool_calls]}
23
+ else
24
+ format_message
25
+ end
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def format_content(content)
32
+ case content
33
+ when String
34
+ content.to_s
35
+ when LLM::Message
36
+ format_content(content.content)
37
+ when LLM::Function::Return
38
+ throw(:abort, {role: "tool", tool_call_id: content.id, content: JSON.dump(content.value)})
39
+ else
40
+ raise LLM::Error::PromptError, "The given object (an instance of #{content.class}) " \
41
+ "is not supported by the DeepSeek chat completions API"
42
+ end
43
+ end
44
+
45
+ def format_message
46
+ case content
47
+ when Array
48
+ format_array
49
+ else
50
+ {role: message.role, content: format_content(content)}
51
+ end
52
+ end
53
+
54
+ def format_array
55
+ if content.empty?
56
+ nil
57
+ elsif returns.any?
58
+ returns.map { {role: "tool", tool_call_id: _1.id, content: JSON.dump(_1.value)} }
59
+ else
60
+ {role: message.role, content: content.flat_map { format_content(_1) }}
61
+ end
62
+ end
63
+
64
+ def message = @message
65
+ def content = message.content
66
+ def returns = content.grep(LLM::Function::Return)
67
+ end
68
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::DeepSeek
4
+ ##
5
+ # @private
6
+ module Format
7
+ require_relative "format/completion_format"
8
+ ##
9
+ # @param [Array<LLM::Message>] messages
10
+ # The messages to format
11
+ # @return [Array<Hash>]
12
+ def format(messages, ...)
13
+ messages.filter_map do |message|
14
+ CompletionFormat.new(message).format
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ ##
21
+ # @param [Hash] params
22
+ # @return [Hash]
23
+ def format_tools(params)
24
+ tools = params.delete(:tools)
25
+ (tools.nil? || tools.empty?) ? {} : {tools: tools.map { _1.format(self) }}
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "openai" unless defined?(LLM::OpenAI)
4
+
5
+ module LLM
6
+ ##
7
+ # The DeepSeek class implements a provider for
8
+ # [DeepSeek](https://deepseek.com)
9
+ # through its OpenAI-compatible API provided via
10
+ # their [web platform](https://platform.deepseek.com).
11
+ class DeepSeek < OpenAI
12
+ require_relative "deepseek/format"
13
+ include DeepSeek::Format
14
+
15
+ ##
16
+ # @param (see LLM::Provider#initialize)
17
+ # @return [LLM::DeepSeek]
18
+ def initialize(host: "api.deepseek.com", port: 443, ssl: true, **)
19
+ super
20
+ end
21
+
22
+ ##
23
+ # @raise [NotImplementedError]
24
+ def files
25
+ raise NotImplementedError
26
+ end
27
+
28
+ ##
29
+ # @raise [NotImplementedError]
30
+ def images
31
+ raise NotImplementedError
32
+ end
33
+
34
+ ##
35
+ # @raise [NotImplementedError]
36
+ def audio
37
+ raise NotImplementedError
38
+ end
39
+
40
+ ##
41
+ # @raise [NotImplementedError]
42
+ def moderations
43
+ raise NotImplementedError
44
+ end
45
+
46
+ ##
47
+ # @raise [NotImplementedError]
48
+ def responses
49
+ raise NotImplementedError
50
+ end
51
+
52
+ ##
53
+ # Returns the default model for chat completions
54
+ # @see https://api-docs.deepseek.com/quick_start/pricing deepseek-chat
55
+ # @return [String]
56
+ def default_model
57
+ "deepseek-chat"
58
+ end
59
+ end
60
+ end
@@ -25,14 +25,14 @@ class LLM::Gemini
25
25
  when Net::HTTPBadRequest
26
26
  reason = body.dig("error", "details", 0, "reason")
27
27
  if reason == "API_KEY_INVALID"
28
- raise LLM::Error::Unauthorized.new { _1.response = res }, "Authentication error"
28
+ raise LLM::UnauthorizedError.new { _1.response = res }, "Authentication error"
29
29
  else
30
- raise LLM::Error::ResponseError.new { _1.response = res }, "Unexpected response"
30
+ raise LLM::ResponseError.new { _1.response = res }, "Unexpected response"
31
31
  end
32
32
  when Net::HTTPTooManyRequests
33
- raise LLM::Error::RateLimit.new { _1.response = res }, "Too many requests"
33
+ raise LLM::RateLimitError.new { _1.response = res }, "Too many requests"
34
34
  else
35
- raise LLM::Error::ResponseError.new { _1.response = res }, "Unexpected response"
35
+ raise LLM::ResponseError.new { _1.response = res }, "Unexpected response"
36
36
  end
37
37
  end
38
38
 
@@ -13,23 +13,24 @@ class LLM::Gemini
13
13
  # in the prompt over and over again (which could be the case in a
14
14
  # multi-turn conversation).
15
15
  #
16
- # @example
16
+ # @example example #1
17
17
  # #!/usr/bin/env ruby
18
18
  # require "llm"
19
19
  #
20
20
  # llm = LLM.gemini(ENV["KEY"])
21
- # bot = LLM::Chat.new(llm).lazy
21
+ # bot = LLM::Bot.new(llm)
22
22
  # file = llm.files.create file: "/audio/haiku.mp3"
23
23
  # bot.chat(file)
24
24
  # bot.chat("Describe the audio file I sent to you")
25
25
  # bot.chat("The audio file is the first message I sent to you.")
26
26
  # bot.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
27
- # @example
27
+ #
28
+ # @example example #2
28
29
  # #!/usr/bin/env ruby
29
30
  # require "llm"
30
31
  #
31
32
  # llm = LLM.gemini(ENV["KEY"])
32
- # bot = LLM::Chat.new(llm).lazy
33
+ # bot = LLM::Bot.new(llm)
33
34
  # file = llm.files.create file: "/audio/haiku.mp3"
34
35
  # bot.chat(["Describe the audio file I sent to you", file])
35
36
  # bot.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
@@ -57,11 +58,11 @@ class LLM::Gemini
57
58
  def all(**params)
58
59
  query = URI.encode_www_form(params.merge!(key: key))
59
60
  req = Net::HTTP::Get.new("/v1beta/files?#{query}", headers)
60
- res = request(http, req)
61
+ res = execute(request: req)
61
62
  LLM::Response::FileList.new(res).tap { |filelist|
62
63
  files = filelist.body["files"]&.map do |file|
63
64
  file = file.transform_keys { snakecase(_1) }
64
- OpenStruct.from_hash(file)
65
+ LLM::Object.from_hash(file)
65
66
  end || []
66
67
  filelist.files = files
67
68
  }
@@ -85,7 +86,7 @@ class LLM::Gemini
85
86
  req["X-Goog-Upload-Command"] = "upload, finalize"
86
87
  file.with_io do |io|
87
88
  set_body_stream(req, io)
88
- res = request(http, req)
89
+ res = execute(request: req)
89
90
  LLM::Response::File.new(res)
90
91
  end
91
92
  end
@@ -105,7 +106,7 @@ class LLM::Gemini
105
106
  file_id = file.respond_to?(:name) ? file.name : file.to_s
106
107
  query = URI.encode_www_form(params.merge!(key: key))
107
108
  req = Net::HTTP::Get.new("/v1beta/#{file_id}?#{query}", headers)
108
- res = request(http, req)
109
+ res = execute(request: req)
109
110
  LLM::Response::File.new(res)
110
111
  end
111
112
 
@@ -123,7 +124,7 @@ class LLM::Gemini
123
124
  file_id = file.respond_to?(:name) ? file.name : file.to_s
124
125
  query = URI.encode_www_form(params.merge!(key: key))
125
126
  req = Net::HTTP::Delete.new("/v1beta/#{file_id}?#{query}", headers)
126
- request(http, req)
127
+ execute(request: req)
127
128
  end
128
129
 
129
130
  ##
@@ -144,20 +145,16 @@ class LLM::Gemini
144
145
  req["X-Goog-Upload-Header-Content-Length"] = file.bytesize
145
146
  req["X-Goog-Upload-Header-Content-Type"] = file.mime_type
146
147
  req.body = JSON.dump(file: {display_name: File.basename(file.path)})
147
- res = request(http, req)
148
+ res = execute(request: req)
148
149
  res["x-goog-upload-url"]
149
150
  end
150
151
 
151
- def http
152
- @provider.instance_variable_get(:@http)
153
- end
154
-
155
152
  def key
156
153
  @provider.instance_variable_get(:@key)
157
154
  end
158
155
 
159
- [:headers, :request, :set_body_stream].each do |m|
160
- define_method(m) { |*args, &b| @provider.send(m, *args, &b) }
156
+ [:headers, :execute, :set_body_stream].each do |m|
157
+ define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
161
158
  end
162
159
  end
163
160
  end
@@ -47,7 +47,7 @@ class LLM::Gemini
47
47
  generationConfig: {responseModalities: ["TEXT", "IMAGE"]}
48
48
  }.merge!(params))
49
49
  req.body = body
50
- res = request(http, req)
50
+ res = execute(request: req)
51
51
  LLM::Response::Image.new(res).extend(response_parser)
52
52
  end
53
53
 
@@ -72,7 +72,7 @@ class LLM::Gemini
72
72
  generationConfig: {responseModalities: ["TEXT", "IMAGE"]}
73
73
  }.merge!(params)).b
74
74
  set_body_stream(req, StringIO.new(body))
75
- res = request(http, req)
75
+ res = execute(request: req)
76
76
  LLM::Response::Image.new(res).extend(response_parser)
77
77
  end
78
78
 
@@ -93,12 +93,8 @@ class LLM::Gemini
93
93
  @provider.instance_variable_get(:@key)
94
94
  end
95
95
 
96
- def http
97
- @provider.instance_variable_get(:@http)
98
- end
99
-
100
- [:response_parser, :headers, :request, :set_body_stream].each do |m|
101
- define_method(m) { |*args, &b| @provider.send(m, *args, &b) }
96
+ [:response_parser, :headers, :execute, :set_body_stream].each do |m|
97
+ define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
102
98
  end
103
99
  end
104
100
  end
@@ -42,7 +42,7 @@ class LLM::Gemini
42
42
  def all(**params)
43
43
  query = URI.encode_www_form(params.merge!(key: key))
44
44
  req = Net::HTTP::Get.new("/v1beta/models?#{query}", headers)
45
- res = request(http, req)
45
+ res = execute(request: req)
46
46
  LLM::Response::ModelList.new(res).tap { |modellist|
47
47
  models = modellist.body["models"].map do |model|
48
48
  model = model.transform_keys { snakecase(_1) }
@@ -54,16 +54,12 @@ class LLM::Gemini
54
54
 
55
55
  private
56
56
 
57
- def http
58
- @provider.instance_variable_get(:@http)
59
- end
60
-
61
57
  def key
62
58
  @provider.instance_variable_get(:@key)
63
59
  end
64
60
 
65
- [:headers, :request].each do |m|
66
- define_method(m) { |*args, &b| @provider.send(m, *args, &b) }
61
+ [:headers, :execute].each do |m|
62
+ define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
67
63
  end
68
64
  end
69
65
  end
@@ -3,7 +3,7 @@
3
3
  module LLM::Gemini::ResponseParser
4
4
  class CompletionParser
5
5
  def initialize(body)
6
- @body = OpenStruct.from_hash(body)
6
+ @body = LLM::Object.from_hash(body)
7
7
  end
8
8
 
9
9
  def format(response)
@@ -32,7 +32,7 @@ module LLM::Gemini::ResponseParser
32
32
  def format_tool_calls(tools)
33
33
  (tools || []).map do |tool|
34
34
  function = {name: tool.name, arguments: tool.args}
35
- OpenStruct.new(function)
35
+ LLM::Object.new(function)
36
36
  end
37
37
  end
38
38
 
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Gemini
4
+ ##
5
+ # @private
6
+ class StreamParser
7
+ ##
8
+ # Returns the fully constructed response body
9
+ # @return [LLM::Object]
10
+ attr_reader :body
11
+
12
+ ##
13
+ # @param [#<<] io An IO-like object
14
+ # @return [LLM::Gemini::StreamParser]
15
+ def initialize(io)
16
+ @body = LLM::Object.new
17
+ @io = io
18
+ end
19
+
20
+ ##
21
+ # @param [Hash] chunk
22
+ # @return [LLM::Gemini::StreamParser]
23
+ def parse!(chunk)
24
+ tap { merge!(chunk) }
25
+ end
26
+
27
+ private
28
+
29
+ def merge!(chunk)
30
+ chunk.each do |key, value|
31
+ if key == "candidates"
32
+ @body.candidates ||= []
33
+ merge_candidates!(value)
34
+ else
35
+ @body[key] = value
36
+ end
37
+ end
38
+ end
39
+
40
+ def merge_candidates!(candidates)
41
+ candidates.each.with_index do |candidate, i|
42
+ if @body.candidates[i].nil?
43
+ merge_one(@body.candidates, candidate, i)
44
+ else
45
+ merge_two(@body.candidates, candidate, i)
46
+ end
47
+ end
48
+ end
49
+
50
+ def merge_one(candidates, candidate, i)
51
+ candidate
52
+ .dig("content", "parts")
53
+ &.filter_map { _1["text"] }
54
+ &.each { @io << _1 if @io.respond_to?(:<<) }
55
+ candidates[i] = candidate
56
+ end
57
+
58
+ def merge_two(candidates, candidate, i)
59
+ parts = candidates[i].dig("content", "parts")
60
+ parts&.each&.with_index do |part, j|
61
+ if part["text"]
62
+ target = candidate["content"]["parts"][j]
63
+ part["text"] << target["text"]
64
+ @io << target["text"] if @io.respond_to?(:<<)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -10,31 +10,34 @@ module LLM
10
10
  # prompt for files under 20MB or via the Gemini Files API for
11
11
  # files that are over 20MB
12
12
  #
13
- # @example
13
+ # @example example #1
14
14
  # #!/usr/bin/env ruby
15
15
  # require "llm"
16
16
  #
17
17
  # llm = LLM.gemini(ENV["KEY"])
18
- # bot = LLM::Chat.new(llm).lazy
19
- # bot.chat LLM::File("/images/capybara.png")
18
+ # bot = LLM::Bot.new(llm)
19
+ # bot.chat LLM.File("/images/capybara.png")
20
20
  # bot.chat "Describe the image"
21
21
  # bot.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
22
- # @example
22
+ #
23
+ # @example example #2
23
24
  # #!/usr/bin/env ruby
24
25
  # require "llm"
25
26
  #
26
27
  # llm = LLM.gemini(ENV["KEY"])
27
- # bot = LLM::Chat.new(llm).lazy
28
+ # bot = LLM::Bot.new(llm)
28
29
  # bot.chat ["Describe the image", LLM::File("/images/capybara.png")]
29
30
  # bot.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
30
31
  class Gemini < Provider
31
32
  require_relative "gemini/error_handler"
32
- require_relative "gemini/response_parser"
33
33
  require_relative "gemini/format"
34
+ require_relative "gemini/stream_parser"
35
+ require_relative "gemini/response_parser"
36
+ require_relative "gemini/models"
34
37
  require_relative "gemini/images"
35
38
  require_relative "gemini/files"
36
39
  require_relative "gemini/audio"
37
- require_relative "gemini/models"
40
+
38
41
  include Format
39
42
 
40
43
  HOST = "generativelanguage.googleapis.com"
@@ -57,7 +60,7 @@ module LLM
57
60
  path = ["/v1beta/models/#{model}", "embedContent?key=#{@key}"].join(":")
58
61
  req = Net::HTTP::Post.new(path, headers)
59
62
  req.body = JSON.dump({content: {parts: [{text: input}]}})
60
- res = request(@http, req)
63
+ res = execute(request: req)
61
64
  Response::Embedding.new(res).extend(response_parser)
62
65
  end
63
66
 
@@ -74,14 +77,15 @@ module LLM
74
77
  def complete(prompt, params = {})
75
78
  params = {role: :user, model: default_model}.merge!(params)
76
79
  params = [params, format_schema(params), format_tools(params)].inject({}, &:merge!).compact
77
- role, model = [:role, :model].map { params.delete(_1) }
80
+ role, model, stream = [:role, :model, :stream].map { params.delete(_1) }
81
+ action = stream ? "streamGenerateContent?key=#{@key}&alt=sse" : "generateContent?key=#{@key}"
78
82
  model.respond_to?(:id) ? model.id : model
79
- path = ["/v1beta/models/#{model}", "generateContent?key=#{@key}"].join(":")
83
+ path = ["/v1beta/models/#{model}", action].join(":")
80
84
  req = Net::HTTP::Post.new(path, headers)
81
85
  messages = [*(params.delete(:messages) || []), LLM::Message.new(role, prompt)]
82
86
  body = JSON.dump({contents: format(messages)}.merge!(params))
83
87
  set_body_stream(req, StringIO.new(body))
84
- res = request(@http, req)
88
+ res = execute(request: req, stream:)
85
89
  Response::Completion.new(res).extend(response_parser)
86
90
  end
87
91
 
@@ -140,6 +144,10 @@ module LLM
140
144
  LLM::Gemini::ResponseParser
141
145
  end
142
146
 
147
+ def stream_parser
148
+ LLM::Gemini::StreamParser
149
+ end
150
+
143
151
  def error_handler
144
152
  LLM::Gemini::ErrorHandler
145
153
  end