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:
Michael Suchacz
2026-03-12 07:37:33 +01:00
committed by GitHub
parent 3325b86903
commit fba00a6b3a
17 changed files with 958 additions and 165 deletions
+32 -1
View File
@@ -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,
+20 -2
View File
@@ -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)
+54
View File
@@ -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
+152
View File
@@ -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()
+54
View File
@@ -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"`
+4
View File
@@ -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"
}
]
},
+17
View File
@@ -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,
);