llm.rb 0.4.2 → 0.6.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +173 -115
  3. data/lib/json/schema/array.rb +5 -0
  4. data/lib/json/schema/boolean.rb +4 -0
  5. data/lib/json/schema/integer.rb +23 -1
  6. data/lib/json/schema/leaf.rb +11 -0
  7. data/lib/json/schema/null.rb +4 -0
  8. data/lib/json/schema/number.rb +23 -1
  9. data/lib/json/schema/object.rb +6 -2
  10. data/lib/json/schema/string.rb +26 -1
  11. data/lib/json/schema/version.rb +2 -0
  12. data/lib/json/schema.rb +10 -10
  13. data/lib/llm/buffer.rb +31 -12
  14. data/lib/llm/chat.rb +56 -29
  15. data/lib/llm/core_ext/ostruct.rb +14 -8
  16. data/lib/llm/file.rb +6 -1
  17. data/lib/llm/function.rb +86 -0
  18. data/lib/llm/message.rb +54 -2
  19. data/lib/llm/provider.rb +32 -46
  20. data/lib/llm/providers/anthropic/format/completion_format.rb +73 -0
  21. data/lib/llm/providers/anthropic/format.rb +8 -33
  22. data/lib/llm/providers/anthropic/response_parser/completion_parser.rb +51 -0
  23. data/lib/llm/providers/anthropic/response_parser.rb +1 -9
  24. data/lib/llm/providers/anthropic.rb +14 -14
  25. data/lib/llm/providers/gemini/audio.rb +9 -9
  26. data/lib/llm/providers/gemini/files.rb +11 -10
  27. data/lib/llm/providers/gemini/format/completion_format.rb +54 -0
  28. data/lib/llm/providers/gemini/format.rb +20 -27
  29. data/lib/llm/providers/gemini/images.rb +12 -7
  30. data/lib/llm/providers/gemini/models.rb +3 -3
  31. data/lib/llm/providers/gemini/response_parser/completion_parser.rb +46 -0
  32. data/lib/llm/providers/gemini/response_parser.rb +13 -20
  33. data/lib/llm/providers/gemini.rb +10 -20
  34. data/lib/llm/providers/ollama/format/completion_format.rb +72 -0
  35. data/lib/llm/providers/ollama/format.rb +11 -30
  36. data/lib/llm/providers/ollama/response_parser/completion_parser.rb +42 -0
  37. data/lib/llm/providers/ollama/response_parser.rb +8 -11
  38. data/lib/llm/providers/ollama.rb +9 -17
  39. data/lib/llm/providers/openai/audio.rb +6 -6
  40. data/lib/llm/providers/openai/files.rb +3 -3
  41. data/lib/llm/providers/openai/format/completion_format.rb +83 -0
  42. data/lib/llm/providers/openai/format/respond_format.rb +69 -0
  43. data/lib/llm/providers/openai/format.rb +27 -58
  44. data/lib/llm/providers/openai/images.rb +4 -2
  45. data/lib/llm/providers/openai/response_parser/completion_parser.rb +55 -0
  46. data/lib/llm/providers/openai/response_parser/respond_parser.rb +56 -0
  47. data/lib/llm/providers/openai/response_parser.rb +8 -44
  48. data/lib/llm/providers/openai/responses.rb +13 -14
  49. data/lib/llm/providers/openai.rb +11 -23
  50. data/lib/llm/providers/voyageai.rb +4 -4
  51. data/lib/llm/response/{output.rb → respond.rb} +2 -2
  52. data/lib/llm/response.rb +1 -1
  53. data/lib/llm/version.rb +1 -1
  54. data/lib/llm.rb +38 -10
  55. data/llm.gemspec +1 -0
  56. metadata +28 -3
@@ -4,33 +4,24 @@ class LLM::Gemini
4
4
  ##
5
5
  # @private
6
6
  module ResponseParser
7
+ require_relative "response_parser/completion_parser"
8
+
7
9
  ##
8
10
  # @param [Hash] body
9
11
  # The response body from the LLM provider
10
12
  # @return [Hash]
11
- def parse_embedding(body)
12
- {
13
- model: "text-embedding-004",
14
- embeddings: body.dig("embedding", "values")
15
- }
13
+ def parse_completion(body)
14
+ CompletionParser.new(body).format(self)
16
15
  end
17
16
 
18
17
  ##
19
18
  # @param [Hash] body
