feat: add experimental CLI to price unpriced AI models by ssncferreira · Pull Request #27926 · coder/coder · GitHub
Skip to content

feat: add experimental CLI to price unpriced AI models - #27926

Merged
ssncferreira merged 7 commits into
mainfrom
ssncf/ai-model-prices-cli
Aug 13, 2026
Merged

feat: add experimental CLI to price unpriced AI models#27926
ssncferreira merged 7 commits into
mainfrom
ssncf/ai-model-prices-cli

Conversation

@ssncferreira

@ssncferreira ssncferreira commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Description

AI Gateway computes the cost of an interception from ai_model_prices, which is seeded on every server start from a price book embedded in the binary. A model the price book does not cover records a NULL cost, so its spend is invisible to cost reporting and is not enforced against budgets. The only fix was to wait for a Coder release that added the model.

This adds an experimental CLI, backed by an experimental HTTP endpoint, for pricing those models. Models the price book already covers are rejected, because the seeder re-applies the book on every start and would overwrite an operator price. Support for custom pricing will be handled in https://linear.app/codercom/issue/AIGOV-589/extend-experimental-cli-command-to-set-custom-ai-model-prices.

Commands

coder exp ai-model-prices list [--provider] [--model]
coder exp ai-model-prices update [file|-] [--provider] [--model] [--input-price] [--output-price] [--cache-read-price] [--cache-write-price] [--yes]

Changes

  • Add GET and POST /api/experimental/ai/model-prices, gated behind the AI Bridge entitlement and the existing ai_model_price RBAC resource.
  • Add a GetAIModelPrices query with optional provider and model filters applied in SQL.
  • Validate the whole request before writing anything, so one bad entry cannot leave the table half updated, and report every problem at once.
  • Reject prices for models the embedded price book already covers, through a new prices.IsDefaultPriced.
  • Add the coder exp ai-model-prices command with list and update. update accepts a JSON document or the single-model flags and prints a plan, asking to confirm unless the document is piped in or --yes is passed.
  • Consolidate the supported provider list into coderd/aibridge/prices/providers so the price generator and the server share one definition.
  • Add codersdk types and client methods for both endpoints, and bound the request body at 1 MiB.
  • Document the command in the AI Gateway cost controls page.

Closes https://linear.app/codercom/issue/AIGOV-567/experimental-cli-command-to-set-prices-for-unpriced-ai-models

Note

Initially generated by Claude Opus 5, modified and reviewed by @ssncferreira

Copy link
Copy Markdown
Contributor Author

@linear-code

linear-code Bot commented Aug 10, 2026

Copy link
Copy Markdown

AIGOV-567

@ssncferreira
ssncferreira force-pushed the ssncf/ai-model-prices-cli branch 2 times, most recently from 9c98399 to b137245 Compare August 11, 2026 10:08
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Docs preview

Check off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here.

@ssncferreira
ssncferreira force-pushed the ssncf/ai-model-prices-cli branch 3 times, most recently from d4fb46f to 97f7500 Compare August 11, 2026 16:42
@ssncferreira

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Chat: Review posted | View chat
Requested: 2026-08-11 16:42 UTC by @ssncferreira

Review history
  • R1 (2026-08-11): 17 reviewers, 7 Nit, 2 Note, 1 P2, 11 P3, COMMENT. Review

deep-review v0.9.0 | Round 1 | b721935..97f7500

Last posted: Round 1, 21 findings (1 P2, 11 P3, 7 Nit, 2 Note), COMMENT. Review

Finding inventory

