active_genie 0.0.12 → 0.0.18

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -22
  3. data/VERSION +1 -1
  4. data/lib/active_genie/battle/README.md +7 -7
  5. data/lib/active_genie/battle/basic.rb +47 -31
  6. data/lib/active_genie/battle.rb +4 -0
  7. data/lib/active_genie/clients/anthropic_client.rb +110 -0
  8. data/lib/active_genie/clients/google_client.rb +158 -0
  9. data/lib/active_genie/clients/helpers/retry.rb +29 -0
  10. data/lib/active_genie/clients/openai_client.rb +61 -83
  11. data/lib/active_genie/clients/unified_client.rb +4 -4
  12. data/lib/active_genie/concerns/loggable.rb +44 -0
  13. data/lib/active_genie/configuration/log_config.rb +1 -1
  14. data/lib/active_genie/configuration/providers/anthropic_config.rb +54 -0
  15. data/lib/active_genie/configuration/providers/base_config.rb +85 -0
  16. data/lib/active_genie/configuration/providers/deepseek_config.rb +54 -0
  17. data/lib/active_genie/configuration/providers/google_config.rb +56 -0
  18. data/lib/active_genie/configuration/providers/openai_config.rb +54 -0
  19. data/lib/active_genie/configuration/providers_config.rb +7 -4
  20. data/lib/active_genie/configuration/runtime_config.rb +35 -0
  21. data/lib/active_genie/configuration.rb +18 -4
  22. data/lib/active_genie/data_extractor/basic.rb +15 -2
  23. data/lib/active_genie/data_extractor.rb +4 -0
  24. data/lib/active_genie/logger.rb +40 -21
  25. data/lib/active_genie/ranking/elo_round.rb +71 -50
  26. data/lib/active_genie/ranking/free_for_all.rb +31 -14
  27. data/lib/active_genie/ranking/player.rb +11 -16
  28. data/lib/active_genie/ranking/players_collection.rb +4 -4
  29. data/lib/active_genie/ranking/ranking.rb +74 -19
  30. data/lib/active_genie/ranking/ranking_scoring.rb +3 -3
  31. data/lib/active_genie/scoring/basic.rb +44 -25
  32. data/lib/active_genie/scoring.rb +3 -0
  33. data/lib/tasks/benchmark.rake +27 -0
  34. metadata +91 -70
  35. data/lib/active_genie/configuration/openai_config.rb +0 -56
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fbc4033f3c0880973cf732d704921bb61c3cb861c438b732aa6ebe0cc88b2de4
4
- data.tar.gz: 11794293170ec43a1d3b3d09e5ec488351a22428a5370ae0e42266a6f8098646
3
+ metadata.gz: 81b6b3ccf366bdeb07e1dfc1942749e4a1d48da74735c48a95cb9d53afb61b33
4
+ data.tar.gz: df2d1ee4ac8bbcfa031b261bedd228ed5c3a8772c055e312360d6a4ad2f699fa
5
5
  SHA512:
6
- metadata.gz: f6b13b3f36a8e516e5126d4cea0de52d046e498834cd10b90b1905460e9bba5ff6e7c4cebd58e6ea2c073176566601c8b806015baa01f11f28792942eec6ca1d
7
- data.tar.gz: fc79aed12aaba0ca335d3a7ef8405f4a7c2809de9d5e41ca14fa7bd843d9fb69db8e124b6a2e96321432575a58b271c724a52fd3c1a1165c5ecfe346bedd4b5f
6
+ metadata.gz: d3a2ff8342483f8b475f0e60d91fa839ba57b0853e82e637ba4e761fd9ae749917e5ae134803200bfe3fd4bab658b297c1888c88d6c433d4f2c0a0694face6aa
7
+ data.tar.gz: a4b37bd1e6ba7a3a4b6edea20bd37f7e2dd11142182b65b8e62707e993d9c42d44486c2247e342ab8a81b01778456dd5bf15616261e01d6c0dd556757646da18
data/README.md CHANGED
@@ -2,16 +2,10 @@
2
2
  > The lodash for GenAI, stop reinventing the wheel
3
3
 
