Files
coder/codersdk/aimodelprices.go
T
Susana Ferreira 2d9b6eda8f 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
2026-08-13 15:00:36 +01:00

97 lines
3.1 KiB
Go

package codersdk
import (
"context"
"net/http"
"time"
)
// AIModelPrice is a per-model token price used by AI Gateway to compute the
// cost of an interception.
//
// Prices are integer micro-units per million tokens, so 1000000 is $1.00 per
// million tokens. A nil price means the price is not known, which the cost
// calculation treats the same as zero. Distinguish that from an explicit 0,
// which declares the model free of charge.
type AIModelPrice struct {
Provider string `json:"provider"`
Model string `json:"model"`
InputPrice *int64 `json:"input_price"`
OutputPrice *int64 `json:"output_price"`
CacheReadPrice *int64 `json:"cache_read_price"`
CacheWritePrice *int64 `json:"cache_write_price"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
}
// MaxAIModelPricesBytes bounds an upsert request body.
const MaxAIModelPricesBytes = 1 << 20 // 1 MiB
// UpsertAIModelPricesRequest sets prices for the listed models. Models absent
// from the request are left untouched.
type UpsertAIModelPricesRequest struct {
Prices []AIModelPriceUpsert `json:"prices"`
}
// AIModelPriceUpsert is one model's prices in an upsert request. It carries
// only the writable fields of AIModelPrice.
type AIModelPriceUpsert struct {
Provider string `json:"provider"`
Model string `json:"model"`
InputPrice *int64 `json:"input_price"`
OutputPrice *int64 `json:"output_price"`
CacheReadPrice *int64 `json:"cache_read_price"`
CacheWritePrice *int64 `json:"cache_write_price"`
}
// AIModelPricesFilter narrows the listed model prices. An empty field does not
// filter on that attribute.
//
// @typescript-ignore AIModelPricesFilter
type AIModelPricesFilter struct {
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
}
func (f AIModelPricesFilter) asRequestOption() RequestOption {
return func(r *http.Request) {
query := r.URL.Query()
if f.Provider != "" {
query.Set("provider", f.Provider)
}
if f.Model != "" {
query.Set("model", f.Model)
}
r.URL.RawQuery = query.Encode()
}
}
// ListAIModelPrices returns the AI model prices matching the filter.
func (c *ExperimentalClient) ListAIModelPrices(ctx context.Context, filter AIModelPricesFilter) ([]AIModelPrice, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/ai/model-prices", nil, filter.asRequestOption())
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, ReadBodyAsError(res)
}
var prices []AIModelPrice
return prices, ReadBodyAsJSON(res, &prices)
}
// UpsertAIModelPrices sets prices for the models in req. The request is
// rejected in full if any model fails validation.
func (c *ExperimentalClient) UpsertAIModelPrices(ctx context.Context, req UpsertAIModelPricesRequest) error {
res, err := c.Request(ctx, http.MethodPost, "/api/experimental/ai/model-prices", req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}