llm_conductor 1.6.0 → 1.7.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 80aca14904612848b82ee2f4e08d05d3b3a0cc4a44e6d754d9f785905aa8c447
4
- data.tar.gz: c4496c6b595bb737583ab72a861942df62c06f3e2200e061afd07ccf08a35bde
3
+ metadata.gz: a132b52551949cd8cd7446e2cf9a26f1dacba997dbae0965494b4ee174cf5905
4
+ data.tar.gz: 89d87446edea2d31c7194f84e1c98cbb11d241a0a1cd57b2878b387c94b441a5
5
5
  SHA512:
6
- metadata.gz: f1d6e0b0d0c185c28dba1d7c26e94aceee1ddfd3f5278cea73d03eeb719c12b79cc3319e214ee8e1e5e99c861f98fce5e39b1c3c041c1ee49ea33accc2e15c36
7
- data.tar.gz: 4ea5a87ba4c4870756153e71ab92fdcb935d804a915fc49fac96f40291c2d6d3e1730e7a4acea8fb8528ea816a9675f5935cb93deb6b32706e80e36659bfb23d
6
+ metadata.gz: 21204a00bc9d437fa702dc67f6224c8ef325e32daa94b252421fdff430929a03ca9903634df739b72d11f4b689320d7bfb1b626e800443aa90d0393ab5d11b01
7
+ data.tar.gz: 490ded3cf0e013a0edaed1bb66b4c91750a6503419c37e58b8fd4e5d53db464ff0785a4de4a76c8b3b87b787684fa327d78a7c8c872dc30608a0225de829c59d
data/.rubocop.yml CHANGED
@@ -30,7 +30,7 @@ Lint/ConstantDefinitionInBlock:
30
30
  Enabled: false
31
31
 
32
32
  Metrics/ClassLength:
33
- Max: 120
33
+ Max: 125
34
34
 
35
35
  Metrics/MethodLength:
36
36
  Max: 15
@@ -38,6 +38,8 @@ Metrics/MethodLength:
38
38
  - 'lib/llm_conductor/prompts.rb'
39
39
  - 'lib/llm_conductor/clients/openrouter_client.rb'
40
40
  - 'lib/llm_conductor/clients/zai_client.rb'
41
+ - 'lib/llm_conductor/client_factory.rb'
42
+ - 'examples/*.rb'
41
43
 
42
44
  RSpec/ExampleLength:
43
45
  Enabled: false
@@ -96,6 +98,12 @@ Metrics/AbcSize:
96
98
  - 'lib/llm_conductor/prompts.rb'
97
99
  - 'lib/llm_conductor/clients/openrouter_client.rb'
98
100
  - 'lib/llm_conductor/clients/zai_client.rb'
101
+ - 'examples/*.rb'
102
+
103
+ Metrics/ParameterLists:
104
+ Exclude:
105
+ - 'lib/llm_conductor.rb'
106
+ - 'lib/llm_conductor/configuration.rb'
99
107
 
100
108
  Metrics/CyclomaticComplexity:
101
109
  Exclude:
@@ -103,6 +111,7 @@ Metrics/CyclomaticComplexity:
103
111
  - 'lib/llm_conductor/prompts.rb'
104
112
  - 'lib/llm_conductor/clients/openrouter_client.rb'
105
113
  - 'lib/llm_conductor/clients/zai_client.rb'
114
+ - 'examples/*.rb'
106
115
 
107
116
  Metrics/PerceivedComplexity:
108
117
  Exclude:
@@ -1,18 +1,90 @@
1
+ #!/usr/bin/env ruby
1
2
  # frozen_string_literal: true
2
3
 
4
+ # Connectivity test for all Gemini auth methods.
5
+ # Omit SCENARIO to run all scenarios that have the required env vars set.
6
+ #
7
+ # Scenario A — Generative Language API (api_key)
8
+ # SCENARIO=api_key GEMINI_API_KEY=... ruby examples/gemini_usage.rb
9
+ #
10
+ # Scenario B — Vertex AI with account-bound API key
11
+ # SCENARIO=vertex_api_key GEMINI_API_KEY=... GOOGLE_VERTEX_PROJECT_ID=... ruby examples/gemini_usage.rb
12
+ #
13
+ # Scenario C — Vertex AI via Application Default Credentials
14
+ # SCENARIO=adc \
15
+ # GOOGLE_VERTEX_PROJECT_ID=... \
16
+ # GOOGLE_APPLICATION_CREDENTIALS=/path/to/sa.json \
17
+ # ruby examples/gemini_usage.rb
18
+ #
19
+ # Scenario C — Vertex AI via credentials file contents
20
+ # SCENARIO=file_contents \
21
+ # GOOGLE_VERTEX_PROJECT_ID=... \
22
+ # GOOGLE_CREDENTIALS_FILE_CONTENTS=$(cat /path/to/sa.json) \
23
+ # ruby examples/gemini_usage.rb
24
+
3
25
  require_relative '../lib/llm_conductor'
