🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

feat: add ai_model_prices table - #24932

Merged
evgeniy-scherbina merged 32 commits into
mainfrom
yevhenii/cost-control-price-table
May 8, 2026
Merged

feat: add ai_model_prices table#24932
evgeniy-scherbina merged 32 commits into
mainfrom
yevhenii/cost-control-price-table

Conversation

@evgeniy-scherbina

@evgeniy-scherbina evgeniy-scherbina commented May 4, 2026

Copy link
Copy Markdown
Contributor

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, 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:

(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

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.

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.

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.

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:

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

Comment thread coderd/database/migrations/000489_ai_model_prices.up.sql
@evgeniy-scherbina
evgeniy-scherbina force-pushed the yevhenii/cost-control-price-table branch from 4c529cb to f272062 Compare May 4, 2026 17:59
Comment thread scripts/aibridgepricesgen/main.go Outdated
Comment thread scripts/aibridgepricesgen/main.go
@evgeniy-scherbina
evgeniy-scherbina force-pushed the yevhenii/cost-control-price-table branch 5 times, most recently from b70d9b2 to 2e8db6b Compare May 5, 2026 16:37
@evgeniy-scherbina
evgeniy-scherbina force-pushed the yevhenii/cost-control-price-table branch 2 times, most recently from e041202 to c778bc9 Compare May 5, 2026 19:49
@evgeniy-scherbina
evgeniy-scherbina force-pushed the yevhenii/cost-control-price-table branch from c778bc9 to d169366 Compare May 5, 2026 20:00
Comment thread coderd/coderd.go Outdated
// embedded JSON is corrupted, which is a release-time bug worth crashing
// on rather than silently disabling cost tracking.
//nolint:gocritic // No user request context here, so use a system-restricted context.
if err := prices.Load(dbauthz.AsSystemRestricted(ctx), options.Database); err != nil {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prices will be loaded in both enterprise and AGPL, I think it's fine?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@evgeniy-scherbina
evgeniy-scherbina force-pushed the yevhenii/cost-control-price-table branch from 78b3169 to 01143f2 Compare May 6, 2026 12:59
Comment thread coderd/database/dbauthz/dbauthz.go
@evgeniy-scherbina
evgeniy-scherbina force-pushed the yevhenii/cost-control-price-table branch from 01143f2 to 3d8ef2a Compare May 6, 2026 14:08
@evgeniy-scherbina
evgeniy-scherbina marked this pull request as ready for review May 6, 2026 14:11
@evgeniy-scherbina
evgeniy-scherbina requested a review from Emyrk as a code owner May 6, 2026 14:11
@evgeniy-scherbina evgeniy-scherbina changed the title feat(coderd/database): add ai_model_prices table feat: add ai_model_prices table May 6, 2026

@Emyrk Emyrk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idk how much of my review made it through this github downtime

Comment thread coderd/database/dbauthz/dbauthz.go
@Emyrk

Emyrk commented May 6, 2026

Copy link
Copy Markdown
Member

Github is having issues. Can the primary key just be (provider,model)? instead of an arbitrary ID?

Comment thread coderd/database/migrations/000489_ai_model_prices.up.sql

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good foundation for AI Bridge cost controls. The table design, RBAC wiring, and startup integration are well-structured. The test suite is genuinely strong: 5 subtests against real Postgres covering fresh seed, idempotency, overwrite (including NULL replacing non-NULL), orphan preservation, and the full dbauthz auth chain. The generator's missing-provider error path and the self-correction from silent skip to hard error (c825ce7) show good defensive thinking.

4 P2, 7 P3, 2 P4, 3 Nit. The P2s cluster around two themes: validation gaps that let bad data through silently (empty seed, upstream schema drift), and a misleading migration comment that contradicts the seeder's actual null semantics.

Melody walked 20 wire-format pairings end-to-end and found zero mismatches across JSON tags, SQLC params, RBAC actions, TypeScript generated types, and constraint enumerations. That is clean plumbing.

"A function that does nothing also does not delete the orphan." (Bisky, on the LeavesOrphanRowsUntouched test)

Process note: the commit history shows four separate "ci: fix linter" round-trips (emdash, xerrors, nolint, and missing ALTER TYPE). Each would have been caught by make lint or make gen locally. The project rules are explicit about running hooks before pushing. The code is correct now, but the CI cost was avoidable.

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/database/migrations/000489_ai_model_prices.up.sql Outdated
Comment thread coderd/aibridge/prices/prices.go Outdated
Comment thread scripts/aibridgepricesgen/main.go
Comment thread coderd/coderd.go Outdated
Comment thread scripts/aibridgepricesgen/main.go Outdated
Comment thread scripts/aibridgepricesgen/main.go Outdated
return nil, xerrors.Errorf("status %d", resp.StatusCode)
}

body, err := io.ReadAll(resp.Body)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P4 [DEREM-12] io.ReadAll(resp.Body) reads the entire upstream response into memory with no size limit. A compromised or misbehaving models.dev could serve an arbitrarily large payload. This is a developer-only build tool, not runtime code, so the blast radius is a developer machine or CI runner. io.LimitReader(resp.Body, 10<<20) is trivial and prevents unbounded reads.

(Kurapika P3, Kite P4)

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed here: e184309

100MiB limit, current file is ~2MiB

Comment thread coderd/database/migrations/000489_ai_model_prices.up.sql Outdated
Comment thread coderd/database/queries/aicostcontrol.sql
Comment thread coderd/aibridge/prices/prices.go Outdated
},
rbac.ResourceApiKey.Type: {policy.ActionRead}, // Validate API keys.
rbac.ResourceAibridgeInterception.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
rbac.ResourceAiModelPrice.Type: {policy.ActionUpdate}, // Required for the startup price seeder.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note [DEREM-22] subjectAibridged grants ActionUpdate but not ActionRead for ResourceAiModelPrice. Correct for the startup seeder, which only writes. But future cost-computation code will need to read prices, likely under the same AsAIBridged context. The next PR adding cost tracking will need to add ActionRead here.

