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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion lib/ruby_llm/models.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,30 @@ def schema_file
File.expand_path('models_schema.json', __dir__)
end

def overrides_file
File.expand_path('models_overrides.json', __dir__)
end

# Hand-verified prices keyed "provider:id", re-applied on top of every refresh so a
# models.dev regeneration cannot silently replace them (see models_overrides.json).
def read_overrides(file = overrides_file)
return {} unless File.exist?(file)

JSON.parse(File.read(file), symbolize_names: true).fetch(:overrides, {}).transform_keys(&:to_s)
end

# Replaces each overridden model's standard text pricing and metadata.cost with the
# override's cost. metadata.cost is replaced whole, so a tier models.dev adds for an
# overridden id is dropped too: the override is the authority for that id.
def apply_overrides(models, overrides = read_overrides)
return models if overrides.empty?

models.map do |model|
override = overrides["#{model.provider}:#{model.id}"]
override ? apply_override(model, override[:cost]) : model
end
end

def load_models(file = RubyLLM.config.model_registry_file)
source = RubyLLM.config.model_registry_source
if source && file == RubyLLM.config.model_registry_file
Expand Down Expand Up @@ -109,6 +133,7 @@ def fetch_merged_models(remote_only: false)
log_models_dev_fetch(models_dev_fetch)

merged_models = merge_with_existing(existing_models, provider_fetch, models_dev_fetch)
merged_models = apply_overrides(merged_models)
payload[:model_count] = merged_models.size
merged_models
end
Expand Down Expand Up @@ -218,7 +243,32 @@ def merge_with_existing(existing_models, provider_fetch, models_dev_fetch)
existing_models.select { |model| model.metadata[:source] == 'models.dev' }
end

merge_models(provider_models, models_dev_models)
keep_manual_models(merge_models(provider_models, models_dev_models), existing_models)
end

# A model with metadata source 'manual' was added by hand. Keep it when neither the
# provider listing nor models.dev returns it, instead of dropping it from the registry.
# A manual model therefore stays until someone deletes it from models.json by hand.
def keep_manual_models(merged_models, existing_models)
merged_keys = index_by_key(merged_models)
missing = existing_models.select do |model|
model.metadata[:source] == 'manual' && !merged_keys.key?("#{model.provider}:#{model.id}")
end
return merged_models if missing.empty?

(merged_models + missing).sort_by { |m| [m.provider, m.id] }
end
Comment on lines +249 to +260

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keep_manual_models changes how every provider's registry is regenerated, but the PR description doesn't mention it. The description only covers the Bedrock override table.

What it does: any existing model with metadata.source == 'manual' now stays in the registry after a refresh, even when its provider was fetched successfully and no longer lists it. So a retired hand-added model stays in models.json until someone deletes it by hand. That is a different policy from the provider-authoritative merge, and it isn't needed for DUX-13714.

Fix: move it to its own PR, or at least describe it under "What this does" so reviewers know it's an intentional change to all providers.


# text_tokens becomes { standard: ... } only, so no other text tier survives for an
# overridden id (a batch tier is dropped too). Model::Pricing keeps only standard and
# batch, so a long-context tier can only arrive through metadata.cost (tiers[] or
# context_over_200k, which Supernova's Llm::PricingRegistry reads); replacing
# metadata.cost whole is what removes it.
def apply_override(model, cost)
data = model.to_h
data[:pricing] = (data[:pricing] || {}).merge(text_tokens: models_dev_pricing(cost)[:text_tokens])
data[:metadata] = (data[:metadata] || {}).merge(cost: cost)
Model::Info.new(data)
end

