llm.rb 0.2.1 → 0.3.1

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +318 -110
  3. data/lib/llm/buffer.rb +83 -0
  4. data/lib/llm/chat.rb +131 -0
  5. data/lib/llm/error.rb +3 -3
  6. data/lib/llm/file.rb +36 -40
  7. data/lib/llm/message.rb +21 -8
  8. data/lib/llm/mime.rb +54 -0
  9. data/lib/llm/multipart.rb +100 -0
  10. data/lib/llm/provider.rb +123 -21
  11. data/lib/llm/providers/anthropic/error_handler.rb +3 -1
  12. data/lib/llm/providers/anthropic/format.rb +2 -0
  13. data/lib/llm/providers/anthropic/response_parser.rb +3 -1
  14. data/lib/llm/providers/anthropic.rb +14 -5
  15. data/lib/llm/providers/gemini/audio.rb +77 -0
  16. data/lib/llm/providers/gemini/error_handler.rb +4 -2
  17. data/lib/llm/providers/gemini/files.rb +162 -0
  18. data/lib/llm/providers/gemini/format.rb +12 -6
  19. data/lib/llm/providers/gemini/images.rb +99 -0
  20. data/lib/llm/providers/gemini/response_parser.rb +27 -1
  21. data/lib/llm/providers/gemini.rb +62 -6
  22. data/lib/llm/providers/ollama/error_handler.rb +3 -1
  23. data/lib/llm/providers/ollama/format.rb +13 -5
  24. data/lib/llm/providers/ollama/response_parser.rb +3 -1
  25. data/lib/llm/providers/ollama.rb +30 -7
  26. data/lib/llm/providers/openai/audio.rb +97 -0
  27. data/lib/llm/providers/openai/error_handler.rb +3 -1
  28. data/lib/llm/providers/openai/files.rb +148 -0
  29. data/lib/llm/providers/openai/format.rb +22 -8
  30. data/lib/llm/providers/openai/images.rb +109 -0
  31. data/lib/llm/providers/openai/response_parser.rb +58 -5
  32. data/lib/llm/providers/openai/responses.rb +85 -0
  33. data/lib/llm/providers/openai.rb +52 -6
  34. data/lib/llm/providers/voyageai/error_handler.rb +1 -1
  35. data/lib/llm/providers/voyageai.rb +2 -2
  36. data/lib/llm/response/audio.rb +13 -0
  37. data/lib/llm/response/audio_transcription.rb +14 -0
  38. data/lib/llm/response/audio_translation.rb +14 -0
  39. data/lib/llm/response/download_file.rb +15 -0
  40. data/lib/llm/response/file.rb +42 -0
  41. data/lib/llm/response/filelist.rb +18 -0
  42. data/lib/llm/response/image.rb +29 -0
  43. data/lib/llm/response/output.rb +56 -0
  44. data/lib/llm/response.rb +18 -6
  45. data/lib/llm/utils.rb +19 -0
  46. data/lib/llm/version.rb +1 -1
  47. data/lib/llm.rb +5 -2
  48. data/llm.gemspec +1 -6
  49. data/spec/anthropic/completion_spec.rb +1 -1
  50. data/spec/gemini/completion_spec.rb +1 -1
  51. data/spec/gemini/conversation_spec.rb +31 -0
  52. data/spec/gemini/files_spec.rb +124 -0
  53. data/spec/gemini/images_spec.rb +47 -0
  54. data/spec/llm/conversation_spec.rb +107 -62
  55. data/spec/ollama/completion_spec.rb +1 -1
  56. data/spec/ollama/conversation_spec.rb +31 -0
  57. data/spec/openai/audio_spec.rb +55 -0
  58. data/spec/openai/completion_spec.rb +5 -4
  59. data/spec/openai/files_spec.rb +204 -0
  60. data/spec/openai/images_spec.rb +95 -0
  61. data/spec/openai/responses_spec.rb +51 -0
  62. data/spec/setup.rb +8 -0
  63. metadata +31 -50
  64. data/LICENSE.txt +0 -21
  65. data/lib/llm/conversation.rb +0 -90
  66. data/lib/llm/http_client.rb +0 -29
  67. data/lib/llm/message_queue.rb +0 -54
