From 2926585203c23048c76588a4de5738aacc597219 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sat, 25 Jul 2026 10:32:31 -0400 Subject: [PATCH 1/2] feat(gateway): serve Claude Opus 5 as a selectable Pro model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One catalog row is the whole feature — it goes live on DEPLOY with no editor release, because the client reads the roster live from GET /account/models. Confirmed against the OpenRouter models API (2026-07-24): anthropic/claude-opus-5 — $5/M in · $25/M out · $0.50/M cached read, 1M context Notes on the two things that are easy to get wrong here: - **Placement.** The row sits immediately after Opus 4.8 because the rates are IDENTICAL, so the two share a 6.67x multiplier. The monotonic-multiplier spec orders by COST, not recency; equal neighbours keep it satisfied. Appending it at the end (or grouping it by family) would have broken that invariant. - **status: :confirmed.** `allowed_models` filters on it, so an :assumption row would render in the picker and then silently fall back to the plan default when selected — no error to debug from. The price is a live quote, so :confirmed. Pro's economics are unchanged: same price sheet as Opus 4.8 means the same multiplier and the same ~39 turns on a $20 plan. What Pro gains is the newer model and a 1M window. The 1M context is load-bearing beyond display — estimate_cost_micros clamps the admission reservation against it, so understating it would refuse long-context turns the budget can afford. Deliberately NOT changed: the plan default stays Kimi K2.7 Code. Opus 5 costs 6.67x per turn, so defaulting to it would burn a Pro budget ~7x faster — it is an opt-in pick, same call as the K3 rollout. Marketing copy now names Opus 5 (served verbatim by /pricing, so the site picks it up on deploy). Opus 4.8 stays on the roster and selectable. Verified: Pro/Pro+/Max/Ultra all reach the id; free still downgrades to gpt-oss; rate_for resolves; ANTHROPIC_FAMILY matches so prompt caching engages. 234 LevelCode examples pass. --- app/services/levelcode/model_catalog.rb | 14 ++++++++++++++ lib/levelcode.rb | 16 ++++++++-------- spec/models/levelcode_model_catalog_spec.rb | 8 ++++++-- spec/models/levelcode_plans_spec.rb | 4 ++-- spec/requests/api/levelcode/v1/account_spec.rb | 8 +++++++- 5 files changed, 37 insertions(+), 13 deletions(-) diff --git a/app/services/levelcode/model_catalog.rb b/app/services/levelcode/model_catalog.rb index bfb8cb5f..a0f21e18 100644 --- a/app/services/levelcode/model_catalog.rb +++ b/app/services/levelcode/model_catalog.rb @@ -67,6 +67,20 @@ module ModelCatalog input: 5.00, cached_input: 0.50, output: 25.00, context: 200_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. + # The 1M context is real and load-bearing: estimate_cost_micros clamps the admission + # reservation against this number, so understating it would refuse long-context turns the + # budget can actually afford. + 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", input: 10.00, cached_input: 1.00, output: 50.00, diff --git a/lib/levelcode.rb b/lib/levelcode.rb index 9b705cee..d295cda2 100644 --- a/lib/levelcode.rb +++ b/lib/levelcode.rb @@ -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" ] }, @@ -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" ] }, @@ -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" ] }, @@ -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" ] } diff --git a/spec/models/levelcode_model_catalog_spec.rb b/spec/models/levelcode_model_catalog_spec.rb index ecc30322..29d36755 100644 --- a/spec/models/levelcode_model_catalog_spec.rb +++ b/spec/models/levelcode_model_catalog_spec.rb @@ -9,6 +9,7 @@ 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") @@ -33,7 +34,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 @@ -51,7 +55,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 diff --git a/spec/models/levelcode_plans_spec.rb b/spec/models/levelcode_plans_spec.rb index a7595aea..a9b5996b 100644 --- a/spec/models/levelcode_plans_spec.rb +++ b/spec/models/levelcode_plans_spec.rb @@ -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 diff --git a/spec/requests/api/levelcode/v1/account_spec.rb b/spec/requests/api/levelcode/v1/account_spec.rb index 702bea95..3d417406 100644 --- a/spec/requests/api/levelcode/v1/account_spec.rb +++ b/spec/requests/api/levelcode/v1/account_spec.rb @@ -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) @@ -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× From 48efb77798f2fe329e357d0450b262917ae9e023 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sat, 25 Jul 2026 10:49:24 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(gateway):=20Opus=204.8's=20context=20wi?= =?UTF-8?q?ndow=20is=201M,=20not=20200K=20=E2=80=94=20it=20was=20under-res?= =?UTF-8?q?erving?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog listed anthropic/claude-opus-4-8 at 200_000, the pre-1M default. Every OpenRouter endpoint for that model — Anthropic first-party, Bedrock, Azure, Google — advertises 1M/128K (checked 2026-07-24). This was not cosmetic. estimate_cost_micros clamps its input estimate DOWN to the context window: est_input = [ est_input, ctx ].min if ctx.positive? so a stale window is a ceiling on what the admission guard will set aside. A 600k-token turn was being reserved as if it were 200k — roughly a third of what it can actually cost — which is the opposite of the "reserve high, reconcile down on settle" contract the guard is built on. Raising the window makes the reservation honest; it never over-bills, because the ledger still meters real usage afterwards. Also corrects a comment I got backwards on the Opus 5 row in the previous commit: understating `context` under-reserves (waves through turns that overrun), it does not "refuse turns the budget can afford". The clamp is a floor on permissiveness, not a gate. Two specs, both of which fail against the old 200K value (verified by reverting): · roster integrity pins every row's window and forbids a nil/0 one, since a zero disables the clamp entirely; · an estimate_cost_micros test that pins the DIRECTION — a 600k body must reserve more than the old 200k clamp would, while a 10M body still clamps to the window rather than running past it. 236 LevelCode examples pass. --- app/services/levelcode/model_catalog.rb | 10 ++++++---- spec/models/levelcode_model_catalog_spec.rb | 12 ++++++++++++ spec/models/levelcode_plans_spec.rb | 21 +++++++++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/app/services/levelcode/model_catalog.rb b/app/services/levelcode/model_catalog.rb index a0f21e18..f431b745 100644 --- a/app/services/levelcode/model_catalog.rb +++ b/app/services/levelcode/model_catalog.rb @@ -64,8 +64,10 @@ 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 @@ -75,9 +77,9 @@ module ModelCatalog # 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. - # The 1M context is real and load-bearing: estimate_cost_micros clamps the admission - # reservation against this number, so understating it would refuse long-context turns the - # budget can actually afford. + # `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 }, diff --git a/spec/models/levelcode_model_catalog_spec.rb b/spec/models/levelcode_model_catalog_spec.rb index 29d36755..0d258bd2 100644 --- a/spec/models/levelcode_model_catalog_spec.rb +++ b/spec/models/levelcode_model_catalog_spec.rb @@ -15,6 +15,18 @@ 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) diff --git a/spec/models/levelcode_plans_spec.rb b/spec/models/levelcode_plans_spec.rb index a9b5996b..a8320f6d 100644 --- a/spec/models/levelcode_plans_spec.rb +++ b/spec/models/levelcode_plans_spec.rb @@ -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.