20
19
  # The response body from the LLM provider
21
20
  # @return [Hash]
22
- def parse_completion(body)
21
+ def parse_embedding(body)
23
22
  {
24
- model: body["modelVersion"],
25
- choices: body["candidates"].map do
26
- LLM::Message.new(
27
- _1.dig("content", "role"),
28
- _1.dig("content", "parts", 0, "text"),
29
- {response: self}
30
- )
31
- end,
32
- prompt_tokens: body.dig("usageMetadata", "promptTokenCount"),
33
- completion_tokens: body.dig("usageMetadata", "candidatesTokenCount")
23
+ model: "text-embedding-004",
24
+ embeddings: body.dig("embedding", "values")
34
25
  }
35
26
  end
36
27
 
@@ -41,10 +32,12 @@ class LLM::Gemini
41
32
  def parse_image(body)
42
33
  {
43
34
  urls: [],
44
- images: body["candidates"].flat_map do |candidate|
45
- candidate["content"]["parts"].filter_map do
46
- next unless _1.dig("inlineData", "data")
47
- StringIO.new(_1["inlineData"]["data"].unpack1("m0"))
35
+ images: body["candidates"].flat_map do |c|
36
+ parts = c["content"]["parts"]
37
+ parts.filter_map do
38
+ data = _1.dig("inlineData", "data")
39
+ next unless data
40
+ StringIO.new(data.unpack1("m0"))
48
41
  end
49
42
  end
50
43
  }
@@ -40,9 +40,9 @@ module LLM
40
40
  HOST = "generativelanguage.googleapis.com"
41
41
 
42
42
  ##
43
- # @param secret (see LLM::Provider#initialize)
44
- def initialize(secret, **)
45
- super(secret, host: HOST, **)
43
+ # @param key (see LLM::Provider#initialize)
44
+ def initialize(**)
45
+ super(host: HOST, **)
46
46
  end
47
47
 
48
48
  ##
@@ -54,7 +54,7 @@ module LLM
54
54
  # @return (see LLM::Provider#embed)
55
55
  def embed(input, model: "text-embedding-004", **params)
56
56
  model = model.respond_to?(:id) ? model.id : model
57
- path = ["/v1beta/models/#{model}", "embedContent?key=#{@secret}"].join(":")
57
+ path = ["/v1beta/models/#{model}", "embedContent?key=#{@key}"].join(":")
58
58
  req = Net::HTTP::Post.new(path, headers)
59
59
  req.body = JSON.dump({content: {parts: [{text: input}]}})
60
60
  res = request(@http, req)
@@ -65,21 +65,21 @@ module LLM
65
65
  # Provides an interface to the chat completions API
66
66
  # @see https://ai.google.dev/api/generate-content#v1beta.models.generateContent Gemini docs
67
67
  # @param prompt (see LLM::Provider#complete)
68
- # @param role (see LLM::Provider#complete)
69
- # @param model (see LLM::Provider#complete)
70
- # @param schema (see LLM::Provider#complete)
71
68
  # @param params (see LLM::Provider#complete)
72
69
  # @example (see LLM::Provider#complete)
73
70
  # @raise (see LLM::Provider#request)
74
71
  # @raise [LLM::Error::PromptError]
75
72
  # When given an object a provider does not understand
76
73
  # @return (see LLM::Provider#complete)
77
- def complete(prompt, role = :user, model: default_model, schema: nil, **params)
74
+ def complete(prompt, params = {})
75
+ params = {role: :user, model: default_model}.merge!(params)
76
+ params = [params, format_schema(params), format_tools(params)].inject({}, &:merge!).compact
77
+ role, model = [:role, :model].map { params.delete(_1) }
78
78
  model.respond_to?(:id) ? model.id : model
79
- path = ["/v1beta/models/#{model}", "generateContent?key=#{@secret}"].join(":")
79
+ path = ["/v1beta/models/#{model}", "generateContent?key=#{@key}"].join(":")
80
80
  req = Net::HTTP::Post.new(path, headers)
81
81
  messages = [*(params.delete(:messages) || []), LLM::Message.new(role, prompt)]
82
- body = JSON.dump({contents: format(messages)}.merge!(expand_schema(schema)))
82
+ body = JSON.dump({contents: format(messages)}.merge!(params))
83
83
  set_body_stream(req, StringIO.new(body))
84
84
  res = request(@http, req)
