diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 03bbcbe1fe..2a610074c2 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -86,6 +86,17 @@ const docTemplate = `{ "description": "Only return prices for this model", "name": "model", "in": "query" + }, + { + "enum": [ + "default", + "custom", + "all" + ], + "type": "string", + "description": "Only return prices from this source, or all to return every price a model holds", + "name": "source", + "in": "query" } ], "responses": { @@ -16191,12 +16202,26 @@ const docTemplate = `{ "provider": { "type": "string" }, + "source": { + "$ref": "#/definitions/codersdk.AIModelPriceSource" + }, "updated_at": { "type": "string", "format": "date-time" } } }, + "codersdk.AIModelPriceSource": { + "type": "string", + "enum": [ + "default", + "custom" + ], + "x-enum-varnames": [ + "AIModelPriceSourceDefault", + "AIModelPriceSourceCustom" + ] + }, "codersdk.AIModelPriceUpsert": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index b8bec92d82..c213ab2b9c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -67,6 +67,13 @@ "description": "Only return prices for this model", "name": "model", "in": "query" + }, + { + "enum": ["default", "custom", "all"], + "type": "string", + "description": "Only return prices from this source, or all to return every price a model holds", + "name": "source", + "in": "query" } ], "responses": { @@ -14466,12 +14473,23 @@ "provider": { "type": "string" }, + "source": { + "$ref": "#/definitions/codersdk.AIModelPriceSource" + }, "updated_at": { "type": "string", "format": "date-time" } } }, + "codersdk.AIModelPriceSource": { + "type": "string", + "enum": ["default", "custom"], + "x-enum-varnames": [ + "AIModelPriceSourceDefault", + "AIModelPriceSourceCustom" + ] + }, "codersdk.AIModelPriceUpsert": { "type": "object", "properties": { diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 2dbcc4248c..4713bbfeee 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1741,6 +1741,7 @@ func AIModelPrice(dbPrice database.AIModelPrice) codersdk.AIModelPrice { OutputPrice: nullInt64Ptr(dbPrice.OutputPrice), CacheReadPrice: nullInt64Ptr(dbPrice.CacheReadPrice), CacheWritePrice: nullInt64Ptr(dbPrice.CacheWritePrice), + Source: codersdk.AIModelPriceSource(dbPrice.Source), CreatedAt: dbPrice.CreatedAt, UpdatedAt: dbPrice.UpdatedAt, } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 14c20ef75f..a17b3bc26c 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -327,7 +327,11 @@ type sqlcQuerier interface { // the price book. GetAIModelPriceByProviderModel(ctx context.Context, arg GetAIModelPriceByProviderModelParams) (AIModelPrice, error) // Returns the price in effect for each model, preferring a custom price over - // the price book. + // the price book. Filtering by source narrows the rows considered first, so a + // model carrying both prices reports the one from the named source. + // The source 'all' reports every row instead. It joins the DISTINCT ON key, so + // each source forms its own group and nothing collapses. Every other source + // contributes the same constant, leaving the key as (provider, model). 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 diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 10b2992379..f39374f581 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -19198,6 +19198,43 @@ func TestGetAIModelPrices(t *testing.T) { want: []string{"anthropic/model-a", "anthropic/model-b", "openai/model-a"}, wantPrices: []int64{9, 2, 3}, }, + { + // anthropic/model-a reports the price book's row, which the + // unfiltered listing hides. + name: "BySourceDefault", + customSeed: customSeed, + params: database.GetAIModelPricesParams{Source: string(database.AIModelPriceSourceDefault)}, + want: []string{"anthropic/model-a", "anthropic/model-b", "openai/model-a"}, + wantPrices: []int64{1, 2, 3}, + }, + { + name: "BySourceCustom", + customSeed: customSeed, + params: database.GetAIModelPricesParams{Source: string(database.AIModelPriceSourceCustom)}, + want: []string{"anthropic/model-a"}, + wantPrices: []int64{9}, + }, + { + // anthropic/model-a reports twice, custom ahead of the price book. + name: "BySourceAll", + customSeed: customSeed, + params: database.GetAIModelPricesParams{ + Provider: "anthropic", + Model: "model-a", + Source: string(codersdk.AIModelPriceSourceFilterAll), + }, + want: []string{"anthropic/model-a", "anthropic/model-a"}, + wantPrices: []int64{9, 1}, + }, + { + name: "BySourceAndProvider", + customSeed: customSeed, + params: database.GetAIModelPricesParams{ + Provider: "openai", + Source: string(database.AIModelPriceSourceCustom), + }, + want: nil, + }, } for _, tt := range tests { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 59a65bea3c..722b9dfe0a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2894,32 +2894,51 @@ func (q *sqlQuerier) GetAIModelPriceByProviderModel(ctx context.Context, arg Get } const getAIModelPrices = `-- name: GetAIModelPrices :many -SELECT DISTINCT ON (provider, model) provider, model, input_price, output_price, cache_read_price, cache_write_price, created_at, updated_at, source +SELECT DISTINCT ON ( + provider, + model, + CASE WHEN $1::text = 'all' THEN source::text ELSE '' END +) provider, model, input_price, output_price, cache_read_price, cache_write_price, created_at, updated_at, source FROM ai_model_prices -- Filter by provider WHERE CASE - WHEN $1::text != '' THEN - provider = $1 + WHEN $2::text != '' THEN + provider = $2 ELSE true END -- Filter by model AND CASE - WHEN $2::text != '' THEN - model = $2 + WHEN $3::text != '' THEN + model = $3 ELSE true END -ORDER BY provider ASC, model ASC, CASE WHEN source = 'custom' THEN 0 ELSE 1 END ASC + -- Filter by source + AND CASE + WHEN $1::text NOT IN ('', 'all') THEN + source = $1::ai_model_price_source + ELSE true + END +ORDER BY + provider ASC, + model ASC, + CASE WHEN $1::text = 'all' THEN source::text ELSE '' END ASC, + CASE WHEN source = 'custom' THEN 0 ELSE 1 END ASC ` type GetAIModelPricesParams struct { + Source string `db:"source" json:"source"` Provider string `db:"provider" json:"provider"` Model string `db:"model" json:"model"` } // Returns the price in effect for each model, preferring a custom price over -// the price book. +// the price book. Filtering by source narrows the rows considered first, so a +// model carrying both prices reports the one from the named source. +// The source 'all' reports every row instead. It joins the DISTINCT ON key, so +// each source forms its own group and nothing collapses. Every other source +// contributes the same constant, leaving the key as (provider, model). func (q *sqlQuerier) GetAIModelPrices(ctx context.Context, arg GetAIModelPricesParams) ([]AIModelPrice, error) { - rows, err := q.db.QueryContext(ctx, getAIModelPrices, arg.Provider, arg.Model) + rows, err := q.db.QueryContext(ctx, getAIModelPrices, arg.Source, arg.Provider, arg.Model) if err != nil { return nil, err } diff --git a/coderd/database/queries/aicostcontrol.sql b/coderd/database/queries/aicostcontrol.sql index f0ce38e524..d6b08661ba 100644 --- a/coderd/database/queries/aicostcontrol.sql +++ b/coderd/database/queries/aicostcontrol.sql @@ -47,8 +47,16 @@ LIMIT 1; -- name: GetAIModelPrices :many -- Returns the price in effect for each model, preferring a custom price over --- the price book. -SELECT DISTINCT ON (provider, model) * +-- the price book. Filtering by source narrows the rows considered first, so a +-- model carrying both prices reports the one from the named source. +-- The source 'all' reports every row instead. It joins the DISTINCT ON key, so +-- each source forms its own group and nothing collapses. Every other source +-- contributes the same constant, leaving the key as (provider, model). +SELECT DISTINCT ON ( + provider, + model, + CASE WHEN @source::text = 'all' THEN source::text ELSE '' END +) * FROM ai_model_prices -- Filter by provider WHERE CASE @@ -62,7 +70,17 @@ WHERE CASE model = @model ELSE true END -ORDER BY provider ASC, model ASC, CASE WHEN source = 'custom' THEN 0 ELSE 1 END ASC; + -- Filter by source + AND CASE + WHEN @source::text NOT IN ('', 'all') THEN + source = @source::ai_model_price_source + ELSE true + END +ORDER BY + provider ASC, + model ASC, + CASE WHEN @source::text = 'all' THEN source::text ELSE '' END ASC, + CASE WHEN source = 'custom' THEN 0 ELSE 1 END ASC; -- name: GetGroupAIBudget :one SELECT * diff --git a/codersdk/aimodelprices.go b/codersdk/aimodelprices.go index 00cae36d18..f584bf7a1d 100644 --- a/codersdk/aimodelprices.go +++ b/codersdk/aimodelprices.go @@ -14,16 +14,41 @@ import ( // 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"` + 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"` + Source AIModelPriceSource `json:"source"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` } +// AIModelPriceSource is where a model price came from. +type AIModelPriceSource string + +const ( + // AIModelPriceSourceDefault is a price from the embedded price book. + AIModelPriceSourceDefault AIModelPriceSource = "default" + // AIModelPriceSourceCustom is a price set through the API. + AIModelPriceSourceCustom AIModelPriceSource = "custom" +) + +// AIModelPriceSourceFilter selects which prices a listing reports. It is +// distinct from AIModelPriceSource because no stored price is "all". +// +// @typescript-ignore AIModelPriceSourceFilter +type AIModelPriceSourceFilter string + +const ( + AIModelPriceSourceFilterDefault = AIModelPriceSourceFilter(AIModelPriceSourceDefault) + AIModelPriceSourceFilterCustom = AIModelPriceSourceFilter(AIModelPriceSourceCustom) + // AIModelPriceSourceFilterAll reports every price a model holds, so a model + // carrying both appears twice. + AIModelPriceSourceFilterAll AIModelPriceSourceFilter = "all" +) + // MaxAIModelPricesBytes bounds an upsert request body. const MaxAIModelPricesBytes = 1 << 20 // 1 MiB @@ -51,6 +76,9 @@ type AIModelPriceUpsert struct { type AIModelPricesFilter struct { Provider string `json:"provider,omitempty"` Model string `json:"model,omitempty"` + // Source narrows to prices from one source. A model with both reports only + // its custom price unless this is set. + Source AIModelPriceSourceFilter `json:"source,omitempty"` } func (f AIModelPricesFilter) asRequestOption() RequestOption { @@ -62,6 +90,9 @@ func (f AIModelPricesFilter) asRequestOption() RequestOption { if f.Model != "" { query.Set("model", f.Model) } + if f.Source != "" { + query.Set("source", string(f.Source)) + } r.URL.RawQuery = query.Encode() } } diff --git a/docs/ai-coder/ai-gateway/cost-controls.md b/docs/ai-coder/ai-gateway/cost-controls.md index 6eeddfc1b5..c63c36b92a 100644 --- a/docs/ai-coder/ai-gateway/cost-controls.md +++ b/docs/ai-coder/ai-gateway/cost-controls.md @@ -189,14 +189,28 @@ snapshot that ships with every Coder release, so no configuration is required. Spend accumulates only from the moment v2.36 is deployed. Upgrading mid-month therefore produces a partial first period. -To see which models are priced in the release version you run, consult the -price book for your Coder version: +To see the prices this deployment uses, list them. The `source` column reports +whether each is a default price or a custom price set on this deployment: + +```sh +coder exp ai-model-prices list +``` + +A custom price takes precedence over its default, so it is the one listed. To +see the default for such a model, filter to it: + +```sh +coder exp ai-model-prices list --source default +``` + +Default prices come from the price book that ships with each Coder release. To +see the price book for a release: ```text https://github.com/coder/coder/blob/release//coderd/aibridge/prices/data/prices.json ``` -Replace `` with your Coder minor version, for example `2.36`. +Replace `` with a Coder minor version, for example `2.36`. To use your own price for any of these models, see [Set model prices](#set-model-prices). @@ -205,10 +219,9 @@ To use your own price for any of these models, see > Spend is an approximation. It can differ from what the provider bills, and > some usage does not count toward it at all: > -> - Prices default to the price book's list prices. A custom price brings spend -> closer to the rates a deployment actually pays, though billing rules that -> are not a per-token rate, such as committed-use discounts, cannot be -> represented. +> - Default prices are list prices. A custom price brings spend closer to the +> rates a deployment actually pays, though billing rules that are not a +> per-token rate, such as committed-use discounts, cannot be represented. > - A model with no price adds nothing to spend. Its token usage is still > recorded, but it never counts toward a limit, so a user who calls only > unpriced models is effectively unlimited. Setting a price for the model @@ -229,12 +242,26 @@ Premium license, and the `ai_model_price:update` permission. Run `coder exp ai-model-prices --help` for the full reference. List the prices this deployment holds, optionally narrowed to one provider or -model: +model. The `source` column reports whether a price is a default price +(`default`) or one set on this deployment (`custom`): ```sh coder exp ai-model-prices list --provider anthropic ``` +List only the prices you have set: + +```sh +coder exp ai-model-prices list --source custom +``` + +A listing reports one price per model, so an overridden model's default is not +shown. To see every price a model holds, both at once: + +```sh +coder exp ai-model-prices list --source all +``` + Price a single model. Prices are micro-units per million tokens, so `3000000` is $3.00 per million tokens. Use `null` for a price you do not have, and `0` to declare a model free: @@ -256,8 +283,8 @@ coder exp ai-model-prices update prices.json > > - Prices are not retroactive. Usage recorded before you set a price stays > unpriced, so past spend does not change. -> - A price you set takes precedence over the price book and stays in effect -> across upgrades, so it does not pick up price book updates. +> - A price you set takes precedence over the default and stays in effect +> across upgrades, so it does not pick up later price books. > - This command is experimental and can change without notice. ## Monitor spend diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index cdb49f6e2a..13f7b0a36f 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1149,22 +1149,38 @@ title: Schemas "model": "string", "output_price": 0, "provider": "string", + "source": "default", "updated_at": "2019-08-24T14:15:22Z" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|---------------------|---------|----------|--------------|-------------| -| `cache_read_price` | integer | false | | | -| `cache_write_price` | integer | false | | | -| `created_at` | string | false | | | -| `input_price` | integer | false | | | -| `model` | string | false | | | -| `output_price` | integer | false | | | -| `provider` | string | false | | | -| `updated_at` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|---------------------|------------------------------------------------------------|----------|--------------|-------------| +| `cache_read_price` | integer | false | | | +| `cache_write_price` | integer | false | | | +| `created_at` | string | false | | | +| `input_price` | integer | false | | | +| `model` | string | false | | | +| `output_price` | integer | false | | | +| `provider` | string | false | | | +| `source` | [codersdk.AIModelPriceSource](#codersdkaimodelpricesource) | false | | | +| `updated_at` | string | false | | | + +## codersdk.AIModelPriceSource + +```json +"default" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|---------------------| +| `custom`, `default` | ## codersdk.AIModelPriceUpsert diff --git a/enterprise/cli/exp_aimodelprices.go b/enterprise/cli/exp_aimodelprices.go index 4cc07690c9..e637b6b0f5 100644 --- a/enterprise/cli/exp_aimodelprices.go +++ b/enterprise/cli/exp_aimodelprices.go @@ -69,6 +69,7 @@ type aiModelPriceRow struct { OutputPrice string `json:"-" table:"output price"` CacheReadPrice string `json:"-" table:"cache read price"` CacheWritePrice string `json:"-" table:"cache write price"` + Source string `json:"-" table:"source"` CreatedAt string `json:"-" table:"created at"` UpdatedAt string `json:"-" table:"updated at"` } @@ -77,9 +78,10 @@ func (r *RootCmd) aiModelPricesList() *serpent.Command { var ( provider string model string + source string formatter = cliui.NewOutputFormatter( cliui.TableFormat([]aiModelPriceRow{}, []string{ - "provider", "model", "input price", "output price", "cache read price", "cache write price", + "provider", "model", "input price", "output price", "cache read price", "cache write price", "source", }), cliui.JSONFormat(), ) @@ -88,8 +90,9 @@ func (r *RootCmd) aiModelPricesList() *serpent.Command { cmd := &serpent.Command{ Use: "list", Short: "List AI Governance model prices", - Long: "Lists every model priced for this deployment. Prices are shown in " + - "dollars per million tokens. Narrow the output with --provider or --model.", + Long: "Lists the price in effect for each model on this deployment, in " + + "dollars per million tokens. Narrow the output with --provider, --model " + + "or --source.", Middleware: serpent.Chain(serpent.RequireNArgs(0)), Options: serpent.OptionSet{ { @@ -102,6 +105,17 @@ func (r *RootCmd) aiModelPricesList() *serpent.Command { Description: "Only show this model.", Value: serpent.StringOf(&model), }, + { + Flag: "source", + Description: "Only show prices from this source, or \"all\" to show every " + + "price a model holds. A model carrying both a price book price and a " + + "custom one appears under either source, and twice under \"all\".", + Value: serpent.EnumOf(&source, + string(codersdk.AIModelPriceSourceFilterDefault), + string(codersdk.AIModelPriceSourceFilterCustom), + string(codersdk.AIModelPriceSourceFilterAll), + ), + }, }, Handler: func(inv *serpent.Invocation) error { ctx := inv.Context() @@ -113,6 +127,7 @@ func (r *RootCmd) aiModelPricesList() *serpent.Command { prices, err := codersdk.NewExperimentalClient(client).ListAIModelPrices(ctx, codersdk.AIModelPricesFilter{ Provider: provider, Model: model, + Source: codersdk.AIModelPriceSourceFilter(source), }) if err != nil { return xerrors.Errorf("list model prices: %w", err) @@ -128,6 +143,7 @@ func (r *RootCmd) aiModelPricesList() *serpent.Command { OutputPrice: formatMicros(price.OutputPrice), CacheReadPrice: formatMicros(price.CacheReadPrice), CacheWritePrice: formatMicros(price.CacheWritePrice), + Source: string(price.Source), CreatedAt: humanize.Time(price.CreatedAt), UpdatedAt: humanize.Time(price.UpdatedAt), }) diff --git a/enterprise/cli/exp_aimodelprices_test.go b/enterprise/cli/exp_aimodelprices_test.go index 85491e516d..26394d3632 100644 --- a/enterprise/cli/exp_aimodelprices_test.go +++ b/enterprise/cli/exp_aimodelprices_test.go @@ -407,6 +407,82 @@ func TestAIModelPricesList(t *testing.T) { require.Contains(t, stdout.String(), "$15.00") require.Contains(t, stdout.String(), "$0.0036") require.Contains(t, stdout.String(), "-") + require.Contains(t, stdout.String(), "custom") + }) + + t.Run("FiltersBySource", 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. + client := setupAIModelPricesCLI(t) + ctx := testutil.Context(t, testutil.WaitLong) + + list := func(source string) []codersdk.AIModelPrice { + inv, conf := newCLI(t, "exp", "ai-model-prices", "list", + "--provider", "anthropic", "--model", "claude-opus-5", + "--source", source, "--output", "json") + clitest.SetupConfig(t, client, conf) //nolint:gocritic // requires owner + + var stdout bytes.Buffer + inv.Stdout = &stdout + require.NoError(t, inv.Run()) + + var prices []codersdk.AIModelPrice + require.NoError(t, json.Unmarshal(stdout.Bytes(), &prices)) + return prices + } + + // The book's row is captured rather than hardcoded, so the assertion + // survives a price book update. + seeded := list("default") + require.Len(t, seeded, 1) + + //nolint:gocritic // Managing AI model prices is owner-only. + require.NoError(t, codersdk.NewExperimentalClient(client).UpsertAIModelPrices(ctx, + codersdk.UpsertAIModelPricesRequest{ + Prices: []codersdk.AIModelPriceUpsert{{ + Provider: "anthropic", Model: "claude-opus-5", InputPrice: new(int64(100)), + }}, + })) + + // When: each source is listed. Then: the model reports under either + // filter, at the price that source holds. + def := list("default") + require.Len(t, def, 1) + require.Equal(t, codersdk.AIModelPriceSourceDefault, def[0].Source) + require.Equal(t, seeded[0].InputPrice, def[0].InputPrice) + require.Equal(t, seeded[0].OutputPrice, def[0].OutputPrice) + require.Equal(t, seeded[0].CacheReadPrice, def[0].CacheReadPrice) + require.Equal(t, seeded[0].CacheWritePrice, def[0].CacheWritePrice) + + // The request set an input price only, so the other three are null. + custom := list("custom") + require.Len(t, custom, 1) + require.Equal(t, codersdk.AIModelPriceSourceCustom, custom[0].Source) + require.Equal(t, int64(100), *custom[0].InputPrice) + require.Nil(t, custom[0].OutputPrice) + require.Nil(t, custom[0].CacheReadPrice) + require.Nil(t, custom[0].CacheWritePrice) + + // The "all" source reports both rows at once, custom first. + all := list("all") + require.Len(t, all, 2) + require.Equal(t, custom[0], all[0]) + require.Equal(t, def[0], all[1]) + }) + + t.Run("RejectsAnUnknownSource", func(t *testing.T) { + t.Parallel() + + client := setupAIModelPricesCLI(t) + inv, conf := newCLI(t, "exp", "ai-model-prices", "list", "--source", "seeded") + clitest.SetupConfig(t, client, conf) //nolint:gocritic // requires owner + + // When: an unknown source is passed. Then: the flag rejects it. + err := inv.Run() + require.Error(t, err) + require.Contains(t, err.Error(), "seeded") }) t.Run("SaysWhenNothingMatches", func(t *testing.T) { diff --git a/enterprise/coderd/aimodelprices.go b/enterprise/coderd/aimodelprices.go index d3f98484a4..4a53167508 100644 --- a/enterprise/coderd/aimodelprices.go +++ b/enterprise/coderd/aimodelprices.go @@ -19,6 +19,13 @@ import ( "github.com/coder/coder/v2/codersdk" ) +// aiModelPriceSources lists the accepted source filters. +var aiModelPriceSources = []string{ + string(codersdk.AIModelPriceSourceFilterDefault), + string(codersdk.AIModelPriceSourceFilterCustom), + string(codersdk.AIModelPriceSourceFilterAll), +} + // EXPERIMENTAL: this endpoint is experimental and is subject to change. // // @Summary List AI model prices @@ -28,15 +35,30 @@ import ( // @Tags Enterprise // @Param provider query string false "Only return prices for this provider" // @Param model query string false "Only return prices for this model" +// @Param source query string false "Only return prices from this source, or all to return every price a model holds" Enums(default,custom,all) // @Success 200 {array} codersdk.AIModelPrice // @Router /api/experimental/ai/model-prices [get] // @x-apidocgen {"skip": true} func (api *API) listAIModelPrices(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() + // An absent source leaves the listing unfiltered. + source := r.URL.Query().Get("source") + if source != "" && !slices.Contains(aiModelPriceSources, source) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid AI model price source.", + Validations: []codersdk.ValidationError{{ + Field: "source", + Detail: fmt.Sprintf("Source %q is not supported. Supported sources: %s.", source, strings.Join(aiModelPriceSources, ", ")), + }}, + }) + return + } + dbPrices, err := api.Database.GetAIModelPrices(ctx, database.GetAIModelPricesParams{ Provider: r.URL.Query().Get("provider"), Model: r.URL.Query().Get("model"), + Source: source, }) if dbauthz.IsNotAuthorizedError(err) { httpapi.Forbidden(rw) diff --git a/enterprise/coderd/aimodelprices_test.go b/enterprise/coderd/aimodelprices_test.go index 94fa4561a9..e586047984 100644 --- a/enterprise/coderd/aimodelprices_test.go +++ b/enterprise/coderd/aimodelprices_test.go @@ -392,11 +392,74 @@ func TestListAIModelPrices(t *testing.T) { 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, and an openai model sharing a name with - // one of them. + // 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) @@ -407,6 +470,7 @@ func TestListAIModelPrices(t *testing.T) { 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), }, })) @@ -440,6 +504,35 @@ func TestListAIModelPrices(t *testing.T) { 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 { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index ee34ca7686..08c526b2ba 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -369,10 +369,16 @@ export interface AIModelPrice { readonly output_price: number | null; readonly cache_read_price: number | null; readonly cache_write_price: number | null; + readonly source: AIModelPriceSource; readonly created_at: string; readonly updated_at: string; } +// From codersdk/aimodelprices.go +export type AIModelPriceSource = "custom" | "default"; + +export const AIModelPriceSources: AIModelPriceSource[] = ["custom", "default"]; + // From codersdk/aimodelprices.go /** * AIModelPriceUpsert is one model's prices in an upsert request. It carries diff --git a/site/src/testHelpers/chatModels.ts b/site/src/testHelpers/chatModels.ts index e1ad922911..a4f75438cd 100644 --- a/site/src/testHelpers/chatModels.ts +++ b/site/src/testHelpers/chatModels.ts @@ -49,6 +49,7 @@ export const MockGPT5ModelPrice: AIModelPrice = { output_price: 10000000, cache_read_price: 125000, cache_write_price: null, + source: "default", created_at: MOCK_TIMESTAMP, updated_at: MOCK_TIMESTAMP, };