Finding inventory

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P3 Open enterprise/cli/exp_aimodelprices_test.go:323 require.Contains(t, stdout.String(), "-") passes off the model name table-model, so a formatMicros(nil) regression is not caught R1 Netero Yes
CRF-2 P2 Open enterprise/coderd/aimodelprices.go:67 upsertAIModelPrices reads the body with io.ReadAll and no MaxBytesReader; the body is decoded twice and re-marshalled, so one authenticated owner can force multi-copy allocation of an arbitrarily large body R1 Hisoka P2, Meruem P3, Chopper P3, Pariston Note, Razor Nit Yes
CRF-3 P3 Open coderd/aibridge/prices/prices.go:39 The startup seeder silently overwrites operator-set prices when a later Coder release adds that model to the embedded book; no log, no audit, no way to restore R1 Mafuuu P3, Pariston P3, Razor P3, Luffy Note Yes
CRF-4 P3 Open docs/ai-coder/ai-gateway/cost-controls.md:236 Docs frame null and 0 as distinct outcomes but the cost path treats them identically, and setting any price silences the unpriced metric even for null categories R1 Razor Yes
CRF-5 P3 Open enterprise/cli/exp_aimodelprices.go:241 Piping a document to update skips the Apply? prompt without --yes; drained stdin is silently treated as consent R1 Pariston P3, Kite Note Yes
CRF-6 P3 Open enterprise/coderd/coderd.go:344 PUT /api/experimental/ai/model-prices runs partial-update semantics on a plural collection URL and there is no DELETE; contract locked at stabilization R1 Ryosuke Yes
CRF-7 P3 Open enterprise/coderd/aimodelprices.go:79 "All four price keys required" rule creates permanent operator friction and drags in the second decoder for no real gain R1 Luffy Yes
CRF-8 P3 Open enterprise/coderd/aimodelprices.go:84 "Request body must be valid JSON." fires when JSON is valid but the shape is wrong; the test at aimodelprices_test.go:113 locks in the misleading message R1 Leorio P3, Chopper Nit Yes
CRF-9 P3 Open enterprise/cli/exp_aimodelprices.go:497 The four price key names are hardcoded string literals in three places; a JSON tag rename compiles cleanly and silently produces NULLs at write time R1 Gon P3, Ryosuke Note, Meruem Note Yes
CRF-10 P3 Open enterprise/coderd/aimodelprices.go:124 Every InternalServerError failure path drops user_id / model list, so the interim audit trail has no record of who tried what when the endpoint fails R1 Chopper Yes
CRF-11 P3 Open enterprise/cli/exp_aimodelprices_test.go:44 The change-preview branch of update is never asserted; a regression in describePriceChanges or the "N to change" summary ships silently R1 Bisky P3, Kite Nit Yes
CRF-12 P3 Open enterprise/cli/exp_aimodelprices.go:394 diffAIModelPrices reports a ~ change for a default-priced model the server will reject; the preview promises a write that never happens R1 Mafuuu Yes
CRF-13 Nit Open enterprise/cli/exp_aimodelprices.go:62 // For JSON format: / // For table format: narrate what the field tags already declare R1 Gon (P2 downgraded to Nit) Yes
CRF-14 Nit Open coderd/aibridge/prices/prices.go:22 The "three places" comment is stale; codersdk.AIModelPriceUpsert is a fourth copy of the same JSON contract R1 Mafu-san Nit, Meruem Note Yes
CRF-15 Nit Open enterprise/coderd/aimodelprices.go:253 Duplicate-detection key uses provider + "/" + model concatenation; collides on strings that span the boundary, inert today R1 Hisoka Yes
CRF-16 Nit Open enterprise/coderd/aimodelprices_internal_test.go:81 Uses upstream-sourced claude-opus-5 where the PR already established claude-mythos-5 as the drift-safe stand-in; same fix at exp_aimodelprices_test.go:243 R1 Bisky Yes
CRF-17 Nit Open coderd/aibridge/prices/prices.go:71 IsDefaultPriced is exported from the AGPL prices package but has one caller (enterprise handler); AGPL surface grows for an enterprise-only concern R1 Ryosuke Yes
CRF-18 Nit Open enterprise/cli/exp_aimodelprices.go:287 setModelPriceFlags reads as imperative but queries which flags were supplied; caller binds the return to setPrices and the parameter name repeats the confusion R1 Gon Yes
CRF-19 Nit Open enterprise/cli/exp_aimodelprices.go:510 1_000_000 micro-units-per-dollar scale factor is inline in two places with no name R1 Gon Yes
CRF-20 Note Open enterprise/cli/exp_aimodelprices.go:509 formatMicros renders as %.2f; real prices under $0.01/mtok all collapse to $0.00 in the table view, so distinct rows look identical R1 Razor Yes
CRF-21 Note Open docs/ai-coder/ai-gateway/cost-controls.md:214 The workflow starts with "find models you haven't priced yet" but the CLI has no way to answer that question; list --unpriced would close the loop R1 Luffy Yes

