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 +7 -0
- data/CHANGELOG.md +34 -0
- data/LICENSE.txt +19 -0
- data/README.md +111 -0
- data/app/models/llm/credential.rb +25 -0
- data/app/models/llm/owned.rb +20 -0
- data/app/models/llm/usage_record.rb +22 -0
- data/app/models/llm.rb +6 -0
- data/app/services/llm/anthropic_adapter.rb +41 -0
- data/app/services/llm/call_result.rb +7 -0
- data/app/services/llm/client.rb +77 -0
- data/app/services/llm/open_ai_compatible_adapter.rb +45 -0
- data/app/services/llm/provider_registry.rb +118 -0
- data/lib/generators/ranked_llm/install/install_generator.rb +41 -0
- data/lib/generators/ranked_llm/install/templates/create_llm_credentials.rb.tt +15 -0
- data/lib/generators/ranked_llm/install/templates/create_llm_usage_records.rb.tt +18 -0
- data/lib/generators/ranked_llm/views/templates/ai_apis_controller.rb.tt +39 -0
- data/lib/generators/ranked_llm/views/templates/index.html.erb.tt +66 -0
- data/lib/generators/ranked_llm/views/templates/ranked_list_controller.js +37 -0
- data/lib/generators/ranked_llm/views/views_generator.rb +46 -0
- data/lib/ranked_llm/engine.rb +8 -0
- data/lib/ranked_llm/version.rb +3 -0
- data/lib/ranked_llm.rb +8 -0
- metadata +164 -0
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,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
|
data/lib/ranked_llm.rb
ADDED
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: []
|