Skip to content
Merged
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
18 changes: 17 additions & 1 deletion app/services/levelcode/model_catalog.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,24 @@ module ModelCatalog
label: "Opus 4.8", provider: "openrouter",
# Confirmed vs OpenRouter (2026-07-07): output $25/M; input list ~$5/M (effective ~$1.56
# after ~75% prompt-cache), cached read $0.50/M. Metering splits cached/uncached, so bills right.
# Context corrected 200K → 1M (2026-07-24): every OpenRouter endpoint for this model — Anthropic
# first-party, Bedrock, Azure, Google — advertises 1M/128K. The old 200K was the pre-1M default.
input: 5.00, cached_input: 0.50, output: 25.00,
context: 200_000, min_tier: :pro, status: :confirmed
context: 1_000_000, min_tier: :pro, status: :confirmed
},
# Sits immediately after Opus 4.8 because the rates are IDENTICAL ($5/$0.50/$25) — the two share
# a multiplier, and equal neighbours keep the monotonic-multiplier invariant (spec) intact. That
# invariant orders by COST, not recency, so the newer model does not jump the queue.
"anthropic/claude-opus-5" => {
label: "Opus 5", provider: "openrouter",
# Confirmed vs the OpenRouter models API (2026-07-24): $5/M in · $25/M out · $0.50/M cached
# read — the same sheet as Opus 4.8, so Pro's per-turn economics are unchanged; what the plan
# gains is the newer model and a 1M window. Reached via OpenRouter like every other slug here.
# `context` is load-bearing, and in the UNDER-reserving direction: estimate_cost_micros clamps
# its input estimate DOWN to this number, so a too-small value makes the admission guard
# reserve less than the request can cost and wave through turns that overrun the budget.
input: 5.00, cached_input: 0.50, output: 25.00,
context: 1_000_000, min_tier: :pro, status: :confirmed
},
"anthropic/claude-fable-5" => {
label: "Fable 5", provider: "openrouter",
Expand Down
16 changes: 8 additions & 8 deletions lib/levelcode.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ module Levelcode
turns: 260,
stripe_lookup_key: "orbits_pro",
features: [
"2,000 credits/mo · ~260 Kimi turns, or ~39 on Opus 4.8",
"2,000 credits/mo · ~260 Kimi turns, or ~39 on Opus 5",
"Usage that refreshes through your billing cycle",
"Kimi K2.7 Code + Opus 4.8",
"Kimi K2.7 Code + Opus 5",
"Bring-your-own-key always free"
]
},
Expand All @@ -44,9 +44,9 @@ module Levelcode
turns: 520,
stripe_lookup_key: "orbits_pro_plus",
features: [
"4,000 credits/mo · ~520 Kimi turns, or ~78 on Opus 4.8",
"4,000 credits/mo · ~520 Kimi turns, or ~78 on Opus 5",
"Usage that refreshes through your billing cycle",
"Kimi K2.7 Code + Opus 4.8",
"Kimi K2.7 Code + Opus 5",
"Priority routing"
]
},
Expand All @@ -60,9 +60,9 @@ module Levelcode
turns: 780,
stripe_lookup_key: "orbits_max",
features: [
"6,000 credits/mo · ~780 Kimi turns, or ~117 on Opus 4.8",
"6,000 credits/mo · ~780 Kimi turns, or ~117 on Opus 5",
"Usage that refreshes through your billing cycle",
"Kimi K2.7 Code + Opus 4.8",
"Kimi K2.7 Code + Opus 5",
"Priority routing"
]
},
Expand All @@ -76,9 +76,9 @@ module Levelcode
turns: 1_300,
stripe_lookup_key: "orbits_ultra",
features: [
"10,000 credits/mo · ~1,300 Kimi turns, or ~195 on Opus 4.8",
"10,000 credits/mo · ~1,300 Kimi turns, or ~195 on Opus 5",
"Usage that refreshes through your billing cycle",
"Kimi K2.7 Code + Opus 4.8",
"Kimi K2.7 Code + Opus 5",
"Highest priority routing"
]
}
Expand Down
20 changes: 18 additions & 2 deletions spec/models/levelcode_model_catalog_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,24 @@
expect(described_class.find("moonshotai/kimi-k2.7-code")[:status]).to eq(:confirmed)
expect(described_class.find("anthropic/claude-opus-4-8")[:status]).to eq(:confirmed) # price confirmed 2026-07-07
expect(described_class.find("moonshotai/kimi-k3")[:status]).to eq(:confirmed) # OpenRouter list, 2026-07-20
expect(described_class.find("anthropic/claude-opus-5")[:status]).to eq(:confirmed) # OpenRouter list, 2026-07-24
assumed = described_class.all.select { |_id, m| m[:status] == :assumption }.keys
expect(assumed).to include("anthropic/claude-fable-5", "openai/gpt-5.5")
expect(assumed).not_to include("anthropic/claude-opus-4-8")
end

