Skip to content

feat(levelcode): GPT-6 Astra at Pro+, and a price ceiling on every paid OpenRouter route - #428

Merged
ndemianc merged 2 commits into
developfrom
feat/gpt-6-astra
Sep 7, 2026
Merged

feat(levelcode): GPT-6 Astra at Pro+, and a price ceiling on every paid OpenRouter route#428
ndemianc merged 2 commits into
developfrom
feat/gpt-6-astra

Conversation

@ndemianc

@ndemianc ndemianc commented Sep 7, 2026

Copy link
Copy Markdown
Member

What

Two things, one of which outlives the model.

GPT-6 Astra (openai/gpt-6-astra) joins the catalog at min_tier: :pro_plus, status: :confirmed. Rate card verified against the OpenRouter models API on 2026-09-06: $10/M in · $50/M out · $1/M cached · 1.05M ctx · image + file input. It is an exact price twin of Fable 5 and sits right after it, so the monotonic-multiplier invariant holds. Nothing on the frontend changes — the pricing page and the editor picker re-read the roster.

A price ceiling on every paid OpenRouter route. OpenRouterAdapter now sends provider.max_price = { prompt, completion } at the model's catalog rate, for every paid catalog row, merged on top of any client-supplied provider preference.

Why the ceiling

Levelcode.cost_micros bills at the catalog rate. OpenRouter's actual usage.cost is never read. Left to route freely, OpenRouter spreads a single model across endpoints priced up to 2× our rate:

Model Our rate Cheapest endpoint Priciest endpoint
GPT-6 Astra $10 / $50 OpenAI Flex $5 / $25 OpenAI Fast $20 / $100
Opus 4.8 / Opus 5 $5 / $25 $5 / $25 Anthropic fast $10 / $50

Routed to the priciest tier we under-bill by half and have no signal that it happened. The ceiling makes wire cost ≤ billed cost by construction. Units line up with no conversion: OpenRouter reads max_price in $/M, and a catalog rate is micro-$/token — the same number.

It is a hard filter: OpenRouter refuses the request if no endpoint fits rather than running it over price. That is the safe money direction (the one rate_for already takes for off-catalog ids), and it turns a stale catalog price into a loud 4xx instead of a silent loss. Every current paid row was checked against the live models API: catalog ≥ listed for all of them, so at least the headline endpoint always fits.

The free model is deliberately not ceilinged — its catalog row ($0.03/$0.15) is below its listed endpoints ($0.04/$0.17), so a ceiling would exclude every endpoint. It keeps its existing FREE_MODEL_PROVIDER pin.

Why Pro+ and not Pro

The dollar budget (CREDIT_COGS_RATIO) means tier placement is a UX decision, not a margin one. At ~13.3× a reference turn Astra buys ~39 turns on Pro+ — the number Pro already advertises for Opus 5 — and ~19 on Pro, which is where rec #3 says a model starts to feel broken. Fable 5 (same price) stays at :max as previously decided; the asymmetry is intentional and easy to close later.

Verification

  • Full suite: 1027 examples, 0 failures. rubocop clean on every changed file.
  • No spec drove #stream / #complete before (request specs double the router), so three wire examples now pin the body at build_request, the seam that serialises it.
  • Each change was reverted in turn to prove its spec is load-bearing:
    • adapter reverted → 7 helper examples fail
    • only the ceiling merge removed → exactly the 3 ceiling examples fail
    • catalog row reverted → the 6 Astra examples fail across catalog / plans / adapter
    • #stream made to skip the helper → its 2 wire examples fail, #complete's passes

