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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +35 -50
  3. data/VERSION +1 -1
  4. data/lib/active_genie/battle/README.md +5 -5
  5. data/lib/active_genie/battle/generalist.rb +132 -0
  6. data/lib/active_genie/battle.rb +6 -5
  7. data/lib/active_genie/clients/providers/anthropic_client.rb +77 -0
  8. data/lib/active_genie/clients/{base_client.rb → providers/base_client.rb} +74 -100
  9. data/lib/active_genie/clients/providers/deepseek_client.rb +91 -0
  10. data/lib/active_genie/clients/providers/google_client.rb +132 -0
  11. data/lib/active_genie/clients/providers/openai_client.rb +96 -0
  12. data/lib/active_genie/clients/unified_client.rb +42 -12
  13. data/lib/active_genie/concerns/loggable.rb +11 -23
  14. data/lib/active_genie/config/battle_config.rb +8 -0
  15. data/lib/active_genie/config/data_extractor_config.rb +23 -0
  16. data/lib/active_genie/config/llm_config.rb +36 -0
  17. data/lib/active_genie/config/log_config.rb +44 -0
  18. data/lib/active_genie/config/providers/anthropic_config.rb +57 -0
  19. data/lib/active_genie/config/providers/deepseek_config.rb +50 -0
  20. data/lib/active_genie/config/providers/google_config.rb +52 -0
  21. data/lib/active_genie/config/providers/openai_config.rb +50 -0
  22. data/lib/active_genie/config/providers/provider_base.rb +89 -0
  23. data/lib/active_genie/config/providers_config.rb +62 -0
  24. data/lib/active_genie/config/ranking_config.rb +21 -0
  25. data/lib/active_genie/config/scoring_config.rb +8 -0
  26. data/lib/active_genie/configuration.rb +51 -28
  27. data/lib/active_genie/data_extractor/README.md +13 -13
  28. data/lib/active_genie/data_extractor/from_informal.rb +54 -48
  29. data/lib/active_genie/data_extractor/generalist.md +12 -0
  30. data/lib/active_genie/data_extractor/generalist.rb +125 -0
  31. data/lib/active_genie/data_extractor.rb +7 -5
  32. data/lib/active_genie/errors/invalid_provider_error.rb +41 -0
  33. data/lib/active_genie/logger.rb +17 -66
  34. data/lib/active_genie/ranking/README.md +31 -1
  35. data/lib/active_genie/ranking/elo_round.rb +107 -104
  36. data/lib/active_genie/ranking/free_for_all.rb +78 -74
  37. data/lib/active_genie/ranking/player.rb +79 -71
  38. data/lib/active_genie/ranking/players_collection.rb +83 -71
  39. data/lib/active_genie/ranking/ranking.rb +71 -94
  40. data/lib/active_genie/ranking/ranking_scoring.rb +71 -50
  41. data/lib/active_genie/ranking.rb +2 -0
  42. data/lib/active_genie/scoring/README.md +4 -4
  43. data/lib/active_genie/scoring/generalist.rb +171 -0
  44. data/lib/active_genie/scoring/recommended_reviewers.rb +70 -71
  45. data/lib/active_genie/scoring.rb +8 -5
  46. data/lib/active_genie.rb +23 -1
  47. data/lib/tasks/benchmark.rake +10 -9
  48. data/lib/tasks/install.rake +3 -1
  49. data/lib/tasks/templates/active_genie.rb +11 -6
  50. metadata +31 -22
  51. data/lib/active_genie/battle/basic.rb +0 -129
  52. data/lib/active_genie/clients/anthropic_client.rb +0 -84
  53. data/lib/active_genie/clients/google_client.rb +0 -135
  54. data/lib/active_genie/clients/helpers/retry.rb +0 -29
  55. data/lib/active_genie/clients/openai_client.rb +0 -98
  56. data/lib/active_genie/configuration/log_config.rb +0 -14
  57. data/lib/active_genie/configuration/providers/anthropic_config.rb +0 -54
  58. data/lib/active_genie/configuration/providers/base_config.rb +0 -85
  59. data/lib/active_genie/configuration/providers/deepseek_config.rb +0 -54
  60. data/lib/active_genie/configuration/providers/google_config.rb +0 -56
  61. data/lib/active_genie/configuration/providers/internal_company_api_config.rb +0 -54
  62. data/lib/active_genie/configuration/providers/openai_config.rb +0 -54
  63. data/lib/active_genie/configuration/providers_config.rb +0 -40
  64. data/lib/active_genie/configuration/runtime_config.rb +0 -35
  65. data/lib/active_genie/data_extractor/basic.rb +0 -101
  66. 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': 'ActiveGenie/1.0',
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
- @app_config = config
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: {}, config: {})
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, config)
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: {}, config: {})
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, config)
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: {}, config: {})
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, config)
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: {}, config: {})
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, config)
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, config)
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
- # 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
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, config)
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.dig(:runtime, :timeout) || DEFAULT_TIMEOUT
142
- http.open_timeout = config.dig(:runtime, :open_timeout) || DEFAULT_OPEN_TIMEOUT
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
- 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
-
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
- 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
- })
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(config = {})
212
- max_retries = config.dig(:runtime, :max_retries) || DEFAULT_MAX_RETRIES
213
- retry_delay = config.dig(:runtime, :retry_delay) || DEFAULT_RETRY_DELAY
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 RateLimitError, NetworkError => e
220
- if retries < max_retries
221
- sleep_time = retry_delay * (2 ** retries)
222
- retries += 1
223
-
224
- ActiveGenie::Logger.trace({
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
- }) if defined?(ActiveGenie::Logger)
231
-
232
- sleep(sleep_time)
233
- retry
234
- else
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
- module ActiveGenie::Clients
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
- raise InvalidProviderError if provider_instance.nil? || provider_instance.client.nil?
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
- provider_instance.client.function_calling(messages, function, model_tier:, config:)
11
- end
9
+ module ActiveGenie
10
+ module Clients
11
+ class UnifiedClient
12
+ class InvalidProviderError < StandardError; end
12
13
 
13
- private
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
- # TODO: improve error message
16
- class InvalidProviderError < StandardError; end
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