llm.rb 0.10.1 → 0.11.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +0 -0
  3. data/README.md +81 -117
  4. data/lib/llm/bot/builder.rb +2 -2
  5. data/lib/llm/bot/conversable.rb +0 -0
  6. data/lib/llm/bot/prompt/completion.rb +0 -0
  7. data/lib/llm/bot/prompt/respond.rb +0 -0
  8. data/lib/llm/bot.rb +9 -11
  9. data/lib/llm/buffer.rb +0 -0
  10. data/lib/llm/error.rb +0 -0
  11. data/lib/llm/event_handler.rb +0 -0
  12. data/lib/llm/eventstream/event.rb +0 -0
  13. data/lib/llm/eventstream/parser.rb +0 -0
  14. data/lib/llm/eventstream.rb +0 -0
  15. data/lib/llm/file.rb +18 -9
  16. data/lib/llm/function.rb +6 -5
  17. data/lib/llm/json/schema/array.rb +0 -0
  18. data/lib/llm/json/schema/boolean.rb +0 -0
  19. data/lib/llm/json/schema/integer.rb +0 -0
  20. data/lib/llm/json/schema/leaf.rb +0 -0
  21. data/lib/llm/json/schema/null.rb +0 -0
  22. data/lib/llm/json/schema/number.rb +0 -0
  23. data/lib/llm/json/schema/object.rb +0 -0
  24. data/lib/llm/json/schema/string.rb +0 -0
  25. data/lib/llm/json/schema/version.rb +0 -0
  26. data/lib/llm/json/schema.rb +0 -0
  27. data/lib/llm/message.rb +8 -0
  28. data/lib/llm/mime.rb +0 -0
  29. data/lib/llm/multipart.rb +0 -0
  30. data/lib/llm/object/builder.rb +0 -0
  31. data/lib/llm/object/kernel.rb +8 -0
  32. data/lib/llm/object.rb +7 -0
  33. data/lib/llm/provider.rb +9 -11
  34. data/lib/llm/providers/anthropic/error_handler.rb +0 -0
  35. data/lib/llm/providers/anthropic/format/completion_format.rb +10 -5
  36. data/lib/llm/providers/anthropic/format.rb +0 -0
  37. data/lib/llm/providers/anthropic/models.rb +2 -7
  38. data/lib/llm/providers/anthropic/response/completion.rb +39 -0
  39. data/lib/llm/providers/anthropic/stream_parser.rb +0 -0
  40. data/lib/llm/providers/anthropic.rb +3 -24
  41. data/lib/llm/providers/deepseek/format/completion_format.rb +3 -3
  42. data/lib/llm/providers/deepseek/format.rb +0 -0
  43. data/lib/llm/providers/deepseek.rb +6 -0
  44. data/lib/llm/providers/gemini/audio.rb +6 -10
  45. data/lib/llm/providers/gemini/error_handler.rb +0 -0
  46. data/lib/llm/providers/gemini/files.rb +11 -14
  47. data/lib/llm/providers/gemini/format/completion_format.rb +20 -5
  48. data/lib/llm/providers/gemini/format.rb +0 -0
  49. data/lib/llm/providers/gemini/images.rb +8 -7
  50. data/lib/llm/providers/gemini/models.rb +2 -8
  51. data/lib/llm/providers/gemini/{response_parser/completion_parser.rb → response/completion.rb} +10 -24
  52. data/lib/llm/providers/gemini/response/embedding.rb +8 -0
  53. data/lib/llm/providers/gemini/response/file.rb +11 -0
  54. data/lib/llm/providers/gemini/response/image.rb +26 -0
  55. data/lib/llm/providers/gemini/stream_parser.rb +0 -0
  56. data/lib/llm/providers/gemini.rb +5 -8
  57. data/lib/llm/providers/llamacpp.rb +6 -0
  58. data/lib/llm/providers/ollama/error_handler.rb +0 -0
  59. data/lib/llm/providers/ollama/format/completion_format.rb +8 -5
  60. data/lib/llm/providers/ollama/format.rb +0 -0
  61. data/lib/llm/providers/ollama/models.rb +2 -8
  62. data/lib/llm/providers/ollama/response/completion.rb +28 -0
  63. data/lib/llm/providers/ollama/response/embedding.rb +10 -0
  64. data/lib/llm/providers/ollama/stream_parser.rb +0 -0
  65. data/lib/llm/providers/ollama.rb +5 -8
  66. data/lib/llm/providers/openai/audio.rb +6 -6
  67. data/lib/llm/providers/openai/error_handler.rb +0 -0
  68. data/lib/llm/providers/openai/files.rb +14 -15
  69. data/lib/llm/providers/openai/format/completion_format.rb +11 -4
  70. data/lib/llm/providers/openai/format/moderation_format.rb +2 -2
  71. data/lib/llm/providers/openai/format/respond_format.rb +7 -4
  72. data/lib/llm/providers/openai/format.rb +0 -0
  73. data/lib/llm/providers/openai/images.rb +8 -7
  74. data/lib/llm/providers/openai/models.rb +2 -7
  75. data/lib/llm/providers/openai/moderations.rb +9 -11
  76. data/lib/llm/providers/openai/response/audio.rb +7 -0
  77. data/lib/llm/providers/openai/{response_parser/completion_parser.rb → response/completion.rb} +15 -31
  78. data/lib/llm/providers/openai/response/embedding.rb +9 -0
  79. data/lib/llm/providers/openai/response/file.rb +7 -0
  80. data/lib/llm/providers/openai/response/image.rb +16 -0
  81. data/lib/llm/providers/openai/response/moderations.rb +34 -0
  82. data/lib/llm/providers/openai/{response_parser/respond_parser.rb → response/responds.rb} +7 -28
  83. data/lib/llm/providers/openai/responses.rb +10 -9
  84. data/lib/llm/providers/openai/stream_parser.rb +0 -0
  85. data/lib/llm/providers/openai/vector_stores.rb +106 -0
  86. data/lib/llm/providers/openai.rb +14 -8
  87. data/lib/llm/response.rb +37 -13
  88. data/lib/llm/utils.rb +0 -0
  89. data/lib/llm/version.rb +1 -1
  90. data/lib/llm.rb +2 -12
  91. data/llm.gemspec +1 -1
  92. metadata +18 -29
  93. data/lib/llm/model.rb +0 -32
  94. data/lib/llm/providers/anthropic/response_parser/completion_parser.rb +0 -51
  95. data/lib/llm/providers/anthropic/response_parser.rb +0 -24
  96. data/lib/llm/providers/gemini/response_parser.rb +0 -46
  97. data/lib/llm/providers/ollama/response_parser/completion_parser.rb +0 -42
  98. data/lib/llm/providers/ollama/response_parser.rb +0 -30
  99. data/lib/llm/providers/openai/response_parser.rb +0 -65
  100. data/lib/llm/providers/voyageai/error_handler.rb +0 -32
  101. data/lib/llm/providers/voyageai/response_parser.rb +0 -13
  102. data/lib/llm/providers/voyageai.rb +0 -44
  103. data/lib/llm/response/audio.rb +0 -13
  104. data/lib/llm/response/audio_transcription.rb +0 -14
  105. data/lib/llm/response/audio_translation.rb +0 -14
  106. data/lib/llm/response/completion.rb +0 -51
  107. data/lib/llm/response/download_file.rb +0 -15
  108. data/lib/llm/response/embedding.rb +0 -23
  109. data/lib/llm/response/file.rb +0 -42
  110. data/lib/llm/response/filelist.rb +0 -18
  111. data/lib/llm/response/image.rb +0 -29
  112. data/lib/llm/response/modellist.rb +0 -18
  113. data/lib/llm/response/moderationlist/moderation.rb +0 -47
  114. data/lib/llm/response/moderationlist.rb +0 -51
  115. data/lib/llm/response/respond.rb +0 -56
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Anthropic::Response
4
+ module Completion
5
+ def choices = format_choices
6
+ def role = body.role
7
+ def model = body.model
8
+ def prompt_tokens = body.usage&.input_tokens || 0
9
+ def completion_tokens = body.usage&.output_tokens || 0
10
+ def total_tokens = prompt_tokens + completion_tokens
11
+
12
+ private
13
+
14
+ def format_choices
15
+ texts.map.with_index do |choice, index|
16
+ extra = {
17
+ index:, response: self,
18
+ tool_calls: format_tool_calls(tools), original_tool_calls: tools
19
+ }
20
+ LLM::Message.new(role, choice["text"], extra)
21
+ end
22
+ end
23
+
24
+ def format_tool_calls(tools)
25
+ (tools || []).filter_map do |tool|
26
+ tool = {
27
+ id: tool.id,
28
+ name: tool.name,
29
+ arguments: tool.input
30
+ }
31
+ LLM::Object.new(tool)
32
+ end
33
+ end
34
+
35
+ def parts = body.content
36
+ def texts = @texts ||= LLM::Object.from_hash(parts.select { _1["type"] == "text" })
37
+ def tools = @tools ||= LLM::Object.from_hash(parts.select { _1["type"] == "tool_use" })
38
+ end
39
+ end
File without changes
@@ -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/response/completion"
8
9
  require_relative "anthropic/format"