@@ -2,8 +2,22 @@
2
2
 
3
3
  module LLM
4
4
  ##
5
- # The Ollama class implements a provider for
6
- # [Ollama](https://ollama.ai/)
5
+ # The Ollama class implements a provider for [Ollama](https://ollama.ai/).
6
+ #
7
+ # This provider supports a wide range of models, it is relatively
8
+ # straight forward to run on your own hardware, and includes multi-modal
9
+ # models that can process images and text. See the example for a demonstration
10
+ # of a multi-modal model by the name `llava`
11
+ #
12
+ # @example
13
+ # #!/usr/bin/env ruby
14
+ # require "llm"
15
+ #
16
+ # llm = LLM.ollama(nil)
17
+ # bot = LLM::Chat.new(llm, model: "llava").lazy
18
+ # bot.chat LLM::File("/images/capybara.png")
19
+ # bot.chat "Describe the image"
20
+ # bot.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
7
21
  class Ollama < Provider
8
22
  require_relative "ollama/error_handler"
9
23
  require_relative "ollama/response_parser"
@@ -19,10 +33,14 @@ module LLM
19
33
  end
20
34
 
21
35
  ##
36
+ # Provides an embedding
22
37
  # @param input (see LLM::Provider#embed)
38
+ # @param model (see LLM::Provider#embed)
39
+ # @param params (see LLM::Provider#embed)
40
+ # @raise (see LLM::Provider#request)
23
41
  # @return (see LLM::Provider#embed)
24
- def embed(input, **params)
25
- params = {model: "llama3.2"}.merge!(params)
42
+ def embed(input, model: "llama3.2", **params)
43
+ params = {model:}.merge!(params)
26
44
  req = Net::HTTP::Post.new("/v1/embeddings", headers)
27
45
  req.body = JSON.dump({input:}.merge!(params))
28
46
  res = request(@http, req)
@@ -30,15 +48,20 @@ module LLM
30
48
  end
31
49
 
32
50
  ##
51
+ # Provides an interface to the chat completions API
33
52
  # @see https://github.com/ollama/ollama/blob/main/docs/api.md#generate-a-chat-completion Ollama docs
34
53
  # @param prompt (see LLM::Provider#complete)
35
54
  # @param role (see LLM::Provider#complete)
55
+ # @param model (see LLM::Provider#complete)
56
+ # @param params (see LLM::Provider#complete)
57
+ # @example (see LLM::Provider#complete)
58
+ # @raise (see LLM::Provider#request)
36
59
  # @return (see LLM::Provider#complete)
37
- def complete(prompt, role = :user, **params)
38
- params = {model: "llama3.2", stream: false}.merge!(params)
60
+ def complete(prompt, role = :user, model: "llama3.2", **params)
61
+ params = {model:, stream: false}.merge!(params)
39
62
  req = Net::HTTP::Post.new("/api/chat", headers)
40
63
  messages = [*(params.delete(:messages) || []), LLM::Message.new(role, prompt)]
41
- req.body = JSON.dump({messages: messages.map(&:to_h)}.merge!(params))
64
+ req.body = JSON.dump({messages: format(messages)}.merge!(params))
42
65
  res = request(@http, req)
43
66
  Response::Completion.new(res).extend(response_parser)
44
67
  end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::OpenAI
