Files
coder/scripts/aibridgepricesdiff/main_test.go
T
Yevhenii Shcherbina 211f490910 refactor: share the AI Gateway price book contract (#28310)
Follow-up to the review discussion on the price book refresh automation:
https://github.com/coder/coder/pull/28146#discussion_r3806439966

## Problem

The price seed row struct was duplicated three times, so a JSON tag
change had to be repeated in each copy:

- `scripts/aibridgepricesgen.priceRow`
- `scripts/aibridgepricesdiff.priceRow`
- `coderd/aibridge/prices.seedRow`

The `(provider, model)` key and its parsing were duplicated too.

## Change

Add `coderd/aibridge/prices/pricebook` holding the shared contract:
`Row`, `Key`, `Row.Key()`, and `Parse`. The package depends only on the
standard library, so the scripts can import it without pulling in the
database layer that `coderd/aibridge/prices` brings.

Consumer-specific behaviour stays where it is: upstream conversion and
validation in the generator, comparison and Markdown rendering in the
diff tool, DB seeding and runtime lookup in the seeder.

The batch SQL upsert still extracts JSON fields by name, so it remains a
separate contract boundary. The note about keeping it in sync now lives
on the shared `Row`.

No generated artifacts change; the serialized schema is identical.

---

<sub>Opened by Coder Agents on behalf of @evgeniy-scherbina.</sub>
2026-08-20 14:04:23 -04:00

316 lines
7.3 KiB
Go

package main
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/aibridge/prices/pricebook"
)
func TestCompare(t *testing.T) {
t.Parallel()
cases := []struct {
name string
old []pricebook.Row
new []pricebook.Row
wantAdded []string
wantRemoved []string
wantChanged []string
}{
{
name: "identical",
old: []pricebook.Row{row("anthropic", "claude", 1, 2)},
new: []pricebook.Row{row("anthropic", "claude", 1, 2)},
},
{
name: "added",
old: []pricebook.Row{
row("anthropic", "claude", 1, 2),
},
new: []pricebook.Row{
row("anthropic", "claude", 1, 2),
row("openai", "gpt", 3, 4),
},
wantAdded: []string{"openai/gpt"},
},
{
name: "removed",
old: []pricebook.Row{
row("anthropic", "claude", 1, 2),
row("openai", "gpt", 3, 4),
},
new: []pricebook.Row{
row("anthropic", "claude", 1, 2),
},
wantRemoved: []string{"openai/gpt"},
},
{
name: "changed",
old: []pricebook.Row{row("anthropic", "claude", 1, 2)},
new: []pricebook.Row{row("anthropic", "claude", 1, 5)},
wantChanged: []string{"anthropic/claude"},
},
{
// Every price field participates, not just input and output.
name: "cache price changed",
old: []pricebook.Row{{
Provider: "anthropic",
Model: "claude",
CacheReadPrice: int64Ptr(1),
}},
new: []pricebook.Row{{
Provider: "anthropic",
Model: "claude",
CacheReadPrice: int64Ptr(2),
}},
wantChanged: []string{"anthropic/claude"},
},
{
// A previously missing price becoming populated is a change.
name: "price set",
old: []pricebook.Row{{
Provider: "anthropic",
Model: "claude",
}},
new: []pricebook.Row{{
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",
old: []pricebook.Row{
row("anthropic", "claude", 1, 2),
},
new: []pricebook.Row{{
Provider: "anthropic",
Model: "claude",
InputPrice: int64Ptr(1),
}},
wantChanged: []string{"anthropic/claude"},
},
{
// Zero is a populated price, distinct from an absent one.
name: "null becomes zero",
old: []pricebook.Row{{
Provider: "openai",
Model: "gpt",
}},
new: []pricebook.Row{{
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",
old: []pricebook.Row{{
Provider: "openai",
Model: "gpt",
InputPrice: int64Ptr(0),
}},
new: []pricebook.Row{{
Provider: "openai",
Model: "gpt",
}},
wantChanged: []string{"openai/gpt"},
},
{
// Same model identifier under two providers must not collide.
name: "same model different providers",
old: []pricebook.Row{
row("anthropic", "shared", 1, 2),
row("openai", "shared", 1, 2),
},
new: []pricebook.Row{
row("anthropic", "shared", 1, 2),
row("openai", "shared", 9, 2),
},
wantChanged: []string{"openai/shared"},
},
{
name: "added removed and changed together",
old: []pricebook.Row{
row("anthropic", "old-model", 1, 2),
row("openai", "gpt", 3, 4),
},
new: []pricebook.Row{
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"},
},
}
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, names(got.changed))
})
}
}
func TestCompareSortsDeterministically(t *testing.T) {
t.Parallel()
newRows := []pricebook.Row{
row("openai", "b", 1, 1),
row("anthropic", "z", 1, 1),
row("anthropic", "a", 1, 1),
}
got := compare(nil, newRows)
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(
[]pricebook.Row{row("anthropic", "claude", 1, 2)},
[]pricebook.Row{row("anthropic", "claude", 1, 2)},
))
require.Equal(t, `## Price book changes
No price changes.
`, out)
})
t.Run("full summary", func(t *testing.T) {
t.Parallel()
out := render(compare(
[]pricebook.Row{
row("anthropic", "gone", 1, 2),
row("openai", "gpt", 1, 2),
},
[]pricebook.Row{
row("anthropic", "fresh", 3, 4),
row("openai", "gpt", 2, 3),
},
))
// 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.
<details>
<summary>Added</summary>
- anthropic/fresh
</details>
<details>
<summary>Removed</summary>
- anthropic/gone
</details>
<details>
<summary>Changed</summary>
- openai/gpt
</details>
`, out)
})
}
func TestRun(t *testing.T) {
t.Parallel()
t.Run("missing flags", func(t *testing.T) {
t.Parallel()
var out strings.Builder
require.ErrorContains(t, run("", "", &out), "-old and -new are both required")
})
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(), "<summary>Changed</summary>\n\n- openai/gpt\n")
})
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
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)
})
}
func row(provider, model string, input, output int64) pricebook.Row {
return pricebook.Row{
Provider: provider,
Model: model,
InputPrice: int64Ptr(input),
OutputPrice: int64Ptr(output),
}
}
func names(keys []pricebook.Key) []string {
if len(keys) == 0 {
return nil
}
out := make([]string, 0, len(keys))
for _, k := range keys {
out = append(out, k.String())
}
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 }