9
10
  require_relative "anthropic/error_handler"
10
11
  require_relative "anthropic/stream_parser"
11
- require_relative "anthropic/response_parser"
12
12
  require_relative "anthropic/models"
13
13
  include Format
14
14
 
@@ -20,23 +20,6 @@ module LLM
20
20
  super(host: HOST, **)
21
21
  end
22
22
 
23
- ##
24
- # Provides an embedding via VoyageAI per
25
- # [Anthropic's recommendation](https://docs.anthropic.com/en/docs/build-with-claude/embeddings)
26
- # @param input (see LLM::Provider#embed)
27
- # @param [String] key
28
- # Valid key for the VoyageAI API
29
- # @param [String] model
30
- # The embedding model to use
31
- # @param [Hash] params
32
- # Other embedding parameters
33
- # @raise (see LLM::Provider#request)
34
- # @return (see LLM::Provider#embed)
35
- def embed(input, key:, model: "voyage-2", **params)
36
- llm = LLM.voyageai(key:)
37
- llm.embed(input, **params.merge(model:))
38
- end
39
-
40
23
  ##
41
24
  # Provides an interface to the chat completions API
42
25
  # @see https://docs.anthropic.com/en/api/messages Anthropic docs
@@ -44,7 +27,7 @@ module LLM
44
27
  # @param params (see LLM::Provider#complete)
45
28
  # @example (see LLM::Provider#complete)
46
29
  # @raise (see LLM::Provider#request)
47
- # @raise [LLM::Error::PromptError]
30
+ # @raise [LLM::PromptError]
48
31
  # When given an object a provider does not understand
49
32
  # @return (see LLM::Provider#complete)
50
33
  def complete(prompt, params = {})
@@ -57,7 +40,7 @@ module LLM
57
40
  body = JSON.dump({messages: [format(messages)].flatten}.merge!(params))
58
41
  set_body_stream(req, StringIO.new(body))
59
42
  res = execute(request: req, stream:)
60
- Response::Completion.new(res).extend(response_parser)
43
+ LLM::Response.new(res).extend(LLM::Anthropic::Response::Completion)
61
44
  end
62
45
 
63
46
  ##
@@ -92,10 +75,6 @@ module LLM
92
75
  )
93
76
  end
94
77
 
95
- def response_parser
96
- LLM::Anthropic::ResponseParser
97
- end
98
-
99
78
  def stream_parser
100
79
  LLM::Anthropic::StreamParser
101
80
  end
@@ -12,7 +12,7 @@ module LLM::DeepSeek::Format
12
12
  end
13
13
 
14
14
  ##
15
- # Formats the message for the OpenAI chat completions API
15
+ # Formats the message for the DeepSeek chat completions API
16
16
  # @return [Hash]
17
17
  def format
18
18
  catch(:abort) do
@@ -37,8 +37,8 @@ module LLM::DeepSeek::Format
37
37
  when LLM::Function::Return
38
38
  throw(:abort, {role: "tool", tool_call_id: content.id, content: JSON.dump(content.value)})
39
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"
40
+ raise LLM::PromptError, "The given object (an instance of #{content.class}) " \
41
+ "is not supported by the DeepSeek chat completions API"
42
42
  end
43
43
  end
44
44
 
File without changes
@@ -49,6 +49,12 @@ module LLM
49
49
  raise NotImplementedError
50
50
  end
51
51
 
52
+ ##
53
+ # @raise [NotImplementedError]
54
+ def vector_stores
55
+ raise NotImplementedError
56
+ end
57
+
52
58
  ##
53
59
  # Returns the default model for chat completions
54
60
  # @see https://api-docs.deepseek.com/quick_start/pricing deepseek-chat
