mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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>
This commit is contained in:
co-authored by
Copilot Autofix powered by AI
parent
4d884c30e7
commit
8eaf4f507b
@@ -0,0 +1,150 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// curationJSON is the checked-in editorial curation input for the frontend
|
||||
// known-models catalog. Entry order within each provider controls suggestion
|
||||
// order in the UI. Everything factual (display name, limits, pricing) is
|
||||
// joined from models.dev at generation time; the curation file only carries
|
||||
// editorial choices: which models to suggest, aliases, reasoning defaults,
|
||||
// and overrides.
|
||||
//
|
||||
//go:embed curation.json
|
||||
var curationJSON []byte
|
||||
|
||||
// curatedModel is one entry in curation.json.
|
||||
type curatedModel struct {
|
||||
ModelIdentifier string `json:"modelIdentifier"`
|
||||
Aliases []string `json:"aliases"`
|
||||
// DisplayName overrides the upstream `name` when set. Needed where
|
||||
// upstream naming does not match what we want to show (for example
|
||||
// "Claude Haiku 4.5 (latest)").
|
||||
DisplayName string `json:"displayName"`
|
||||
// ReasoningEffort is editorial, not from models.dev. Mutually
|
||||
// exclusive with ThinkingBudgetTokens.
|
||||
ReasoningEffort string `json:"reasoningEffort"`
|
||||
// ThinkingBudgetTokens is Anthropic-only, for models that do not
|
||||
// support adaptive thinking and use the legacy
|
||||
// `thinking.budget_tokens` API instead.
|
||||
ThinkingBudgetTokens int `json:"thinkingBudgetTokens"`
|
||||
}
|
||||
|
||||
// catalogEntry matches the frontend KnownModel shape (knownModels/types.ts).
|
||||
// Costs are flat USD per million tokens, straight from models.dev; tiered
|
||||
// pricing such as context_over_200k is intentionally omitted.
|
||||
type catalogEntry struct {
|
||||
Provider string `json:"provider"`
|
||||
ModelIdentifier string `json:"modelIdentifier"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Aliases []string `json:"aliases"`
|
||||
ContextLimit *int64 `json:"contextLimit,omitempty"`
|
||||
MaxOutputTokens *int64 `json:"maxOutputTokens,omitempty"`
|
||||
ReasoningEffort string `json:"reasoningEffort,omitempty"`
|
||||
ThinkingBudgetTokens int `json:"thinkingBudgetTokens,omitempty"`
|
||||
InputCost *float64 `json:"inputCost,omitempty"`
|
||||
OutputCost *float64 `json:"outputCost,omitempty"`
|
||||
CacheReadCost *float64 `json:"cacheReadCost,omitempty"`
|
||||
CacheWriteCost *float64 `json:"cacheWriteCost,omitempty"`
|
||||
}
|
||||
|
||||
// validReasoningEfforts are the values accepted for curatedModel.ReasoningEffort.
|
||||
var validReasoningEfforts = map[string]bool{"low": true, "medium": true, "high": true}
|
||||
|
||||
// buildCatalog joins the curation file with the upstream models.dev payload
|
||||
// and returns provider-keyed ordered entry lists.
|
||||
func buildCatalog(upstream map[string]upstreamProvider, curation map[string][]curatedModel) (map[string][]catalogEntry, error) {
|
||||
out := make(map[string][]catalogEntry, len(curation))
|
||||
for providerID, curated := range curation {
|
||||
provider, ok := upstream[providerID]
|
||||
if !ok {
|
||||
return nil, xerrors.Errorf("provider %q missing from upstream", providerID)
|
||||
}
|
||||
seenIdentifiers := make(map[string]bool, len(curated))
|
||||
seenAliases := make(map[string]bool)
|
||||
entries := make([]catalogEntry, 0, len(curated))
|
||||
for _, c := range curated {
|
||||
if c.ModelIdentifier == "" {
|
||||
return nil, xerrors.Errorf("provider %q: entry with empty modelIdentifier", providerID)
|
||||
}
|
||||
if seenIdentifiers[c.ModelIdentifier] {
|
||||
return nil, xerrors.Errorf("provider %q: duplicate modelIdentifier %q", providerID, c.ModelIdentifier)
|
||||
}
|
||||
seenIdentifiers[c.ModelIdentifier] = true
|
||||
if c.ReasoningEffort != "" && !validReasoningEfforts[c.ReasoningEffort] {
|
||||
return nil, xerrors.Errorf(`%s/%s: reasoningEffort %q is not one of "low", "medium", "high"`, providerID, c.ModelIdentifier, c.ReasoningEffort)
|
||||
}
|
||||
if c.ThinkingBudgetTokens < 0 {
|
||||
return nil, xerrors.Errorf("%s/%s: thinkingBudgetTokens %d is negative", providerID, c.ModelIdentifier, c.ThinkingBudgetTokens)
|
||||
}
|
||||
if c.ReasoningEffort != "" && c.ThinkingBudgetTokens != 0 {
|
||||
return nil, xerrors.Errorf("%s/%s: reasoningEffort and thinkingBudgetTokens are mutually exclusive", providerID, c.ModelIdentifier)
|
||||
}
|
||||
for _, alias := range c.Aliases {
|
||||
if alias == "" {
|
||||
return nil, xerrors.Errorf("%s/%s: empty-string alias", providerID, c.ModelIdentifier)
|
||||
}
|
||||
if seenAliases[alias] {
|
||||
return nil, xerrors.Errorf("%s/%s: alias %q declared more than once in provider", providerID, c.ModelIdentifier, alias)
|
||||
}
|
||||
seenAliases[alias] = true
|
||||
}
|
||||
m, ok := provider.Models[c.ModelIdentifier]
|
||||
if !ok {
|
||||
return nil, xerrors.Errorf("%s/%s: model missing from upstream (patch it in via overrides.jq if intentional)", providerID, c.ModelIdentifier)
|
||||
}
|
||||
if !m.Cost.hasPricing() {
|
||||
return nil, xerrors.Errorf("%s/%s: upstream model has no pricing data", providerID, c.ModelIdentifier)
|
||||
}
|
||||
if m.Limit.Context == nil || m.Limit.Output == nil {
|
||||
return nil, xerrors.Errorf("%s/%s: upstream model missing limit.context or limit.output", providerID, c.ModelIdentifier)
|
||||
}
|
||||
displayName := cmp.Or(c.DisplayName, m.Name)
|
||||
if displayName == "" {
|
||||
return nil, xerrors.Errorf("%s/%s: no displayName override and upstream name is empty", providerID, c.ModelIdentifier)
|
||||
}
|
||||
aliases := c.Aliases
|
||||
if aliases == nil {
|
||||
aliases = []string{}
|
||||
}
|
||||
entries = append(entries, catalogEntry{
|
||||
Provider: providerID,
|
||||
ModelIdentifier: c.ModelIdentifier,
|
||||
DisplayName: displayName,
|
||||
Aliases: aliases,
|
||||
ContextLimit: m.Limit.Context,
|
||||
MaxOutputTokens: m.Limit.Output,
|
||||
ReasoningEffort: c.ReasoningEffort,
|
||||
ThinkingBudgetTokens: c.ThinkingBudgetTokens,
|
||||
InputCost: m.Cost.Input,
|
||||
OutputCost: m.Cost.Output,
|
||||
CacheReadCost: m.Cost.CacheRead,
|
||||
CacheWriteCost: m.Cost.CacheWrite,
|
||||
})
|
||||
}
|
||||
// An alias resolving to a canonical identifier would make exact-alias
|
||||
// lookup and canonical-id lookup disagree.
|
||||
for alias := range seenAliases {
|
||||
if seenIdentifiers[alias] {
|
||||
return nil, xerrors.Errorf("alias %q duplicates a modelIdentifier in provider %q", alias, providerID)
|
||||
}
|
||||
}
|
||||
out[providerID] = entries
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func writeCatalog(w io.Writer, catalog map[string][]catalogEntry) error {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(catalog); err != nil {
|
||||
return xerrors.Errorf("encode: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fixtureUpstream returns a small upstream payload covering the join cases:
|
||||
// fully priced models with limits and a costless model.
|
||||
func fixtureUpstream(t *testing.T) map[string]upstreamProvider {
|
||||
t.Helper()
|
||||
const upstreamJSON = `{
|
||||
"anthropic": {
|
||||
"models": {
|
||||
"claude-fable-5": {
|
||||
"name": "Claude Fable 5",
|
||||
"limit": {"context": 1000000, "output": 128000},
|
||||
"cost": {"input": 10, "output": 50, "cache_read": 1, "cache_write": 12.5}
|
||||
},
|
||||
"claude-mythos-5": {
|
||||
"name": "Claude Mythos 5",
|
||||
"limit": {"context": 1000000, "output": 128000},
|
||||
"cost": {"input": 10, "output": 50, "cache_read": 1, "cache_write": 12.5}
|
||||
},
|
||||
"claude-costless": {
|
||||
"name": "Claude Costless",
|
||||
"limit": {"context": 200000, "output": 64000}
|
||||
},
|
||||
"claude-nameless": {
|
||||
"name": "",
|
||||
"limit": {"context": 200000, "output": 64000},
|
||||
"cost": {"input": 1, "output": 5}
|
||||
}
|
||||
}
|
||||
},
|
||||
"openai": {
|
||||
"models": {
|
||||
"gpt-5.6-sol": {
|
||||
"name": "GPT-5.6 Sol",
|
||||
"limit": {"context": 1050000, "output": 128000},
|
||||
"cost": {"input": 5, "output": 30, "cache_read": 0.5, "cache_write": 6.25}
|
||||
},
|
||||
"gpt-partial": {
|
||||
"name": "GPT Partial",
|
||||
"limit": {"context": 400000, "output": 128000},
|
||||
"cost": {"input": 0.2, "output": 1.25}
|
||||
},
|
||||
"gpt-limitless": {
|
||||
"name": "GPT Limitless",
|
||||
"limit": {"context": 400000},
|
||||
"cost": {"input": 0.2, "output": 1.25}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
var upstream map[string]upstreamProvider
|
||||
require.NoError(t, json.Unmarshal([]byte(upstreamJSON), &upstream))
|
||||
return upstream
|
||||
}
|
||||
|
||||
func TestBuildCatalog(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
curation := map[string][]curatedModel{
|
||||
"openai": {
|
||||
{ModelIdentifier: "gpt-5.6-sol", Aliases: []string{"gpt-5.6"}, ReasoningEffort: "medium"},
|
||||
{ModelIdentifier: "gpt-partial"},
|
||||
},
|
||||
"anthropic": {
|
||||
{ModelIdentifier: "claude-fable-5", ReasoningEffort: "high"},
|
||||
{ModelIdentifier: "claude-mythos-5", DisplayName: "Mythos 5 Override", ThinkingBudgetTokens: 8192},
|
||||
},
|
||||
}
|
||||
|
||||
catalog, err := buildCatalog(fixtureUpstream(t), curation)
|
||||
require.NoError(t, err)
|
||||
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, writeCatalog(&buf, catalog))
|
||||
|
||||
const want = `{
|
||||
"anthropic": [
|
||||
{
|
||||
"provider": "anthropic",
|
||||
"modelIdentifier": "claude-fable-5",
|
||||
"displayName": "Claude Fable 5",
|
||||
"aliases": [],
|
||||
"contextLimit": 1000000,
|
||||
"maxOutputTokens": 128000,
|
||||
"reasoningEffort": "high",
|
||||
"inputCost": 10,
|
||||
"outputCost": 50,
|
||||
"cacheReadCost": 1,
|
||||
"cacheWriteCost": 12.5
|
||||
},
|
||||
{
|
||||
"provider": "anthropic",
|
||||
"modelIdentifier": "claude-mythos-5",
|
||||
"displayName": "Mythos 5 Override",
|
||||
"aliases": [],
|
||||
"contextLimit": 1000000,
|
||||
"maxOutputTokens": 128000,
|
||||
"thinkingBudgetTokens": 8192,
|
||||
"inputCost": 10,
|
||||
"outputCost": 50,
|
||||
"cacheReadCost": 1,
|
||||
"cacheWriteCost": 12.5
|
||||
}
|
||||
],
|
||||
"openai": [
|
||||
{
|
||||
"provider": "openai",
|
||||
"modelIdentifier": "gpt-5.6-sol",
|
||||
"displayName": "GPT-5.6 Sol",
|
||||
"aliases": [
|
||||
"gpt-5.6"
|
||||
],
|
||||
"contextLimit": 1050000,
|
||||
"maxOutputTokens": 128000,
|
||||
"reasoningEffort": "medium",
|
||||
"inputCost": 5,
|
||||
"outputCost": 30,
|
||||
"cacheReadCost": 0.5,
|
||||
"cacheWriteCost": 6.25
|
||||
},
|
||||
{
|
||||
"provider": "openai",
|
||||
"modelIdentifier": "gpt-partial",
|
||||
"displayName": "GPT Partial",
|
||||
"aliases": [],
|
||||
"contextLimit": 400000,
|
||||
"maxOutputTokens": 128000,
|
||||
"inputCost": 0.2,
|
||||
"outputCost": 1.25
|
||||
}
|
||||
]
|
||||
}
|
||||
`
|
||||
require.Equal(t, want, buf.String())
|
||||
}
|
||||
|
||||
func TestBuildCatalogErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
curation map[string][]curatedModel
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "MissingUpstreamModel",
|
||||
curation: map[string][]curatedModel{
|
||||
"openai": {{ModelIdentifier: "gpt-nonexistent"}},
|
||||
},
|
||||
wantErr: "model missing from upstream",
|
||||
},
|
||||
{
|
||||
name: "NoCostBlock",
|
||||
curation: map[string][]curatedModel{
|
||||
"anthropic": {{ModelIdentifier: "claude-costless"}},
|
||||
},
|
||||
wantErr: "no pricing data",
|
||||
},
|
||||
{
|
||||
name: "MissingUpstreamLimit",
|
||||
curation: map[string][]curatedModel{
|
||||
"openai": {{ModelIdentifier: "gpt-limitless"}},
|
||||
},
|
||||
wantErr: "missing limit.context or limit.output",
|
||||
},
|
||||
{
|
||||
name: "EmptyUpstreamName",
|
||||
curation: map[string][]curatedModel{
|
||||
"anthropic": {{ModelIdentifier: "claude-nameless"}},
|
||||
},
|
||||
wantErr: "upstream name is empty",
|
||||
},
|
||||
{
|
||||
name: "EffortAndBudgetBothSet",
|
||||
curation: map[string][]curatedModel{
|
||||
"anthropic": {{ModelIdentifier: "claude-fable-5", ReasoningEffort: "high", ThinkingBudgetTokens: 8192}},
|
||||
},
|
||||
wantErr: "mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "InvalidReasoningEffort",
|
||||
curation: map[string][]curatedModel{
|
||||
"anthropic": {{ModelIdentifier: "claude-fable-5", ReasoningEffort: "maximum"}},
|
||||
},
|
||||
wantErr: "is not one of",
|
||||
},
|
||||
{
|
||||
name: "NegativeThinkingBudget",
|
||||
curation: map[string][]curatedModel{
|
||||
"anthropic": {{ModelIdentifier: "claude-fable-5", ThinkingBudgetTokens: -1}},
|
||||
},
|
||||
wantErr: "is negative",
|
||||
},
|
||||
{
|
||||
name: "DuplicateModelIdentifier",
|
||||
curation: map[string][]curatedModel{
|
||||
"anthropic": {
|
||||
{ModelIdentifier: "claude-fable-5"},
|
||||
{ModelIdentifier: "claude-fable-5"},
|
||||
},
|
||||
},
|
||||
wantErr: "duplicate modelIdentifier",
|
||||
},
|
||||
{
|
||||
name: "DuplicateAlias",
|
||||
curation: map[string][]curatedModel{
|
||||
"anthropic": {
|
||||
{ModelIdentifier: "claude-fable-5", Aliases: []string{"claude-latest"}},
|
||||
{ModelIdentifier: "claude-mythos-5", Aliases: []string{"claude-latest"}},
|
||||
},
|
||||
},
|
||||
wantErr: "declared more than once",
|
||||
},
|
||||
{
|
||||
name: "EmptyAlias",
|
||||
curation: map[string][]curatedModel{
|
||||
"anthropic": {{ModelIdentifier: "claude-fable-5", Aliases: []string{""}}},
|
||||
},
|
||||
wantErr: "empty-string alias",
|
||||
},
|
||||
{
|
||||
name: "AliasShadowsModelIdentifier",
|
||||
curation: map[string][]curatedModel{
|
||||
"anthropic": {
|
||||
{ModelIdentifier: "claude-fable-5", Aliases: []string{"claude-mythos-5"}},
|
||||
{ModelIdentifier: "claude-mythos-5"},
|
||||
},
|
||||
},
|
||||
wantErr: "duplicates a modelIdentifier",
|
||||
},
|
||||
{
|
||||
name: "MissingProvider",
|
||||
curation: map[string][]curatedModel{
|
||||
"google": {{ModelIdentifier: "gemini"}},
|
||||
},
|
||||
wantErr: `provider "google" missing`,
|
||||
},
|
||||
{
|
||||
name: "EmptyModelIdentifier",
|
||||
curation: map[string][]curatedModel{
|
||||
"openai": {{}},
|
||||
},
|
||||
wantErr: "empty modelIdentifier",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := buildCatalog(fixtureUpstream(t), tc.curation)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tc.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCurationMatchesGeneratedCatalog is a drift test: the editorial fields
|
||||
// (per provider, in order) in the embedded curation.json must exactly match
|
||||
// their projection in the checked-in generated frontend catalog. Fails when
|
||||
// curation.json changes without running `make gen/aibridge-prices`.
|
||||
func TestCurationMatchesGeneratedCatalog(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
curation := embeddedCuration(t)
|
||||
|
||||
data, err := os.ReadFile("../../site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/knownModelsGenerated.json")
|
||||
require.NoError(t, err)
|
||||
var generated map[string][]catalogEntry
|
||||
require.NoError(t, json.Unmarshal(data, &generated))
|
||||
|
||||
// editorial is the curation-owned projection of an entry. displayName is
|
||||
// only compared when the curation sets an override; otherwise it comes
|
||||
// from upstream and is not the curation's to pin.
|
||||
type editorial struct {
|
||||
ModelIdentifier string
|
||||
Aliases []string
|
||||
DisplayName string
|
||||
ReasoningEffort string
|
||||
ThinkingBudgetTokens int
|
||||
}
|
||||
|
||||
curatedProjection := make(map[string][]editorial, len(curation))
|
||||
for providerID, entries := range curation {
|
||||
projected := make([]editorial, 0, len(entries))
|
||||
for _, c := range entries {
|
||||
aliases := c.Aliases
|
||||
if aliases == nil {
|
||||
aliases = []string{}
|
||||
}
|
||||
projected = append(projected, editorial{
|
||||
ModelIdentifier: c.ModelIdentifier,
|
||||
Aliases: aliases,
|
||||
DisplayName: c.DisplayName,
|
||||
ReasoningEffort: c.ReasoningEffort,
|
||||
ThinkingBudgetTokens: c.ThinkingBudgetTokens,
|
||||
})
|
||||
}
|
||||
curatedProjection[providerID] = projected
|
||||
}
|
||||
|
||||
generatedProjection := make(map[string][]editorial, len(generated))
|
||||
for providerID, entries := range generated {
|
||||
curated := map[string]curatedModel{}
|
||||
for _, c := range curation[providerID] {
|
||||
curated[c.ModelIdentifier] = c
|
||||
}
|
||||
projected := make([]editorial, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
displayName := ""
|
||||
if curated[e.ModelIdentifier].DisplayName != "" {
|
||||
displayName = e.DisplayName
|
||||
}
|
||||
projected = append(projected, editorial{
|
||||
ModelIdentifier: e.ModelIdentifier,
|
||||
Aliases: e.Aliases,
|
||||
DisplayName: displayName,
|
||||
ReasoningEffort: e.ReasoningEffort,
|
||||
ThinkingBudgetTokens: e.ThinkingBudgetTokens,
|
||||
})
|
||||
}
|
||||
generatedProjection[providerID] = projected
|
||||
}
|
||||
|
||||
require.Equal(t, curatedProjection, generatedProjection,
|
||||
"curation.json and knownModelsGenerated.json disagree; run `make gen/aibridge-prices`")
|
||||
}
|
||||
|
||||
func embeddedCuration(t *testing.T) map[string][]curatedModel {
|
||||
t.Helper()
|
||||
var curation map[string][]curatedModel
|
||||
require.NoError(t, json.Unmarshal(curationJSON, &curation))
|
||||
return curation
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"openai": [
|
||||
{
|
||||
"modelIdentifier": "gpt-5.6-sol",
|
||||
"aliases": ["gpt-5.6"],
|
||||
"reasoningEffort": "medium"
|
||||
},
|
||||
{
|
||||
"modelIdentifier": "gpt-5.6-terra",
|
||||
"reasoningEffort": "medium"
|
||||
},
|
||||
{
|
||||
"modelIdentifier": "gpt-5.6-luna",
|
||||
"reasoningEffort": "medium"
|
||||
},
|
||||
{
|
||||
"modelIdentifier": "gpt-5.5",
|
||||
"reasoningEffort": "medium"
|
||||
},
|
||||
{
|
||||
"modelIdentifier": "gpt-5.5-pro",
|
||||
"reasoningEffort": "high"
|
||||
},
|
||||
{
|
||||
"modelIdentifier": "gpt-5.4"
|
||||
},
|
||||
{
|
||||
"modelIdentifier": "gpt-5.4-mini",
|
||||
"reasoningEffort": "medium"
|
||||
},
|
||||
{
|
||||
"modelIdentifier": "gpt-5.4-nano"
|
||||
},
|
||||
{
|
||||
"modelIdentifier": "gpt-5.3-codex",
|
||||
"reasoningEffort": "medium"
|
||||
}
|
||||
],
|
||||
"anthropic": [
|
||||
{
|
||||
"modelIdentifier": "claude-fable-5",
|
||||
"reasoningEffort": "high"
|
||||
},
|
||||
{
|
||||
"modelIdentifier": "claude-mythos-5",
|
||||
"reasoningEffort": "high"
|
||||
},
|
||||
{
|
||||
"modelIdentifier": "claude-opus-4-8",
|
||||
"reasoningEffort": "high"
|
||||
},
|
||||
{
|
||||
"modelIdentifier": "claude-opus-4-7",
|
||||
"reasoningEffort": "high"
|
||||
},
|
||||
{
|
||||
"modelIdentifier": "claude-opus-4-6",
|
||||
"reasoningEffort": "high"
|
||||
},
|
||||
{
|
||||
"modelIdentifier": "claude-sonnet-5",
|
||||
"reasoningEffort": "high"
|
||||
},
|
||||
{
|
||||
"modelIdentifier": "claude-sonnet-4-6",
|
||||
"reasoningEffort": "medium"
|
||||
},
|
||||
{
|
||||
"modelIdentifier": "claude-haiku-4-5",
|
||||
"aliases": ["claude-haiku-4-5-20251001"],
|
||||
"displayName": "Claude Haiku 4.5",
|
||||
"thinkingBudgetTokens": 8192
|
||||
},
|
||||
{
|
||||
"modelIdentifier": "claude-sonnet-4-5",
|
||||
"aliases": ["claude-sonnet-4-5-20250929"],
|
||||
"displayName": "Claude Sonnet 4.5",
|
||||
"thinkingBudgetTokens": 8192
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,37 +1,30 @@
|
||||
// aibridgepricesgen fetches model pricing from models.dev and writes a JSON
|
||||
// seed file consumable by the AI Bridge cost-control loader. Output is sorted
|
||||
// by (provider, model) so regenerations produce minimal diffs.
|
||||
// aibridgepricesgen converts a models.dev api.json snapshot into generated
|
||||
// artifacts, selected by -format:
|
||||
//
|
||||
// Run via the gen/aibridge-prices Make target. 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.
|
||||
// - "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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
const (
|
||||
sourceURL = "https://models.dev/api.json"
|
||||
fetchTimeout = 30 * time.Second
|
||||
// Cap the upstream body read. The current api.json is ~2 MiB, so 100
|
||||
// MiB is pure defense-in-depth against a misbehaving upstream eating
|
||||
// arbitrary memory on developer or CI machines. An overflow surfaces
|
||||
// as a JSON parse error (LimitReader truncates silently at the cap).
|
||||
maxBodyBytes = 100 << 20
|
||||
)
|
||||
|
||||
// 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"}
|
||||
@@ -42,10 +35,18 @@ type upstreamProvider struct {
|
||||
}
|
||||
|
||||
type upstreamModel struct {
|
||||
Cost *upstreamCost `json:"cost"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// Pointers distinguish "key absent" (nil) from "key present and zero" (0).
|
||||
type upstreamCost struct {
|
||||
Input *float64 `json:"input"`
|
||||
Output *float64 `json:"output"`
|
||||
@@ -80,17 +81,51 @@ type priceRow struct {
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
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() error {
|
||||
upstream, err := fetch()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch %s: %w", sourceURL, err)
|
||||
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
|
||||
@@ -105,28 +140,20 @@ func run() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetch() (map[string]upstreamProvider, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, 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 nil, err
|
||||
return err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if err := writeCatalog(os.Stdout, catalog); err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, xerrors.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var data map[string]upstreamProvider
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, maxBodyBytes)).Decode(&data); err != nil {
|
||||
return nil, xerrors.Errorf("parse: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
_, _ = 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
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Patches applied to the raw models.dev api.json before aibridgepricesgen
|
||||
# consumes it. The Makefile pipes the fetched payload through this filter
|
||||
# (jq -f scripts/aibridgepricesgen/overrides.jq) and both generated outputs
|
||||
# (prices.json and knownModelsGenerated.json) read the patched snapshot.
|
||||
#
|
||||
# Every patch guards its assumption about upstream, so a stale override
|
||||
# fails the pipeline loudly instead of silently patching nothing.
|
||||
|
||||
# claude-sonnet-4-5: models.dev advertises a 1M-token context window, which
|
||||
# is incorrect. Anthropic retired the 1M context window beta on May 1st,
|
||||
# 2026. Ref: https://platform.claude.com/docs/en/about-claude/models/overview
|
||||
if .anthropic.models | has("claude-sonnet-4-5") then
|
||||
.anthropic.models."claude-sonnet-4-5".limit.context = 200000
|
||||
else
|
||||
error("overrides.jq: claude-sonnet-4-5 gone from upstream; drop or update its context pin")
|
||||
end
|
||||
|
||||
# claude-mythos-5: not listed on models.dev. Anthropic documents it as sharing
|
||||
# claude-fable-5's specs and pricing, so inject it as a copy with its own
|
||||
# id and display name.
|
||||
# Ref: https://platform.claude.com/docs/en/about-claude/pricing#model-pricing
|
||||
| if (.anthropic.models | has("claude-fable-5") | not) then
|
||||
error("overrides.jq: claude-fable-5 gone from upstream; the claude-mythos-5 copy has no source")
|
||||
elif (.anthropic.models | has("claude-mythos-5")) then
|
||||
error("overrides.jq: claude-mythos-5 now present upstream; drop the injection")
|
||||
else
|
||||
.anthropic.models."claude-mythos-5" = (
|
||||
.anthropic.models."claude-fable-5"
|
||||
| .id = "claude-mythos-5"
|
||||
| .name = "Claude Mythos 5"
|
||||
)
|
||||
end
|
||||
Reference in New Issue
Block a user