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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ bundle add openai
# Opper (uses OpenAI-compatible API)
bundle add openai

# Cheaper Inference (uses OpenAI-compatible API)
bundle add openai

# DeepSeek (uses OpenAI-compatible API)
bundle add openai

Expand Down
1 change: 1 addition & 0 deletions docs/actions/mcps.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ All providers accept `mcps:`. 🟩 means the provider runs a remote `url:` serve
|:------------------------------|:--------------:|:------------------:|:------|
| **Anthropic** | 🟩 | 🟦 | Provider support is beta |
| **Azure** | 🟦 | 🟦 | |
| **Cheaper Inference** | 🟦 | 🟦 | |
| **DeepSeek** | 🟦 | 🟦 | Ignores `mcp_servers` rather than rejecting it |
| **Gemini** | 🟦 | 🟦 | |
| **Mock** | 🟦 | 🟦 | Accepted, but Mock never emits tool calls |
Expand Down
17 changes: 17 additions & 0 deletions lib/active_agent/providers/cheaper_inference/_types.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

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

module ActiveAgent
module Providers
module CheaperInference
# ActiveModel type for casting and serializing Cheaper Inference requests.
#
# Cheaper Inference is OpenAI-compatible, so requests use the same shape
# as the OpenAI Chat API. This delegates entirely to
# OpenAI::Chat::RequestType.
RequestType = ActiveAgent::Providers::OpenAI::Chat::RequestType
end
end
end
39 changes: 39 additions & 0 deletions lib/active_agent/providers/cheaper_inference/options.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

require_relative "../open_ai/options"

module ActiveAgent
module Providers
module CheaperInference
# Configuration options for the Cheaper Inference provider.
#
# Extends OpenAI::Options, overriding the base URL to point at Cheaper
# Inference's OpenAI-compatible gateway and resolving the API key from
# CHEAPER_INFERENCE_API_KEY. Cheaper Inference does not use organization
# or project identifiers.
#
# @example Basic configuration
# options = Options.new(api_key: ENV["CHEAPER_INFERENCE_API_KEY"])
#
# @see https://cheaperinference.com/docs
# @see https://cheaperinference.com/signup Cheaper Inference API Keys
class Options < ActiveAgent::Providers::OpenAI::Options
# @!attribute base_url
# @return [String] API endpoint (default: "https://api.cheaperinference.com/v1")
attribute :base_url, :string, as: "https://api.cheaperinference.com/v1"

private

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

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

require_gem!(:openai, __FILE__)

require_relative "open_ai_provider"
require_relative "cheaper_inference/_types"

module ActiveAgent
module Providers
# Provides access to Cheaper Inference's OpenAI-compatible LLM gateway.
#
# Extends the OpenAI provider to work with Cheaper Inference's
# OpenAI-compatible API, enabling access to models from many providers
# through a single interface. Model ids are bare names (e.g.
# +gpt-5.4-mini+).
#
# Cheaper Inference is a plain OpenAI-compatible gateway: requests,
# responses and transforms are identical to the OpenAI Chat API, so this
# provider reuses OpenAI::Chat::RequestType and OpenAI::Chat::Transforms
# directly. The only Cheaper Inference-specific configuration is the base
# URL and API key, which live in CheaperInference::Options.
#
# @example Configuration in active_agent.yml
# cheaper_inference:
# service: "CheaperInference"
# api_key: <%= ENV["CHEAPER_INFERENCE_API_KEY"] %>
# model: "gpt-5.4-mini"
#
# @see OpenAI::ChatProvider
# @see https://cheaperinference.com/docs
class CheaperInferenceProvider < OpenAI::ChatProvider
# @return [String]
def self.service_name
"CheaperInference"
end

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

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

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

protected

# Executes an embedding request via Cheaper Inference's OpenAI-compatible API.
#
# @param parameters [Hash]
# @return [Hash] symbolized API response
def api_embed_execute(parameters)
client.embeddings.create(**parameters).as_json.deep_symbolize_keys
end

# @see BaseProvider#api_response_normalize
# @param api_response [OpenAI::Models::ChatCompletion]
# @return [Hash] normalized response hash
def api_response_normalize(api_response)
return api_response unless api_response

OpenAI::Chat::Transforms.gem_to_hash(api_response)
end
end
end
end
35 changes: 35 additions & 0 deletions test/providers/cheaper_inference/provider_loading_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# frozen_string_literal: true

require "test_helper"

class CheaperInferenceProviderLoadingTest < ActiveSupport::TestCase
test "loads CheaperInferenceProvider via cheaper_inference_provider path" do
require "active_agent/providers/cheaper_inference_provider"

assert defined?(ActiveAgent::Providers::CheaperInferenceProvider)
assert defined?(ActiveAgent::Providers::CheaperInference::Options)
end

test "provider concern loads CheaperInference service correctly" do
# Simulate how the provider concern loads providers
service_name = "CheaperInference"
require "active_agent/providers/#{service_name.underscore}_provider"

remaps = ActiveAgent::Provider::PROVIDER_SERVICE_NAMES_REMAPS
remapped = Hash.new(service_name).merge!(remaps)[service_name]

assert_equal "CheaperInference", remapped

provider_class = ActiveAgent::Providers.const_get("#{remapped.camelize}Provider")
assert_equal ActiveAgent::Providers::CheaperInferenceProvider, provider_class
end