Not in this PR — surfaced while checking

  • openai/gpt-oss-120b is under-priced in the catalog ($0.03/$0.15 vs $0.04/$0.17 listed). free_budget_micros is derived from it, so the free ceiling understates real free COGS by ~30%. One-line fix; it changes free wallet provisioning, so it wants its own change.
  • Kimi K2.7 Code is over-priced ($0.74/$3.50 vs $0.66/$3.40 listed) — the "sized against the old price" note in PLANS is still true, in the other direction.
  • No per-model circuit breaker exists (rec Rails app vite build #6 in the GPT-5.6 plan). Not new; noted because this is the second frontier row added without one.
  • A live probe of the ceiling against OpenRouter has not been run — it would spend a real (tiny) completion on the production key. Offered separately.

Companion: the editor needs a CAPS row for gpt-6-astra or the composer refuses images and the meter assumes a 200k window — levelcodeai/levelcode#91 (levelcodeai/levelcode#91).

…id OpenRouter route

GPT-6 Astra (openai/gpt-6-astra) joins the catalog at the same rate card as Fable 5 —
$10/M in, $50/M out, $1/M cached, 1.05M ctx — confirmed against the OpenRouter models API
on 2026-09-06. It sits immediately after Fable 5 as an equal neighbour, the way Opus 5
sits beside Opus 4.8, so the monotonic-multiplier invariant holds untouched.

Tier: pro_plus, not max. At ~13.3x a reference turn it buys ~39 turns on Pro+ — the count
Pro already advertises for Opus 5 — and only ~19 on Pro, which is where rec #3 says a model
starts to feel broken. The dollar budget makes the tier a UX choice rather than a margin
one; margin is 1 - CREDIT_COGS_RATIO regardless of what a user picks. Fable 5 stays at max
as previously decided.

The ceiling is the part that matters beyond this one model. Metering bills at the catalog
rate and never reads OpenRouter's own usage.cost; OpenRouter, left to route freely, spreads a
model over endpoints priced up to 2x that rate — 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. Every paid catalog row now carries
provider.max_price at its own catalog rate, merged on top of any client preference (a
client may steer routing, it may not lift the ceiling). OpenRouter treats max_price as a
hard filter, so a stale catalog price becomes a loud 4xx rather than a silent loss — the
same money direction rate_for already takes for an off-catalog id.

The free model is deliberately not ceilinged: its catalog row ($0.03/$0.15) is below its
listed endpoints ($0.04/$0.17), so a ceiling would exclude every endpoint and take the free
tier down. It keeps its existing provider pin. That row being under list is a separate
finding, noted in the PR.

Verification. Full suite 1027 examples, 0 failures (was 1027 - 9 + the 3 wire examples).
Each change was reverted in turn to prove its spec is load-bearing: adapter reverted -> 7
helper examples fail; ceiling merge alone removed -> exactly the 3 ceiling examples fail;
catalog row reverted -> the 6 Astra examples fail across catalog, plans and adapter; #stream
made to skip the helper -> its 2 wire examples fail while #complete's passes. No spec drove
#stream/#complete before this (the request specs double the router), so the wire examples
pin the body at build_request, the seam that serialises it. rubocop clean.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

with_provider_routing can raise (and/or emit duplicate JSON keys) when provider is not a Hash or is symbol-keyed, which risks gateway 500s and undermines the “ceiling cannot be lifted” invariant.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds the new OpenRouter model openai/gpt-6-astra to the Levelcode catalog (tiered to Pro+) and updates the OpenRouter request shaping to enforce a per-model price ceiling (provider.max_price) for paid catalog models so upstream routing can’t exceed the catalog billing rate.

Changes:

  • Add openai/gpt-6-astra to Levelcode::ModelCatalog with confirmed pricing and Pro+ entitlement.
  • Inject provider.max_price in Levelcode::OpenRouterAdapter for non-free catalog models, merged with any client routing preferences.
  • Add/adjust specs to pin the routing helper behavior and tier entitlements.
File summaries
File Description
app/services/levelcode/open_router_adapter.rb Adds provider-routing helper that pins free model providers and ceilings paid models via max_price.
app/services/levelcode/model_catalog.rb Adds GPT-6 Astra catalog row (rates, context, min tier).
spec/services/levelcode/open_router_adapter_spec.rb Pins helper output shape and ensures #stream/#complete put routing on the wire.
spec/models/levelcode_plans_spec.rb Updates plan-level allowed-model expectations and gateway routing for Astra.
spec/models/levelcode_model_catalog_spec.rb Extends catalog entitlement/multiplier/context assertions to include Astra.
Review details

Suppressed comments (1)

app/services/levelcode/open_router_adapter.rb:120

  • The with_provider_routing docstring says "off-catalog / Moonshot-native → untouched", but the implementation ceilings any model present in Levelcode::ModelCatalog (including moonshotai/* rows) whenever the request routes through OpenRouterAdapter (e.g., when MoonshotAdapter is disabled). This line is misleading; consider rewording to only describe the actual behavior.
    #   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.
    #
  • Files reviewed: 5/5 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +146 to +147
client = body["provider"] || body[:provider] || {}
body.merge("provider" => client.merge("max_price" => ceiling))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in da13cc7.

  • Non-object provider (string / array / bool / number): dropped, ceiling applied — never raises. You were right that this was a regression: before the ceiling such a body reached OpenRouter and came back as a sanitised 400; with it, it was a NoMethodError of ours. Chose drop over reject because the ceiling is the part that must survive and the only client (the editor) never sends a preference.
  • Symbol-keyed body: keys stringified, any client max_price stripped before ours is merged, :provider removed so exactly one string-keyed "provider" goes out. The new example asserts on JSON.generate output — one "provider", one "max_price".

One nuance for the record: in production the body arrives as params.permit!.to_h (indifferent access), so the duplicate-key case could not occur live — but the || body[:provider] fallback invites internal callers to pass plain symbol hashes, so it was worth closing rather than arguing about.

Bypass-verified: reverting the normalisation to the reviewed lines fails exactly the two new examples. Full suite 1032 / 0.

Comment on lines +20 to +22
# model to reliable providers and exclude WandB. Scoped to the free model; paid models get a PRICE
# ceiling instead (with_provider_routing). See atompp-internal/orbits-build/DECISIONS.md (D58,
# D60). If WandB reappears, verify the `ignore` slug against OpenRouter's provider list.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in da13cc7 — the path is gone rather than replaced. It was the only pointer of its kind in this repository (a pre-existing one I had extended), and the surrounding comment already carries the full reasoning, so nothing is lost for a reader here. The decision record lives in the commit message and PR description instead.

…e; drop the external pointer

Two review findings on #428.

The ceiling helper called `.merge` on whatever the client put under `provider`. A string, an
array, `true` — anything that is not an object — raised NoMethodError, and that is a 500 of
ours where the request used to reach OpenRouter and come back as a sanitised 400. A
non-object preference is now dropped and the ceiling applied: the ceiling is the part that
has to survive, and the only client never sends a preference at all.

Symbol-keyed bodies serialised `:provider` beside `"provider"` (and `:max_price` beside
`"max_price"`), leaving it to OpenRouter's parser which one won — the ceiling would hold or
not by accident. In production this could not happen (bodies arrive as `params.permit!.to_h`,
indifferent access), but the `|| body[:provider]` fallback invites internal callers to pass
plain symbol hashes, so it is closed: keys are stringified, any client `max_price` is
stripped before ours goes on, and exactly one string-keyed `"provider"` leaves. The new
examples assert on the serialised JSON — one `"provider"`, one `"max_price"`.

The comment on FREE_MODEL_PROVIDER pointed at a file in another repository. It was the only
such pointer in this codebase and the surrounding comment already carries the reasoning, so
the path is gone rather than replaced.

Verified: adapter spec 16 examples; with the normalisation reverted to the reviewed lines
exactly the two new examples fail. Full suite 1032 examples, 0 failures. rubocop clean.
@ndemianc
ndemianc merged commit 36b7340 into develop Sep 7, 2026
3 checks passed
@ndemianc
ndemianc deleted the feat/gpt-6-astra branch September 7, 2026 03:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants