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
14 changes: 14 additions & 0 deletions app/services/levelcode/model_catalog.rb
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,20 @@ module ModelCatalog
# gated to Max/Ultra so it never feels broken (rec #3).
input: 10.00, cached_input: 1.00, output: 50.00,
context: 1_000_000, min_tier: :max, 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.
"openai/gpt-6-astra" => {
label: "GPT-6 Astra", provider: "openrouter",
# Confirmed vs the OpenRouter models API (2026-09-06, listed 2026-09-04): $10/M in · $50/M out
# · $1/M cached read · 1.05M ctx (128K out). Image + file input. Its ENDPOINTS span $5/$25 (Flex)
# to $20/$100 (Fast) — the routing ceiling in OpenRouterAdapter is what keeps our cost at this row.
#
# 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.
input: 10.00, cached_input: 1.00, output: 50.00,
context: 1_050_000, min_tier: :pro_plus, status: :confirmed
}
}.freeze

Expand Down
63 changes: 51 additions & 12 deletions app/services/levelcode/open_router_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ class OpenRouterAdapter

# OpenRouter provider routing for the FREE model (gpt-oss). OpenRouter's "WandB" provider
# mishandles the gpt-oss "harmony" format and 401s with "Unknown role: final", so pin the free
# model to reliable providers and exclude WandB. Scoped to the free modelpaid models keep full
# routing. See atompp-internal/orbits-build/DECISIONS.md (2026-07-08). If WandB reappears, verify
# the `ignore` slug against OpenRouter's provider list / Activity log.
# model to reliable providers and exclude WandB. Scoped to the free model; paid models get a PRICE
# ceiling instead (with_provider_routing). If WandB reappears, verify the `ignore` slug against
# OpenRouter's provider list / Activity log.
FREE_MODEL_PROVIDER = {
"order" => %w[fireworks together deepinfra],
"ignore" => %w[wandb],
Expand All @@ -46,7 +46,7 @@ def stream(body, on_chunk:)
# final usage chunk otherwise, and the request would consume tokens unmetered
# (cap bypass + under-billing). Preserve any client-supplied stream_options.
stream_opts = (body["stream_options"] || body[:stream_options] || {}).merge("include_usage" => true)
request = build_request(uri, with_free_model_routing(body).merge("stream" => true, "stream_options" => stream_opts))
request = build_request(uri, with_provider_routing(body).merge("stream" => true, "stream_options" => stream_opts))

usage = nil
model = body["model"] || body[:model]
Expand Down Expand Up @@ -89,7 +89,7 @@ def stream(body, on_chunk:)
# Non-streaming pass-through. Returns the parsed upstream JSON Hash.
def complete(body)
uri = URI(BASE_URL)
request = build_request(uri, with_free_model_routing(body).merge("stream" => false))
request = build_request(uri, with_provider_routing(body).merge("stream" => false))
response = http(uri).request(request)

unless response.code.to_i == 200
Expand All @@ -111,15 +111,54 @@ def initialize(status, body)

private

# Inject the free-model provider routing (FREE_MODEL_PROVIDER) when the request targets the
# gpt-oss free model, unless the client already supplied its own `provider` preference. Every
# other model is returned unchanged so paid routing is untouched.
def with_free_model_routing(body)
# Shape the upstream `provider` preference for the routed model.
#
# free model (gpt-oss) → FREE_MODEL_PROVIDER, unless the client sent its own preference.
# any other catalog row → a PRICE CEILING at that row's catalog rate, merged ON TOP of whatever the
# client sent (a client may steer routing; it may not lift the ceiling).
# off-catalog / Moonshot-native → untouched.
#
# WHY THE CEILING. Metering bills a request at the catalog rate (Levelcode.cost_micros) and never
# reads what OpenRouter actually charged. OpenRouter, left to route freely, spreads one model over
# endpoints priced up to 2x that rate — GPT-6 Astra runs $5/$25 (Flex) through $20/$100 (Fast), and
# Opus 4.8 has an Anthropic "fast" tier at $10/$50 against our $5/$25. Routed there, we under-bill by
# half and cannot see it. `max_price` makes wire cost <= billed cost by construction.
#
# It is a HARD filter: OpenRouter refuses the request outright if no endpoint fits, rather than
# quietly running it over price. That is the safe money direction — the same direction rate_for
# takes for an off-catalog id — and it turns a stale catalog price into a loud 4xx instead of a
# silent loss. Units line up without conversion: OpenRouter reads max_price in $/M tokens, and a
# catalog rate is micro-$/token, which is the same number.
#
# The FREE model is deliberately NOT ceilinged: its catalog row ($0.03/$0.15) sits below its listed
# endpoints ($0.04/$0.17), so a ceiling would exclude every endpoint and take the free tier down.
def with_provider_routing(body)
model = (body["model"] || body[:model]).to_s
return body unless model.include?("gpt-oss")
return body if body.key?("provider") || body.key?(:provider)
if model.include?("gpt-oss")
return body if body.key?("provider") || body.key?(:provider)

return body.merge("provider" => FREE_MODEL_PROVIDER)
end

ceiling = price_ceiling(model)
return body unless ceiling

# Exactly one string-keyed "provider" goes out, whatever the caller sent. A symbol-keyed body
# would otherwise serialise :provider beside "provider" (and :max_price beside "max_price"), and
# which of the two OpenRouter honours would be down to its parser — the ceiling would hold or
# not by accident. A preference that is not an object is dropped rather than raised on: the
# ceiling is the part that has to survive, and a 500 here would be ours, not upstream's.
client = body["provider"] || body[:provider]
client = client.is_a?(Hash) ? client.transform_keys(&:to_s).except("max_price") : {}
body.except(:provider).merge("provider" => client.merge("max_price" => ceiling))
end

# `{"prompt" => $/M, "completion" => $/M}` for a catalog model, nil for anything else.
def price_ceiling(model)
row = Levelcode::ModelCatalog.find(model)
return nil unless row

body.merge("provider" => FREE_MODEL_PROVIDER)
{ "prompt" => row[:input].to_f, "completion" => row[:output].to_f }
end

def http(uri)
Expand Down
20 changes: 15 additions & 5 deletions spec/models/levelcode_model_catalog_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
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)
expect(described_class.find("openai/gpt-6-astra")[:context]).to eq(1_050_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
Expand All @@ -49,7 +50,10 @@
# 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,
"openai/gpt-5.5" => 7.40, "anthropic/claude-fable-5" => 13.35
"openai/gpt-5.5" => 7.40,
# 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
}.each { |id, mult| expect(described_class.multiplier(id)).to be_within(0.02).of(mult) }
end