@@ -34,20 +34,18 @@ class LLM::Gemini
34
34
  # res = llm.audio.create_transcription(file: "/audio/rocket.mp3")
35
35
  # res.text # => "A dog on a rocket to the moon"
36
36
  # @see https://ai.google.dev/gemini-api/docs/audio Gemini docs
37
- # @param [String, LLM::File, LLM::Response::File] file The input audio
37
+ # @param [String, LLM::File, LLM::Response] file The input audio
38
38
  # @param [String] model The model to use
39
39
  # @param [Hash] params Other parameters (see Gemini docs)
40
40
  # @raise (see LLM::Provider#request)
41
- # @return [LLM::Response::AudioTranscription]
41
+ # @return [LLM::Response]
42
42
  def create_transcription(file:, model: "gemini-1.5-flash", **params)
43
43
  res = @provider.complete [
44
44
  "Your task is to transcribe the contents of an audio file",
45
45
  "Your response should include the transcription, and nothing else",
46
46
  LLM.File(file)
47
47
  ], params.merge(role: :user, model:)
48
- LLM::Response::AudioTranscription
49
- .new(res)
50
- .tap { _1.text = res.choices[0].content }
48
+ res.tap { _1.define_singleton_method(:text) { choices[0].content } }
51
49
  end
52
50
 
53
51
  ##
@@ -58,20 +56,18 @@ class LLM::Gemini
58
56
  # res = llm.audio.create_translation(file: "/audio/bismillah.mp3")
59
57
  # res.text # => "In the name of Allah, the Beneficent, the Merciful."
60
58
  # @see https://ai.google.dev/gemini-api/docs/audio Gemini docs
61
- # @param [String, LLM::File, LLM::Response::File] file The input audio
59
+ # @param [String, LLM::File, LLM::Response] file The input audio
62
60
  # @param [String] model The model to use
63
61
  # @param [Hash] params Other parameters (see Gemini docs)
64
62
  # @raise (see LLM::Provider#request)
65
- # @return [LLM::Response::AudioTranslation]
63
+ # @return [LLM::Response]
66
64
  def create_translation(file:, model: "gemini-1.5-flash", **params)
67
65
  res = @provider.complete [
68
66
  "Your task is to translate the contents of an audio file into English",
69
67
  "Your response should include the translation, and nothing else",
70
68
  LLM.File(file)
71
69
  ], params.merge(role: :user, model:)
72
- LLM::Response::AudioTranslation
73
- .new(res)
74
- .tap { _1.text = res.choices[0].content }
70
+ res.tap { _1.define_singleton_method(:text) { choices[0].content } }
75
71
  end
76
72
  end
77
73
  end
File without changes
@@ -35,6 +35,8 @@ class LLM::Gemini
35
35
  # bot.chat(["Describe the audio file I sent to you", file])
36
36
  # bot.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
37
37
  class Files
38
+ require_relative "response/file"
39
+
38
40
  ##
39
41
  # Returns a new Files object
40
42
  # @param provider [LLM::Provider]
@@ -54,18 +56,12 @@ class LLM::Gemini
54
56
  # @see https://ai.google.dev/gemini-api/docs/files Gemini docs
55
57
  # @param [Hash] params Other parameters (see Gemini docs)
56
58
  # @raise (see LLM::Provider#request)
57
- # @return [LLM::Response::FileList]
59
+ # @return [LLM::Response]
58
60
  def all(**params)
59
61
  query = URI.encode_www_form(params.merge!(key: key))
60
62
  req = Net::HTTP::Get.new("/v1beta/files?#{query}", headers)
61
63
  res = execute(request: req)
62
- LLM::Response::FileList.new(res).tap { |filelist|
63
- files = filelist.body["files"]&.map do |file|
64
- file = file.transform_keys { snakecase(_1) }
65
- LLM::Object.from_hash(file)
66
- end || []
67
- filelist.files = files
68
- }
64
+ LLM::Response.new(res)
69
65
  end
70
66
 
71
67
  ##
@@ -77,7 +73,7 @@ class LLM::Gemini
77
73
  # @param [String, LLM::File] file The file
78
74
  # @param [Hash] params Other parameters (see Gemini docs)
79
75
  # @raise (see LLM::Provider#request)
80
- # @return [LLM::Response::File]
76
+ # @return [LLM::Response]
81
77
  def create(file:, **params)
