From fba00a6b3a1196e6df0f4943dd7a13044171184e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 12 Mar 2026 07:37:33 +0100 Subject: [PATCH] feat(agents): add chat model pricing metadata (#22959) ## Summary - add chat model pricing metadata to the agents admin form and SDK metadata - split pricing into its own section and show default pricing as placeholders - apply default pricing when admins leave pricing fields blank --- coderd/chatd/chatprovider/chatprovider.go | 33 +++- .../chatd/chatprovider/chatprovider_test.go | 22 ++- coderd/chats.go | 54 ++++++ coderd/chats_test.go | 152 +++++++++++++++++ codersdk/chats.go | 54 ++++++ docs/ai-coder/agents/models.md | 4 + site/src/api/chatModelOptionsGenerated.json | 32 ++++ site/src/api/typesGenerated.ts | 17 ++ .../ChatModelAdminPanel.stories.tsx | 37 ++++- .../ChatModelAdminPanel/ModelConfigFields.tsx | 86 ++++++++-- .../ChatModelAdminPanel/ModelForm.tsx | 150 ++++++++++------- .../ModelsSection.test.tsx | 99 +++++++++++ .../ChatModelAdminPanel/ModelsSection.tsx | 156 ++++++++++-------- .../modelConfigFormLogic.test.ts | 61 +++++++ .../modelConfigFormLogic.ts | 59 ++++--- .../ChatModelAdminPanel/pricingFields.test.ts | 42 +++++ .../ChatModelAdminPanel/pricingFields.ts | 65 ++++++++ 17 files changed, 958 insertions(+), 165 deletions(-) create mode 100644 site/src/pages/AgentsPage/ChatModelAdminPanel/ModelsSection.test.tsx create mode 100644 site/src/pages/AgentsPage/ChatModelAdminPanel/pricingFields.test.ts create mode 100644 site/src/pages/AgentsPage/ChatModelAdminPanel/pricingFields.ts diff --git a/coderd/chatd/chatprovider/chatprovider.go b/coderd/chatd/chatprovider/chatprovider.go index 7e08d98049..4a7bb51443 100644 --- a/coderd/chatd/chatprovider/chatprovider.go +++ b/coderd/chatd/chatprovider/chatprovider.go @@ -553,7 +553,8 @@ func normalizedEnumValue(value string, allowed ...string) *string { return nil } -// MergeMissingCallConfig fills unset call config values from defaults. +// MergeMissingCallConfig fills unset call config values from a provider or +// profile default config. func MergeMissingCallConfig( dst *codersdk.ChatModelCallConfig, defaults codersdk.ChatModelCallConfig, @@ -576,9 +577,39 @@ func MergeMissingCallConfig( if dst.FrequencyPenalty == nil { dst.FrequencyPenalty = defaults.FrequencyPenalty } + MergeMissingModelCostConfig(&dst.Cost, defaults.Cost) MergeMissingProviderOptions(&dst.ProviderOptions, defaults.ProviderOptions) } +// MergeMissingModelCostConfig fills unset pricing metadata from defaults. +func MergeMissingModelCostConfig( + dst **codersdk.ModelCostConfig, + defaults *codersdk.ModelCostConfig, +) { + if defaults == nil { + return + } + if *dst == nil { + copied := *defaults + *dst = &copied + return + } + + current := *dst + if current.InputPricePerMillionTokens == nil { + current.InputPricePerMillionTokens = defaults.InputPricePerMillionTokens + } + if current.OutputPricePerMillionTokens == nil { + current.OutputPricePerMillionTokens = defaults.OutputPricePerMillionTokens + } + if current.CacheReadPricePerMillionTokens == nil { + current.CacheReadPricePerMillionTokens = defaults.CacheReadPricePerMillionTokens + } + if current.CacheWritePricePerMillionTokens == nil { + current.CacheWritePricePerMillionTokens = defaults.CacheWritePricePerMillionTokens + } +} + // MergeMissingProviderOptions fills unset provider option fields from defaults. func MergeMissingProviderOptions( dst **codersdk.ChatModelProviderOptions, diff --git a/coderd/chatd/chatprovider/chatprovider_test.go b/coderd/chatd/chatprovider/chatprovider_test.go index 277aead26c..920e8ea363 100644 --- a/coderd/chatd/chatprovider/chatprovider_test.go +++ b/coderd/chatd/chatprovider/chatprovider_test.go @@ -142,16 +142,25 @@ func TestMergeMissingCallConfig_FillsUnsetFields(t *testing.T) { dst := codersdk.ChatModelCallConfig{ Temperature: float64Ptr(0.2), + Cost: &codersdk.ModelCostConfig{ + OutputPricePerMillionTokens: float64Ptr(0.7), + }, ProviderOptions: &codersdk.ChatModelProviderOptions{ OpenAI: &codersdk.ChatModelOpenAIProviderOptions{ User: stringPtr("alice"), }, }, } - defaults := codersdk.ChatModelCallConfig{ + defaultCallConfig := codersdk.ChatModelCallConfig{ MaxOutputTokens: int64Ptr(512), Temperature: float64Ptr(0.9), TopP: float64Ptr(0.8), + Cost: &codersdk.ModelCostConfig{ + InputPricePerMillionTokens: float64Ptr(0.15), + OutputPricePerMillionTokens: float64Ptr(0.9), + CacheReadPricePerMillionTokens: float64Ptr(0.03), + CacheWritePricePerMillionTokens: float64Ptr(0.3), + }, ProviderOptions: &codersdk.ChatModelProviderOptions{ OpenAI: &codersdk.ChatModelOpenAIProviderOptions{ User: stringPtr("bob"), @@ -160,7 +169,7 @@ func TestMergeMissingCallConfig_FillsUnsetFields(t *testing.T) { }, } - chatprovider.MergeMissingCallConfig(&dst, defaults) + chatprovider.MergeMissingCallConfig(&dst, defaultCallConfig) require.NotNil(t, dst.MaxOutputTokens) require.EqualValues(t, 512, *dst.MaxOutputTokens) @@ -168,6 +177,15 @@ func TestMergeMissingCallConfig_FillsUnsetFields(t *testing.T) { require.Equal(t, 0.2, *dst.Temperature) require.NotNil(t, dst.TopP) require.Equal(t, 0.8, *dst.TopP) + require.NotNil(t, dst.Cost) + require.NotNil(t, dst.Cost.InputPricePerMillionTokens) + require.Equal(t, 0.15, *dst.Cost.InputPricePerMillionTokens) + require.NotNil(t, dst.Cost.OutputPricePerMillionTokens) + require.Equal(t, 0.7, *dst.Cost.OutputPricePerMillionTokens) + require.NotNil(t, dst.Cost.CacheReadPricePerMillionTokens) + require.Equal(t, 0.03, *dst.Cost.CacheReadPricePerMillionTokens) + require.NotNil(t, dst.Cost.CacheWritePricePerMillionTokens) + require.Equal(t, 0.3, *dst.Cost.CacheWritePricePerMillionTokens) require.NotNil(t, dst.ProviderOptions) require.NotNil(t, dst.ProviderOptions.OpenAI) require.Equal(t, "alice", *dst.ProviderOptions.OpenAI.User) diff --git a/coderd/chats.go b/coderd/chats.go index 1586e7a282..31c9ffa3c3 100644 --- a/coderd/chats.go +++ b/coderd/chats.go @@ -3095,6 +3095,10 @@ func marshalChatModelCallConfig( return json.RawMessage("{}"), nil } + if err := validateChatModelCallConfig(modelConfig); err != nil { + return nil, err + } + encoded, err := json.Marshal(modelConfig) if err != nil { return nil, xerrors.Errorf("encode model config: %w", err) @@ -3102,6 +3106,44 @@ func marshalChatModelCallConfig( return encoded, nil } +func validateChatModelCallConfig(modelConfig *codersdk.ChatModelCallConfig) error { + if modelConfig == nil { + return nil + } + + costConfig := codersdk.ModelCostConfig{} + if modelConfig.Cost != nil { + costConfig = *modelConfig.Cost + } + + pricingFields := []struct { + name string + value *float64 + }{ + {name: "cost.input_price_per_million_tokens", value: costConfig.InputPricePerMillionTokens}, + {name: "cost.output_price_per_million_tokens", value: costConfig.OutputPricePerMillionTokens}, + {name: "cost.cache_read_price_per_million_tokens", value: costConfig.CacheReadPricePerMillionTokens}, + {name: "cost.cache_write_price_per_million_tokens", value: costConfig.CacheWritePricePerMillionTokens}, + } + for _, field := range pricingFields { + if err := validateNonNegativeFloat64Field(field.name, field.value); err != nil { + return err + } + } + + return nil +} + +func validateNonNegativeFloat64Field(name string, value *float64) error { + if value == nil { + return nil + } + if *value < 0 { + return xerrors.Errorf("%s must be greater than or equal to zero", name) + } + return nil +} + func unmarshalChatModelCallConfig( raw json.RawMessage, ) *codersdk.ChatModelCallConfig { @@ -3130,9 +3172,21 @@ func isZeroChatModelCallConfig(config *codersdk.ChatModelCallConfig) bool { config.TopK == nil && config.PresencePenalty == nil && config.FrequencyPenalty == nil && + isZeroModelCostConfig(config.Cost) && isZeroChatModelProviderOptions(config.ProviderOptions) } +func isZeroModelCostConfig(cost *codersdk.ModelCostConfig) bool { + if cost == nil { + return true + } + + return cost.InputPricePerMillionTokens == nil && + cost.OutputPricePerMillionTokens == nil && + cost.CacheReadPricePerMillionTokens == nil && + cost.CacheWritePricePerMillionTokens == nil +} + func isZeroChatModelProviderOptions(options *codersdk.ChatModelProviderOptions) bool { if options == nil { return true diff --git a/coderd/chats_test.go b/coderd/chats_test.go index f93532e289..d451be1f04 100644 --- a/coderd/chats_test.go +++ b/coderd/chats_test.go @@ -22,6 +22,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/externalauth" coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" "github.com/coder/websocket" @@ -902,6 +903,48 @@ func TestListChatModelConfigs(t *testing.T) { require.True(t, found) }) + t.Run("DeserializesLegacyPricingJSON", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client) + + _, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ + Provider: "openai", + APIKey: "test-api-key", + }) + require.NoError(t, err) + + legacyOptions := json.RawMessage(`{"input_price_per_million_tokens":0.15,"output_price_per_million_tokens":0.6,"cache_read_price_per_million_tokens":0.03,"cache_write_price_per_million_tokens":0.3}`) + storedConfig, err := db.InsertChatModelConfig(dbauthz.AsSystemRestricted(ctx), database.InsertChatModelConfigParams{ + Provider: "openai", + Model: "gpt-4o-mini-legacy", + DisplayName: "GPT-4o Mini Legacy", + CreatedBy: uuid.NullUUID{UUID: firstUser.UserID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: firstUser.UserID, Valid: true}, + Enabled: true, + IsDefault: false, + ContextLimit: 4096, + CompressionThreshold: 80, + Options: legacyOptions, + }) + require.NoError(t, err) + + configs, err := client.ListChatModelConfigs(ctx) + require.NoError(t, err) + require.Len(t, configs, 1) + require.Equal(t, storedConfig.ID, configs[0].ID) + requireChatModelPricing(t, configs[0].ModelConfig, &codersdk.ChatModelCallConfig{ + Cost: &codersdk.ModelCostConfig{ + InputPricePerMillionTokens: ptr.Ref(0.15), + OutputPricePerMillionTokens: ptr.Ref(0.6), + CacheReadPricePerMillionTokens: ptr.Ref(0.03), + CacheWritePricePerMillionTokens: ptr.Ref(0.3), + }, + }) + }) + t.Run("SuccessForOrganizationMember", func(t *testing.T) { t.Parallel() @@ -946,11 +989,20 @@ func TestCreateChatModelConfig(t *testing.T) { contextLimit := int64(4096) isDefault := true + pricing := &codersdk.ChatModelCallConfig{ + Cost: &codersdk.ModelCostConfig{ + InputPricePerMillionTokens: ptr.Ref(0.15), + OutputPricePerMillionTokens: ptr.Ref(0.6), + CacheReadPricePerMillionTokens: ptr.Ref(0.03), + CacheWritePricePerMillionTokens: ptr.Ref(0.3), + }, + } modelConfig, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ Provider: "openai", Model: "gpt-4o-mini", ContextLimit: &contextLimit, IsDefault: &isDefault, + ModelConfig: pricing, }) require.NoError(t, err) require.NotEqual(t, uuid.Nil, modelConfig.ID) @@ -958,6 +1010,45 @@ func TestCreateChatModelConfig(t *testing.T) { require.Equal(t, "gpt-4o-mini", modelConfig.Model) require.EqualValues(t, 4096, modelConfig.ContextLimit) require.True(t, modelConfig.IsDefault) + requireChatModelPricing(t, modelConfig.ModelConfig, pricing) + + configs, err := client.ListChatModelConfigs(ctx) + require.NoError(t, err) + require.Len(t, configs, 1) + requireChatModelPricing(t, configs[0].ModelConfig, pricing) + }) + + t.Run("RejectsNegativePricing", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + _, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{ + Provider: "openai", + APIKey: "test-api-key", + }) + require.NoError(t, err) + + contextLimit := int64(4096) + _, err = client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + Provider: "openai", + Model: "gpt-4o-mini", + ContextLimit: &contextLimit, + ModelConfig: &codersdk.ChatModelCallConfig{ + Cost: &codersdk.ModelCostConfig{ + InputPricePerMillionTokens: ptr.Ref(-0.01), + }, + }, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid model config.", sdkErr.Message) + require.Equal( + t, + "cost.input_price_per_million_tokens must be greater than or equal to zero", + sdkErr.Detail, + ) }) t.Run("MissingContextLimit", func(t *testing.T) { @@ -1028,14 +1119,53 @@ func TestUpdateChatModelConfig(t *testing.T) { modelConfig := createChatModelConfig(t, client) contextLimit := int64(8192) + pricing := &codersdk.ChatModelCallConfig{ + Cost: &codersdk.ModelCostConfig{ + InputPricePerMillionTokens: ptr.Ref(0.2), + OutputPricePerMillionTokens: ptr.Ref(0.8), + CacheReadPricePerMillionTokens: ptr.Ref(0.04), + CacheWritePricePerMillionTokens: ptr.Ref(0.4), + }, + } updated, err := client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{ DisplayName: "GPT-4o Mini Updated", ContextLimit: &contextLimit, + ModelConfig: pricing, }) require.NoError(t, err) require.Equal(t, modelConfig.ID, updated.ID) require.Equal(t, "GPT-4o Mini Updated", updated.DisplayName) require.EqualValues(t, 8192, updated.ContextLimit) + requireChatModelPricing(t, updated.ModelConfig, pricing) + + configs, err := client.ListChatModelConfigs(ctx) + require.NoError(t, err) + require.Len(t, configs, 1) + requireChatModelPricing(t, configs[0].ModelConfig, pricing) + }) + + t.Run("RejectsNegativePricing", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + modelConfig := createChatModelConfig(t, client) + + _, err := client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{ + ModelConfig: &codersdk.ChatModelCallConfig{ + Cost: &codersdk.ModelCostConfig{ + OutputPricePerMillionTokens: ptr.Ref(-1.0), + }, + }, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid model config.", sdkErr.Message) + require.Equal( + t, + "cost.output_price_per_million_tokens must be greater than or equal to zero", + sdkErr.Detail, + ) }) t.Run("NotFound", func(t *testing.T) { @@ -3303,6 +3433,28 @@ func TestGetChatFile(t *testing.T) { }) } +func requireChatModelPricing( + t *testing.T, + actual *codersdk.ChatModelCallConfig, + expected *codersdk.ChatModelCallConfig, +) { + t.Helper() + require.NotNil(t, actual) + require.NotNil(t, expected) + + require.NotNil(t, actual.Cost) + require.NotNil(t, expected.Cost) + require.NotNil(t, actual.Cost.InputPricePerMillionTokens) + require.NotNil(t, actual.Cost.OutputPricePerMillionTokens) + require.NotNil(t, actual.Cost.CacheReadPricePerMillionTokens) + require.NotNil(t, actual.Cost.CacheWritePricePerMillionTokens) + + require.Equal(t, *expected.Cost.InputPricePerMillionTokens, *actual.Cost.InputPricePerMillionTokens) + require.Equal(t, *expected.Cost.OutputPricePerMillionTokens, *actual.Cost.OutputPricePerMillionTokens) + require.Equal(t, *expected.Cost.CacheReadPricePerMillionTokens, *actual.Cost.CacheReadPricePerMillionTokens) + require.Equal(t, *expected.Cost.CacheWritePricePerMillionTokens, *actual.Cost.CacheWritePricePerMillionTokens) +} + func createChatModelConfig(t *testing.T, client *codersdk.Client) codersdk.ChatModelConfig { t.Helper() diff --git a/codersdk/chats.go b/codersdk/chats.go index baa16e672b..009055ec6a 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -420,6 +420,17 @@ type ChatModelVercelProviderOptions struct { ExtraBody map[string]any `json:"extra_body,omitempty" description:"Additional fields to include in the request body" hidden:"true"` } +// ModelCostConfig stores pricing metadata for a chat model. +type ModelCostConfig struct { + // Pricing is stored as configuration metadata and currently only needs to + // round-trip cleanly through the API and admin UI. If we later use these + // values for billing-grade arithmetic, switch to a fixed-point type. + InputPricePerMillionTokens *float64 `json:"input_price_per_million_tokens,omitempty" description:"Input token price in USD per 1M tokens"` + OutputPricePerMillionTokens *float64 `json:"output_price_per_million_tokens,omitempty" description:"Output token price in USD per 1M tokens"` + CacheReadPricePerMillionTokens *float64 `json:"cache_read_price_per_million_tokens,omitempty" description:"Cache read token price in USD per 1M tokens"` + CacheWritePricePerMillionTokens *float64 `json:"cache_write_price_per_million_tokens,omitempty" description:"Cache write or cache creation token price in USD per 1M tokens"` +} + // ChatModelCallConfig configures per-call model behavior defaults. type ChatModelCallConfig struct { MaxOutputTokens *int64 `json:"max_output_tokens,omitempty" description:"Upper bound on tokens the model may generate"` @@ -428,9 +439,52 @@ type ChatModelCallConfig struct { TopK *int64 `json:"top_k,omitempty" description:"Number of highest-probability tokens to keep for sampling"` PresencePenalty *float64 `json:"presence_penalty,omitempty" description:"Penalty for tokens that have already appeared in the output"` FrequencyPenalty *float64 `json:"frequency_penalty,omitempty" description:"Penalty for tokens based on their frequency in the output"` + Cost *ModelCostConfig `json:"cost,omitempty" description:"Optional pricing metadata for this model"` ProviderOptions *ChatModelProviderOptions `json:"provider_options,omitempty" description:"Provider-specific option overrides"` } +// UnmarshalJSON accepts both the current nested cost object and the previous +// top-level pricing keys so legacy stored model_config JSON continues to load. +func (c *ChatModelCallConfig) UnmarshalJSON(data []byte) error { + type chatModelCallConfigAlias ChatModelCallConfig + aux := struct { + *chatModelCallConfigAlias + InputPricePerMillionTokens *float64 `json:"input_price_per_million_tokens,omitempty"` + OutputPricePerMillionTokens *float64 `json:"output_price_per_million_tokens,omitempty"` + CacheReadPricePerMillionTokens *float64 `json:"cache_read_price_per_million_tokens,omitempty"` + CacheWritePricePerMillionTokens *float64 `json:"cache_write_price_per_million_tokens,omitempty"` + }{ + chatModelCallConfigAlias: (*chatModelCallConfigAlias)(c), + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + if aux.InputPricePerMillionTokens == nil && + aux.OutputPricePerMillionTokens == nil && + aux.CacheReadPricePerMillionTokens == nil && + aux.CacheWritePricePerMillionTokens == nil { + return nil + } + + if c.Cost == nil { + c.Cost = &ModelCostConfig{} + } + if c.Cost.InputPricePerMillionTokens == nil { + c.Cost.InputPricePerMillionTokens = aux.InputPricePerMillionTokens + } + if c.Cost.OutputPricePerMillionTokens == nil { + c.Cost.OutputPricePerMillionTokens = aux.OutputPricePerMillionTokens + } + if c.Cost.CacheReadPricePerMillionTokens == nil { + c.Cost.CacheReadPricePerMillionTokens = aux.CacheReadPricePerMillionTokens + } + if c.Cost.CacheWritePricePerMillionTokens == nil { + c.Cost.CacheWritePricePerMillionTokens = aux.CacheWritePricePerMillionTokens + } + return nil +} + // CreateChatModelConfigRequest creates a chat model config. type CreateChatModelConfigRequest struct { Provider string `json:"provider"` diff --git a/docs/ai-coder/agents/models.md b/docs/ai-coder/agents/models.md index 3ed543ee8a..ec2018c5fd 100644 --- a/docs/ai-coder/agents/models.md +++ b/docs/ai-coder/agents/models.md @@ -113,6 +113,10 @@ These options apply to all providers: | Top K | Limits token selection to the top K candidates. | | Presence Penalty | Penalizes tokens that have already appeared in the conversation. | | Frequency Penalty | Penalizes tokens proportional to how often they have appeared. | +| Input Price | Optional USD price metadata for input tokens, recorded per 1M tokens. | +| Output Price | Optional USD price metadata for output tokens, recorded per 1M tokens. | +| Cache Read Price | Optional USD price metadata for cache read tokens, recorded per 1M tokens. | +| Cache Write Price | Optional USD price metadata for cache creation/write tokens, recorded per 1M tokens. | ### Provider-specific options diff --git a/site/src/api/chatModelOptionsGenerated.json b/site/src/api/chatModelOptionsGenerated.json index 995eabb4a6..14d866ff8d 100644 --- a/site/src/api/chatModelOptionsGenerated.json +++ b/site/src/api/chatModelOptionsGenerated.json @@ -48,6 +48,38 @@ "description": "Penalty for tokens based on their frequency in the output", "required": false, "input_type": "input" + }, + { + "json_name": "cost.input_price_per_million_tokens", + "go_name": "Cost.InputPricePerMillionTokens", + "type": "number", + "description": "Input token price in USD per 1M tokens", + "required": false, + "input_type": "input" + }, + { + "json_name": "cost.output_price_per_million_tokens", + "go_name": "Cost.OutputPricePerMillionTokens", + "type": "number", + "description": "Output token price in USD per 1M tokens", + "required": false, + "input_type": "input" + }, + { + "json_name": "cost.cache_read_price_per_million_tokens", + "go_name": "Cost.CacheReadPricePerMillionTokens", + "type": "number", + "description": "Cache read token price in USD per 1M tokens", + "required": false, + "input_type": "input" + }, + { + "json_name": "cost.cache_write_price_per_million_tokens", + "go_name": "Cost.CacheWritePricePerMillionTokens", + "type": "number", + "description": "Cache write or cache creation token price in USD per 1M tokens", + "required": false, + "input_type": "input" } ] }, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index faafc51ecc..e0c2f29e35 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1273,6 +1273,7 @@ export interface ChatModelCallConfig { readonly top_k?: number; readonly presence_penalty?: number; readonly frequency_penalty?: number; + readonly cost?: ModelCostConfig; readonly provider_options?: ChatModelProviderOptions; } @@ -3410,6 +3411,22 @@ export interface MinimalUser { readonly avatar_url?: string; } +// From codersdk/chats.go +/** + * ModelCostConfig stores pricing metadata for a chat model. + */ +export interface ModelCostConfig { + /** + * Pricing is stored as configuration metadata and currently only needs to + * round-trip cleanly through the API and admin UI. If we later use these + * values for billing-grade arithmetic, switch to a fixed-point type. + */ + readonly input_price_per_million_tokens?: number; + readonly output_price_per_million_tokens?: number; + readonly cache_read_price_per_million_tokens?: number; + readonly cache_write_price_per_million_tokens?: number; +} + // From netcheck/netcheck.go /** * Report contains the result of a single netcheck. diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx index 435797e2d2..d52652ac84 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx @@ -39,6 +39,7 @@ const createModelConfig = ( is_default: overrides.is_default ?? false, context_limit: overrides.context_limit ?? 200000, compression_threshold: overrides.compression_threshold ?? 70, + model_config: overrides.model_config, created_at: overrides.created_at ?? now, updated_at: overrides.updated_at ?? now, }); @@ -125,6 +126,7 @@ const setupChatSpies = (state: { Number.isFinite(req.compression_threshold) ? req.compression_threshold : 70, + model_config: req.model_config, }); state.modelConfigs = [...state.modelConfigs, created]; return created; @@ -419,7 +421,7 @@ export const NoModelConfigByDefault: Story = { model: "gpt-5-pro", }), ); - // The request should not include a model_config key. + // Blank pricing fields should remain unset in the payload. const callArgs = ( API.createChatModelConfig as unknown as ReturnType ).mock.calls[0][0] as Record; @@ -621,6 +623,39 @@ export const ModelFormBedrock: Story = { }, }; +export const ModelPricingWarningInList: Story = { + args: { section: "models" as ChatModelAdminSection }, + beforeEach: () => { + setupChatSpies({ + providerConfigs: [ + createProviderConfig({ + id: "provider-openai", + provider: "openai", + display_name: "OpenAI", + source: "database", + has_api_key: true, + }), + ], + modelConfigs: [ + createModelConfig({ + id: "model-warning", + provider: "openai", + model: "gpt-4.1", + display_name: "GPT-4.1", + }), + ], + modelCatalog: { providers: [] }, + }); + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await expect(await body.findByText("GPT-4.1")).toBeInTheDocument(); + await expect( + body.getByText("Model pricing is not defined"), + ).toBeInTheDocument(); + }, +}; + export const ModelDeleteConfirmation: Story = { args: { section: "models" as ChatModelAdminSection }, beforeEach: () => { diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelConfigFields.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelConfigFields.tsx index dcf676628e..3a43db13e7 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelConfigFields.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelConfigFields.tsx @@ -24,6 +24,10 @@ import type { ModelConfigFormBuildResult, ModelFormValues, } from "./modelConfigFormLogic"; +import { + getPricingPlaceholderForField, + pricingFieldNames, +} from "./pricingFields"; /** Sentinel value for Select components to represent "no selection". */ const unsetSelectValue = "__unset__"; @@ -49,6 +53,11 @@ function snakeToPrettyLabel(jsonName: string): string { * Derive a sensible placeholder from the field schema type. */ function placeholderForField(field: FieldSchema): string { + const pricingPlaceholder = getPricingPlaceholderForField(field.json_name); + if (pricingPlaceholder !== undefined) { + return pricingPlaceholder; + } + switch (field.type) { case "integer": case "number": @@ -364,27 +373,25 @@ export const ModelConfigFields: FC = ({ }; /** - * General model config fields (max output tokens, temperature, - * top P, etc.) intended to be shown under an "Advanced" section. - * - * Fields are driven by the auto-generated schema in - * `api/chatModelOptions`. + * Shared renderer for general model config fields backed by the + * top-level ChatModelCallConfig schema. */ -export const GeneralModelConfigFields: FC = ({ - form, - fieldErrors, - disabled, -}) => { +const GeneralFieldsGroup: FC< + ModelConfigFieldsProps & { + fields: FieldSchema[]; + } +> = ({ form, fieldErrors, disabled, fields }) => { const ctx: FieldRenderContext = { form, fieldErrors, disabled }; - const fields = getVisibleGeneralFields(); return ( <> {fields.map((field) => { - // General field keys use camelCase of the json_name directly - // under "config.", matching the existing form state shape: - // config.maxOutputTokens, config.temperature, etc. - const camelName = snakeToCamel(field.json_name); + // General field keys support nested json_name values, such as + // cost.input_price_per_million_tokens. + const camelName = field.json_name + .split(".") + .map(snakeToCamel) + .join("."); const fieldKey = `config.${camelName}`; const label = snakeToPrettyLabel(field.json_name); @@ -403,3 +410,52 @@ export const GeneralModelConfigFields: FC = ({ ); }; + +/** + * General pricing fields shown in the main form body so admins can + * define optional pricing metadata without opening the advanced section. + */ +export const PricingModelConfigFields: FC = ({ + provider, + form, + fieldErrors, + disabled, +}) => { + return ( + + pricingFieldNames.has(json_name), + )} + /> + ); +}; + +/** + * General model config fields (max output tokens, temperature, + * top P, etc.) intended to be shown under an "Advanced" section. + * + * Fields are driven by the auto-generated schema in + * `api/chatModelOptions`. + */ +export const GeneralModelConfigFields: FC = ({ + provider, + form, + fieldErrors, + disabled, +}) => { + return ( + !pricingFieldNames.has(json_name), + )} + /> + ); +}; diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelForm.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelForm.tsx index d0f3d28e81..fae7012675 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelForm.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelForm.tsx @@ -25,6 +25,7 @@ import type { ProviderState } from "./ChatModelAdminPanel"; import { GeneralModelConfigFields, ModelConfigFields, + PricingModelConfigFields, } from "./ModelConfigFields"; import { buildInitialModelFormValues, @@ -93,6 +94,7 @@ export const ModelForm: FC = ({ onDeleteModel, }) => { const isEditing = Boolean(editingModel); + const [showPricing, setShowPricing] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false); const [confirmingDelete, setConfirmingDelete] = useState(false); @@ -407,66 +409,102 @@ export const ModelForm: FC = ({ disabled={isSaving} /> - {/* Advanced — toggle */} -
- {" "} - {showAdvanced && ( -
-
- -
-
- -

- Percentage at which context is compressed. -

- - {compressionThresholdField.error && ( -

- {compressionThresholdField.helperText} +

+ {/* Pricing — toggle */} +
+ {" "} + {showPricing && ( +
+
+

+ Optional USD pricing metadata per 1M tokens. Leave any + field blank to keep pricing unset and use provider or + profile defaults when available.

- )} +
+
+ +
-
- )} + )} +
+ + {/* Advanced — toggle */} +
+ {" "} + {showAdvanced && ( +
+
+ +
+
+ +

+ Percentage at which context is compressed. +

+ + {compressionThresholdField.error && ( +

+ {compressionThresholdField.helperText} +

+ )} +
+
+ )} +
- {/* Footer — pushed to bottom */}

diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelsSection.test.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelsSection.test.tsx new file mode 100644 index 0000000000..190b5c7720 --- /dev/null +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelsSection.test.tsx @@ -0,0 +1,99 @@ +import { render, screen } from "@testing-library/react"; +import type * as TypesGen from "api/typesGenerated"; +import { TooltipProvider } from "components/Tooltip/Tooltip"; +import { describe, expect, it, vi } from "vitest"; +import type { ProviderState } from "./ChatModelAdminPanel"; +import { ModelsSection } from "./ModelsSection"; + +vi.mock("./ProviderIcon", () => ({ + ProviderIcon: ({ provider }: { provider: string }) => ( +
{provider}
+ ), +})); + +const providerState: ProviderState = { + provider: "openai", + label: "OpenAI", + providerConfig: { + id: "provider-config-id", + provider: "openai", + display_name: "OpenAI", + enabled: true, + has_api_key: true, + base_url: undefined, + source: "database", + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + }, + modelConfigs: [], + catalogModelCount: 0, + hasManagedAPIKey: true, + hasCatalogAPIKey: true, + hasEffectiveAPIKey: true, + isEnvPreset: false, + baseURL: "", +}; + +const baseModelConfig: TypesGen.ChatModelConfig = { + id: "model-config-id", + provider: "openai", + model: "gpt-4.1", + display_name: "GPT-4.1", + enabled: true, + is_default: false, + context_limit: 128000, + compression_threshold: 80, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", +}; + +const renderModelsSection = ( + modelConfigs: readonly TypesGen.ChatModelConfig[], +) => { + return render( + + + , + ); +}; + +describe("ModelsSection", () => { + it("shows a warning when a model has no custom pricing configured", () => { + renderModelsSection([baseModelConfig]); + + expect( + screen.getByText("Model pricing is not defined"), + ).toBeInTheDocument(); + }); + + it("hides the warning when a model has explicit zero pricing", () => { + renderModelsSection([ + { + ...baseModelConfig, + model_config: { + cost: { + output_price_per_million_tokens: 0, + }, + }, + }, + ]); + + expect( + screen.queryByText("Model pricing is not defined"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelsSection.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelsSection.tsx index 949ac54c4e..e42892d780 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelsSection.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelsSection.tsx @@ -17,6 +17,7 @@ import { ChevronRightIcon, PlusIcon, StarIcon, + TriangleAlertIcon, } from "lucide-react"; import { type FC, type ReactNode, useState } from "react"; import { cn } from "utils/cn"; @@ -24,6 +25,7 @@ import { SectionHeader } from "../SectionHeader"; import type { ProviderState } from "./ChatModelAdminPanel"; import { ModelForm } from "./ModelForm"; import { ProviderIcon } from "./ProviderIcon"; +import { hasCustomPricing } from "./pricingFields"; type ModelView = | { mode: "list" } @@ -190,79 +192,91 @@ export const ModelsSection: FC = ({
) : (
- {modelConfigs.map((modelConfig) => ( -
- {" "} - {/* Star for default */} - - - - - - {modelConfig.is_default - ? "Default model for new chats" - : "Set as default for new chats"} - - - {/* Clickable row content */} - + + + {modelConfig.is_default + ? "Default model for new chats" + : "Set as default for new chats"} + + + {/* Clickable row content */} + {" "} -
- ))} +
+ {modelConfig.enabled === false && ( + + disabled + + )} + + {" "} +
+ ); + })} )} diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/modelConfigFormLogic.test.ts b/site/src/pages/AgentsPage/ChatModelAdminPanel/modelConfigFormLogic.test.ts index 0c0d609b51..756fffbe4d 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/modelConfigFormLogic.test.ts +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/modelConfigFormLogic.test.ts @@ -205,6 +205,32 @@ describe("extractModelConfigFormState", () => { expect(result.frequencyPenalty).toBe("0.3"); }); + it("extracts pricing fields", () => { + const model: TypesGen.ChatModelConfig = { + ...baseChatModelConfig, + model_config: { + cost: { + input_price_per_million_tokens: 0.15, + output_price_per_million_tokens: 0.6, + cache_read_price_per_million_tokens: 0.03, + cache_write_price_per_million_tokens: 0.3, + }, + }, + }; + const result = extractModelConfigFormState(model); + expect(deepGet(result, ["cost", "inputPricePerMillionTokens"])).toBe( + "0.15", + ); + expect(deepGet(result, ["cost", "outputPricePerMillionTokens"])).toBe( + "0.6", + ); + expect(deepGet(result, ["cost", "cacheReadPricePerMillionTokens"])).toBe( + "0.03", + ); + expect(deepGet(result, ["cost", "cacheWritePricePerMillionTokens"])).toBe( + "0.3", + ); + }); it("extracts OpenAI provider options", () => { const model: TypesGen.ChatModelConfig = { ...baseChatModelConfig, @@ -511,6 +537,41 @@ describe("buildModelConfigFromForm", () => { }); }); + describe("pricing fields", () => { + it("builds config with valid pricing fields", () => { + const result = buildModelConfigFromForm( + "openai", + formWith({ + cost: { + inputPricePerMillionTokens: "0.15", + outputPricePerMillionTokens: "0.6", + cacheReadPricePerMillionTokens: "0.03", + cacheWritePricePerMillionTokens: "0.3", + }, + }), + ); + expect(result.fieldErrors).toEqual({}); + expect(result.modelConfig).toMatchObject({ + cost: { + input_price_per_million_tokens: 0.15, + output_price_per_million_tokens: 0.6, + cache_read_price_per_million_tokens: 0.03, + cache_write_price_per_million_tokens: 0.3, + }, + }); + }); + + it("reports error for negative pricing fields", () => { + const result = buildModelConfigFromForm( + "openai", + formWith({ cost: { inputPricePerMillionTokens: "-0.5" } }), + ); + expect(result.fieldErrors["cost.inputPricePerMillionTokens"]).toContain( + "must be zero or greater", + ); + expect(result.modelConfig).toBeUndefined(); + }); + }); describe("OpenAI / Azure provider", () => { it("builds OpenAI provider options with reasoning effort", () => { const result = buildModelConfigFromForm( diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/modelConfigFormLogic.ts b/site/src/pages/AgentsPage/ChatModelAdminPanel/modelConfigFormLogic.ts index c289866f75..3a548d4a53 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/modelConfigFormLogic.ts +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/modelConfigFormLogic.ts @@ -8,6 +8,7 @@ import { } from "api/chatModelOptions"; import type * as TypesGen from "api/typesGenerated"; import * as Yup from "yup"; +import { pricingFieldNames } from "./pricingFields"; // ── Preserved public types ───────────────────────────────────── @@ -155,10 +156,10 @@ function buildEmptyProviderState(provider: string): Record { export const emptyModelConfigFormState: ModelConfigFormState = (() => { const state: ModelConfigFormState = {}; - // General fields (e.g. maxOutputTokens, temperature). + // General fields (e.g. maxOutputTokens, cost.inputPricePerMillionTokens). for (const field of getGeneralFields()) { - const key = snakeToCamel(field.json_name); - state[key] = ""; + const camelSegments = field.json_name.split(".").map(snakeToCamel); + deepSet(state, camelSegments, ""); } // Provider sub-objects. @@ -181,12 +182,12 @@ export const extractModelConfigFormState = ( const state: ModelConfigFormState = {}; - // General fields — read from the top level of the API config - // using the snake_case json_name. + // General fields may be nested (for example, cost.input_price_per_million_tokens). for (const field of getGeneralFields()) { - const camelKey = snakeToCamel(field.json_name); - const apiValue = (config as Record)[field.json_name]; - state[camelKey] = toFormString(apiValue); + const snakeSegments = field.json_name.split("."); + const camelSegments = snakeSegments.map(snakeToCamel); + const apiValue = deepGet(config, snakeSegments); + deepSet(state, camelSegments, toFormString(apiValue)); } // Provider sub-objects. @@ -233,6 +234,25 @@ export const buildInitialModelFormValues = ( : structuredClone(emptyModelConfigFormState), }); +function isNonNegativePricingField(field: FieldSchema): boolean { + return pricingFieldNames.has(field.json_name); +} + +function isValidOptionalNumber( + value: string | undefined, + minimum?: number, +): boolean { + const trimmed = value?.trim(); + if (!trimmed) { + return true; + } + + const parsed = Number(trimmed); + return ( + Number.isFinite(parsed) && (minimum === undefined || parsed >= minimum) + ); +} + // ── Schema-driven Yup validation ─────────────────────────────── /** @@ -255,16 +275,16 @@ function yupTestForField(field: FieldSchema): Yup.StringSchema { }, ); - case "number": - return Yup.string().test( - "optional-number", - `${label} must be a valid number.`, - (value) => { - const trimmed = value?.trim(); - if (!trimmed) return true; - return Number.isFinite(Number(trimmed)); - }, + case "number": { + const minimum = isNonNegativePricingField(field) ? 0 : undefined; + const errorMessage = + minimum === 0 + ? `${label} must be zero or greater.` + : `${label} must be a valid number.`; + return Yup.string().test("optional-number", errorMessage, (value) => + isValidOptionalNumber(value, minimum), ); + } case "boolean": return Yup.string().test( @@ -472,11 +492,12 @@ export const buildModelConfigFromForm = ( const modelConfig: Record = {}; for (const field of getGeneralFields()) { - const formValue = form[snakeToCamel(field.json_name)]; + const camelSegments = field.json_name.split(".").map(snakeToCamel); + const formValue = deepGet(form, camelSegments); if (typeof formValue !== "string") continue; const converted = convertFormValue(formValue, field); if (converted !== undefined) { - modelConfig[field.json_name] = converted; + deepSet(modelConfig, field.json_name.split("."), converted); } } diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/pricingFields.test.ts b/site/src/pages/AgentsPage/ChatModelAdminPanel/pricingFields.test.ts new file mode 100644 index 0000000000..06a724bf49 --- /dev/null +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/pricingFields.test.ts @@ -0,0 +1,42 @@ +import type * as TypesGen from "api/typesGenerated"; +import { describe, expect, it } from "vitest"; +import { + getDefaultPricingForField, + getPricingPlaceholderForField, + hasCustomPricing, + pricingFieldNameList, +} from "./pricingFields"; + +describe("pricingFields", () => { + it("uses $0 defaults for every pricing field", () => { + for (const fieldName of pricingFieldNameList) { + expect(getDefaultPricingForField(fieldName)).toBe(0); + expect(getPricingPlaceholderForField(fieldName)).toBe("0"); + } + }); + + it("treats missing pricing as undefined pricing", () => { + expect(hasCustomPricing()).toBe(false); + }); + + it("treats explicit zero pricing as custom pricing", () => { + expect( + hasCustomPricing({ + cost: { + input_price_per_million_tokens: 0, + output_price_per_million_tokens: 0, + }, + } satisfies TypesGen.ChatModelCallConfig), + ).toBe(true); + }); + + it("detects custom pricing when any pricing field is greater than zero", () => { + expect( + hasCustomPricing({ + cost: { + cache_write_price_per_million_tokens: 0.25, + }, + } satisfies TypesGen.ChatModelCallConfig), + ).toBe(true); + }); +}); diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/pricingFields.ts b/site/src/pages/AgentsPage/ChatModelAdminPanel/pricingFields.ts new file mode 100644 index 0000000000..7c4c753c97 --- /dev/null +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/pricingFields.ts @@ -0,0 +1,65 @@ +import type * as TypesGen from "api/typesGenerated"; + +// Single source of truth for the model config fields that belong in the +// Pricing section and require non-negative validation. +export const pricingFieldNameList = [ + "cost.input_price_per_million_tokens", + "cost.output_price_per_million_tokens", + "cost.cache_read_price_per_million_tokens", + "cost.cache_write_price_per_million_tokens", +] as const; + +export const pricingFieldNames = new Set(pricingFieldNameList); + +type PricingFieldName = (typeof pricingFieldNameList)[number]; + +export const defaultPricingByFieldName = { + "cost.input_price_per_million_tokens": 0, + "cost.output_price_per_million_tokens": 0, + "cost.cache_read_price_per_million_tokens": 0, + "cost.cache_write_price_per_million_tokens": 0, +} as const satisfies Record; + +export const pricingPlaceholderByFieldName = { + "cost.input_price_per_million_tokens": "0", + "cost.output_price_per_million_tokens": "0", + "cost.cache_read_price_per_million_tokens": "0", + "cost.cache_write_price_per_million_tokens": "0", +} as const satisfies Record; + +export const getDefaultPricingForField = ( + fieldName: string, +): number | undefined => + defaultPricingByFieldName[ + fieldName as keyof typeof defaultPricingByFieldName + ]; + +export const getPricingPlaceholderForField = ( + fieldName: string, +): string | undefined => + pricingPlaceholderByFieldName[ + fieldName as keyof typeof pricingPlaceholderByFieldName + ]; + +const getNestedValue = (value: unknown, path: readonly string[]): unknown => { + let current = value; + for (const segment of path) { + if ( + current === undefined || + current === null || + typeof current !== "object" + ) { + return undefined; + } + current = (current as Record)[segment]; + } + return current; +}; + +export const hasCustomPricing = ( + modelConfig?: TypesGen.ChatModelCallConfig, +): boolean => + pricingFieldNameList.some( + (fieldName) => + getNestedValue(modelConfig, fieldName.split(".")) !== undefined, + );