# `context` is not decoration — estimate_cost_micros clamps its input estimate DOWN to it, so a
# stale (too small) window makes the admission guard under-reserve. Both Opus rows are 1M: every
# OpenRouter endpoint for them (Anthropic first-party, Bedrock, Azure, Google) advertises 1M/128K.
it "carries each model's real context window" do
expect(described_class.find("anthropic/claude-opus-4-8")[:context]).to eq(1_000_000)
expect(described_class.find("anthropic/claude-opus-5")[:context]).to eq(1_000_000)
expect(described_class.find("moonshotai/kimi-k3")[:context]).to eq(1_048_576)
expect(described_class.find("openai/gpt-oss-120b")[:context]).to eq(131_072)
# No row may go without one: a nil/0 window disables the clamp entirely.
expect(described_class.all.values.map { |m| m[:context] }).to all(be_positive)
end

it "rate_table exposes every roster model's per-token rates (feeds Levelcode.cost_micros)" do
expect(described_class.rate_table["moonshotai/kimi-k2.7-code"]).to eq(input: 0.74, cached_input: 0.15, output: 3.50)
expect(described_class.rate_table["moonshotai/kimi-k3"]).to eq(input: 3.00, cached_input: 0.30, output: 15.00)
Expand All @@ -33,7 +46,10 @@
{
"openai/gpt-oss-120b" => 0.05, "moonshotai/kimi-k2.7-code" => 1.00, "openai/codex-5.3" => 2.22,
"openai/gpt-5.5" => 2.66, "anthropic/claude-sonnet-5" => 4.00, "moonshotai/kimi-k3" => 4.00,
"anthropic/claude-opus-4-8" => 6.67, "anthropic/claude-fable-5" => 13.35
# Opus 5 TIES Opus 4.8 — identical rates ⇒ identical multiplier. That tie is exactly what lets
# it sit beside 4.8 without disturbing the monotonic ordering asserted below.
"anthropic/claude-opus-4-8" => 6.67, "anthropic/claude-opus-5" => 6.67,
"anthropic/claude-fable-5" => 13.35
}.each { |id, mult| expect(described_class.multiplier(id)).to be_within(0.02).of(mult) }
end

Expand All @@ -51,7 +67,7 @@

it "pro reaches the roster EXCEPT Fable (gated to Max/Ultra by UX, rec #3)" do
pro = described_class.entitled(:pro)
expect(pro).to include("moonshotai/kimi-k2.7-code", "anthropic/claude-opus-4-8", "openai/gpt-5.5")
expect(pro).to include("moonshotai/kimi-k2.7-code", "anthropic/claude-opus-4-8", "anthropic/claude-opus-5", "openai/gpt-5.5")
expect(pro).not_to include("anthropic/claude-fable-5")
end

Expand Down
25 changes: 23 additions & 2 deletions spec/models/levelcode_plans_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,8 @@
end

it 'a paid plan may use the flagship AND the open-weights engine (confirmed-price roster)' do
# Kimi K3 joined the confirmed-price roster (a selectable Pro pick — see the gateway K3 change).
expect(Levelcode.allowed_models('orbits_pro')).to match_array([ Levelcode::DEFAULT_MODEL, Levelcode::FREE_MODEL, 'anthropic/claude-opus-4-8', 'moonshotai/kimi-k3' ])
# Kimi K3, then Opus 5, joined the confirmed-price roster as selectable Pro picks.
expect(Levelcode.allowed_models('orbits_pro')).to match_array([ Levelcode::DEFAULT_MODEL, Levelcode::FREE_MODEL, 'anthropic/claude-opus-4-8', 'anthropic/claude-opus-5', 'moonshotai/kimi-k3' ])
# A tier-entitled but ASSUMPTION-priced frontier model stays staged — never billed on a guess.
expect(Levelcode.allowed_models('orbits_pro')).not_to include('openai/gpt-5.5')
end
Expand Down Expand Up @@ -215,6 +215,27 @@
end
end

describe '.estimate_cost_micros (admission-time reservation)' do
# The input estimate is clamped DOWN to the model's context window, so the window is a ceiling on
# what the guard will reserve. A STALE (too small) window therefore under-reserves — it waves
# through a long-context turn that can cost several times the amount set aside for it. This is why
# Opus 4.8's window was corrected 200K → 1M; the assertion below fails against the old value.
it 'reserves against the real context window, so a long request cannot under-reserve' do
long = { 'messages' => [ { 'role' => 'user', 'content' => 'x' * 2_400_000 } ] } # ~600k tokens
reserved = Levelcode.estimate_cost_micros('anthropic/claude-opus-4-8', long)

# 600k input is well inside a 1M window, so the estimate must reflect all of it — not the 200k
# the old row would have clamped it to.
clamped_at_200k = Levelcode.cost_micros('anthropic/claude-opus-4-8', 200_000, Levelcode::PAID_MAX_TOKENS, 0)
expect(reserved).to be > clamped_at_200k

# And it is still bounded: a body far beyond the window clamps to the window, never past it.
absurd = { 'messages' => [ { 'role' => 'user', 'content' => 'x' * 40_000_000 } ] } # ~10M tokens
ceiling = Levelcode.cost_micros('anthropic/claude-opus-4-8', 1_000_000, Levelcode::PAID_MAX_TOKENS, 0)
expect(Levelcode.estimate_cost_micros('anthropic/claude-opus-4-8', absurd)).to eq(ceiling)
end
end

describe '.cost_micros' do
# Every charge includes the OpenRouter routing fee (× ROUTING_FEE) so the ledger meters the true
# wire cost and the target margin holds. Base list-price figures below, then × 1.055.
Expand Down
8 changes: 7 additions & 1 deletion spec/requests/api/levelcode/v1/account_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
body = response.parsed_body
expect(body["plan"]).to eq("Pro")
ids = body["models"].map { |m| m["id"] }
expect(ids).to include("openai/gpt-oss-120b", "moonshotai/kimi-k2.7-code", "moonshotai/kimi-k3", "anthropic/claude-opus-4-8")
expect(ids).to include("openai/gpt-oss-120b", "moonshotai/kimi-k2.7-code", "moonshotai/kimi-k3", "anthropic/claude-opus-4-8", "anthropic/claude-opus-5")
kimi = body["models"].find { |m| m["id"] == "moonshotai/kimi-k2.7-code" }
expect(kimi["live"]).to be(true)
expect(kimi["multiplier"]).to eq(1.0)
Expand All @@ -116,6 +116,12 @@
opus = body["models"].find { |m| m["id"] == "anthropic/claude-opus-4-8" }
expect(opus["live"]).to be(true) # price confirmed → live + billable
expect(opus["multiplier"]).to be_within(0.05).of(6.67)
# Opus 5 rides the same price sheet, so Pro sees it at the SAME multiplier as 4.8 — the newer
# model costs a Pro user no more per turn. `live` is the bit that matters: an :assumption-priced
# row would render in the picker but silently fall back to the plan default when selected.
opus5 = body["models"].find { |m| m["id"] == "anthropic/claude-opus-5" }
expect(opus5["live"]).to be(true)
expect(opus5["multiplier"]).to be_within(0.05).of(6.67)

# Per-turn cost rides along in the SAME retail unit as the balance fields, so the dashboard can
# render "N credits/turn" beside "≈ turns left" using one conversion. ~7.7 credits on the 1×
Expand Down