82
78
  file = LLM.File(file)
83
79
  req = Net::HTTP::Post.new(request_upload_url(file:), {})
@@ -87,7 +83,7 @@ class LLM::Gemini
87
83
  file.with_io do |io|
88
84
  set_body_stream(req, io)
89
85
  res = execute(request: req)
90
- LLM::Response::File.new(res)
86
+ LLM::Response.new(res).extend(LLM::Gemini::Response::File)
91
87
  end
92
88
  end
93
89
 
@@ -101,13 +97,13 @@ class LLM::Gemini
101
97
  # @param [#name, String] file The file to get
102
98
  # @param [Hash] params Other parameters (see Gemini docs)
103
99
  # @raise (see LLM::Provider#request)
104
- # @return [LLM::Response::File]
100
+ # @return [LLM::Response]
105
101
  def get(file:, **params)
106
102
  file_id = file.respond_to?(:name) ? file.name : file.to_s
107
103
  query = URI.encode_www_form(params.merge!(key: key))
108
104
  req = Net::HTTP::Get.new("/v1beta/#{file_id}?#{query}", headers)
109
105
  res = execute(request: req)
110
- LLM::Response::File.new(res)
106
+ LLM::Response.new(res).extend(LLM::Gemini::Response::File)
111
107
  end
112
108
 
113
109
  ##
@@ -119,12 +115,13 @@ class LLM::Gemini
119
115
  # @param [#name, String] file The file to delete
120
116
  # @param [Hash] params Other parameters (see Gemini docs)
121
117
  # @raise (see LLM::Provider#request)
122
- # @return [LLM::Response::File]
118
+ # @return [LLM::Response]
123
119
  def delete(file:, **params)
124
120
  file_id = file.respond_to?(:name) ? file.name : file.to_s
125
121
  query = URI.encode_www_form(params.merge!(key: key))
126
122
  req = Net::HTTP::Delete.new("/v1beta/#{file_id}?#{query}", headers)
127
- execute(request: req)
123
+ res = execute(request: req)
124
+ LLM::Response.new(res)
128
125
  end
129
126
 
130
127
  ##
@@ -30,9 +30,11 @@ module LLM::Gemini::Format
30
30
  case content
31
31
  when Array
32
32
  content.empty? ? throw(:abort, nil) : content.flat_map { format_content(_1) }
33
- when LLM::Response::File
34
- file = content
35
- [{file_data: {mime_type: file.mime_type, file_uri: file.uri}}]
33
+ when LLM::Response
34
+ format_response(content)
35
+ when File
36
+ content.close unless content.closed?
37
+ format_content(LLM.File(content.path))
36
38
  when LLM::File
37
39
  file = content
38
40
  [{inline_data: {mime_type: file.mime_type, data: file.to_b64}}]
@@ -43,11 +45,24 @@ module LLM::Gemini::Format
43
45
  when LLM::Function::Return
44
46
  [{text: JSON.dump(content.value)}]
45
47
  else
46
- raise LLM::Error::PromptError, "The given object (an instance of #{content.class}) " \
47
- "is not supported by the Gemini API"
48
+ prompt_error!(content)
48
49
  end
49
50
  end
50
51
 
52
+ def format_response(response)
53
+ if response.file?
54
+ file = response
55
+ [{file_data: {mime_type: file.mime_type, file_uri: file.uri}}]
56
+ else
57
+ prompt_error!(content)
58
+ end
59
+ end
60
+
61
+ def prompt_error!(object)
62
+ raise LLM::PromptError, "The given object (an instance of #{object.class}) " \
63
+ "is not supported by the Gemini API"
64
+ end
65
+
51
66
  def message = @message
52
67
  def content = message.content
53
68
  end
File without changes
@@ -15,6 +15,7 @@ class LLM::Gemini
15
15
  # res = llm.images.create prompt: "A dog on a rocket to the moon"
16
16
  # IO.copy_stream res.images[0], "rocket.png"
17
17
  class Images
18
+ require_relative "response/image"
18
19
  include Format
19
20
 
20
21
  ##
@@ -39,16 +40,16 @@ class LLM::Gemini
39
40
  # The prompt should make it clear you want to generate an image, or you
40
41
  # might unexpectedly receive a purely textual response. This is due to how
41
42
  # Gemini implements image generation under the hood.
