Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions lib/active_agent/providers/gemini/_types.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true

require_relative "options"
require_relative "../open_ai/chat/_types"
require_relative "../open_ai/embedding/_types"

module ActiveAgent
module Providers
module Gemini
# Reuse OpenAI Chat request type (same API format)
RequestType = OpenAI::Chat::RequestType

# Reuse OpenAI Embedding types (same API format)
module Embedding
RequestType = OpenAI::Embedding::RequestType
end
end
end
end
41 changes: 41 additions & 0 deletions lib/active_agent/providers/gemini/options.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true

require_relative "../open_ai/options"

module ActiveAgent
module Providers
module Gemini
# Configuration options for Gemini provider
#
# Extends OpenAI::Options with Gemini-specific settings including
# the default base URL for Gemini's OpenAI-compatible API endpoint.
#
# @example Basic configuration
# options = Options.new(api_key: 'your-api-key')
#
# @example With environment variable
# # Set GEMINI_API_KEY or GOOGLE_API_KEY
# options = Options.new({})
#
# @see https://ai.google.dev/gemini-api/docs/openai
class Options < ActiveAgent::Providers::OpenAI::Options
GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"

attribute :base_url, :string, fallback: GEMINI_BASE_URL

private

def resolve_api_key(kwargs)
kwargs[:api_key] ||
kwargs[:access_token] ||
ENV["GEMINI_API_KEY"] ||
ENV["GOOGLE_API_KEY"]
end

# Not used as part of Gemini
def resolve_organization_id(kwargs) = nil
def resolve_project_id(kwargs) = nil
end
end
end
end
94 changes: 94 additions & 0 deletions lib/active_agent/providers/gemini_provider.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
require_relative "_base_provider"

require_gem!(:openai, __FILE__)

require_relative "open_ai_provider"
require_relative "gemini/_types"

module ActiveAgent
module Providers
# Provides access to Google's Gemini API via OpenAI-compatible endpoint.
#
# Extends OpenAI provider to work with Gemini's OpenAI-compatible API,
# enabling access to Gemini models through a familiar interface.
#
# @see OpenAI::ChatProvider
# @see https://ai.google.dev/gemini-api/docs/openai
class GeminiProvider < OpenAI::ChatProvider
# @return [String]
def self.service_name
"Gemini"
end

# @return [Class]
def self.options_klass
namespace::Options
end

# @return [ActiveModel::Type::Value]
def self.prompt_request_type
namespace::RequestType.new
end

# @return [ActiveModel::Type::Value]
def self.embed_request_type
namespace::Embedding::RequestType.new
end

protected

# Executes chat completion request with Gemini-specific error handling.
#
# @see OpenAI::ChatProvider#api_prompt_execute
# @param parameters [Hash]
# @return [Object, nil] response object or nil for streaming
# @raise [OpenAI::Errors::APIConnectionError] when Gemini API unreachable
def api_prompt_execute(parameters)
super

rescue ::OpenAI::Errors::APIConnectionError => exception
log_connection_error(exception)
raise exception
end

# Executes embedding request with Gemini-specific error handling.
#
# @param parameters [Hash]
# @return [Hash] symbolized API response
# @raise [OpenAI::Errors::APIConnectionError] when Gemini API unreachable
def api_embed_execute(parameters)
client.embeddings.create(**parameters).as_json.deep_symbolize_keys
rescue ::OpenAI::Errors::APIConnectionError => exception
log_connection_error(exception)
raise exception
end

# Merges streaming delta into the message with role cleanup.
#
# Overrides parent to handle Gemini's role copying behavior which duplicates
# the role field in every streaming chunk, requiring manual cleanup to prevent
# message corruption.
#
# @see OpenAI::ChatProvider#message_merge_delta
# @param message [Hash]
# @param delta [Hash]
# @return [Hash]
def message_merge_delta(message, delta)
message[:role] = delta.delete(:role) if delta[:role]

hash_merge_delta(message, delta)
end

