Files
coder/scripts/aibridgepricesgen/main.go
T
Cian JohnstonandCopilot Autofix powered by AI 8eaf4f507b feat: generate the known-models catalog and aigateway prices (#27146)
- Regenerates `prices.json` from models.dev. The seeder only upserts, so
existing deployments keep delisted models.
- Generate the frontend known-models catalog instead of hand-writing it.
`make gen/aibridge-prices` fetches models.dev once
- Moved patches to model definitions to separate `overrides.jq` which 
  handles both `claude-sonnet-4-5` 200k context and 'aliasing' Fable 5
  as Mythos 5.
- Editorial choices of selection, order, aliases, and reasoning defaults 
  live in `curation.json`.
- Adds golden join tests with one error case per validation, a
no-network drift test comparing curation to the checked-in artifact, and
pinned invariants for the Anthropic thinking-mode split (the wrong side
returns HTTP 400) and the sonnet-4-5 context pin.

Adding a model is now one `curation.json` entry plus `make
gen/aibridge-prices`, assuming it is present on models.dev.

> This PR was authored by Coder Agents on Cian's behalf.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-14 19:36:27 +00:00

237 lines
7.4 KiB
Go

// aibridgepricesgen converts a models.dev api.json snapshot into generated
// artifacts, selected by -format:
//
// - "prices": a JSON seed file consumable by the AI Gateway cost-control
// loader, sorted by (provider, model) so regenerations produce minimal
// diffs.
// - "catalog": the frontend known-models JSON, joining the snapshot with
// the editorial curation in curation.json and preserving its entry order.
//
// Run via the gen/aibridge-prices Make target, which fetches and patches the
// snapshot (_gen/models-dev.json). Kept out of `make gen` because the output
// depends on live upstream data; refreshing prices should land in dedicated,
// reviewable commits rather than appearing as drift on unrelated gen runs.
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"math"
"os"
"sort"
"golang.org/x/xerrors"
)
// supportedProviders lists the providers we ship prices for. Adding a
// provider here is enough to include it on the next regeneration.
var supportedProviders = []string{"anthropic", "openai"}
// upstreamProvider is the subset of a models.dev per-provider entry we read.
type upstreamProvider struct {
Models map[string]upstreamModel `json:"models"`
}
type upstreamModel struct {
Name string `json:"name"`
Limit upstreamLimit `json:"limit"`
Cost *upstreamCost `json:"cost"`
}
// Pointer fields in upstreamLimit and upstreamCost distinguish "key absent"
// (nil) from "key present and zero" (0).
type upstreamLimit struct {
Context *int64 `json:"context"`
Output *int64 `json:"output"`
}
type upstreamCost struct {
Input *float64 `json:"input"`
Output *float64 `json:"output"`
CacheRead *float64 `json:"cache_read"`
CacheWrite *float64 `json:"cache_write"`
}
// hasPricing reports whether the cost block has at least one populated price.
// Returns false for a nil receiver, so callers can pass m.Cost without a
// preceding nil check.
func (c *upstreamCost) hasPricing() bool {
if c == nil {
return false
}
return c.Input != nil || c.Output != nil ||
c.CacheRead != nil || c.CacheWrite != nil
}
// Pointer fields preserve the distinction between "not populated by upstream"
// (null) and "explicitly zero" (0).
//
// NOTE: the JSON contract for the price seed lives in three places that must
// stay in sync: the tags here, the corresponding struct in the price seeder,
// and the column extraction in the batch SQL upsert.
type priceRow struct {
Provider string `json:"provider"`
Model string `json:"model"`
InputPrice *int64 `json:"input_price"`
OutputPrice *int64 `json:"output_price"`
CacheReadPrice *int64 `json:"cache_read_price"`
CacheWritePrice *int64 `json:"cache_write_price"`
}
func main() {
format := flag.String("format", "prices", `output format: "prices" (cost-control seed) or "catalog" (frontend known-models JSON)`)
upstreamPath := flag.String("upstream", "", "path to a models.dev api.json snapshot (required)")
flag.Parse()
if err := run(*format, *upstreamPath); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "aibridgepricesgen: %v\n", err)
os.Exit(1)
}
}
func run(format, upstreamPath string) error {
// Validate flags before touching the filesystem so a typo fails fast.
switch format {
case "prices", "catalog":
default:
return xerrors.Errorf(`unknown -format %q (want "prices" or "catalog")`, format)
}
if upstreamPath == "" {
return xerrors.New("-upstream is required; run via `make gen/aibridge-prices`, which fetches and patches the snapshot")
}
upstream, err := readUpstream(upstreamPath)
if err != nil {
return xerrors.Errorf("read %s: %w", upstreamPath, err)
}
if format == "catalog" {
return runCatalog(upstream)
}
return runPrices(upstream)
}
// readUpstream loads a models.dev api.json snapshot from disk, typically the
// Makefile's _gen/models-dev.json (fetched once and patched by overrides.jq).
func readUpstream(path string) (map[string]upstreamProvider, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var upstream map[string]upstreamProvider
if err := json.Unmarshal(data, &upstream); err != nil {
return nil, xerrors.Errorf("parse: %w", err)
}
return upstream, nil
}
func runPrices(upstream map[string]upstreamProvider) error {
rows, err := convert(upstream, supportedProviders)
if err != nil {
return err
}
if err := validate(rows); err != nil {
return err
}
if err := write(os.Stdout, rows); err != nil {
return err
}
_, _ = fmt.Fprintf(os.Stderr, "aibridgepricesgen: wrote %d prices for %d provider(s)\n", len(rows), len(supportedProviders))
return nil
}
func runCatalog(upstream map[string]upstreamProvider) error {
var curation map[string][]curatedModel
if err := json.Unmarshal(curationJSON, &curation); err != nil {
return xerrors.Errorf("parse embedded curation.json: %w", err)
}
catalog, err := buildCatalog(upstream, curation)
if err != nil {
return err
}
if err := writeCatalog(os.Stdout, catalog); err != nil {
return err
}
_, _ = fmt.Fprintf(os.Stderr, "aibridgepricesgen: wrote catalog for %d provider(s)\n", len(catalog))
return nil
}
// convert flattens the upstream map into table-shaped rows for the configured
// providers. If any configured provider is absent from the upstream payload,
// every missing provider is reported and the function returns an error so the
// caller doesn't ship an incomplete seed.
func convert(upstream map[string]upstreamProvider, providers []string) ([]priceRow, error) {
var (
rows []priceRow
missing []string
)
for _, providerID := range providers {
provider, ok := upstream[providerID]
if !ok || len(provider.Models) == 0 {
missing = append(missing, providerID)
continue
}
for modelID, m := range provider.Models {
if !m.Cost.hasPricing() {
continue
}
rows = append(rows, priceRow{
Provider: providerID,
Model: modelID,
InputPrice: toMicros(m.Cost.Input),
OutputPrice: toMicros(m.Cost.Output),
CacheReadPrice: toMicros(m.Cost.CacheRead),
CacheWritePrice: toMicros(m.Cost.CacheWrite),
})
}
}
if len(missing) > 0 {
return nil, xerrors.Errorf("providers missing or empty in upstream: %v", missing)
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Provider != rows[j].Provider {
return rows[i].Provider < rows[j].Provider
}
return rows[i].Model < rows[j].Model
})
return rows, nil
}
// validate checks invariants on the converted rows. Catches upstream
// changes that produce structurally valid but semantically broken seed
// data, e.g. a renamed `cost` key that leaves every row with all-null
// prices.
func validate(rows []priceRow) error {
for _, r := range rows {
if r.InputPrice != nil || r.OutputPrice != nil {
return nil
}
}
return xerrors.New("converted rows have no pricing data; upstream schema may have changed")
}
// toMicros scales a price into integer micro-units (1 unit = 1,000,000),
// rounding to avoid float-truncation errors. Returns nil for nil input, and
// for negative values, which are treated as missing.
func toMicros(price *float64) *int64 {
if price == nil {
return nil
}
if *price < 0 {
_, _ = fmt.Fprintf(os.Stderr, "warning: negative price %f, treating as missing\n", *price)
return nil
}
micros := int64(math.Round(*price * 1_000_000))
return &micros
}
func write(w io.Writer, rows []priceRow) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(rows); err != nil {
return xerrors.Errorf("encode: %w", err)
}
return nil
}