def merge_models(provider_models, models_dev_models)
Expand Down
137 changes: 137 additions & 0 deletions lib/ruby_llm/models_overrides.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
{
"description": [
"Prices verified by hand against the vendor. rake models:update re-applies every entry here after it merges the provider listings with models.dev (RubyLLM::Models.apply_overrides), so a regeneration cannot replace them.",
"Each entry sets the model's pricing.text_tokens to { standard: ... } only and replaces metadata.cost whole, so no long-context tier is kept for these ids in either place.",
"rake models:update also keeps every models.json entry with metadata source 'manual' that no provider listing or models.dev returns (RubyLLM::Models.keep_manual_models). Such an entry stays until it is deleted from models.json by hand.",
"No long-context tier exists for these models. Anthropic's pricing page (platform.claude.com/docs/en/about-claude/pricing, read 2026-10-14) says: 'Claude 4.6 and later models (except Claude Haiku 5.5) ... include the full 1M token context window at standard pricing.' The AWS Price List has long_ctx SKUs only for Claude Haiku 5.5 and Claude Sonnet 4.",
"Edit models.json and this file together. spec/ruby_llm/models_overrides_spec.rb fails when they disagree."
],
"overrides": {
"bedrock:us.anthropic.claude-fable-5-1": {
"source": "AWS Price List API, AmazonBedrockFoundationModels offer, us-west-2, publication 2026-10-12: on-demand regional ('Standard' / 'Regional') SKUs",
"cost": {
"input": 11,
"output": 55,
"cache_read": 0.275,
"cache_write": 13.75
}
},
"bedrock:us.anthropic.claude-fable-5": {
"source": "AWS Price List API, AmazonBedrockFoundationModels offer, us-west-2, publication 2026-10-12: on-demand regional ('Standard' / 'Regional') SKUs",
"cost": {
"input": 11,
"output": 55,
"cache_read": 1.1,
"cache_write": 13.75
}
},
"bedrock:us.anthropic.claude-sonnet-5-5": {
"source": "AWS Price List API, AmazonBedrockFoundationModels offer, us-west-2, publication 2026-10-12: on-demand regional ('Standard' / 'Regional') SKUs",
"cost": {
"input": 2.2,
"output": 11,
"cache_read": 0.11,
"cache_write": 2.75
}
},
"bedrock:us.anthropic.claude-sonnet-5": {
"source": "AWS Price List API, AmazonBedrockFoundationModels offer, us-west-2, publication 2026-10-12: on-demand regional ('Standard' / 'Regional') SKUs",
"cost": {
"input": 2.2,
"output": 11,
"cache_read": 0.22,
"cache_write": 2.75
}
},
"bedrock:us.anthropic.claude-opus-5-5": {
"source": "AWS Price List API, AmazonBedrockFoundationModels offer, us-west-2, publication 2026-10-12: on-demand regional ('Standard' / 'Regional') SKUs",
"cost": {
"input": 4.4,
"output": 22,
"cache_read": 0.22,
"cache_write": 5.5
}
},
"bedrock:us.anthropic.claude-opus-5": {
"source": "AWS Price List API, AmazonBedrockFoundationModels offer, us-west-2, publication 2026-10-12: on-demand regional ('Standard' / 'Regional') SKUs",
"cost": {
"input": 5.5,
"output": 27.5,
"cache_read": 0.55,
"cache_write": 6.875
}
},
"bedrock:us.anthropic.claude-opus-4-8": {
"source": "AWS Price List API, AmazonBedrockFoundationModels offer, us-west-2, publication 2026-10-12: on-demand regional ('Standard' / 'Regional') SKUs",
"cost": {
"input": 5.5,
"output": 27.5,
"cache_read": 0.55,
"cache_write": 6.875
}
},
"bedrock:us.anthropic.claude-opus-4-7": {
"source": "AWS Price List API, AmazonBedrockFoundationModels offer, us-west-2, publication 2026-10-12: on-demand regional ('Standard' / 'Regional') SKUs",
"cost": {
"input": 5.5,
"output": 27.5,
"cache_read": 0.55,
"cache_write": 6.875
}
},
"bedrock:us.anthropic.claude-opus-4-6-v1": {
"source": "AWS Price List API, AmazonBedrockFoundationModels offer, us-west-2, publication 2026-10-12: on-demand regional ('Standard' / 'Regional') SKUs",
"cost": {
"input": 5.5,
"output": 27.5,
"cache_read": 0.55,
"cache_write": 6.875
}
},
"bedrock:us.anthropic.claude-opus-4-5-20251101-v1:0": {
"source": "AWS Price List API, AmazonBedrockFoundationModels offer, us-west-2, publication 2026-10-12: on-demand regional ('Standard' / 'Regional') SKUs",
"cost": {
"input": 5.5,
"output": 27.5,
"cache_read": 0.55,
"cache_write": 6.875
}
},
"bedrock:us.anthropic.claude-sonnet-4-6": {
"source": "AWS Price List API, AmazonBedrockFoundationModels offer, us-west-2, publication 2026-10-12: on-demand regional ('Standard' / 'Regional') SKUs",
"cost": {
"input": 3.3,
"output": 16.5,
"cache_read": 0.33,
"cache_write": 4.125
}
},
"bedrock:us.anthropic.claude-sonnet-4-5-20250929-v1:0": {
"source": "AWS Price List API, AmazonBedrockFoundationModels offer, us-west-2, publication 2026-10-12: on-demand regional ('Standard' / 'Regional') SKUs",
"cost": {
"input": 3.3,
"output": 16.5,
"cache_read": 0.33,
"cache_write": 4.125
}
},
"bedrock:us.anthropic.claude-haiku-4-5-20251001-v1:0": {
"source": "Global rate on purpose: the AWS bill for 2026-10-05 to 2026-10-12 shows us. Haiku 4.5 at 94% of its regional rate (reason unknown), and the global rate (AWS Price List API, AmazonBedrockFoundationModels offer, us-west-2, publication 2026-10-12: on-demand 'Standard, Global' SKUs) is closer to the bill.",
"cost": {
"input": 1,
"output": 5,
"cache_read": 0.1,
"cache_write": 1.25
}
},
"bedrock:global.anthropic.claude-fable-5-1": {
"source": "AWS Price List API, AmazonBedrockFoundationModels offer, us-west-2, publication 2026-10-12: on-demand 'Standard, Global' SKUs",
"cost": {
"input": 10,
"output": 50,
"cache_read": 0.25,
"cache_write": 12.5
}
}
}
}
136 changes: 136 additions & 0 deletions spec/ruby_llm/models_overrides_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe RubyLLM::Models do
include_context 'with configured RubyLLM'