42
- # @return [LLM::Response::Image]
43
+ # @return [LLM::Response]
43
44
  def create(prompt:, model: "gemini-2.0-flash-exp-image-generation", **params)
44
45
  req = Net::HTTP::Post.new("/v1beta/models/#{model}:generateContent?key=#{key}", headers)
45
46
  body = JSON.dump({
46
- contents: [{parts: [{text: create_prompt}, {text: prompt}]}],
47
+ contents: [{parts: [{text: system_prompt}, {text: prompt}]}],
47
48
  generationConfig: {responseModalities: ["TEXT", "IMAGE"]}
48
49
  }.merge!(params))
49
50
  req.body = body
50
51
  res = execute(request: req)
51
- LLM::Response::Image.new(res).extend(response_parser)
52
+ LLM::Response.new(res).extend(LLM::Gemini::Response::Image)
52
53
  end
53
54
 
54
55
  ##
@@ -63,7 +64,7 @@ class LLM::Gemini
63
64
  # @param [Hash] params Other parameters (see Gemini docs)
64
65
  # @raise (see LLM::Provider#request)
65
66
  # @note (see LLM::Gemini::Images#create)
66
- # @return [LLM::Response::Image]
67
+ # @return [LLM::Response]
67
68
  def edit(image:, prompt:, model: "gemini-2.0-flash-exp-image-generation", **params)
68
69
  req = Net::HTTP::Post.new("/v1beta/models/#{model}:generateContent?key=#{key}", headers)
69
70
  image = LLM.File(image)
@@ -73,7 +74,7 @@ class LLM::Gemini
73
74
  }.merge!(params)).b
74
75
  set_body_stream(req, StringIO.new(body))
75
76
  res = execute(request: req)
76
- LLM::Response::Image.new(res).extend(response_parser)
77
+ LLM::Response.new(res).extend(LLM::Gemini::Response::Image)
77
78
  end
78
79
 
79
80
  ##
@@ -93,7 +94,7 @@ class LLM::Gemini
93
94
  @provider.instance_variable_get(:@key)
94
95
  end
95
96
 
96
- def create_prompt
97
+ def system_prompt
97
98
  <<~PROMPT
98
99
  Your task is to generate one or more image(s) from
99
100
  text I will provide to you. Your response *MUST* include
@@ -102,7 +103,7 @@ class LLM::Gemini
102
103
  PROMPT
103
104
  end
104
105
 
105
- [:response_parser, :headers, :execute, :set_body_stream].each do |m|
106
+ [:headers, :execute, :set_body_stream].each do |m|
106
107
  define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
107
108
  end
108
109
  end
@@ -38,18 +38,12 @@ class LLM::Gemini
38
38
  # @see https://ai.google.dev/api/models?hl=en#method:-models.list Gemini docs
39
39
  # @param [Hash] params Other parameters (see Gemini docs)
40
40
  # @raise (see LLM::Provider#request)
41
- # @return [LLM::Response::ModelList]
41
+ # @return [LLM::Response]
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
45
  res = execute(request: req)
46
- LLM::Response::ModelList.new(res).tap { |modellist|
47
- models = modellist.body["models"].map do |model|
48
- model = model.transform_keys { snakecase(_1) }
49
- LLM::Model.from_hash(model).tap { _1.provider = @provider }
50
- end
51
- modellist.models = models
52
- }
46
+ LLM::Response.new(res)
53
47
  end
54
48
 
55
49
  private
@@ -1,30 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module LLM::Gemini::ResponseParser
4
- class CompletionParser
5
- def initialize(body)
6
- @body = LLM::Object.from_hash(body)
7
- end
8
-
9
- def format(response)
10
- {
11
- model:,
12
- prompt_tokens:,
13
- completion_tokens:,
14
- total_tokens:,
15
- choices: format_choices(response)
16
- }
17
- end
3
+ module LLM::Gemini::Response
4
+ module Completion
5
+ def model = body.modelVersion
6
+ def prompt_tokens = body.usageMetadata.promptTokenCount
7
+ def completion_tokens = body.usageMetadata.candidatesTokenCount
8
+ def total_tokens = body.usageMetadata.totalTokenCount
9
+ def choices = format_choices
18
10
 
19
11
  private
20
12
 
21
- def format_choices(response)
13
+ def format_choices
22
14
  candidates.map.with_index do |choice, index|