4
+ ##
5
+ # The {LLM::OpenAI::Audio LLM::OpenAI::Audio} class provides an audio
6
+ # object for interacting with [OpenAI's audio API](https://platform.openai.com/docs/api-reference/audio/createSpeech).
7
+ # @example
8
+ # llm = LLM.openai(ENV["KEY"])
9
+ # res = llm.audio.create_speech(input: "A dog on a rocket to the moon")
10
+ # File.binwrite("rocket.mp3", res.audio.string)
11
+ class Audio
12
+ require "stringio"
13
+
14
+ ##
15
+ # Returns a new Audio object
16
+ # @param provider [LLM::Provider]
17
+ # @return [LLM::OpenAI::Responses]
18
+ def initialize(provider)
19
+ @provider = provider
20
+ end
21
+
22
+ ##
23
+ # Create an audio track
24
+ # @example
25
+ # llm = LLM.openai(ENV["KEY"])
26
+ # res = llm.images.create_speech(input: "A dog on a rocket to the moon")
27
+ # File.binwrite("rocket.mp3", res.audio.string)
28
+ # @see https://platform.openai.com/docs/api-reference/audio/createSpeech OpenAI docs
29
+ # @param [String] input The text input
30
+ # @param [String] voice The voice to use
31
+ # @param [String] model The model to use
32
+ # @param [String] response_format The response format
33
+ # @param [Hash] params Other parameters (see OpenAI docs)
34
+ # @raise (see LLM::Provider#request)
35
+ # @return [LLM::Response::Audio]
36
+ def create_speech(input:, voice: "alloy", model: "gpt-4o-mini-tts", response_format: "mp3", **params)
37
+ req = Net::HTTP::Post.new("/v1/audio/speech", headers)
38
+ req.body = JSON.dump({input:, voice:, model:, response_format:}.merge!(params))
39
+ io = StringIO.new("".b)
40
+ res = request(http, req) { _1.read_body { |chunk| io << chunk } }
41
+ LLM::Response::Audio.new(res).tap { _1.audio = io }
42
+ end
43
+
44
+ ##
45
+ # Create an audio transcription
46
+ # @example
47
+ # llm = LLM.openai(ENV["KEY"])
48
+ # res = llm.audio.create_transcription(file: LLM::File("/rocket.mp3"))
49
+ # res.text # => "A dog on a rocket to the moon"
50
+ # @see https://platform.openai.com/docs/api-reference/audio/createTranscription OpenAI docs
51
+ # @param [LLM::File] file The input audio
52
+ # @param [String] model The model to use
53
+ # @param [Hash] params Other parameters (see OpenAI docs)
54
+ # @raise (see LLM::Provider#request)
55
+ # @return [LLM::Response::AudioTranscription]
56
+ def create_transcription(file:, model: "whisper-1", **params)
57
+ multi = LLM::Multipart.new(params.merge!(file:, model:))
58
+ req = Net::HTTP::Post.new("/v1/audio/transcriptions", headers)
59
+ req["content-type"] = multi.content_type
60
+ req.body_stream = multi.body
61
+ res = request(http, req)
62
+ LLM::Response::AudioTranscription.new(res).tap { _1.text = _1.body["text"] }
63
+ end
64
+
65
+ ##
66
+ # Create an audio translation (in English)
67
+ # @example
68
+ # # Arabic => English
69
+ # llm = LLM.openai(ENV["KEY"])
70
+ # res = llm.audio.create_translation(file: LLM::File("/bismillah.mp3"))
71
+ # res.text # => "In the name of Allah, the Beneficent, the Merciful."
72
+ # @see https://platform.openai.com/docs/api-reference/audio/createTranslation OpenAI docs
73
+ # @param [LLM::File] file The input audio
74
+ # @param [String] model The model to use
75
+ # @param [Hash] params Other parameters (see OpenAI docs)
76
+ # @raise (see LLM::Provider#request)
77
+ # @return [LLM::Response::AudioTranslation]
78
+ def create_translation(file:, model: "whisper-1", **params)
79
+ multi = LLM::Multipart.new(params.merge!(file:, model:))
80
+ req = Net::HTTP::Post.new("/v1/audio/translations", headers)
81
+ req["content-type"] = multi.content_type
82
+ req.body_stream = multi.body
83
+ res = request(http, req)
84
+ LLM::Response::AudioTranslation.new(res).tap { _1.text = _1.body["text"] }
85
+ end
86
+
87
+ private
88
+
89
+ def http
90
+ @provider.instance_variable_get(:@http)
91
+ end
92
+
93
+ [:headers, :request].each do |m|
94
+ define_method(m) { |*args, &b| @provider.send(m, *args, &b) }
95
+ end
96
+ end
97
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class LLM::OpenAI
4
+ ##
5
+ # @private
4
6
  class ErrorHandler
