feat: report unpriced AI models to owners (#28419)

Closes
[AIGOV-568](https://linear.app/codercom/issue/AIGOV-568/notify-admins-about-unpriced-ai-models).

AI Bridge records token usage for a model with no price at a NULL cost,
so its spend is neither reported nor enforced against any budget. Until
now that only reached admins through an info log and a Prometheus
metric.

Owners now receive a weekly report listing models used without a price
in the past week. The report links to documentation explaining how Coder
calculates spend from the default price book and how owners can
configure missing prices with `coder exp ai-model-prices`.

## Screenshots

<img width="726" height="531" alt="image"
src="https://github.com/user-attachments/assets/168ae378-60cb-4c96-8f60-36c6edc0b268"
/>
<img width="470" height="417" alt="image"
src="https://github.com/user-attachments/assets/7dc71a1e-0c57-44f1-86f7-df800603c865"
/>



## Design

**Derived, not tracked.** The unpriced set is computed at report time
from interceptions that recorded token usage, joined against the current
`ai_model_prices` table. Requests that produced no token usage are
excluded.

**No new loop or table.** `reportUnpricedAIModels` is a sibling of
`reportFailedWorkspaceBuilds` in the existing report generator, reusing
its ticker and `notification_report_generator_logs`. Each report runs in
its own transaction under a separate advisory lock, so failures and lock
contention do not couple otherwise independent reports. The weekly
frequency is enforced by the persisted timestamp rather than by the
ticker, which restarts with the process and runs on a different phase in
each replica.

## Behaviour

| Situation | Outcome |
|---|---|
| Model used without a price | Listed in the next weekly report |
| Price set | Disappears from the next report |
| Still unpriced and still in use | Reported again each week |
| Model stops being used | Drops out of the report |
| Nothing unpriced | No notification; window still advances |
| More than 100 unpriced models | Top 100 by usage, with the total
reported alongside |
| openai-compat models | Excluded, they cannot be priced |
| Many replicas | Exactly one report per week |
| First ever run | Reports models used in the preceding week immediately
|

## Design choices

1. **openai-compat** is excluded because it cannot be priced and would
produce permanent, unactionable notifications.
2. **Token volume** orders the list but is not shown. The 100-model cap
therefore prioritizes models responsible for the most unpriced usage.
3. **Placement** remains in the generic report generator rather than
`aibridgedserver`. This keeps one periodic-report lifecycle at the cost
of coupling the notifications package to an AI cost-control query.
4. **No license gate.** The reporter runs even when AI Gateway is
disabled. With no recorded token usage the query is empty and no
notification is sent.

---

Created by Coder Agents on behalf of @evgeniy-scherbina.
This commit is contained in:
Yevhenii Shcherbina
2026-08-27 17:04:13 -04:00
committed by GitHub
parent ac95e7b743
commit 0bc2858b6e
19 changed files with 917 additions and 28 deletions
+5 -4
View File
@@ -1141,10 +1141,6 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
// nolint:gocritic // We need to run the manager in a notifier context.
notificationsManager.Run(dbauthz.AsNotifier(ctx))
// Run report generator to distribute periodic reports.
notificationReportGenerator := reports.NewReportGenerator(ctx, logger.Named("notifications.report_generator"), options.Database, options.NotificationsEnqueuer, quartz.NewReal())
defer notificationReportGenerator.Close()
// We use a separate coderAPICloser so the Enterprise API
// can have its own close functions. This is cleaner
// than abstracting the Coder API itself.
@@ -1173,6 +1169,11 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
// Must run after newAPI so options.Database is dbcrypt-wrapped.
coderd.BackfillBedrockProviderType(aibridgeInitCtx, options.Database, logger.Named("aibridge.backfill"))
// Run report generator to distribute periodic reports.
// Must run after newAPI so prices and providers are initialized.
notificationReportGenerator := reports.NewReportGenerator(ctx, logger.Named("notifications.report_generator"), options.Database, options.NotificationsEnqueuer, quartz.NewReal())
defer notificationReportGenerator.Close()
// In-memory aibridge daemon. Registered on coderd so chatd can
// dispatch LLM requests via the in-process transport without
// crossing the gated /api/v2/ai-gateway HTTP route. The HTTP route
+7
View File
@@ -5146,6 +5146,13 @@ func (q *querier) GetUnexpiredLicenses(ctx context.Context) ([]database.License,
return q.db.GetUnexpiredLicenses(ctx)
}
func (q *querier) GetUnpricedAIModelsSince(ctx context.Context, arg database.GetUnpricedAIModelsSinceParams) ([]database.GetUnpricedAIModelsSinceRow, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAiModelPrice); err != nil {
return nil, err
}
return q.db.GetUnpricedAIModelsSince(ctx, arg)
}
func (q *querier) GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAIBudgetOverride, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUserObject(userID)); err != nil {
return database.UserAIBudgetOverride{}, err
+5
View File
@@ -7133,6 +7133,11 @@ func (s *MethodTestSuite) TestAIBridge() {
check.Args(database.GetAIModelPricesParams{}).Asserts(rbac.ResourceAiModelPrice, policy.ActionRead)
}))
s.Run("GetUnpricedAIModelsSince", s.Mocked(func(db *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
db.EXPECT().GetUnpricedAIModelsSince(gomock.Any(), gomock.Any()).Return([]database.GetUnpricedAIModelsSinceRow{}, nil).AnyTimes()
check.Args(database.GetUnpricedAIModelsSinceParams{}).Asserts(rbac.ResourceAiModelPrice, policy.ActionRead)
}))
s.Run("GetOrganizationGroupsAISpend", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
org := testutil.Fake(s.T(), faker, database.Organization{})
row1 := testutil.Fake(s.T(), faker, database.GetOrganizationGroupsAISpendRow{OrganizationID: org.ID})
+8
View File
@@ -3232,6 +3232,14 @@ func (m queryMetricsStore) GetUnexpiredLicenses(ctx context.Context) ([]database
return r0, r1
}
func (m queryMetricsStore) GetUnpricedAIModelsSince(ctx context.Context, since database.GetUnpricedAIModelsSinceParams) ([]database.GetUnpricedAIModelsSinceRow, error) {
start := time.Now()
r0, r1 := m.s.GetUnpricedAIModelsSince(ctx, since)
m.queryLatencies.WithLabelValues("GetUnpricedAIModelsSince").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUnpricedAIModelsSince").Inc()
return r0, r1
}
func (m queryMetricsStore) GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAIBudgetOverride, error) {
start := time.Now()
r0, r1 := m.s.GetUserAIBudgetOverride(ctx, userID)
+15
View File
@@ -6089,6 +6089,21 @@ func (mr *MockStoreMockRecorder) GetUnexpiredLicenses(ctx any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUnexpiredLicenses", reflect.TypeOf((*MockStore)(nil).GetUnexpiredLicenses), ctx)
}
// GetUnpricedAIModelsSince mocks base method.
func (m *MockStore) GetUnpricedAIModelsSince(ctx context.Context, arg database.GetUnpricedAIModelsSinceParams) ([]database.GetUnpricedAIModelsSinceRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUnpricedAIModelsSince", ctx, arg)
ret0, _ := ret[0].([]database.GetUnpricedAIModelsSinceRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetUnpricedAIModelsSince indicates an expected call of GetUnpricedAIModelsSince.
func (mr *MockStoreMockRecorder) GetUnpricedAIModelsSince(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUnpricedAIModelsSince", reflect.TypeOf((*MockStore)(nil).GetUnpricedAIModelsSince), ctx, arg)
}
// GetUserAIBudgetOverride mocks base method.
func (m *MockStore) GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAIBudgetOverride, error) {
m.ctrl.T.Helper()
+1
View File
@@ -19,6 +19,7 @@ const (
// Deprecated: Reserved to prevent reuse. Do not use at runtime.
LockIDChatModelConfigWrites
LockIDChatCapacityAdmission
LockIDNotifyUnpricedAIModels
)
// Per-setting advisory lock IDs for the chat instruction settings. These
+2
View File
@@ -32,6 +32,8 @@ func TestChatInstructionLockIDsDistinct(t *testing.T) {
"LockIDBoundaryUsageStats": LockIDBoundaryUsageStats,
"LockIDAIProvidersEnvSeed": LockIDAIProvidersEnvSeed,
"LockIDChatModelConfigWrites": LockIDChatModelConfigWrites,
"LockIDChatCapacityAdmission": LockIDChatCapacityAdmission,
"LockIDNotifyUnpricedAIModels": LockIDNotifyUnpricedAIModels,
}
// The two generated IDs are pairwise distinct.
@@ -0,0 +1,2 @@
DELETE FROM notification_templates
WHERE id = '1b7d9fa7-f5a8-4e46-8078-5cf53abfed94';
@@ -0,0 +1,30 @@
INSERT INTO notification_templates (
id,
name,
title_template,
body_template,
actions,
"group",
method,
kind,
enabled_by_default
)
VALUES (
'1b7d9fa7-f5a8-4e46-8078-5cf53abfed94',
'Missing AI Model Prices',
E'Missing AI Model Prices',
$$These models were used in the last {{.Data.report_frequency}}, but they have no price, so their usage is missing from AI spend and does not count toward any AI budget. Reported spend is lower than actual, and a user who calls only these models has no effective limit.
{{range $model := .Data.models}}
* {{$model.provider}}/{{$model.model}}
{{- end}}
{{if .Data.truncated}}
{{len .Data.models}} of {{.Data.total_count}} models with no price are shown, ordered by usage.
{{end}}
Every Coder release ships with prices for most models, so only the models above need one: see [how spend is calculated](https://coder.com/docs/ai-coder/ai-gateway/cost-controls#how-spend-is-calculated) and [how to configure prices](https://coder.com/docs/ai-coder/ai-gateway/cost-controls#configure-model-prices). Prices are not retroactive, so usage recorded before you set a price stays unpriced.$$,
'[]'::jsonb,
'AI Cost Control Admin Events',
NULL,
'system'::notification_template_kind,
true
);
+3
View File
@@ -910,6 +910,9 @@ type sqlcQuerier interface {
// rollup and accept day-granularity bounds.
GetTotalUsageHBAgentRuntimeV1(ctx context.Context, arg GetTotalUsageHBAgentRuntimeV1Params) (int64, error)
GetUnexpiredLicenses(ctx context.Context) ([]License, error)
// Returns the models used since the given time that hold no price, most used
// first. openai-compat providers cannot be priced, so their models are excluded.
GetUnpricedAIModelsSince(ctx context.Context, arg GetUnpricedAIModelsSinceParams) ([]GetUnpricedAIModelsSinceRow, error)
GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (UserAIBudgetOverride, error)
GetUserAIProviderKeyByProviderID(ctx context.Context, arg GetUserAIProviderKeyByProviderIDParams) (UserAIProviderKey, error)
// GetUserAIProviderKeys is used by dbcrypt key rotation. Request paths should use
+74
View File
@@ -19315,6 +19315,80 @@ func TestOAuth2ProviderScopeNotEmpty(t *testing.T) {
})
}
func TestGetUnpricedAIModelsSince(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, _ := dbtestutil.NewDB(t)
now := dbtime.Now()
initiator := dbgen.User(t, db, database.User{})
anthropic := dbgen.AIProvider(t, db, database.AIProvider{
Name: "anthropic",
Type: database.AIProviderTypeAnthropic,
})
openai := dbgen.AIProvider(t, db, database.AIProvider{
Name: "openai",
Type: database.AIProviderTypeOpenai,
})
seedUsage := func(provider database.AIProvider, model string, startedAt time.Time, inputTokens, outputTokens int64, cost sql.NullInt64) {
interception := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: initiator.ID,
Provider: string(provider.Type),
ProviderName: provider.Name,
Model: model,
StartedAt: startedAt,
}, nil)
dbgen.AIBridgeTokenUsage(t, db, database.InsertAIBridgeTokenUsageParams{
InterceptionID: interception.ID,
InputTokens: inputTokens,
OutputTokens: outputTokens,
CostMicros: cost,
CreatedAt: startedAt,
})
}
// Included and aggregated into one result with 500 total tokens.
seedUsage(anthropic, "model-a", now, 200, 100, sql.NullInt64{})
seedUsage(anthropic, "model-a", now, 100, 100, sql.NullInt64{})
// Included after model-a because it has only 200 total tokens.
seedUsage(anthropic, "model-b", now, 100, 100, sql.NullInt64{})
// Excluded because its recorded cost is not NULL.
seedUsage(anthropic, "costed-model", now, 100, 100, sql.NullInt64{Int64: 1, Valid: true})
// Excluded because its interception started before the requested window.
seedUsage(anthropic, "old-model", now.Add(-2*time.Hour), 100, 100, sql.NullInt64{})
// Excluded because OpenAI is not in the supplied priceable provider list.
seedUsage(openai, "excluded-provider-model", now, 100, 100, sql.NullInt64{})
// Excluded because an interception without token usage does not represent
// model usage.
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: initiator.ID,
Provider: string(anthropic.Type),
ProviderName: anthropic.Name,
Model: "model-without-usage",
StartedAt: now,
}, nil)
// Excluded because a current price exists for this provider and model.
const pricedSeed = `[{"provider":"anthropic","model":"priced-model","input_price":1,"output_price":2,"cache_read_price":null,"cache_write_price":null}]`
require.NoError(t, db.UpsertAIModelPrices(ctx, database.UpsertAIModelPricesParams{
Seed: []byte(pricedSeed),
Source: database.AIModelPriceSourceCustom,
}))
seedUsage(anthropic, "priced-model", now, 100, 100, sql.NullInt64{})
got, err := db.GetUnpricedAIModelsSince(ctx, database.GetUnpricedAIModelsSinceParams{
Since: now.Add(-time.Hour),
PriceableProviders: []string{string(database.AIProviderTypeAnthropic)},
})
require.NoError(t, err)
require.Equal(t, []database.GetUnpricedAIModelsSinceRow{
{ProviderType: "anthropic", Model: "model-a", TokenCount: 500},
{ProviderType: "anthropic", Model: "model-b", TokenCount: 200},
}, got)
}
func TestGetAIModelPriceByProviderModel(t *testing.T) {
t.Parallel()
+65
View File
@@ -3410,6 +3410,71 @@ func (q *sqlQuerier) GetOverBudgetUsersPerGroup(ctx context.Context, periodStart
return items, nil
}
const getUnpricedAIModelsSince = `-- name: GetUnpricedAIModelsSince :many
SELECT
providers.type::text AS provider_type,
interceptions.model AS model,
SUM(
token_usages.input_tokens
+ token_usages.output_tokens
+ token_usages.cache_read_input_tokens
+ token_usages.cache_write_input_tokens
)::bigint AS token_count
FROM aibridge_interceptions AS interceptions
JOIN aibridge_token_usages AS token_usages
ON token_usages.interception_id = interceptions.id
JOIN ai_providers AS providers
ON providers.name = interceptions.provider_name
AND providers.deleted = false
WHERE interceptions.started_at >= $1::timestamptz
AND token_usages.cost_micros IS NULL
AND providers.type::text = ANY($2::text[])
AND NOT EXISTS (
SELECT 1
FROM ai_model_prices AS prices
WHERE prices.provider = providers.type::text
AND prices.model = interceptions.model
)
GROUP BY providers.type, interceptions.model
ORDER BY token_count DESC, provider_type ASC, model ASC
`
type GetUnpricedAIModelsSinceParams struct {
Since time.Time `db:"since" json:"since"`
PriceableProviders []string `db:"priceable_providers" json:"priceable_providers"`
}
type GetUnpricedAIModelsSinceRow struct {
ProviderType string `db:"provider_type" json:"provider_type"`
Model string `db:"model" json:"model"`
TokenCount int64 `db:"token_count" json:"token_count"`
}
// Returns the models used since the given time that hold no price, most used
// first. openai-compat providers cannot be priced, so their models are excluded.
func (q *sqlQuerier) GetUnpricedAIModelsSince(ctx context.Context, arg GetUnpricedAIModelsSinceParams) ([]GetUnpricedAIModelsSinceRow, error) {
rows, err := q.db.QueryContext(ctx, getUnpricedAIModelsSince, arg.Since, pq.Array(arg.PriceableProviders))
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetUnpricedAIModelsSinceRow
for rows.Next() {
var i GetUnpricedAIModelsSinceRow
if err := rows.Scan(&i.ProviderType, &i.Model, &i.TokenCount); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getUserAIBudgetOverride = `-- name: GetUserAIBudgetOverride :one
SELECT user_id, group_id, spend_limit_micros, created_at, updated_at
FROM user_ai_budget_overrides
+30
View File
@@ -482,3 +482,33 @@ GROUP BY
ai.provider,
ai.provider_name
ORDER BY ai.initiator_id, tu.effective_group_id, ai.provider, ai.provider_name, ai.model;
-- name: GetUnpricedAIModelsSince :many
-- Returns the models used since the given time that hold no price, most used
-- first. openai-compat providers cannot be priced, so their models are excluded.
SELECT
providers.type::text AS provider_type,
interceptions.model AS model,
SUM(
token_usages.input_tokens
+ token_usages.output_tokens
+ token_usages.cache_read_input_tokens
+ token_usages.cache_write_input_tokens
)::bigint AS token_count
FROM aibridge_interceptions AS interceptions
JOIN aibridge_token_usages AS token_usages
ON token_usages.interception_id = interceptions.id
JOIN ai_providers AS providers
ON providers.name = interceptions.provider_name
AND providers.deleted = false
WHERE interceptions.started_at >= @since::timestamptz
AND token_usages.cost_micros IS NULL
AND providers.type::text = ANY(@priceable_providers::text[])
AND NOT EXISTS (
SELECT 1
FROM ai_model_prices AS prices
WHERE prices.provider = providers.type::text
AND prices.model = interceptions.model
)
GROUP BY providers.type, interceptions.model
ORDER BY token_count DESC, provider_type ASC, model ASC;
+1
View File
@@ -76,4 +76,5 @@ var (
TemplateAIBudgetLimitReachedUser = uuid.MustParse("cdcf2ecd-f003-4169-9800-abb2661ea522")
TemplateAIBudgetWarningAdmin = uuid.MustParse("2a7b0ac1-00e1-4625-9cd5-1e5933972c77")
TemplateAIBudgetLimitReachedAdmin = uuid.MustParse("0bafe0ea-a78b-4217-ad05-1ef12e92e025")
TemplateAIModelsUnpricedReport = uuid.MustParse("1b7d9fa7-f5a8-4e46-8078-5cf53abfed94")
)
@@ -1619,6 +1619,27 @@ func TestNotificationTemplates_Golden(t *testing.T) {
Data: map[string]any{},
},
},
{
name: "TemplateAIModelsUnpricedReport",
id: notifications.TemplateAIModelsUnpricedReport,
payload: types.MessagePayload{
UserName: "Bobby",
UserEmail: "bobby@coder.com",
UserUsername: "bobby",
Labels: map[string]string{},
// We need to use floats as `json.Unmarshal` unmarshal numbers in `map[string]any` to floats.
Data: map[string]any{
"report_frequency": "week",
"models": []map[string]any{
{"provider": "anthropic", "model": "claude-opus-4-8"},
{"provider": "openai", "model": "gpt-5.7"},
{"provider": "openrouter", "model": "z-ai/glm-5.4"},
},
"total_count": 15.0,
"truncated": true,
},
},
},
}
// We must have a test case for every notification_template. This is enforced below:
+130 -24
View File
@@ -12,6 +12,7 @@ import (
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/aibridge/prices/providers"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
@@ -25,6 +26,30 @@ const (
delay = 15 * time.Minute
)
// runReport executes one report in its own transaction, guarded by its own
// advisory lock so that only one replica generates it. A replica that cannot
// take the lock skips this tick and tries again on the next one; how often a
// report is actually sent is enforced by the report itself, against the
// timestamp it persists.
func runReport(ctx context.Context, logger slog.Logger, db database.Store, lockID int64, name string, report func(tx database.Store) error) {
err := db.InTx(func(tx database.Store) error {
ok, err := tx.TryAcquireLock(ctx, lockID)
if err != nil {
return xerrors.Errorf("failed to acquire report lock: %w", err)
}
if !ok {
logger.Debug(ctx, "unable to acquire lock for generating periodic report, skipping", slog.F("report", name))
return nil
}
return report(tx)
}, nil)
if err != nil {
logger.Error(ctx, "failed to generate report", slog.F("report", name), slog.Error(err))
}
}
// NewReportGenerator periodically generates failed workspace build and
// unpriced AI model reports.
func NewReportGenerator(ctx context.Context, logger slog.Logger, db database.Store, enqueuer notifications.Enqueuer, clk quartz.Clock) io.Closer {
closed := make(chan struct{})
@@ -35,32 +60,19 @@ func NewReportGenerator(ctx context.Context, logger slog.Logger, db database.Sto
// Start the ticker with the initial delay.
ticker := clk.NewTicker(delay)
ticker.Stop()
doTick := func(start time.Time) {
doTick := func(_ time.Time) {
defer ticker.Reset(delay)
// Start a transaction to grab advisory lock, we don't want to run generator jobs at the same time (multiple replicas).
if err := db.InTx(func(tx database.Store) error {
// Acquire a lock to ensure that only one instance of the generator is running at a time.
ok, err := tx.TryAcquireLock(ctx, database.LockIDNotificationsReportGenerator)
if err != nil {
return xerrors.Errorf("failed to acquire report generator lock: %w", err)
}
if !ok {
logger.Debug(ctx, "unable to acquire lock for generating periodic reports, skipping")
return nil
}
err = reportFailedWorkspaceBuilds(ctx, logger, tx, enqueuer, clk)
if err != nil {
return xerrors.Errorf("unable to generate reports with failed workspace builds: %w", err)
}
logger.Info(ctx, "report generator finished", slog.F("duration", clk.Since(start)))
return nil
}, nil); err != nil {
logger.Error(ctx, "failed to generate reports", slog.Error(err))
return
}
// Reports are independent, so each runs in its own transaction under
// its own advisory lock. Sharing either would couple them.
runReport(ctx, logger, db, database.LockIDNotificationsReportGenerator, "failed workspace builds",
func(tx database.Store) error {
return reportFailedWorkspaceBuilds(ctx, logger, tx, enqueuer, clk)
})
runReport(ctx, logger, db, database.LockIDNotifyUnpricedAIModels, "unpriced AI models",
func(tx database.Store) error {
return reportUnpricedAIModels(ctx, logger, tx, enqueuer, clk)
})
}
go func() {
@@ -330,3 +342,97 @@ func findTemplateAdmins(ctx context.Context, db database.Store, stats database.G
})
return templateAdmins, nil
}
const (
unpricedAIModelsReportFrequency = 7 * 24 * time.Hour
unpricedAIModelsReportFrequencyLabel = "week"
// unpricedAIModelsLimit caps how many models a single report lists.
// A deployment can accumulate more unpriced models than we want to display
// in a single notification, so the remaining models are reported as a count.
unpricedAIModelsLimit = 100
)
// reportUnpricedAIModels notifies owners about models used without a price
// in the preceding week. Unpriced usage is recorded but contributes nothing to
// spend, so it is neither reported nor enforced against a budget.
//
// The set of unpriced models is derived at report time from interceptions and
// the price table, so setting a price removes the model from the next report.
func reportUnpricedAIModels(ctx context.Context, logger slog.Logger, db database.Store, enqueuer notifications.Enqueuer, clk quartz.Clock) error {
now := clk.Now()
since := now.Add(-unpricedAIModelsReportFrequency)
reportLog, err := db.GetNotificationReportGeneratorLogByTemplate(ctx, notifications.TemplateAIModelsUnpricedReport)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("unable to read report generator log: %w", err)
}
// Check if the job has not been running recently. The ticker alone cannot
// enforce the frequency: it restarts with the process and each replica
// runs on its own phase.
if !reportLog.LastGeneratedAt.IsZero() && reportLog.LastGeneratedAt.Add(unpricedAIModelsReportFrequency).After(now) {
return nil // reports sent recently, no need to send them now
}
// Fetch the models used without a price.
unpricedModels, err := db.GetUnpricedAIModelsSince(ctx, database.GetUnpricedAIModelsSinceParams{
Since: dbtime.Time(since).UTC(),
PriceableProviders: providers.SupportedStrings(),
})
if err != nil {
return xerrors.Errorf("unable to fetch unpriced AI models: %w", err)
}
if len(unpricedModels) > 0 {
owners, err := db.GetUsers(ctx, database.GetUsersParams{
RbacRole: []string{codersdk.RoleOwner},
})
if err != nil {
return xerrors.Errorf("unable to fetch owners: %w", err)
}
reportData := buildDataForReportUnpricedAIModels(unpricedModels)
for _, owner := range owners {
if _, err := enqueuer.EnqueueWithData(ctx, owner.ID, notifications.TemplateAIModelsUnpricedReport,
map[string]string{},
reportData,
"report_generator",
); err != nil {
logger.Warn(ctx, "failed to send a report with unpriced AI models", slog.F("user_id", owner.ID), slog.Error(err))
}
}
}
// Update the timestamp in the generator log. This happens even
// when nothing was reported, so the next report covers one week rather
// than every week since usage was last seen.
err = db.UpsertNotificationReportGeneratorLog(ctx, database.UpsertNotificationReportGeneratorLogParams{
NotificationTemplateID: notifications.TemplateAIModelsUnpricedReport,
LastGeneratedAt: dbtime.Time(now).UTC(),
})
if err != nil {
return xerrors.Errorf("unable to update report generator logs: %w", err)
}
return nil
}
// buildDataForReportUnpricedAIModels renders the models most used first, so
// the models dropped by the limit are the ones with the least unreported
// usage.
func buildDataForReportUnpricedAIModels(unpricedModels []database.GetUnpricedAIModelsSinceRow) map[string]any {
reportedCount := min(len(unpricedModels), unpricedAIModelsLimit)
reportedModels := make([]map[string]any, 0, reportedCount)
for _, row := range unpricedModels[:reportedCount] {
reportedModels = append(reportedModels, map[string]any{
"provider": row.ProviderType,
"model": row.Model,
})
}
return map[string]any{
"report_frequency": unpricedAIModelsReportFrequencyLabel,
"models": reportedModels,
"total_count": len(unpricedModels),
"truncated": len(unpricedModels) > reportedCount,
}
}
@@ -0,0 +1,374 @@
package reports
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/notifications/notificationstest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestReportGenerator_TicksUnpricedAIModels(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
_, logger, db, _, notifEnq, clk := setup(t)
seedOwner(t, db)
// The ticker is reset after each generator run, so this trap signals when
// the immediate or ticker-driven run has finished.
resetTrap := clk.Trap().TickerReset()
defer resetTrap.Close()
generator := NewReportGenerator(ctx, logger, db, notifEnq, clk)
t.Cleanup(func() {
require.NoError(t, generator.Close())
})
// The generator runs once immediately without waiting for the ticker delay.
resetTrap.MustWait(ctx).MustRelease(ctx)
require.Empty(t, notifEnq.Sent())
// The initial run records the current time. Backdate it beyond the weekly
// frequency so the report is eligible on the next 15-minute ticker run.
require.NoError(t, db.UpsertNotificationReportGeneratorLog(ctx, database.UpsertNotificationReportGeneratorLogParams{
NotificationTemplateID: notifications.TemplateAIModelsUnpricedReport,
LastGeneratedAt: clk.Now().Add(-unpricedAIModelsReportFrequency - time.Minute),
}))
seedUnpricedUsage(t, db, "anthropic", database.AIProviderTypeAnthropic, "claude-opus-4-8", clk.Now())
// Advance to the first ticker-driven run and wait for it to finish.
advance := clk.Advance(delay)
resetTrap.MustWait(ctx).MustRelease(ctx)
advance.MustWait(ctx)
sent := notifEnq.Sent()
require.Len(t, sent, 1)
require.Equal(t, notifications.TemplateAIModelsUnpricedReport, sent[0].TemplateID)
}
func TestReportUnpricedAIModels(t *testing.T) {
t.Parallel()
t.Run("FirstRun_ReportsPrecedingWeek", func(t *testing.T) {
t.Parallel()
ctx, logger, db, _, notifEnq, clk := setup(t)
owner := seedOwner(t, db)
seedUnpricedUsage(t, db, "anthropic", database.AIProviderTypeAnthropic, "claude-opus-4-8", clk.Now())
require.NoError(t, reportUnpricedAIModels(ctx, logger, db, notifEnq, clk))
sent := notifEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateAIModelsUnpricedReport))
require.Len(t, sent, 1)
require.Equal(t, owner.ID, sent[0].UserID)
require.Equal(t, "week", sent[0].Data["report_frequency"])
require.Equal(t, []map[string]any{
{"provider": "anthropic", "model": "claude-opus-4-8"},
}, modelsFromPayload(t, sent[0].Data))
require.EqualValues(t, 1, sent[0].Data["total_count"])
require.Equal(t, false, sent[0].Data["truncated"])
})
t.Run("ReportsOnlyOncePerFrequency", func(t *testing.T) {
t.Parallel()
ctx, logger, db, _, notifEnq, clk := setup(t)
seedOwner(t, db)
seedUnpricedUsage(t, db, "anthropic", database.AIProviderTypeAnthropic, "claude-opus-4-8", clk.Now())
require.NoError(t, reportUnpricedAIModels(ctx, logger, db, notifEnq, clk))
require.Len(t, notifEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateAIModelsUnpricedReport)), 1)
notifEnq.Clear()
clk.Advance(time.Hour)
require.NoError(t, reportUnpricedAIModels(ctx, logger, db, notifEnq, clk))
require.Empty(t, notifEnq.Sent())
})
t.Run("StillUnpricedAndInUse_IsReportedAgain", func(t *testing.T) {
t.Parallel()
ctx, logger, db, _, notifEnq, clk := setup(t)
seedOwner(t, db)
initiator := dbgen.User(t, db, database.User{})
provider := seedProvider(t, db, "anthropic", database.AIProviderTypeAnthropic)
seedUnpricedModelUsage(t, db, initiator, provider, "claude-opus-4-8", clk.Now(), 100)
require.NoError(t, reportUnpricedAIModels(ctx, logger, db, notifEnq, clk))
require.Len(t, notifEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateAIModelsUnpricedReport)), 1)
notifEnq.Clear()
clk.Advance(unpricedAIModelsReportFrequency + time.Minute)
seedUnpricedModelUsage(t, db, initiator, provider, "claude-opus-4-8", clk.Now(), 100)
require.NoError(t, reportUnpricedAIModels(ctx, logger, db, notifEnq, clk))
require.Len(t, notifEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateAIModelsUnpricedReport)), 1)
})
t.Run("NoLongerUsed_IsNotReported", func(t *testing.T) {
t.Parallel()
ctx, logger, db, _, notifEnq, clk := setup(t)
seedOwner(t, db)
initiator := dbgen.User(t, db, database.User{})
provider := seedProvider(t, db, "anthropic", database.AIProviderTypeAnthropic)
seedUnpricedModelUsage(t, db, initiator, provider, "claude-opus-4-8", clk.Now(), 100)
require.NoError(t, reportUnpricedAIModels(ctx, logger, db, notifEnq, clk))
require.Len(t, notifEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateAIModelsUnpricedReport)), 1)
notifEnq.Clear()
clk.Advance(unpricedAIModelsReportFrequency + time.Minute)
require.NoError(t, reportUnpricedAIModels(ctx, logger, db, notifEnq, clk))
require.Empty(t, notifEnq.Sent())
})
t.Run("InterceptionWithoutTokenUsage_IsNotReported", func(t *testing.T) {
t.Parallel()
ctx, logger, db, _, notifEnq, clk := setup(t)
seedOwner(t, db)
initiator := dbgen.User(t, db, database.User{})
provider := seedProvider(t, db, "anthropic", database.AIProviderTypeAnthropic)
seedInterception(t, db, initiator, provider, "mistyped-model", clk.Now())
require.NoError(t, reportUnpricedAIModels(ctx, logger, db, notifEnq, clk))
require.Empty(t, notifEnq.Sent())
})
t.Run("HistoricallyCostedUsage_IsNotReported", func(t *testing.T) {
t.Parallel()
ctx, logger, db, _, notifEnq, clk := setup(t)
seedOwner(t, db)
initiator := dbgen.User(t, db, database.User{})
provider := seedProvider(t, db, "anthropic", database.AIProviderTypeAnthropic)
interception := seedInterception(t, db, initiator, provider, "previously-priced-model", clk.Now())
dbgen.AIBridgeTokenUsage(t, db, database.InsertAIBridgeTokenUsageParams{
InterceptionID: interception.ID,
InputTokens: 100,
CostMicros: sql.NullInt64{Int64: 10, Valid: true},
CreatedAt: clk.Now(),
})
require.NoError(t, reportUnpricedAIModels(ctx, logger, db, notifEnq, clk))
require.Empty(t, notifEnq.Sent())
})
t.Run("PricedModel_IsNotReported", func(t *testing.T) {
t.Parallel()
ctx, logger, db, _, notifEnq, clk := setup(t)
seedOwner(t, db)
initiator := dbgen.User(t, db, database.User{})
provider := seedProvider(t, db, "anthropic", database.AIProviderTypeAnthropic)
seedUnpricedModelUsage(t, db, initiator, provider, "claude-opus-4-8", clk.Now(), 100)
seedPrice(ctx, t, db, "anthropic", "claude-opus-4-8")
require.NoError(t, reportUnpricedAIModels(ctx, logger, db, notifEnq, clk))
require.Empty(t, notifEnq.Sent())
})
t.Run("PriceMustMatchProviderAndModel", func(t *testing.T) {
t.Parallel()
ctx, logger, db, _, notifEnq, clk := setup(t)
seedOwner(t, db)
initiator := dbgen.User(t, db, database.User{})
provider := seedProvider(t, db, "anthropic", database.AIProviderTypeAnthropic)
seedUnpricedModelUsage(t, db, initiator, provider, "target-model", clk.Now(), 100)
// The model matches the OpenAI price, and the provider matches the other
// Anthropic price. Neither price matches anthropic/target-model exactly.
seedPrice(ctx, t, db, "openai", "target-model")
seedPrice(ctx, t, db, "anthropic", "different-model")
require.NoError(t, reportUnpricedAIModels(ctx, logger, db, notifEnq, clk))
sent := notifEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateAIModelsUnpricedReport))
require.Len(t, sent, 1)
require.Equal(t, []map[string]any{
{"provider": "anthropic", "model": "target-model"},
}, modelsFromPayload(t, sent[0].Data))
})
t.Run("OpenAICompat_IsNotReported", func(t *testing.T) {
t.Parallel()
ctx, logger, db, _, notifEnq, clk := setup(t)
seedOwner(t, db)
initiator := dbgen.User(t, db, database.User{})
provider := seedProvider(t, db, "self-hosted", database.AIProviderTypeOpenaiCompat)
seedUnpricedModelUsage(t, db, initiator, provider, "llama-4", clk.Now(), 100)
require.NoError(t, reportUnpricedAIModels(ctx, logger, db, notifEnq, clk))
require.Empty(t, notifEnq.Sent())
})
t.Run("ReportsEveryOwner", func(t *testing.T) {
t.Parallel()
ctx, logger, db, _, notifEnq, clk := setup(t)
firstOwner := seedOwner(t, db)
secondOwner := seedOwner(t, db)
member := dbgen.User(t, db, database.User{})
initiator := dbgen.User(t, db, database.User{})
provider := seedProvider(t, db, "anthropic", database.AIProviderTypeAnthropic)
seedUnpricedModelUsage(t, db, initiator, provider, "claude-opus-4-8", clk.Now(), 100)
require.NoError(t, reportUnpricedAIModels(ctx, logger, db, notifEnq, clk))
sent := notifEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateAIModelsUnpricedReport))
require.Len(t, sent, 2)
recipients := []uuid.UUID{sent[0].UserID, sent[1].UserID}
require.Contains(t, recipients, firstOwner.ID)
require.Contains(t, recipients, secondOwner.ID)
require.NotContains(t, recipients, member.ID)
})
t.Run("TruncatesToLimit", func(t *testing.T) {
t.Parallel()
ctx, logger, db, _, notifEnq, clk := setup(t)
seedOwner(t, db)
initiator := dbgen.User(t, db, database.User{})
provider := seedProvider(t, db, "anthropic", database.AIProviderTypeAnthropic)
// Give the first model more token usage so it sorts ahead of the others.
const overflow = 5
seedUnpricedModelUsage(t, db, initiator, provider, "most-used-model", clk.Now(), 1_000)
for i := range unpricedAIModelsLimit + overflow - 1 {
seedUnpricedModelUsage(t, db, initiator, provider, fmt.Sprintf("model-%03d", i), clk.Now(), 100)
}
require.NoError(t, reportUnpricedAIModels(ctx, logger, db, notifEnq, clk))
sent := notifEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateAIModelsUnpricedReport))
require.Len(t, sent, 1)
models := modelsFromPayload(t, sent[0].Data)
require.Len(t, models, unpricedAIModelsLimit)
require.Equal(t, "most-used-model", models[0]["model"])
require.EqualValues(t, unpricedAIModelsLimit+overflow, sent[0].Data["total_count"])
require.Equal(t, true, sent[0].Data["truncated"])
})
t.Run("NothingToReport_AdvancesWindow", func(t *testing.T) {
t.Parallel()
ctx, logger, db, _, notifEnq, clk := setup(t)
seedOwner(t, db)
now := clk.Now()
require.NoError(t, reportUnpricedAIModels(ctx, logger, db, notifEnq, clk))
require.Empty(t, notifEnq.Sent())
reportLog, err := db.GetNotificationReportGeneratorLogByTemplate(ctx, notifications.TemplateAIModelsUnpricedReport)
require.NoError(t, err)
require.True(t, now.Equal(reportLog.LastGeneratedAt))
})
}
func seedOwner(t *testing.T, db database.Store) database.User {
t.Helper()
return dbgen.User(t, db, database.User{
RBACRoles: []string{codersdk.RoleOwner},
})
}
func seedProvider(t *testing.T, db database.Store, name string, providerType database.AIProviderType) database.AIProvider {
t.Helper()
return dbgen.AIProvider(t, db, database.AIProvider{
Name: name,
Type: providerType,
})
}
func seedInterception(t *testing.T, db database.Store, initiator database.User, provider database.AIProvider, model string, startedAt time.Time) database.AIBridgeInterception {
t.Helper()
return dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: initiator.ID,
Provider: string(provider.Type),
ProviderName: provider.Name,
Model: model,
StartedAt: startedAt,
}, nil)
}
func seedUnpricedModelUsage(t *testing.T, db database.Store, initiator database.User, provider database.AIProvider, model string, createdAt time.Time, inputTokens int64) {
t.Helper()
interception := seedInterception(t, db, initiator, provider, model, createdAt)
dbgen.AIBridgeTokenUsage(t, db, database.InsertAIBridgeTokenUsageParams{
InterceptionID: interception.ID,
InputTokens: inputTokens,
CreatedAt: createdAt,
})
}
func seedUnpricedUsage(t *testing.T, db database.Store, providerName string, providerType database.AIProviderType, model string, createdAt time.Time) {
t.Helper()
seedUnpricedModelUsage(t, db, dbgen.User(t, db, database.User{}), seedProvider(t, db, providerName, providerType), model, createdAt, 100)
}
func seedPrice(ctx context.Context, t *testing.T, db database.Store, provider, model string) {
t.Helper()
seed, err := json.Marshal([]map[string]any{{
"provider": provider,
"model": model,
"input_price": 3_000_000,
"output_price": 15_000_000,
"cache_read_price": nil,
"cache_write_price": nil,
}})
require.NoError(t, err)
require.NoError(t, db.UpsertAIModelPrices(ctx, database.UpsertAIModelPricesParams{
Seed: seed,
Source: database.AIModelPriceSourceCustom,
}))
}
func modelsFromPayload(t *testing.T, data map[string]any) []map[string]any {
t.Helper()
models, ok := data["models"].([]map[string]any)
require.True(t, ok, "models missing from report payload")
return models
}
func TestReportUnpricedAIModels_ConcurrentReplicas(t *testing.T) {
t.Parallel()
ctx, logger, db, _, notifEnq, clk := setup(t)
seedOwner(t, db)
initiator := dbgen.User(t, db, database.User{})
provider := seedProvider(t, db, "anthropic", database.AIProviderTypeAnthropic)
seedUnpricedModelUsage(t, db, initiator, provider, "claude-opus-4-8", clk.Now(), 100)
var wg sync.WaitGroup
for range 2 {
wg.Add(1)
go func() {
defer wg.Done()
runReport(ctx, logger, db, database.LockIDNotifyUnpricedAIModels, "unpriced AI models",
func(tx database.Store) error {
return reportUnpricedAIModels(ctx, logger, tx, notifEnq, clk)
})
}()
}
wg.Wait()
require.Len(t, notifEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateAIModelsUnpricedReport)), 1)
}
@@ -0,0 +1,105 @@
From: system@coder.com
To: bobby@coder.com
Subject: Missing AI Model Prices
Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48
Date: Fri, 11 Oct 2024 09:03:06 +0000
Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
MIME-Version: 1.0
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
Content-Type: text/plain; charset=UTF-8
Hi Bobby,
These models were used in the last week, but they have no price, so their u=
sage is missing from AI spend and does not count toward any AI budget. Repo=
rted spend is lower than actual, and a user who calls only these models has=
no effective limit.
anthropic/claude-opus-4-8
openai/gpt-5.7
openrouter/z-ai/glm-5.4
3 of 15 models with no price are shown, ordered by usage.
Every Coder release ships with prices for most models, so only the models a=
bove need one: see how spend is calculated (https://coder.com/docs/ai-coder=
/ai-gateway/cost-controls#how-spend-is-calculated) and how to configure pri=
ces (https://coder.com/docs/ai-coder/ai-gateway/cost-controls#configure-mod=
el-prices). Prices are not retroactive, so usage recorded before you set a =
price stays unpriced.
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
Content-Type: text/html; charset=UTF-8
<!doctype html>
<html lang=3D"en">
<head>
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Missing AI Model Prices</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
l', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617=
; background: #f8fafc;">
<div style=3D"max-width: 600px; margin: 20px auto; padding: 60px; borde=
r: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-alig=
n: left; font-size: 14px; line-height: 1.5;">
<div style=3D"text-align: center;">
<img src=3D"https://coder.com/coder-logo-horizontal.png" alt=3D"Cod=
er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
Missing AI Model Prices
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
<p>These models were used in the last week, but they have no price,=
so their usage is missing from AI spend and does not count toward any AI b=
udget. Reported spend is lower than actual, and a user who calls only these=
models has no effective limit.</p>
<ul>
<li>anthropic/claude-opus-4-8<br>
</li>
<li>openai/gpt-5.7<br>
</li>
<li>openrouter/z-ai/glm-5.4<br>
</li>
</ul>
<p>3 of 15 models with no price are shown, ordered by usage.</p>
<p>Every Coder release ships with prices for most models, so only the model=
s above need one: see <a href=3D"https://coder.com/docs/ai-coder/ai-gateway=
/cost-controls#how-spend-is-calculated">how spend is calculated</a> and <a =
href=3D"https://coder.com/docs/ai-coder/ai-gateway/cost-controls#configure-=
model-prices">how to configure prices</a>. Prices are not retroactive, so u=
sage recorded before you set a price stays unpriced.</p>
</div>
<div style=3D"text-align: center; margin-top: 32px;">
=20
</div>
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
<p>&copy;&nbsp;2024&nbsp;Coder. All rights reserved&nbsp;-&nbsp;<a =
href=3D"http://test.com" style=3D"color: #2563eb; text-decoration: none;">h=
ttp://test.com</a></p>
<p><a href=3D"http://test.com/settings/notifications" style=3D"colo=
r: #2563eb; text-decoration: none;">Click here to manage your notification =
settings</a></p>
<p><a href=3D"http://test.com/settings/notifications?disabled=3D1b7=
d9fa7-f5a8-4e46-8078-5cf53abfed94" style=3D"color: #2563eb; text-decoration=
: none;">Stop receiving emails like this</a></p>
</div>
</div>
</body>
</html>
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4--
@@ -0,0 +1,39 @@
{
"_version": "1.1",
"msg_id": "00000000-0000-0000-0000-000000000000",
"payload": {
"_version": "1.2",
"notification_name": "Missing AI Model Prices",
"notification_template_id": "00000000-0000-0000-0000-000000000000",
"user_id": "00000000-0000-0000-0000-000000000000",
"user_email": "bobby@coder.com",
"user_name": "Bobby",
"user_username": "bobby",
"actions": [],
"labels": {},
"data": {
"models": [
{
"model": "claude-opus-4-8",
"provider": "anthropic"
},
{
"model": "gpt-5.7",
"provider": "openai"
},
{
"model": "z-ai/glm-5.4",
"provider": "openrouter"
}
],
"report_frequency": "week",
"total_count": 15,
"truncated": true
},
"targets": null
},
"title": "Missing AI Model Prices",
"title_markdown": "Missing AI Model Prices",
"body": "These models were used in the last week, but they have no price, so their usage is missing from AI spend and does not count toward any AI budget. Reported spend is lower than actual, and a user who calls only these models has no effective limit.\n\nanthropic/claude-opus-4-8\nopenai/gpt-5.7\nopenrouter/z-ai/glm-5.4\n\n3 of 15 models with no price are shown, ordered by usage.\n\nEvery Coder release ships with prices for most models, so only the models above need one: see how spend is calculated (https://coder.com/docs/ai-coder/ai-gateway/cost-controls#how-spend-is-calculated) and how to configure prices (https://coder.com/docs/ai-coder/ai-gateway/cost-controls#configure-model-prices). Prices are not retroactive, so usage recorded before you set a price stays unpriced.",
"body_markdown": "These models were used in the last week, but they have no price, so their usage is missing from AI spend and does not count toward any AI budget. Reported spend is lower than actual, and a user who calls only these models has no effective limit.\n\n\n* anthropic/claude-opus-4-8\n* openai/gpt-5.7\n* openrouter/z-ai/glm-5.4\n\n3 of 15 models with no price are shown, ordered by usage.\n\nEvery Coder release ships with prices for most models, so only the models above need one: see [how spend is calculated](https://coder.com/docs/ai-coder/ai-gateway/cost-controls#how-spend-is-calculated) and [how to configure prices](https://coder.com/docs/ai-coder/ai-gateway/cost-controls#configure-model-prices). Prices are not retroactive, so usage recorded before you set a price stays unpriced."
}