From 0ff9c76f517c8c843b8e5a4eff0677d468da54d5 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Tue, 8 Sep 2026 12:27:53 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(levelcode):=20Fable=205=20and=20Fable?= =?UTF-8?q?=205.1=20at=20Pro=20=E2=80=94=20the=20owner's=20call=20to=20lea?= =?UTF-8?q?d=20with=20the=20frontier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fable 5.1 (anthropic/claude-fable-5.1) joins the catalog and Fable 5 moves from max to pro, so both reach Pro, Pro+, Max and Ultra. GPT-6 Astra stays at pro_plus. The decision, recorded so it is not mistaken for drift: on 2026-09-08 the owner chose reach over rec #3 ("~19 turns a month feels broken"). As of that date no business-approved editor ships a Fable/Astra-class model, and the window to lead with one is now. The dollar budget keeps this a UX decision rather than a margin one — a pricey pick drains the allowance faster and margin stays 1 - CREDIT_COGS_RATIO — so the mitigation is honesty, not gating: the pricing-page copy now names the models and prints the real counts (~20 on Fable 5.1 for Pro), and a new spec pins those printed counts to turns_for so the page can never promise turns the budget does not buy. Fable 5.1 is NOT a price twin of Fable 5. Same $10/$50, but cached reads are $0.25/M against $1.00, so a reference turn is cheaper — 12.93x Kimi against 13.34x — and the monotonic-multiplier invariant puts it BEFORE Fable 5 and Astra, not beside them. Confirmed against the OpenRouter models API on 2026-09-08 (listed 2026-09-01): 1M ctx, image + file. The routing ceiling from #428 holds for both: every Fable 5.1 endpoint is at exactly $10/$50; Fable 5 has five there and one (Google EU, $11/$55) the ceiling now excludes. Verified: full suite 1033 examples, 0 failures; rubocop clean on every changed file. Each change reverted in turn fails only its own examples: catalog reverted -> 9; only Fable 5's tier flipped back to max -> exactly the 5 entitlement examples; pricing copy perturbed (~20 -> ~25) -> exactly the advertised-count guard. --- app/services/levelcode/model_catalog.rb | 25 ++++++++++++++--- lib/levelcode.rb | 16 +++++------ spec/models/levelcode_model_catalog_spec.rb | 27 +++++++++++------- spec/models/levelcode_plans_spec.rb | 31 ++++++++++++++++----- 4 files changed, 70 insertions(+), 29 deletions(-) diff --git a/app/services/levelcode/model_catalog.rb b/app/services/levelcode/model_catalog.rb index 54f62e71..53503729 100644 --- a/app/services/levelcode/model_catalog.rb +++ b/app/services/levelcode/model_catalog.rb @@ -92,12 +92,28 @@ module ModelCatalog input: 5.00, cached_input: 0.50, output: 30.00, context: 1_050_000, min_tier: :pro, status: :confirmed }, + # Fable 5.1 is NOT a price twin of Fable 5: same $10/$50, but cached reads are $0.25/M against + # $1.00, so a reference turn is cheaper (~12.9x vs ~13.3x). The monotonic-multiplier invariant + # orders by cost, so it sits BEFORE Fable 5 and Astra rather than beside them. + "anthropic/claude-fable-5.1" => { + label: "Fable 5.1", provider: "openrouter", + # Confirmed vs the OpenRouter models API (2026-09-08, listed 2026-09-01): $10/M in · $50/M out + # · $0.25/M cached read · 1M ctx. Image + file input. Every endpoint (Anthropic, Azure, Bedrock, + # Google) is at exactly this price, so the routing ceiling leaves all of them standing. + # Tier: pro — the same decision as Fable 5 below. ~20 turns a month on Pro, ~40 on Pro+. + input: 10.00, cached_input: 0.25, output: 50.00, + context: 1_000_000, min_tier: :pro, status: :confirmed + }, "anthropic/claude-fable-5" => { label: "Fable 5", provider: "openrouter", - # OpenRouter 2026-07-26: $10/M in, $50/M out, $1/M cached, 1M ctx. ~13.3x -> ~28 Pro turns; - # gated to Max/Ultra so it never feels broken (rec #3). + # OpenRouter 2026-07-26, re-checked 2026-09-08: $10/M in, $50/M out, $1/M cached, 1M ctx. ~13.3x. + # + # Tier: pro (was max). Owner's decision, 2026-09-08: reach over rec #3. As of that date no + # business-approved editor ships a Fable/Astra-class model, and the dollar budget means a pricey + # pick drains the allowance faster without touching margin. What Pro buys is ~19 turns a month, + # so the honest count lives on the pricing page and in the picker rather than in the gate. input: 10.00, cached_input: 1.00, output: 50.00, - context: 1_000_000, min_tier: :max, status: :confirmed + context: 1_000_000, min_tier: :pro, status: :confirmed }, # Sits immediately after Fable 5 because the rate card is IDENTICAL ($10/$1/$50) — an equal # neighbour, exactly as Opus 5 follows Opus 4.8, so the monotonic-multiplier invariant holds. @@ -109,7 +125,8 @@ module ModelCatalog # # Tier: pro_plus, not max. Same per-turn cost as Fable 5 (~13.3x), which buys ~39 turns on Pro+ — # the number Pro already advertises for Opus 5 — and only ~19 on Pro, which is where rec #3 says a - # model starts to feel broken. Fable 5 stays at :max as previously decided; revisit together. + # model starts to feel broken. Fable 5 / 5.1 went to :pro on 2026-09-08 by the owner's call; + # Astra stays at pro_plus until the same call is made for it. input: 10.00, cached_input: 1.00, output: 50.00, context: 1_050_000, min_tier: :pro_plus, status: :confirmed } diff --git a/lib/levelcode.rb b/lib/levelcode.rb index d295cda2..67d5ec81 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 5", + "2,000 credits/mo · ~260 Kimi turns · ~39 on Opus 5 · ~20 on Fable 5.1", "Usage that refreshes through your billing cycle", - "Kimi K2.7 Code + Opus 5", + "Kimi K2.7 Code + Opus 5 + Fable 5 / 5.1", "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 5", + "4,000 credits/mo · ~520 Kimi turns · ~78 on Opus 5 · ~40 on Fable 5.1", "Usage that refreshes through your billing cycle", - "Kimi K2.7 Code + Opus 5", + "Kimi K2.7 Code + Opus 5 + Fable 5 / 5.1 + GPT-6 Astra", "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 5", + "6,000 credits/mo · ~780 Kimi turns · ~117 on Opus 5 · ~60 on Fable 5.1", "Usage that refreshes through your billing cycle", - "Kimi K2.7 Code + Opus 5", + "Kimi K2.7 Code + Opus 5 + Fable 5 / 5.1 + GPT-6 Astra", "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 5", + "10,000 credits/mo · ~1,300 Kimi turns · ~195 on Opus 5 · ~100 on Fable 5.1", "Usage that refreshes through your billing cycle", - "Kimi K2.7 Code + Opus 5", + "Kimi K2.7 Code + Opus 5 + Fable 5 / 5.1 + GPT-6 Astra", "Highest priority routing" ] } diff --git a/spec/models/levelcode_model_catalog_spec.rb b/spec/models/levelcode_model_catalog_spec.rb index 99ef9c27..421b1ab9 100644 --- a/spec/models/levelcode_model_catalog_spec.rb +++ b/spec/models/levelcode_model_catalog_spec.rb @@ -24,6 +24,7 @@ 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) expect(described_class.find("openai/gpt-6-astra")[:context]).to eq(1_050_000) + expect(described_class.find("anthropic/claude-fable-5.1")[:context]).to eq(1_000_000) # 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 @@ -51,6 +52,9 @@ # 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, "openai/gpt-5.5" => 7.40, + # Fable 5.1 is CHEAPER than Fable 5 per turn — same $10/$50 but $0.25 cached vs $1.00 — so it + # must sit before Fable 5 for the monotonic ordering below to hold. + "anthropic/claude-fable-5.1" => 12.93, # GPT-6 Astra TIES Fable 5 — identical rate card ⇒ identical multiplier — so it sits after Fable # without disturbing the monotonic ordering, the same way Opus 5 sits beside Opus 4.8. "anthropic/claude-fable-5" => 13.35, "openai/gpt-6-astra" => 13.35 @@ -69,20 +73,21 @@ expect(described_class.entitled(:free)).to eq([ "openai/gpt-oss-120b" ]) end - it "pro reaches the roster EXCEPT the ~13x models (Fable, Astra — ~19 Pro turns feels broken, rec #3)" do + it "pro reaches Fable 5 and 5.1 (owner's call, 2026-09-08 — reach over rec #3) but not Astra" do pro = described_class.entitled(:pro) - 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", "openai/gpt-6-astra") + expect(pro).to include("moonshotai/kimi-k2.7-code", "anthropic/claude-opus-4-8", "anthropic/claude-opus-5", "openai/gpt-5.5", + "anthropic/claude-fable-5", "anthropic/claude-fable-5.1") + expect(pro).not_to include("openai/gpt-6-astra") end - it "pro_plus adds GPT-6 Astra (~39 turns — the count Pro already advertises for Opus 5) but not Fable" do + it "pro_plus adds GPT-6 Astra on top of everything Pro reaches" do pro_plus = described_class.entitled(:pro_plus) - expect(pro_plus).to include("openai/gpt-6-astra") - expect(pro_plus).not_to include("anthropic/claude-fable-5") + expect(pro_plus).to include("openai/gpt-6-astra", "anthropic/claude-fable-5", "anthropic/claude-fable-5.1") + expect(pro_plus).to include(*described_class.entitled(:pro)) end - it "Max/Ultra reach the full roster incl. Fable and Astra" do - expect(described_class.entitled(:max)).to include("anthropic/claude-fable-5", "openai/gpt-6-astra") + it "Max/Ultra reach the full roster" do + expect(described_class.entitled(:max)).to include("anthropic/claude-fable-5", "anthropic/claude-fable-5.1", "openai/gpt-6-astra") expect(described_class.entitled(:ultra)).to match_array(described_class.ids) end end @@ -139,9 +144,11 @@ end describe ".entitled_models / .plan_tier" do - it "maps plan keys to tiers and gates Fable to Max/Ultra" do + it "maps plan keys to tiers; Pro reaches Fable 5 / 5.1 (2026-09-08), Astra still needs Pro+" do expect(Levelcode.plan_tier("orbits_pro")).to eq(:pro) - expect(Levelcode.entitled_models("orbits_pro")).not_to include("anthropic/claude-fable-5") + expect(Levelcode.entitled_models("orbits_pro")).to include("anthropic/claude-fable-5", "anthropic/claude-fable-5.1") + expect(Levelcode.entitled_models("orbits_pro")).not_to include("openai/gpt-6-astra") + expect(Levelcode.entitled_models("orbits_pro_plus")).to include("openai/gpt-6-astra") expect(Levelcode.entitled_models("orbits_ultra")).to include("anthropic/claude-fable-5") expect(Levelcode.entitled_models("free")).to eq([ "openai/gpt-oss-120b" ]) end diff --git a/spec/models/levelcode_plans_spec.rb b/spec/models/levelcode_plans_spec.rb index 7d077452..28c424b1 100644 --- a/spec/models/levelcode_plans_spec.rb +++ b/spec/models/levelcode_plans_spec.rb @@ -107,6 +107,22 @@ end end + describe 'the turn counts advertised in PLANS features' do + # The pricing page prints these strings verbatim. Pin them to the formula so a catalog price change + # (or a hand edit) cannot leave the page promising turns the budget no longer buys. + it 'match what turns_for computes — exact for the frontier models, within 1% for the rounded Kimi figure' do + Levelcode::PLANS.each do |plan| + line = plan[:features].first + kimi = line[/~([\d,]+) Kimi turns/, 1].delete(',').to_i + opus = line[/~(\d+) on Opus 5/, 1].to_i + fable = line[/~(\d+) on Fable 5\.1/, 1].to_i + expect(opus).to eq(Levelcode.turns_for(plan[:key], 'anthropic/claude-opus-5')), "#{plan[:key]}: Opus 5" + expect(fable).to eq(Levelcode.turns_for(plan[:key], 'anthropic/claude-fable-5.1')), "#{plan[:key]}: Fable 5.1" + expect(kimi).to be_within(Levelcode.turns_for(plan[:key], Levelcode::DEFAULT_MODEL) * 0.01).of(Levelcode.turns_for(plan[:key], Levelcode::DEFAULT_MODEL)), "#{plan[:key]}: Kimi" + end + end + end + describe '.allowed_models / .gateway_model (entitlement)' do it 'free/no-plan may use ONLY the open-weights engine' do expect(Levelcode.allowed_models('free')).to eq([ Levelcode::FREE_MODEL ]) @@ -115,15 +131,16 @@ it 'a paid plan may use the flagship AND the open-weights engine (confirmed-price roster)' do # Every Pro-tier engine is confirmed now — the frontier rows (Codex 5.3, GPT-5.5, Sonnet 5) went live. - 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', 'openai/gpt-5.3-codex', 'openai/gpt-5.5', 'anthropic/claude-sonnet-5' ]) - # Fable 5 is confirmed too, but Max-tier — a Pro plan still can't reach it (entitlement, not price). - expect(Levelcode.allowed_models('orbits_pro')).not_to include('anthropic/claude-fable-5', 'openai/gpt-6-astra') + 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', 'openai/gpt-5.3-codex', 'openai/gpt-5.5', 'anthropic/claude-sonnet-5', + 'anthropic/claude-fable-5', 'anthropic/claude-fable-5.1' ]) + # Fable 5 / 5.1 are Pro-tier since 2026-09-08 (owner's call). Astra is the one Pro can't reach. + expect(Levelcode.allowed_models('orbits_pro')).not_to include('openai/gpt-6-astra') end - it 'Pro+ reaches GPT-6 Astra; Fable stays Max-tier; Max reaches both' do - expect(Levelcode.allowed_models('orbits_pro_plus')).to include('openai/gpt-6-astra') - expect(Levelcode.allowed_models('orbits_pro_plus')).not_to include('anthropic/claude-fable-5') - expect(Levelcode.allowed_models('orbits_max')).to include('openai/gpt-6-astra', 'anthropic/claude-fable-5') + it 'Pro reaches Fable 5.1 as requested; Pro+ adds GPT-6 Astra; Max reaches all three' do + expect(Levelcode.gateway_model('orbits_pro', 'anthropic/claude-fable-5.1')).to eq('anthropic/claude-fable-5.1') + expect(Levelcode.allowed_models('orbits_pro_plus')).to include('openai/gpt-6-astra', 'anthropic/claude-fable-5', 'anthropic/claude-fable-5.1') + expect(Levelcode.allowed_models('orbits_max')).to include('openai/gpt-6-astra', 'anthropic/claude-fable-5', 'anthropic/claude-fable-5.1') # Entitlement, not price: a Pro user asking for Astra is routed to the plan default, never 402'd. expect(Levelcode.gateway_model('orbits_pro', 'openai/gpt-6-astra')).to eq(Levelcode::DEFAULT_MODEL) expect(Levelcode.gateway_model('orbits_pro_plus', 'openai/gpt-6-astra')).to eq('openai/gpt-6-astra') From 6208dd2f2c3c1c612eca159202d0b0bacfb2e29e Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sat, 3 Oct 2026 01:45:59 -0400 Subject: [PATCH 2/3] test(levelcode): "every roster engine" is asked of the roster, not of a hand-kept list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review on #431: Fable 5.1 was not in the list of ids the "carries a confirmed price on every roster engine" example iterates, so a status regression on it would go unnoticed there. True, and it was not the only one. The list had nine ids and the roster has eleven — GPT-6 Astra, added in #428, was missing too. Rather than add two ids and wait for the next model to be forgotten, the example now rejects every row whose status is not :confirmed and expects none. That also widens what it can see: the old second half looked only for :assumption, so a mistyped or missing status on an unlisted row matched neither check. That matters because allowed_models selects on `status == :confirmed`: any other value makes a model silently unselectable and unbillable. Verified by breaking the catalog and running the example before and after: hand-kept list roster-wide Fable 5.1 status mistyped passes FAILS GPT-6 Astra status key removed passes FAILS Fable 5 mistyped (was on the list) fails fails a row staged as :assumption fails fails The failure now names the row: expected [], got ["anthropic/claude-fable-5.1"]. --- spec/models/levelcode_model_catalog_spec.rb | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/spec/models/levelcode_model_catalog_spec.rb b/spec/models/levelcode_model_catalog_spec.rb index 421b1ab9..68c53d37 100644 --- a/spec/models/levelcode_model_catalog_spec.rb +++ b/spec/models/levelcode_model_catalog_spec.rb @@ -4,15 +4,12 @@ # design invariants hold (entitlement tiers, monotonic multipliers, dollar-denominated budgets). RSpec.describe Levelcode::ModelCatalog do describe "roster integrity" do + # Asked of the roster itself, not of a list of ids kept by hand: only a :confirmed row is selectable + # or billable, so any other status — staged as "coming soon", mistyped, or missing — is a model + # that has silently gone dark. A hand-kept list guards only the rows someone remembered to add. it "carries a confirmed price on every roster engine — the frontier rows are enabled now" do - %w[openai/gpt-oss-120b moonshotai/kimi-k2.7-code anthropic/claude-opus-4-8 moonshotai/kimi-k3 - anthropic/claude-opus-5 openai/gpt-5.3-codex openai/gpt-5.5 anthropic/claude-sonnet-5 - anthropic/claude-fable-5].each do |id| - expect(described_class.find(id)[:status]).to eq(:confirmed), id - end - # Nothing left staged — the "coming soon" set is empty. - assumed = described_class.all.select { |_id, m| m[:status] == :assumption }.keys - expect(assumed).to be_empty + unconfirmed = described_class.all.reject { |_id, model| model[:status] == :confirmed }.keys + expect(unconfirmed).to eq([]) end # `context` is not decoration — estimate_cost_micros clamps its input estimate DOWN to it, so a From 0f97d64d4f7ab20c923b639999caa44bdc69fb68 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sat, 3 Oct 2026 01:47:16 -0400 Subject: [PATCH 3/3] test(levelcode): the pricing-line guard says what is wrong when it fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from review on #431, and a third of the same kind found while fixing them. The example read its numbers with `line[/…/, 1].delete(',').to_i`. If the copy changed shape, a missed pattern was a nil: NoMethodError for the Kimi figure, and for the other two a count that silently read as 0. The line is now matched against one pattern with named captures, and that match is asserted before anything is read from it. The assertions also carried custom messages — "orbits_pro: Opus 5" — which replace RSpec's own. So a genuine drift printed the plan and the model and neither number. They are gone; the two exact counts are compared as one hash, whose diff names the model and shows both sides. And the example looped over the plans inside a single `it`, so the first plan to fail hid the rest. There is now one example per plan. What it prints, before and after, for the same three breaks: copy reshaped (Kimi) NoMethodError: undefined method 'delete' for nil -> expected "2,000 credits/mo · ~260 turns on Kimi · …" to match /…/ copy reshaped (Opus) orbits_pro: Opus 5 -> expected "… ~39 with Opus 5 …" to match /…/ Pro advertises ~25 orbits_pro: Fable 5.1 -> expected: {fable: 20, opus: 39} got: {fable: 25, opus: 39} On the wording: the reviewer read `it 'match what turns_for computes'` as ungrammatical. RSpec prints it joined to its describe — "the turn counts … match what turns_for computes" — where the plural is correct and "matches" would not be. But an `it` that does not read on its own is a fair complaint, so the subject is now singular: "the pricing line advertises the turns Pro's budget buys". Also verified: a drift on Max alone fails only Max's example; the rounded Kimi figure off by more than 1% fails; and a catalog price change — Fable 5.1's cached read to $1.00 — fails all four plans, each showing what the budget buys against what the page says. --- spec/models/levelcode_plans_spec.rb | 30 ++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/spec/models/levelcode_plans_spec.rb b/spec/models/levelcode_plans_spec.rb index 28c424b1..133535ac 100644 --- a/spec/models/levelcode_plans_spec.rb +++ b/spec/models/levelcode_plans_spec.rb @@ -107,18 +107,26 @@ end end - describe 'the turn counts advertised in PLANS features' do - # The pricing page prints these strings verbatim. Pin them to the formula so a catalog price change - # (or a hand edit) cannot leave the page promising turns the budget no longer buys. - it 'match what turns_for computes — exact for the frontier models, within 1% for the rounded Kimi figure' do - Levelcode::PLANS.each do |plan| + # The pricing page prints PLANS[:features] verbatim. Pin the turn counts in each plan's first line + # to the formula, so a catalog price change (or a hand edit) cannot leave the page promising turns + # the budget no longer buys. + describe 'the pricing line' do + advertised = /~(?[\d,]+) Kimi turns · ~(?\d+) on Opus 5 · ~(?\d+) on Fable 5\.1\z/ + + Levelcode::PLANS.each do |plan| + it "advertises the turns #{plan[:name]}'s budget buys — exact for the frontier models, within 1% for the rounded Kimi figure" do line = plan[:features].first - kimi = line[/~([\d,]+) Kimi turns/, 1].delete(',').to_i - opus = line[/~(\d+) on Opus 5/, 1].to_i - fable = line[/~(\d+) on Fable 5\.1/, 1].to_i - expect(opus).to eq(Levelcode.turns_for(plan[:key], 'anthropic/claude-opus-5')), "#{plan[:key]}: Opus 5" - expect(fable).to eq(Levelcode.turns_for(plan[:key], 'anthropic/claude-fable-5.1')), "#{plan[:key]}: Fable 5.1" - expect(kimi).to be_within(Levelcode.turns_for(plan[:key], Levelcode::DEFAULT_MODEL) * 0.01).of(Levelcode.turns_for(plan[:key], Levelcode::DEFAULT_MODEL)), "#{plan[:key]}: Kimi" + # Asserted before it is relied on: if the copy changes shape, the failure is this line saying + # so — not a nil further down, or a count that silently reads as 0. + expect(line).to match(advertised) + counts = line.match(advertised) + + expect(opus: counts[:opus].to_i, fable: counts[:fable].to_i).to eq( + opus: Levelcode.turns_for(plan[:key], 'anthropic/claude-opus-5'), + fable: Levelcode.turns_for(plan[:key], 'anthropic/claude-fable-5.1') + ) + kimi = Levelcode.turns_for(plan[:key], Levelcode::DEFAULT_MODEL) + expect(counts[:kimi].delete(',').to_i).to be_within(kimi * 0.01).of(kimi) end end end