Skip to content

Commit 8080fe7

Browse files
committed
Add GPT-6 Astra, and price long prompts the way vendors actually bill them
Astra's API access opened today. Capabilities are verified live against api.openai.com rather than taken from the docs: vision, native PDF, and the low/medium/high/xhigh/max reasoning range all confirmed on real calls. Two of its facts don't fit the old catalog shape, and neither is specific to Astra, so both go in as general mechanism: Prompt-size pricing. Astra bills 2x input and 1.5x output for the WHOLE request once the prompt passes 272K. Gemini has long charged a similar bend. price_tiers expresses this as ordered multiplier data — any threshold, any vendor, any number of bands, highest one wins, an omitted multiplier meaning 1x. The threshold counts the whole prompt including cached tokens, because a 280K prompt is a 280K prompt to the vendor no matter how much of it was a cache read. Rates live in the shared catalog, so the CLI ledger, the gateway ledger and www's calculateCost all read one rule instead of three. Effort floors. Astra rejects reasoning effort "none", which is exactly what a classifier turn sends. min_reasoning_effort states the bottom of a model's range as data, so the adapter clamps up to it and the next model with a different floor needs no Go change. ModelPricing still returns the base card for estimates and displays; CostUSD now goes through ModelPricingAt so a real request prices under the right band.
1 parent 582410c commit 8080fe7

4 files changed

Lines changed: 267 additions & 17 deletions

File tree

‎catalog/catalog.go‎

