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.
- checksums.yaml +4 -4
- data/README.md +173 -115
- data/lib/json/schema/array.rb +5 -0
- data/lib/json/schema/boolean.rb +4 -0
- data/lib/json/schema/integer.rb +23 -1
- data/lib/json/schema/leaf.rb +11 -0
- data/lib/json/schema/null.rb +4 -0
- data/lib/json/schema/number.rb +23 -1
- data/lib/json/schema/object.rb +6 -2
- data/lib/json/schema/string.rb +26 -1
- data/lib/json/schema/version.rb +2 -0
- data/lib/json/schema.rb +10 -10
- data/lib/llm/buffer.rb +31 -12
- data/lib/llm/chat.rb +56 -29
- data/lib/llm/core_ext/ostruct.rb +14 -8
- data/lib/llm/file.rb +6 -1
- data/lib/llm/function.rb +86 -0
- data/lib/llm/message.rb +54 -2
- data/lib/llm/provider.rb +32 -46
- data/lib/llm/providers/anthropic/format/completion_format.rb +73 -0
- data/lib/llm/providers/anthropic/format.rb +8 -33
- data/lib/llm/providers/anthropic/response_parser/completion_parser.rb +51 -0
- data/lib/llm/providers/anthropic/response_parser.rb +1 -9
- data/lib/llm/providers/anthropic.rb +14 -14
- data/lib/llm/providers/gemini/audio.rb +9 -9
- data/lib/llm/providers/gemini/files.rb +11 -10
- data/lib/llm/providers/gemini/format/completion_format.rb +54 -0
- data/lib/llm/providers/gemini/format.rb +20 -27
- data/lib/llm/providers/gemini/images.rb +12 -7
- data/lib/llm/providers/gemini/models.rb +3 -3
- data/lib/llm/providers/gemini/response_parser/completion_parser.rb +46 -0
- data/lib/llm/providers/gemini/response_parser.rb +13 -20
- data/lib/llm/providers/gemini.rb +10 -20
- data/lib/llm/providers/ollama/format/completion_format.rb +72 -0
- data/lib/llm/providers/ollama/format.rb +11 -30
- data/lib/llm/providers/ollama/response_parser/completion_parser.rb +42 -0
- data/lib/llm/providers/ollama/response_parser.rb +8 -11
- data/lib/llm/providers/ollama.rb +9 -17
- data/lib/llm/providers/openai/audio.rb +6 -6
- data/lib/llm/providers/openai/files.rb +3 -3
- data/lib/llm/providers/openai/format/completion_format.rb +83 -0
- data/lib/llm/providers/openai/format/respond_format.rb +69 -0
- data/lib/llm/providers/openai/format.rb +27 -58
- data/lib/llm/providers/openai/images.rb +4 -2
- data/lib/llm/providers/openai/response_parser/completion_parser.rb +55 -0
- data/lib/llm/providers/openai/response_parser/respond_parser.rb +56 -0
- data/lib/llm/providers/openai/response_parser.rb +8 -44
- data/lib/llm/providers/openai/responses.rb +13 -14
- data/lib/llm/providers/openai.rb +11 -23
- data/lib/llm/providers/voyageai.rb +4 -4
- data/lib/llm/response/{output.rb → respond.rb} +2 -2
- data/lib/llm/response.rb +1 -1
- data/lib/llm/version.rb +1 -1
- data/lib/llm.rb +38 -10
- data/llm.gemspec +1 -0
- 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
|
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
|
21
|
+
def parse_embedding(body)
|
23
22
|
{
|
24
|
-
model:
|
25
|
-
|
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 |
|
45
|
-
|
46
|
-
|
47
|
-
|
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
|
}
|
data/lib/llm/providers/gemini.rb
CHANGED
@@ -40,9 +40,9 @@ module LLM
|
|
40
40
|
HOST = "generativelanguage.googleapis.com"
|
41
41
|
|
42
42
|
##
|
43
|
-
# @param
|
44
|
-
def initialize(
|
45
|
-
super(
|
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=#{@
|
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,
|
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=#{@
|
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!(
|
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.
|
13
|
-
|
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 [
|
27
|
-
# The
|
28
|
-
# @return [
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
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
|
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
|
21
|
+
def parse_embedding(body)
|
25
22
|
{
|
26
23
|
model: body["model"],
|
27
|
-
|
28
|
-
prompt_tokens: body.dig("
|
29
|
-
|
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
|
data/lib/llm/providers/ollama.rb
CHANGED
@@ -28,9 +28,9 @@ module LLM
|
|
28
28
|
HOST = "localhost"
|
29
29
|
|
30
30
|
##
|
31
|
-
# @param
|
32
|
-
def initialize(
|
33
|
-
super(
|
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,
|
64
|
-
params = {model
|
65
|
-
|
66
|
-
|
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 #{@
|
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
|
-
#
|
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:
|
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
|
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:
|
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
|
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:
|
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:
|
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:
|
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
|