overrides = described_class.read_overrides

def bedrock_entry(id)
described_class.read_from_json.find { |m| m.provider == 'bedrock' && m.id == id }
end

def standard_pricing(model)
model.pricing.text_tokens.standard.to_h.slice(
:input_per_million, :output_per_million,
:cache_read_input_per_million, :cache_write_input_per_million
)
end

def expected_standard(cost)
{
input_per_million: cost[:input],
output_per_million: cost[:output],
cache_read_input_per_million: cost[:cache_read],
cache_write_input_per_million: cost[:cache_write]
}
end

it 'has overrides to check' do
expect(overrides).not_to be_empty
end

# models.json must already hold each override, so the JSON file and a regenerated registry
# agree. A hand edit to one file without the other fails here.
overrides.each do |key, override|
provider, id = key.split(':', 2)

it "keeps #{key} in models.json at the override price" do
model = described_class.read_from_json.find { |m| m.provider == provider && m.id == id }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: each of the ~21 generated examples here and in bedrock_entry re-parses all of models.json. Parsing it once (for example, a memoized helper or a before(:all)) would make this spec noticeably faster.


expect(model).not_to be_nil
expect(model.metadata[:cost]).to eq(override[:cost])
expect(standard_pricing(model)).to eq(expected_standard(override[:cost]))
end
end