Expand All @@ -65,14 +69,20 @@
expect(described_class.entitled(:free)).to eq([ "openai/gpt-oss-120b" ])
end

it "pro reaches the roster EXCEPT Fable (gated to Max/Ultra by UX, rec #3)" do
it "pro reaches the roster EXCEPT the ~13x models (Fable, Astra — ~19 Pro turns feels broken, rec #3)" 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")
expect(pro).not_to include("anthropic/claude-fable-5", "openai/gpt-6-astra")
end

it "Max/Ultra reach the full roster incl. Fable" do
expect(described_class.entitled(:max)).to include("anthropic/claude-fable-5")
it "pro_plus adds GPT-6 Astra (~39 turns — the count Pro already advertises for Opus 5) but not Fable" 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")
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")
expect(described_class.entitled(:ultra)).to match_array(described_class.ids)
end
end
Expand Down
11 changes: 10 additions & 1 deletion spec/models/levelcode_plans_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,16 @@
# 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')
expect(Levelcode.allowed_models('orbits_pro')).not_to include('anthropic/claude-fable-5', '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')
# 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')
end

it 'free CANNOT reach the flagship no matter what is requested' do
Expand Down
99 changes: 99 additions & 0 deletions spec/services/levelcode/open_router_adapter_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,103 @@ def status_of(payload) = adapter.send(:error_frame_status, payload)
expect(status_of('{"usage":{"total_tokens":9}}')).to be_nil
end
end

# Metering bills at the catalog rate and never reads OpenRouter's own cost, so the only thing
# keeping wire cost <= billed cost is the price ceiling this helper injects. These pin its shape.
describe "#with_provider_routing (a private request-shaping helper)" do
def routed(body) = adapter.send(:with_provider_routing, body)

it "pins the free model to FREE_MODEL_PROVIDER and never ceilings it" do
out = routed("model" => Levelcode::FREE_MODEL, "messages" => [])
expect(out["provider"]).to eq(described_class::FREE_MODEL_PROVIDER)
expect(out["provider"]).not_to have_key("max_price")
end

