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
+30
View File
@@ -6,6 +6,7 @@ import (
"context"
_ "embed"
"encoding/json"
"sync"
"golang.org/x/xerrors"
@@ -60,3 +61,32 @@ func parseSeed(data []byte) ([]seedRow, error) {
}
return rows, nil
}
// modelKey identifies a priced model.
type modelKey struct {
provider string
model string
}
// defaultPricedModels indexes the embedded price book by provider and model.
// Built on first use, since a deployment that never sets a price never needs
// it.
var defaultPricedModels = sync.OnceValue(func() map[modelKey]struct{} {
rows, err := parseSeed(seedJSON)
if err != nil {
panic(xerrors.Errorf("parse embedded price seed: %w", err))
}
index := make(map[modelKey]struct{}, len(rows))
for _, row := range rows {
index[modelKey{provider: row.Provider, model: row.Model}] = struct{}{}
}
return index
})
// IsDefaultPriced reports whether the embedded price book already carries a
// price for the model. Coder owns those prices and re-applies them on every
// startup, so an operator price set for one would not survive a restart.
func IsDefaultPriced(provider, model string) bool {
_, ok := defaultPricedModels()[modelKey{provider: provider, model: model}]
return ok
}
+53
View File
@@ -289,3 +289,56 @@ func TestSeed(t *testing.T) {
db, _ := dbtestutil.NewDB(t)
require.NoError(t, prices.Seed(ctx, db))
}
// TestIsDefaultPriced reads the real embedded price book, so it uses a model
// the generator injects rather than one that could drift out of upstream.
func TestIsDefaultPriced(t *testing.T) {
t.Parallel()
tests := []struct {
name string
provider string
model string
want bool
}{
{
name: "ModelInThePriceBook",
provider: "anthropic",
model: "claude-opus-5",
want: true,
},
{
name: "ModelNotInThePriceBook",
provider: "anthropic",
model: "not-a-real-model",
want: false,
},
{
// The book is keyed on both columns, so the same model under
// another provider is a different entry.
name: "SameModelUnderAnotherProvider",
provider: "openai",
model: "claude-opus-5",
want: false,
},
{
name: "UnknownProvider",
provider: "unknown-provider",
model: "claude-opus-5",
want: false,
},
{
name: "Empty",
provider: "",
model: "",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, prices.IsDefaultPriced(tt.provider, tt.model))
})
}
}
@@ -0,0 +1,28 @@
package providers
import "github.com/coder/coder/v2/coderd/database"
// Supported lists the providers a model price may be set for.
//
// openai-compat is excluded: it is a generic passthrough, so the upstream
// vendor is unknown and a price cannot be attributed to it. Listed explicitly
// rather than derived from ai_provider_type so a new provider is opt-in.
var Supported = []database.AIProviderType{
database.AIProviderTypeAnthropic,
database.AIProviderTypeAzure,
database.AIProviderTypeBedrock,
database.AIProviderTypeCopilot,
database.AIProviderTypeGoogle,
database.AIProviderTypeOpenai,
database.AIProviderTypeOpenrouter,
database.AIProviderTypeVercel,
}
// SupportedStrings returns the supported providers as plain strings.
func SupportedStrings() []string {
ids := make([]string, len(Supported))
for i, provider := range Supported {
ids[i] = string(provider)
}
return ids
}
+144
View File
@@ -64,6 +64,85 @@ const docTemplate = `{
}
}
},
"/api/experimental/ai/model-prices": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "List AI model prices",
"operationId": "list-ai-model-prices",
"parameters": [
{
"type": "string",
"description": "Only return prices for this provider",
"name": "provider",
"in": "query"
},
{
"type": "string",
"description": "Only return prices for this model",
"name": "model",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIModelPrice"
}
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
},
"post": {
"consumes": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Upsert AI model prices",
"operationId": "upsert-ai-model-prices",
"parameters": [
{
"description": "Prices to set",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpsertAIModelPricesRequest"
}
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/api/experimental/chats": {
"get": {
"description": "Experimental: this endpoint is subject to change.",
@@ -15939,6 +16018,60 @@ const docTemplate = `{
}
}
},
"codersdk.AIModelPrice": {
"type": "object",
"properties": {
"cache_read_price": {
"type": "integer"
},
"cache_write_price": {
"type": "integer"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"input_price": {
"type": "integer"
},
"model": {
"type": "string"
},
"output_price": {
"type": "integer"
},
"provider": {
"type": "string"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"codersdk.AIModelPriceUpsert": {
"type": "object",
"properties": {
"cache_read_price": {
"type": "integer"
},
"cache_write_price": {
"type": "integer"
},
"input_price": {
"type": "integer"
},
"model": {
"type": "string"
},
"output_price": {
"type": "integer"
},
"provider": {
"type": "string"
}
}
},
"codersdk.AIProvider": {
"type": "object",
"properties": {
@@ -26475,6 +26608,17 @@ const docTemplate = `{
}
}
},
"codersdk.UpsertAIModelPricesRequest": {
"type": "object",
"properties": {
"prices": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIModelPriceUpsert"
}
}
}
},
"codersdk.UpsertGroupAIBudgetRequest": {
"type": "object",
"properties": {
+136
View File
@@ -49,6 +49,77 @@
}
}
},
"/api/experimental/ai/model-prices": {
"get": {
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "List AI model prices",
"operationId": "list-ai-model-prices",
"parameters": [
{
"type": "string",
"description": "Only return prices for this provider",
"name": "provider",
"in": "query"
},
{
"type": "string",
"description": "Only return prices for this model",
"name": "model",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIModelPrice"
}
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
},
"post": {
"consumes": ["application/json"],
"tags": ["Enterprise"],
"summary": "Upsert AI model prices",
"operationId": "upsert-ai-model-prices",
"parameters": [
{
"description": "Prices to set",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpsertAIModelPricesRequest"
}
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/api/experimental/chats": {
"get": {
"description": "Experimental: this endpoint is subject to change.",
@@ -14226,6 +14297,60 @@
}
}
},
"codersdk.AIModelPrice": {
"type": "object",
"properties": {
"cache_read_price": {
"type": "integer"
},
"cache_write_price": {
"type": "integer"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"input_price": {
"type": "integer"
},
"model": {
"type": "string"
},
"output_price": {
"type": "integer"
},
"provider": {
"type": "string"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"codersdk.AIModelPriceUpsert": {
"type": "object",
"properties": {
"cache_read_price": {
"type": "integer"
},
"cache_write_price": {
"type": "integer"
},
"input_price": {
"type": "integer"
},
"model": {
"type": "string"
},
"output_price": {
"type": "integer"
},
"provider": {
"type": "string"
}
}
},
"codersdk.AIProvider": {
"type": "object",
"properties": {
@@ -24351,6 +24476,17 @@
}
}
},
"codersdk.UpsertAIModelPricesRequest": {
"type": "object",
"properties": {
"prices": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIModelPriceUpsert"
}
}
}
},
"codersdk.UpsertGroupAIBudgetRequest": {
"type": "object",
"properties": {
+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