Contested and acknowledged

(none yet)

Law analysis

  • Round: R1
  • Head SHA: 97f7500
  • Effective LOC: 2151 (1022 production, 1129 test, 478 generated)
  • Verdict: Don't split
  • Enforcement: Advisory
  • Rationale: One vertical feature end-to-end; no independent risk domain bundled; only surgical extraction (provider list consolidation) is 20 LOC and does not remove danger from the rest. Horizontal cuts would leave dangling in-tree stubs and split the validation contract between endpoint and CLI.

Round log

Round 1

Panel. Reviewed against b721935..97f7500. Netero + Law ran in parallel; Netero returned P3 only so the panel gate opened. 17-reviewer panel (Bisky, Hisoka, Mafu-san, Mafuuu, Pariston, Komugi, Gon, Leorio, Chopper, Ging-go, Kite, Knuckle, Kurapika, Luffy, Ryosuke) + wildcards (Meruem, Razor). New: 1 P2, 11 P3, 7 Nit, 2 Note. Komugi, Ging-go, Knuckle, Kurapika returned no findings; Kurapika chained the auth verification and Kite added four praise notes.

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

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

Solid feature end-to-end: RBAC and entitlement gates wire through cleanly, the WritesNothingWhenAnyEntryIsInvalid test proves the batch atomicity claim, and consolidating the provider list into one package retires an old duplication. Test density is 52.5% and the TestValidateAIModelPrices matrix is thorough.

Severity spread: 0 P0, 0 P1, 1 P2, 11 P3, 7 Nit, 2 Note. The P2 is the missing MaxBytesReader on the upsert body, which is the single common pattern used by every other JSON-body endpoint in this repo. The most consequential P3 is the silent one-way overwrite that runs when a later Coder release adds a model to the seed that an operator has already priced; three reviewers converged on it and it is worth naming in the docs and adding a startup warning even before AIGOV-589 lands. PUT on a plural collection URL with partial-update semantics also deserves a decision before the endpoint graduates out of exp.

A note from Kurapika on the chain review: "No TOCTOU. IsDefaultPriced is served from a sync.OnceValue over the compile-time embedded seed. prices.Seed runs once in coderd.New before the router accepts requests; there is no mid-lifecycle re-seed that could race an operator write."

Law ran and returned "Don't split, Advisory." Effective LOC 2151 across four layers all serving the same feature; the only clean vertical carve is the provider list consolidation, which is too small to justify its own PR, and horizontal cuts would split the validation contract between the endpoint and the CLI.


coderd/aibridge/prices/prices.go:39

P3 [CRF-3] The startup seeder silently overwrites operator-set prices when a later Coder release adds that model to the embedded book; no log, no audit, no way to restore. (Mafuuu P3, Pariston P3, Razor P3, Luffy Note)

validateAIModelPrices refuses a price for any model in the embedded book, "so a price set for a model it covers would not survive a restart." Seed re-applies the embedded book on every startup, and the IS DISTINCT FROM guard in queries/aicostcontrol.sql only skips rewriting when the columns already match. Take an operator who prices anthropic/foo today because Coder has no price for it, and priced it because their contract is $2/mtok, not the models.dev list price. When the next Coder release adds anthropic/foo to the embedded book, restart runs the seeder, IS DISTINCT FROM fires, the row is rewritten to the book's price with a fresh updated_at, and cost reports for that model change.

