mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
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>
316 lines
7.3 KiB
Go
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 }
|