85
85
  Response::Completion.new(res).extend(response_parser)
@@ -136,16 +136,6 @@ module LLM
136
136
  }
137
137
  end
138
138
 
139
- def expand_schema(schema)
140
- return {} unless schema
141
- {
142
- "generationConfig" => {
143
- "response_mime_type" => "application/json",
144
- "response_schema" => schema
145
- }
146
- }
147
- end
148
-
149
139
  def response_parser
150
140
  LLM::Gemini::ResponseParser
151
141
  end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Ollama::Format
4
+ ##
5
+ # @private
6
+ class CompletionFormat
7
+ ##
8
+ # @param [LLM::Message] message
9
+ # The message to format
10
+ def initialize(message)
11
+ @message = message
12
+ end
13
+
14
+ ##
15
+ # Returns the message for the Ollama chat completions API
16
+ # @return [Hash]
17
+ def format
18
+ catch(:abort) do
19
+ if Hash === message
20
+ {role: message[:role]}.merge(format_content(message[:content]))
21
+ else
22
+ format_message
23
+ end
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def format_content(content)
30
+ case content
31
+ when LLM::File
32
+ if content.image?
33
+ {content: "This message has an image associated with it", images: [content.to_b64]}
34
+ 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
+ end
39
+ when String
40
+ {content:}
41
+ when LLM::Message
42
+ format_content(content.content)
43
+ else
44
+ raise LLM::Error::PromptError, "The given object (an instance of #{content.class}) " \
45
+ "is not supported by the Ollama API"
46
+ end
47
+ end
48
+
49
+ def format_message
50
+ case content
51
+ when Array
52
+ format_array
53
+ else
54
+ {role: message.role}.merge(format_content(content))
55
+ end
56
+ end
57
+
58
+ def format_array
59
+ if content.empty?
60
+ nil
61
+ elsif returns.any?
62
+ returns.map { {role: "tool", tool_call_id: _1.id, content: JSON.dump(_1.value)} }
63
+ else
64
+ [{role: message.role, content: content.flat_map { format_content(_1) }}]
65
+ end
66
+ end
67
+
68
+ def message = @message
69
+ def content = message.content
70
+ def returns = content.grep(LLM::Function::Return)
71
+ end
72
+ end
@@ -4,47 +4,28 @@ class LLM::Ollama
4
4
  ##
5
5
  # @private
6
6
  module Format
7
+ require_relative "format/completion_format"
8
+
7
9
  ##
8
10
  # @param [Array<LLM::Message>] messages
9
11
  # The messages to format
10
12
  # @return [Array<Hash>]
11
13
  def format(messages)
12
- messages.map do
13
- if Hash === _1
14
- {role: _1[:role]}
15
- .merge!(_1)
16
- .merge!(format_content(_1[:content]))
17
- else
18
- {role: _1.role}.merge! format_content(_1.content)
19
- end
14
+ messages.filter_map do |message|
15
+ CompletionFormat.new(message).format
20
16
  end
21
17
  end
22
18
 
23
19
  private
24
20
 
25
21
  ##
26
- # @param [String, URI] content
27
- # The content to format
28
- # @return [String, Hash]
29
- # The formatted content
30
- def format_content(content)
31
- case content
32
- when LLM::File
33
- if content.image?
34
- {content: "This message has an image associated with it", images: [content.to_b64]}
35
- else
36
- raise LLM::Error::PromptError, "The given object (an instance of #{content.class}) " \
37
- "is not an image, and therefore not supported by the " \
38
- "Ollama API"
39
- end
40
- when String
41
- {content:}
42
- when LLM::Message
43
- format_content(content.content)
44
- else
45
- raise LLM::Error::PromptError, "The given object (an instance of #{content.class}) " \
46
- "is not supported by the Ollama API"
47
- end
22
+ # @param [Array<LLM::Function>] tools
23
+ # The tools to format
24
+ # @return [Hash]
25
+ def format_tools(params)
26
+ return {} unless params and params[:tools]&.any?
27
+ tools = params[:tools]
28
+ {tools: tools.map { _1.format(self) }}
48
29
  end
49
30
  end