4
4
  [![Gem Version](https://badge.fury.io/rb/active_genie.svg?icon=si%3Arubygems)](https://badge.fury.io/rb/active_genie)
5
- [![Ruby](https://github.com/roriz/active_genie/actions/workflows/ruby.yml/badge.svg)](https://github.com/roriz/active_genie/actions/workflows/ruby.yml)
5
+ [![Ruby](https://github.com/roriz/active_genie/actions/workflows/benchmark.yml/badge.svg)](https://github.com/roriz/active_genie/actions/workflows/benchmark.yml)
6
6
 
7
- ActiveGenie is a Ruby gem that provides a polished, production-ready interface for working with Generative AI (GenAI) models. Just like Lodash or ActiveStorage, ActiveGenie simplifies GenAI integration in your Ruby applications.
8
-
9
- ## Features
10
-
11
- - 🎯 **Data Extraction**: Extract structured data from unstructured text with type validation
12
- - 📊 **Data Scoring**: Multi-reviewer evaluation system
13
- - ⚔️ **Data Battle**: Battle between two data like a political debate
14
- - 💭 **Data Ranking**: Consistent rank data using scoring + elo ranking + battles
7
+ ActiveGenie is a Ruby gem that provides valuable solutions powered by Generative AI (GenAI) models. Just like Lodash or ActiveStorage, ActiveGenie brings a set of Modules reach real value fast and reliable.
8
+ ActiveGenie is backed by a custom benchmarking system that ensures consistent quality and performance across different models and providers in every release.
15
9
 
16
10
  ## Installation
17
11
 
@@ -41,6 +35,7 @@ end
41
35
  ## Quick Start
42
36
 
43
37
  ### Data Extractor
38
+
44
39
  Extract structured data from text using AI-powered analysis, handling informal language and complex expressions.
45
40
 
46
41
  ```ruby
@@ -55,13 +50,17 @@ schema = {
55
50
  minimum: 0
56
51
  },
57
52
  size: {
58
- type: 'integer',
53
+ type: 'number',
59
54
  minimum: 35,
60
55
  maximum: 46
61
56
  }
62
57
  }
63
58
 
64
- result = ActiveGenie::DataExtractor.call(text, schema)
59
+ result = ActiveGenie::DataExtractor.call(
60
+ text,
61
+ schema,
62
+ config: { provider: :openai, model: 'gpt-4o-mini' } # optional
63
+ )
65
64
  # => {
66
65
  # brand: "Nike",
67
66
  # brand_explanation: "Brand name found at start of text",
@@ -72,6 +71,8 @@ result = ActiveGenie::DataExtractor.call(text, schema)
72
71
  # }
73
72
  ```
74
73
 
74
+ *Recommended model*: `gpt-4o-mini`
75
+
75
76
  Features:
76
77
  - Structured data extraction with type validation
77
78
  - Schema-based extraction with custom constraints
@@ -80,14 +81,18 @@ Features:
80
81
 
81
82
  See the [Data Extractor README](lib/active_genie/data_extractor/README.md) for informal text processing, advanced schemas, and detailed interface documentation.
82
83
 
83
- ### Data Scoring
84
+ ### Scoring
84
85
  Text evaluation system that provides detailed scoring and feedback using multiple expert reviewers. Get balanced scoring through AI-powered expert reviewers that automatically adapt to your content.
85
86
 
86
87
  ```ruby
87
88
  text = "The code implements a binary search algorithm with O(log n) complexity"
88
89
  criteria = "Evaluate technical accuracy and clarity"
89
90
 
90
- result = ActiveGenie::Scoring.basic(text, criteria)
91
+ result = ActiveGenie::Scoring.basic(
92
+ text,
93
+ criteria,
94
+ config: { provider: :anthropic, model: 'claude-3-5-haiku-20241022' } # optional
95
+ )
91
96
  # => {
92
97
  # algorithm_expert_score: 95,
93
98
  # algorithm_expert_reasoning: "Accurately describes binary search and its complexity",
@@ -97,6 +102,8 @@ result = ActiveGenie::Scoring.basic(text, criteria)
97
102
  # }
98
103
  ```
99
104
 
105
+ *Recommended model*: `claude-3-5-haiku-20241022`
106
+
100
107
  Features:
101
108
  - Multi-reviewer evaluation with automatic expert selection
102
109
  - Detailed feedback with scoring reasoning
@@ -105,26 +112,33 @@ Features:
105
112
 
106
113
  See the [Scoring README](lib/active_genie/scoring/README.md) for advanced usage, custom reviewers, and detailed interface documentation.
107
114
 
108
- ### Data Battle
115
+ ### Battle
109
116
  AI-powered battle evaluation system that determines winners between two players based on specified criteria.
110
117
 
111
118
  ```ruby
112
119
  require 'active_genie'
113
120
 
114
- player_a = "Implementation uses dependency injection for better testability"
115
- player_b = "Code has high test coverage but tightly coupled components"
121
+ player_1 = "Implementation uses dependency injection for better testability"
122
+ player_2 = "Code has high test coverage but tightly coupled components"
116
123
  criteria = "Evaluate code quality and maintainability"
117
124
 
118
- result = ActiveGenie::Battle.call(player_a, player_b, criteria)
125
+ result = ActiveGenie::Battle.call(
126
+ player_1,
127
+ player_2,
128
+ criteria,
129
+ config: { provider: :google, model: 'gemini-2.0-flash-lite' } # optional
130
+ )
119
131
  # => {
120
132
  # winner_player: "Implementation uses dependency injection for better testability",
121
- # reasoning: "Player A's implementation demonstrates better maintainability through dependency injection,
122
- # which allows for easier testing and component replacement. While Player B has good test coverage,
133
+ # reasoning: "Player 1 implementation demonstrates better maintainability through dependency injection,
134
+ # which allows for easier testing and component replacement. While Player 2 has good test coverage,
123
135
  # the tight coupling makes the code harder to maintain and modify.",
124
136
  # what_could_be_changed_to_avoid_draw: "Focus on specific architectural patterns and design principles"
125
137
  # }
126
138
  ```
127
139
 
140
+ *Recommended model*: `gemini-2.0-flash-lite`
141
+
128
142
  Features:
129
143
  - Multi-reviewer evaluation with automatic expert selection
130
144
  - Detailed feedback with scoring reasoning
@@ -133,23 +147,28 @@ Features:
133
147
 
134
148
  See the [Battle README](lib/active_genie/battle/README.md) for advanced usage, custom reviewers, and detailed interface documentation.
135
149
 
136
- ### Data Ranking
150
+ ### Ranking
137
151
  The Ranking module provides competitive ranking through multi-stage evaluation:
138
152
 
139
-
140
153
  ```ruby
141
154
  require 'active_genie'
142
155
 
143
156
  players = ['REST API', 'GraphQL API', 'SOAP API', 'gRPC API', 'Websocket API']
144
157
  criteria = "Best one to be used into a high changing environment"
145
158
 
146
- result = ActiveGenie::Ranking.call(players, criteria)
159
+ result = ActiveGenie::Ranking.call(
160
+ players,
161
+ criteria,
162
+ config: { provider: :google, model: 'gemini-2.0-flash-lite' } # optional
163
+ )
147
164
  # => {
148
165
  # winner_player: "gRPC API",
149
166
  # reasoning: "gRPC API is the best one to be used into a high changing environment",
150
167
  # }
151
168
  ```
152
169
 
170
+ *Recommended model*: `gemini-2.0-flash-lite`
171
+
153
172
  - **Multi-phase ranking system** combining expert scoring and ELO algorithms
154
173
  - **Automatic elimination** of inconsistent performers using statistical analysis
155
174
  - **Dynamic ranking adjustments** based on simulated pairwise battles, from bottom to top
@@ -157,10 +176,34 @@ result = ActiveGenie::Ranking.call(players, criteria)
157
176
  See the [Ranking README](lib/active_genie/ranking/README.md) for implementation details, configuration, and advanced ranking strategies.
158
177
 
159
178
  ### Text Summarizer (Future)
179
+ ### Categorizer (Future)
160
180
  ### Language detector (Future)
161
181
  ### Translator (Future)
162
182
  ### Sentiment analyzer (Future)
163
183
 
184
+ ## Benchmarking 🧪
185
+
186
+ ActiveGenie includes a comprehensive benchmarking system to ensure consistent, high-quality outputs across different LLM models and providers.
187
+
188
+ ```ruby
189
+ # Run all benchmarks
190
+ bundle exec rake active_genie:benchmark
191
+
192
+ # Run benchmarks for a specific module
193
+ bundle exec rake active_genie:benchmark[data_extractor]
194
+ ```
195
+
196
+ ### Latest Results
197
+
198
+ | Model | Overall Precision |
199
+ |-------|-------------------|
200
+ | claude-3-5-haiku-20241022 | 92.25% |
201
+ | gemini-2.0-flash-lite | 84.25% |
202
+ | gpt-4o-mini | 62.75% |
203
+ | deepseek-chat | 57.25% |
204
+
205
+ See the [Benchmark README](benchmark/README.md) for detailed results, methodology, and how to contribute to our test suite.
206
+
164
207
  ## Configuration
165
208
 
166
209
  | Config | Description | Default |
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.0.12
1
+ 0.0.18
@@ -12,11 +12,11 @@ AI-powered battle evaluation system that determines winners between two players
12
12
  Evaluate a battle between two players with simple text content:
13
13
 
14
14
  ```ruby
15
- player_a = "Implementation uses dependency injection for better testability"
16
- player_b = "Code has high test coverage but tightly coupled components"
15
+ player_1 = "Implementation uses dependency injection for better testability"
16
+ player_2 = "Code has high test coverage but tightly coupled components"
17
17
  criteria = "Evaluate code quality and maintainability"
18
18
 
19
- result = ActiveGenie::Battle::Basic.call(player_a, player_b, criteria)
19
+ result = ActiveGenie::Battle::Basic.call(player_1, player_2, criteria)
20
20
  # => {
21
21
  # winner_player: "Implementation uses dependency injection for better testability",
22
22
  # reasoning: "Player A's implementation demonstrates better maintainability through dependency injection,
@@ -27,13 +27,13 @@ result = ActiveGenie::Battle::Basic.call(player_a, player_b, criteria)
27
27
  ```
28
28
 
29
29
  ## Interface
30
- ### Basic.call(player_a, player_b, criteria, config: {})
31
- - `player_a` [String, Hash] - The content or submission from the first player
32
- - `player_b` [String, Hash] - The content or submission from the second player
30
+ ### Basic.call(player_1, player_2, criteria, config: {})
31
+ - `player_1` [String, Hash] - The content or submission from the first player
32
+ - `player_2` [String, Hash] - The content or submission from the second player
33
33
  - `criteria` [String] - The evaluation criteria or rules to assess against
34
34
  - `config` [Hash] - Additional configuration config that modify the battle evaluation behavior
35
35
 
36
36
  Returns a Hash containing:
37
- - `winner_player` [String, Hash] - The winning player's content (either player_a or player_b)
37
+ - `winner_player` [String, Hash] - The winning player's content (either player_1 or player_2)
38
38
  - `reasoning` [String] - Detailed explanation of why the winner was chosen
39
39
  - `what_could_be_changed_to_avoid_draw` [String] - A suggestion on how to avoid a draw
@@ -18,17 +18,17 @@ module ActiveGenie::Battle
18
18
  new(...).call
19
19
  end
20
20
 
21
- # @param player_a [String] The content or submission from the first player
22
- # @param player_b [String] The content or submission from the second player
21
+ # @param player_1 [String] The content or submission from the first player
22
+ # @param player_2 [String] The content or submission from the second player
23
23
  # @param criteria [String] The evaluation criteria or rules to assess against
24
- # @param config [Hash] Additional configuration config that modify the battle evaluation behavior
24
+ # @param config [Hash] Additional configuration options that modify the battle evaluation behavior
25
25
  # @return [Hash] The evaluation result containing the winner and reasoning
26
- # @return [String] :winner The @param player_a or player_b
26
+ # @return [String] :winner The winner, either player_1 or player_2
27
27
  # @return [String] :reasoning Detailed explanation of why the winner was chosen
28
28
  # @return [String] :what_could_be_changed_to_avoid_draw A suggestion on how to avoid a draw
29
- def initialize(player_a, player_b, criteria, config: {})
30
- @player_a = player_a
31
- @player_b = player_b
29
+ def initialize(player_1, player_2, criteria, config: {})
30
+ @player_1 = player_1
31
+ @player_2 = player_2
32
32
  @criteria = criteria
33
33
  @config = ActiveGenie::Configuration.to_h(config)
34
34
  end
@@ -37,8 +37,8 @@ module ActiveGenie::Battle
37
37
  messages = [
38
38
  { role: 'system', content: PROMPT },
39
39
  { role: 'user', content: "criteria: #{@criteria}" },
40
- { role: 'user', content: "player_a: #{@player_a}" },
41
- { role: 'user', content: "player_b: #{@player_b}" },
40
+ { role: 'user', content: "player_1: #{@player_1}" },
41
+ { role: 'user', content: "player_2: #{@player_2}" },
42
42
  ]
43
43
 
44
44
  response = ::ActiveGenie::Clients::UnifiedClient.function_calling(
@@ -48,6 +48,15 @@ module ActiveGenie::Battle
48
48
  config: @config
49
49
  )
50
50
 
51
+ ActiveGenie::Logger.debug({
52
+ code: :battle,
53
+ player_1: @player_1[0..30],
54
+ player_2: @player_2[0..30],
55
+ criteria: @criteria[0..30],
56
+ winner: response['impartial_judge_winner'],
57
+ reasoning: response['impartial_judge_winner_reasoning']
58
+ })
59
+
51
60
  response_formatted(response)
52
61
  end
53
62
 
@@ -56,23 +65,22 @@ module ActiveGenie::Battle
56
65
  def response_formatted(response)
57
66
  winner = response['impartial_judge_winner']
58
67
  loser = case response['impartial_judge_winner']
59
- when 'player_a' then 'player_b'
60
- when 'player_b' then 'player_a'
61
- else 'draw'
68
+ when 'player_1' then 'player_2'
69
+ when 'player_2' then 'player_1'
62
70
  end
63
71
 
64
- { winner:, loser:, reasoning: response['impartial_judge_winner_reasoning'] }
72
+ { 'winner' => winner, 'loser' => loser, 'reasoning' => response['impartial_judge_winner_reasoning'] }
65
73
  end
66
74
 
67
75
  PROMPT = <<~PROMPT
68
- Based on two players, player_a and player_b, they will battle against each other based on criteria. Criteria are vital as they provide a clear metric to compare the players. Follow these criteria strictly.
76
+ Based on two players, player_1 and player_2, they will battle against each other based on criteria. Criteria are vital as they provide a clear metric to compare the players. Follow these criteria strictly.
69
77
 
70
78
  # Steps
71
- 1. Player_a sells himself, highlighting his strengths and how he meets the criteria. Max of 100 words.
72
- 2. Player_b sells himself, highlighting his strengths and how he meets the criteria. Max of 100 words.
73
- 3. Player_a argues why he is the winner compared to player_b. Max of 100 words.
74
- 4. Player_b counter-argues why he is the winner compared to player_a. Max of 100 words.
75
- 5. The impartial judge chooses which player as the winner.
79
+ 1. player_1 presents their strengths and how they meet the criteria. Max of 100 words.
80
+ 2. player_2 presents their strengths and how they meet the criteria. Max of 100 words.
81
+ 3. player_1 argues why they should be the winner compared to player_2. Max of 100 words.
82
+ 4. player_2 counter-argues why they should be the winner compared to player_1. Max of 100 words.
83
+ 5. The impartial judge chooses the winner.
76
84
 
77
85
  # Output Format
78
86
  - The impartial judge chooses this player as the winner.
@@ -85,25 +93,25 @@ module ActiveGenie::Battle
85
93
 
86
94
  FUNCTION = {
87
95
  name: 'battle_evaluation',
88
- description: 'Evaluate a battle between player_a and player_b using predefined criteria and identify the winner.',
96
+ description: 'Evaluate a battle between player_1 and player_2 using predefined criteria and identify the winner.',
89
97
  schema: {
90
98
  type: "object",
91
99
  properties: {
92
- player_a_sell_himself: {
100
+ player_1_sell_himself: {
93
101
  type: 'string',
94
- description: 'player_a sell himself, highlighting his strengths and how he meets the criteria. Max of 100 words.',
102
+ description: 'player_1 presents their strengths and how they meet the criteria. Max of 100 words.',
95
103
  },
96
- player_b_sell_himself: {
104
+ player_2_sell_himself: {
97
105
  type: 'string',
98
- description: 'player_b sell himself, highlighting his strengths and how he meets the criteria. Max of 100 words.',
106
+ description: 'player_2 presents their strengths and how they meet the criteria. Max of 100 words.',
99
107
  },
100
- player_a_arguments: {
108
+ player_1_arguments: {
101
109
  type: 'string',
102
- description: 'player_a arguments why he is the winner compared to player_b. Max of 100 words.',
110
+ description: 'player_1 arguments for why they should be the winner compared to player_2. Max of 100 words.',
103
111
  },
104
- player_b_counter: {
112
+ player_2_counter: {
105
113
  type: 'string',
106
- description: 'player_b counter arguments why he is the winner compared to player_a. Max of 100 words.',
114
+ description: 'player_2 counter arguments for why they should be the winner compared to player_1. Max of 100 words.',
107
115
  },
108
116
  impartial_judge_winner_reasoning: {
109
117
  type: 'string',
@@ -111,10 +119,18 @@ module ActiveGenie::Battle
111
119
  },
112
120
  impartial_judge_winner: {
113
121
  type: 'string',
114
- description: 'The impartial judge chose this player as the winner.',
115
- enum: ['player_a', 'player_b', 'draw']
122
+ description: 'Who is the winner based on the impartial judge reasoning?',
123
+ enum: ['player_1', 'player_2']
116
124
  },
117
- }
125
+ },
126
+ required: [
127
+ 'player_1_sell_himself',
128
+ 'player_2_sell_himself',
129
+ 'player_1_arguments',
130
+ 'player_2_counter',
131
+ 'impartial_judge_winner_reasoning',
132
+ 'impartial_judge_winner'
133
+ ]
118
134
  }
119
135
  }
120
136
  end
@@ -9,5 +9,9 @@ module ActiveGenie
9
9
  def basic(...)
10
10
  Basic.call(...)
11
11
  end
12
+
13
+ def call(...)
14
+ Basic.call(...)
15
+ end
12
16
  end
13
17
  end
@@ -0,0 +1,110 @@
1
+ require 'json'
2
+ require 'net/http'
3
+ require 'uri'
4
+ require_relative './helpers/retry'
5
+
6
+ module ActiveGenie
7
+ module Clients
8
+ # Client for interacting with the Anthropic (Claude) API with json response
9
+ class AnthropicClient
10
+ class AnthropicError < StandardError; end
11
+ class RateLimitError < AnthropicError; end
12
+
13
+ def initialize(config)
14
+ @app_config = config
15
+ end
16
+
17
+ # Requests structured JSON output from the Anthropic Claude 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
+ # Claude uses 'user', 'assistant', and 'system' roles.
22
+ # @param function [Hash] A JSON schema definition describing the desired output format.
23
+ # @param model_tier [Symbol, nil] A symbolic representation of the model quality/size tier.
24
+ # @param config [Hash] Optional configuration overrides:
25
+ # - :api_key [String] Override the default API key.
26
+ # - :model [String] Override the model name directly.
27
+ # - :max_retries [Integer] Max retries for the request.
28
+ # - :retry_delay [Integer] Initial delay for retries.
29
+ # - :anthropic_version [String] Override the default Anthropic API version.
30
+ # @return [Hash, nil] The parsed JSON object matching the schema, or nil if parsing fails or content is empty.
31
+ def function_calling(messages, function, model_tier: nil, config: {})
32
+ model = config[:runtime][:model] || @app_config.tier_to_model(model_tier)
33
+
34
+ system_message = messages.find { |m| m[:role] == 'system' }&.dig(:content) || ''
35
+ user_messages = messages.select { |m| m[:role] == 'user' || m[:role] == 'assistant' }
36
+ .map { |m| { role: m[:role], content: m[:content] } }
37
+
38
+ anthropic_function = function
39
+ anthropic_function[:input_schema] = function[:schema]
40
+ anthropic_function.delete(:schema)
41
+
42
+ payload = {
43
+ model:,
44
+ system: system_message,
45
+ messages: user_messages,
46
+ tools: [anthropic_function],
47
+ tool_choice: { name: anthropic_function[:name], type: 'tool' },
48
+ max_tokens: config[:runtime][:max_tokens],
49
+ temperature: config[:runtime][:temperature] || 0,
50
+ }
51
+
52
+ api_key = config[:runtime][:api_key] || @app_config.api_key
53
+ headers = DEFAULT_HEADERS.merge(
54
+ 'x-api-key': api_key,
55
+ 'anthropic-version': config[:anthropic_version] || ANTHROPIC_VERSION
56
+ ).compact
57
+
58
+ retry_with_backoff(config:) do
59
+ response = request(payload, headers, config:)
60
+ content = response.dig('content', 0, 'input')
61
+
62
+ ActiveGenie::Logger.trace({code: :function_calling, payload:, parsed_response: content})
63
+
64
+ content
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ DEFAULT_HEADERS = {
71
+ 'Content-Type': 'application/json',
72
+ }
73
+ ANTHROPIC_VERSION = '2023-06-01'
74
+
75
+ def request(payload, headers, config:)
76
+ start_time = Time.now
77
+
78
+ retry_with_backoff(config:) do
79
+ response = Net::HTTP.post(
80
+ URI("#{@app_config.api_url}/v1/messages"),
81
+ payload.to_json,
82
+ headers
83
+ )
84
+
85
+ if response.is_a?(Net::HTTPTooManyRequests)
86
+ raise RateLimitError, "Anthropic API rate limit exceeded: #{response.body}"
87
+ end
88
+
89
+ raise AnthropicError, response.body unless response.is_a?(Net::HTTPSuccess)
90
+
91
+ return nil if response.body.empty?
92
+
93
+ parsed_body = JSON.parse(response.body)
94
+
95
+ ActiveGenie::Logger.trace({
96
+ code: :llm_usage,
97
+ input_tokens: parsed_body.dig('usage', 'input_tokens'),
98
+ output_tokens: parsed_body.dig('usage', 'output_tokens'),
99
+ total_tokens: parsed_body.dig('usage', 'input_tokens') + parsed_body.dig('usage', 'output_tokens'),
100
+ model: payload[:model],
101
+ duration: Time.now - start_time,
102
+ usage: parsed_body.dig('usage')
103
+ })
104
+
105
+ parsed_body
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,158 @@
1
+ require 'json'
2
+ require 'net/http'
3
+ require 'uri'
4
+ require_relative './helpers/retry'
5
+
6
+ module ActiveGenie
7
+ module Clients
8
+ # Client for interacting with the Google Generative Language API.
9
+ class GoogleClient
10
+ class GoogleError < StandardError; end
11
+ class RateLimitError < GoogleError; end
12
+
13
+ API_VERSION_PATH = '/v1beta/models'.freeze
14
+ DEFAULT_HEADERS = {
15
+ 'Content-Type': 'application/json',
16
+ }.freeze
17
+
18
+ def initialize(config)
19
+ @app_config = config
20
+ end
21
+
22
+ # Requests structured JSON output from the Google Generative Language model based on a schema.
23
+ #
24
+ # @param messages [Array<Hash>] A list of messages representing the conversation history.
25
+ # Each hash should have :role ('user' or 'model') and :content (String).
26
+ # Google Generative Language uses 'user' and 'model' roles.
27
+ # @param function [Hash] A JSON schema definition describing the desired output format.
28
+ # @param model_tier [Symbol, nil] A symbolic representation of the model quality/size tier.
29
+ # @param config [Hash] Optional configuration overrides:
30
+ # - :api_key [String] Override the default API key.
31
+ # - :model [String] Override the model name directly.
32
+ # - :max_retries [Integer] Max retries for the request.
33
+ # - :retry_delay [Integer] Initial delay for retries.
34
+ # @return [Hash, nil] The parsed JSON object matching the schema, or nil if parsing fails or content is empty.
35
+ def function_calling(messages, function, model_tier: nil, config: {})
36
+ model = config[:runtime][:model] || @app_config.tier_to_model(model_tier)
37
+ api_key = config[:runtime][:api_key] || @app_config.api_key
38
+
39
+ contents = convert_messages_to_contents(messages, function)
40
+ contents << output_as_json_schema(function)
41
+
42
+ payload = {
43
+ contents: contents,
44
+ generationConfig: {
45
+ response_mime_type: "application/json",
46
+ temperature: 0.1
47
+ }
48
+ }
49
+
50
+ url = URI("#{@app_config.api_url}#{API_VERSION_PATH}/#{model}:generateContent?key=#{api_key}")
51
+
52
+ retry_with_backoff(config:) do
53
+ response = request(url, payload, model, config:)
54
+
55
+ json_string = response&.dig('candidates', 0, 'content', 'parts', 0, 'text')
56
+
57
+ return nil if json_string.nil? || json_string.empty?
58
+
59
+ parsed_response = JSON.parse(json_string)
60
+
61
+ ActiveGenie::Logger.trace({ code: :function_calling, payload:, parsed_response: })
62
+
63
+ normalize_function_output(parsed_response)
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def normalize_function_output(output)
70
+ output = if output.is_a?(Array)
71
+ output.dig(0, 'properties') || output.dig(0)
72
+ else
73
+ output
74
+ end
75
+
76
+ output.dig('input_schema', 'properties') || output
77
+ end
78
+
79
+ def request(url, payload, model, config:)
80
+ start_time = Time.now
81
+
82
+ retry_with_backoff(config:) do
83
+ response = Net::HTTP.post(url, payload.to_json, DEFAULT_HEADERS)
84
+
85
+ case response
86
+ when Net::HTTPSuccess
87
+ return nil if response.body.nil? || response.body.empty?
88
+
89
+ parsed_body = JSON.parse(response.body)
90
+
91
+ usage_metadata = parsed_body['usageMetadata'] || {}
92
+ prompt_tokens = usage_metadata['promptTokenCount'] || 0
93
+ candidates_tokens = usage_metadata['candidatesTokenCount'] || 0
94
+ total_tokens = usage_metadata['totalTokenCount'] || (prompt_tokens + candidates_tokens)
95
+
96
+ ActiveGenie::Logger.trace({
97
+ code: :llm_usage,
98
+ input_tokens: prompt_tokens,
99
+ output_tokens: candidates_tokens,
100
+ total_tokens: total_tokens,
101
+ model: model,
102
+ duration: Time.now - start_time,
103
+ usage: usage_metadata # Log the whole usage block
104
+ })
105
+
106
+ parsed_body
107
+
108
+ when Net::HTTPTooManyRequests
109
+ # Rate Limit Error
110
+ raise RateLimitError, "Google API rate limit exceeded (HTTP 429): #{response.body}"
111
+
112
+ else
113
+ # Other Errors
114
+ raise GoogleError, "Google API error (HTTP #{response.code}): #{response.body}"
115
+ end
116
+ end
117
+ rescue JSON::ParserError => e
118
+ raise GoogleError, "Failed to parse Google API response body: #{e.message} - Body: #{response&.body}"
119
+ end
120
+
121
+ ROLE_TO_GOOGLE_ROLE = {
122
+ user: 'user',
123
+ assistant: 'model',
124
+ }.freeze
125
+
126
+ # Converts standard message format to Google's 'contents' format
127
+ # and injects JSON schema instructions.
128
+ # @param messages [Array<Hash>] Array of { role: 'user'/'assistant'/'system', content: '...' }
129
+ # @param function_schema [Hash] The JSON schema for the desired output.
130
+ # @return [Array<Hash>] Array formatted for Google's 'contents' field.
131
+ def convert_messages_to_contents(messages, function_schema)
132
+ messages.map do |message|
133
+ {
134
+ role: ROLE_TO_GOOGLE_ROLE[message[:role].to_sym] || 'user',
135
+ parts: [{ text: message[:content] }]
136
+ }
137
+ end
138
+ end
139
+
140
+ def output_as_json_schema(function_schema)
141
+ json_instruction = <<~PROMPT
142
+ Generate a JSON object that strictly adheres to the following JSON schema:
143
+
144
+ ```json
145
+ #{JSON.pretty_generate(function_schema)}
146
+ ```
147
+
148
+ IMPORTANT: Only output the raw JSON object. Do not include any other text, explanations, or markdown formatting like ```json ... ``` wrappers around the final output.
149
+ PROMPT
150
+
151
+ {
152
+ role: 'user',
153
+ parts: [{ text: json_instruction }]
154
+ }
155
+ end
156
+ end
157
+ end
158
+ end