# Logs connection failures with Gemini API details for debugging.
#
# @param error [Exception]
# @return [void]
def log_connection_error(error)
instrument("connection_error.provider.active_agent",
uri_base: options.base_url,
exception: error.class,
message: error.message)
end
end
end
end
22 changes: 22 additions & 0 deletions test/dummy/app/agents/providers/gemini_agent.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module Providers
# Example agent using Google's Gemini models.
#
# Demonstrates basic prompt generation with the Gemini provider.
# Configured to use Gemini 2.0 Flash with default instructions.
#
# @example Basic usage
# response = Providers::GeminiAgent.ask(message: "Hello").generate_now
# response.message.content #=> "Hi! How can I help you today?"
# region agent
class GeminiAgent < ApplicationAgent
generate_with :gemini, model: "gemini-2.0-flash"

# @return [ActiveAgent::Generation]
def ask
prompt(message: params[:message])
end
end
# endregion agent
end
12 changes: 12 additions & 0 deletions test/dummy/config/active_agent.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ mock: &mock
ruby_llm: &ruby_llm
service: "RubyLLM"
# endregion ruby_llm_anchor
# region gemini_anchor
gemini: &gemini
service: "Gemini"
model: "gemini-2.0-flash"
api_key: <%= Rails.application.credentials.dig(:gemini, :api_key) %>
# endregion gemini_anchor
# endregion config_anchors

# region config_development
Expand Down Expand Up @@ -72,6 +78,10 @@ development:
ruby_llm:
<<: *ruby_llm
# endregion ruby_llm_dev_config
# region gemini_dev_config
gemini:
<<: *gemini
# endregion gemini_dev_config
# endregion config_development

# region config_test
Expand All @@ -92,4 +102,6 @@ test:
<<: *mock
ruby_llm:
<<: *ruby_llm
gemini:
<<: *gemini
# endregion config_test
66 changes: 66 additions & 0 deletions test/providers/gemini/gemini_provider_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# frozen_string_literal: true

require "test_helper"

GEMINI_PROVIDER_OPENAI_AVAILABLE = begin
require "openai"
true
rescue LoadError
warn "OpenAI gem not available, skipping Gemini provider tests"
false
end

require_relative "../../../lib/active_agent/providers/gemini_provider" if GEMINI_PROVIDER_OPENAI_AVAILABLE

class GeminiProviderTest < ActiveSupport::TestCase
setup do
skip "OpenAI gem not available" unless GEMINI_PROVIDER_OPENAI_AVAILABLE
@valid_config = {
service: "Gemini",
api_key: "test-api-key",
messages: [ { role: "user", content: "Hello" } ]
}
end

test "service_name returns Gemini" do
assert_equal "Gemini", ActiveAgent::Providers::GeminiProvider.service_name
end

test "options_klass returns Gemini::Options" do
assert_equal(
ActiveAgent::Providers::Gemini::Options,
ActiveAgent::Providers::GeminiProvider.options_klass
)
end

test "prompt_request_type returns Gemini::RequestType" do
request_type = ActiveAgent::Providers::GeminiProvider.prompt_request_type

# Gemini::RequestType is aliased to OpenAI::Chat::RequestType
assert_instance_of ActiveAgent::Providers::OpenAI::Chat::RequestType, request_type
end

test "embed_request_type returns OpenAI::Embedding::RequestType" do
request_type = ActiveAgent::Providers::GeminiProvider.embed_request_type

# Gemini::Embedding::RequestType is aliased to OpenAI::Embedding::RequestType
assert_instance_of ActiveAgent::Providers::OpenAI::Embedding::RequestType, request_type
end

test "initializes provider with valid configuration" do
provider = ActiveAgent::Providers::GeminiProvider.new(@valid_config)

assert_instance_of ActiveAgent::Providers::GeminiProvider, provider
end

test "inherits from OpenAI::ChatProvider" do
assert ActiveAgent::Providers::GeminiProvider < ActiveAgent::Providers::OpenAI::ChatProvider
end