5
7
  ##
6
8
  # @return [Net::HTTPResponse]
@@ -25,7 +27,7 @@ class LLM::OpenAI
25
27
  when Net::HTTPTooManyRequests
26
28
  raise LLM::Error::RateLimit.new { _1.response = res }, "Too many requests"
27
29
  else
28
- raise LLM::Error::BadResponse.new { _1.response = res }, "Unexpected response"
30
+ raise LLM::Error::ResponseError.new { _1.response = res }, "Unexpected response"
29
31
  end
30
32
  end
31
33
  end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::OpenAI
4
+ ##
5
+ # The {LLM::OpenAI::Files LLM::OpenAI::Files} class provides a files
6
+ # object for interacting with [OpenAI's Files API](https://platform.openai.com/docs/api-reference/files/create).
7
+ # The files API allows a client to upload files for use with OpenAI's models
8
+ # and API endpoints. OpenAI supports multiple file formats, including text
9
+ # files, CSV files, JSON files, and more.
10
+ #
11
+ # @example
12
+ # #!/usr/bin/env ruby
13
+ # require "llm"
14
+ #
15
+ # llm = LLM.openai(ENV["KEY"])
16
+ # bot = LLM::Chat.new(llm).lazy
17
+ # file = llm.files.create file: LLM::File("/documents/freebsd.pdf")
18
+ # bot.chat(file)
19
+ # bot.chat("Describe the document")
20
+ # bot.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
21
+ # @example
22
+ # #!/usr/bin/env ruby
23
+ # require "llm"
24
+ #
25
+ # llm = LLM.openai(ENV["KEY"])
26
+ # bot = LLM::Chat.new(llm).lazy
27
+ # file = llm.files.create file: LLM::File("/documents/openbsd.pdf")
28
+ # bot.chat(["Describe the document I sent to you", file])
29
+ # bot.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
30
+ class Files
31
+ ##
32
+ # Returns a new Files object
33
+ # @param provider [LLM::Provider]
34
+ # @return [LLM::OpenAI::Files]
35
+ def initialize(provider)
36
+ @provider = provider
37
+ end
38
+
39
+ ##
40
+ # List all files
41
+ # @example
42
+ # llm = LLM.openai(ENV["KEY"])
43
+ # res = llm.files.all
44
+ # res.each do |file|
45
+ # print "id: ", file.id, "\n"
46
+ # end
47
+ # @see https://platform.openai.com/docs/api-reference/files/list OpenAI docs
48
+ # @param [Hash] params Other parameters (see OpenAI docs)
49
+ # @raise (see LLM::Provider#request)
50
+ # @return [LLM::Response::FileList]
51
+ def all(**params)
52
+ query = URI.encode_www_form(params)
53
+ req = Net::HTTP::Get.new("/v1/files?#{query}", headers)
54
+ res = request(http, req)
55
+ LLM::Response::FileList.new(res).tap { |filelist|
56
+ files = filelist.body["data"].map { OpenStruct.from_hash(_1) }
57
+ filelist.files = files
58
+ }
59
+ end
60
+
61
+ ##
62
+ # Create a file
63
+ # @example
64
+ # llm = LLM.openai(ENV["KEY"])
65
+ # res = llm.files.create file: LLM::File("/documents/haiku.txt"),
66
+ # @see https://platform.openai.com/docs/api-reference/files/create OpenAI docs
67
+ # @param [File] file The file
68
+ # @param [String] purpose The purpose of the file (see OpenAI docs)
69
+ # @param [Hash] params Other parameters (see OpenAI docs)
70
+ # @raise (see LLM::Provider#request)
71
+ # @return [LLM::Response::File]
72
+ def create(file:, purpose: "assistants", **params)
73
+ multi = LLM::Multipart.new(params.merge!(file:, purpose:))
74
+ req = Net::HTTP::Post.new("/v1/files", headers)
75
+ req["content-type"] = multi.content_type
76
+ req.body_stream = multi.body
77
+ res = request(http, req)
78
+ LLM::Response::File.new(res)
79
+ end
80
+
81
+ ##
82
+ # Get a file
83
+ # @example
84
+ # llm = LLM.openai(ENV["KEY"])
85
+ # res = llm.files.get(file: "file-1234567890")
86
+ # print "id: ", res.id, "\n"
87
+ # @see https://platform.openai.com/docs/api-reference/files/get OpenAI docs
88
+ # @param [#id, #to_s] file The file ID
89
+ # @param [Hash] params Other parameters (see OpenAI docs)
90
+ # @raise (see LLM::Provider#request)
91
+ # @return [LLM::Response::File]
92
+ def get(file:, **params)
93
+ file_id = file.respond_to?(:id) ? file.id : file
94
+ query = URI.encode_www_form(params)
95
+ req = Net::HTTP::Get.new("/v1/files/#{file_id}?#{query}", headers)
96
+ res = request(http, req)
97
+ LLM::Response::File.new(res)
98
+ end
99
+
100
+ ##
101
+ # Download the content of a file
102
+ # @example
103
+ # llm = LLM.openai(ENV["KEY"])
104
+ # res = llm.files.download(file: "file-1234567890")
105
+ # File.binwrite "haiku1.txt", res.file.read
106
+ # print res.file.read, "\n"
107
+ # @see https://platform.openai.com/docs/api-reference/files/content OpenAI docs
108
+ # @param [#id, #to_s] file The file ID
109
+ # @param [Hash] params Other parameters (see OpenAI docs)
110
+ # @raise (see LLM::Provider#request)
111
+ # @return [LLM::Response::DownloadFile]
112
+ def download(file:, **params)
113
+ query = URI.encode_www_form(params)
114
+ file_id = file.respond_to?(:id) ? file.id : file
115
+ req = Net::HTTP::Get.new("/v1/files/#{file_id}/content?#{query}", headers)
116
+ io = StringIO.new("".b)
117
+ res = request(http, req) { |res| res.read_body { |chunk| io << chunk } }
118
+ LLM::Response::DownloadFile.new(res).tap { _1.file = io }
119
+ end
120
+
121
+ ##
122
+ # Delete a file
123
+ # @example
124
+ # llm = LLM.openai(ENV["KEY"])
125
+ # res = llm.files.delete(file: "file-1234567890")
126
+ # print res.deleted, "\n"
127
+ # @see https://platform.openai.com/docs/api-reference/files/delete OpenAI docs
128
+ # @param [#id, #to_s] file The file ID
129
+ # @raise (see LLM::Provider#request)
130
+ # @return [OpenStruct] Response body
131
+ def delete(file:)
132
+ file_id = file.respond_to?(:id) ? file.id : file
133
+ req = Net::HTTP::Delete.new("/v1/files/#{file_id}", headers)
134
+ res = request(http, req)
135
+ OpenStruct.from_hash JSON.parse(res.body)
136
+ end
137
+
138
+ private
139
+
140
+ def http
141
+ @provider.instance_variable_get(:@http)
142
+ end
143
+
144
+ [:headers, :request].each do |m|
145
+ define_method(m) { |*args, &b| @provider.send(m, *args, &b) }
146
+ end
147
+ end
148
+ end
@@ -1,17 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class LLM::OpenAI
4
+ ##
5
+ # @private
4
6
  module Format