(Hisoka, Mafuuu)

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think future reads may happen in user context, not in AsAIBridged context.

@ssncferreira ssncferreira left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall LGTM, but I think we should address some concerns, mainly the individual INSERT ... ON CONFLICT statements in a transaction.

nit: Maybe we could have split this into 2 PRs to facilitate the review process, one for the script and another one for the coder/aibridge related changes

Comment thread coderd/aibridge/prices/data/prices.json
Comment thread coderd/aibridge/prices/prices.go Outdated
Comment thread coderd/aibridge/prices/prices.go Outdated
Comment thread coderd/aibridge/prices/prices.go Outdated
Comment on lines +41 to +43
// 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{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is an important check and should have its own test and not be coupled to a fresh database test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SeedsFreshDatabase subtest covers two important cases:

  • model with all prices set (e.g. Anthropic)
  • model with some prices are null (e.g. OpenAI)

Do you want separate subtest for null case?

},
"ai_model_price": {
Actions: map[Action]ActionDefinition{
ActionRead: "read AI model prices",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we also add this action to the aibridge daemon? 🤔

@evgeniy-scherbina evgeniy-scherbina May 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The aibridge daemon doesn’t need read access for now since it only seeds the initial prices and doesn’t read them. It’s unclear whether it will need this in the future - read access might instead happen in the context of user/admin queries?

GetAIModelPriceByProviderModel (read method) is currently only used in tests.

But I can still add it if you think it make sense—I don’t think it hurts, especially since we’ve already granted write permissions to the aibridge daemon.

Comment thread coderd/coderd.go Outdated
Comment thread scripts/aibridgepricesgen/main.go Outdated
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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: consider using log.Printf for timestamp and consistent prefix

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think timestamps aren't useful for a one-shot build tool. aibridgepricesgen runs in seconds, prints two lines, and exits.

Also I see that _, _ = fmt.Fprintf is heavily used in scripts folder.

Comment thread coderd/aibridge/prices/data/prices.json Outdated
Comment on lines +187 to +192
"provider": "openai",
"model": "chatgpt-image-latest",
"input_price": null,
"output_price": null,
"cache_read_price": null,
"cache_write_price": null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if we should have an extra layer of filtering, because this for instance, is an image model I believe. Maybe a good filter will be to discard models where all prices are null, wdyt? 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's already done as part of other comment. But it only discarded 4 out of 75 models.


`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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will there be a way for operator to update / overwrite the prices manually? If so will manual override have priority over default (and won't be over written)?

@evgeniy-scherbina evgeniy-scherbina May 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will there be a way for operator to update / overwrite the prices manually?

yes, but in Phase 2, IIRC it's out of scope for current RFC

If so will manual override have priority over default

yes

That said, I haven’t thought much about the implementation details yet. One approach could be to track whether the admin manually updated the prices, and if so, ignore prices.Seed() afterward?

Comment on lines +22 to +29
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"`
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a avid supporter of single source of truth. Comments never work, linter rule (if modified then modify other place) would be ok as a backup. IMO some pricerow package in scripts directory that exports priceRow struct would be worth it.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

require.Empty/Zero for missing prices?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean check require.Zero(t, got.CacheWritePrice.Int64) on top of require.False(t, got.CacheWritePrice.Valid)? If so - I did it.

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(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is creation time needed?

@evgeniy-scherbina evgeniy-scherbina May 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was in RFC, so I kept it. It shows when for 1st time specific provider/model pair was registered, maybe useful, considering upstream data may change over time.

(elem->>'output_price')::bigint,
(elem->>'cache_read_price')::bigint,
(elem->>'cache_write_price')::bigint
FROM jsonb_array_elements(@seed::jsonb) AS elem

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why ::jsonb type is used? Maybe it is me but wouldn't normal fields be clearer / easier? Seeder already unmarshals json?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed offline. ::jsonb seems reasonable middle ground between performance and convenience (due to lacking batching support in postgresql driver).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a big discussion, I updated PR description, see Batching the AI model price seed: three approaches section

@@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Bridge -> AI Gateway

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you want to rename everything:

  • package name
  • make target
  • etc.
    ?

return q.db.UpdateWorkspacesTTLByTemplateID(ctx, arg)
}

func (q *querier) UpsertAIModelPrices(ctx context.Context, seed json.RawMessage) error {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mentioned already in coderd/database/queries/aimodelprices.sql but why seed is json.RawMessage type? I feel it is much more error prone then using eg. []priceRow type.

@evgeniy-scherbina evgeniy-scherbina May 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same see Batching the AI model price seed: three approaches section

if len(rows) == 0 {
return xerrors.New("price seed is empty")
}
return db.UpsertAIModelPrices(ctx, data)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe db.UpsertAIModelPrices(ctx, data) -> db.UpsertAIModelPrices(ctx, rows) (https://github.com/coder/coder/pull/24932/changes#r3208437056)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same see Batching the AI model price seed: three approaches section

@evgeniy-scherbina
evgeniy-scherbina merged commit 4124d11 into main May 8, 2026
48 of 52 checks passed
@evgeniy-scherbina
evgeniy-scherbina deleted the yevhenii/cost-control-price-table branch May 8, 2026 20:45
@github-actions github-actions Bot locked and limited conversation to collaborators May 8, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants