diff --git a/Makefile b/Makefile index bcd766e9c9..ebcb1e84a8 100644 --- a/Makefile +++ b/Makefile @@ -168,6 +168,10 @@ _gen/bin/apikeyscopesgen: $(wildcard scripts/apikeyscopesgen/*.go) $(RBAC_GO_FIL @mkdir -p _gen/bin go build -o $@ ./scripts/apikeyscopesgen +_gen/bin/aibridgepricesgen: $(wildcard scripts/aibridgepricesgen/*.go) | _gen + @mkdir -p _gen/bin + go build -o $@ ./scripts/aibridgepricesgen + _gen/bin/metricsdocgen: $(wildcard scripts/metricsdocgen/*.go) | _gen @mkdir -p _gen/bin go build -o $@ ./scripts/metricsdocgen @@ -989,6 +993,16 @@ gen: gen/db gen/golden-files $(GEN_FILES) gen/db: $(DB_GEN_FILES) .PHONY: gen/db +# Refresh the AI Bridge pricing seed file from models.dev. Kept out of +# `make gen`. Phony so each invocation regenerates. +coderd/aibridge/prices/data/prices.json: _gen/bin/aibridgepricesgen | _gen + @mkdir -p $(dir $@) + $(call atomic_write,_gen/bin/aibridgepricesgen) +.PHONY: coderd/aibridge/prices/data/prices.json + +gen/aibridge-prices: coderd/aibridge/prices/data/prices.json +.PHONY: gen/aibridge-prices + gen/golden-files: \ agent/unit/testdata/.gen-golden \ cli/testdata/.gen-golden \ diff --git a/coderd/aibridge/prices/data/README.md b/coderd/aibridge/prices/data/README.md new file mode 100644 index 0000000000..e5d90b3472 --- /dev/null +++ b/coderd/aibridge/prices/data/README.md @@ -0,0 +1,5 @@ +# AI Bridge price seed + +`prices.json` in this directory is generated by `make gen/aibridge-prices` and +embedded into the Coder binary at build time. Do not edit it manually; the +next regeneration will overwrite any changes. diff --git a/coderd/aibridge/prices/data/prices.json b/coderd/aibridge/prices/data/prices.json new file mode 100644 index 0000000000..4c8b4527e1 --- /dev/null +++ b/coderd/aibridge/prices/data/prices.json @@ -0,0 +1,570 @@ +[ + { + "provider": "anthropic", + "model": "claude-3-5-haiku-20241022", + "input_price": 800000, + "output_price": 4000000, + "cache_read_price": 80000, + "cache_write_price": 1000000 + }, + { + "provider": "anthropic", + "model": "claude-3-5-haiku-latest", + "input_price": 800000, + "output_price": 4000000, + "cache_read_price": 80000, + "cache_write_price": 1000000 + }, + { + "provider": "anthropic", + "model": "claude-3-5-sonnet-20240620", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "anthropic", + "model": "claude-3-5-sonnet-20241022", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "anthropic", + "model": "claude-3-7-sonnet-20250219", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "anthropic", + "model": "claude-3-haiku-20240307", + "input_price": 250000, + "output_price": 1250000, + "cache_read_price": 30000, + "cache_write_price": 300000 + }, + { + "provider": "anthropic", + "model": "claude-3-opus-20240229", + "input_price": 15000000, + "output_price": 75000000, + "cache_read_price": 1500000, + "cache_write_price": 18750000 + }, + { + "provider": "anthropic", + "model": "claude-3-sonnet-20240229", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 300000 + }, + { + "provider": "anthropic", + "model": "claude-haiku-4-5", + "input_price": 1000000, + "output_price": 5000000, + "cache_read_price": 100000, + "cache_write_price": 1250000 + }, + { + "provider": "anthropic", + "model": "claude-haiku-4-5-20251001", + "input_price": 1000000, + "output_price": 5000000, + "cache_read_price": 100000, + "cache_write_price": 1250000 + }, + { + "provider": "anthropic", + "model": "claude-opus-4-0", + "input_price": 15000000, + "output_price": 75000000, + "cache_read_price": 1500000, + "cache_write_price": 18750000 + }, + { + "provider": "anthropic", + "model": "claude-opus-4-1", + "input_price": 15000000, + "output_price": 75000000, + "cache_read_price": 1500000, + "cache_write_price": 18750000 + }, + { + "provider": "anthropic", + "model": "claude-opus-4-1-20250805", + "input_price": 15000000, + "output_price": 75000000, + "cache_read_price": 1500000, + "cache_write_price": 18750000 + }, + { + "provider": "anthropic", + "model": "claude-opus-4-20250514", + "input_price": 15000000, + "output_price": 75000000, + "cache_read_price": 1500000, + "cache_write_price": 18750000 + }, + { + "provider": "anthropic", + "model": "claude-opus-4-5", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "anthropic", + "model": "claude-opus-4-5-20251101", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "anthropic", + "model": "claude-opus-4-6", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "anthropic", + "model": "claude-opus-4-7", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "anthropic", + "model": "claude-sonnet-4-0", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "anthropic", + "model": "claude-sonnet-4-20250514", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "anthropic", + "model": "claude-sonnet-4-5", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "anthropic", + "model": "claude-sonnet-4-5-20250929", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "anthropic", + "model": "claude-sonnet-4-6", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "openai", + "model": "gpt-3.5-turbo", + "input_price": 500000, + "output_price": 1500000, + "cache_read_price": 1250000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-4", + "input_price": 30000000, + "output_price": 60000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-4-turbo", + "input_price": 10000000, + "output_price": 30000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-4.1", + "input_price": 2000000, + "output_price": 8000000, + "cache_read_price": 500000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-4.1-mini", + "input_price": 400000, + "output_price": 1600000, + "cache_read_price": 100000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-4.1-nano", + "input_price": 100000, + "output_price": 400000, + "cache_read_price": 30000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-4o", + "input_price": 2500000, + "output_price": 10000000, + "cache_read_price": 1250000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-4o-2024-05-13", + "input_price": 5000000, + "output_price": 15000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-4o-2024-08-06", + "input_price": 2500000, + "output_price": 10000000, + "cache_read_price": 1250000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-4o-2024-11-20", + "input_price": 2500000, + "output_price": 10000000, + "cache_read_price": 1250000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-4o-mini", + "input_price": 150000, + "output_price": 600000, + "cache_read_price": 80000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 125000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5-chat-latest", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5-codex", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 125000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5-mini", + "input_price": 250000, + "output_price": 2000000, + "cache_read_price": 25000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5-nano", + "input_price": 50000, + "output_price": 400000, + "cache_read_price": 5000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5-pro", + "input_price": 15000000, + "output_price": 120000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.1", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 130000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.1-chat-latest", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 125000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.1-codex", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 125000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.1-codex-max", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 125000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.1-codex-mini", + "input_price": 250000, + "output_price": 2000000, + "cache_read_price": 25000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.2", + "input_price": 1750000, + "output_price": 14000000, + "cache_read_price": 175000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.2-chat-latest", + "input_price": 1750000, + "output_price": 14000000, + "cache_read_price": 175000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.2-codex", + "input_price": 1750000, + "output_price": 14000000, + "cache_read_price": 175000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.2-pro", + "input_price": 21000000, + "output_price": 168000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.3-chat-latest", + "input_price": 1750000, + "output_price": 14000000, + "cache_read_price": 175000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.3-codex", + "input_price": 1750000, + "output_price": 14000000, + "cache_read_price": 175000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.3-codex-spark", + "input_price": 1750000, + "output_price": 14000000, + "cache_read_price": 175000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.4", + "input_price": 2500000, + "output_price": 15000000, + "cache_read_price": 250000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.4-mini", + "input_price": 750000, + "output_price": 4500000, + "cache_read_price": 75000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.4-nano", + "input_price": 200000, + "output_price": 1250000, + "cache_read_price": 20000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.4-pro", + "input_price": 30000000, + "output_price": 180000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.5", + "input_price": 5000000, + "output_price": 30000000, + "cache_read_price": 500000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.5-pro", + "input_price": 30000000, + "output_price": 180000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "o1", + "input_price": 15000000, + "output_price": 60000000, + "cache_read_price": 7500000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "o1-mini", + "input_price": 1100000, + "output_price": 4400000, + "cache_read_price": 550000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "o1-preview", + "input_price": 15000000, + "output_price": 60000000, + "cache_read_price": 7500000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "o1-pro", + "input_price": 150000000, + "output_price": 600000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "o3", + "input_price": 2000000, + "output_price": 8000000, + "cache_read_price": 500000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "o3-deep-research", + "input_price": 10000000, + "output_price": 40000000, + "cache_read_price": 2500000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "o3-mini", + "input_price": 1100000, + "output_price": 4400000, + "cache_read_price": 550000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "o3-pro", + "input_price": 20000000, + "output_price": 80000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "o4-mini", + "input_price": 1100000, + "output_price": 4400000, + "cache_read_price": 280000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "o4-mini-deep-research", + "input_price": 2000000, + "output_price": 8000000, + "cache_read_price": 500000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "text-embedding-3-large", + "input_price": 130000, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "text-embedding-3-small", + "input_price": 20000, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "text-embedding-ada-002", + "input_price": 100000, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + } +] diff --git a/coderd/aibridge/prices/prices.go b/coderd/aibridge/prices/prices.go new file mode 100644 index 0000000000..bbb5689ea0 --- /dev/null +++ b/coderd/aibridge/prices/prices.go @@ -0,0 +1,62 @@ +// Package prices seeds the ai_model_prices table from an embedded JSON +// price book at server startup. +package prices + +import ( + "context" + _ "embed" + "encoding/json" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" +) + +//go:embed data/prices.json +var seedJSON []byte + +// Pointer fields preserve the distinction between "not populated by upstream" +// (null) and "explicitly zero" (0). Used only for Go-side type validation in +// parseSeed; the upsert reads the raw JSON bytes via the batch SQL query. +// +// NOTE: the JSON contract for the price seed lives in three places that must +// stay in sync: the corresponding struct in the price generator, the column +// extraction in the batch SQL upsert, and the tags here. +type seedRow 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"` +} + +// Seed applies the embedded price seed to ai_model_prices table, replacing the +// price columns of any existing (provider, model) row and inserting new ones. +// Rows already in the table that no longer appear in the seed are left +// untouched, so historical entries persist across upstream model deprecations. +func Seed(ctx context.Context, db database.Store) error { + return SeedFromBytes(ctx, db, seedJSON) +} + +// SeedFromBytes applies an arbitrary JSON seed. Most callers should use Seed, +// which applies the seed embedded in this binary; SeedFromBytes is exposed +// for tests that need to inject a deterministic seed. +func SeedFromBytes(ctx context.Context, db database.Store, data []byte) error { + rows, err := parseSeed(data) + if err != nil { + return xerrors.Errorf("parse price seed: %w", err) + } + if len(rows) == 0 { + return xerrors.New("price seed is empty") + } + return db.UpsertAIModelPrices(ctx, data) +} + +func parseSeed(data []byte) ([]seedRow, error) { + var rows []seedRow + if err := json.Unmarshal(data, &rows); err != nil { + return nil, err + } + return rows, nil +} diff --git a/coderd/aibridge/prices/prices_test.go b/coderd/aibridge/prices/prices_test.go new file mode 100644 index 0000000000..1ce642e208 --- /dev/null +++ b/coderd/aibridge/prices/prices_test.go @@ -0,0 +1,188 @@ +package prices_test + +import ( + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/aibridge/prices" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/testutil" +) + +// testSeedJSON is a synthetic seed used by tests instead of the embedded +// one, so assertions don't depend on whatever values currently live in the +// embedded seed. +const testSeedJSON = `[ + { + "provider": "anthropic", + "model": "claude-opus-4-7", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "openai", + "model": "gpt-4o", + "input_price": 2500000, + "output_price": 10000000, + "cache_read_price": 1250000, + "cache_write_price": null + } +]` + +func TestSeedFromBytes(t *testing.T) { + t.Parallel() + + t.Run("SeedsFreshDatabase", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + db, _ := dbtestutil.NewDB(t) + + require.NoError(t, prices.SeedFromBytes(ctx, db, []byte(testSeedJSON))) + + // Spot-check a fully-populated row. + opus, err := db.GetAIModelPriceByProviderModel(ctx, database.GetAIModelPriceByProviderModelParams{ + Provider: "anthropic", + Model: "claude-opus-4-7", + }) + require.NoError(t, err) + require.Equal(t, int64(5_000_000), opus.InputPrice.Int64) + require.Equal(t, int64(25_000_000), opus.OutputPrice.Int64) + require.Equal(t, int64(500_000), opus.CacheReadPrice.Int64) + require.Equal(t, int64(6_250_000), opus.CacheWritePrice.Int64) + + // Spot-check a row where the seed has a NULL price (OpenAI does not + // publish a cache_write_price). The column should land as SQL NULL. + gpt, err := db.GetAIModelPriceByProviderModel(ctx, database.GetAIModelPriceByProviderModelParams{ + Provider: "openai", + Model: "gpt-4o", + }) + require.NoError(t, err) + require.Equal(t, int64(2_500_000), gpt.InputPrice.Int64) + require.Equal(t, int64(10_000_000), gpt.OutputPrice.Int64) + require.Equal(t, int64(1_250_000), gpt.CacheReadPrice.Int64) + require.False(t, gpt.CacheWritePrice.Valid) + require.Zero(t, gpt.CacheWritePrice.Int64) + }) + + t.Run("Idempotent", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + db, _ := dbtestutil.NewDB(t) + + require.NoError(t, prices.SeedFromBytes(ctx, db, []byte(testSeedJSON))) + first, err := db.GetAIModelPriceByProviderModel(ctx, database.GetAIModelPriceByProviderModelParams{ + Provider: "openai", Model: "gpt-4o", + }) + require.NoError(t, err) + + require.NoError(t, prices.SeedFromBytes(ctx, db, []byte(testSeedJSON))) + second, err := db.GetAIModelPriceByProviderModel(ctx, database.GetAIModelPriceByProviderModelParams{ + Provider: "openai", Model: "gpt-4o", + }) + require.NoError(t, err) + + // Prices must be identical across runs and CreatedAt must be + // preserved (only updated_at moves on a no-op upsert). + require.Equal(t, first.InputPrice, second.InputPrice) + require.Equal(t, first.OutputPrice, second.OutputPrice) + require.Equal(t, first.CreatedAt, second.CreatedAt) + }) + + t.Run("OverwritesExistingPrices", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + db, _ := dbtestutil.NewDB(t) + + // Pre-seed with deliberately wrong values for all four price columns. + // cache_write_price is set to a non-NULL value here even though the + // embedded seed leaves it NULL for OpenAI; Seed must replace it with + // NULL to keep the table in sync with the seed. + require.NoError(t, db.UpsertAIModelPrices(ctx, []byte(`[{ + "provider": "openai", + "model": "gpt-4o", + "input_price": 1, + "output_price": 2, + "cache_read_price": 3, + "cache_write_price": 4 + }]`))) + + require.NoError(t, prices.SeedFromBytes(ctx, db, []byte(testSeedJSON))) + + got, err := db.GetAIModelPriceByProviderModel(ctx, database.GetAIModelPriceByProviderModelParams{ + Provider: "openai", Model: "gpt-4o", + }) + require.NoError(t, err) + require.Equal(t, int64(2_500_000), got.InputPrice.Int64) + require.Equal(t, int64(10_000_000), got.OutputPrice.Int64) + require.Equal(t, int64(1_250_000), got.CacheReadPrice.Int64) + require.False(t, got.CacheWritePrice.Valid) + require.Zero(t, got.CacheWritePrice.Int64) + }) + + t.Run("LeavesOrphanRowsUntouched", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + db, _ := dbtestutil.NewDB(t) + + // Insert a row for a (provider, model) the seed doesn't cover. After + // Seed it should still be there with its values intact. + require.NoError(t, db.UpsertAIModelPrices(ctx, []byte(`[{ + "provider": "test-provider", + "model": "test-model-not-in-seed", + "input_price": 12345, + "output_price": 67890, + "cache_read_price": null, + "cache_write_price": null + }]`))) + + require.NoError(t, prices.SeedFromBytes(ctx, db, []byte(testSeedJSON))) + + got, err := db.GetAIModelPriceByProviderModel(ctx, database.GetAIModelPriceByProviderModelParams{ + Provider: "test-provider", Model: "test-model-not-in-seed", + }) + require.NoError(t, err) + require.Equal(t, int64(12345), got.InputPrice.Int64) + require.Equal(t, int64(67890), got.OutputPrice.Int64) + }) + + // Verifies the chain: AsAIBridged context -> dbauthz wrapper auth check + // -> subjectAibridged's permission grant. A missing or wrong action on + // the subject would surface as "unauthorized: rbac: forbidden" here, even + // though the unit tests above (which bypass dbauthz) would still pass. + t.Run("AuthorizedAsAIBridged", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + rawDB, _ := dbtestutil.NewDB(t) + authzDB := dbauthz.New(rawDB, rbac.NewStrictAuthorizer(prometheus.NewRegistry()), slogtest.Make(t, nil), coderdtest.AccessControlStorePointer()) + + require.NoError(t, prices.SeedFromBytes(dbauthz.AsAIBridged(ctx), authzDB, []byte(testSeedJSON))) + + // Read back via the raw DB. + got, err := rawDB.GetAIModelPriceByProviderModel(ctx, database.GetAIModelPriceByProviderModelParams{ + Provider: "openai", Model: "gpt-4o", + }) + require.NoError(t, err) + require.True(t, got.InputPrice.Valid) + require.Equal(t, int64(2_500_000), got.InputPrice.Int64) + }) +} + +// TestSeed exercises the real embedded prices.json so we catch a corrupted, +// empty, or unparseable seed file at test time rather than at server startup. +// Intentionally makes no assertions about specific prices, since those drift +// whenever the seed is regenerated from upstream. +func TestSeed(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + db, _ := dbtestutil.NewDB(t) + require.NoError(t, prices.Seed(ctx, db)) +} diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 701c57a808..a9e8f47cb4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -14467,6 +14467,9 @@ const docTemplate = `{ "enum": [ "all", "application_connect", + "ai_model_price:*", + "ai_model_price:read", + "ai_model_price:update", "ai_seat:*", "ai_seat:create", "ai_seat:read", @@ -14679,6 +14682,9 @@ const docTemplate = `{ "x-enum-varnames": [ "APIKeyScopeAll", "APIKeyScopeApplicationConnect", + "APIKeyScopeAiModelPriceAll", + "APIKeyScopeAiModelPriceRead", + "APIKeyScopeAiModelPriceUpdate", "APIKeyScopeAiSeatAll", "APIKeyScopeAiSeatCreate", "APIKeyScopeAiSeatRead", @@ -21248,6 +21254,7 @@ const docTemplate = `{ "type": "string", "enum": [ "*", + "ai_model_price", "ai_seat", "aibridge_interception", "api_key", @@ -21295,6 +21302,7 @@ const docTemplate = `{ ], "x-enum-varnames": [ "ResourceWildcard", + "ResourceAiModelPrice", "ResourceAiSeat", "ResourceAibridgeInterception", "ResourceApiKey", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 4adee486c5..d57994180c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -12935,6 +12935,9 @@ "enum": [ "all", "application_connect", + "ai_model_price:*", + "ai_model_price:read", + "ai_model_price:update", "ai_seat:*", "ai_seat:create", "ai_seat:read", @@ -13147,6 +13150,9 @@ "x-enum-varnames": [ "APIKeyScopeAll", "APIKeyScopeApplicationConnect", + "APIKeyScopeAiModelPriceAll", + "APIKeyScopeAiModelPriceRead", + "APIKeyScopeAiModelPriceUpdate", "APIKeyScopeAiSeatAll", "APIKeyScopeAiSeatCreate", "APIKeyScopeAiSeatRead", @@ -19479,6 +19485,7 @@ "type": "string", "enum": [ "*", + "ai_model_price", "ai_seat", "aibridge_interception", "api_key", @@ -19526,6 +19533,7 @@ ], "x-enum-varnames": [ "ResourceWildcard", + "ResourceAiModelPrice", "ResourceAiSeat", "ResourceAibridgeInterception", "ResourceApiKey", diff --git a/coderd/coderd.go b/coderd/coderd.go index ddb97d66fc..619a91f7b0 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -46,6 +46,7 @@ import ( "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/coderd/agentapi" "github.com/coder/coder/v2/coderd/agentapi/metadatabatcher" + "github.com/coder/coder/v2/coderd/aibridge/prices" "github.com/coder/coder/v2/coderd/aiseats" _ "github.com/coder/coder/v2/coderd/apidoc" // Used for swagger docs. "github.com/coder/coder/v2/coderd/appearance" @@ -592,6 +593,12 @@ func New(options *Options) *API { options.Logger.Fatal(ctx, "failed to reconcile system role permissions", slog.Error(err)) } + // Seed the AI Bridge model price table from the embedded price book. + //nolint:gocritic // Startup seeder needs to run as aibridge context. + if err := prices.Seed(dbauthz.AsAIBridged(ctx), options.Database); err != nil { + options.Logger.Error(ctx, "failed to seed AI Bridge prices; cost tracking may use stale prices", slog.Error(err)) + } + // AGPL uses a no-op build usage checker as there are no license // entitlements to enforce. This is swapped out in // enterprise/coderd/coderd.go. diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index 1a209d785c..a3b837bf22 100644 --- a/coderd/database/check_constraint.go +++ b/coderd/database/check_constraint.go @@ -6,6 +6,10 @@ type CheckConstraint string // CheckConstraint enums. const ( + CheckAiModelPricesCacheReadPriceCheck CheckConstraint = "ai_model_prices_cache_read_price_check" // ai_model_prices + CheckAiModelPricesCacheWritePriceCheck CheckConstraint = "ai_model_prices_cache_write_price_check" // ai_model_prices + CheckAiModelPricesInputPriceCheck CheckConstraint = "ai_model_prices_input_price_check" // ai_model_prices + CheckAiModelPricesOutputPriceCheck CheckConstraint = "ai_model_prices_output_price_check" // ai_model_prices CheckAPIKeysAllowListNotEmpty CheckConstraint = "api_keys_allow_list_not_empty" // api_keys CheckChatModelConfigsCompressionThresholdCheck CheckConstraint = "chat_model_configs_compression_threshold_check" // chat_model_configs CheckChatModelConfigsContextLimitCheck CheckConstraint = "chat_model_configs_context_limit_check" // chat_model_configs diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 41334f3823..e4e3550639 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -626,6 +626,7 @@ var ( }, rbac.ResourceApiKey.Type: {policy.ActionRead}, // Validate API keys. rbac.ResourceAibridgeInterception.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceAiModelPrice.Type: {policy.ActionUpdate}, // Required for the startup price seeder. rbac.ResourceAiSeat.Type: {policy.ActionCreate}, // Required for UpsertAISeatState. }), User: []rbac.Permission{}, @@ -2480,6 +2481,13 @@ func (q *querier) GetAIBridgeUserPromptsByInterceptionID(ctx context.Context, in return q.db.GetAIBridgeUserPromptsByInterceptionID(ctx, interceptionID) } +func (q *querier) GetAIModelPriceByProviderModel(ctx context.Context, arg database.GetAIModelPriceByProviderModelParams) (database.AiModelPrice, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAiModelPrice); err != nil { + return database.AiModelPrice{}, err + } + return q.db.GetAIModelPriceByProviderModel(ctx, arg) +} + func (q *querier) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) { return fetch(q.log, q.auth, q.db.GetAPIKeyByID)(ctx, id) } @@ -7534,6 +7542,13 @@ func (q *querier) UpdateWorkspacesTTLByTemplateID(ctx context.Context, arg datab return q.db.UpdateWorkspacesTTLByTemplateID(ctx, arg) } +func (q *querier) UpsertAIModelPrices(ctx context.Context, seed json.RawMessage) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceAiModelPrice); err != nil { + return err + } + return q.db.UpsertAIModelPrices(ctx, seed) +} + func (q *querier) UpsertAISeatState(ctx context.Context, arg database.UpsertAISeatStateParams) (bool, error) { if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceAiSeat); err != nil { return false, err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 6a749a2bb5..53023ad41f 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -6126,6 +6126,16 @@ func (s *MethodTestSuite) TestAIBridge() { db.EXPECT().DeleteOldAIBridgeRecords(gomock.Any(), t).Return(int64(0), nil).AnyTimes() check.Args(t).Asserts(rbac.ResourceAibridgeInterception, policy.ActionDelete) })) + + s.Run("UpsertAIModelPrices", s.Mocked(func(db *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + db.EXPECT().UpsertAIModelPrices(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + check.Args(json.RawMessage(`[]`)).Asserts(rbac.ResourceAiModelPrice, policy.ActionUpdate) + })) + + s.Run("GetAIModelPriceByProviderModel", s.Mocked(func(db *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + db.EXPECT().GetAIModelPriceByProviderModel(gomock.Any(), gomock.Any()).Return(database.AiModelPrice{}, nil).AnyTimes() + check.Args(database.GetAIModelPriceByProviderModelParams{}).Asserts(rbac.ResourceAiModelPrice, policy.ActionRead) + })) } func (s *MethodTestSuite) TestTelemetry() { diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index e7f4378427..88a5ecd766 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -5,6 +5,7 @@ package dbmetrics import ( "context" + "encoding/json" "slices" "time" @@ -976,6 +977,14 @@ func (m queryMetricsStore) GetAIBridgeUserPromptsByInterceptionID(ctx context.Co return r0, r1 } +func (m queryMetricsStore) GetAIModelPriceByProviderModel(ctx context.Context, arg database.GetAIModelPriceByProviderModelParams) (database.AiModelPrice, error) { + start := time.Now() + r0, r1 := m.s.GetAIModelPriceByProviderModel(ctx, arg) + m.queryLatencies.WithLabelValues("GetAIModelPriceByProviderModel").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAIModelPriceByProviderModel").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) { start := time.Now() r0, r1 := m.s.GetAPIKeyByID(ctx, id) @@ -5368,6 +5377,14 @@ func (m queryMetricsStore) UpdateWorkspacesTTLByTemplateID(ctx context.Context, return r0 } +func (m queryMetricsStore) UpsertAIModelPrices(ctx context.Context, seed json.RawMessage) error { + start := time.Now() + r0 := m.s.UpsertAIModelPrices(ctx, seed) + m.queryLatencies.WithLabelValues("UpsertAIModelPrices").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertAIModelPrices").Inc() + return r0 +} + func (m queryMetricsStore) UpsertAISeatState(ctx context.Context, arg database.UpsertAISeatStateParams) (bool, error) { start := time.Now() r0, r1 := m.s.UpsertAISeatState(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index d84681026e..b8c4b73d64 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -11,6 +11,7 @@ package dbmock import ( context "context" + json "encoding/json" reflect "reflect" time "time" @@ -1682,6 +1683,21 @@ func (mr *MockStoreMockRecorder) GetAIBridgeUserPromptsByInterceptionID(ctx, int return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIBridgeUserPromptsByInterceptionID", reflect.TypeOf((*MockStore)(nil).GetAIBridgeUserPromptsByInterceptionID), ctx, interceptionID) } +// GetAIModelPriceByProviderModel mocks base method. +func (m *MockStore) GetAIModelPriceByProviderModel(ctx context.Context, arg database.GetAIModelPriceByProviderModelParams) (database.AiModelPrice, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAIModelPriceByProviderModel", ctx, arg) + ret0, _ := ret[0].(database.AiModelPrice) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAIModelPriceByProviderModel indicates an expected call of GetAIModelPriceByProviderModel. +func (mr *MockStoreMockRecorder) GetAIModelPriceByProviderModel(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIModelPriceByProviderModel", reflect.TypeOf((*MockStore)(nil).GetAIModelPriceByProviderModel), ctx, arg) +} + // GetAPIKeyByID mocks base method. func (m *MockStore) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) { m.ctrl.T.Helper() @@ -10090,6 +10106,20 @@ func (mr *MockStoreMockRecorder) UpdateWorkspacesTTLByTemplateID(ctx, arg any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspacesTTLByTemplateID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspacesTTLByTemplateID), ctx, arg) } +// UpsertAIModelPrices mocks base method. +func (m *MockStore) UpsertAIModelPrices(ctx context.Context, seed json.RawMessage) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertAIModelPrices", ctx, seed) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpsertAIModelPrices indicates an expected call of UpsertAIModelPrices. +func (mr *MockStoreMockRecorder) UpsertAIModelPrices(ctx, seed any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertAIModelPrices", reflect.TypeOf((*MockStore)(nil).UpsertAIModelPrices), ctx, seed) +} + // UpsertAISeatState mocks base method. func (m *MockStore) UpsertAISeatState(ctx context.Context, arg database.UpsertAISeatStateParams) (bool, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index e7d192a0ca..687e80e1b5 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -223,7 +223,10 @@ CREATE TYPE api_key_scope AS ENUM ( 'chat:*', 'ai_seat:*', 'ai_seat:create', - 'ai_seat:read' + 'ai_seat:read', + 'ai_model_price:*', + 'ai_model_price:read', + 'ai_model_price:update' ); CREATE TYPE app_sharing_level AS ENUM ( @@ -1061,6 +1064,23 @@ BEGIN END; $$; +CREATE TABLE ai_model_prices ( + provider text NOT NULL, + model text NOT NULL, + input_price bigint, + output_price bigint, + cache_read_price bigint, + cache_write_price bigint, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT ai_model_prices_cache_read_price_check CHECK ((cache_read_price >= 0)), + CONSTRAINT ai_model_prices_cache_write_price_check CHECK ((cache_write_price >= 0)), + CONSTRAINT ai_model_prices_input_price_check CHECK ((input_price >= 0)), + CONSTRAINT ai_model_prices_output_price_check CHECK ((output_price >= 0)) +); + +COMMENT ON TABLE ai_model_prices IS 'Per-model token prices used by AI Bridge to compute interception cost.'; + CREATE TABLE ai_seat_state ( user_id uuid NOT NULL, first_used_at timestamp with time zone NOT NULL, @@ -3358,6 +3378,9 @@ ALTER TABLE ONLY workspace_resource_metadata ALTER COLUMN id SET DEFAULT nextval ALTER TABLE ONLY workspace_agent_stats ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id); +ALTER TABLE ONLY ai_model_prices + ADD CONSTRAINT ai_model_prices_pkey PRIMARY KEY (provider, model); + ALTER TABLE ONLY ai_seat_state ADD CONSTRAINT ai_seat_state_pkey PRIMARY KEY (user_id); diff --git a/coderd/database/migrations/000489_ai_model_prices.down.sql b/coderd/database/migrations/000489_ai_model_prices.down.sql new file mode 100644 index 0000000000..86167d9565 --- /dev/null +++ b/coderd/database/migrations/000489_ai_model_prices.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS ai_model_prices CASCADE; diff --git a/coderd/database/migrations/000489_ai_model_prices.up.sql b/coderd/database/migrations/000489_ai_model_prices.up.sql new file mode 100644 index 0000000000..bbc3c5902b --- /dev/null +++ b/coderd/database/migrations/000489_ai_model_prices.up.sql @@ -0,0 +1,19 @@ +CREATE TABLE ai_model_prices ( + provider TEXT NOT NULL, + model TEXT NOT NULL, + -- Prices per million tokens, in micro-units (1 unit = 1,000,000). + -- A NULL column means the price is unknown for this dimension; an explicit zero means "free". + input_price BIGINT CHECK (input_price >= 0), + output_price BIGINT CHECK (output_price >= 0), + cache_read_price BIGINT CHECK (cache_read_price >= 0), + cache_write_price BIGINT CHECK (cache_write_price >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (provider, model) +); + +COMMENT ON TABLE ai_model_prices IS 'Per-model token prices used by AI Bridge to compute interception cost.'; + +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'ai_model_price:*'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'ai_model_price:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'ai_model_price:update'; diff --git a/coderd/database/migrations/testdata/fixtures/000489_ai_model_prices.up.sql b/coderd/database/migrations/testdata/fixtures/000489_ai_model_prices.up.sql new file mode 100644 index 0000000000..54e68f71f6 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000489_ai_model_prices.up.sql @@ -0,0 +1,10 @@ +INSERT INTO ai_model_prices ( + provider, + model, + input_price, + output_price, + cache_read_price, + cache_write_price +) VALUES + ('anthropic', 'claude-3-5-sonnet-20241022', 3000000, 15000000, 300000, 3750000), + ('openai', 'gpt-4o', 2500000, 10000000, 1250000, NULL); diff --git a/coderd/database/models.go b/coderd/database/models.go index a9dc787afb..143a97a15a 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -227,6 +227,9 @@ const ( ApiKeyScopeAiSeat APIKeyScope = "ai_seat:*" ApiKeyScopeAiSeatCreate APIKeyScope = "ai_seat:create" ApiKeyScopeAiSeatRead APIKeyScope = "ai_seat:read" + ApiKeyScopeAiModelPrice APIKeyScope = "ai_model_price:*" + ApiKeyScopeAiModelPriceRead APIKeyScope = "ai_model_price:read" + ApiKeyScopeAiModelPriceUpdate APIKeyScope = "ai_model_price:update" ) func (e *APIKeyScope) Scan(src interface{}) error { @@ -473,7 +476,10 @@ func (e APIKeyScope) Valid() bool { ApiKeyScopeChat, ApiKeyScopeAiSeat, ApiKeyScopeAiSeatCreate, - ApiKeyScopeAiSeatRead: + ApiKeyScopeAiSeatRead, + ApiKeyScopeAiModelPrice, + ApiKeyScopeAiModelPriceRead, + ApiKeyScopeAiModelPriceUpdate: return true } return false @@ -689,6 +695,9 @@ func AllAPIKeyScopeValues() []APIKeyScope { ApiKeyScopeAiSeat, ApiKeyScopeAiSeatCreate, ApiKeyScopeAiSeatRead, + ApiKeyScopeAiModelPrice, + ApiKeyScopeAiModelPriceRead, + ApiKeyScopeAiModelPriceUpdate, } } @@ -4307,6 +4316,18 @@ type APIKey struct { AllowList AllowList `db:"allow_list" json:"allow_list"` } +// Per-model token prices used by AI Bridge to compute interception cost. +type AiModelPrice struct { + Provider string `db:"provider" json:"provider"` + Model string `db:"model" json:"model"` + InputPrice sql.NullInt64 `db:"input_price" json:"input_price"` + OutputPrice sql.NullInt64 `db:"output_price" json:"output_price"` + CacheReadPrice sql.NullInt64 `db:"cache_read_price" json:"cache_read_price"` + CacheWritePrice sql.NullInt64 `db:"cache_write_price" json:"cache_write_price"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + type AiSeatState struct { UserID uuid.UUID `db:"user_id" json:"user_id"` FirstUsedAt time.Time `db:"first_used_at" json:"first_used_at"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 44273cfc6b..23301e6b62 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -6,6 +6,7 @@ package database import ( "context" + "encoding/json" "time" "github.com/google/uuid" @@ -244,6 +245,7 @@ type sqlcQuerier interface { GetAIBridgeTokenUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeTokenUsage, error) GetAIBridgeToolUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeToolUsage, error) GetAIBridgeUserPromptsByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeUserPrompt, error) + GetAIModelPriceByProviderModel(ctx context.Context, arg GetAIModelPriceByProviderModelParams) (AiModelPrice, error) GetAPIKeyByID(ctx context.Context, id string) (APIKey, error) // there is no unique constraint on empty token names GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNameParams) (APIKey, error) @@ -1239,6 +1241,10 @@ type sqlcQuerier interface { UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDormantDeletingAtByTemplateIDParams) ([]WorkspaceTable, error) UpdateWorkspacesTTLByTemplateID(ctx context.Context, arg UpdateWorkspacesTTLByTemplateIDParams) error + // Upsert a batch of (provider, model) rows from a JSON array. Each element + // must have provider, model, and the four price fields; null prices are + // written as SQL NULL. + UpsertAIModelPrices(ctx context.Context, seed json.RawMessage) error // Returns true if a new rows was inserted, false otherwise. UpsertAISeatState(ctx context.Context, arg UpsertAISeatStateParams) (bool, error) UpsertAnnouncementBanners(ctx context.Context, value string) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 6979ea3037..43acf4f25d 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1787,6 +1787,61 @@ func (q *sqlQuerier) UpdateAIBridgeInterceptionEnded(ctx context.Context, arg Up return i, err } +const getAIModelPriceByProviderModel = `-- name: GetAIModelPriceByProviderModel :one +SELECT provider, model, input_price, output_price, cache_read_price, cache_write_price, created_at, updated_at +FROM ai_model_prices +WHERE provider = $1 AND model = $2 +` + +type GetAIModelPriceByProviderModelParams struct { + Provider string `db:"provider" json:"provider"` + Model string `db:"model" json:"model"` +} + +func (q *sqlQuerier) GetAIModelPriceByProviderModel(ctx context.Context, arg GetAIModelPriceByProviderModelParams) (AiModelPrice, error) { + row := q.db.QueryRowContext(ctx, getAIModelPriceByProviderModel, arg.Provider, arg.Model) + var i AiModelPrice + err := row.Scan( + &i.Provider, + &i.Model, + &i.InputPrice, + &i.OutputPrice, + &i.CacheReadPrice, + &i.CacheWritePrice, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const upsertAIModelPrices = `-- name: UpsertAIModelPrices :exec +INSERT INTO ai_model_prices ( + provider, model, input_price, output_price, cache_read_price, cache_write_price +) +SELECT + elem->>'provider', + elem->>'model', + (elem->>'input_price')::bigint, + (elem->>'output_price')::bigint, + (elem->>'cache_read_price')::bigint, + (elem->>'cache_write_price')::bigint +FROM jsonb_array_elements($1::jsonb) AS elem +ON CONFLICT (provider, model) DO UPDATE SET + input_price = EXCLUDED.input_price, + output_price = EXCLUDED.output_price, + cache_read_price = EXCLUDED.cache_read_price, + cache_write_price = EXCLUDED.cache_write_price, + updated_at = NOW() +` + +// Upsert a batch of (provider, model) rows from a JSON array. Each element +// must have provider, model, and the four price fields; null prices are +// written as SQL NULL. +func (q *sqlQuerier) UpsertAIModelPrices(ctx context.Context, seed json.RawMessage) error { + _, err := q.db.ExecContext(ctx, upsertAIModelPrices, seed) + return err +} + const getActiveAISeatCount = `-- name: GetActiveAISeatCount :one SELECT COUNT(*) diff --git a/coderd/database/queries/aicostcontrol.sql b/coderd/database/queries/aicostcontrol.sql new file mode 100644 index 0000000000..d2b66c4d3b --- /dev/null +++ b/coderd/database/queries/aicostcontrol.sql @@ -0,0 +1,26 @@ +-- name: UpsertAIModelPrices :exec +-- Upsert a batch of (provider, model) rows from a JSON array. Each element +-- must have provider, model, and the four price fields; null prices are +-- written as SQL NULL. +INSERT INTO ai_model_prices ( + provider, model, input_price, output_price, cache_read_price, cache_write_price +) +SELECT + elem->>'provider', + elem->>'model', + (elem->>'input_price')::bigint, + (elem->>'output_price')::bigint, + (elem->>'cache_read_price')::bigint, + (elem->>'cache_write_price')::bigint +FROM jsonb_array_elements(@seed::jsonb) AS elem +ON CONFLICT (provider, model) DO UPDATE SET + input_price = EXCLUDED.input_price, + output_price = EXCLUDED.output_price, + cache_read_price = EXCLUDED.cache_read_price, + cache_write_price = EXCLUDED.cache_write_price, + updated_at = NOW(); + +-- name: GetAIModelPriceByProviderModel :one +SELECT * +FROM ai_model_prices +WHERE provider = @provider AND model = @model; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index c7d45e1844..9c71259b23 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -7,6 +7,7 @@ type UniqueConstraint string // UniqueConstraint enums. const ( UniqueAgentStatsPkey UniqueConstraint = "agent_stats_pkey" // ALTER TABLE ONLY workspace_agent_stats ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id); + UniqueAiModelPricesPkey UniqueConstraint = "ai_model_prices_pkey" // ALTER TABLE ONLY ai_model_prices ADD CONSTRAINT ai_model_prices_pkey PRIMARY KEY (provider, model); UniqueAiSeatStatePkey UniqueConstraint = "ai_seat_state_pkey" // ALTER TABLE ONLY ai_seat_state ADD CONSTRAINT ai_seat_state_pkey PRIMARY KEY (user_id); UniqueAibridgeInterceptionsPkey UniqueConstraint = "aibridge_interceptions_pkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_pkey PRIMARY KEY (id); UniqueAibridgeTokenUsagesPkey UniqueConstraint = "aibridge_token_usages_pkey" // ALTER TABLE ONLY aibridge_token_usages ADD CONSTRAINT aibridge_token_usages_pkey PRIMARY KEY (id); diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 338c454591..6ca3c3a3cd 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -15,6 +15,14 @@ var ( Type: "*", } + // ResourceAiModelPrice + // Valid Actions + // - "ActionRead" :: read AI model prices + // - "ActionUpdate" :: update AI model prices + ResourceAiModelPrice = Object{ + Type: "ai_model_price", + } + // ResourceAiSeat // Valid Actions // - "ActionCreate" :: record AI seat usage @@ -441,6 +449,7 @@ var ( func AllResources() []Objecter { return []Objecter{ ResourceWildcard, + ResourceAiModelPrice, ResourceAiSeat, ResourceAibridgeInterception, ResourceApiKey, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index c60bf10299..c366dd9a14 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -392,6 +392,12 @@ var RBACPermissions = map[string]PermissionDefinition{ ActionCreate: "create aibridge interceptions & related records", }, }, + "ai_model_price": { + Actions: map[Action]ActionDefinition{ + ActionRead: "read AI model prices", + ActionUpdate: "update AI model prices", + }, + }, "ai_seat": { Actions: map[Action]ActionDefinition{ ActionCreate: "record AI seat usage", diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index a59f40461d..6f10c7bf99 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -1121,6 +1121,15 @@ func TestRolePermissions(t *testing.T) { false: {owner, setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin}, }, }, + { + Name: "AiModelPrice", + Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate}, + Resource: rbac.ResourceAiModelPrice, + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner}, + false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin}, + }, + }, { Name: "ChatUsageCRU", Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, diff --git a/coderd/rbac/scopes_constants_gen.go b/coderd/rbac/scopes_constants_gen.go index d94d0e5fd1..85ef453602 100644 --- a/coderd/rbac/scopes_constants_gen.go +++ b/coderd/rbac/scopes_constants_gen.go @@ -7,6 +7,8 @@ package rbac // declared in code, not here, to avoid duplication. const ( + ScopeAiModelPriceRead ScopeName = "ai_model_price:read" + ScopeAiModelPriceUpdate ScopeName = "ai_model_price:update" ScopeAiSeatCreate ScopeName = "ai_seat:create" ScopeAiSeatRead ScopeName = "ai_seat:read" ScopeAibridgeInterceptionCreate ScopeName = "aibridge_interception:create" @@ -173,6 +175,8 @@ func (e ScopeName) Valid() bool { case ScopeName("coder:all"), ScopeName("coder:application_connect"), ScopeName("no_user_data"), + ScopeAiModelPriceRead, + ScopeAiModelPriceUpdate, ScopeAiSeatCreate, ScopeAiSeatRead, ScopeAibridgeInterceptionCreate, @@ -340,6 +344,8 @@ func AllScopeNameValues() []ScopeName { ScopeName("coder:all"), ScopeName("coder:application_connect"), ScopeName("no_user_data"), + ScopeAiModelPriceRead, + ScopeAiModelPriceUpdate, ScopeAiSeatCreate, ScopeAiSeatRead, ScopeAibridgeInterceptionCreate, diff --git a/codersdk/apikey_scopes_gen.go b/codersdk/apikey_scopes_gen.go index dd3a94bb3c..464d96968a 100644 --- a/codersdk/apikey_scopes_gen.go +++ b/codersdk/apikey_scopes_gen.go @@ -6,6 +6,9 @@ const ( APIKeyScopeAll APIKeyScope = "all" // Deprecated: use codersdk.APIKeyScopeCoderApplicationConnect instead. APIKeyScopeApplicationConnect APIKeyScope = "application_connect" + APIKeyScopeAiModelPriceAll APIKeyScope = "ai_model_price:*" + APIKeyScopeAiModelPriceRead APIKeyScope = "ai_model_price:read" + APIKeyScopeAiModelPriceUpdate APIKeyScope = "ai_model_price:update" APIKeyScopeAiSeatAll APIKeyScope = "ai_seat:*" APIKeyScopeAiSeatCreate APIKeyScope = "ai_seat:create" APIKeyScopeAiSeatRead APIKeyScope = "ai_seat:read" diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 833af15f56..d1e9853f23 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -5,6 +5,7 @@ type RBACResource string const ( ResourceWildcard RBACResource = "*" + ResourceAiModelPrice RBACResource = "ai_model_price" ResourceAiSeat RBACResource = "ai_seat" ResourceAibridgeInterception RBACResource = "aibridge_interception" ResourceApiKey RBACResource = "api_key" @@ -78,6 +79,7 @@ const ( // said resource type. var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceWildcard: {}, + ResourceAiModelPrice: {ActionRead, ActionUpdate}, ResourceAiSeat: {ActionCreate, ActionRead}, ResourceAibridgeInterception: {ActionCreate, ActionRead, ActionUpdate}, ResourceApiKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index cd2cc7ea19..b04c6408c1 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -193,10 +193,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_model_price`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -326,10 +326,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_model_price`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -459,10 +459,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_model_price`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -554,10 +554,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_model_price`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -960,9 +960,9 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_model_price`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index c20d026091..d232008c36 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1391,9 +1391,9 @@ #### Enumerated Values -| Value(s) | -|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai_seat:*`, `ai_seat:create`, `ai_seat:read`, `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `chat:*`, `chat:create`, `chat:delete`, `chat:read`, `chat:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | +| Value(s) | +|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai_model_price:*`, `ai_model_price:read`, `ai_model_price:update`, `ai_seat:*`, `ai_seat:create`, `ai_seat:read`, `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `chat:*`, `chat:create`, `chat:delete`, `chat:read`, `chat:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | ## codersdk.AddLicenseRequest @@ -10371,9 +10371,9 @@ Only certain features set these fields: - FeatureManagedAgentLimit| #### Enumerated Values -| Value(s) | -|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `*`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Value(s) | +|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `*`, `ai_model_price`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | ## codersdk.RateLimitConfig diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index 3876efbc0a..39323c6540 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -856,11 +856,11 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | `*`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | -| `login_type` | `github`, `oidc`, `password`, `token` | -| `scope` | `all`, `application_connect` | +| Property | Value(s) | +|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `type` | `*`, `ai_model_price`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| `login_type` | `github`, `oidc`, `password`, `token` | +| `scope` | `all`, `application_connect` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/scripts/aibridgepricesgen/main.go b/scripts/aibridgepricesgen/main.go new file mode 100644 index 0000000000..20a26c0f1b --- /dev/null +++ b/scripts/aibridgepricesgen/main.go @@ -0,0 +1,209 @@ +// aibridgepricesgen fetches model pricing from models.dev and writes a JSON +// seed file consumable by the AI Bridge cost-control loader. Output is sorted +// by (provider, model) so regenerations produce minimal diffs. +// +// Run via the gen/aibridge-prices Make target. Kept out of `make gen` because +// the output depends on live upstream data; refreshing prices should land in +// dedicated, reviewable commits rather than appearing as drift on unrelated +// gen runs. +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "os" + "sort" + "time" + + "golang.org/x/xerrors" +) + +const ( + sourceURL = "https://models.dev/api.json" + fetchTimeout = 30 * time.Second + // Cap the upstream body read. The current api.json is ~2 MiB, so 100 + // MiB is pure defense-in-depth against a misbehaving upstream eating + // arbitrary memory on developer or CI machines. An overflow surfaces + // as a JSON parse error (LimitReader truncates silently at the cap). + maxBodyBytes = 100 << 20 +) + +// supportedProviders lists the providers we ship prices for. Adding a +// provider here is enough to include it on the next regeneration. +var supportedProviders = []string{"anthropic", "openai"} + +// upstreamProvider is the subset of a models.dev per-provider entry we read. +type upstreamProvider struct { + Models map[string]upstreamModel `json:"models"` +} + +type upstreamModel struct { + Cost *upstreamCost `json:"cost"` +} + +// Pointers distinguish "key absent" (nil) from "key present and zero" (0). +type upstreamCost struct { + Input *float64 `json:"input"` + Output *float64 `json:"output"` + CacheRead *float64 `json:"cache_read"` + CacheWrite *float64 `json:"cache_write"` +} + +// hasPricing reports whether the cost block has at least one populated price. +// Returns false for a nil receiver, so callers can pass m.Cost without a +// preceding nil check. +func (c *upstreamCost) hasPricing() bool { + if c == nil { + return false + } + return c.Input != nil || c.Output != nil || + c.CacheRead != nil || c.CacheWrite != nil +} + +// Pointer fields preserve the distinction between "not populated by upstream" +// (null) and "explicitly zero" (0). +// +// NOTE: the JSON contract for the price seed lives in three places that must +// stay in sync: the tags here, the corresponding struct in the price seeder, +// and the column extraction in the batch SQL upsert. +type priceRow 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"` +} + +func main() { + if err := run(); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "aibridgepricesgen: %v\n", err) + os.Exit(1) + } +} + +func run() error { + upstream, err := fetch() + if err != nil { + return xerrors.Errorf("fetch %s: %w", sourceURL, err) + } + rows, err := convert(upstream, supportedProviders) + if err != nil { + return err + } + if err := validate(rows); err != nil { + return err + } + if err := write(os.Stdout, rows); err != nil { + return err + } + _, _ = fmt.Fprintf(os.Stderr, "aibridgepricesgen: wrote %d prices for %d provider(s)\n", len(rows), len(supportedProviders)) + return nil +} + +func fetch() (map[string]upstreamProvider, error) { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, xerrors.Errorf("status %d", resp.StatusCode) + } + + var data map[string]upstreamProvider + if err := json.NewDecoder(io.LimitReader(resp.Body, maxBodyBytes)).Decode(&data); err != nil { + return nil, xerrors.Errorf("parse: %w", err) + } + return data, nil +} + +// convert flattens the upstream map into table-shaped rows for the configured +// providers. If any configured provider is absent from the upstream payload, +// every missing provider is reported and the function returns an error so the +// caller doesn't ship an incomplete seed. +func convert(upstream map[string]upstreamProvider, providers []string) ([]priceRow, error) { + var ( + rows []priceRow + missing []string + ) + for _, providerID := range providers { + provider, ok := upstream[providerID] + if !ok || len(provider.Models) == 0 { + missing = append(missing, providerID) + continue + } + for modelID, m := range provider.Models { + if !m.Cost.hasPricing() { + continue + } + rows = append(rows, priceRow{ + Provider: providerID, + Model: modelID, + InputPrice: toMicros(m.Cost.Input), + OutputPrice: toMicros(m.Cost.Output), + CacheReadPrice: toMicros(m.Cost.CacheRead), + CacheWritePrice: toMicros(m.Cost.CacheWrite), + }) + } + } + if len(missing) > 0 { + return nil, xerrors.Errorf("providers missing or empty in upstream: %v", missing) + } + + sort.Slice(rows, func(i, j int) bool { + if rows[i].Provider != rows[j].Provider { + return rows[i].Provider < rows[j].Provider + } + return rows[i].Model < rows[j].Model + }) + return rows, nil +} + +// validate checks invariants on the converted rows. Catches upstream +// changes that produce structurally valid but semantically broken seed +// data, e.g. a renamed `cost` key that leaves every row with all-null +// prices. +func validate(rows []priceRow) error { + for _, r := range rows { + if r.InputPrice != nil || r.OutputPrice != nil { + return nil + } + } + return xerrors.New("converted rows have no pricing data; upstream schema may have changed") +} + +// toMicros scales a price into integer micro-units (1 unit = 1,000,000), +// rounding to avoid float-truncation errors. Returns nil for nil input, and +// for negative values, which are treated as missing. +func toMicros(price *float64) *int64 { + if price == nil { + return nil + } + if *price < 0 { + _, _ = fmt.Fprintf(os.Stderr, "warning: negative price %f, treating as missing\n", *price) + return nil + } + micros := int64(math.Round(*price * 1_000_000)) + return µs +} + +func write(w io.Writer, rows []priceRow) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(rows); err != nil { + return xerrors.Errorf("encode: %w", err) + } + return nil +} diff --git a/scripts/aibridgepricesgen/main_test.go b/scripts/aibridgepricesgen/main_test.go new file mode 100644 index 0000000000..b21793f0d6 --- /dev/null +++ b/scripts/aibridgepricesgen/main_test.go @@ -0,0 +1,162 @@ +package main + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestToMicros(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in *float64 + want *int64 + }{ + {"missing", nil, nil}, + {"zero", floatPtr(0), int64Ptr(0)}, + {"whole", floatPtr(3), int64Ptr(3_000_000)}, + {"fractional", floatPtr(0.075), int64Ptr(75_000)}, + {"negative", floatPtr(-1), nil}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := toMicros(tc.in) + if tc.want == nil { + require.Nil(t, got) + return + } + require.NotNil(t, got) + require.Equal(t, *tc.want, *got) + }) + } +} + +func TestConvert(t *testing.T) { + t.Parallel() + + const upstreamJSON = `{ + "anthropic": { + "models": { + "claude-sonnet-4-7": { + "cost": {"input": 3, "output": 15, "cache_read": 0.3, "cache_write": 3.75} + }, + "claude-haiku": { + "cost": {"input": 0.8, "output": 4} + } + } + }, + "openai": { + "models": { + "gpt-4o": {"cost": {"input": 2.5, "output": 10, "cache_read": 1.25}}, + "gpt-no-prices": {} + } + }, + "alibaba": { + "models": { + "should-be-ignored": {"cost": {"input": 1, "output": 1}} + } + } + }` + + var upstream map[string]upstreamProvider + require.NoError(t, json.Unmarshal([]byte(upstreamJSON), &upstream)) + + rows, err := convert(upstream, []string{"anthropic", "openai"}) + require.NoError(t, err) + + // alibaba is dropped (not a supported provider) and gpt-no-prices is + // dropped (no per-token pricing), leaving three priced rows. + require.Len(t, rows, 3) + + // Sorted (provider, model). + require.Equal(t, "anthropic", rows[0].Provider) + require.Equal(t, "claude-haiku", rows[0].Model) + require.Equal(t, "anthropic", rows[1].Provider) + require.Equal(t, "claude-sonnet-4-7", rows[1].Model) + require.Equal(t, "openai", rows[2].Provider) + require.Equal(t, "gpt-4o", rows[2].Model) + + // All four prices populated for Anthropic Sonnet. + sonnet := rows[1] + require.Equal(t, int64(3_000_000), *sonnet.InputPrice) + require.Equal(t, int64(15_000_000), *sonnet.OutputPrice) + require.Equal(t, int64(300_000), *sonnet.CacheReadPrice) + require.Equal(t, int64(3_750_000), *sonnet.CacheWritePrice) + + // Missing keys stay nil for OpenAI gpt-4o. + gpt := rows[2] + require.Equal(t, int64(2_500_000), *gpt.InputPrice) + require.Equal(t, int64(10_000_000), *gpt.OutputPrice) + require.Equal(t, int64(1_250_000), *gpt.CacheReadPrice) + require.Nil(t, gpt.CacheWritePrice) +} + +// TestConvertMissingProvider covers both shapes of "configured provider has +// no usable data": the provider's key is absent from upstream, or the key +// exists but its Models map is empty. Both should fail loud so we never +// ship a partial seed. +func TestConvertMissingProvider(t *testing.T) { + t.Parallel() + + t.Run("Absent", func(t *testing.T) { + t.Parallel() + upstream := map[string]upstreamProvider{ + "openai": {Models: map[string]upstreamModel{ + "gpt-4o": {Cost: &upstreamCost{Input: floatPtr(2.5)}}, + }}, + } + rows, err := convert(upstream, []string{"anthropic", "openai"}) + require.Error(t, err) + require.Contains(t, err.Error(), "anthropic") + require.Nil(t, rows) + }) + + t.Run("EmptyModels", func(t *testing.T) { + t.Parallel() + upstream := map[string]upstreamProvider{ + "anthropic": {Models: map[string]upstreamModel{}}, + "openai": {Models: map[string]upstreamModel{ + "gpt-4o": {Cost: &upstreamCost{Input: floatPtr(2.5)}}, + }}, + } + rows, err := convert(upstream, []string{"anthropic", "openai"}) + require.Error(t, err) + require.Contains(t, err.Error(), "anthropic") + require.Nil(t, rows) + }) +} + +func TestValidate(t *testing.T) { + t.Parallel() + + t.Run("PassesWhenAnyRowHasPricing", func(t *testing.T) { + t.Parallel() + rows := []priceRow{ + {Provider: "openai", Model: "no-prices"}, + {Provider: "anthropic", Model: "claude", InputPrice: int64Ptr(3_000_000)}, + } + require.NoError(t, validate(rows)) + }) + + t.Run("FailsWhenNoRowHasPricing", func(t *testing.T) { + t.Parallel() + // Mirrors what would happen if upstream renamed the `cost` key: + // Go's decoder silently drops it, every row gets all-null prices, + // and convert returns syntactically valid rows with no pricing. + rows := []priceRow{ + {Provider: "anthropic", Model: "claude-x"}, + {Provider: "openai", Model: "gpt-x"}, + } + err := validate(rows) + require.Error(t, err) + require.Contains(t, err.Error(), "converted rows have no pricing data") + }) +} + +func floatPtr(v float64) *float64 { return &v } +func int64Ptr(v int64) *int64 { return &v } diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index a2cad73aa1..dcb373239f 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -8,6 +8,10 @@ import type { RBACAction, RBACResource } from "./typesGenerated"; export const RBACResourceActions: Partial< Record>> > = { + ai_model_price: { + read: "read AI model prices", + update: "update AI model prices", + }, ai_seat: { create: "record AI seat usage", read: "read AI seat state", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index be124a19eb..6591ad23bf 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -343,6 +343,9 @@ export interface APIKey { // From codersdk/apikey.go export type APIKeyScope = + | "ai_model_price:*" + | "ai_model_price:read" + | "ai_model_price:update" | "ai_seat:*" | "ai_seat:create" | "ai_seat:read" @@ -555,6 +558,9 @@ export type APIKeyScope = | "workspace:update_agent"; export const APIKeyScopes: APIKeyScope[] = [ + "ai_model_price:*", + "ai_model_price:read", + "ai_model_price:update", "ai_seat:*", "ai_seat:create", "ai_seat:read", @@ -6334,6 +6340,7 @@ export const RBACActions: RBACAction[] = [ // From codersdk/rbacresources_gen.go export type RBACResource = + | "ai_model_price" | "ai_seat" | "aibridge_interception" | "api_key" @@ -6381,6 +6388,7 @@ export type RBACResource = | "workspace_proxy"; export const RBACResources: RBACResource[] = [ + "ai_model_price", "ai_seat", "aibridge_interception", "api_key",