From 1195de344a6cac0b25e9a48e662a1628408aaa59 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Thu, 13 Aug 2026 19:22:31 +0000 Subject: [PATCH 01/30] feat: automate weekly AI model price book refresh The price book is regenerated by hand with `make gen/aibridge-prices`, so newly launched models stay unpriced and changed prices stay stale until someone remembers to run it. Add a weekly workflow that regenerates both artifacts from live models.dev data and opens a pull request when the output changes. The refresh is never merged automatically: prices are customer-visible cost numbers, so every change goes through human review. A generator failure, which happens by design when upstream drops a pinned or curated model, is announced in Slack. The pull request body carries a generated summary of models added, models removed, and prices changed, rendered by a new deterministic scripts/aibridgepricesdiff tool. Add CODEOWNERS entries for the price book, the frontend catalog, and both generators so the right reviewer is requested on every refresh. --- .../workflows/aibridge-prices-refresh.yaml | 163 +++++++++++ CODEOWNERS | 9 + scripts/aibridgepricesdiff/main.go | 276 ++++++++++++++++++ scripts/aibridgepricesdiff/main_test.go | 241 +++++++++++++++ 4 files changed, 689 insertions(+) create mode 100644 .github/workflows/aibridge-prices-refresh.yaml create mode 100644 scripts/aibridgepricesdiff/main.go create mode 100644 scripts/aibridgepricesdiff/main_test.go diff --git a/.github/workflows/aibridge-prices-refresh.yaml b/.github/workflows/aibridge-prices-refresh.yaml new file mode 100644 index 00000000000..3c776a13e84 --- /dev/null +++ b/.github/workflows/aibridge-prices-refresh.yaml @@ -0,0 +1,163 @@ +# Refreshes the AI Bridge price book from live upstream data (models.dev) +# once a week and opens a pull request when the generated artifacts change. +# +# The price book seeds customer-visible cost numbers, so the refresh is never +# merged automatically. The workflow only ever proposes a change; a human +# reviews and merges it. +# +# Behavior: +# - Runs every Monday. If regeneration produces no diff, the run ends +# without opening anything. +# - Reuses a single branch and pull request, force-pushing each week, so at +# most one refresh PR is open and it always carries the newest snapshot. +# - Fails loudly when the generator refuses to run, which it does by design +# when upstream drops a model pinned in overrides.jq or curated in +# curation.json. Failures are announced in Slack. +name: aibridge-prices-refresh + +on: + schedule: + # 09:00 UTC every Monday, so the PR is waiting when EU and US start the + # week and there is room to review well before the release freeze. + - cron: "0 9 * * 1" + workflow_dispatch: # allows manual runs for testing + +permissions: {} + +concurrency: + group: aibridge-prices-refresh + +env: + REFRESH_BRANCH: bot/aibridge-prices-refresh + PRICES_FILE: coderd/aibridge/prices/data/prices.json + CATALOG_FILE: site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/knownModelsGenerated.json + +jobs: + refresh: + name: Refresh price book + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Harden Runner + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up mise tools + uses: ./.github/actions/setup-mise + with: + install-args: "go node pnpm" + + # Catalog generation formats its output through scripts/biome_format.sh, + # which runs `pnpm exec biome` from site/. Without the install, that + # script logs a warning and exits 0, committing an unformatted file. + - name: Install pnpm dependencies + uses: ./.github/actions/pnpm-install + + - name: Snapshot the current price book + run: cp "${PRICES_FILE}" "${RUNNER_TEMP}/prices-before.json" + + - name: Regenerate price book and model catalog + run: make gen/aibridge-prices + + - name: Detect changes + id: detect + run: | + set -euo pipefail + if git diff --quiet -- "${PRICES_FILE}" "${CATALOG_FILE}"; then + echo "Price book already matches upstream; nothing to propose." + echo "changed=false" >> "$GITHUB_OUTPUT" + else + git diff --stat -- "${PRICES_FILE}" "${CATALOG_FILE}" + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Build pull request body + if: steps.detect.outputs.changed == 'true' + run: | + set -euo pipefail + go run ./scripts/aibridgepricesdiff \ + -old "${RUNNER_TEMP}/prices-before.json" \ + -new "${PRICES_FILE}" > "${RUNNER_TEMP}/summary.md" + + { + cat "${RUNNER_TEMP}/summary.md" + echo + echo "## Review notes" + echo + echo "Regenerated by \`make gen/aibridge-prices\` from the live" + echo "[models.dev](https://models.dev) catalog. Both artifacts come from one" + echo "snapshot, so they ship together:" + echo + echo "- \`${PRICES_FILE}\`" + echo "- \`${CATALOG_FILE}\`" + echo + echo "These are customer-visible cost numbers taken from upstream data, so this" + echo "PR is never merged automatically. Check the summary above against the" + echo "provider's published pricing before approving." + echo + echo "Opened automatically by the [aibridge-prices-refresh workflow](${RUN_URL})." + } > "${RUNNER_TEMP}/body.md" + + cat "${RUNNER_TEMP}/body.md" + env: + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + + - name: Open or update the refresh pull request + if: steps.detect.outputs.changed == 'true' + env: + # The default GITHUB_TOKEN cannot trigger workflow runs, which would + # leave the PR without CI signal on a file that feeds cost + # calculation. cdrci is the machine user already used for + # bot-authored PRs in release.yaml. + GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} + PR_TITLE: "chore: refresh AI model price book" + run: | + set -euo pipefail + + # persist-credentials is disabled on checkout, so authenticate the + # push explicitly. + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git config user.name "cdrci" + git config user.email "cdrci@coder.com" + + git checkout -b "${REFRESH_BRANCH}" + git add -- "${PRICES_FILE}" "${CATALOG_FILE}" + git commit -m "${PR_TITLE}" + + # Force-push: the branch is regenerated from the newest upstream + # snapshot each week, so the previous contents are always stale. + git push --force origin "refs/heads/${REFRESH_BRANCH}" + + pr_number="$(gh pr list --head "${REFRESH_BRANCH}" --base main --state open --json number --jq '.[0].number // empty')" + if [ -n "${pr_number}" ]; then + gh pr edit "${pr_number}" --title "${PR_TITLE}" --body-file "${RUNNER_TEMP}/body.md" + echo "Updated existing PR #${pr_number}." + else + gh pr create \ + --base main \ + --head "${REFRESH_BRANCH}" \ + --title "${PR_TITLE}" \ + --body-file "${RUNNER_TEMP}/body.md" + fi + + - name: Send Slack notification on failure + if: failure() + env: + SLACK_WEBHOOK: ${{ secrets.AIBRIDGE_PRICES_SLACK_WEBHOOK }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + if [ -z "${SLACK_WEBHOOK}" ]; then + echo "::warning::AIBRIDGE_PRICES_SLACK_WEBHOOK is not set; skipping notification." + exit 0 + fi + text=":warning: *AI model price book refresh failed.*\nThe generator fails by design when upstream drops a model pinned in scripts/aibridgepricesgen/overrides.jq or curated in curation.json. Logs: ${RUN_URL}" + payload="$(jq -nc --arg text "${text}" '{text: $text}')" + curl -fsSL -X POST -H 'Content-type: application/json' -d "${payload}" "${SLACK_WEBHOOK}" + echo "Sent Slack notification" diff --git a/CODEOWNERS b/CODEOWNERS index 513013605a2..229cd605681 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -40,3 +40,12 @@ coderd/database/queries/ai_provider_keys.sql @ibetitsmike @johnstcn coderd/database/queries/aicostcontrol.sql @ibetitsmike @johnstcn codersdk/aiproviders.go @ibetitsmike @johnstcn codersdk/aiproviders_bedrock.go @ibetitsmike @johnstcn + +# Generated AI model price book and frontend model catalog, plus the +# generators behind them. Both artifacts are refreshed from live models.dev +# data by the aibridge-prices-refresh workflow and carry customer-visible +# cost numbers, so every refresh needs a review from someone who owns them. +coderd/aibridge/prices/data/prices.json @evgeniy-scherbina +scripts/aibridgepricesgen/ @evgeniy-scherbina +scripts/aibridgepricesdiff/ @evgeniy-scherbina +site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/ @evgeniy-scherbina diff --git a/scripts/aibridgepricesdiff/main.go b/scripts/aibridgepricesdiff/main.go new file mode 100644 index 00000000000..0ed8f80351d --- /dev/null +++ b/scripts/aibridgepricesdiff/main.go @@ -0,0 +1,276 @@ +// aibridgepricesdiff renders a human-readable Markdown summary of the +// difference between two AI Bridge price seed files (the prices.json produced +// by aibridgepricesgen). +// +// The weekly price refresh workflow uses it to fill the pull request body, so +// a reviewer sees which models appeared, which disappeared, and which prices +// moved without reading the raw JSON diff. The raw diff remains the source of +// truth; this output is a review aid. +// +// Usage: +// +// aibridgepricesdiff -old -new +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + "sort" + "strings" + + "golang.org/x/xerrors" +) + +// priceRow mirrors the seed file schema written by aibridgepricesgen. Pointer +// fields preserve the distinction between "not populated by upstream" (null) +// and "explicitly zero" (0). +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"` +} + +// key identifies a row across the two snapshots. +type key struct { + provider string + model string +} + +func (r priceRow) key() key { + return key{provider: r.Provider, model: r.Model} +} + +// priceField names one comparable price on a row, paired with an accessor so +// the comparison loop stays data-driven and column order stays stable. +type priceField struct { + label string + get func(priceRow) *int64 +} + +var priceFields = []priceField{ + {"input", func(r priceRow) *int64 { return r.InputPrice }}, + {"output", func(r priceRow) *int64 { return r.OutputPrice }}, + {"cache read", func(r priceRow) *int64 { return r.CacheReadPrice }}, + {"cache write", func(r priceRow) *int64 { return r.CacheWritePrice }}, +} + +// change records a single price field that differs between snapshots. +type change struct { + provider string + model string + field string + old *int64 + new *int64 +} + +// diff is the full comparison between two snapshots. +type diff struct { + added []priceRow + removed []priceRow + changed []change +} + +func (d diff) empty() bool { + return len(d.added) == 0 && len(d.removed) == 0 && len(d.changed) == 0 +} + +func main() { + oldPath := flag.String("old", "", "path to the previous prices.json (required)") + newPath := flag.String("new", "", "path to the refreshed prices.json (required)") + flag.Parse() + if err := run(*oldPath, *newPath, os.Stdout); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "aibridgepricesdiff: %v\n", err) + os.Exit(1) + } +} + +func run(oldPath, newPath string, w io.Writer) error { + if oldPath == "" || newPath == "" { + return xerrors.New("-old and -new are both required") + } + oldRows, err := readRows(oldPath) + if err != nil { + return xerrors.Errorf("read %s: %w", oldPath, err) + } + newRows, err := readRows(newPath) + if err != nil { + return xerrors.Errorf("read %s: %w", newPath, err) + } + _, err = io.WriteString(w, render(compare(oldRows, newRows))) + return err +} + +func readRows(path string) ([]priceRow, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var rows []priceRow + if err := json.Unmarshal(data, &rows); err != nil { + return nil, xerrors.Errorf("parse: %w", err) + } + return rows, nil +} + +// compare classifies every row as added, removed, or changed. Results are +// sorted by (provider, model) so the same inputs always render identically. +func compare(oldRows, newRows []priceRow) diff { + oldByKey := make(map[key]priceRow, len(oldRows)) + for _, r := range oldRows { + oldByKey[r.key()] = r + } + newByKey := make(map[key]priceRow, len(newRows)) + for _, r := range newRows { + newByKey[r.key()] = r + } + + var d diff + for _, r := range newRows { + prev, ok := oldByKey[r.key()] + if !ok { + d.added = append(d.added, r) + continue + } + for _, f := range priceFields { + before, after := f.get(prev), f.get(r) + if equalPrice(before, after) { + continue + } + d.changed = append(d.changed, change{ + provider: r.Provider, + model: r.Model, + field: f.label, + old: before, + new: after, + }) + } + } + for _, r := range oldRows { + if _, ok := newByKey[r.key()]; !ok { + d.removed = append(d.removed, r) + } + } + + sort.Slice(d.added, func(i, j int) bool { return lessRow(d.added[i], d.added[j]) }) + sort.Slice(d.removed, func(i, j int) bool { return lessRow(d.removed[i], d.removed[j]) }) + sort.SliceStable(d.changed, func(i, j int) bool { + if d.changed[i].provider != d.changed[j].provider { + return d.changed[i].provider < d.changed[j].provider + } + return d.changed[i].model < d.changed[j].model + }) + return d +} + +func lessRow(a, b priceRow) bool { + if a.Provider != b.Provider { + return a.Provider < b.Provider + } + return a.Model < b.Model +} + +func equalPrice(a, b *int64) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} + +// render writes the Markdown summary. Sections with no entries are omitted so +// a small refresh produces a short body. +func render(d diff) string { + var b strings.Builder + write := func(format string, args ...any) { + _, _ = fmt.Fprintf(&b, format, args...) + } + + write("## Price book changes\n\n") + if d.empty() { + write("No price changes; only non-price fields differ.\n") + return b.String() + } + + write("%s added, %s removed, %s changed.\n", + plural(len(d.added), "model"), + plural(len(d.removed), "model"), + plural(len(d.changed), "price"), + ) + write("\nPrices are USD per million tokens.\n") + + renderRows := func(heading string, rows []priceRow) { + if len(rows) == 0 { + return + } + write("\n### %s\n\n", heading) + write("| Provider | Model | Input | Output | Cache read | Cache write |\n") + write("| --- | --- | --- | --- | --- | --- |\n") + for _, r := range rows { + write("| %s | %s | %s | %s | %s | %s |\n", + r.Provider, r.Model, + formatPrice(r.InputPrice), formatPrice(r.OutputPrice), + formatPrice(r.CacheReadPrice), formatPrice(r.CacheWritePrice), + ) + } + } + renderRows("Added", d.added) + renderRows("Removed", d.removed) + + if len(d.changed) > 0 { + write("\n### Changed\n\n") + write("| Provider | Model | Field | Old | New | Delta |\n") + write("| --- | --- | --- | --- | --- | --- |\n") + for _, c := range d.changed { + write("| %s | %s | %s | %s | %s | %s |\n", + c.provider, c.model, c.field, + formatPrice(c.old), formatPrice(c.new), formatDelta(c.old, c.new), + ) + } + } + return b.String() +} + +func plural(n int, noun string) string { + if n == 1 { + return fmt.Sprintf("%d %s", n, noun) + } + return fmt.Sprintf("%d %ss", n, noun) +} + +// formatPrice converts integer micro-units back to the upstream USD figure. +// Trailing zeros are trimmed so 10000000 reads as "10" rather than "10.000000". +func formatPrice(micros *int64) string { + if micros == nil { + return "unset" + } + s := fmt.Sprintf("%.6f", float64(*micros)/1_000_000) + s = strings.TrimRight(s, "0") + s = strings.TrimSuffix(s, ".") + if s == "" || s == "-" { + return "0" + } + return s +} + +// formatDelta renders the relative move between two prices. A percentage is +// only meaningful when the previous value exists and is non-zero; every other +// transition is described in words. +func formatDelta(prev, next *int64) string { + switch { + case prev == nil && next == nil: + return "n/a" + case prev == nil: + return "newly priced" + case next == nil: + return "price removed" + case *prev == 0: + return "was free" + } + pct := (float64(*next) - float64(*prev)) / float64(*prev) * 100 + return fmt.Sprintf("%+.1f%%", pct) +} diff --git a/scripts/aibridgepricesdiff/main_test.go b/scripts/aibridgepricesdiff/main_test.go new file mode 100644 index 00000000000..bb6eba63b0f --- /dev/null +++ b/scripts/aibridgepricesdiff/main_test.go @@ -0,0 +1,241 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCompare(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + old []priceRow + new []priceRow + wantAdded []string + wantRemoved []string + wantChanged []change + }{ + { + name: "identical", + old: []priceRow{row("anthropic", "claude", 1, 2)}, + new: []priceRow{row("anthropic", "claude", 1, 2)}, + }, + { + name: "added", + old: []priceRow{row("anthropic", "claude", 1, 2)}, + new: []priceRow{row("anthropic", "claude", 1, 2), row("openai", "gpt", 3, 4)}, + wantAdded: []string{"openai/gpt"}, + }, + { + name: "removed", + old: []priceRow{row("anthropic", "claude", 1, 2), row("openai", "gpt", 3, 4)}, + new: []priceRow{row("anthropic", "claude", 1, 2)}, + wantRemoved: []string{"openai/gpt"}, + }, + { + name: "changed", + old: []priceRow{row("anthropic", "claude", 1, 2)}, + new: []priceRow{row("anthropic", "claude", 1, 5)}, + wantChanged: []change{ + {provider: "anthropic", model: "claude", field: "output", old: int64Ptr(2), new: int64Ptr(5)}, + }, + }, + { + // A model whose price becomes null is a change, not a removal. + name: "price unset", + old: []priceRow{row("anthropic", "claude", 1, 2)}, + new: []priceRow{{Provider: "anthropic", Model: "claude", InputPrice: int64Ptr(1)}}, + wantChanged: []change{ + {provider: "anthropic", model: "claude", field: "output", old: int64Ptr(2), new: nil}, + }, + }, + { + // Same model identifier under two providers must not collide. + name: "same model different providers", + old: []priceRow{row("anthropic", "shared", 1, 2), row("openai", "shared", 1, 2)}, + new: []priceRow{row("anthropic", "shared", 1, 2), row("openai", "shared", 9, 2)}, + wantChanged: []change{ + {provider: "openai", model: "shared", field: "input", old: int64Ptr(1), new: int64Ptr(9)}, + }, + }, + { + name: "added removed and changed together", + old: []priceRow{row("anthropic", "old-model", 1, 2), row("openai", "gpt", 3, 4)}, + new: []priceRow{row("anthropic", "new-model", 5, 6), row("openai", "gpt", 3, 7)}, + wantAdded: []string{"anthropic/new-model"}, + wantRemoved: []string{"anthropic/old-model"}, + wantChanged: []change{ + {provider: "openai", model: "gpt", field: "output", old: int64Ptr(4), new: int64Ptr(7)}, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := compare(tc.old, tc.new) + require.Equal(t, tc.wantAdded, names(got.added)) + require.Equal(t, tc.wantRemoved, names(got.removed)) + require.Equal(t, tc.wantChanged, got.changed) + }) + } +} + +func TestCompareSortsDeterministically(t *testing.T) { + t.Parallel() + + old := []priceRow{} + updated := []priceRow{ + row("openai", "b", 1, 1), + row("anthropic", "z", 1, 1), + row("anthropic", "a", 1, 1), + } + + got := compare(old, updated) + require.Equal(t, []string{"anthropic/a", "anthropic/z", "openai/b"}, names(got.added)) +} + +func TestRender(t *testing.T) { + t.Parallel() + + t.Run("no changes", func(t *testing.T) { + t.Parallel() + + out := render(compare( + []priceRow{row("anthropic", "claude", 1, 2)}, + []priceRow{row("anthropic", "claude", 1, 2)}, + )) + require.Contains(t, out, "No price changes") + require.NotContains(t, out, "### Added") + }) + + t.Run("full summary", func(t *testing.T) { + t.Parallel() + + out := render(compare( + []priceRow{row("anthropic", "gone", 1_000_000, 2_000_000), row("openai", "gpt", 1_000_000, 2_000_000)}, + []priceRow{row("anthropic", "fresh", 3_000_000, 4_000_000), row("openai", "gpt", 2_000_000, 2_000_000)}, + )) + + require.Contains(t, out, "1 model added, 1 model removed, 1 price changed.") + require.Contains(t, out, "| anthropic | fresh | 3 | 4 | unset | unset |") + require.Contains(t, out, "| anthropic | gone | 1 | 2 | unset | unset |") + require.Contains(t, out, "| openai | gpt | input | 1 | 2 | +100.0% |") + }) +} + +func TestFormatPrice(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in *int64 + want string + }{ + {"missing", nil, "unset"}, + {"zero", int64Ptr(0), "0"}, + {"whole", int64Ptr(10_000_000), "10"}, + {"fractional", int64Ptr(75_000), "0.075"}, + {"sub micro unit", int64Ptr(1), "0.000001"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, formatPrice(tc.in)) + }) + } +} + +func TestFormatDelta(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + prev, next *int64 + want string + }{ + {"increase", int64Ptr(1_000_000), int64Ptr(2_000_000), "+100.0%"}, + {"decrease", int64Ptr(2_000_000), int64Ptr(1_000_000), "-50.0%"}, + {"newly priced", nil, int64Ptr(1), "newly priced"}, + {"price removed", int64Ptr(1), nil, "price removed"}, + {"was free", int64Ptr(0), int64Ptr(1), "was free"}, + {"both missing", nil, nil, "n/a"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, formatDelta(tc.prev, tc.next)) + }) + } +} + +func TestRun(t *testing.T) { + t.Parallel() + + t.Run("missing flags", func(t *testing.T) { + t.Parallel() + + var out strings.Builder + require.Error(t, run("", "", &out)) + }) + + t.Run("reads files", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + oldPath := filepath.Join(dir, "old.json") + newPath := filepath.Join(dir, "new.json") + writeFile(t, oldPath, `[{"provider":"openai","model":"gpt","input_price":1000000,"output_price":2000000,"cache_read_price":null,"cache_write_price":null}]`) + writeFile(t, newPath, `[{"provider":"openai","model":"gpt","input_price":1500000,"output_price":2000000,"cache_read_price":null,"cache_write_price":null}]`) + + var out strings.Builder + require.NoError(t, run(oldPath, newPath, &out)) + require.Contains(t, out.String(), "| openai | gpt | input | 1 | 1.5 | +50.0% |") + }) + + t.Run("invalid json", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "bad.json") + writeFile(t, path, "{") + + var out strings.Builder + require.Error(t, run(path, path, &out)) + }) +} + +func row(provider, model string, input, output int64) priceRow { + return priceRow{ + Provider: provider, + Model: model, + InputPrice: int64Ptr(input), + OutputPrice: int64Ptr(output), + } +} + +func names(rows []priceRow) []string { + if len(rows) == 0 { + return nil + } + out := make([]string, 0, len(rows)) + for _, r := range rows { + out = append(out, r.Provider+"/"+r.Model) + } + return out +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) +} + +func int64Ptr(v int64) *int64 { return &v } From 76b0a4b3dd35c61d839ae6bc9ef8918747bd8fa0 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Thu, 13 Aug 2026 19:56:51 +0000 Subject: [PATCH 02/30] refactor(scripts/aibridgepricesdiff): list models only in the summary The pull request diff already shows exact prices for every added, removed, and changed row, so repeating them in the body duplicated what a reviewer can read directly. Keep the counts and the model lists, which answer what moved without restating the diff. --- .../workflows/aibridge-prices-refresh.yaml | 4 +- scripts/aibridgepricesdiff/main.go | 103 +++++++----------- scripts/aibridgepricesdiff/main_test.go | 62 +++-------- 3 files changed, 58 insertions(+), 111 deletions(-) diff --git a/.github/workflows/aibridge-prices-refresh.yaml b/.github/workflows/aibridge-prices-refresh.yaml index 3c776a13e84..7aec3ad3038 100644 --- a/.github/workflows/aibridge-prices-refresh.yaml +++ b/.github/workflows/aibridge-prices-refresh.yaml @@ -98,8 +98,8 @@ jobs: echo "- \`${CATALOG_FILE}\`" echo echo "These are customer-visible cost numbers taken from upstream data, so this" - echo "PR is never merged automatically. Check the summary above against the" - echo "provider's published pricing before approving." + echo "PR is never merged automatically. The summary above lists what moved; check" + echo "the diff for exact figures before approving." echo echo "Opened automatically by the [aibridge-prices-refresh workflow](${RUN_URL})." } > "${RUNNER_TEMP}/body.md" diff --git a/scripts/aibridgepricesdiff/main.go b/scripts/aibridgepricesdiff/main.go index 0ed8f80351d..36427fa5d93 100644 --- a/scripts/aibridgepricesdiff/main.go +++ b/scripts/aibridgepricesdiff/main.go @@ -3,9 +3,9 @@ // by aibridgepricesgen). // // The weekly price refresh workflow uses it to fill the pull request body, so -// a reviewer sees which models appeared, which disappeared, and which prices -// moved without reading the raw JSON diff. The raw diff remains the source of -// truth; this output is a review aid. +// a reviewer sees at a glance which models appeared, which disappeared, and +// which repriced. Exact figures are deliberately left to the pull request +// diff, which is the source of truth. // // Usage: // @@ -182,8 +182,10 @@ func equalPrice(a, b *int64) bool { return *a == *b } -// render writes the Markdown summary. Sections with no entries are omitted so -// a small refresh produces a short body. +// render writes the Markdown summary: counts, then the models in each +// category. Only provider and model are listed. Exact prices live in the +// pull request diff, so repeating them here would restate what a reviewer +// can already read. func render(d diff) string { var b strings.Builder write := func(format string, args ...any) { @@ -196,81 +198,60 @@ func render(d diff) string { return b.String() } - write("%s added, %s removed, %s changed.\n", + changedModels := changedModelNames(d.changed) + write("%s added, %s removed, %s changed across %s.\n", plural(len(d.added), "model"), plural(len(d.removed), "model"), plural(len(d.changed), "price"), + plural(len(changedModels), "model"), ) - write("\nPrices are USD per million tokens.\n") - renderRows := func(heading string, rows []priceRow) { - if len(rows) == 0 { + renderList := func(heading string, models []string) { + if len(models) == 0 { return } write("\n### %s\n\n", heading) - write("| Provider | Model | Input | Output | Cache read | Cache write |\n") - write("| --- | --- | --- | --- | --- | --- |\n") - for _, r := range rows { - write("| %s | %s | %s | %s | %s | %s |\n", - r.Provider, r.Model, - formatPrice(r.InputPrice), formatPrice(r.OutputPrice), - formatPrice(r.CacheReadPrice), formatPrice(r.CacheWritePrice), - ) + for _, m := range models { + write("- %s\n", m) } } - renderRows("Added", d.added) - renderRows("Removed", d.removed) + renderList("Added", modelNames(d.added)) + renderList("Removed", modelNames(d.removed)) + renderList("Changed", changedModels) - if len(d.changed) > 0 { - write("\n### Changed\n\n") - write("| Provider | Model | Field | Old | New | Delta |\n") - write("| --- | --- | --- | --- | --- | --- |\n") - for _, c := range d.changed { - write("| %s | %s | %s | %s | %s | %s |\n", - c.provider, c.model, c.field, - formatPrice(c.old), formatPrice(c.new), formatDelta(c.old, c.new), - ) - } - } return b.String() } -func plural(n int, noun string) string { - if n == 1 { - return fmt.Sprintf("%d %s", n, noun) +// modelNames renders rows as "provider/model", preserving input order. +func modelNames(rows []priceRow) []string { + out := make([]string, 0, len(rows)) + for _, r := range rows { + out = append(out, r.Provider+"/"+r.Model) } - return fmt.Sprintf("%d %ss", n, noun) + return out } -// formatPrice converts integer micro-units back to the upstream USD figure. -// Trailing zeros are trimmed so 10000000 reads as "10" rather than "10.000000". -func formatPrice(micros *int64) string { - if micros == nil { - return "unset" - } - s := fmt.Sprintf("%.6f", float64(*micros)/1_000_000) - s = strings.TrimRight(s, "0") - s = strings.TrimSuffix(s, ".") - if s == "" || s == "-" { - return "0" +// changedModelNames collapses per-field changes into one entry per model, so +// a model that repriced across every field is listed once. +func changedModelNames(changes []change) []string { + var ( + seen = make(map[string]struct{}, len(changes)) + out []string + ) + for _, c := range changes { + name := c.provider + "/" + c.model + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + out = append(out, name) } - return s + return out } -// formatDelta renders the relative move between two prices. A percentage is -// only meaningful when the previous value exists and is non-zero; every other -// transition is described in words. -func formatDelta(prev, next *int64) string { - switch { - case prev == nil && next == nil: - return "n/a" - case prev == nil: - return "newly priced" - case next == nil: - return "price removed" - case *prev == 0: - return "was free" +func plural(n int, noun string) string { + if n == 1 { + return fmt.Sprintf("%d %s", n, noun) } - pct := (float64(*next) - float64(*prev)) / float64(*prev) * 100 - return fmt.Sprintf("%+.1f%%", pct) + return fmt.Sprintf("%d %ss", n, noun) } diff --git a/scripts/aibridgepricesdiff/main_test.go b/scripts/aibridgepricesdiff/main_test.go index bb6eba63b0f..e314024a0c4 100644 --- a/scripts/aibridgepricesdiff/main_test.go +++ b/scripts/aibridgepricesdiff/main_test.go @@ -120,61 +120,27 @@ func TestRender(t *testing.T) { out := render(compare( []priceRow{row("anthropic", "gone", 1_000_000, 2_000_000), row("openai", "gpt", 1_000_000, 2_000_000)}, - []priceRow{row("anthropic", "fresh", 3_000_000, 4_000_000), row("openai", "gpt", 2_000_000, 2_000_000)}, + []priceRow{row("anthropic", "fresh", 3_000_000, 4_000_000), row("openai", "gpt", 2_000_000, 3_000_000)}, )) - require.Contains(t, out, "1 model added, 1 model removed, 1 price changed.") - require.Contains(t, out, "| anthropic | fresh | 3 | 4 | unset | unset |") - require.Contains(t, out, "| anthropic | gone | 1 | 2 | unset | unset |") - require.Contains(t, out, "| openai | gpt | input | 1 | 2 | +100.0% |") + require.Contains(t, out, "1 model added, 1 model removed, 2 prices changed across 1 model.") + require.Contains(t, out, "### Added\n\n- anthropic/fresh\n") + require.Contains(t, out, "### Removed\n\n- anthropic/gone\n") + // A model that repriced across two fields is listed once. + require.Contains(t, out, "### Changed\n\n- openai/gpt\n") + require.NotContains(t, out, "| Provider |") }) } -func TestFormatPrice(t *testing.T) { +func TestChangedModelNames(t *testing.T) { t.Parallel() - cases := []struct { - name string - in *int64 - want string - }{ - {"missing", nil, "unset"}, - {"zero", int64Ptr(0), "0"}, - {"whole", int64Ptr(10_000_000), "10"}, - {"fractional", int64Ptr(75_000), "0.075"}, - {"sub micro unit", int64Ptr(1), "0.000001"}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - require.Equal(t, tc.want, formatPrice(tc.in)) - }) - } -} - -func TestFormatDelta(t *testing.T) { - t.Parallel() - - cases := []struct { - name string - prev, next *int64 - want string - }{ - {"increase", int64Ptr(1_000_000), int64Ptr(2_000_000), "+100.0%"}, - {"decrease", int64Ptr(2_000_000), int64Ptr(1_000_000), "-50.0%"}, - {"newly priced", nil, int64Ptr(1), "newly priced"}, - {"price removed", int64Ptr(1), nil, "price removed"}, - {"was free", int64Ptr(0), int64Ptr(1), "was free"}, - {"both missing", nil, nil, "n/a"}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - require.Equal(t, tc.want, formatDelta(tc.prev, tc.next)) - }) + changes := []change{ + {provider: "openai", model: "gpt", field: "input"}, + {provider: "openai", model: "gpt", field: "output"}, + {provider: "anthropic", model: "claude", field: "input"}, } + require.Equal(t, []string{"openai/gpt", "anthropic/claude"}, changedModelNames(changes)) } func TestRun(t *testing.T) { @@ -198,7 +164,7 @@ func TestRun(t *testing.T) { var out strings.Builder require.NoError(t, run(oldPath, newPath, &out)) - require.Contains(t, out.String(), "| openai | gpt | input | 1 | 1.5 | +50.0% |") + require.Contains(t, out.String(), "### Changed\n\n- openai/gpt\n") }) t.Run("invalid json", func(t *testing.T) { From 131b1c1c796b471e4f996c8d54a5ede41370a9ea Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Thu, 13 Aug 2026 20:03:17 +0000 Subject: [PATCH 03/30] refactor(scripts/aibridgepricesdiff): count changed models, not price fields The per-field count did not match the model list below it and needed a qualifier to reconcile. Counting models keeps every figure on the same unit. --- scripts/aibridgepricesdiff/main.go | 3 +-- scripts/aibridgepricesdiff/main_test.go | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/aibridgepricesdiff/main.go b/scripts/aibridgepricesdiff/main.go index 36427fa5d93..266d7dd3a11 100644 --- a/scripts/aibridgepricesdiff/main.go +++ b/scripts/aibridgepricesdiff/main.go @@ -199,10 +199,9 @@ func render(d diff) string { } changedModels := changedModelNames(d.changed) - write("%s added, %s removed, %s changed across %s.\n", + write("%s added, %s removed, %s changed.\n", plural(len(d.added), "model"), plural(len(d.removed), "model"), - plural(len(d.changed), "price"), plural(len(changedModels), "model"), ) diff --git a/scripts/aibridgepricesdiff/main_test.go b/scripts/aibridgepricesdiff/main_test.go index e314024a0c4..1404f523a45 100644 --- a/scripts/aibridgepricesdiff/main_test.go +++ b/scripts/aibridgepricesdiff/main_test.go @@ -123,7 +123,7 @@ func TestRender(t *testing.T) { []priceRow{row("anthropic", "fresh", 3_000_000, 4_000_000), row("openai", "gpt", 2_000_000, 3_000_000)}, )) - require.Contains(t, out, "1 model added, 1 model removed, 2 prices changed across 1 model.") + require.Contains(t, out, "1 model added, 1 model removed, 1 model changed.") require.Contains(t, out, "### Added\n\n- anthropic/fresh\n") require.Contains(t, out, "### Removed\n\n- anthropic/gone\n") // A model that repriced across two fields is listed once. From 2eb49448a493819431b200f2fbd81532b47302ef Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Sun, 16 Aug 2026 17:44:34 +0000 Subject: [PATCH 04/30] refactor(scripts/aibridgepricesdiff): compare models instead of price fields The summary lists models per category, so tracking which individual price field moved produced detail nothing rendered. Compare rows as a unit and carry model keys through, which removes the per-field change type, the field accessor table, and the two name-mapping helpers. Every category now holds at most one entry per model, so a single key comparator gives a total order and the stable-sort requirement disappears. Verified byte-identical output against the previous implementation on a live upstream snapshot. --- scripts/aibridgepricesdiff/main.go | 154 ++++++++---------------- scripts/aibridgepricesdiff/main_test.go | 88 +++++++------- 2 files changed, 93 insertions(+), 149 deletions(-) diff --git a/scripts/aibridgepricesdiff/main.go b/scripts/aibridgepricesdiff/main.go index 266d7dd3a11..0e10cdbd69f 100644 --- a/scripts/aibridgepricesdiff/main.go +++ b/scripts/aibridgepricesdiff/main.go @@ -36,44 +36,34 @@ type priceRow struct { CacheWritePrice *int64 `json:"cache_write_price"` } -// key identifies a row across the two snapshots. -type key struct { +// modelKey identifies a model across the two snapshots. A model identifier is +// only unique within its provider, so both fields are part of the key. +type modelKey struct { provider string model string } -func (r priceRow) key() key { - return key{provider: r.Provider, model: r.Model} +func (r priceRow) key() modelKey { + return modelKey{provider: r.Provider, model: r.Model} } -// priceField names one comparable price on a row, paired with an accessor so -// the comparison loop stays data-driven and column order stays stable. -type priceField struct { - label string - get func(priceRow) *int64 +func (k modelKey) String() string { + return k.provider + "/" + k.model } -var priceFields = []priceField{ - {"input", func(r priceRow) *int64 { return r.InputPrice }}, - {"output", func(r priceRow) *int64 { return r.OutputPrice }}, - {"cache read", func(r priceRow) *int64 { return r.CacheReadPrice }}, - {"cache write", func(r priceRow) *int64 { return r.CacheWritePrice }}, -} - -// change records a single price field that differs between snapshots. -type change struct { - provider string - model string - field string - old *int64 - new *int64 +func less(a, b modelKey) bool { + if a.provider != b.provider { + return a.provider < b.provider + } + return a.model < b.model } -// diff is the full comparison between two snapshots. +// diff is the full comparison between two snapshots, as the models in each +// category. Which individual price fields moved is left to the file diff. type diff struct { - added []priceRow - removed []priceRow - changed []change + added []modelKey + removed []modelKey + changed []modelKey } func (d diff) empty() bool { @@ -118,61 +108,47 @@ func readRows(path string) ([]priceRow, error) { return rows, nil } -// compare classifies every row as added, removed, or changed. Results are -// sorted by (provider, model) so the same inputs always render identically. +// compare classifies every model as added, removed, or changed. Each category +// holds at most one entry per model, so sorting by key is a total order and +// the same inputs always render identically. func compare(oldRows, newRows []priceRow) diff { - oldByKey := make(map[key]priceRow, len(oldRows)) + oldByKey := make(map[modelKey]priceRow, len(oldRows)) for _, r := range oldRows { oldByKey[r.key()] = r } - newByKey := make(map[key]priceRow, len(newRows)) - for _, r := range newRows { - newByKey[r.key()] = r - } var d diff + seen := make(map[modelKey]struct{}, len(newRows)) for _, r := range newRows { + seen[r.key()] = struct{}{} prev, ok := oldByKey[r.key()] - if !ok { - d.added = append(d.added, r) - continue - } - for _, f := range priceFields { - before, after := f.get(prev), f.get(r) - if equalPrice(before, after) { - continue - } - d.changed = append(d.changed, change{ - provider: r.Provider, - model: r.Model, - field: f.label, - old: before, - new: after, - }) + switch { + case !ok: + d.added = append(d.added, r.key()) + case !samePrices(prev, r): + d.changed = append(d.changed, r.key()) } } for _, r := range oldRows { - if _, ok := newByKey[r.key()]; !ok { - d.removed = append(d.removed, r) + if _, ok := seen[r.key()]; !ok { + d.removed = append(d.removed, r.key()) } } - sort.Slice(d.added, func(i, j int) bool { return lessRow(d.added[i], d.added[j]) }) - sort.Slice(d.removed, func(i, j int) bool { return lessRow(d.removed[i], d.removed[j]) }) - sort.SliceStable(d.changed, func(i, j int) bool { - if d.changed[i].provider != d.changed[j].provider { - return d.changed[i].provider < d.changed[j].provider - } - return d.changed[i].model < d.changed[j].model - }) + for _, keys := range [][]modelKey{d.added, d.removed, d.changed} { + sort.Slice(keys, func(i, j int) bool { return less(keys[i], keys[j]) }) + } return d } -func lessRow(a, b priceRow) bool { - if a.Provider != b.Provider { - return a.Provider < b.Provider - } - return a.Model < b.Model +// samePrices reports whether two rows for the same model carry identical +// prices. A price moving to or from null counts as a change, so nil is +// compared rather than ignored. +func samePrices(a, b priceRow) bool { + return equalPrice(a.InputPrice, b.InputPrice) && + equalPrice(a.OutputPrice, b.OutputPrice) && + equalPrice(a.CacheReadPrice, b.CacheReadPrice) && + equalPrice(a.CacheWritePrice, b.CacheWritePrice) } func equalPrice(a, b *int64) bool { @@ -183,9 +159,7 @@ func equalPrice(a, b *int64) bool { } // render writes the Markdown summary: counts, then the models in each -// category. Only provider and model are listed. Exact prices live in the -// pull request diff, so repeating them here would restate what a reviewer -// can already read. +// category. Empty categories are omitted. func render(d diff) string { var b strings.Builder write := func(format string, args ...any) { @@ -198,56 +172,28 @@ func render(d diff) string { return b.String() } - changedModels := changedModelNames(d.changed) write("%s added, %s removed, %s changed.\n", plural(len(d.added), "model"), plural(len(d.removed), "model"), - plural(len(changedModels), "model"), + plural(len(d.changed), "model"), ) - renderList := func(heading string, models []string) { - if len(models) == 0 { + renderList := func(heading string, keys []modelKey) { + if len(keys) == 0 { return } write("\n### %s\n\n", heading) - for _, m := range models { - write("- %s\n", m) + for _, k := range keys { + write("- %s\n", k) } } - renderList("Added", modelNames(d.added)) - renderList("Removed", modelNames(d.removed)) - renderList("Changed", changedModels) + renderList("Added", d.added) + renderList("Removed", d.removed) + renderList("Changed", d.changed) return b.String() } -// modelNames renders rows as "provider/model", preserving input order. -func modelNames(rows []priceRow) []string { - out := make([]string, 0, len(rows)) - for _, r := range rows { - out = append(out, r.Provider+"/"+r.Model) - } - return out -} - -// changedModelNames collapses per-field changes into one entry per model, so -// a model that repriced across every field is listed once. -func changedModelNames(changes []change) []string { - var ( - seen = make(map[string]struct{}, len(changes)) - out []string - ) - for _, c := range changes { - name := c.provider + "/" + c.model - if _, ok := seen[name]; ok { - continue - } - seen[name] = struct{}{} - out = append(out, name) - } - return out -} - func plural(n int, noun string) string { if n == 1 { return fmt.Sprintf("%d %s", n, noun) diff --git a/scripts/aibridgepricesdiff/main_test.go b/scripts/aibridgepricesdiff/main_test.go index 1404f523a45..f3d188661b6 100644 --- a/scripts/aibridgepricesdiff/main_test.go +++ b/scripts/aibridgepricesdiff/main_test.go @@ -18,7 +18,7 @@ func TestCompare(t *testing.T) { new []priceRow wantAdded []string wantRemoved []string - wantChanged []change + wantChanged []string }{ { name: "identical", @@ -38,30 +38,44 @@ func TestCompare(t *testing.T) { wantRemoved: []string{"openai/gpt"}, }, { - name: "changed", - old: []priceRow{row("anthropic", "claude", 1, 2)}, - new: []priceRow{row("anthropic", "claude", 1, 5)}, - wantChanged: []change{ - {provider: "anthropic", model: "claude", field: "output", old: int64Ptr(2), new: int64Ptr(5)}, - }, + name: "changed", + old: []priceRow{row("anthropic", "claude", 1, 2)}, + new: []priceRow{row("anthropic", "claude", 1, 5)}, + wantChanged: []string{"anthropic/claude"}, + }, + { + // Every price field participates, not just input and output. + name: "cache price changed", + old: []priceRow{{ + Provider: "anthropic", Model: "claude", + CacheReadPrice: int64Ptr(1), + }}, + new: []priceRow{{ + Provider: "anthropic", Model: "claude", + CacheReadPrice: int64Ptr(2), + }}, + wantChanged: []string{"anthropic/claude"}, }, { // A model whose price becomes null is a change, not a removal. - name: "price unset", - old: []priceRow{row("anthropic", "claude", 1, 2)}, - new: []priceRow{{Provider: "anthropic", Model: "claude", InputPrice: int64Ptr(1)}}, - wantChanged: []change{ - {provider: "anthropic", model: "claude", field: "output", old: int64Ptr(2), new: nil}, - }, + name: "price unset", + old: []priceRow{row("anthropic", "claude", 1, 2)}, + new: []priceRow{{Provider: "anthropic", Model: "claude", InputPrice: int64Ptr(1)}}, + wantChanged: []string{"anthropic/claude"}, + }, + { + // Zero is a real price, distinct from an absent one. + name: "zero is not null", + old: []priceRow{{Provider: "openai", Model: "gpt", InputPrice: int64Ptr(0)}}, + new: []priceRow{{Provider: "openai", Model: "gpt"}}, + wantChanged: []string{"openai/gpt"}, }, { // Same model identifier under two providers must not collide. - name: "same model different providers", - old: []priceRow{row("anthropic", "shared", 1, 2), row("openai", "shared", 1, 2)}, - new: []priceRow{row("anthropic", "shared", 1, 2), row("openai", "shared", 9, 2)}, - wantChanged: []change{ - {provider: "openai", model: "shared", field: "input", old: int64Ptr(1), new: int64Ptr(9)}, - }, + name: "same model different providers", + old: []priceRow{row("anthropic", "shared", 1, 2), row("openai", "shared", 1, 2)}, + new: []priceRow{row("anthropic", "shared", 1, 2), row("openai", "shared", 9, 2)}, + wantChanged: []string{"openai/shared"}, }, { name: "added removed and changed together", @@ -69,9 +83,7 @@ func TestCompare(t *testing.T) { new: []priceRow{row("anthropic", "new-model", 5, 6), row("openai", "gpt", 3, 7)}, wantAdded: []string{"anthropic/new-model"}, wantRemoved: []string{"anthropic/old-model"}, - wantChanged: []change{ - {provider: "openai", model: "gpt", field: "output", old: int64Ptr(4), new: int64Ptr(7)}, - }, + wantChanged: []string{"openai/gpt"}, }, } @@ -82,7 +94,7 @@ func TestCompare(t *testing.T) { got := compare(tc.old, tc.new) require.Equal(t, tc.wantAdded, names(got.added)) require.Equal(t, tc.wantRemoved, names(got.removed)) - require.Equal(t, tc.wantChanged, got.changed) + require.Equal(t, tc.wantChanged, names(got.changed)) }) } } @@ -90,14 +102,13 @@ func TestCompare(t *testing.T) { func TestCompareSortsDeterministically(t *testing.T) { t.Parallel() - old := []priceRow{} updated := []priceRow{ row("openai", "b", 1, 1), row("anthropic", "z", 1, 1), row("anthropic", "a", 1, 1), } - got := compare(old, updated) + got := compare(nil, updated) require.Equal(t, []string{"anthropic/a", "anthropic/z", "openai/b"}, names(got.added)) } @@ -119,30 +130,17 @@ func TestRender(t *testing.T) { t.Parallel() out := render(compare( - []priceRow{row("anthropic", "gone", 1_000_000, 2_000_000), row("openai", "gpt", 1_000_000, 2_000_000)}, - []priceRow{row("anthropic", "fresh", 3_000_000, 4_000_000), row("openai", "gpt", 2_000_000, 3_000_000)}, + []priceRow{row("anthropic", "gone", 1, 2), row("openai", "gpt", 1, 2)}, + []priceRow{row("anthropic", "fresh", 3, 4), row("openai", "gpt", 2, 3)}, )) require.Contains(t, out, "1 model added, 1 model removed, 1 model changed.") require.Contains(t, out, "### Added\n\n- anthropic/fresh\n") require.Contains(t, out, "### Removed\n\n- anthropic/gone\n") - // A model that repriced across two fields is listed once. require.Contains(t, out, "### Changed\n\n- openai/gpt\n") - require.NotContains(t, out, "| Provider |") }) } -func TestChangedModelNames(t *testing.T) { - t.Parallel() - - changes := []change{ - {provider: "openai", model: "gpt", field: "input"}, - {provider: "openai", model: "gpt", field: "output"}, - {provider: "anthropic", model: "claude", field: "input"}, - } - require.Equal(t, []string{"openai/gpt", "anthropic/claude"}, changedModelNames(changes)) -} - func TestRun(t *testing.T) { t.Parallel() @@ -188,13 +186,13 @@ func row(provider, model string, input, output int64) priceRow { } } -func names(rows []priceRow) []string { - if len(rows) == 0 { +func names(keys []modelKey) []string { + if len(keys) == 0 { return nil } - out := make([]string, 0, len(rows)) - for _, r := range rows { - out = append(out, r.Provider+"/"+r.Model) + out := make([]string, 0, len(keys)) + for _, k := range keys { + out = append(out, k.String()) } return out } From 34ea73789138b59e1b7bb1e51108ad6398b34572 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Sun, 16 Aug 2026 18:54:24 +0000 Subject: [PATCH 05/30] refactor: minor changes in comments --- scripts/aibridgepricesdiff/main.go | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/scripts/aibridgepricesdiff/main.go b/scripts/aibridgepricesdiff/main.go index 0e10cdbd69f..5918e5092cb 100644 --- a/scripts/aibridgepricesdiff/main.go +++ b/scripts/aibridgepricesdiff/main.go @@ -2,7 +2,7 @@ // difference between two AI Bridge price seed files (the prices.json produced // by aibridgepricesgen). // -// The weekly price refresh workflow uses it to fill the pull request body, so +// The price refresh workflow uses it to fill the pull request body, so // a reviewer sees at a glance which models appeared, which disappeared, and // which repriced. Exact figures are deliberately left to the pull request // diff, which is the source of truth. @@ -36,8 +36,7 @@ type priceRow struct { CacheWritePrice *int64 `json:"cache_write_price"` } -// modelKey identifies a model across the two snapshots. A model identifier is -// only unique within its provider, so both fields are part of the key. +// modelKey identifies a model across the two snapshots. type modelKey struct { provider string model string @@ -58,8 +57,7 @@ func less(a, b modelKey) bool { return a.model < b.model } -// diff is the full comparison between two snapshots, as the models in each -// category. Which individual price fields moved is left to the file diff. +// diff is the full comparison between two snapshots type diff struct { added []modelKey removed []modelKey @@ -108,9 +106,7 @@ func readRows(path string) ([]priceRow, error) { return rows, nil } -// compare classifies every model as added, removed, or changed. Each category -// holds at most one entry per model, so sorting by key is a total order and -// the same inputs always render identically. +// compare classifies every model as added, removed, or changed. func compare(oldRows, newRows []priceRow) diff { oldByKey := make(map[modelKey]priceRow, len(oldRows)) for _, r := range oldRows { @@ -142,8 +138,7 @@ func compare(oldRows, newRows []priceRow) diff { } // samePrices reports whether two rows for the same model carry identical -// prices. A price moving to or from null counts as a change, so nil is -// compared rather than ignored. +// prices. A price moving to or from null counts as a change. func samePrices(a, b priceRow) bool { return equalPrice(a.InputPrice, b.InputPrice) && equalPrice(a.OutputPrice, b.OutputPrice) && @@ -168,7 +163,7 @@ func render(d diff) string { write("## Price book changes\n\n") if d.empty() { - write("No price changes; only non-price fields differ.\n") + write("No price changes.\n") return b.String() } From 6a2d79dc2d4ca8a9450e1a17f0bfb15637ec707c Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Sun, 16 Aug 2026 19:03:33 +0000 Subject: [PATCH 06/30] test(scripts/aibridgepricesdiff): split multi-model fixtures onto their own lines Cases with more than one model packed both rows onto a single line, which made the difference between the old and new snapshot hard to spot when reviewing. --- scripts/aibridgepricesdiff/main_test.go | 40 ++++++++++++++++++------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/scripts/aibridgepricesdiff/main_test.go b/scripts/aibridgepricesdiff/main_test.go index f3d188661b6..04a50459d7e 100644 --- a/scripts/aibridgepricesdiff/main_test.go +++ b/scripts/aibridgepricesdiff/main_test.go @@ -26,14 +26,20 @@ func TestCompare(t *testing.T) { new: []priceRow{row("anthropic", "claude", 1, 2)}, }, { - name: "added", - old: []priceRow{row("anthropic", "claude", 1, 2)}, - new: []priceRow{row("anthropic", "claude", 1, 2), row("openai", "gpt", 3, 4)}, + name: "added", + old: []priceRow{row("anthropic", "claude", 1, 2)}, + new: []priceRow{ + row("anthropic", "claude", 1, 2), + row("openai", "gpt", 3, 4), + }, wantAdded: []string{"openai/gpt"}, }, { - name: "removed", - old: []priceRow{row("anthropic", "claude", 1, 2), row("openai", "gpt", 3, 4)}, + name: "removed", + old: []priceRow{ + row("anthropic", "claude", 1, 2), + row("openai", "gpt", 3, 4), + }, new: []priceRow{row("anthropic", "claude", 1, 2)}, wantRemoved: []string{"openai/gpt"}, }, @@ -72,15 +78,27 @@ func TestCompare(t *testing.T) { }, { // Same model identifier under two providers must not collide. - name: "same model different providers", - old: []priceRow{row("anthropic", "shared", 1, 2), row("openai", "shared", 1, 2)}, - new: []priceRow{row("anthropic", "shared", 1, 2), row("openai", "shared", 9, 2)}, + name: "same model different providers", + old: []priceRow{ + row("anthropic", "shared", 1, 2), + row("openai", "shared", 1, 2), + }, + new: []priceRow{ + row("anthropic", "shared", 1, 2), + row("openai", "shared", 9, 2), + }, wantChanged: []string{"openai/shared"}, }, { - name: "added removed and changed together", - old: []priceRow{row("anthropic", "old-model", 1, 2), row("openai", "gpt", 3, 4)}, - new: []priceRow{row("anthropic", "new-model", 5, 6), row("openai", "gpt", 3, 7)}, + name: "added removed and changed together", + old: []priceRow{ + row("anthropic", "old-model", 1, 2), + row("openai", "gpt", 3, 4), + }, + new: []priceRow{ + row("anthropic", "new-model", 5, 6), + row("openai", "gpt", 3, 7), + }, wantAdded: []string{"anthropic/new-model"}, wantRemoved: []string{"anthropic/old-model"}, wantChanged: []string{"openai/gpt"}, From 29aa76932b3c185657d251bfae8c72bc8e029ec3 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Sun, 16 Aug 2026 19:13:00 +0000 Subject: [PATCH 07/30] test(scripts/aibridgepricesdiff): one field and one row per line in fixtures Struct literals packed two fields onto a line, and cases mixed an inline old snapshot with a multiline new one, so the two sides did not line up when read side by side. Inline both only when each snapshot holds a single row. --- scripts/aibridgepricesdiff/main_test.go | 49 +++++++++++++++++++------ 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/scripts/aibridgepricesdiff/main_test.go b/scripts/aibridgepricesdiff/main_test.go index 04a50459d7e..38bbd9e94d9 100644 --- a/scripts/aibridgepricesdiff/main_test.go +++ b/scripts/aibridgepricesdiff/main_test.go @@ -27,7 +27,9 @@ func TestCompare(t *testing.T) { }, { name: "added", - old: []priceRow{row("anthropic", "claude", 1, 2)}, + old: []priceRow{ + row("anthropic", "claude", 1, 2), + }, new: []priceRow{ row("anthropic", "claude", 1, 2), row("openai", "gpt", 3, 4), @@ -40,7 +42,9 @@ func TestCompare(t *testing.T) { row("anthropic", "claude", 1, 2), row("openai", "gpt", 3, 4), }, - new: []priceRow{row("anthropic", "claude", 1, 2)}, + new: []priceRow{ + row("anthropic", "claude", 1, 2), + }, wantRemoved: []string{"openai/gpt"}, }, { @@ -53,27 +57,42 @@ func TestCompare(t *testing.T) { // Every price field participates, not just input and output. name: "cache price changed", old: []priceRow{{ - Provider: "anthropic", Model: "claude", + Provider: "anthropic", + Model: "claude", CacheReadPrice: int64Ptr(1), }}, new: []priceRow{{ - Provider: "anthropic", Model: "claude", + Provider: "anthropic", + Model: "claude", CacheReadPrice: int64Ptr(2), }}, wantChanged: []string{"anthropic/claude"}, }, { // A model whose price becomes null is a change, not a removal. - name: "price unset", - old: []priceRow{row("anthropic", "claude", 1, 2)}, - new: []priceRow{{Provider: "anthropic", Model: "claude", InputPrice: int64Ptr(1)}}, + name: "price unset", + old: []priceRow{ + row("anthropic", "claude", 1, 2), + }, + new: []priceRow{{ + Provider: "anthropic", + Model: "claude", + InputPrice: int64Ptr(1), + }}, wantChanged: []string{"anthropic/claude"}, }, { // Zero is a real price, distinct from an absent one. - name: "zero is not null", - old: []priceRow{{Provider: "openai", Model: "gpt", InputPrice: int64Ptr(0)}}, - new: []priceRow{{Provider: "openai", Model: "gpt"}}, + name: "zero is not null", + old: []priceRow{{ + Provider: "openai", + Model: "gpt", + InputPrice: int64Ptr(0), + }}, + new: []priceRow{{ + Provider: "openai", + Model: "gpt", + }}, wantChanged: []string{"openai/gpt"}, }, { @@ -148,8 +167,14 @@ func TestRender(t *testing.T) { t.Parallel() out := render(compare( - []priceRow{row("anthropic", "gone", 1, 2), row("openai", "gpt", 1, 2)}, - []priceRow{row("anthropic", "fresh", 3, 4), row("openai", "gpt", 2, 3)}, + []priceRow{ + row("anthropic", "gone", 1, 2), + row("openai", "gpt", 1, 2), + }, + []priceRow{ + row("anthropic", "fresh", 3, 4), + row("openai", "gpt", 2, 3), + }, )) require.Contains(t, out, "1 model added, 1 model removed, 1 model changed.") From 9f5d1dfd9aaa3eb7dfcefd3c0d7c66eae0bd1811 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Sun, 16 Aug 2026 20:00:14 +0000 Subject: [PATCH 08/30] refactor: minor changes --- scripts/aibridgepricesdiff/main_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/aibridgepricesdiff/main_test.go b/scripts/aibridgepricesdiff/main_test.go index 38bbd9e94d9..9b748da9461 100644 --- a/scripts/aibridgepricesdiff/main_test.go +++ b/scripts/aibridgepricesdiff/main_test.go @@ -139,13 +139,13 @@ func TestCompare(t *testing.T) { func TestCompareSortsDeterministically(t *testing.T) { t.Parallel() - updated := []priceRow{ + newRows := []priceRow{ row("openai", "b", 1, 1), row("anthropic", "z", 1, 1), row("anthropic", "a", 1, 1), } - got := compare(nil, updated) + got := compare(nil, newRows) require.Equal(t, []string{"anthropic/a", "anthropic/z", "openai/b"}, names(got.added)) } From f55330628d6d6499dc3767a2c701527280916e5a Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Sun, 16 Aug 2026 20:02:34 +0000 Subject: [PATCH 09/30] test(scripts/aibridgepricesdiff): assert the rendered document and error messages Substring assertions left section order, spacing, and stray content unverified: reordering the Removed and Changed sections passed the suite. Compare the whole rendered summary for both the empty and populated cases. Assert on error contents rather than mere failure, and cover a missing input file, which the workflow hits if the snapshot step is ever skipped. --- scripts/aibridgepricesdiff/main_test.go | 47 ++++++++++++++++++++----- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/scripts/aibridgepricesdiff/main_test.go b/scripts/aibridgepricesdiff/main_test.go index 9b748da9461..b79e187c640 100644 --- a/scripts/aibridgepricesdiff/main_test.go +++ b/scripts/aibridgepricesdiff/main_test.go @@ -159,8 +159,10 @@ func TestRender(t *testing.T) { []priceRow{row("anthropic", "claude", 1, 2)}, []priceRow{row("anthropic", "claude", 1, 2)}, )) - require.Contains(t, out, "No price changes") - require.NotContains(t, out, "### Added") + require.Equal(t, `## Price book changes + +No price changes. +`, out) }) t.Run("full summary", func(t *testing.T) { @@ -177,10 +179,24 @@ func TestRender(t *testing.T) { }, )) - require.Contains(t, out, "1 model added, 1 model removed, 1 model changed.") - require.Contains(t, out, "### Added\n\n- anthropic/fresh\n") - require.Contains(t, out, "### Removed\n\n- anthropic/gone\n") - require.Contains(t, out, "### Changed\n\n- openai/gpt\n") + // Asserted whole rather than by substring so section order, spacing, + // and the absence of stray content are covered too. + require.Equal(t, `## Price book changes + +1 model added, 1 model removed, 1 model changed. + +### Added + +- anthropic/fresh + +### Removed + +- anthropic/gone + +### Changed + +- openai/gpt +`, out) }) } @@ -191,7 +207,7 @@ func TestRun(t *testing.T) { t.Parallel() var out strings.Builder - require.Error(t, run("", "", &out)) + require.ErrorContains(t, run("", "", &out), "-old and -new are both required") }) t.Run("reads files", func(t *testing.T) { @@ -216,7 +232,22 @@ func TestRun(t *testing.T) { writeFile(t, path, "{") var out strings.Builder - require.Error(t, run(path, path, &out)) + err := run(path, path, &out) + // The path is part of the message so a failed refresh names the file + // that could not be read. + require.ErrorContains(t, err, path) + require.ErrorContains(t, err, "parse:") + }) + + t.Run("missing file", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "absent.json") + + var out strings.Builder + err := run(path, path, &out) + require.ErrorIs(t, err, os.ErrNotExist) + require.ErrorContains(t, err, path) }) } From 9d17c89bd2160a1e48d44cfd89d0d23b48f7c802 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Mon, 17 Aug 2026 14:58:02 +0000 Subject: [PATCH 10/30] refactor: minor changes --- .github/workflows/aibridge-prices-refresh.yaml | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/.github/workflows/aibridge-prices-refresh.yaml b/.github/workflows/aibridge-prices-refresh.yaml index 7aec3ad3038..c45d2359be4 100644 --- a/.github/workflows/aibridge-prices-refresh.yaml +++ b/.github/workflows/aibridge-prices-refresh.yaml @@ -17,8 +17,7 @@ name: aibridge-prices-refresh on: schedule: - # 09:00 UTC every Monday, so the PR is waiting when EU and US start the - # week and there is room to review well before the release freeze. + # 09:00 UTC every Monday, so the PR is waiting when EU and US start the week. - cron: "0 9 * * 1" workflow_dispatch: # allows manual runs for testing @@ -53,9 +52,7 @@ jobs: with: install-args: "go node pnpm" - # Catalog generation formats its output through scripts/biome_format.sh, - # which runs `pnpm exec biome` from site/. Without the install, that - # script logs a warning and exits 0, committing an unformatted file. + # Needed by catalog generation, which formats its output with biome. - name: Install pnpm dependencies uses: ./.github/actions/pnpm-install @@ -70,9 +67,11 @@ jobs: run: | set -euo pipefail if git diff --quiet -- "${PRICES_FILE}" "${CATALOG_FILE}"; then + # exit 0 => NO differences => nothing to propose echo "Price book already matches upstream; nothing to propose." echo "changed=false" >> "$GITHUB_OUTPUT" else + # exit 1 => differences found git diff --stat -- "${PRICES_FILE}" "${CATALOG_FILE}" echo "changed=true" >> "$GITHUB_OUTPUT" fi @@ -111,10 +110,9 @@ jobs: - name: Open or update the refresh pull request if: steps.detect.outputs.changed == 'true' env: - # The default GITHUB_TOKEN cannot trigger workflow runs, which would - # leave the PR without CI signal on a file that feeds cost - # calculation. cdrci is the machine user already used for - # bot-authored PRs in release.yaml. + # Use cdrci's token instead of the default GITHUB_TOKEN: PRs opened + # with GITHUB_TOKEN do not trigger workflow runs, so the refresh + # would arrive without CI signal. GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} PR_TITLE: "chore: refresh AI model price book" run: | From e756f5b8ae1ef52cfeca1cd328648743d2cedeca Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Mon, 17 Aug 2026 16:39:05 +0000 Subject: [PATCH 11/30] fix(.github/workflows): attribute refresh commits to github-actions[bot] The identity was a plausible-looking address that is not verified on any GitHub account, so refresh commits would show an unlinked author. Use the bot identity that backport.yaml and cherry-pick.yaml already use, whose numeric noreply address associates the commit with a real profile. --- .github/workflows/aibridge-prices-refresh.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/aibridge-prices-refresh.yaml b/.github/workflows/aibridge-prices-refresh.yaml index c45d2359be4..aee6de292ba 100644 --- a/.github/workflows/aibridge-prices-refresh.yaml +++ b/.github/workflows/aibridge-prices-refresh.yaml @@ -121,8 +121,8 @@ jobs: # persist-credentials is disabled on checkout, so authenticate the # push explicitly. git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - git config user.name "cdrci" - git config user.email "cdrci@coder.com" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git checkout -b "${REFRESH_BRANCH}" git add -- "${PRICES_FILE}" "${CATALOG_FILE}" From 4b198c1060fd4d4a1dfa0c1bca189b16074da4e8 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Mon, 17 Aug 2026 16:48:47 +0000 Subject: [PATCH 12/30] docs: say AI Gateway in prose, keep aibridge identifiers The style guide's word-choice table lists AI Bridge as a form to avoid. Identifiers stay as they are, since they name the paths and make targets this workflow drives: coderd/aibridge/prices, make gen/aibridge-prices, and scripts/aibridgepricesgen. --- .github/workflows/aibridge-prices-refresh.yaml | 2 +- scripts/aibridgepricesdiff/main.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/aibridge-prices-refresh.yaml b/.github/workflows/aibridge-prices-refresh.yaml index aee6de292ba..b7fd5a8166d 100644 --- a/.github/workflows/aibridge-prices-refresh.yaml +++ b/.github/workflows/aibridge-prices-refresh.yaml @@ -1,4 +1,4 @@ -# Refreshes the AI Bridge price book from live upstream data (models.dev) +# Refreshes the AI Gateway price book from live upstream data (models.dev) # once a week and opens a pull request when the generated artifacts change. # # The price book seeds customer-visible cost numbers, so the refresh is never diff --git a/scripts/aibridgepricesdiff/main.go b/scripts/aibridgepricesdiff/main.go index 5918e5092cb..95d45f94e12 100644 --- a/scripts/aibridgepricesdiff/main.go +++ b/scripts/aibridgepricesdiff/main.go @@ -1,5 +1,5 @@ // aibridgepricesdiff renders a human-readable Markdown summary of the -// difference between two AI Bridge price seed files (the prices.json produced +// difference between two AI Gateway price seed files (the prices.json produced // by aibridgepricesgen). // // The price refresh workflow uses it to fill the pull request body, so From a9cc6392dda7c9ca190269d63baa885b17679833 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Mon, 17 Aug 2026 17:19:28 +0000 Subject: [PATCH 13/30] chore(CODEOWNERS): own only the generated price artifacts The refresh PR touches exactly these two files, so directory-level entries on the generators never fired on it. The knownModels directory also holds hand-written TypeScript, which would have requested review on unrelated frontend work. --- CODEOWNERS | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 229cd605681..50438d559ea 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -41,11 +41,9 @@ coderd/database/queries/aicostcontrol.sql @ibetitsmike @johnstcn codersdk/aiproviders.go @ibetitsmike @johnstcn codersdk/aiproviders_bedrock.go @ibetitsmike @johnstcn -# Generated AI model price book and frontend model catalog, plus the -# generators behind them. Both artifacts are refreshed from live models.dev -# data by the aibridge-prices-refresh workflow and carry customer-visible -# cost numbers, so every refresh needs a review from someone who owns them. +# Generated price book and frontend model catalog. The +# aibridge-prices-refresh workflow proposes updates to both from live +# models.dev data, and they carry customer-visible cost numbers, so every +# refresh needs a review from someone who owns them. coderd/aibridge/prices/data/prices.json @evgeniy-scherbina -scripts/aibridgepricesgen/ @evgeniy-scherbina -scripts/aibridgepricesdiff/ @evgeniy-scherbina -site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/ @evgeniy-scherbina +site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/knownModelsGenerated.json @evgeniy-scherbina From b32746f713c6f171b17be4c10b93d2b89e39bb7d Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Mon, 17 Aug 2026 17:37:15 +0000 Subject: [PATCH 14/30] refactor(.github/workflows): name the refresh workflow aigateway New artifacts follow the aigateway naming; a file named aibridge would be invisible to anyone grepping the current name. The branch and concurrency group move with it, since neither exists yet. Paths, make targets, and the scripts keep their aibridge names, which match what they refer to. --- ...-prices-refresh.yaml => aigateway-prices-refresh.yaml} | 8 ++++---- CODEOWNERS | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) rename .github/workflows/{aibridge-prices-refresh.yaml => aigateway-prices-refresh.yaml} (96%) diff --git a/.github/workflows/aibridge-prices-refresh.yaml b/.github/workflows/aigateway-prices-refresh.yaml similarity index 96% rename from .github/workflows/aibridge-prices-refresh.yaml rename to .github/workflows/aigateway-prices-refresh.yaml index b7fd5a8166d..e0668bbf834 100644 --- a/.github/workflows/aibridge-prices-refresh.yaml +++ b/.github/workflows/aigateway-prices-refresh.yaml @@ -13,7 +13,7 @@ # - Fails loudly when the generator refuses to run, which it does by design # when upstream drops a model pinned in overrides.jq or curated in # curation.json. Failures are announced in Slack. -name: aibridge-prices-refresh +name: aigateway-prices-refresh on: schedule: @@ -24,10 +24,10 @@ on: permissions: {} concurrency: - group: aibridge-prices-refresh + group: aigateway-prices-refresh env: - REFRESH_BRANCH: bot/aibridge-prices-refresh + REFRESH_BRANCH: bot/aigateway-prices-refresh PRICES_FILE: coderd/aibridge/prices/data/prices.json CATALOG_FILE: site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/knownModelsGenerated.json @@ -100,7 +100,7 @@ jobs: echo "PR is never merged automatically. The summary above lists what moved; check" echo "the diff for exact figures before approving." echo - echo "Opened automatically by the [aibridge-prices-refresh workflow](${RUN_URL})." + echo "Opened automatically by the [aigateway-prices-refresh workflow](${RUN_URL})." } > "${RUNNER_TEMP}/body.md" cat "${RUNNER_TEMP}/body.md" diff --git a/CODEOWNERS b/CODEOWNERS index 50438d559ea..cfbfb004893 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -42,7 +42,7 @@ codersdk/aiproviders.go @ibetitsmike @johnstcn codersdk/aiproviders_bedrock.go @ibetitsmike @johnstcn # Generated price book and frontend model catalog. The -# aibridge-prices-refresh workflow proposes updates to both from live +# aigateway-prices-refresh workflow proposes updates to both from live # models.dev data, and they carry customer-visible cost numbers, so every # refresh needs a review from someone who owns them. coderd/aibridge/prices/data/prices.json @evgeniy-scherbina From f06344e8c748127e270fe52f0600ea7806540631 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Mon, 17 Aug 2026 17:44:33 +0000 Subject: [PATCH 15/30] refactor(.github/workflows): rename the webhook secret to AIGATEWAY_PRICES_SLACK_WEBHOOK Matches the workflow name and the _SLACK_WEBHOOK convention. The secret does not exist yet, so renaming now costs nothing. --- .github/workflows/aigateway-prices-refresh.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/aigateway-prices-refresh.yaml b/.github/workflows/aigateway-prices-refresh.yaml index e0668bbf834..c7257ce86b7 100644 --- a/.github/workflows/aigateway-prices-refresh.yaml +++ b/.github/workflows/aigateway-prices-refresh.yaml @@ -147,12 +147,12 @@ jobs: - name: Send Slack notification on failure if: failure() env: - SLACK_WEBHOOK: ${{ secrets.AIBRIDGE_PRICES_SLACK_WEBHOOK }} + SLACK_WEBHOOK: ${{ secrets.AIGATEWAY_PRICES_SLACK_WEBHOOK }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -euo pipefail if [ -z "${SLACK_WEBHOOK}" ]; then - echo "::warning::AIBRIDGE_PRICES_SLACK_WEBHOOK is not set; skipping notification." + echo "::warning::AIGATEWAY_PRICES_SLACK_WEBHOOK is not set; skipping notification." exit 0 fi text=":warning: *AI model price book refresh failed.*\nThe generator fails by design when upstream drops a model pinned in scripts/aibridgepricesgen/overrides.jq or curated in curation.json. Logs: ${RUN_URL}" From ff7cf11917b625c37f2b48530a15f6fd2cdc0d44 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Mon, 17 Aug 2026 18:03:24 +0000 Subject: [PATCH 16/30] TEMPORARY: run the price refresh on push to this branch DO NOT MERGE. Drop this commit after verifying the run. --- .github/workflows/aigateway-prices-refresh.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/aigateway-prices-refresh.yaml b/.github/workflows/aigateway-prices-refresh.yaml index c7257ce86b7..90d1388ffef 100644 --- a/.github/workflows/aigateway-prices-refresh.yaml +++ b/.github/workflows/aigateway-prices-refresh.yaml @@ -20,6 +20,12 @@ on: # 09:00 UTC every Monday, so the PR is waiting when EU and US start the week. - cron: "0 9 * * 1" workflow_dispatch: # allows manual runs for testing + # TEMPORARY: schedule and workflow_dispatch only fire from the default + # branch, so this trigger is the only way to exercise the workflow before + # it merges. Drop this commit once the run has been verified. + push: + branches: + - yevhenii/aigov-578-automate-updates-to-the-shipped-ai-model-price-book permissions: {} From 8ee6bd9d099f8341031791bfe8c1c34f59cef2d0 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Mon, 17 Aug 2026 18:17:03 +0000 Subject: [PATCH 17/30] fix(.github/workflows): send a real newline in the Slack alert The alert built its text in double quotes, so bash kept \n as two characters and jq --arg escaped the backslash. Slack printed a literal \n mid-sentence instead of breaking the line; build the text with printf. --- .github/workflows/aigateway-prices-refresh.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/aigateway-prices-refresh.yaml b/.github/workflows/aigateway-prices-refresh.yaml index 90d1388ffef..a6142530f5c 100644 --- a/.github/workflows/aigateway-prices-refresh.yaml +++ b/.github/workflows/aigateway-prices-refresh.yaml @@ -161,7 +161,10 @@ jobs: echo "::warning::AIGATEWAY_PRICES_SLACK_WEBHOOK is not set; skipping notification." exit 0 fi - text=":warning: *AI model price book refresh failed.*\nThe generator fails by design when upstream drops a model pinned in scripts/aibridgepricesgen/overrides.jq or curated in curation.json. Logs: ${RUN_URL}" + # printf, not a double-quoted literal: bash leaves \n as two + # characters, and jq --arg then escapes the backslash, so Slack + # would print \n as text instead of breaking the line. + text="$(printf ':warning: *AI model price book refresh failed.*\nThe generator fails by design when upstream drops a model pinned in scripts/aibridgepricesgen/overrides.jq or curated in curation.json. Logs: %s' "${RUN_URL}")" payload="$(jq -nc --arg text "${text}" '{text: $text}')" curl -fsSL -X POST -H 'Content-type: application/json' -d "${payload}" "${SLACK_WEBHOOK}" echo "Sent Slack notification" From b890e7d19650467be7d527082f80f2e1043ecc2e Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Mon, 17 Aug 2026 19:09:15 +0000 Subject: [PATCH 18/30] fix(.github/workflows): fail when the Slack webhook is missing The step only runs after the refresh has already failed, so exiting 0 hid the more serious problem: nobody was told. Mark the step red and name the secret, now that it exists and an empty value means a misconfiguration. --- .github/workflows/aigateway-prices-refresh.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/aigateway-prices-refresh.yaml b/.github/workflows/aigateway-prices-refresh.yaml index a6142530f5c..eff7a0d6b5c 100644 --- a/.github/workflows/aigateway-prices-refresh.yaml +++ b/.github/workflows/aigateway-prices-refresh.yaml @@ -157,9 +157,11 @@ jobs: RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -euo pipefail + # Fail loudly: the refresh has already failed at this point, and a + # missing webhook means nobody is being told about it. if [ -z "${SLACK_WEBHOOK}" ]; then - echo "::warning::AIGATEWAY_PRICES_SLACK_WEBHOOK is not set; skipping notification." - exit 0 + echo "::error::AIGATEWAY_PRICES_SLACK_WEBHOOK is not set; the failure alert could not be sent." + exit 1 fi # printf, not a double-quoted literal: bash leaves \n as two # characters, and jq --arg then escapes the backslash, so Slack From c8f49c486722371061ef809a3ee27a6af07e9c6b Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Mon, 17 Aug 2026 19:12:38 +0000 Subject: [PATCH 19/30] refactor: minor changes --- .github/workflows/aigateway-prices-refresh.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/aigateway-prices-refresh.yaml b/.github/workflows/aigateway-prices-refresh.yaml index eff7a0d6b5c..7003d757342 100644 --- a/.github/workflows/aigateway-prices-refresh.yaml +++ b/.github/workflows/aigateway-prices-refresh.yaml @@ -157,8 +157,6 @@ jobs: RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -euo pipefail - # Fail loudly: the refresh has already failed at this point, and a - # missing webhook means nobody is being told about it. if [ -z "${SLACK_WEBHOOK}" ]; then echo "::error::AIGATEWAY_PRICES_SLACK_WEBHOOK is not set; the failure alert could not be sent." exit 1 From 5ae4a9494b019847d93133f61921a52986a3489b Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Mon, 17 Aug 2026 19:26:36 +0000 Subject: [PATCH 20/30] fix(.github/workflows): manage the refresh PR through the REST API gh pr list/create/edit go through GraphQL, whose queries resolve reviewer and team fields that require the read:org scope. cdrci's token carries only repo and workflow, so the calls succeed while no pull request exists and fail once CODEOWNERS attaches a reviewer: the first refresh would open a PR and every run after it would fail. Observed on this branch: run 32058047624 created #28224, then 32058781222 and 32059086614 both failed on gh pr list with a read:org scope error. The equivalent REST endpoints need only repo. --- .../workflows/aigateway-prices-refresh.yaml | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/workflows/aigateway-prices-refresh.yaml b/.github/workflows/aigateway-prices-refresh.yaml index 7003d757342..53e9234403d 100644 --- a/.github/workflows/aigateway-prices-refresh.yaml +++ b/.github/workflows/aigateway-prices-refresh.yaml @@ -138,16 +138,23 @@ jobs: # snapshot each week, so the previous contents are always stale. git push --force origin "refs/heads/${REFRESH_BRANCH}" - pr_number="$(gh pr list --head "${REFRESH_BRANCH}" --base main --state open --json number --jq '.[0].number // empty')" + # REST, not `gh pr`: those go through GraphQL, which needs a read:org + # scope that cdrci's token lacks. + owner="${GITHUB_REPOSITORY%%/*}" + pr_number="$(gh api "repos/${GITHUB_REPOSITORY}/pulls?state=open&base=main&head=${owner}:${REFRESH_BRANCH}" --jq '.[0].number // empty')" if [ -n "${pr_number}" ]; then - gh pr edit "${pr_number}" --title "${PR_TITLE}" --body-file "${RUNNER_TEMP}/body.md" + gh api --method PATCH "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}" \ + -f title="${PR_TITLE}" \ + -f body="$(cat "${RUNNER_TEMP}/body.md")" \ + --silent echo "Updated existing PR #${pr_number}." else - gh pr create \ - --base main \ - --head "${REFRESH_BRANCH}" \ - --title "${PR_TITLE}" \ - --body-file "${RUNNER_TEMP}/body.md" + gh api --method POST "repos/${GITHUB_REPOSITORY}/pulls" \ + -f title="${PR_TITLE}" \ + -f head="${REFRESH_BRANCH}" \ + -f base=main \ + -f body="$(cat "${RUNNER_TEMP}/body.md")" \ + --jq '.html_url' fi - name: Send Slack notification on failure From 4cca01038a1f85603851f58ef1e6403df3510ccf Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Mon, 17 Aug 2026 20:25:50 +0000 Subject: [PATCH 21/30] chore(CODEOWNERS): add @ssncferreira and @johnstcn to the price artifacts Spreads review of the weekly refresh beyond one person, so a stale price book is not blocked on a single reviewer's availability. --- CODEOWNERS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index cfbfb004893..2c50db3b79b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -45,5 +45,5 @@ codersdk/aiproviders_bedrock.go @ibetitsmike @johnstcn # aigateway-prices-refresh workflow proposes updates to both from live # models.dev data, and they carry customer-visible cost numbers, so every # refresh needs a review from someone who owns them. -coderd/aibridge/prices/data/prices.json @evgeniy-scherbina -site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/knownModelsGenerated.json @evgeniy-scherbina +coderd/aibridge/prices/data/prices.json @evgeniy-scherbina @ssncferreira @johnstcn +site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/knownModelsGenerated.json @evgeniy-scherbina @ssncferreira @johnstcn From 64a3b55ccc32a7a93c725192bd76316a47cd0967 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Tue, 18 Aug 2026 20:25:39 +0000 Subject: [PATCH 22/30] chore(.github/workflows): refresh AI Gateway prices on Thursdays Monday leaves only one business day before Tuesday releases and competes with release preparation. Thursday leaves Thursday, Friday, and Monday for review without changing the weekly freshness bound. --- .github/workflows/aigateway-prices-refresh.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/aigateway-prices-refresh.yaml b/.github/workflows/aigateway-prices-refresh.yaml index 53e9234403d..66e872f56c9 100644 --- a/.github/workflows/aigateway-prices-refresh.yaml +++ b/.github/workflows/aigateway-prices-refresh.yaml @@ -6,7 +6,7 @@ # reviews and merges it. # # Behavior: -# - Runs every Monday. If regeneration produces no diff, the run ends +# - Runs every Thursday. If regeneration produces no diff, the run ends # without opening anything. # - Reuses a single branch and pull request, force-pushing each week, so at # most one refresh PR is open and it always carries the newest snapshot. @@ -17,8 +17,8 @@ name: aigateway-prices-refresh on: schedule: - # 09:00 UTC every Monday, so the PR is waiting when EU and US start the week. - - cron: "0 9 * * 1" + # 09:00 UTC every Thursday, leaving three business days before Tuesday releases. + - cron: "0 9 * * 4" workflow_dispatch: # allows manual runs for testing # TEMPORARY: schedule and workflow_dispatch only fire from the default # branch, so this trigger is the only way to exercise the workflow before From 52845524424f42363b823e26f877c7a134b818d1 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Tue, 18 Aug 2026 21:13:19 +0000 Subject: [PATCH 23/30] test(scripts/aibridgepricesdiff): cover a newly populated price Complements the value-to-null case and verifies that an existing model whose price becomes available is reported as changed, not added. --- scripts/aibridgepricesdiff/main_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/aibridgepricesdiff/main_test.go b/scripts/aibridgepricesdiff/main_test.go index b79e187c640..8d97965c497 100644 --- a/scripts/aibridgepricesdiff/main_test.go +++ b/scripts/aibridgepricesdiff/main_test.go @@ -68,6 +68,20 @@ func TestCompare(t *testing.T) { }}, wantChanged: []string{"anthropic/claude"}, }, + { + // A previously missing price becoming populated is a change. + name: "price set", + old: []priceRow{{ + Provider: "anthropic", + Model: "claude", + }}, + new: []priceRow{{ + Provider: "anthropic", + Model: "claude", + InputPrice: int64Ptr(1), + }}, + wantChanged: []string{"anthropic/claude"}, + }, { // A model whose price becomes null is a change, not a removal. name: "price unset", From 127ce34bac4bcc19ef1f74c9021a2be8b302163c Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Tue, 18 Aug 2026 21:17:43 +0000 Subject: [PATCH 24/30] test(scripts/aibridgepricesdiff): cover a missing price becoming zero Complements zero-to-null and verifies that an explicit zero is treated as a populated price in both directions. --- scripts/aibridgepricesdiff/main_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/aibridgepricesdiff/main_test.go b/scripts/aibridgepricesdiff/main_test.go index 8d97965c497..783ee7cd753 100644 --- a/scripts/aibridgepricesdiff/main_test.go +++ b/scripts/aibridgepricesdiff/main_test.go @@ -95,6 +95,20 @@ func TestCompare(t *testing.T) { }}, wantChanged: []string{"anthropic/claude"}, }, + { + // Zero is a populated price, distinct from an absent one. + name: "null becomes zero", + old: []priceRow{{ + Provider: "openai", + Model: "gpt", + }}, + new: []priceRow{{ + Provider: "openai", + Model: "gpt", + InputPrice: int64Ptr(0), + }}, + wantChanged: []string{"openai/gpt"}, + }, { // Zero is a real price, distinct from an absent one. name: "zero is not null", From 16d0d6584098e116b1655b155e530f4ffd7f5c54 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Wed, 19 Aug 2026 13:39:48 +0000 Subject: [PATCH 25/30] feat(scripts/aibridgepricesdiff): collapse model lists in refresh PRs Large upstream refreshes can add or reprice dozens of models, which buries the review notes under a long body. Keep the counts visible and put each model category in a native GitHub details block so reviewers can expand only the sections they need. --- scripts/aibridgepricesdiff/main.go | 3 ++- scripts/aibridgepricesdiff/main_test.go | 14 ++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/scripts/aibridgepricesdiff/main.go b/scripts/aibridgepricesdiff/main.go index 95d45f94e12..c2167fb809d 100644 --- a/scripts/aibridgepricesdiff/main.go +++ b/scripts/aibridgepricesdiff/main.go @@ -177,10 +177,11 @@ func render(d diff) string { if len(keys) == 0 { return } - write("\n### %s\n\n", heading) + write("\n
\n%s\n\n", heading) for _, k := range keys { write("- %s\n", k) } + write("
\n") } renderList("Added", d.added) renderList("Removed", d.removed) diff --git a/scripts/aibridgepricesdiff/main_test.go b/scripts/aibridgepricesdiff/main_test.go index 783ee7cd753..0b334ce131d 100644 --- a/scripts/aibridgepricesdiff/main_test.go +++ b/scripts/aibridgepricesdiff/main_test.go @@ -213,17 +213,23 @@ No price changes. 1 model added, 1 model removed, 1 model changed. -### Added +
+Added - anthropic/fresh +
-### Removed +
+Removed - anthropic/gone +
-### Changed +
+Changed - openai/gpt +
`, out) }) } @@ -249,7 +255,7 @@ func TestRun(t *testing.T) { var out strings.Builder require.NoError(t, run(oldPath, newPath, &out)) - require.Contains(t, out.String(), "### Changed\n\n- openai/gpt\n") + require.Contains(t, out.String(), "Changed\n\n- openai/gpt\n") }) t.Run("invalid json", func(t *testing.T) { From 56976905bc5f27d1d7fe34fc0eafef2dea7a3781 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Wed, 19 Aug 2026 13:51:34 +0000 Subject: [PATCH 26/30] style(.github/workflows): keep PR review-note paragraphs on single lines GitHub wraps rendered prose to the viewer width, so hard-wrapping the source at roughly 70 characters only made the raw Markdown and copied text look cramped. --- .github/workflows/aigateway-prices-refresh.yaml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/aigateway-prices-refresh.yaml b/.github/workflows/aigateway-prices-refresh.yaml index 66e872f56c9..4514abd2fef 100644 --- a/.github/workflows/aigateway-prices-refresh.yaml +++ b/.github/workflows/aigateway-prices-refresh.yaml @@ -95,16 +95,12 @@ jobs: echo echo "## Review notes" echo - echo "Regenerated by \`make gen/aibridge-prices\` from the live" - echo "[models.dev](https://models.dev) catalog. Both artifacts come from one" - echo "snapshot, so they ship together:" + echo "Regenerated by \`make gen/aibridge-prices\` from the live [models.dev](https://models.dev) catalog. Both artifacts come from one snapshot, so they ship together:" echo echo "- \`${PRICES_FILE}\`" echo "- \`${CATALOG_FILE}\`" echo - echo "These are customer-visible cost numbers taken from upstream data, so this" - echo "PR is never merged automatically. The summary above lists what moved; check" - echo "the diff for exact figures before approving." + echo "These are customer-visible cost numbers taken from upstream data, so this PR is never merged automatically. The summary above lists what moved; check the diff for exact figures before approving." echo echo "Opened automatically by the [aigateway-prices-refresh workflow](${RUN_URL})." } > "${RUNNER_TEMP}/body.md" From 915c61be9c418a069a36456f87815053759fb07a Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Wed, 19 Aug 2026 14:11:20 +0000 Subject: [PATCH 27/30] feat(.github/workflows): send a weekly no-change Slack heartbeat When generation is unchanged there is no refresh PR, so a successful no-op and a scheduled workflow that never ran are otherwise indistinguishable. Post a short heartbeat only for the no-change path; a refresh PR remains the success signal when changes exist. --- .github/workflows/aigateway-prices-refresh.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/aigateway-prices-refresh.yaml b/.github/workflows/aigateway-prices-refresh.yaml index 4514abd2fef..812137c785c 100644 --- a/.github/workflows/aigateway-prices-refresh.yaml +++ b/.github/workflows/aigateway-prices-refresh.yaml @@ -153,6 +153,22 @@ jobs: --jq '.html_url' fi + - name: Send Slack notification when price book is current + if: steps.detect.outputs.changed == 'false' + env: + SLACK_WEBHOOK: ${{ secrets.AIGATEWAY_PRICES_SLACK_WEBHOOK }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + if [ -z "${SLACK_WEBHOOK}" ]; then + echo "::error::AIGATEWAY_PRICES_SLACK_WEBHOOK is not set; the no-change notification could not be sent." + exit 1 + fi + text=":white_check_mark: *AI Gateway price book refresh completed.* No generated changes were found. Run: ${RUN_URL}" + payload="$(jq -nc --arg text "${text}" '{text: $text}')" + curl -fsSL -X POST -H 'Content-type: application/json' -d "${payload}" "${SLACK_WEBHOOK}" + echo "Sent Slack notification" + - name: Send Slack notification on failure if: failure() env: From b279d2ea28f58eafbac6cffc0457481e0c06ad04 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Wed, 19 Aug 2026 14:36:29 +0000 Subject: [PATCH 28/30] refactor(.github/workflows): consolidate Slack notifications Select the failure alert or no-change heartbeat in one final step. This removes duplicated webhook validation, JSON encoding, and curl setup, and prevents a failed heartbeat delivery from triggering a second, contradictory failure notification. --- .../workflows/aigateway-prices-refresh.yaml | 35 ++++++++----------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/.github/workflows/aigateway-prices-refresh.yaml b/.github/workflows/aigateway-prices-refresh.yaml index 812137c785c..f3346a1e750 100644 --- a/.github/workflows/aigateway-prices-refresh.yaml +++ b/.github/workflows/aigateway-prices-refresh.yaml @@ -153,37 +153,32 @@ jobs: --jq '.html_url' fi - - name: Send Slack notification when price book is current - if: steps.detect.outputs.changed == 'false' + - name: Send Slack notification + if: failure() || steps.detect.outputs.changed == 'false' env: + REFRESH_STATUS: ${{ job.status }} + PRICE_BOOK_CHANGED: ${{ steps.detect.outputs.changed }} SLACK_WEBHOOK: ${{ secrets.AIGATEWAY_PRICES_SLACK_WEBHOOK }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -euo pipefail if [ -z "${SLACK_WEBHOOK}" ]; then - echo "::error::AIGATEWAY_PRICES_SLACK_WEBHOOK is not set; the no-change notification could not be sent." + echo "::error::AIGATEWAY_PRICES_SLACK_WEBHOOK is not set; the notification could not be sent." exit 1 fi - text=":white_check_mark: *AI Gateway price book refresh completed.* No generated changes were found. Run: ${RUN_URL}" - payload="$(jq -nc --arg text "${text}" '{text: $text}')" - curl -fsSL -X POST -H 'Content-type: application/json' -d "${payload}" "${SLACK_WEBHOOK}" - echo "Sent Slack notification" - - name: Send Slack notification on failure - if: failure() - env: - SLACK_WEBHOOK: ${{ secrets.AIGATEWAY_PRICES_SLACK_WEBHOOK }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - set -euo pipefail - if [ -z "${SLACK_WEBHOOK}" ]; then - echo "::error::AIGATEWAY_PRICES_SLACK_WEBHOOK is not set; the failure alert could not be sent." + if [ "${REFRESH_STATUS}" = "failure" ]; then + # printf, not a double-quoted literal: bash leaves \n as two + # characters, and jq --arg then escapes the backslash, so Slack + # would print \n as text instead of breaking the line. + text="$(printf ':warning: *AI model price book refresh failed.*\nThe generator fails by design when upstream drops a model pinned in scripts/aibridgepricesgen/overrides.jq or curated in curation.json. Logs: %s' "${RUN_URL}")" + elif [ "${PRICE_BOOK_CHANGED}" = "false" ]; then + text=":white_check_mark: *AI Gateway price book refresh completed.* No generated changes were found. Run: ${RUN_URL}" + else + echo "::error::Unexpected notification state: status=${REFRESH_STATUS}, changed=${PRICE_BOOK_CHANGED}" exit 1 fi - # printf, not a double-quoted literal: bash leaves \n as two - # characters, and jq --arg then escapes the backslash, so Slack - # would print \n as text instead of breaking the line. - text="$(printf ':warning: *AI model price book refresh failed.*\nThe generator fails by design when upstream drops a model pinned in scripts/aibridgepricesgen/overrides.jq or curated in curation.json. Logs: %s' "${RUN_URL}")" + payload="$(jq -nc --arg text "${text}" '{text: $text}')" curl -fsSL -X POST -H 'Content-type: application/json' -d "${payload}" "${SLACK_WEBHOOK}" echo "Sent Slack notification" From 1d6c51a6ebebd073007848360eea7bca194d34bd Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Wed, 19 Aug 2026 14:44:10 +0000 Subject: [PATCH 29/30] refactor(.github/workflows): name the notification status after its context JOB_STATUS makes it clear that the value comes directly from job.status and is not a custom refresh result. --- .github/workflows/aigateway-prices-refresh.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/aigateway-prices-refresh.yaml b/.github/workflows/aigateway-prices-refresh.yaml index f3346a1e750..0073affea32 100644 --- a/.github/workflows/aigateway-prices-refresh.yaml +++ b/.github/workflows/aigateway-prices-refresh.yaml @@ -156,7 +156,7 @@ jobs: - name: Send Slack notification if: failure() || steps.detect.outputs.changed == 'false' env: - REFRESH_STATUS: ${{ job.status }} + JOB_STATUS: ${{ job.status }} PRICE_BOOK_CHANGED: ${{ steps.detect.outputs.changed }} SLACK_WEBHOOK: ${{ secrets.AIGATEWAY_PRICES_SLACK_WEBHOOK }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} @@ -167,7 +167,7 @@ jobs: exit 1 fi - if [ "${REFRESH_STATUS}" = "failure" ]; then + if [ "${JOB_STATUS}" = "failure" ]; then # printf, not a double-quoted literal: bash leaves \n as two # characters, and jq --arg then escapes the backslash, so Slack # would print \n as text instead of breaking the line. @@ -175,7 +175,7 @@ jobs: elif [ "${PRICE_BOOK_CHANGED}" = "false" ]; then text=":white_check_mark: *AI Gateway price book refresh completed.* No generated changes were found. Run: ${RUN_URL}" else - echo "::error::Unexpected notification state: status=${REFRESH_STATUS}, changed=${PRICE_BOOK_CHANGED}" + echo "::error::Unexpected notification state: status=${JOB_STATUS}, changed=${PRICE_BOOK_CHANGED}" exit 1 fi From 2de1651ad7a50f47c7ae6873f48f1189e31cbc73 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:46:15 +0000 Subject: [PATCH 30/30] chore: refresh AI model price book --- coderd/aibridge/prices/data/prices.json | 798 +++++++++++++----- .../knownModels/knownModelsGenerated.json | 102 +-- 2 files changed, 643 insertions(+), 257 deletions(-) diff --git a/coderd/aibridge/prices/data/prices.json b/coderd/aibridge/prices/data/prices.json index 7570561b618..5781282e357 100644 --- a/coderd/aibridge/prices/data/prices.json +++ b/coderd/aibridge/prices/data/prices.json @@ -31,22 +31,6 @@ "cache_read_price": 1000000, "cache_write_price": 12500000 }, - { - "provider": "anthropic", - "model": "claude-opus-4-1", - "input_price": 15000000, - "output_price": 75000000, - "cache_read_price": 1500000, - "cache_write_price": 18750000 - }, - { - "provider": "anthropic", - "model": "claude-opus-4-1-20250805", - "input_price": 15000000, - "output_price": 75000000, - "cache_read_price": 1500000, - "cache_write_price": 18750000 - }, { "provider": "anthropic", "model": "claude-opus-4-5", @@ -143,6 +127,14 @@ "cache_read_price": 100000, "cache_write_price": 1250000 }, + { + "provider": "azure", + "model": "claude-mythos-5", + "input_price": 10000000, + "output_price": 50000000, + "cache_read_price": 1000000, + "cache_write_price": 12500000 + }, { "provider": "azure", "model": "claude-opus-4-1", @@ -167,6 +159,14 @@ "cache_read_price": 500000, "cache_write_price": 6250000 }, + { + "provider": "azure", + "model": "claude-opus-4-7", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, { "provider": "azure", "model": "claude-opus-4-8", @@ -514,10 +514,10 @@ { "provider": "azure", "model": "gpt-5.6-luna", - "input_price": 1000000, - "output_price": 6000000, - "cache_read_price": 100000, - "cache_write_price": 1250000 + "input_price": 200000, + "output_price": 1200000, + "cache_read_price": 20000, + "cache_write_price": 250000 }, { "provider": "azure", @@ -525,15 +525,15 @@ "input_price": 5000000, "output_price": 30000000, "cache_read_price": 500000, - "cache_write_price": null + "cache_write_price": 6250000 }, { "provider": "azure", "model": "gpt-5.6-terra", - "input_price": 2500000, - "output_price": 15000000, - "cache_read_price": 250000, - "cache_write_price": 3125000 + "input_price": 2000000, + "output_price": 12000000, + "cache_read_price": 200000, + "cache_write_price": 2500000 }, { "provider": "azure", @@ -615,6 +615,14 @@ "cache_read_price": null, "cache_write_price": null }, + { + "provider": "azure", + "model": "kimi-k2.7-code", + "input_price": 950000, + "output_price": 4000000, + "cache_read_price": 190000, + "cache_write_price": null + }, { "provider": "azure", "model": "llama-3.3-70b-instruct", @@ -1135,6 +1143,30 @@ "cache_read_price": 200000, "cache_write_price": 2500000 }, + { + "provider": "bedrock", + "model": "global.openai.gpt-5.6-luna", + "input_price": 220000, + "output_price": 1320000, + "cache_read_price": 22000, + "cache_write_price": 275000 + }, + { + "provider": "bedrock", + "model": "global.openai.gpt-5.6-sol", + "input_price": 5500000, + "output_price": 33000000, + "cache_read_price": 550000, + "cache_write_price": 6875000 + }, + { + "provider": "bedrock", + "model": "global.openai.gpt-5.6-terra", + "input_price": 2200000, + "output_price": 13200000, + "cache_read_price": 220000, + "cache_write_price": 2750000 + }, { "provider": "bedrock", "model": "google.gemma-3-12b-it", @@ -1429,7 +1461,7 @@ "input_price": 5500000, "output_price": 33000000, "cache_read_price": 550000, - "cache_write_price": 6880000 + "cache_write_price": 6875000 }, { "provider": "bedrock", @@ -1815,6 +1847,14 @@ "cache_read_price": 150000, "cache_write_price": null }, + { + "provider": "copilot", + "model": "gemini-3.7-flash", + "input_price": 750000, + "output_price": 3750000, + "cache_read_price": 75000, + "cache_write_price": null + }, { "provider": "copilot", "model": "gpt-4.1", @@ -1919,6 +1959,14 @@ "cache_read_price": 500000, "cache_write_price": null }, + { + "provider": "copilot", + "model": "grok-4.6", + "input_price": 2000000, + "output_price": 6000000, + "cache_read_price": 500000, + "cache_write_price": null + }, { "provider": "copilot", "model": "kimi-k2.7-code", @@ -1927,6 +1975,14 @@ "cache_read_price": 190000, "cache_write_price": null }, + { + "provider": "copilot", + "model": "kimi-k3", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": null + }, { "provider": "copilot", "model": "mai-code-1-flash-picker", @@ -1935,6 +1991,14 @@ "cache_read_price": 75000, "cache_write_price": null }, + { + "provider": "copilot", + "model": "mai-code-1.1-flash", + "input_price": 200000, + "output_price": 1200000, + "cache_read_price": 20000, + "cache_write_price": null + }, { "provider": "google", "model": "deep-research-max-preview-04-2026", @@ -1951,22 +2015,6 @@ "cache_read_price": 200000, "cache_write_price": null }, - { - "provider": "google", - "model": "gemini-2.0-flash", - "input_price": 100000, - "output_price": 400000, - "cache_read_price": 25000, - "cache_write_price": null - }, - { - "provider": "google", - "model": "gemini-2.0-flash-lite", - "input_price": 75000, - "output_price": 300000, - "cache_read_price": null, - "cache_write_price": null - }, { "provider": "google", "model": "gemini-2.5-computer-use-preview-10-2025", @@ -2047,14 +2095,6 @@ "cache_read_price": null, "cache_write_price": null }, - { - "provider": "google", - "model": "gemini-3-pro-preview", - "input_price": 2000000, - "output_price": 12000000, - "cache_read_price": 200000, - "cache_write_price": null - }, { "provider": "google", "model": "gemini-3.1-flash-image", @@ -2159,6 +2199,14 @@ "cache_read_price": 150000, "cache_write_price": null }, + { + "provider": "google", + "model": "gemini-3.7-flash", + "input_price": 750000, + "output_price": 3750000, + "cache_read_price": 75000, + "cache_write_price": null + }, { "provider": "google", "model": "gemini-embedding-001", @@ -2839,6 +2887,22 @@ "cache_read_price": null, "cache_write_price": null }, + { + "provider": "openrouter", + "model": "bytedance-seed/seed-2-1-turbo", + "input_price": 500000, + "output_price": 2500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "bytedance-seed/seed-2.0-code", + "input_price": 500000, + "output_price": 3000000, + "cache_read_price": null, + "cache_write_price": null + }, { "provider": "openrouter", "model": "bytedance-seed/seed-2.0-lite", @@ -2930,9 +2994,9 @@ { "provider": "openrouter", "model": "deepseek/deepseek-chat-v3-0324", - "input_price": 270000, - "output_price": 1120000, - "cache_read_price": 135000, + "input_price": 250000, + "output_price": 1000000, + "cache_read_price": null, "cache_write_price": null }, { @@ -2972,7 +3036,7 @@ "model": "deepseek/deepseek-v3.1-terminus", "input_price": 270000, "output_price": 1000000, - "cache_read_price": 135000, + "cache_read_price": null, "cache_write_price": null }, { @@ -2994,6 +3058,14 @@ { "provider": "openrouter", "model": "deepseek/deepseek-v4-flash", + "input_price": 82600, + "output_price": 165200, + "cache_read_price": 16520, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "deepseek/deepseek-v4-flash-0731", "input_price": 140000, "output_price": 280000, "cache_read_price": 28000, @@ -3001,18 +3073,26 @@ }, { "provider": "openrouter", - "model": "deepseek/deepseek-v4-flash-0731", - "input_price": 90000, - "output_price": 180000, - "cache_read_price": 18000, + "model": "deepseek/deepseek-v4-pro", + "input_price": 1440000, + "output_price": 2880000, + "cache_read_price": 121500, "cache_write_price": null }, { "provider": "openrouter", - "model": "deepseek/deepseek-v4-pro", - "input_price": 435000, - "output_price": 870000, - "cache_read_price": 3625, + "model": "deepseek/deepseek-v4-pro-0813", + "input_price": 660000, + "output_price": 1980000, + "cache_read_price": 22000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "dots-studio/dots-3-note-preview:free", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, "cache_write_price": null }, { @@ -3162,10 +3242,18 @@ { "provider": "openrouter", "model": "google/gemini-3.6-flash", - "input_price": 1500000, - "output_price": 7500000, - "cache_read_price": 150000, - "cache_write_price": 83333 + "input_price": 750000, + "output_price": 3750000, + "cache_read_price": 75000, + "cache_write_price": 41667 + }, + { + "provider": "openrouter", + "model": "google/gemini-3.7-flash", + "input_price": 375000, + "output_price": 1875000, + "cache_read_price": 37500, + "cache_write_price": 20833 }, { "provider": "openrouter", @@ -3226,9 +3314,9 @@ { "provider": "openrouter", "model": "google/gemma-4-31b-it", - "input_price": 100000, + "input_price": 90000, "output_price": 340000, - "cache_read_price": 100000, + "cache_read_price": 50000, "cache_write_price": null }, { @@ -3258,8 +3346,8 @@ { "provider": "openrouter", "model": "gryphe/mythomax-l2-13b", - "input_price": 80000, - "output_price": 110000, + "input_price": 60000, + "output_price": 60000, "cache_read_price": null, "cache_write_price": null }, @@ -3305,10 +3393,10 @@ }, { "provider": "openrouter", - "model": "inclusionai/ling-3.0-flash:free", - "input_price": 0, - "output_price": 0, - "cache_read_price": null, + "model": "inclusionai/ling-3.0-flash", + "input_price": 21000, + "output_price": 63000, + "cache_read_price": 4200, "cache_write_price": null }, { @@ -3343,6 +3431,14 @@ "cache_read_price": 150000, "cache_write_price": null }, + { + "provider": "openrouter", + "model": "liquid/lfm-2.5-2.6b:free", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, { "provider": "openrouter", "model": "mancer/weaver", @@ -3423,6 +3519,14 @@ "cache_read_price": null, "cache_write_price": null }, + { + "provider": "openrouter", + "model": "meta/muse-glimmer-30b", + "input_price": 350000, + "output_price": 1500000, + "cache_read_price": 40000, + "cache_write_price": null + }, { "provider": "openrouter", "model": "meta/muse-spark-1.1", @@ -3431,6 +3535,14 @@ "cache_read_price": 150000, "cache_write_price": null }, + { + "provider": "openrouter", + "model": "meta/muse-spark-1.2", + "input_price": 1250000, + "output_price": 4250000, + "cache_read_price": 150000, + "cache_write_price": null + }, { "provider": "openrouter", "model": "microsoft/phi-4", @@ -3490,7 +3602,7 @@ { "provider": "openrouter", "model": "minimax/minimax-m2.5", - "input_price": 150000, + "input_price": 220000, "output_price": 900000, "cache_read_price": 50000, "cache_write_price": null @@ -3498,9 +3610,9 @@ { "provider": "openrouter", "model": "minimax/minimax-m2.7", - "input_price": 250000, - "output_price": 1000000, - "cache_read_price": 50000, + "input_price": 300000, + "output_price": 1200000, + "cache_read_price": 60000, "cache_write_price": null }, { @@ -3634,8 +3746,8 @@ { "provider": "openrouter", "model": "mistralai/mistral-small-3.2-24b-instruct", - "input_price": 75000, - "output_price": 200000, + "input_price": 93750, + "output_price": 250000, "cache_read_price": null, "cache_write_price": null }, @@ -3682,23 +3794,23 @@ { "provider": "openrouter", "model": "moonshotai/kimi-k2.5", - "input_price": 570000, - "output_price": 2850000, - "cache_read_price": 95000, + "input_price": 450000, + "output_price": 2250000, + "cache_read_price": 70000, "cache_write_price": null }, { "provider": "openrouter", "model": "moonshotai/kimi-k2.6", - "input_price": 589000, - "output_price": 2480000, - "cache_read_price": 99200, + "input_price": 950000, + "output_price": 4000000, + "cache_read_price": 160000, "cache_write_price": null }, { "provider": "openrouter", "model": "moonshotai/kimi-k2.7-code", - "input_price": 730000, + "input_price": 710000, "output_price": 3500000, "cache_read_price": 150000, "cache_write_price": null @@ -3839,6 +3951,22 @@ "cache_read_price": null, "cache_write_price": null }, + { + "provider": "openrouter", + "model": "nvidia/nemotron-3.5-lightning", + "input_price": 80000, + "output_price": 200000, + "cache_read_price": 40000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "nvidia/nemotron-3.5-lightning:free", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, { "provider": "openrouter", "model": "nvidia/nemotron-nano-12b-v2-vl:free", @@ -4095,14 +4223,6 @@ "cache_read_price": null, "cache_write_price": null }, - { - "provider": "openrouter", - "model": "openai/gpt-5.3-chat", - "input_price": 1750000, - "output_price": 14000000, - "cache_read_price": 175000, - "cache_write_price": null - }, { "provider": "openrouter", "model": "openai/gpt-5.3-codex", @@ -4170,50 +4290,50 @@ { "provider": "openrouter", "model": "openai/gpt-5.6-luna", - "input_price": 100000, - "output_price": 600000, - "cache_read_price": 10000, - "cache_write_price": 125000 + "input_price": 200000, + "output_price": 1200000, + "cache_read_price": 20000, + "cache_write_price": 250000 }, { "provider": "openrouter", "model": "openai/gpt-5.6-luna-pro", - "input_price": 100000, - "output_price": 600000, - "cache_read_price": 10000, - "cache_write_price": 125000 + "input_price": 200000, + "output_price": 1200000, + "cache_read_price": 20000, + "cache_write_price": 250000 }, { "provider": "openrouter", "model": "openai/gpt-5.6-sol", - "input_price": 5000000, - "output_price": 30000000, - "cache_read_price": 500000, - "cache_write_price": 6250000 + "input_price": 2500000, + "output_price": 15000000, + "cache_read_price": 250000, + "cache_write_price": 3125000 }, { "provider": "openrouter", "model": "openai/gpt-5.6-sol-pro", - "input_price": 5000000, - "output_price": 30000000, - "cache_read_price": 500000, - "cache_write_price": 6250000 + "input_price": 2500000, + "output_price": 15000000, + "cache_read_price": 250000, + "cache_write_price": 3125000 }, { "provider": "openrouter", "model": "openai/gpt-5.6-terra", - "input_price": 1000000, - "output_price": 6000000, - "cache_read_price": 100000, - "cache_write_price": 1250000 + "input_price": 2000000, + "output_price": 12000000, + "cache_read_price": 200000, + "cache_write_price": 2500000 }, { "provider": "openrouter", "model": "openai/gpt-5.6-terra-pro", - "input_price": 1000000, - "output_price": 6000000, - "cache_read_price": 100000, - "cache_write_price": 1250000 + "input_price": 2000000, + "output_price": 12000000, + "cache_read_price": 200000, + "cache_write_price": 2500000 }, { "provider": "openrouter", @@ -4242,9 +4362,9 @@ { "provider": "openrouter", "model": "openai/gpt-oss-120b", - "input_price": 37000, + "input_price": 30000, "output_price": 170000, - "cache_read_price": null, + "cache_read_price": 30000, "cache_write_price": null }, { @@ -4466,24 +4586,24 @@ { "provider": "openrouter", "model": "qwen/qwen-plus-2025-07-28:thinking", - "input_price": 400000, - "output_price": 1200000, + "input_price": 260000, + "output_price": 780000, "cache_read_price": null, - "cache_write_price": 500000 + "cache_write_price": null }, { "provider": "openrouter", "model": "qwen/qwen2.5-vl-72b-instruct", - "input_price": 250000, - "output_price": 750000, - "cache_read_price": null, + "input_price": 800000, + "output_price": 1000000, + "cache_read_price": 400000, "cache_write_price": null }, { "provider": "openrouter", "model": "qwen/qwen3-14b", - "input_price": 227500, - "output_price": 910000, + "input_price": 120000, + "output_price": 240000, "cache_read_price": null, "cache_write_price": null }, @@ -4498,8 +4618,8 @@ { "provider": "openrouter", "model": "qwen/qwen3-235b-a22b-2507", - "input_price": 149500, - "output_price": 598000, + "input_price": 90000, + "output_price": 550000, "cache_read_price": null, "cache_write_price": null }, @@ -4514,8 +4634,8 @@ { "provider": "openrouter", "model": "qwen/qwen3-30b-a3b", - "input_price": 120000, - "output_price": 500000, + "input_price": 130000, + "output_price": 520000, "cache_read_price": null, "cache_write_price": null }, @@ -4563,7 +4683,7 @@ "provider": "openrouter", "model": "qwen/qwen3-coder-30b-a3b-instruct", "input_price": 70000, - "output_price": 270000, + "output_price": 280000, "cache_read_price": null, "cache_write_price": null }, @@ -4634,16 +4754,16 @@ { "provider": "openrouter", "model": "qwen/qwen3-vl-235b-a22b-thinking", - "input_price": 980000, - "output_price": 3950000, + "input_price": 400000, + "output_price": 4000000, "cache_read_price": null, "cache_write_price": null }, { "provider": "openrouter", "model": "qwen/qwen3-vl-30b-a3b-instruct", - "input_price": 150000, - "output_price": 600000, + "input_price": 130000, + "output_price": 520000, "cache_read_price": null, "cache_write_price": null }, @@ -4698,9 +4818,9 @@ { "provider": "openrouter", "model": "qwen/qwen3.5-35b-a3b", - "input_price": 140000, - "output_price": 1000000, - "cache_read_price": null, + "input_price": 250000, + "output_price": 1250000, + "cache_read_price": 250000, "cache_write_price": null }, { @@ -4746,9 +4866,9 @@ { "provider": "openrouter", "model": "qwen/qwen3.6-27b", - "input_price": 289000, - "output_price": 2400000, - "cache_read_price": null, + "input_price": 300000, + "output_price": 2000000, + "cache_read_price": 30000, "cache_write_price": null }, { @@ -4756,7 +4876,7 @@ "model": "qwen/qwen3.6-35b-a3b", "input_price": 140000, "output_price": 1000000, - "cache_read_price": null, + "cache_read_price": 50000, "cache_write_price": null }, { @@ -4807,6 +4927,22 @@ "cache_read_price": 64000, "cache_write_price": 400000 }, + { + "provider": "openrouter", + "model": "qwen/qwen3.8-2.4t-a95b", + "input_price": 2000000, + "output_price": 6000000, + "cache_read_price": 250000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3.8-27b", + "input_price": 450000, + "output_price": 3200000, + "cache_read_price": 50000, + "cache_write_price": null + }, { "provider": "openrouter", "model": "qwen/qwen3.8-max", @@ -4855,6 +4991,14 @@ "cache_read_price": 500000, "cache_write_price": null }, + { + "provider": "openrouter", + "model": "sakana/sakana-namazu", + "input_price": 950000, + "output_price": 4000000, + "cache_read_price": 150000, + "cache_write_price": null + }, { "provider": "openrouter", "model": "sao10k/l3-lunaris-8b", @@ -4914,9 +5058,9 @@ { "provider": "openrouter", "model": "tencent/hy3-preview", - "input_price": 63000, - "output_price": 210000, - "cache_read_price": 21000, + "input_price": 180000, + "output_price": 600000, + "cache_read_price": 60000, "cache_write_price": null }, { @@ -4954,15 +5098,15 @@ { "provider": "openrouter", "model": "thinkingmachines/inkling", - "input_price": 1000000, + "input_price": 950000, "output_price": 4050000, - "cache_read_price": 170000, + "cache_read_price": 160000, "cache_write_price": null }, { "provider": "openrouter", "model": "thinkingmachines/inkling-small", - "input_price": 500000, + "input_price": 450000, "output_price": 1200000, "cache_read_price": 100000, "cache_write_price": null @@ -4983,6 +5127,14 @@ "cache_read_price": 15000, "cache_write_price": null }, + { + "provider": "openrouter", + "model": "upstage/solar-pro4", + "input_price": 30000, + "output_price": 120000, + "cache_read_price": 6000, + "cache_write_price": null + }, { "provider": "openrouter", "model": "writer/palmyra-x5", @@ -5023,6 +5175,14 @@ "cache_read_price": 300000, "cache_write_price": null }, + { + "provider": "openrouter", + "model": "x-ai/grok-4.6", + "input_price": 2000000, + "output_price": 6000000, + "cache_read_price": 500000, + "cache_write_price": null + }, { "provider": "openrouter", "model": "x-ai/grok-build-0.1", @@ -5106,9 +5266,9 @@ { "provider": "openrouter", "model": "z-ai/glm-5", - "input_price": 950000, - "output_price": 2550000, - "cache_read_price": 200000, + "input_price": 600000, + "output_price": 1920000, + "cache_read_price": 120000, "cache_write_price": null }, { @@ -5130,9 +5290,25 @@ { "provider": "openrouter", "model": "z-ai/glm-5.2", - "input_price": 760000, - "output_price": 2420000, - "cache_read_price": 140000, + "input_price": 966000, + "output_price": 3036000, + "cache_read_price": 193200, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "z-ai/glm-5.2:free", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "z-ai/glm-5.3", + "input_price": 1400000, + "output_price": 4400000, + "cache_read_price": 260000, "cache_write_price": null }, { @@ -5178,18 +5354,18 @@ { "provider": "openrouter", "model": "~deepseek/deepseek-v4-flash-latest", - "input_price": 90000, - "output_price": 180000, - "cache_read_price": 18000, + "input_price": 76500, + "output_price": 153000, + "cache_read_price": 15300, "cache_write_price": null }, { "provider": "openrouter", "model": "~google/gemini-flash-latest", - "input_price": 1500000, - "output_price": 7500000, - "cache_read_price": 150000, - "cache_write_price": 83333 + "input_price": 375000, + "output_price": 1875000, + "cache_read_price": 37500, + "cache_write_price": 20833 }, { "provider": "openrouter", @@ -5202,18 +5378,18 @@ { "provider": "openrouter", "model": "~moonshotai/kimi-latest", - "input_price": 2900000, - "output_price": 14000000, + "input_price": 2600000, + "output_price": 13000000, "cache_read_price": 290000, "cache_write_price": null }, { "provider": "openrouter", "model": "~openai/gpt-latest", - "input_price": 5000000, - "output_price": 30000000, - "cache_read_price": 500000, - "cache_write_price": 6250000 + "input_price": 2500000, + "output_price": 15000000, + "cache_read_price": 250000, + "cache_write_price": 3125000 }, { "provider": "openrouter", @@ -5228,7 +5404,7 @@ "model": "~x-ai/grok-latest", "input_price": 2000000, "output_price": 6000000, - "cache_read_price": 300000, + "cache_read_price": 500000, "cache_write_price": null }, { @@ -5431,6 +5607,22 @@ "cache_read_price": 80000, "cache_write_price": 500000 }, + { + "provider": "vercel", + "model": "alibaba/qwen3.8-2.4t-a95b", + "input_price": 2000000, + "output_price": 6000000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "alibaba/qwen3.8-27b", + "input_price": 100000, + "output_price": 400000, + "cache_read_price": 10000, + "cache_write_price": null + }, { "provider": "vercel", "model": "alibaba/qwen3.8-max", @@ -5503,14 +5695,6 @@ "cache_read_price": 1500000, "cache_write_price": 18750000 }, - { - "provider": "vercel", - "model": "anthropic/claude-opus-4.1", - "input_price": 15000000, - "output_price": 75000000, - "cache_read_price": 1500000, - "cache_write_price": 18750000 - }, { "provider": "vercel", "model": "anthropic/claude-opus-4.5", @@ -5690,9 +5874,9 @@ { "provider": "vercel", "model": "deepseek/deepseek-v4-flash", - "input_price": 200000, - "output_price": 400000, - "cache_read_price": 40000, + "input_price": 130000, + "output_price": 260000, + "cache_read_price": 28000, "cache_write_price": null }, { @@ -5706,9 +5890,17 @@ { "provider": "vercel", "model": "deepseek/deepseek-v4-pro", - "input_price": 435000, - "output_price": 870000, - "cache_read_price": 3600, + "input_price": 1740000, + "output_price": 3480000, + "cache_read_price": 140000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "deepseek/deepseek-v4-pro-0813", + "input_price": 1320000, + "output_price": 3960000, + "cache_read_price": 132000, "cache_write_price": null }, { @@ -5818,9 +6010,17 @@ { "provider": "vercel", "model": "google/gemini-3.6-flash", - "input_price": 1500000, - "output_price": 7500000, - "cache_read_price": 150000, + "input_price": 750000, + "output_price": 3750000, + "cache_read_price": 75000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "google/gemini-3.7-flash", + "input_price": 750000, + "output_price": 3750000, + "cache_read_price": 75000, "cache_write_price": null }, { @@ -5865,10 +6065,10 @@ }, { "provider": "vercel", - "model": "inclusionai/ling-3.0-flash-free", - "input_price": 0, - "output_price": 0, - "cache_read_price": null, + "model": "inclusionai/ling-3.0-flash", + "input_price": 60000, + "output_price": 180000, + "cache_read_price": 12000, "cache_write_price": null }, { @@ -5951,6 +6151,14 @@ "cache_read_price": null, "cache_write_price": null }, + { + "provider": "vercel", + "model": "meta/muse-glimmer-30b", + "input_price": 350000, + "output_price": 1500000, + "cache_read_price": 40000, + "cache_write_price": null + }, { "provider": "vercel", "model": "meta/muse-spark-1.1", @@ -5959,6 +6167,22 @@ "cache_read_price": 150000, "cache_write_price": null }, + { + "provider": "vercel", + "model": "meta/muse-spark-1.2", + "input_price": 1250000, + "output_price": 4250000, + "cache_read_price": 150000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "meta/muse-spark-1.2-contributor", + "input_price": 100000, + "output_price": 200000, + "cache_read_price": 2000, + "cache_write_price": null + }, { "provider": "vercel", "model": "minimax/minimax-m2", @@ -6239,6 +6463,14 @@ "cache_read_price": 120000, "cache_write_price": null }, + { + "provider": "vercel", + "model": "nvidia/nemotron-3.5-lightning", + "input_price": 50000, + "output_price": 200000, + "cache_read_price": 10000, + "cache_write_price": null + }, { "provider": "vercel", "model": "nvidia/nemotron-nano-12b-v2-vl", @@ -6279,6 +6511,14 @@ "cache_read_price": 500000, "cache_write_price": null }, + { + "provider": "vercel", + "model": "openai/gpt-4.1-fast", + "input_price": 3500000, + "output_price": 14000000, + "cache_read_price": 875000, + "cache_write_price": null + }, { "provider": "vercel", "model": "openai/gpt-4.1-mini", @@ -6287,6 +6527,14 @@ "cache_read_price": 100000, "cache_write_price": null }, + { + "provider": "vercel", + "model": "openai/gpt-4.1-mini-fast", + "input_price": 700000, + "output_price": 2800000, + "cache_read_price": 175000, + "cache_write_price": null + }, { "provider": "vercel", "model": "openai/gpt-4.1-nano", @@ -6295,6 +6543,14 @@ "cache_read_price": 25000, "cache_write_price": null }, + { + "provider": "vercel", + "model": "openai/gpt-4.1-nano-fast", + "input_price": 200000, + "output_price": 800000, + "cache_read_price": 50000, + "cache_write_price": null + }, { "provider": "vercel", "model": "openai/gpt-4o", @@ -6303,6 +6559,14 @@ "cache_read_price": 1250000, "cache_write_price": null }, + { + "provider": "vercel", + "model": "openai/gpt-4o-fast", + "input_price": 4250000, + "output_price": 17000000, + "cache_read_price": 2125000, + "cache_write_price": null + }, { "provider": "vercel", "model": "openai/gpt-4o-mini", @@ -6311,6 +6575,14 @@ "cache_read_price": 75000, "cache_write_price": null }, + { + "provider": "vercel", + "model": "openai/gpt-4o-mini-fast", + "input_price": 250000, + "output_price": 1000000, + "cache_read_price": 125000, + "cache_write_price": null + }, { "provider": "vercel", "model": "openai/gpt-4o-mini-search-preview", @@ -6351,6 +6623,14 @@ "cache_read_price": 130000, "cache_write_price": null }, + { + "provider": "vercel", + "model": "openai/gpt-5-fast", + "input_price": 2500000, + "output_price": 20000000, + "cache_read_price": 250000, + "cache_write_price": null + }, { "provider": "vercel", "model": "openai/gpt-5-mini", @@ -6359,6 +6639,14 @@ "cache_read_price": 25000, "cache_write_price": null }, + { + "provider": "vercel", + "model": "openai/gpt-5-mini-fast", + "input_price": 450000, + "output_price": 3600000, + "cache_read_price": 45000, + "cache_write_price": null + }, { "provider": "vercel", "model": "openai/gpt-5-nano", @@ -6401,18 +6689,18 @@ }, { "provider": "vercel", - "model": "openai/gpt-5.1-instant", + "model": "openai/gpt-5.1-thinking", "input_price": 1250000, "output_price": 10000000, - "cache_read_price": 130000, + "cache_read_price": 125000, "cache_write_price": null }, { "provider": "vercel", - "model": "openai/gpt-5.1-thinking", - "input_price": 1250000, - "output_price": 10000000, - "cache_read_price": 125000, + "model": "openai/gpt-5.1-thinking-fast", + "input_price": 2500000, + "output_price": 20000000, + "cache_read_price": 250000, "cache_write_price": null }, { @@ -6431,6 +6719,14 @@ "cache_read_price": 175000, "cache_write_price": null }, + { + "provider": "vercel", + "model": "openai/gpt-5.2-fast", + "input_price": 3500000, + "output_price": 28000000, + "cache_read_price": 350000, + "cache_write_price": null + }, { "provider": "vercel", "model": "openai/gpt-5.2-pro", @@ -6441,7 +6737,7 @@ }, { "provider": "vercel", - "model": "openai/gpt-5.3-chat", + "model": "openai/gpt-5.3-codex", "input_price": 1750000, "output_price": 14000000, "cache_read_price": 175000, @@ -6449,10 +6745,10 @@ }, { "provider": "vercel", - "model": "openai/gpt-5.3-codex", - "input_price": 1750000, - "output_price": 14000000, - "cache_read_price": 175000, + "model": "openai/gpt-5.3-codex-fast", + "input_price": 3500000, + "output_price": 28000000, + "cache_read_price": 350000, "cache_write_price": null }, { @@ -6463,6 +6759,14 @@ "cache_read_price": 250000, "cache_write_price": null }, + { + "provider": "vercel", + "model": "openai/gpt-5.4-fast", + "input_price": 5000000, + "output_price": 30000000, + "cache_read_price": 500000, + "cache_write_price": null + }, { "provider": "vercel", "model": "openai/gpt-5.4-mini", @@ -6471,6 +6775,14 @@ "cache_read_price": 75000, "cache_write_price": null }, + { + "provider": "vercel", + "model": "openai/gpt-5.4-mini-fast", + "input_price": 1500000, + "output_price": 9000000, + "cache_read_price": 150000, + "cache_write_price": null + }, { "provider": "vercel", "model": "openai/gpt-5.4-nano", @@ -6495,6 +6807,14 @@ "cache_read_price": 500000, "cache_write_price": null }, + { + "provider": "vercel", + "model": "openai/gpt-5.5-fast", + "input_price": 12500000, + "output_price": 75000000, + "cache_read_price": 1250000, + "cache_write_price": null + }, { "provider": "vercel", "model": "openai/gpt-5.5-pro", @@ -6511,13 +6831,29 @@ "cache_read_price": 20000, "cache_write_price": 250000 }, + { + "provider": "vercel", + "model": "openai/gpt-5.6-luna-fast", + "input_price": 400000, + "output_price": 2400000, + "cache_read_price": 40000, + "cache_write_price": 250000 + }, { "provider": "vercel", "model": "openai/gpt-5.6-sol", + "input_price": 2500000, + "output_price": 15000000, + "cache_read_price": 250000, + "cache_write_price": 3125000 + }, + { + "provider": "vercel", + "model": "openai/gpt-5.6-sol-fast", "input_price": 5000000, "output_price": 30000000, "cache_read_price": 500000, - "cache_write_price": 6250000 + "cache_write_price": 3125000 }, { "provider": "vercel", @@ -6527,6 +6863,14 @@ "cache_read_price": 200000, "cache_write_price": 2500000 }, + { + "provider": "vercel", + "model": "openai/gpt-5.6-terra-fast", + "input_price": 4000000, + "output_price": 24000000, + "cache_read_price": 400000, + "cache_write_price": 2500000 + }, { "provider": "vercel", "model": "openai/gpt-image-1", @@ -6639,6 +6983,14 @@ "cache_read_price": 2500000, "cache_write_price": null }, + { + "provider": "vercel", + "model": "openai/o3-fast", + "input_price": 3500000, + "output_price": 14000000, + "cache_read_price": 875000, + "cache_write_price": null + }, { "provider": "vercel", "model": "openai/o3-mini", @@ -6663,6 +7015,14 @@ "cache_read_price": 275000, "cache_write_price": null }, + { + "provider": "vercel", + "model": "openai/o4-mini-fast", + "input_price": 2000000, + "output_price": 8000000, + "cache_read_price": 500000, + "cache_write_price": null + }, { "provider": "vercel", "model": "poolside/laguna-s-2.1", @@ -6687,6 +7047,14 @@ "cache_read_price": 500000, "cache_write_price": null }, + { + "provider": "vercel", + "model": "sakana/namazu", + "input_price": 950000, + "output_price": 4000000, + "cache_read_price": 150000, + "cache_write_price": null + }, { "provider": "vercel", "model": "stepfun/step-3.5-flash", @@ -6807,6 +7175,14 @@ "cache_read_price": 300000, "cache_write_price": null }, + { + "provider": "vercel", + "model": "xai/grok-4.6", + "input_price": 2000000, + "output_price": 6000000, + "cache_read_price": 500000, + "cache_write_price": null + }, { "provider": "vercel", "model": "xai/grok-build-0.1", @@ -6922,9 +7298,9 @@ { "provider": "vercel", "model": "zai/glm-5.2", - "input_price": 1100000, - "output_price": 3851000, - "cache_read_price": 275000, + "input_price": 800000, + "output_price": 2550000, + "cache_read_price": 160000, "cache_write_price": null }, { @@ -6935,6 +7311,14 @@ "cache_read_price": 210000, "cache_write_price": null }, + { + "provider": "vercel", + "model": "zai/glm-5.3", + "input_price": 1400000, + "output_price": 4400000, + "cache_read_price": 260000, + "cache_write_price": null + }, { "provider": "vercel", "model": "zai/glm-5v-turbo", diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/knownModelsGenerated.json b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/knownModelsGenerated.json index 5ae75467a28..4658e6b2094 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/knownModelsGenerated.json +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/knownModelsGenerated.json @@ -200,7 +200,8 @@ "maxOutputTokens": 128000, "inputCost": 5, "outputCost": 30, - "cacheReadCost": 0.5 + "cacheReadCost": 0.5, + "cacheWriteCost": 6.25 }, { "provider": "azure", @@ -209,10 +210,10 @@ "aliases": [], "contextLimit": 1050000, "maxOutputTokens": 128000, - "inputCost": 2.5, - "outputCost": 15, - "cacheReadCost": 0.25, - "cacheWriteCost": 3.125 + "inputCost": 2, + "outputCost": 12, + "cacheReadCost": 0.2, + "cacheWriteCost": 2.5 }, { "provider": "azure", @@ -221,10 +222,10 @@ "aliases": [], "contextLimit": 1050000, "maxOutputTokens": 128000, - "inputCost": 1, - "outputCost": 6, - "cacheReadCost": 0.1, - "cacheWriteCost": 1.25 + "inputCost": 0.2, + "outputCost": 1.2, + "cacheReadCost": 0.02, + "cacheWriteCost": 0.25 }, { "provider": "azure", @@ -464,10 +465,10 @@ "aliases": [], "contextLimit": 1050000, "maxOutputTokens": 128000, - "inputCost": 5, - "outputCost": 30, - "cacheReadCost": 0.5, - "cacheWriteCost": 6.25 + "inputCost": 2.5, + "outputCost": 15, + "cacheReadCost": 0.25, + "cacheWriteCost": 3.125 }, { "provider": "openrouter", @@ -476,10 +477,10 @@ "aliases": [], "contextLimit": 1050000, "maxOutputTokens": 128000, - "inputCost": 1, - "outputCost": 6, - "cacheReadCost": 0.1, - "cacheWriteCost": 1.25 + "inputCost": 2, + "outputCost": 12, + "cacheReadCost": 0.2, + "cacheWriteCost": 2.5 }, { "provider": "openrouter", @@ -488,10 +489,10 @@ "aliases": [], "contextLimit": 1050000, "maxOutputTokens": 128000, - "inputCost": 0.1, - "outputCost": 0.6, - "cacheReadCost": 0.01, - "cacheWriteCost": 0.125 + "inputCost": 0.2, + "outputCost": 1.2, + "cacheReadCost": 0.02, + "cacheWriteCost": 0.25 }, { "provider": "openrouter", @@ -500,8 +501,9 @@ "aliases": [], "contextLimit": 131072, "maxOutputTokens": 131072, - "inputCost": 0.037, - "outputCost": 0.17 + "inputCost": 0.03, + "outputCost": 0.17, + "cacheReadCost": 0.03 }, { "provider": "openrouter", @@ -520,10 +522,10 @@ "displayName": "GLM-5.2", "aliases": [], "contextLimit": 1048576, - "maxOutputTokens": 262144, - "inputCost": 0.76, - "outputCost": 2.42, - "cacheReadCost": 0.14 + "maxOutputTokens": 131072, + "inputCost": 0.966, + "outputCost": 3.036, + "cacheReadCost": 0.1932 }, { "provider": "openrouter", @@ -603,10 +605,10 @@ "displayName": "DeepSeek V4 Flash", "aliases": [], "contextLimit": 1048576, - "maxOutputTokens": 393216, - "inputCost": 0.14, - "outputCost": 0.28, - "cacheReadCost": 0.028 + "maxOutputTokens": 384000, + "inputCost": 0.0826, + "outputCost": 0.1652, + "cacheReadCost": 0.01652 }, { "provider": "openrouter", @@ -614,10 +616,10 @@ "displayName": "DeepSeek V4 Pro", "aliases": [], "contextLimit": 1048576, - "maxOutputTokens": 384000, - "inputCost": 0.435, - "outputCost": 0.87, - "cacheReadCost": 0.003625 + "maxOutputTokens": 393216, + "inputCost": 1.44, + "outputCost": 2.88, + "cacheReadCost": 0.1215 }, { "provider": "openrouter", @@ -651,10 +653,10 @@ "aliases": [], "contextLimit": 1050000, "maxOutputTokens": 128000, - "inputCost": 5, - "outputCost": 30, - "cacheReadCost": 0.5, - "cacheWriteCost": 6.25 + "inputCost": 2.5, + "outputCost": 15, + "cacheReadCost": 0.25, + "cacheWriteCost": 3.125 }, { "provider": "vercel", @@ -708,9 +710,9 @@ "aliases": [], "contextLimit": 1000000, "maxOutputTokens": 128000, - "inputCost": 1.1, - "outputCost": 3.851, - "cacheReadCost": 0.275 + "inputCost": 0.8, + "outputCost": 2.55, + "cacheReadCost": 0.16 }, { "provider": "vercel", @@ -791,20 +793,20 @@ "aliases": [], "contextLimit": 1000000, "maxOutputTokens": 384000, - "inputCost": 0.2, - "outputCost": 0.4, - "cacheReadCost": 0.04 + "inputCost": 0.13, + "outputCost": 0.26, + "cacheReadCost": 0.028 }, { "provider": "vercel", "modelIdentifier": "deepseek/deepseek-v4-pro", "displayName": "DeepSeek V4 Pro", "aliases": [], - "contextLimit": 1000000, - "maxOutputTokens": 384000, - "inputCost": 0.435, - "outputCost": 0.87, - "cacheReadCost": 0.0036 + "contextLimit": 1048600, + "maxOutputTokens": 1048600, + "inputCost": 1.74, + "outputCost": 3.48, + "cacheReadCost": 0.14 } ] }