IsDefaultPriced guards write-time only, so the sequence is: (1) Operator prices anthropic/new-model at their negotiated rate. (2) Next Coder release adds anthropic/new-model to the seed. (3) Operator upgrades. Seed() overwrites their price with the vendor default. No warning, no log line naming the changed rows, no audit event. The startup log fires only on failure.

Orchestrator: three P3s converged on the same lifecycle mismatch. AIGOV-589 tracks the deeper fix (source column so the seeder only touches its own rows). Two cheap mitigations land inside this PR: log at Warn from the seeder when a (provider, model) row already exists with prices that differ from the seed's before overwriting it, and add a paragraph to docs/ai-coder/ai-gateway/cost-controls.md alongside "Prices are not retroactive." Neither replaces the ticket; both let an operator notice.

🤖

coderd/aibridge/prices/prices.go:22

Nit [CRF-14] The "three places" comment is stale; codersdk.AIModelPriceUpsert is a fourth copy of the same JSON contract. (Mafu-san Nit, Meruem Note)

The comment says the seed contract lives in three places (the seeder struct here, the generator's priceRow, and the SQL extraction). This PR adds codersdk.AIModelPriceUpsert with the same six json tags. The upsert handler does json.Marshal(req.Prices) on the typed slice and passes the bytes to UpsertAIModelPrices, so any field this struct does not know about is silently dropped from the wire, unlike the seed path where the raw file bytes flow through to SQL. That makes AIModelPriceUpsert the strictest gate of the four.

Orchestrator: update the comment here and the mirror at scripts/aibridgepricesgen/main.go:66 to name codersdk.AIModelPriceUpsert as the fourth place, or the next author adding a price column will update three copies and drop the field on the new PUT endpoint. This bites the same target as CRF-9; both are cheap in one edit.

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread enterprise/coderd/aimodelprices.go
Comment thread docs/ai-coder/ai-gateway/cost-controls.md
Comment thread enterprise/cli/exp_aimodelprices.go
Comment thread enterprise/coderd/coderd.go Outdated
Comment thread enterprise/coderd/aimodelprices.go
Comment thread enterprise/cli/exp_aimodelprices.go Outdated
Comment thread enterprise/cli/exp_aimodelprices.go Outdated
Comment thread enterprise/cli/exp_aimodelprices.go
Comment thread enterprise/cli/exp_aimodelprices_test.go
@@ -212,7 +212,46 @@ Replace `<VERSION>` with your Coder minor version, for example `2.36`.
Monitor `coder_ai_gateway_cost_control_unpriced_token_usage_records_total`,
labeled by `provider` and `model`, to detect unpriced usage. Any non-zero value
means spend is under-counted. Because the price book ships with the release, a

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 [CRF-21] The workflow starts with "find models you haven't priced yet" but the CLI has no way to answer that question. (Luffy)

The docs page routes the operator through metrics: monitor coder_ai_gateway_cost_control_unpriced_token_usage_records_total, read the provider and model labels off it, type them into coder exp ai-model-prices update. The CLI has no way to answer the question which models has this deployment seen traffic on that don't have a price yet?, which is the only question that leads to using this command at all.

Orchestrator: not this PR's job to build the query. Worth naming as the obvious next step so it doesn't get lost. A list --unpriced (or a subcommand that reads recent interception rows and diffs them against ai_model_prices) would close the loop.

🤖

@ssncferreira ssncferreira Aug 12, 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.

This is an interesting idea 🤔 my only concern is that this would process a lot of interceptions (depending on the retention period), meaning this could be a potentially heavy operation. We would need to investigate this better.

@ssncferreira
ssncferreira force-pushed the ssncf/ai-model-prices-cli branch 2 times, most recently from 434f269 to ef0ce63 Compare August 12, 2026 14:07
// IsDefaultPriced reports whether the embedded price book already carries a
// price for the model. Coder owns those prices and re-applies them on every
// startup, so an operator price set for one would not survive a restart.
func IsDefaultPriced(provider, model string) bool {

@ssncferreira ssncferreira Aug 12, 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.

Initially, I thought we would need to restore the models removed between 2.35 and 2.36. However, I think this is not required. After the patch lands, the CLI will only be available after 2.36, meaning that whatever was released on the price book is what is considered a model with "default" source (once AIGOV-589 lands), meaning that if operators decide to add custom values for removed models, these will be considered "custom".

So we have 2 paths going forward:

  1. Only consider prices that were available on 2.36 as default, meaning that if an operator changes a model that was available on 2.35 this will be considered a "custom" model.
  2. Consider all prices from when the prices table was first introduced in 2.35. However, if we do decide this route, we need to restore these models in the patch as well. Otherwise, operators might change these models via the CLI, and then once we introduce the previously removed models and the source column, these models would be considered "default" and would overwrite the prices defined by the operator.

Given that cost control was officially released in 2.36 and the cost reporting via the effective group is only available from 2.36 (even though raw interception cost without a group was released in 2.35, but all the data returned by the endpoints always guarantees the effective group constraint), and that it is unlikely that deprecated models will still be used, I would say option 1 is an acceptable approach.

However, I think this brings up another topic on how we should handle "deprecated" models going forward: should we remove them from the price book? Because this would mean that for customers that skip a version, some models might be considered "unpriced", while for customers that do not skip any versions, they would be considered "default" (since we never delete models from the database). In conclusion, this would mean that we could end up with different customers with different default models in their databases. Wdyt?

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.

I think it's acceptable for those models.
However, it might be simpler to just keep deprecated models around so that we are more likely to have a consistent list of prices. It's not a huge amount of data.

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.

Discussed internally and we decided that it is ok to not keep removed models in the price book. With the introduction of custom pricing, customers will be able to introduce models not built-in if needed.

@ssncferreira
ssncferreira force-pushed the ssncf/ai-model-prices-cli branch from ef0ce63 to ef37d59 Compare August 12, 2026 16:11
Comment on lines +240 to +241
// An entry sets all four columns, so an absent key would clear that
// price rather than leave it alone.

@ssncferreira ssncferreira Aug 12, 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.

We explicitly require all price fields to be included in the input for each model to avoid potential confusion about whether the missing fields are updated to null or kept as in the row. Similarly to the price book always including all fields, with null expressing unknown values.

_, err = fmt.Fprintln(inv.Stdout, "No changes to apply.")
return err
}
printAIModelPriceChanges(inv, additions, changes)

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.

Actual content validation (like price book models, provider as a supported provider, non-negative prices, etc) is done server-side, while on the client side we only do flag validation. Therefore, we show a little plan (similar to terraform plan) before sending the request upstream.

Plan: 1 to change.
  ~ anthropic/my-model
      cache_write_price  $0.000005 -> -
> Apply? (yes/no)

But the resultant plan may fail. I would prefer avoiding moving certain validation client side. I think this is acceptable, but let me know what you think

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.

I think we should avoid client-side validation; it tends to get out of sync.

@ssncferreira
ssncferreira marked this pull request as ready for review August 12, 2026 16:30
Comment thread coderd/aibridge/prices/providers/providers.go Outdated
Comment thread enterprise/coderd/aimodelprices.go Outdated
Comment thread codersdk/aimodelprices.go Outdated

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.

I think it's acceptable for those models.
However, it might be simpler to just keep deprecated models around so that we are more likely to have a consistent list of prices. It's not a huge amount of data.

@ssncferreira
ssncferreira force-pushed the ssncf/ai-model-prices-cli branch from ef37d59 to 2c6d22c Compare August 13, 2026 12:29
@ssncferreira
ssncferreira merged commit 2d9b6ed into main Aug 13, 2026
32 checks passed
@ssncferreira
ssncferreira deleted the ssncf/ai-model-prices-cli branch August 13, 2026 14:00
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 13, 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.

2 participants