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