50
31
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Ollama::ResponseParser
4
+ ##
5
+ # @private
6
+ class CompletionParser
7
+ def initialize(body)
8
+ @body = OpenStruct.from_hash(body)
9
+ end
10
+
11
+ def format(response)
12
+ {
13
+ model:,
14
+ choices: [format_choices(response)],
15
+ prompt_tokens:,
16
+ completion_tokens:
17
+ }
18
+ end
19
+
20
+ private
21
+
22
+ def format_choices(response)
23
+ role, content, calls = message.to_h.values_at(:role, :content, :tool_calls)
24
+ extra = {response:, tool_calls: format_tool_calls(calls)}
25
+ LLM::Message.new(role, content, extra)
26
+ end
27
+
28
+ def format_tool_calls(tools)
29
+ return [] unless tools
30
+ tools.filter_map do |tool|
31
+ next unless tool["function"]
32
+ OpenStruct.new(tool["function"])
33
+ end
34
+ end
35
+
36
+ def body = @body
37
+ def model = body.model
38
+ def prompt_tokens = body.prompt_eval_count
39
+ def completion_tokens = body.eval_count
40
+ def message = body.message
41
+ end
42
+ end
@@ -4,29 +4,26 @@ class LLM::Ollama
4
4
  ##
5
5
  # @private
6
6
  module ResponseParser
7
+ require_relative "response_parser/completion_parser"
8
+
7
9
  ##
8
10
  # @param [Hash] body
9
11
  # The response body from the LLM provider
10
12
  # @return [Hash]
11
- def parse_embedding(body)
12
- {
13
- model: body["model"],
14
- embeddings: body["data"].map { _1["embedding"] },
15
- prompt_tokens: body.dig("usage", "prompt_tokens"),
16
- total_tokens: body.dig("usage", "total_tokens")
17
- }
13
+ def parse_completion(body)
14
+ CompletionParser.new(body).format(self)
18
15
  end
19
16
 
20
17
  ##
21
18
  # @param [Hash] body
22
19
  # The response body from the LLM provider
23
20
  # @return [Hash]
24
- def parse_completion(body)
21
+ def parse_embedding(body)
25
22
  {
26
23
  model: body["model"],
27
- choices: [LLM::Message.new(*body["message"].values_at("role", "content"), {response: self})],
28
- prompt_tokens: body.dig("prompt_eval_count"),
29
- completion_tokens: body.dig("eval_count")
24
+ embeddings: body["data"].map { _1["embedding"] },
25
+ prompt_tokens: body.dig("usage", "prompt_tokens"),
26
+ total_tokens: body.dig("usage", "total_tokens")
30
27
  }
31
28
  end
32
29
  end
@@ -28,9 +28,9 @@ module LLM
28
28
  HOST = "localhost"
29
29
 
30
30
  ##
31
- # @param secret (see LLM::Provider#initialize)
32
- def initialize(secret, **)
33
- super(secret, host: HOST, port: 11434, ssl: false, **)
31
+ # @param key (see LLM::Provider#initialize)
32
+ def initialize(**)
33
+ super(host: HOST, port: 11434, ssl: false, **)
34
34
  end
35
35
 
36
36
  ##
@@ -52,22 +52,19 @@ module LLM
52
52
  # Provides an interface to the chat completions API
53
53
  # @see https://github.com/ollama/ollama/blob/main/docs/api.md#generate-a-chat-completion Ollama docs
54
54
  # @param prompt (see LLM::Provider#complete)
55
- # @param role (see LLM::Provider#complete)
56
- # @param model (see LLM::Provider#complete)
57
55
  # @param params (see LLM::Provider#complete)
58
56
  # @example (see LLM::Provider#complete)
59
57
  # @raise (see LLM::Provider#request)
60
58
  # @raise [LLM::Error::PromptError]
61
59
  # When given an object a provider does not understand
62
60
  # @return (see LLM::Provider#complete)
63
- def complete(prompt, role = :user, model: default_model, schema: nil, **params)
64
- params = {model:, stream: false}
65
- .merge!(expand_schema(schema))
66
- .merge!(params)
67
- .compact
61
+ def complete(prompt, params = {})
62
+ params = {role: :user, model: default_model, stream: false}.merge!(params)
63
+ params = [params, {format: params[:schema]}, format_tools(params)].inject({}, &:merge!).compact
64
+ role = params.delete(:role)
68
65
  req = Net::HTTP::Post.new("/api/chat", headers)
69
66
  messages = [*(params.delete(:messages) || []), LLM::Message.new(role, prompt)]
