feat: add experimental CLI to price unpriced AI models (#27926)

## Description

AI Gateway computes the cost of an interception from `ai_model_prices`,
which is seeded on every server start from a price book embedded in the
binary. A model the price book does not cover records a NULL cost, so
its spend is invisible to cost reporting and is not enforced against
budgets. The only fix was to wait for a Coder release that added the
model.

This adds an experimental CLI, backed by an experimental HTTP endpoint,
for pricing those models. Models the price book already covers are
rejected, because the seeder re-applies the book on every start and
would overwrite an operator price. Support for custom pricing will be
handled in
https://linear.app/codercom/issue/AIGOV-589/extend-experimental-cli-command-to-set-custom-ai-model-prices.

## Commands

```
coder exp ai-model-prices list [--provider] [--model]
coder exp ai-model-prices update [file|-] [--provider] [--model] [--input-price] [--output-price] [--cache-read-price] [--cache-write-price] [--yes]
```

## Changes

- Add `GET` and `POST /api/experimental/ai/model-prices`, gated behind
the AI Bridge entitlement and the existing `ai_model_price` RBAC
resource.
- Add a `GetAIModelPrices` query with optional `provider` and `model`
filters applied in SQL.
- Validate the whole request before writing anything, so one bad entry
cannot leave the table half updated, and report every problem at once.
- Reject prices for models the embedded price book already covers,
through a new `prices.IsDefaultPriced`.
- Add the `coder exp ai-model-prices` command with `list` and `update`.
`update` accepts a JSON document or the single-model flags and prints a
plan, asking to confirm unless the document is piped in or `--yes` is
passed.
- Consolidate the supported provider list into
`coderd/aibridge/prices/providers` so the price generator and the server
share one definition.
- Add `codersdk` types and client methods for both endpoints, and bound
the request body at 1 MiB.
- Document the command in the AI Gateway cost controls page.

Closes
https://linear.app/codercom/issue/AIGOV-567/experimental-cli-command-to-set-prices-for-unpriced-ai-models

> [!NOTE]
> Initially generated by Claude Opus 5, modified and reviewed by
@ssncferreira
This commit is contained in:
Susana Ferreira
2026-08-13 15:00:36 +01:00
committed by GitHub
parent abe9c79605
commit 2d9b6eda8f
27 changed files with 2828 additions and 20 deletions
+21
View File
@@ -1725,6 +1725,27 @@ func chatMessageParts(m database.ChatMessage) ([]codersdk.ChatMessagePart, error
return filtered, nil
}
func AIModelPrices(dbPrices []database.AIModelPrice) []codersdk.AIModelPrice {
out := make([]codersdk.AIModelPrice, 0, len(dbPrices))
for _, dbPrice := range dbPrices {
out = append(out, AIModelPrice(dbPrice))
}
return out
}
func AIModelPrice(dbPrice database.AIModelPrice) codersdk.AIModelPrice {
return codersdk.AIModelPrice{
Provider: dbPrice.Provider,
Model: dbPrice.Model,
InputPrice: nullInt64Ptr(dbPrice.InputPrice),
OutputPrice: nullInt64Ptr(dbPrice.OutputPrice),
CacheReadPrice: nullInt64Ptr(dbPrice.CacheReadPrice),
CacheWritePrice: nullInt64Ptr(dbPrice.CacheWritePrice),
CreatedAt: dbPrice.CreatedAt,
UpdatedAt: dbPrice.UpdatedAt,
}
}
func nullUUIDPtr(v uuid.NullUUID) *uuid.UUID {
if !v.Valid {
return nil
+7
View File
@@ -2841,6 +2841,13 @@ func (q *querier) GetAIModelPriceByProviderModel(ctx context.Context, arg databa
return q.db.GetAIModelPriceByProviderModel(ctx, arg)
}
func (q *querier) GetAIModelPrices(ctx context.Context, arg database.GetAIModelPricesParams) ([]database.AIModelPrice, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAiModelPrice); err != nil {
return nil, err
}
return q.db.GetAIModelPrices(ctx, arg)
}
func (q *querier) GetAIProviderByID(ctx context.Context, id uuid.UUID) (database.AIProvider, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAIProvider); err != nil {
return database.AIProvider{}, err
+5
View File
@@ -6849,6 +6849,11 @@ func (s *MethodTestSuite) TestAIBridge() {
check.Args(database.GetAIModelPriceByProviderModelParams{}).Asserts(rbac.ResourceAiModelPrice, policy.ActionRead)
}))
s.Run("GetAIModelPrices", s.Mocked(func(db *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
db.EXPECT().GetAIModelPrices(gomock.Any(), gomock.Any()).Return([]database.AIModelPrice{}, nil).AnyTimes()
check.Args(database.GetAIModelPricesParams{}).Asserts(rbac.ResourceAiModelPrice, policy.ActionRead)
}))
s.Run("GetOrganizationGroupsAISpend", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
org := testutil.Fake(s.T(), faker, database.Organization{})
row1 := testutil.Fake(s.T(), faker, database.GetOrganizationGroupsAISpendRow{OrganizationID: org.ID})
+8
View File
@@ -1129,6 +1129,14 @@ func (m queryMetricsStore) GetAIModelPriceByProviderModel(ctx context.Context, a
return r0, r1
}
func (m queryMetricsStore) GetAIModelPrices(ctx context.Context, arg database.GetAIModelPricesParams) ([]database.AIModelPrice, error) {
start := time.Now()
r0, r1 := m.s.GetAIModelPrices(ctx, arg)
m.queryLatencies.WithLabelValues("GetAIModelPrices").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAIModelPrices").Inc()
return r0, r1
}
func (m queryMetricsStore) GetAIProviderByID(ctx context.Context, id uuid.UUID) (database.AIProvider, error) {
start := time.Now()
r0, r1 := m.s.GetAIProviderByID(ctx, id)
+15
View File
@@ -1965,6 +1965,21 @@ func (mr *MockStoreMockRecorder) GetAIModelPriceByProviderModel(ctx, arg any) *g
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIModelPriceByProviderModel", reflect.TypeOf((*MockStore)(nil).GetAIModelPriceByProviderModel), ctx, arg)
}
// GetAIModelPrices mocks base method.
func (m *MockStore) GetAIModelPrices(ctx context.Context, arg database.GetAIModelPricesParams) ([]database.AIModelPrice, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAIModelPrices", ctx, arg)
ret0, _ := ret[0].([]database.AIModelPrice)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetAIModelPrices indicates an expected call of GetAIModelPrices.
func (mr *MockStoreMockRecorder) GetAIModelPrices(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIModelPrices", reflect.TypeOf((*MockStore)(nil).GetAIModelPrices), ctx, arg)
}
// GetAIProviderByID mocks base method.
func (m *MockStore) GetAIProviderByID(ctx context.Context, id uuid.UUID) (database.AIProvider, error) {
m.ctrl.T.Helper()
+1
View File
@@ -318,6 +318,7 @@ type sqlcQuerier interface {
// so a returned row is itself proof the secret is valid.
GetAIGatewayKeyByHashedSecret(ctx context.Context, hashedSecret []byte) (AIGatewayKey, error)
GetAIModelPriceByProviderModel(ctx context.Context, arg GetAIModelPriceByProviderModelParams) (AIModelPrice, error)
GetAIModelPrices(ctx context.Context, arg GetAIModelPricesParams) ([]AIModelPrice, error)
GetAIProviderByID(ctx context.Context, id uuid.UUID) (AIProvider, error)
// Lock the provider row until the model-config write completes. The
// transaction alone does not stop a concurrent soft-delete or disable
+71
View File
@@ -18881,3 +18881,74 @@ func TestGetActiveUsersAuthorizationRolesParity(t *testing.T) {
require.ElementsMatch(t, single.Groups, row.Groups, "groups diverged for user %s", row.ID)
}
}
func TestGetAIModelPrices(t *testing.T) {
t.Parallel()
// Two anthropic models, and an openai model sharing a name with one of
// them, so provider and model can be told apart.
const seed = `[
{"provider":"anthropic","model":"model-a","input_price":1,"output_price":null,"cache_read_price":null,"cache_write_price":null},
{"provider":"anthropic","model":"model-b","input_price":2,"output_price":null,"cache_read_price":null,"cache_write_price":null},
{"provider":"openai","model":"model-a","input_price":3,"output_price":null,"cache_read_price":null,"cache_write_price":null}
]`
tests := []struct {
name string
params database.GetAIModelPricesParams
want []string
}{
{
name: "NoFilterReturnsEveryPrice",
params: database.GetAIModelPricesParams{},
want: []string{"anthropic/model-a", "anthropic/model-b", "openai/model-a"},
},
{
name: "ByProvider",
params: database.GetAIModelPricesParams{Provider: "anthropic"},
want: []string{"anthropic/model-a", "anthropic/model-b"},
},
{
name: "ByModelSpansProviders",
params: database.GetAIModelPricesParams{Model: "model-a"},
want: []string{"anthropic/model-a", "openai/model-a"},
},
{
name: "ByProviderAndModel",
params: database.GetAIModelPricesParams{Provider: "anthropic", Model: "model-a"},
want: []string{"anthropic/model-a"},
},
{
name: "UnknownProviderMatchesNothing",
params: database.GetAIModelPricesParams{Provider: "unknown-provider"},
want: nil,
},
{
name: "MismatchedProviderAndModel",
params: database.GetAIModelPricesParams{Provider: "openai", Model: "model-b"},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, _ := dbtestutil.NewDB(t)
require.NoError(t, db.UpsertAIModelPrices(ctx, []byte(seed)))
prices, err := db.GetAIModelPrices(ctx, tt.params)
require.NoError(t, err)
got := make([]string, 0, len(prices))
for _, price := range prices {
got = append(got, price.Provider+"/"+price.Model)
}
if len(tt.want) == 0 {
require.Empty(t, got)
return
}
require.Equal(t, tt.want, got)
})
}
}
+55
View File
@@ -2888,6 +2888,61 @@ func (q *sqlQuerier) GetAIModelPriceByProviderModel(ctx context.Context, arg Get
return i, err
}
const getAIModelPrices = `-- name: GetAIModelPrices :many
SELECT provider, model, input_price, output_price, cache_read_price, cache_write_price, created_at, updated_at
FROM ai_model_prices
-- Filter by provider
WHERE CASE
WHEN $1::text != '' THEN
provider = $1
ELSE true
END
-- Filter by model
AND CASE
WHEN $2::text != '' THEN
model = $2
ELSE true
END
ORDER BY provider, model
`
type GetAIModelPricesParams struct {
Provider string `db:"provider" json:"provider"`
Model string `db:"model" json:"model"`
}
func (q *sqlQuerier) GetAIModelPrices(ctx context.Context, arg GetAIModelPricesParams) ([]AIModelPrice, error) {
rows, err := q.db.QueryContext(ctx, getAIModelPrices, arg.Provider, arg.Model)
if err != nil {
return nil, err
}
defer rows.Close()
var items []AIModelPrice
for rows.Next() {
var i AIModelPrice
if err := rows.Scan(
&i.Provider,
&i.Model,
&i.InputPrice,
&i.OutputPrice,
&i.CacheReadPrice,
&i.CacheWritePrice,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getGroupAIBudget = `-- name: GetGroupAIBudget :one
SELECT group_id, spend_limit_micros, created_at, updated_at
FROM group_ai_budgets
+17
View File
@@ -39,6 +39,23 @@ SELECT *
FROM ai_model_prices
WHERE provider = @provider AND model = @model;
-- name: GetAIModelPrices :many
SELECT *
FROM ai_model_prices
-- Filter by provider
WHERE CASE
WHEN @provider::text != '' THEN
provider = @provider
ELSE true
END
-- Filter by model
AND CASE
WHEN @model::text != '' THEN
model = @model
ELSE true
END
ORDER BY provider, model;
-- name: GetGroupAIBudget :one
SELECT *
FROM group_ai_budgets