Lines changed: 106 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,22 @@ type CatalogModel struct {
4343
PriceOut float64 `json:"price_out,omitempty"`
4444
PriceCacheRead float64 `json:"price_cache_read,omitempty"` // optional override (default in×0.1)
4545

46+
// PriceTiers bends the rate card by PROMPT SIZE. Several vendors now price a
47+
// long prompt differently from a short one (GPT-6 Astra doubles input and
48+
// adds half again to output past 272K), and that is a per-request fact, not a
49+
// per-model one. Expressing it as ordered multiplier data — rather than a
50+
// special case in Go — keeps the rule where every consumer already reads it:
51+
// this file is shared verbatim with the gateway and with apps/www's own
52+
// calculateCost, and only one of those three can run Go.
53+
PriceTiers []PriceTier `json:"price_tiers,omitempty"`
54+
55+
// MinReasoningEffort is the LOWEST reasoning effort this model accepts, in the
56+
// vendor's own vocabulary. Vendors disagree about the bottom of the range —
57+
// GPT-5.6 takes "none", GPT-6 Astra's floor is "low" and 400s below it — and
58+
// that is a model fact, so it lives here rather than as another adapter bool.
59+
// Empty means "no floor": send whatever the effort maps to.
60+
MinReasoningEffort string `json:"min_reasoning_effort,omitempty"`
61+
4662
// Fallback is the model's mid-turn failure chain, in LABELS: who covers
4763
// when this model errors after transport retries, walked IN ORDER by the
4864
// CLI's recovery executor (availability/billing filtering happens at walk
@@ -56,12 +72,13 @@ type CatalogModel struct {
5672
// tier word to its vendor, e.g. gemini+flash-lite). First matching rule that
5773
// DECLARES a field wins for that field.
5874
type familyRule struct {
59-
Match []string `json:"match,omitempty"`
60-
MatchAll []string `json:"match_all,omitempty"`
61-
Window int `json:"window,omitempty"`
62-
PriceIn float64 `json:"price_in,omitempty"`
63-
PriceOut float64 `json:"price_out,omitempty"`
64-
PriceCacheRead float64 `json:"price_cache_read,omitempty"`
75+
Match []string `json:"match,omitempty"`
76+
MatchAll []string `json:"match_all,omitempty"`
77+
Window int `json:"window,omitempty"`
78+
PriceIn float64 `json:"price_in,omitempty"`
79+
PriceOut float64 `json:"price_out,omitempty"`
80+
PriceCacheRead float64 `json:"price_cache_read,omitempty"`
81+
PriceTiers []PriceTier `json:"price_tiers,omitempty"`
6582
}
6683

6784
func (r familyRule) matches(model string) bool {
@@ -217,37 +234,109 @@ func MaxOutputTokens(model string) int {
217234
return 0
218235
}
219236

237+
// MinReasoningEffort returns the lowest reasoning effort a model accepts, or ""
238+
// when the catalog declares no floor. Adapters clamp against it so a cheap
239+
// classifier turn can't 400 on a model whose range starts above "none".
240+
func MinReasoningEffort(model string) string {
241+
if m, ok := LookupModel(model); ok {
242+
return m.MinReasoningEffort
243+
}
244+
return ""
245+
}
246+
220247
// Pricing is APPROXIMATE per-model rates in USD per MILLION tokens. Cache write ≈
221248
// 1.25× base input; cache read ≈ 0.1× base input unless the catalog overrides it.
222249
type Pricing struct{ Input, Output, CacheWrite, CacheRead float64 }
223250

251+
// PriceTier is one prompt-size pricing band: once a request's prompt exceeds
252+
// AbovePromptTokens, every rate on the card is multiplied for the WHOLE request
253+
// (not just the tokens past the line — that is how vendors actually bill it).
254+
//
255+
// A multiplier left at 0 means 1× (unchanged), so a tier states only what it
256+
// bends. Tiers are independent: the highest threshold a prompt clears wins, so
257+
// order in JSON doesn't matter and a vendor can declare as many bands as it likes.
258+
type PriceTier struct {
259+
AbovePromptTokens int `json:"above_prompt_tokens"`
260+
In float64 `json:"in,omitempty"`
261+
Out float64 `json:"out,omitempty"`
262+
CacheRead float64 `json:"cache_read,omitempty"`
263+
CacheWrite float64 `json:"cache_write,omitempty"`
264+
}
265+
266+
// scale applies one multiplier, treating 0 (absent) as 1×.
267+
func scale(v, mult float64) float64 {
268+
if mult == 0 {
269+
return v
270+
}
271+
return v * mult
272+
}
273+
274+
// apply bends a base card by every tier the prompt clears, highest threshold winning.
275+
func applyTiers(p Pricing, tiers []PriceTier, promptTokens int) Pricing {
276+
var win *PriceTier
277+
for i := range tiers {
278+
t := &tiers[i]
279+
if promptTokens > t.AbovePromptTokens && (win == nil || t.AbovePromptTokens > win.AbovePromptTokens) {
280+
win = t
281+
}
282+
}
283+
if win == nil {
284+
return p
285+
}
286+
return Pricing{
287+
Input: scale(p.Input, win.In),
288+
Output: scale(p.Output, win.Out),
289+
CacheWrite: scale(p.CacheWrite, win.CacheWrite),
290+
CacheRead: scale(p.CacheRead, win.CacheRead),
291+
}
292+
}
293+
224294
func makePricing(in, out, cacheRead float64) Pricing {
225295
if cacheRead == 0 {
226296
cacheRead = in * 0.1
227297
}
228298
return Pricing{Input: in, Output: out, CacheWrite: in * 1.25, CacheRead: cacheRead}
229299
}
230300

231-
// ModelPricing returns the rate card for a model id or label: the catalog entry
232-
// if it declares one, else the first matching family rule, else the default.
233-
// Both ledgers price against this (the CLI's and the gateway's), so every id
234-
// that can serve must resolve to a non-zero card — the family floor guarantees
235-
// Fireworks-served ids are never $0.
236-
func ModelPricing(model string) Pricing {
301+
// rateCard resolves the BASE card plus any prompt-size tiers for a model id or
302+
// label: the catalog entry if it declares one, else the first matching family
303+
// rule, else the default. Both ledgers price against this (the CLI's and the
304+
// gateway's), so every id that can serve must resolve to a non-zero card — the
305+
// family floor guarantees Fireworks-served ids are never $0.
306+
func rateCard(model string) (Pricing, []PriceTier) {
237307
if m, ok := LookupModel(model); ok && m.PriceIn > 0 {
238-
return makePricing(m.PriceIn, m.PriceOut, m.PriceCacheRead)
308+
return makePricing(m.PriceIn, m.PriceOut, m.PriceCacheRead), m.PriceTiers
239309
}
240310
for _, r := range modelCatalog.file.Families {
241311
if r.PriceIn > 0 && r.matches(model) {
242-
return makePricing(r.PriceIn, r.PriceOut, r.PriceCacheRead)
312+
return makePricing(r.PriceIn, r.PriceOut, r.PriceCacheRead), r.PriceTiers
243313
}
244314
}
245-
return makePricing(modelCatalog.file.Defaults.PriceIn, modelCatalog.file.Defaults.PriceOut, 0)
315+
return makePricing(modelCatalog.file.Defaults.PriceIn, modelCatalog.file.Defaults.PriceOut, 0), nil
316+
}
317+
318+
// ModelPricing returns a model's BASE rate card — the short-prompt rates, before
319+
// any prompt-size tier applies. Estimates and rate displays want this; anything
320+
// billing a real request must use ModelPricingAt so a long prompt prices right.
321+
func ModelPricing(model string) Pricing {
322+
p, _ := rateCard(model)
323+
return p
324+
}
325+
326+
// ModelPricingAt returns the rate card that actually governs a request whose
327+
// prompt is promptTokens long — base card with the winning tier's multipliers
328+
// folded in. promptTokens is the WHOLE prompt: fresh input plus cache reads plus
329+
// cache writes, since vendors measure the threshold against everything they read.
330+
func ModelPricingAt(model string, promptTokens int) Pricing {
331+
p, tiers := rateCard(model)
332+
return applyTiers(p, tiers, promptTokens)
246333
}
247334

248-
// CostUSD prices one response's token usage under its model's rate card.
335+
// CostUSD prices one response's token usage under its model's rate card. Token
336+
// counts here are cache-EXCLUSIVE (see compat.applyUsage), so the prompt that
337+
// decides the tier is the sum of all three input components.
249338
func CostUSD(model string, inTok, outTok, cacheRead, cacheWrite int) float64 {
250-
p := ModelPricing(model)
339+
p := ModelPricingAt(model, inTok+cacheRead+cacheWrite)
251340
return (float64(inTok)*p.Input + float64(outTok)*p.Output +
252341
float64(cacheRead)*p.CacheRead + float64(cacheWrite)*p.CacheWrite) / 1e6
253342
}

‎catalog/models.json‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,42 @@
197197
"luna"
198198
]
199199
},
200+
{
201+
"id": "gpt-6-astra",
202+
"label": "astra",
203+
"vendor": "openai",
204+
"window": 1050000,
205+
"vision": true,
206+
"reasoning": true,
207+
"price_in": 10.0,
208+
"price_out": 50.0,
209+
"price_tiers": [
210+
{
211+
"above_prompt_tokens": 272000,
212+
"in": 2.0,
213+
"out": 1.5,
214+
"cache_read": 2.0,
215+
"cache_write": 2.0
216+
}
217+
],
218+
"min_reasoning_effort": "low",
219+
"_note": "GPT-6 Astra (OpenAI, GA 2026-09-03; API access opened 2026-09-05). Frontier tier. Responses API. 1.05M context, 128K max output, vision, PDF, reasoning (low/medium/high/xhigh/max) - note the floor is \"low\", NOT \"none\": a classifier turn sending none gets a 400, hence min_reasoning_effort. Cached input $1/M = the default in*0.1, so no price_cache_read override. Prompts over 272K bill 2x input/cache and 1.5x output for the WHOLE request; that is the price_tiers entry. Capabilities verified live against api.openai.com, not from docs alone.",
220+
"pinnable": true,
221+
"group": "OpenAI",
222+
"desc": "Strongest reasoning",
223+
"name": "GPT-6 Astra",
224+
"pdf": true,
225+
"www": {
226+
"chat": true,
227+
"order": 105,
228+
"description": "OpenAI frontier GPT-6 Astra. Top-tier intelligence for coding, computer use and professional work, with a 1.05M context window."
229+
},
230+
"max_output": 128000,
231+
"fallback": [
232+
"opus",
233+
"sol"
234+
]
235+
},
200236
{
201237
"id": "gpt-5.6-sol",
202238
"label": "sol",

‎catalog/pricing_test.go‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,92 @@ func TestSonnet5Pricing(t *testing.T) {
169169
t.Fatalf("claude-sonnet-5 = %.2f/%.2f, want 2.00/10.00", p.Input, p.Output)
170170
}
171171
}
172+
173+
// Prompt-size tiers are generic mechanism, not an Astra special case: the base
174+
// card holds under the threshold, every declared multiplier applies over it, and
175+
// the bend covers the WHOLE request rather than the tokens past the line.
176+
func TestPriceTiers(t *testing.T) {
177+
base := Pricing{Input: 10, Output: 50, CacheWrite: 12.5, CacheRead: 1}
178+
tiers := []PriceTier{{AbovePromptTokens: 272000, In: 2, Out: 1.5, CacheRead: 2, CacheWrite: 2}}
179+
180+
if got := applyTiers(base, tiers, 272000); got != base {
181+
t.Errorf("at the threshold = %+v, want base %+v (strictly ABOVE bends)", got, base)
182+
}
183+
want := Pricing{Input: 20, Output: 75, CacheWrite: 25, CacheRead: 2}
184+
if got := applyTiers(base, tiers, 272001); got != want {
185+
t.Errorf("over the threshold = %+v, want %+v", got, want)
186+
}
187+
if got := applyTiers(base, nil, 10_000_000); got != base {
188+
t.Errorf("no tiers = %+v, want base %+v", got, base)
189+
}
190+
}
191+
192+
// An omitted multiplier means 1x, so a tier states only what it bends.
193+
func TestPriceTierPartialMultipliers(t *testing.T) {
194+
base := Pricing{Input: 10, Output: 50, CacheWrite: 12.5, CacheRead: 1}
195+
got := applyTiers(base, []PriceTier{{AbovePromptTokens: 100, Out: 3}}, 200)
196+
want := Pricing{Input: 10, Output: 150, CacheWrite: 12.5, CacheRead: 1}
197+
if got != want {
198+
t.Errorf("partial tier = %+v, want %+v", got, want)
199+
}
200+
}
201+
202+
// Several bands are allowed and independent: the highest one the prompt clears
203+
// wins, regardless of the order they appear in JSON.
204+
func TestPriceTierHighestWinsRegardlessOfOrder(t *testing.T) {
205+
base := Pricing{Input: 10}
206+
tiers := []PriceTier{{AbovePromptTokens: 1_000_000, In: 4}, {AbovePromptTokens: 200_000, In: 2}}
207+
for _, c := range []struct {
208+
prompt int
209+
want float64
210+
}{{100_000, 10}, {300_000, 20}, {2_000_000, 40}} {
211+
if got := applyTiers(base, tiers, c.prompt).Input; got != c.want {
212+
t.Errorf("prompt %d = %v, want %v", c.prompt, got, c.want)
213+
}
214+
}
215+
}
216+
217+
// The catalog's Astra entry must actually carry the 272K band, and CostUSD must
218+
// route a long request through it — this is the money path, not just the struct.
219+
func TestAstraLongPromptCost(t *testing.T) {
220+
if p := ModelPricing("gpt-6-astra"); p.Input != 10 || p.Output != 50 || p.CacheRead != 1 {
221+
t.Fatalf("base card = %+v, want 10/50 with cache read 1", p)
222+
}
223+
// 300K prompt clears 272K: input 2x, output 1.5x.
224+
got := CostUSD("gpt-6-astra", 300_000, 1_000, 0, 0)
225+
want := (300_000*20.0 + 1_000*75.0) / 1e6
226+
if math.Abs(got-want) > 1e-9 {
227+
t.Errorf("long-prompt cost = %v, want %v", got, want)
228+
}
229+
// Same model, short prompt, base rates.
230+
short := CostUSD("gpt-6-astra", 1_000, 1_000, 0, 0)
231+
if wantShort := (1_000*10.0 + 1_000*50.0) / 1e6; math.Abs(short-wantShort) > 1e-9 {
232+
t.Errorf("short-prompt cost = %v, want %v", short, wantShort)
233+
}
234+
}
235+
236+
// The threshold measures the whole prompt, so cache reads and writes count
237+
// toward it — billing a 280K prompt at base rates just because most of it was
238+
// cached is exactly the underbill this mechanism exists to prevent.
239+
func TestPriceTierThresholdCountsCachedPrompt(t *testing.T) {
240+
// 10K fresh + 280K cache read = 290K prompt → over the line.
241+
got := CostUSD("gpt-6-astra", 10_000, 0, 280_000, 0)
242+
want := (10_000*20.0 + 280_000*2.0) / 1e6
243+
if math.Abs(got-want) > 1e-9 {
244+
t.Errorf("cached long prompt = %v, want %v", got, want)
245+
}
246+
}
247+
248+
// The effort floor is a catalog FACT, not an adapter special case: Astra rejects
249+
// "none", GPT-5.6 accepts it, and a model that declares no floor reports "".
250+
func TestMinReasoningEffort(t *testing.T) {
251+
if got := MinReasoningEffort("gpt-6-astra"); got != "low" {
252+
t.Errorf("astra floor = %q, want \"low\"", got)
253+
}
254+
if got := MinReasoningEffort("gpt-5.6-sol"); got != "" {
255+
t.Errorf("sol floor = %q, want \"\" (no floor)", got)
256+
}
257+
if got := MinReasoningEffort("some-model-nobody-added"); got != "" {
258+
t.Errorf("unknown floor = %q, want \"\"", got)
259+
}
260+
}

‎models.json‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,42 @@
197197
"luna"
198198
]
199199
},
200+
{
201+
"id": "gpt-6-astra",
202+
"label": "astra",
203+
"vendor": "openai",
204+
"window": 1050000,
205+
"vision": true,
206+
"reasoning": true,
207+
"price_in": 10.0,
208+
"price_out": 50.0,
209+
"price_tiers": [
210+
{
211+
"above_prompt_tokens": 272000,
212+
"in": 2.0,
213+
"out": 1.5,
214+
"cache_read": 2.0,
215+
"cache_write": 2.0
216+
}
217+
],
218+
"min_reasoning_effort": "low",
219+
"_note": "GPT-6 Astra (OpenAI, GA 2026-09-03; API access opened 2026-09-05). Frontier tier. Responses API. 1.05M context, 128K max output, vision, PDF, reasoning (low/medium/high/xhigh/max) - note the floor is \"low\", NOT \"none\": a classifier turn sending none gets a 400, hence min_reasoning_effort. Cached input $1/M = the default in*0.1, so no price_cache_read override. Prompts over 272K bill 2x input/cache and 1.5x output for the WHOLE request; that is the price_tiers entry. Capabilities verified live against api.openai.com, not from docs alone.",
220+
"pinnable": true,
221+
"group": "OpenAI",
222+
"desc": "Strongest reasoning",
223+
"name": "GPT-6 Astra",
224+
"pdf": true,
225+
"www": {
226+
"chat": true,
227+
"order": 105,
228+
"description": "OpenAI frontier GPT-6 Astra. Top-tier intelligence for coding, computer use and professional work, with a 1.05M context window."
229+
},
230+
"max_output": 128000,
231+
"fallback": [
232+
"opus",
233+
"sol"
234+
]
235+
},
200236
{
201237
"id": "gpt-5.6-sol",
202238
"label": "sol",

0 commit comments

Comments
 (0)