5
7
  ##
6
8
  # @param [Array<LLM::Message>] messages
7
9
  # The messages to format
10
+ # @param [Symbol] mode
11
+ # The mode to format the messages for
8
12
  # @return [Array<Hash>]
9
- def format(messages)
13
+ def format(messages, mode)
10
14
  messages.map do
11
15
  if Hash === _1
12
- {role: _1[:role], content: format_content(_1[:content])}
16
+ {role: _1[:role], content: format_content(_1[:content], mode)}
13
17
  else
14
- {role: _1.role, content: format_content(_1.content)}
18
+ {role: _1.role, content: format_content(_1.content, mode)}
15
19
  end
16
20
  end
17
21
  end
@@ -23,11 +27,21 @@ class LLM::OpenAI
23
27
  # The content to format
24
28
  # @return [String, Hash]
25
29
  # The formatted content
26
- def format_content(content)
27
- if URI === content
28
- [{type: :image_url, image_url: {url: content.to_s}}]
29
- else
30
- content
30
+ def format_content(content, mode)
31
+ if mode == :complete
32
+ case content
33
+ when Array then content.flat_map { format_content(_1, mode) }
34
+ when URI then [{type: :image_url, image_url: {url: content.to_s}}]
35
+ when LLM::Response::File then [{type: :file, file: {file_id: content.id}}]
36
+ else [{type: :text, text: content.to_s}]
37
+ end
38
+ elsif mode == :response
39
+ case content
40
+ when Array then content.flat_map { format_content(_1, mode) }
41
+ when URI then [{type: :image_url, image_url: {url: content.to_s}}]
42
+ when LLM::Response::File then [{type: :input_file, file_id: content.id}]
43
+ else [{type: :input_text, text: content.to_s}]
44
+ end
31
45
  end
32
46
  end
33
47
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::OpenAI
4
+ ##
5
+ # The {LLM::OpenAI::Images LLM::OpenAI::Images} class provides an images
6
+ # object for interacting with [OpenAI's images API](https://platform.openai.com/docs/api-reference/images).
7
+ # OpenAI supports multiple response formats: temporary URLs, or binary strings
8
+ # encoded in base64. The default is to return temporary URLs.
9
+ #
10
+ # @example
11
+ # #!/usr/bin/env ruby
12
+ # require "llm"
13
+ # require "open-uri"
14
+ # require "fileutils"
15
+ #
16
+ # llm = LLM.openai(ENV["KEY"])
17
+ # res = llm.images.create prompt: "A dog on a rocket to the moon"
18
+ # FileUtils.mv OpenURI.open_uri(res.urls[0]).path,
19
+ # "rocket.png"
20
+ # @example
21
+ # #!/usr/bin/env ruby
22
+ # require "llm"
23
+ #
24
+ # llm = LLM.openai(ENV["KEY"])
25
+ # res = llm.images.create prompt: "A dog on a rocket to the moon",
26
+ # response_format: "b64_json"
27
+ # File.binwrite("rocket.png", res.images[0].binary)
28
+ class Images
29
+ ##
30
+ # Returns a new Images object
31
+ # @param provider [LLM::Provider]
32
+ # @return [LLM::OpenAI::Responses]
33
+ def initialize(provider)
34
+ @provider = provider
35
+ end
36
+
37
+ ##
38
+ # Create an image
39
+ # @example
40
+ # llm = LLM.openai(ENV["KEY"])
41
+ # res = llm.images.create prompt: "A dog on a rocket to the moon"
42
+ # p res.urls
43
+ # @see https://platform.openai.com/docs/api-reference/images/create OpenAI docs
44
+ # @param [String] prompt The prompt
45
+ # @param [String] model The model to use
46
+ # @param [Hash] params Other parameters (see OpenAI docs)
47
+ # @raise (see LLM::Provider#request)
48
+ # @return [LLM::Response::Image]
49
+ def create(prompt:, model: "dall-e-3", **params)
50
+ req = Net::HTTP::Post.new("/v1/images/generations", headers)
51
+ req.body = JSON.dump({prompt:, n: 1, model:}.merge!(params))
52
+ res = request(http, req)
53
+ LLM::Response::Image.new(res).extend(response_parser)
54
+ end
55
+
56
+ ##
57
+ # Create image variations
58
+ # @example
59
+ # llm = LLM.openai(ENV["KEY"])
60
+ # res = llm.images.create_variation(image: LLM::File("/images/hat.png"), n: 5)
61
+ # p res.urls
62
+ # @see https://platform.openai.com/docs/api-reference/images/createVariation OpenAI docs
63
+ # @param [File] image The image to create variations from
64
+ # @param [String] model The model to use
65
+ # @param [Hash] params Other parameters (see OpenAI docs)
66
+ # @raise (see LLM::Provider#request)
67
+ # @return [LLM::Response::Image]
68
+ def create_variation(image:, model: "dall-e-2", **params)
69
+ multi = LLM::Multipart.new(params.merge!(image:, model:))
70
+ req = Net::HTTP::Post.new("/v1/images/variations", headers)
71
+ req["content-type"] = multi.content_type
72
+ req.body_stream = multi.body
73
+ res = request(http, req)
74
+ LLM::Response::Image.new(res).extend(response_parser)
75
+ end
76
+
77
+ ##
78
+ # Edit an image
79
+ # @example
80
+ # llm = LLM.openai(ENV["KEY"])
81
+ # res = llm.images.edit(image: LLM::File("/images/hat.png"), prompt: "A cat wearing this hat")
82
+ # p res.urls
83
+ # @see https://platform.openai.com/docs/api-reference/images/createEdit OpenAI docs
84
+ # @param [File] image The image to edit
85
+ # @param [String] prompt The prompt
86
+ # @param [String] model The model to use
87
+ # @param [Hash] params Other parameters (see OpenAI docs)
88
+ # @raise (see LLM::Provider#request)
89
+ # @return [LLM::Response::Image]
90
+ def edit(image:, prompt:, model: "dall-e-2", **params)
91
+ multi = LLM::Multipart.new(params.merge!(image:, prompt:, model:))
92
+ req = Net::HTTP::Post.new("/v1/images/edits", headers)
93
+ req["content-type"] = multi.content_type
94
+ req.body_stream = multi.body
95
+ res = request(http, req)
96
+ LLM::Response::Image.new(res).extend(response_parser)
97
+ end
98
+
99
+ private
100
+
101
+ def http
102
+ @provider.instance_variable_get(:@http)
103
+ end
104
+
105
+ [:response_parser, :headers, :request].each do |m|
106
+ define_method(m) { |*args, &b| @provider.send(m, *args, &b) }
107
+ end
108
+ end
109
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class LLM::OpenAI
4
+ ##
5
+ # @private
4
6
  module ResponseParser
