feat: add ai_model_prices table - #24932
Conversation
4c529cb to
f272062
Compare
b70d9b2 to
2e8db6b
Compare
e041202 to
c778bc9
Compare
c778bc9 to
d169366
Compare
| // 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 { |
There was a problem hiding this comment.
Prices will be loaded in both enterprise and AGPL, I think it's fine?
There was a problem hiding this comment.
According to internal discussion, it is:
https://codercom.slack.com/archives/C096PFVBZKN/p1778201099153079
78b3169 to
01143f2
Compare
01143f2 to
3d8ef2a
Compare
Emyrk
left a comment
There was a problem hiding this comment.
Idk how much of my review made it through this github downtime
|
Github is having issues. Can the primary key just be |
There was a problem hiding this comment.
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.
| return nil, xerrors.Errorf("status %d", resp.StatusCode) | ||
| } | ||
|
|
||
| body, err := io.ReadAll(resp.Body) |
There was a problem hiding this comment.
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)
🤖
There was a problem hiding this comment.
Fixed here: e184309
100MiB limit, current file is ~2MiB
| }, | ||
| 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. |
There was a problem hiding this comment.
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)
🤖
There was a problem hiding this comment.
I think future reads may happen in user context, not in AsAIBridged context.
ssncferreira
left a comment
There was a problem hiding this comment.
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
| // 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{ |
There was a problem hiding this comment.
I think this is an important check and should have its own test and not be coupled to a fresh database test.
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
Shouldn't we also add this action to the aibridge daemon? 🤔
There was a problem hiding this comment.
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.
| 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)) |
There was a problem hiding this comment.
nit: consider using log.Printf for timestamp and consistent prefix
There was a problem hiding this comment.
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.
| "provider": "openai", | ||
| "model": "chatgpt-image-latest", | ||
| "input_price": null, | ||
| "output_price": null, | ||
| "cache_read_price": null, | ||
| "cache_write_price": null |
There was a problem hiding this comment.
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? 🤔
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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)?
There was a problem hiding this comment.
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?
| 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"` | ||
| } |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
require.Empty/Zero for missing prices?
There was a problem hiding this comment.
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(), |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Why ::jsonb type is used? Maybe it is me but wouldn't normal fields be clearer / easier? Seeder already unmarshals json?
There was a problem hiding this comment.
Discussed offline. ::jsonb seems reasonable middle ground between performance and convenience (due to lacking batching support in postgresql driver).
There was a problem hiding this comment.
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 | |||
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
maybe db.UpsertAIModelPrices(ctx, data) -> db.UpsertAIModelPrices(ctx, rows) (https://github.com/coder/coder/pull/24932/changes#r3208437056)
There was a problem hiding this comment.
same see Batching the AI model price seed: three approaches section
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_pricestable from an embedded JSON price book (~70 rows today, will grow as we add providers, potentially 4000+).Each row is:
Any of the four price columns can be:
NULL→ “price unknown for this dimension”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
Pros
sql.NullInt64.Cons
Nround-trips per seed.Approach 2 —
UNNESTwith parallel arraysPass each column as a separate Go slice. Postgres unnests them in parallel into a virtual table, then
INSERT ... SELECT.Go side: flatten rows into six parallel slices.
Use a sentinel (
-1) for “missing”, sincelib/pqcan't encodeNULLinto abigint[]element.Pros
Cons
sqlcparams become plain[]int64, which can't representNULL.Approach 3 —
jsonb_array_elementsover a single@seed::jsonb(chosen)Pass the raw seed JSON as one parameter; let Postgres expand and parse it.
Go side reduces to:
Pros
(elem->>'cache_write_price')::bigintbecomesNULLjsonb_array_elementsis already used elsewhere in the codebaseCons
UNNESTUNNESTjsonb_array_elements->>extraction syntaxDecision
We picked Approach 3.
It collapses the round-trips like
UNNESTdoes, but without: