feat: add ai_model_prices table (#24932)

# Summary

Implements
https://linear.app/codercom/issue/AIGOV-282/add-ai-model-price-table-and-seed-generator

This PR lays the groundwork for AI Bridge cost controls (per the AI
Governance RFC). It adds the foundation needed for future cost tracking:
a place to store per-model token prices, a way to keep those prices in
sync with upstream pricing data, and a startup mechanism that ensures
every deployment has prices loaded before AI Bridge starts processing
requests.

The price data comes from [models.dev](https://models.dev/), a
community-maintained catalogue of AI provider pricing. A generator
script fetches the latest prices, filters to Anthropic and OpenAI for
now, and produces a seed file checked into the repository.

On every server startup the seed is applied to the database, so new
releases automatically pick up any price corrections that landed since
the previous one. Existing rows are overwritten with the latest prices;
rows for models no longer in the seed are left untouched.

# Batching the AI model price seed: three approaches

Context: at server startup we seed the `ai_model_prices` table from an
embedded JSON price book (~70 rows today, will grow as we add providers,
potentially 4000+).

Each row is:

```text
(provider, model, input_price, output_price, cache_read_price, cache_write_price)
```

Any of the four price columns can be:

- `NULL` → “price unknown for this dimension”
- explicit `0` → “free”

The batch must be an UPSERT so re-running is idempotent and existing
rows pick up new prices.

We considered three implementations.

---

## Approach 1 — Per-row UPSERT in a Go loop

```go
for _, row := range rows {
    if err := db.UpsertAIModelPrice(ctx, database.UpsertAIModelPriceParams{
        Provider:   row.Provider,
        Model:      row.Model,
        InputPrice: nullInt64(row.InputPrice),
        // ...
    }); err != nil {
        return err
    }
}
```

### Pros

- Trivial.
- NULL handling falls out naturally from `sql.NullInt64`.

### Cons

- `N` round-trips per seed.
- With ~70 rows that means ~70 statement executions on every startup,
even inside a transaction.
- Doesn't scale gracefully as the price book grows, potentially 4000+.

---

## Approach 2 — `UNNEST` with parallel arrays

Pass each column as a separate Go slice. Postgres unnests them in
parallel into a virtual table, then `INSERT ... SELECT`.

```sql
INSERT INTO ai_model_prices (
    provider,
    model,
    input_price,
    output_price,
    cache_read_price,
    cache_write_price
)
SELECT
    UNNEST(@providers::text[]),
    UNNEST(@models::text[]),
    NULLIF(UNNEST(@input_prices::bigint[]), -1),
    NULLIF(UNNEST(@output_prices::bigint[]), -1),
    NULLIF(UNNEST(@cache_read_prices::bigint[]), -1),
    NULLIF(UNNEST(@cache_write_prices::bigint[]), -1)
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();
```

Go side: flatten rows into six parallel slices.

Use a sentinel (`-1`) for “missing”, since `lib/pq` can't encode `NULL`
into a `bigint[]` element.

```go
providers := make([]string, len(rows))
models    := make([]string, len(rows))
inputs    := make([]int64,  len(rows))
outputs   := make([]int64,  len(rows))
cacheR    := make([]int64,  len(rows))
cacheW    := make([]int64,  len(rows))

for i, r := range rows {
    providers[i] = r.Provider
    models[i]    = r.Model

    inputs[i] = -1
    if r.InputPrice != nil {
        inputs[i] = *r.InputPrice
    }

    outputs[i] = -1
    if r.OutputPrice != nil {
        outputs[i] = *r.OutputPrice
    }

    cacheR[i] = -1
    if r.CacheReadPrice != nil {
        cacheR[i] = *r.CacheReadPrice
    }

    cacheW[i] = -1
    if r.CacheWritePrice != nil {
        cacheW[i] = *r.CacheWritePrice
    }
}

return db.UpsertAIModelPrices(ctx, database.UpsertAIModelPricesParams{
    Providers:        providers,
    Models:           models,
    InputPrices:      inputs,
    OutputPrices:     outputs,
    CacheReadPrices:  cacheR,
    CacheWritePrices: cacheW,
})
```

### Pros

- Single round-trip.

### Cons

- The generated `sqlc` params become plain `[]int64`, which can't
represent `NULL`.

---

## Approach 3 — `jsonb_array_elements` over a single `@seed::jsonb`
(chosen)

Pass the raw seed JSON as one parameter; let Postgres expand and parse
it.

```sql
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();
```

Go side reduces to:

```go
return db.UpsertAIModelPrices(ctx, seedJSON)
```

### Pros

- Single round-trip.
- NULLs fall out naturally:
  - `(elem->>'cache_write_price')::bigint` becomes `NULL`
  - no sentinels
- The seed is already JSON:
- Existing precedent:
  - `jsonb_array_elements` is already used elsewhere in the codebase

### Cons

- Less type-safe at the SQL boundary than `UNNEST`
- Slightly less standard than `UNNEST`
- Readers need familiarity with:
  - `jsonb_array_elements`
  - `->>` extraction syntax
- Postgres pays JSON parse cost
  - negligible at our scale

---

---

# Decision

We picked Approach 3.

It collapses the round-trips like `UNNEST` does, but without:

- nullable-array workarounds
- sentinel values
This commit is contained in:
Yevhenii Shcherbina
2026-05-08 16:45:14 -04:00
committed by GitHub
parent 638e2220e9
commit 4124d1137d
35 changed files with 1551 additions and 33 deletions
+14
View File
@@ -168,6 +168,10 @@ _gen/bin/apikeyscopesgen: $(wildcard scripts/apikeyscopesgen/*.go) $(RBAC_GO_FIL
@mkdir -p _gen/bin @mkdir -p _gen/bin
go build -o $@ ./scripts/apikeyscopesgen 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 _gen/bin/metricsdocgen: $(wildcard scripts/metricsdocgen/*.go) | _gen
@mkdir -p _gen/bin @mkdir -p _gen/bin
go build -o $@ ./scripts/metricsdocgen go build -o $@ ./scripts/metricsdocgen
@@ -989,6 +993,16 @@ gen: gen/db gen/golden-files $(GEN_FILES)
gen/db: $(DB_GEN_FILES) gen/db: $(DB_GEN_FILES)
.PHONY: gen/db .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: \ gen/golden-files: \
agent/unit/testdata/.gen-golden \ agent/unit/testdata/.gen-golden \
cli/testdata/.gen-golden \ cli/testdata/.gen-golden \
+5
View File
@@ -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.
+570
View File
@@ -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
}
]
+62
View File
@@ -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
}
+188
View File
@@ -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))
}
+8
View File
@@ -14467,6 +14467,9 @@ const docTemplate = `{
"enum": [ "enum": [
"all", "all",
"application_connect", "application_connect",
"ai_model_price:*",
"ai_model_price:read",
"ai_model_price:update",
"ai_seat:*", "ai_seat:*",
"ai_seat:create", "ai_seat:create",
"ai_seat:read", "ai_seat:read",
@@ -14679,6 +14682,9 @@ const docTemplate = `{
"x-enum-varnames": [ "x-enum-varnames": [
"APIKeyScopeAll", "APIKeyScopeAll",
"APIKeyScopeApplicationConnect", "APIKeyScopeApplicationConnect",
"APIKeyScopeAiModelPriceAll",
"APIKeyScopeAiModelPriceRead",
"APIKeyScopeAiModelPriceUpdate",
"APIKeyScopeAiSeatAll", "APIKeyScopeAiSeatAll",
"APIKeyScopeAiSeatCreate", "APIKeyScopeAiSeatCreate",
"APIKeyScopeAiSeatRead", "APIKeyScopeAiSeatRead",
@@ -21248,6 +21254,7 @@ const docTemplate = `{
"type": "string", "type": "string",
"enum": [ "enum": [
"*", "*",
"ai_model_price",
"ai_seat", "ai_seat",
"aibridge_interception", "aibridge_interception",
"api_key", "api_key",
@@ -21295,6 +21302,7 @@ const docTemplate = `{
], ],
"x-enum-varnames": [ "x-enum-varnames": [
"ResourceWildcard", "ResourceWildcard",
"ResourceAiModelPrice",
"ResourceAiSeat", "ResourceAiSeat",
"ResourceAibridgeInterception", "ResourceAibridgeInterception",
"ResourceApiKey", "ResourceApiKey",
+8
View File
@@ -12935,6 +12935,9 @@
"enum": [ "enum": [
"all", "all",
"application_connect", "application_connect",
"ai_model_price:*",
"ai_model_price:read",
"ai_model_price:update",
"ai_seat:*", "ai_seat:*",
"ai_seat:create", "ai_seat:create",
"ai_seat:read", "ai_seat:read",
@@ -13147,6 +13150,9 @@
"x-enum-varnames": [ "x-enum-varnames": [
"APIKeyScopeAll", "APIKeyScopeAll",
"APIKeyScopeApplicationConnect", "APIKeyScopeApplicationConnect",
"APIKeyScopeAiModelPriceAll",
"APIKeyScopeAiModelPriceRead",
"APIKeyScopeAiModelPriceUpdate",
"APIKeyScopeAiSeatAll", "APIKeyScopeAiSeatAll",
"APIKeyScopeAiSeatCreate", "APIKeyScopeAiSeatCreate",
"APIKeyScopeAiSeatRead", "APIKeyScopeAiSeatRead",
@@ -19479,6 +19485,7 @@
"type": "string", "type": "string",
"enum": [ "enum": [
"*", "*",
"ai_model_price",
"ai_seat", "ai_seat",
"aibridge_interception", "aibridge_interception",
"api_key", "api_key",
@@ -19526,6 +19533,7 @@
], ],
"x-enum-varnames": [ "x-enum-varnames": [
"ResourceWildcard", "ResourceWildcard",
"ResourceAiModelPrice",
"ResourceAiSeat", "ResourceAiSeat",
"ResourceAibridgeInterception", "ResourceAibridgeInterception",
"ResourceApiKey", "ResourceApiKey",
+7
View File
@@ -46,6 +46,7 @@ import (
"github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/coderd/agentapi" "github.com/coder/coder/v2/coderd/agentapi"
"github.com/coder/coder/v2/coderd/agentapi/metadatabatcher" "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/aiseats"
_ "github.com/coder/coder/v2/coderd/apidoc" // Used for swagger docs. _ "github.com/coder/coder/v2/coderd/apidoc" // Used for swagger docs.
"github.com/coder/coder/v2/coderd/appearance" "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)) 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 // AGPL uses a no-op build usage checker as there are no license
// entitlements to enforce. This is swapped out in // entitlements to enforce. This is swapped out in
// enterprise/coderd/coderd.go. // enterprise/coderd/coderd.go.
+4
View File
@@ -6,6 +6,10 @@ type CheckConstraint string
// CheckConstraint enums. // CheckConstraint enums.
const ( 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 CheckAPIKeysAllowListNotEmpty CheckConstraint = "api_keys_allow_list_not_empty" // api_keys
CheckChatModelConfigsCompressionThresholdCheck CheckConstraint = "chat_model_configs_compression_threshold_check" // chat_model_configs CheckChatModelConfigsCompressionThresholdCheck CheckConstraint = "chat_model_configs_compression_threshold_check" // chat_model_configs
CheckChatModelConfigsContextLimitCheck CheckConstraint = "chat_model_configs_context_limit_check" // chat_model_configs CheckChatModelConfigsContextLimitCheck CheckConstraint = "chat_model_configs_context_limit_check" // chat_model_configs
+15
View File
@@ -626,6 +626,7 @@ var (
}, },
rbac.ResourceApiKey.Type: {policy.ActionRead}, // Validate API keys. rbac.ResourceApiKey.Type: {policy.ActionRead}, // Validate API keys.
rbac.ResourceAibridgeInterception.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, 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. rbac.ResourceAiSeat.Type: {policy.ActionCreate}, // Required for UpsertAISeatState.
}), }),
User: []rbac.Permission{}, User: []rbac.Permission{},
@@ -2480,6 +2481,13 @@ func (q *querier) GetAIBridgeUserPromptsByInterceptionID(ctx context.Context, in
return q.db.GetAIBridgeUserPromptsByInterceptionID(ctx, interceptionID) 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) { func (q *querier) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) {
return fetch(q.log, q.auth, q.db.GetAPIKeyByID)(ctx, id) 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) 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) { func (q *querier) UpsertAISeatState(ctx context.Context, arg database.UpsertAISeatStateParams) (bool, error) {
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceAiSeat); err != nil { if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceAiSeat); err != nil {
return false, err return false, err
+10
View File
@@ -6126,6 +6126,16 @@ func (s *MethodTestSuite) TestAIBridge() {
db.EXPECT().DeleteOldAIBridgeRecords(gomock.Any(), t).Return(int64(0), nil).AnyTimes() db.EXPECT().DeleteOldAIBridgeRecords(gomock.Any(), t).Return(int64(0), nil).AnyTimes()
check.Args(t).Asserts(rbac.ResourceAibridgeInterception, policy.ActionDelete) 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() { func (s *MethodTestSuite) TestTelemetry() {
+17
View File
@@ -5,6 +5,7 @@ package dbmetrics
import ( import (
"context" "context"
"encoding/json"
"slices" "slices"
"time" "time"
@@ -976,6 +977,14 @@ func (m queryMetricsStore) GetAIBridgeUserPromptsByInterceptionID(ctx context.Co
return r0, r1 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) { func (m queryMetricsStore) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) {
start := time.Now() start := time.Now()
r0, r1 := m.s.GetAPIKeyByID(ctx, id) r0, r1 := m.s.GetAPIKeyByID(ctx, id)
@@ -5368,6 +5377,14 @@ func (m queryMetricsStore) UpdateWorkspacesTTLByTemplateID(ctx context.Context,
return r0 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) { func (m queryMetricsStore) UpsertAISeatState(ctx context.Context, arg database.UpsertAISeatStateParams) (bool, error) {
start := time.Now() start := time.Now()
r0, r1 := m.s.UpsertAISeatState(ctx, arg) r0, r1 := m.s.UpsertAISeatState(ctx, arg)
+30
View File
@@ -11,6 +11,7 @@ package dbmock
import ( import (
context "context" context "context"
json "encoding/json"
reflect "reflect" reflect "reflect"
time "time" 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) 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. // GetAPIKeyByID mocks base method.
func (m *MockStore) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) { func (m *MockStore) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) {
m.ctrl.T.Helper() 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) 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. // UpsertAISeatState mocks base method.
func (m *MockStore) UpsertAISeatState(ctx context.Context, arg database.UpsertAISeatStateParams) (bool, error) { func (m *MockStore) UpsertAISeatState(ctx context.Context, arg database.UpsertAISeatStateParams) (bool, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
+24 -1
View File
@@ -223,7 +223,10 @@ CREATE TYPE api_key_scope AS ENUM (
'chat:*', 'chat:*',
'ai_seat:*', 'ai_seat:*',
'ai_seat:create', '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 ( CREATE TYPE app_sharing_level AS ENUM (
@@ -1061,6 +1064,23 @@ BEGIN
END; 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 ( CREATE TABLE ai_seat_state (
user_id uuid NOT NULL, user_id uuid NOT NULL,
first_used_at timestamp with time zone 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 ALTER TABLE ONLY workspace_agent_stats
ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id); 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 ALTER TABLE ONLY ai_seat_state
ADD CONSTRAINT ai_seat_state_pkey PRIMARY KEY (user_id); ADD CONSTRAINT ai_seat_state_pkey PRIMARY KEY (user_id);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS ai_model_prices CASCADE;
@@ -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';
@@ -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);
+22 -1
View File
@@ -227,6 +227,9 @@ const (
ApiKeyScopeAiSeat APIKeyScope = "ai_seat:*" ApiKeyScopeAiSeat APIKeyScope = "ai_seat:*"
ApiKeyScopeAiSeatCreate APIKeyScope = "ai_seat:create" ApiKeyScopeAiSeatCreate APIKeyScope = "ai_seat:create"
ApiKeyScopeAiSeatRead APIKeyScope = "ai_seat:read" 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 { func (e *APIKeyScope) Scan(src interface{}) error {
@@ -473,7 +476,10 @@ func (e APIKeyScope) Valid() bool {
ApiKeyScopeChat, ApiKeyScopeChat,
ApiKeyScopeAiSeat, ApiKeyScopeAiSeat,
ApiKeyScopeAiSeatCreate, ApiKeyScopeAiSeatCreate,
ApiKeyScopeAiSeatRead: ApiKeyScopeAiSeatRead,
ApiKeyScopeAiModelPrice,
ApiKeyScopeAiModelPriceRead,
ApiKeyScopeAiModelPriceUpdate:
return true return true
} }
return false return false
@@ -689,6 +695,9 @@ func AllAPIKeyScopeValues() []APIKeyScope {
ApiKeyScopeAiSeat, ApiKeyScopeAiSeat,
ApiKeyScopeAiSeatCreate, ApiKeyScopeAiSeatCreate,
ApiKeyScopeAiSeatRead, ApiKeyScopeAiSeatRead,
ApiKeyScopeAiModelPrice,
ApiKeyScopeAiModelPriceRead,
ApiKeyScopeAiModelPriceUpdate,
} }
} }
@@ -4307,6 +4316,18 @@ type APIKey struct {
AllowList AllowList `db:"allow_list" json:"allow_list"` 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 { type AiSeatState struct {
UserID uuid.UUID `db:"user_id" json:"user_id"` UserID uuid.UUID `db:"user_id" json:"user_id"`
FirstUsedAt time.Time `db:"first_used_at" json:"first_used_at"` FirstUsedAt time.Time `db:"first_used_at" json:"first_used_at"`
+6
View File
@@ -6,6 +6,7 @@ package database
import ( import (
"context" "context"
"encoding/json"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -244,6 +245,7 @@ type sqlcQuerier interface {
GetAIBridgeTokenUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeTokenUsage, error) GetAIBridgeTokenUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeTokenUsage, error)
GetAIBridgeToolUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeToolUsage, error) GetAIBridgeToolUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeToolUsage, error)
GetAIBridgeUserPromptsByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeUserPrompt, 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) GetAPIKeyByID(ctx context.Context, id string) (APIKey, error)
// there is no unique constraint on empty token names // there is no unique constraint on empty token names
GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNameParams) (APIKey, error) GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNameParams) (APIKey, error)
@@ -1239,6 +1241,10 @@ type sqlcQuerier interface {
UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error
UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDormantDeletingAtByTemplateIDParams) ([]WorkspaceTable, error) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDormantDeletingAtByTemplateIDParams) ([]WorkspaceTable, error)
UpdateWorkspacesTTLByTemplateID(ctx context.Context, arg UpdateWorkspacesTTLByTemplateIDParams) 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. // Returns true if a new rows was inserted, false otherwise.
UpsertAISeatState(ctx context.Context, arg UpsertAISeatStateParams) (bool, error) UpsertAISeatState(ctx context.Context, arg UpsertAISeatStateParams) (bool, error)
UpsertAnnouncementBanners(ctx context.Context, value string) error UpsertAnnouncementBanners(ctx context.Context, value string) error
+55
View File
@@ -1787,6 +1787,61 @@ func (q *sqlQuerier) UpdateAIBridgeInterceptionEnded(ctx context.Context, arg Up
return i, err 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 const getActiveAISeatCount = `-- name: GetActiveAISeatCount :one
SELECT SELECT
COUNT(*) COUNT(*)
+26
View File
@@ -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;
+1
View File
@@ -7,6 +7,7 @@ type UniqueConstraint string
// UniqueConstraint enums. // UniqueConstraint enums.
const ( const (
UniqueAgentStatsPkey UniqueConstraint = "agent_stats_pkey" // ALTER TABLE ONLY workspace_agent_stats ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id); 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); 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); 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); UniqueAibridgeTokenUsagesPkey UniqueConstraint = "aibridge_token_usages_pkey" // ALTER TABLE ONLY aibridge_token_usages ADD CONSTRAINT aibridge_token_usages_pkey PRIMARY KEY (id);
+9
View File
@@ -15,6 +15,14 @@ var (
Type: "*", Type: "*",
} }
// ResourceAiModelPrice
// Valid Actions
// - "ActionRead" :: read AI model prices
// - "ActionUpdate" :: update AI model prices
ResourceAiModelPrice = Object{
Type: "ai_model_price",
}
// ResourceAiSeat // ResourceAiSeat
// Valid Actions // Valid Actions
// - "ActionCreate" :: record AI seat usage // - "ActionCreate" :: record AI seat usage
@@ -441,6 +449,7 @@ var (
func AllResources() []Objecter { func AllResources() []Objecter {
return []Objecter{ return []Objecter{
ResourceWildcard, ResourceWildcard,
ResourceAiModelPrice,
ResourceAiSeat, ResourceAiSeat,
ResourceAibridgeInterception, ResourceAibridgeInterception,
ResourceApiKey, ResourceApiKey,
+6
View File
@@ -392,6 +392,12 @@ var RBACPermissions = map[string]PermissionDefinition{
ActionCreate: "create aibridge interceptions & related records", 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": { "ai_seat": {
Actions: map[Action]ActionDefinition{ Actions: map[Action]ActionDefinition{
ActionCreate: "record AI seat usage", ActionCreate: "record AI seat usage",
+9
View File
@@ -1121,6 +1121,15 @@ func TestRolePermissions(t *testing.T) {
false: {owner, setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin}, 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", Name: "ChatUsageCRU",
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate},
+6
View File
@@ -7,6 +7,8 @@ package rbac
// declared in code, not here, to avoid duplication. // declared in code, not here, to avoid duplication.
const ( const (
ScopeAiModelPriceRead ScopeName = "ai_model_price:read"
ScopeAiModelPriceUpdate ScopeName = "ai_model_price:update"
ScopeAiSeatCreate ScopeName = "ai_seat:create" ScopeAiSeatCreate ScopeName = "ai_seat:create"
ScopeAiSeatRead ScopeName = "ai_seat:read" ScopeAiSeatRead ScopeName = "ai_seat:read"
ScopeAibridgeInterceptionCreate ScopeName = "aibridge_interception:create" ScopeAibridgeInterceptionCreate ScopeName = "aibridge_interception:create"
@@ -173,6 +175,8 @@ func (e ScopeName) Valid() bool {
case ScopeName("coder:all"), case ScopeName("coder:all"),
ScopeName("coder:application_connect"), ScopeName("coder:application_connect"),
ScopeName("no_user_data"), ScopeName("no_user_data"),
ScopeAiModelPriceRead,
ScopeAiModelPriceUpdate,
ScopeAiSeatCreate, ScopeAiSeatCreate,
ScopeAiSeatRead, ScopeAiSeatRead,
ScopeAibridgeInterceptionCreate, ScopeAibridgeInterceptionCreate,
@@ -340,6 +344,8 @@ func AllScopeNameValues() []ScopeName {
ScopeName("coder:all"), ScopeName("coder:all"),
ScopeName("coder:application_connect"), ScopeName("coder:application_connect"),
ScopeName("no_user_data"), ScopeName("no_user_data"),
ScopeAiModelPriceRead,
ScopeAiModelPriceUpdate,
ScopeAiSeatCreate, ScopeAiSeatCreate,
ScopeAiSeatRead, ScopeAiSeatRead,
ScopeAibridgeInterceptionCreate, ScopeAibridgeInterceptionCreate,
+3
View File
@@ -6,6 +6,9 @@ const (
APIKeyScopeAll APIKeyScope = "all" APIKeyScopeAll APIKeyScope = "all"
// Deprecated: use codersdk.APIKeyScopeCoderApplicationConnect instead. // Deprecated: use codersdk.APIKeyScopeCoderApplicationConnect instead.
APIKeyScopeApplicationConnect APIKeyScope = "application_connect" 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:*" APIKeyScopeAiSeatAll APIKeyScope = "ai_seat:*"
APIKeyScopeAiSeatCreate APIKeyScope = "ai_seat:create" APIKeyScopeAiSeatCreate APIKeyScope = "ai_seat:create"
APIKeyScopeAiSeatRead APIKeyScope = "ai_seat:read" APIKeyScopeAiSeatRead APIKeyScope = "ai_seat:read"
+2
View File
@@ -5,6 +5,7 @@ type RBACResource string
const ( const (
ResourceWildcard RBACResource = "*" ResourceWildcard RBACResource = "*"
ResourceAiModelPrice RBACResource = "ai_model_price"
ResourceAiSeat RBACResource = "ai_seat" ResourceAiSeat RBACResource = "ai_seat"
ResourceAibridgeInterception RBACResource = "aibridge_interception" ResourceAibridgeInterception RBACResource = "aibridge_interception"
ResourceApiKey RBACResource = "api_key" ResourceApiKey RBACResource = "api_key"
@@ -78,6 +79,7 @@ const (
// said resource type. // said resource type.
var RBACResourceActions = map[RBACResource][]RBACAction{ var RBACResourceActions = map[RBACResource][]RBACAction{
ResourceWildcard: {}, ResourceWildcard: {},
ResourceAiModelPrice: {ActionRead, ActionUpdate},
ResourceAiSeat: {ActionCreate, ActionRead}, ResourceAiSeat: {ActionCreate, ActionRead},
ResourceAibridgeInterception: {ActionCreate, ActionRead, ActionUpdate}, ResourceAibridgeInterception: {ActionCreate, ActionRead, ActionUpdate},
ResourceApiKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceApiKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
+20 -20
View File
@@ -193,10 +193,10 @@ Status Code **200**
#### Enumerated Values #### Enumerated Values
| Property | Value(s) | | 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` | | `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` | | `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). To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -326,10 +326,10 @@ Status Code **200**
#### Enumerated Values #### Enumerated Values
| Property | Value(s) | | 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` | | `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` | | `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). To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -459,10 +459,10 @@ Status Code **200**
#### Enumerated Values #### Enumerated Values
| Property | Value(s) | | 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` | | `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` | | `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). To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -554,10 +554,10 @@ Status Code **200**
#### Enumerated Values #### Enumerated Values
| Property | Value(s) | | 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` | | `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` | | `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). To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -960,9 +960,9 @@ Status Code **200**
#### Enumerated Values #### Enumerated Values
| Property | Value(s) | | 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` | | `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` | | `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). To perform this operation, you must be authenticated. [Learn more](authentication.md).
+6 -6
View File
@@ -1391,9 +1391,9 @@
#### Enumerated Values #### Enumerated Values
| Value(s) | | 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` | | `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 ## codersdk.AddLicenseRequest
@@ -10371,9 +10371,9 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
#### Enumerated Values #### Enumerated Values
| Value(s) | | 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` | | `*`, `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 ## codersdk.RateLimitConfig
+5 -5
View File
@@ -856,11 +856,11 @@ Status Code **200**
#### Enumerated Values #### Enumerated Values
| Property | Value(s) | | 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` | | `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` | | `login_type` | `github`, `oidc`, `password`, `token` |
| `scope` | `all`, `application_connect` | | `scope` | `all`, `application_connect` |
To perform this operation, you must be authenticated. [Learn more](authentication.md). To perform this operation, you must be authenticated. [Learn more](authentication.md).
+209
View File
@@ -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 &micros
}
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
}
+162
View File
@@ -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 }
+4
View File
@@ -8,6 +8,10 @@ import type { RBACAction, RBACResource } from "./typesGenerated";
export const RBACResourceActions: Partial< export const RBACResourceActions: Partial<
Record<RBACResource, Partial<Record<RBACAction, string>>> Record<RBACResource, Partial<Record<RBACAction, string>>>
> = { > = {
ai_model_price: {
read: "read AI model prices",
update: "update AI model prices",
},
ai_seat: { ai_seat: {
create: "record AI seat usage", create: "record AI seat usage",
read: "read AI seat state", read: "read AI seat state",
+8
View File
@@ -343,6 +343,9 @@ export interface APIKey {
// From codersdk/apikey.go // From codersdk/apikey.go
export type APIKeyScope = export type APIKeyScope =
| "ai_model_price:*"
| "ai_model_price:read"
| "ai_model_price:update"
| "ai_seat:*" | "ai_seat:*"
| "ai_seat:create" | "ai_seat:create"
| "ai_seat:read" | "ai_seat:read"
@@ -555,6 +558,9 @@ export type APIKeyScope =
| "workspace:update_agent"; | "workspace:update_agent";
export const APIKeyScopes: APIKeyScope[] = [ export const APIKeyScopes: APIKeyScope[] = [
"ai_model_price:*",
"ai_model_price:read",
"ai_model_price:update",
"ai_seat:*", "ai_seat:*",
"ai_seat:create", "ai_seat:create",
"ai_seat:read", "ai_seat:read",
@@ -6334,6 +6340,7 @@ export const RBACActions: RBACAction[] = [
// From codersdk/rbacresources_gen.go // From codersdk/rbacresources_gen.go
export type RBACResource = export type RBACResource =
| "ai_model_price"
| "ai_seat" | "ai_seat"
| "aibridge_interception" | "aibridge_interception"
| "api_key" | "api_key"
@@ -6381,6 +6388,7 @@ export type RBACResource =
| "workspace_proxy"; | "workspace_proxy";
export const RBACResources: RBACResource[] = [ export const RBACResources: RBACResource[] = [
"ai_model_price",
"ai_seat", "ai_seat",
"aibridge_interception", "aibridge_interception",
"api_key", "api_key",