4
26
 
5
- # Configure Gemini API key
6
- LlmConductor.configure do |config|
7
- config.gemini(api_key: ENV['GEMINI_API_KEY'] || 'your_gemini_api_key_here')
27
+ MODEL = 'gemini-2.5-flash'
28
+ PROMPT = 'Say hello.'
29
+
30
+ SCENARIOS = {
31
+ 'api_key' => -> { ENV['GEMINI_API_KEY'] && !ENV['GOOGLE_VERTEX_PROJECT_ID'] },
32
+ 'vertex_api_key' => -> { ENV['GEMINI_API_KEY'] && ENV['GOOGLE_VERTEX_PROJECT_ID'] },
33
+ 'adc' => -> { ENV['GOOGLE_VERTEX_PROJECT_ID'] && ENV['GOOGLE_APPLICATION_CREDENTIALS'] },
34
+ 'file_contents' => -> { ENV['GOOGLE_VERTEX_PROJECT_ID'] && ENV['GOOGLE_CREDENTIALS_FILE_CONTENTS'] }
35
+ }.freeze
36
+
37
+ def run_scenario(name)
38
+ case name
39
+ when 'api_key'
40
+ LlmConductor.configure { |c| c.gemini(api_key: ENV.fetch('GEMINI_API_KEY')) }
41
+ label = 'api_key'
42
+
43
+ when 'vertex_api_key'
44
+ LlmConductor.configure do |c|
45
+ c.gemini(api_key: ENV.fetch('GEMINI_API_KEY'),
46
+ project_id: ENV.fetch('GOOGLE_VERTEX_PROJECT_ID'),
47
+ region: ENV['GOOGLE_VERTEX_REGION'])
48
+ end
49
+ label = 'vertex_ai/api_key'
50
+
51
+ when 'adc'
52
+ LlmConductor.configure do |c|
53
+ c.gemini(project_id: ENV.fetch('GOOGLE_VERTEX_PROJECT_ID'),
54
+ region: ENV['GOOGLE_VERTEX_REGION'])
55
+ end
56
+ label = 'vertex_ai/adc'
57
+
58
+ when 'file_contents'
59
+ LlmConductor.configure do |c|
60
+ c.gemini(project_id: ENV.fetch('GOOGLE_VERTEX_PROJECT_ID'),
61
+ region: ENV['GOOGLE_VERTEX_REGION'],
62
+ file_contents: ENV.fetch('GOOGLE_CREDENTIALS_FILE_CONTENTS'))
63
+ end
64
+ label = 'vertex_ai/file_contents'
65
+ end
66
+
67
+ response = LlmConductor.generate(model: MODEL, prompt: PROMPT)
68
+ raise 'Empty response' if response.output.nil? || response.output.strip.empty?
69
+
70
+ puts "[#{label}] OK — #{response.output.strip}"
71
+ rescue StandardError => e
72
+ puts "[#{label}] FAILED — #{e.message}"
73
+ false
8
74
  end
9
75
 
10
- # Example usage
11
- response = LlmConductor.generate(
12
- model: 'gemini-2.5-flash',
13
- prompt: 'Explain how AI works in a few words'
14
- )
76
+ scenario = ENV['SCENARIO']
15
77
 
16
- puts "Model: #{response.model}"
17
- puts "Output: #{response.output}"
18
- puts "Vendor: #{response.metadata[:vendor]}"
78
+ if scenario
79
+ abort "Unknown SCENARIO=#{scenario}. Use: api_key | vertex_api_key | adc | file_contents" unless SCENARIOS.key?(scenario)
80
+ run_scenario(scenario)
81
+ else
82
+ ran = 0
83
+ SCENARIOS.each do |name, available|
84
+ next unless available.call
85
+
86
+ run_scenario(name)
87
+ ran += 1
88
+ end
89
+ puts '(no scenarios ran — set the required env vars)' if ran.zero?
90
+ end
@@ -19,6 +19,8 @@ module LlmConductor
19
19
  ollama: Clients::OllamaClient,
20
20
  gemini: Clients::GeminiClient,
21
21
  google: Clients::GeminiClient,
22
+ vertex_ai: Clients::GeminiClient,
23
+ vertex: Clients::GeminiClient,
22
24
  groq: Clients::GroqClient,
23
25
  zai: Clients::ZaiClient
24
26
  }
@@ -5,6 +5,7 @@ require 'base64'
5
5
  require 'net/http'
6
6
  require 'uri'
7
7
  require_relative 'concerns/vision_support'
8
+ require_relative '../patches/gemini_vertex_api_key'
8
9
 
9
10
  module LlmConductor
10
11
  module Clients
@@ -32,7 +33,7 @@ module LlmConductor
32
33
 
33
34
  payload = {
34
35
  contents: [
35
- { parts: }
36
+ { role: 'user', parts: }
36
37
  ]
37
38
  }
38
39
 
@@ -156,14 +157,33 @@ module LlmConductor
156
157
  @client ||= begin
157
158
  config = LlmConductor.configuration.provider_config(:gemini)
158
159
  Gemini.new(
159
- credentials: {
160
- service: 'generative-language-api',
161
- api_key: config[:api_key]
162
- },
160
+ credentials: build_credentials(config),
163
161
  options: { model: }
164
162
  )
165
163
  end
166
164
  end
165
+
166
+ def build_credentials(config)
167
+ if config[:project_id] && config[:api_key]
168
+ { service: 'vertex-ai-api', region: config[:region], project_id: config[:project_id],
169
+ api_key: config[:api_key] }
170
+ elsif config[:project_id]
171
+ vertex_ai_credentials(config)
172
+ else
173
+ { service: 'generative-language-api', api_key: config[:api_key] }
174
+ end
175
+ end
176
+
177
+ def vertex_ai_credentials(config)
178
+ creds = {
179
+ service: 'vertex-ai-api',
180
+ region: config[:region],
181
+ project_id: config[:project_id]
182
+ }
183
+ creds[:file_path] = config[:file_path] if config[:file_path]
184
+ creds[:file_contents] = config[:file_contents] if config[:file_contents]
185
+ creds
186
+ end
167
187
  end
168
188
  end
169
189
  end
@@ -56,12 +56,23 @@ module LlmConductor
56
56
  }
57
57
  end
58
58
 
59
- # Configure Google Gemini provider
60
- def gemini(api_key: nil, **options)
59
+ # Configure Google Gemini provider (Generative Language API or Vertex AI)
60
+ #
61
+ # For the standard Generative Language API, provide api_key.
62
+ # For Vertex AI, provide project_id and optionally region (defaults to 'global').
63
+ # Authentication falls back to Application Default Credentials
64
+ # (ADC / GOOGLE_APPLICATION_CREDENTIALS) when neither file_path nor file_contents is supplied.
65
+ # Env vars (GEMINI_API_KEY, GOOGLE_VERTEX_PROJECT_ID, etc.) are only applied automatically
66
+ # on boot via setup_defaults_from_env — explicit calls use only what is passed.
67
+ def gemini(api_key: nil, project_id: nil, region: nil, file_path: nil, file_contents: nil, **options)
61
68
  @providers[:gemini] = {
62
- api_key: api_key || ENV['GEMINI_API_KEY'],
69
+ api_key:,
70
+ project_id:,
71
+ region: region || 'global',
72
+ file_path:,
73
+ file_contents:,
63
74
  **options
64
- }
75
+ }.compact
65
76
  end
66
77
 
67
78
  # Configure Groq provider
@@ -149,11 +160,22 @@ module LlmConductor
149
160
  anthropic if ENV['ANTHROPIC_API_KEY']
150
161
  openai if ENV['OPENAI_API_KEY']
151
162
  openrouter if ENV['OPENROUTER_API_KEY']
152
- gemini if ENV['GEMINI_API_KEY']
163
+ setup_gemini_from_env
153
164
  groq if ENV['GROQ_API_KEY']
154
165
  zai if ENV['ZAI_API_KEY']
155
166
  ollama # Always configure Ollama with default URL
156
167
  end