70
- body = JSON.dump({messages: format(messages)}.merge!(params))
67
+ body = JSON.dump({messages: [format(messages)].flatten}.merge!(params))
71
68
  set_body_stream(req, StringIO.new(body))
72
69
  res = request(@http, req)
73
70
  Response::Completion.new(res).extend(response_parser)
@@ -100,15 +97,10 @@ module LLM
100
97
  def headers
101
98
  {
102
99
  "Content-Type" => "application/json",
103
- "Authorization" => "Bearer #{@secret}"
100
+ "Authorization" => "Bearer #{@key}"
104
101
  }
105
102
  end
106
103
 
107
- def expand_schema(schema)
108
- return {} unless schema
109
- {format: schema}
110
- end
111
-
112
104
  def response_parser
113
105
  LLM::Ollama::ResponseParser
114
106
  end
@@ -7,7 +7,7 @@ class LLM::OpenAI
7
7
  # @example
8
8
  # llm = LLM.openai(ENV["KEY"])
9
9
  # res = llm.audio.create_speech(input: "A dog on a rocket to the moon")
10
- # File.binwrite("rocket.mp3", res.audio.string)
10
+ # IO.copy_stream res.audio, "rocket.mp3"
11
11
  class Audio
12
12
  ##
13
13
  # Returns a new Audio object
@@ -43,16 +43,16 @@ class LLM::OpenAI
43
43
  # Create an audio transcription
44
44
  # @example
45
45
  # llm = LLM.openai(ENV["KEY"])
46
- # res = llm.audio.create_transcription(file: LLM::File("/rocket.mp3"))
46
+ # res = llm.audio.create_transcription(file: "/audio/rocket.mp3")
47
47
  # res.text # => "A dog on a rocket to the moon"
48
48
  # @see https://platform.openai.com/docs/api-reference/audio/createTranscription OpenAI docs
49
- # @param [LLM::File] file The input audio
49
+ # @param [String, LLM::File] file The input audio
50
50
  # @param [String] model The model to use
51
51
  # @param [Hash] params Other parameters (see OpenAI docs)
52
52
  # @raise (see LLM::Provider#request)
53
53
  # @return [LLM::Response::AudioTranscription]
54
54
  def create_transcription(file:, model: "whisper-1", **params)
55
- multi = LLM::Multipart.new(params.merge!(file:, model:))
55
+ multi = LLM::Multipart.new(params.merge!(file: LLM.File(file), model:))
56
56
  req = Net::HTTP::Post.new("/v1/audio/transcriptions", headers)
57
57
  req["content-type"] = multi.content_type
58
58
  set_body_stream(req, multi.body)
@@ -65,7 +65,7 @@ class LLM::OpenAI
65
65
  # @example
66
66
  # # Arabic => English
67
67
  # llm = LLM.openai(ENV["KEY"])
68
- # res = llm.audio.create_translation(file: LLM::File("/bismillah.mp3"))
68
+ # res = llm.audio.create_translation(file: "/audio/bismillah.mp3")
69
69
  # res.text # => "In the name of Allah, the Beneficent, the Merciful."
70
70
  # @see https://platform.openai.com/docs/api-reference/audio/createTranslation OpenAI docs
71
71
  # @param [LLM::File] file The input audio
@@ -74,7 +74,7 @@ class LLM::OpenAI
74
74
  # @raise (see LLM::Provider#request)
75
75
  # @return [LLM::Response::AudioTranslation]
76
76
  def create_translation(file:, model: "whisper-1", **params)
77
- multi = LLM::Multipart.new(params.merge!(file:, model:))
77
+ multi = LLM::Multipart.new(params.merge!(file: LLM.File(file), model:))
78
78
  req = Net::HTTP::Post.new("/v1/audio/translations", headers)
79
79
  req["content-type"] = multi.content_type
80
80
  set_body_stream(req, multi.body)
@@ -14,7 +14,7 @@ class LLM::OpenAI
14
14
  #
15
15
  # llm = LLM.openai(ENV["KEY"])
16
16
  # bot = LLM::Chat.new(llm).lazy
17
- # file = llm.files.create file: LLM::File("/documents/freebsd.pdf")
17
+ # file = llm.files.create file: "/documents/freebsd.pdf"
18
18
  # bot.chat(file)
19
19
  # bot.chat("Describe the document")
20
20
  # bot.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
@@ -24,7 +24,7 @@ class LLM::OpenAI
24
24
  #
25
25
  # llm = LLM.openai(ENV["KEY"])
