Skip to content

Commit fe3db1c

Browse files
authored
Add Gemini provider support (#324)
* Add Gemini provider via OpenAI-compatible API Enables using Google's Gemini models through the OpenAI-compatible API endpoint at generativelanguage.googleapis.com. The provider inherits from OpenAI::ChatProvider, reusing streaming, tool use, and structured output functionality while overriding only Gemini-specific behaviors. Implementation: - GeminiProvider inherits OpenAI::ChatProvider, overrides message_merge_delta to fix Gemini's streaming role duplication (Gemini sends role in every chunk, causing "assistantassistant...") - Gemini::Options handles API key resolution: explicit api_key, access_token alias, then environment variables (GEMINI_API_KEY, GOOGLE_API_KEY in priority order) - Reuses OpenAI::Chat::RequestType — no protocol translation needed as Gemini implements OpenAI-compatible format - organization_id and project_id disabled (not used by Gemini API) - Connection error handling with instrumentation logging Follows the same pattern established by OllamaProvider which also inherits from OpenAI::ChatProvider for OpenAI-compatible endpoints. * Add tests for Gemini provider Comprehensive test coverage for GeminiProvider, Gemini::Options, and streaming lifecycle behaviors. Test coverage (21 tests, 35 assertions): - Provider class (6 tests): service_name, options_klass, prompt_request_type delegation to OpenAI::Chat::RequestType, initialization, inheritance from OpenAI::ChatProvider, client construction - Options (8 tests): api_key validation, GEMINI_API_KEY env resolution, GOOGLE_API_KEY env resolution, GEMINI over GOOGLE precedence, explicit-over-ENV precedence, access_token alias, organization_id returns nil, project_id returns nil - Streaming lifecycle (7 tests): inherits :open event emission from OpenAI::ChatProvider, broadcast_stream_open idempotency, message_merge_delta handles Gemini role duplication correctly, full lifecycle event ordering (open -> update -> close), streaming flag state transitions The streaming tests specifically verify the message_merge_delta override prevents role concatenation when Gemini sends role in every chunk. * Add embedding support for Gemini provider Enables text embedding functionality using Gemini's OpenAI-compatible embeddings endpoint. Implementation: - Add embed_request_type class method returning OpenAI::Embedding::RequestType (Gemini uses same request format as OpenAI) - Add api_embed_execute with connection error handling and instrumentation - Add Gemini::Embedding::RequestType alias in _types.rb Test coverage (1 test, 1 assertion): - embed_request_type returns OpenAI::Embedding::RequestType instance * Replace top-level return with Minitest skip in Gemini tests - Follows Minitest best practices for optional dependency handling - Skipped tests are now visible in test reports (e.g., "3 skips") - Use Minitest's official skip method for conditional test skipping - Use unique constant names per file to avoid conflicts - Change puts to warn for proper stderr output * Rename test to match actual assertion in Gemini provider test The test "client is configured with Gemini base_url" only asserts the client type, not the base_url. Renamed to "client returns OpenAI::Client instance" to accurately reflect what it tests.
1 parent c09c797 commit fe3db1c

8 files changed

Lines changed: 575 additions & 0 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "options"
4+
require_relative "../open_ai/chat/_types"
5+
require_relative "../open_ai/embedding/_types"
6+
7+
module ActiveAgent
8+
module Providers
9+
module Gemini
10+
# Reuse OpenAI Chat request type (same API format)
11+
RequestType = OpenAI::Chat::RequestType
12+
13+
# Reuse OpenAI Embedding types (same API format)
14+
module Embedding
15+
RequestType = OpenAI::Embedding::RequestType
16+
end
17+
end
18+
end
19+
end
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "../open_ai/options"
4+
5+
module ActiveAgent
6+
module Providers
7+
module Gemini
8+
# Configuration options for Gemini provider
9+
#
10+
# Extends OpenAI::Options with Gemini-specific settings including
11+
# the default base URL for Gemini's OpenAI-compatible API endpoint.
12+
#
13+
# @example Basic configuration
14+
# options = Options.new(api_key: 'your-api-key')
15+
#
16+
# @example With environment variable
17+
# # Set GEMINI_API_KEY or GOOGLE_API_KEY
18+
# options = Options.new({})
19+
#
20+
# @see https://ai.google.dev/gemini-api/docs/openai
21+
class Options < ActiveAgent::Providers::OpenAI::Options
22+
GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"
23+
24+
attribute :base_url, :string, fallback: GEMINI_BASE_URL
25+
26+
private
27+
28+
def resolve_api_key(kwargs)
29+
kwargs[:api_key] ||
30+
kwargs[:access_token] ||
31+
ENV["GEMINI_API_KEY"] ||
32+
ENV["GOOGLE_API_KEY"]
33+
end
34+
35+
# Not used as part of Gemini
36+
def resolve_organization_id(kwargs) = nil
37+
def resolve_project_id(kwargs) = nil
38+
end
39+
end
40+
end
41+
end
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
require_relative "_base_provider"
2+
3+
require_gem!(:openai, __FILE__)
4+
5+
require_relative "open_ai_provider"
6+
require_relative "gemini/_types"
7+
8+
module ActiveAgent
9+
module Providers
10+
# Provides access to Google's Gemini API via OpenAI-compatible endpoint.
11+
#
12+
# Extends OpenAI provider to work with Gemini's OpenAI-compatible API,
13+
# enabling access to Gemini models through a familiar interface.
14+
#
15+
# @see OpenAI::ChatProvider
16+
# @see https://ai.google.dev/gemini-api/docs/openai
17+
class GeminiProvider < OpenAI::ChatProvider
18+
# @return [String]
19+
def self.service_name
20+
"Gemini"
21+
end
22+
23+
# @return [Class]
24+
def self.options_klass
25+
namespace::Options
26+
end
27+
28+
# @return [ActiveModel::Type::Value]
29+
def self.prompt_request_type
30+
namespace::RequestType.new
31+
end
32+
33+
# @return [ActiveModel::Type::Value]
34+
def self.embed_request_type
35+
namespace::Embedding::RequestType.new
36+
end
37+
38+
protected
39+
40+
# Executes chat completion request with Gemini-specific error handling.
41+
#
42+
# @see OpenAI::ChatProvider#api_prompt_execute
43+
# @param parameters [Hash]
44+
# @return [Object, nil] response object or nil for streaming
45+
# @raise [OpenAI::Errors::APIConnectionError] when Gemini API unreachable
46+
def api_prompt_execute(parameters)
47+
super
48+
49+
rescue ::OpenAI::Errors::APIConnectionError => exception
50+
log_connection_error(exception)
51+
raise exception
52+
end
53+
54+
# Executes embedding request with Gemini-specific error handling.
55+
#
56+
# @param parameters [Hash]
57+
# @return [Hash] symbolized API response
58+
# @raise [OpenAI::Errors::APIConnectionError] when Gemini API unreachable
59+
def api_embed_execute(parameters)
60+
client.embeddings.create(**parameters).as_json.deep_symbolize_keys
61+
rescue ::OpenAI::Errors::APIConnectionError => exception
62+
log_connection_error(exception)
63+
raise exception
64+
end
65+
66+
# Merges streaming delta into the message with role cleanup.
67+
#
68+
# Overrides parent to handle Gemini's role copying behavior which duplicates
69+
# the role field in every streaming chunk, requiring manual cleanup to prevent
70+
# message corruption.
71+
#
72+
# @see OpenAI::ChatProvider#message_merge_delta
73+
# @param message [Hash]
74+
# @param delta [Hash]
75+
# @return [Hash]
76+
def message_merge_delta(message, delta)
77+
message[:role] = delta.delete(:role) if delta[:role]
78+
79+
hash_merge_delta(message, delta)
80+
end
81+
82+
# Logs connection failures with Gemini API details for debugging.
83+
#
84+
# @param error [Exception]
85+
# @return [void]
86+
def log_connection_error(error)
87+
instrument("connection_error.provider.active_agent",
88+
uri_base: options.base_url,
89+
exception: error.class,
90+
message: error.message)
91+
end
92+
end
93+
end
94+
end
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# frozen_string_literal: true
2+
3+
module Providers
4+
# Example agent using Google's Gemini models.
5+
#
6+
# Demonstrates basic prompt generation with the Gemini provider.
7+
# Configured to use Gemini 2.0 Flash with default instructions.
8+
#
9+
# @example Basic usage
10+
# response = Providers::GeminiAgent.ask(message: "Hello").generate_now
11+
# response.message.content #=> "Hi! How can I help you today?"
12+
# region agent
13+
class GeminiAgent < ApplicationAgent
14+
generate_with :gemini, model: "gemini-2.0-flash"
15+
16+
# @return [ActiveAgent::Generation]
17+
def ask
18+
prompt(message: params[:message])
19+
end
20+
end
21+
# endregion agent
22+
end

test/dummy/config/active_agent.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ mock: &mock
3838
ruby_llm: &ruby_llm
3939
service: "RubyLLM"
4040
# endregion ruby_llm_anchor
41+
# region gemini_anchor
42+
gemini: &gemini
43+
service: "Gemini"
44+
model: "gemini-2.0-flash"
45+
api_key: <%= Rails.application.credentials.dig(:gemini, :api_key) %>
46+
# endregion gemini_anchor
4147
# endregion config_anchors
4248

4349
# region config_development
@@ -72,6 +78,10 @@ development:
7278
ruby_llm:
7379
<<: *ruby_llm
7480
# endregion ruby_llm_dev_config
81+
# region gemini_dev_config
82+
gemini:
83+
<<: *gemini
84+
# endregion gemini_dev_config
7585
# endregion config_development
7686

7787
# region config_test
@@ -92,4 +102,6 @@ test:
92102
<<: *mock
93103
ruby_llm:
94104
<<: *ruby_llm
105+
gemini:
106+
<<: *gemini
95107
# endregion config_test
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
GEMINI_PROVIDER_OPENAI_AVAILABLE = begin
6+
require "openai"
7+
true
8+
rescue LoadError
9+
warn "OpenAI gem not available, skipping Gemini provider tests"
10+
false
11+
end
12+
13+
require_relative "../../../lib/active_agent/providers/gemini_provider" if GEMINI_PROVIDER_OPENAI_AVAILABLE
14+
15+
class GeminiProviderTest < ActiveSupport::TestCase
16+
setup do
17+
skip "OpenAI gem not available" unless GEMINI_PROVIDER_OPENAI_AVAILABLE
18+
@valid_config = {
19+
service: "Gemini",
20+
api_key: "test-api-key",
21+
messages: [ { role: "user", content: "Hello" } ]
22+
}
23+
end
24+
25+
test "service_name returns Gemini" do
26+
assert_equal "Gemini", ActiveAgent::Providers::GeminiProvider.service_name
27+
end
28+
29+
test "options_klass returns Gemini::Options" do
30+
assert_equal(
31+
ActiveAgent::Providers::Gemini::Options,
32+
ActiveAgent::Providers::GeminiProvider.options_klass
33+
)
34+
end
35+
36+
test "prompt_request_type returns Gemini::RequestType" do
37+
request_type = ActiveAgent::Providers::GeminiProvider.prompt_request_type
38+
39+
# Gemini::RequestType is aliased to OpenAI::Chat::RequestType
40+
assert_instance_of ActiveAgent::Providers::OpenAI::Chat::RequestType, request_type
41+
end
42+
43+
test "embed_request_type returns OpenAI::Embedding::RequestType" do
44+
request_type = ActiveAgent::Providers::GeminiProvider.embed_request_type
45+
46+
# Gemini::Embedding::RequestType is aliased to OpenAI::Embedding::RequestType
47+
assert_instance_of ActiveAgent::Providers::OpenAI::Embedding::RequestType, request_type
48+
end
49+
50+
test "initializes provider with valid configuration" do
51+
provider = ActiveAgent::Providers::GeminiProvider.new(@valid_config)
52+
53+
assert_instance_of ActiveAgent::Providers::GeminiProvider, provider
54+
end
55+
56+
test "inherits from OpenAI::ChatProvider" do
57+
assert ActiveAgent::Providers::GeminiProvider < ActiveAgent::Providers::OpenAI::ChatProvider
58+
end
59+
60+
test "client returns OpenAI::Client instance" do
61+
provider = ActiveAgent::Providers::GeminiProvider.new(@valid_config)
62+
client = provider.client
63+
64+
assert_kind_of ::OpenAI::Client, client
65+
end
66+
end
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
GEMINI_OPTIONS_OPENAI_AVAILABLE = begin
6+
require "openai"
7+
true
8+
rescue LoadError
9+
warn "OpenAI gem not available, skipping Gemini options tests"
10+
false
11+
end
12+
13+
require_relative "../../../lib/active_agent/providers/gemini_provider" if GEMINI_OPTIONS_OPENAI_AVAILABLE
14+
15+
class GeminiOptionsTest < ActiveSupport::TestCase
16+
setup do
17+
skip "OpenAI gem not available" unless GEMINI_OPTIONS_OPENAI_AVAILABLE
18+
@valid_options = {
19+
api_key: "test-api-key"
20+
}
21+
end
22+
23+
test "validates presence of api_key" do
24+
original_keys = [
25+
ENV["GEMINI_API_KEY"],
26+
ENV["GOOGLE_API_KEY"]
27+
]
28+
ENV.delete("GEMINI_API_KEY")
29+
ENV.delete("GOOGLE_API_KEY")
30+
31+
options = ActiveAgent::Providers::Gemini::Options.new({})
32+
33+
assert_not options.valid?
34+
assert_includes options.errors[:api_key], "can't be blank"
35+
ensure
36+
ENV["GEMINI_API_KEY"] = original_keys[0]
37+
ENV["GOOGLE_API_KEY"] = original_keys[1]
38+
end
39+
40+
test "resolves api_key from GEMINI_API_KEY environment variable" do
41+
original_keys = [
42+
ENV["GEMINI_API_KEY"],
43+
ENV["GOOGLE_API_KEY"]
44+
]
45+
ENV["GEMINI_API_KEY"] = "env-gemini-key"
46+
ENV.delete("GOOGLE_API_KEY")
47+
48+
options = ActiveAgent::Providers::Gemini::Options.new({})
49+
50+
assert_equal "env-gemini-key", options.api_key
51+
ensure
52+
ENV["GEMINI_API_KEY"] = original_keys[0]
53+
ENV["GOOGLE_API_KEY"] = original_keys[1]
54+
end
55+
56+
test "resolves api_key from GOOGLE_API_KEY environment variable" do
57+
original_keys = [
58+
ENV["GEMINI_API_KEY"],
59+
ENV["GOOGLE_API_KEY"]
60+
]
61+
ENV.delete("GEMINI_API_KEY")
62+
ENV["GOOGLE_API_KEY"] = "env-google-key"
63+
64+
options = ActiveAgent::Providers::Gemini::Options.new({})
65+
66+
assert_equal "env-google-key", options.api_key
67+
ensure
68+
ENV["GEMINI_API_KEY"] = original_keys[0]
69+
ENV["GOOGLE_API_KEY"] = original_keys[1]
70+
end
71+
72+
test "prefers GEMINI_API_KEY over GOOGLE_API_KEY" do
73+
original_keys = [
74+
ENV["GEMINI_API_KEY"],
75+
ENV["GOOGLE_API_KEY"]
76+
]
77+
ENV["GEMINI_API_KEY"] = "gemini-key"
78+
ENV["GOOGLE_API_KEY"] = "google-key"
79+
80+
options = ActiveAgent::Providers::Gemini::Options.new({})
81+
82+
assert_equal "gemini-key", options.api_key
83+
ensure
84+
ENV["GEMINI_API_KEY"] = original_keys[0]
85+
ENV["GOOGLE_API_KEY"] = original_keys[1]
86+
end
87+
88+
test "prefers explicit api_key over environment variables" do
89+
original_key = ENV["GEMINI_API_KEY"]
90+
ENV["GEMINI_API_KEY"] = "env-key"
91+
92+
options = ActiveAgent::Providers::Gemini::Options.new(@valid_options)
93+
94+
assert_equal "test-api-key", options.api_key
95+
ensure
96+
ENV["GEMINI_API_KEY"] = original_key
97+
end
98+
99+
test "accepts access_token as alias for api_key" do
100+
options = ActiveAgent::Providers::Gemini::Options.new(
101+
access_token: "token-via-access-token"
102+
)
103+
104+
assert_equal "token-via-access-token", options.api_key
105+
end
106+
107+
test "organization_id returns nil" do
108+
options = ActiveAgent::Providers::Gemini::Options.new(@valid_options)
109+
110+
assert_nil options.organization
111+
end
112+
113+
test "project_id returns nil" do
114+
options = ActiveAgent::Providers::Gemini::Options.new(@valid_options)
115+
116+
assert_nil options.project
117+
end
118+
end

0 commit comments

Comments
 (0)