15
+ choice = LLM::Object.from_hash(choice)
23
16
  content = choice.content
24
17
  role, parts = content.role, content.parts
25
18
  text = parts.filter_map { _1["text"] }.join
26
19
  tools = parts.filter_map { _1["functionCall"] }
27
- extra = {index:, response:, tool_calls: format_tool_calls(tools), original_tool_calls: tools}
20
+ extra = {index:, response: self, tool_calls: format_tool_calls(tools), original_tool_calls: tools}
28
21
  LLM::Message.new(role, text, extra)
29
22
  end
30
23
  end
@@ -35,12 +28,5 @@ module LLM::Gemini::ResponseParser
35
28
  LLM::Object.new(function)
36
29
  end
37
30
  end
38
-
39
- def body = @body
40
- def model = body.modelVersion
41
- def prompt_tokens = body.usageMetadata.promptTokenCount
42
- def completion_tokens = body.usageMetadata.candidatesTokenCount
43
- def total_tokens = body.usageMetadata.totalTokenCount
44
- def candidates = body.candidates
45
31
  end
46
32
  end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Gemini::Response
4
+ module Embedding
5
+ def model = "text-embedding-004"
6
+ def embeddings = body.dig("embedding", "values")
7
+ end
8
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Gemini::Response
4
+ module File
5
+ def name = respond_to?(:file) ? file.name : body.name
6
+ def display_name = respond_to?(:file) ? file.displayName : body.displayName
7
+ def mime_type = respond_to?(:file) ? file.mimeType : body.mimeType
8
+ def uri = respond_to?(:file) ? file.uri : body.uri
9
+ def file? = true
10
+ end
11
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Gemini::Response
4
+ module Image
5
+ ##
6
+ # @return [Array<StringIO>]
7
+ def images
8
+ candidates.flat_map do |candidate|
9
+ parts = candidate["content"]["parts"]
10
+ parts.filter_map do
11
+ data = _1.dig(:inlineData, :data)
12
+ next unless data
13
+ StringIO.new(data.unpack1("m0"))
14
+ end
15
+ end
16
+ end
17
+
18
+ ##
19
+ # Returns one or more image URLs, or an empty array
20
+ # @note
21
+ # Gemini's image generation API does not return URLs, so this method
22
+ # will always return an empty array.
23
+ # @return [Array<String>]
24
+ def urls = []
25
+ end
26
+ end
File without changes
@@ -29,10 +29,11 @@ module LLM
29
29
  # bot.chat ["Describe the image", LLM::File("/images/capybara.png")]
30
30
  # bot.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
31
31
  class Gemini < Provider
32
+ require_relative "gemini/response/embedding"
33
+ require_relative "gemini/response/completion"
32
34
  require_relative "gemini/error_handler"
33
35
  require_relative "gemini/format"
34
36
  require_relative "gemini/stream_parser"
35
- require_relative "gemini/response_parser"
36
37
  require_relative "gemini/models"
37
38
  require_relative "gemini/images"
38
39
  require_relative "gemini/files"
@@ -61,7 +62,7 @@ module LLM
61
62
  req = Net::HTTP::Post.new(path, headers)
62
63
  req.body = JSON.dump({content: {parts: [{text: input}]}})
63
64
  res = execute(request: req)
64
- Response::Embedding.new(res).extend(response_parser)
65
+ LLM::Response.new(res).extend(LLM::Gemini::Response::Embedding)
65
66
  end
66
67
 
67
68
  ##
@@ -71,7 +72,7 @@ module LLM
71
72
  # @param params (see LLM::Provider#complete)
72
73
  # @example (see LLM::Provider#complete)
73
74
  # @raise (see LLM::Provider#request)
74
- # @raise [LLM::Error::PromptError]
75
+ # @raise [LLM::PromptError]
75
76
  # When given an object a provider does not understand
76
77
  # @return (see LLM::Provider#complete)
77
78
  def complete(prompt, params = {})
@@ -86,7 +87,7 @@ module LLM
86
87
  body = JSON.dump({contents: format(messages)}.merge!(params))
87
88
  set_body_stream(req, StringIO.new(body))
88
89
  res = execute(request: req, stream:)
89
- Response::Completion.new(res).extend(response_parser)
90
+ LLM::Response.new(res).extend(LLM::Gemini::Response::Completion)
90
91
  end
