last_llm 0.0.4 → 0.0.5
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/lib/last_llm/providers/anthropic.rb +62 -48
- data/lib/last_llm/providers/deepseek.rb +115 -104
- data/lib/last_llm/providers/google_gemini.rb +169 -150
- data/lib/last_llm/providers/ollama.rb +119 -106
- data/lib/last_llm/providers/openai.rb +184 -176
- data/lib/last_llm/providers/test_provider.rb +51 -28
- data/lib/last_llm/version.rb +1 -1
- metadata +2 -2
@@ -2,174 +2,193 @@
|
|
2
2
|
|
3
3
|
require 'last_llm/providers/constants'
|
4
4
|
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
5
|
+
module LastLLM
|
6
|
+
module Providers
|
7
|
+
# Google Gemini provider implementation
|
8
|
+
class GoogleGemini < LastLLM::Provider
|
9
|
+
# API Configuration
|
10
|
+
BASE_ENDPOINT = 'https://generativelanguage.googleapis.com'
|
11
|
+
DEFAULT_MODEL = 'gemini-1.5-flash'
|
12
|
+
|
13
|
+
# LLM Default Parameters
|
14
|
+
DEFAULT_TEMPERATURE = 0.3
|
15
|
+
DEFAULT_TOP_P = 0.95
|
16
|
+
DEFAULT_TOP_K = 40
|
17
|
+
DEFAULT_MAX_TOKENS = 1024
|
18
|
+
|
19
|
+
# Response Configuration
|
20
|
+
JSON_MIME_TYPE = 'application/json'
|
21
|
+
SUCCESS_STATUS = 200
|
22
|
+
|
23
|
+
# Error Status Codes
|
24
|
+
UNAUTHORIZED_STATUS = 401
|
25
|
+
BAD_REQUEST_STATUS = 400
|
26
|
+
UNAUTHENTICATED_STATUS = 'UNAUTHENTICATED'
|
27
|
+
|
28
|
+
def initialize(config)
|
29
|
+
super(Constants::GOOGLE_GEMINI, config)
|
30
|
+
@api_key = config[:api_key]
|
31
|
+
@conn = connection(config[:base_url] || BASE_ENDPOINT)
|
32
|
+
end
|
14
33
|
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
req.body = {
|
21
|
-
contents: contents,
|
22
|
-
generationConfig: {
|
23
|
-
maxOutputTokens: options[:max_tokens],
|
24
|
-
temperature: options[:temperature] || 0.3,
|
25
|
-
topP: options[:top_p] || 0.95,
|
26
|
-
topK: options[:top_k] || 40
|
27
|
-
}.compact
|
28
|
-
}.compact
|
29
|
-
end
|
34
|
+
def generate_text(prompt, options = {})
|
35
|
+
make_request(prompt, options) do |response|
|
36
|
+
extract_text_content(response)
|
37
|
+
end
|
38
|
+
end
|
30
39
|
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
40
|
+
def generate_object(prompt, schema, options = {})
|
41
|
+
options = options.merge(response_mime_type: JSON_MIME_TYPE, response_schema: schema)
|
42
|
+
make_request(prompt, options) do |response|
|
43
|
+
parse_json_response(extract_text_content(response))
|
44
|
+
end
|
45
|
+
end
|
37
46
|
|
38
|
-
|
39
|
-
content = result.dig(:candidates, 0, :content, :parts, 0, :text)
|
47
|
+
private
|
40
48
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
49
|
+
def make_request(prompt, options = {})
|
50
|
+
model = options[:model] || @config[:model] || DEFAULT_MODEL
|
51
|
+
contents = format_contents(prompt, options)
|
52
|
+
|
53
|
+
response = @conn.post("/v1beta/models/#{model}:generateContent?key=#{@api_key}") do |req|
|
54
|
+
req.body = build_request_body(contents, options)
|
55
|
+
end
|
56
|
+
|
57
|
+
handle_response(response) { |result| yield(result) }
|
58
|
+
rescue Faraday::Error => e
|
59
|
+
handle_gemini_error(e)
|
60
|
+
end
|
45
61
|
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
topK: options[:top_k] || 40,
|
58
|
-
responseMimeType: 'application/json',
|
59
|
-
responseSchema: schema
|
62
|
+
def build_request_body(contents, options)
|
63
|
+
{
|
64
|
+
contents: contents,
|
65
|
+
generationConfig: {
|
66
|
+
maxOutputTokens: options[:max_tokens] || DEFAULT_MAX_TOKENS,
|
67
|
+
temperature: options[:temperature] || DEFAULT_TEMPERATURE,
|
68
|
+
topP: options[:top_p] || DEFAULT_TOP_P,
|
69
|
+
topK: options[:top_k] || DEFAULT_TOP_K,
|
70
|
+
responseMimeType: options[:response_mime_type],
|
71
|
+
responseSchema: options[:response_schema]
|
72
|
+
}.compact
|
60
73
|
}.compact
|
61
|
-
|
62
|
-
end
|
74
|
+
end
|
63
75
|
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
end
|
76
|
+
def handle_response(response)
|
77
|
+
if response.status != SUCCESS_STATUS
|
78
|
+
error = build_error(response)
|
79
|
+
return handle_gemini_error(error)
|
80
|
+
end
|
70
81
|
|
71
|
-
|
72
|
-
|
82
|
+
result = parse_response(response)
|
83
|
+
yield(result)
|
84
|
+
end
|
73
85
|
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
86
|
+
def build_error(response)
|
87
|
+
StandardError.new("HTTP #{response.status}").tap do |error|
|
88
|
+
error.define_singleton_method(:response) do
|
89
|
+
{
|
90
|
+
status: response.status,
|
91
|
+
body: response.body
|
92
|
+
}
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
82
96
|
|
83
|
-
|
97
|
+
def extract_text_content(response)
|
98
|
+
content = response.dig(:candidates, 0, :content, :parts, 0, :text)
|
99
|
+
content.to_s
|
100
|
+
end
|
84
101
|
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
end
|
91
|
-
end
|
102
|
+
def parse_json_response(content)
|
103
|
+
JSON.parse(content, symbolize_names: true)
|
104
|
+
rescue JSON::ParserError => e
|
105
|
+
raise LastLLM::ApiError, "Invalid JSON response: #{e.message}"
|
106
|
+
end
|
92
107
|
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
]
|
101
|
-
else
|
102
|
-
[{ role: 'user', parts: [{ text: prompt.to_s }] }]
|
103
|
-
end
|
104
|
-
end
|
108
|
+
def connection(endpoint)
|
109
|
+
Faraday.new(url: endpoint) do |faraday|
|
110
|
+
faraday.request :json
|
111
|
+
faraday.response :json, content_type: /\bjson$/
|
112
|
+
faraday.adapter Faraday.default_adapter
|
113
|
+
end
|
114
|
+
end
|
105
115
|
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
+
def format_contents(prompt, options)
|
117
|
+
if prompt.is_a?(Array)
|
118
|
+
prompt.map { |m| { role: m[:role], parts: [{ text: m[:content] }] } }
|
119
|
+
elsif options[:system_prompt]
|
120
|
+
[
|
121
|
+
{ role: 'user', parts: [{ text: options[:system_prompt] }] },
|
122
|
+
{ role: 'user', parts: [{ text: prompt.to_s }] }
|
123
|
+
]
|
124
|
+
else
|
125
|
+
[{ role: 'user', parts: [{ text: prompt.to_s }] }]
|
126
|
+
end
|
127
|
+
end
|
116
128
|
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
129
|
+
def self.format_tool(tool)
|
130
|
+
{
|
131
|
+
name: tool.name,
|
132
|
+
description: tool.description,
|
133
|
+
parameters: tool.parameters
|
134
|
+
}
|
135
|
+
end
|
124
136
|
|
125
|
-
|
126
|
-
|
127
|
-
|
137
|
+
def self.execute_tool(tool, response)
|
138
|
+
function_call = response.dig(:candidates, 0, :content, :parts, 0, :functionCall)
|
139
|
+
return nil unless function_call && function_call[:name] == tool.name
|
128
140
|
|
129
|
-
|
130
|
-
def handle_gemini_error(error)
|
131
|
-
status = nil
|
132
|
-
message = "API request failed: #{error.message}"
|
133
|
-
|
134
|
-
if error.respond_to?(:response) && error.response.is_a?(Hash)
|
135
|
-
status = error.response[:status]
|
136
|
-
body = error.response[:body]
|
137
|
-
|
138
|
-
if body.is_a?(String) && !body.empty?
|
139
|
-
begin
|
140
|
-
parsed_body = JSON.parse(body)
|
141
|
-
# Handle array response format
|
142
|
-
if parsed_body.is_a?(Array) && parsed_body[0] && parsed_body[0]['error']
|
143
|
-
error_obj = parsed_body[0]['error']
|
144
|
-
message = "API error: #{error_obj['message'] || error_obj}"
|
145
|
-
# Handle object response format
|
146
|
-
elsif parsed_body['error']
|
147
|
-
error_message = parsed_body['error']['message'] || parsed_body['error']
|
148
|
-
error_code = parsed_body['error']['code']
|
149
|
-
error_status = parsed_body['error']['status']
|
150
|
-
message = "API error (#{error_code}): #{error_message}"
|
151
|
-
# Handle authentication errors
|
152
|
-
if error_code == 401 && error_status == 'UNAUTHENTICATED'
|
153
|
-
message = 'Authentication failed: Invalid API key or credentials. Please check your Google API key.'
|
154
|
-
elsif error_code == 400 && error_message.include?('API key not valid')
|
155
|
-
message = "Authentication failed: Invalid API key format or credentials. \
|
156
|
-
Please check your Google API key."
|
157
|
-
end
|
158
|
-
end
|
159
|
-
rescue JSON::ParserError
|
160
|
-
# Use default message if we can't parse the body
|
161
|
-
end
|
141
|
+
tool.call(function_call[:args])
|
162
142
|
end
|
163
|
-
end
|
164
143
|
|
165
|
-
|
166
|
-
|
167
|
-
|
144
|
+
def handle_gemini_error(error)
|
145
|
+
status = error.response&.dig(:status)
|
146
|
+
message = parse_error_message(error)
|
168
147
|
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
148
|
+
raise LastLLM::ApiError.new(message, status)
|
149
|
+
end
|
150
|
+
|
151
|
+
def parse_error_message(error)
|
152
|
+
return "API request failed: #{error.message}" unless error.response&.dig(:body)
|
153
|
+
|
154
|
+
body = parse_error_body(error.response[:body])
|
155
|
+
format_error_message(body)
|
156
|
+
rescue JSON::ParserError
|
157
|
+
"API request failed: #{error.message}"
|
158
|
+
end
|
159
|
+
|
160
|
+
def parse_error_body(body)
|
161
|
+
return {} unless body.is_a?(String) && !body.empty?
|
162
|
+
JSON.parse(body)
|
163
|
+
end
|
164
|
+
|
165
|
+
def format_error_message(body)
|
166
|
+
if body.is_a?(Array) && body[0]&.dig('error')
|
167
|
+
error_obj = body[0]['error']
|
168
|
+
"API error: #{error_obj['message'] || error_obj}"
|
169
|
+
elsif body['error']
|
170
|
+
format_detailed_error(body['error'])
|
171
|
+
else
|
172
|
+
'Unknown API error'
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
def format_detailed_error(error)
|
177
|
+
message = error['message']
|
178
|
+
code = error['code']
|
179
|
+
status = error['status']
|
180
|
+
|
181
|
+
case [code, status]
|
182
|
+
when [UNAUTHORIZED_STATUS, UNAUTHENTICATED_STATUS]
|
183
|
+
'Authentication failed: Invalid API key or credentials. Please check your Google API key.'
|
184
|
+
when [BAD_REQUEST_STATUS]
|
185
|
+
message.include?('API key not valid') ?
|
186
|
+
'Authentication failed: Invalid API key format or credentials. Please check your Google API key.' :
|
187
|
+
"API error (#{code}): #{message}"
|
188
|
+
else
|
189
|
+
"API error (#{code}): #{message}"
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
174
193
|
end
|
175
194
|
end
|
@@ -2,123 +2,136 @@
|
|
2
2
|
|
3
3
|
require 'last_llm/providers/constants'
|
4
4
|
|
5
|
-
|
6
|
-
|
7
|
-
|
5
|
+
module LastLLM
|
6
|
+
module Providers
|
7
|
+
# Ollama provider implementation
|
8
|
+
class Ollama < LastLLM::Provider
|
9
|
+
# API Configuration
|
10
|
+
BASE_ENDPOINT = 'http://172.17.0.1:11434'
|
11
|
+
DEFAULT_MODEL = 'llama3.2:latest'
|
12
|
+
|
13
|
+
# LLM Default Parameters
|
14
|
+
DEFAULT_TEMPERATURE = 0.7
|
15
|
+
DEFAULT_TOP_P = 0.7
|
16
|
+
DEFAULT_MAX_TOKENS = 24_576
|
17
|
+
DEFAULT_TEMPERATURE_OBJECT = 0.2
|
18
|
+
|
19
|
+
# Response Configuration
|
20
|
+
SUCCESS_STATUS = 200
|
21
|
+
|
22
|
+
# Error Status Codes
|
23
|
+
SERVER_ERROR_STATUS = 500
|
24
|
+
BAD_REQUEST_STATUS = 400
|
25
|
+
|
26
|
+
def initialize(config)
|
27
|
+
super(Constants::OLLAMA, config)
|
28
|
+
@conn = connection(config[:base_url] || BASE_ENDPOINT)
|
29
|
+
end
|
8
30
|
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
31
|
+
def generate_text(prompt, options = {})
|
32
|
+
make_request(prompt, options) do |result|
|
33
|
+
result.dig(:choices, 0, :message, :content).to_s
|
34
|
+
end
|
35
|
+
end
|
13
36
|
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
response = @conn.post('/v1/chat/completions') do |req|
|
18
|
-
req.body = {
|
19
|
-
model: options[:model] || @config[:model] || 'llama3.2:latest',
|
20
|
-
messages: messages,
|
21
|
-
temperature: options[:temperature] || 0.7,
|
22
|
-
top_p: options[:top_p] || 0.7,
|
23
|
-
max_tokens: options[:max_tokens] || 24_576,
|
24
|
-
stream: false
|
25
|
-
}.compact
|
26
|
-
end
|
37
|
+
def generate_object(prompt, schema, options = {})
|
38
|
+
system_prompt = 'You are a helpful assistant that responds with valid JSON.'
|
39
|
+
formatted_prompt = LastLLM::StructuredOutput.format_prompt(prompt, schema)
|
27
40
|
|
28
|
-
|
29
|
-
|
41
|
+
options = options.dup
|
42
|
+
options[:system_prompt] = system_prompt
|
43
|
+
options[:temperature] ||= DEFAULT_TEMPERATURE_OBJECT
|
30
44
|
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
45
|
+
make_request(formatted_prompt, options) do |result|
|
46
|
+
content = result.dig(:choices, 0, :message, :content)
|
47
|
+
parse_json_response(content)
|
48
|
+
end
|
49
|
+
end
|
35
50
|
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
req.body = {
|
47
|
-
model: options[:model] || @config[:model] || 'llama3.2:latest',
|
48
|
-
messages: messages,
|
49
|
-
temperature: options[:temperature] || 0.2,
|
50
|
-
stream: false
|
51
|
-
}.compact
|
52
|
-
end
|
51
|
+
# Format a tool for Ollama function calling
|
52
|
+
# @param tool [LastLLM::Tool] The tool to format
|
53
|
+
# @return [Hash] The tool in Ollama format
|
54
|
+
def self.format_tool(tool)
|
55
|
+
{
|
56
|
+
name: tool.name,
|
57
|
+
description: tool.description,
|
58
|
+
parameters: tool.parameters
|
59
|
+
}
|
60
|
+
end
|
53
61
|
|
54
|
-
|
55
|
-
|
62
|
+
# Execute a tool from an Ollama response
|
63
|
+
# @param tool [LastLLM::Tool] The tool to execute
|
64
|
+
# @param response [Hash] The Ollama response containing tool call information
|
65
|
+
# @return [Hash, nil] The result of the function call or nil if the tool wasn't called
|
66
|
+
def self.execute_tool(tool, response)
|
67
|
+
# Ollama doesn't have native function calling, so we need to parse from the content
|
68
|
+
# This is a simplified implementation that would need to be enhanced for production
|
69
|
+
content = response.dig(:message, :content)
|
70
|
+
return nil unless content&.include?(tool.name)
|
71
|
+
|
72
|
+
# Simple regex to extract JSON from the content
|
73
|
+
# This is a basic implementation and might need enhancement
|
74
|
+
if content =~ /#{tool.name}\s*\(([^)]+)\)/i
|
75
|
+
args_str = ::Regexp.last_match(1)
|
76
|
+
begin
|
77
|
+
args = JSON.parse("{#{args_str}}", symbolize_names: true)
|
78
|
+
return tool.call(args)
|
79
|
+
rescue JSON::ParserError
|
80
|
+
return nil
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
nil
|
85
|
+
end
|
56
86
|
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
87
|
+
private
|
88
|
+
|
89
|
+
def make_request(prompt, options = {})
|
90
|
+
messages = format_messages(prompt, options)
|
91
|
+
|
92
|
+
response = @conn.post('/v1/chat/completions') do |req|
|
93
|
+
req.body = {
|
94
|
+
model: options[:model] || @config[:model] || DEFAULT_MODEL,
|
95
|
+
messages: messages,
|
96
|
+
temperature: options[:temperature] || DEFAULT_TEMPERATURE,
|
97
|
+
top_p: options[:top_p] || DEFAULT_TOP_P,
|
98
|
+
max_tokens: options[:max_tokens] || DEFAULT_MAX_TOKENS,
|
99
|
+
stream: false
|
100
|
+
}.compact
|
101
|
+
end
|
102
|
+
|
103
|
+
result = parse_response(response)
|
104
|
+
yield(result)
|
105
|
+
rescue Faraday::Error => e
|
106
|
+
handle_request_error(e)
|
107
|
+
end
|
65
108
|
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
end
|
79
|
-
end
|
109
|
+
def format_messages(prompt, options)
|
110
|
+
if prompt.is_a?(Array) && prompt.all? { |m| m.is_a?(Hash) && m[:role] && m[:content] }
|
111
|
+
prompt
|
112
|
+
elsif options[:system_prompt]
|
113
|
+
[
|
114
|
+
{ role: 'system', content: options[:system_prompt] },
|
115
|
+
{ role: 'user', content: prompt.to_s }
|
116
|
+
]
|
117
|
+
else
|
118
|
+
[{ role: 'user', content: prompt.to_s }]
|
119
|
+
end
|
120
|
+
end
|
80
121
|
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
parameters: tool.parameters
|
89
|
-
}
|
90
|
-
end
|
122
|
+
def parse_json_response(content)
|
123
|
+
begin
|
124
|
+
JSON.parse(content, symbolize_names: true)
|
125
|
+
rescue JSON::ParserError => e
|
126
|
+
raise LastLLM::ApiError, "Invalid JSON response: #{e.message}"
|
127
|
+
end
|
128
|
+
end
|
91
129
|
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
def self.execute_tool(tool, response)
|
97
|
-
# Ollama doesn't have native function calling, so we need to parse from the content
|
98
|
-
# This is a simplified implementation that would need to be enhanced for production
|
99
|
-
content = response.dig(:message, :content)
|
100
|
-
return nil unless content&.include?(tool.name)
|
101
|
-
|
102
|
-
# Simple regex to extract JSON from the content
|
103
|
-
# This is a basic implementation and might need enhancement
|
104
|
-
if content =~ /#{tool.name}\s*\(([^)]+)\)/i
|
105
|
-
args_str = ::Regexp.last_match(1)
|
106
|
-
begin
|
107
|
-
args = JSON.parse("{#{args_str}}", symbolize_names: true)
|
108
|
-
return tool.call(args)
|
109
|
-
rescue JSON::ParserError
|
110
|
-
return nil
|
130
|
+
def handle_request_error(error)
|
131
|
+
message = "Ollama API request failed: #{error.message}"
|
132
|
+
status = error.respond_to?(:response) && error.response.respond_to?(:status) ? error.response.status : nil
|
133
|
+
raise LastLLM::ApiError.new(message, status)
|
111
134
|
end
|
112
135
|
end
|
113
|
-
|
114
|
-
nil
|
115
|
-
end
|
116
|
-
end
|
117
|
-
|
118
|
-
# Also define it in the LastLLM::Providers namespace for consistency
|
119
|
-
module LastLLM
|
120
|
-
module Providers
|
121
|
-
# Reference to the Ollama class defined above
|
122
|
-
Ollama = ::Ollama
|
123
136
|
end
|
124
137
|
end
|