RankedLlm
A ranked list of AI API credentials — spanning any mix of OpenAI, Anthropic, DeepSeek, Mistral, OpenRouter, etc. — tried in order, falling back to the next on any failure from the current one. Tracks per-model pricing and logs real spend per call, so cost is visible instead of assumed.
Extracted from Progentick.
What it replaces
An app whose AI-calling features are pinned to one hardcoded provider/model/key. If that credential is down, rate-limited, or misconfigured, every feature that depends on it just breaks.
Install
Add to your Gemfile:
gem "ranked_llm", github: "ohheyjeremy/ranked_llm"
Then:
bundle install
bin/rails generate ranked_llm:install
bin/rails db:migrate
This creates llm_credentials and llm_usage_records tables, scoped polymorphically to whatever your app's
tenant/owner concept is — the gem never assumes it's called Account.
Wire up your owner model:
class Account < ApplicationRecord # or Team, Organization, User, ...
include Llm::Owned
end
If this app doesn't already use Active Record Encryption (Llm::Credential#api_key is encrypted at rest with it):
bin/rails db:encryption:init
Usage
result = Llm::Client.for(current_account).call_tool(
system: "You triage bug reports.",
tool: { name: "triage", description: "...", input_schema: { type: "object", properties: { ... } } },
max_tokens: 500,
messages: [ { role: "user", content: "..." } ]
)
# => { "priority" => "high", ... } — a plain Hash with string keys, the parsed tool-call arguments.
messages:for a full multi-turn conversation, orcontent_blocks:({ kind: :text, text: "..." }/{ kind: :image, media_type: "...", data: "<base64>" }) for a single implicit user turn — pass exactly one.- If any
content_blocksentry iskind: :image, only vision-capable ranked credentials are attempted. - On failure, the client automatically retries the next-ranked credential. Raises
Llm::Client::AllProvidersFailedErrorif every configured credential fails, orLlm::Client::NoCredentialsErrorif none are configured (or none are vision-capable when an image is present). - Every successful call logs an
Llm::UsageRecord(provider, model, token counts, cost at the time of the call) — failed attempts that got skipped in the fallback chain are never logged.
Settings UI
There's no mounted engine UI — every host app has its own auth boundary and design system. Instead, scaffold a starting point and adapt it:
bin/rails generate ranked_llm:views
This copies a controller, view, and Stimulus drag-to-reorder controller into your app. Read the generator's output
for what to rename (current_account → your app's actual auth helper) and restyle.
Pricing data
Llm::ProviderRegistry::PROVIDERS hand-seeds input_price_per_million/output_price_per_million (USD per 1M
tokens) per model, current as of when this gem was last updated. This drifts. Verify against each provider's
current pricing page before trusting it for real budgeting decisions. Models with genuinely variable pricing (e.g.
openrouter/auto, a meta-router) have nil pricing — Llm::ProviderRegistry.cost_for returns nil rather than
0 for these, so a usage record's cost_usd can be nil and callers must not treat that as "free."
Adding a new provider/model: edit PROVIDERS in app/services/llm/provider_registry.rb. Model names are
deliberately not free-text on Llm::Credential — they're validated against this registry, since provider catalogs
(especially OpenRouter's) move fast and typos are easy.
Adding a new provider
- If it speaks the OpenAI-style chat-completions shape (function-calling via
tools/tool_choice), just add it toPROVIDERSwith itsbase_uri—Llm::OpenAiCompatibleAdapterhandles it. - Otherwise (like Anthropic), add a case to
Llm::Client#adapter_forand a new adapter implementing#call_tool(system:, tool:, max_tokens:, content_blocks: nil, messages: nil) -> Llm::CallResult.
Testing
bundle install
bundle exec rake test
Uses combustion to run the model/service specs against an in-memory dummy
Rails app (test/internal) — no full generated dummy app checked in.
License
MIT.