mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
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
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Generated
+17
@@ -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.
|
||||
|
||||
@@ -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<typeof spyOn>
|
||||
).mock.calls[0][0] as Record<string, unknown>;
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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<ModelConfigFieldsProps> = ({
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<ModelConfigFieldsProps> = ({
|
||||
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<ModelConfigFieldsProps> = ({
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<ModelConfigFieldsProps> = ({
|
||||
provider,
|
||||
form,
|
||||
fieldErrors,
|
||||
disabled,
|
||||
}) => {
|
||||
return (
|
||||
<GeneralFieldsGroup
|
||||
provider={provider}
|
||||
form={form}
|
||||
fieldErrors={fieldErrors}
|
||||
disabled={disabled}
|
||||
fields={getVisibleGeneralFields().filter(({ json_name }) =>
|
||||
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<ModelConfigFieldsProps> = ({
|
||||
provider,
|
||||
form,
|
||||
fieldErrors,
|
||||
disabled,
|
||||
}) => {
|
||||
return (
|
||||
<GeneralFieldsGroup
|
||||
provider={provider}
|
||||
form={form}
|
||||
fieldErrors={fieldErrors}
|
||||
disabled={disabled}
|
||||
fields={getVisibleGeneralFields().filter(
|
||||
({ json_name }) => !pricingFieldNames.has(json_name),
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<ModelFormProps> = ({
|
||||
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<ModelFormProps> = ({
|
||||
disabled={isSaving}
|
||||
/>
|
||||
|
||||
{/* Advanced — toggle */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
className="inline-flex cursor-pointer items-center gap-1 bg-transparent border-0 p-0 text-sm font-medium text-content-secondary transition-colors hover:text-content-primary"
|
||||
>
|
||||
{showAdvanced ? (
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
)}
|
||||
Advanced
|
||||
</button>{" "}
|
||||
{showAdvanced && (
|
||||
<div className="mt-4 space-y-5">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<GeneralModelConfigFields
|
||||
provider={selectedProviderState.provider}
|
||||
form={form}
|
||||
fieldErrors={modelConfigFormBuildResult.fieldErrors}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label
|
||||
htmlFor={compressionThresholdField.id}
|
||||
className="text-sm font-medium text-content-primary"
|
||||
>
|
||||
Compression Threshold
|
||||
</Label>
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
Percentage at which context is compressed.
|
||||
</p>
|
||||
<Input
|
||||
id={compressionThresholdField.id}
|
||||
name={compressionThresholdField.name}
|
||||
className={cn(
|
||||
"h-9 text-[13px] placeholder:text-content-disabled",
|
||||
compressionThresholdField.error &&
|
||||
"border-content-destructive",
|
||||
)}
|
||||
placeholder="70"
|
||||
value={compressionThresholdField.value}
|
||||
onChange={compressionThresholdField.onChange}
|
||||
onBlur={compressionThresholdField.onBlur}
|
||||
disabled={isSaving}
|
||||
aria-invalid={compressionThresholdField.error}
|
||||
/>
|
||||
{compressionThresholdField.error && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
{compressionThresholdField.helperText}
|
||||
<div className="space-y-5">
|
||||
{/* Pricing — toggle */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPricing((v) => !v)}
|
||||
className="inline-flex cursor-pointer items-center gap-1 bg-transparent border-0 p-0 text-sm font-medium text-content-secondary transition-colors hover:text-content-primary"
|
||||
>
|
||||
{showPricing ? (
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
)}
|
||||
Pricing
|
||||
</button>{" "}
|
||||
{showPricing && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
Optional USD pricing metadata per 1M tokens. Leave any
|
||||
field blank to keep pricing unset and use provider or
|
||||
profile defaults when available.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<PricingModelConfigFields
|
||||
provider={selectedProviderState.provider}
|
||||
form={form}
|
||||
fieldErrors={modelConfigFormBuildResult.fieldErrors}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced — toggle */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
className="inline-flex cursor-pointer items-center gap-1 bg-transparent border-0 p-0 text-sm font-medium text-content-secondary transition-colors hover:text-content-primary"
|
||||
>
|
||||
{showAdvanced ? (
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
)}
|
||||
Advanced
|
||||
</button>{" "}
|
||||
{showAdvanced && (
|
||||
<div className="mt-4 space-y-5">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<GeneralModelConfigFields
|
||||
provider={selectedProviderState.provider}
|
||||
form={form}
|
||||
fieldErrors={modelConfigFormBuildResult.fieldErrors}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label
|
||||
htmlFor={compressionThresholdField.id}
|
||||
className="text-sm font-medium text-content-primary"
|
||||
>
|
||||
Compression Threshold
|
||||
</Label>
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
Percentage at which context is compressed.
|
||||
</p>
|
||||
<Input
|
||||
id={compressionThresholdField.id}
|
||||
name={compressionThresholdField.name}
|
||||
className={cn(
|
||||
"h-9 text-[13px] placeholder:text-content-disabled",
|
||||
compressionThresholdField.error &&
|
||||
"border-content-destructive",
|
||||
)}
|
||||
placeholder="70"
|
||||
value={compressionThresholdField.value}
|
||||
onChange={compressionThresholdField.onChange}
|
||||
onBlur={compressionThresholdField.onBlur}
|
||||
disabled={isSaving}
|
||||
aria-invalid={compressionThresholdField.error}
|
||||
/>
|
||||
{compressionThresholdField.error && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
{compressionThresholdField.helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer — pushed to bottom */}
|
||||
<div className="mt-auto pt-6">
|
||||
<hr className="mb-4 border-0 border-t border-solid border-border" />
|
||||
|
||||
@@ -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 }) => (
|
||||
<div data-testid="provider-icon">{provider}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
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(
|
||||
<TooltipProvider>
|
||||
<ModelsSection
|
||||
sectionLabel="Models"
|
||||
providerStates={[providerState]}
|
||||
selectedProvider="openai"
|
||||
selectedProviderState={providerState}
|
||||
onSelectedProviderChange={vi.fn()}
|
||||
modelConfigs={modelConfigs}
|
||||
modelConfigsUnavailable={false}
|
||||
isCreating={false}
|
||||
isUpdating={false}
|
||||
isDeleting={false}
|
||||
onCreateModel={vi.fn()}
|
||||
onUpdateModel={vi.fn()}
|
||||
onDeleteModel={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<ModelsSectionProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50">
|
||||
{modelConfigs.map((modelConfig) => (
|
||||
<div
|
||||
key={modelConfig.id}
|
||||
className="flex items-center gap-3.5 px-3 py-3"
|
||||
>
|
||||
{" "}
|
||||
{/* Star for default */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSetDefault(modelConfig);
|
||||
}}
|
||||
aria-disabled={isUpdating || modelConfig.is_default}
|
||||
aria-label={
|
||||
modelConfig.is_default
|
||||
? "Default model"
|
||||
: "Set as default model"
|
||||
}
|
||||
className={cn(
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-transparent border-0 p-0 transition-colors",
|
||||
modelConfig.is_default
|
||||
? "text-yellow-400"
|
||||
: "cursor-pointer text-content-secondary/30 hover:text-content-secondary",
|
||||
)}
|
||||
>
|
||||
<StarIcon
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
modelConfig.is_default && "fill-current",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{modelConfig.is_default
|
||||
? "Default model for new chats"
|
||||
: "Set as default for new chats"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Clickable row content */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView({ mode: "edit", model: modelConfig })}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3.5 bg-transparent border-0 p-0 text-left transition-colors hover:opacity-80"
|
||||
{modelConfigs.map((modelConfig) => {
|
||||
const showPricingWarning = !hasCustomPricing(
|
||||
modelConfig.model_config,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={modelConfig.id}
|
||||
className="flex items-center gap-3.5 px-3 py-3"
|
||||
>
|
||||
<ProviderIcon
|
||||
provider={modelConfig.provider}
|
||||
className="h-8 w-8 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-[15px] font-medium",
|
||||
modelConfig.enabled === false
|
||||
? "text-content-secondary"
|
||||
: "text-content-primary",
|
||||
{" "}
|
||||
{/* Star for default */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSetDefault(modelConfig);
|
||||
}}
|
||||
aria-disabled={isUpdating || modelConfig.is_default}
|
||||
aria-label={
|
||||
modelConfig.is_default
|
||||
? "Default model"
|
||||
: "Set as default model"
|
||||
}
|
||||
className={cn(
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-transparent border-0 p-0 transition-colors",
|
||||
modelConfig.is_default
|
||||
? "text-yellow-400"
|
||||
: "cursor-pointer text-content-secondary/30 hover:text-content-secondary",
|
||||
)}
|
||||
>
|
||||
<StarIcon
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
modelConfig.is_default && "fill-current",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{modelConfig.is_default
|
||||
? "Default model for new chats"
|
||||
: "Set as default for new chats"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Clickable row content */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView({ mode: "edit", model: modelConfig })}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3.5 bg-transparent border-0 p-0 text-left transition-colors hover:opacity-80"
|
||||
>
|
||||
<ProviderIcon
|
||||
provider={modelConfig.provider}
|
||||
className="h-8 w-8 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-[15px] font-medium",
|
||||
modelConfig.enabled === false
|
||||
? "text-content-secondary"
|
||||
: "text-content-primary",
|
||||
)}
|
||||
>
|
||||
{modelConfig.display_name || modelConfig.model}
|
||||
</span>
|
||||
{showPricingWarning && (
|
||||
<span className="mt-1 flex items-center gap-1 text-xs text-content-warning">
|
||||
<TriangleAlertIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
Model pricing is not defined
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
{modelConfig.display_name || modelConfig.model}
|
||||
</span>
|
||||
</div>
|
||||
{modelConfig.enabled === false && (
|
||||
<Badge size="xs" variant="warning">
|
||||
disabled
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronRightIcon className="h-5 w-5 shrink-0 text-content-secondary" />
|
||||
</button>{" "}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{modelConfig.enabled === false && (
|
||||
<Badge size="xs" variant="warning">
|
||||
disabled
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronRightIcon className="h-5 w-5 shrink-0 text-content-secondary" />
|
||||
</button>{" "}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>)[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<string, unknown> = {};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string>(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<PricingFieldName, number>;
|
||||
|
||||
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<PricingFieldName, string>;
|
||||
|
||||
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<string, unknown>)[segment];
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
export const hasCustomPricing = (
|
||||
modelConfig?: TypesGen.ChatModelCallConfig,
|
||||
): boolean =>
|
||||
pricingFieldNameList.some(
|
||||
(fieldName) =>
|
||||
getNestedValue(modelConfig, fieldName.split(".")) !== undefined,
|
||||
);
|
||||
Reference in New Issue
Block a user