diff --git a/app/services/levelcode/model_catalog.rb b/app/services/levelcode/model_catalog.rb index 84f92b62..54f62e71 100644 --- a/app/services/levelcode/model_catalog.rb +++ b/app/services/levelcode/model_catalog.rb @@ -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 diff --git a/app/services/levelcode/open_router_adapter.rb b/app/services/levelcode/open_router_adapter.rb index bccc77cc..486802bf 100644 --- a/app/services/levelcode/open_router_adapter.rb +++ b/app/services/levelcode/open_router_adapter.rb @@ -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 model — paid 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], @@ -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] @@ -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 @@ -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) diff --git a/spec/models/levelcode_model_catalog_spec.rb b/spec/models/levelcode_model_catalog_spec.rb index 81fb1c0c..99ef9c27 100644 --- a/spec/models/levelcode_model_catalog_spec.rb +++ b/spec/models/levelcode_model_catalog_spec.rb @@ -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 @@ -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 @@ -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 diff --git a/spec/models/levelcode_plans_spec.rb b/spec/models/levelcode_plans_spec.rb index 5722138c..7d077452 100644 --- a/spec/models/levelcode_plans_spec.rb +++ b/spec/models/levelcode_plans_spec.rb @@ -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 diff --git a/spec/services/levelcode/open_router_adapter_spec.rb b/spec/services/levelcode/open_router_adapter_spec.rb index d9d7a6dd..ae5064b0 100644 --- a/spec/services/levelcode/open_router_adapter_spec.rb +++ b/spec/services/levelcode/open_router_adapter_spec.rb @@ -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