test "client returns OpenAI::Client instance" do
provider = ActiveAgent::Providers::GeminiProvider.new(@valid_config)
client = provider.client

assert_kind_of ::OpenAI::Client, client
end
end
118 changes: 118 additions & 0 deletions test/providers/gemini/options_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# frozen_string_literal: true

require "test_helper"

GEMINI_OPTIONS_OPENAI_AVAILABLE = begin
require "openai"
true
rescue LoadError
warn "OpenAI gem not available, skipping Gemini options tests"
false
end

require_relative "../../../lib/active_agent/providers/gemini_provider" if GEMINI_OPTIONS_OPENAI_AVAILABLE

class GeminiOptionsTest < ActiveSupport::TestCase
setup do
skip "OpenAI gem not available" unless GEMINI_OPTIONS_OPENAI_AVAILABLE
@valid_options = {
api_key: "test-api-key"
}
end

test "validates presence of api_key" do
original_keys = [
ENV["GEMINI_API_KEY"],
ENV["GOOGLE_API_KEY"]
]
ENV.delete("GEMINI_API_KEY")
ENV.delete("GOOGLE_API_KEY")

options = ActiveAgent::Providers::Gemini::Options.new({})

assert_not options.valid?
assert_includes options.errors[:api_key], "can't be blank"
ensure
ENV["GEMINI_API_KEY"] = original_keys[0]
ENV["GOOGLE_API_KEY"] = original_keys[1]
end

test "resolves api_key from GEMINI_API_KEY environment variable" do
original_keys = [
ENV["GEMINI_API_KEY"],
ENV["GOOGLE_API_KEY"]
]
ENV["GEMINI_API_KEY"] = "env-gemini-key"
ENV.delete("GOOGLE_API_KEY")

options = ActiveAgent::Providers::Gemini::Options.new({})

assert_equal "env-gemini-key", options.api_key
ensure
ENV["GEMINI_API_KEY"] = original_keys[0]
ENV["GOOGLE_API_KEY"] = original_keys[1]
end

test "resolves api_key from GOOGLE_API_KEY environment variable" do
original_keys = [
ENV["GEMINI_API_KEY"],
ENV["GOOGLE_API_KEY"]
]
ENV.delete("GEMINI_API_KEY")
ENV["GOOGLE_API_KEY"] = "env-google-key"

options = ActiveAgent::Providers::Gemini::Options.new({})

assert_equal "env-google-key", options.api_key
ensure
ENV["GEMINI_API_KEY"] = original_keys[0]
ENV["GOOGLE_API_KEY"] = original_keys[1]
end

test "prefers GEMINI_API_KEY over GOOGLE_API_KEY" do
original_keys = [
ENV["GEMINI_API_KEY"],
ENV["GOOGLE_API_KEY"]
]
ENV["GEMINI_API_KEY"] = "gemini-key"
ENV["GOOGLE_API_KEY"] = "google-key"

options = ActiveAgent::Providers::Gemini::Options.new({})

assert_equal "gemini-key", options.api_key
ensure
ENV["GEMINI_API_KEY"] = original_keys[0]
ENV["GOOGLE_API_KEY"] = original_keys[1]
end

test "prefers explicit api_key over environment variables" do
original_key = ENV["GEMINI_API_KEY"]
ENV["GEMINI_API_KEY"] = "env-key"

options = ActiveAgent::Providers::Gemini::Options.new(@valid_options)

assert_equal "test-api-key", options.api_key
ensure
ENV["GEMINI_API_KEY"] = original_key
end

test "accepts access_token as alias for api_key" do
options = ActiveAgent::Providers::Gemini::Options.new(
access_token: "token-via-access-token"
)

assert_equal "token-via-access-token", options.api_key
end

test "organization_id returns nil" do
options = ActiveAgent::Providers::Gemini::Options.new(@valid_options)

assert_nil options.organization
end

test "project_id returns nil" do
options = ActiveAgent::Providers::Gemini::Options.new(@valid_options)

assert_nil options.project
end
end
Loading