91
92
 
92
93
  ##
@@ -140,10 +141,6 @@ module LLM
140
141
  )
141
142
  end
142
143
 
143
- def response_parser
144
- LLM::Gemini::ResponseParser
145
- end
146
-
147
144
  def stream_parser
148
145
  LLM::Gemini::StreamParser
149
146
  end
@@ -46,6 +46,12 @@ module LLM
46
46
  raise NotImplementedError
47
47
  end
48
48
 
49
+ ##
50
+ # @raise [NotImplementedError]
51
+ def vector_stores
52
+ raise NotImplementedError
53
+ end
54
+
49
55
  ##
50
56
  # Returns the default model for chat completions
51
57
  # @see https://ollama.com/library/qwen3 qwen3
File without changes
@@ -28,13 +28,16 @@ module LLM::Ollama::Format
28
28
 
29
29
  def format_content(content)
30
30
  case content
31
+ when File
32
+ content.close unless content.closed?
33
+ format_content(LLM.File(content.path))
31
34
  when LLM::File
32
35
  if content.image?
33
36
  {content: "This message has an image associated with it", images: [content.to_b64]}
34
37
  else
35
- raise LLM::Error::PromptError, "The given object (an instance of #{content.class}) " \
36
- "is not an image, and therefore not supported by the " \
37
- "Ollama API"
38
+ raise LLM::PromptError, "The given object (an instance of #{content.class}) " \
39
+ "is not an image, and therefore not supported by the " \
40
+ "Ollama API"
38
41
  end
39
42
  when String
40
43
  {content:}
@@ -43,8 +46,8 @@ module LLM::Ollama::Format
43
46
  when LLM::Function::Return
44
47
  throw(:abort, {role: "tool", tool_call_id: content.id, content: JSON.dump(content.value)})
45
48
  else
46
- raise LLM::Error::PromptError, "The given object (an instance of #{content.class}) " \
47
- "is not supported by the Ollama API"
49
+ raise LLM::PromptError, "The given object (an instance of #{content.class}) " \
50
+ "is not supported by the Ollama API"
48
51
  end
49
52
  end
50
53
 
File without changes
@@ -39,18 +39,12 @@ class LLM::Ollama
39
39
  # @see https://ollama.com/library Ollama library
40
40
  # @param [Hash] params Other parameters (see Ollama docs)
41
41
  # @raise (see LLM::Provider#request)
42
- # @return [LLM::Response::ModelList]
42
+ # @return [LLM::Response]
43
43
  def all(**params)
44
44
  query = URI.encode_www_form(params)
45
45
  req = Net::HTTP::Get.new("/api/tags?#{query}", headers)
46
46
  res = execute(request: req)
47
- LLM::Response::ModelList.new(res).tap { |modellist|
48
- models = modellist.body["models"].map do |model|
49
- model = model.transform_keys { snakecase(_1) }
50
- LLM::Model.from_hash(model).tap { _1.provider = @provider }
51
- end
52
- modellist.models = models
53
- }
47
+ LLM::Response.new(res)
54
48
  end
55
49
 
56
50
  private
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Ollama::Response
4
+ module Completion
5
+ def model = body.model
6
+ def prompt_tokens = body.prompt_eval_count || 0
7
+ def completion_tokens = body.eval_count || 0
8
+ def total_tokens = prompt_tokens + completion_tokens
9
+ def message = body.message
10
+ def choices = [format_choices]
11
+
12
+ private
13
+
14
+ def format_choices
15
+ role, content, calls = message.to_h.values_at("role", "content", "tool_calls")
16
+ extra = {response: self, tool_calls: format_tool_calls(calls)}
17
+ LLM::Message.new(role, content, extra)
18
+ end
19
+
20
+ def format_tool_calls(tools)
21
+ return [] unless tools
22
+ tools.filter_map do |tool|
23
+ next unless tool["function"]
24
+ LLM::Object.new(tool["function"])
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,10 @@
1
+
2
+ # frozen_string_literal: true
3
+
4
+ module LLM::Ollama::Response
5
+ module Embedding
6
+ def embeddings = data.map { _1["embedding"] }
7
+ def prompt_tokens = body.dig("usage", "prompt_tokens") || 0
8
+ def total_tokens = body.dig("usage", "total_tokens") || 0
9
+ end
10
+ end