ranked_llm 0.2.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c9609e477e95f6b743ca320ed83d57e5c93fcc85bdb44fed154c61ef31e00509
4
+ data.tar.gz: af8579c4bea899322880e00582ff5276a1be17d6dfdea41bab04b203763d5d04
5
+ SHA512:
6
+ metadata.gz: e2b73f9b1bf57fee78149c76242f576824746bd53ee05407d21c991ee5e6ec524214f0291b2c4d6afb46e33b7e73258448014313dcc34d638efc5747468f1477
7
+ data.tar.gz: f6d85e4dfb92374bc89faaa81a05b24456842a116bd18af4a82c7cddc6f24786e27b561621ca4b382e44abd19683574b0cd0a4082c51a40a67f979008b2006fa
data/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. Format loosely follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ ## [0.2.0] - 2026-07-24
7
+
8
+ ### Added
9
+
10
+ - Kimi (Moonshot AI) as a provider: `kimi-k3` (1M context, vision), `kimi-k2.7-code`
11
+ (262k context, coding-focused, no vision), `kimi-k2.6` (262k context, vision).
12
+ Model IDs, context windows, vision support, and pricing verified against the
13
+ official docs at platform.kimi.ai. Uses the existing `Llm::OpenAiCompatibleAdapter`
14
+ — no adapter changes needed, since Moonshot's API supports forced `tool_choice`
15
+ on a standard chat/completions endpoint.
16
+
17
+ ## [0.1.0] - 2026-07-23
18
+
19
+ ### Added
20
+
21
+ - Initial extraction from Progentick: ranked, multi-provider AI API credentials
22
+ with automatic fallback and per-call cost tracking for Rails apps.
23
+ - `Llm::Owned` concern — include in any tenant/owner model (Account, Team, User, ...).
24
+ - `Llm::Client.for(owner).call_tool(...)` — tries ranked credentials in order,
25
+ falling back to the next on any failure; only vision-capable credentials are
26
+ attempted when the call includes an image.
27
+ - `Llm::AnthropicAdapter` and `Llm::OpenAiCompatibleAdapter` (shared by OpenAI,
28
+ DeepSeek, Mistral, OpenRouter, and any other OpenAI-compatible provider).
29
+ - `Llm::ProviderRegistry` — hand-maintained model catalog with per-model pricing
30
+ and vision flags.
31
+ - `Llm::Credential` (encrypted `api_key`, positioned per-owner) and
32
+ `Llm::UsageRecord` (token counts + cost per call).
33
+ - Rails generators: `ranked_llm:install` (migrations) and `ranked_llm:views`
34
+ (scaffolded settings UI — controller, view, drag-to-reorder Stimulus controller).
data/LICENSE.txt ADDED
@@ -0,0 +1,19 @@
1
+ Copyright 2026 Jeremy
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # RankedLlm
2
+
3
+ A ranked list of AI API credentials — spanning any mix of OpenAI, Anthropic, DeepSeek, Mistral, OpenRouter, etc. —
4
+ tried in order, falling back to the next on **any** failure from the current one. Tracks per-model pricing and logs
5
+ real spend per call, so cost is visible instead of assumed.
6
+
7
+ Extracted from [Progentick](https://github.com/ohheyjeremy/progentick).
8
+
9
+ ## What it replaces
10
+
11
+ An app whose AI-calling features are pinned to one hardcoded provider/model/key. If that credential is down,
12
+ rate-limited, or misconfigured, every feature that depends on it just breaks.
13
+
14
+ ## Install
15
+
16
+ Add to your Gemfile:
17
+
18
+ ```ruby
19
+ gem "ranked_llm", github: "ohheyjeremy/ranked_llm"
20
+ ```
21
+
22
+ Then:
23
+
24
+ ```bash
25
+ bundle install
26
+ bin/rails generate ranked_llm:install
27
+ bin/rails db:migrate
28
+ ```
29
+
30
+ This creates `llm_credentials` and `llm_usage_records` tables, scoped **polymorphically** to whatever your app's
31
+ tenant/owner concept is — the gem never assumes it's called `Account`.
32
+
33
+ Wire up your owner model:
34
+
35
+ ```ruby
36
+ class Account < ApplicationRecord # or Team, Organization, User, ...
37
+ include Llm::Owned
38
+ end
39
+ ```
40
+
41
+ If this app doesn't already use Active Record Encryption (`Llm::Credential#api_key` is encrypted at rest with it):
42
+
43
+ ```bash
44
+ bin/rails db:encryption:init
45
+ ```
46
+
47
+ ## Usage
48
+
49
+ ```ruby
50
+ result = Llm::Client.for(current_account).call_tool(
51
+ system: "You triage bug reports.",
52
+ tool: { name: "triage", description: "...", input_schema: { type: "object", properties: { ... } } },
53
+ max_tokens: 500,
54
+ messages: [ { role: "user", content: "..." } ]
55
+ )
56
+ # => { "priority" => "high", ... } — a plain Hash with string keys, the parsed tool-call arguments.
57
+ ```
58
+
59
+ - `messages:` for a full multi-turn conversation, or `content_blocks:` (`{ kind: :text, text: "..." }` /
60
+ `{ kind: :image, media_type: "...", data: "<base64>" }`) for a single implicit user turn — pass exactly one.
61
+ - If any `content_blocks` entry is `kind: :image`, only vision-capable ranked credentials are attempted.
62
+ - On failure, the client automatically retries the next-ranked credential. Raises
63
+ `Llm::Client::AllProvidersFailedError` if every configured credential fails, or
64
+ `Llm::Client::NoCredentialsError` if none are configured (or none are vision-capable when an image is present).
65
+ - Every **successful** call logs an `Llm::UsageRecord` (provider, model, token counts, cost at the time of the
66
+ call) — failed attempts that got skipped in the fallback chain are never logged.
67
+
68
+ ## Settings UI
69
+
70
+ There's no mounted engine UI — every host app has its own auth boundary and design system. Instead, scaffold a
71
+ starting point and adapt it:
72
+
73
+ ```bash
74
+ bin/rails generate ranked_llm:views
75
+ ```
76
+
77
+ This copies a controller, view, and Stimulus drag-to-reorder controller into your app. Read the generator's output
78
+ for what to rename (`current_account` → your app's actual auth helper) and restyle.
79
+
80
+ ## Pricing data
81
+
82
+ `Llm::ProviderRegistry::PROVIDERS` hand-seeds `input_price_per_million`/`output_price_per_million` (USD per 1M
83
+ tokens) per model, current as of when this gem was last updated. **This drifts.** Verify against each provider's
84
+ current pricing page before trusting it for real budgeting decisions. Models with genuinely variable pricing (e.g.
85
+ `openrouter/auto`, a meta-router) have `nil` pricing — `Llm::ProviderRegistry.cost_for` returns `nil` rather than
86
+ `0` for these, so a usage record's `cost_usd` can be `nil` and callers must not treat that as "free."
87
+
88
+ Adding a new provider/model: edit `PROVIDERS` in `app/services/llm/provider_registry.rb`. Model names are
89
+ deliberately not free-text on `Llm::Credential` — they're validated against this registry, since provider catalogs
90
+ (especially OpenRouter's) move fast and typos are easy.
91
+
92
+ ## Adding a new provider
93
+
94
+ - If it speaks the OpenAI-style chat-completions shape (function-calling via `tools`/`tool_choice`), just add it to
95
+ `PROVIDERS` with its `base_uri` — `Llm::OpenAiCompatibleAdapter` handles it.
96
+ - Otherwise (like Anthropic), add a case to `Llm::Client#adapter_for` and a new adapter implementing
97
+ `#call_tool(system:, tool:, max_tokens:, content_blocks: nil, messages: nil) -> Llm::CallResult`.
98
+
99
+ ## Testing
100
+
101
+ ```bash
102
+ bundle install
103
+ bundle exec rake test
104
+ ```
105
+
106
+ Uses [combustion](https://github.com/pat/combustion) to run the model/service specs against an in-memory dummy
107
+ Rails app (`test/internal`) — no full generated dummy app checked in.
108
+
109
+ ## License
110
+
111
+ MIT.
@@ -0,0 +1,25 @@
1
+ module Llm
2
+ # A ranked AI API credential. Llm::Client tries these in position order for
3
+ # every LLM call, falling back to the next rank on any failure from the
4
+ # current one.
5
+ class Credential < ApplicationRecord
6
+ belongs_to :owner, polymorphic: true
7
+
8
+ encrypts :api_key
9
+
10
+ positioned on: :owner
11
+
12
+ validates :provider, :model, :api_key, presence: true
13
+ validate :provider_and_model_are_known
14
+
15
+ scope :ranked, -> { order(:position) }
16
+
17
+ private
18
+
19
+ def provider_and_model_are_known
20
+ return if Llm::ProviderRegistry.valid?(provider, model)
21
+
22
+ errors.add(:model, "#{model.inspect} is not a supported model for provider #{provider.inspect}")
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,20 @@
1
+ module Llm
2
+ # Include in whatever model is the tenant/owner concept in the host app
3
+ # (Account, Team, Organization, User, ...). Credentials and usage records
4
+ # belong to that model polymorphically, so this gem never has to assume
5
+ # what the owner is called.
6
+ #
7
+ # class Account < ApplicationRecord
8
+ # include Llm::Owned
9
+ # end
10
+ #
11
+ # Llm::Client.for(current_account).call_tool(...)
12
+ module Owned
13
+ extend ActiveSupport::Concern
14
+
15
+ included do
16
+ has_many :llm_credentials, as: :owner, class_name: "Llm::Credential", dependent: :destroy
17
+ has_many :llm_usage_records, as: :owner, class_name: "Llm::UsageRecord", dependent: :destroy
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,22 @@
1
+ module Llm
2
+ # One row per successful Llm::Client call: which provider/model actually
3
+ # served it, how many tokens it used, and what that cost at the time (using
4
+ # Llm::ProviderRegistry's pricing at the moment of the call, not whatever
5
+ # the registry says later — prices drift, history shouldn't).
6
+ #
7
+ # Deliberately keyed by provider/model strings, not a credential_id — a
8
+ # credential can be reranked or removed, but its usage history shouldn't
9
+ # disappear with it.
10
+ class UsageRecord < ApplicationRecord
11
+ belongs_to :owner, polymorphic: true
12
+
13
+ validates :provider, :model, presence: true
14
+ validates :input_tokens, :output_tokens, numericality: { greater_than_or_equal_to: 0 }
15
+
16
+ scope :this_month, -> { where(created_at: Time.current.all_month) }
17
+
18
+ def self.total_cost(scope = all)
19
+ scope.sum(:cost_usd)
20
+ end
21
+ end
22
+ end
data/app/models/llm.rb ADDED
@@ -0,0 +1,6 @@
1
+ # Multi-provider LLM abstraction: a ranked list of API credentials tried in
2
+ # order, falling back to the next on any failure, shared by every feature in
3
+ # the host app that calls an LLM.
4
+ module Llm
5
+ def self.table_name_prefix = "llm_"
6
+ end
@@ -0,0 +1,41 @@
1
+ module Llm
2
+ class AnthropicAdapter
3
+ def initialize(credential)
4
+ @credential = credential
5
+ end
6
+
7
+ def call_tool(system:, tool:, max_tokens:, content_blocks: nil, messages: nil)
8
+ wire_messages = messages || [ { role: "user", content: content_blocks.map { |block| to_wire_block(block) } } ]
9
+ client = Anthropic::Client.new(api_key: @credential.api_key)
10
+ response = client.messages.create(
11
+ model: @credential.model,
12
+ max_tokens: max_tokens,
13
+ system: system,
14
+ messages: wire_messages,
15
+ tools: [ { name: tool[:name], description: tool[:description], input_schema: tool[:input_schema] } ],
16
+ tool_choice: { type: "tool", name: tool[:name] }
17
+ )
18
+ tool_use = response.content.find { |block| block.type == :tool_use }
19
+ raise "Llm::AnthropicAdapter: no #{tool[:name]} tool call in response" unless tool_use
20
+
21
+ # The anthropic gem parses response JSON with symbolize_names: true, so
22
+ # tool_use.input has symbol keys — stringify to match
23
+ # Llm::OpenAiCompatibleAdapter#call_tool's return shape, which callers
24
+ # rely on via string-key access.
25
+ CallResult.new(
26
+ data: tool_use.input.deep_stringify_keys,
27
+ input_tokens: response.usage.input_tokens,
28
+ output_tokens: response.usage.output_tokens
29
+ )
30
+ end
31
+
32
+ private
33
+
34
+ def to_wire_block(block)
35
+ case block[:kind]
36
+ when :text then { type: "text", text: block[:text] }
37
+ when :image then { type: "image", source: { type: "base64", media_type: block[:media_type], data: block[:data] } }
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,7 @@
1
+ module Llm
2
+ # What an adapter's #call_tool returns internally: the tool-call data a
3
+ # caller wants, plus token counts so Llm::Client can log spend before
4
+ # unwrapping to just `data` for the caller (existing callers are unaffected
5
+ # by this — they never see token counts).
6
+ CallResult = Struct.new(:data, :input_tokens, :output_tokens, keyword_init: true)
7
+ end
@@ -0,0 +1,77 @@
1
+ # Common interface every adapter implements:
2
+ # #call_tool(system:, tool:, max_tokens:, content_blocks: nil, messages: nil) -> Llm::CallResult
3
+ #
4
+ # Exactly one of content_blocks:/messages: is given. content_blocks is a
5
+ # neutral shape each adapter translates to a single implicit user turn:
6
+ # { kind: :text, text: "..." } / { kind: :image, media_type: "...", data: "<base64>" }.
7
+ # messages is a full multi-turn conversation: [{ role: "user"|"assistant", content: "..." }, ...],
8
+ # for callers that need history, not a single turn.
9
+ # tool is { name:, description:, input_schema: } — plain JSON schema, provider-agnostic.
10
+ #
11
+ # Llm::Client itself has the same #call_tool(...) signature, but returns just
12
+ # the tool-call Hash (string keys) — it unwraps Llm::CallResult#data after
13
+ # logging Llm::UsageRecord from #input_tokens/#output_tokens, so callers never
14
+ # deal with the adapter-level result type.
15
+ #
16
+ # Llm::Client tries the owner's Llm::Credential rows in rank order, falling
17
+ # back to the next on ANY error from the current one. If the call includes an
18
+ # image (content_blocks with a :image block), only vision-capable credentials
19
+ # are attempted — everything else has no such constraint.
20
+ #
21
+ # `owner` is whatever model included Llm::Owned (Account, Team, User, ...).
22
+ module Llm
23
+ class Client
24
+ class NoCredentialsError < StandardError; end
25
+ class AllProvidersFailedError < StandardError; end
26
+
27
+ def self.for(owner)
28
+ new(owner)
29
+ end
30
+
31
+ def initialize(owner)
32
+ @owner = owner
33
+ end
34
+
35
+ def call_tool(system:, tool:, max_tokens:, content_blocks: nil, messages: nil)
36
+ needs_vision = content_blocks&.any? { |block| block[:kind] == :image } || false
37
+
38
+ credentials = @owner.llm_credentials.ranked.to_a
39
+ credentials = credentials.select { |c| Llm::ProviderRegistry.vision_capable?(c.provider, c.model) } if needs_vision
40
+ raise NoCredentialsError, "No configured AI API credential can handle this request" if credentials.empty?
41
+
42
+ last_error = nil
43
+ credentials.each do |credential|
44
+ result = adapter_for(credential).call_tool(system: system, tool: tool, max_tokens: max_tokens, content_blocks: content_blocks, messages: messages)
45
+ record_usage(credential, result)
46
+ return result.data
47
+ rescue StandardError => e
48
+ last_error = e
49
+ Rails.logger.warn("Llm::Client: #{credential.provider}/#{credential.model} failed (#{e.class}: #{e.message}), trying next")
50
+ end
51
+
52
+ raise AllProvidersFailedError, "All #{credentials.size} configured AI API credential(s) failed. Last error: #{last_error.class}: #{last_error.message}"
53
+ end
54
+
55
+ private
56
+
57
+ def adapter_for(credential)
58
+ case credential.provider
59
+ when "anthropic" then AnthropicAdapter.new(credential)
60
+ else OpenAiCompatibleAdapter.new(credential)
61
+ end
62
+ end
63
+
64
+ def record_usage(credential, result)
65
+ @owner.llm_usage_records.create!(
66
+ provider: credential.provider,
67
+ model: credential.model,
68
+ input_tokens: result.input_tokens,
69
+ output_tokens: result.output_tokens,
70
+ cost_usd: ProviderRegistry.cost_for(credential.provider, credential.model, input_tokens: result.input_tokens, output_tokens: result.output_tokens)
71
+ )
72
+ rescue StandardError => e
73
+ # Never let usage logging break a successful call.
74
+ Rails.logger.error("Llm::Client: failed to record usage for #{credential.provider}/#{credential.model}: #{e.message}")
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,45 @@
1
+ # Shared by any provider that speaks the OpenAI-style chat-completions shape
2
+ # (function-calling via tools/tool_choice) — Mistral, OpenRouter, DeepSeek,
3
+ # OpenAI itself, etc.
4
+ module Llm
5
+ class OpenAiCompatibleAdapter
6
+ def initialize(credential)
7
+ @credential = credential
8
+ @base_uri = ProviderRegistry.base_uri_for(credential.provider)
9
+ end
10
+
11
+ def call_tool(system:, tool:, max_tokens:, content_blocks: nil, messages: nil)
12
+ wire_messages = messages || [ { role: "user", content: content_blocks.map { |block| to_wire_block(block) } } ]
13
+ response = HTTParty.post(
14
+ "#{@base_uri}/chat/completions",
15
+ headers: { "Authorization" => "Bearer #{@credential.api_key}", "Content-Type" => "application/json" },
16
+ body: {
17
+ model: @credential.model,
18
+ max_tokens: max_tokens,
19
+ messages: [ { role: "system", content: system } ] + wire_messages,
20
+ tools: [ { type: "function", function: { name: tool[:name], description: tool[:description], parameters: tool[:input_schema] } } ],
21
+ tool_choice: { type: "function", function: { name: tool[:name] } }
22
+ }.to_json
23
+ )
24
+ raise "Llm::OpenAiCompatibleAdapter: API error #{response.code} — #{response.body}" unless response.success?
25
+
26
+ arguments = response.parsed_response.dig("choices", 0, "message", "tool_calls", 0, "function", "arguments")
27
+ raise "Llm::OpenAiCompatibleAdapter: no #{tool[:name]} tool call in response" unless arguments
28
+
29
+ CallResult.new(
30
+ data: JSON.parse(arguments),
31
+ input_tokens: response.parsed_response.dig("usage", "prompt_tokens") || 0,
32
+ output_tokens: response.parsed_response.dig("usage", "completion_tokens") || 0
33
+ )
34
+ end
35
+
36
+ private
37
+
38
+ def to_wire_block(block)
39
+ case block[:kind]
40
+ when :text then { type: "text", text: block[:text] }
41
+ when :image then { type: "image_url", image_url: { url: "data:#{block[:media_type]};base64,#{block[:data]}" } }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,118 @@
1
+ # Curated, hand-maintained list of supported providers and models.
2
+ # Deliberately not free-text: provider model catalogs (especially
3
+ # OpenRouter's) move fast, so this needs occasional manual updates in
4
+ # exchange for never risking a typo'd/unsupported model string.
5
+ #
6
+ # Each model carries a `vision:` flag. Callers that need to see an image
7
+ # (e.g. analyzing a screenshot) can restrict Llm::Client to vision-capable
8
+ # ranked credentials only — everything else has no such constraint and can
9
+ # use any ranked credential, vision or not.
10
+ #
11
+ # Each model also carries `input_price_per_million`/`output_price_per_million`
12
+ # (USD per 1M tokens, nil if genuinely variable e.g. openrouter/auto). These
13
+ # are seeded from public pricing pages at the time this was written and WILL
14
+ # drift — re-check against each provider's current pricing before trusting
15
+ # them for real budgeting decisions.
16
+ module Llm
17
+ module ProviderRegistry
18
+ PROVIDERS = {
19
+ "anthropic" => {
20
+ label: "Anthropic",
21
+ base_uri: nil, # handled by the Anthropic gem directly, not the generic OpenAI-compatible adapter
22
+ models: {
23
+ "claude-sonnet-5" => { vision: true, input_price_per_million: 3.00, output_price_per_million: 15.00 },
24
+ "claude-opus-4-8" => { vision: true, input_price_per_million: 15.00, output_price_per_million: 75.00 },
25
+ "claude-haiku-4-5-20251001" => { vision: true, input_price_per_million: 1.00, output_price_per_million: 5.00 }
26
+ }
27
+ },
28
+ "openai" => {
29
+ label: "OpenAI",
30
+ base_uri: "https://api.openai.com/v1",
31
+ models: {
32
+ "gpt-4o" => { vision: true, input_price_per_million: 2.50, output_price_per_million: 10.00 },
33
+ "gpt-4o-mini" => { vision: true, input_price_per_million: 0.15, output_price_per_million: 0.60 },
34
+ "gpt-4.1" => { vision: true, input_price_per_million: 2.00, output_price_per_million: 8.00 },
35
+ "o3-mini" => { vision: false, input_price_per_million: 1.10, output_price_per_million: 4.40 }
36
+ }
37
+ },
38
+ "deepseek" => {
39
+ label: "DeepSeek",
40
+ base_uri: "https://api.deepseek.com/v1",
41
+ # No vision-capable model today — never eligible for vision-only
42
+ # calls, but fine for text-only calls.
43
+ models: {
44
+ "deepseek-chat" => { vision: false, input_price_per_million: 0.27, output_price_per_million: 1.10 },
45
+ "deepseek-reasoner" => { vision: false, input_price_per_million: 0.55, output_price_per_million: 2.19 }
46
+ }
47
+ },
48
+ "mistral" => {
49
+ label: "Mistral",
50
+ base_uri: "https://api.mistral.ai/v1",
51
+ models: {
52
+ "pixtral-large-latest" => { vision: true, input_price_per_million: 2.00, output_price_per_million: 6.00 },
53
+ "pixtral-12b-latest" => { vision: true, input_price_per_million: 0.15, output_price_per_million: 0.15 }
54
+ }
55
+ },
56
+ "kimi" => {
57
+ label: "Kimi (Moonshot AI)",
58
+ base_uri: "https://api.moonshot.ai/v1",
59
+ # Pricing here is the standard (cache-miss) input rate — this gem
60
+ # doesn't model cache-hit discounts for any provider.
61
+ models: {
62
+ "kimi-k3" => { vision: true, input_price_per_million: 3.00, output_price_per_million: 15.00 },
63
+ "kimi-k2.7-code" => { vision: false, input_price_per_million: 0.95, output_price_per_million: 4.00 },
64
+ "kimi-k2.6" => { vision: true, input_price_per_million: 0.95, output_price_per_million: 4.00 }
65
+ }
66
+ },
67
+ "openrouter" => {
68
+ label: "OpenRouter",
69
+ base_uri: "https://openrouter.ai/api/v1",
70
+ models: {
71
+ "anthropic/claude-3.5-sonnet" => { vision: true, input_price_per_million: 3.00, output_price_per_million: 15.00 },
72
+ "google/gemini-2.0-flash-001" => { vision: true, input_price_per_million: 0.10, output_price_per_million: 0.40 },
73
+ "openai/gpt-4o" => { vision: true, input_price_per_million: 2.50, output_price_per_million: 10.00 },
74
+ # Lets OpenRouter's own router pick the best underlying model per
75
+ # request instead of pinning one — convenient, but it's a
76
+ # meta-router rather than a specific model, so treat it as
77
+ # non-vision-guaranteed (which model it picks varies) and
78
+ # unpriceable (cost depends on whatever it picks).
79
+ "openrouter/auto" => { vision: false, input_price_per_million: nil, output_price_per_million: nil }
80
+ }
81
+ }
82
+ }.freeze
83
+
84
+ def self.valid?(provider, model)
85
+ PROVIDERS.key?(provider) && PROVIDERS[provider][:models].key?(model)
86
+ end
87
+
88
+ def self.vision_capable?(provider, model)
89
+ valid?(provider, model) && PROVIDERS[provider][:models][model][:vision]
90
+ end
91
+
92
+ def self.base_uri_for(provider)
93
+ PROVIDERS.fetch(provider).fetch(:base_uri)
94
+ end
95
+
96
+ def self.pricing_for(provider, model)
97
+ return nil unless valid?(provider, model)
98
+
99
+ cfg = PROVIDERS[provider][:models][model]
100
+ return nil if cfg[:input_price_per_million].nil? || cfg[:output_price_per_million].nil?
101
+
102
+ { input_price_per_million: cfg[:input_price_per_million], output_price_per_million: cfg[:output_price_per_million] }
103
+ end
104
+
105
+ # Returns nil (rather than 0) when a model has no known/fixed pricing —
106
+ # callers must not silently treat "unknown" as "free".
107
+ def self.cost_for(provider, model, input_tokens:, output_tokens:)
108
+ pricing = pricing_for(provider, model)
109
+ return nil unless pricing
110
+
111
+ (input_tokens * pricing[:input_price_per_million] + output_tokens * pricing[:output_price_per_million]) / 1_000_000.0
112
+ end
113
+
114
+ def self.grouped_options
115
+ PROVIDERS.map { |key, cfg| [ cfg[:label], cfg[:models].keys.map { |m| [ m, "#{key}:#{m}" ] } ] }
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,41 @@
1
+ require "rails/generators"
2
+ require "rails/generators/migration"
3
+
4
+ module RankedLlm
5
+ module Generators
6
+ class InstallGenerator < ::Rails::Generators::Base
7
+ include ::Rails::Generators::Migration
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ def self.next_migration_number(dirname)
12
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
13
+ end
14
+
15
+ def create_migrations
16
+ migration_template "create_llm_credentials.rb.tt", "db/migrate/create_llm_credentials.rb"
17
+ sleep 1 if migrations_need_distinct_timestamps?
18
+ migration_template "create_llm_usage_records.rb.tt", "db/migrate/create_llm_usage_records.rb"
19
+ end
20
+
21
+ def show_post_install_message
22
+ say ""
23
+ say "ranked_llm installed. Next steps:", :green
24
+ say " 1. Run: bin/rails db:migrate"
25
+ say " 2. Add `include Llm::Owned` to whatever model is your tenant/owner concept (Account, Team, User, ...)."
26
+ say " 3. Configure Active Record Encryption if this app doesn't already (bin/rails db:encryption:init) —"
27
+ say " Llm::Credential#api_key is encrypted at rest using it."
28
+ say " 4. Call it: Llm::Client.for(current_account).call_tool(system:, tool:, max_tokens:, messages:)"
29
+ say ""
30
+ say " Optional settings UI scaffold: bin/rails generate ranked_llm:views"
31
+ say ""
32
+ end
33
+
34
+ private
35
+
36
+ def migrations_need_distinct_timestamps?
37
+ true
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,15 @@
1
+ class CreateLlmCredentials < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :llm_credentials do |t|
4
+ t.references :owner, polymorphic: true, null: false
5
+ t.string :provider, null: false
6
+ t.string :model, null: false
7
+ t.text :api_key, null: false
8
+ t.integer :position, null: false
9
+
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :llm_credentials, [ :owner_type, :owner_id, :position ], unique: true, name: "index_llm_credentials_on_owner_and_position"
14
+ end
15
+ end
@@ -0,0 +1,18 @@
1
+ class CreateLlmUsageRecords < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :llm_usage_records do |t|
4
+ t.references :owner, polymorphic: true, null: false
5
+ t.string :provider, null: false
6
+ t.string :model, null: false
7
+ t.integer :input_tokens, null: false
8
+ t.integer :output_tokens, null: false
9
+ # Nullable: some models (e.g. openrouter/auto) have no fixed price.
10
+ t.decimal :cost_usd, precision: 12, scale: 6
11
+
12
+ t.timestamps
13
+ end
14
+
15
+ add_index :llm_usage_records, [ :owner_type, :owner_id, :created_at ], name: "index_llm_usage_records_on_owner_and_created_at"
16
+ add_index :llm_usage_records, [ :owner_type, :owner_id, :provider, :model ], name: "index_llm_usage_records_on_owner_and_provider_and_model"
17
+ end
18
+ end
@@ -0,0 +1,39 @@
1
+ module Settings
2
+ class AiApisController < ApplicationController
3
+ before_action :require_account! # TODO: replace with this app's real auth boundary
4
+ before_action :set_credential, only: [ :destroy, :move ]
5
+
6
+ def index
7
+ @credentials = current_account.llm_credentials.ranked
8
+ usage_this_month = current_account.llm_usage_records.this_month.group(:provider, :model)
9
+ @monthly_cost_by_provider_model = usage_this_month.sum(:cost_usd)
10
+ @monthly_tokens_by_provider_model = usage_this_month.sum("input_tokens + output_tokens")
11
+ end
12
+
13
+ def create
14
+ provider, model = params[:llm_credential][:provider_and_model].to_s.split(":", 2)
15
+ credential = current_account.llm_credentials.new(provider: provider, model: model, api_key: params[:llm_credential][:api_key])
16
+ if credential.save
17
+ redirect_to settings_ai_apis_path, notice: "AI API added."
18
+ else
19
+ redirect_to settings_ai_apis_path, alert: credential.errors.full_messages.to_sentence
20
+ end
21
+ end
22
+
23
+ def destroy
24
+ @credential.destroy
25
+ redirect_to settings_ai_apis_path, notice: "AI API removed."
26
+ end
27
+
28
+ def move
29
+ @credential.update!(position: params[:position].to_i.clamp(1, 100))
30
+ head :ok
31
+ end
32
+
33
+ private
34
+
35
+ def set_credential
36
+ @credential = current_account.llm_credentials.find(params[:id])
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,66 @@
1
+ <div class="max-w-2xl mx-auto mt-8">
2
+ <h1 class="text-2xl font-bold mb-2">AI APIs</h1>
3
+ <p class="text-sm text-slate-500 mb-6">
4
+ Every AI feature in this app uses these credentials in rank order, falling back to the next one if a call
5
+ fails. Drag to reorder.
6
+ </p>
7
+
8
+ <div class="bg-white border border-slate-200 rounded-lg p-5 mb-6">
9
+ <%%= form_with url: settings_ai_apis_path, method: :post, class: "space-y-4" do |f| %>
10
+ <div>
11
+ <%%= label_tag "llm_credential_provider_and_model", "Provider", class: "block text-sm font-medium mb-1" %>
12
+ <%%= select_tag "llm_credential[provider_and_model]", grouped_options_for_select(Llm::ProviderRegistry.grouped_options),
13
+ class: "w-full rounded-md border border-slate-300 px-3 py-2 text-sm bg-white" %>
14
+ </div>
15
+ <div>
16
+ <%%= label_tag "llm_credential_api_key", "API key", class: "block text-sm font-medium mb-1" %>
17
+ <%%= password_field_tag "llm_credential[api_key]", nil, placeholder: "paste key…", autocomplete: "off",
18
+ class: "w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500" %>
19
+ </div>
20
+ <%%= f.submit "Add", class: "rounded-md bg-amber-500 hover:bg-amber-600 text-white text-sm font-medium px-4 py-2 cursor-pointer" %>
21
+ <%% end %>
22
+ </div>
23
+
24
+ <div class="bg-white border border-slate-200 rounded-lg divide-y divide-slate-100" data-controller="ranked-list">
25
+ <%% if @credentials.empty? %>
26
+ <p class="px-5 py-6 text-sm text-slate-500 text-center">No AI APIs configured yet — every AI feature will be unavailable until you add one.</p>
27
+ <%% end %>
28
+ <%% @credentials.each do |credential| %>
29
+ <div class="flex items-center justify-between px-5 py-3 cursor-move" data-ranked-list-target="item" data-move-url="<%%= move_settings_ai_api_path(credential) %>">
30
+ <div class="flex items-center gap-3 min-w-0">
31
+ <span class="text-slate-300" aria-hidden="true">⠿</span>
32
+ <div class="min-w-0">
33
+ <p class="font-medium text-sm">
34
+ <%%= Llm::ProviderRegistry::PROVIDERS.dig(credential.provider, :label) || credential.provider %>
35
+ <span class="font-normal text-slate-500">· <%%= credential.model %></span>
36
+ <%% if Llm::ProviderRegistry.vision_capable?(credential.provider, credential.model) %>
37
+ <span class="rounded bg-slate-100 text-slate-500 text-xs px-1.5 py-0.5 align-middle">vision</span>
38
+ <%% end %>
39
+ </p>
40
+ <%% pricing = Llm::ProviderRegistry.pricing_for(credential.provider, credential.model) %>
41
+ <%% tokens_this_month = @monthly_tokens_by_provider_model[[ credential.provider, credential.model ]] %>
42
+ <%% cost_this_month = @monthly_cost_by_provider_model[[ credential.provider, credential.model ]] %>
43
+ <p class="text-xs text-slate-500">
44
+ Rank #<%%= credential.position %>
45
+ ·
46
+ <%% if pricing %>
47
+ <%%= number_to_currency(pricing[:input_price_per_million], precision: 2) %> / <%%= number_to_currency(pricing[:output_price_per_million], precision: 2) %> per 1M tokens (in/out)
48
+ <%% else %>
49
+ pricing varies
50
+ <%% end %>
51
+ <%% if tokens_this_month.present? && tokens_this_month.positive? %>
52
+ · <%%= number_with_delimiter(tokens_this_month) %> tokens this month<%% if cost_this_month.present? %> (~<%%= number_to_currency(cost_this_month, precision: 4) %>)<%% end %>
53
+ <%% end %>
54
+ </p>
55
+ </div>
56
+ </div>
57
+ <%%= button_to "Remove", settings_ai_api_path(credential), method: :delete,
58
+ data: { turbo_confirm: "Remove this AI API? Anything using it will fall through to the next rank." },
59
+ class: "text-xs text-rose-600 hover:underline cursor-pointer shrink-0" %>
60
+ </div>
61
+ <%% end %>
62
+ </div>
63
+ <p class="text-xs text-slate-400 mt-3">
64
+ Pricing is seeded from each provider's public rates and may drift — verify against their current pricing page before budgeting off it.
65
+ </p>
66
+ </div>
@@ -0,0 +1,37 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import Sortable from "sortablejs"
3
+
4
+ // Single-list drag-and-drop reordering: persists position when an item is
5
+ // dropped. Always one list — no cross-column "group" concept.
6
+ export default class extends Controller {
7
+ static targets = ["item"]
8
+
9
+ connect() {
10
+ this.sortable = new Sortable(this.element, {
11
+ animation: 150,
12
+ ghostClass: "opacity-40",
13
+ onEnd: event => this.persist(event)
14
+ })
15
+ }
16
+
17
+ disconnect() {
18
+ this.sortable?.destroy()
19
+ }
20
+
21
+ async persist(event) {
22
+ const url = event.item.dataset.moveUrl
23
+ const position = event.newIndex + 1
24
+
25
+ const response = await fetch(url, {
26
+ method: "PATCH",
27
+ headers: {
28
+ "Content-Type": "application/json",
29
+ "Accept": "application/json",
30
+ "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content
31
+ },
32
+ body: JSON.stringify({ position })
33
+ })
34
+
35
+ if (!response.ok) window.location.reload()
36
+ }
37
+ }
@@ -0,0 +1,46 @@
1
+ require "rails/generators"
2
+
3
+ module RankedLlm
4
+ module Generators
5
+ # Scaffolds a starting-point settings controller/view/JS for managing
6
+ # ranked credentials. Deliberately not auto-mounted: every host app has
7
+ # its own auth boundary (current_account/current_team/...) and UI
8
+ # conventions, so this is meant to be copied and adapted, not used as-is.
9
+ class ViewsGenerator < ::Rails::Generators::Base
10
+ source_root File.expand_path("templates", __dir__)
11
+
12
+ def copy_controller
13
+ template "ai_apis_controller.rb.tt", "app/controllers/settings/ai_apis_controller.rb"
14
+ end
15
+
16
+ def copy_view
17
+ template "index.html.erb.tt", "app/views/settings/ai_apis/index.html.erb"
18
+ end
19
+
20
+ def copy_stimulus_controller
21
+ copy_file "ranked_list_controller.js", "app/javascript/controllers/ranked_list_controller.js"
22
+ end
23
+
24
+ def show_post_install_message
25
+ say ""
26
+ say "Copied a starting-point settings controller/view/JS for ranked_llm.", :green
27
+ say "Adjust the following before using it:", :yellow
28
+ say " - `current_account` in app/controllers/settings/ai_apis_controller.rb — rename to whatever"
29
+ say " your app's auth boundary actually calls the owner (current_team, current_user, ...)."
30
+ say " - Restyle app/views/settings/ai_apis/index.html.erb to match this app's existing UI conventions —"
31
+ say " these Tailwind classes are just a starting point."
32
+ say " - Add a route, e.g.:"
33
+ say " namespace :settings do"
34
+ say " resources :ai_apis, only: [ :index, :create, :destroy ] do"
35
+ say " patch :move, on: :member"
36
+ say " end"
37
+ say " end"
38
+ say " - Register the Stimulus controller if this app doesn't auto-register app/javascript/controllers"
39
+ say " (importmap-rails/Stimulus's default setup already does this)."
40
+ say " - This view drags with SortableJS via the `ranked-list` Stimulus controller — make sure"
41
+ say " the `sortablejs` package (or your import-mapped equivalent) is available."
42
+ say ""
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,8 @@
1
+ module RankedLlm
2
+ # Registers the gem's app/models and app/services directories the normal
3
+ # Rails engine way. Deliberately not `isolate_namespace`d — call sites in
4
+ # host apps address these classes as plain `Llm::...`, not
5
+ # `RankedLlm::Llm::...`.
6
+ class Engine < ::Rails::Engine
7
+ end
8
+ end
@@ -0,0 +1,3 @@
1
+ module RankedLlm
2
+ VERSION = "0.2.0"
3
+ end
data/lib/ranked_llm.rb ADDED
@@ -0,0 +1,8 @@
1
+ require "positioning" # Llm::Credential uses `positioned` at class-definition time — must be loaded before app/models autoloads it.
2
+ require "httparty"
3
+ require "anthropic"
4
+ require "ranked_llm/version"
5
+ require "ranked_llm/engine"
6
+
7
+ module RankedLlm
8
+ end
metadata ADDED
@@ -0,0 +1,164 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ranked_llm
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Jeremy
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: positioning
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.4'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.4'
40
+ - !ruby/object:Gem::Dependency
41
+ name: httparty
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: anthropic
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: combustion
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.5'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '1.5'
82
+ - !ruby/object:Gem::Dependency
83
+ name: sqlite3
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: minitest
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ description: A ranked list of AI API credentials (OpenAI, Anthropic, DeepSeek, Mistral,
111
+ OpenRouter, ...) tried in order, falling back to the next on any failure, with per-model
112
+ pricing and per-call spend logging. Extracted from Progentick.
113
+ executables: []
114
+ extensions: []
115
+ extra_rdoc_files: []
116
+ files:
117
+ - CHANGELOG.md
118
+ - LICENSE.txt
119
+ - README.md
120
+ - app/models/llm.rb
121
+ - app/models/llm/credential.rb
122
+ - app/models/llm/owned.rb
123
+ - app/models/llm/usage_record.rb
124
+ - app/services/llm/anthropic_adapter.rb
125
+ - app/services/llm/call_result.rb
126
+ - app/services/llm/client.rb
127
+ - app/services/llm/open_ai_compatible_adapter.rb
128
+ - app/services/llm/provider_registry.rb
129
+ - lib/generators/ranked_llm/install/install_generator.rb
130
+ - lib/generators/ranked_llm/install/templates/create_llm_credentials.rb.tt
131
+ - lib/generators/ranked_llm/install/templates/create_llm_usage_records.rb.tt
132
+ - lib/generators/ranked_llm/views/templates/ai_apis_controller.rb.tt
133
+ - lib/generators/ranked_llm/views/templates/index.html.erb.tt
134
+ - lib/generators/ranked_llm/views/templates/ranked_list_controller.js
135
+ - lib/generators/ranked_llm/views/views_generator.rb
136
+ - lib/ranked_llm.rb
137
+ - lib/ranked_llm/engine.rb
138
+ - lib/ranked_llm/version.rb
139
+ homepage: https://github.com/ohheyjeremy/ranked_llm
140
+ licenses:
141
+ - MIT
142
+ metadata:
143
+ homepage_uri: https://github.com/ohheyjeremy/ranked_llm
144
+ source_code_uri: https://github.com/ohheyjeremy/ranked_llm
145
+ changelog_uri: https://github.com/ohheyjeremy/ranked_llm/blob/main/CHANGELOG.md
146
+ rdoc_options: []
147
+ require_paths:
148
+ - lib
149
+ required_ruby_version: !ruby/object:Gem::Requirement
150
+ requirements:
151
+ - - ">="
152
+ - !ruby/object:Gem::Version
153
+ version: '3.2'
154
+ required_rubygems_version: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - ">="
157
+ - !ruby/object:Gem::Version
158
+ version: '0'
159
+ requirements: []
160
+ rubygems_version: 4.0.12
161
+ specification_version: 4
162
+ summary: Ranked, multi-provider AI API credentials with automatic fallback and cost
163
+ tracking for Rails apps.
164
+ test_files: []