26
26
  # bot = LLM::Chat.new(llm).lazy
27
- # file = llm.files.create file: LLM::File("/documents/openbsd.pdf")
27
+ # file = llm.files.create file: "/documents/openbsd.pdf"
28
28
  # bot.chat(["Describe the document I sent to you", file])
29
29
  # bot.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
30
30
  class Files
@@ -62,7 +62,7 @@ class LLM::OpenAI
62
62
  # Create a file
63
63
  # @example
64
64
  # llm = LLM.openai(ENV["KEY"])
65
- # res = llm.files.create file: LLM::File("/documents/haiku.txt"),
65
+ # res = llm.files.create file: "/documents/haiku.txt"
66
66
  # @see https://platform.openai.com/docs/api-reference/files/create OpenAI docs
67
67
  # @param [File] file The file
68
68
  # @param [String] purpose The purpose of the file (see OpenAI docs)
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::OpenAI::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 URI
34
+ [{type: :image_url, image_url: {url: content.to_s}}]
35
+ when LLM::File
36
+ format_file(content)
37
+ when LLM::Response::File
38
+ [{type: :file, file: {file_id: content.id}}]
39
+ when String
40
+ [{type: :text, text: content.to_s}]
41
+ when LLM::Message
42
+ format_content(content.content)
43
+ when LLM::Function::Return
44
+ throw(:abort, {role: "tool", tool_call_id: content.id, content: JSON.dump(content.value)})
45
+ else
46
+ raise LLM::Error::PromptError, "The given object (an instance of #{content.class}) " \
47
+ "is not supported by the OpenAI chat completions API"
48
+ end
49
+ end
50
+
51
+ def format_file(content)
52
+ file = content
53
+ if file.image?
54
+ [{type: :image_url, image_url: {url: file.to_data_uri}}]
55
+ else
56
+ [{type: :file, file: {filename: file.basename, file_data: file.to_data_uri}}]
57
+ end
58
+ end
59
+
60
+ def format_message
61
+ case content
62
+ when Array
63
+ format_array
64
+ else
65
+ {role: message.role, content: format_content(content)}
66
+ end
67
+ end
68
+
69
+ def format_array
70
+ if content.empty?
71
+ nil
72
+ elsif returns.any?
73
+ returns.map { {role: "tool", tool_call_id: _1.id, content: JSON.dump(_1.value)} }
74
+ else
75
+ {role: message.role, content: content.flat_map { format_content(_1) }}
76
+ end
77
+ end
78
+
79
+ def message = @message
80
+ def content = message.content
81
+ def returns = content.grep(LLM::Function::Return)
82
+ end
83
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::OpenAI::Format
4
+ ##
5
+ # @private
6
+ class RespondFormat
7
+ def initialize(message)
8
+ @message = message
9
+ end
10
+
11
+ def format
12
+ catch(:abort) do
13
+ if Hash === message
14
+ {role: message[:role], content: format_content(message[:content])}
15
+ else
16
+ format_message
17
+ end
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def format_content(content)
24
+ case content
25
+ when LLM::Response::File
26
+ format_file(content)
27
+ when String
28
+ [{type: :input_text, text: content.to_s}]
29
+ when LLM::Message
30
+ format_content(content.content)
31
+ else
32
+ raise LLM::Error::PromptError, "The given object (an instance of #{content.class}) " \
33
+ "is not supported by the OpenAI responses API"
34
+ end
35
+ end
36
+
37
+ def format_message
38
+ case content
39
+ when Array
40
+ format_array
41
+ else
42
+ {role: message.role, content: format_content(content)}
43
+ end
44
+ end
45
+
46
+ def format_array
47
+ if content.empty?
48
+ nil
49
+ elsif returns.any?
50
+ returns.map { {type: "function_call_output", call_id: _1.id, output: JSON.dump(_1.value)} }
51
+ else
52
+ {role: message.role, content: content.flat_map { format_content(_1) }}
53
+ end
54
+ end
55
+
56
+ def format_file(content)
57
+ file = LLM::File(content.filename)
58
+ if file.image?
59
+ [{type: :input_image, file_id: content.id}]
60
+ else
61
+ [{type: :input_file, file_id: content.id}]
62
+ end
63
+ end
64
+
65
+ def message = @message
66
+ def content = message.content
67
+ def returns = content.grep(LLM::Function::Return)
68
+ end
69
+ end