feat(pricing): 支持分组与访问密钥价格倍率 (#584)
* feat(pricing): 增加分组与访问密钥价格倍率 * fix(pricing): 在原始总费用计算后应用倍率
This commit is contained in:
1 parent
16be863ea5
commit
25061d6579
90 files changed
+2477
-204
No files matched your search
@@ -41,16 +41,17 @@ type accessKeyCollectionRecord struct {
|
||||
}
|
||||
|
||||
type accessKeyCollectionRow struct {
|
||||
ID uint
|
||||
Name string
|
||||
KeySuffix string
|
||||
Status string
|
||||
Filters models.JSON
|
||||
RPMLimit int64
|
||||
ExpiresAtMS *int64
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
LastRequestAtMS *int64
|
||||
PriceMultiplierMicros *int64
|
||||
ID uint
|
||||
Name string
|
||||
KeySuffix string
|
||||
Status string
|
||||
Filters models.JSON
|
||||
RPMLimit int64
|
||||
ExpiresAtMS *int64
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
LastRequestAtMS *int64
|
||||
}
|
||||
|
||||
func (s *Service) ListAccessKeyCollection(
|
||||
@@ -89,7 +90,7 @@ func (s *Service) captureAccessKeyCollectionRecords(
|
||||
Select(
|
||||
"access_keys.id", "access_keys.name", "access_keys.key_suffix",
|
||||
"access_keys.status", "access_keys.filters", "access_keys.rpm_limit",
|
||||
"access_keys.expires_at_ms",
|
||||
"access_keys.expires_at_ms", "access_keys.price_multiplier_micros",
|
||||
"access_keys.created_at_ms", "access_keys.updated_at_ms",
|
||||
"(SELECT MAX(request_logs.completed_at_ms) FROM request_logs WHERE request_logs.access_key_id = access_keys.id) AS last_request_at_ms",
|
||||
).
|
||||
@@ -121,15 +122,16 @@ func (s *Service) captureAccessKeyCollectionRecords(
|
||||
}
|
||||
for _, row := range rows {
|
||||
metadata, err := mapAccessKeyMetadataRow(accessKeyMetadataRow{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
KeySuffix: row.KeySuffix,
|
||||
Status: row.Status,
|
||||
Filters: row.Filters,
|
||||
RPMLimit: row.RPMLimit,
|
||||
ExpiresAtMS: row.ExpiresAtMS,
|
||||
CreatedAtMS: row.CreatedAtMS,
|
||||
UpdatedAtMS: row.UpdatedAtMS,
|
||||
PriceMultiplierMicros: row.PriceMultiplierMicros,
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
KeySuffix: row.KeySuffix,
|
||||
Status: row.Status,
|
||||
Filters: row.Filters,
|
||||
RPMLimit: row.RPMLimit,
|
||||
ExpiresAtMS: row.ExpiresAtMS,
|
||||
CreatedAtMS: row.CreatedAtMS,
|
||||
UpdatedAtMS: row.UpdatedAtMS,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -24,12 +24,13 @@ type accessKeyFilterDigestBody struct {
|
||||
}
|
||||
|
||||
type accessKeyCreateDigestBody struct {
|
||||
Name string `json:"name"`
|
||||
Status *state.AccessKeyStatus `json:"status,omitempty"`
|
||||
Filters accessKeyFilterDigestBody `json:"filters"`
|
||||
RPMLimit int64 `json:"rpm_limit"`
|
||||
CostLimitRules []AccessKeyCostLimitRuleRequest `json:"cost_limit_rules,omitempty"`
|
||||
ExpiresAtMS *int64 `json:"expires_at_ms,omitempty"`
|
||||
PriceMultiplier string `json:"price_multiplier,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Status *state.AccessKeyStatus `json:"status,omitempty"`
|
||||
Filters accessKeyFilterDigestBody `json:"filters"`
|
||||
RPMLimit int64 `json:"rpm_limit"`
|
||||
CostLimitRules []AccessKeyCostLimitRuleRequest `json:"cost_limit_rules,omitempty"`
|
||||
ExpiresAtMS *int64 `json:"expires_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) CreateAccessKeyIdempotent(
|
||||
@@ -63,13 +64,18 @@ func (s *Service) CreateAccessKeyIdempotent(
|
||||
if err := validateOptionalExpiresAtMS(request.ExpiresAtMS); err != nil {
|
||||
return AccessKeyCreateResult{}, err
|
||||
}
|
||||
priceMultiplier, err := normalizePriceMultiplier(request.PriceMultiplier)
|
||||
if err != nil {
|
||||
return AccessKeyCreateResult{}, err
|
||||
}
|
||||
var digestStatus *state.AccessKeyStatus
|
||||
if status != state.AccessKeyStatusActive {
|
||||
digestStatus = &status
|
||||
}
|
||||
digestFilters := canonicalAccessKeyFilterSet(filters)
|
||||
canonicalBody, err := canonicalIdempotencyBody(accessKeyCreateDigestBody{
|
||||
Name: name, Status: digestStatus, Filters: digestFilters, RPMLimit: rpmLimit,
|
||||
PriceMultiplier: priceMultiplierDigest(priceMultiplier),
|
||||
Name: name, Status: digestStatus, Filters: digestFilters, RPMLimit: rpmLimit,
|
||||
CostLimitRules: costLimitRuleRequestsForDigest(costLimitRules),
|
||||
ExpiresAtMS: request.ExpiresAtMS,
|
||||
})
|
||||
@@ -109,6 +115,7 @@ func (s *Service) CreateAccessKeyIdempotent(
|
||||
if err != nil {
|
||||
return idempotentMutationResult{}, err
|
||||
}
|
||||
row.PriceMultiplierMicros = priceMultiplierStorage(priceMultiplier)
|
||||
row.Status = string(status)
|
||||
row.ExpiresAtMS = cloneOptionalInt64(request.ExpiresAtMS)
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
@@ -120,7 +127,8 @@ func (s *Service) CreateAccessKeyIdempotent(
|
||||
}
|
||||
metadata, err := mapAccessKeyMetadataRow(accessKeyMetadataRow{
|
||||
ID: row.ID, Name: row.Name, KeySuffix: row.KeySuffix,
|
||||
Status: row.Status, Filters: row.Filters, RPMLimit: row.RPMLimit,
|
||||
PriceMultiplierMicros: row.PriceMultiplierMicros,
|
||||
Status: row.Status, Filters: row.Filters, RPMLimit: row.RPMLimit,
|
||||
ExpiresAtMS: row.ExpiresAtMS,
|
||||
CreatedAtMS: row.CreatedAtMS, UpdatedAtMS: row.UpdatedAtMS,
|
||||
})
|
||||
@@ -158,6 +166,9 @@ func (s *Service) CreateAccessKeyIdempotent(
|
||||
if err := json.Unmarshal(operationResult.CanonicalResult, &metadata); err != nil {
|
||||
return AccessKeyCreateResult{}, app_errors.ErrInternalServer
|
||||
}
|
||||
if metadata.PriceMultiplier == "" {
|
||||
metadata.PriceMultiplier = "1"
|
||||
}
|
||||
if metadata.CostLimitRules == nil {
|
||||
// Pre-0002 idempotency results did not carry this additive field. Preserve
|
||||
// replay compatibility while keeping the current wire contract array-shaped.
|
||||
|
||||
@@ -127,6 +127,9 @@ func (s *Service) RotateAccessKeyIdempotent(
|
||||
if err := json.Unmarshal(operationResult.CanonicalResult, &metadata); err != nil {
|
||||
return AccessKeyRotateResult{}, app_errors.ErrInternalServer
|
||||
}
|
||||
if metadata.PriceMultiplier == "" {
|
||||
metadata.PriceMultiplier = "1"
|
||||
}
|
||||
if metadata.CostLimitRules == nil {
|
||||
metadata.CostLimitRules = []AccessKeyCostLimitRule{}
|
||||
}
|
||||
@@ -148,7 +151,7 @@ func loadAccessKeyMetadataRow(tx *gorm.DB, id uint) (accessKeyMetadataRow, error
|
||||
if err := tx.Model(&models.AccessKey{}).
|
||||
Select(
|
||||
"id", "name", "key_suffix", "status", "filters", "rpm_limit",
|
||||
"expires_at_ms", "created_at_ms", "updated_at_ms",
|
||||
"expires_at_ms", "created_at_ms", "updated_at_ms", "price_multiplier_micros",
|
||||
).
|
||||
Where("id = ?", id).
|
||||
Take(&row).Error; err != nil {
|
||||
|
||||
@@ -137,21 +137,23 @@ func (value *OptionalRPMLimit) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
type AccessKeyCreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
Status *state.AccessKeyStatus `json:"status"`
|
||||
Filters *AccessKeyFilters `json:"filters"`
|
||||
RPMLimit OptionalRPMLimit `json:"rpm_limit"`
|
||||
CostLimitRules OptionalAccessKeyCostLimitRules `json:"cost_limit_rules"`
|
||||
ExpiresAtMS *int64 `json:"expires_at_ms"`
|
||||
PriceMultiplier optionalField[string] `json:"price_multiplier"`
|
||||
Name string `json:"name"`
|
||||
Status *state.AccessKeyStatus `json:"status"`
|
||||
Filters *AccessKeyFilters `json:"filters"`
|
||||
RPMLimit OptionalRPMLimit `json:"rpm_limit"`
|
||||
CostLimitRules OptionalAccessKeyCostLimitRules `json:"cost_limit_rules"`
|
||||
ExpiresAtMS *int64 `json:"expires_at_ms"`
|
||||
}
|
||||
|
||||
type AccessKeyUpdateRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Status *state.AccessKeyStatus `json:"status"`
|
||||
Filters *AccessKeyFilters `json:"filters"`
|
||||
RPMLimit OptionalRPMLimit `json:"rpm_limit"`
|
||||
CostLimitRules OptionalAccessKeyCostLimitRules `json:"cost_limit_rules"`
|
||||
ExpiresAtMS OptionalNullableEpochMS `json:"expires_at_ms"`
|
||||
PriceMultiplier optionalField[string] `json:"price_multiplier"`
|
||||
Name *string `json:"name"`
|
||||
Status *state.AccessKeyStatus `json:"status"`
|
||||
Filters *AccessKeyFilters `json:"filters"`
|
||||
RPMLimit OptionalRPMLimit `json:"rpm_limit"`
|
||||
CostLimitRules OptionalAccessKeyCostLimitRules `json:"cost_limit_rules"`
|
||||
ExpiresAtMS OptionalNullableEpochMS `json:"expires_at_ms"`
|
||||
}
|
||||
|
||||
type AccessKeyCostLimitResetRequest struct {
|
||||
@@ -159,6 +161,7 @@ type AccessKeyCostLimitResetRequest struct {
|
||||
}
|
||||
|
||||
type AccessKeyMetadata struct {
|
||||
PriceMultiplier string `json:"price_multiplier"`
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
MaskedKey string `json:"masked_key"`
|
||||
@@ -191,15 +194,16 @@ type AccessKeyRevealResult struct {
|
||||
}
|
||||
|
||||
type accessKeyMetadataRow struct {
|
||||
ID uint
|
||||
Name string
|
||||
KeySuffix string
|
||||
Status string
|
||||
Filters models.JSON
|
||||
RPMLimit int64
|
||||
ExpiresAtMS *int64
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
PriceMultiplierMicros *int64
|
||||
ID uint
|
||||
Name string
|
||||
KeySuffix string
|
||||
Status string
|
||||
Filters models.JSON
|
||||
RPMLimit int64
|
||||
ExpiresAtMS *int64
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
type generatedAccessKeyCredential struct {
|
||||
@@ -284,6 +288,10 @@ func (s *Service) CreateAccessKey(
|
||||
return AccessKeyCreateResult{}, err
|
||||
}
|
||||
|
||||
priceMultiplier, err := normalizePriceMultiplier(request.PriceMultiplier)
|
||||
if err != nil {
|
||||
return AccessKeyCreateResult{}, err
|
||||
}
|
||||
var result AccessKeyCreateResult
|
||||
_, err = s.writeConfig(ctx, func(tx *gorm.DB) error {
|
||||
if err := validateFutureExpiresAtMS(request.ExpiresAtMS, s.now()); err != nil {
|
||||
@@ -296,6 +304,7 @@ func (s *Service) CreateAccessKey(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row.PriceMultiplierMicros = priceMultiplierStorage(priceMultiplier)
|
||||
row.Status = string(status)
|
||||
row.ExpiresAtMS = cloneOptionalInt64(request.ExpiresAtMS)
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
@@ -307,7 +316,8 @@ func (s *Service) CreateAccessKey(
|
||||
}
|
||||
metadata, err := mapAccessKeyMetadataRow(accessKeyMetadataRow{
|
||||
ID: row.ID, Name: row.Name, KeySuffix: row.KeySuffix,
|
||||
Status: row.Status, Filters: row.Filters, RPMLimit: row.RPMLimit,
|
||||
PriceMultiplierMicros: row.PriceMultiplierMicros,
|
||||
Status: row.Status, Filters: row.Filters, RPMLimit: row.RPMLimit,
|
||||
ExpiresAtMS: row.ExpiresAtMS,
|
||||
CreatedAtMS: row.CreatedAtMS, UpdatedAtMS: row.UpdatedAtMS,
|
||||
})
|
||||
@@ -333,7 +343,7 @@ func (s *Service) UpdateAccessKey(
|
||||
request AccessKeyUpdateRequest,
|
||||
) (AccessKeyMetadata, error) {
|
||||
if id == 0 || (request.Name == nil && request.Status == nil && request.Filters == nil &&
|
||||
!request.RPMLimit.Set && !request.CostLimitRules.Set && !request.ExpiresAtMS.Set) {
|
||||
!request.RPMLimit.Set && !request.CostLimitRules.Set && !request.ExpiresAtMS.Set && !request.PriceMultiplier.Set) {
|
||||
return AccessKeyMetadata{}, app_errors.ErrBadRequest
|
||||
}
|
||||
if _, err := normalizeRPMLimit(request.RPMLimit, 0); err != nil {
|
||||
@@ -344,6 +354,10 @@ func (s *Service) UpdateAccessKey(
|
||||
return AccessKeyMetadata{}, err
|
||||
}
|
||||
}
|
||||
priceMultiplier, err := normalizePriceMultiplier(request.PriceMultiplier)
|
||||
if err != nil {
|
||||
return AccessKeyMetadata{}, err
|
||||
}
|
||||
var desiredCostLimitRules []normalizedAccessKeyCostLimitRule
|
||||
if request.CostLimitRules.Set {
|
||||
var err error
|
||||
@@ -382,7 +396,7 @@ func (s *Service) UpdateAccessKey(
|
||||
}
|
||||
|
||||
var result AccessKeyMetadata
|
||||
_, err := s.writeConfig(ctx, func(tx *gorm.DB) error {
|
||||
_, err = s.writeConfig(ctx, func(tx *gorm.DB) error {
|
||||
if request.ExpiresAtMS.Set {
|
||||
if err := validateFutureExpiresAtMS(request.ExpiresAtMS.Value, s.now()); err != nil {
|
||||
return err
|
||||
@@ -391,7 +405,7 @@ func (s *Service) UpdateAccessKey(
|
||||
var row accessKeyMetadataRow
|
||||
if err := tx.Model(&models.AccessKey{}).
|
||||
Select(
|
||||
"id", "name", "key_suffix", "status", "filters", "rpm_limit", "expires_at_ms",
|
||||
"id", "name", "key_suffix", "status", "filters", "rpm_limit", "expires_at_ms", "price_multiplier_micros",
|
||||
"created_at_ms", "updated_at_ms",
|
||||
).
|
||||
Where("id = ?", id).
|
||||
@@ -423,7 +437,11 @@ func (s *Service) UpdateAccessKey(
|
||||
return fmt.Errorf("access key %d has invalid status", row.ID)
|
||||
}
|
||||
|
||||
updates := make(map[string]any, 4)
|
||||
updates := make(map[string]any, 5)
|
||||
if request.PriceMultiplier.Set {
|
||||
row.PriceMultiplierMicros = priceMultiplierStorage(priceMultiplier)
|
||||
updates["price_multiplier_micros"] = int64(priceMultiplier)
|
||||
}
|
||||
if name != nil {
|
||||
row.Name = *name
|
||||
updates["name"] = row.Name
|
||||
@@ -462,7 +480,7 @@ func (s *Service) UpdateAccessKey(
|
||||
}
|
||||
if err := tx.Model(&models.AccessKey{}).
|
||||
Select(
|
||||
"id", "name", "key_suffix", "status", "filters", "rpm_limit", "expires_at_ms",
|
||||
"id", "name", "key_suffix", "status", "filters", "rpm_limit", "expires_at_ms", "price_multiplier_micros",
|
||||
"created_at_ms", "updated_at_ms",
|
||||
).
|
||||
Where("id = ?", row.ID).
|
||||
@@ -586,7 +604,8 @@ func mapAccessKeyMetadataRow(row accessKeyMetadataRow) (AccessKeyMetadata, error
|
||||
)
|
||||
}
|
||||
return AccessKeyMetadata{
|
||||
ID: row.ID, Name: row.Name,
|
||||
PriceMultiplier: priceMultiplierResponse(row.PriceMultiplierMicros),
|
||||
ID: row.ID, Name: row.Name,
|
||||
MaskedKey: maskedAccessKey(row.KeySuffix),
|
||||
Status: status, Filters: filters, RPMLimit: row.RPMLimit,
|
||||
ExpiresAtMS: cloneOptionalInt64(row.ExpiresAtMS),
|
||||
|
||||
@@ -40,6 +40,7 @@ type GroupCollectionCredentialCounts struct {
|
||||
}
|
||||
|
||||
type GroupCollectionItem struct {
|
||||
PriceMultiplier string `json:"price_multiplier"`
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ChannelID channel.ID `json:"channel_id"`
|
||||
@@ -85,6 +86,7 @@ func cloneGroupRows(rows []models.Group) []models.Group {
|
||||
value := *rows[index].ValidationModel
|
||||
cloned[index].ValidationModel = &value
|
||||
}
|
||||
cloned[index].PriceMultiplierMicros = cloneOptionalInt64(rows[index].PriceMultiplierMicros)
|
||||
cloned[index].Credentials = nil
|
||||
}
|
||||
return cloned
|
||||
@@ -353,7 +355,8 @@ func mapGroupCollectionRecords(
|
||||
catalog := snapshot.GroupCatalog[group.ID]
|
||||
record := groupCollectionRecord{
|
||||
GroupCollectionItem: GroupCollectionItem{
|
||||
ID: group.ID, Name: group.Name, ChannelID: channelID,
|
||||
PriceMultiplier: priceMultiplierResponse(group.PriceMultiplierMicros),
|
||||
ID: group.ID, Name: group.Name, ChannelID: channelID,
|
||||
ConnectionType: normalizeGroupConnectionType(group.ConnectionType),
|
||||
Params: append(json.RawMessage(nil), params...),
|
||||
ModelCount: int64(len(groupModels)),
|
||||
|
||||
@@ -16,12 +16,14 @@ import (
|
||||
"gpt-load/internal/platform/config"
|
||||
app_errors "gpt-load/internal/platform/errors"
|
||||
"gpt-load/internal/platform/utils"
|
||||
"gpt-load/internal/pricing"
|
||||
"gpt-load/internal/state"
|
||||
stateloader "gpt-load/internal/state/loader"
|
||||
"gpt-load/internal/storage/models"
|
||||
)
|
||||
|
||||
type GroupCreateRequest struct {
|
||||
PriceMultiplier optionalField[string] `json:"price_multiplier"`
|
||||
Name *string `json:"name"`
|
||||
ChannelID channel.ID `json:"channel_id"`
|
||||
ConnectionType models.ConnectionType `json:"connection_type"`
|
||||
@@ -50,6 +52,7 @@ type SameTargetConflictData struct {
|
||||
}
|
||||
|
||||
type normalizedGroupCreate struct {
|
||||
priceMultiplier pricing.PriceMultiplier
|
||||
channelID channel.ID
|
||||
connectionType models.ConnectionType
|
||||
params models.JSON
|
||||
@@ -105,14 +108,15 @@ func (s *Service) CreateGroup(ctx context.Context, request GroupCreateRequest) (
|
||||
return fmt.Errorf("encode group models: %w", err)
|
||||
}
|
||||
group := models.Group{
|
||||
Name: name,
|
||||
ChannelID: string(normalized.channelID),
|
||||
ConnectionType: normalized.connectionType,
|
||||
Params: append(models.JSON(nil), normalized.params...),
|
||||
Models: models.JSON(encodedModels),
|
||||
Overrides: normalized.encodedOverrides,
|
||||
ProxyConfig: normalized.proxyConfig,
|
||||
Enabled: true,
|
||||
PriceMultiplierMicros: priceMultiplierStorage(normalized.priceMultiplier),
|
||||
Name: name,
|
||||
ChannelID: string(normalized.channelID),
|
||||
ConnectionType: normalized.connectionType,
|
||||
Params: append(models.JSON(nil), normalized.params...),
|
||||
Models: models.JSON(encodedModels),
|
||||
Overrides: normalized.encodedOverrides,
|
||||
ProxyConfig: normalized.proxyConfig,
|
||||
Enabled: true,
|
||||
}
|
||||
if err := tx.Create(&group).Error; err != nil {
|
||||
return app_errors.ParseDBError(err)
|
||||
@@ -159,6 +163,10 @@ func (s *Service) normalizeGroupCreate(
|
||||
if s == nil || s.channelRegistry == nil || request.ChannelID == "" {
|
||||
return normalizedGroupCreate{}, app_errors.ErrValidation
|
||||
}
|
||||
priceMultiplier, err := normalizePriceMultiplier(request.PriceMultiplier)
|
||||
if err != nil {
|
||||
return normalizedGroupCreate{}, err
|
||||
}
|
||||
connectionType, err := s.resolveChannelConnectionType(request.ChannelID, request.ConnectionType)
|
||||
if err != nil {
|
||||
return normalizedGroupCreate{}, app_errors.ErrValidation
|
||||
@@ -239,7 +247,7 @@ func (s *Service) normalizeGroupCreate(
|
||||
GlobalProxy: globalProxy,
|
||||
EnvironmentProxy: s.environmentProxy,
|
||||
Groups: []state.GroupConfig{{
|
||||
ID: 1, Name: "candidate", ChannelID: request.ChannelID,
|
||||
ID: 1, Name: "candidate", ChannelID: request.ChannelID, PriceMultiplier: &priceMultiplier,
|
||||
ConnectionType: string(connectionType), Params: canonicalParams,
|
||||
Models: runtimeModels, Settings: config.Settings{}, Proxy: proxy, Enabled: true,
|
||||
}},
|
||||
@@ -258,6 +266,7 @@ func (s *Service) normalizeGroupCreate(
|
||||
defaultName = hostname
|
||||
}
|
||||
return normalizedGroupCreate{
|
||||
priceMultiplier: priceMultiplier,
|
||||
channelID: request.ChannelID,
|
||||
connectionType: connectionType,
|
||||
params: models.JSON(canonicalParams),
|
||||
|
||||
@@ -28,6 +28,7 @@ type GroupEffectiveConfigResponse struct {
|
||||
// It deliberately excludes models and configuration that are loaded through focused
|
||||
// resources.
|
||||
type GroupSummaryResponse struct {
|
||||
PriceMultiplier string `json:"price_multiplier"`
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ChannelID channel.ID `json:"channel_id"`
|
||||
@@ -52,7 +53,8 @@ func (s *Service) GetGroupSummary(ctx context.Context, groupID uint) (GroupSumma
|
||||
continue
|
||||
}
|
||||
return GroupSummaryResponse{
|
||||
ID: record.ID, Name: record.Name,
|
||||
PriceMultiplier: record.PriceMultiplier,
|
||||
ID: record.ID, Name: record.Name,
|
||||
ChannelID: record.ChannelID, Params: append(json.RawMessage(nil), record.Params...),
|
||||
ConnectionType: record.ConnectionType,
|
||||
ServiceStatus: record.Status,
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestGetGroupSummaryUsesCollectionServiceStatusAndOnlyReturnsHeaderCounts(t
|
||||
}
|
||||
wantFields := map[string]struct{}{
|
||||
"id": {}, "name": {}, "channel_id": {}, "params": {}, "service_status": {}, "service_status_reason": {},
|
||||
"connection_type": {}, "credential_count": {}, "model_count": {},
|
||||
"connection_type": {}, "credential_count": {}, "model_count": {}, "price_multiplier": {},
|
||||
}
|
||||
for name := range fields {
|
||||
if _, exists := wantFields[name]; !exists {
|
||||
@@ -91,6 +91,9 @@ func TestGetGroupSummaryUsesCollectionServiceStatusAndOnlyReturnsHeaderCounts(t
|
||||
t.Fatalf("summary is missing expected field %q: %s", name, encoded)
|
||||
}
|
||||
}
|
||||
if string(fields["price_multiplier"]) != `"1"` {
|
||||
t.Fatalf("default price multiplier = %s, want string 1", fields["price_multiplier"])
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"models", "config", "effective_config", "enabled", "weight_manual", "validation_model",
|
||||
} {
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
)
|
||||
|
||||
type groupCreateDigestBody struct {
|
||||
PriceMultiplier string `json:"price_multiplier,omitempty"`
|
||||
Name *string `json:"name"`
|
||||
ChannelID channel.ID `json:"channel_id"`
|
||||
ConnectionType models.ConnectionType `json:"connection_type"`
|
||||
@@ -53,6 +54,7 @@ func (s *Service) CreateGroupIdempotent(
|
||||
}
|
||||
}
|
||||
digestBody := groupCreateDigestBody{
|
||||
PriceMultiplier: priceMultiplierDigest(normalized.priceMultiplier),
|
||||
Name: normalized.explicitName,
|
||||
ChannelID: normalized.channelID,
|
||||
ConnectionType: normalized.connectionType,
|
||||
@@ -122,14 +124,15 @@ func (s *Service) CreateGroupIdempotent(
|
||||
return idempotentMutationResult{}, app_errors.ErrInternalServer
|
||||
}
|
||||
group := models.Group{
|
||||
Name: name,
|
||||
ChannelID: string(normalized.channelID),
|
||||
ConnectionType: normalized.connectionType,
|
||||
Params: append(models.JSON(nil), normalized.params...),
|
||||
Models: models.JSON(encodedModels),
|
||||
Overrides: normalized.encodedOverrides,
|
||||
ProxyConfig: normalized.proxyConfig,
|
||||
Enabled: true,
|
||||
PriceMultiplierMicros: priceMultiplierStorage(normalized.priceMultiplier),
|
||||
Name: name,
|
||||
ChannelID: string(normalized.channelID),
|
||||
ConnectionType: normalized.connectionType,
|
||||
Params: append(models.JSON(nil), normalized.params...),
|
||||
Models: models.JSON(encodedModels),
|
||||
Overrides: normalized.encodedOverrides,
|
||||
ProxyConfig: normalized.proxyConfig,
|
||||
Enabled: true,
|
||||
}
|
||||
if err := tx.Create(&group).Error; err != nil {
|
||||
return idempotentMutationResult{}, app_errors.ParseDBError(err)
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
)
|
||||
|
||||
type GroupSettingsResponse struct {
|
||||
PriceMultiplier string `json:"price_multiplier"`
|
||||
ChannelID channel.ID `json:"channel_id"`
|
||||
ConnectionType models.ConnectionType `json:"connection_type"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
@@ -32,6 +33,7 @@ type GroupSettingsResponse struct {
|
||||
}
|
||||
|
||||
type GroupSettingsUpdateRequest struct {
|
||||
PriceMultiplier optionalField[string] `json:"price_multiplier"`
|
||||
Name optionalField[string] `json:"name"`
|
||||
Params optionalField[json.RawMessage] `json:"params"`
|
||||
ValidationModel optionalField[string] `json:"validation_model"`
|
||||
@@ -42,18 +44,19 @@ type GroupSettingsUpdateRequest struct {
|
||||
}
|
||||
|
||||
type normalizedGroupSettingsUpdate struct {
|
||||
name *string
|
||||
params json.RawMessage
|
||||
paramsSet bool
|
||||
validationModel *string
|
||||
validationModelSet bool
|
||||
enabled *bool
|
||||
weightManual *int
|
||||
weightManualSet bool
|
||||
encodedOverrides models.JSON
|
||||
overridesSet bool
|
||||
proxyConfig *string
|
||||
proxySet bool
|
||||
priceMultiplierMicros *int64
|
||||
name *string
|
||||
params json.RawMessage
|
||||
paramsSet bool
|
||||
validationModel *string
|
||||
validationModelSet bool
|
||||
enabled *bool
|
||||
weightManual *int
|
||||
weightManualSet bool
|
||||
encodedOverrides models.JSON
|
||||
overridesSet bool
|
||||
proxyConfig *string
|
||||
proxySet bool
|
||||
}
|
||||
|
||||
func (s *Service) GetGroupSettings(ctx context.Context, groupID uint) (GroupSettingsResponse, error) {
|
||||
@@ -115,6 +118,7 @@ func groupSettingsResponse(
|
||||
)
|
||||
}
|
||||
return GroupSettingsResponse{
|
||||
PriceMultiplier: priceMultiplierResponse(group.PriceMultiplierMicros),
|
||||
ChannelID: channelID,
|
||||
ConnectionType: normalizeGroupConnectionType(group.ConnectionType),
|
||||
Params: validated.CanonicalJSON(),
|
||||
@@ -150,11 +154,18 @@ func normalizeGroupSettingsUpdate(
|
||||
}
|
||||
}
|
||||
if !request.Name.Set && !request.Params.Set && !request.ValidationModel.Set &&
|
||||
!request.Enabled.Set && !request.WeightManual.Set && !request.Overrides.Set && !request.Proxy.Set {
|
||||
!request.Enabled.Set && !request.WeightManual.Set && !request.Overrides.Set && !request.Proxy.Set && !request.PriceMultiplier.Set {
|
||||
return normalizedGroupSettingsUpdate{}, app_errors.ErrBadRequest
|
||||
}
|
||||
|
||||
result := normalizedGroupSettingsUpdate{}
|
||||
if request.PriceMultiplier.Set {
|
||||
value, err := normalizePriceMultiplier(request.PriceMultiplier)
|
||||
if err != nil {
|
||||
return normalizedGroupSettingsUpdate{}, err
|
||||
}
|
||||
result.priceMultiplierMicros = priceMultiplierStorage(value)
|
||||
}
|
||||
if request.Name.Set {
|
||||
value, err := normalizeGroupName(&request.Name.Value)
|
||||
if err != nil {
|
||||
@@ -236,7 +247,11 @@ func (s *Service) UpdateGroupSettings(
|
||||
return app_errors.ErrValidation
|
||||
}
|
||||
|
||||
updates := make(map[string]any, 7)
|
||||
updates := make(map[string]any, 8)
|
||||
if normalized.priceMultiplierMicros != nil {
|
||||
group.PriceMultiplierMicros = normalized.priceMultiplierMicros
|
||||
updates["price_multiplier_micros"] = *normalized.priceMultiplierMicros
|
||||
}
|
||||
if normalized.name != nil {
|
||||
group.Name = *normalized.name
|
||||
updates["name"] = group.Name
|
||||
|
||||
@@ -48,7 +48,9 @@ func mapGroupRowToState(group models.Group) (state.GroupConfig, error) {
|
||||
if group.ValidationModel != nil {
|
||||
validationModel = *group.ValidationModel
|
||||
}
|
||||
multiplier := priceMultiplierFromStorage(group.PriceMultiplierMicros)
|
||||
result := state.GroupConfig{
|
||||
PriceMultiplier: &multiplier,
|
||||
ID: group.ID,
|
||||
Name: group.Name,
|
||||
ChannelID: channel.ID(group.ChannelID),
|
||||
|
||||
+14
-12
@@ -58,16 +58,17 @@ type homeCredentialRow struct {
|
||||
}
|
||||
|
||||
type homeAccessKeyRow struct {
|
||||
ID uint
|
||||
Name string
|
||||
KeySuffix string
|
||||
Status string
|
||||
Filters models.JSON
|
||||
RPMLimit int64
|
||||
ExpiresAtMS *int64
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
LastRequestAtMS *int64
|
||||
PriceMultiplierMicros *int64
|
||||
ID uint
|
||||
Name string
|
||||
KeySuffix string
|
||||
Status string
|
||||
Filters models.JSON
|
||||
RPMLimit int64
|
||||
ExpiresAtMS *int64
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
LastRequestAtMS *int64
|
||||
}
|
||||
|
||||
type homeReadRows struct {
|
||||
@@ -259,7 +260,7 @@ func (s *Service) readHomeRows(
|
||||
}
|
||||
if err := tx.Model(&models.AccessKey{}).
|
||||
Select(
|
||||
"id", "name", "key_suffix", "status", "filters", "rpm_limit",
|
||||
"id", "name", "key_suffix", "status", "filters", "rpm_limit", "price_multiplier_micros",
|
||||
"expires_at_ms",
|
||||
"created_at_ms", "updated_at_ms",
|
||||
"(SELECT MAX(request_logs.completed_at_ms) FROM request_logs WHERE request_logs.access_key_id = access_keys.id) AS last_request_at_ms",
|
||||
@@ -539,7 +540,8 @@ func mapHomeCurrentAccessKey(
|
||||
observedAtMS int64,
|
||||
) (AccessKeyCollectionItem, error) {
|
||||
metadata, err := mapAccessKeyMetadataRow(accessKeyMetadataRow{
|
||||
ID: row.ID, Name: row.Name, KeySuffix: row.KeySuffix,
|
||||
PriceMultiplierMicros: row.PriceMultiplierMicros,
|
||||
ID: row.ID, Name: row.Name, KeySuffix: row.KeySuffix,
|
||||
Status: row.Status, Filters: row.Filters, RPMLimit: row.RPMLimit,
|
||||
ExpiresAtMS: row.ExpiresAtMS,
|
||||
CreatedAtMS: row.CreatedAtMS, UpdatedAtMS: row.UpdatedAtMS,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
app_errors "gpt-load/internal/platform/errors"
|
||||
"gpt-load/internal/pricing"
|
||||
)
|
||||
|
||||
func normalizePriceMultiplier(field optionalField[string]) (pricing.PriceMultiplier, error) {
|
||||
if !field.Set {
|
||||
return pricing.DefaultPriceMultiplier, nil
|
||||
}
|
||||
if field.Null {
|
||||
return 0, app_errors.ErrValidation
|
||||
}
|
||||
value, err := pricing.ParsePriceMultiplier(field.Value)
|
||||
if err != nil {
|
||||
return 0, app_errors.ErrValidation
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func priceMultiplierStorage(value pricing.PriceMultiplier) *int64 {
|
||||
micros := int64(value)
|
||||
return µs
|
||||
}
|
||||
|
||||
func priceMultiplierFromStorage(value *int64) pricing.PriceMultiplier {
|
||||
if value == nil {
|
||||
return pricing.DefaultPriceMultiplier
|
||||
}
|
||||
return pricing.PriceMultiplier(*value)
|
||||
}
|
||||
|
||||
func priceMultiplierResponse(value *int64) string {
|
||||
return pricing.FormatPriceMultiplier(priceMultiplierFromStorage(value))
|
||||
}
|
||||
|
||||
func priceMultiplierDigest(value pricing.PriceMultiplier) string {
|
||||
if value == pricing.DefaultPriceMultiplier {
|
||||
return ""
|
||||
}
|
||||
return pricing.FormatPriceMultiplier(value)
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"gpt-load/internal/channel"
|
||||
app_errors "gpt-load/internal/platform/errors"
|
||||
"gpt-load/internal/pricing"
|
||||
"gpt-load/internal/storage/models"
|
||||
)
|
||||
|
||||
func TestPriceMultiplierGroupAndAccessKeyConfiguration(t *testing.T) {
|
||||
fixture := newServiceFixture(t)
|
||||
groupID := createGroupWithCredentials(t, fixture, "sk-price-multiplier")
|
||||
settings, err := fixture.service.GetGroupSettings(t.Context(), groupID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPriceMultiplierJSON(t, settings, "1")
|
||||
for _, value := range []string{"0", "0.125000", "1000"} {
|
||||
var request GroupSettingsUpdateRequest
|
||||
if err := json.Unmarshal([]byte(`{"price_multiplier":"`+value+`"}`), &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated, err := fixture.service.UpdateGroupSettings(t.Context(), groupID, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := value
|
||||
if value == "0.125000" {
|
||||
want = "0.125"
|
||||
}
|
||||
assertPriceMultiplierJSON(t, updated, want)
|
||||
}
|
||||
var create AccessKeyCreateRequest
|
||||
if err := json.Unmarshal([]byte(`{"name":"priced key","price_multiplier":"0"}`), &create); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, err := fixture.service.CreateAccessKey(t.Context(), create)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPriceMultiplierJSON(t, key, "0")
|
||||
previous := fixture.manager.Current()
|
||||
if previous.AccessKeysByID[key.ID].PriceMultiplier != 0 || previous.Groups[groupID].PriceMultiplier != 1_000_000_000 {
|
||||
t.Fatal("configured multipliers were not published")
|
||||
}
|
||||
var update AccessKeyUpdateRequest
|
||||
if err := json.Unmarshal([]byte(`{"price_multiplier":"1.250000"}`), &update); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated, err := fixture.service.UpdateAccessKey(t.Context(), key.ID, update)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPriceMultiplierJSON(t, updated, "1.25")
|
||||
if previous.AccessKeysByID[key.ID].PriceMultiplier != 0 || fixture.manager.Current().AccessKeysByID[key.ID].PriceMultiplier != 1_250_000 {
|
||||
t.Fatal("snapshot publication changed historical multiplier")
|
||||
}
|
||||
collection, err := fixture.service.ListAccessKeyCollection(t.Context(), AccessKeyCollectionQuery{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(collection.Items) != 1 {
|
||||
t.Fatalf("collection = %#v", collection)
|
||||
}
|
||||
assertPriceMultiplierJSON(t, collection.Items[0], "1.25")
|
||||
name := "renamed key"
|
||||
preserved, err := fixture.service.UpdateAccessKey(t.Context(), key.ID, AccessKeyUpdateRequest{Name: &name})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPriceMultiplierJSON(t, preserved, "1.25")
|
||||
group, err := fixture.service.GetGroupSummary(t.Context(), groupID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPriceMultiplierJSON(t, group, "1000")
|
||||
preservedGroup, err := fixture.service.UpdateGroupSettings(t.Context(), groupID, GroupSettingsUpdateRequest{
|
||||
Name: optionalField[string]{Set: true, Value: "renamed multiplier group"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPriceMultiplierJSON(t, preservedGroup, "1000")
|
||||
home, err := fixture.service.ReadAccessKeyHomeBase(t.Context(), fixture.service.now().UnixMilli(), key.ID)
|
||||
if err != nil || home.CurrentAccessKey == nil {
|
||||
t.Fatalf("access key home = %#v, %v", home, err)
|
||||
}
|
||||
assertPriceMultiplierJSON(t, home.CurrentAccessKey, "1.25")
|
||||
rotated, err := fixture.service.RotateAccessKeyIdempotent(t.Context(), "218f47a2-9c35-4d6e-8b1a-123456789011", key.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPriceMultiplierJSON(t, rotated, "1.25")
|
||||
if fixture.manager.Current().AccessKeysByID[key.ID].PriceMultiplier != 1_250_000 {
|
||||
t.Fatal("rotation changed the configured price multiplier")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceMultiplierLegacyAccessKeyResultsReplayOriginalDefault(t *testing.T) {
|
||||
for _, operation := range []string{"create", "rotate"} {
|
||||
t.Run(operation, func(t *testing.T) {
|
||||
fixture := newServiceFixture(t)
|
||||
request := AccessKeyCreateRequest{Name: "legacy multiplier"}
|
||||
const idempotencyKey = "218f47a2-9c35-4d6e-8b1a-123456789012"
|
||||
var id uint
|
||||
if operation == "create" {
|
||||
created, err := fixture.service.CreateAccessKeyIdempotent(t.Context(), idempotencyKey, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id = created.ID
|
||||
} else {
|
||||
created, err := fixture.service.CreateAccessKey(t.Context(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id = created.ID
|
||||
if _, err := fixture.service.RotateAccessKeyIdempotent(t.Context(), idempotencyKey, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var row models.ControlOperation
|
||||
if err := fixture.db.Where("idempotency_key = ?", idempotencyKey).Take(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var legacyResult map[string]json.RawMessage
|
||||
if err := json.Unmarshal(row.CanonicalResult, &legacyResult); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delete(legacyResult, "price_multiplier")
|
||||
encoded, err := json.Marshal(legacyResult)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := fixture.db.Model(&row).Update("canonical_result", models.JSON(encoded)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fixture.service.UpdateAccessKey(t.Context(), id, AccessKeyUpdateRequest{
|
||||
PriceMultiplier: optionalField[string]{Set: true, Value: "0.5"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if operation == "create" {
|
||||
request.PriceMultiplier = optionalField[string]{Set: true, Value: "1.000000"}
|
||||
replayed, err := fixture.service.CreateAccessKeyIdempotent(t.Context(), idempotencyKey, request)
|
||||
if err != nil || !replayed.Replayed || replayed.Key != "" {
|
||||
t.Fatalf("legacy create replay = %#v, %v", replayed, err)
|
||||
}
|
||||
assertPriceMultiplierJSON(t, replayed, "1")
|
||||
} else {
|
||||
replayed, err := fixture.service.RotateAccessKeyIdempotent(t.Context(), idempotencyKey, id)
|
||||
if err != nil || !replayed.Replayed || replayed.Key != "" {
|
||||
t.Fatalf("legacy rotate replay = %#v, %v", replayed, err)
|
||||
}
|
||||
assertPriceMultiplierJSON(t, replayed, "1")
|
||||
}
|
||||
if fixture.manager.Current().AccessKeysByID[id].PriceMultiplier != 500_000 {
|
||||
t.Fatal("replay changed the current price multiplier")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceMultiplierGroupDefaultPreservesLegacyDigest(t *testing.T) {
|
||||
fixture := newServiceFixture(t)
|
||||
request := GroupCreateRequest{
|
||||
Name: stringPointer("legacy multiplier"), ChannelID: channel.OpenAI, ConnectionType: "api_key",
|
||||
Params: json.RawMessage(`{}`), Models: optionalGroupModels{Set: true}, Credentials: "K",
|
||||
}
|
||||
const idempotencyKey = "218f47a2-9c35-4d6e-8b1a-123456789013"
|
||||
created, err := fixture.service.CreateGroupIdempotent(t.Context(), idempotencyKey, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyDigest, err := buildIdempotencyDigest(idempotencyDigestInput{
|
||||
Version: 1, Method: "POST", OperationKind: operationKindGroupCreate,
|
||||
PathTemplate: "/api/groups", ResourceLocator: "new", AuthScopeID: idempotencyAuthScopeID,
|
||||
CanonicalBody: []byte(`{"channel_id":"openai","connection_type":"api_key","credentials":["K"],"models":null,"name":"legacy multiplier","params":{}}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var row models.ControlOperation
|
||||
if err := fixture.db.Where("idempotency_key = ?", idempotencyKey).Take(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(row.RequestDigest, legacyDigest.Digest[:]) {
|
||||
t.Fatal("default multiplier changed the pre-multiplier group create digest")
|
||||
}
|
||||
request.PriceMultiplier = optionalField[string]{Set: true, Value: "1.000000"}
|
||||
replayed, err := fixture.service.CreateGroupIdempotent(t.Context(), idempotencyKey, request)
|
||||
if err != nil || replayed != created {
|
||||
t.Fatalf("default-equivalent replay = %#v, %v", replayed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceMultiplierCreationAndIdempotency(t *testing.T) {
|
||||
fixture := newServiceFixture(t)
|
||||
request := GroupCreateRequest{
|
||||
Name: stringPointer("multiplier-group"), ChannelID: channel.OpenAICompatible,
|
||||
ConnectionType: "api_key", Params: json.RawMessage(`{"base_url":"https://multiplier.example/v1"}`),
|
||||
Models: optionalGroupModels{Set: true, Values: []GroupModel{{ID: "model"}}}, Credentials: "sk-multiplier",
|
||||
PriceMultiplier: optionalField[string]{Set: true, Value: "0"},
|
||||
}
|
||||
created, err := fixture.service.CreateGroupIdempotent(t.Context(), "218f47a2-9c35-4d6e-8b1a-123456789008", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, err := fixture.service.GetGroupSettings(t.Context(), created.GroupID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPriceMultiplierJSON(t, settings, "0")
|
||||
request.PriceMultiplier.Value = "0.000000"
|
||||
if _, err := fixture.service.CreateGroupIdempotent(t.Context(), "218f47a2-9c35-4d6e-8b1a-123456789008", request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.PriceMultiplier.Value = "1"
|
||||
_, err = fixture.service.CreateGroupIdempotent(t.Context(), "218f47a2-9c35-4d6e-8b1a-123456789008", request)
|
||||
assertAPIErrorCode(t, err, app_errors.ErrIdempotencyKeyReused.Code)
|
||||
keyRequest := AccessKeyCreateRequest{Name: "idempotent multiplier"}
|
||||
const key = "218f47a2-9c35-4d6e-8b1a-123456789009"
|
||||
first, err := fixture.service.CreateAccessKeyIdempotent(t.Context(), key, keyRequest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPriceMultiplierJSON(t, first, "1")
|
||||
keyRequest.PriceMultiplier = optionalField[string]{Set: true, Value: "1.000000"}
|
||||
replayed, err := fixture.service.CreateAccessKeyIdempotent(t.Context(), key, keyRequest)
|
||||
if err != nil || !replayed.Replayed {
|
||||
t.Fatalf("default replay = %#v, %v", replayed, err)
|
||||
}
|
||||
keyRequest.PriceMultiplier.Value = "0.5"
|
||||
_, err = fixture.service.CreateAccessKeyIdempotent(t.Context(), key, keyRequest)
|
||||
assertAPIErrorCode(t, err, app_errors.ErrIdempotencyKeyReused.Code)
|
||||
keyRequest.PriceMultiplier.Value = "0"
|
||||
zero, err := fixture.service.CreateAccessKeyIdempotent(t.Context(), "218f47a2-9c35-4d6e-8b1a-123456789010", keyRequest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPriceMultiplierJSON(t, zero, "0")
|
||||
if fixture.manager.Current().AccessKeysByID[zero.ID].PriceMultiplier != pricing.PriceMultiplier(0) {
|
||||
t.Fatal("idempotent create lost zero multiplier")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceMultiplierConfigurationRejectsInvalidValues(t *testing.T) {
|
||||
fixture := newServiceFixture(t)
|
||||
groupID := createGroupWithCredentials(t, fixture, "sk-price-invalid")
|
||||
key, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{Name: "invalid multiplier"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, raw := range []string{`null`, `""`, `"-1"`, `"1000.000001"`, `"0.1234567"`, `"1e2"`, `1`, `true`} {
|
||||
t.Run(raw, func(t *testing.T) {
|
||||
var groupRequest GroupSettingsUpdateRequest
|
||||
if err := json.Unmarshal([]byte(`{"price_multiplier":`+raw+`}`), &groupRequest); err == nil {
|
||||
_, err = fixture.service.UpdateGroupSettings(t.Context(), groupID, groupRequest)
|
||||
assertAPIErrorCode(t, err, app_errors.ErrValidation.Code)
|
||||
}
|
||||
var keyRequest AccessKeyUpdateRequest
|
||||
if err := json.Unmarshal([]byte(`{"price_multiplier":`+raw+`}`), &keyRequest); err == nil {
|
||||
_, err = fixture.service.UpdateAccessKey(t.Context(), key.ID, keyRequest)
|
||||
assertAPIErrorCode(t, err, app_errors.ErrValidation.Code)
|
||||
}
|
||||
var createRequest AccessKeyCreateRequest
|
||||
if err := json.Unmarshal([]byte(`{"name":"bad multiplier","price_multiplier":`+raw+`}`), &createRequest); err == nil {
|
||||
_, err = fixture.service.CreateAccessKey(t.Context(), createRequest)
|
||||
assertAPIErrorCode(t, err, app_errors.ErrValidation.Code)
|
||||
}
|
||||
var groupCreate GroupCreateRequest
|
||||
if err := json.Unmarshal([]byte(`{"name":"bad multiplier group","channel_id":"openai","connection_type":"api_key","params":{},"models":[],"credentials":"sk-invalid","price_multiplier":`+raw+`}`), &groupCreate); err == nil {
|
||||
_, err = fixture.service.CreateGroup(t.Context(), groupCreate)
|
||||
assertAPIErrorCode(t, err, app_errors.ErrValidation.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertPriceMultiplierJSON(t *testing.T, value any, want string) {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(encoded, &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["price_multiplier"] != want {
|
||||
t.Fatalf("price_multiplier = %#v, want %q", body["price_multiplier"], want)
|
||||
}
|
||||
}
|
||||
@@ -119,9 +119,11 @@ type requestLogPricingReceiptResponse struct {
|
||||
MethodVersion int `json:"method_version"`
|
||||
Currency string `json:"currency"`
|
||||
PricingMode pricing.Mode `json:"pricing_mode"`
|
||||
PriceMultipliers *pricing.PriceMultipliers `json:"price_multipliers,omitempty"`
|
||||
Rule requestLogPricingIdentityResponse `json:"rule"`
|
||||
ContextThresholdTokens *string `json:"context_threshold_tokens"`
|
||||
LineItems []requestLogPricingLineResponse `json:"line_items"`
|
||||
BaseTotalNanoUSD *string `json:"base_total_nano_usd,omitempty"`
|
||||
TotalNanoUSD string `json:"total_nano_usd"`
|
||||
}
|
||||
|
||||
@@ -1132,14 +1134,15 @@ func mapRequestLogPricingReceipt(
|
||||
return nil, fmt.Errorf("map request log pricing receipt: %w", err)
|
||||
}
|
||||
result := &requestLogPricingReceiptResponse{
|
||||
SchemaVersion: receipt.SchemaVersion,
|
||||
Method: receipt.Method,
|
||||
MethodVersion: receipt.MethodVersion,
|
||||
Currency: receipt.Currency,
|
||||
PricingMode: receipt.PricingMode,
|
||||
Rule: requestLogPricingIdentityResponse{ModelID: receipt.Rule.ModelID},
|
||||
LineItems: make([]requestLogPricingLineResponse, 0, len(receipt.LineItems)),
|
||||
TotalNanoUSD: strconv.FormatInt(receipt.TotalNanoUSD, 10),
|
||||
SchemaVersion: receipt.SchemaVersion,
|
||||
Method: receipt.Method,
|
||||
MethodVersion: receipt.MethodVersion,
|
||||
Currency: receipt.Currency,
|
||||
PricingMode: receipt.PricingMode,
|
||||
PriceMultipliers: receipt.PriceMultipliers,
|
||||
Rule: requestLogPricingIdentityResponse{ModelID: receipt.Rule.ModelID},
|
||||
LineItems: make([]requestLogPricingLineResponse, 0, len(receipt.LineItems)),
|
||||
TotalNanoUSD: strconv.FormatInt(receipt.TotalNanoUSD, 10),
|
||||
}
|
||||
if receipt.SchemaVersion < 4 {
|
||||
result.PricingMode = pricing.ModeStandard
|
||||
@@ -1148,7 +1151,7 @@ func mapRequestLogPricingReceipt(
|
||||
scopeKey := receipt.Rule.ScopeKey
|
||||
result.Rule.ScopeKey = &scopeKey
|
||||
}
|
||||
if receipt.SchemaVersion == 3 || receipt.SchemaVersion == 4 {
|
||||
if receipt.SchemaVersion >= 3 {
|
||||
channelID := channel.ID(receipt.Rule.ChannelID)
|
||||
if _, ok := requestLogChannelRegistry.Get(channelID); !ok {
|
||||
return nil, fmt.Errorf("map request log pricing receipt: unknown channel ID")
|
||||
@@ -1160,6 +1163,10 @@ func mapRequestLogPricingReceipt(
|
||||
value := strconv.FormatInt(*receipt.ContextThresholdTokens, 10)
|
||||
result.ContextThresholdTokens = &value
|
||||
}
|
||||
if receipt.BaseTotalNanoUSD != nil {
|
||||
value := strconv.FormatInt(*receipt.BaseTotalNanoUSD, 10)
|
||||
result.BaseTotalNanoUSD = &value
|
||||
}
|
||||
for _, line := range receipt.LineItems {
|
||||
mapped := requestLogPricingLineResponse{
|
||||
Code: line.Code,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"gpt-load/internal/pricing"
|
||||
)
|
||||
|
||||
func TestMapRequestLogReceiptIncludesFrozenPriceMultipliers(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
schema int
|
||||
baseField string
|
||||
wantBase string
|
||||
lineAmount int64
|
||||
}{
|
||||
{"v6 separates original and final totals", 6, `,"base_total_nano_usd":2000000`, "2000000", 2_000_000},
|
||||
{"v5 preserves historical adjusted lines", 5, "", "", 2_400_000},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var receipt pricing.Receipt
|
||||
if err := json.Unmarshal(fmt.Appendf(nil, `{
|
||||
"schema_version":%d,"method":"unit_rate_sum","method_version":1,
|
||||
"currency":"USD","pricing_mode":"standard",
|
||||
"rule":{"channel_id":"openai","model_id":"gpt-4o"},
|
||||
"price_multipliers":{"group":"0.8","access_key":"1.5"},
|
||||
"line_items":[{"code":"input","quantity":1000,"rate_nano_usd_per_million":2000000000,
|
||||
"multiplier":{"numerator":1,"denominator":1},"state":"priced","amount_nano_usd":%d}],
|
||||
"total_nano_usd":2400000%s
|
||||
}`, test.schema, test.lineAmount, test.baseField), &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := mapRequestLogPricingReceipt(&receipt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encoded, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var actual struct {
|
||||
Rule struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
} `json:"rule"`
|
||||
PriceMultipliers map[string]string `json:"price_multipliers"`
|
||||
TotalNanoUSD string `json:"total_nano_usd"`
|
||||
BaseTotalNanoUSD *string `json:"base_total_nano_usd"`
|
||||
LineItems []struct {
|
||||
AmountNanoUSD string `json:"amount_nano_usd"`
|
||||
} `json:"line_items"`
|
||||
}
|
||||
if err := json.Unmarshal(encoded, &actual); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if actual.Rule.ChannelID != "openai" || actual.PriceMultipliers["group"] != "0.8" || actual.PriceMultipliers["access_key"] != "1.5" || actual.TotalNanoUSD != "2400000" {
|
||||
t.Fatalf("receipt response = %s", encoded)
|
||||
}
|
||||
if test.wantBase == "" {
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(encoded, &fields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, present := fields["base_total_nano_usd"]; present {
|
||||
t.Fatalf("historical receipt acquired a base total: %s", encoded)
|
||||
}
|
||||
} else if actual.BaseTotalNanoUSD == nil || *actual.BaseTotalNanoUSD != test.wantBase {
|
||||
t.Fatalf("receipt lost original total: %s", encoded)
|
||||
}
|
||||
if len(actual.LineItems) != 1 || actual.LineItems[0].AmountNanoUSD != fmt.Sprint(test.lineAmount) {
|
||||
t.Fatalf("receipt reinterpreted original lines: %s", encoded)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -171,7 +171,7 @@ func TestHandlerAccountsFinalEstimateWhenRequestIDGenerationFails(t *testing.T)
|
||||
Usage: usage.Result{State: usage.StateComplete, Tokens: usage.Tokens{UncachedInput: 1_000_000}},
|
||||
}}}
|
||||
sink := &recordingRequestLogSink{}
|
||||
engine, handler, _, _ := newRequestLogHandlerTestRuntime(
|
||||
engine, handler, manager, _ := newRequestLogHandlerTestRuntime(
|
||||
t, forwarder, &recordingAccessKeyRPMLimiter{}, sink, "sk-first",
|
||||
)
|
||||
runtime := accessquota.NewRuntime()
|
||||
@@ -180,6 +180,11 @@ func TestHandlerAccountsFinalEstimateWhenRequestIDGenerationFails(t *testing.T)
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := gatewayAccessQuotaCompileInput(handler, nil)
|
||||
setGatewayPriceMultipliers(t, &input, "0.8", "1.5")
|
||||
if _, err := manager.Publish(input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler.accessQuota = runtime
|
||||
handler.priceTables = &mutableGatewayPriceTableProvider{table: mustGatewayPriceTable(t, 2_000_000_000, true)}
|
||||
handler.newRequestID = func() (string, error) { return "", errors.New("entropy unavailable") }
|
||||
@@ -194,7 +199,7 @@ func TestHandlerAccountsFinalEstimateWhenRequestIDGenerationFails(t *testing.T)
|
||||
t.Fatalf("response = %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
view := runtime.Snapshot(1, time.Unix(3_001, 0))
|
||||
if len(view.Rules) != 1 || view.Rules[0].UsedNanoUSD != 2_000_000_000 {
|
||||
if len(view.Rules) != 1 || view.Rules[0].UsedNanoUSD != 2_400_000_000 {
|
||||
t.Fatalf("quota view = %#v", view)
|
||||
}
|
||||
if events := sink.snapshot(); len(events) != 0 {
|
||||
|
||||
@@ -116,14 +116,19 @@ func (handler *Handler) freezeAttemptPricing(
|
||||
selection scheduler.Selection,
|
||||
observations dialect.RequestMetadata,
|
||||
observationsAvailable bool,
|
||||
accessKeyMultiplier pricing.PriceMultiplier,
|
||||
) frozenAttemptPricing {
|
||||
frozen := frozenAttemptPricing{
|
||||
channelID: string(selection.ChannelID),
|
||||
groupID: selection.GroupID,
|
||||
upstreamModel: optionalModelValue(selection.UpstreamModelID),
|
||||
applicable: observations.ObserveUsage,
|
||||
metadataSet: true,
|
||||
pricingMode: observations.PricingMode,
|
||||
channelID: string(selection.ChannelID),
|
||||
groupID: selection.GroupID,
|
||||
upstreamModel: optionalModelValue(selection.UpstreamModelID),
|
||||
applicable: observations.ObserveUsage,
|
||||
metadataSet: true,
|
||||
pricingMode: observations.PricingMode,
|
||||
priceMultipliers: pricing.PriceMultipliers{
|
||||
Group: selection.Group.PriceMultiplier,
|
||||
AccessKey: accessKeyMultiplier,
|
||||
},
|
||||
usageDiagnostics: observations.UsageDiagnostics,
|
||||
reasoning: observations.Reasoning.Clone(),
|
||||
}
|
||||
@@ -403,6 +408,7 @@ func (handler *Handler) Handle(ginContext *gin.Context) {
|
||||
selectedRoute.Protocol,
|
||||
handler.requestNow,
|
||||
)
|
||||
recorder.accessKeyMultiplier = accessKey.PriceMultiplier
|
||||
defer func() {
|
||||
recorder.completeMissingOutcome(
|
||||
ginContext.Writer.Written(),
|
||||
@@ -885,6 +891,7 @@ func (handler *Handler) executeAttempts(
|
||||
selection,
|
||||
attemptObservations,
|
||||
attemptObservationsAvailable,
|
||||
recorder.accessKeyMultiplier,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1132,6 +1139,7 @@ func (handler *Handler) executeAttempts(
|
||||
selection,
|
||||
attemptObservations,
|
||||
attemptObservationsAvailable,
|
||||
recorder.accessKeyMultiplier,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gpt-load/internal/accessquota"
|
||||
"gpt-load/internal/pricing"
|
||||
"gpt-load/internal/state"
|
||||
"gpt-load/internal/usage"
|
||||
)
|
||||
|
||||
func TestHandlerFreezesPriceMultipliersAndAccountsTheSameEstimate(t *testing.T) {
|
||||
for _, stream := range []bool{false, true} {
|
||||
name := "ordinary"
|
||||
if stream {
|
||||
name = "stream"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
forwarder := &scriptedForwarder{results: []UpstreamResult{{
|
||||
StatusCode: http.StatusOK, Header: make(http.Header), RequestWritten: true,
|
||||
Body: []byte(`{"ok":true}`),
|
||||
Usage: usage.Result{State: usage.StateComplete, Tokens: usage.Tokens{UncachedInput: 1, Output: 1}},
|
||||
}}}
|
||||
if stream {
|
||||
forwarder.results[0].Committed = true
|
||||
forwarder.results[0].Stream = StreamObservation{EndReason: StreamEndCleanEOF}
|
||||
forwarder.streamResults = forwarder.results
|
||||
}
|
||||
sink := &recordingRequestLogSink{}
|
||||
engine, handler, manager, _ := newRequestLogHandlerTestRuntime(
|
||||
t, forwarder, &recordingAccessKeyRPMLimiter{}, sink, "sk-first",
|
||||
)
|
||||
runtime := accessquota.NewRuntime()
|
||||
rules := []accessquota.Rule{{ID: 901, Revision: 1, Kind: accessquota.KindTotal, LimitNanoUSD: 10_000_000}}
|
||||
if err := runtime.Reconcile(map[uint][]accessquota.Rule{1: rules}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler.accessQuota = runtime
|
||||
table, err := pricing.NewTable([]pricing.Rule{{
|
||||
Identity: pricing.Identity{ChannelID: "openai", ModelID: "gpt-4o"},
|
||||
Prices: pricing.Prices{
|
||||
Input: pricing.Price{NanoUSDPerMillion: 600_000, Set: true},
|
||||
Output: pricing.Price{NanoUSDPerMillion: 600_000, Set: true},
|
||||
},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler.priceTables = &mutableGatewayPriceTableProvider{table: table}
|
||||
input := gatewayAccessQuotaCompileInput(handler, rules)
|
||||
setGatewayPriceMultipliers(t, &input, "2", "1")
|
||||
if _, err := manager.Publish(input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forwarder.onCall = func(int) {
|
||||
setGatewayPriceMultipliers(t, &input, "7", "9")
|
||||
if _, err := manager.Publish(input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
forwarder.onStreamCall = func(index int, _ http.ResponseWriter) { forwarder.onCall(index) }
|
||||
body := `{"model":"gpt-4o"}`
|
||||
if stream {
|
||||
body = `{"model":"gpt-4o","stream":true}`
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
||||
request.Header.Set("Authorization", "Bearer gl-client")
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("response = %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
events := sink.snapshot()
|
||||
if len(events) != 1 || events[0].Usage.Pricing.EstimatedCostNanoUSD != 4 {
|
||||
t.Fatalf("frozen multiplier estimate = %#v, want original base 2 adjusted to 4", events)
|
||||
}
|
||||
var receipt struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
BaseTotalNanoUSD *int64 `json:"base_total_nano_usd"`
|
||||
PriceMultipliers struct {
|
||||
Group string `json:"group"`
|
||||
AccessKey string `json:"access_key"`
|
||||
} `json:"price_multipliers"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(events[0].Usage.Pricing.ReceiptJSON), &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if receipt.SchemaVersion != 6 || receipt.PriceMultipliers.Group != "2" || receipt.PriceMultipliers.AccessKey != "1" || receipt.BaseTotalNanoUSD == nil || *receipt.BaseTotalNanoUSD != 2 {
|
||||
t.Fatalf("frozen receipt = %#v", receipt)
|
||||
}
|
||||
view := runtime.Snapshot(1, time.Now())
|
||||
if len(view.Rules) != 1 || view.Rules[0].UsedNanoUSD != 4 {
|
||||
t.Fatalf("quota = %#v, want the same adjusted estimate", view)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setGatewayPriceMultipliers(t *testing.T, input *state.CompileInput, group, accessKey string) {
|
||||
t.Helper()
|
||||
groupMultiplier, err := pricing.ParsePriceMultiplier(group)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyMultiplier, err := pricing.ParsePriceMultiplier(accessKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input.Groups[0].PriceMultiplier = &groupMultiplier
|
||||
input.AccessKeys[0].PriceMultiplier = &keyMultiplier
|
||||
}
|
||||
|
||||
func TestHandlerCrossGroupRetryUsesFinalUsageGroupMultiplier(t *testing.T) {
|
||||
forwarder := &scriptedForwarder{results: []UpstreamResult{
|
||||
{
|
||||
StatusCode: http.StatusTooManyRequests, Header: make(http.Header), RequestWritten: true,
|
||||
Body: []byte(`{"error":{"type":"rate_limit_error"}}`),
|
||||
ClassificationBody: []byte(`{"error":{"type":"rate_limit_error"}}`),
|
||||
Usage: usage.Result{State: usage.StateComplete, Tokens: usage.Tokens{UncachedInput: 1_000_000}},
|
||||
},
|
||||
{
|
||||
StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true,
|
||||
Usage: usage.Result{State: usage.StateComplete, Tokens: usage.Tokens{UncachedInput: 1000}},
|
||||
},
|
||||
}}
|
||||
sink := &recordingRequestLogSink{}
|
||||
engine, handler, manager, registry := newRequestLogHandlerTestRuntime(
|
||||
t, forwarder, &recordingAccessKeyRPMLimiter{}, sink, "sk-first", "sk-second",
|
||||
)
|
||||
entries, err := registry.SnapshotGroupCredentialEntriesExact(1, []uint{1, 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries[1].GroupID = 2
|
||||
if err := registry.ReplaceCredentials(entries); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := gatewayAccessQuotaCompileInput(handler, nil)
|
||||
setGatewayPriceMultipliers(t, &input, "7", "1.5")
|
||||
second := input.Groups[0]
|
||||
second.ID, second.Name = 2, "second"
|
||||
secondMultiplier := pricing.PriceMultiplier(800_000)
|
||||
second.PriceMultiplier = &secondMultiplier
|
||||
input.Groups = append(input.Groups, second)
|
||||
input.Credentials = append(input.Credentials, state.CredentialConfig{
|
||||
ID: 2, GroupID: 2, Version: 1, IdentityGeneration: 2, Fingerprint: "credential-2", Status: state.CredentialStatusActive,
|
||||
})
|
||||
if _, err := manager.Publish(input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler.newRandom = func() *rand.Rand { return rand.New(zeroSource{}) }
|
||||
handler.priceTables = &mutableGatewayPriceTableProvider{table: mustGatewayPriceTable(t, 2_000_000_000, true)}
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gpt-4o"}`))
|
||||
request.Header.Set("Authorization", "Bearer gl-client")
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, request)
|
||||
events := sink.snapshot()
|
||||
if response.Code != http.StatusOK || len(events) != 1 || len(events[0].Attempts) != 2 ||
|
||||
events[0].Usage.GroupID != 2 || events[0].Usage.AttemptSequence != 2 || events[0].Usage.Pricing.EstimatedCostNanoUSD != 2_400_000 {
|
||||
t.Fatalf("retry response = %d; events = %#v", response.Code, events)
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ type frozenAttemptPricing struct {
|
||||
applicable bool
|
||||
metadataSet bool
|
||||
pricingMode pricing.Mode
|
||||
priceMultipliers pricing.PriceMultipliers
|
||||
usageDiagnostics usage.Diagnostics
|
||||
reasoning reasoning.Config
|
||||
}
|
||||
@@ -55,6 +56,7 @@ type requestRecorder struct {
|
||||
requestID string
|
||||
startedAt time.Time
|
||||
accessKeyID uint
|
||||
accessKeyMultiplier pricing.PriceMultiplier
|
||||
protocol protocol.Protocol
|
||||
operation execution.Operation
|
||||
clientModel string
|
||||
@@ -97,10 +99,11 @@ func newRequestRecorder(
|
||||
return &requestRecorder{
|
||||
sink: sink, requestID: requestID, startedAt: startedAt,
|
||||
accessKeyID: accessKeyID, protocol: value, now: now,
|
||||
pendingRetry: -1,
|
||||
redactor: redact.New(),
|
||||
usageApplicable: true,
|
||||
usage: notApplicableUsageObservation(),
|
||||
accessKeyMultiplier: pricing.DefaultPriceMultiplier,
|
||||
pendingRetry: -1,
|
||||
redactor: redact.New(),
|
||||
usageApplicable: true,
|
||||
usage: notApplicableUsageObservation(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,10 +577,11 @@ func quoteFrozenAttempt(
|
||||
if frozen.table == nil || frozen.upstreamModel == "" {
|
||||
return observation
|
||||
}
|
||||
quote, receipt := frozen.table.QuoteForModeWithReceipt(pricing.Identity{
|
||||
identity := pricing.Identity{
|
||||
ChannelID: frozen.channelID,
|
||||
ModelID: frozen.upstreamModel,
|
||||
}, result, pricingMode)
|
||||
}
|
||||
quote, receipt := frozen.table.QuoteForModeWithMultipliers(identity, result, pricingMode, frozen.priceMultipliers)
|
||||
observation.CostState = string(quote.State)
|
||||
observation.PricingCompleteness = string(quote.Completeness)
|
||||
observation.EstimatedCostNanoUSD = int64(quote.EstimatedCostNanoUSD)
|
||||
|
||||
@@ -1023,6 +1023,7 @@ func TestRequestRecorderPreservesKnownCostWhenUsedOutputPriceIsUnavailable(t *te
|
||||
recorder.freezeNextAttemptPricing(frozenAttemptPricing{
|
||||
channelID: string(channel.OpenAI), groupID: 1,
|
||||
upstreamModel: model, table: table, applicable: true,
|
||||
priceMultipliers: pricing.PriceMultipliers{Group: pricing.DefaultPriceMultiplier, AccessKey: pricing.DefaultPriceMultiplier},
|
||||
})
|
||||
selection := requestLogSelection(1, 2, "group")
|
||||
selection.UpstreamModelID = &model
|
||||
@@ -1062,6 +1063,7 @@ func TestRequestRecorderMergesRequestPricingDiagnosticsIntoKnownCost(t *testing.
|
||||
recorder.freezeNextAttemptPricing(frozenAttemptPricing{
|
||||
channelID: string(channel.OpenAI), groupID: 1,
|
||||
upstreamModel: model, table: table, applicable: true,
|
||||
priceMultipliers: pricing.PriceMultipliers{Group: pricing.DefaultPriceMultiplier, AccessKey: pricing.DefaultPriceMultiplier},
|
||||
})
|
||||
selection := requestLogSelection(1, 2, "group")
|
||||
selection.UpstreamModelID = &model
|
||||
@@ -1104,12 +1106,13 @@ func TestRequestRecorderUsesFrozenAttemptMetadata(t *testing.T) {
|
||||
model := "gpt-4o"
|
||||
recorder.freezeNextAttemptPricing(frozenAttemptPricing{
|
||||
channelID: string(channel.OpenAI), groupID: 1,
|
||||
upstreamModel: model,
|
||||
table: mustGatewayPriceTableWithFast(t, 2_000_000_000, 5_000_000_000),
|
||||
applicable: true,
|
||||
metadataSet: true,
|
||||
pricingMode: pricing.ModeFast,
|
||||
reasoning: reasoning.Config{Effort: "high"},
|
||||
upstreamModel: model,
|
||||
table: mustGatewayPriceTableWithFast(t, 2_000_000_000, 5_000_000_000),
|
||||
priceMultipliers: pricing.PriceMultipliers{Group: pricing.DefaultPriceMultiplier, AccessKey: pricing.DefaultPriceMultiplier},
|
||||
applicable: true,
|
||||
metadataSet: true,
|
||||
pricingMode: pricing.ModeFast,
|
||||
reasoning: reasoning.Config{Effort: "high"},
|
||||
})
|
||||
selection := requestLogSelection(1, 2, "group")
|
||||
selection.UpstreamModelID = &model
|
||||
@@ -1156,6 +1159,7 @@ func TestRequestRecorderDoesNotReuseClientMetadataWhenAttemptObservationIsUnavai
|
||||
selection,
|
||||
dialect.RequestMetadata{ObserveUsage: true},
|
||||
false,
|
||||
pricing.DefaultPriceMultiplier,
|
||||
))
|
||||
index := recorder.appendAttempt(
|
||||
selection,
|
||||
|
||||
@@ -186,7 +186,7 @@ func validateReceiptRule(rule ReceiptRule, schemaVersion int) error {
|
||||
return fmt.Errorf("global receipt must not contain a channel ID")
|
||||
}
|
||||
return nil
|
||||
case 3, 4:
|
||||
case 3, 4, 5, 6:
|
||||
if rule.ScopeKey != "" {
|
||||
return fmt.Errorf("channel receipt must not contain a scope key")
|
||||
}
|
||||
|
||||
@@ -91,6 +91,37 @@ func QuoteComponent(tokens int64, price NanoUSD, multiplier Multiplier) (NanoUSD
|
||||
return NanoUSD(quotient.Int64()), true
|
||||
}
|
||||
|
||||
// quoteComponentWithPriceMultipliers 仅用于验证历史 v5 的分项倍率回执。
|
||||
func quoteComponentWithPriceMultipliers(tokens int64, price NanoUSD, multiplier Multiplier, priceMultipliers PriceMultipliers) (NanoUSD, bool) {
|
||||
if tokens < 0 || price < 0 || multiplier.Numerator <= 0 || multiplier.Denominator <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
if !priceMultipliers.Group.Valid() || !priceMultipliers.AccessKey.Valid() {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
numerator := big.NewInt(tokens)
|
||||
numerator.Mul(numerator, big.NewInt(int64(price)))
|
||||
numerator.Mul(numerator, big.NewInt(multiplier.Numerator))
|
||||
numerator.Mul(numerator, big.NewInt(int64(priceMultipliers.Group)))
|
||||
numerator.Mul(numerator, big.NewInt(int64(priceMultipliers.AccessKey)))
|
||||
denominator := big.NewInt(tokensPerMillion)
|
||||
denominator.Mul(denominator, big.NewInt(multiplier.Denominator))
|
||||
denominator.Mul(denominator, big.NewInt(int64(DefaultPriceMultiplier)))
|
||||
denominator.Mul(denominator, big.NewInt(int64(DefaultPriceMultiplier)))
|
||||
|
||||
quotient, remainder := new(big.Int), new(big.Int)
|
||||
quotient.QuoRem(numerator, denominator, remainder)
|
||||
remainder.Lsh(remainder, 1)
|
||||
if remainder.Cmp(denominator) >= 0 {
|
||||
quotient.Add(quotient, big.NewInt(1))
|
||||
}
|
||||
if quotient.Sign() < 0 || !quotient.IsInt64() {
|
||||
return 0, false
|
||||
}
|
||||
return NanoUSD(quotient.Int64()), true
|
||||
}
|
||||
|
||||
// CheckedAddNanoUSD adds non-negative NanoUSD values without overflow.
|
||||
func CheckedAddNanoUSD(left, right NanoUSD) (NanoUSD, bool) {
|
||||
if left < 0 || right < 0 || left > NanoUSD(math.MaxInt64)-right {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PriceMultiplier 以百万分之一为单位保存价格倍率,零值表示零倍。
|
||||
type PriceMultiplier int64
|
||||
|
||||
const DefaultPriceMultiplier PriceMultiplier = 1_000_000
|
||||
|
||||
// PriceMultipliers 是本次请求冻结的分组和访问密钥价格倍率。
|
||||
type PriceMultipliers struct {
|
||||
Group PriceMultiplier `json:"group"`
|
||||
AccessKey PriceMultiplier `json:"access_key"`
|
||||
}
|
||||
|
||||
// ParsePriceMultiplier 解析最多六位小数、范围为 0 到 1000 的十进制倍率。
|
||||
func ParsePriceMultiplier(value string) (PriceMultiplier, error) {
|
||||
integer, fraction, hasFraction := strings.Cut(value, ".")
|
||||
if integer == "" || !decimalDigits(integer) || len(fraction) > 6 ||
|
||||
(hasFraction && (fraction == "" || !decimalDigits(fraction))) {
|
||||
return 0, errors.New("price multiplier must be a non-negative decimal with at most six decimal places")
|
||||
}
|
||||
wholeText := strings.TrimLeft(integer, "0")
|
||||
if wholeText == "" {
|
||||
wholeText = "0"
|
||||
}
|
||||
whole, err := strconv.ParseInt(wholeText, 10, 64)
|
||||
if err != nil || whole > 1000 {
|
||||
return 0, errors.New("price multiplier must be between 0 and 1000")
|
||||
}
|
||||
decimals, err := strconv.ParseInt(fraction+strings.Repeat("0", 6-len(fraction)), 10, 64)
|
||||
if err != nil {
|
||||
return 0, errors.New("price multiplier has invalid decimal places")
|
||||
}
|
||||
parsed := PriceMultiplier(whole*int64(DefaultPriceMultiplier) + decimals)
|
||||
if !parsed.Valid() {
|
||||
return 0, errors.New("price multiplier must be between 0 and 1000")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// FormatPriceMultiplier 返回倍率的规范十进制表示。
|
||||
func FormatPriceMultiplier(value PriceMultiplier) string {
|
||||
whole := strconv.FormatInt(int64(value/DefaultPriceMultiplier), 10)
|
||||
fraction := int64(value % DefaultPriceMultiplier)
|
||||
if fraction == 0 {
|
||||
return whole
|
||||
}
|
||||
if fraction < 0 {
|
||||
fraction = -fraction
|
||||
if whole == "0" {
|
||||
whole = "-0"
|
||||
}
|
||||
}
|
||||
fractionText := strconv.FormatInt(fraction, 10)
|
||||
return whole + "." + strings.TrimRight(strings.Repeat("0", 6-len(fractionText))+fractionText, "0")
|
||||
}
|
||||
|
||||
// Valid 判断倍率是否在允许范围内。
|
||||
func (value PriceMultiplier) Valid() bool { return value >= 0 && value <= 1_000_000_000 }
|
||||
|
||||
// MarshalJSON 将精确倍率输出为字符串,避免客户端浮点转换。
|
||||
func (value PriceMultiplier) MarshalJSON() ([]byte, error) {
|
||||
if !value.Valid() {
|
||||
return nil, errors.New("invalid price multiplier")
|
||||
}
|
||||
return json.Marshal(FormatPriceMultiplier(value))
|
||||
}
|
||||
|
||||
// UnmarshalJSON 仅接受明确的十进制字符串,null 不表示默认值。
|
||||
func (value *PriceMultiplier) UnmarshalJSON(data []byte) error {
|
||||
var text *string
|
||||
if err := json.Unmarshal(data, &text); err != nil {
|
||||
return err
|
||||
}
|
||||
if text == nil {
|
||||
return errors.New("price multiplier must be a decimal string")
|
||||
}
|
||||
parsed, err := ParsePriceMultiplier(*text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*value = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON 要求两层倍率均显式存在,不能把遗漏字段解释为零倍。
|
||||
func (multipliers *PriceMultipliers) UnmarshalJSON(data []byte) error {
|
||||
var fields struct {
|
||||
Group *PriceMultiplier `json:"group"`
|
||||
AccessKey *PriceMultiplier `json:"access_key"`
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&fields); err != nil {
|
||||
return err
|
||||
}
|
||||
if fields.Group == nil || fields.AccessKey == nil {
|
||||
return errors.New("group and access key price multipliers are required")
|
||||
}
|
||||
*multipliers = PriceMultipliers{Group: *fields.Group, AccessKey: *fields.AccessKey}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyPriceMultipliers 对原有纳美元总额一次应用全部倍率,倍率之间不舍入。
|
||||
func applyPriceMultipliers(amount NanoUSD, multipliers PriceMultipliers) (NanoUSD, bool) {
|
||||
if amount < 0 || !multipliers.Group.Valid() || !multipliers.AccessKey.Valid() {
|
||||
return 0, false
|
||||
}
|
||||
numerator := big.NewInt(int64(amount))
|
||||
numerator.Mul(numerator, big.NewInt(int64(multipliers.Group)))
|
||||
numerator.Mul(numerator, big.NewInt(int64(multipliers.AccessKey)))
|
||||
denominator := big.NewInt(int64(DefaultPriceMultiplier) * int64(DefaultPriceMultiplier))
|
||||
quotient, remainder := new(big.Int), new(big.Int)
|
||||
quotient.QuoRem(numerator, denominator, remainder)
|
||||
remainder.Lsh(remainder, 1)
|
||||
if remainder.Cmp(denominator) >= 0 {
|
||||
quotient.Add(quotient, big.NewInt(1))
|
||||
}
|
||||
if !quotient.IsInt64() {
|
||||
return 0, false
|
||||
}
|
||||
return NanoUSD(quotient.Int64()), true
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPriceMultiplierParsesExactDecimalsAndFormatsCanonically(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
input string
|
||||
want PriceMultiplier
|
||||
canonical string
|
||||
}{
|
||||
{"0", 0, "0"},
|
||||
{"0.000000", 0, "0"},
|
||||
{"1", DefaultPriceMultiplier, "1"},
|
||||
{"0001.200000", 1_200_000, "1.2"},
|
||||
{"0.000001", 1, "0.000001"},
|
||||
{"0.123456", 123_456, "0.123456"},
|
||||
{"999.999999", 999_999_999, "999.999999"},
|
||||
{"1000.000000", 1_000_000_000, "1000"},
|
||||
} {
|
||||
t.Run(test.input, func(t *testing.T) {
|
||||
got, err := ParsePriceMultiplier(test.input)
|
||||
if err != nil || got != test.want || !got.Valid() {
|
||||
t.Fatalf("ParsePriceMultiplier(%q) = %d, %v; want %d valid", test.input, got, err, test.want)
|
||||
}
|
||||
if got := FormatPriceMultiplier(got); got != test.canonical {
|
||||
t.Fatalf("FormatPriceMultiplier() = %q, want %q", got, test.canonical)
|
||||
}
|
||||
encoded, err := json.Marshal(got)
|
||||
if err != nil || string(encoded) != `"`+test.canonical+`"` {
|
||||
t.Fatalf("Marshal() = %s, %v", encoded, err)
|
||||
}
|
||||
var decoded PriceMultiplier
|
||||
if err := json.Unmarshal(encoded, &decoded); err != nil || decoded != got {
|
||||
t.Fatalf("Unmarshal() = %d, %v", decoded, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceMultiplierRejectsInvalidDecimalsAndJSONTypes(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"", " ", " 1", "1 ", "1\n", "-1", "-0", "+1", ".5", "1.", "1..1", "1e2",
|
||||
"1E2", "NaN", "Infinity", "0", "0.0000001", "1.0000000", "1000.000001", "1001", "9223372036854775807",
|
||||
} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
if value, err := ParsePriceMultiplier(input); err == nil {
|
||||
t.Fatalf("ParsePriceMultiplier(%q) = %d, want error", input, value)
|
||||
}
|
||||
encoded, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded PriceMultiplier
|
||||
if err := json.Unmarshal(encoded, &decoded); err == nil {
|
||||
t.Fatalf("Unmarshal(%s) accepted invalid multiplier", encoded)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, input := range []string{"null", "0", "1.2", "true", "[]", "{}"} {
|
||||
var decoded PriceMultiplier
|
||||
if err := json.Unmarshal([]byte(input), &decoded); err == nil {
|
||||
t.Fatalf("Unmarshal(%s) accepted a non-string", input)
|
||||
}
|
||||
}
|
||||
for _, value := range []PriceMultiplier{-1, 1_000_000_001} {
|
||||
if value.Valid() {
|
||||
t.Fatalf("Valid(%d) accepted invalid multiplier", value)
|
||||
}
|
||||
if _, err := json.Marshal(value); err == nil {
|
||||
t.Fatalf("Marshal(%d) accepted invalid multiplier", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceMultipliersRequireBothExplicitJSONFactors(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`null`, `{}`, `{"group":"0"}`, `{"access_key":"0"}`,
|
||||
`{"group":null,"access_key":"1"}`, `{"group":"1","access_key":null}`,
|
||||
`{"group":"1","access_key":"1","extra":"1"}`,
|
||||
} {
|
||||
var multipliers PriceMultipliers
|
||||
if err := json.Unmarshal([]byte(input), &multipliers); err == nil {
|
||||
t.Fatalf("Unmarshal(%s) accepted incomplete multipliers", input)
|
||||
}
|
||||
}
|
||||
var multipliers PriceMultipliers
|
||||
if err := json.Unmarshal([]byte(`{"group":"0","access_key":"1.200000"}`), &multipliers); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if multipliers != (PriceMultipliers{Group: 0, AccessKey: 1_200_000}) {
|
||||
t.Fatalf("decoded multipliers = %#v", multipliers)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,30 @@ func (table *Table) QuoteForMode(identity Identity, result usage.Result, mode Mo
|
||||
return quote
|
||||
}
|
||||
|
||||
// QuoteForModeWithMultipliers 在原计价完成后统一调整请求总费用。
|
||||
func (table *Table) QuoteForModeWithMultipliers(identity Identity, result usage.Result, mode Mode, multipliers PriceMultipliers) (Quote, *Receipt) {
|
||||
if !multipliers.Group.Valid() || !multipliers.AccessKey.Valid() {
|
||||
return unavailableQuote(), nil
|
||||
}
|
||||
quote, receipt := table.QuoteForModeWithReceipt(identity, result, mode)
|
||||
if receipt == nil {
|
||||
return quote, nil
|
||||
}
|
||||
baseTotal := receipt.TotalNanoUSD
|
||||
if quote.State == CostStatePriced {
|
||||
adjusted, ok := applyPriceMultipliers(quote.EstimatedCostNanoUSD, multipliers)
|
||||
if !ok {
|
||||
return unavailableQuote(), nil
|
||||
}
|
||||
quote.EstimatedCostNanoUSD = adjusted
|
||||
receipt.TotalNanoUSD = int64(adjusted)
|
||||
}
|
||||
receipt.SchemaVersion = 6
|
||||
receipt.BaseTotalNanoUSD = &baseTotal
|
||||
receipt.PriceMultipliers = &multipliers
|
||||
return quote, receipt
|
||||
}
|
||||
|
||||
// QuoteForModeWithReceipt freezes the exact tier or mode schedule used. A mode
|
||||
// without a matching persisted schedule is indistinguishable from a standard
|
||||
// quote.
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gpt-load/internal/usage"
|
||||
)
|
||||
|
||||
func TestQuotePriceMultipliersAdjustsCompletedBaseTotal(t *testing.T) {
|
||||
identity := Identity{ChannelID: "openai", ModelID: "model"}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
tokens int64
|
||||
rate NanoUSD
|
||||
group PriceMultiplier
|
||||
key PriceMultiplier
|
||||
want NanoUSD
|
||||
}{
|
||||
{"preserve original component rounding", 1, 490_000, 3_000_000, DefaultPriceMultiplier, 0},
|
||||
{"avoid rounding between factors", 1, 1_000_000, 500_000, 1_500_000, 1},
|
||||
{"half up at final nanodollar", 1, 1_000_000, 500_000, DefaultPriceMultiplier, 1},
|
||||
{"exact fractional product", 1_000_000, 100, 800_000, 1_500_000, 120},
|
||||
{"zero group", 1_000_000, 100, 0, 1_500_000, 0},
|
||||
{"zero access key", 1_000_000, 100, 800_000, 0, 0},
|
||||
{"full six decimal precision", 1_000_000, 1_000_000_000_000, 123_456, 654_321, 80_779_853_376},
|
||||
{"large intermediate stays exact", 1_000_000, math.MaxInt64, 1_000_000_000, 1_000, math.MaxInt64},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
table := mustTable(t, Rule{Identity: identity, Prices: Prices{Input: fixedPrice(test.rate)}})
|
||||
multipliers := PriceMultipliers{Group: test.group, AccessKey: test.key}
|
||||
quote, receipt := table.QuoteForModeWithMultipliers(identity, usage.Result{
|
||||
State: usage.StateComplete, Tokens: usage.Tokens{UncachedInput: test.tokens},
|
||||
}, ModeStandard, multipliers)
|
||||
if quote != (Quote{State: CostStatePriced, Completeness: CompletenessComplete, EstimatedCostNanoUSD: test.want}) {
|
||||
t.Fatalf("quote = %#v, want amount %d", quote, test.want)
|
||||
}
|
||||
if receipt == nil || receipt.SchemaVersion != 6 || receipt.PriceMultipliers == nil || *receipt.PriceMultipliers != multipliers {
|
||||
t.Fatalf("receipt did not freeze v6 multipliers: %#v", receipt)
|
||||
}
|
||||
baseQuote, baseReceipt := table.QuoteForModeWithReceipt(identity, usage.Result{
|
||||
State: usage.StateComplete, Tokens: usage.Tokens{UncachedInput: test.tokens},
|
||||
}, ModeStandard)
|
||||
if baseReceipt == nil || !reflect.DeepEqual(receipt.LineItems, baseReceipt.LineItems) {
|
||||
t.Fatalf("adjustment changed original line items: %#v", receipt.LineItems)
|
||||
}
|
||||
|
||||
if err := ValidateReceipt(*receipt); err != nil {
|
||||
t.Fatalf("generated v6 receipt: %v", err)
|
||||
}
|
||||
encoded, err := json.Marshal(receipt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var frozen struct {
|
||||
BaseTotal *int64 `json:"base_total_nano_usd"`
|
||||
}
|
||||
if err := json.Unmarshal(encoded, &frozen); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if frozen.BaseTotal == nil || *frozen.BaseTotal != int64(baseQuote.EstimatedCostNanoUSD) {
|
||||
t.Fatalf("receipt base total = %#v, want %d", frozen, baseQuote.EstimatedCostNanoUSD)
|
||||
}
|
||||
var decoded Receipt
|
||||
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateReceipt(decoded); err != nil {
|
||||
t.Fatalf("round-tripped v6 receipt: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotePriceMultipliersAppliesAfterSummingOriginalRoundedComponents(t *testing.T) {
|
||||
identity := Identity{ChannelID: "openai", ModelID: "model"}
|
||||
table := mustTable(t, Rule{
|
||||
Identity: identity,
|
||||
Prices: Prices{Input: fixedPrice(600_000), Output: fixedPrice(600_000)},
|
||||
})
|
||||
quote, receipt := table.QuoteForModeWithMultipliers(identity, usage.Result{
|
||||
State: usage.StateComplete, Tokens: usage.Tokens{UncachedInput: 1, Output: 1},
|
||||
}, ModeStandard, PriceMultipliers{Group: 2_000_000, AccessKey: DefaultPriceMultiplier})
|
||||
// 原计价先把两个 0.6 纳美元分项各舍入为 1,基础总额 2 再乘 2 得到 4。
|
||||
if quote != (Quote{State: CostStatePriced, Completeness: CompletenessComplete, EstimatedCostNanoUSD: 4}) {
|
||||
t.Fatalf("quote = %#v, want original total 2 adjusted to 4", quote)
|
||||
}
|
||||
if receipt == nil || receipt.TotalNanoUSD != 4 || len(receipt.LineItems) != 2 {
|
||||
t.Fatalf("receipt = %#v, want two base lines with adjusted total 4", receipt)
|
||||
}
|
||||
for index, code := range []string{"input", "output"} {
|
||||
line := receipt.LineItems[index]
|
||||
if line.Code != code || line.AmountNanoUSD == nil || *line.AmountNanoUSD != 1 {
|
||||
t.Fatalf("receipt line = %#v, want %s amount 1", line, code)
|
||||
}
|
||||
}
|
||||
if err := ValidateReceipt(*receipt); err != nil {
|
||||
t.Fatalf("individually rounded receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotePriceMultipliersPreserveContextFastAndCacheSelection(t *testing.T) {
|
||||
identity := Identity{ChannelID: "anthropic", ModelID: "model"}
|
||||
table := mustTable(t, Rule{
|
||||
Identity: identity,
|
||||
Prices: Prices{Input: fixedPrice(10), CacheWrite: fixedPrice(25)},
|
||||
ContextTiers: []ContextTier{{InputThresholdTokens: 2_000_000, Prices: Prices{Input: fixedPrice(20), CacheWrite: fixedPrice(50)}}},
|
||||
ModeSchedules: map[Mode]Schedule{ModeFast: {Prices: Prices{Input: fixedPrice(30), CacheWrite: fixedPrice(75)}}},
|
||||
})
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mode Mode
|
||||
tokens usage.Tokens
|
||||
wantMode Mode
|
||||
wantTier bool
|
||||
want NanoUSD
|
||||
}{
|
||||
{"standard base", ModeStandard, usage.Tokens{CacheWrite1H: 1_000_000}, ModeStandard, false, 48},
|
||||
{"fast base", ModeFast, usage.Tokens{CacheWrite1H: 1_000_000}, ModeFast, false, 144},
|
||||
{"tier wins over fast", ModeFast, usage.Tokens{UncachedInput: 1_000_000, CacheWrite1H: 1_000_000}, ModeStandard, true, 120},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
quote, receipt := table.QuoteForModeWithMultipliers(identity, usage.Result{State: usage.StateComplete, Tokens: test.tokens}, test.mode, PriceMultipliers{Group: 800_000, AccessKey: 1_500_000})
|
||||
if quote.EstimatedCostNanoUSD != test.want || receipt == nil || receipt.PricingMode != test.wantMode || (receipt.ContextThresholdTokens != nil) != test.wantTier {
|
||||
t.Fatalf("quote = %#v, receipt = %#v", quote, receipt)
|
||||
}
|
||||
line := receipt.LineItems[len(receipt.LineItems)-1]
|
||||
if line.Multiplier != cacheWriteOneHourMultiplier {
|
||||
t.Fatalf("cache multiplier = %#v", line.Multiplier)
|
||||
}
|
||||
if err := ValidateReceipt(*receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotePriceMultipliersPreserveUnavailableAndPartialStates(t *testing.T) {
|
||||
identity := Identity{ChannelID: "openai", ModelID: "model"}
|
||||
table := mustTable(t, Rule{Identity: identity, Prices: Prices{Input: fixedPrice(100)}})
|
||||
zero := PriceMultipliers{Group: 0, AccessKey: DefaultPriceMultiplier}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
identity Identity
|
||||
result usage.Result
|
||||
want Quote
|
||||
wantReceipt bool
|
||||
}{
|
||||
{"missing usage", identity, usage.Result{State: usage.StateMissing}, unavailableQuote(), false},
|
||||
{"not applicable", identity, usage.Result{State: usage.StateNotApplicable}, Quote{State: CostStateNotApplicable, Completeness: CompletenessNotApplicable}, false},
|
||||
{"missing rule", Identity{ChannelID: "openai", ModelID: "missing"}, usage.Result{State: usage.StateComplete}, unavailableQuote(), false},
|
||||
{"all prices missing", identity, usage.Result{State: usage.StateComplete, Tokens: usage.Tokens{Output: 10}}, unavailableQuote(), true},
|
||||
{"one price missing", identity, usage.Result{State: usage.StateComplete, Tokens: usage.Tokens{UncachedInput: 10, Output: 10}}, Quote{State: CostStatePriced, Completeness: CompletenessPartial}, true},
|
||||
{"unknown cache duration", identity, usage.Result{State: usage.StateComplete, Tokens: usage.Tokens{UncachedInput: 10, CacheWriteUnknown: 10}}, Quote{State: CostStatePriced, Completeness: CompletenessPartial}, true},
|
||||
{"partial usage", identity, usage.Result{State: usage.StatePartial, Tokens: usage.Tokens{UncachedInput: 10}}, Quote{State: CostStatePriced, Completeness: CompletenessPartial}, true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
quote, receipt := table.QuoteForModeWithMultipliers(test.identity, test.result, ModeStandard, zero)
|
||||
if quote != test.want || (receipt != nil) != test.wantReceipt {
|
||||
t.Fatalf("quote = %#v, receipt = %#v; want %#v, receipt %t", quote, receipt, test.want, test.wantReceipt)
|
||||
}
|
||||
if receipt != nil {
|
||||
if err := ValidateReceipt(*receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotePriceMultipliersFailClosedForInvalidFactorsAndOverflow(t *testing.T) {
|
||||
identity := Identity{ChannelID: "openai", ModelID: "model"}
|
||||
table := mustTable(t, Rule{Identity: identity, Prices: Prices{Input: fixedPrice(math.MaxInt64), Output: fixedPrice(math.MaxInt64)}})
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
multipliers PriceMultipliers
|
||||
tokens usage.Tokens
|
||||
}{
|
||||
{"invalid group", PriceMultipliers{Group: -1, AccessKey: DefaultPriceMultiplier}, usage.Tokens{UncachedInput: 1}},
|
||||
{"invalid access key", PriceMultipliers{Group: DefaultPriceMultiplier, AccessKey: 1_000_000_001}, usage.Tokens{UncachedInput: 1}},
|
||||
{"component overflow", PriceMultipliers{Group: 2_000_000, AccessKey: DefaultPriceMultiplier}, usage.Tokens{UncachedInput: 1_000_000}},
|
||||
{"sum overflow", PriceMultipliers{Group: 2_000_000, AccessKey: DefaultPriceMultiplier}, usage.Tokens{UncachedInput: 250_000, Output: 250_000}},
|
||||
{"base overflow is not rescued by discount", PriceMultipliers{Group: 500_000, AccessKey: DefaultPriceMultiplier}, usage.Tokens{UncachedInput: 2_000_000}},
|
||||
{"base overflow is not rescued by zero", PriceMultipliers{Group: 0, AccessKey: DefaultPriceMultiplier}, usage.Tokens{UncachedInput: 2_000_000}},
|
||||
{"negative tokens even at zero", PriceMultipliers{}, usage.Tokens{UncachedInput: -1}},
|
||||
{"token total overflow even at zero", PriceMultipliers{}, usage.Tokens{UncachedInput: math.MaxInt64, Output: 1}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
quote, receipt := table.QuoteForModeWithMultipliers(identity, usage.Result{State: usage.StateComplete, Tokens: test.tokens}, ModeStandard, test.multipliers)
|
||||
if quote != unavailableQuote() || receipt != nil {
|
||||
t.Fatalf("quote = %#v, receipt = %#v; want unavailable with no receipt", quote, receipt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,64 @@
|
||||
package pricing
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// UnmarshalJSON 区分缺省字段与显式 null,并保持历史版本的字段边界。
|
||||
func (receipt *Receipt) UnmarshalJSON(data []byte) error {
|
||||
var header struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
PriceMultipliers json.RawMessage `json:"price_multipliers"`
|
||||
BaseTotalNanoUSD json.RawMessage `json:"base_total_nano_usd"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &header); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(header.PriceMultipliers) > 0 && (header.SchemaVersion < 5 || bytes.Equal(bytes.TrimSpace(header.PriceMultipliers), []byte("null"))) {
|
||||
return fmt.Errorf("price multipliers require a non-null receipt field from v5 onward")
|
||||
}
|
||||
if len(header.BaseTotalNanoUSD) > 0 && (header.SchemaVersion < 6 || bytes.Equal(bytes.TrimSpace(header.BaseTotalNanoUSD), []byte("null"))) {
|
||||
return fmt.Errorf("base total requires a non-null receipt field from v6 onward")
|
||||
}
|
||||
type receiptJSON Receipt
|
||||
var decoded receiptJSON
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
*receipt = Receipt(decoded)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateReceipt verifies a persisted request-time receipt without consulting
|
||||
// the mutable current pricing table.
|
||||
func ValidateReceipt(receipt Receipt) error {
|
||||
if (receipt.SchemaVersion != 1 && receipt.SchemaVersion != 2 &&
|
||||
receipt.SchemaVersion != 3 && receipt.SchemaVersion != 4) ||
|
||||
receipt.SchemaVersion != 3 && receipt.SchemaVersion != 4 && receipt.SchemaVersion != 5 && receipt.SchemaVersion != 6) ||
|
||||
receipt.Method != ReceiptMethodUnitRateSum || receipt.MethodVersion != 1 || receipt.Currency != "USD" {
|
||||
return fmt.Errorf("unsupported pricing receipt contract")
|
||||
}
|
||||
priceMultipliers := PriceMultipliers{Group: DefaultPriceMultiplier, AccessKey: DefaultPriceMultiplier}
|
||||
if receipt.SchemaVersion < 5 {
|
||||
if receipt.PriceMultipliers != nil {
|
||||
return fmt.Errorf("historical pricing receipt must not contain price multipliers")
|
||||
}
|
||||
} else {
|
||||
if receipt.PriceMultipliers == nil || !receipt.PriceMultipliers.Group.Valid() || !receipt.PriceMultipliers.AccessKey.Valid() {
|
||||
return fmt.Errorf("invalid pricing receipt price multipliers")
|
||||
}
|
||||
priceMultipliers = *receipt.PriceMultipliers
|
||||
}
|
||||
if receipt.SchemaVersion < 6 {
|
||||
if receipt.BaseTotalNanoUSD != nil {
|
||||
return fmt.Errorf("historical pricing receipt must not contain a base total")
|
||||
}
|
||||
} else if receipt.BaseTotalNanoUSD == nil || *receipt.BaseTotalNanoUSD < 0 {
|
||||
return fmt.Errorf("invalid pricing receipt base total")
|
||||
}
|
||||
if receipt.SchemaVersion < 4 {
|
||||
if receipt.PricingMode != "" {
|
||||
return fmt.Errorf("historical pricing receipt must not contain a pricing mode")
|
||||
@@ -56,6 +105,9 @@ func ValidateReceipt(receipt Receipt) error {
|
||||
NanoUSD(*line.RateNanoUSDPerMillion),
|
||||
line.Multiplier,
|
||||
)
|
||||
if receipt.SchemaVersion == 5 {
|
||||
amount, ok = quoteComponentWithPriceMultipliers(line.Quantity, NanoUSD(*line.RateNanoUSDPerMillion), line.Multiplier, priceMultipliers)
|
||||
}
|
||||
if !ok || int64(amount) != *line.AmountNanoUSD {
|
||||
return fmt.Errorf("pricing receipt line amount mismatch")
|
||||
}
|
||||
@@ -71,7 +123,15 @@ func ValidateReceipt(receipt Receipt) error {
|
||||
return fmt.Errorf("invalid pricing receipt line state")
|
||||
}
|
||||
}
|
||||
if int64(total) != receipt.TotalNanoUSD {
|
||||
if receipt.SchemaVersion == 6 {
|
||||
if int64(total) != *receipt.BaseTotalNanoUSD {
|
||||
return fmt.Errorf("pricing receipt base total mismatch")
|
||||
}
|
||||
adjusted, ok := applyPriceMultipliers(total, priceMultipliers)
|
||||
if !ok || int64(adjusted) != receipt.TotalNanoUSD {
|
||||
return fmt.Errorf("pricing receipt adjusted total mismatch")
|
||||
}
|
||||
} else if int64(total) != receipt.TotalNanoUSD {
|
||||
return fmt.Errorf("pricing receipt total mismatch")
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gpt-load/internal/usage"
|
||||
)
|
||||
|
||||
func TestValidateReceiptPriceMultipliersPreserveHistoricalVersionBoundary(t *testing.T) {
|
||||
for version := 1; version <= 4; version++ {
|
||||
rule := ReceiptRule{ModelID: "model"}
|
||||
if version == 1 {
|
||||
rule.ScopeKey = "provider:openai"
|
||||
} else if version >= 3 {
|
||||
rule.ChannelID = "openai"
|
||||
}
|
||||
receipt := validReceipt(version, rule)
|
||||
if err := ValidateReceipt(receipt); err != nil {
|
||||
t.Fatalf("historical v%d receipt rejected: %v", version, err)
|
||||
}
|
||||
receipt.PriceMultipliers = &PriceMultipliers{Group: DefaultPriceMultiplier, AccessKey: DefaultPriceMultiplier}
|
||||
if err := ValidateReceipt(receipt); err == nil {
|
||||
t.Fatalf("historical v%d receipt accepted price multipliers", version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReceiptPriceMultipliersRejectsMissingAndTamperedInputs(t *testing.T) {
|
||||
identity := Identity{ChannelID: "openai", ModelID: "model"}
|
||||
table := mustTable(t, Rule{Identity: identity, Prices: Prices{Input: fixedPrice(100)}})
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*Receipt)
|
||||
}{
|
||||
{"missing multipliers", func(r *Receipt) { r.PriceMultipliers = nil }},
|
||||
{"negative group", func(r *Receipt) { r.PriceMultipliers.Group = -1 }},
|
||||
{"access key over limit", func(r *Receipt) { r.PriceMultipliers.AccessKey = 1_000_000_001 }},
|
||||
{"changed group", func(r *Receipt) { r.PriceMultipliers.Group = DefaultPriceMultiplier }},
|
||||
{"changed access key", func(r *Receipt) { r.PriceMultipliers.AccessKey = DefaultPriceMultiplier }},
|
||||
{"changed protocol multiplier", func(r *Receipt) { r.LineItems[0].Multiplier.Numerator = 2 }},
|
||||
{"changed base rate", func(r *Receipt) { *r.LineItems[0].RateNanoUSDPerMillion = 200 }},
|
||||
{"changed line amount", func(r *Receipt) { *r.LineItems[0].AmountNanoUSD = 101 }},
|
||||
{"missing base total", func(r *Receipt) { r.BaseTotalNanoUSD = nil }},
|
||||
{"negative base total", func(r *Receipt) { *r.BaseTotalNanoUSD = -1 }},
|
||||
{"changed base total", func(r *Receipt) { *r.BaseTotalNanoUSD = 101 }},
|
||||
{"changed total", func(r *Receipt) { r.TotalNanoUSD = 100 }},
|
||||
{"downgraded schema", func(r *Receipt) { r.SchemaVersion = 4 }},
|
||||
{"downgraded to component multiplier schema", func(r *Receipt) { r.SchemaVersion = 5 }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, receipt := table.QuoteForModeWithMultipliers(identity, usage.Result{
|
||||
State: usage.StateComplete, Tokens: usage.Tokens{UncachedInput: 1_000_000},
|
||||
}, ModeStandard, PriceMultipliers{Group: 800_000, AccessKey: 1_500_000})
|
||||
if receipt == nil {
|
||||
t.Fatal("expected receipt")
|
||||
}
|
||||
test.mutate(receipt)
|
||||
if err := ValidateReceipt(*receipt); err == nil {
|
||||
t.Fatalf("ValidateReceipt accepted %s", test.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiptJSONRejectsNullMultipliersAndHistoricalInjectedFields(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
json string
|
||||
}{
|
||||
{"v5 null factors", `{"schema_version":5,"price_multipliers":null}`},
|
||||
{"v4 null factors", `{"schema_version":4,"price_multipliers":null}`},
|
||||
{"v4 explicit factors", `{"schema_version":4,"price_multipliers":{"group":"1","access_key":"1"}}`},
|
||||
{"v5 missing group", `{"schema_version":5,"price_multipliers":{"access_key":"1"}}`},
|
||||
{"v5 unknown factor", `{"schema_version":5,"price_multipliers":{"group":"1","access_key":"1","other":"1"}}`},
|
||||
{"v6 null base total", `{"schema_version":6,"base_total_nano_usd":null}`},
|
||||
{"v5 injected base total", `{"schema_version":5,"base_total_nano_usd":0}`},
|
||||
{"v5 null base total", `{"schema_version":5,"base_total_nano_usd":null}`},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var receipt Receipt
|
||||
if err := json.Unmarshal([]byte(test.json), &receipt); err == nil {
|
||||
t.Fatalf("Unmarshal accepted %s", test.json)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 自定义解码仍须保留请求日志现有的未知字段拒绝行为。
|
||||
var receipt Receipt
|
||||
decoder := json.NewDecoder(strings.NewReader(`{"schema_version":4,"unknown":1}`))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&receipt); err == nil {
|
||||
t.Fatal("receipt decoder accepted an unknown field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateHistoricalV5ReceiptKeepsOriginalComponentRounding(t *testing.T) {
|
||||
const encoded = `{
|
||||
"schema_version":5,"method":"unit_rate_sum","method_version":1,"currency":"USD","pricing_mode":"standard",
|
||||
"rule":{"channel_id":"openai","model_id":"model"},"price_multipliers":{"group":"2","access_key":"1"},
|
||||
"line_items":[
|
||||
{"code":"input","quantity":1,"rate_nano_usd_per_million":600000,"multiplier":{"numerator":1,"denominator":1},"state":"priced","amount_nano_usd":1},
|
||||
{"code":"output","quantity":1,"rate_nano_usd_per_million":600000,"multiplier":{"numerator":1,"denominator":1},"state":"priced","amount_nano_usd":1}
|
||||
],"total_nano_usd":2
|
||||
}`
|
||||
var receipt Receipt
|
||||
if err := json.Unmarshal([]byte(encoded), &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateReceipt(receipt); err != nil {
|
||||
t.Fatalf("historical v5 receipt changed: %v", err)
|
||||
}
|
||||
if receipt.BaseTotalNanoUSD != nil || receipt.TotalNanoUSD != 2 {
|
||||
t.Fatalf("historical receipt was reinterpreted: %#v", receipt)
|
||||
}
|
||||
// 相同已存分项,v5 总额不能套用 v6 的总额倍率算法改成 4。
|
||||
receipt.TotalNanoUSD = 4
|
||||
if err := ValidateReceipt(receipt); err == nil {
|
||||
t.Fatal("historical v5 accepted total-adjustment calculation")
|
||||
}
|
||||
}
|
||||
@@ -113,15 +113,17 @@ type ReceiptLine struct {
|
||||
// Receipt is the immutable, versioned explanation of one request-time quote.
|
||||
// It deliberately stores calculation inputs instead of a presentation string.
|
||||
type Receipt struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Method string `json:"method"`
|
||||
MethodVersion int `json:"method_version"`
|
||||
Currency string `json:"currency"`
|
||||
PricingMode Mode `json:"pricing_mode,omitempty"`
|
||||
Rule ReceiptRule `json:"rule"`
|
||||
ContextThresholdTokens *int64 `json:"context_threshold_tokens,omitempty"`
|
||||
LineItems []ReceiptLine `json:"line_items"`
|
||||
TotalNanoUSD int64 `json:"total_nano_usd"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Method string `json:"method"`
|
||||
MethodVersion int `json:"method_version"`
|
||||
Currency string `json:"currency"`
|
||||
PricingMode Mode `json:"pricing_mode,omitempty"`
|
||||
PriceMultipliers *PriceMultipliers `json:"price_multipliers,omitempty"`
|
||||
BaseTotalNanoUSD *int64 `json:"base_total_nano_usd,omitempty"`
|
||||
Rule ReceiptRule `json:"rule"`
|
||||
ContextThresholdTokens *int64 `json:"context_threshold_tokens,omitempty"`
|
||||
LineItems []ReceiptLine `json:"line_items"`
|
||||
TotalNanoUSD int64 `json:"total_nano_usd"`
|
||||
}
|
||||
|
||||
// Table is an immutable exact pricing snapshot.
|
||||
|
||||
@@ -190,7 +190,7 @@ func canonicalPricingReceipt(
|
||||
if err := pricing.ValidateReceipt(receipt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if receipt.SchemaVersion != 4 || receipt.Rule != (pricing.ReceiptRule{
|
||||
if (receipt.SchemaVersion != 4 && receipt.SchemaVersion != 5 && receipt.SchemaVersion != 6) || receipt.Rule != (pricing.ReceiptRule{
|
||||
ChannelID: channelID,
|
||||
ModelID: observation.UpstreamModel,
|
||||
}) ||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package requestlog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gpt-load/internal/platform/redact"
|
||||
"gpt-load/internal/storage/models"
|
||||
"gpt-load/internal/usage"
|
||||
)
|
||||
|
||||
func TestPriceMultipliersSurvivePersistenceAndAllCostQueries(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
schema int
|
||||
group string
|
||||
accessKey string
|
||||
tokens usage.Tokens
|
||||
rate int64
|
||||
lineAmount int64
|
||||
baseTotal int64
|
||||
finalTotal int64
|
||||
}{
|
||||
{"v6 frozen original and adjusted totals", 6, "0.8", "1.5", usage.Tokens{UncachedInput: 1000}, 2_000_000_000, 2_000_000, 2_000_000, 2_400_000},
|
||||
// 原始两项各 0.6 nano,分别舍入为 1,合计 2 后乘 2,最终为 4。
|
||||
{"v6 rounds original lines before adjusting total", 6, "0.8", "2.5", usage.Tokens{UncachedInput: 1, Output: 1}, 600_000, 1, 2, 4},
|
||||
// v5 各项 0.6 × 2 后舍入为 1,总计 2;读取时不得套用 v6 的总费公式。
|
||||
{"v5 preserves historical line rounding", 5, "0.8", "2.5", usage.Tokens{UncachedInput: 1, Output: 1}, 600_000, 1, 0, 2},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
event := channelScopedEvent(t, "00000000-0000-4000-8000-000000009001")
|
||||
event.Usage.Result = usage.Result{State: usage.StateComplete, Tokens: test.tokens}
|
||||
event.Usage.Pricing.EstimatedCostNanoUSD = test.finalTotal
|
||||
lines := []any{map[string]any{
|
||||
"code": "input", "quantity": test.tokens.UncachedInput, "rate_nano_usd_per_million": test.rate,
|
||||
"multiplier": map[string]any{"numerator": 1, "denominator": 1},
|
||||
"state": "priced", "amount_nano_usd": test.lineAmount,
|
||||
}}
|
||||
if test.tokens.Output > 0 {
|
||||
lines = append(lines, map[string]any{
|
||||
"code": "output", "quantity": test.tokens.Output, "rate_nano_usd_per_million": test.rate,
|
||||
"multiplier": map[string]any{"numerator": 1, "denominator": 1},
|
||||
"state": "priced", "amount_nano_usd": test.lineAmount,
|
||||
})
|
||||
}
|
||||
receipt := map[string]any{
|
||||
"schema_version": test.schema, "method": "unit_rate_sum", "method_version": 1,
|
||||
"currency": "USD", "pricing_mode": "standard",
|
||||
"rule": map[string]any{"channel_id": "openai", "model_id": event.UpstreamModel},
|
||||
"price_multipliers": map[string]any{"group": test.group, "access_key": test.accessKey},
|
||||
"line_items": lines,
|
||||
"total_nano_usd": test.finalTotal,
|
||||
}
|
||||
if test.schema == 6 {
|
||||
receipt["base_total_nano_usd"] = test.baseTotal
|
||||
}
|
||||
encoded, err := json.Marshal(receipt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
event.Usage.Pricing.ReceiptJSON = string(encoded)
|
||||
row, err := mapEvent(redact.New(), event)
|
||||
if err != nil {
|
||||
t.Fatalf("map adjusted estimate: %v", err)
|
||||
}
|
||||
db := openRequestLogQueryDB(t)
|
||||
service := newRequestLogTestService(db)
|
||||
if err := service.writer.WriteBatch(t.Context(), []models.RequestLog{row}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 重放同一请求不能重复累计已应用倍率的金额。
|
||||
if err := service.writer.WriteBatch(t.Context(), []models.RequestLog{row}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
minimum := test.finalTotal - 1
|
||||
page, err := service.List(t.Context(), ListQuery{Limit: 10, CostMinNanoUSD: &minimum})
|
||||
if err != nil || len(page.Items) != 1 || page.Items[0].EstimatedCostNanoUSD != test.finalTotal || len(page.Items[0].Attempts) != 0 {
|
||||
t.Fatalf("filtered list = %#v, %v", page, err)
|
||||
}
|
||||
detail, err := service.Get(t.Context(), row.ID)
|
||||
if err != nil || detail.EstimatedCostNanoUSD != test.finalTotal || len(detail.Attempts) != 1 || detail.Attempts[0].PricingReceipt == nil {
|
||||
t.Fatalf("detail = %#v, %v", detail, err)
|
||||
}
|
||||
frozen, err := json.Marshal(detail.Attempts[0].PricingReceipt)
|
||||
if err != nil || !strings.Contains(string(frozen), fmt.Sprintf(`"price_multipliers":{"group":%q,"access_key":%q}`, test.group, test.accessKey)) {
|
||||
t.Fatalf("frozen receipt = %s, %v", frozen, err)
|
||||
}
|
||||
var frozenFields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(frozen, &frozenFields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base, hasBase := frozenFields["base_total_nano_usd"]
|
||||
if hasBase != (test.schema == 6) || (hasBase && string(base) != fmt.Sprint(test.baseTotal)) {
|
||||
t.Fatalf("frozen base total = %s, want schema v%d base %d", frozen, test.schema, test.baseTotal)
|
||||
}
|
||||
for _, line := range detail.Attempts[0].PricingReceipt.LineItems {
|
||||
if line.AmountNanoUSD == nil || *line.AmountNanoUSD != test.lineAmount {
|
||||
t.Fatalf("frozen line amount = %#v, want %d", line, test.lineAmount)
|
||||
}
|
||||
}
|
||||
from := event.CompletedAt.Truncate(time.Hour)
|
||||
report, err := service.QueryUsage(t.Context(), UsageQuery{
|
||||
FromMS: from.UnixMilli(), ToMS: from.Add(time.Hour).UnixMilli(), Granularity: UsageGranularityHour,
|
||||
})
|
||||
if err != nil || report.Summary.EstimatedCostNanoUSD != test.finalTotal || report.Summary.RequestCount != 1 {
|
||||
t.Fatalf("usage report = %#v, %v", report, err)
|
||||
}
|
||||
for _, dimension := range []UsageDistributionDimension{UsageDistributionDimensionGroup, UsageDistributionDimensionModel, UsageDistributionDimensionAccessKey} {
|
||||
distribution, ok := report.Distributions.Get(dimension, UsageDistributionMetricCost)
|
||||
var total int64
|
||||
for _, item := range distribution.Items {
|
||||
total += item.EstimatedCostNanoUSD
|
||||
}
|
||||
if distribution.Other != nil {
|
||||
total += distribution.Other.EstimatedCostNanoUSD
|
||||
}
|
||||
if !ok || total != test.finalTotal {
|
||||
t.Fatalf("cost distribution %s = %#v", dimension, distribution)
|
||||
}
|
||||
}
|
||||
home, err := service.QueryHomeStatistics(t.Context(), HomeStatisticsQuery{
|
||||
Range: HomeStatistics24H, ObservedAtMS: from.Add(time.Hour).UnixMilli(),
|
||||
})
|
||||
if err != nil || home.Summary.EstimatedCostNanoUSD != test.finalTotal || len(home.TopModels) != 1 || len(home.TopGroups) != 1 || len(home.TopAccessKeys) != 1 ||
|
||||
home.TopModels[0].EstimatedCostNanoUSD != test.finalTotal || home.TopGroups[0].EstimatedCostNanoUSD != test.finalTotal || home.TopAccessKeys[0].EstimatedCostNanoUSD != test.finalTotal {
|
||||
t.Fatalf("home statistics = %#v, %v", home, err)
|
||||
}
|
||||
for _, source := range []CredentialWindowUsageSource{CredentialWindowUsageSourceRequestLogs, CredentialWindowUsageSourceHourlyStats} {
|
||||
observed, err := service.QueryCredentialWindowUsage(t.Context(), CredentialWindowUsageQuery{
|
||||
CredentialID: row.CredentialID, FromMS: from.UnixMilli(), ToMS: from.Add(time.Hour).UnixMilli(), Source: source,
|
||||
})
|
||||
if err != nil || observed.EstimatedCostNanoUSD != test.finalTotal {
|
||||
t.Fatalf("credential window %s = %#v, %v", source, observed, err)
|
||||
}
|
||||
}
|
||||
row.AttemptRows[0].ChannelID = "anthropic"
|
||||
if _, err := decodeAttemptPricingReceipt(row.AttemptRows[0]); err == nil {
|
||||
t.Fatalf("v%d receipt accepted a mismatching channel", test.schema)
|
||||
}
|
||||
if test.schema == 6 {
|
||||
event.Usage.Pricing.EstimatedCostNanoUSD = test.baseTotal
|
||||
if _, err := mapEvent(redact.New(), event); err == nil {
|
||||
t.Fatal("accepted original total as final estimated cost")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -294,7 +294,7 @@ func decodeAttemptPricingReceipt(row models.RequestLogAttempt) (*pricing.Receipt
|
||||
if err := pricing.ValidateReceipt(decoded); err != nil {
|
||||
return nil, fmt.Errorf("decode request log pricing receipt: %w", err)
|
||||
}
|
||||
if (decoded.SchemaVersion == 3 || decoded.SchemaVersion == 4) && decoded.Rule != (pricing.ReceiptRule{
|
||||
if decoded.SchemaVersion >= 3 && decoded.Rule != (pricing.ReceiptRule{
|
||||
ChannelID: row.ChannelID,
|
||||
ModelID: row.UpstreamModel,
|
||||
}) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"gpt-load/internal/platform/config"
|
||||
"gpt-load/internal/platform/encryption"
|
||||
"gpt-load/internal/platform/utils"
|
||||
"gpt-load/internal/pricing"
|
||||
"gpt-load/internal/protocol"
|
||||
"gpt-load/internal/state"
|
||||
"gpt-load/internal/storage/models"
|
||||
@@ -253,7 +254,7 @@ func queryCompileRows(ctx context.Context, db *gorm.DB) (compileRows, error) {
|
||||
return compileRows{}, fmt.Errorf("query credential metadata: %w", err)
|
||||
}
|
||||
if err := db.
|
||||
Select("id", "name", "key_hash", "key_suffix", "status", "filters", "rpm_limit", "expires_at_ms").
|
||||
Select("id", "name", "key_hash", "key_suffix", "status", "filters", "rpm_limit", "expires_at_ms", "price_multiplier_micros").
|
||||
Order("id ASC").
|
||||
Find(&rows.accessKeys).Error; err != nil {
|
||||
return compileRows{}, fmt.Errorf("query access keys: %w", err)
|
||||
@@ -630,7 +631,12 @@ func mapSystemAndGroups(
|
||||
for _, model := range storedModels {
|
||||
runtimeModels = append(runtimeModels, state.ModelConfig{ID: model.ID, Alias: model.Alias})
|
||||
}
|
||||
multiplier, err := persistedPriceMultiplier(row.PriceMultiplierMicros)
|
||||
if err != nil {
|
||||
return state.CompileInput{}, fmt.Errorf("group %d: %w", row.ID, err)
|
||||
}
|
||||
group := state.GroupConfig{
|
||||
PriceMultiplier: &multiplier,
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
ChannelID: channel.ID(row.ChannelID),
|
||||
@@ -694,8 +700,13 @@ func mapAccessKeys(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compile access key %d allowed CIDRs: %w", row.ID, err)
|
||||
}
|
||||
multiplier, err := persistedPriceMultiplier(row.PriceMultiplierMicros)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("access key %d: %w", row.ID, err)
|
||||
}
|
||||
result = append(result, state.AccessKeyConfig{
|
||||
ID: row.ID, Name: row.Name, KeyHash: row.KeyHash, KeySuffix: row.KeySuffix,
|
||||
PriceMultiplier: &multiplier,
|
||||
ID: row.ID, Name: row.Name, KeyHash: row.KeyHash, KeySuffix: row.KeySuffix,
|
||||
Status: state.AccessKeyStatus(row.Status), Filters: filters.toState(), RPMLimit: row.RPMLimit,
|
||||
ExpiresAtMS: cloneInt64Pointer(row.ExpiresAtMS), AllowedPeerCIDRs: allowedPeerCIDRs,
|
||||
CostLimitRules: append([]accessquota.Rule(nil), rulesByAccessKey[row.ID]...),
|
||||
@@ -873,3 +884,10 @@ func cloneWeight(value *int) *int {
|
||||
cloned := *value
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func persistedPriceMultiplier(value *int64) (pricing.PriceMultiplier, error) {
|
||||
if value == nil || !pricing.PriceMultiplier(*value).Valid() {
|
||||
return 0, fmt.Errorf("invalid persisted price multiplier")
|
||||
}
|
||||
return pricing.PriceMultiplier(*value), nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package loader_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gpt-load/internal/channel"
|
||||
"gpt-load/internal/pricing"
|
||||
"gpt-load/internal/state"
|
||||
"gpt-load/internal/state/loader"
|
||||
"gpt-load/internal/storage/models"
|
||||
)
|
||||
|
||||
func TestBuildCompileInputLoadsPersistedPriceMultipliers(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
stored *int64
|
||||
want pricing.PriceMultiplier
|
||||
}{
|
||||
{name: "default", want: pricing.DefaultPriceMultiplier},
|
||||
{name: "zero", stored: multiplierMicros(0), want: 0},
|
||||
{name: "fraction", stored: multiplierMicros(123_456), want: 123_456},
|
||||
{name: "maximum", stored: multiplierMicros(1_000_000_000), want: 1_000_000_000},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
db := openMigratedDatabase(t)
|
||||
group := models.Group{
|
||||
Name: "multiplier", ChannelID: string(channel.OpenAI), Params: models.JSON(`{}`),
|
||||
Models: models.JSON(`[]`), PriceMultiplierMicros: test.stored,
|
||||
}
|
||||
key := models.AccessKey{
|
||||
Name: "multiplier", KeyValue: "cipher", KeyHash: "multiplier-hash", KeySuffix: "1234",
|
||||
Status: string(state.AccessKeyStatusActive), PriceMultiplierMicros: test.stored,
|
||||
}
|
||||
mustCreate(t, db, &group)
|
||||
mustCreate(t, db, &key)
|
||||
input, err := loader.BuildCompileInput(t.Context(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := state.Compile(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.Groups[group.ID].PriceMultiplier != test.want ||
|
||||
snapshot.AccessKeysByID[key.ID].PriceMultiplier != test.want ||
|
||||
snapshot.AccessKeysByHash[key.KeyHash].PriceMultiplier != test.want {
|
||||
t.Fatalf("loaded multipliers do not equal %d", test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCompileInputRejectsInvalidPersistedPriceMultipliers(t *testing.T) {
|
||||
db := openMigratedDatabase(t)
|
||||
group := models.Group{
|
||||
Name: "invalid multiplier", ChannelID: string(channel.OpenAI), Params: models.JSON(`{}`), Models: models.JSON(`[]`),
|
||||
}
|
||||
key := models.AccessKey{
|
||||
Name: "invalid multiplier", KeyValue: "cipher", KeyHash: "invalid-multiplier", KeySuffix: "1234",
|
||||
}
|
||||
mustCreate(t, db, &group)
|
||||
mustCreate(t, db, &key)
|
||||
if err := db.Exec("PRAGMA ignore_check_constraints = ON").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, resource := range []struct {
|
||||
model any
|
||||
name string
|
||||
}{
|
||||
{model: &group, name: "group"},
|
||||
{model: &key, name: "access key"},
|
||||
} {
|
||||
for _, invalid := range []int64{-1, 1_000_000_001} {
|
||||
t.Run(fmt.Sprintf("%s/%d", resource.name, invalid), func(t *testing.T) {
|
||||
if err := db.Model(resource.model).UpdateColumn("price_multiplier_micros", invalid).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loader.BuildCompileInput(t.Context(), db); err == nil ||
|
||||
!strings.Contains(err.Error(), resource.name) || !strings.Contains(err.Error(), "price multiplier") {
|
||||
t.Fatalf("invalid persisted multiplier error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := db.Model(resource.model).UpdateColumn("price_multiplier_micros", int64(pricing.DefaultPriceMultiplier)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func multiplierMicros(value int64) *int64 { return &value }
|
||||
@@ -0,0 +1,57 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"gpt-load/internal/channel"
|
||||
"gpt-load/internal/pricing"
|
||||
)
|
||||
|
||||
func TestCompilePriceMultiplierDefaultsAndSnapshotOwnership(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := CompileInput{
|
||||
ChannelRegistry: channel.NewRegistry(),
|
||||
Groups: []GroupConfig{{
|
||||
ID: 1, ChannelID: channel.OpenAI, ConnectionType: "api_key", Params: json.RawMessage(`{}`), Enabled: true,
|
||||
}},
|
||||
AccessKeys: []AccessKeyConfig{{ID: 1, KeyHash: "multiplier-key", Status: AccessKeyStatusActive}},
|
||||
}
|
||||
defaults, err := Compile(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if defaults.Groups[1].PriceMultiplier != pricing.DefaultPriceMultiplier ||
|
||||
defaults.AccessKeysByID[1].PriceMultiplier != pricing.DefaultPriceMultiplier ||
|
||||
defaults.AccessKeysByHash["multiplier-key"].PriceMultiplier != pricing.DefaultPriceMultiplier {
|
||||
t.Fatal("omitted compile multiplier did not default to one")
|
||||
}
|
||||
groupMultiplier, keyMultiplier := pricing.PriceMultiplier(0), pricing.PriceMultiplier(1_234_567)
|
||||
input.Groups[0].PriceMultiplier = &groupMultiplier
|
||||
input.AccessKeys[0].PriceMultiplier = &keyMultiplier
|
||||
snapshot, err := Compile(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
groupMultiplier, keyMultiplier = pricing.PriceMultiplier(1_000_000_000), pricing.PriceMultiplier(0)
|
||||
if snapshot.Groups[1].PriceMultiplier != 0 || snapshot.AccessKeysByID[1].PriceMultiplier != 1_234_567 ||
|
||||
snapshot.AccessKeysByHash["multiplier-key"].PriceMultiplier != 1_234_567 {
|
||||
t.Fatal("compiled multipliers retained mutable input pointers")
|
||||
}
|
||||
if defaults.Groups[1].PriceMultiplier != pricing.DefaultPriceMultiplier ||
|
||||
defaults.AccessKeysByID[1].PriceMultiplier != pricing.DefaultPriceMultiplier {
|
||||
t.Fatal("compilation changed a previous snapshot")
|
||||
}
|
||||
for _, invalid := range []pricing.PriceMultiplier{-1, 1_000_000_001} {
|
||||
input.Groups[0].PriceMultiplier = &invalid
|
||||
input.AccessKeys[0].PriceMultiplier = nil
|
||||
if _, err := Compile(input); err == nil {
|
||||
t.Fatalf("accepted invalid group multiplier %d", invalid)
|
||||
}
|
||||
input.Groups[0].PriceMultiplier = nil
|
||||
input.AccessKeys[0].PriceMultiplier = &invalid
|
||||
if _, err := Compile(input); err == nil {
|
||||
t.Fatalf("accepted invalid access key multiplier %d", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"gpt-load/internal/outboundproxy"
|
||||
"gpt-load/internal/parameteroverride"
|
||||
"gpt-load/internal/platform/config"
|
||||
"gpt-load/internal/pricing"
|
||||
"gpt-load/internal/protocol"
|
||||
)
|
||||
|
||||
@@ -32,6 +33,7 @@ type CompileInput struct {
|
||||
}
|
||||
|
||||
type GroupConfig struct {
|
||||
PriceMultiplier *pricing.PriceMultiplier
|
||||
ID uint
|
||||
Name string
|
||||
ChannelID channel.ID
|
||||
@@ -70,6 +72,7 @@ func externalModelName(model ModelConfig) string {
|
||||
}
|
||||
|
||||
type AccessKeyConfig struct {
|
||||
PriceMultiplier *pricing.PriceMultiplier
|
||||
ID uint
|
||||
Name string
|
||||
KeyHash string
|
||||
@@ -122,6 +125,7 @@ type HeaderRules struct {
|
||||
}
|
||||
|
||||
type GroupView struct {
|
||||
PriceMultiplier pricing.PriceMultiplier
|
||||
ID uint
|
||||
Name string
|
||||
ChannelID channel.ID
|
||||
@@ -149,6 +153,7 @@ type GroupCatalogView struct {
|
||||
}
|
||||
|
||||
type AccessKeyView struct {
|
||||
PriceMultiplier pricing.PriceMultiplier
|
||||
ID uint
|
||||
Name string
|
||||
KeySuffix string
|
||||
@@ -218,6 +223,7 @@ func Compile(input CompileInput) (*ConfigSnapshot, error) {
|
||||
}
|
||||
|
||||
view := GroupView{
|
||||
PriceMultiplier: resolvePriceMultiplier(group.PriceMultiplier),
|
||||
ID: group.ID,
|
||||
Name: group.Name,
|
||||
ValidationModel: strings.TrimSpace(group.ValidationModel),
|
||||
@@ -275,7 +281,8 @@ func newAccessKeyView(input AccessKeyConfig) AccessKeyView {
|
||||
return rules[i].ID < rules[j].ID
|
||||
})
|
||||
return AccessKeyView{
|
||||
ID: input.ID, Name: input.Name, Status: input.Status,
|
||||
PriceMultiplier: resolvePriceMultiplier(input.PriceMultiplier),
|
||||
ID: input.ID, Name: input.Name, Status: input.Status,
|
||||
KeySuffix: input.KeySuffix,
|
||||
Filters: cloneFilterSet(input.Filters),
|
||||
ExpiresAtMS: cloneAccessKeyExpiry(input.ExpiresAtMS),
|
||||
@@ -418,6 +425,9 @@ func validateCompileInput(input CompileInput) error {
|
||||
return fmt.Errorf("duplicate group id %d", group.ID)
|
||||
}
|
||||
groupIDs[group.ID] = struct{}{}
|
||||
if group.PriceMultiplier != nil && !group.PriceMultiplier.Valid() {
|
||||
return fmt.Errorf("group %d price multiplier is invalid", group.ID)
|
||||
}
|
||||
if input.ChannelRegistry == nil {
|
||||
return fmt.Errorf("group %d channel registry is required", group.ID)
|
||||
}
|
||||
@@ -495,6 +505,9 @@ func validateCompileInput(input CompileInput) error {
|
||||
return fmt.Errorf("duplicate access key id %d", accessKey.ID)
|
||||
}
|
||||
accessKeyIDs[accessKey.ID] = struct{}{}
|
||||
if accessKey.PriceMultiplier != nil && !accessKey.PriceMultiplier.Valid() {
|
||||
return fmt.Errorf("access key %d price multiplier is invalid", accessKey.ID)
|
||||
}
|
||||
if accessKey.RPMLimit < 0 {
|
||||
return fmt.Errorf("access key %d rpm limit must not be negative", accessKey.ID)
|
||||
}
|
||||
@@ -598,3 +611,10 @@ func cloneAllowedPeerCIDRs(source []netip.Prefix) []netip.Prefix {
|
||||
}
|
||||
return append(make([]netip.Prefix, 0, len(source)), source...)
|
||||
}
|
||||
|
||||
func resolvePriceMultiplier(value *pricing.PriceMultiplier) pricing.PriceMultiplier {
|
||||
if value == nil {
|
||||
return pricing.DefaultPriceMultiplier
|
||||
}
|
||||
return *value
|
||||
}
|
||||
@@ -97,9 +97,9 @@ func TestExternalDatabaseLifecycle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
for table, columns := range map[string][]string{
|
||||
"groups": {"connection_type", "proxy_config"},
|
||||
"groups": {"connection_type", "proxy_config", "price_multiplier_micros"},
|
||||
"credentials": {"identity_fingerprint", "secret_version", "auth_state", "auth_error_code", "proxy_config"},
|
||||
"access_keys": {"expires_at_ms"},
|
||||
"access_keys": {"expires_at_ms", "price_multiplier_micros"},
|
||||
"request_log_attempts": {
|
||||
"upstream_protocol", "failure_origin", "failure_scope",
|
||||
"retry_directive", "effect", "rule_id",
|
||||
@@ -123,15 +123,16 @@ func TestExternalDatabaseLifecycle(t *testing.T) {
|
||||
if err := db.Table("schema_migrations").Order("id").Pluck("id", &migrationIDs).Error; err != nil {
|
||||
t.Fatalf("read migration ledger: %v", err)
|
||||
}
|
||||
if len(migrationIDs) != 8 || migrationIDs[0] != "0001_initial" ||
|
||||
if len(migrationIDs) != 9 || migrationIDs[0] != "0001_initial" ||
|
||||
migrationIDs[1] != "0002_access_key_cost_limits" ||
|
||||
migrationIDs[2] != "0003_remove_observation_fresh_until" ||
|
||||
migrationIDs[3] != "0004_usage_stats_group_activity_index" ||
|
||||
migrationIDs[4] != "0005_proxy_config" ||
|
||||
migrationIDs[5] != "0006_error_decision" ||
|
||||
migrationIDs[6] != "0007_access_key_lifecycle" ||
|
||||
migrationIDs[7] != "0008_remove_inject_usage_options" {
|
||||
t.Fatalf("migration ledger = %v, want complete 0001-0008 chain", migrationIDs)
|
||||
migrationIDs[7] != "0008_remove_inject_usage_options" ||
|
||||
migrationIDs[8] != "0009_price_multipliers" {
|
||||
t.Fatalf("migration ledger = %v, want complete 0001-0009 chain", migrationIDs)
|
||||
}
|
||||
if !db.Migrator().HasIndex("usage_stats", "idx_usage_stats_group_bucket") {
|
||||
t.Fatal("usage_stats group activity index is missing")
|
||||
|
||||
@@ -657,6 +657,7 @@ func TestAutoMigrateCreatesUsageJournalAndMigrationLedger(t *testing.T) {
|
||||
"0006_error_decision",
|
||||
"0007_access_key_lifecycle",
|
||||
"0008_remove_inject_usage_options",
|
||||
"0009_price_multipliers",
|
||||
}
|
||||
if !reflect.DeepEqual(migrationIDs, wantMigrationIDs) {
|
||||
t.Fatalf("schema_migrations IDs = %v, want %v", migrationIDs, wantMigrationIDs)
|
||||
|
||||
@@ -87,6 +87,12 @@ var migrations = []migration{
|
||||
Validate: migrationfiles.Validate0008,
|
||||
ValidateRecoverable: migrationfiles.ValidateRecoverable0008,
|
||||
},
|
||||
{
|
||||
ID: migrationfiles.ID0009,
|
||||
Up: migrationfiles.Up0009,
|
||||
Validate: migrationfiles.Validate0009,
|
||||
ValidateRecoverable: migrationfiles.ValidateRecoverable0009,
|
||||
},
|
||||
}
|
||||
|
||||
func applyMigrations(db *gorm.DB) error {
|
||||
|
||||
@@ -20,6 +20,7 @@ func TestMigrationRegistryContainsOrderedMigrations(t *testing.T) {
|
||||
migrationfiles.ID0006,
|
||||
migrationfiles.ID0007,
|
||||
migrationfiles.ID0008,
|
||||
migrationfiles.ID0009,
|
||||
}
|
||||
if len(migrations) != len(wantIDs) {
|
||||
t.Fatalf("migration registry length = %d, want %d", len(migrations), len(wantIDs))
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestRemoveObservationFreshUntilMigrationDropsColumnAndPreservesRows(t *test
|
||||
ConnectionType: models.ConnectionTypeSubscription,
|
||||
Params: models.JSON(`{}`), Models: models.JSON(`[]`), Enabled: true,
|
||||
}
|
||||
if err := db.Omit("ProxyConfig").Create(&group).Error; err != nil {
|
||||
if err := db.Omit("ProxyConfig", "PriceMultiplierMicros").Create(&group).Error; err != nil {
|
||||
t.Fatalf("create group: %v", err)
|
||||
}
|
||||
credential := models.Credential{
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestAccessKeyLifecycleMigrationAddsNullableExpiryAndPreservesRows(t *testin
|
||||
Name: "legacy", KeyValue: "ciphertext", KeyHash: "legacy-hash",
|
||||
KeySuffix: "cafe", Status: "active", Filters: models.JSON(`{}`),
|
||||
}
|
||||
if err := db.Omit("ExpiresAtMS").Create(&accessKey).Error; err != nil {
|
||||
if err := db.Omit("ExpiresAtMS", "PriceMultiplierMicros").Create(&accessKey).Error; err != nil {
|
||||
t.Fatalf("create legacy access key: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const ID0009 = "0009_price_multipliers"
|
||||
|
||||
var priceMultiplierTables0009 = []struct{ table, constraint string }{
|
||||
{"groups", "chk_group_price_multiplier"},
|
||||
{"access_keys", "chk_access_key_price_multiplier"},
|
||||
}
|
||||
|
||||
// Up0009 用独立且原子的列 DDL 保持 MySQL 中断后的安全恢复。
|
||||
func Up0009(db *gorm.DB) error {
|
||||
if err := ValidateRecoverable0009(db); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, definition := range priceMultiplierTables0009 {
|
||||
if db.Migrator().HasColumn(definition.table, "price_multiplier_micros") {
|
||||
continue
|
||||
}
|
||||
statement := fmt.Sprintf("ALTER TABLE %s ADD COLUMN price_multiplier_micros BIGINT NOT NULL DEFAULT 1000000 CONSTRAINT %s CHECK (price_multiplier_micros >= 0 AND price_multiplier_micros <= 1000000000)", quotePriceMultiplierIdentifier0009(db, definition.table), quotePriceMultiplierIdentifier0009(db, definition.constraint))
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
return fmt.Errorf("add %s.price_multiplier_micros: %w", definition.table, err)
|
||||
}
|
||||
}
|
||||
return Validate0009(db)
|
||||
}
|
||||
|
||||
func ValidateRecoverable0009(db *gorm.DB) error {
|
||||
for _, definition := range priceMultiplierTables0009 {
|
||||
if !db.Migrator().HasTable(definition.table) {
|
||||
return fmt.Errorf("price multiplier table %q is missing", definition.table)
|
||||
}
|
||||
if db.Migrator().HasColumn(definition.table, "price_multiplier_micros") {
|
||||
if err := validatePriceMultiplierColumn0009(db, definition.table, definition.constraint); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Validate0009(db *gorm.DB) error {
|
||||
for _, definition := range priceMultiplierTables0009 {
|
||||
if !db.Migrator().HasColumn(definition.table, "price_multiplier_micros") {
|
||||
return fmt.Errorf("%s.price_multiplier_micros is missing", definition.table)
|
||||
}
|
||||
if err := validatePriceMultiplierColumn0009(db, definition.table, definition.constraint); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePriceMultiplierColumn0009(db *gorm.DB, table, constraint string) error {
|
||||
columns, err := db.Migrator().ColumnTypes(table)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect %s price multiplier: %w", table, err)
|
||||
}
|
||||
found := false
|
||||
for _, column := range columns {
|
||||
if !strings.EqualFold(column.Name(), "price_multiplier_micros") {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if !strings.Contains(strings.ToLower(column.DatabaseTypeName()), "int") {
|
||||
return fmt.Errorf("%s price multiplier is not integer", table)
|
||||
}
|
||||
if nullable, known := column.Nullable(); !known || nullable {
|
||||
return fmt.Errorf("%s price multiplier is nullable", table)
|
||||
}
|
||||
defaultValue, known := column.DefaultValue()
|
||||
if strings.EqualFold(db.Dialector.Name(), "sqlite") {
|
||||
// SQLite 驱动解析列尾约束时可能把 CHECK 也并入默认值,读取数据库元数据。
|
||||
if err := db.Raw("SELECT dflt_value FROM pragma_table_info(?) WHERE name = ?", table, "price_multiplier_micros").Scan(&defaultValue).Error; err != nil {
|
||||
return fmt.Errorf("inspect %s price multiplier default: %w", table, err)
|
||||
}
|
||||
known = defaultValue != ""
|
||||
}
|
||||
defaultValue = strings.Trim(strings.Split(defaultValue, "::")[0], "()' \"")
|
||||
value, parseErr := strconv.ParseInt(defaultValue, 10, 64)
|
||||
if !known || parseErr != nil || value != 1_000_000 {
|
||||
return fmt.Errorf("%s price multiplier has invalid default %q (known %t)", table, defaultValue, known)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("%s price multiplier is missing", table)
|
||||
}
|
||||
if !db.Migrator().HasConstraint(table, constraint) {
|
||||
return fmt.Errorf("%s price multiplier constraint is missing", table)
|
||||
}
|
||||
var definition string
|
||||
switch strings.ToLower(db.Dialector.Name()) {
|
||||
case "sqlite":
|
||||
err = db.Raw("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", table).Scan(&definition).Error
|
||||
case "mysql":
|
||||
err = db.Raw("SELECT CHECK_CLAUSE FROM information_schema.check_constraints WHERE constraint_schema = DATABASE() AND constraint_name = ?", constraint).Scan(&definition).Error
|
||||
case "postgres", "postgresql":
|
||||
err = db.Raw("SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = ? AND conrelid = ?::regclass", constraint, table).Scan(&definition).Error
|
||||
default:
|
||||
return fmt.Errorf("unsupported price multiplier migration driver %q", db.Dialector.Name())
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect %s price multiplier constraint: %w", table, err)
|
||||
}
|
||||
normalized := strings.NewReplacer(" ", "", "\n", "", "\t", "", "(", "", ")", "", "`", "", `"`, "", "::bigint", "").Replace(strings.ToLower(definition))
|
||||
if !strings.Contains(normalized, "price_multiplier_micros>=0andprice_multiplier_micros<=1000000000") {
|
||||
return fmt.Errorf("%s price multiplier constraint has invalid bounds", table)
|
||||
}
|
||||
var invalid int64
|
||||
if err := db.Table(table).Where("price_multiplier_micros IS NULL OR price_multiplier_micros < 0 OR price_multiplier_micros > 1000000000").Count(&invalid).Error; err != nil {
|
||||
return fmt.Errorf("validate %s price multiplier values: %w", table, err)
|
||||
}
|
||||
if invalid != 0 {
|
||||
return fmt.Errorf("%s contains invalid price multipliers", table)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func quotePriceMultiplierIdentifier0009(db *gorm.DB, value string) string {
|
||||
if strings.EqualFold(db.Dialector.Name(), "mysql") {
|
||||
return "`" + value + "`"
|
||||
}
|
||||
return `"` + value + `"`
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package models
|
||||
|
||||
// AccessKey is an encrypted client credential and its persisted access policy.
|
||||
type AccessKey struct {
|
||||
PriceMultiplierMicros *int64 `gorm:"column:price_multiplier_micros;type:bigint;not null;default:1000000;check:chk_access_key_price_multiplier,price_multiplier_micros >= 0 AND price_multiplier_micros <= 1000000000"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(255);not null"`
|
||||
KeyValue string `gorm:"type:text;not null"`
|
||||
|
||||
@@ -11,20 +11,21 @@ import (
|
||||
// Group is the persisted configuration for an upstream service group.
|
||||
// API DTOs and runtime state views must be defined outside the storage package.
|
||||
type Group struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(255);not null;uniqueIndex"`
|
||||
ChannelID string `gorm:"type:varchar(64);not null"`
|
||||
ConnectionType ConnectionType `gorm:"type:varchar(32);not null;default:'api_key';check:chk_group_connection_type,connection_type IN ('api_key','subscription')"`
|
||||
Params JSON `gorm:"type:json;not null"`
|
||||
Models JSON `gorm:"type:json;not null"`
|
||||
WeightManual *int
|
||||
ValidationModel *string `gorm:"type:varchar(255)"`
|
||||
Overrides JSON `gorm:"type:json"`
|
||||
ProxyConfig *string `gorm:"column:proxy_config;type:text"`
|
||||
Enabled bool `gorm:"not null;default:true"`
|
||||
Credentials []Credential `gorm:"foreignKey:GroupID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
CreatedAtMS int64 `gorm:"column:created_at_ms;not null;autoCreateTime:milli;check:chk_group_created_at,created_at_ms >= 0"`
|
||||
UpdatedAtMS int64 `gorm:"column:updated_at_ms;not null;autoUpdateTime:milli;check:chk_group_updated_at,updated_at_ms >= 0"`
|
||||
PriceMultiplierMicros *int64 `gorm:"column:price_multiplier_micros;type:bigint;not null;default:1000000;check:chk_group_price_multiplier,price_multiplier_micros >= 0 AND price_multiplier_micros <= 1000000000"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(255);not null;uniqueIndex"`
|
||||
ChannelID string `gorm:"type:varchar(64);not null"`
|
||||
ConnectionType ConnectionType `gorm:"type:varchar(32);not null;default:'api_key';check:chk_group_connection_type,connection_type IN ('api_key','subscription')"`
|
||||
Params JSON `gorm:"type:json;not null"`
|
||||
Models JSON `gorm:"type:json;not null"`
|
||||
WeightManual *int
|
||||
ValidationModel *string `gorm:"type:varchar(255)"`
|
||||
Overrides JSON `gorm:"type:json"`
|
||||
ProxyConfig *string `gorm:"column:proxy_config;type:text"`
|
||||
Enabled bool `gorm:"not null;default:true"`
|
||||
Credentials []Credential `gorm:"foreignKey:GroupID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
CreatedAtMS int64 `gorm:"column:created_at_ms;not null;autoCreateTime:milli;check:chk_group_created_at,created_at_ms >= 0"`
|
||||
UpdatedAtMS int64 `gorm:"column:updated_at_ms;not null;autoUpdateTime:milli;check:chk_group_updated_at,updated_at_ms >= 0"`
|
||||
}
|
||||
|
||||
// BeforeSave keeps channel parameters representable. Channel-specific shape
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
migrationfiles "gpt-load/internal/storage/migrations"
|
||||
)
|
||||
|
||||
func TestPriceMultiplierMigrationAddsConfigurationColumns(t *testing.T) {
|
||||
db := openInternalMigrationTestDatabase(t)
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, table := range []string{"groups", "access_keys"} {
|
||||
if !db.Migrator().HasColumn(table, "price_multiplier_micros") {
|
||||
t.Fatalf("%s.price_multiplier_micros missing", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceMultiplierMigrationUpgradeAndRecovery(t *testing.T) {
|
||||
testPriceMultiplierMigrationContract(t, func(t *testing.T) *gorm.DB { return openInternalMigrationTestDatabase(t) })
|
||||
}
|
||||
|
||||
func TestExternalPriceMultiplierMigrationContract(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("GPT_LOAD_DATABASE_TEST_DSN"))
|
||||
if dsn == "" {
|
||||
t.Skip("GPT_LOAD_DATABASE_TEST_DSN is not set")
|
||||
}
|
||||
testPriceMultiplierMigrationContract(t, func(t *testing.T) *gorm.DB { return openExternalIncrementalMigrationDatabase(t, dsn) })
|
||||
}
|
||||
|
||||
func testPriceMultiplierMigrationContract(t *testing.T, open func(*testing.T) *gorm.DB) {
|
||||
t.Helper()
|
||||
for _, mode := range []string{"fresh", "upgrade", "interrupted", "columns_added"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
db := open(t)
|
||||
if mode != "fresh" {
|
||||
if err := applyMigrationRegistry(db, migrations[:8]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Table("groups").Create(map[string]any{
|
||||
"id": 1, "name": "legacy multiplier group", "channel_id": "openai", "connection_type": "api_key",
|
||||
"params": "{}", "models": "[]", "enabled": true, "created_at_ms": 1, "updated_at_ms": 1,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO access_keys (id, name, key_value, key_hash, key_suffix, status, filters, created_at_ms, updated_at_ms) VALUES (1, 'legacy multiplier key', 'encrypted', 'multiplier-hash', 'cafe', 'active', '{}', 1, 1)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if mode == "interrupted" || mode == "columns_added" {
|
||||
entry := migrations[8]
|
||||
entry.Up = func(tx *gorm.DB) error {
|
||||
if mode == "columns_added" {
|
||||
if err := migrationfiles.Up0009(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("simulated interruption before recording price multiplier migration")
|
||||
}
|
||||
if err := tx.Exec(`ALTER TABLE ? ADD COLUMN price_multiplier_micros BIGINT NOT NULL DEFAULT 1000000 CONSTRAINT chk_group_price_multiplier CHECK (price_multiplier_micros >= 0 AND price_multiplier_micros <= 1000000000)`, clause.Table{Name: "groups"}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("simulated interruption after first price multiplier column")
|
||||
}
|
||||
entries := append([]migration(nil), migrations[:8]...)
|
||||
entries = append(entries, entry)
|
||||
if err := applyMigrationRegistry(db, entries); err == nil {
|
||||
t.Fatal("interrupted migration succeeded")
|
||||
}
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
if !db.Migrator().HasColumn("groups", "price_multiplier_micros") {
|
||||
t.Fatal("MySQL did not retain first DDL")
|
||||
}
|
||||
} else if db.Migrator().HasColumn("groups", "price_multiplier_micros") {
|
||||
t.Fatal("transactional driver did not roll back interrupted DDL")
|
||||
}
|
||||
}
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrationfiles.Validate0009(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertInternalMigrationComplete(t, db, registeredMigrationIDs())
|
||||
if mode == "fresh" {
|
||||
return
|
||||
}
|
||||
for _, table := range []string{"groups", "access_keys"} {
|
||||
var value int64
|
||||
if err := db.Table(table).Select("price_multiplier_micros").Where("id = 1").Scan(&value).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value != 1_000_000 {
|
||||
t.Fatalf("%s legacy multiplier = %d", table, value)
|
||||
}
|
||||
for _, valid := range []int64{0, 125_000, 1_000_000_000} {
|
||||
if err := db.Table(table).Where("id = 1").Update("price_multiplier_micros", valid).Error; err != nil {
|
||||
t.Fatalf("%s rejects %d: %v", table, valid, err)
|
||||
}
|
||||
}
|
||||
for _, invalid := range []any{int64(-1), int64(1_000_000_001), nil} {
|
||||
if err := db.Table(table).Where("id = 1").Update("price_multiplier_micros", invalid).Error; err == nil {
|
||||
t.Fatalf("%s accepted invalid multiplier %v", table, invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ export interface GroupCollectionSummaryDto {
|
||||
export interface GroupCollectionItemDto {
|
||||
id: number
|
||||
name: string
|
||||
price_multiplier: string
|
||||
channel_id: string
|
||||
connection_type: ConnectionType
|
||||
params: ChannelParamsDto
|
||||
@@ -80,6 +81,7 @@ export interface GroupCollectionResponseDto {
|
||||
export interface GroupSummaryDto {
|
||||
id: number
|
||||
name: string
|
||||
price_multiplier: string
|
||||
channel_id: string
|
||||
connection_type: ConnectionType
|
||||
params: ChannelParamsDto
|
||||
@@ -131,6 +133,7 @@ export interface GroupEffectiveConfigDto {
|
||||
|
||||
export interface GroupSettingsDto {
|
||||
name: string
|
||||
price_multiplier: string
|
||||
channel_id: string
|
||||
connection_type: ConnectionType
|
||||
params: ChannelParamsDto
|
||||
@@ -528,6 +531,7 @@ export interface HealthAccessKeyCostLimitDto {
|
||||
export interface AccessKeyDto {
|
||||
id: number
|
||||
name: string
|
||||
price_multiplier: string
|
||||
masked_key: string
|
||||
status: 'active' | 'disabled'
|
||||
filters: AccessKeyFiltersDto
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
projectEpochMilliseconds,
|
||||
projectEnum,
|
||||
projectNullableEpochMilliseconds,
|
||||
projectPriceMultiplier,
|
||||
projectRecord,
|
||||
projectSafeInteger,
|
||||
projectString,
|
||||
@@ -56,6 +57,7 @@ export interface CreateAccessKeyRequest {
|
||||
filters: AccessKeyFiltersDto
|
||||
expires_at_ms: number | null
|
||||
rpm_limit: number
|
||||
price_multiplier: string
|
||||
cost_limit_rules: AccessKeyCostLimitRuleInput[]
|
||||
}
|
||||
|
||||
@@ -72,6 +74,7 @@ export type UpdateAccessKeyRequest = Partial<{
|
||||
filters: AccessKeyFiltersDto
|
||||
expires_at_ms: number | null
|
||||
rpm_limit: number
|
||||
price_multiplier: string
|
||||
cost_limit_rules: AccessKeyCostLimitRuleInput[]
|
||||
}>
|
||||
|
||||
@@ -83,6 +86,7 @@ const metadataFields = [
|
||||
'filters',
|
||||
'expires_at_ms',
|
||||
'rpm_limit',
|
||||
'price_multiplier',
|
||||
'cost_limit_rules',
|
||||
'cost_limit_status',
|
||||
'created_at_ms',
|
||||
@@ -281,6 +285,7 @@ export function projectAccessKeyMetadata(value: unknown): AccessKeyDto {
|
||||
filters: projectFilters(record.filters),
|
||||
expires_at_ms: projectNullableEpochMilliseconds(record.expires_at_ms),
|
||||
rpm_limit: projectSafeInteger(record.rpm_limit, { minimum: 0 }),
|
||||
price_multiplier: projectPriceMultiplier(record.price_multiplier),
|
||||
cost_limit_rules: costLimitRules,
|
||||
cost_limit_status: costLimitStatus,
|
||||
created_at_ms: projectEpochMilliseconds(record.created_at_ms),
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
projectEpochMilliseconds,
|
||||
projectEnum,
|
||||
projectFiniteNumber,
|
||||
projectPriceMultiplier,
|
||||
projectRecord,
|
||||
projectSafeInteger,
|
||||
projectString,
|
||||
@@ -47,6 +48,7 @@ import { projectProxyView } from './proxy'
|
||||
const groupSummaryFields = [
|
||||
'id',
|
||||
'name',
|
||||
'price_multiplier',
|
||||
'channel_id',
|
||||
'connection_type',
|
||||
'params',
|
||||
@@ -57,6 +59,7 @@ const groupSummaryFields = [
|
||||
] as const
|
||||
const groupSettingsFields = [
|
||||
'name',
|
||||
'price_multiplier',
|
||||
'channel_id',
|
||||
'connection_type',
|
||||
'params',
|
||||
@@ -80,6 +83,7 @@ const groupCollectionSummaryFields = ['total', 'available', 'unavailable', 'disa
|
||||
const groupCollectionItemFields = [
|
||||
'id',
|
||||
'name',
|
||||
'price_multiplier',
|
||||
'channel_id',
|
||||
'connection_type',
|
||||
'params',
|
||||
@@ -147,6 +151,7 @@ export type {
|
||||
|
||||
export type GroupSettingsUpdateRequest = Partial<{
|
||||
name: string
|
||||
price_multiplier: string
|
||||
params: ChannelParamsDto
|
||||
validation_model: string | null
|
||||
enabled: boolean
|
||||
@@ -189,6 +194,7 @@ export interface GroupModelsReplaceRequest {
|
||||
|
||||
export interface GroupCreateRequest {
|
||||
name?: string
|
||||
price_multiplier: string
|
||||
channel_id: string
|
||||
connection_type: ConnectionType
|
||||
params: ChannelParamsDto
|
||||
@@ -386,6 +392,7 @@ export function projectGroupSummary(value: unknown): GroupSummaryDto {
|
||||
channel_id: projectChannelID(record.channel_id),
|
||||
connection_type: projectEnum(record.connection_type, connectionTypes),
|
||||
params: projectChannelParams(record.params),
|
||||
price_multiplier: projectPriceMultiplier(record.price_multiplier),
|
||||
service_status: serviceStatus,
|
||||
service_status_reason: serviceStatusReason,
|
||||
credential_count: projectSafeInteger(record.credential_count, { minimum: 0 }),
|
||||
@@ -401,6 +408,7 @@ export function projectGroupSettings(value: unknown): GroupSettingsDto {
|
||||
channel_id: projectChannelID(record.channel_id),
|
||||
connection_type: projectEnum(record.connection_type, connectionTypes),
|
||||
params: projectChannelParams(record.params),
|
||||
price_multiplier: projectPriceMultiplier(record.price_multiplier),
|
||||
validation_model:
|
||||
record.validation_model === null ? null : projectNonBlankString(record.validation_model),
|
||||
enabled: projectBoolean(record.enabled),
|
||||
@@ -497,6 +505,7 @@ function projectGroupCollectionItem(value: unknown): GroupCollectionItemDto {
|
||||
connection_type: projectEnum(record.connection_type, connectionTypes),
|
||||
params: projectChannelParams(record.params),
|
||||
status,
|
||||
price_multiplier: projectPriceMultiplier(record.price_multiplier),
|
||||
model_count: modelCount,
|
||||
credential_counts: credentialCounts,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { InvalidResponseError } from '@/api/errors'
|
||||
import { isValidPriceMultiplier, normalizePriceMultiplier } from '@/lib/price-multiplier'
|
||||
|
||||
interface NumberBounds {
|
||||
minimum?: number
|
||||
@@ -37,6 +38,14 @@ export function projectString(value: unknown, options: StringOptions = {}): stri
|
||||
return value
|
||||
}
|
||||
|
||||
export function projectPriceMultiplier(value: unknown): string {
|
||||
const result = projectString(value)
|
||||
if (!isValidPriceMultiplier(result) || result !== normalizePriceMultiplier(result)) {
|
||||
invalidResponse()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function projectBoolean(value: unknown): boolean {
|
||||
if (typeof value !== 'boolean') invalidResponse()
|
||||
return value
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
projectEnum,
|
||||
projectInt64String,
|
||||
projectNonNegativeInt64String,
|
||||
projectPriceMultiplier,
|
||||
projectRecord,
|
||||
projectSafeInteger,
|
||||
projectString,
|
||||
@@ -109,14 +110,16 @@ export interface RequestLogPricingLineDto {
|
||||
}
|
||||
|
||||
export interface RequestLogPricingReceiptDto {
|
||||
schema_version: 1 | 2 | 3 | 4
|
||||
schema_version: 1 | 2 | 3 | 4 | 5 | 6
|
||||
method: 'unit_rate_sum'
|
||||
method_version: 1
|
||||
currency: 'USD'
|
||||
pricing_mode: string
|
||||
price_multipliers?: { group: string; access_key: string }
|
||||
rule: { scope_key?: string; channel_id?: string; model_id: string }
|
||||
context_threshold_tokens: string | null
|
||||
line_items: RequestLogPricingLineDto[]
|
||||
base_total_nano_usd?: string
|
||||
total_nano_usd: string
|
||||
}
|
||||
|
||||
@@ -350,9 +353,11 @@ function projectPricingReceipt(value: unknown): RequestLogPricingReceiptDto | nu
|
||||
'method_version',
|
||||
'currency',
|
||||
'pricing_mode',
|
||||
'price_multipliers',
|
||||
'rule',
|
||||
'context_threshold_tokens',
|
||||
'line_items',
|
||||
'base_total_nano_usd',
|
||||
'total_nano_usd',
|
||||
])
|
||||
const rule = projectRecord(record.rule)
|
||||
@@ -388,15 +393,31 @@ function projectPricingReceipt(value: unknown): RequestLogPricingReceiptDto | nu
|
||||
line.amount_nano_usd === null ? null : projectNonNegativeInt64String(line.amount_nano_usd),
|
||||
}
|
||||
})
|
||||
const schemaVersion = projectSafeInteger(record.schema_version, { minimum: 1, maximum: 4 }) as
|
||||
1 | 2 | 3 | 4
|
||||
const schemaVersion = projectSafeInteger(record.schema_version, { minimum: 1, maximum: 6 }) as
|
||||
1 | 2 | 3 | 4 | 5 | 6
|
||||
let priceMultipliers: RequestLogPricingReceiptDto['price_multipliers']
|
||||
if (schemaVersion >= 5) {
|
||||
const multipliers = projectRecord(record.price_multipliers)
|
||||
assertNoSecretLikeFields(multipliers, ['group', 'access_key'])
|
||||
priceMultipliers = {
|
||||
group: projectPriceMultiplier(multipliers.group),
|
||||
access_key: projectPriceMultiplier(multipliers.access_key),
|
||||
}
|
||||
} else if (record.price_multipliers !== undefined) {
|
||||
invalidResponse()
|
||||
}
|
||||
let baseTotal: string | undefined
|
||||
if (schemaVersion === 6) {
|
||||
baseTotal = projectNonNegativeInt64String(record.base_total_nano_usd)
|
||||
} else if (record.base_total_nano_usd !== undefined) {
|
||||
invalidResponse()
|
||||
}
|
||||
const scopeKey = rule.scope_key === undefined ? undefined : projectNonBlankString(rule.scope_key)
|
||||
const channelID = rule.channel_id === undefined ? undefined : projectChannelID(rule.channel_id)
|
||||
if (
|
||||
(schemaVersion === 1 && (scopeKey === undefined || channelID !== undefined)) ||
|
||||
(schemaVersion === 2 && (scopeKey !== undefined || channelID !== undefined)) ||
|
||||
((schemaVersion === 3 || schemaVersion === 4) &&
|
||||
(scopeKey !== undefined || channelID === undefined))
|
||||
(schemaVersion >= 3 && (scopeKey !== undefined || channelID === undefined))
|
||||
) {
|
||||
invalidResponse()
|
||||
}
|
||||
@@ -406,6 +427,7 @@ function projectPricingReceipt(value: unknown): RequestLogPricingReceiptDto | nu
|
||||
method_version: projectSafeInteger(record.method_version, { minimum: 1, maximum: 1 }) as 1,
|
||||
currency: projectEnum(record.currency, ['USD'] as const),
|
||||
pricing_mode: projectPricingMode(record.pricing_mode),
|
||||
...(priceMultipliers === undefined ? {} : { price_multipliers: priceMultipliers }),
|
||||
rule: {
|
||||
...(scopeKey === undefined ? {} : { scope_key: scopeKey }),
|
||||
...(channelID === undefined ? {} : { channel_id: channelID }),
|
||||
@@ -416,6 +438,7 @@ function projectPricingReceipt(value: unknown): RequestLogPricingReceiptDto | nu
|
||||
? null
|
||||
: projectNonNegativeInt64String(record.context_threshold_tokens),
|
||||
line_items: lines,
|
||||
...(baseTotal === undefined ? {} : { base_total_nano_usd: baseTotal }),
|
||||
total_nano_usd: projectNonNegativeInt64String(record.total_nano_usd),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ const presentations = computed(() =>
|
||||
allModels: t('accessKeys.allModels'),
|
||||
unlimited: t('accessKeys.unlimited'),
|
||||
costRules: (count) => t('accessKeys.costLimits.ruleCount', { count }),
|
||||
priceMultiplier: (value) => t('common.priceMultiplier.value', { value }),
|
||||
},
|
||||
protocolLabel: (protocol) => protocol,
|
||||
}),
|
||||
|
||||
@@ -20,6 +20,7 @@ import AppButton from '@/components/ui/AppButton.vue'
|
||||
import AppDrawer from '@/components/ui/AppDrawer.vue'
|
||||
import type { SearchableMultiSelectOption } from '@/components/ui/SearchableMultiSelect.vue'
|
||||
import { createUUID } from '@/lib/uuid'
|
||||
import { isValidPriceMultiplier } from '@/lib/price-multiplier'
|
||||
|
||||
import {
|
||||
accessKeyProtocolOptions,
|
||||
@@ -246,6 +247,9 @@ const saveBlockerKey = computed(() => {
|
||||
if (!Number.isSafeInteger(draft.value.rpm_limit) || draft.value.rpm_limit < 0) {
|
||||
return 'accessKeys.drawer.saveBlockedRPM'
|
||||
}
|
||||
if (!isValidPriceMultiplier(draft.value.price_multiplier)) {
|
||||
return 'common.priceMultiplier.invalid'
|
||||
}
|
||||
if (!areAccessKeyCostLimitRulesValid(draft.value.costLimitRules)) {
|
||||
return 'accessKeys.drawer.saveBlockedCostLimits'
|
||||
}
|
||||
@@ -690,10 +694,12 @@ onBeforeUnmount(clearLocalState)
|
||||
:name="draft.name"
|
||||
:status="draft.status"
|
||||
:rpm-limit="draft.rpm_limit"
|
||||
:price-multiplier="draft.price_multiplier"
|
||||
:disabled="formLocked"
|
||||
@update:name="draft.name = $event"
|
||||
@update:status="draft.status = $event"
|
||||
@update:rpm-limit="draft.rpm_limit = $event"
|
||||
@update:price-multiplier="draft.price_multiplier = $event"
|
||||
/>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -4,17 +4,20 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { AccessKeyDto } from '@/api/control/types'
|
||||
import AppTextInput from '@/components/ui/AppTextInput.vue'
|
||||
import { isValidPriceMultiplier } from '@/lib/price-multiplier'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
status: AccessKeyDto['status']
|
||||
rpmLimit: number
|
||||
priceMultiplier: string
|
||||
disabled: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:name': [value: string]
|
||||
'update:status': [value: AccessKeyDto['status']]
|
||||
'update:rpmLimit': [value: number]
|
||||
'update:priceMultiplier': [value: string]
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
const nameInput = ref<InstanceType<typeof AppTextInput>>()
|
||||
@@ -54,6 +57,33 @@ defineExpose({ focusName })
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="access-key-drawer__field">
|
||||
<span class="access-key-drawer__field-label" aria-hidden="true">
|
||||
{{ t('common.priceMultiplier.label') }}
|
||||
</span>
|
||||
<AppTextInput
|
||||
id="access-key-price-multiplier"
|
||||
:model-value="priceMultiplier"
|
||||
:label="t('common.priceMultiplier.label')"
|
||||
inputmode="decimal"
|
||||
appearance="surface"
|
||||
size="compact"
|
||||
described-by="access-key-price-multiplier-description"
|
||||
:invalid="!isValidPriceMultiplier(priceMultiplier)"
|
||||
:disabled="disabled"
|
||||
@update:model-value="emit('update:priceMultiplier', $event)"
|
||||
/>
|
||||
<small id="access-key-price-multiplier-description">
|
||||
{{
|
||||
t(
|
||||
isValidPriceMultiplier(priceMultiplier)
|
||||
? 'common.priceMultiplier.accessKeyHelp'
|
||||
: 'common.priceMultiplier.invalid',
|
||||
)
|
||||
}}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="access-key-setting-row">
|
||||
<div class="access-key-setting-row__label">
|
||||
<strong>{{ t('accessKeys.drawer.enabled') }}</strong>
|
||||
|
||||
@@ -20,6 +20,7 @@ export function cloneAccessKeyCreatePayload(
|
||||
},
|
||||
expires_at_ms: payload.expires_at_ms,
|
||||
rpm_limit: payload.rpm_limit,
|
||||
price_multiplier: payload.price_multiplier,
|
||||
cost_limit_rules: payload.cost_limit_rules.map((rule) => ({ ...rule })),
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from '@/app/resources/access-keys'
|
||||
import type { AccessKeyDto, AccessKeyFiltersDto } from '@/api/control/types'
|
||||
import { createUUID } from '@/lib/uuid'
|
||||
import { isValidPriceMultiplier, normalizePriceMultiplier } from '@/lib/price-multiplier'
|
||||
|
||||
import {
|
||||
createAccessKeyScopeModes,
|
||||
@@ -23,6 +24,7 @@ export interface AccessKeyDraft {
|
||||
expirationMode: 'never' | 'specified'
|
||||
expires_at_ms: number | null
|
||||
rpm_limit: number
|
||||
price_multiplier: string
|
||||
costLimitRules: AccessKeyCostLimitRuleDraft[]
|
||||
}
|
||||
|
||||
@@ -75,6 +77,7 @@ export function createAccessKeyDraft(accessKey?: AccessKeyDto | null): AccessKey
|
||||
expirationMode: accessKey?.expires_at_ms == null ? 'never' : 'specified',
|
||||
expires_at_ms: accessKey?.expires_at_ms ?? null,
|
||||
rpm_limit: accessKey?.rpm_limit ?? 0,
|
||||
price_multiplier: accessKey?.price_multiplier ?? '1',
|
||||
costLimitRules: (accessKey?.cost_limit_rules ?? []).map(costLimitRuleDraft),
|
||||
}
|
||||
}
|
||||
@@ -90,6 +93,7 @@ export function createAccessKeyDraftFromCreateInput(input: CreateAccessKeyReques
|
||||
expirationMode: input.expires_at_ms === null ? 'never' : 'specified',
|
||||
expires_at_ms: input.expires_at_ms,
|
||||
rpm_limit: input.rpm_limit,
|
||||
price_multiplier: input.price_multiplier,
|
||||
costLimitRules: input.cost_limit_rules.map(costLimitRuleDraft),
|
||||
}
|
||||
}
|
||||
@@ -109,6 +113,7 @@ export function createAccessKeyDraftFromUpdate(
|
||||
expirationMode: expiresAt === null ? 'never' : 'specified',
|
||||
expires_at_ms: expiresAt,
|
||||
rpm_limit: patch.rpm_limit ?? base.rpm_limit,
|
||||
price_multiplier: patch.price_multiplier ?? base.price_multiplier,
|
||||
costLimitRules: (patch.cost_limit_rules ?? base.cost_limit_rules).map(costLimitRuleDraft),
|
||||
}
|
||||
}
|
||||
@@ -134,6 +139,7 @@ export function isAccessKeyDraftValid(
|
||||
draft.name.trim().length > 0 &&
|
||||
Number.isSafeInteger(draft.rpm_limit) &&
|
||||
draft.rpm_limit >= 0 &&
|
||||
isValidPriceMultiplier(draft.price_multiplier) &&
|
||||
expirationValid &&
|
||||
(draft.sourceMode === 'all' || (allowedCIDRs.length > 0 && allowedCIDRs.length <= 64)) &&
|
||||
areAccessKeyCostLimitRulesValid(draft.costLimitRules) &&
|
||||
@@ -153,6 +159,7 @@ export function buildCreateAccessKeyInput(draft: AccessKeyDraft): CreateAccessKe
|
||||
filters: materializeDraftFilters(draft),
|
||||
expires_at_ms: expirationValue(draft),
|
||||
rpm_limit: draft.rpm_limit,
|
||||
price_multiplier: normalizePriceMultiplier(draft.price_multiplier),
|
||||
cost_limit_rules: costLimitInputs(draft.costLimitRules, false),
|
||||
}
|
||||
}
|
||||
@@ -273,6 +280,7 @@ export function isAccessKeyDraftDirty(draft: AccessKeyDraft, base?: AccessKeyDto
|
||||
draft.status !== initial.status ||
|
||||
draft.expires_at_ms !== initial.expires_at_ms ||
|
||||
draft.rpm_limit !== initial.rpm_limit ||
|
||||
normalizePriceMultiplier(draft.price_multiplier) !== initial.price_multiplier ||
|
||||
!equalFilters(draft.filters, initial.filters) ||
|
||||
!equalCostLimitRules(
|
||||
costLimitInputs(draft.costLimitRules, false),
|
||||
@@ -292,6 +300,8 @@ export function accessKeyMatchesUpdatePatch(
|
||||
(patch.filters === undefined || equalFilters(patch.filters, accessKey.filters)) &&
|
||||
(patch.expires_at_ms === undefined || patch.expires_at_ms === accessKey.expires_at_ms) &&
|
||||
(patch.rpm_limit === undefined || patch.rpm_limit === accessKey.rpm_limit) &&
|
||||
(patch.price_multiplier === undefined ||
|
||||
normalizePriceMultiplier(patch.price_multiplier) === accessKey.price_multiplier) &&
|
||||
(patch.cost_limit_rules === undefined ||
|
||||
costLimitRulesMatchReconciliation(
|
||||
accessKey.cost_limit_rules,
|
||||
@@ -366,6 +376,8 @@ export function buildAccessKeyUpdatePatch(
|
||||
if (!equalFilters(filters, base.filters)) patch.filters = filters
|
||||
if (expiresAt !== base.expires_at_ms) patch.expires_at_ms = expiresAt
|
||||
if (draft.rpm_limit !== base.rpm_limit) patch.rpm_limit = draft.rpm_limit
|
||||
const priceMultiplier = normalizePriceMultiplier(draft.price_multiplier)
|
||||
if (priceMultiplier !== base.price_multiplier) patch.price_multiplier = priceMultiplier
|
||||
const desiredCostLimits = costLimitInputs(draft.costLimitRules, true)
|
||||
if (!equalCostLimitRules(desiredCostLimits, base.cost_limit_rules)) {
|
||||
patch.cost_limit_rules = desiredCostLimits
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface AccessKeyPresenterLabels {
|
||||
allModels: string
|
||||
unlimited: string
|
||||
costRules(count: number): string
|
||||
priceMultiplier(value: string): string
|
||||
}
|
||||
|
||||
export interface AccessKeyPresenterOptions {
|
||||
@@ -68,6 +69,9 @@ function presentAccessKeyWithGroupNames(
|
||||
ipRestricted: accessKey.filters.allowed_cidrs.length > 0,
|
||||
scopeRows,
|
||||
limits: [
|
||||
...(accessKey.price_multiplier === '1'
|
||||
? []
|
||||
: [options.labels.priceMultiplier(accessKey.price_multiplier)]),
|
||||
accessKey.rpm_limit === 0
|
||||
? options.labels.unlimited
|
||||
: `${new Intl.NumberFormat(options.locale).format(accessKey.rpm_limit)} RPM`,
|
||||
|
||||
@@ -44,6 +44,9 @@ const channelName = computed(() => channel.value?.name.trim() || props.group.cha
|
||||
</div>
|
||||
<div class="group-header__details">
|
||||
<span class="group-header__id">#{{ group.id }}</span>
|
||||
<span v-if="group.price_multiplier !== '1'" class="meta-tag">
|
||||
{{ t('common.priceMultiplier.value', { value: group.price_multiplier }) }}
|
||||
</span>
|
||||
<span class="meta-tag">
|
||||
<ChannelIcon
|
||||
v-if="channel"
|
||||
|
||||
@@ -524,6 +524,13 @@ function connectionTypeBadgeClass(type: ConnectionType): string {
|
||||
>
|
||||
{{ channelName(group.channel_id) }}
|
||||
</OverflowTooltip>
|
||||
<span
|
||||
v-if="group.price_multiplier !== '1'"
|
||||
class="connection-type-badge"
|
||||
:title="t('common.priceMultiplier.groupHelp')"
|
||||
>
|
||||
{{ t('common.priceMultiplier.value', { value: group.price_multiplier }) }}
|
||||
</span>
|
||||
<span
|
||||
class="connection-type-badge"
|
||||
:class="connectionTypeBadgeClass(group.connection_type)"
|
||||
|
||||
@@ -541,6 +541,7 @@ function referenceCostTitle(window: CredentialQuotaWindowDto): string | undefine
|
||||
const observed = window.observed_usage
|
||||
if (!observed) return undefined
|
||||
return estimateTitles(
|
||||
'group.credentials.subscription.estimate.priceMultiplierBasis',
|
||||
observed.data_complete ? '' : 'group.credentials.subscription.estimate.dataIncomplete',
|
||||
observed.pricing_complete ? '' : 'group.credentials.subscription.estimate.pricingIncomplete',
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ChannelParamsDto, GroupModelItemDto } from '@/api/control/types'
|
||||
import type { ChannelFieldDto } from '@/app/resources/channels'
|
||||
import AppSwitch from '@/components/ui/AppSwitch.vue'
|
||||
import SegmentedControl from '@/components/ui/SegmentedControl.vue'
|
||||
import { isValidPriceMultiplier } from '@/lib/price-multiplier'
|
||||
|
||||
const props = defineProps<{
|
||||
section: 'general' | 'routing'
|
||||
@@ -16,6 +17,7 @@ const props = defineProps<{
|
||||
validationModel: string | null
|
||||
models: GroupModelItemDto[]
|
||||
weightManual: number | null
|
||||
priceMultiplier: string
|
||||
enabled: boolean
|
||||
pending: boolean
|
||||
paramsDisabled?: boolean
|
||||
@@ -27,6 +29,7 @@ const emit = defineEmits<{
|
||||
'update:name': [value: string]
|
||||
'update:validationModel': [value: string | null]
|
||||
'update:weightManual': [value: number | null]
|
||||
'update:priceMultiplier': [value: string]
|
||||
'update:enabled': [value: boolean]
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
@@ -142,6 +145,21 @@ function setWeightMode(value: string): void {
|
||||
</datalist>
|
||||
<small>{{ t('group.settings.base.validationModelHelp') }}</small>
|
||||
</label>
|
||||
<label class="group-settings__field">
|
||||
<span>{{ t('common.priceMultiplier.label') }}</span>
|
||||
<input
|
||||
class="group-settings__mono"
|
||||
:value="priceMultiplier"
|
||||
inputmode="decimal"
|
||||
:disabled="pending"
|
||||
:aria-invalid="!isValidPriceMultiplier(priceMultiplier) || undefined"
|
||||
@input="emit('update:priceMultiplier', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<small v-if="!isValidPriceMultiplier(priceMultiplier)" role="alert">
|
||||
{{ t('common.priceMultiplier.invalid') }}
|
||||
</small>
|
||||
<small v-else>{{ t('common.priceMultiplier.groupHelp') }}</small>
|
||||
</label>
|
||||
<template v-for="field in paramFields" :key="field.key">
|
||||
<div v-if="isOptionalBaseURL(field)" class="group-settings__field group-settings__wide">
|
||||
<span>{{ t('group.settings.base.customUrl') }}</span>
|
||||
|
||||
@@ -45,6 +45,7 @@ import SectionNav from '@/components/ui/SectionNav.vue'
|
||||
import StickySaveBar from '@/components/ui/StickySaveBar.vue'
|
||||
import { useSectionNavigation } from '@/composables/use-section-navigation'
|
||||
import { isValidUpstreamBaseURL } from '@/lib/upstream-base-url'
|
||||
import { isValidPriceMultiplier } from '@/lib/price-multiplier'
|
||||
|
||||
import GroupDeleteDialog from './GroupDeleteDialog.vue'
|
||||
import GroupSettingsBaseForm from './GroupSettingsBaseForm.vue'
|
||||
@@ -225,6 +226,7 @@ const valid = computed(
|
||||
!nameError.value &&
|
||||
Object.keys(paramErrors.value).length === 0 &&
|
||||
weightValid.value &&
|
||||
isValidPriceMultiplier(draft.value?.price_multiplier ?? '') &&
|
||||
timeoutValid.value &&
|
||||
policyCountsValid.value &&
|
||||
headerRulesValid.value &&
|
||||
@@ -533,6 +535,7 @@ onBeforeUnmount(() => {
|
||||
:validation-model="draft.validation_model"
|
||||
:models="modelsQuery.data.value?.items ?? []"
|
||||
:weight-manual="draft.weight_manual"
|
||||
:price-multiplier="draft.price_multiplier"
|
||||
:enabled="draft.enabled"
|
||||
:pending="mutationPending"
|
||||
:params-disabled="channelParamsDisabled"
|
||||
@@ -542,6 +545,7 @@ onBeforeUnmount(() => {
|
||||
@update:name="draft.name = $event"
|
||||
@update:validation-model="draft.validation_model = $event"
|
||||
@update:weight-manual="draft.weight_manual = $event"
|
||||
@update:price-multiplier="draft.price_multiplier = $event"
|
||||
@update:enabled="draft.enabled = $event"
|
||||
/>
|
||||
<GroupSettingsBaseForm
|
||||
@@ -553,6 +557,7 @@ onBeforeUnmount(() => {
|
||||
:validation-model="draft.validation_model"
|
||||
:models="modelsQuery.data.value?.items ?? []"
|
||||
:weight-manual="draft.weight_manual"
|
||||
:price-multiplier="draft.price_multiplier"
|
||||
:enabled="draft.enabled"
|
||||
:pending="mutationPending"
|
||||
:params-disabled="channelParamsDisabled"
|
||||
@@ -562,6 +567,7 @@ onBeforeUnmount(() => {
|
||||
@update:name="draft.name = $event"
|
||||
@update:validation-model="draft.validation_model = $event"
|
||||
@update:weight-manual="draft.weight_manual = $event"
|
||||
@update:price-multiplier="draft.price_multiplier = $event"
|
||||
@update:enabled="draft.enabled = $event"
|
||||
/>
|
||||
<section id="settings-runtime" class="group-settings__section">
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
GroupSettingsUpdateRequest,
|
||||
HeaderRulesDto,
|
||||
} from '@/app/resources/groups'
|
||||
import { normalizePriceMultiplier } from '@/lib/price-multiplier'
|
||||
|
||||
export type GroupTimeoutKey = 'first_byte_timeout' | 'request_timeout' | 'stream_idle_timeout'
|
||||
export type GroupPolicyCountKey = 'retry_count' | 'blacklist_threshold'
|
||||
@@ -21,6 +22,7 @@ export interface GroupSettingsDraft {
|
||||
validation_model: string | null
|
||||
enabled: boolean
|
||||
weight_manual: number | null
|
||||
price_multiplier: string
|
||||
overrides: GroupRuntimeConfigDto
|
||||
}
|
||||
|
||||
@@ -183,6 +185,8 @@ export function buildGroupSettingsPatch(
|
||||
const validationModel = draft.validation_model?.trim() || null
|
||||
if (validationModel !== base.validation_model) patch.validation_model = validationModel
|
||||
if (draft.enabled !== base.enabled) patch.enabled = draft.enabled
|
||||
const priceMultiplier = normalizePriceMultiplier(draft.price_multiplier)
|
||||
if (priceMultiplier !== base.price_multiplier) patch.price_multiplier = priceMultiplier
|
||||
if (draft.weight_manual !== base.weight_manual) patch.weight_manual = draft.weight_manual
|
||||
if (JSON.stringify(overrides) !== JSON.stringify(normalizeOverrides(base.overrides))) {
|
||||
patch.overrides = overrides
|
||||
|
||||
@@ -108,6 +108,14 @@ function ruleTone(rule: AccessKeyCostLimitRuleStatusDto): 'success' | 'warning'
|
||||
</div>
|
||||
|
||||
<dl class="current-access-key__facts">
|
||||
<div>
|
||||
<dt>{{ t('common.priceMultiplier.label') }}</dt>
|
||||
<dd>
|
||||
<OverflowTooltip :content="t('common.priceMultiplier.accessKeyHelp')">
|
||||
×{{ accessKey.price_multiplier }}
|
||||
</OverflowTooltip>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('home.ledger.currentAccessKey.rpm') }}</dt>
|
||||
<dd>{{ rpm }}</dd>
|
||||
@@ -306,7 +314,7 @@ function ruleTone(rule: AccessKeyCostLimitRuleStatusDto): 'success' | 'warning'
|
||||
|
||||
.current-access-key__facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
margin: 0;
|
||||
gap: 1px;
|
||||
background: var(--color-border-subtle);
|
||||
|
||||
@@ -9,6 +9,7 @@ import AppSelect from '@/components/ui/AppSelect.vue'
|
||||
import AppSwitch from '@/components/ui/AppSwitch.vue'
|
||||
import AppTextInput from '@/components/ui/AppTextInput.vue'
|
||||
import FormField from '@/components/ui/FormField.vue'
|
||||
import { isValidPriceMultiplier } from '@/lib/price-multiplier'
|
||||
import { hasUpstreamBaseURLVersionMismatch, isValidUpstreamBaseURL } from '@/lib/upstream-base-url'
|
||||
|
||||
import type { ImportProxyDraft } from './model-draft'
|
||||
@@ -16,6 +17,7 @@ import type { ImportProxyDraft } from './model-draft'
|
||||
const props = defineProps<{
|
||||
channel: ChannelDto | null
|
||||
name: string
|
||||
priceMultiplier: string
|
||||
params: Record<string, string>
|
||||
proxy: ImportProxyDraft
|
||||
paramErrors: Readonly<Record<string, string>>
|
||||
@@ -25,6 +27,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:name': [value: string]
|
||||
'update:priceMultiplier': [value: string]
|
||||
'update:param': [key: string, value: string]
|
||||
'update:proxy': [value: ImportProxyDraft]
|
||||
'update:base-url-override': [enabled: boolean]
|
||||
@@ -205,6 +208,29 @@ function baseURLVersionWarning(key: string): string | undefined {
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
id="import-price-multiplier"
|
||||
class="import-connection__multiplier"
|
||||
:label="t('common.priceMultiplier.label')"
|
||||
:description="t('common.priceMultiplier.groupHelp')"
|
||||
:error="
|
||||
isValidPriceMultiplier(priceMultiplier) ? undefined : t('common.priceMultiplier.invalid')
|
||||
"
|
||||
size="compact"
|
||||
>
|
||||
<template #default="field">
|
||||
<input
|
||||
id="import-price-multiplier"
|
||||
:value="priceMultiplier"
|
||||
inputmode="decimal"
|
||||
:disabled="disabled"
|
||||
:aria-describedby="field.describedBy"
|
||||
:aria-invalid="field.invalid || undefined"
|
||||
@input="emit('update:priceMultiplier', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</template>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
v-if="channel"
|
||||
id="import-group-proxy-mode"
|
||||
@@ -281,10 +307,16 @@ function baseURLVersionWarning(key: string): string | undefined {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.import-connection__multiplier,
|
||||
.import-connection__proxy {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.import-connection__multiplier input {
|
||||
max-width: 260px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.import-connection__proxy-controls {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
|
||||
@@ -57,6 +57,8 @@ import ImportConnectionSection from './ImportConnectionSection.vue'
|
||||
import ImportOperationNotice from './ImportOperationNotice.vue'
|
||||
import { useImportOperationOwner } from './import-operation-owner'
|
||||
import { useImportRecovery } from './import-recovery'
|
||||
import { isValidPriceMultiplier, normalizePriceMultiplier } from '@/lib/price-multiplier'
|
||||
|
||||
import { analyzeCredentials } from './credential-analysis'
|
||||
import CredentialTextarea from './CredentialTextarea.vue'
|
||||
import SubscriptionCredentialStager from './SubscriptionCredentialStager.vue'
|
||||
@@ -93,6 +95,7 @@ function freshDraft(): ImportDraft {
|
||||
params: {},
|
||||
proxy: { mode: 'inherit', url: '' },
|
||||
name: '',
|
||||
price_multiplier: '1',
|
||||
credentials: '',
|
||||
staged_credentials: [],
|
||||
models: [],
|
||||
@@ -351,6 +354,7 @@ const submissionErrorMessage = computed(
|
||||
)
|
||||
const submitBlockedReason = computed(() => {
|
||||
if (payloadLocked.value || mutationPending.value) return ''
|
||||
if (!isValidPriceMultiplier(draft.price_multiplier)) return t('common.priceMultiplier.invalid')
|
||||
if (paramsError.value) {
|
||||
if (selectedChannel.value === null) return t('import.presets.channelRequired')
|
||||
return visibleParamError.value || t('import.steps.channel.incomplete')
|
||||
@@ -379,6 +383,7 @@ const canCreate = computed(
|
||||
() =>
|
||||
!payloadLocked.value &&
|
||||
!mutationPending.value &&
|
||||
isValidPriceMultiplier(draft.price_multiplier) &&
|
||||
!paramsError.value &&
|
||||
credentialCount.value > 0 &&
|
||||
(draft.connection_type === 'subscription' || !credentialAnalysis.value.tooManyCredentials) &&
|
||||
@@ -906,6 +911,7 @@ function buildCreateBody(confirmSameTarget: boolean): GroupCreateRequest {
|
||||
),
|
||||
...(draftProxyOverride.value === undefined ? {} : { proxy: draftProxyOverride.value }),
|
||||
...(name ? { name } : {}),
|
||||
price_multiplier: normalizePriceMultiplier(draft.price_multiplier),
|
||||
models: toGroupModels(draft.models),
|
||||
...(draft.connection_type === 'subscription'
|
||||
? { staged_credential_ids: currentReadyStages().map(({ stage_id }) => stage_id) }
|
||||
@@ -1247,6 +1253,7 @@ onBeforeUnmount(() => {
|
||||
<ImportConnectionSection
|
||||
:channel="connectionChannel"
|
||||
:name="draft.name"
|
||||
:price-multiplier="draft.price_multiplier"
|
||||
:params="draft.params"
|
||||
:proxy="draft.proxy"
|
||||
:proxy-disabled="proxyLocked"
|
||||
@@ -1254,6 +1261,7 @@ onBeforeUnmount(() => {
|
||||
:base-url-override-enabled="baseUrlOverrideEnabled"
|
||||
:disabled="payloadLocked"
|
||||
@update:name="draft.name = $event"
|
||||
@update:price-multiplier="draft.price_multiplier = $event"
|
||||
@update:param="setChannelParam"
|
||||
@update:proxy="draft.proxy = $event"
|
||||
@update:base-url-override="setBaseURLOverride"
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface ImportRecoveryService {
|
||||
}
|
||||
|
||||
interface ImportRecoveryRecord {
|
||||
version: 7
|
||||
version: 8
|
||||
expires_at: number
|
||||
draft: ImportRecoveryDraft
|
||||
}
|
||||
@@ -93,6 +93,7 @@ function isNewImportDraft(value: Record<string, unknown>): boolean {
|
||||
'params',
|
||||
'proxy',
|
||||
'name',
|
||||
'price_multiplier',
|
||||
'credentials',
|
||||
'staged_credentials',
|
||||
'models',
|
||||
@@ -103,6 +104,7 @@ function isNewImportDraft(value: Record<string, unknown>): boolean {
|
||||
isChannelParams(value.params) &&
|
||||
isImportProxyDraft(value.proxy) &&
|
||||
typeof value.name === 'string' &&
|
||||
typeof value.price_multiplier === 'string' &&
|
||||
typeof value.credentials === 'string' &&
|
||||
Array.isArray(value.staged_credentials) &&
|
||||
value.staged_credentials.every(isRecoveredStage) &&
|
||||
@@ -189,10 +191,17 @@ function parseRecoveryRecord(raw: string): ImportRecoveryRecord | null {
|
||||
: value.draft,
|
||||
}
|
||||
}
|
||||
if (isRecord(value) && value.version === 7 && isRecord(value.draft)) {
|
||||
value = {
|
||||
...value,
|
||||
version: 8,
|
||||
draft: value.draft.mode === 'new' ? { ...value.draft, price_multiplier: '1' } : value.draft,
|
||||
}
|
||||
}
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!hasOnlyFields(value, ['version', 'expires_at', 'draft']) ||
|
||||
value.version !== 7 ||
|
||||
value.version !== 8 ||
|
||||
typeof value.expires_at !== 'number' ||
|
||||
!Number.isFinite(value.expires_at) ||
|
||||
!isImportDraft(value.draft)
|
||||
@@ -270,7 +279,7 @@ export function createImportRecoveryService(
|
||||
if (!deps.storage) return 'storage-unavailable'
|
||||
|
||||
const record: ImportRecoveryRecord = {
|
||||
version: 7,
|
||||
version: 8,
|
||||
expires_at: deps.now() + importRecoveryTtlMs,
|
||||
draft,
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface ImportDraft {
|
||||
params: ChannelParamsDto
|
||||
proxy: ImportProxyDraft
|
||||
name: string
|
||||
price_multiplier: string
|
||||
credentials: string
|
||||
staged_credentials: CredentialStage[]
|
||||
models: ModelDraftItem[]
|
||||
|
||||
@@ -23,7 +23,7 @@ import OverflowTooltip from '@/components/ui/OverflowTooltip.vue'
|
||||
import QueryFeedback from '@/components/ui/QueryFeedback.vue'
|
||||
import SkeletonSurface from '@/components/ui/SkeletonSurface.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import { formatEstimatedCost } from '@/lib/format'
|
||||
import { formatEstimatedCost, formatExactNanoUSD } from '@/lib/format'
|
||||
|
||||
import { formatCacheHitRate } from '@/lib/cache-rate'
|
||||
import {
|
||||
@@ -220,14 +220,16 @@ function dispatchStateLabel(attempt: RequestLogAttemptDto): string {
|
||||
|
||||
function formatFormulaLine(line: RequestLogPricingLineDto): string {
|
||||
const quantity = formatLogTokenCount(line.quantity, locale.value)
|
||||
const multipliers = receipt.value?.schema_version === 5 ? receipt.value.price_multipliers : null
|
||||
const priceMultiplier = multipliers ? ` × ${multipliers.group} × ${multipliers.access_key}` : ''
|
||||
if (line.state === 'unpriced' || line.rate_nano_usd_per_million === null) {
|
||||
return `${quantity} × —`
|
||||
return `${quantity} × —${priceMultiplier}`
|
||||
}
|
||||
const multiplier =
|
||||
line.multiplier.numerator === line.multiplier.denominator
|
||||
? ''
|
||||
: ` × ${line.multiplier.numerator}/${line.multiplier.denominator}`
|
||||
return `${quantity} × ${formatEstimatedCost(line.rate_nano_usd_per_million, locale.value)}/1M${multiplier}`
|
||||
return `${quantity} × ${formatExactNanoUSD(line.rate_nano_usd_per_million, locale.value)}/1M${multiplier}${priceMultiplier}`
|
||||
}
|
||||
|
||||
function accessKeyLabel(): string {
|
||||
@@ -595,6 +597,15 @@ function toggleAttemptErrorMessage(sequence: number): void {
|
||||
<code>{{ pricingIdentity }}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="!selfScoped && receipt?.price_multipliers" class="log-detail__wide">
|
||||
<dt>{{ t('common.priceMultiplier.label') }}</dt>
|
||||
<dd>
|
||||
{{ t('common.priceMultiplier.group') }} ×{{ receipt.price_multipliers.group }} ·
|
||||
{{ t('common.priceMultiplier.accessKey') }} ×{{
|
||||
receipt.price_multipliers.access_key
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
!selfScoped &&
|
||||
@@ -608,6 +619,33 @@ function toggleAttemptErrorMessage(sequence: number): void {
|
||||
<dd class="log-detail__formula">
|
||||
<span>{{ t('monitor.logs.receipt.input') }} = {{ formula.input }}</span>
|
||||
<span>{{ t('monitor.logs.receipt.output') }} = {{ formula.output }}</span>
|
||||
<template
|
||||
v-if="
|
||||
receipt.schema_version === 6 &&
|
||||
receipt.base_total_nano_usd !== undefined &&
|
||||
receipt.price_multipliers
|
||||
"
|
||||
>
|
||||
<span>
|
||||
{{ t('monitor.logs.receipt.baseTotal') }} =
|
||||
{{ formatExactNanoUSD(receipt.base_total_nano_usd, locale) }}
|
||||
</span>
|
||||
<span>
|
||||
{{ t('monitor.logs.receipt.finalTotal') }} =
|
||||
{{ formatExactNanoUSD(receipt.base_total_nano_usd, locale) }} ×
|
||||
{{ receipt.price_multipliers.group }} ×
|
||||
{{ receipt.price_multipliers.access_key }} =
|
||||
{{ formatExactNanoUSD(receipt.total_nano_usd, locale) }}
|
||||
</span>
|
||||
<small>{{ t('monitor.logs.receipt.totalRounding') }}</small>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>
|
||||
{{ t('monitor.logs.receipt.total') }} =
|
||||
{{ formatExactNanoUSD(receipt.total_nano_usd, locale) }}
|
||||
</span>
|
||||
<small>{{ t('monitor.logs.receipt.rounding') }}</small>
|
||||
</template>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@@ -199,9 +199,9 @@ export default {
|
||||
enableTotal: 'Enable total limit',
|
||||
enablePeriodic: 'Enable periodic limits',
|
||||
totalDescription:
|
||||
"Limit this access key's cumulative estimated cost. The total limit does not reset automatically.",
|
||||
'Limit cumulative estimated cost after Group and access key multipliers. The total limit does not reset automatically.',
|
||||
periodicDescription:
|
||||
'Limit estimated cost in fixed periods triggered by the first request. Up to 10 rules.',
|
||||
'Limit adjusted estimated cost in fixed periods triggered by the first request. Up to 10 rules.',
|
||||
addPeriodic: 'Add periodic limit',
|
||||
total: 'Total limit',
|
||||
periodic: 'Periodic limit',
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
export default {
|
||||
common: {
|
||||
priceMultiplier: {
|
||||
label: 'Price multiplier',
|
||||
value: 'Multiplier ×{value}',
|
||||
group: 'Group',
|
||||
accessKey: 'Access key',
|
||||
groupHelp:
|
||||
'Base rate × Group multiplier × access key multiplier. Default 1; 0 zeroes priced amounts. Applies to future requests only.',
|
||||
accessKeyHelp:
|
||||
'Multiplied by the selected Group multiplier. Logs, statistics, and cost limits use adjusted amounts. Default 1; 0 zeroes priced amounts. Future requests only.',
|
||||
invalid: 'Enter a multiplier from 0 to 1000 with at most 6 decimals',
|
||||
},
|
||||
appName: 'GPT-Load',
|
||||
retry: 'Retry',
|
||||
modelDiscoveryFailed: 'Discovery failed; draft unchanged',
|
||||
@@ -298,7 +309,7 @@ export default {
|
||||
title: 'Estimated over 30 days',
|
||||
viewDetail: 'View details →',
|
||||
viewModel: 'View usage details for {model}',
|
||||
caption: 'Top five models by spend over 30 days',
|
||||
caption: 'Top five models by adjusted estimated cost over 30 days',
|
||||
scrollHint: 'Scroll horizontally to inspect every column',
|
||||
columns: {
|
||||
model: 'Model',
|
||||
|
||||
@@ -632,6 +632,8 @@ export default {
|
||||
usedPercent: '{value}% used',
|
||||
requests: 'Requests',
|
||||
tokens: 'Tokens',
|
||||
priceMultiplierBasis:
|
||||
'Estimated using model reference prices and Group/access key multipliers at request time; not an actual bill',
|
||||
referenceCost: 'API reference cost',
|
||||
unavailable: 'Local statistics are not currently available',
|
||||
dataIncomplete: 'Statistics do not cover the full window, so the value may be low',
|
||||
|
||||
@@ -23,7 +23,7 @@ export default {
|
||||
},
|
||||
matrix: {
|
||||
heading: 'Price',
|
||||
unit: 'USD / 1M',
|
||||
unit: 'USD / 1M · Before multipliers',
|
||||
thresholdColumn: 'Tier',
|
||||
baseRow: 'Default',
|
||||
addTier: 'Add tier',
|
||||
|
||||
@@ -4,7 +4,8 @@ export default {
|
||||
loading: 'Loading models…',
|
||||
loadFailed: 'Unable to load models',
|
||||
stale: 'The current model information may be stale because the background refresh failed',
|
||||
context: 'Preset prices take priority over Models.dev · USD / 1M tokens · estimates only',
|
||||
context:
|
||||
'Preset prices take priority over Models.dev · Base rates in USD / 1M tokens, before Group and access key multipliers',
|
||||
result: 'Showing {shown} of {total} client models',
|
||||
actions: {
|
||||
sync: 'Sync catalog and automatic prices',
|
||||
@@ -18,7 +19,7 @@ export default {
|
||||
models: '{count} client models',
|
||||
upstreams: '{count} upstream models',
|
||||
pending: '{count} pending',
|
||||
unit: 'USD / 1M tokens · estimate only',
|
||||
unit: 'Base USD / 1M tokens · Before multipliers',
|
||||
},
|
||||
catalog: {
|
||||
available: 'Preset price catalog available',
|
||||
|
||||
@@ -275,7 +275,7 @@ export default {
|
||||
totalTokens: 'Total reported tokens',
|
||||
persistedWindow: 'Current reporting window',
|
||||
estimatedCost: 'Estimated cost',
|
||||
estimatedCostBasis: 'Based on reported tokens',
|
||||
estimatedCostBasis: 'Reported tokens and multipliers at request time',
|
||||
},
|
||||
tokens: {
|
||||
title: 'Reported token categories',
|
||||
@@ -976,6 +976,13 @@ export default {
|
||||
partial: 'Token usage is partial',
|
||||
},
|
||||
receipt: {
|
||||
total: 'Priced total',
|
||||
baseTotal: 'Base priced total',
|
||||
finalTotal: 'Final priced total',
|
||||
totalRounding:
|
||||
'Components are rounded using the original pricing rules and summed. The total is then multiplied by both group and access key multipliers and rounded once to the nearest 0.000000001 USD. Unpriced portions are excluded.',
|
||||
rounding:
|
||||
'Each priced component is rounded to the nearest 0.000000001 USD after all multipliers, then summed. Unpriced portions are excluded.',
|
||||
formula: 'Cost calculation',
|
||||
input: 'Input',
|
||||
output: 'Output',
|
||||
|
||||
@@ -197,9 +197,9 @@ export default {
|
||||
enableTotal: '総額上限を有効化',
|
||||
enablePeriodic: '周期上限を有効化',
|
||||
totalDescription:
|
||||
'このアクセスキーの累計概算費用を制限します。総額上限は自動でリセットされません。',
|
||||
'グループ・アクセスキー倍率適用後の累積推定コストを制限します。合計上限は自動リセットされません。',
|
||||
periodicDescription:
|
||||
'最初のリクエストを起点とする固定周期ごとに概算費用を制限します。最大 10 件まで設定できます。',
|
||||
'最初のリクエストから始まる固定期間で倍率適用後の推定コストを制限します。最大 10 ルール。',
|
||||
addPeriodic: '周期上限を追加',
|
||||
total: '総額上限',
|
||||
periodic: '周期上限',
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
export default {
|
||||
common: {
|
||||
priceMultiplier: {
|
||||
label: '価格倍率',
|
||||
value: '倍率 ×{value}',
|
||||
group: 'グループ',
|
||||
accessKey: 'アクセスキー',
|
||||
groupHelp:
|
||||
'基準単価 × グループ倍率 × アクセスキー倍率。既定値は 1、0 は計算可能な金額をゼロにします。以後のリクエストに適用されます。',
|
||||
accessKeyHelp:
|
||||
'選択されたグループ倍率と乗算します。ログ、統計、コスト上限は調整後の金額を使用します。既定値は 1、0 は計算可能な金額をゼロにします。以後のリクエストに適用されます。',
|
||||
invalid: '0〜1000 の倍率を小数点以下 6 桁以内で入力してください',
|
||||
},
|
||||
appName: 'GPT-Load',
|
||||
retry: '再試行',
|
||||
modelDiscoveryFailed: 'モデル取得に失敗しました。下書きは未変更です',
|
||||
@@ -296,7 +307,7 @@ export default {
|
||||
title: '直近 30 日間の概算',
|
||||
viewDetail: '詳細を見る →',
|
||||
viewModel: '{model} の使用状況を見る',
|
||||
caption: '直近 30 日間の消費上位 5 モデル',
|
||||
caption: '直近 30 日間の調整後推定コスト上位 5 モデル',
|
||||
scrollHint: '横にスクロールするとすべての列を確認できます',
|
||||
columns: {
|
||||
model: 'モデル',
|
||||
|
||||
@@ -637,6 +637,8 @@ export default {
|
||||
usedPercent: '使用済み {value}%',
|
||||
requests: 'リクエスト',
|
||||
tokens: 'トークン',
|
||||
priceMultiplierBasis:
|
||||
'リクエスト時のモデル参考価格とグループ・アクセスキー倍率に基づく推定で、実際の請求額ではありません',
|
||||
referenceCost: 'API 参考コスト',
|
||||
unavailable: '利用可能なローカル統計は現在ありません',
|
||||
dataIncomplete: '統計データが期間全体をカバーしていないため、値が少ない場合があります',
|
||||
|
||||
@@ -23,7 +23,7 @@ export default {
|
||||
},
|
||||
matrix: {
|
||||
heading: '価格',
|
||||
unit: 'USD / 1M',
|
||||
unit: 'USD / 1M · 倍率適用前の単価',
|
||||
thresholdColumn: '段階',
|
||||
baseRow: 'デフォルト',
|
||||
addTier: '段階を追加',
|
||||
|
||||
@@ -4,7 +4,8 @@ export default {
|
||||
loading: 'モデルを読み込み中…',
|
||||
loadFailed: 'モデルを読み込めません',
|
||||
stale: 'バックグラウンド更新に失敗したため、現在のモデル情報は古い可能性があります',
|
||||
context: 'プリセット価格は Models.dev より優先 · USD / 100 万 Token · 概算のみ',
|
||||
context:
|
||||
'プリセット価格を Models.dev より優先 · 基準単価 USD / 100万トークン、グループ・アクセスキー倍率適用前',
|
||||
result: '{total} 件中 {shown} 件のクライアントモデルを表示',
|
||||
actions: {
|
||||
sync: 'カタログと自動価格を同期',
|
||||
@@ -18,7 +19,7 @@ export default {
|
||||
models: 'クライアントモデル {count} 件',
|
||||
upstreams: '上流モデル {count} 件',
|
||||
pending: '価格待ち {count} 件',
|
||||
unit: 'USD / 100 万 tokens · 概算のみ',
|
||||
unit: '基準単価 USD / 100万トークン · 倍率適用前',
|
||||
},
|
||||
catalog: {
|
||||
available: 'プリセット価格カタログ利用可能',
|
||||
|
||||
@@ -275,7 +275,7 @@ export default {
|
||||
totalTokens: '報告済みトークン合計',
|
||||
persistedWindow: '現在の集計期間',
|
||||
estimatedCost: '推定コスト',
|
||||
estimatedCostBasis: '報告済みトークンに基づく推定',
|
||||
estimatedCostBasis: '報告済みトークンとリクエスト時の倍率に基づく推定',
|
||||
},
|
||||
tokens: {
|
||||
title: '報告済みトークン分類',
|
||||
@@ -974,6 +974,13 @@ export default {
|
||||
partial: 'Token 使用量が部分的です',
|
||||
},
|
||||
receipt: {
|
||||
total: '計算済み合計',
|
||||
baseTotal: '倍率適用前の計算済み合計',
|
||||
finalTotal: '最終計算済み合計',
|
||||
totalRounding:
|
||||
'各項目を従来のルールで四捨五入して合算し、合計にグループとアクセスキーの両倍率を掛け、最後に 0.000000001 USD 単位で一度四捨五入します。未価格設定の部分は含みません。',
|
||||
rounding:
|
||||
'各項目に全倍率を適用後、0.000000001 USD 単位で四捨五入して合算します。未価格設定の部分は含みません。',
|
||||
formula: 'コスト計算',
|
||||
input: '入力',
|
||||
output: '出力',
|
||||
|
||||
@@ -187,8 +187,9 @@ export default {
|
||||
costLimits: {
|
||||
enableTotal: '启用总额度',
|
||||
enablePeriodic: '启用周期额度',
|
||||
totalDescription: '限制此访问密钥的累计估算费用;达到限额后不会自动恢复。',
|
||||
periodicDescription: '按首次请求触发的固定周期限制估算费用,最多可设置 10 条。',
|
||||
totalDescription:
|
||||
'限制此访问密钥的累计估算费用(含分组和访问密钥倍率);达到限额后不会自动恢复。',
|
||||
periodicDescription: '按首次请求触发的固定周期限制倍率后的估算费用,最多可设置 10 条。',
|
||||
addPeriodic: '添加周期额度',
|
||||
total: '总额度',
|
||||
periodic: '周期额度',
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
export default {
|
||||
common: {
|
||||
priceMultiplier: {
|
||||
label: '价格倍率',
|
||||
value: '倍率 ×{value}',
|
||||
group: '分组',
|
||||
accessKey: '访问密钥',
|
||||
groupHelp: '基础单价 × 分组倍率 × 访问密钥倍率。默认 1,0 将可计价金额归零;仅影响后续请求。',
|
||||
accessKeyHelp:
|
||||
'与实际分组倍率相乘;日志、统计和成本额度均使用调整后金额。默认 1,0 将可计价金额归零;仅影响后续请求。',
|
||||
invalid: '请输入 0–1000 的倍率,最多 6 位小数',
|
||||
},
|
||||
appName: 'GPT-Load',
|
||||
retry: '重试',
|
||||
modelDiscoveryFailed: '模型发现失败,草稿未变',
|
||||
@@ -283,7 +293,7 @@ export default {
|
||||
title: '近 30 天估算',
|
||||
viewDetail: '查看明细 →',
|
||||
viewModel: '查看 {model} 的用量明细',
|
||||
caption: '近 30 天消耗前五的模型',
|
||||
caption: '近 30 天调整后估算成本前五的模型',
|
||||
scrollHint: '横向滚动可查看全部列',
|
||||
columns: {
|
||||
model: '模型',
|
||||
|
||||
@@ -612,6 +612,7 @@ export default {
|
||||
usedPercent: '已用 {value}%',
|
||||
requests: '请求',
|
||||
tokens: 'Token',
|
||||
priceMultiplierBasis: '按请求当时的模型参考价格及分组、访问密钥倍率估算,不代表实际账单',
|
||||
referenceCost: 'API 参考成本',
|
||||
unavailable: '暂时没有可用的本地统计',
|
||||
dataIncomplete: '统计数据未覆盖完整窗口,数值可能偏低',
|
||||
|
||||
@@ -22,7 +22,7 @@ export default {
|
||||
},
|
||||
matrix: {
|
||||
heading: '价格',
|
||||
unit: 'USD / 1M',
|
||||
unit: 'USD / 1M · 倍率前单价',
|
||||
thresholdColumn: '档位',
|
||||
baseRow: '默认',
|
||||
addTier: '添加档位',
|
||||
|
||||
@@ -4,7 +4,7 @@ export default {
|
||||
loading: '正在加载模型…',
|
||||
loadFailed: '无法加载模型',
|
||||
stale: '后台刷新失败,当前模型信息可能已过期',
|
||||
context: '预设价格优先于 Models.dev;USD / 1M Token,仅供估算',
|
||||
context: '预设价格优先于 Models.dev;基础单价 USD / 1M Token,未含分组及访问密钥倍率',
|
||||
result: '显示 {shown} / {total} 个客户端模型',
|
||||
actions: {
|
||||
sync: '同步目录与自动价格',
|
||||
@@ -18,7 +18,7 @@ export default {
|
||||
models: '{count} 个客户端模型',
|
||||
upstreams: '{count} 个上游模型',
|
||||
pending: '{count} 个待定价',
|
||||
unit: 'USD / 百万 Token · 仅估算',
|
||||
unit: '基础单价 USD / 百万 Token · 未含倍率',
|
||||
},
|
||||
catalog: {
|
||||
available: '预设价格目录可用',
|
||||
|
||||
@@ -266,7 +266,7 @@ export default {
|
||||
totalTokens: '已报告 Token 总数',
|
||||
persistedWindow: '当前统计窗口',
|
||||
estimatedCost: '预估成本',
|
||||
estimatedCostBasis: '基于已报告 Token 估算',
|
||||
estimatedCostBasis: '基于已报告 Token 与请求当时的倍率',
|
||||
},
|
||||
tokens: {
|
||||
title: '已报告 Token 分类',
|
||||
@@ -953,6 +953,13 @@ export default {
|
||||
partial: '当前 Token 用量不完整',
|
||||
},
|
||||
receipt: {
|
||||
total: '已计价合计',
|
||||
baseTotal: '原始已计价合计',
|
||||
finalTotal: '最终已计价合计',
|
||||
totalRounding:
|
||||
'各计价项按原规则四舍五入后合计,再同时乘以分组和访问密钥倍率,最后四舍五入到 0.000000001 USD;未定价部分不计入合计。',
|
||||
rounding:
|
||||
'各计价项应用全部倍率后四舍五入到 0.000000001 USD,再相加;未定价部分不计入合计。',
|
||||
formula: '成本计算',
|
||||
input: '输入',
|
||||
output: '输出',
|
||||
|
||||
@@ -233,6 +233,13 @@ export function formatEstimatedCost(nanoUSD: string, locale: string): string {
|
||||
return formatCurrency(whole, fraction, locale)
|
||||
}
|
||||
|
||||
export function formatExactNanoUSD(nanoUSD: string, locale: string): string {
|
||||
if (!/^(?:0|[1-9]\d*)$/u.test(nanoUSD)) return '—'
|
||||
const value = BigInt(nanoUSD)
|
||||
const fraction = (value % NANO_USD_PER_USD).toString().padStart(9, '0').replace(/0+$/u, '')
|
||||
return formatCurrency(value / NANO_USD_PER_USD, fraction.padEnd(2, '0'), locale)
|
||||
}
|
||||
|
||||
export function formatUSD(value: string, locale: string): string {
|
||||
if (!/^(?:0|[1-9]\d*)(?:\.\d{1,9})?$/u.test(value)) return '—'
|
||||
const [whole = '0', fraction = ''] = value.split('.')
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export function normalizePriceMultiplier(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if (!isValidPriceMultiplier(trimmed)) return trimmed
|
||||
const [whole = '0', fraction = ''] = trimmed.split('.')
|
||||
const normalizedWhole = whole.replace(/^0+/u, '') || '0'
|
||||
const normalizedFraction = fraction.replace(/0+$/u, '')
|
||||
return normalizedFraction ? `${normalizedWhole}.${normalizedFraction}` : normalizedWhole
|
||||
}
|
||||
|
||||
export function isValidPriceMultiplier(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
if (!/^\d+(?:\.\d{1,6})?$/u.test(trimmed)) return false
|
||||
const [whole = '0', fraction = ''] = trimmed.split('.')
|
||||
const normalizedWhole = whole.replace(/^0+/u, '') || '0'
|
||||
if (normalizedWhole.length > 4) return false
|
||||
const millionths = BigInt(normalizedWhole) * 1_000_000n + BigInt(fraction.padEnd(6, '0'))
|
||||
return millionths <= 1_000_000_000n
|
||||
}
|
||||
Reference in new issue
Block a user