test "CheaperInference options default to the Cheaper Inference gateway and CHEAPER_INFERENCE_API_KEY" do
require "active_agent/providers/cheaper_inference_provider"

options = ActiveAgent::Providers::CheaperInference::Options.new(api_key: "ci-test")

assert_equal "https://api.cheaperinference.com/v1", options.base_url
assert_equal "ci-test", options.api_key
end
end
159 changes: 159 additions & 0 deletions test/providers/cheaper_inference/request_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# frozen_string_literal: true

require "test_helper"

begin
require "openai"
rescue LoadError
puts "OpenAI gem not available, skipping Cheaper Inference request tests"
return
end

require_relative "../../../lib/active_agent/providers/cheaper_inference_provider"

module Providers
module CheaperInference
class RequestTest < ActiveSupport::TestCase
include WebMock::API

CHAT_ENDPOINT = "https://api.cheaperinference.com/v1/chat/completions"
EMBEDDINGS_ENDPOINT = "https://api.cheaperinference.com/v1/embeddings"

# Cleared around every test so a key in the developer's or the CI
# environment cannot decide the outcome.
KEY_ENV = %w[
CHEAPER_INFERENCE_API_KEY
OPENAI_API_KEY
].freeze

# =====================================================================
# Options
# =====================================================================

test "falls back to CHEAPER_INFERENCE_API_KEY when no explicit key is given" do
with_key_env("CHEAPER_INFERENCE_API_KEY" => "ci-env") do
assert_equal "ci-env", ActiveAgent::Providers::CheaperInference::Options.new.api_key
end
end

test "prefers an explicit api_key over CHEAPER_INFERENCE_API_KEY" do
with_key_env("CHEAPER_INFERENCE_API_KEY" => "ci-env") do
options = ActiveAgent::Providers::CheaperInference::Options.new(api_key: "ci-explicit")

assert_equal "ci-explicit", options.api_key
end
end

# =====================================================================
# Prompt
# =====================================================================

test "sends a prompt to the Cheaper Inference chat completions endpoint with the explicit key" do
stub_chat_completion

with_key_env("CHEAPER_INFERENCE_API_KEY" => "ci-env", "OPENAI_API_KEY" => "sk-openai-env") do
prompt_provider(api_key: "ci-explicit").prompt
end

assert_requested(:post, CHAT_ENDPOINT, headers: { "Authorization" => "Bearer ci-explicit" })
end

test "sends a prompt with the CHEAPER_INFERENCE_API_KEY key when no explicit key is given" do
stub_chat_completion

with_key_env("CHEAPER_INFERENCE_API_KEY" => "ci-env") do
prompt_provider.prompt
end

assert_requested(:post, CHAT_ENDPOINT, headers: { "Authorization" => "Bearer ci-env" })
end

test "sends bare model ids unchanged" do
stub_chat_completion

with_key_env("CHEAPER_INFERENCE_API_KEY" => "ci-env") do
prompt_provider(model: "gpt-5.4-mini").prompt
prompt_provider(model: "gpt-5.4").prompt
end

assert_requested(:post, CHAT_ENDPOINT) { |request| JSON.parse(request.body)["model"] == "gpt-5.4-mini" }
assert_requested(:post, CHAT_ENDPOINT) { |request| JSON.parse(request.body)["model"] == "gpt-5.4" }
end

# =====================================================================
# Embeddings
# =====================================================================

test "sends an embedding request to the Cheaper Inference embeddings endpoint with the explicit key" do
stub_request(:post, EMBEDDINGS_ENDPOINT).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: embedding_response.to_json
)

response = with_key_env("CHEAPER_INFERENCE_API_KEY" => "ci-env", "OPENAI_API_KEY" => "sk-openai-env") do
ActiveAgent::Providers::CheaperInferenceProvider.new(
service: "CheaperInference",
model: "text-embedding-3-small",
input: "hello",
api_key: "ci-explicit"
).embed
end

assert_equal [ 0.1, 0.2 ], response.data.first[:embedding]
assert_requested(:post, EMBEDDINGS_ENDPOINT, headers: { "Authorization" => "Bearer ci-explicit" })
end

private

def prompt_provider(model: "gpt-5.4-mini", **credential)
ActiveAgent::Providers::CheaperInferenceProvider.new(
service: "CheaperInference",
model: model,
messages: [ { role: "user", content: "hi" } ],
**credential
)
end

def stub_chat_completion
stub_request(:post, CHAT_ENDPOINT).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: chat_completion.to_json
)
end

def chat_completion
{
id: "chatcmpl-cheaperinference-request",
object: "chat.completion",
created: 1_700_000_000,
model: "gpt-5.4-mini",
choices: [ { index: 0, message: { role: "assistant", content: "Hello." }, finish_reason: "stop" } ],
usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 }
}
end

def embedding_response
{
object: "list",
model: "text-embedding-3-small",
data: [ { object: "embedding", index: 0, embedding: [ 0.1, 0.2 ] } ],
usage: { prompt_tokens: 2, total_tokens: 2 }
}
end

# Clears every variable in KEY_ENV, applies values on top, and restores
# the original environment afterwards.
def with_key_env(values = {})
names = KEY_ENV | values.keys
previous = names.to_h { |name| [ name, ENV[name] ] }
names.each { |name| ENV.delete(name) }
values.each { |name, value| ENV[name] = value }
yield
ensure
previous.each { |name, value| ENV[name] = value }
end
end
end
end
Loading