5
7
  ##
6
8
  # @param [Hash] body
@@ -22,16 +24,67 @@ class LLM::OpenAI
22
24
  def parse_completion(body)
23
25
  {
24
26
  model: body["model"],
25
- choices: body["choices"].map do
26
- mesg = _1["message"]
27
- logprobs = _1["logprobs"]
28
- role, content = mesg.values_at("role", "content")
29
- LLM::Message.new(role, content, {completion: self, logprobs:})
27
+ choices: body["choices"].map.with_index do
28
+ extra = {
29
+ index: _2, response: self,
30
+ logprobs: _1["logprobs"]
31
+ }
32
+ LLM::Message.new(*_1["message"].values_at("role", "content"), extra)
30
33
  end,
31
34
  prompt_tokens: body.dig("usage", "prompt_tokens"),
32
35
  completion_tokens: body.dig("usage", "completion_tokens"),
33
36
  total_tokens: body.dig("usage", "total_tokens")
34
37
  }
35
38
  end
39
+
40
+ ##
41
+ # @param [Hash] body
42
+ # The response body from the LLM provider
43
+ # @return [Hash]
44
+ def parse_output_response(body)
45
+ {
46
+ id: body["id"],
47
+ model: body["model"],
48
+ input_tokens: body.dig("usage", "input_tokens"),
49
+ output_tokens: body.dig("usage", "output_tokens"),
50
+ total_tokens: body.dig("usage", "total_tokens"),
51
+ outputs: body["output"].filter_map.with_index do |output, index|
52
+ next unless output["content"]
53
+ extra = {
54
+ index:, response: self,
55
+ contents: output["content"],
56
+ annotations: output["annotations"]
57
+ }
58
+ LLM::Message.new(output["role"], text(output), extra)
59
+ end
60
+ }
61
+ end
62
+
63
+ ##
64
+ # @param [Hash] body
65
+ # The response body from the LLM provider
66
+ # @return [Hash]
67
+ def parse_image(body)
68
+ {
69
+ urls: body["data"].filter_map { _1["url"] },
70
+ images: body["data"].filter_map do
71
+ next unless _1["b64_json"]
72
+ OpenStruct.from_hash(
73
+ mime_type: nil,
74
+ encoded: _1["b64_json"],
75
+ binary: _1["b64_json"].unpack1("m0")
76
+ )
77
+ end
78
+ }
79
+ end
80
+
81
+ private
82
+
83
+ def text(output)
84
+ output["content"]
85
+ .select { _1["type"] == "output_text" }
86
+ .map { _1["text"] }
87
+ .join("\n")
88
+ end
36
89
  end
