Files
coder/enterprise/coderd/aimodelprices_test.go
T
Susana Ferreira 50ecd6959f feat: add a source filter to the AI model price list (#28329)
## Description

With prices from the price book and prices set through the API both in the table, `list` showed only the price in effect and gave no way to tell where it came from. There was also no way to see the price book's entry for a model that had been overridden, since the listing resolves to the custom price.

This adds a `source` column to the output and a `--source` filter for narrowing to one source, or to every row with `all`.

Note: `--source` selects rows rather than models. A model carrying both a price book price and a custom one appears under either value, so the two filtered listings do not sum to the unfiltered one. `--source all` reports both rows at once.

## Changes

- Add `Source` to `codersdk.AIModelPrice` and `AIModelPricesFilter`, and map it in `db2sdk`, which previously dropped it.
- Filter `GetAIModelPrices` by source before resolution, so the price book's row for an overridden model stays reachable.
- Accept `all` as a source, which reports every price a model holds instead of resolving to one.
- Accept a `source` query parameter on the list endpoint, rejected with a 400 when outside the enum.
- Add `--source` to the list command and a `source` column to its output.
- Document the column and the filter, and lead the price section with `coder exp ai-model-prices list`.
- Distinguish a deployment's default prices from the price book that ships with each release.

Closes https://linear.app/codercom/issue/AIGOV-593

> [!NOTE]
> Initially generated by Claude Opus 5, modified and reviewed by @ssncferreira
2026-08-20 11:56:58 +01:00

585 lines
20 KiB
Go

package coderd_test
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/testutil"
)
// setupAIModelPricesTest returns a client entitled to manage model prices.
func setupAIModelPricesTest(t *testing.T) (*codersdk.Client, codersdk.CreateFirstUserResponse) {
t.Helper()
return coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
codersdk.FeatureAIBridge: 1,
},
},
})
}
func newAIModelPrice(provider, model string, input int64) codersdk.AIModelPriceUpsert {
return codersdk.AIModelPriceUpsert{
Provider: provider,
Model: model,
InputPrice: &input,
}
}
func TestUpsertAIModelPrices(t *testing.T) {
t.Parallel()
t.Run("LicenseEntitlement", func(t *testing.T) {
t.Parallel()
// Given: a deployment without the AI Bridge feature.
ownerClient, _ := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{},
},
})
ctx := testutil.Context(t, testutil.WaitLong)
// When: an owner sets a price for anthropic/my-model.
//nolint:gocritic // Managing AI model prices is owner-only.
err := codersdk.NewExperimentalClient(ownerClient).UpsertAIModelPrices(ctx, codersdk.UpsertAIModelPricesRequest{
Prices: []codersdk.AIModelPriceUpsert{newAIModelPrice("anthropic", "my-model", 100)},
})
// Then: RequireFeatureMW rejects it as a Premium feature.
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
require.Contains(t, sdkErr.Message, "Premium feature")
})
t.Run("Forbidden", func(t *testing.T) {
t.Parallel()
// Given: a member without ai_model_price:update.
ownerClient, owner := setupAIModelPricesTest(t)
memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
ctx := testutil.Context(t, testutil.WaitLong)
// When: they set a price for anthropic/my-model.
err := codersdk.NewExperimentalClient(memberClient).UpsertAIModelPrices(ctx, codersdk.UpsertAIModelPricesRequest{
Prices: []codersdk.AIModelPriceUpsert{newAIModelPrice("anthropic", "my-model", 100)},
})
// Then: the request is forbidden by ai_model_price:update.
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
})
t.Run("RejectsAnOversizedBody", func(t *testing.T) {
t.Parallel()
// Given: an entitled deployment and a body over the size cap.
ownerClient, _ := setupAIModelPricesTest(t)
ctx := testutil.Context(t, testutil.WaitLong)
body := fmt.Sprintf(`{"prices":[{"provider":"anthropic","model":%q}]}`,
strings.Repeat("a", codersdk.MaxAIModelPricesBytes))
// When: the body is sent.
//nolint:gocritic // Managing AI model prices is owner-only.
res, err := ownerClient.Request(ctx, http.MethodPost,
"/api/experimental/ai/model-prices", json.RawMessage(body))
require.NoError(t, err)
defer res.Body.Close()
// Then: it is rejected before the body is decoded.
require.Equal(t, http.StatusRequestEntityTooLarge, res.StatusCode)
})
t.Run("RejectsMalformedBody", func(t *testing.T) {
t.Parallel()
// Given: an entitled deployment.
ownerClient, _ := setupAIModelPricesTest(t)
tests := []struct {
name string
body string
wantDetail string
}{
{
name: "NotAnArray",
body: `{"prices": "not-an-array"}`,
wantDetail: "cannot unmarshal string",
},
{
name: "WrongFieldType",
body: `{"prices":[{"provider":"anthropic","model":"my-model","input_price":"abc","output_price":null,"cache_read_price":null,"cache_write_price":null}]}`,
wantDetail: "input_price",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// When: the body is sent. json.RawMessage marshals verbatim, so
// it reaches the handler unchanged.
//nolint:gocritic // Managing AI model prices is owner-only.
res, err := ownerClient.Request(ctx, http.MethodPost,
"/api/experimental/ai/model-prices", json.RawMessage(tt.body))
require.NoError(t, err)
defer res.Body.Close()
// Then: the detail names the problem, since both decodes report
// the same message.
require.Equal(t, http.StatusBadRequest, res.StatusCode)
var sdkErr *codersdk.Error
require.ErrorAs(t, codersdk.ReadBodyAsError(res), &sdkErr)
require.Equal(t, "Request body must be valid JSON.", sdkErr.Message)
require.Contains(t, sdkErr.Detail, tt.wantDetail)
})
}
})
t.Run("RejectsInvalidPrices", func(t *testing.T) {
t.Parallel()
// Given: an entitled deployment.
ownerClient, _ := setupAIModelPricesTest(t)
exp := codersdk.NewExperimentalClient(ownerClient)
ctx := testutil.Context(t, testutil.WaitLong)
// When: an entry carries an empty model name.
//nolint:gocritic // Managing AI model prices is owner-only.
err := exp.UpsertAIModelPrices(ctx, codersdk.UpsertAIModelPricesRequest{
Prices: []codersdk.AIModelPriceUpsert{newAIModelPrice("anthropic", "", 100)},
})
// Then: a 400 comes back naming the fields that failed.
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Equal(t, "Invalid AI model prices.", sdkErr.Message)
require.NotEmpty(t, sdkErr.Validations)
})
// A rejected request must leave the table untouched, so a large document
// cannot be applied halfway.
t.Run("WritesNothingWhenAnyEntryIsInvalid", func(t *testing.T) {
t.Parallel()
// Given: the prices already stored.
ownerClient, _ := setupAIModelPricesTest(t)
exp := codersdk.NewExperimentalClient(ownerClient)
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // Managing AI model prices is owner-only.
before, err := exp.ListAIModelPrices(ctx, codersdk.AIModelPricesFilter{})
require.NoError(t, err)
// When: a document holds a valid good-model ahead of an entry with an
// empty model name.
err = exp.UpsertAIModelPrices(ctx, codersdk.UpsertAIModelPricesRequest{
Prices: []codersdk.AIModelPriceUpsert{
newAIModelPrice("anthropic", "good-model", 100),
newAIModelPrice("anthropic", "", 100),
},
})
require.Error(t, err)
// Then: good-model is not written either, so the row count is unchanged.
after, err := exp.ListAIModelPrices(ctx, codersdk.AIModelPricesFilter{})
require.NoError(t, err)
require.Len(t, after, len(before), "no price should have been written")
})
t.Run("SetsPrices", func(t *testing.T) {
t.Parallel()
// Given: anthropic/my-model, which the price book does not cover.
ownerClient, _ := setupAIModelPricesTest(t)
exp := codersdk.NewExperimentalClient(ownerClient)
ctx := testutil.Context(t, testutil.WaitLong)
// When: it is priced with an input price only.
//nolint:gocritic // Managing AI model prices is owner-only.
require.NoError(t, exp.UpsertAIModelPrices(ctx, codersdk.UpsertAIModelPricesRequest{
Prices: []codersdk.AIModelPriceUpsert{newAIModelPrice("anthropic", "my-model", 3_000_000)},
}))
// Then: the input price is stored and the other three are null.
prices, err := exp.ListAIModelPrices(ctx, codersdk.AIModelPricesFilter{
Provider: "anthropic",
Model: "my-model",
})
require.NoError(t, err)
require.Len(t, prices, 1)
require.Equal(t, int64(3_000_000), *prices[0].InputPrice)
require.Nil(t, prices[0].OutputPrice)
require.Nil(t, prices[0].CacheReadPrice)
require.Nil(t, prices[0].CacheWritePrice)
})
t.Run("UpdatesAPriceItSet", func(t *testing.T) {
t.Parallel()
// Given: anthropic/my-model priced at 100 through this endpoint.
ownerClient, _ := setupAIModelPricesTest(t)
exp := codersdk.NewExperimentalClient(ownerClient)
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // Managing AI model prices is owner-only.
require.NoError(t, exp.UpsertAIModelPrices(ctx, codersdk.UpsertAIModelPricesRequest{
Prices: []codersdk.AIModelPriceUpsert{newAIModelPrice("anthropic", "my-model", 100)},
}))
// When: the same model is priced again at 200.
require.NoError(t, exp.UpsertAIModelPrices(ctx, codersdk.UpsertAIModelPricesRequest{
Prices: []codersdk.AIModelPriceUpsert{newAIModelPrice("anthropic", "my-model", 200)},
}))
// Then: the row is updated to 200.
prices, err := exp.ListAIModelPrices(ctx, codersdk.AIModelPricesFilter{
Provider: "anthropic",
Model: "my-model",
})
require.NoError(t, err)
require.Len(t, prices, 1)
require.Equal(t, int64(200), *prices[0].InputPrice)
})
// anthropic/claude-opus-5 is covered by the seeded price book, so these
// write over a price Coder ships.
t.Run("OverridesAPriceBookModel", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
price codersdk.AIModelPriceUpsert
want codersdk.AIModelPrice
}{
{
name: "ReplacesEveryPrice",
price: codersdk.AIModelPriceUpsert{
Provider: "anthropic", Model: "claude-opus-5",
InputPrice: ptr.Ref(int64(100)),
OutputPrice: ptr.Ref(int64(200)),
CacheReadPrice: ptr.Ref(int64(300)),
CacheWritePrice: ptr.Ref(int64(400)),
},
want: codersdk.AIModelPrice{
InputPrice: ptr.Ref(int64(100)),
OutputPrice: ptr.Ref(int64(200)),
CacheReadPrice: ptr.Ref(int64(300)),
CacheWritePrice: ptr.Ref(int64(400)),
},
},
{
name: "StoresNullPricesAsNull",
price: newAIModelPrice("anthropic", "claude-opus-5", 100),
want: codersdk.AIModelPrice{
InputPrice: ptr.Ref(int64(100)),
OutputPrice: nil,
CacheReadPrice: nil,
CacheWritePrice: nil,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Given: a deployment holding the book's price for the model.
ownerClient, _ := setupAIModelPricesTest(t)
exp := codersdk.NewExperimentalClient(ownerClient)
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // Managing AI model prices is owner-only.
seeded, err := exp.ListAIModelPrices(ctx, codersdk.AIModelPricesFilter{
Provider: "anthropic", Model: "claude-opus-5",
})
require.NoError(t, err)
require.Len(t, seeded, 1)
require.NotEqual(t, int64(100), *seeded[0].InputPrice)
// When: it is priced through the endpoint.
require.NoError(t, exp.UpsertAIModelPrices(ctx, codersdk.UpsertAIModelPricesRequest{
Prices: []codersdk.AIModelPriceUpsert{tt.price},
}))
// Then: the request is the price in effect, and nothing carries
// over from the book.
prices, err := exp.ListAIModelPrices(ctx, codersdk.AIModelPricesFilter{
Provider: "anthropic", Model: "claude-opus-5",
})
require.NoError(t, err)
require.Len(t, prices, 1)
require.Equal(t, tt.want.InputPrice, prices[0].InputPrice)
require.Equal(t, tt.want.OutputPrice, prices[0].OutputPrice)
require.Equal(t, tt.want.CacheReadPrice, prices[0].CacheReadPrice)
require.Equal(t, tt.want.CacheWritePrice, prices[0].CacheWritePrice)
})
}
})
t.Run("StoresAModelNameWithASeparator", func(t *testing.T) {
t.Parallel()
// Given: an openrouter model whose ID carries a "/".
ownerClient, _ := setupAIModelPricesTest(t)
exp := codersdk.NewExperimentalClient(ownerClient)
ctx := testutil.Context(t, testutil.WaitLong)
// When: it is priced.
//nolint:gocritic // Managing AI model prices is owner-only.
require.NoError(t, exp.UpsertAIModelPrices(ctx, codersdk.UpsertAIModelPricesRequest{
Prices: []codersdk.AIModelPriceUpsert{
newAIModelPrice("openrouter", "anthropic/my-model", 100),
},
}))
// Then: the row is stored under the full model name, and filtering on
// it round-trips through the query parameter.
prices, err := exp.ListAIModelPrices(ctx, codersdk.AIModelPricesFilter{
Provider: "openrouter", Model: "anthropic/my-model",
})
require.NoError(t, err)
require.Len(t, prices, 1)
require.Equal(t, "openrouter", prices[0].Provider)
require.Equal(t, "anthropic/my-model", prices[0].Model)
require.Equal(t, int64(100), *prices[0].InputPrice)
})
}
func TestListAIModelPrices(t *testing.T) {
t.Parallel()
t.Run("ReturnsPriceBook", func(t *testing.T) {
t.Parallel()
// Given: a deployment seeded with the embedded price book at startup.
ownerClient, _ := setupAIModelPricesTest(t)
exp := codersdk.NewExperimentalClient(ownerClient)
ctx := testutil.Context(t, testutil.WaitLong)
// When: the prices are listed.
//nolint:gocritic // Reading AI model prices is owner-only.
prices, err := exp.ListAIModelPrices(ctx, codersdk.AIModelPricesFilter{})
require.NoError(t, err)
require.NotEmpty(t, prices, "the embedded price book is seeded at startup")
// Then: a model the book covers comes back with all four prices.
seeded, err := exp.ListAIModelPrices(ctx, codersdk.AIModelPricesFilter{
Provider: "anthropic", Model: "claude-opus-5",
})
require.NoError(t, err)
require.Len(t, seeded, 1)
require.Equal(t, int64(5_000_000), *seeded[0].InputPrice)
require.Equal(t, int64(25_000_000), *seeded[0].OutputPrice)
require.Equal(t, int64(500_000), *seeded[0].CacheReadPrice)
require.Equal(t, int64(6_250_000), *seeded[0].CacheWritePrice)
})
t.Run("RejectsAnUnknownSource", func(t *testing.T) {
t.Parallel()
// Given: an entitled deployment.
ownerClient, _ := setupAIModelPricesTest(t)
ctx := testutil.Context(t, testutil.WaitLong)
// When: the prices are listed with a source outside the enum.
//nolint:gocritic // Reading AI model prices is owner-only.
_, err := codersdk.NewExperimentalClient(ownerClient).ListAIModelPrices(ctx,
codersdk.AIModelPricesFilter{Source: "seeded"})
// Then: a 400 comes back naming the field.
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Equal(t, "Invalid AI model price source.", sdkErr.Message)
require.Len(t, sdkErr.Validations, 1)
require.Equal(t, "source", sdkErr.Validations[0].Field)
})
t.Run("BySourceAllReportsEveryRow", func(t *testing.T) {
t.Parallel()
// Given: anthropic/claude-opus-5 priced by the seeded book and then
// overridden, so it carries a row under each source.
ownerClient, _ := setupAIModelPricesTest(t)
exp := codersdk.NewExperimentalClient(ownerClient)
ctx := testutil.Context(t, testutil.WaitLong)
filter := codersdk.AIModelPricesFilter{Provider: "anthropic", Model: "claude-opus-5"}
// The book's row is captured rather than hardcoded, so the assertion
// survives a price book update.
//nolint:gocritic // Managing AI model prices is owner-only.
seeded, err := exp.ListAIModelPrices(ctx, filter)
require.NoError(t, err)
require.Len(t, seeded, 1)
require.NoError(t, exp.UpsertAIModelPrices(ctx, codersdk.UpsertAIModelPricesRequest{
Prices: []codersdk.AIModelPriceUpsert{
newAIModelPrice("anthropic", "claude-opus-5", 100),
},
}))
// When: the model is listed under every source.
filter.Source = codersdk.AIModelPriceSourceFilterAll
prices, err := exp.ListAIModelPrices(ctx, filter)
require.NoError(t, err)
// Then: both rows come back, the custom price ahead of the book's. The
// request set an input price only, so the other three are null.
require.Len(t, prices, 2)
require.Equal(t, codersdk.AIModelPriceSourceCustom, prices[0].Source)
require.Equal(t, int64(100), *prices[0].InputPrice)
require.Nil(t, prices[0].OutputPrice)
require.Nil(t, prices[0].CacheReadPrice)
require.Nil(t, prices[0].CacheWritePrice)
require.Equal(t, seeded[0], prices[1])
})
t.Run("Filters", func(t *testing.T) {
t.Parallel()
// Given: two anthropic models, an openai model sharing a name with one
// of them, and claude-opus-5, which the price book also carries. The
// endpoint records every price it writes as custom, so only
// claude-opus-5 holds a default price too.
ownerClient, _ := setupAIModelPricesTest(t)
exp := codersdk.NewExperimentalClient(ownerClient)
setupCtx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // Managing AI model prices is owner-only.
require.NoError(t, exp.UpsertAIModelPrices(setupCtx, codersdk.UpsertAIModelPricesRequest{
Prices: []codersdk.AIModelPriceUpsert{
newAIModelPrice("anthropic", "model-a", 1),
newAIModelPrice("anthropic", "model-b", 2),
{Provider: "openai", Model: "model-a", InputPrice: ptr.Ref(int64(3))},
newAIModelPrice("anthropic", "claude-opus-5", 4),
},
}))
tests := []struct {
name string
filter codersdk.AIModelPricesFilter
want []string
}{
{
name: "NoFilter",
filter: codersdk.AIModelPricesFilter{},
want: []string{"anthropic/model-a", "anthropic/model-b", "openai/model-a"},
},
{
name: "ByProvider",
filter: codersdk.AIModelPricesFilter{Provider: "anthropic"},
want: []string{"anthropic/model-a", "anthropic/model-b"},
},
{
name: "ByModelSpansProviders",
filter: codersdk.AIModelPricesFilter{Model: "model-a"},
want: []string{"anthropic/model-a", "openai/model-a"},
},
{
name: "ByProviderAndModel",
filter: codersdk.AIModelPricesFilter{Provider: "anthropic", Model: "model-a"},
want: []string{"anthropic/model-a"},
},
{
name: "UnknownProviderMatchesNothing",
filter: codersdk.AIModelPricesFilter{Provider: "unknown-provider"},
want: nil,
},
{
// The override is skipped, leaving the price book's row.
name: "BySourceDefault",
filter: codersdk.AIModelPricesFilter{
Model: "claude-opus-5",
Source: codersdk.AIModelPriceSourceFilterDefault,
},
want: []string{"anthropic/claude-opus-5"},
},
{
// The price book does not carry model-a, so filtering to it
// leaves nothing.
name: "BySourceDefaultMatchesNothing",
filter: codersdk.AIModelPricesFilter{
Model: "model-a",
Source: codersdk.AIModelPriceSourceFilterDefault,
},
want: nil,
},
{
// These are the only prices set through the endpoint. The rest
// of the table is the seeded price book.
name: "BySourceCustom",
filter: codersdk.AIModelPricesFilter{Source: codersdk.AIModelPriceSourceFilterCustom},
want: []string{
"anthropic/claude-opus-5", "anthropic/model-a",
"anthropic/model-b", "openai/model-a",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// When: the prices are listed with the filter.
prices, err := exp.ListAIModelPrices(ctx, tt.filter)
require.NoError(t, err)
// Then: the expected models come back, and nothing outside the
// filter does.
got := make([]string, 0, len(prices))
for _, price := range prices {
got = append(got, price.Provider+"/"+price.Model)
if tt.filter.Provider != "" {
require.Equal(t, tt.filter.Provider, price.Provider)
}
if tt.filter.Model != "" {
require.Equal(t, tt.filter.Model, price.Model)
}
}
if len(tt.want) == 0 {
require.Empty(t, got)
return
}
require.Subset(t, got, tt.want)
})
}
})
t.Run("Forbidden", func(t *testing.T) {
t.Parallel()
// Given: a member without ai_model_price:read.
ownerClient, owner := setupAIModelPricesTest(t)
memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleMember())
ctx := testutil.Context(t, testutil.WaitLong)
// When: they list the prices.
_, err := codersdk.NewExperimentalClient(memberClient).ListAIModelPrices(ctx, codersdk.AIModelPricesFilter{})
// Then: the request is forbidden by ai_model_price:read.
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
})
}