it "leaves a client-supplied provider preference alone on the free model" do
pref = { "order" => %w[fireworks] }
expect(routed("model" => Levelcode::FREE_MODEL, "provider" => pref)["provider"]).to eq(pref)
end

it "ceilings a paid catalog model at exactly its catalog rate, in $/M" do
row = Levelcode::ModelCatalog.find("openai/gpt-6-astra")
out = routed("model" => "openai/gpt-6-astra", "messages" => [])
expect(out["provider"]).to eq("max_price" => { "prompt" => 10.0, "completion" => 50.0 })
expect(out["provider"]["max_price"]).to eq("prompt" => row[:input].to_f, "completion" => row[:output].to_f)
end

it "ceilings every confirmed paid row at its own rate (no model is left routable over price)" do
Levelcode::ModelCatalog.ids.reject { |id| id == Levelcode::FREE_MODEL }.each do |id|
row = Levelcode::ModelCatalog.find(id)
mp = routed("model" => id)["provider"]["max_price"]
expect(mp).to eq("prompt" => row[:input].to_f, "completion" => row[:output].to_f), id
end
end

it "merges the ceiling ON TOP of a client preference — steering is kept, the ceiling cannot be lifted" do
out = routed("model" => "anthropic/claude-opus-5",
"provider" => { "sort" => "throughput", "max_price" => { "prompt" => 99.0, "completion" => 999.0 } })
expect(out["provider"]["sort"]).to eq("throughput")
expect(out["provider"]["max_price"]).to eq("prompt" => 5.0, "completion" => 25.0)
end

it "drops a provider preference that is not an object and still applies the ceiling (never a 500)" do
[ "openai", %w[openai azure], true, false, nil, 7 ].each do |bad|
out = routed("model" => "openai/gpt-6-astra", "provider" => bad)
expect(out["provider"]).to eq("max_price" => { "prompt" => 10.0, "completion" => 50.0 }), bad.inspect
end
end

it "normalises a symbol-keyed body to ONE string-keyed provider, so the ceiling survives serialisation" do
out = routed(model: "openai/gpt-6-astra", provider: { sort: "price", max_price: { prompt: 99.0, completion: 999.0 } })
expect(out).not_to have_key(:provider)
expect(out["provider"]).to eq("sort" => "price", "max_price" => { "prompt" => 10.0, "completion" => 50.0 })
json = JSON.generate(out)
expect(json.scan('"provider"').size).to eq(1)
expect(json.scan('"max_price"').size).to eq(1)
end

it "leaves an off-catalog model untouched (nothing to ceiling against)" do
body = { "model" => "vendor/not-in-catalog", "messages" => [] }
expect(routed(body)).to eq(body)
end

it "does not mutate the caller's body" do
body = { "model" => "openai/gpt-6-astra" }.freeze
expect { routed(body) }.not_to raise_error
expect(body).not_to have_key("provider")
end
end

# The helper above is only worth anything if #stream and #complete actually route through it. No
# spec drives those (the request specs double the router), so pin them at the adapter's own wire
# seam: build_request is what serialises the upstream body, so the body it receives IS the wire.
# A sentinel stops the call before any socket is opened.
describe "#stream / #complete put the provider routing on the wire" do
sentinel = Class.new(StandardError)
let(:sent) { {} }

before do
allow(adapter).to receive(:build_request) do |_uri, body|
sent.replace(body)
raise sentinel
end
end

it "#stream sends the price ceiling for a paid model" do
expect { adapter.stream({ "model" => "openai/gpt-6-astra", "messages" => [] }, on_chunk: ->(_) { }) }.to raise_error(sentinel)
expect(sent["provider"]).to eq("max_price" => { "prompt" => 10.0, "completion" => 50.0 })
expect(sent["stream"]).to be(true)
end

it "#complete sends the price ceiling for a paid model" do
expect { adapter.complete("model" => "anthropic/claude-opus-5", "messages" => []) }.to raise_error(sentinel)
expect(sent["provider"]).to eq("max_price" => { "prompt" => 5.0, "completion" => 25.0 })
expect(sent["stream"]).to be(false)
end

it "#stream still pins the free model to FREE_MODEL_PROVIDER" do
expect { adapter.stream({ "model" => Levelcode::FREE_MODEL, "messages" => [] }, on_chunk: ->(_) { }) }.to raise_error(sentinel)
expect(sent["provider"]).to eq(described_class::FREE_MODEL_PROVIDER)
end
end
end