active_genie 0.0.24 → 0.0.25
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 +35 -50
- data/VERSION +1 -1
- data/lib/active_genie/battle/README.md +5 -5
- data/lib/active_genie/battle/generalist.rb +132 -0
- data/lib/active_genie/battle.rb +6 -5
- data/lib/active_genie/clients/providers/anthropic_client.rb +77 -0
- data/lib/active_genie/clients/{base_client.rb → providers/base_client.rb} +74 -100
- data/lib/active_genie/clients/providers/deepseek_client.rb +91 -0
- data/lib/active_genie/clients/providers/google_client.rb +132 -0
- data/lib/active_genie/clients/providers/openai_client.rb +96 -0
- data/lib/active_genie/clients/unified_client.rb +42 -12
- data/lib/active_genie/concerns/loggable.rb +11 -23
- data/lib/active_genie/config/battle_config.rb +8 -0
- data/lib/active_genie/config/data_extractor_config.rb +23 -0
- data/lib/active_genie/config/llm_config.rb +36 -0
- data/lib/active_genie/config/log_config.rb +44 -0
- data/lib/active_genie/config/providers/anthropic_config.rb +57 -0
- data/lib/active_genie/config/providers/deepseek_config.rb +50 -0
- data/lib/active_genie/config/providers/google_config.rb +52 -0
- data/lib/active_genie/config/providers/openai_config.rb +50 -0
- data/lib/active_genie/config/providers/provider_base.rb +89 -0
- data/lib/active_genie/config/providers_config.rb +62 -0
- data/lib/active_genie/config/ranking_config.rb +21 -0
- data/lib/active_genie/config/scoring_config.rb +8 -0
- data/lib/active_genie/configuration.rb +51 -28
- data/lib/active_genie/data_extractor/README.md +13 -13
- data/lib/active_genie/data_extractor/from_informal.rb +54 -48
- data/lib/active_genie/data_extractor/generalist.md +12 -0
- data/lib/active_genie/data_extractor/generalist.rb +125 -0
- data/lib/active_genie/data_extractor.rb +7 -5
- data/lib/active_genie/errors/invalid_provider_error.rb +41 -0
- data/lib/active_genie/logger.rb +17 -66
- data/lib/active_genie/ranking/README.md +31 -1
- data/lib/active_genie/ranking/elo_round.rb +107 -104
- data/lib/active_genie/ranking/free_for_all.rb +78 -74
- data/lib/active_genie/ranking/player.rb +79 -71
- data/lib/active_genie/ranking/players_collection.rb +83 -71
- data/lib/active_genie/ranking/ranking.rb +71 -94
- data/lib/active_genie/ranking/ranking_scoring.rb +71 -50
- data/lib/active_genie/ranking.rb +2 -0
- data/lib/active_genie/scoring/README.md +4 -4
- data/lib/active_genie/scoring/generalist.rb +171 -0
- data/lib/active_genie/scoring/recommended_reviewers.rb +70 -71
- data/lib/active_genie/scoring.rb +8 -5
- data/lib/active_genie.rb +23 -1
- data/lib/tasks/benchmark.rake +10 -9
- data/lib/tasks/install.rake +3 -1
- data/lib/tasks/templates/active_genie.rb +11 -6
- metadata +31 -22
- data/lib/active_genie/battle/basic.rb +0 -129
- data/lib/active_genie/clients/anthropic_client.rb +0 -84
- data/lib/active_genie/clients/google_client.rb +0 -135
- data/lib/active_genie/clients/helpers/retry.rb +0 -29
- data/lib/active_genie/clients/openai_client.rb +0 -98
- data/lib/active_genie/configuration/log_config.rb +0 -14
- data/lib/active_genie/configuration/providers/anthropic_config.rb +0 -54
- data/lib/active_genie/configuration/providers/base_config.rb +0 -85
- data/lib/active_genie/configuration/providers/deepseek_config.rb +0 -54
- data/lib/active_genie/configuration/providers/google_config.rb +0 -56
- data/lib/active_genie/configuration/providers/internal_company_api_config.rb +0 -54
- data/lib/active_genie/configuration/providers/openai_config.rb +0 -54
- data/lib/active_genie/configuration/providers_config.rb +0 -40
- data/lib/active_genie/configuration/runtime_config.rb +0 -35
- data/lib/active_genie/data_extractor/basic.rb +0 -101
- data/lib/active_genie/scoring/basic.rb +0 -170
@@ -1,15 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module ActiveGenie
|
2
4
|
module Clients
|
3
5
|
class BaseClient
|
4
6
|
class ClientError < StandardError; end
|
5
|
-
class RateLimitError < ClientError; end
|
6
|
-
class TimeoutError < ClientError; end
|
7
|
-
class NetworkError < ClientError; end
|
8
7
|
|
9
8
|
DEFAULT_HEADERS = {
|
10
9
|
'Content-Type': 'application/json',
|
11
10
|
'Accept': 'application/json',
|
12
|
-
'User-Agent':
|
11
|
+
'User-Agent': "ActiveGenie/#{ActiveGenie::VERSION}"
|
13
12
|
}.freeze
|
14
13
|
|
15
14
|
DEFAULT_TIMEOUT = 60 # seconds
|
@@ -17,10 +16,8 @@ module ActiveGenie
|
|
17
16
|
DEFAULT_MAX_RETRIES = 3
|
18
17
|
DEFAULT_RETRY_DELAY = 1 # seconds
|
19
18
|
|
20
|
-
attr_reader :app_config
|
21
|
-
|
22
19
|
def initialize(config)
|
23
|
-
@
|
20
|
+
@config = config
|
24
21
|
end
|
25
22
|
|
26
23
|
# Make a GET request to the specified endpoint
|
@@ -28,12 +25,11 @@ module ActiveGenie
|
|
28
25
|
# @param endpoint [String] The API endpoint to call
|
29
26
|
# @param headers [Hash] Additional headers to include in the request
|
30
27
|
# @param params [Hash] Query parameters for the request
|
31
|
-
# @param config [Hash] Configuration options including timeout, retries, etc.
|
32
28
|
# @return [Hash, nil] The parsed JSON response or nil if empty
|
33
|
-
def get(endpoint, params: {}, headers: {}
|
29
|
+
def get(endpoint, params: {}, headers: {})
|
34
30
|
uri = build_uri(endpoint, params)
|
35
31
|
request = Net::HTTP::Get.new(uri)
|
36
|
-
execute_request(uri, request, headers
|
32
|
+
execute_request(uri, request, headers)
|
37
33
|
end
|
38
34
|
|
39
35
|
# Make a POST request to the specified endpoint
|
@@ -41,13 +37,12 @@ module ActiveGenie
|
|
41
37
|
# @param endpoint [String] The API endpoint to call
|
42
38
|
# @param payload [Hash] The request body to send
|
43
39
|
# @param headers [Hash] Additional headers to include in the request
|
44
|
-
# @param config [Hash] Configuration options including timeout, retries, etc.
|
45
40
|
# @return [Hash, nil] The parsed JSON response or nil if empty
|
46
|
-
def post(endpoint, payload, params: {}, headers: {}
|
41
|
+
def post(endpoint, payload, params: {}, headers: {})
|
47
42
|
uri = build_uri(endpoint, params)
|
48
43
|
request = Net::HTTP::Post.new(uri)
|
49
44
|
request.body = payload.to_json
|
50
|
-
execute_request(uri, request, headers
|
45
|
+
execute_request(uri, request, headers)
|
51
46
|
end
|
52
47
|
|
53
48
|
# Make a PUT request to the specified endpoint
|
@@ -55,13 +50,12 @@ module ActiveGenie
|
|
55
50
|
# @param endpoint [String] The API endpoint to call
|
56
51
|
# @param payload [Hash] The request body to send
|
57
52
|
# @param headers [Hash] Additional headers to include in the request
|
58
|
-
# @param config [Hash] Configuration options including timeout, retries, etc.
|
59
53
|
# @return [Hash, nil] The parsed JSON response or nil if empty
|
60
|
-
def put(endpoint, payload, headers: {}
|
54
|
+
def put(endpoint, payload, headers: {})
|
61
55
|
uri = build_uri(endpoint)
|
62
56
|
request = Net::HTTP::Put.new(uri)
|
63
57
|
request.body = payload.to_json
|
64
|
-
execute_request(uri, request, headers
|
58
|
+
execute_request(uri, request, headers)
|
65
59
|
end
|
66
60
|
|
67
61
|
# Make a DELETE request to the specified endpoint
|
@@ -69,12 +63,11 @@ module ActiveGenie
|
|
69
63
|
# @param endpoint [String] The API endpoint to call
|
70
64
|
# @param headers [Hash] Additional headers to include in the request
|
71
65
|
# @param params [Hash] Query parameters for the request
|
72
|
-
# @param config [Hash] Configuration options including timeout, retries, etc.
|
73
66
|
# @return [Hash, nil] The parsed JSON response or nil if empty
|
74
|
-
def delete(endpoint, headers: {}, params: {}
|
67
|
+
def delete(endpoint, headers: {}, params: {})
|
75
68
|
uri = build_uri(endpoint, params)
|
76
69
|
request = Net::HTTP::Delete.new(uri)
|
77
|
-
execute_request(uri, request, headers
|
70
|
+
execute_request(uri, request, headers)
|
78
71
|
end
|
79
72
|
|
80
73
|
protected
|
@@ -84,62 +77,47 @@ module ActiveGenie
|
|
84
77
|
# @param uri [URI] The URI for the request
|
85
78
|
# @param request [Net::HTTP::Request] The request object
|
86
79
|
# @param headers [Hash] Additional headers to include
|
87
|
-
# @param config [Hash] Configuration options
|
88
80
|
# @return [Hash, nil] The parsed JSON response or nil if empty
|
89
|
-
def execute_request(uri, request, headers
|
81
|
+
def execute_request(uri, request, headers)
|
90
82
|
start_time = Time.now
|
91
|
-
|
83
|
+
|
92
84
|
# Apply headers
|
93
85
|
apply_headers(request, headers)
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
parsed_response
|
117
|
-
when Net::HTTPTooManyRequests
|
118
|
-
raise RateLimitError, "Rate limit exceeded: #{response.body}"
|
119
|
-
when Net::HTTPClientError, Net::HTTPServerError
|
120
|
-
raise ClientError, "HTTP Error #{response.code}: #{response.body}"
|
121
|
-
else
|
122
|
-
raise ClientError, "Unexpected response: #{response.code} - #{response.body}"
|
123
|
-
end
|
124
|
-
rescue Timeout::Error, Errno::ETIMEDOUT
|
125
|
-
raise TimeoutError, "Request to #{uri} timed out"
|
126
|
-
rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, SocketError => e
|
127
|
-
raise NetworkError, "Network error: #{e.message}"
|
128
|
-
end
|
86
|
+
|
87
|
+
http = create_http_client(uri)
|
88
|
+
|
89
|
+
response = http.request(request)
|
90
|
+
|
91
|
+
case response
|
92
|
+
when Net::HTTPSuccess
|
93
|
+
parsed_response = parse_response(response)
|
94
|
+
|
95
|
+
log_request_details(
|
96
|
+
uri: uri,
|
97
|
+
method: request.method,
|
98
|
+
status: response.code,
|
99
|
+
duration: Time.now - start_time,
|
100
|
+
response: parsed_response
|
101
|
+
)
|
102
|
+
|
103
|
+
parsed_response
|
104
|
+
when Net::HTTPTooManyRequests, Net::HTTPClientError, Net::HTTPServerError
|
105
|
+
raise ClientError, "HTTP Error: #{response.code} - #{response.body}"
|
106
|
+
else
|
107
|
+
raise ClientError, "Unexpected response: #{response.code} - #{response.body}"
|
129
108
|
end
|
130
109
|
end
|
131
110
|
|
132
111
|
# Create and configure an HTTP client
|
133
112
|
#
|
134
113
|
# @param uri [URI] The URI for the request
|
135
|
-
# @param config [Hash] Configuration options
|
136
114
|
# @return [Net::HTTP] Configured HTTP client
|
137
|
-
def create_http_client(uri
|
115
|
+
def create_http_client(uri)
|
138
116
|
http = Net::HTTP.new(uri.host, uri.port)
|
139
117
|
http.use_ssl = (uri.scheme == 'https')
|
140
118
|
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
141
|
-
http.read_timeout = config.
|
142
|
-
http.open_timeout = config.
|
119
|
+
http.read_timeout = @config.llm.read_timeout || DEFAULT_TIMEOUT
|
120
|
+
http.open_timeout = @config.llm.open_timeout || DEFAULT_OPEN_TIMEOUT
|
143
121
|
http
|
144
122
|
end
|
145
123
|
|
@@ -151,7 +129,7 @@ module ActiveGenie
|
|
151
129
|
DEFAULT_HEADERS.each do |key, value|
|
152
130
|
request[key] = value
|
153
131
|
end
|
154
|
-
|
132
|
+
|
155
133
|
headers.each do |key, value|
|
156
134
|
request[key.to_s] = value
|
157
135
|
end
|
@@ -163,13 +141,10 @@ module ActiveGenie
|
|
163
141
|
# @param params [Hash] Query parameters
|
164
142
|
# @return [URI] The constructed URI
|
165
143
|
def build_uri(endpoint, params = {})
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
uri.query = URI.encode_www_form(params)
|
171
|
-
end
|
172
|
-
|
144
|
+
uri = endpoint.is_a?(URI) ? endpoint : URI(endpoint)
|
145
|
+
|
146
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
147
|
+
|
173
148
|
uri
|
174
149
|
end
|
175
150
|
|
@@ -179,7 +154,7 @@ module ActiveGenie
|
|
179
154
|
# @return [Hash, nil] Parsed JSON or nil if empty
|
180
155
|
def parse_response(response)
|
181
156
|
return nil if response.body.nil? || response.body.empty?
|
182
|
-
|
157
|
+
|
183
158
|
begin
|
184
159
|
JSON.parse(response.body)
|
185
160
|
rescue JSON::ParserError => e
|
@@ -191,51 +166,50 @@ module ActiveGenie
|
|
191
166
|
#
|
192
167
|
# @param details [Hash] Request and response details
|
193
168
|
def log_request_details(details)
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
169
|
+
ActiveGenie::Logger.call(
|
170
|
+
{
|
171
|
+
code: :http_request,
|
172
|
+
uri: details[:uri].to_s,
|
173
|
+
method: details[:method],
|
174
|
+
status: details[:status],
|
175
|
+
duration: details[:duration],
|
176
|
+
response_size: details[:response].to_s.bytesize
|
177
|
+
}
|
178
|
+
)
|
204
179
|
end
|
205
180
|
|
206
181
|
# Retry a block with exponential backoff
|
207
182
|
#
|
208
|
-
# @param config [Hash] Configuration options
|
209
183
|
# @yield The block to retry
|
210
184
|
# @return [Object] The result of the block
|
211
|
-
def retry_with_backoff
|
212
|
-
max_retries = config.
|
213
|
-
retry_delay = config.
|
214
|
-
|
185
|
+
def retry_with_backoff
|
186
|
+
max_retries = @config.llm.max_retries || DEFAULT_MAX_RETRIES
|
187
|
+
retry_delay = @config.llm.retry_delay || DEFAULT_RETRY_DELAY
|
188
|
+
|
215
189
|
retries = 0
|
216
|
-
|
190
|
+
|
217
191
|
begin
|
218
192
|
yield
|
219
|
-
rescue
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
193
|
+
rescue Net::HTTPError => e
|
194
|
+
raise unless retries < max_retries
|
195
|
+
|
196
|
+
sleep_time = retry_delay * (2**retries)
|
197
|
+
retries += 1
|
198
|
+
|
199
|
+
ActiveGenie::Logger.call(
|
200
|
+
{
|
225
201
|
code: :retry_attempt,
|
226
202
|
attempt: retries,
|
227
203
|
max_retries: max_retries,
|
228
204
|
delay: sleep_time,
|
229
205
|
error: e.message
|
230
|
-
}
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
raise
|
236
|
-
end
|
206
|
+
}
|
207
|
+
)
|
208
|
+
|
209
|
+
sleep(sleep_time)
|
210
|
+
retry
|
237
211
|
end
|
238
212
|
end
|
239
213
|
end
|
240
214
|
end
|
241
|
-
end
|
215
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'net/http'
|
5
|
+
|
6
|
+
require_relative './base_client'
|
7
|
+
|
8
|
+
module ActiveGenie
|
9
|
+
module Clients
|
10
|
+
class DeepseekClient < BaseClient
|
11
|
+
class DeepseekError < ClientError; end
|
12
|
+
class RateLimitError < DeepseekError; end
|
13
|
+
class InvalidResponseError < StandardError; end
|
14
|
+
|
15
|
+
# Requests structured JSON output from the DeepSeek model based on a schema.
|
16
|
+
#
|
17
|
+
# @param messages [Array<Hash>] A list of messages representing the conversation history.
|
18
|
+
# Each hash should have :role ('user', 'assistant', or 'system') and :content (String).
|
19
|
+
# @param function [Hash] A JSON schema definition describing the desired output format.
|
20
|
+
# @return [Hash, nil] The parsed JSON object matching the schema, or nil if parsing fails or content is empty.
|
21
|
+
def function_calling(messages, function)
|
22
|
+
model = @config.llm.model || @config.providers.deepseek.tier_to_model(@config.llm.model_tier)
|
23
|
+
|
24
|
+
payload = {
|
25
|
+
messages:,
|
26
|
+
tools: [{
|
27
|
+
type: 'function',
|
28
|
+
function: {
|
29
|
+
**function,
|
30
|
+
parameters: {
|
31
|
+
**function[:parameters],
|
32
|
+
additionalProperties: false
|
33
|
+
},
|
34
|
+
strict: true
|
35
|
+
}.compact
|
36
|
+
}],
|
37
|
+
tool_choice: { type: 'function', function: { name: function[:name] } },
|
38
|
+
stream: false,
|
39
|
+
model:
|
40
|
+
}
|
41
|
+
|
42
|
+
headers = {
|
43
|
+
'Authorization': "Bearer #{@config.providers.deepseek.api_key}"
|
44
|
+
}.compact
|
45
|
+
|
46
|
+
retry_with_backoff do
|
47
|
+
response = request_deepseek(payload, headers)
|
48
|
+
|
49
|
+
parsed_response = JSON.parse(response.dig('choices', 0, 'message', 'tool_calls', 0, 'function', 'arguments'))
|
50
|
+
parsed_response = parsed_response['message'] || parsed_response
|
51
|
+
|
52
|
+
if parsed_response.nil? || parsed_response.keys.empty?
|
53
|
+
raise InvalidResponseError,
|
54
|
+
"Invalid response: #{parsed_response}"
|
55
|
+
end
|
56
|
+
|
57
|
+
ActiveGenie::Logger.call({ code: :function_calling, payload:, parsed_response: })
|
58
|
+
|
59
|
+
parsed_response
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
# Make a request to the DeepSeek API
|
66
|
+
#
|
67
|
+
# @param payload [Hash] The request payload
|
68
|
+
# @param headers [Hash] Additional headers
|
69
|
+
# @return [Hash] The parsed response
|
70
|
+
def request_deepseek(payload, headers)
|
71
|
+
start_time = Time.now
|
72
|
+
url = "#{@config.providers.deepseek.api_url}/chat/completions"
|
73
|
+
|
74
|
+
response = post(url, payload, headers: headers)
|
75
|
+
return nil if response.nil?
|
76
|
+
|
77
|
+
ActiveGenie::Logger.call({
|
78
|
+
code: :llm_usage,
|
79
|
+
input_tokens: response.dig('usage', 'prompt_tokens'),
|
80
|
+
output_tokens: response.dig('usage', 'completion_tokens'),
|
81
|
+
total_tokens: response.dig('usage', 'total_tokens'),
|
82
|
+
model: payload[:model],
|
83
|
+
duration: Time.now - start_time,
|
84
|
+
usage: response['usage']
|
85
|
+
})
|
86
|
+
|
87
|
+
response
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,132 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'net/http'
|
5
|
+
require 'uri'
|
6
|
+
require_relative './base_client'
|
7
|
+
|
8
|
+
module ActiveGenie
|
9
|
+
module Clients
|
10
|
+
# Client for interacting with the Google Generative Language API.
|
11
|
+
class GoogleClient < BaseClient
|
12
|
+
class GoogleError < ClientError; end
|
13
|
+
class RateLimitError < GoogleError; end
|
14
|
+
|
15
|
+
API_VERSION_PATH = '/v1beta/models'
|
16
|
+
|
17
|
+
# Requests structured JSON output from the Google Generative Language model based on a schema.
|
18
|
+
#
|
19
|
+
# @param messages [Array<Hash>] A list of messages representing the conversation history.
|
20
|
+
# Each hash should have :role ('user' or 'model') and :content (String).
|
21
|
+
# Google Generative Language uses 'user' and 'model' roles.
|
22
|
+
# @param function [Hash] A JSON schema definition describing the desired output format.
|
23
|
+
# @return [Hash, nil] The parsed JSON object matching the schema, or nil if parsing fails or content is empty.
|
24
|
+
def function_calling(messages, function)
|
25
|
+
model = @config.llm.model || provider_config.tier_to_model(@config.llm.model_tier)
|
26
|
+
|
27
|
+
contents = convert_messages_to_contents(messages, function)
|
28
|
+
contents << output_as_json_schema(function)
|
29
|
+
|
30
|
+
payload = {
|
31
|
+
contents: contents,
|
32
|
+
generationConfig: {
|
33
|
+
response_mime_type: 'application/json',
|
34
|
+
temperature: 0.1
|
35
|
+
}
|
36
|
+
}
|
37
|
+
|
38
|
+
endpoint = "#{API_VERSION_PATH}/#{model}:generateContent"
|
39
|
+
params = { key: provider_config.api_key }
|
40
|
+
headers = DEFAULT_HEADERS
|
41
|
+
|
42
|
+
retry_with_backoff do
|
43
|
+
start_time = Time.now
|
44
|
+
url = "#{provider_config.api_url}#{endpoint}"
|
45
|
+
|
46
|
+
response = post(url, payload, headers:, params:)
|
47
|
+
|
48
|
+
json_string = response&.dig('candidates', 0, 'content', 'parts', 0, 'text')
|
49
|
+
return nil if json_string.nil? || json_string.empty?
|
50
|
+
|
51
|
+
begin
|
52
|
+
parsed_response = JSON.parse(json_string)
|
53
|
+
|
54
|
+
# Log usage metrics
|
55
|
+
usage_metadata = response['usageMetadata'] || {}
|
56
|
+
prompt_tokens = usage_metadata['promptTokenCount'] || 0
|
57
|
+
candidates_tokens = usage_metadata['candidatesTokenCount'] || 0
|
58
|
+
total_tokens = usage_metadata['totalTokenCount'] || (prompt_tokens + candidates_tokens)
|
59
|
+
|
60
|
+
ActiveGenie::Logger.call({
|
61
|
+
code: :llm_usage,
|
62
|
+
input_tokens: prompt_tokens,
|
63
|
+
output_tokens: candidates_tokens,
|
64
|
+
total_tokens: total_tokens,
|
65
|
+
model: model,
|
66
|
+
duration: Time.now - start_time,
|
67
|
+
usage: usage_metadata
|
68
|
+
})
|
69
|
+
|
70
|
+
ActiveGenie::Logger.call({ code: :function_calling, payload:, parsed_response: })
|
71
|
+
|
72
|
+
normalize_function_output(parsed_response)
|
73
|
+
rescue JSON::ParserError => e
|
74
|
+
raise GoogleError, "Failed to parse Google API response: #{e.message} - Content: #{json_string}"
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def provider_config
|
80
|
+
@config.providers.google
|
81
|
+
end
|
82
|
+
|
83
|
+
private
|
84
|
+
|
85
|
+
def normalize_function_output(output)
|
86
|
+
output = if output.is_a?(Array)
|
87
|
+
output.dig(0, 'properties') || output[0]
|
88
|
+
else
|
89
|
+
output
|
90
|
+
end
|
91
|
+
|
92
|
+
output.dig('input_schema', 'properties') || output
|
93
|
+
end
|
94
|
+
|
95
|
+
ROLE_TO_GOOGLE_ROLE = {
|
96
|
+
user: 'user',
|
97
|
+
assistant: 'model'
|
98
|
+
}.freeze
|
99
|
+
|
100
|
+
# Converts standard message format to Google's 'contents' format
|
101
|
+
# and injects JSON schema instructions.
|
102
|
+
# @param messages [Array<Hash>] Array of { role: 'user'/'assistant'/'system', content: '...' }
|
103
|
+
# @param function_schema [Hash] The JSON schema for the desired output.
|
104
|
+
# @return [Array<Hash>] Array formatted for Google's 'contents' field.
|
105
|
+
def convert_messages_to_contents(messages, _function_schema)
|
106
|
+
messages.map do |message|
|
107
|
+
{
|
108
|
+
role: ROLE_TO_GOOGLE_ROLE[message[:role].to_sym] || 'user',
|
109
|
+
parts: [{ text: message[:content] }]
|
110
|
+
}
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def output_as_json_schema(function_schema)
|
115
|
+
json_instruction = <<~PROMPT
|
116
|
+
Generate a JSON object that strictly adheres to the following JSON schema:
|
117
|
+
|
118
|
+
```json
|
119
|
+
#{JSON.pretty_generate(function_schema[:parameters])}
|
120
|
+
```
|
121
|
+
|
122
|
+
IMPORTANT: Only output the raw JSON object. Do not include any other text, explanations, or markdown formatting like ```json ... ``` wrappers around the final output.
|
123
|
+
PROMPT
|
124
|
+
|
125
|
+
{
|
126
|
+
role: 'user',
|
127
|
+
parts: [{ text: json_instruction }]
|
128
|
+
}
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'net/http'
|
5
|
+
|
6
|
+
require_relative './base_client'
|
7
|
+
|
8
|
+
module ActiveGenie
|
9
|
+
module Clients
|
10
|
+
class OpenaiClient < BaseClient
|
11
|
+
class OpenaiError < ClientError; end
|
12
|
+
class RateLimitError < OpenaiError; end
|
13
|
+
class InvalidResponseError < StandardError; end
|
14
|
+
|
15
|
+
# Requests structured JSON output from the OpenAI model based on a schema.
|
16
|
+
#
|
17
|
+
# @param messages [Array<Hash>] A list of messages representing the conversation history.
|
18
|
+
# Each hash should have :role ('user', 'assistant', or 'system') and :content (String).
|
19
|
+
# @param function [Hash] A JSON schema definition describing the desired output format.
|
20
|
+
# @return [Hash, nil] The parsed JSON object matching the schema, or nil if parsing fails or content is empty.
|
21
|
+
def function_calling(messages, function)
|
22
|
+
model = @config.llm.model || provider_config.tier_to_model(@config.llm.model_tier)
|
23
|
+
|
24
|
+
payload = {
|
25
|
+
messages:,
|
26
|
+
tools: [{
|
27
|
+
type: 'function',
|
28
|
+
function: {
|
29
|
+
**function,
|
30
|
+
parameters: {
|
31
|
+
**function[:parameters],
|
32
|
+
additionalProperties: false
|
33
|
+
},
|
34
|
+
strict: true
|
35
|
+
}.compact
|
36
|
+
}],
|
37
|
+
tool_choice: { type: 'function', function: { name: function[:name] } },
|
38
|
+
stream: false,
|
39
|
+
model:
|
40
|
+
}
|
41
|
+
|
42
|
+
headers = {
|
43
|
+
'Authorization': "Bearer #{provider_config.api_key}"
|
44
|
+
}.compact
|
45
|
+
|
46
|
+
retry_with_backoff do
|
47
|
+
response = request_openai(payload, headers)
|
48
|
+
|
49
|
+
parsed_response = JSON.parse(response.dig('choices', 0, 'message', 'tool_calls', 0, 'function', 'arguments'))
|
50
|
+
parsed_response = parsed_response['message'] || parsed_response
|
51
|
+
|
52
|
+
if parsed_response.nil? || parsed_response.keys.empty?
|
53
|
+
raise InvalidResponseError,
|
54
|
+
"Invalid response: #{parsed_response}"
|
55
|
+
end
|
56
|
+
|
57
|
+
ActiveGenie::Logger.call({ code: :function_calling, payload:, parsed_response: })
|
58
|
+
|
59
|
+
parsed_response
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def provider_config
|
64
|
+
@config.providers.openai
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
# Make a request to the OpenAI API
|
70
|
+
#
|
71
|
+
# @param payload [Hash] The request payload
|
72
|
+
# @param headers [Hash] Additional headers
|
73
|
+
# @return [Hash] The parsed response
|
74
|
+
def request_openai(payload, headers)
|
75
|
+
start_time = Time.now
|
76
|
+
url = "#{provider_config.api_url}/chat/completions"
|
77
|
+
|
78
|
+
response = post(url, payload, headers: headers)
|
79
|
+
|
80
|
+
return nil if response.nil?
|
81
|
+
|
82
|
+
ActiveGenie::Logger.call({
|
83
|
+
code: :llm_usage,
|
84
|
+
input_tokens: response.dig('usage', 'prompt_tokens'),
|
85
|
+
output_tokens: response.dig('usage', 'completion_tokens'),
|
86
|
+
total_tokens: response.dig('usage', 'total_tokens'),
|
87
|
+
model: payload[:model],
|
88
|
+
duration: Time.now - start_time,
|
89
|
+
usage: response['usage']
|
90
|
+
})
|
91
|
+
|
92
|
+
response
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -1,19 +1,49 @@
|
|
1
|
-
|
2
|
-
class UnifiedClient
|
3
|
-
class << self
|
4
|
-
def function_calling(messages, function, model_tier: nil, config: {})
|
5
|
-
provider_name = config[:runtime][:provider]&.to_s&.downcase&.strip&.to_sym || ActiveGenie.configuration.providers.default
|
6
|
-
provider_instance = ActiveGenie.configuration.providers.valid[provider_name]
|
1
|
+
# frozen_string_literal: true
|
7
2
|
|
8
|
-
|
3
|
+
require_relative './providers/openai_client'
|
4
|
+
require_relative './providers/anthropic_client'
|
5
|
+
require_relative './providers/google_client'
|
6
|
+
require_relative './providers/deepseek_client'
|
7
|
+
require_relative '../errors/invalid_provider_error'
|
9
8
|
|
10
|
-
|
11
|
-
|
9
|
+
module ActiveGenie
|
10
|
+
module Clients
|
11
|
+
class UnifiedClient
|
12
|
+
class InvalidProviderError < StandardError; end
|
12
13
|
|
13
|
-
|
14
|
+
class << self
|
15
|
+
PROVIDER_NAME_TO_CLIENT = {
|
16
|
+
openai: OpenaiClient,
|
17
|
+
anthropic: AnthropicClient,
|
18
|
+
google: GoogleClient,
|
19
|
+
deepseek: DeepseekClient
|
20
|
+
}.freeze
|
14
21
|
|
15
|
-
|
16
|
-
|
22
|
+
def function_calling(messages, function, config: {})
|
23
|
+
client = config.llm.client
|
24
|
+
|
25
|
+
unless client
|
26
|
+
provider_name = config.llm.provider || config.providers.default
|
27
|
+
client = PROVIDER_NAME_TO_CLIENT[provider_name.to_sym]
|
28
|
+
end
|
29
|
+
|
30
|
+
raise ActiveGenie::InvalidProviderError, provider_name if client.nil?
|
31
|
+
|
32
|
+
response = client.new(config).function_calling(messages, function)
|
33
|
+
|
34
|
+
normalize_response(response)
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def normalize_response(response)
|
40
|
+
response.each do |key, value|
|
41
|
+
response[key] = nil if ['null', 'none', 'undefined', '', 'unknown', '<unknown>'].include?(value.to_s.strip.downcase)
|
42
|
+
end
|
43
|
+
|
44
|
+
response
|
45
|
+
end
|
46
|
+
end
|
17
47
|
end
|
18
48
|
end
|
19
49
|
end
|