# Anthropic's pricing page (platform.claude.com/docs/en/about-claude/pricing, read
# 2026-10-14), "Long context pricing": "Claude 4.6 and later models (except Claude Haiku 5.5)
# ... include the full 1M token context window at standard pricing." The AWS Price List
# (AmazonBedrockFoundationModels, us-west-2, publication 2026-10-12) has long_ctx SKUs only
# for Claude Haiku 5.5 and Claude Sonnet 4. A tier on any of these ids would overcharge every
# request over 200k tokens.
%w[
us.anthropic.claude-opus-4-8 us.anthropic.claude-opus-5 us.anthropic.claude-opus-5-5
us.anthropic.claude-sonnet-5 us.anthropic.claude-sonnet-5-5
us.anthropic.claude-fable-5 us.anthropic.claude-fable-5-1
].each do |id|
it "prices #{id} with no long-context tier" do
model = bedrock_entry(id)

expect(model.context_window).to eq(1_000_000)
expect(model.metadata[:cost].keys).to contain_exactly(:input, :output, :cache_read, :cache_write)
expect(model.pricing.to_h[:text_tokens].keys).to eq([:standard])
end
end

describe '.apply_overrides' do
let(:cost) { { input: 4.4, output: 22, cache_read: 0.22, cache_write: 5.5 } }
let(:tiered_model) do
RubyLLM::Model::Info.new(
id: 'us.anthropic.claude-opus-5-5', name: 'Claude Opus 5.5 (US)', provider: 'bedrock',
pricing: {
text_tokens: {
standard: { input_per_million: 4, output_per_million: 20 },
context_over_200k: { input_per_million: 8, output_per_million: 30 }
}
},
metadata: {
cost: { input: 4, output: 20, context_over_200k: { input: 8, output: 30 },
tiers: [{ input: 8, output: 30, tier: { type: 'context', size: 200_000 } }] }
}
)
end

# Model::Pricing already drops any text tier but standard and batch, so the tier that
# matters is the one in metadata.cost; this pins both places.
it 'drops a long-context tier from both pricing and metadata.cost' do
model = described_class.apply_overrides([tiered_model],
{ 'bedrock:us.anthropic.claude-opus-5-5' => { cost: cost } })
.first

expect(model.pricing.to_h[:text_tokens]).to eq(standard: expected_standard(cost))
expect(model.metadata[:cost]).to eq(cost)
end
end

describe 'refresh' do
let(:overridden_id) { 'us.anthropic.claude-opus-5-5' }
let(:models_dev_model) do
RubyLLM::Model::Info.new(
id: overridden_id, name: 'Claude Opus 5.5 (US)', provider: 'bedrock',
pricing: { text_tokens: { standard: { input_per_million: 4, output_per_million: 20 } } },
metadata: {
source: 'models.dev',
cost: { input: 4, output: 20, tiers: [{ input: 8, output: 30, tier: { type: 'context', size: 200_000 } }] }
}
)
end
let(:manual_model) do
RubyLLM::Model::Info.new(id: 'us.anthropic.claude-hand-added', name: 'Hand added', provider: 'bedrock',
metadata: { source: 'manual', cost: { input: 1, output: 2 } })
end

before do
allow(described_class).to receive(:read_existing_models).and_return([manual_model])
allow(described_class).to receive_messages(
fetch_provider_models: { models: [], fetched_providers: ['bedrock'], configured_names: [], failed: [] },
fetch_models_dev_models: { models: [models_dev_model], fetched: true }
)
end

it 'applies the override over the models.dev price, tiers included' do
model = described_class.fetch_merged_models.find { |m| m.id == overridden_id }

expect(model.metadata[:cost]).to eq(overrides["bedrock:#{overridden_id}"][:cost])
expect(standard_pricing(model)).to eq(expected_standard(overrides["bedrock:#{overridden_id}"][:cost]))
end

it 'keeps a manual model that no source returns' do
ids = described_class.fetch_merged_models.map(&:id)

expect(ids).to include('us.anthropic.claude-hand-added')
end
end
end
Loading