last_llm 0.0.4 → 0.0.6

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.
@@ -2,174 +2,193 @@
2
2
 
3
3
  require 'last_llm/providers/constants'
4
4
 
5
- # Google Gemini provider implementation
6
- class GoogleGemini < LastLLM::Provider
7
- BASE_ENDPOINT = 'https://generativelanguage.googleapis.com'
8
-
9
- def initialize(config)
10
- super(Constants::GOOGLE_GEMINI, config)
11
- @api_key = config[:api_key]
12
- @conn = connection(config[:base_url] || BASE_ENDPOINT)
13
- end
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
- def generate_text(prompt, options = {})
16
- model = options[:model] || @config[:model] || 'gemini-1.5-flash'
17
- contents = format_contents(prompt, options)
18
-
19
- response = @conn.post("/v1beta/models/#{model}:generateContent?key=#{@api_key}") do |req|
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
- # Check for error responses even when they don't raise exceptions
32
- if response.status != 200
33
- error = Faraday::Error.new("HTTP #{response.status}")
34
- error.instance_variable_set(:@response, { status: response.status, body: response.body.to_json })
35
- return handle_gemini_error(error)
36
- end
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
- result = parse_response(response)
39
- content = result.dig(:candidates, 0, :content, :parts, 0, :text)
47
+ private
40
48
 
41
- content.to_s
42
- rescue Faraday::Error => e
43
- handle_gemini_error(e)
44
- end
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
- def generate_object(prompt, schema, options = {})
47
- model = options[:model] || @config[:model] || 'gemini-1.5-flash'
48
- contents = format_contents(prompt, options)
49
-
50
- response = @conn.post("/v1beta/models/#{model}:generateContent?key=#{@api_key}") do |req|
51
- req.body = {
52
- contents: contents,
53
- generationConfig: {
54
- temperature: options[:temperature] || 0.7,
55
- maxOutputTokens: options[:max_tokens],
56
- topP: options[:top_p] || 0.95,
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
- }.compact
62
- end
74
+ end
63
75
 
64
- # Check for error responses even when they don't raise exceptions
65
- if response.status != 200
66
- error = Faraday::Error.new("HTTP #{response.status}")
67
- error.instance_variable_set(:@response, { status: response.status, body: response.body.to_json })
68
- return handle_gemini_error(error)
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
- result = parse_response(response)
72
- content = result.dig(:candidates, 0, :content, :parts, 0, :text)
82
+ result = parse_response(response)
83
+ yield(result)
84
+ end
73
85
 
74
- begin
75
- JSON.parse(content, symbolize_names: true)
76
- rescue JSON::ParserError => e
77
- raise LastLLM::ApiError, "Invalid JSON response: #{e.message}"
78
- end
79
- rescue Faraday::Error => e
80
- handle_gemini_error(e)
81
- end
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
- private
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
- def connection(endpoint)
86
- Faraday.new(url: endpoint) do |faraday|
87
- faraday.request :json
88
- faraday.response :json, content_type: /\bjson$/
89
- faraday.adapter Faraday.default_adapter
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
- def format_contents(prompt, options)
94
- if prompt.is_a?(Array)
95
- prompt.map { |m| { role: m[:role], parts: [{ text: m[:content] }] } }
96
- elsif options[:system_prompt]
97
- [
98
- { role: 'user', parts: [{ text: options[:system_prompt] }] },
99
- { role: 'user', parts: [{ text: prompt.to_s }] }
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
- # Format a tool for Google Gemini function calling
107
- # @param tool [LastLLM::Tool] The tool to format
108
- # @return [Hash] The tool in Google Gemini format
109
- def self.format_tool(tool)
110
- {
111
- name: tool.name,
112
- description: tool.description,
113
- parameters: tool.parameters
114
- }
115
- end
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
- # Execute a tool from a Google Gemini response
118
- # @param tool [LastLLM::Tool] The tool to execute
119
- # @param response [Hash] The Google Gemini response containing function call information
120
- # @return [Hash, nil] The result of the function call or nil if the tool wasn't called
121
- def self.execute_tool(tool, response)
122
- function_call = response.dig(:candidates, 0, :content, :parts, 0, :functionCall)
123
- return nil unless function_call && function_call[:name] == tool.name
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
- arguments = function_call[:args]
126
- tool.call(arguments)
127
- end
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
- # Custom error handler for Gemini API responses
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
- raise LastLLM::ApiError.new(message, status)
166
- end
167
- end
144
+ def handle_gemini_error(error)
145
+ status = error.response&.dig(:status)
146
+ message = parse_error_message(error)
168
147
 
169
- # Also define it in the LastLLM::Providers namespace for consistency
170
- module LastLLM
171
- module Providers
172
- # Reference to the GoogleGemini class defined above
173
- GoogleGemini = ::GoogleGemini
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
- # Ollama provider implementation
6
- class Ollama < LastLLM::Provider
7
- BASE_ENDPOINT = 'http://172.17.0.1:11434'
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
- def initialize(config)
10
- super(Constants::OLLAMA, config)
11
- @conn = connection(config[:base_url] || BASE_ENDPOINT)
12
- end
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
- def generate_text(prompt, options = {})
15
- messages = format_messages(prompt, options)
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
- result = parse_response(response)
29
- content = result.dig(:choices, 0, :message, :content)
41
+ options = options.dup
42
+ options[:system_prompt] = system_prompt
43
+ options[:temperature] ||= DEFAULT_TEMPERATURE_OBJECT
30
44
 
31
- content.to_s
32
- rescue Faraday::Error => e
33
- handle_request_error(e)
34
- end
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
- def generate_object(prompt, schema, options = {})
37
- system_prompt = 'You are a helpful assistant that responds with valid JSON.'
38
- formatted_prompt = LastLLM::StructuredOutput.format_prompt(prompt, schema)
39
-
40
- messages = [
41
- { role: 'system', content: system_prompt },
42
- { role: 'user', content: formatted_prompt }
43
- ]
44
-
45
- response = @conn.post('/v1/chat/completions') do |req|
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
- result = parse_response(response)
55
- content = result.dig(:choices, 0, :message, :content)
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
- begin
58
- JSON.parse(content, symbolize_names: true)
59
- rescue JSON::ParserError => e
60
- raise LastLLM::ApiError, "Invalid JSON response: #{e.message}"
61
- end
62
- rescue Faraday::Error => e
63
- handle_request_error(e)
64
- end
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
- private
67
-
68
- def format_messages(prompt, options)
69
- if prompt.is_a?(Array) && prompt.all? { |m| m.is_a?(Hash) && m[:role] && m[:content] }
70
- prompt
71
- elsif options[:system_prompt]
72
- [
73
- { role: 'system', content: options[:system_prompt] },
74
- { role: 'user', content: prompt.to_s }
75
- ]
76
- else
77
- [{ role: 'user', content: prompt.to_s }]
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
- # Format a tool for Ollama function calling
82
- # @param tool [LastLLM::Tool] The tool to format
83
- # @return [Hash] The tool in Ollama format
84
- def self.format_tool(tool)
85
- {
86
- name: tool.name,
87
- description: tool.description,
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
- # Execute a tool from an Ollama response
93
- # @param tool [LastLLM::Tool] The tool to execute
94
- # @param response [Hash] The Ollama response containing tool call information
95
- # @return [Hash, nil] The result of the function call or nil if the tool wasn't called
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