168
+
169
+ def setup_gemini_from_env
170
+ return unless ENV.values_at('GEMINI_API_KEY', 'GOOGLE_VERTEX_PROJECT_ID').any?
171
+
172
+ gemini(
173
+ api_key: ENV['GEMINI_API_KEY'],
174
+ project_id: ENV['GOOGLE_VERTEX_PROJECT_ID'],
175
+ region: ENV['GOOGLE_VERTEX_REGION'],
176
+ file_contents: ENV['GOOGLE_CREDENTIALS_FILE_CONTENTS']
177
+ )
178
+ end
157
179
  end
158
180
 
159
181
  def self.configuration
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Patches the gemini-ai gem for two Vertex AI issues:
4
+ #
5
+ # 1. api_key + Vertex AI: the gem natively supports api_key only for
6
+ # generative-language-api. When both project_id and api_key are present,
7
+ # this patch rebuilds @base_address so the key is appended as ?key=... to
8
+ # the correct Vertex AI endpoint.
9
+ #
10
+ # 2. ADC (Application Default Credentials): the gem calls
11
+ # Google::Auth.get_application_default without a scope, which causes
12
+ # `invalid_scope` when exchanging service-account credentials for a token.
13
+ # This patch re-fetches the authorizer with the required cloud-platform scope.
14
+ module Gemini
15
+ module Controllers
16
+ class Client
17
+ module VertexAiPatch
18
+ CLOUD_PLATFORM_SCOPE = 'https://www.googleapis.com/auth/cloud-platform'
19
+
20
+ def initialize(config)
21
+ super
22
+ fix_vertex_api_key_base_address(config) if @authentication == :api_key && @service == 'vertex-ai-api'
23
+ fix_adc_scope if @authentication == :default_credentials
24
+ end
25
+
26
+ private
27
+
28
+ def fix_vertex_api_key_base_address(config)
29
+ project_id = config.dig(:credentials, :project_id)
30
+
31
+ if project_id.nil?
32
+ raise Errors::MissingProjectIdError,
33
+ 'project_id is required for vertex-ai-api with api_key'
34
+ end
35
+
36
+ region = config.dig(:credentials, :region) || 'global'
37
+ @base_address = if region == 'global'
38
+ "https://aiplatform.googleapis.com/#{@service_version}/projects/#{project_id}/locations/#{region}"
39
+ else
40
+ "https://#{region}-aiplatform.googleapis.com/#{@service_version}/projects/#{project_id}/locations/#{region}"
41
+ end
42
+ end
43
+
44
+ def fix_adc_scope
45
+ @authorizer = ::Google::Auth.get_application_default(CLOUD_PLATFORM_SCOPE)
46
+ end
47
+ end
48
+
49
+ prepend VertexAiPatch
50
+ end
51
+ end
52
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmConductor
4
- VERSION = '1.6.0'
4
+ VERSION = '1.7.0'
5
5
  end
data/lib/llm_conductor.rb CHANGED
@@ -29,7 +29,6 @@ module LlmConductor
29
29
  end
30
30
 
31
31
  # Unified generate method supporting both simple prompts and legacy template-based generation
32
- # rubocop:disable Metrics/ParameterLists
33
32
  def self.generate(model: nil, prompt: nil, type: nil, data: nil, vendor: nil, params: {})
34
33
  if prompt && !type && !data
35
34
  generate_simple_prompt(model:, prompt:, vendor:, params:)
@@ -40,7 +39,6 @@ module LlmConductor
40
39
  "Invalid arguments. Use either: generate(prompt: 'text') or generate(type: :custom, data: {...})"
41
40
  end
42
41
  end
43
- # rubocop:enable Metrics/ParameterLists
44
42
 
45
43
  class << self
46
44
  private
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_conductor
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.0
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben Zheng
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-02-11 00:00:00.000000000 Z
10
+ date: 2026-05-15 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activesupport
@@ -181,6 +181,7 @@ files:
181
181
  - lib/llm_conductor/clients/zai_client.rb
182
182
  - lib/llm_conductor/configuration.rb
183
183
  - lib/llm_conductor/data_builder.rb
184
+ - lib/llm_conductor/patches/gemini_vertex_api_key.rb
184
185
  - lib/llm_conductor/prompt_manager.rb
185
186
  - lib/llm_conductor/prompts.rb
186
187
  - lib/llm_conductor/prompts/base_prompt.rb