37
90
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::OpenAI
4
+ ##
5
+ # The {LLM::OpenAI::Responses LLM::OpenAI::Responses} class provides a responses
6
+ # object for interacting with [OpenAI's response API](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses).
7
+ # The responses API is similar to the chat completions API but it can maintain
8
+ # conversation state across multiple requests. This is useful when you want to
9
+ # save bandwidth and/or not maintain the message thread by yourself.
10
+ #
11
+ # @example
12
+ # #!/usr/bin/env ruby
13
+ # require "llm"
14
+ #
15
+ # llm = LLM.openai(ENV["KEY"])
16
+ # res1 = llm.responses.create "Your task is to help me with math", :developer
17
+ # res2 = llm.responses.create "5 + 5 = ?", :user, previous_response_id: res1.id
18
+ # [res1,res2].each { llm.responses.delete(_1) }
19
+ class Responses
20
+ include Format
21
+
22
+ ##
23
+ # Returns a new Responses object
24
+ # @param provider [LLM::Provider]
25
+ # @return [LLM::OpenAI::Responses]
26
+ def initialize(provider)
27
+ @provider = provider
28
+ end
29
+
30
+ ##
31
+ # Create a response
32
+ # @see https://platform.openai.com/docs/api-reference/responses/create OpenAI docs
33
+ # @param prompt (see LLM::Provider#complete)
34
+ # @param role (see LLM::Provider#complete)
35
+ # @param model (see LLM::Provider#complete)
36
+ # @param [Hash] params Response params
37
+ # @raise (see LLM::Provider#request)
38
+ # @return [LLM::Response::Output]
39
+ def create(prompt, role = :user, model: "gpt-4o-mini", **params)
40
+ params = {model:}.merge!(params)
41
+ req = Net::HTTP::Post.new("/v1/responses", headers)
42
+ messages = [*(params.delete(:input) || []), LLM::Message.new(role, prompt)]
43
+ req.body = JSON.dump({input: format(messages, :response)}.merge!(params))
44
+ res = request(http, req)
45
+ LLM::Response::Output.new(res).extend(response_parser)
46
+ end
47
+
48
+ ##
49
+ # Get a response
50
+ # @see https://platform.openai.com/docs/api-reference/responses/get OpenAI docs
51
+ # @param [#id, #to_s] response Response ID
52
+ # @raise (see LLM::Provider#request)
53
+ # @return [LLM::Response::Output]
54
+ def get(response, **params)
55
+ response_id = response.respond_to?(:id) ? response.id : response
56
+ query = URI.encode_www_form(params)
57
+ req = Net::HTTP::Get.new("/v1/responses/#{response_id}?#{query}", headers)
58
+ res = request(http, req)
59
+ LLM::Response::Output.new(res).extend(response_parser)
60
+ end
61
+
62
+ ##
63
+ # Deletes a response
64
+ # @see https://platform.openai.com/docs/api-reference/responses/delete OpenAI docs
65
+ # @param [#id, #to_s] response Response ID
66
+ # @raise (see LLM::Provider#request)
67
+ # @return [OpenStruct] Response body
68
+ def delete(response)
69
+ response_id = response.respond_to?(:id) ? response.id : response
70
+ req = Net::HTTP::Delete.new("/v1/responses/#{response_id}", headers)
71
+ res = request(http, req)
72
+ OpenStruct.from_hash JSON.parse(res.body)
73
+ end
74
+
75
+ private
76
+
77
+ def http
78
+ @provider.instance_variable_get(:@http)
79
+ end
80
+
81
+ [:response_parser, :headers, :request].each do |m|
82
+ define_method(m) { |*args, &b| @provider.send(m, *args, &b) }
83
+ end
84
+ end
85
+ end