mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-01 15:02:58 +08:00
Merge origin/main into feat/channel-monitor-v2-ops-ui
Resolves three conflicts, all of the "both branches appended to the same block" shape. Every one is resolved as a union of both sides; nothing from either parent is dropped. - handler/admin/setting_handler_update.go: keep ChannelMonitorHideThroughput (V2) alongside GrokDefaultTextModel / GrokCrossClientModelMapEnabled / GrokDefaultBaseURLMode (#5408). UpdateSettings writes every key on each save, so dropping either side would reset those settings to zero values. - service/domain_constants.go: keep SettingKeyChannelMonitorHideThroughput and the three SettingKeyGrok* constants. - repository/migrations_runner.go: keep the 195 checksum rule (V2) and the 218/219/220 rules (#5408).
This commit is contained in:
@@ -143,3 +143,6 @@ docs/*
|
||||
frontend/coverage/
|
||||
aicodex
|
||||
output/
|
||||
|
||||
# Vitest / Vite cache at repo root
|
||||
.vite/
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.1.171
|
||||
0.1.172
|
||||
|
||||
@@ -149,7 +149,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
openAIOAuthService := service.ProvideOpenAIOAuthService(proxyRepository, openAIOAuthClient, privacyClientFactory)
|
||||
openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI)
|
||||
grokOAuthClient := repository.NewGrokOAuthClient()
|
||||
grokOAuthService := service.NewGrokOAuthService(proxyRepository, grokOAuthClient)
|
||||
grokOAuthService := service.ProvideGrokOAuthService(proxyRepository, grokOAuthClient, configConfig, redisClient)
|
||||
grokTokenProvider := service.ProvideGrokTokenProvider(accountRepository, geminiTokenCache, grokOAuthService, oAuthRefreshAPI, tempUnschedCache)
|
||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, grokTokenProvider, modelPricingResolver, channelService, balanceNotifyService, settingService, serviceUserPlatformQuotaRepository)
|
||||
geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig)
|
||||
@@ -196,11 +196,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
|
||||
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
|
||||
grokQuotaFetcher := service.NewGrokQuotaFetcher()
|
||||
grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, configConfig, usageLogRepository)
|
||||
grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, configConfig, usageLogRepository, settingService)
|
||||
openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIGatewayService)
|
||||
usageCache := service.NewUsageCache()
|
||||
accountUsageService := service.ProvideAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService, openAIGatewayService)
|
||||
accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService)
|
||||
accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService)
|
||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
||||
accountHandler := admin.ProvideAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
|
||||
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
|
||||
|
||||
+71
-2
@@ -85,8 +85,18 @@ type Group struct {
|
||||
VideoPrice720p *float64 `json:"video_price_720p,omitempty"`
|
||||
// VideoPrice1080p holds the value of the "video_price_1080p" field.
|
||||
VideoPrice1080p *float64 `json:"video_price_1080p,omitempty"`
|
||||
// 按模型族和分辨率覆盖视频每秒价格
|
||||
VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"`
|
||||
// Codex alpha/search 网页搜索单次价格(USD/次);nil 表示使用默认价 0.01(官方 $10/1000 次)
|
||||
WebSearchPricePerCall *float64 `json:"web_search_price_per_call,omitempty"`
|
||||
// 搜索工具价格 per 1000 calls(web_search 等)
|
||||
SearchPricePer1k *float64 `json:"search_price_per_1k,omitempty"`
|
||||
// Voice realtime 每分钟价格(USD)
|
||||
AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min,omitempty"`
|
||||
// TTS 每百万字符价格(USD)
|
||||
AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars,omitempty"`
|
||||
// STT 每小时价格(USD)
|
||||
AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour,omitempty"`
|
||||
// 是否仅允许 Claude Code 客户端
|
||||
ClaudeCodeOnly bool `json:"claude_code_only,omitempty"`
|
||||
// 非 Claude Code 请求降级使用的分组 ID
|
||||
@@ -235,11 +245,11 @@ func (*Group) scanValues(columns []string) ([]any, error) {
|
||||
values := make([]any, len(columns))
|
||||
for i := range columns {
|
||||
switch columns[i] {
|
||||
case group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings:
|
||||
case group.FieldVideoModelPrices, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings:
|
||||
values[i] = new([]byte)
|
||||
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled:
|
||||
values[i] = new(sql.NullBool)
|
||||
case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall, group.FieldProfitMinMargin, group.FieldProfitSafetyBuffer:
|
||||
case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall, group.FieldSearchPricePer1k, group.FieldAudioRealtimePricePerMin, group.FieldAudioTtsPricePerMillionChars, group.FieldAudioSttPricePerHour, group.FieldProfitMinMargin, group.FieldProfitSafetyBuffer:
|
||||
values[i] = new(sql.NullFloat64)
|
||||
case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit:
|
||||
values[i] = new(sql.NullInt64)
|
||||
@@ -478,6 +488,14 @@ func (_m *Group) assignValues(columns []string, values []any) error {
|
||||
_m.VideoPrice1080p = new(float64)
|
||||
*_m.VideoPrice1080p = value.Float64
|
||||
}
|
||||
case group.FieldVideoModelPrices:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field video_model_prices", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.VideoModelPrices); err != nil {
|
||||
return fmt.Errorf("unmarshal field video_model_prices: %w", err)
|
||||
}
|
||||
}
|
||||
case group.FieldWebSearchPricePerCall:
|
||||
if value, ok := values[i].(*sql.NullFloat64); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field web_search_price_per_call", values[i])
|
||||
@@ -485,6 +503,34 @@ func (_m *Group) assignValues(columns []string, values []any) error {
|
||||
_m.WebSearchPricePerCall = new(float64)
|
||||
*_m.WebSearchPricePerCall = value.Float64
|
||||
}
|
||||
case group.FieldSearchPricePer1k:
|
||||
if value, ok := values[i].(*sql.NullFloat64); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field search_price_per_1k", values[i])
|
||||
} else if value.Valid {
|
||||
_m.SearchPricePer1k = new(float64)
|
||||
*_m.SearchPricePer1k = value.Float64
|
||||
}
|
||||
case group.FieldAudioRealtimePricePerMin:
|
||||
if value, ok := values[i].(*sql.NullFloat64); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field audio_realtime_price_per_min", values[i])
|
||||
} else if value.Valid {
|
||||
_m.AudioRealtimePricePerMin = new(float64)
|
||||
*_m.AudioRealtimePricePerMin = value.Float64
|
||||
}
|
||||
case group.FieldAudioTtsPricePerMillionChars:
|
||||
if value, ok := values[i].(*sql.NullFloat64); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field audio_tts_price_per_million_chars", values[i])
|
||||
} else if value.Valid {
|
||||
_m.AudioTtsPricePerMillionChars = new(float64)
|
||||
*_m.AudioTtsPricePerMillionChars = value.Float64
|
||||
}
|
||||
case group.FieldAudioSttPricePerHour:
|
||||
if value, ok := values[i].(*sql.NullFloat64); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field audio_stt_price_per_hour", values[i])
|
||||
} else if value.Valid {
|
||||
_m.AudioSttPricePerHour = new(float64)
|
||||
*_m.AudioSttPricePerHour = value.Float64
|
||||
}
|
||||
case group.FieldClaudeCodeOnly:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field claude_code_only", values[i])
|
||||
@@ -822,11 +868,34 @@ func (_m *Group) String() string {
|
||||
builder.WriteString(fmt.Sprintf("%v", *v))
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("video_model_prices=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.VideoModelPrices))
|
||||
builder.WriteString(", ")
|
||||
if v := _m.WebSearchPricePerCall; v != nil {
|
||||
builder.WriteString("web_search_price_per_call=")
|
||||
builder.WriteString(fmt.Sprintf("%v", *v))
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
if v := _m.SearchPricePer1k; v != nil {
|
||||
builder.WriteString("search_price_per_1k=")
|
||||
builder.WriteString(fmt.Sprintf("%v", *v))
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
if v := _m.AudioRealtimePricePerMin; v != nil {
|
||||
builder.WriteString("audio_realtime_price_per_min=")
|
||||
builder.WriteString(fmt.Sprintf("%v", *v))
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
if v := _m.AudioTtsPricePerMillionChars; v != nil {
|
||||
builder.WriteString("audio_tts_price_per_million_chars=")
|
||||
builder.WriteString(fmt.Sprintf("%v", *v))
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
if v := _m.AudioSttPricePerHour; v != nil {
|
||||
builder.WriteString("audio_stt_price_per_hour=")
|
||||
builder.WriteString(fmt.Sprintf("%v", *v))
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("claude_code_only=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ClaudeCodeOnly))
|
||||
builder.WriteString(", ")
|
||||
|
||||
@@ -82,8 +82,18 @@ const (
|
||||
FieldVideoPrice720p = "video_price_720p"
|
||||
// FieldVideoPrice1080p holds the string denoting the video_price_1080p field in the database.
|
||||
FieldVideoPrice1080p = "video_price_1080p"
|
||||
// FieldVideoModelPrices holds the string denoting the video_model_prices field in the database.
|
||||
FieldVideoModelPrices = "video_model_prices"
|
||||
// FieldWebSearchPricePerCall holds the string denoting the web_search_price_per_call field in the database.
|
||||
FieldWebSearchPricePerCall = "web_search_price_per_call"
|
||||
// FieldSearchPricePer1k holds the string denoting the search_price_per_1k field in the database.
|
||||
FieldSearchPricePer1k = "search_price_per_1k"
|
||||
// FieldAudioRealtimePricePerMin holds the string denoting the audio_realtime_price_per_min field in the database.
|
||||
FieldAudioRealtimePricePerMin = "audio_realtime_price_per_min"
|
||||
// FieldAudioTtsPricePerMillionChars holds the string denoting the audio_tts_price_per_million_chars field in the database.
|
||||
FieldAudioTtsPricePerMillionChars = "audio_tts_price_per_million_chars"
|
||||
// FieldAudioSttPricePerHour holds the string denoting the audio_stt_price_per_hour field in the database.
|
||||
FieldAudioSttPricePerHour = "audio_stt_price_per_hour"
|
||||
// FieldClaudeCodeOnly holds the string denoting the claude_code_only field in the database.
|
||||
FieldClaudeCodeOnly = "claude_code_only"
|
||||
// FieldFallbackGroupID holds the string denoting the fallback_group_id field in the database.
|
||||
@@ -234,7 +244,12 @@ var Columns = []string{
|
||||
FieldVideoPrice480p,
|
||||
FieldVideoPrice720p,
|
||||
FieldVideoPrice1080p,
|
||||
FieldVideoModelPrices,
|
||||
FieldWebSearchPricePerCall,
|
||||
FieldSearchPricePer1k,
|
||||
FieldAudioRealtimePricePerMin,
|
||||
FieldAudioTtsPricePerMillionChars,
|
||||
FieldAudioSttPricePerHour,
|
||||
FieldClaudeCodeOnly,
|
||||
FieldFallbackGroupID,
|
||||
FieldFallbackGroupIDOnInvalidRequest,
|
||||
@@ -341,6 +356,14 @@ var (
|
||||
DefaultVideoRateIndependent bool
|
||||
// DefaultVideoRateMultiplier holds the default value on creation for the "video_rate_multiplier" field.
|
||||
DefaultVideoRateMultiplier float64
|
||||
// SearchPricePer1kValidator is a validator for the "search_price_per_1k" field. It is called by the builders before save.
|
||||
SearchPricePer1kValidator func(float64) error
|
||||
// AudioRealtimePricePerMinValidator is a validator for the "audio_realtime_price_per_min" field. It is called by the builders before save.
|
||||
AudioRealtimePricePerMinValidator func(float64) error
|
||||
// AudioTtsPricePerMillionCharsValidator is a validator for the "audio_tts_price_per_million_chars" field. It is called by the builders before save.
|
||||
AudioTtsPricePerMillionCharsValidator func(float64) error
|
||||
// AudioSttPricePerHourValidator is a validator for the "audio_stt_price_per_hour" field. It is called by the builders before save.
|
||||
AudioSttPricePerHourValidator func(float64) error
|
||||
// DefaultClaudeCodeOnly holds the default value on creation for the "claude_code_only" field.
|
||||
DefaultClaudeCodeOnly bool
|
||||
// DefaultModelRoutingEnabled holds the default value on creation for the "model_routing_enabled" field.
|
||||
@@ -561,6 +584,26 @@ func ByWebSearchPricePerCall(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldWebSearchPricePerCall, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// BySearchPricePer1k orders the results by the search_price_per_1k field.
|
||||
func BySearchPricePer1k(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldSearchPricePer1k, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByAudioRealtimePricePerMin orders the results by the audio_realtime_price_per_min field.
|
||||
func ByAudioRealtimePricePerMin(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldAudioRealtimePricePerMin, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByAudioTtsPricePerMillionChars orders the results by the audio_tts_price_per_million_chars field.
|
||||
func ByAudioTtsPricePerMillionChars(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldAudioTtsPricePerMillionChars, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByAudioSttPricePerHour orders the results by the audio_stt_price_per_hour field.
|
||||
func ByAudioSttPricePerHour(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldAudioSttPricePerHour, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByClaudeCodeOnly orders the results by the claude_code_only field.
|
||||
func ByClaudeCodeOnly(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldClaudeCodeOnly, opts...).ToFunc()
|
||||
|
||||
@@ -225,6 +225,26 @@ func WebSearchPricePerCall(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldWebSearchPricePerCall, v))
|
||||
}
|
||||
|
||||
// SearchPricePer1k applies equality check predicate on the "search_price_per_1k" field. It's identical to SearchPricePer1kEQ.
|
||||
func SearchPricePer1k(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldSearchPricePer1k, v))
|
||||
}
|
||||
|
||||
// AudioRealtimePricePerMin applies equality check predicate on the "audio_realtime_price_per_min" field. It's identical to AudioRealtimePricePerMinEQ.
|
||||
func AudioRealtimePricePerMin(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldAudioRealtimePricePerMin, v))
|
||||
}
|
||||
|
||||
// AudioTtsPricePerMillionChars applies equality check predicate on the "audio_tts_price_per_million_chars" field. It's identical to AudioTtsPricePerMillionCharsEQ.
|
||||
func AudioTtsPricePerMillionChars(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldAudioTtsPricePerMillionChars, v))
|
||||
}
|
||||
|
||||
// AudioSttPricePerHour applies equality check predicate on the "audio_stt_price_per_hour" field. It's identical to AudioSttPricePerHourEQ.
|
||||
func AudioSttPricePerHour(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldAudioSttPricePerHour, v))
|
||||
}
|
||||
|
||||
// ClaudeCodeOnly applies equality check predicate on the "claude_code_only" field. It's identical to ClaudeCodeOnlyEQ.
|
||||
func ClaudeCodeOnly(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldClaudeCodeOnly, v))
|
||||
@@ -1765,6 +1785,16 @@ func VideoPrice1080pNotNil() predicate.Group {
|
||||
return predicate.Group(sql.FieldNotNull(FieldVideoPrice1080p))
|
||||
}
|
||||
|
||||
// VideoModelPricesIsNil applies the IsNil predicate on the "video_model_prices" field.
|
||||
func VideoModelPricesIsNil() predicate.Group {
|
||||
return predicate.Group(sql.FieldIsNull(FieldVideoModelPrices))
|
||||
}
|
||||
|
||||
// VideoModelPricesNotNil applies the NotNil predicate on the "video_model_prices" field.
|
||||
func VideoModelPricesNotNil() predicate.Group {
|
||||
return predicate.Group(sql.FieldNotNull(FieldVideoModelPrices))
|
||||
}
|
||||
|
||||
// WebSearchPricePerCallEQ applies the EQ predicate on the "web_search_price_per_call" field.
|
||||
func WebSearchPricePerCallEQ(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldWebSearchPricePerCall, v))
|
||||
@@ -1815,6 +1845,206 @@ func WebSearchPricePerCallNotNil() predicate.Group {
|
||||
return predicate.Group(sql.FieldNotNull(FieldWebSearchPricePerCall))
|
||||
}
|
||||
|
||||
// SearchPricePer1kEQ applies the EQ predicate on the "search_price_per_1k" field.
|
||||
func SearchPricePer1kEQ(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldSearchPricePer1k, v))
|
||||
}
|
||||
|
||||
// SearchPricePer1kNEQ applies the NEQ predicate on the "search_price_per_1k" field.
|
||||
func SearchPricePer1kNEQ(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldSearchPricePer1k, v))
|
||||
}
|
||||
|
||||
// SearchPricePer1kIn applies the In predicate on the "search_price_per_1k" field.
|
||||
func SearchPricePer1kIn(vs ...float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldIn(FieldSearchPricePer1k, vs...))
|
||||
}
|
||||
|
||||
// SearchPricePer1kNotIn applies the NotIn predicate on the "search_price_per_1k" field.
|
||||
func SearchPricePer1kNotIn(vs ...float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldNotIn(FieldSearchPricePer1k, vs...))
|
||||
}
|
||||
|
||||
// SearchPricePer1kGT applies the GT predicate on the "search_price_per_1k" field.
|
||||
func SearchPricePer1kGT(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldGT(FieldSearchPricePer1k, v))
|
||||
}
|
||||
|
||||
// SearchPricePer1kGTE applies the GTE predicate on the "search_price_per_1k" field.
|
||||
func SearchPricePer1kGTE(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldGTE(FieldSearchPricePer1k, v))
|
||||
}
|
||||
|
||||
// SearchPricePer1kLT applies the LT predicate on the "search_price_per_1k" field.
|
||||
func SearchPricePer1kLT(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldLT(FieldSearchPricePer1k, v))
|
||||
}
|
||||
|
||||
// SearchPricePer1kLTE applies the LTE predicate on the "search_price_per_1k" field.
|
||||
func SearchPricePer1kLTE(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldLTE(FieldSearchPricePer1k, v))
|
||||
}
|
||||
|
||||
// SearchPricePer1kIsNil applies the IsNil predicate on the "search_price_per_1k" field.
|
||||
func SearchPricePer1kIsNil() predicate.Group {
|
||||
return predicate.Group(sql.FieldIsNull(FieldSearchPricePer1k))
|
||||
}
|
||||
|
||||
// SearchPricePer1kNotNil applies the NotNil predicate on the "search_price_per_1k" field.
|
||||
func SearchPricePer1kNotNil() predicate.Group {
|
||||
return predicate.Group(sql.FieldNotNull(FieldSearchPricePer1k))
|
||||
}
|
||||
|
||||
// AudioRealtimePricePerMinEQ applies the EQ predicate on the "audio_realtime_price_per_min" field.
|
||||
func AudioRealtimePricePerMinEQ(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldAudioRealtimePricePerMin, v))
|
||||
}
|
||||
|
||||
// AudioRealtimePricePerMinNEQ applies the NEQ predicate on the "audio_realtime_price_per_min" field.
|
||||
func AudioRealtimePricePerMinNEQ(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldAudioRealtimePricePerMin, v))
|
||||
}
|
||||
|
||||
// AudioRealtimePricePerMinIn applies the In predicate on the "audio_realtime_price_per_min" field.
|
||||
func AudioRealtimePricePerMinIn(vs ...float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldIn(FieldAudioRealtimePricePerMin, vs...))
|
||||
}
|
||||
|
||||
// AudioRealtimePricePerMinNotIn applies the NotIn predicate on the "audio_realtime_price_per_min" field.
|
||||
func AudioRealtimePricePerMinNotIn(vs ...float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldNotIn(FieldAudioRealtimePricePerMin, vs...))
|
||||
}
|
||||
|
||||
// AudioRealtimePricePerMinGT applies the GT predicate on the "audio_realtime_price_per_min" field.
|
||||
func AudioRealtimePricePerMinGT(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldGT(FieldAudioRealtimePricePerMin, v))
|
||||
}
|
||||
|
||||
// AudioRealtimePricePerMinGTE applies the GTE predicate on the "audio_realtime_price_per_min" field.
|
||||
func AudioRealtimePricePerMinGTE(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldGTE(FieldAudioRealtimePricePerMin, v))
|
||||
}
|
||||
|
||||
// AudioRealtimePricePerMinLT applies the LT predicate on the "audio_realtime_price_per_min" field.
|
||||
func AudioRealtimePricePerMinLT(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldLT(FieldAudioRealtimePricePerMin, v))
|
||||
}
|
||||
|
||||
// AudioRealtimePricePerMinLTE applies the LTE predicate on the "audio_realtime_price_per_min" field.
|
||||
func AudioRealtimePricePerMinLTE(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldLTE(FieldAudioRealtimePricePerMin, v))
|
||||
}
|
||||
|
||||
// AudioRealtimePricePerMinIsNil applies the IsNil predicate on the "audio_realtime_price_per_min" field.
|
||||
func AudioRealtimePricePerMinIsNil() predicate.Group {
|
||||
return predicate.Group(sql.FieldIsNull(FieldAudioRealtimePricePerMin))
|
||||
}
|
||||
|
||||
// AudioRealtimePricePerMinNotNil applies the NotNil predicate on the "audio_realtime_price_per_min" field.
|
||||
func AudioRealtimePricePerMinNotNil() predicate.Group {
|
||||
return predicate.Group(sql.FieldNotNull(FieldAudioRealtimePricePerMin))
|
||||
}
|
||||
|
||||
// AudioTtsPricePerMillionCharsEQ applies the EQ predicate on the "audio_tts_price_per_million_chars" field.
|
||||
func AudioTtsPricePerMillionCharsEQ(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldAudioTtsPricePerMillionChars, v))
|
||||
}
|
||||
|
||||
// AudioTtsPricePerMillionCharsNEQ applies the NEQ predicate on the "audio_tts_price_per_million_chars" field.
|
||||
func AudioTtsPricePerMillionCharsNEQ(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldAudioTtsPricePerMillionChars, v))
|
||||
}
|
||||
|
||||
// AudioTtsPricePerMillionCharsIn applies the In predicate on the "audio_tts_price_per_million_chars" field.
|
||||
func AudioTtsPricePerMillionCharsIn(vs ...float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldIn(FieldAudioTtsPricePerMillionChars, vs...))
|
||||
}
|
||||
|
||||
// AudioTtsPricePerMillionCharsNotIn applies the NotIn predicate on the "audio_tts_price_per_million_chars" field.
|
||||
func AudioTtsPricePerMillionCharsNotIn(vs ...float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldNotIn(FieldAudioTtsPricePerMillionChars, vs...))
|
||||
}
|
||||
|
||||
// AudioTtsPricePerMillionCharsGT applies the GT predicate on the "audio_tts_price_per_million_chars" field.
|
||||
func AudioTtsPricePerMillionCharsGT(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldGT(FieldAudioTtsPricePerMillionChars, v))
|
||||
}
|
||||
|
||||
// AudioTtsPricePerMillionCharsGTE applies the GTE predicate on the "audio_tts_price_per_million_chars" field.
|
||||
func AudioTtsPricePerMillionCharsGTE(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldGTE(FieldAudioTtsPricePerMillionChars, v))
|
||||
}
|
||||
|
||||
// AudioTtsPricePerMillionCharsLT applies the LT predicate on the "audio_tts_price_per_million_chars" field.
|
||||
func AudioTtsPricePerMillionCharsLT(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldLT(FieldAudioTtsPricePerMillionChars, v))
|
||||
}
|
||||
|
||||
// AudioTtsPricePerMillionCharsLTE applies the LTE predicate on the "audio_tts_price_per_million_chars" field.
|
||||
func AudioTtsPricePerMillionCharsLTE(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldLTE(FieldAudioTtsPricePerMillionChars, v))
|
||||
}
|
||||
|
||||
// AudioTtsPricePerMillionCharsIsNil applies the IsNil predicate on the "audio_tts_price_per_million_chars" field.
|
||||
func AudioTtsPricePerMillionCharsIsNil() predicate.Group {
|
||||
return predicate.Group(sql.FieldIsNull(FieldAudioTtsPricePerMillionChars))
|
||||
}
|
||||
|
||||
// AudioTtsPricePerMillionCharsNotNil applies the NotNil predicate on the "audio_tts_price_per_million_chars" field.
|
||||
func AudioTtsPricePerMillionCharsNotNil() predicate.Group {
|
||||
return predicate.Group(sql.FieldNotNull(FieldAudioTtsPricePerMillionChars))
|
||||
}
|
||||
|
||||
// AudioSttPricePerHourEQ applies the EQ predicate on the "audio_stt_price_per_hour" field.
|
||||
func AudioSttPricePerHourEQ(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldAudioSttPricePerHour, v))
|
||||
}
|
||||
|
||||
// AudioSttPricePerHourNEQ applies the NEQ predicate on the "audio_stt_price_per_hour" field.
|
||||
func AudioSttPricePerHourNEQ(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldAudioSttPricePerHour, v))
|
||||
}
|
||||
|
||||
// AudioSttPricePerHourIn applies the In predicate on the "audio_stt_price_per_hour" field.
|
||||
func AudioSttPricePerHourIn(vs ...float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldIn(FieldAudioSttPricePerHour, vs...))
|
||||
}
|
||||
|
||||
// AudioSttPricePerHourNotIn applies the NotIn predicate on the "audio_stt_price_per_hour" field.
|
||||
func AudioSttPricePerHourNotIn(vs ...float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldNotIn(FieldAudioSttPricePerHour, vs...))
|
||||
}
|
||||
|
||||
// AudioSttPricePerHourGT applies the GT predicate on the "audio_stt_price_per_hour" field.
|
||||
func AudioSttPricePerHourGT(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldGT(FieldAudioSttPricePerHour, v))
|
||||
}
|
||||
|
||||
// AudioSttPricePerHourGTE applies the GTE predicate on the "audio_stt_price_per_hour" field.
|
||||
func AudioSttPricePerHourGTE(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldGTE(FieldAudioSttPricePerHour, v))
|
||||
}
|
||||
|
||||
// AudioSttPricePerHourLT applies the LT predicate on the "audio_stt_price_per_hour" field.
|
||||
func AudioSttPricePerHourLT(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldLT(FieldAudioSttPricePerHour, v))
|
||||
}
|
||||
|
||||
// AudioSttPricePerHourLTE applies the LTE predicate on the "audio_stt_price_per_hour" field.
|
||||
func AudioSttPricePerHourLTE(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldLTE(FieldAudioSttPricePerHour, v))
|
||||
}
|
||||
|
||||
// AudioSttPricePerHourIsNil applies the IsNil predicate on the "audio_stt_price_per_hour" field.
|
||||
func AudioSttPricePerHourIsNil() predicate.Group {
|
||||
return predicate.Group(sql.FieldIsNull(FieldAudioSttPricePerHour))
|
||||
}
|
||||
|
||||
// AudioSttPricePerHourNotNil applies the NotNil predicate on the "audio_stt_price_per_hour" field.
|
||||
func AudioSttPricePerHourNotNil() predicate.Group {
|
||||
return predicate.Group(sql.FieldNotNull(FieldAudioSttPricePerHour))
|
||||
}
|
||||
|
||||
// ClaudeCodeOnlyEQ applies the EQ predicate on the "claude_code_only" field.
|
||||
func ClaudeCodeOnlyEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldClaudeCodeOnly, v))
|
||||
|
||||
@@ -483,6 +483,12 @@ func (_c *GroupCreate) SetNillableVideoPrice1080p(v *float64) *GroupCreate {
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetVideoModelPrices sets the "video_model_prices" field.
|
||||
func (_c *GroupCreate) SetVideoModelPrices(v map[string]map[string]float64) *GroupCreate {
|
||||
_c.mutation.SetVideoModelPrices(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
|
||||
func (_c *GroupCreate) SetWebSearchPricePerCall(v float64) *GroupCreate {
|
||||
_c.mutation.SetWebSearchPricePerCall(v)
|
||||
@@ -497,6 +503,62 @@ func (_c *GroupCreate) SetNillableWebSearchPricePerCall(v *float64) *GroupCreate
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetSearchPricePer1k sets the "search_price_per_1k" field.
|
||||
func (_c *GroupCreate) SetSearchPricePer1k(v float64) *GroupCreate {
|
||||
_c.mutation.SetSearchPricePer1k(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableSearchPricePer1k sets the "search_price_per_1k" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableSearchPricePer1k(v *float64) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetSearchPricePer1k(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field.
|
||||
func (_c *GroupCreate) SetAudioRealtimePricePerMin(v float64) *GroupCreate {
|
||||
_c.mutation.SetAudioRealtimePricePerMin(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableAudioRealtimePricePerMin(v *float64) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetAudioRealtimePricePerMin(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field.
|
||||
func (_c *GroupCreate) SetAudioTtsPricePerMillionChars(v float64) *GroupCreate {
|
||||
_c.mutation.SetAudioTtsPricePerMillionChars(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableAudioTtsPricePerMillionChars(v *float64) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetAudioTtsPricePerMillionChars(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field.
|
||||
func (_c *GroupCreate) SetAudioSttPricePerHour(v float64) *GroupCreate {
|
||||
_c.mutation.SetAudioSttPricePerHour(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableAudioSttPricePerHour sets the "audio_stt_price_per_hour" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableAudioSttPricePerHour(v *float64) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetAudioSttPricePerHour(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetClaudeCodeOnly sets the "claude_code_only" field.
|
||||
func (_c *GroupCreate) SetClaudeCodeOnly(v bool) *GroupCreate {
|
||||
_c.mutation.SetClaudeCodeOnly(v)
|
||||
@@ -1155,6 +1217,26 @@ func (_c *GroupCreate) check() error {
|
||||
if _, ok := _c.mutation.VideoRateMultiplier(); !ok {
|
||||
return &ValidationError{Name: "video_rate_multiplier", err: errors.New(`ent: missing required field "Group.video_rate_multiplier"`)}
|
||||
}
|
||||
if v, ok := _c.mutation.SearchPricePer1k(); ok {
|
||||
if err := group.SearchPricePer1kValidator(v); err != nil {
|
||||
return &ValidationError{Name: "search_price_per_1k", err: fmt.Errorf(`ent: validator failed for field "Group.search_price_per_1k": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _c.mutation.AudioRealtimePricePerMin(); ok {
|
||||
if err := group.AudioRealtimePricePerMinValidator(v); err != nil {
|
||||
return &ValidationError{Name: "audio_realtime_price_per_min", err: fmt.Errorf(`ent: validator failed for field "Group.audio_realtime_price_per_min": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _c.mutation.AudioTtsPricePerMillionChars(); ok {
|
||||
if err := group.AudioTtsPricePerMillionCharsValidator(v); err != nil {
|
||||
return &ValidationError{Name: "audio_tts_price_per_million_chars", err: fmt.Errorf(`ent: validator failed for field "Group.audio_tts_price_per_million_chars": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _c.mutation.AudioSttPricePerHour(); ok {
|
||||
if err := group.AudioSttPricePerHourValidator(v); err != nil {
|
||||
return &ValidationError{Name: "audio_stt_price_per_hour", err: fmt.Errorf(`ent: validator failed for field "Group.audio_stt_price_per_hour": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.ClaudeCodeOnly(); !ok {
|
||||
return &ValidationError{Name: "claude_code_only", err: errors.New(`ent: missing required field "Group.claude_code_only"`)}
|
||||
}
|
||||
@@ -1378,10 +1460,30 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(group.FieldVideoPrice1080p, field.TypeFloat64, value)
|
||||
_node.VideoPrice1080p = &value
|
||||
}
|
||||
if value, ok := _c.mutation.VideoModelPrices(); ok {
|
||||
_spec.SetField(group.FieldVideoModelPrices, field.TypeJSON, value)
|
||||
_node.VideoModelPrices = value
|
||||
}
|
||||
if value, ok := _c.mutation.WebSearchPricePerCall(); ok {
|
||||
_spec.SetField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value)
|
||||
_node.WebSearchPricePerCall = &value
|
||||
}
|
||||
if value, ok := _c.mutation.SearchPricePer1k(); ok {
|
||||
_spec.SetField(group.FieldSearchPricePer1k, field.TypeFloat64, value)
|
||||
_node.SearchPricePer1k = &value
|
||||
}
|
||||
if value, ok := _c.mutation.AudioRealtimePricePerMin(); ok {
|
||||
_spec.SetField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64, value)
|
||||
_node.AudioRealtimePricePerMin = &value
|
||||
}
|
||||
if value, ok := _c.mutation.AudioTtsPricePerMillionChars(); ok {
|
||||
_spec.SetField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64, value)
|
||||
_node.AudioTtsPricePerMillionChars = &value
|
||||
}
|
||||
if value, ok := _c.mutation.AudioSttPricePerHour(); ok {
|
||||
_spec.SetField(group.FieldAudioSttPricePerHour, field.TypeFloat64, value)
|
||||
_node.AudioSttPricePerHour = &value
|
||||
}
|
||||
if value, ok := _c.mutation.ClaudeCodeOnly(); ok {
|
||||
_spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value)
|
||||
_node.ClaudeCodeOnly = value
|
||||
@@ -2156,6 +2258,24 @@ func (u *GroupUpsert) ClearVideoPrice1080p() *GroupUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetVideoModelPrices sets the "video_model_prices" field.
|
||||
func (u *GroupUpsert) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpsert {
|
||||
u.Set(group.FieldVideoModelPrices, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateVideoModelPrices sets the "video_model_prices" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateVideoModelPrices() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldVideoModelPrices)
|
||||
return u
|
||||
}
|
||||
|
||||
// ClearVideoModelPrices clears the value of the "video_model_prices" field.
|
||||
func (u *GroupUpsert) ClearVideoModelPrices() *GroupUpsert {
|
||||
u.SetNull(group.FieldVideoModelPrices)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
|
||||
func (u *GroupUpsert) SetWebSearchPricePerCall(v float64) *GroupUpsert {
|
||||
u.Set(group.FieldWebSearchPricePerCall, v)
|
||||
@@ -2180,6 +2300,102 @@ func (u *GroupUpsert) ClearWebSearchPricePerCall() *GroupUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetSearchPricePer1k sets the "search_price_per_1k" field.
|
||||
func (u *GroupUpsert) SetSearchPricePer1k(v float64) *GroupUpsert {
|
||||
u.Set(group.FieldSearchPricePer1k, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateSearchPricePer1k sets the "search_price_per_1k" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateSearchPricePer1k() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldSearchPricePer1k)
|
||||
return u
|
||||
}
|
||||
|
||||
// AddSearchPricePer1k adds v to the "search_price_per_1k" field.
|
||||
func (u *GroupUpsert) AddSearchPricePer1k(v float64) *GroupUpsert {
|
||||
u.Add(group.FieldSearchPricePer1k, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field.
|
||||
func (u *GroupUpsert) ClearSearchPricePer1k() *GroupUpsert {
|
||||
u.SetNull(group.FieldSearchPricePer1k)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field.
|
||||
func (u *GroupUpsert) SetAudioRealtimePricePerMin(v float64) *GroupUpsert {
|
||||
u.Set(group.FieldAudioRealtimePricePerMin, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateAudioRealtimePricePerMin() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldAudioRealtimePricePerMin)
|
||||
return u
|
||||
}
|
||||
|
||||
// AddAudioRealtimePricePerMin adds v to the "audio_realtime_price_per_min" field.
|
||||
func (u *GroupUpsert) AddAudioRealtimePricePerMin(v float64) *GroupUpsert {
|
||||
u.Add(group.FieldAudioRealtimePricePerMin, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// ClearAudioRealtimePricePerMin clears the value of the "audio_realtime_price_per_min" field.
|
||||
func (u *GroupUpsert) ClearAudioRealtimePricePerMin() *GroupUpsert {
|
||||
u.SetNull(group.FieldAudioRealtimePricePerMin)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field.
|
||||
func (u *GroupUpsert) SetAudioTtsPricePerMillionChars(v float64) *GroupUpsert {
|
||||
u.Set(group.FieldAudioTtsPricePerMillionChars, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateAudioTtsPricePerMillionChars() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldAudioTtsPricePerMillionChars)
|
||||
return u
|
||||
}
|
||||
|
||||
// AddAudioTtsPricePerMillionChars adds v to the "audio_tts_price_per_million_chars" field.
|
||||
func (u *GroupUpsert) AddAudioTtsPricePerMillionChars(v float64) *GroupUpsert {
|
||||
u.Add(group.FieldAudioTtsPricePerMillionChars, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// ClearAudioTtsPricePerMillionChars clears the value of the "audio_tts_price_per_million_chars" field.
|
||||
func (u *GroupUpsert) ClearAudioTtsPricePerMillionChars() *GroupUpsert {
|
||||
u.SetNull(group.FieldAudioTtsPricePerMillionChars)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field.
|
||||
func (u *GroupUpsert) SetAudioSttPricePerHour(v float64) *GroupUpsert {
|
||||
u.Set(group.FieldAudioSttPricePerHour, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateAudioSttPricePerHour sets the "audio_stt_price_per_hour" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateAudioSttPricePerHour() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldAudioSttPricePerHour)
|
||||
return u
|
||||
}
|
||||
|
||||
// AddAudioSttPricePerHour adds v to the "audio_stt_price_per_hour" field.
|
||||
func (u *GroupUpsert) AddAudioSttPricePerHour(v float64) *GroupUpsert {
|
||||
u.Add(group.FieldAudioSttPricePerHour, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// ClearAudioSttPricePerHour clears the value of the "audio_stt_price_per_hour" field.
|
||||
func (u *GroupUpsert) ClearAudioSttPricePerHour() *GroupUpsert {
|
||||
u.SetNull(group.FieldAudioSttPricePerHour)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetClaudeCodeOnly sets the "claude_code_only" field.
|
||||
func (u *GroupUpsert) SetClaudeCodeOnly(v bool) *GroupUpsert {
|
||||
u.Set(group.FieldClaudeCodeOnly, v)
|
||||
@@ -3157,6 +3373,27 @@ func (u *GroupUpsertOne) ClearVideoPrice1080p() *GroupUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetVideoModelPrices sets the "video_model_prices" field.
|
||||
func (u *GroupUpsertOne) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetVideoModelPrices(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateVideoModelPrices sets the "video_model_prices" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateVideoModelPrices() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateVideoModelPrices()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearVideoModelPrices clears the value of the "video_model_prices" field.
|
||||
func (u *GroupUpsertOne) ClearVideoModelPrices() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.ClearVideoModelPrices()
|
||||
})
|
||||
}
|
||||
|
||||
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
|
||||
func (u *GroupUpsertOne) SetWebSearchPricePerCall(v float64) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
@@ -3185,6 +3422,118 @@ func (u *GroupUpsertOne) ClearWebSearchPricePerCall() *GroupUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetSearchPricePer1k sets the "search_price_per_1k" field.
|
||||
func (u *GroupUpsertOne) SetSearchPricePer1k(v float64) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetSearchPricePer1k(v)
|
||||
})
|
||||
}
|
||||
|
||||
// AddSearchPricePer1k adds v to the "search_price_per_1k" field.
|
||||
func (u *GroupUpsertOne) AddSearchPricePer1k(v float64) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.AddSearchPricePer1k(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateSearchPricePer1k sets the "search_price_per_1k" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateSearchPricePer1k() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateSearchPricePer1k()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field.
|
||||
func (u *GroupUpsertOne) ClearSearchPricePer1k() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.ClearSearchPricePer1k()
|
||||
})
|
||||
}
|
||||
|
||||
// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field.
|
||||
func (u *GroupUpsertOne) SetAudioRealtimePricePerMin(v float64) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetAudioRealtimePricePerMin(v)
|
||||
})
|
||||
}
|
||||
|
||||
// AddAudioRealtimePricePerMin adds v to the "audio_realtime_price_per_min" field.
|
||||
func (u *GroupUpsertOne) AddAudioRealtimePricePerMin(v float64) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.AddAudioRealtimePricePerMin(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateAudioRealtimePricePerMin() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateAudioRealtimePricePerMin()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearAudioRealtimePricePerMin clears the value of the "audio_realtime_price_per_min" field.
|
||||
func (u *GroupUpsertOne) ClearAudioRealtimePricePerMin() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.ClearAudioRealtimePricePerMin()
|
||||
})
|
||||
}
|
||||
|
||||
// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field.
|
||||
func (u *GroupUpsertOne) SetAudioTtsPricePerMillionChars(v float64) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetAudioTtsPricePerMillionChars(v)
|
||||
})
|
||||
}
|
||||
|
||||
// AddAudioTtsPricePerMillionChars adds v to the "audio_tts_price_per_million_chars" field.
|
||||
func (u *GroupUpsertOne) AddAudioTtsPricePerMillionChars(v float64) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.AddAudioTtsPricePerMillionChars(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateAudioTtsPricePerMillionChars() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateAudioTtsPricePerMillionChars()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearAudioTtsPricePerMillionChars clears the value of the "audio_tts_price_per_million_chars" field.
|
||||
func (u *GroupUpsertOne) ClearAudioTtsPricePerMillionChars() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.ClearAudioTtsPricePerMillionChars()
|
||||
})
|
||||
}
|
||||
|
||||
// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field.
|
||||
func (u *GroupUpsertOne) SetAudioSttPricePerHour(v float64) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetAudioSttPricePerHour(v)
|
||||
})
|
||||
}
|
||||
|
||||
// AddAudioSttPricePerHour adds v to the "audio_stt_price_per_hour" field.
|
||||
func (u *GroupUpsertOne) AddAudioSttPricePerHour(v float64) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.AddAudioSttPricePerHour(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateAudioSttPricePerHour sets the "audio_stt_price_per_hour" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateAudioSttPricePerHour() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateAudioSttPricePerHour()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearAudioSttPricePerHour clears the value of the "audio_stt_price_per_hour" field.
|
||||
func (u *GroupUpsertOne) ClearAudioSttPricePerHour() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.ClearAudioSttPricePerHour()
|
||||
})
|
||||
}
|
||||
|
||||
// SetClaudeCodeOnly sets the "claude_code_only" field.
|
||||
func (u *GroupUpsertOne) SetClaudeCodeOnly(v bool) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
@@ -4379,6 +4728,27 @@ func (u *GroupUpsertBulk) ClearVideoPrice1080p() *GroupUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetVideoModelPrices sets the "video_model_prices" field.
|
||||
func (u *GroupUpsertBulk) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetVideoModelPrices(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateVideoModelPrices sets the "video_model_prices" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateVideoModelPrices() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateVideoModelPrices()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearVideoModelPrices clears the value of the "video_model_prices" field.
|
||||
func (u *GroupUpsertBulk) ClearVideoModelPrices() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.ClearVideoModelPrices()
|
||||
})
|
||||
}
|
||||
|
||||
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
|
||||
func (u *GroupUpsertBulk) SetWebSearchPricePerCall(v float64) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
@@ -4407,6 +4777,118 @@ func (u *GroupUpsertBulk) ClearWebSearchPricePerCall() *GroupUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetSearchPricePer1k sets the "search_price_per_1k" field.
|
||||
func (u *GroupUpsertBulk) SetSearchPricePer1k(v float64) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetSearchPricePer1k(v)
|
||||
})
|
||||
}
|
||||
|
||||
// AddSearchPricePer1k adds v to the "search_price_per_1k" field.
|
||||
func (u *GroupUpsertBulk) AddSearchPricePer1k(v float64) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.AddSearchPricePer1k(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateSearchPricePer1k sets the "search_price_per_1k" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateSearchPricePer1k() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateSearchPricePer1k()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field.
|
||||
func (u *GroupUpsertBulk) ClearSearchPricePer1k() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.ClearSearchPricePer1k()
|
||||
})
|
||||
}
|
||||
|
||||
// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field.
|
||||
func (u *GroupUpsertBulk) SetAudioRealtimePricePerMin(v float64) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetAudioRealtimePricePerMin(v)
|
||||
})
|
||||
}
|
||||
|
||||
// AddAudioRealtimePricePerMin adds v to the "audio_realtime_price_per_min" field.
|
||||
func (u *GroupUpsertBulk) AddAudioRealtimePricePerMin(v float64) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.AddAudioRealtimePricePerMin(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateAudioRealtimePricePerMin() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateAudioRealtimePricePerMin()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearAudioRealtimePricePerMin clears the value of the "audio_realtime_price_per_min" field.
|
||||
func (u *GroupUpsertBulk) ClearAudioRealtimePricePerMin() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.ClearAudioRealtimePricePerMin()
|
||||
})
|
||||
}
|
||||
|
||||
// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field.
|
||||
func (u *GroupUpsertBulk) SetAudioTtsPricePerMillionChars(v float64) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetAudioTtsPricePerMillionChars(v)
|
||||
})
|
||||
}
|
||||
|
||||
// AddAudioTtsPricePerMillionChars adds v to the "audio_tts_price_per_million_chars" field.
|
||||
func (u *GroupUpsertBulk) AddAudioTtsPricePerMillionChars(v float64) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.AddAudioTtsPricePerMillionChars(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateAudioTtsPricePerMillionChars() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateAudioTtsPricePerMillionChars()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearAudioTtsPricePerMillionChars clears the value of the "audio_tts_price_per_million_chars" field.
|
||||
func (u *GroupUpsertBulk) ClearAudioTtsPricePerMillionChars() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.ClearAudioTtsPricePerMillionChars()
|
||||
})
|
||||
}
|
||||
|
||||
// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field.
|
||||
func (u *GroupUpsertBulk) SetAudioSttPricePerHour(v float64) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetAudioSttPricePerHour(v)
|
||||
})
|
||||
}
|
||||
|
||||
// AddAudioSttPricePerHour adds v to the "audio_stt_price_per_hour" field.
|
||||
func (u *GroupUpsertBulk) AddAudioSttPricePerHour(v float64) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.AddAudioSttPricePerHour(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateAudioSttPricePerHour sets the "audio_stt_price_per_hour" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateAudioSttPricePerHour() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateAudioSttPricePerHour()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearAudioSttPricePerHour clears the value of the "audio_stt_price_per_hour" field.
|
||||
func (u *GroupUpsertBulk) ClearAudioSttPricePerHour() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.ClearAudioSttPricePerHour()
|
||||
})
|
||||
}
|
||||
|
||||
// SetClaudeCodeOnly sets the "claude_code_only" field.
|
||||
func (u *GroupUpsertBulk) SetClaudeCodeOnly(v bool) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
|
||||
@@ -640,6 +640,18 @@ func (_u *GroupUpdate) ClearVideoPrice1080p() *GroupUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetVideoModelPrices sets the "video_model_prices" field.
|
||||
func (_u *GroupUpdate) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpdate {
|
||||
_u.mutation.SetVideoModelPrices(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearVideoModelPrices clears the value of the "video_model_prices" field.
|
||||
func (_u *GroupUpdate) ClearVideoModelPrices() *GroupUpdate {
|
||||
_u.mutation.ClearVideoModelPrices()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
|
||||
func (_u *GroupUpdate) SetWebSearchPricePerCall(v float64) *GroupUpdate {
|
||||
_u.mutation.ResetWebSearchPricePerCall()
|
||||
@@ -667,6 +679,114 @@ func (_u *GroupUpdate) ClearWebSearchPricePerCall() *GroupUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetSearchPricePer1k sets the "search_price_per_1k" field.
|
||||
func (_u *GroupUpdate) SetSearchPricePer1k(v float64) *GroupUpdate {
|
||||
_u.mutation.ResetSearchPricePer1k()
|
||||
_u.mutation.SetSearchPricePer1k(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableSearchPricePer1k sets the "search_price_per_1k" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableSearchPricePer1k(v *float64) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetSearchPricePer1k(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddSearchPricePer1k adds value to the "search_price_per_1k" field.
|
||||
func (_u *GroupUpdate) AddSearchPricePer1k(v float64) *GroupUpdate {
|
||||
_u.mutation.AddSearchPricePer1k(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field.
|
||||
func (_u *GroupUpdate) ClearSearchPricePer1k() *GroupUpdate {
|
||||
_u.mutation.ClearSearchPricePer1k()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field.
|
||||
func (_u *GroupUpdate) SetAudioRealtimePricePerMin(v float64) *GroupUpdate {
|
||||
_u.mutation.ResetAudioRealtimePricePerMin()
|
||||
_u.mutation.SetAudioRealtimePricePerMin(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableAudioRealtimePricePerMin(v *float64) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetAudioRealtimePricePerMin(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddAudioRealtimePricePerMin adds value to the "audio_realtime_price_per_min" field.
|
||||
func (_u *GroupUpdate) AddAudioRealtimePricePerMin(v float64) *GroupUpdate {
|
||||
_u.mutation.AddAudioRealtimePricePerMin(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearAudioRealtimePricePerMin clears the value of the "audio_realtime_price_per_min" field.
|
||||
func (_u *GroupUpdate) ClearAudioRealtimePricePerMin() *GroupUpdate {
|
||||
_u.mutation.ClearAudioRealtimePricePerMin()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field.
|
||||
func (_u *GroupUpdate) SetAudioTtsPricePerMillionChars(v float64) *GroupUpdate {
|
||||
_u.mutation.ResetAudioTtsPricePerMillionChars()
|
||||
_u.mutation.SetAudioTtsPricePerMillionChars(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableAudioTtsPricePerMillionChars(v *float64) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetAudioTtsPricePerMillionChars(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddAudioTtsPricePerMillionChars adds value to the "audio_tts_price_per_million_chars" field.
|
||||
func (_u *GroupUpdate) AddAudioTtsPricePerMillionChars(v float64) *GroupUpdate {
|
||||
_u.mutation.AddAudioTtsPricePerMillionChars(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearAudioTtsPricePerMillionChars clears the value of the "audio_tts_price_per_million_chars" field.
|
||||
func (_u *GroupUpdate) ClearAudioTtsPricePerMillionChars() *GroupUpdate {
|
||||
_u.mutation.ClearAudioTtsPricePerMillionChars()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field.
|
||||
func (_u *GroupUpdate) SetAudioSttPricePerHour(v float64) *GroupUpdate {
|
||||
_u.mutation.ResetAudioSttPricePerHour()
|
||||
_u.mutation.SetAudioSttPricePerHour(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableAudioSttPricePerHour sets the "audio_stt_price_per_hour" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableAudioSttPricePerHour(v *float64) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetAudioSttPricePerHour(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddAudioSttPricePerHour adds value to the "audio_stt_price_per_hour" field.
|
||||
func (_u *GroupUpdate) AddAudioSttPricePerHour(v float64) *GroupUpdate {
|
||||
_u.mutation.AddAudioSttPricePerHour(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearAudioSttPricePerHour clears the value of the "audio_stt_price_per_hour" field.
|
||||
func (_u *GroupUpdate) ClearAudioSttPricePerHour() *GroupUpdate {
|
||||
_u.mutation.ClearAudioSttPricePerHour()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetClaudeCodeOnly sets the "claude_code_only" field.
|
||||
func (_u *GroupUpdate) SetClaudeCodeOnly(v bool) *GroupUpdate {
|
||||
_u.mutation.SetClaudeCodeOnly(v)
|
||||
@@ -1304,6 +1424,26 @@ func (_u *GroupUpdate) check() error {
|
||||
return &ValidationError{Name: "subscription_type", err: fmt.Errorf(`ent: validator failed for field "Group.subscription_type": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.SearchPricePer1k(); ok {
|
||||
if err := group.SearchPricePer1kValidator(v); err != nil {
|
||||
return &ValidationError{Name: "search_price_per_1k", err: fmt.Errorf(`ent: validator failed for field "Group.search_price_per_1k": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.AudioRealtimePricePerMin(); ok {
|
||||
if err := group.AudioRealtimePricePerMinValidator(v); err != nil {
|
||||
return &ValidationError{Name: "audio_realtime_price_per_min", err: fmt.Errorf(`ent: validator failed for field "Group.audio_realtime_price_per_min": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.AudioTtsPricePerMillionChars(); ok {
|
||||
if err := group.AudioTtsPricePerMillionCharsValidator(v); err != nil {
|
||||
return &ValidationError{Name: "audio_tts_price_per_million_chars", err: fmt.Errorf(`ent: validator failed for field "Group.audio_tts_price_per_million_chars": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.AudioSttPricePerHour(); ok {
|
||||
if err := group.AudioSttPricePerHourValidator(v); err != nil {
|
||||
return &ValidationError{Name: "audio_stt_price_per_hour", err: fmt.Errorf(`ent: validator failed for field "Group.audio_stt_price_per_hour": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.DefaultMappedModel(); ok {
|
||||
if err := group.DefaultMappedModelValidator(v); err != nil {
|
||||
return &ValidationError{Name: "default_mapped_model", err: fmt.Errorf(`ent: validator failed for field "Group.default_mapped_model": %w`, err)}
|
||||
@@ -1506,6 +1646,12 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if _u.mutation.VideoPrice1080pCleared() {
|
||||
_spec.ClearField(group.FieldVideoPrice1080p, field.TypeFloat64)
|
||||
}
|
||||
if value, ok := _u.mutation.VideoModelPrices(); ok {
|
||||
_spec.SetField(group.FieldVideoModelPrices, field.TypeJSON, value)
|
||||
}
|
||||
if _u.mutation.VideoModelPricesCleared() {
|
||||
_spec.ClearField(group.FieldVideoModelPrices, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.WebSearchPricePerCall(); ok {
|
||||
_spec.SetField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value)
|
||||
}
|
||||
@@ -1515,6 +1661,42 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if _u.mutation.WebSearchPricePerCallCleared() {
|
||||
_spec.ClearField(group.FieldWebSearchPricePerCall, field.TypeFloat64)
|
||||
}
|
||||
if value, ok := _u.mutation.SearchPricePer1k(); ok {
|
||||
_spec.SetField(group.FieldSearchPricePer1k, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AddedSearchPricePer1k(); ok {
|
||||
_spec.AddField(group.FieldSearchPricePer1k, field.TypeFloat64, value)
|
||||
}
|
||||
if _u.mutation.SearchPricePer1kCleared() {
|
||||
_spec.ClearField(group.FieldSearchPricePer1k, field.TypeFloat64)
|
||||
}
|
||||
if value, ok := _u.mutation.AudioRealtimePricePerMin(); ok {
|
||||
_spec.SetField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AddedAudioRealtimePricePerMin(); ok {
|
||||
_spec.AddField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64, value)
|
||||
}
|
||||
if _u.mutation.AudioRealtimePricePerMinCleared() {
|
||||
_spec.ClearField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64)
|
||||
}
|
||||
if value, ok := _u.mutation.AudioTtsPricePerMillionChars(); ok {
|
||||
_spec.SetField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AddedAudioTtsPricePerMillionChars(); ok {
|
||||
_spec.AddField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64, value)
|
||||
}
|
||||
if _u.mutation.AudioTtsPricePerMillionCharsCleared() {
|
||||
_spec.ClearField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64)
|
||||
}
|
||||
if value, ok := _u.mutation.AudioSttPricePerHour(); ok {
|
||||
_spec.SetField(group.FieldAudioSttPricePerHour, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AddedAudioSttPricePerHour(); ok {
|
||||
_spec.AddField(group.FieldAudioSttPricePerHour, field.TypeFloat64, value)
|
||||
}
|
||||
if _u.mutation.AudioSttPricePerHourCleared() {
|
||||
_spec.ClearField(group.FieldAudioSttPricePerHour, field.TypeFloat64)
|
||||
}
|
||||
if value, ok := _u.mutation.ClaudeCodeOnly(); ok {
|
||||
_spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value)
|
||||
}
|
||||
@@ -2533,6 +2715,18 @@ func (_u *GroupUpdateOne) ClearVideoPrice1080p() *GroupUpdateOne {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetVideoModelPrices sets the "video_model_prices" field.
|
||||
func (_u *GroupUpdateOne) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpdateOne {
|
||||
_u.mutation.SetVideoModelPrices(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearVideoModelPrices clears the value of the "video_model_prices" field.
|
||||
func (_u *GroupUpdateOne) ClearVideoModelPrices() *GroupUpdateOne {
|
||||
_u.mutation.ClearVideoModelPrices()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
|
||||
func (_u *GroupUpdateOne) SetWebSearchPricePerCall(v float64) *GroupUpdateOne {
|
||||
_u.mutation.ResetWebSearchPricePerCall()
|
||||
@@ -2560,6 +2754,114 @@ func (_u *GroupUpdateOne) ClearWebSearchPricePerCall() *GroupUpdateOne {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetSearchPricePer1k sets the "search_price_per_1k" field.
|
||||
func (_u *GroupUpdateOne) SetSearchPricePer1k(v float64) *GroupUpdateOne {
|
||||
_u.mutation.ResetSearchPricePer1k()
|
||||
_u.mutation.SetSearchPricePer1k(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableSearchPricePer1k sets the "search_price_per_1k" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableSearchPricePer1k(v *float64) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetSearchPricePer1k(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddSearchPricePer1k adds value to the "search_price_per_1k" field.
|
||||
func (_u *GroupUpdateOne) AddSearchPricePer1k(v float64) *GroupUpdateOne {
|
||||
_u.mutation.AddSearchPricePer1k(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field.
|
||||
func (_u *GroupUpdateOne) ClearSearchPricePer1k() *GroupUpdateOne {
|
||||
_u.mutation.ClearSearchPricePer1k()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field.
|
||||
func (_u *GroupUpdateOne) SetAudioRealtimePricePerMin(v float64) *GroupUpdateOne {
|
||||
_u.mutation.ResetAudioRealtimePricePerMin()
|
||||
_u.mutation.SetAudioRealtimePricePerMin(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableAudioRealtimePricePerMin(v *float64) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetAudioRealtimePricePerMin(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddAudioRealtimePricePerMin adds value to the "audio_realtime_price_per_min" field.
|
||||
func (_u *GroupUpdateOne) AddAudioRealtimePricePerMin(v float64) *GroupUpdateOne {
|
||||
_u.mutation.AddAudioRealtimePricePerMin(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearAudioRealtimePricePerMin clears the value of the "audio_realtime_price_per_min" field.
|
||||
func (_u *GroupUpdateOne) ClearAudioRealtimePricePerMin() *GroupUpdateOne {
|
||||
_u.mutation.ClearAudioRealtimePricePerMin()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field.
|
||||
func (_u *GroupUpdateOne) SetAudioTtsPricePerMillionChars(v float64) *GroupUpdateOne {
|
||||
_u.mutation.ResetAudioTtsPricePerMillionChars()
|
||||
_u.mutation.SetAudioTtsPricePerMillionChars(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableAudioTtsPricePerMillionChars(v *float64) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetAudioTtsPricePerMillionChars(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddAudioTtsPricePerMillionChars adds value to the "audio_tts_price_per_million_chars" field.
|
||||
func (_u *GroupUpdateOne) AddAudioTtsPricePerMillionChars(v float64) *GroupUpdateOne {
|
||||
_u.mutation.AddAudioTtsPricePerMillionChars(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearAudioTtsPricePerMillionChars clears the value of the "audio_tts_price_per_million_chars" field.
|
||||
func (_u *GroupUpdateOne) ClearAudioTtsPricePerMillionChars() *GroupUpdateOne {
|
||||
_u.mutation.ClearAudioTtsPricePerMillionChars()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field.
|
||||
func (_u *GroupUpdateOne) SetAudioSttPricePerHour(v float64) *GroupUpdateOne {
|
||||
_u.mutation.ResetAudioSttPricePerHour()
|
||||
_u.mutation.SetAudioSttPricePerHour(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableAudioSttPricePerHour sets the "audio_stt_price_per_hour" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableAudioSttPricePerHour(v *float64) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetAudioSttPricePerHour(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddAudioSttPricePerHour adds value to the "audio_stt_price_per_hour" field.
|
||||
func (_u *GroupUpdateOne) AddAudioSttPricePerHour(v float64) *GroupUpdateOne {
|
||||
_u.mutation.AddAudioSttPricePerHour(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearAudioSttPricePerHour clears the value of the "audio_stt_price_per_hour" field.
|
||||
func (_u *GroupUpdateOne) ClearAudioSttPricePerHour() *GroupUpdateOne {
|
||||
_u.mutation.ClearAudioSttPricePerHour()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetClaudeCodeOnly sets the "claude_code_only" field.
|
||||
func (_u *GroupUpdateOne) SetClaudeCodeOnly(v bool) *GroupUpdateOne {
|
||||
_u.mutation.SetClaudeCodeOnly(v)
|
||||
@@ -3210,6 +3512,26 @@ func (_u *GroupUpdateOne) check() error {
|
||||
return &ValidationError{Name: "subscription_type", err: fmt.Errorf(`ent: validator failed for field "Group.subscription_type": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.SearchPricePer1k(); ok {
|
||||
if err := group.SearchPricePer1kValidator(v); err != nil {
|
||||
return &ValidationError{Name: "search_price_per_1k", err: fmt.Errorf(`ent: validator failed for field "Group.search_price_per_1k": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.AudioRealtimePricePerMin(); ok {
|
||||
if err := group.AudioRealtimePricePerMinValidator(v); err != nil {
|
||||
return &ValidationError{Name: "audio_realtime_price_per_min", err: fmt.Errorf(`ent: validator failed for field "Group.audio_realtime_price_per_min": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.AudioTtsPricePerMillionChars(); ok {
|
||||
if err := group.AudioTtsPricePerMillionCharsValidator(v); err != nil {
|
||||
return &ValidationError{Name: "audio_tts_price_per_million_chars", err: fmt.Errorf(`ent: validator failed for field "Group.audio_tts_price_per_million_chars": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.AudioSttPricePerHour(); ok {
|
||||
if err := group.AudioSttPricePerHourValidator(v); err != nil {
|
||||
return &ValidationError{Name: "audio_stt_price_per_hour", err: fmt.Errorf(`ent: validator failed for field "Group.audio_stt_price_per_hour": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.DefaultMappedModel(); ok {
|
||||
if err := group.DefaultMappedModelValidator(v); err != nil {
|
||||
return &ValidationError{Name: "default_mapped_model", err: fmt.Errorf(`ent: validator failed for field "Group.default_mapped_model": %w`, err)}
|
||||
@@ -3429,6 +3751,12 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
|
||||
if _u.mutation.VideoPrice1080pCleared() {
|
||||
_spec.ClearField(group.FieldVideoPrice1080p, field.TypeFloat64)
|
||||
}
|
||||
if value, ok := _u.mutation.VideoModelPrices(); ok {
|
||||
_spec.SetField(group.FieldVideoModelPrices, field.TypeJSON, value)
|
||||
}
|
||||
if _u.mutation.VideoModelPricesCleared() {
|
||||
_spec.ClearField(group.FieldVideoModelPrices, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.WebSearchPricePerCall(); ok {
|
||||
_spec.SetField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value)
|
||||
}
|
||||
@@ -3438,6 +3766,42 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
|
||||
if _u.mutation.WebSearchPricePerCallCleared() {
|
||||
_spec.ClearField(group.FieldWebSearchPricePerCall, field.TypeFloat64)
|
||||
}
|
||||
if value, ok := _u.mutation.SearchPricePer1k(); ok {
|
||||
_spec.SetField(group.FieldSearchPricePer1k, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AddedSearchPricePer1k(); ok {
|
||||
_spec.AddField(group.FieldSearchPricePer1k, field.TypeFloat64, value)
|
||||
}
|
||||
if _u.mutation.SearchPricePer1kCleared() {
|
||||
_spec.ClearField(group.FieldSearchPricePer1k, field.TypeFloat64)
|
||||
}
|
||||
if value, ok := _u.mutation.AudioRealtimePricePerMin(); ok {
|
||||
_spec.SetField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AddedAudioRealtimePricePerMin(); ok {
|
||||
_spec.AddField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64, value)
|
||||
}
|
||||
if _u.mutation.AudioRealtimePricePerMinCleared() {
|
||||
_spec.ClearField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64)
|
||||
}
|
||||
if value, ok := _u.mutation.AudioTtsPricePerMillionChars(); ok {
|
||||
_spec.SetField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AddedAudioTtsPricePerMillionChars(); ok {
|
||||
_spec.AddField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64, value)
|
||||
}
|
||||
if _u.mutation.AudioTtsPricePerMillionCharsCleared() {
|
||||
_spec.ClearField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64)
|
||||
}
|
||||
if value, ok := _u.mutation.AudioSttPricePerHour(); ok {
|
||||
_spec.SetField(group.FieldAudioSttPricePerHour, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AddedAudioSttPricePerHour(); ok {
|
||||
_spec.AddField(group.FieldAudioSttPricePerHour, field.TypeFloat64, value)
|
||||
}
|
||||
if _u.mutation.AudioSttPricePerHourCleared() {
|
||||
_spec.ClearField(group.FieldAudioSttPricePerHour, field.TypeFloat64)
|
||||
}
|
||||
if value, ok := _u.mutation.ClaudeCodeOnly(); ok {
|
||||
_spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value)
|
||||
}
|
||||
|
||||
@@ -928,7 +928,12 @@ var (
|
||||
{Name: "video_price_480p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
|
||||
{Name: "video_price_720p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
|
||||
{Name: "video_price_1080p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
|
||||
{Name: "video_model_prices", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "web_search_price_per_call", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
|
||||
{Name: "search_price_per_1k", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
|
||||
{Name: "audio_realtime_price_per_min", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
|
||||
{Name: "audio_tts_price_per_million_chars", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
|
||||
{Name: "audio_stt_price_per_hour", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
|
||||
{Name: "claude_code_only", Type: field.TypeBool, Default: false},
|
||||
{Name: "fallback_group_id", Type: field.TypeInt64, Nullable: true},
|
||||
{Name: "fallback_group_id_on_invalid_request", Type: field.TypeInt64, Nullable: true},
|
||||
@@ -985,7 +990,7 @@ var (
|
||||
{
|
||||
Name: "group_sort_order",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{GroupsColumns[42]},
|
||||
Columns: []*schema.Column{GroupsColumns[47]},
|
||||
},
|
||||
{
|
||||
Name: "idx_groups_duplicate_operation_id_active",
|
||||
@@ -1621,6 +1626,8 @@ var (
|
||||
{Name: "model", Type: field.TypeString, Size: 100},
|
||||
{Name: "requested_model", Type: field.TypeString, Nullable: true, Size: 100},
|
||||
{Name: "upstream_model", Type: field.TypeString, Nullable: true, Size: 100},
|
||||
{Name: "upstream_response_model", Type: field.TypeString, Nullable: true, Size: 200},
|
||||
{Name: "upstream_model_mismatch", Type: field.TypeBool, Nullable: true},
|
||||
{Name: "channel_id", Type: field.TypeInt64, Nullable: true},
|
||||
{Name: "model_mapping_chain", Type: field.TypeString, Nullable: true, Size: 500},
|
||||
{Name: "billing_tier", Type: field.TypeString, Nullable: true, Size: 50},
|
||||
@@ -1671,31 +1678,31 @@ var (
|
||||
ForeignKeys: []*schema.ForeignKey{
|
||||
{
|
||||
Symbol: "usage_logs_api_keys_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[41]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[43]},
|
||||
RefColumns: []*schema.Column{APIKeysColumns[0]},
|
||||
OnDelete: schema.NoAction,
|
||||
},
|
||||
{
|
||||
Symbol: "usage_logs_accounts_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[42]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[44]},
|
||||
RefColumns: []*schema.Column{AccountsColumns[0]},
|
||||
OnDelete: schema.NoAction,
|
||||
},
|
||||
{
|
||||
Symbol: "usage_logs_groups_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[43]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[45]},
|
||||
RefColumns: []*schema.Column{GroupsColumns[0]},
|
||||
OnDelete: schema.SetNull,
|
||||
},
|
||||
{
|
||||
Symbol: "usage_logs_users_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[44]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[46]},
|
||||
RefColumns: []*schema.Column{UsersColumns[0]},
|
||||
OnDelete: schema.NoAction,
|
||||
},
|
||||
{
|
||||
Symbol: "usage_logs_user_subscriptions_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[45]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[47]},
|
||||
RefColumns: []*schema.Column{UserSubscriptionsColumns[0]},
|
||||
OnDelete: schema.SetNull,
|
||||
},
|
||||
@@ -1704,32 +1711,32 @@ var (
|
||||
{
|
||||
Name: "usagelog_user_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[44]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[46]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_api_key_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[41]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[43]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_account_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[42]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[44]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_group_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[43]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[45]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_subscription_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[45]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[47]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_created_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[40]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[42]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_model",
|
||||
@@ -1749,17 +1756,17 @@ var (
|
||||
{
|
||||
Name: "usagelog_user_id_created_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[44], UsageLogsColumns[40]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[46], UsageLogsColumns[42]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_api_key_id_created_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[41], UsageLogsColumns[40]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[43], UsageLogsColumns[42]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_group_id_created_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[43], UsageLogsColumns[40]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[45], UsageLogsColumns[42]},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+649
-2
@@ -21896,8 +21896,17 @@ type GroupMutation struct {
|
||||
addvideo_price_720p *float64
|
||||
video_price_1080p *float64
|
||||
addvideo_price_1080p *float64
|
||||
video_model_prices *map[string]map[string]float64
|
||||
web_search_price_per_call *float64
|
||||
addweb_search_price_per_call *float64
|
||||
search_price_per_1k *float64
|
||||
addsearch_price_per_1k *float64
|
||||
audio_realtime_price_per_min *float64
|
||||
addaudio_realtime_price_per_min *float64
|
||||
audio_tts_price_per_million_chars *float64
|
||||
addaudio_tts_price_per_million_chars *float64
|
||||
audio_stt_price_per_hour *float64
|
||||
addaudio_stt_price_per_hour *float64
|
||||
claude_code_only *bool
|
||||
fallback_group_id *int64
|
||||
addfallback_group_id *int64
|
||||
@@ -23722,6 +23731,55 @@ func (m *GroupMutation) ResetVideoPrice1080p() {
|
||||
delete(m.clearedFields, group.FieldVideoPrice1080p)
|
||||
}
|
||||
|
||||
// SetVideoModelPrices sets the "video_model_prices" field.
|
||||
func (m *GroupMutation) SetVideoModelPrices(value map[string]map[string]float64) {
|
||||
m.video_model_prices = &value
|
||||
}
|
||||
|
||||
// VideoModelPrices returns the value of the "video_model_prices" field in the mutation.
|
||||
func (m *GroupMutation) VideoModelPrices() (r map[string]map[string]float64, exists bool) {
|
||||
v := m.video_model_prices
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldVideoModelPrices returns the old "video_model_prices" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldVideoModelPrices(ctx context.Context) (v map[string]map[string]float64, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldVideoModelPrices is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldVideoModelPrices requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldVideoModelPrices: %w", err)
|
||||
}
|
||||
return oldValue.VideoModelPrices, nil
|
||||
}
|
||||
|
||||
// ClearVideoModelPrices clears the value of the "video_model_prices" field.
|
||||
func (m *GroupMutation) ClearVideoModelPrices() {
|
||||
m.video_model_prices = nil
|
||||
m.clearedFields[group.FieldVideoModelPrices] = struct{}{}
|
||||
}
|
||||
|
||||
// VideoModelPricesCleared returns if the "video_model_prices" field was cleared in this mutation.
|
||||
func (m *GroupMutation) VideoModelPricesCleared() bool {
|
||||
_, ok := m.clearedFields[group.FieldVideoModelPrices]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResetVideoModelPrices resets all changes to the "video_model_prices" field.
|
||||
func (m *GroupMutation) ResetVideoModelPrices() {
|
||||
m.video_model_prices = nil
|
||||
delete(m.clearedFields, group.FieldVideoModelPrices)
|
||||
}
|
||||
|
||||
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
|
||||
func (m *GroupMutation) SetWebSearchPricePerCall(f float64) {
|
||||
m.web_search_price_per_call = &f
|
||||
@@ -23792,6 +23850,286 @@ func (m *GroupMutation) ResetWebSearchPricePerCall() {
|
||||
delete(m.clearedFields, group.FieldWebSearchPricePerCall)
|
||||
}
|
||||
|
||||
// SetSearchPricePer1k sets the "search_price_per_1k" field.
|
||||
func (m *GroupMutation) SetSearchPricePer1k(f float64) {
|
||||
m.search_price_per_1k = &f
|
||||
m.addsearch_price_per_1k = nil
|
||||
}
|
||||
|
||||
// SearchPricePer1k returns the value of the "search_price_per_1k" field in the mutation.
|
||||
func (m *GroupMutation) SearchPricePer1k() (r float64, exists bool) {
|
||||
v := m.search_price_per_1k
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldSearchPricePer1k returns the old "search_price_per_1k" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldSearchPricePer1k(ctx context.Context) (v *float64, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldSearchPricePer1k is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldSearchPricePer1k requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldSearchPricePer1k: %w", err)
|
||||
}
|
||||
return oldValue.SearchPricePer1k, nil
|
||||
}
|
||||
|
||||
// AddSearchPricePer1k adds f to the "search_price_per_1k" field.
|
||||
func (m *GroupMutation) AddSearchPricePer1k(f float64) {
|
||||
if m.addsearch_price_per_1k != nil {
|
||||
*m.addsearch_price_per_1k += f
|
||||
} else {
|
||||
m.addsearch_price_per_1k = &f
|
||||
}
|
||||
}
|
||||
|
||||
// AddedSearchPricePer1k returns the value that was added to the "search_price_per_1k" field in this mutation.
|
||||
func (m *GroupMutation) AddedSearchPricePer1k() (r float64, exists bool) {
|
||||
v := m.addsearch_price_per_1k
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field.
|
||||
func (m *GroupMutation) ClearSearchPricePer1k() {
|
||||
m.search_price_per_1k = nil
|
||||
m.addsearch_price_per_1k = nil
|
||||
m.clearedFields[group.FieldSearchPricePer1k] = struct{}{}
|
||||
}
|
||||
|
||||
// SearchPricePer1kCleared returns if the "search_price_per_1k" field was cleared in this mutation.
|
||||
func (m *GroupMutation) SearchPricePer1kCleared() bool {
|
||||
_, ok := m.clearedFields[group.FieldSearchPricePer1k]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResetSearchPricePer1k resets all changes to the "search_price_per_1k" field.
|
||||
func (m *GroupMutation) ResetSearchPricePer1k() {
|
||||
m.search_price_per_1k = nil
|
||||
m.addsearch_price_per_1k = nil
|
||||
delete(m.clearedFields, group.FieldSearchPricePer1k)
|
||||
}
|
||||
|
||||
// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field.
|
||||
func (m *GroupMutation) SetAudioRealtimePricePerMin(f float64) {
|
||||
m.audio_realtime_price_per_min = &f
|
||||
m.addaudio_realtime_price_per_min = nil
|
||||
}
|
||||
|
||||
// AudioRealtimePricePerMin returns the value of the "audio_realtime_price_per_min" field in the mutation.
|
||||
func (m *GroupMutation) AudioRealtimePricePerMin() (r float64, exists bool) {
|
||||
v := m.audio_realtime_price_per_min
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldAudioRealtimePricePerMin returns the old "audio_realtime_price_per_min" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldAudioRealtimePricePerMin(ctx context.Context) (v *float64, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldAudioRealtimePricePerMin is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldAudioRealtimePricePerMin requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldAudioRealtimePricePerMin: %w", err)
|
||||
}
|
||||
return oldValue.AudioRealtimePricePerMin, nil
|
||||
}
|
||||
|
||||
// AddAudioRealtimePricePerMin adds f to the "audio_realtime_price_per_min" field.
|
||||
func (m *GroupMutation) AddAudioRealtimePricePerMin(f float64) {
|
||||
if m.addaudio_realtime_price_per_min != nil {
|
||||
*m.addaudio_realtime_price_per_min += f
|
||||
} else {
|
||||
m.addaudio_realtime_price_per_min = &f
|
||||
}
|
||||
}
|
||||
|
||||
// AddedAudioRealtimePricePerMin returns the value that was added to the "audio_realtime_price_per_min" field in this mutation.
|
||||
func (m *GroupMutation) AddedAudioRealtimePricePerMin() (r float64, exists bool) {
|
||||
v := m.addaudio_realtime_price_per_min
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// ClearAudioRealtimePricePerMin clears the value of the "audio_realtime_price_per_min" field.
|
||||
func (m *GroupMutation) ClearAudioRealtimePricePerMin() {
|
||||
m.audio_realtime_price_per_min = nil
|
||||
m.addaudio_realtime_price_per_min = nil
|
||||
m.clearedFields[group.FieldAudioRealtimePricePerMin] = struct{}{}
|
||||
}
|
||||
|
||||
// AudioRealtimePricePerMinCleared returns if the "audio_realtime_price_per_min" field was cleared in this mutation.
|
||||
func (m *GroupMutation) AudioRealtimePricePerMinCleared() bool {
|
||||
_, ok := m.clearedFields[group.FieldAudioRealtimePricePerMin]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResetAudioRealtimePricePerMin resets all changes to the "audio_realtime_price_per_min" field.
|
||||
func (m *GroupMutation) ResetAudioRealtimePricePerMin() {
|
||||
m.audio_realtime_price_per_min = nil
|
||||
m.addaudio_realtime_price_per_min = nil
|
||||
delete(m.clearedFields, group.FieldAudioRealtimePricePerMin)
|
||||
}
|
||||
|
||||
// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field.
|
||||
func (m *GroupMutation) SetAudioTtsPricePerMillionChars(f float64) {
|
||||
m.audio_tts_price_per_million_chars = &f
|
||||
m.addaudio_tts_price_per_million_chars = nil
|
||||
}
|
||||
|
||||
// AudioTtsPricePerMillionChars returns the value of the "audio_tts_price_per_million_chars" field in the mutation.
|
||||
func (m *GroupMutation) AudioTtsPricePerMillionChars() (r float64, exists bool) {
|
||||
v := m.audio_tts_price_per_million_chars
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldAudioTtsPricePerMillionChars returns the old "audio_tts_price_per_million_chars" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldAudioTtsPricePerMillionChars(ctx context.Context) (v *float64, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldAudioTtsPricePerMillionChars is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldAudioTtsPricePerMillionChars requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldAudioTtsPricePerMillionChars: %w", err)
|
||||
}
|
||||
return oldValue.AudioTtsPricePerMillionChars, nil
|
||||
}
|
||||
|
||||
// AddAudioTtsPricePerMillionChars adds f to the "audio_tts_price_per_million_chars" field.
|
||||
func (m *GroupMutation) AddAudioTtsPricePerMillionChars(f float64) {
|
||||
if m.addaudio_tts_price_per_million_chars != nil {
|
||||
*m.addaudio_tts_price_per_million_chars += f
|
||||
} else {
|
||||
m.addaudio_tts_price_per_million_chars = &f
|
||||
}
|
||||
}
|
||||
|
||||
// AddedAudioTtsPricePerMillionChars returns the value that was added to the "audio_tts_price_per_million_chars" field in this mutation.
|
||||
func (m *GroupMutation) AddedAudioTtsPricePerMillionChars() (r float64, exists bool) {
|
||||
v := m.addaudio_tts_price_per_million_chars
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// ClearAudioTtsPricePerMillionChars clears the value of the "audio_tts_price_per_million_chars" field.
|
||||
func (m *GroupMutation) ClearAudioTtsPricePerMillionChars() {
|
||||
m.audio_tts_price_per_million_chars = nil
|
||||
m.addaudio_tts_price_per_million_chars = nil
|
||||
m.clearedFields[group.FieldAudioTtsPricePerMillionChars] = struct{}{}
|
||||
}
|
||||
|
||||
// AudioTtsPricePerMillionCharsCleared returns if the "audio_tts_price_per_million_chars" field was cleared in this mutation.
|
||||
func (m *GroupMutation) AudioTtsPricePerMillionCharsCleared() bool {
|
||||
_, ok := m.clearedFields[group.FieldAudioTtsPricePerMillionChars]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResetAudioTtsPricePerMillionChars resets all changes to the "audio_tts_price_per_million_chars" field.
|
||||
func (m *GroupMutation) ResetAudioTtsPricePerMillionChars() {
|
||||
m.audio_tts_price_per_million_chars = nil
|
||||
m.addaudio_tts_price_per_million_chars = nil
|
||||
delete(m.clearedFields, group.FieldAudioTtsPricePerMillionChars)
|
||||
}
|
||||
|
||||
// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field.
|
||||
func (m *GroupMutation) SetAudioSttPricePerHour(f float64) {
|
||||
m.audio_stt_price_per_hour = &f
|
||||
m.addaudio_stt_price_per_hour = nil
|
||||
}
|
||||
|
||||
// AudioSttPricePerHour returns the value of the "audio_stt_price_per_hour" field in the mutation.
|
||||
func (m *GroupMutation) AudioSttPricePerHour() (r float64, exists bool) {
|
||||
v := m.audio_stt_price_per_hour
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldAudioSttPricePerHour returns the old "audio_stt_price_per_hour" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldAudioSttPricePerHour(ctx context.Context) (v *float64, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldAudioSttPricePerHour is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldAudioSttPricePerHour requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldAudioSttPricePerHour: %w", err)
|
||||
}
|
||||
return oldValue.AudioSttPricePerHour, nil
|
||||
}
|
||||
|
||||
// AddAudioSttPricePerHour adds f to the "audio_stt_price_per_hour" field.
|
||||
func (m *GroupMutation) AddAudioSttPricePerHour(f float64) {
|
||||
if m.addaudio_stt_price_per_hour != nil {
|
||||
*m.addaudio_stt_price_per_hour += f
|
||||
} else {
|
||||
m.addaudio_stt_price_per_hour = &f
|
||||
}
|
||||
}
|
||||
|
||||
// AddedAudioSttPricePerHour returns the value that was added to the "audio_stt_price_per_hour" field in this mutation.
|
||||
func (m *GroupMutation) AddedAudioSttPricePerHour() (r float64, exists bool) {
|
||||
v := m.addaudio_stt_price_per_hour
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// ClearAudioSttPricePerHour clears the value of the "audio_stt_price_per_hour" field.
|
||||
func (m *GroupMutation) ClearAudioSttPricePerHour() {
|
||||
m.audio_stt_price_per_hour = nil
|
||||
m.addaudio_stt_price_per_hour = nil
|
||||
m.clearedFields[group.FieldAudioSttPricePerHour] = struct{}{}
|
||||
}
|
||||
|
||||
// AudioSttPricePerHourCleared returns if the "audio_stt_price_per_hour" field was cleared in this mutation.
|
||||
func (m *GroupMutation) AudioSttPricePerHourCleared() bool {
|
||||
_, ok := m.clearedFields[group.FieldAudioSttPricePerHour]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResetAudioSttPricePerHour resets all changes to the "audio_stt_price_per_hour" field.
|
||||
func (m *GroupMutation) ResetAudioSttPricePerHour() {
|
||||
m.audio_stt_price_per_hour = nil
|
||||
m.addaudio_stt_price_per_hour = nil
|
||||
delete(m.clearedFields, group.FieldAudioSttPricePerHour)
|
||||
}
|
||||
|
||||
// SetClaudeCodeOnly sets the "claude_code_only" field.
|
||||
func (m *GroupMutation) SetClaudeCodeOnly(b bool) {
|
||||
m.claude_code_only = &b
|
||||
@@ -25097,7 +25435,7 @@ func (m *GroupMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *GroupMutation) Fields() []string {
|
||||
fields := make([]string, 0, 55)
|
||||
fields := make([]string, 0, 60)
|
||||
if m.created_at != nil {
|
||||
fields = append(fields, group.FieldCreatedAt)
|
||||
}
|
||||
@@ -25197,9 +25535,24 @@ func (m *GroupMutation) Fields() []string {
|
||||
if m.video_price_1080p != nil {
|
||||
fields = append(fields, group.FieldVideoPrice1080p)
|
||||
}
|
||||
if m.video_model_prices != nil {
|
||||
fields = append(fields, group.FieldVideoModelPrices)
|
||||
}
|
||||
if m.web_search_price_per_call != nil {
|
||||
fields = append(fields, group.FieldWebSearchPricePerCall)
|
||||
}
|
||||
if m.search_price_per_1k != nil {
|
||||
fields = append(fields, group.FieldSearchPricePer1k)
|
||||
}
|
||||
if m.audio_realtime_price_per_min != nil {
|
||||
fields = append(fields, group.FieldAudioRealtimePricePerMin)
|
||||
}
|
||||
if m.audio_tts_price_per_million_chars != nil {
|
||||
fields = append(fields, group.FieldAudioTtsPricePerMillionChars)
|
||||
}
|
||||
if m.audio_stt_price_per_hour != nil {
|
||||
fields = append(fields, group.FieldAudioSttPricePerHour)
|
||||
}
|
||||
if m.claude_code_only != nil {
|
||||
fields = append(fields, group.FieldClaudeCodeOnly)
|
||||
}
|
||||
@@ -25337,8 +25690,18 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.VideoPrice720p()
|
||||
case group.FieldVideoPrice1080p:
|
||||
return m.VideoPrice1080p()
|
||||
case group.FieldVideoModelPrices:
|
||||
return m.VideoModelPrices()
|
||||
case group.FieldWebSearchPricePerCall:
|
||||
return m.WebSearchPricePerCall()
|
||||
case group.FieldSearchPricePer1k:
|
||||
return m.SearchPricePer1k()
|
||||
case group.FieldAudioRealtimePricePerMin:
|
||||
return m.AudioRealtimePricePerMin()
|
||||
case group.FieldAudioTtsPricePerMillionChars:
|
||||
return m.AudioTtsPricePerMillionChars()
|
||||
case group.FieldAudioSttPricePerHour:
|
||||
return m.AudioSttPricePerHour()
|
||||
case group.FieldClaudeCodeOnly:
|
||||
return m.ClaudeCodeOnly()
|
||||
case group.FieldFallbackGroupID:
|
||||
@@ -25456,8 +25819,18 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
|
||||
return m.OldVideoPrice720p(ctx)
|
||||
case group.FieldVideoPrice1080p:
|
||||
return m.OldVideoPrice1080p(ctx)
|
||||
case group.FieldVideoModelPrices:
|
||||
return m.OldVideoModelPrices(ctx)
|
||||
case group.FieldWebSearchPricePerCall:
|
||||
return m.OldWebSearchPricePerCall(ctx)
|
||||
case group.FieldSearchPricePer1k:
|
||||
return m.OldSearchPricePer1k(ctx)
|
||||
case group.FieldAudioRealtimePricePerMin:
|
||||
return m.OldAudioRealtimePricePerMin(ctx)
|
||||
case group.FieldAudioTtsPricePerMillionChars:
|
||||
return m.OldAudioTtsPricePerMillionChars(ctx)
|
||||
case group.FieldAudioSttPricePerHour:
|
||||
return m.OldAudioSttPricePerHour(ctx)
|
||||
case group.FieldClaudeCodeOnly:
|
||||
return m.OldClaudeCodeOnly(ctx)
|
||||
case group.FieldFallbackGroupID:
|
||||
@@ -25740,6 +26113,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetVideoPrice1080p(v)
|
||||
return nil
|
||||
case group.FieldVideoModelPrices:
|
||||
v, ok := value.(map[string]map[string]float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetVideoModelPrices(v)
|
||||
return nil
|
||||
case group.FieldWebSearchPricePerCall:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
@@ -25747,6 +26127,34 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetWebSearchPricePerCall(v)
|
||||
return nil
|
||||
case group.FieldSearchPricePer1k:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetSearchPricePer1k(v)
|
||||
return nil
|
||||
case group.FieldAudioRealtimePricePerMin:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetAudioRealtimePricePerMin(v)
|
||||
return nil
|
||||
case group.FieldAudioTtsPricePerMillionChars:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetAudioTtsPricePerMillionChars(v)
|
||||
return nil
|
||||
case group.FieldAudioSttPricePerHour:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetAudioSttPricePerHour(v)
|
||||
return nil
|
||||
case group.FieldClaudeCodeOnly:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
@@ -25953,6 +26361,18 @@ func (m *GroupMutation) AddedFields() []string {
|
||||
if m.addweb_search_price_per_call != nil {
|
||||
fields = append(fields, group.FieldWebSearchPricePerCall)
|
||||
}
|
||||
if m.addsearch_price_per_1k != nil {
|
||||
fields = append(fields, group.FieldSearchPricePer1k)
|
||||
}
|
||||
if m.addaudio_realtime_price_per_min != nil {
|
||||
fields = append(fields, group.FieldAudioRealtimePricePerMin)
|
||||
}
|
||||
if m.addaudio_tts_price_per_million_chars != nil {
|
||||
fields = append(fields, group.FieldAudioTtsPricePerMillionChars)
|
||||
}
|
||||
if m.addaudio_stt_price_per_hour != nil {
|
||||
fields = append(fields, group.FieldAudioSttPricePerHour)
|
||||
}
|
||||
if m.addfallback_group_id != nil {
|
||||
fields = append(fields, group.FieldFallbackGroupID)
|
||||
}
|
||||
@@ -26013,6 +26433,14 @@ func (m *GroupMutation) AddedField(name string) (ent.Value, bool) {
|
||||
return m.AddedVideoPrice1080p()
|
||||
case group.FieldWebSearchPricePerCall:
|
||||
return m.AddedWebSearchPricePerCall()
|
||||
case group.FieldSearchPricePer1k:
|
||||
return m.AddedSearchPricePer1k()
|
||||
case group.FieldAudioRealtimePricePerMin:
|
||||
return m.AddedAudioRealtimePricePerMin()
|
||||
case group.FieldAudioTtsPricePerMillionChars:
|
||||
return m.AddedAudioTtsPricePerMillionChars()
|
||||
case group.FieldAudioSttPricePerHour:
|
||||
return m.AddedAudioSttPricePerHour()
|
||||
case group.FieldFallbackGroupID:
|
||||
return m.AddedFallbackGroupID()
|
||||
case group.FieldFallbackGroupIDOnInvalidRequest:
|
||||
@@ -26153,6 +26581,34 @@ func (m *GroupMutation) AddField(name string, value ent.Value) error {
|
||||
}
|
||||
m.AddWebSearchPricePerCall(v)
|
||||
return nil
|
||||
case group.FieldSearchPricePer1k:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.AddSearchPricePer1k(v)
|
||||
return nil
|
||||
case group.FieldAudioRealtimePricePerMin:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.AddAudioRealtimePricePerMin(v)
|
||||
return nil
|
||||
case group.FieldAudioTtsPricePerMillionChars:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.AddAudioTtsPricePerMillionChars(v)
|
||||
return nil
|
||||
case group.FieldAudioSttPricePerHour:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.AddAudioSttPricePerHour(v)
|
||||
return nil
|
||||
case group.FieldFallbackGroupID:
|
||||
v, ok := value.(int64)
|
||||
if !ok {
|
||||
@@ -26239,9 +26695,24 @@ func (m *GroupMutation) ClearedFields() []string {
|
||||
if m.FieldCleared(group.FieldVideoPrice1080p) {
|
||||
fields = append(fields, group.FieldVideoPrice1080p)
|
||||
}
|
||||
if m.FieldCleared(group.FieldVideoModelPrices) {
|
||||
fields = append(fields, group.FieldVideoModelPrices)
|
||||
}
|
||||
if m.FieldCleared(group.FieldWebSearchPricePerCall) {
|
||||
fields = append(fields, group.FieldWebSearchPricePerCall)
|
||||
}
|
||||
if m.FieldCleared(group.FieldSearchPricePer1k) {
|
||||
fields = append(fields, group.FieldSearchPricePer1k)
|
||||
}
|
||||
if m.FieldCleared(group.FieldAudioRealtimePricePerMin) {
|
||||
fields = append(fields, group.FieldAudioRealtimePricePerMin)
|
||||
}
|
||||
if m.FieldCleared(group.FieldAudioTtsPricePerMillionChars) {
|
||||
fields = append(fields, group.FieldAudioTtsPricePerMillionChars)
|
||||
}
|
||||
if m.FieldCleared(group.FieldAudioSttPricePerHour) {
|
||||
fields = append(fields, group.FieldAudioSttPricePerHour)
|
||||
}
|
||||
if m.FieldCleared(group.FieldFallbackGroupID) {
|
||||
fields = append(fields, group.FieldFallbackGroupID)
|
||||
}
|
||||
@@ -26301,9 +26772,24 @@ func (m *GroupMutation) ClearField(name string) error {
|
||||
case group.FieldVideoPrice1080p:
|
||||
m.ClearVideoPrice1080p()
|
||||
return nil
|
||||
case group.FieldVideoModelPrices:
|
||||
m.ClearVideoModelPrices()
|
||||
return nil
|
||||
case group.FieldWebSearchPricePerCall:
|
||||
m.ClearWebSearchPricePerCall()
|
||||
return nil
|
||||
case group.FieldSearchPricePer1k:
|
||||
m.ClearSearchPricePer1k()
|
||||
return nil
|
||||
case group.FieldAudioRealtimePricePerMin:
|
||||
m.ClearAudioRealtimePricePerMin()
|
||||
return nil
|
||||
case group.FieldAudioTtsPricePerMillionChars:
|
||||
m.ClearAudioTtsPricePerMillionChars()
|
||||
return nil
|
||||
case group.FieldAudioSttPricePerHour:
|
||||
m.ClearAudioSttPricePerHour()
|
||||
return nil
|
||||
case group.FieldFallbackGroupID:
|
||||
m.ClearFallbackGroupID()
|
||||
return nil
|
||||
@@ -26420,9 +26906,24 @@ func (m *GroupMutation) ResetField(name string) error {
|
||||
case group.FieldVideoPrice1080p:
|
||||
m.ResetVideoPrice1080p()
|
||||
return nil
|
||||
case group.FieldVideoModelPrices:
|
||||
m.ResetVideoModelPrices()
|
||||
return nil
|
||||
case group.FieldWebSearchPricePerCall:
|
||||
m.ResetWebSearchPricePerCall()
|
||||
return nil
|
||||
case group.FieldSearchPricePer1k:
|
||||
m.ResetSearchPricePer1k()
|
||||
return nil
|
||||
case group.FieldAudioRealtimePricePerMin:
|
||||
m.ResetAudioRealtimePricePerMin()
|
||||
return nil
|
||||
case group.FieldAudioTtsPricePerMillionChars:
|
||||
m.ResetAudioTtsPricePerMillionChars()
|
||||
return nil
|
||||
case group.FieldAudioSttPricePerHour:
|
||||
m.ResetAudioSttPricePerHour()
|
||||
return nil
|
||||
case group.FieldClaudeCodeOnly:
|
||||
m.ResetClaudeCodeOnly()
|
||||
return nil
|
||||
@@ -43356,6 +43857,8 @@ type UsageLogMutation struct {
|
||||
model *string
|
||||
requested_model *string
|
||||
upstream_model *string
|
||||
upstream_response_model *string
|
||||
upstream_model_mismatch *bool
|
||||
channel_id *int64
|
||||
addchannel_id *int64
|
||||
model_mapping_chain *string
|
||||
@@ -43805,6 +44308,104 @@ func (m *UsageLogMutation) ResetUpstreamModel() {
|
||||
delete(m.clearedFields, usagelog.FieldUpstreamModel)
|
||||
}
|
||||
|
||||
// SetUpstreamResponseModel sets the "upstream_response_model" field.
|
||||
func (m *UsageLogMutation) SetUpstreamResponseModel(s string) {
|
||||
m.upstream_response_model = &s
|
||||
}
|
||||
|
||||
// UpstreamResponseModel returns the value of the "upstream_response_model" field in the mutation.
|
||||
func (m *UsageLogMutation) UpstreamResponseModel() (r string, exists bool) {
|
||||
v := m.upstream_response_model
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldUpstreamResponseModel returns the old "upstream_response_model" field's value of the UsageLog entity.
|
||||
// If the UsageLog object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *UsageLogMutation) OldUpstreamResponseModel(ctx context.Context) (v *string, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldUpstreamResponseModel is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldUpstreamResponseModel requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldUpstreamResponseModel: %w", err)
|
||||
}
|
||||
return oldValue.UpstreamResponseModel, nil
|
||||
}
|
||||
|
||||
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
|
||||
func (m *UsageLogMutation) ClearUpstreamResponseModel() {
|
||||
m.upstream_response_model = nil
|
||||
m.clearedFields[usagelog.FieldUpstreamResponseModel] = struct{}{}
|
||||
}
|
||||
|
||||
// UpstreamResponseModelCleared returns if the "upstream_response_model" field was cleared in this mutation.
|
||||
func (m *UsageLogMutation) UpstreamResponseModelCleared() bool {
|
||||
_, ok := m.clearedFields[usagelog.FieldUpstreamResponseModel]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResetUpstreamResponseModel resets all changes to the "upstream_response_model" field.
|
||||
func (m *UsageLogMutation) ResetUpstreamResponseModel() {
|
||||
m.upstream_response_model = nil
|
||||
delete(m.clearedFields, usagelog.FieldUpstreamResponseModel)
|
||||
}
|
||||
|
||||
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
|
||||
func (m *UsageLogMutation) SetUpstreamModelMismatch(b bool) {
|
||||
m.upstream_model_mismatch = &b
|
||||
}
|
||||
|
||||
// UpstreamModelMismatch returns the value of the "upstream_model_mismatch" field in the mutation.
|
||||
func (m *UsageLogMutation) UpstreamModelMismatch() (r bool, exists bool) {
|
||||
v := m.upstream_model_mismatch
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldUpstreamModelMismatch returns the old "upstream_model_mismatch" field's value of the UsageLog entity.
|
||||
// If the UsageLog object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *UsageLogMutation) OldUpstreamModelMismatch(ctx context.Context) (v *bool, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldUpstreamModelMismatch is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldUpstreamModelMismatch requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldUpstreamModelMismatch: %w", err)
|
||||
}
|
||||
return oldValue.UpstreamModelMismatch, nil
|
||||
}
|
||||
|
||||
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
|
||||
func (m *UsageLogMutation) ClearUpstreamModelMismatch() {
|
||||
m.upstream_model_mismatch = nil
|
||||
m.clearedFields[usagelog.FieldUpstreamModelMismatch] = struct{}{}
|
||||
}
|
||||
|
||||
// UpstreamModelMismatchCleared returns if the "upstream_model_mismatch" field was cleared in this mutation.
|
||||
func (m *UsageLogMutation) UpstreamModelMismatchCleared() bool {
|
||||
_, ok := m.clearedFields[usagelog.FieldUpstreamModelMismatch]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResetUpstreamModelMismatch resets all changes to the "upstream_model_mismatch" field.
|
||||
func (m *UsageLogMutation) ResetUpstreamModelMismatch() {
|
||||
m.upstream_model_mismatch = nil
|
||||
delete(m.clearedFields, usagelog.FieldUpstreamModelMismatch)
|
||||
}
|
||||
|
||||
// SetChannelID sets the "channel_id" field.
|
||||
func (m *UsageLogMutation) SetChannelID(i int64) {
|
||||
m.channel_id = &i
|
||||
@@ -46001,7 +46602,7 @@ func (m *UsageLogMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *UsageLogMutation) Fields() []string {
|
||||
fields := make([]string, 0, 45)
|
||||
fields := make([]string, 0, 47)
|
||||
if m.user != nil {
|
||||
fields = append(fields, usagelog.FieldUserID)
|
||||
}
|
||||
@@ -46023,6 +46624,12 @@ func (m *UsageLogMutation) Fields() []string {
|
||||
if m.upstream_model != nil {
|
||||
fields = append(fields, usagelog.FieldUpstreamModel)
|
||||
}
|
||||
if m.upstream_response_model != nil {
|
||||
fields = append(fields, usagelog.FieldUpstreamResponseModel)
|
||||
}
|
||||
if m.upstream_model_mismatch != nil {
|
||||
fields = append(fields, usagelog.FieldUpstreamModelMismatch)
|
||||
}
|
||||
if m.channel_id != nil {
|
||||
fields = append(fields, usagelog.FieldChannelID)
|
||||
}
|
||||
@@ -46159,6 +46766,10 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.RequestedModel()
|
||||
case usagelog.FieldUpstreamModel:
|
||||
return m.UpstreamModel()
|
||||
case usagelog.FieldUpstreamResponseModel:
|
||||
return m.UpstreamResponseModel()
|
||||
case usagelog.FieldUpstreamModelMismatch:
|
||||
return m.UpstreamModelMismatch()
|
||||
case usagelog.FieldChannelID:
|
||||
return m.ChannelID()
|
||||
case usagelog.FieldModelMappingChain:
|
||||
@@ -46258,6 +46869,10 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value
|
||||
return m.OldRequestedModel(ctx)
|
||||
case usagelog.FieldUpstreamModel:
|
||||
return m.OldUpstreamModel(ctx)
|
||||
case usagelog.FieldUpstreamResponseModel:
|
||||
return m.OldUpstreamResponseModel(ctx)
|
||||
case usagelog.FieldUpstreamModelMismatch:
|
||||
return m.OldUpstreamModelMismatch(ctx)
|
||||
case usagelog.FieldChannelID:
|
||||
return m.OldChannelID(ctx)
|
||||
case usagelog.FieldModelMappingChain:
|
||||
@@ -46392,6 +47007,20 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetUpstreamModel(v)
|
||||
return nil
|
||||
case usagelog.FieldUpstreamResponseModel:
|
||||
v, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetUpstreamResponseModel(v)
|
||||
return nil
|
||||
case usagelog.FieldUpstreamModelMismatch:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetUpstreamModelMismatch(v)
|
||||
return nil
|
||||
case usagelog.FieldChannelID:
|
||||
v, ok := value.(int64)
|
||||
if !ok {
|
||||
@@ -46949,6 +47578,12 @@ func (m *UsageLogMutation) ClearedFields() []string {
|
||||
if m.FieldCleared(usagelog.FieldUpstreamModel) {
|
||||
fields = append(fields, usagelog.FieldUpstreamModel)
|
||||
}
|
||||
if m.FieldCleared(usagelog.FieldUpstreamResponseModel) {
|
||||
fields = append(fields, usagelog.FieldUpstreamResponseModel)
|
||||
}
|
||||
if m.FieldCleared(usagelog.FieldUpstreamModelMismatch) {
|
||||
fields = append(fields, usagelog.FieldUpstreamModelMismatch)
|
||||
}
|
||||
if m.FieldCleared(usagelog.FieldChannelID) {
|
||||
fields = append(fields, usagelog.FieldChannelID)
|
||||
}
|
||||
@@ -47023,6 +47658,12 @@ func (m *UsageLogMutation) ClearField(name string) error {
|
||||
case usagelog.FieldUpstreamModel:
|
||||
m.ClearUpstreamModel()
|
||||
return nil
|
||||
case usagelog.FieldUpstreamResponseModel:
|
||||
m.ClearUpstreamResponseModel()
|
||||
return nil
|
||||
case usagelog.FieldUpstreamModelMismatch:
|
||||
m.ClearUpstreamModelMismatch()
|
||||
return nil
|
||||
case usagelog.FieldChannelID:
|
||||
m.ClearChannelID()
|
||||
return nil
|
||||
@@ -47106,6 +47747,12 @@ func (m *UsageLogMutation) ResetField(name string) error {
|
||||
case usagelog.FieldUpstreamModel:
|
||||
m.ResetUpstreamModel()
|
||||
return nil
|
||||
case usagelog.FieldUpstreamResponseModel:
|
||||
m.ResetUpstreamResponseModel()
|
||||
return nil
|
||||
case usagelog.FieldUpstreamModelMismatch:
|
||||
m.ResetUpstreamModelMismatch()
|
||||
return nil
|
||||
case usagelog.FieldChannelID:
|
||||
m.ResetChannelID()
|
||||
return nil
|
||||
|
||||
@@ -1117,80 +1117,96 @@ func init() {
|
||||
groupDescVideoRateMultiplier := groupFields[26].Descriptor()
|
||||
// group.DefaultVideoRateMultiplier holds the default value on creation for the video_rate_multiplier field.
|
||||
group.DefaultVideoRateMultiplier = groupDescVideoRateMultiplier.Default.(float64)
|
||||
// groupDescSearchPricePer1k is the schema descriptor for search_price_per_1k field.
|
||||
groupDescSearchPricePer1k := groupFields[32].Descriptor()
|
||||
// group.SearchPricePer1kValidator is a validator for the "search_price_per_1k" field. It is called by the builders before save.
|
||||
group.SearchPricePer1kValidator = groupDescSearchPricePer1k.Validators[0].(func(float64) error)
|
||||
// groupDescAudioRealtimePricePerMin is the schema descriptor for audio_realtime_price_per_min field.
|
||||
groupDescAudioRealtimePricePerMin := groupFields[33].Descriptor()
|
||||
// group.AudioRealtimePricePerMinValidator is a validator for the "audio_realtime_price_per_min" field. It is called by the builders before save.
|
||||
group.AudioRealtimePricePerMinValidator = groupDescAudioRealtimePricePerMin.Validators[0].(func(float64) error)
|
||||
// groupDescAudioTtsPricePerMillionChars is the schema descriptor for audio_tts_price_per_million_chars field.
|
||||
groupDescAudioTtsPricePerMillionChars := groupFields[34].Descriptor()
|
||||
// group.AudioTtsPricePerMillionCharsValidator is a validator for the "audio_tts_price_per_million_chars" field. It is called by the builders before save.
|
||||
group.AudioTtsPricePerMillionCharsValidator = groupDescAudioTtsPricePerMillionChars.Validators[0].(func(float64) error)
|
||||
// groupDescAudioSttPricePerHour is the schema descriptor for audio_stt_price_per_hour field.
|
||||
groupDescAudioSttPricePerHour := groupFields[35].Descriptor()
|
||||
// group.AudioSttPricePerHourValidator is a validator for the "audio_stt_price_per_hour" field. It is called by the builders before save.
|
||||
group.AudioSttPricePerHourValidator = groupDescAudioSttPricePerHour.Validators[0].(func(float64) error)
|
||||
// groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field.
|
||||
groupDescClaudeCodeOnly := groupFields[31].Descriptor()
|
||||
groupDescClaudeCodeOnly := groupFields[36].Descriptor()
|
||||
// group.DefaultClaudeCodeOnly holds the default value on creation for the claude_code_only field.
|
||||
group.DefaultClaudeCodeOnly = groupDescClaudeCodeOnly.Default.(bool)
|
||||
// groupDescModelRoutingEnabled is the schema descriptor for model_routing_enabled field.
|
||||
groupDescModelRoutingEnabled := groupFields[35].Descriptor()
|
||||
groupDescModelRoutingEnabled := groupFields[40].Descriptor()
|
||||
// group.DefaultModelRoutingEnabled holds the default value on creation for the model_routing_enabled field.
|
||||
group.DefaultModelRoutingEnabled = groupDescModelRoutingEnabled.Default.(bool)
|
||||
// groupDescMcpXMLInject is the schema descriptor for mcp_xml_inject field.
|
||||
groupDescMcpXMLInject := groupFields[36].Descriptor()
|
||||
groupDescMcpXMLInject := groupFields[41].Descriptor()
|
||||
// group.DefaultMcpXMLInject holds the default value on creation for the mcp_xml_inject field.
|
||||
group.DefaultMcpXMLInject = groupDescMcpXMLInject.Default.(bool)
|
||||
// groupDescSupportedModelScopes is the schema descriptor for supported_model_scopes field.
|
||||
groupDescSupportedModelScopes := groupFields[37].Descriptor()
|
||||
groupDescSupportedModelScopes := groupFields[42].Descriptor()
|
||||
// group.DefaultSupportedModelScopes holds the default value on creation for the supported_model_scopes field.
|
||||
group.DefaultSupportedModelScopes = groupDescSupportedModelScopes.Default.([]string)
|
||||
// groupDescSortOrder is the schema descriptor for sort_order field.
|
||||
groupDescSortOrder := groupFields[38].Descriptor()
|
||||
groupDescSortOrder := groupFields[43].Descriptor()
|
||||
// group.DefaultSortOrder holds the default value on creation for the sort_order field.
|
||||
group.DefaultSortOrder = groupDescSortOrder.Default.(int)
|
||||
// groupDescAllowMessagesDispatch is the schema descriptor for allow_messages_dispatch field.
|
||||
groupDescAllowMessagesDispatch := groupFields[39].Descriptor()
|
||||
groupDescAllowMessagesDispatch := groupFields[44].Descriptor()
|
||||
// group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field.
|
||||
group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool)
|
||||
// groupDescAllowLive is the schema descriptor for allow_live field.
|
||||
groupDescAllowLive := groupFields[40].Descriptor()
|
||||
groupDescAllowLive := groupFields[45].Descriptor()
|
||||
// group.DefaultAllowLive holds the default value on creation for the allow_live field.
|
||||
group.DefaultAllowLive = groupDescAllowLive.Default.(bool)
|
||||
// groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field.
|
||||
groupDescRequireOauthOnly := groupFields[41].Descriptor()
|
||||
groupDescRequireOauthOnly := groupFields[46].Descriptor()
|
||||
// group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field.
|
||||
group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool)
|
||||
// groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field.
|
||||
groupDescRequirePrivacySet := groupFields[42].Descriptor()
|
||||
groupDescRequirePrivacySet := groupFields[47].Descriptor()
|
||||
// group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field.
|
||||
group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool)
|
||||
// groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field.
|
||||
groupDescDefaultMappedModel := groupFields[43].Descriptor()
|
||||
groupDescDefaultMappedModel := groupFields[48].Descriptor()
|
||||
// group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field.
|
||||
group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string)
|
||||
// group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save.
|
||||
group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error)
|
||||
// groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field.
|
||||
groupDescMessagesDispatchModelConfig := groupFields[44].Descriptor()
|
||||
groupDescMessagesDispatchModelConfig := groupFields[49].Descriptor()
|
||||
// group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field.
|
||||
group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig)
|
||||
// groupDescModelsListConfig is the schema descriptor for models_list_config field.
|
||||
groupDescModelsListConfig := groupFields[45].Descriptor()
|
||||
groupDescModelsListConfig := groupFields[50].Descriptor()
|
||||
// group.DefaultModelsListConfig holds the default value on creation for the models_list_config field.
|
||||
group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig)
|
||||
// groupDescRpmLimit is the schema descriptor for rpm_limit field.
|
||||
groupDescRpmLimit := groupFields[46].Descriptor()
|
||||
groupDescRpmLimit := groupFields[51].Descriptor()
|
||||
// group.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
|
||||
group.DefaultRpmLimit = groupDescRpmLimit.Default.(int)
|
||||
// groupDescMaxReasoningEffort is the schema descriptor for max_reasoning_effort field.
|
||||
groupDescMaxReasoningEffort := groupFields[47].Descriptor()
|
||||
groupDescMaxReasoningEffort := groupFields[52].Descriptor()
|
||||
// group.DefaultMaxReasoningEffort holds the default value on creation for the max_reasoning_effort field.
|
||||
group.DefaultMaxReasoningEffort = groupDescMaxReasoningEffort.Default.(string)
|
||||
// group.MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save.
|
||||
group.MaxReasoningEffortValidator = groupDescMaxReasoningEffort.Validators[0].(func(string) error)
|
||||
// groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field.
|
||||
groupDescReasoningEffortMappings := groupFields[48].Descriptor()
|
||||
groupDescReasoningEffortMappings := groupFields[53].Descriptor()
|
||||
// group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field.
|
||||
group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping)
|
||||
// groupDescProfitControlEnabled is the schema descriptor for profit_control_enabled field.
|
||||
groupDescProfitControlEnabled := groupFields[49].Descriptor()
|
||||
groupDescProfitControlEnabled := groupFields[54].Descriptor()
|
||||
// group.DefaultProfitControlEnabled holds the default value on creation for the profit_control_enabled field.
|
||||
group.DefaultProfitControlEnabled = groupDescProfitControlEnabled.Default.(bool)
|
||||
// groupDescProfitMinMargin is the schema descriptor for profit_min_margin field.
|
||||
groupDescProfitMinMargin := groupFields[50].Descriptor()
|
||||
groupDescProfitMinMargin := groupFields[55].Descriptor()
|
||||
// group.DefaultProfitMinMargin holds the default value on creation for the profit_min_margin field.
|
||||
group.DefaultProfitMinMargin = groupDescProfitMinMargin.Default.(float64)
|
||||
// groupDescProfitSafetyBuffer is the schema descriptor for profit_safety_buffer field.
|
||||
groupDescProfitSafetyBuffer := groupFields[51].Descriptor()
|
||||
groupDescProfitSafetyBuffer := groupFields[56].Descriptor()
|
||||
// group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field.
|
||||
group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64)
|
||||
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
|
||||
@@ -1982,124 +1998,128 @@ func init() {
|
||||
usagelogDescUpstreamModel := usagelogFields[6].Descriptor()
|
||||
// usagelog.UpstreamModelValidator is a validator for the "upstream_model" field. It is called by the builders before save.
|
||||
usagelog.UpstreamModelValidator = usagelogDescUpstreamModel.Validators[0].(func(string) error)
|
||||
// usagelogDescUpstreamResponseModel is the schema descriptor for upstream_response_model field.
|
||||
usagelogDescUpstreamResponseModel := usagelogFields[7].Descriptor()
|
||||
// usagelog.UpstreamResponseModelValidator is a validator for the "upstream_response_model" field. It is called by the builders before save.
|
||||
usagelog.UpstreamResponseModelValidator = usagelogDescUpstreamResponseModel.Validators[0].(func(string) error)
|
||||
// usagelogDescModelMappingChain is the schema descriptor for model_mapping_chain field.
|
||||
usagelogDescModelMappingChain := usagelogFields[8].Descriptor()
|
||||
usagelogDescModelMappingChain := usagelogFields[10].Descriptor()
|
||||
// usagelog.ModelMappingChainValidator is a validator for the "model_mapping_chain" field. It is called by the builders before save.
|
||||
usagelog.ModelMappingChainValidator = usagelogDescModelMappingChain.Validators[0].(func(string) error)
|
||||
// usagelogDescBillingTier is the schema descriptor for billing_tier field.
|
||||
usagelogDescBillingTier := usagelogFields[9].Descriptor()
|
||||
usagelogDescBillingTier := usagelogFields[11].Descriptor()
|
||||
// usagelog.BillingTierValidator is a validator for the "billing_tier" field. It is called by the builders before save.
|
||||
usagelog.BillingTierValidator = usagelogDescBillingTier.Validators[0].(func(string) error)
|
||||
// usagelogDescBillingMode is the schema descriptor for billing_mode field.
|
||||
usagelogDescBillingMode := usagelogFields[10].Descriptor()
|
||||
usagelogDescBillingMode := usagelogFields[12].Descriptor()
|
||||
// usagelog.BillingModeValidator is a validator for the "billing_mode" field. It is called by the builders before save.
|
||||
usagelog.BillingModeValidator = usagelogDescBillingMode.Validators[0].(func(string) error)
|
||||
// usagelogDescInputTokens is the schema descriptor for input_tokens field.
|
||||
usagelogDescInputTokens := usagelogFields[13].Descriptor()
|
||||
usagelogDescInputTokens := usagelogFields[15].Descriptor()
|
||||
// usagelog.DefaultInputTokens holds the default value on creation for the input_tokens field.
|
||||
usagelog.DefaultInputTokens = usagelogDescInputTokens.Default.(int)
|
||||
// usagelogDescOutputTokens is the schema descriptor for output_tokens field.
|
||||
usagelogDescOutputTokens := usagelogFields[14].Descriptor()
|
||||
usagelogDescOutputTokens := usagelogFields[16].Descriptor()
|
||||
// usagelog.DefaultOutputTokens holds the default value on creation for the output_tokens field.
|
||||
usagelog.DefaultOutputTokens = usagelogDescOutputTokens.Default.(int)
|
||||
// usagelogDescCacheCreationTokens is the schema descriptor for cache_creation_tokens field.
|
||||
usagelogDescCacheCreationTokens := usagelogFields[15].Descriptor()
|
||||
usagelogDescCacheCreationTokens := usagelogFields[17].Descriptor()
|
||||
// usagelog.DefaultCacheCreationTokens holds the default value on creation for the cache_creation_tokens field.
|
||||
usagelog.DefaultCacheCreationTokens = usagelogDescCacheCreationTokens.Default.(int)
|
||||
// usagelogDescCacheReadTokens is the schema descriptor for cache_read_tokens field.
|
||||
usagelogDescCacheReadTokens := usagelogFields[16].Descriptor()
|
||||
usagelogDescCacheReadTokens := usagelogFields[18].Descriptor()
|
||||
// usagelog.DefaultCacheReadTokens holds the default value on creation for the cache_read_tokens field.
|
||||
usagelog.DefaultCacheReadTokens = usagelogDescCacheReadTokens.Default.(int)
|
||||
// usagelogDescCacheCreation5mTokens is the schema descriptor for cache_creation_5m_tokens field.
|
||||
usagelogDescCacheCreation5mTokens := usagelogFields[17].Descriptor()
|
||||
usagelogDescCacheCreation5mTokens := usagelogFields[19].Descriptor()
|
||||
// usagelog.DefaultCacheCreation5mTokens holds the default value on creation for the cache_creation_5m_tokens field.
|
||||
usagelog.DefaultCacheCreation5mTokens = usagelogDescCacheCreation5mTokens.Default.(int)
|
||||
// usagelogDescCacheCreation1hTokens is the schema descriptor for cache_creation_1h_tokens field.
|
||||
usagelogDescCacheCreation1hTokens := usagelogFields[18].Descriptor()
|
||||
usagelogDescCacheCreation1hTokens := usagelogFields[20].Descriptor()
|
||||
// usagelog.DefaultCacheCreation1hTokens holds the default value on creation for the cache_creation_1h_tokens field.
|
||||
usagelog.DefaultCacheCreation1hTokens = usagelogDescCacheCreation1hTokens.Default.(int)
|
||||
// usagelogDescInputCost is the schema descriptor for input_cost field.
|
||||
usagelogDescInputCost := usagelogFields[19].Descriptor()
|
||||
usagelogDescInputCost := usagelogFields[21].Descriptor()
|
||||
// usagelog.DefaultInputCost holds the default value on creation for the input_cost field.
|
||||
usagelog.DefaultInputCost = usagelogDescInputCost.Default.(float64)
|
||||
// usagelogDescOutputCost is the schema descriptor for output_cost field.
|
||||
usagelogDescOutputCost := usagelogFields[20].Descriptor()
|
||||
usagelogDescOutputCost := usagelogFields[22].Descriptor()
|
||||
// usagelog.DefaultOutputCost holds the default value on creation for the output_cost field.
|
||||
usagelog.DefaultOutputCost = usagelogDescOutputCost.Default.(float64)
|
||||
// usagelogDescCacheCreationCost is the schema descriptor for cache_creation_cost field.
|
||||
usagelogDescCacheCreationCost := usagelogFields[21].Descriptor()
|
||||
usagelogDescCacheCreationCost := usagelogFields[23].Descriptor()
|
||||
// usagelog.DefaultCacheCreationCost holds the default value on creation for the cache_creation_cost field.
|
||||
usagelog.DefaultCacheCreationCost = usagelogDescCacheCreationCost.Default.(float64)
|
||||
// usagelogDescCacheReadCost is the schema descriptor for cache_read_cost field.
|
||||
usagelogDescCacheReadCost := usagelogFields[22].Descriptor()
|
||||
usagelogDescCacheReadCost := usagelogFields[24].Descriptor()
|
||||
// usagelog.DefaultCacheReadCost holds the default value on creation for the cache_read_cost field.
|
||||
usagelog.DefaultCacheReadCost = usagelogDescCacheReadCost.Default.(float64)
|
||||
// usagelogDescTotalCost is the schema descriptor for total_cost field.
|
||||
usagelogDescTotalCost := usagelogFields[23].Descriptor()
|
||||
usagelogDescTotalCost := usagelogFields[25].Descriptor()
|
||||
// usagelog.DefaultTotalCost holds the default value on creation for the total_cost field.
|
||||
usagelog.DefaultTotalCost = usagelogDescTotalCost.Default.(float64)
|
||||
// usagelogDescActualCost is the schema descriptor for actual_cost field.
|
||||
usagelogDescActualCost := usagelogFields[24].Descriptor()
|
||||
usagelogDescActualCost := usagelogFields[26].Descriptor()
|
||||
// usagelog.DefaultActualCost holds the default value on creation for the actual_cost field.
|
||||
usagelog.DefaultActualCost = usagelogDescActualCost.Default.(float64)
|
||||
// usagelogDescRateMultiplier is the schema descriptor for rate_multiplier field.
|
||||
usagelogDescRateMultiplier := usagelogFields[25].Descriptor()
|
||||
usagelogDescRateMultiplier := usagelogFields[27].Descriptor()
|
||||
// usagelog.DefaultRateMultiplier holds the default value on creation for the rate_multiplier field.
|
||||
usagelog.DefaultRateMultiplier = usagelogDescRateMultiplier.Default.(float64)
|
||||
// usagelogDescLongContextBillingApplied is the schema descriptor for long_context_billing_applied field.
|
||||
usagelogDescLongContextBillingApplied := usagelogFields[26].Descriptor()
|
||||
usagelogDescLongContextBillingApplied := usagelogFields[28].Descriptor()
|
||||
// usagelog.DefaultLongContextBillingApplied holds the default value on creation for the long_context_billing_applied field.
|
||||
usagelog.DefaultLongContextBillingApplied = usagelogDescLongContextBillingApplied.Default.(bool)
|
||||
// usagelogDescBillingType is the schema descriptor for billing_type field.
|
||||
usagelogDescBillingType := usagelogFields[28].Descriptor()
|
||||
usagelogDescBillingType := usagelogFields[30].Descriptor()
|
||||
// usagelog.DefaultBillingType holds the default value on creation for the billing_type field.
|
||||
usagelog.DefaultBillingType = usagelogDescBillingType.Default.(int8)
|
||||
// usagelogDescStream is the schema descriptor for stream field.
|
||||
usagelogDescStream := usagelogFields[29].Descriptor()
|
||||
usagelogDescStream := usagelogFields[31].Descriptor()
|
||||
// usagelog.DefaultStream holds the default value on creation for the stream field.
|
||||
usagelog.DefaultStream = usagelogDescStream.Default.(bool)
|
||||
// usagelogDescUserAgent is the schema descriptor for user_agent field.
|
||||
usagelogDescUserAgent := usagelogFields[32].Descriptor()
|
||||
usagelogDescUserAgent := usagelogFields[34].Descriptor()
|
||||
// usagelog.UserAgentValidator is a validator for the "user_agent" field. It is called by the builders before save.
|
||||
usagelog.UserAgentValidator = usagelogDescUserAgent.Validators[0].(func(string) error)
|
||||
// usagelogDescIPAddress is the schema descriptor for ip_address field.
|
||||
usagelogDescIPAddress := usagelogFields[33].Descriptor()
|
||||
usagelogDescIPAddress := usagelogFields[35].Descriptor()
|
||||
// usagelog.IPAddressValidator is a validator for the "ip_address" field. It is called by the builders before save.
|
||||
usagelog.IPAddressValidator = usagelogDescIPAddress.Validators[0].(func(string) error)
|
||||
// usagelogDescImageCount is the schema descriptor for image_count field.
|
||||
usagelogDescImageCount := usagelogFields[34].Descriptor()
|
||||
usagelogDescImageCount := usagelogFields[36].Descriptor()
|
||||
// usagelog.DefaultImageCount holds the default value on creation for the image_count field.
|
||||
usagelog.DefaultImageCount = usagelogDescImageCount.Default.(int)
|
||||
// usagelogDescImageSize is the schema descriptor for image_size field.
|
||||
usagelogDescImageSize := usagelogFields[35].Descriptor()
|
||||
usagelogDescImageSize := usagelogFields[37].Descriptor()
|
||||
// usagelog.ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save.
|
||||
usagelog.ImageSizeValidator = usagelogDescImageSize.Validators[0].(func(string) error)
|
||||
// usagelogDescImageInputSize is the schema descriptor for image_input_size field.
|
||||
usagelogDescImageInputSize := usagelogFields[36].Descriptor()
|
||||
usagelogDescImageInputSize := usagelogFields[38].Descriptor()
|
||||
// usagelog.ImageInputSizeValidator is a validator for the "image_input_size" field. It is called by the builders before save.
|
||||
usagelog.ImageInputSizeValidator = usagelogDescImageInputSize.Validators[0].(func(string) error)
|
||||
// usagelogDescImageOutputSize is the schema descriptor for image_output_size field.
|
||||
usagelogDescImageOutputSize := usagelogFields[37].Descriptor()
|
||||
usagelogDescImageOutputSize := usagelogFields[39].Descriptor()
|
||||
// usagelog.ImageOutputSizeValidator is a validator for the "image_output_size" field. It is called by the builders before save.
|
||||
usagelog.ImageOutputSizeValidator = usagelogDescImageOutputSize.Validators[0].(func(string) error)
|
||||
// usagelogDescImageSizeSource is the schema descriptor for image_size_source field.
|
||||
usagelogDescImageSizeSource := usagelogFields[38].Descriptor()
|
||||
usagelogDescImageSizeSource := usagelogFields[40].Descriptor()
|
||||
// usagelog.ImageSizeSourceValidator is a validator for the "image_size_source" field. It is called by the builders before save.
|
||||
usagelog.ImageSizeSourceValidator = usagelogDescImageSizeSource.Validators[0].(func(string) error)
|
||||
// usagelogDescVideoCount is the schema descriptor for video_count field.
|
||||
usagelogDescVideoCount := usagelogFields[40].Descriptor()
|
||||
usagelogDescVideoCount := usagelogFields[42].Descriptor()
|
||||
// usagelog.DefaultVideoCount holds the default value on creation for the video_count field.
|
||||
usagelog.DefaultVideoCount = usagelogDescVideoCount.Default.(int)
|
||||
// usagelogDescVideoResolution is the schema descriptor for video_resolution field.
|
||||
usagelogDescVideoResolution := usagelogFields[41].Descriptor()
|
||||
usagelogDescVideoResolution := usagelogFields[43].Descriptor()
|
||||
// usagelog.VideoResolutionValidator is a validator for the "video_resolution" field. It is called by the builders before save.
|
||||
usagelog.VideoResolutionValidator = usagelogDescVideoResolution.Validators[0].(func(string) error)
|
||||
// usagelogDescCacheTTLOverridden is the schema descriptor for cache_ttl_overridden field.
|
||||
usagelogDescCacheTTLOverridden := usagelogFields[43].Descriptor()
|
||||
usagelogDescCacheTTLOverridden := usagelogFields[45].Descriptor()
|
||||
// usagelog.DefaultCacheTTLOverridden holds the default value on creation for the cache_ttl_overridden field.
|
||||
usagelog.DefaultCacheTTLOverridden = usagelogDescCacheTTLOverridden.Default.(bool)
|
||||
// usagelogDescCreatedAt is the schema descriptor for created_at field.
|
||||
usagelogDescCreatedAt := usagelogFields[44].Descriptor()
|
||||
usagelogDescCreatedAt := usagelogFields[46].Descriptor()
|
||||
// usagelog.DefaultCreatedAt holds the default value on creation for the created_at field.
|
||||
usagelog.DefaultCreatedAt = usagelogDescCreatedAt.Default.(func() time.Time)
|
||||
userMixin := schema.User{}.Mixin()
|
||||
|
||||
@@ -148,12 +148,44 @@ func (Group) Fields() []ent.Field {
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}),
|
||||
field.JSON("video_model_prices", map[string]map[string]float64{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("按模型族和分辨率覆盖视频每秒价格"),
|
||||
field.Float("web_search_price_per_call").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Comment("Codex alpha/search 网页搜索单次价格(USD/次);nil 表示使用默认价 0.01(官方 $10/1000 次)"),
|
||||
|
||||
// 搜索/工具调用显式定价(per 1k calls),用于 Grok web_search 等。
|
||||
field.Float("search_price_per_1k").
|
||||
Optional().
|
||||
Nillable().
|
||||
Min(0).
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Comment("搜索工具价格 per 1000 calls(web_search 等)"),
|
||||
|
||||
// Grok Voice 显式定价(realtime / TTS / STT),不按文本 RateMultiplier。
|
||||
field.Float("audio_realtime_price_per_min").
|
||||
Optional().
|
||||
Nillable().
|
||||
Min(0).
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Comment("Voice realtime 每分钟价格(USD)"),
|
||||
field.Float("audio_tts_price_per_million_chars").
|
||||
Optional().
|
||||
Nillable().
|
||||
Min(0).
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Comment("TTS 每百万字符价格(USD)"),
|
||||
field.Float("audio_stt_price_per_hour").
|
||||
Optional().
|
||||
Nillable().
|
||||
Min(0).
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Comment("STT 每小时价格(USD)"),
|
||||
|
||||
// Claude Code 客户端限制 (added by migration 029)
|
||||
field.Bool("claude_code_only").
|
||||
Default(false).
|
||||
|
||||
@@ -53,6 +53,17 @@ func (UsageLog) Fields() []ent.Field {
|
||||
MaxLen(100).
|
||||
Optional().
|
||||
Nillable(),
|
||||
// UpstreamResponseModel stores the model name declared by the upstream
|
||||
// response before any protocol conversion or client-facing rewrite.
|
||||
field.String("upstream_response_model").
|
||||
MaxLen(200).
|
||||
Optional().
|
||||
Nillable(),
|
||||
// UpstreamModelMismatch is tri-state: NULL means the upstream response did
|
||||
// not declare a model (or predates this field); false/true means observed.
|
||||
field.Bool("upstream_model_mismatch").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.Int64("channel_id").Optional().Nillable().Comment("渠道 ID"),
|
||||
field.String("model_mapping_chain").MaxLen(500).Optional().Nillable().Comment("模型映射链"),
|
||||
field.String("billing_tier").MaxLen(50).Optional().Nillable().Comment("计费层级标签"),
|
||||
|
||||
+30
-2
@@ -37,6 +37,10 @@ type UsageLog struct {
|
||||
RequestedModel *string `json:"requested_model,omitempty"`
|
||||
// UpstreamModel holds the value of the "upstream_model" field.
|
||||
UpstreamModel *string `json:"upstream_model,omitempty"`
|
||||
// UpstreamResponseModel holds the value of the "upstream_response_model" field.
|
||||
UpstreamResponseModel *string `json:"upstream_response_model,omitempty"`
|
||||
// UpstreamModelMismatch holds the value of the "upstream_model_mismatch" field.
|
||||
UpstreamModelMismatch *bool `json:"upstream_model_mismatch,omitempty"`
|
||||
// 渠道 ID
|
||||
ChannelID *int64 `json:"channel_id,omitempty"`
|
||||
// 模型映射链
|
||||
@@ -198,13 +202,13 @@ func (*UsageLog) scanValues(columns []string) ([]any, error) {
|
||||
switch columns[i] {
|
||||
case usagelog.FieldImageSizeBreakdown:
|
||||
values[i] = new([]byte)
|
||||
case usagelog.FieldLongContextBillingApplied, usagelog.FieldStream, usagelog.FieldCacheTTLOverridden:
|
||||
case usagelog.FieldUpstreamModelMismatch, usagelog.FieldLongContextBillingApplied, usagelog.FieldStream, usagelog.FieldCacheTTLOverridden:
|
||||
values[i] = new(sql.NullBool)
|
||||
case usagelog.FieldInputCost, usagelog.FieldOutputCost, usagelog.FieldCacheCreationCost, usagelog.FieldCacheReadCost, usagelog.FieldTotalCost, usagelog.FieldActualCost, usagelog.FieldRateMultiplier, usagelog.FieldAccountRateMultiplier:
|
||||
values[i] = new(sql.NullFloat64)
|
||||
case usagelog.FieldID, usagelog.FieldUserID, usagelog.FieldAPIKeyID, usagelog.FieldAccountID, usagelog.FieldChannelID, usagelog.FieldGroupID, usagelog.FieldSubscriptionID, usagelog.FieldInputTokens, usagelog.FieldOutputTokens, usagelog.FieldCacheCreationTokens, usagelog.FieldCacheReadTokens, usagelog.FieldCacheCreation5mTokens, usagelog.FieldCacheCreation1hTokens, usagelog.FieldBillingType, usagelog.FieldDurationMs, usagelog.FieldFirstTokenMs, usagelog.FieldImageCount, usagelog.FieldVideoCount, usagelog.FieldVideoDurationSeconds:
|
||||
values[i] = new(sql.NullInt64)
|
||||
case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldRequestedModel, usagelog.FieldUpstreamModel, usagelog.FieldModelMappingChain, usagelog.FieldBillingTier, usagelog.FieldBillingMode, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize, usagelog.FieldImageInputSize, usagelog.FieldImageOutputSize, usagelog.FieldImageSizeSource, usagelog.FieldVideoResolution:
|
||||
case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldRequestedModel, usagelog.FieldUpstreamModel, usagelog.FieldUpstreamResponseModel, usagelog.FieldModelMappingChain, usagelog.FieldBillingTier, usagelog.FieldBillingMode, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize, usagelog.FieldImageInputSize, usagelog.FieldImageOutputSize, usagelog.FieldImageSizeSource, usagelog.FieldVideoResolution:
|
||||
values[i] = new(sql.NullString)
|
||||
case usagelog.FieldCreatedAt:
|
||||
values[i] = new(sql.NullTime)
|
||||
@@ -273,6 +277,20 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error {
|
||||
_m.UpstreamModel = new(string)
|
||||
*_m.UpstreamModel = value.String
|
||||
}
|
||||
case usagelog.FieldUpstreamResponseModel:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field upstream_response_model", values[i])
|
||||
} else if value.Valid {
|
||||
_m.UpstreamResponseModel = new(string)
|
||||
*_m.UpstreamResponseModel = value.String
|
||||
}
|
||||
case usagelog.FieldUpstreamModelMismatch:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field upstream_model_mismatch", values[i])
|
||||
} else if value.Valid {
|
||||
_m.UpstreamModelMismatch = new(bool)
|
||||
*_m.UpstreamModelMismatch = value.Bool
|
||||
}
|
||||
case usagelog.FieldChannelID:
|
||||
if value, ok := values[i].(*sql.NullInt64); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field channel_id", values[i])
|
||||
@@ -606,6 +624,16 @@ func (_m *UsageLog) String() string {
|
||||
builder.WriteString(*v)
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
if v := _m.UpstreamResponseModel; v != nil {
|
||||
builder.WriteString("upstream_response_model=")
|
||||
builder.WriteString(*v)
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
if v := _m.UpstreamModelMismatch; v != nil {
|
||||
builder.WriteString("upstream_model_mismatch=")
|
||||
builder.WriteString(fmt.Sprintf("%v", *v))
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
if v := _m.ChannelID; v != nil {
|
||||
builder.WriteString("channel_id=")
|
||||
builder.WriteString(fmt.Sprintf("%v", *v))
|
||||
|
||||
@@ -28,6 +28,10 @@ const (
|
||||
FieldRequestedModel = "requested_model"
|
||||
// FieldUpstreamModel holds the string denoting the upstream_model field in the database.
|
||||
FieldUpstreamModel = "upstream_model"
|
||||
// FieldUpstreamResponseModel holds the string denoting the upstream_response_model field in the database.
|
||||
FieldUpstreamResponseModel = "upstream_response_model"
|
||||
// FieldUpstreamModelMismatch holds the string denoting the upstream_model_mismatch field in the database.
|
||||
FieldUpstreamModelMismatch = "upstream_model_mismatch"
|
||||
// FieldChannelID holds the string denoting the channel_id field in the database.
|
||||
FieldChannelID = "channel_id"
|
||||
// FieldModelMappingChain holds the string denoting the model_mapping_chain field in the database.
|
||||
@@ -163,6 +167,8 @@ var Columns = []string{
|
||||
FieldModel,
|
||||
FieldRequestedModel,
|
||||
FieldUpstreamModel,
|
||||
FieldUpstreamResponseModel,
|
||||
FieldUpstreamModelMismatch,
|
||||
FieldChannelID,
|
||||
FieldModelMappingChain,
|
||||
FieldBillingTier,
|
||||
@@ -222,6 +228,8 @@ var (
|
||||
RequestedModelValidator func(string) error
|
||||
// UpstreamModelValidator is a validator for the "upstream_model" field. It is called by the builders before save.
|
||||
UpstreamModelValidator func(string) error
|
||||
// UpstreamResponseModelValidator is a validator for the "upstream_response_model" field. It is called by the builders before save.
|
||||
UpstreamResponseModelValidator func(string) error
|
||||
// ModelMappingChainValidator is a validator for the "model_mapping_chain" field. It is called by the builders before save.
|
||||
ModelMappingChainValidator func(string) error
|
||||
// BillingTierValidator is a validator for the "billing_tier" field. It is called by the builders before save.
|
||||
@@ -327,6 +335,16 @@ func ByUpstreamModel(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldUpstreamModel, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByUpstreamResponseModel orders the results by the upstream_response_model field.
|
||||
func ByUpstreamResponseModel(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldUpstreamResponseModel, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByUpstreamModelMismatch orders the results by the upstream_model_mismatch field.
|
||||
func ByUpstreamModelMismatch(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldUpstreamModelMismatch, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByChannelID orders the results by the channel_id field.
|
||||
func ByChannelID(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldChannelID, opts...).ToFunc()
|
||||
|
||||
@@ -90,6 +90,16 @@ func UpstreamModel(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModel applies equality check predicate on the "upstream_response_model" field. It's identical to UpstreamResponseModelEQ.
|
||||
func UpstreamResponseModel(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamModelMismatch applies equality check predicate on the "upstream_model_mismatch" field. It's identical to UpstreamModelMismatchEQ.
|
||||
func UpstreamModelMismatch(v bool) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModelMismatch, v))
|
||||
}
|
||||
|
||||
// ChannelID applies equality check predicate on the "channel_id" field. It's identical to ChannelIDEQ.
|
||||
func ChannelID(v int64) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldChannelID, v))
|
||||
@@ -615,6 +625,101 @@ func UpstreamModelContainsFold(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldContainsFold(FieldUpstreamModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelEQ applies the EQ predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelEQ(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelNEQ applies the NEQ predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelNEQ(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldNEQ(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelIn applies the In predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelIn(vs ...string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldIn(FieldUpstreamResponseModel, vs...))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelNotIn applies the NotIn predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelNotIn(vs ...string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldNotIn(FieldUpstreamResponseModel, vs...))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelGT applies the GT predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelGT(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldGT(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelGTE applies the GTE predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelGTE(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldGTE(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelLT applies the LT predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelLT(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldLT(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelLTE applies the LTE predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelLTE(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldLTE(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelContains applies the Contains predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelContains(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldContains(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelHasPrefix applies the HasPrefix predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelHasPrefix(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldHasPrefix(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelHasSuffix applies the HasSuffix predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelHasSuffix(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldHasSuffix(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelIsNil applies the IsNil predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelIsNil() predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldIsNull(FieldUpstreamResponseModel))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelNotNil applies the NotNil predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelNotNil() predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldNotNull(FieldUpstreamResponseModel))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelEqualFold applies the EqualFold predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelEqualFold(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEqualFold(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelContainsFold applies the ContainsFold predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelContainsFold(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldContainsFold(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamModelMismatchEQ applies the EQ predicate on the "upstream_model_mismatch" field.
|
||||
func UpstreamModelMismatchEQ(v bool) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModelMismatch, v))
|
||||
}
|
||||
|
||||
// UpstreamModelMismatchNEQ applies the NEQ predicate on the "upstream_model_mismatch" field.
|
||||
func UpstreamModelMismatchNEQ(v bool) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldNEQ(FieldUpstreamModelMismatch, v))
|
||||
}
|
||||
|
||||
// UpstreamModelMismatchIsNil applies the IsNil predicate on the "upstream_model_mismatch" field.
|
||||
func UpstreamModelMismatchIsNil() predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldIsNull(FieldUpstreamModelMismatch))
|
||||
}
|
||||
|
||||
// UpstreamModelMismatchNotNil applies the NotNil predicate on the "upstream_model_mismatch" field.
|
||||
func UpstreamModelMismatchNotNil() predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldNotNull(FieldUpstreamModelMismatch))
|
||||
}
|
||||
|
||||
// ChannelIDEQ applies the EQ predicate on the "channel_id" field.
|
||||
func ChannelIDEQ(v int64) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldChannelID, v))
|
||||
|
||||
@@ -85,6 +85,34 @@ func (_c *UsageLogCreate) SetNillableUpstreamModel(v *string) *UsageLogCreate {
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetUpstreamResponseModel sets the "upstream_response_model" field.
|
||||
func (_c *UsageLogCreate) SetUpstreamResponseModel(v string) *UsageLogCreate {
|
||||
_c.mutation.SetUpstreamResponseModel(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableUpstreamResponseModel sets the "upstream_response_model" field if the given value is not nil.
|
||||
func (_c *UsageLogCreate) SetNillableUpstreamResponseModel(v *string) *UsageLogCreate {
|
||||
if v != nil {
|
||||
_c.SetUpstreamResponseModel(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
|
||||
func (_c *UsageLogCreate) SetUpstreamModelMismatch(v bool) *UsageLogCreate {
|
||||
_c.mutation.SetUpstreamModelMismatch(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableUpstreamModelMismatch sets the "upstream_model_mismatch" field if the given value is not nil.
|
||||
func (_c *UsageLogCreate) SetNillableUpstreamModelMismatch(v *bool) *UsageLogCreate {
|
||||
if v != nil {
|
||||
_c.SetUpstreamModelMismatch(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetChannelID sets the "channel_id" field.
|
||||
func (_c *UsageLogCreate) SetChannelID(v int64) *UsageLogCreate {
|
||||
_c.mutation.SetChannelID(v)
|
||||
@@ -788,6 +816,11 @@ func (_c *UsageLogCreate) check() error {
|
||||
return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _c.mutation.UpstreamResponseModel(); ok {
|
||||
if err := usagelog.UpstreamResponseModelValidator(v); err != nil {
|
||||
return &ValidationError{Name: "upstream_response_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_response_model": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _c.mutation.ModelMappingChain(); ok {
|
||||
if err := usagelog.ModelMappingChainValidator(v); err != nil {
|
||||
return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)}
|
||||
@@ -950,6 +983,14 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(usagelog.FieldUpstreamModel, field.TypeString, value)
|
||||
_node.UpstreamModel = &value
|
||||
}
|
||||
if value, ok := _c.mutation.UpstreamResponseModel(); ok {
|
||||
_spec.SetField(usagelog.FieldUpstreamResponseModel, field.TypeString, value)
|
||||
_node.UpstreamResponseModel = &value
|
||||
}
|
||||
if value, ok := _c.mutation.UpstreamModelMismatch(); ok {
|
||||
_spec.SetField(usagelog.FieldUpstreamModelMismatch, field.TypeBool, value)
|
||||
_node.UpstreamModelMismatch = &value
|
||||
}
|
||||
if value, ok := _c.mutation.ChannelID(); ok {
|
||||
_spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value)
|
||||
_node.ChannelID = &value
|
||||
@@ -1327,6 +1368,42 @@ func (u *UsageLogUpsert) ClearUpstreamModel() *UsageLogUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetUpstreamResponseModel sets the "upstream_response_model" field.
|
||||
func (u *UsageLogUpsert) SetUpstreamResponseModel(v string) *UsageLogUpsert {
|
||||
u.Set(usagelog.FieldUpstreamResponseModel, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateUpstreamResponseModel sets the "upstream_response_model" field to the value that was provided on create.
|
||||
func (u *UsageLogUpsert) UpdateUpstreamResponseModel() *UsageLogUpsert {
|
||||
u.SetExcluded(usagelog.FieldUpstreamResponseModel)
|
||||
return u
|
||||
}
|
||||
|
||||
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
|
||||
func (u *UsageLogUpsert) ClearUpstreamResponseModel() *UsageLogUpsert {
|
||||
u.SetNull(usagelog.FieldUpstreamResponseModel)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
|
||||
func (u *UsageLogUpsert) SetUpstreamModelMismatch(v bool) *UsageLogUpsert {
|
||||
u.Set(usagelog.FieldUpstreamModelMismatch, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateUpstreamModelMismatch sets the "upstream_model_mismatch" field to the value that was provided on create.
|
||||
func (u *UsageLogUpsert) UpdateUpstreamModelMismatch() *UsageLogUpsert {
|
||||
u.SetExcluded(usagelog.FieldUpstreamModelMismatch)
|
||||
return u
|
||||
}
|
||||
|
||||
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
|
||||
func (u *UsageLogUpsert) ClearUpstreamModelMismatch() *UsageLogUpsert {
|
||||
u.SetNull(usagelog.FieldUpstreamModelMismatch)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetChannelID sets the "channel_id" field.
|
||||
func (u *UsageLogUpsert) SetChannelID(v int64) *UsageLogUpsert {
|
||||
u.Set(usagelog.FieldChannelID, v)
|
||||
@@ -2162,6 +2239,48 @@ func (u *UsageLogUpsertOne) ClearUpstreamModel() *UsageLogUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetUpstreamResponseModel sets the "upstream_response_model" field.
|
||||
func (u *UsageLogUpsertOne) SetUpstreamResponseModel(v string) *UsageLogUpsertOne {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.SetUpstreamResponseModel(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateUpstreamResponseModel sets the "upstream_response_model" field to the value that was provided on create.
|
||||
func (u *UsageLogUpsertOne) UpdateUpstreamResponseModel() *UsageLogUpsertOne {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.UpdateUpstreamResponseModel()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
|
||||
func (u *UsageLogUpsertOne) ClearUpstreamResponseModel() *UsageLogUpsertOne {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.ClearUpstreamResponseModel()
|
||||
})
|
||||
}
|
||||
|
||||
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
|
||||
func (u *UsageLogUpsertOne) SetUpstreamModelMismatch(v bool) *UsageLogUpsertOne {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.SetUpstreamModelMismatch(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateUpstreamModelMismatch sets the "upstream_model_mismatch" field to the value that was provided on create.
|
||||
func (u *UsageLogUpsertOne) UpdateUpstreamModelMismatch() *UsageLogUpsertOne {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.UpdateUpstreamModelMismatch()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
|
||||
func (u *UsageLogUpsertOne) ClearUpstreamModelMismatch() *UsageLogUpsertOne {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.ClearUpstreamModelMismatch()
|
||||
})
|
||||
}
|
||||
|
||||
// SetChannelID sets the "channel_id" field.
|
||||
func (u *UsageLogUpsertOne) SetChannelID(v int64) *UsageLogUpsertOne {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
@@ -3276,6 +3395,48 @@ func (u *UsageLogUpsertBulk) ClearUpstreamModel() *UsageLogUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetUpstreamResponseModel sets the "upstream_response_model" field.
|
||||
func (u *UsageLogUpsertBulk) SetUpstreamResponseModel(v string) *UsageLogUpsertBulk {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.SetUpstreamResponseModel(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateUpstreamResponseModel sets the "upstream_response_model" field to the value that was provided on create.
|
||||
func (u *UsageLogUpsertBulk) UpdateUpstreamResponseModel() *UsageLogUpsertBulk {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.UpdateUpstreamResponseModel()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
|
||||
func (u *UsageLogUpsertBulk) ClearUpstreamResponseModel() *UsageLogUpsertBulk {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.ClearUpstreamResponseModel()
|
||||
})
|
||||
}
|
||||
|
||||
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
|
||||
func (u *UsageLogUpsertBulk) SetUpstreamModelMismatch(v bool) *UsageLogUpsertBulk {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.SetUpstreamModelMismatch(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateUpstreamModelMismatch sets the "upstream_model_mismatch" field to the value that was provided on create.
|
||||
func (u *UsageLogUpsertBulk) UpdateUpstreamModelMismatch() *UsageLogUpsertBulk {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.UpdateUpstreamModelMismatch()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
|
||||
func (u *UsageLogUpsertBulk) ClearUpstreamModelMismatch() *UsageLogUpsertBulk {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.ClearUpstreamModelMismatch()
|
||||
})
|
||||
}
|
||||
|
||||
// SetChannelID sets the "channel_id" field.
|
||||
func (u *UsageLogUpsertBulk) SetChannelID(v int64) *UsageLogUpsertBulk {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
|
||||
@@ -142,6 +142,46 @@ func (_u *UsageLogUpdate) ClearUpstreamModel() *UsageLogUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetUpstreamResponseModel sets the "upstream_response_model" field.
|
||||
func (_u *UsageLogUpdate) SetUpstreamResponseModel(v string) *UsageLogUpdate {
|
||||
_u.mutation.SetUpstreamResponseModel(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableUpstreamResponseModel sets the "upstream_response_model" field if the given value is not nil.
|
||||
func (_u *UsageLogUpdate) SetNillableUpstreamResponseModel(v *string) *UsageLogUpdate {
|
||||
if v != nil {
|
||||
_u.SetUpstreamResponseModel(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
|
||||
func (_u *UsageLogUpdate) ClearUpstreamResponseModel() *UsageLogUpdate {
|
||||
_u.mutation.ClearUpstreamResponseModel()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
|
||||
func (_u *UsageLogUpdate) SetUpstreamModelMismatch(v bool) *UsageLogUpdate {
|
||||
_u.mutation.SetUpstreamModelMismatch(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableUpstreamModelMismatch sets the "upstream_model_mismatch" field if the given value is not nil.
|
||||
func (_u *UsageLogUpdate) SetNillableUpstreamModelMismatch(v *bool) *UsageLogUpdate {
|
||||
if v != nil {
|
||||
_u.SetUpstreamModelMismatch(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
|
||||
func (_u *UsageLogUpdate) ClearUpstreamModelMismatch() *UsageLogUpdate {
|
||||
_u.mutation.ClearUpstreamModelMismatch()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetChannelID sets the "channel_id" field.
|
||||
func (_u *UsageLogUpdate) SetChannelID(v int64) *UsageLogUpdate {
|
||||
_u.mutation.ResetChannelID()
|
||||
@@ -1016,6 +1056,11 @@ func (_u *UsageLogUpdate) check() error {
|
||||
return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.UpstreamResponseModel(); ok {
|
||||
if err := usagelog.UpstreamResponseModelValidator(v); err != nil {
|
||||
return &ValidationError{Name: "upstream_response_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_response_model": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.ModelMappingChain(); ok {
|
||||
if err := usagelog.ModelMappingChainValidator(v); err != nil {
|
||||
return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)}
|
||||
@@ -1108,6 +1153,18 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if _u.mutation.UpstreamModelCleared() {
|
||||
_spec.ClearField(usagelog.FieldUpstreamModel, field.TypeString)
|
||||
}
|
||||
if value, ok := _u.mutation.UpstreamResponseModel(); ok {
|
||||
_spec.SetField(usagelog.FieldUpstreamResponseModel, field.TypeString, value)
|
||||
}
|
||||
if _u.mutation.UpstreamResponseModelCleared() {
|
||||
_spec.ClearField(usagelog.FieldUpstreamResponseModel, field.TypeString)
|
||||
}
|
||||
if value, ok := _u.mutation.UpstreamModelMismatch(); ok {
|
||||
_spec.SetField(usagelog.FieldUpstreamModelMismatch, field.TypeBool, value)
|
||||
}
|
||||
if _u.mutation.UpstreamModelMismatchCleared() {
|
||||
_spec.ClearField(usagelog.FieldUpstreamModelMismatch, field.TypeBool)
|
||||
}
|
||||
if value, ok := _u.mutation.ChannelID(); ok {
|
||||
_spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value)
|
||||
}
|
||||
@@ -1599,6 +1656,46 @@ func (_u *UsageLogUpdateOne) ClearUpstreamModel() *UsageLogUpdateOne {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetUpstreamResponseModel sets the "upstream_response_model" field.
|
||||
func (_u *UsageLogUpdateOne) SetUpstreamResponseModel(v string) *UsageLogUpdateOne {
|
||||
_u.mutation.SetUpstreamResponseModel(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableUpstreamResponseModel sets the "upstream_response_model" field if the given value is not nil.
|
||||
func (_u *UsageLogUpdateOne) SetNillableUpstreamResponseModel(v *string) *UsageLogUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetUpstreamResponseModel(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
|
||||
func (_u *UsageLogUpdateOne) ClearUpstreamResponseModel() *UsageLogUpdateOne {
|
||||
_u.mutation.ClearUpstreamResponseModel()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
|
||||
func (_u *UsageLogUpdateOne) SetUpstreamModelMismatch(v bool) *UsageLogUpdateOne {
|
||||
_u.mutation.SetUpstreamModelMismatch(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableUpstreamModelMismatch sets the "upstream_model_mismatch" field if the given value is not nil.
|
||||
func (_u *UsageLogUpdateOne) SetNillableUpstreamModelMismatch(v *bool) *UsageLogUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetUpstreamModelMismatch(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
|
||||
func (_u *UsageLogUpdateOne) ClearUpstreamModelMismatch() *UsageLogUpdateOne {
|
||||
_u.mutation.ClearUpstreamModelMismatch()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetChannelID sets the "channel_id" field.
|
||||
func (_u *UsageLogUpdateOne) SetChannelID(v int64) *UsageLogUpdateOne {
|
||||
_u.mutation.ResetChannelID()
|
||||
@@ -2486,6 +2583,11 @@ func (_u *UsageLogUpdateOne) check() error {
|
||||
return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.UpstreamResponseModel(); ok {
|
||||
if err := usagelog.UpstreamResponseModelValidator(v); err != nil {
|
||||
return &ValidationError{Name: "upstream_response_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_response_model": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.ModelMappingChain(); ok {
|
||||
if err := usagelog.ModelMappingChainValidator(v); err != nil {
|
||||
return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)}
|
||||
@@ -2595,6 +2697,18 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err
|
||||
if _u.mutation.UpstreamModelCleared() {
|
||||
_spec.ClearField(usagelog.FieldUpstreamModel, field.TypeString)
|
||||
}
|
||||
if value, ok := _u.mutation.UpstreamResponseModel(); ok {
|
||||
_spec.SetField(usagelog.FieldUpstreamResponseModel, field.TypeString, value)
|
||||
}
|
||||
if _u.mutation.UpstreamResponseModelCleared() {
|
||||
_spec.ClearField(usagelog.FieldUpstreamResponseModel, field.TypeString)
|
||||
}
|
||||
if value, ok := _u.mutation.UpstreamModelMismatch(); ok {
|
||||
_spec.SetField(usagelog.FieldUpstreamModelMismatch, field.TypeBool, value)
|
||||
}
|
||||
if _u.mutation.UpstreamModelMismatchCleared() {
|
||||
_spec.ClearField(usagelog.FieldUpstreamModelMismatch, field.TypeBool)
|
||||
}
|
||||
if value, ok := _u.mutation.ChannelID(); ok {
|
||||
_spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value)
|
||||
}
|
||||
|
||||
@@ -250,6 +250,8 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:E
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
@@ -310,6 +312,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||
@@ -346,6 +350,8 @@ github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7P
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
@@ -380,6 +386,8 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -415,6 +423,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
|
||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||
|
||||
@@ -1022,6 +1022,39 @@ type GatewayConfig struct {
|
||||
// UserMessageQueue: 用户消息串行队列配置
|
||||
// 对 role:"user" 的真实用户消息实施账号级串行化 + RPM 自适应延迟
|
||||
UserMessageQueue UserMessageQueueConfig `mapstructure:"user_message_queue"`
|
||||
|
||||
// Grok: Grok/xAI gateway scheduling and free-tier soft-gate settings.
|
||||
Grok GatewayGrokConfig `mapstructure:"grok"`
|
||||
}
|
||||
|
||||
// GatewayGrokConfig holds Grok-specific gateway scheduling knobs.
|
||||
//
|
||||
// Free-quota soft gate keys (gateway.grok.*):
|
||||
// - free_quota_soft_gate_enabled: enable local rolling-window scheduling guard for
|
||||
// OAuth accounts whose subscription_tier/plan_type is explicitly "free".
|
||||
// Default true is safe only because free-tier detection is strict (unknown/paid fail open).
|
||||
// - free_quota_token_limit: nominal rolling-window token allowance.
|
||||
// - free_quota_soft_gate_percent: stop new scheduling before the nominal limit (1-100).
|
||||
// - free_quota_window_hours: local usage rolling window length in hours.
|
||||
// - free_quota_stats_cache_seconds: cache TTL for free-tier usage stats
|
||||
// (hot path never blocks on DB; misses fail open and refresh in background).
|
||||
type GatewayGrokConfig struct {
|
||||
// PasswordAuthEnabled controls the optional password-to-SSO OAuth flow.
|
||||
// It defaults to false and must be explicitly enabled by the operator.
|
||||
// When true, POST /admin/grok/oauth/password is functional (not ignored).
|
||||
PasswordAuthEnabled bool `mapstructure:"password_auth_enabled"`
|
||||
// FreeQuotaSoftGateEnabled enables a local rolling-window scheduling guard
|
||||
// for explicitly free Grok OAuth accounts only.
|
||||
FreeQuotaSoftGateEnabled bool `mapstructure:"free_quota_soft_gate_enabled"`
|
||||
// FreeQuotaTokenLimit is the nominal rolling-window allowance.
|
||||
FreeQuotaTokenLimit int64 `mapstructure:"free_quota_token_limit"`
|
||||
// FreeQuotaSoftGatePercent stops new scheduling before the nominal limit.
|
||||
FreeQuotaSoftGatePercent int `mapstructure:"free_quota_soft_gate_percent"`
|
||||
// FreeQuotaWindowHours controls the local rolling usage window.
|
||||
FreeQuotaWindowHours int `mapstructure:"free_quota_window_hours"`
|
||||
// FreeQuotaStatsCacheSeconds is the soft-gate stats cache TTL. Hot path never
|
||||
// waits on usage_logs; misses fail open and refresh asynchronously.
|
||||
FreeQuotaStatsCacheSeconds int `mapstructure:"free_quota_stats_cache_seconds"`
|
||||
}
|
||||
|
||||
type GatewayLiveConfig struct {
|
||||
@@ -2309,6 +2342,15 @@ func setDefaults() {
|
||||
viper.SetDefault("gateway.openai_proxy_stream_circuit.failure_threshold", 2)
|
||||
viper.SetDefault("gateway.openai_proxy_stream_circuit.window_seconds", 60)
|
||||
viper.SetDefault("gateway.openai_proxy_stream_circuit.ttl_seconds", 600)
|
||||
// Grok free-tier local soft gate (scheduler-only; admin QueryQuota does not use this).
|
||||
// Enabled by default because free detection requires an explicit free tier marker.
|
||||
viper.SetDefault("gateway.grok.free_quota_soft_gate_enabled", true)
|
||||
viper.SetDefault("gateway.grok.password_auth_enabled", false)
|
||||
// Free soft-gate nominal limit: 500k tokens / rolling 24h (operator policy).
|
||||
viper.SetDefault("gateway.grok.free_quota_token_limit", int64(500_000))
|
||||
viper.SetDefault("gateway.grok.free_quota_soft_gate_percent", 95)
|
||||
viper.SetDefault("gateway.grok.free_quota_window_hours", 24)
|
||||
viper.SetDefault("gateway.grok.free_quota_stats_cache_seconds", 60)
|
||||
viper.SetDefault("gateway.image_concurrency.enabled", false)
|
||||
viper.SetDefault("gateway.image_concurrency.max_concurrent_requests", 0)
|
||||
viper.SetDefault("gateway.image_concurrency.overflow_mode", ImageConcurrencyOverflowModeReject)
|
||||
@@ -3518,6 +3560,20 @@ func (c *Config) Validate() error {
|
||||
if c.Concurrency.PingInterval < 5 || c.Concurrency.PingInterval > 30 {
|
||||
return fmt.Errorf("concurrency.ping_interval must be between 5-30 seconds")
|
||||
}
|
||||
if c.Gateway.Grok.FreeQuotaSoftGateEnabled {
|
||||
if c.Gateway.Grok.FreeQuotaTokenLimit <= 0 {
|
||||
return fmt.Errorf("gateway.grok.free_quota_token_limit must be positive")
|
||||
}
|
||||
if c.Gateway.Grok.FreeQuotaSoftGatePercent < 1 || c.Gateway.Grok.FreeQuotaSoftGatePercent > 100 {
|
||||
return fmt.Errorf("gateway.grok.free_quota_soft_gate_percent must be between 1 and 100")
|
||||
}
|
||||
if c.Gateway.Grok.FreeQuotaWindowHours <= 0 {
|
||||
return fmt.Errorf("gateway.grok.free_quota_window_hours must be positive")
|
||||
}
|
||||
}
|
||||
if c.Gateway.Grok.FreeQuotaStatsCacheSeconds < 0 {
|
||||
return fmt.Errorf("gateway.grok.free_quota_stats_cache_seconds must be non-negative")
|
||||
}
|
||||
if err := ValidateDingTalkConfig(c.DingTalk); err != nil {
|
||||
return fmt.Errorf("dingtalk_connect: %w", err)
|
||||
}
|
||||
|
||||
@@ -538,6 +538,19 @@ func TestLoadOpenAICompactModelFromEnv(t *testing.T) {
|
||||
require.Equal(t, "gpt-5.3-codex", cfg.Gateway.OpenAICompactModel)
|
||||
}
|
||||
|
||||
func TestLoadDefaultGrokFreeQuotaSoftGate(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.False(t, cfg.Gateway.Grok.PasswordAuthEnabled)
|
||||
require.True(t, cfg.Gateway.Grok.FreeQuotaSoftGateEnabled)
|
||||
require.Equal(t, int64(500_000), cfg.Gateway.Grok.FreeQuotaTokenLimit)
|
||||
require.Equal(t, 95, cfg.Gateway.Grok.FreeQuotaSoftGatePercent)
|
||||
require.Equal(t, 24, cfg.Gateway.Grok.FreeQuotaWindowHours)
|
||||
require.Equal(t, 60, cfg.Gateway.Grok.FreeQuotaStatsCacheSeconds)
|
||||
}
|
||||
|
||||
func TestLoadDefaultOpenAIHTTP2Enabled(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
|
||||
@@ -117,6 +117,12 @@ var DefaultAntigravityModelMapping = map[string]string{
|
||||
"gemini-3.1-flash-image": "gemini-3.1-flash-image",
|
||||
// Gemini 3.1 image preview 映射
|
||||
"gemini-3.1-flash-image-preview": "gemini-3.1-flash-image",
|
||||
// Gemini 3.6 Flash tiered models
|
||||
"gemini-3.6-flash": "gemini-3.6-flash",
|
||||
"gemini-3.6-flash-high": "gemini-3.6-flash-high",
|
||||
"gemini-3.6-flash-low": "gemini-3.6-flash-low",
|
||||
"gemini-3.6-flash-medium": "gemini-3.6-flash-medium",
|
||||
"gemini-3.6-flash-tiered": "gemini-3.6-flash-tiered",
|
||||
// Gemini 3 image 兼容映射(向 3.1 image 迁移)
|
||||
"gemini-3-pro-image": "gemini-3.1-flash-image",
|
||||
"gemini-3-pro-image-preview": "gemini-3.1-flash-image",
|
||||
|
||||
@@ -65,6 +65,14 @@ func TestDefaultAntigravityModelMapping_Gemini31ProAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAntigravityModelMapping_Gemini36FlashModels(t *testing.T) {
|
||||
for _, model := range []string{"gemini-3.6-flash", "gemini-3.6-flash-high", "gemini-3.6-flash-low", "gemini-3.6-flash-medium", "gemini-3.6-flash-tiered"} {
|
||||
if got := DefaultAntigravityModelMapping[model]; got != model {
|
||||
t.Fatalf("expected %s to map to itself, got %q", model, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1065,6 +1065,10 @@ type TestAccountRequest struct {
|
||||
ModelID string `json:"model_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
Mode string `json:"mode"`
|
||||
// Optional media for Grok (and future) real generation tests.
|
||||
// ImageDataURL / AudioDataURL are data:<mime>;base64,... payloads.
|
||||
ImageDataURL string `json:"image_data_url"`
|
||||
AudioDataURL string `json:"audio_data_url"`
|
||||
}
|
||||
|
||||
type SyncFromCRSRequest struct {
|
||||
@@ -1094,8 +1098,13 @@ func (h *AccountHandler) Test(c *gin.Context) {
|
||||
// Allow empty body, model_id is optional
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
opts := service.AccountTestOptions{
|
||||
ImageDataURL: req.ImageDataURL,
|
||||
AudioDataURL: req.AudioDataURL,
|
||||
}
|
||||
|
||||
// Use AccountTestService to test the account with SSE streaming
|
||||
if err := h.accountTestService.TestAccountConnection(c, accountID, req.ModelID, req.Prompt, req.Mode); err != nil {
|
||||
if err := h.accountTestService.TestAccountConnection(c, accountID, req.ModelID, req.Prompt, req.Mode, opts); err != nil {
|
||||
// Error already sent via SSE, just log
|
||||
return
|
||||
}
|
||||
@@ -1415,6 +1424,9 @@ func (h *AccountHandler) ApplyOAuthCredentials(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Drop SSO/password residue; re-auth must leave only OAuth tokens on disk.
|
||||
req.Credentials = service.SanitizeStoredCredentials(existing.Platform, req.Credentials)
|
||||
|
||||
updatedAccount, err := h.adminService.UpdateAccount(ctx, accountID, &service.UpdateAccountInput{
|
||||
Type: req.Type,
|
||||
Credentials: req.Credentials,
|
||||
@@ -1442,6 +1454,20 @@ func (h *AccountHandler) ApplyOAuthCredentials(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Successful re-auth clears the soft spending-limit reauth flag for Grok.
|
||||
if existing.Platform == service.PlatformGrok {
|
||||
if clearErr := h.adminService.UpdateAccountExtra(ctx, accountID, map[string]any{
|
||||
"grok_needs_reauth": false,
|
||||
"grok_needs_reauth_reason": "",
|
||||
"grok_needs_reauth_at": "",
|
||||
}); clearErr != nil {
|
||||
slog.Warn("apply_oauth_credentials.clear_grok_reauth_failed",
|
||||
"account_id", accountID,
|
||||
"err", clearErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if cleared, clearErr := h.adminService.ClearAccountError(ctx, accountID); clearErr != nil {
|
||||
slog.Warn("apply_oauth_credentials.clear_error_failed",
|
||||
"account_id", accountID,
|
||||
@@ -2422,6 +2448,11 @@ type BatchTodayStatsRequest struct {
|
||||
AccountIDs []int64 `json:"account_ids" binding:"required"`
|
||||
}
|
||||
|
||||
type BatchUsageRequest struct {
|
||||
AccountIDs []int64 `json:"account_ids" binding:"required"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
// GetBatchTodayStats 批量获取多个账号的今日统计。
|
||||
// POST /api/v1/admin/accounts/today-stats/batch
|
||||
func (h *AccountHandler) GetBatchTodayStats(c *gin.Context) {
|
||||
@@ -2468,6 +2499,36 @@ func (h *AccountHandler) GetBatchTodayStats(c *gin.Context) {
|
||||
response.Success(c, payload)
|
||||
}
|
||||
|
||||
// GetBatchUsage 批量获取多个账号的 current usage。
|
||||
// POST /api/v1/admin/accounts/usage/batch
|
||||
func (h *AccountHandler) GetBatchUsage(c *gin.Context) {
|
||||
var req BatchUsageRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
accountIDs := normalizeInt64IDList(req.AccountIDs)
|
||||
if len(accountIDs) == 0 {
|
||||
response.Success(c, gin.H{
|
||||
"usage": map[string]any{},
|
||||
"errors": map[string]string{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
usageByAccount, errorsByAccount, err := h.accountUsageService.GetUsageBatch(c.Request.Context(), accountIDs, req.Force)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"usage": usageByAccount,
|
||||
"errors": errorsByAccount,
|
||||
})
|
||||
}
|
||||
|
||||
// SetSchedulableRequest represents the request body for setting schedulable status
|
||||
type SetSchedulableRequest struct {
|
||||
Schedulable bool `json:"schedulable"`
|
||||
|
||||
@@ -64,6 +64,18 @@ func parseTimeRange(c *gin.Context) (time.Time, time.Time) {
|
||||
return startTime, endTime
|
||||
}
|
||||
|
||||
func parseOptionalBoolDashboardFilter(c *gin.Context, name string) (*bool, error) {
|
||||
raw := strings.TrimSpace(c.Query(name))
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
value, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &value, nil
|
||||
}
|
||||
|
||||
// GetStats handles getting dashboard statistics
|
||||
// GET /api/v1/admin/dashboard/stats
|
||||
func (h *DashboardHandler) GetStats(c *gin.Context) {
|
||||
@@ -200,6 +212,7 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
|
||||
var requestType *int16
|
||||
var stream *bool
|
||||
var billingType *int8
|
||||
var upstreamModelMismatch *bool
|
||||
|
||||
if userIDStr := c.Query("user_id"); userIDStr != "" {
|
||||
if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
|
||||
@@ -249,8 +262,13 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
|
||||
return
|
||||
}
|
||||
|
||||
trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType)
|
||||
trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, upstreamModelMismatch)
|
||||
if err != nil {
|
||||
response.Error(c, 500, "Failed to get usage trend")
|
||||
return
|
||||
@@ -277,6 +295,7 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
|
||||
var requestType *int16
|
||||
var stream *bool
|
||||
var billingType *int8
|
||||
var upstreamModelMismatch *bool
|
||||
|
||||
if userIDStr := c.Query("user_id"); userIDStr != "" {
|
||||
if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
|
||||
@@ -330,8 +349,13 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
|
||||
return
|
||||
}
|
||||
|
||||
stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, billingType)
|
||||
stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, billingType, upstreamModelMismatch)
|
||||
if err != nil {
|
||||
response.Error(c, 500, "Failed to get model statistics")
|
||||
return
|
||||
@@ -355,6 +379,7 @@ func (h *DashboardHandler) GetGroupStats(c *gin.Context) {
|
||||
var requestType *int16
|
||||
var stream *bool
|
||||
var billingType *int8
|
||||
var upstreamModelMismatch *bool
|
||||
|
||||
if userIDStr := c.Query("user_id"); userIDStr != "" {
|
||||
if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
|
||||
@@ -401,8 +426,13 @@ func (h *DashboardHandler) GetGroupStats(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
|
||||
return
|
||||
}
|
||||
|
||||
stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
|
||||
stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, upstreamModelMismatch)
|
||||
if err != nil {
|
||||
response.Error(c, 500, "Failed to get group statistics")
|
||||
return
|
||||
|
||||
@@ -19,11 +19,26 @@ type dashboardUsageRepoCapture struct {
|
||||
trendStream *bool
|
||||
modelRequestType *int16
|
||||
modelStream *bool
|
||||
trendMismatch *bool
|
||||
modelMismatch *bool
|
||||
groupMismatch *bool
|
||||
rankingLimit int
|
||||
ranking []usagestats.UserSpendingRankingItem
|
||||
rankingTotal float64
|
||||
}
|
||||
|
||||
func (s *dashboardUsageRepoCapture) GetUsageTrendWithUsageFilters(
|
||||
ctx context.Context,
|
||||
startTime, endTime time.Time,
|
||||
granularity string,
|
||||
filters usagestats.UsageLogFilters,
|
||||
) ([]usagestats.TrendDataPoint, error) {
|
||||
s.trendRequestType = filters.RequestType
|
||||
s.trendStream = filters.Stream
|
||||
s.trendMismatch = filters.UpstreamModelMismatch
|
||||
return []usagestats.TrendDataPoint{}, nil
|
||||
}
|
||||
|
||||
func (s *dashboardUsageRepoCapture) GetUsageTrendWithFilters(
|
||||
ctx context.Context,
|
||||
startTime, endTime time.Time,
|
||||
@@ -39,6 +54,27 @@ func (s *dashboardUsageRepoCapture) GetUsageTrendWithFilters(
|
||||
return []usagestats.TrendDataPoint{}, nil
|
||||
}
|
||||
|
||||
func (s *dashboardUsageRepoCapture) GetModelStatsWithUsageFiltersBySource(
|
||||
ctx context.Context,
|
||||
startTime, endTime time.Time,
|
||||
filters usagestats.UsageLogFilters,
|
||||
source string,
|
||||
) ([]usagestats.ModelStat, error) {
|
||||
s.modelRequestType = filters.RequestType
|
||||
s.modelStream = filters.Stream
|
||||
s.modelMismatch = filters.UpstreamModelMismatch
|
||||
return []usagestats.ModelStat{}, nil
|
||||
}
|
||||
|
||||
func (s *dashboardUsageRepoCapture) GetGroupStatsWithUsageFilters(
|
||||
ctx context.Context,
|
||||
startTime, endTime time.Time,
|
||||
filters usagestats.UsageLogFilters,
|
||||
) ([]usagestats.GroupStat, error) {
|
||||
s.groupMismatch = filters.UpstreamModelMismatch
|
||||
return []usagestats.GroupStat{}, nil
|
||||
}
|
||||
|
||||
func (s *dashboardUsageRepoCapture) GetModelStatsWithFilters(
|
||||
ctx context.Context,
|
||||
startTime, endTime time.Time,
|
||||
@@ -73,6 +109,7 @@ func newDashboardRequestTypeTestRouter(repo *dashboardUsageRepoCapture) *gin.Eng
|
||||
router := gin.New()
|
||||
router.GET("/admin/dashboard/trend", handler.GetUsageTrend)
|
||||
router.GET("/admin/dashboard/models", handler.GetModelStats)
|
||||
router.GET("/admin/dashboard/groups", handler.GetGroupStats)
|
||||
router.GET("/admin/dashboard/users-ranking", handler.GetUserSpendingRanking)
|
||||
return router
|
||||
}
|
||||
@@ -171,6 +208,46 @@ func TestDashboardModelStatsValidModelSource(t *testing.T) {
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
func TestDashboardModelAuditFilterPropagatesToTrendModelAndGroupQueries(t *testing.T) {
|
||||
resetDashboardReadCachesForTest()
|
||||
repo := &dashboardUsageRepoCapture{}
|
||||
router := newDashboardRequestTypeTestRouter(repo)
|
||||
|
||||
for _, path := range []string{
|
||||
"/admin/dashboard/trend?upstream_model_mismatch=true",
|
||||
"/admin/dashboard/models?upstream_model_mismatch=true",
|
||||
"/admin/dashboard/groups?upstream_model_mismatch=true",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code, path)
|
||||
}
|
||||
|
||||
require.NotNil(t, repo.trendMismatch)
|
||||
require.True(t, *repo.trendMismatch)
|
||||
require.NotNil(t, repo.modelMismatch)
|
||||
require.True(t, *repo.modelMismatch)
|
||||
require.NotNil(t, repo.groupMismatch)
|
||||
require.True(t, *repo.groupMismatch)
|
||||
}
|
||||
|
||||
func TestDashboardModelAuditFilterRejectsInvalidBoolean(t *testing.T) {
|
||||
repo := &dashboardUsageRepoCapture{}
|
||||
router := newDashboardRequestTypeTestRouter(repo)
|
||||
|
||||
for _, path := range []string{
|
||||
"/admin/dashboard/trend?upstream_model_mismatch=invalid",
|
||||
"/admin/dashboard/models?upstream_model_mismatch=invalid",
|
||||
"/admin/dashboard/groups?upstream_model_mismatch=invalid",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code, path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardUsersRankingLimitAndCache(t *testing.T) {
|
||||
dashboardUsersRankingCache = newSnapshotCache(5 * time.Minute)
|
||||
repo := &dashboardUsageRepoCapture{
|
||||
|
||||
@@ -18,30 +18,32 @@ var (
|
||||
)
|
||||
|
||||
type dashboardTrendCacheKey struct {
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
Granularity string `json:"granularity"`
|
||||
UserID int64 `json:"user_id"`
|
||||
APIKeyID int64 `json:"api_key_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
Model string `json:"model"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
Granularity string `json:"granularity"`
|
||||
UserID int64 `json:"user_id"`
|
||||
APIKeyID int64 `json:"api_key_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
Model string `json:"model"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
|
||||
}
|
||||
|
||||
type dashboardModelGroupCacheKey struct {
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
UserID int64 `json:"user_id"`
|
||||
APIKeyID int64 `json:"api_key_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
ModelSource string `json:"model_source,omitempty"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
UserID int64 `json:"user_id"`
|
||||
APIKeyID int64 `json:"api_key_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
ModelSource string `json:"model_source,omitempty"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
|
||||
}
|
||||
|
||||
type dashboardEntityTrendCacheKey struct {
|
||||
@@ -84,22 +86,28 @@ func (h *DashboardHandler) getUsageTrendCached(
|
||||
requestType *int16,
|
||||
stream *bool,
|
||||
billingType *int8,
|
||||
upstreamModelMismatch *bool,
|
||||
) ([]usagestats.TrendDataPoint, bool, error) {
|
||||
key := mustMarshalDashboardCacheKey(dashboardTrendCacheKey{
|
||||
StartTime: startTime.UTC().Format(time.RFC3339),
|
||||
EndTime: endTime.UTC().Format(time.RFC3339),
|
||||
Granularity: granularity,
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
Model: model,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
StartTime: startTime.UTC().Format(time.RFC3339),
|
||||
EndTime: endTime.UTC().Format(time.RFC3339),
|
||||
Granularity: granularity,
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
Model: model,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
})
|
||||
entry, hit, err := dashboardTrendCache.GetOrLoad(key, func() (any, error) {
|
||||
return h.dashboardService.GetUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType)
|
||||
return h.dashboardService.GetUsageTrendWithUsageFilters(ctx, startTime, endTime, granularity, usagestats.UsageLogFilters{
|
||||
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
|
||||
Model: model, RequestType: requestType, Stream: stream, BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, hit, err
|
||||
@@ -116,21 +124,27 @@ func (h *DashboardHandler) getModelStatsCached(
|
||||
requestType *int16,
|
||||
stream *bool,
|
||||
billingType *int8,
|
||||
upstreamModelMismatch *bool,
|
||||
) ([]usagestats.ModelStat, bool, error) {
|
||||
key := mustMarshalDashboardCacheKey(dashboardModelGroupCacheKey{
|
||||
StartTime: startTime.UTC().Format(time.RFC3339),
|
||||
EndTime: endTime.UTC().Format(time.RFC3339),
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
ModelSource: usagestats.NormalizeModelSource(modelSource),
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
StartTime: startTime.UTC().Format(time.RFC3339),
|
||||
EndTime: endTime.UTC().Format(time.RFC3339),
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
ModelSource: usagestats.NormalizeModelSource(modelSource),
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
})
|
||||
entry, hit, err := dashboardModelStatsCache.GetOrLoad(key, func() (any, error) {
|
||||
return h.dashboardService.GetModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, modelSource)
|
||||
return h.dashboardService.GetModelStatsWithUsageFiltersBySource(ctx, startTime, endTime, usagestats.UsageLogFilters{
|
||||
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
|
||||
RequestType: requestType, Stream: stream, BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
}, modelSource)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, hit, err
|
||||
@@ -146,20 +160,26 @@ func (h *DashboardHandler) getGroupStatsCached(
|
||||
requestType *int16,
|
||||
stream *bool,
|
||||
billingType *int8,
|
||||
upstreamModelMismatch *bool,
|
||||
) ([]usagestats.GroupStat, bool, error) {
|
||||
key := mustMarshalDashboardCacheKey(dashboardModelGroupCacheKey{
|
||||
StartTime: startTime.UTC().Format(time.RFC3339),
|
||||
EndTime: endTime.UTC().Format(time.RFC3339),
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
StartTime: startTime.UTC().Format(time.RFC3339),
|
||||
EndTime: endTime.UTC().Format(time.RFC3339),
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
})
|
||||
entry, hit, err := dashboardGroupStatsCache.GetOrLoad(key, func() (any, error) {
|
||||
return h.dashboardService.GetGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
|
||||
return h.dashboardService.GetGroupStatsWithUsageFilters(ctx, startTime, endTime, usagestats.UsageLogFilters{
|
||||
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
|
||||
RequestType: requestType, Stream: stream, BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, hit, err
|
||||
|
||||
@@ -37,34 +37,36 @@ type dashboardSnapshotV2Response struct {
|
||||
}
|
||||
|
||||
type dashboardSnapshotV2Filters struct {
|
||||
UserID int64
|
||||
APIKeyID int64
|
||||
AccountID int64
|
||||
GroupID int64
|
||||
Model string
|
||||
RequestType *int16
|
||||
Stream *bool
|
||||
BillingType *int8
|
||||
UserID int64
|
||||
APIKeyID int64
|
||||
AccountID int64
|
||||
GroupID int64
|
||||
Model string
|
||||
RequestType *int16
|
||||
Stream *bool
|
||||
BillingType *int8
|
||||
UpstreamModelMismatch *bool
|
||||
}
|
||||
|
||||
type dashboardSnapshotV2CacheKey struct {
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
Granularity string `json:"granularity"`
|
||||
UserID int64 `json:"user_id"`
|
||||
APIKeyID int64 `json:"api_key_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
Model string `json:"model"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
IncludeStats bool `json:"include_stats"`
|
||||
IncludeTrend bool `json:"include_trend"`
|
||||
IncludeModels bool `json:"include_models"`
|
||||
IncludeGroups bool `json:"include_groups"`
|
||||
IncludeUsersTrend bool `json:"include_users_trend"`
|
||||
UsersTrendLimit int `json:"users_trend_limit"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
Granularity string `json:"granularity"`
|
||||
UserID int64 `json:"user_id"`
|
||||
APIKeyID int64 `json:"api_key_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
Model string `json:"model"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
|
||||
IncludeStats bool `json:"include_stats"`
|
||||
IncludeTrend bool `json:"include_trend"`
|
||||
IncludeModels bool `json:"include_models"`
|
||||
IncludeGroups bool `json:"include_groups"`
|
||||
IncludeUsersTrend bool `json:"include_users_trend"`
|
||||
UsersTrendLimit int `json:"users_trend_limit"`
|
||||
}
|
||||
|
||||
func (h *DashboardHandler) GetSnapshotV2(c *gin.Context) {
|
||||
@@ -93,23 +95,24 @@ func (h *DashboardHandler) GetSnapshotV2(c *gin.Context) {
|
||||
}
|
||||
|
||||
keyRaw, _ := json.Marshal(dashboardSnapshotV2CacheKey{
|
||||
StartTime: startTime.UTC().Format(time.RFC3339),
|
||||
EndTime: endTime.UTC().Format(time.RFC3339),
|
||||
Granularity: granularity,
|
||||
UserID: filters.UserID,
|
||||
APIKeyID: filters.APIKeyID,
|
||||
AccountID: filters.AccountID,
|
||||
GroupID: filters.GroupID,
|
||||
Model: filters.Model,
|
||||
RequestType: filters.RequestType,
|
||||
Stream: filters.Stream,
|
||||
BillingType: filters.BillingType,
|
||||
IncludeStats: includeStats,
|
||||
IncludeTrend: includeTrend,
|
||||
IncludeModels: includeModels,
|
||||
IncludeGroups: includeGroups,
|
||||
IncludeUsersTrend: includeUsersTrend,
|
||||
UsersTrendLimit: usersTrendLimit,
|
||||
StartTime: startTime.UTC().Format(time.RFC3339),
|
||||
EndTime: endTime.UTC().Format(time.RFC3339),
|
||||
Granularity: granularity,
|
||||
UserID: filters.UserID,
|
||||
APIKeyID: filters.APIKeyID,
|
||||
AccountID: filters.AccountID,
|
||||
GroupID: filters.GroupID,
|
||||
Model: filters.Model,
|
||||
RequestType: filters.RequestType,
|
||||
Stream: filters.Stream,
|
||||
BillingType: filters.BillingType,
|
||||
UpstreamModelMismatch: filters.UpstreamModelMismatch,
|
||||
IncludeStats: includeStats,
|
||||
IncludeTrend: includeTrend,
|
||||
IncludeModels: includeModels,
|
||||
IncludeGroups: includeGroups,
|
||||
IncludeUsersTrend: includeUsersTrend,
|
||||
UsersTrendLimit: usersTrendLimit,
|
||||
})
|
||||
cacheKey := string(keyRaw)
|
||||
|
||||
@@ -184,6 +187,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
|
||||
filters.RequestType,
|
||||
filters.Stream,
|
||||
filters.BillingType,
|
||||
filters.UpstreamModelMismatch,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to get usage trend")
|
||||
@@ -204,6 +208,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
|
||||
filters.RequestType,
|
||||
filters.Stream,
|
||||
filters.BillingType,
|
||||
filters.UpstreamModelMismatch,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to get model statistics")
|
||||
@@ -223,6 +228,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
|
||||
filters.RequestType,
|
||||
filters.Stream,
|
||||
filters.BillingType,
|
||||
filters.UpstreamModelMismatch,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to get group statistics")
|
||||
@@ -299,5 +305,13 @@ func parseDashboardSnapshotV2Filters(c *gin.Context) (*dashboardSnapshotV2Filter
|
||||
filters.BillingType = &bt
|
||||
}
|
||||
|
||||
if mismatchStr := strings.TrimSpace(c.Query("upstream_model_mismatch")); mismatchStr != "" {
|
||||
value, err := strconv.ParseBool(mismatchStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filters.UpstreamModelMismatch = &value
|
||||
}
|
||||
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
const (
|
||||
grokImportProbeConcurrency = 3
|
||||
grokImportProbeTimeout = 25 * time.Second
|
||||
grokImportProbeQueueLimit = 64
|
||||
)
|
||||
|
||||
type grokImportProber interface {
|
||||
@@ -27,6 +28,8 @@ type grokImportProbeTask struct {
|
||||
type grokImportProbeScheduler struct {
|
||||
mu sync.Mutex
|
||||
queue []grokImportProbeTask
|
||||
pending map[int64]struct{}
|
||||
inFlight map[int64]struct{}
|
||||
concurrency int
|
||||
workers int
|
||||
maxWorkers int
|
||||
@@ -48,6 +51,8 @@ func newGrokImportProbeScheduler(concurrency int, timeout time.Duration) *grokIm
|
||||
return &grokImportProbeScheduler{
|
||||
concurrency: concurrency,
|
||||
timeout: timeout,
|
||||
pending: make(map[int64]struct{}),
|
||||
inFlight: make(map[int64]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +65,21 @@ func (s *grokImportProbeScheduler) schedule(prober grokImportProber, account *se
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if _, exists := s.pending[account.ID]; exists {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if _, exists := s.inFlight[account.ID]; exists {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if len(s.queue) >= grokImportProbeQueueLimit {
|
||||
s.mu.Unlock()
|
||||
slog.Debug("grok_import_active_probe_dropped", "account_id", account.ID, "reason", "queue_full")
|
||||
return
|
||||
}
|
||||
s.queue = append(s.queue, grokImportProbeTask{prober: prober, accountID: account.ID})
|
||||
s.pending[account.ID] = struct{}{}
|
||||
if s.workers < s.concurrency {
|
||||
s.workers++
|
||||
if s.workers > s.maxWorkers {
|
||||
@@ -78,6 +97,7 @@ func (s *grokImportProbeScheduler) worker() {
|
||||
return
|
||||
}
|
||||
s.run(task.prober, task.accountID)
|
||||
s.finish(task.accountID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,9 +114,17 @@ func (s *grokImportProbeScheduler) nextTask() (grokImportProbeTask, bool) {
|
||||
if len(s.queue) == 0 {
|
||||
s.queue = nil
|
||||
}
|
||||
delete(s.pending, task.accountID)
|
||||
s.inFlight[task.accountID] = struct{}{}
|
||||
return task, true
|
||||
}
|
||||
|
||||
func (s *grokImportProbeScheduler) finish(accountID int64) {
|
||||
s.mu.Lock()
|
||||
delete(s.inFlight, accountID)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *grokImportProbeScheduler) run(prober grokImportProber, accountID int64) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
@@ -108,8 +136,6 @@ func (s *grokImportProbeScheduler) run(prober grokImportProber, accountID int64)
|
||||
}
|
||||
}()
|
||||
|
||||
// Queue time is intentionally excluded: every imported account is probed,
|
||||
// while this timeout only bounds the actual upstream probe execution.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
|
||||
defer cancel()
|
||||
result, err := prober.QueryQuota(ctx, accountID)
|
||||
|
||||
@@ -4,6 +4,7 @@ package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -62,6 +63,10 @@ func (grokImportOAuthClientStub) RefreshToken(context.Context, string, string, s
|
||||
return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil
|
||||
}
|
||||
|
||||
func (grokImportOAuthClientStub) LoginWithPassword(context.Context, string, string, string) (*service.GrokPasswordLoginResult, error) {
|
||||
return nil, errors.New("unexpected password login")
|
||||
}
|
||||
|
||||
func (grokImportOAuthClientStub) ConvertSSOToBuild(context.Context, string, string) (*xai.TokenResponse, error) {
|
||||
return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ func TestGrokImportProbeSchedulerProbesSingleAccountOnce(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGrokImportProbeSchedulerQueuesBatchWithoutPerTaskGoroutines(t *testing.T) {
|
||||
const taskCount = 100
|
||||
const taskCount = 50
|
||||
release := make(chan struct{})
|
||||
scheduler := newGrokImportProbeScheduler(3, time.Second)
|
||||
prober := newGrokImportProbeStub(taskCount)
|
||||
@@ -156,7 +156,7 @@ func TestGrokImportProbeSchedulerQueuesBatchWithoutPerTaskGoroutines(t *testing.
|
||||
awaitGrokProbeSignal(t, prober.started)
|
||||
}
|
||||
snapshot := snapshotGrokImportProbeScheduler(scheduler)
|
||||
require.Equal(t, 97, snapshot.queued)
|
||||
require.Equal(t, taskCount-3, snapshot.queued)
|
||||
require.Equal(t, 3, snapshot.workers)
|
||||
require.Equal(t, 3, snapshot.maxWorkers)
|
||||
select {
|
||||
@@ -182,6 +182,55 @@ func TestGrokImportProbeSchedulerQueuesBatchWithoutPerTaskGoroutines(t *testing.
|
||||
require.Equal(t, 3, snapshot.maxWorkers)
|
||||
}
|
||||
|
||||
func TestGrokImportProbeSchedulerDeduplicatesPendingAndInFlightAccounts(t *testing.T) {
|
||||
scheduler := newGrokImportProbeScheduler(1, time.Second)
|
||||
prober := newGrokImportProbeStub(2)
|
||||
release := make(chan struct{})
|
||||
prober.block = release
|
||||
account := newGrokOAuthImportAccount(501)
|
||||
queued := newGrokOAuthImportAccount(502)
|
||||
|
||||
scheduler.schedule(prober, account)
|
||||
require.Equal(t, int64(501), awaitGrokProbeSignal(t, prober.started))
|
||||
scheduler.schedule(prober, account)
|
||||
scheduler.schedule(prober, queued)
|
||||
scheduler.schedule(prober, queued)
|
||||
|
||||
scheduler.mu.Lock()
|
||||
require.Len(t, scheduler.queue, 1)
|
||||
require.Contains(t, scheduler.inFlight, int64(501))
|
||||
require.Contains(t, scheduler.pending, int64(502))
|
||||
scheduler.mu.Unlock()
|
||||
|
||||
close(release)
|
||||
require.Equal(t, int64(501), awaitGrokProbeSignal(t, prober.done))
|
||||
require.Equal(t, int64(502), awaitGrokProbeSignal(t, prober.done))
|
||||
calls, _, _ := prober.snapshot()
|
||||
require.Equal(t, 1, calls[501])
|
||||
require.Equal(t, 1, calls[502])
|
||||
}
|
||||
|
||||
func TestGrokImportProbeSchedulerBoundsPendingQueue(t *testing.T) {
|
||||
scheduler := newGrokImportProbeScheduler(1, time.Second)
|
||||
prober := newGrokImportProbeStub(grokImportProbeQueueLimit + 1)
|
||||
release := make(chan struct{})
|
||||
prober.block = release
|
||||
scheduler.schedule(prober, newGrokOAuthImportAccount(600))
|
||||
require.Equal(t, int64(600), awaitGrokProbeSignal(t, prober.started))
|
||||
for id := int64(601); id < 601+grokImportProbeQueueLimit+10; id++ {
|
||||
scheduler.schedule(prober, newGrokOAuthImportAccount(id))
|
||||
}
|
||||
|
||||
scheduler.mu.Lock()
|
||||
require.Len(t, scheduler.queue, grokImportProbeQueueLimit)
|
||||
scheduler.mu.Unlock()
|
||||
|
||||
close(release)
|
||||
for i := 0; i < grokImportProbeQueueLimit+1; i++ {
|
||||
awaitGrokProbeSignal(t, prober.done)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrokImportProbeSchedulerTimeoutCancelsProbe(t *testing.T) {
|
||||
neverRelease := make(chan struct{})
|
||||
scheduler := newGrokImportProbeScheduler(1, 20*time.Millisecond)
|
||||
|
||||
@@ -47,6 +47,10 @@ type GrokGenerateAuthURLRequest struct {
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) GetCapabilities(c *gin.Context) {
|
||||
response.Success(c, h.grokOAuthService.GetCapabilities())
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) GenerateAuthURL(c *gin.Context) {
|
||||
var req GrokGenerateAuthURLRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -95,6 +99,17 @@ type GrokRefreshTokenRequest struct {
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
}
|
||||
|
||||
type GrokSSOTokenRequest struct {
|
||||
SSOToken string `json:"sso_token"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
}
|
||||
|
||||
type GrokPasswordAuthorizeRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) RefreshToken(c *gin.Context) {
|
||||
var req GrokRefreshTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -113,9 +128,15 @@ func (h *GrokOAuthHandler) RefreshToken(c *gin.Context) {
|
||||
var proxyURL string
|
||||
if req.ProxyID != nil {
|
||||
proxy, err := h.adminService.GetProxy(c.Request.Context(), *req.ProxyID)
|
||||
if err == nil && proxy != nil {
|
||||
proxyURL = proxy.URL()
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if proxy == nil {
|
||||
response.BadRequest(c, "GROK_OAUTH_PROXY_NOT_FOUND: proxy not found")
|
||||
return
|
||||
}
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
tokenInfo, err := h.grokOAuthService.RefreshToken(c.Request.Context(), refreshToken, proxyURL, req.ClientID)
|
||||
if err != nil {
|
||||
@@ -125,6 +146,38 @@ func (h *GrokOAuthHandler) RefreshToken(c *gin.Context) {
|
||||
response.Success(c, tokenInfo)
|
||||
}
|
||||
|
||||
// ValidateSSOToken converts a Web SSO cookie into Build OAuth tokens.
|
||||
// Response contains OAuth token info only — never echoes sso_token.
|
||||
func (h *GrokOAuthHandler) ValidateSSOToken(c *gin.Context) {
|
||||
var req GrokSSOTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
tokenInfo, err := h.grokOAuthService.ValidateSSOToken(c.Request.Context(), req.SSOToken, req.ProxyID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, tokenInfo)
|
||||
}
|
||||
|
||||
// AuthorizePassword exchanges email/password for Build OAuth tokens via SSO conversion.
|
||||
// Response never includes password or raw sso_token.
|
||||
func (h *GrokOAuthHandler) AuthorizePassword(c *gin.Context) {
|
||||
var req GrokPasswordAuthorizeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
tokenInfo, err := h.grokOAuthService.AuthorizePassword(c.Request.Context(), req.Email, req.Password, req.ProxyID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, tokenInfo)
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) RefreshAccountToken(c *gin.Context) {
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -412,11 +465,38 @@ func (h *GrokOAuthHandler) createAccountFromSSOToken(ctx context.Context, req Gr
|
||||
// 配置且 Build 恒写官方地址,会吞掉导入时指定的自定义转发地址——与
|
||||
// RefreshAccountToken 的保留逻辑对齐,请求显式提供时以请求为准。
|
||||
func grokSSOImportCredentials(built map[string]any, reqCredentials map[string]any) map[string]any {
|
||||
credentials := service.MergeCredentials(cloneGrokSSOMap(reqCredentials), built)
|
||||
// Only merge operator config from the request — never free-form secrets
|
||||
// (password / sso_token / cookie / etc.) into stored credentials.
|
||||
allowedReqKeys := map[string]struct{}{
|
||||
"base_url": {}, "model_mapping": {},
|
||||
"header_override": {}, "header_overrides": {}, "header_override_enabled": {},
|
||||
"custom_headers": {},
|
||||
}
|
||||
ops := map[string]any{}
|
||||
for k, v := range reqCredentials {
|
||||
if _, ok := allowedReqKeys[k]; !ok {
|
||||
continue
|
||||
}
|
||||
if service.IsSensitiveCredentialKey(k) {
|
||||
continue
|
||||
}
|
||||
ops[k] = v
|
||||
}
|
||||
credentials := service.MergeCredentials(ops, built)
|
||||
// Strip any sensitive keys that might have slipped in via older callers.
|
||||
for k := range credentials {
|
||||
if service.IsSensitiveCredentialKey(k) {
|
||||
// Keep only keys produced by BuildAccountCredentials (tokens).
|
||||
if k == "access_token" || k == "refresh_token" || k == "id_token" {
|
||||
continue
|
||||
}
|
||||
delete(credentials, k)
|
||||
}
|
||||
}
|
||||
if reqBaseURL, ok := reqCredentials["base_url"].(string); ok && strings.TrimSpace(reqBaseURL) != "" {
|
||||
credentials["base_url"] = strings.TrimSpace(reqBaseURL)
|
||||
}
|
||||
return credentials
|
||||
return service.SanitizeStoredCredentials(service.PlatformGrok, credentials)
|
||||
}
|
||||
|
||||
func grokSSOImportExpiry(requestExpiresAt *int64, requestAutoPause *bool, tokenInfo *service.GrokTokenInfo) (*int64, *bool) {
|
||||
|
||||
@@ -4,6 +4,7 @@ package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -128,14 +130,22 @@ func TestGrokOAuthHandlerQueryQuotaProbesUpstream(t *testing.T) {
|
||||
require.Contains(t, rec.Body.String(), `"snapshot":`)
|
||||
require.Contains(t, rec.Body.String(), `"headers_observed":true`)
|
||||
require.NotContains(t, rec.Body.String(), "access-token")
|
||||
require.Eventually(t, func() bool {
|
||||
upstream.mu.Lock()
|
||||
defer upstream.mu.Unlock()
|
||||
return len(upstream.requests) == 4
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
upstream.mu.Lock()
|
||||
requests := append([]*http.Request(nil), upstream.requests...)
|
||||
bodies := append([][]byte(nil), upstream.bodies...)
|
||||
upstream.mu.Unlock()
|
||||
require.Len(t, requests, 3)
|
||||
require.Len(t, requests, 4)
|
||||
responsesProbeSeen := false
|
||||
modelsSyncSeen := false
|
||||
for i, upstreamReq := range requests {
|
||||
require.Equal(t, "Bearer access-token", upstreamReq.Header.Get("Authorization"))
|
||||
if upstreamReq.URL.String() == xai.DefaultCLIBaseURL+"/responses" {
|
||||
responsesProbeSeen = true
|
||||
require.Equal(t, "application/json, text/event-stream", upstreamReq.Header.Get("Accept"))
|
||||
require.Contains(t, string(bodies[i]), `"model":"grok-4.5"`)
|
||||
require.Contains(t, string(bodies[i]), `"input":"hi"`)
|
||||
@@ -143,7 +153,12 @@ func TestGrokOAuthHandlerQueryQuotaProbesUpstream(t *testing.T) {
|
||||
require.NotContains(t, string(bodies[i]), `"max_output_tokens"`)
|
||||
require.NotContains(t, string(bodies[i]), `"store"`)
|
||||
}
|
||||
if upstreamReq.URL.String() == xai.DefaultCLIBaseURL+"/models" {
|
||||
modelsSyncSeen = true
|
||||
}
|
||||
}
|
||||
require.True(t, responsesProbeSeen)
|
||||
require.True(t, modelsSyncSeen)
|
||||
require.NotNil(t, repo.updates[42])
|
||||
}
|
||||
|
||||
@@ -189,6 +204,84 @@ func TestGrokOAuthHandlerRuntimeSanityDoesNotExposeSecrets(t *testing.T) {
|
||||
require.NotContains(t, rec.Body.String(), "client-secret-like-value")
|
||||
}
|
||||
|
||||
type grokOAuthHandlerClient struct{}
|
||||
|
||||
func (c *grokOAuthHandlerClient) ExchangeCode(context.Context, string, string, string, string, string) (*xai.TokenResponse, error) {
|
||||
return nil, errors.New("unexpected exchange")
|
||||
}
|
||||
|
||||
func (c *grokOAuthHandlerClient) RefreshToken(context.Context, string, string, string) (*xai.TokenResponse, error) {
|
||||
return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil
|
||||
}
|
||||
|
||||
func (c *grokOAuthHandlerClient) LoginWithPassword(_ context.Context, email, _ string, _ string) (*service.GrokPasswordLoginResult, error) {
|
||||
return &service.GrokPasswordLoginResult{
|
||||
Email: email,
|
||||
SSOToken: "sso-from-password",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *grokOAuthHandlerClient) ConvertSSOToBuild(context.Context, string, string) (*xai.TokenResponse, error) {
|
||||
return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil
|
||||
}
|
||||
|
||||
func TestGrokOAuthHandlerValidateSSOTokenReturnsTokenInfo(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
oauthClient := &grokOAuthHandlerClient{}
|
||||
oauthService := service.NewGrokOAuthService(nil, oauthClient)
|
||||
defer oauthService.Stop()
|
||||
handler := NewGrokOAuthHandler(oauthService, nil, nil, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/grok/oauth/sso-token", handler.ValidateSSOToken)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/oauth/sso-token", strings.NewReader(`{"sso_token":"sso-token"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), `"access_token":"access-token"`)
|
||||
require.NotContains(t, rec.Body.String(), `"sso_token"`)
|
||||
}
|
||||
|
||||
func TestGrokOAuthHandlerAuthorizePasswordReturnsTokenInfoWithoutPassword(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
oauthClient := &grokOAuthHandlerClient{}
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.Grok.PasswordAuthEnabled = true
|
||||
oauthService := service.NewGrokOAuthService(nil, oauthClient, cfg)
|
||||
defer oauthService.Stop()
|
||||
handler := NewGrokOAuthHandler(oauthService, nil, nil, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/grok/oauth/password", handler.AuthorizePassword)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/oauth/password", strings.NewReader(`{"email":"user@example.com","password":"super-secret"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), `"access_token":"access-token"`)
|
||||
require.NotContains(t, rec.Body.String(), "super-secret")
|
||||
}
|
||||
|
||||
func TestGrokOAuthHandlerPasswordCapabilityDefaultsToDisabled(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
oauthService := service.NewGrokOAuthService(nil, &grokOAuthHandlerClient{})
|
||||
defer oauthService.Stop()
|
||||
handler := NewGrokOAuthHandler(oauthService, nil, nil, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.GET("/api/v1/admin/grok/oauth/capabilities", handler.GetCapabilities)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/admin/grok/oauth/capabilities", nil))
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), `"password_auth_enabled":false`)
|
||||
}
|
||||
|
||||
func TestGrokSSOImportExpiryUsesTokenExpiryWithoutRefreshToken(t *testing.T) {
|
||||
tokenExpiry := time.Now().Add(6 * time.Hour).Unix()
|
||||
expiresAt, autoPause := grokSSOImportExpiry(nil, nil, &service.GrokTokenInfo{
|
||||
@@ -266,14 +359,12 @@ func TestGrokSSOImportCredentialsDefaultsToOfficialBaseURL(t *testing.T) {
|
||||
require.Equal(t, "at-2", credentials["access_token"])
|
||||
}
|
||||
|
||||
func TestGrokSSOImportWorkerRecoversPanic(t *testing.T) {
|
||||
func TestGrokSSOImportWorkerHandlesMissingOAuthService(t *testing.T) {
|
||||
h := &GrokOAuthHandler{}
|
||||
result := h.safeCreateAccountFromSSOToken(context.Background(), GrokSSOToOAuthRequest{}, "token", 2, 3)
|
||||
// Without a service, createAccountFromSSOToken would panic on nil service access.
|
||||
// Recovery must convert that into a failed item and keep the worker alive.
|
||||
require.False(t, result.created)
|
||||
require.Equal(t, 2, result.item.Index)
|
||||
require.Contains(t, result.item.Error, "internal worker panic")
|
||||
require.Contains(t, result.item.Error, "GROK_OAUTH_CLIENT_NOT_CONFIGURED")
|
||||
}
|
||||
|
||||
func TestGrokOAuthHandlerReconcileDefaultsToDryRun(t *testing.T) {
|
||||
|
||||
@@ -106,31 +106,36 @@ type CreateGroupRequest struct {
|
||||
WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
|
||||
MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
|
||||
// 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置)
|
||||
AllowImageGeneration bool `json:"allow_image_generation"`
|
||||
AllowBatchImageGeneration bool `json:"allow_batch_image_generation"`
|
||||
ImageRateIndependent bool `json:"image_rate_independent"`
|
||||
ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
|
||||
BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"`
|
||||
BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"`
|
||||
VideoRateIndependent bool `json:"video_rate_independent"`
|
||||
VideoRateMultiplier *float64 `json:"video_rate_multiplier"`
|
||||
PeakRateEnabled bool `json:"peak_rate_enabled"`
|
||||
PeakStart string `json:"peak_start"`
|
||||
PeakEnd string `json:"peak_end"`
|
||||
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
|
||||
ProfitControlEnabled bool `json:"profit_control_enabled"`
|
||||
ProfitMinMargin *float64 `json:"profit_min_margin"`
|
||||
ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"`
|
||||
ImagePrice1K *float64 `json:"image_price_1k"`
|
||||
ImagePrice2K *float64 `json:"image_price_2k"`
|
||||
ImagePrice4K *float64 `json:"image_price_4k"`
|
||||
VideoPrice480P *float64 `json:"video_price_480p"`
|
||||
VideoPrice720P *float64 `json:"video_price_720p"`
|
||||
VideoPrice1080P *float64 `json:"video_price_1080p"`
|
||||
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
|
||||
ClaudeCodeOnly bool `json:"claude_code_only"`
|
||||
FallbackGroupID *int64 `json:"fallback_group_id"`
|
||||
FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"`
|
||||
AllowImageGeneration bool `json:"allow_image_generation"`
|
||||
AllowBatchImageGeneration bool `json:"allow_batch_image_generation"`
|
||||
ImageRateIndependent bool `json:"image_rate_independent"`
|
||||
ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
|
||||
BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"`
|
||||
BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"`
|
||||
VideoRateIndependent bool `json:"video_rate_independent"`
|
||||
VideoRateMultiplier *float64 `json:"video_rate_multiplier"`
|
||||
PeakRateEnabled bool `json:"peak_rate_enabled"`
|
||||
PeakStart string `json:"peak_start"`
|
||||
PeakEnd string `json:"peak_end"`
|
||||
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
|
||||
ProfitControlEnabled bool `json:"profit_control_enabled"`
|
||||
ProfitMinMargin *float64 `json:"profit_min_margin"`
|
||||
ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"`
|
||||
ImagePrice1K *float64 `json:"image_price_1k"`
|
||||
ImagePrice2K *float64 `json:"image_price_2k"`
|
||||
ImagePrice4K *float64 `json:"image_price_4k"`
|
||||
VideoPrice480P *float64 `json:"video_price_480p"`
|
||||
VideoPrice720P *float64 `json:"video_price_720p"`
|
||||
VideoPrice1080P *float64 `json:"video_price_1080p"`
|
||||
VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"`
|
||||
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
|
||||
SearchPricePer1k *float64 `json:"search_price_per_1k"`
|
||||
AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min"`
|
||||
AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars"`
|
||||
AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour"`
|
||||
ClaudeCodeOnly bool `json:"claude_code_only"`
|
||||
FallbackGroupID *int64 `json:"fallback_group_id"`
|
||||
FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"`
|
||||
// 模型路由配置(仅 anthropic 平台使用)
|
||||
ModelRouting map[string][]int64 `json:"model_routing"`
|
||||
ModelRoutingEnabled bool `json:"model_routing_enabled"`
|
||||
@@ -168,31 +173,36 @@ type UpdateGroupRequest struct {
|
||||
WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
|
||||
MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
|
||||
// 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置)
|
||||
AllowImageGeneration *bool `json:"allow_image_generation"`
|
||||
AllowBatchImageGeneration *bool `json:"allow_batch_image_generation"`
|
||||
ImageRateIndependent *bool `json:"image_rate_independent"`
|
||||
ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
|
||||
BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"`
|
||||
BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"`
|
||||
VideoRateIndependent *bool `json:"video_rate_independent"`
|
||||
VideoRateMultiplier *float64 `json:"video_rate_multiplier"`
|
||||
PeakRateEnabled *bool `json:"peak_rate_enabled"`
|
||||
PeakStart *string `json:"peak_start"`
|
||||
PeakEnd *string `json:"peak_end"`
|
||||
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
|
||||
ProfitControlEnabled *bool `json:"profit_control_enabled"`
|
||||
ProfitMinMargin *float64 `json:"profit_min_margin"`
|
||||
ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"`
|
||||
ImagePrice1K *float64 `json:"image_price_1k"`
|
||||
ImagePrice2K *float64 `json:"image_price_2k"`
|
||||
ImagePrice4K *float64 `json:"image_price_4k"`
|
||||
VideoPrice480P *float64 `json:"video_price_480p"`
|
||||
VideoPrice720P *float64 `json:"video_price_720p"`
|
||||
VideoPrice1080P *float64 `json:"video_price_1080p"`
|
||||
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
|
||||
ClaudeCodeOnly *bool `json:"claude_code_only"`
|
||||
FallbackGroupID *int64 `json:"fallback_group_id"`
|
||||
FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"`
|
||||
AllowImageGeneration *bool `json:"allow_image_generation"`
|
||||
AllowBatchImageGeneration *bool `json:"allow_batch_image_generation"`
|
||||
ImageRateIndependent *bool `json:"image_rate_independent"`
|
||||
ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
|
||||
BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"`
|
||||
BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"`
|
||||
VideoRateIndependent *bool `json:"video_rate_independent"`
|
||||
VideoRateMultiplier *float64 `json:"video_rate_multiplier"`
|
||||
PeakRateEnabled *bool `json:"peak_rate_enabled"`
|
||||
PeakStart *string `json:"peak_start"`
|
||||
PeakEnd *string `json:"peak_end"`
|
||||
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
|
||||
ProfitControlEnabled *bool `json:"profit_control_enabled"`
|
||||
ProfitMinMargin *float64 `json:"profit_min_margin"`
|
||||
ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"`
|
||||
ImagePrice1K *float64 `json:"image_price_1k"`
|
||||
ImagePrice2K *float64 `json:"image_price_2k"`
|
||||
ImagePrice4K *float64 `json:"image_price_4k"`
|
||||
VideoPrice480P *float64 `json:"video_price_480p"`
|
||||
VideoPrice720P *float64 `json:"video_price_720p"`
|
||||
VideoPrice1080P *float64 `json:"video_price_1080p"`
|
||||
VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"`
|
||||
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
|
||||
SearchPricePer1k *float64 `json:"search_price_per_1k"`
|
||||
AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min"`
|
||||
AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars"`
|
||||
AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour"`
|
||||
ClaudeCodeOnly *bool `json:"claude_code_only"`
|
||||
FallbackGroupID *int64 `json:"fallback_group_id"`
|
||||
FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"`
|
||||
// 模型路由配置(仅 anthropic 平台使用)
|
||||
ModelRouting map[string][]int64 `json:"model_routing"`
|
||||
ModelRoutingEnabled *bool `json:"model_routing_enabled"`
|
||||
@@ -519,7 +529,12 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
VideoPrice480P: req.VideoPrice480P,
|
||||
VideoPrice720P: req.VideoPrice720P,
|
||||
VideoPrice1080P: req.VideoPrice1080P,
|
||||
VideoModelPrices: req.VideoModelPrices,
|
||||
WebSearchPricePerCall: req.WebSearchPricePerCall,
|
||||
SearchPricePer1k: req.SearchPricePer1k,
|
||||
AudioRealtimePricePerMin: req.AudioRealtimePricePerMin,
|
||||
AudioTTSPricePerMillionChars: req.AudioTtsPricePerMillionChars,
|
||||
AudioSTTPricePerHour: req.AudioSttPricePerHour,
|
||||
ClaudeCodeOnly: req.ClaudeCodeOnly,
|
||||
FallbackGroupID: req.FallbackGroupID,
|
||||
FallbackGroupIDOnInvalidRequest: req.FallbackGroupIDOnInvalidRequest,
|
||||
@@ -641,7 +656,12 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
VideoPrice480P: req.VideoPrice480P,
|
||||
VideoPrice720P: req.VideoPrice720P,
|
||||
VideoPrice1080P: req.VideoPrice1080P,
|
||||
VideoModelPrices: req.VideoModelPrices,
|
||||
WebSearchPricePerCall: req.WebSearchPricePerCall,
|
||||
SearchPricePer1k: req.SearchPricePer1k,
|
||||
AudioRealtimePricePerMin: req.AudioRealtimePricePerMin,
|
||||
AudioTTSPricePerMillionChars: req.AudioTtsPricePerMillionChars,
|
||||
AudioSTTPricePerHour: req.AudioSttPricePerHour,
|
||||
ClaudeCodeOnly: req.ClaudeCodeOnly,
|
||||
FallbackGroupID: req.FallbackGroupID,
|
||||
FallbackGroupIDOnInvalidRequest: req.FallbackGroupIDOnInvalidRequest,
|
||||
|
||||
@@ -374,6 +374,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
||||
ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
|
||||
|
||||
GrokDefaultTextModel: settings.GrokDefaultTextModel,
|
||||
GrokCrossClientModelMapEnabled: settings.GrokCrossClientModelMapEnabled,
|
||||
GrokDefaultBaseURLMode: settings.GrokDefaultBaseURLMode,
|
||||
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
@@ -382,7 +386,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
|
||||
AffiliateEnabled: settings.AffiliateEnabled,
|
||||
|
||||
AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
|
||||
AccountSchedulingThresholds: settings.AccountSchedulingThresholds,
|
||||
AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
|
||||
}
|
||||
|
||||
// OpenAI fast policy (stored under a dedicated setting key)
|
||||
|
||||
@@ -598,6 +598,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if !equalPlatformQuotaSettings(before.DefaultPlatformQuotas, after.DefaultPlatformQuotas) {
|
||||
changed = append(changed, service.SettingKeyDefaultPlatformQuotas)
|
||||
}
|
||||
if !equalAccountSchedulingThresholds(before.AccountSchedulingThresholds, after.AccountSchedulingThresholds) {
|
||||
changed = append(changed, service.SettingKeyAccountSchedulingThresholds)
|
||||
}
|
||||
changed = appendAuthSourceDefaultChanges(changed, beforeAuthSourceDefaults, afterAuthSourceDefaults)
|
||||
return changed
|
||||
}
|
||||
@@ -811,6 +814,27 @@ func slotOf(s *service.DefaultPlatformQuotaSetting, win string) *float64 {
|
||||
}
|
||||
|
||||
// equalPlatformQuotaSettings reports whether two platform-quota maps are identical across all allowed slots.
|
||||
func equalAccountSchedulingThresholds(before, after map[string]int) bool {
|
||||
for _, platform := range service.AllowedSchedulingThresholdPlatforms {
|
||||
beforeValue := 100
|
||||
if before != nil {
|
||||
if value, ok := before[platform]; ok {
|
||||
beforeValue = value
|
||||
}
|
||||
}
|
||||
afterValue := 100
|
||||
if after != nil {
|
||||
if value, ok := after[platform]; ok {
|
||||
afterValue = value
|
||||
}
|
||||
}
|
||||
if beforeValue != afterValue {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func equalPlatformQuotaSettings(before, after map[string]*service.DefaultPlatformQuotaSetting) bool {
|
||||
for _, platform := range service.AllowedQuotaPlatforms {
|
||||
b := before[platform]
|
||||
|
||||
@@ -66,6 +66,18 @@ func TestUpdateSettingsSMTPFromAliasIsWritable(t *testing.T) {
|
||||
require.Equal(t, "new@example.com", repo.values[service.SettingKeySMTPFrom])
|
||||
}
|
||||
|
||||
func TestUpdateSettingsGrokDefaultBaseURLModeIsWritable(t *testing.T) {
|
||||
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
|
||||
service.SettingKeyGrokDefaultBaseURLMode: service.GrokDefaultBaseURLModeCLI,
|
||||
})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{
|
||||
"grok_default_base_url_mode": service.GrokDefaultBaseURLModeEUWest1,
|
||||
}, nil)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, service.GrokDefaultBaseURLModeEUWest1, repo.values[service.SettingKeyGrokDefaultBaseURLMode])
|
||||
}
|
||||
|
||||
func TestUpdateSettingsRejectsTwoCaptchaProviders(t *testing.T) {
|
||||
h, _ := newStepUpSwitchTestHandler(t, map[string]string{
|
||||
service.SettingKeyTurnstileEnabled: "true",
|
||||
|
||||
@@ -332,6 +332,11 @@ type UpdateSettingsRequest struct {
|
||||
ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"`
|
||||
ChannelMonitorHideThroughput *bool `json:"channel_monitor_hide_throughput"`
|
||||
|
||||
// Grok model mapping policy
|
||||
GrokDefaultTextModel *string `json:"grok_default_text_model"`
|
||||
GrokCrossClientModelMapEnabled *bool `json:"grok_cross_client_model_map_enabled"`
|
||||
GrokDefaultBaseURLMode *string `json:"grok_default_base_url_mode"`
|
||||
|
||||
// Available Channels feature switch (user-facing)
|
||||
AvailableChannelsEnabled *bool `json:"available_channels_enabled"`
|
||||
|
||||
@@ -356,6 +361,9 @@ type UpdateSettingsRequest struct {
|
||||
// 系统全局 platform quota 默认值(整体替换语义:nil = 不修改,non-nil = 整体覆盖)。
|
||||
DefaultPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"default_platform_quotas"`
|
||||
|
||||
// 各平台账号自动停调阈值(整体替换语义:nil = 不修改,non-nil = 整体覆盖)。
|
||||
AccountSchedulingThresholds map[string]int `json:"account_scheduling_thresholds"`
|
||||
|
||||
// auth-source 层 platform quota 覆盖(override 语义:nil = 不修改,non-nil = 整体覆盖该 source 的 quota 配置)。
|
||||
AuthSourceEmailPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_email_platform_quotas"`
|
||||
AuthSourceLinuxDoPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_linuxdo_platform_quotas"`
|
||||
@@ -1478,7 +1486,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
|
||||
settings := &service.SystemSettings{
|
||||
// 系统全局 platform quota 默认值(整体替换语义)
|
||||
DefaultPlatformQuotas: req.DefaultPlatformQuotas,
|
||||
DefaultPlatformQuotas: req.DefaultPlatformQuotas,
|
||||
AccountSchedulingThresholds: req.AccountSchedulingThresholds,
|
||||
|
||||
RegistrationEnabled: req.RegistrationEnabled,
|
||||
EmailVerifyEnabled: req.EmailVerifyEnabled,
|
||||
@@ -1874,6 +1883,24 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.ChannelMonitorHideThroughput
|
||||
}(),
|
||||
GrokDefaultTextModel: func() string {
|
||||
if req.GrokDefaultTextModel != nil {
|
||||
return *req.GrokDefaultTextModel
|
||||
}
|
||||
return previousSettings.GrokDefaultTextModel
|
||||
}(),
|
||||
GrokCrossClientModelMapEnabled: func() bool {
|
||||
if req.GrokCrossClientModelMapEnabled != nil {
|
||||
return *req.GrokCrossClientModelMapEnabled
|
||||
}
|
||||
return previousSettings.GrokCrossClientModelMapEnabled
|
||||
}(),
|
||||
GrokDefaultBaseURLMode: func() string {
|
||||
if req.GrokDefaultBaseURLMode != nil {
|
||||
return strings.TrimSpace(*req.GrokDefaultBaseURLMode)
|
||||
}
|
||||
return previousSettings.GrokDefaultBaseURLMode
|
||||
}(),
|
||||
AvailableChannelsEnabled: func() bool {
|
||||
if req.AvailableChannelsEnabled != nil {
|
||||
return *req.AvailableChannelsEnabled
|
||||
@@ -2309,6 +2336,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds,
|
||||
ChannelMonitorHideThroughput: updatedSettings.ChannelMonitorHideThroughput,
|
||||
|
||||
GrokDefaultTextModel: updatedSettings.GrokDefaultTextModel,
|
||||
GrokCrossClientModelMapEnabled: updatedSettings.GrokCrossClientModelMapEnabled,
|
||||
GrokDefaultBaseURLMode: updatedSettings.GrokDefaultBaseURLMode,
|
||||
|
||||
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
|
||||
|
||||
ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled,
|
||||
@@ -2320,6 +2351,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
RiskControlEnabled: updatedSettings.RiskControlEnabled,
|
||||
CyberSessionBlockEnabled: updatedSettings.CyberSessionBlockEnabled,
|
||||
CyberSessionBlockTTLSeconds: updatedSettings.CyberSessionBlockTTLSeconds,
|
||||
AccountSchedulingThresholds: updatedSettings.AccountSchedulingThresholds,
|
||||
AllowUserViewErrorRequests: updatedSettings.AllowUserViewErrorRequests,
|
||||
}
|
||||
if fastPolicy, err := h.settingService.GetOpenAIFastPolicySettings(c.Request.Context()); err != nil {
|
||||
|
||||
@@ -143,6 +143,16 @@ func (h *UsageHandler) List(c *gin.Context) {
|
||||
billingType = &bt
|
||||
}
|
||||
|
||||
var upstreamModelMismatch *bool
|
||||
if raw := strings.TrimSpace(c.Query("upstream_model_mismatch")); raw != "" {
|
||||
value, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
|
||||
return
|
||||
}
|
||||
upstreamModelMismatch = &value
|
||||
}
|
||||
|
||||
// Parse date range
|
||||
var startTime, endTime *time.Time
|
||||
userTZ := c.Query("timezone") // Get user's timezone from request
|
||||
@@ -173,20 +183,21 @@ func (h *UsageHandler) List(c *gin.Context) {
|
||||
SortOrder: c.DefaultQuery("sort_order", "desc"),
|
||||
}
|
||||
filters := usagestats.UsageLogFilters{
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
RequestID: requestID,
|
||||
Model: model,
|
||||
ModelFilterSource: usagestats.ModelSourceRequested,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
BillingMode: billingMode,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
ExactTotal: exactTotal,
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
RequestID: requestID,
|
||||
Model: model,
|
||||
ModelFilterSource: usagestats.ModelSourceRequested,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
BillingMode: billingMode,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
ExactTotal: exactTotal,
|
||||
}
|
||||
|
||||
records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters)
|
||||
@@ -276,6 +287,16 @@ func (h *UsageHandler) Stats(c *gin.Context) {
|
||||
billingType = &bt
|
||||
}
|
||||
|
||||
var upstreamModelMismatch *bool
|
||||
if raw := strings.TrimSpace(c.Query("upstream_model_mismatch")); raw != "" {
|
||||
value, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
|
||||
return
|
||||
}
|
||||
upstreamModelMismatch = &value
|
||||
}
|
||||
|
||||
// Parse date range
|
||||
userTZ := c.Query("timezone")
|
||||
now := timezone.NowInUserLocation(userTZ)
|
||||
@@ -315,18 +336,19 @@ func (h *UsageHandler) Stats(c *gin.Context) {
|
||||
|
||||
// Build filters and call GetStatsWithFilters
|
||||
filters := usagestats.UsageLogFilters{
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
Model: model,
|
||||
ModelFilterSource: usagestats.ModelSourceRequested,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
BillingMode: billingMode,
|
||||
StartTime: &startTime,
|
||||
EndTime: &endTime,
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
Model: model,
|
||||
ModelFilterSource: usagestats.ModelSourceRequested,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
BillingMode: billingMode,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
StartTime: &startTime,
|
||||
EndTime: &endTime,
|
||||
}
|
||||
|
||||
var stats *usagestats.UsageStats
|
||||
|
||||
@@ -11,17 +11,18 @@ import (
|
||||
var usageStatsCache = newSnapshotCache(30 * time.Second)
|
||||
|
||||
type usageStatsCacheKeyData struct {
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
UserID int64 `json:"user_id"`
|
||||
APIKeyID int64 `json:"api_key_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
Model string `json:"model"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
UserID int64 `json:"user_id"`
|
||||
APIKeyID int64 `json:"api_key_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
Model string `json:"model"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
|
||||
}
|
||||
|
||||
func usageStatsCacheKey(filters usagestats.UsageLogFilters) string {
|
||||
@@ -34,17 +35,18 @@ func usageStatsCacheKey(filters usagestats.UsageLogFilters) string {
|
||||
end = filters.EndTime.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return mustMarshalDashboardCacheKey(usageStatsCacheKeyData{
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
UserID: filters.UserID,
|
||||
APIKeyID: filters.APIKeyID,
|
||||
AccountID: filters.AccountID,
|
||||
GroupID: filters.GroupID,
|
||||
Model: filters.Model,
|
||||
BillingMode: filters.BillingMode,
|
||||
RequestType: filters.RequestType,
|
||||
Stream: filters.Stream,
|
||||
BillingType: filters.BillingType,
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
UserID: filters.UserID,
|
||||
APIKeyID: filters.APIKeyID,
|
||||
AccountID: filters.AccountID,
|
||||
GroupID: filters.GroupID,
|
||||
Model: filters.Model,
|
||||
BillingMode: filters.BillingMode,
|
||||
RequestType: filters.RequestType,
|
||||
Stream: filters.Stream,
|
||||
BillingType: filters.BillingType,
|
||||
UpstreamModelMismatch: filters.UpstreamModelMismatch,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1998,6 +1998,21 @@ func (h *AuthHandler) ExchangePendingOAuthCompletion(c *gin.Context) {
|
||||
response.Success(c, payload)
|
||||
return
|
||||
}
|
||||
// ─── 安全修复(账号接管 0day)────────────────────────────────────────────
|
||||
// 非终态 session(如 choose_account_action_required)的 TargetUserID 可能来自
|
||||
// 攻击者提交的他人邮箱:createPendingOAuthAccount / SendPendingOAuthVerifyCode
|
||||
// 发现邮箱已存在时会把本 session 指向该邮箱用户,全程无密码、无邮箱验证码、
|
||||
// 无账号所有权证明。若此时带着 adoption decision 继续执行,下方的
|
||||
// applyPendingOAuthAdoption 会把本 OAuth identity 直接绑定到 TargetUserID,
|
||||
// 攻击者随后再次 OAuth 登录即被系统识别为受害者本人(完整账号接管)。
|
||||
// 只有两类 session 允许在此处执行 adoption/binding:
|
||||
// 1. canIssueTokenPair == true —— 登录终态,identity 已安全绑定该用户;
|
||||
// 2. intent == bind_current_user —— 已登录用户主动发起绑定(绑定目标来自登录态 cookie)。
|
||||
// 其余状态一律只返回 payload,不绑定、不消费 session。
|
||||
if !canIssueTokenPair && !strings.EqualFold(strings.TrimSpace(session.Intent), oauthIntentBindCurrentUser) {
|
||||
response.Success(c, payload)
|
||||
return
|
||||
}
|
||||
if !adoptionDecision.hasDecision() {
|
||||
adoptionRequired, _ := payload["adoption_required"].(bool)
|
||||
if adoptionRequired {
|
||||
|
||||
@@ -910,6 +910,92 @@ func TestExchangePendingOAuthCompletionRejectsDisabledTargetUser(t *testing.T) {
|
||||
require.Nil(t, storedSession.ConsumedAt)
|
||||
}
|
||||
|
||||
func TestExchangePendingOAuthCompletionChoiceStateDoesNotBindIdentity(t *testing.T) {
|
||||
// 回归测试:复刻"补邮箱/创建账户"路径的账号接管 0day。
|
||||
// 攻击者用自己的 OAuth 账号登录后,在 create-account 步骤提交受害者邮箱,
|
||||
// 后端发现邮箱已存在会把 pending session 转入 choice 状态并指向受害者
|
||||
// (TargetUserID=受害者、无密码/验证码证明)。此时带 adoption decision 调
|
||||
// exchange 绝不能把 OAuth identity 绑定到受害者账号。
|
||||
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
||||
ctx := context.Background()
|
||||
|
||||
victim, err := client.User.Create().
|
||||
SetEmail("victim@example.com").
|
||||
SetUsername("victim-user").
|
||||
SetPasswordHash("hash").
|
||||
SetRole(service.RoleUser).
|
||||
SetStatus(service.StatusActive).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
session, err := client.PendingAuthSession.Create().
|
||||
SetSessionToken("choice-state-attack-session-token").
|
||||
SetIntent("login").
|
||||
SetProviderType("linuxdo").
|
||||
SetProviderKey("linuxdo").
|
||||
SetProviderSubject("attacker-subject-123").
|
||||
SetTargetUserID(victim.ID).
|
||||
SetResolvedEmail(victim.Email).
|
||||
SetBrowserSessionKey("choice-state-attack-browser-session-key").
|
||||
SetUpstreamIdentityClaims(map[string]any{
|
||||
"username": "attacker_linuxdo_user",
|
||||
"suggested_display_name": "Attacker Display Name",
|
||||
"suggested_avatar_url": "https://cdn.example/attacker.png",
|
||||
}).
|
||||
SetLocalFlowState(map[string]any{
|
||||
oauthCompletionResponseKey: map[string]any{
|
||||
"step": oauthPendingChoiceStep,
|
||||
"adoption_required": true,
|
||||
"force_email_on_signup": true,
|
||||
"email_binding_required": true,
|
||||
"existing_account_bindable": true,
|
||||
"email": victim.Email,
|
||||
"resolved_email": victim.Email,
|
||||
"redirect": "/dashboard",
|
||||
},
|
||||
}).
|
||||
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
body := bytes.NewBufferString(`{"adopt_display_name":true,"adopt_avatar":true}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
ginCtx, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
||||
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("choice-state-attack-browser-session-key")})
|
||||
ginCtx.Request = req
|
||||
|
||||
handler.ExchangePendingOAuthCompletion(ginCtx)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
data := decodeJSONResponseData(t, recorder)
|
||||
require.NotContains(t, data, "access_token")
|
||||
require.Equal(t, oauthPendingChoiceStep, data["step"])
|
||||
|
||||
// 攻击者的 OAuth identity 绝不能绑定到受害者账号
|
||||
identityCount, err := client.AuthIdentity.Query().
|
||||
Where(
|
||||
authidentity.ProviderTypeEQ("linuxdo"),
|
||||
authidentity.ProviderKeyEQ("linuxdo"),
|
||||
authidentity.ProviderSubjectEQ("attacker-subject-123"),
|
||||
).
|
||||
Count(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, identityCount)
|
||||
|
||||
// 受害者资料不得被 adoption 篡改
|
||||
storedVictim, err := client.User.Get(ctx, victim.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "victim-user", storedVictim.Username)
|
||||
|
||||
// session 不得被消费(攻击者无法进入下一环)
|
||||
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, storedSession.ConsumedAt)
|
||||
}
|
||||
|
||||
func TestNormalizePendingOAuthCompletionResponseScrubsLegacyTokenPayload(t *testing.T) {
|
||||
payload := normalizePendingOAuthCompletionResponse(map[string]any{
|
||||
"access_token": "legacy-access-token",
|
||||
|
||||
@@ -202,7 +202,12 @@ func groupFromServiceBase(g *service.Group) Group {
|
||||
VideoPrice480P: g.VideoPrice480P,
|
||||
VideoPrice720P: g.VideoPrice720P,
|
||||
VideoPrice1080P: g.VideoPrice1080P,
|
||||
VideoModelPrices: g.VideoModelPrices,
|
||||
WebSearchPricePerCall: g.WebSearchPricePerCall,
|
||||
SearchPricePer1k: g.SearchPricePer1k,
|
||||
AudioRealtimePricePerMin: g.AudioRealtimePricePerMin,
|
||||
AudioTtsPricePerMillionChars: g.AudioTTSPricePerMillionChars,
|
||||
AudioSttPricePerHour: g.AudioSTTPricePerHour,
|
||||
ClaudeCodeOnly: g.ClaudeCodeOnly,
|
||||
FallbackGroupID: g.FallbackGroupID,
|
||||
FallbackGroupIDOnInvalidRequest: g.FallbackGroupIDOnInvalidRequest,
|
||||
@@ -705,6 +710,8 @@ func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog {
|
||||
return &AdminUsageLog{
|
||||
UsageLog: usageLog,
|
||||
UpstreamModel: l.UpstreamModel,
|
||||
UpstreamResponseModel: l.UpstreamResponseModel,
|
||||
UpstreamModelMismatch: l.UpstreamModelMismatch,
|
||||
ChannelID: l.ChannelID,
|
||||
ModelMappingChain: l.ModelMappingChain,
|
||||
BillingTier: l.BillingTier,
|
||||
|
||||
@@ -110,11 +110,15 @@ func TestUsageLogFromService_UsesRequestedModelAndKeepsUpstreamAdminOnly(t *test
|
||||
t.Parallel()
|
||||
|
||||
upstreamModel := "claude-sonnet-4-20250514"
|
||||
upstreamResponseModel := "claude-sonnet-4-20250513"
|
||||
upstreamModelMismatch := true
|
||||
log := &service.UsageLog{
|
||||
RequestID: "req_4",
|
||||
Model: upstreamModel,
|
||||
RequestedModel: "claude-sonnet-4",
|
||||
UpstreamModel: &upstreamModel,
|
||||
RequestID: "req_4",
|
||||
Model: upstreamModel,
|
||||
RequestedModel: "claude-sonnet-4",
|
||||
UpstreamModel: &upstreamModel,
|
||||
UpstreamResponseModel: &upstreamResponseModel,
|
||||
UpstreamModelMismatch: &upstreamModelMismatch,
|
||||
}
|
||||
|
||||
userDTO := UsageLogFromService(log)
|
||||
@@ -126,10 +130,14 @@ func TestUsageLogFromService_UsesRequestedModelAndKeepsUpstreamAdminOnly(t *test
|
||||
userJSON, err := json.Marshal(userDTO)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(userJSON), "upstream_model")
|
||||
require.NotContains(t, string(userJSON), "upstream_response_model")
|
||||
require.NotContains(t, string(userJSON), "upstream_model_mismatch")
|
||||
|
||||
adminJSON, err := json.Marshal(adminDTO)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(adminJSON), `"upstream_model":"claude-sonnet-4-20250514"`)
|
||||
require.Contains(t, string(adminJSON), `"upstream_response_model":"claude-sonnet-4-20250513"`)
|
||||
require.Contains(t, string(adminJSON), `"upstream_model_mismatch":true`)
|
||||
}
|
||||
|
||||
func TestUsageLogFromService_KeepsUserBillingAndIPWithoutAdminCostFields(t *testing.T) {
|
||||
|
||||
@@ -305,6 +305,11 @@ type SystemSettings struct {
|
||||
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
||||
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
|
||||
|
||||
// Grok model mapping policy (admin settings; empty account mapping falls back to these).
|
||||
GrokDefaultTextModel string `json:"grok_default_text_model"`
|
||||
GrokCrossClientModelMapEnabled bool `json:"grok_cross_client_model_map_enabled"`
|
||||
GrokDefaultBaseURLMode string `json:"grok_default_base_url_mode"`
|
||||
|
||||
// Available Channels feature switch (user-facing aggregate view)
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
@@ -329,6 +334,9 @@ type SystemSettings struct {
|
||||
// 系统全局默认平台配额(key = platform,nil/缺省 = 不限制)
|
||||
DefaultPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"default_platform_quotas,omitempty"`
|
||||
|
||||
// 系统全局账号自动停调阈值(key = platform,100 = disabled)
|
||||
AccountSchedulingThresholds map[string]int `json:"account_scheduling_thresholds,omitempty"`
|
||||
|
||||
// 允许终端用户在用量页查看自己的失败请求
|
||||
AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"`
|
||||
}
|
||||
|
||||
@@ -121,8 +121,14 @@ type Group struct {
|
||||
VideoPrice480P *float64 `json:"video_price_480p"`
|
||||
VideoPrice720P *float64 `json:"video_price_720p"`
|
||||
VideoPrice1080P *float64 `json:"video_price_1080p"`
|
||||
// VideoModelPrices 可选按模型族×分辨率覆盖视频每秒单价 (USD/s)。
|
||||
VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"`
|
||||
// Codex alpha/search 网页搜索单次价格(USD/次);null 表示使用默认价 0.01
|
||||
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
|
||||
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
|
||||
SearchPricePer1k *float64 `json:"search_price_per_1k"`
|
||||
AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min"`
|
||||
AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars"`
|
||||
AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour"`
|
||||
|
||||
// Claude Code 客户端限制
|
||||
ClaudeCodeOnly bool `json:"claude_code_only"`
|
||||
@@ -555,6 +561,10 @@ type AdminUsageLog struct {
|
||||
// UpstreamModel is the actual model sent to the upstream provider after mapping.
|
||||
// Omitted when no mapping was applied (requested model was used as-is).
|
||||
UpstreamModel *string `json:"upstream_model,omitempty"`
|
||||
// UpstreamResponseModel is the raw model declared by the upstream response.
|
||||
UpstreamResponseModel *string `json:"upstream_response_model,omitempty"`
|
||||
// UpstreamModelMismatch is nil when the upstream did not declare a model.
|
||||
UpstreamModelMismatch *bool `json:"upstream_model_mismatch,omitempty"`
|
||||
|
||||
// ChannelID 渠道 ID
|
||||
ChannelID *int64 `json:"channel_id,omitempty"`
|
||||
|
||||
@@ -2416,6 +2416,33 @@ func (h *GatewayHandler) submitUsageRecordTask(parent context.Context, task serv
|
||||
task(ctx)
|
||||
}
|
||||
|
||||
// submitMandatoryUsageRecordTask never silently drops billing work on pool overflow.
|
||||
func (h *GatewayHandler) submitMandatoryUsageRecordTask(parent context.Context, task service.UsageRecordTask) {
|
||||
if task == nil {
|
||||
return
|
||||
}
|
||||
task = wrapUsageRecordTaskContext(parent, task)
|
||||
if h.usageRecordWorkerPool != nil {
|
||||
if mode := h.usageRecordWorkerPool.Submit(task); !mode.Dropped() {
|
||||
return
|
||||
}
|
||||
logger.L().With(
|
||||
zap.String("component", "handler.gateway.usage"),
|
||||
).Warn("gateway.usage_record_task_mandatory_sync_fallback")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
logger.L().With(
|
||||
zap.String("component", "handler.gateway.usage"),
|
||||
zap.Any("panic", recovered),
|
||||
).Error("gateway.usage_record_task_panic_recovered")
|
||||
}
|
||||
}()
|
||||
task(ctx)
|
||||
}
|
||||
|
||||
// getUserMsgQueueMode 获取当前请求的 UMQ 模式
|
||||
// 返回 "serialize" | "throttle" | ""
|
||||
func (h *GatewayHandler) getUserMsgQueueMode(account *service.Account, parsed *service.ParsedRequest) string {
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/websearch"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/tidwall/gjson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultGrokWebSearchResults = 5
|
||||
maxGrokWebSearchResults = 20
|
||||
)
|
||||
|
||||
func (h *GatewayHandler) WebSearch(c *gin.Context) {
|
||||
type webSearchReq struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
MaxResults int `json:"max_results"`
|
||||
}
|
||||
|
||||
var req webSearchReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
|
||||
"type": "invalid_request_error",
|
||||
"message": err.Error(),
|
||||
}})
|
||||
return
|
||||
}
|
||||
req.MaxResults = normalizeGrokWebSearchMaxResults(req.MaxResults)
|
||||
|
||||
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
|
||||
if !ok || apiKey == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{
|
||||
"type": "authentication_error",
|
||||
"message": "API key required",
|
||||
}})
|
||||
return
|
||||
}
|
||||
|
||||
if apiKey.Group == nil || apiKey.Group.Platform != "grok" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
|
||||
"type": "invalid_request_error",
|
||||
"message": "web search is only supported for grok groups",
|
||||
}})
|
||||
return
|
||||
}
|
||||
|
||||
// Billing eligibility (same as other requests)
|
||||
subscription, _ := middleware2.GetSubscriptionFromContext(c)
|
||||
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
|
||||
status, code, message, retryAfter := billingErrorDetails(err)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
c.JSON(status, gin.H{"error": gin.H{"type": code, "message": message}})
|
||||
return
|
||||
}
|
||||
|
||||
subject, _ := middleware2.GetAuthSubjectFromContext(c)
|
||||
reqLog := requestLogger(c, "handler.gateway.web_search")
|
||||
// Audit user search query before upstream Grok web_search traffic.
|
||||
auditBody, _ := json.Marshal(map[string]any{
|
||||
"messages": []map[string]any{{
|
||||
"role": "user", "content": req.Query,
|
||||
}},
|
||||
})
|
||||
if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, xai.DefaultTextModel, auditBody); decision != nil && !decision.AllowNextStage {
|
||||
status := decision.HTTPStatus
|
||||
if status == 0 {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
code := decision.ErrorCode
|
||||
if code == "" {
|
||||
code = "content_policy_violation"
|
||||
}
|
||||
msg := decision.ClientMessage
|
||||
if msg == "" {
|
||||
msg = "Request blocked by content policy"
|
||||
}
|
||||
c.JSON(status, gin.H{"error": gin.H{"type": code, "message": msg}})
|
||||
return
|
||||
}
|
||||
|
||||
// Use exactly the same scheduling as other requests (SelectAccountWithLoadAwareness handles load, rate limit, sticky, etc.)
|
||||
groupID := apiKey.GroupID
|
||||
if groupID == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
|
||||
"type": "invalid_request_error",
|
||||
"message": "group required",
|
||||
}})
|
||||
return
|
||||
}
|
||||
|
||||
failedAccounts := make(map[int64]struct{})
|
||||
var account *service.Account
|
||||
var accountReleaseFunc func()
|
||||
var nativeResp *websearch.SearchResponse
|
||||
var providerName string
|
||||
var err error
|
||||
|
||||
// Acquire + release holder for the whole handler (including failover retries).
|
||||
defer func() {
|
||||
if accountReleaseFunc != nil {
|
||||
accountReleaseFunc()
|
||||
}
|
||||
}()
|
||||
|
||||
// First attempt + up to 3 failover accounts (max 4 total).
|
||||
for attempt := 0; attempt < 4; attempt++ {
|
||||
selected, selectErr := h.gatewayService.SelectAccountWithLoadAwareness(
|
||||
c.Request.Context(), groupID, "", xai.DefaultTextModel, failedAccounts, "", 0,
|
||||
)
|
||||
if selectErr != nil {
|
||||
if attempt == 0 {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{
|
||||
"type": "scheduling_error",
|
||||
"message": selectErr.Error(),
|
||||
}})
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
if selected == nil || selected.Account == nil {
|
||||
if attempt == 0 {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{
|
||||
"type": "scheduling_error",
|
||||
"message": "No available accounts",
|
||||
}})
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
release, acquireOK, acquireErr := h.acquireWebSearchAccountSlot(c, selected)
|
||||
if !acquireOK {
|
||||
// First hop: surface concurrency errors; later hops try another account.
|
||||
if attempt == 0 && acquireErr != nil {
|
||||
h.handleConcurrencyError(c, acquireErr, "account", false)
|
||||
return
|
||||
}
|
||||
failedAccounts[selected.Account.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
account = selected.Account
|
||||
accountReleaseFunc = release
|
||||
|
||||
nativeResp, providerName, err = h.doGrokNativeWebSearch(c.Request.Context(), c, account, req.Query, req.MaxResults)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
var failoverErr *service.UpstreamFailoverError
|
||||
if !errors.As(err, &failoverErr) || !failoverErr.ShouldRetryNextAccount() {
|
||||
break
|
||||
}
|
||||
failedAccounts[account.ID] = struct{}{}
|
||||
if accountReleaseFunc != nil {
|
||||
accountReleaseFunc()
|
||||
accountReleaseFunc = nil
|
||||
}
|
||||
account = nil
|
||||
}
|
||||
if err != nil || nativeResp == nil {
|
||||
msg := "web search failed"
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": gin.H{"type": "web_search_error", "message": msg}})
|
||||
return
|
||||
}
|
||||
if account == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{
|
||||
"type": "scheduling_error",
|
||||
"message": "No available accounts",
|
||||
}})
|
||||
return
|
||||
}
|
||||
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
clientIP := ip.GetClientIP(c)
|
||||
inboundEndpoint := GetInboundEndpoint(c)
|
||||
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
|
||||
requestPayloadHash := service.HashUsageRequestPayload([]byte(req.Query))
|
||||
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
|
||||
// Request IDs are billing idempotency keys, so they must be unique per invocation.
|
||||
// Query/IP/UA hashes would collapse repeated identical searches into one charge.
|
||||
searchRequestID := "web_search:" + uuid.NewString()
|
||||
if apiKey.Group != nil {
|
||||
if p := apiKey.Group.GetSearchPricePer1k(); p != nil && *p == 0 {
|
||||
logger.L().With(
|
||||
zap.String("component", "handler.gateway.web_search"),
|
||||
zap.Int64("group_id", apiKey.Group.ID),
|
||||
).Info("gateway.web_search.search_price_per_1k_explicit_free")
|
||||
}
|
||||
}
|
||||
h.submitMandatoryUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
|
||||
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
||||
Result: &service.ForwardResult{
|
||||
RequestID: searchRequestID,
|
||||
Model: "grok-web-search",
|
||||
SearchCount: 1,
|
||||
Duration: 0,
|
||||
},
|
||||
APIKey: apiKey,
|
||||
User: apiKey.User,
|
||||
Account: account,
|
||||
Subscription: subscription,
|
||||
InboundEndpoint: inboundEndpoint,
|
||||
UpstreamEndpoint: upstreamEndpoint,
|
||||
UserAgent: userAgent,
|
||||
IPAddress: clientIP,
|
||||
RequestPayloadHash: requestPayloadHash,
|
||||
APIKeyService: h.apiKeyService,
|
||||
QuotaPlatform: quotaPlatform,
|
||||
}); err != nil {
|
||||
logger.L().With(
|
||||
zap.String("component", "handler.gateway.web_search"),
|
||||
zap.Int64("user_id", apiKey.User.ID),
|
||||
zap.Int64("api_key_id", apiKey.ID),
|
||||
zap.Int64("account_id", account.ID),
|
||||
).Error("gateway.web_search.record_usage_failed", zap.Error(err))
|
||||
}
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"query": req.Query,
|
||||
"results": nativeResp.Results,
|
||||
"provider": providerName,
|
||||
"max_results": req.MaxResults,
|
||||
})
|
||||
}
|
||||
|
||||
// acquireWebSearchAccountSlot resolves an immediate slot or WaitPlan wait.
|
||||
// On failure returns (nil, false, err); err is non-nil for concurrency acquire
|
||||
// failures so the first hop can map them to HTTP. Wait-queue full returns
|
||||
// (nil, false, nil) so failover can try another account.
|
||||
func (h *GatewayHandler) acquireWebSearchAccountSlot(
|
||||
c *gin.Context,
|
||||
selected *service.AccountSelectionResult,
|
||||
) (release func(), ok bool, acquireErr error) {
|
||||
if selected == nil || selected.Account == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
if selected.Acquired {
|
||||
return selected.ReleaseFunc, true, nil
|
||||
}
|
||||
if selected.WaitPlan == nil || h.concurrencyHelper == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
account := selected.Account
|
||||
accountWaitCounted := false
|
||||
canWait, waitErr := h.concurrencyHelper.IncrementAccountWaitCount(c.Request.Context(), account.ID, selected.WaitPlan.MaxWaiting)
|
||||
if waitErr != nil {
|
||||
logger.L().Warn("gateway.web_search.account_wait_counter_increment_failed",
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.Error(waitErr),
|
||||
)
|
||||
// Best-effort wait without counter (same as first-hop legacy path).
|
||||
} else if !canWait {
|
||||
return nil, false, nil
|
||||
} else {
|
||||
accountWaitCounted = true
|
||||
}
|
||||
releaseWait := func() {
|
||||
if accountWaitCounted {
|
||||
h.concurrencyHelper.DecrementAccountWaitCount(c.Request.Context(), account.ID)
|
||||
accountWaitCounted = false
|
||||
}
|
||||
}
|
||||
streamStarted := false
|
||||
slotRelease, err := h.concurrencyHelper.AcquireAccountSlotWithWaitTimeout(
|
||||
c,
|
||||
account.ID,
|
||||
selected.WaitPlan.MaxConcurrency,
|
||||
selected.WaitPlan.Timeout,
|
||||
false,
|
||||
&streamStarted,
|
||||
)
|
||||
releaseWait()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return slotRelease, true, nil
|
||||
}
|
||||
|
||||
// doGrokNativeWebSearch executes web search using the Grok account's native capability
|
||||
// by calling the responses endpoint with web_search tool, then normalizes sources to unified format.
|
||||
func (h *GatewayHandler) doGrokNativeWebSearch(ctx context.Context, c *gin.Context, account *service.Account, query string, maxResults int) (*websearch.SearchResponse, string, error) {
|
||||
maxResults = normalizeGrokWebSearchMaxResults(maxResults)
|
||||
|
||||
// Build a minimal responses request that triggers Grok web search tool.
|
||||
// Ask for structured metadata because xAI action.sources commonly contains URLs only.
|
||||
searchBody := map[string]any{
|
||||
"model": xai.DefaultTextModel,
|
||||
"input": buildGrokWebSearchPrompt(query, maxResults),
|
||||
"tools": []map[string]any{{"type": "web_search"}},
|
||||
"include": []string{"web_search_call.action.sources"},
|
||||
"store": false,
|
||||
"stream": false,
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(searchBody)
|
||||
|
||||
respBytes, err := h.gatewayService.DoGrokNativeResponsesJSON(ctx, account, bodyBytes)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// Extract sources from Grok responses output.
|
||||
// Prefer web_search_call.action.sources (standardized), fallback to annotations or text links.
|
||||
results := extractGrokWebSearchSources(respBytes, maxResults)
|
||||
|
||||
return &websearch.SearchResponse{
|
||||
Results: results,
|
||||
Query: query,
|
||||
}, "grok-native", nil
|
||||
}
|
||||
|
||||
func normalizeGrokWebSearchMaxResults(maxResults int) int {
|
||||
if maxResults <= 0 {
|
||||
return defaultGrokWebSearchResults
|
||||
}
|
||||
if maxResults > maxGrokWebSearchResults {
|
||||
return maxGrokWebSearchResults
|
||||
}
|
||||
return maxResults
|
||||
}
|
||||
|
||||
func buildGrokWebSearchPrompt(query string, maxResults int) string {
|
||||
return fmt.Sprintf(`Search the web for the user query below. Return ONLY valid JSON with this exact shape: {"results":[{"url":"https://...","title":"page title","snippet":"concise factual summary"}]}. Return at most %d unique results. Every URL must be an actual web_search source. Populate a non-empty title and snippet for every result. Do not wrap the JSON in markdown.
|
||||
|
||||
User query:
|
||||
%s`, normalizeGrokWebSearchMaxResults(maxResults), query)
|
||||
}
|
||||
|
||||
// extractGrokWebSearchSources returns model-enriched results only when their URLs
|
||||
// are present in the actual web_search sources, then falls back to raw sources.
|
||||
func extractGrokWebSearchSources(body []byte, maxResults int) []websearch.SearchResult {
|
||||
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
return nil
|
||||
}
|
||||
maxResults = normalizeGrokWebSearchMaxResults(maxResults)
|
||||
|
||||
sources := make(map[string]websearch.SearchResult)
|
||||
var sourceOrder []string
|
||||
addSource := func(rawURL, title, snippet string) {
|
||||
key, ok := normalizeGrokWebSearchURL(rawURL)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, exists := sources[key]
|
||||
if !exists {
|
||||
result.URL = strings.TrimSpace(rawURL)
|
||||
sourceOrder = append(sourceOrder, key)
|
||||
}
|
||||
if result.Title == "" {
|
||||
result.Title = usableGrokWebSearchTitle(title, result.URL)
|
||||
}
|
||||
if result.Snippet == "" {
|
||||
result.Snippet = strings.TrimSpace(snippet)
|
||||
}
|
||||
sources[key] = result
|
||||
}
|
||||
|
||||
output := gjson.GetBytes(body, "output")
|
||||
output.ForEach(func(_, item gjson.Result) bool {
|
||||
if item.Get("type").String() == "web_search_call" {
|
||||
sources := item.Get("action.sources")
|
||||
if sources.IsArray() {
|
||||
sources.ForEach(func(_, src gjson.Result) bool {
|
||||
addSource(src.Get("url").String(), src.Get("title").String(), src.Get("snippet").String())
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
if item.Get("type").String() == "message" {
|
||||
item.Get("content").ForEach(func(_, part gjson.Result) bool {
|
||||
if part.Get("type").String() != "output_text" {
|
||||
return true
|
||||
}
|
||||
part.Get("annotations").ForEach(func(_, ann gjson.Result) bool {
|
||||
if ann.Get("type").String() == "url_citation" || ann.Get("type").String() == "web" {
|
||||
addSource(ann.Get("url").String(), ann.Get("title").String(), "")
|
||||
}
|
||||
return true
|
||||
})
|
||||
return true
|
||||
})
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
var out []websearch.SearchResult
|
||||
seen := make(map[string]bool)
|
||||
output.ForEach(func(_, item gjson.Result) bool {
|
||||
if item.Get("type").String() != "message" {
|
||||
return true
|
||||
}
|
||||
item.Get("content").ForEach(func(_, part gjson.Result) bool {
|
||||
if part.Get("type").String() != "output_text" || len(out) >= maxResults {
|
||||
return true
|
||||
}
|
||||
for _, result := range parseGrokWebSearchStructuredResults(part.Get("text").String()) {
|
||||
key, ok := normalizeGrokWebSearchURL(result.URL)
|
||||
if !ok || seen[key] {
|
||||
continue
|
||||
}
|
||||
source, allowed := sources[key]
|
||||
if !allowed {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
result.URL = source.URL
|
||||
result.Title = usableGrokWebSearchTitle(result.Title, result.URL)
|
||||
if result.Title == "" {
|
||||
result.Title = source.Title
|
||||
}
|
||||
result.Snippet = strings.TrimSpace(result.Snippet)
|
||||
if result.Snippet == "" {
|
||||
result.Snippet = source.Snippet
|
||||
}
|
||||
out = append(out, result)
|
||||
if len(out) >= maxResults {
|
||||
break
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return len(out) < maxResults
|
||||
})
|
||||
|
||||
for _, key := range sourceOrder {
|
||||
if len(out) >= maxResults {
|
||||
break
|
||||
}
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
result := sources[key]
|
||||
if result.Title == "" {
|
||||
result.Title = grokWebSearchTitleFromURL(result.URL)
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, result)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseGrokWebSearchStructuredResults(text string) []websearch.SearchResult {
|
||||
text = strings.TrimSpace(text)
|
||||
start := strings.IndexByte(text, '{')
|
||||
end := strings.LastIndexByte(text, '}')
|
||||
if start < 0 || end < start {
|
||||
return nil
|
||||
}
|
||||
var payload struct {
|
||||
Results []websearch.SearchResult `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(text[start:end+1]), &payload); err != nil {
|
||||
return nil
|
||||
}
|
||||
return payload.Results
|
||||
}
|
||||
|
||||
func normalizeGrokWebSearchURL(rawURL string) (string, bool) {
|
||||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return "", false
|
||||
}
|
||||
u.Scheme = strings.ToLower(u.Scheme)
|
||||
u.Host = strings.ToLower(u.Host)
|
||||
u.Fragment = ""
|
||||
if u.Path == "" {
|
||||
u.Path = "/"
|
||||
}
|
||||
return u.String(), true
|
||||
}
|
||||
|
||||
func usableGrokWebSearchTitle(title, rawURL string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" || title == rawURL {
|
||||
return ""
|
||||
}
|
||||
if _, err := strconv.Atoi(title); err == nil {
|
||||
return ""
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
func grokWebSearchTitleFromURL(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return rawURL
|
||||
}
|
||||
return strings.TrimPrefix(strings.ToLower(u.Host), "www.")
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
coderws "github.com/coder/websocket"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// GrokRealtime exposes xAI's native Voice Realtime WebSocket.
|
||||
// Only Grok-platform API keys may use this endpoint.
|
||||
func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) {
|
||||
if c == nil || c.Request == nil || !isOpenAIWSUpgradeRequest(c.Request) {
|
||||
h.errorResponse(c, http.StatusUpgradeRequired, "invalid_request_error", "WebSocket upgrade required (Upgrade: websocket)")
|
||||
return
|
||||
}
|
||||
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
|
||||
if !ok || apiKey.Group == nil || apiKey.Group.Platform != service.PlatformGrok {
|
||||
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Realtime API is not supported for this platform")
|
||||
return
|
||||
}
|
||||
if !h.ensureResponsesDependencies(c, nil) {
|
||||
return
|
||||
}
|
||||
subscription, _ := middleware2.GetSubscriptionFromContext(c)
|
||||
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
|
||||
status, code, message, retryAfter := billingErrorDetails(err)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
h.errorResponse(c, status, code, message)
|
||||
return
|
||||
}
|
||||
|
||||
selection, _, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
|
||||
c.Request.Context(),
|
||||
apiKey.GroupID,
|
||||
"",
|
||||
"",
|
||||
"grok-4.5",
|
||||
nil,
|
||||
service.OpenAIUpstreamTransportHTTPSSE,
|
||||
// Grok only advertises chat_completions + media capabilities on HEAD.
|
||||
service.OpenAIEndpointCapabilityChatCompletions,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
service.PlatformGrok,
|
||||
)
|
||||
if err != nil || selection == nil || selection.Account == nil {
|
||||
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available Grok accounts")
|
||||
return
|
||||
}
|
||||
|
||||
var streamStarted bool
|
||||
reqLog := requestLogger(c, "handler.openai_gateway.grok_realtime")
|
||||
release, slotStatus := h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", selection, true, &streamStarted, reqLog)
|
||||
if slotStatus != openAISlotAcquireOK {
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
token, _, err := h.gatewayService.GetRequestCredential(c.Request.Context(), c, selection.Account)
|
||||
if err != nil {
|
||||
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Grok credential unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := coderws.Accept(c.Writer, c.Request, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.CloseNow() }()
|
||||
|
||||
model := c.Query("model")
|
||||
if strings.TrimSpace(model) == "" {
|
||||
model = "grok-voice-latest"
|
||||
}
|
||||
started := time.Now()
|
||||
proxyErr := h.gatewayService.ProxyGrokRealtime(c.Request.Context(), c, conn, selection.Account, token, model)
|
||||
elapsed := time.Since(started)
|
||||
if proxyErr != nil {
|
||||
reqLog.Info("grok_realtime.proxy_failed", zap.Error(proxyErr))
|
||||
if !isExpectedGrokRealtimeClose(proxyErr) {
|
||||
_ = conn.Close(coderws.StatusInternalError, "upstream realtime websocket failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
// A relay normally returns a close error when either side closes normally.
|
||||
// Those sessions still consumed upstream audio time and must be billed.
|
||||
if elapsed > 0 {
|
||||
result := &service.OpenAIForwardResult{
|
||||
// One durable id per WS session so retries cannot collapse or double under client ids.
|
||||
RequestID: service.StableGrokRealtimeBillingRequestID(""),
|
||||
Model: model,
|
||||
Duration: elapsed,
|
||||
AudioUsage: &service.AudioUsage{Mode: "realtime", DurationOrUnits: elapsed.Minutes()},
|
||||
}
|
||||
h.recordGrokVoiceUsage(c, apiKey, selection.Account, subscription, "realtime", nil, result)
|
||||
}
|
||||
}
|
||||
|
||||
func isExpectedGrokRealtimeClose(err error) bool {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
switch coderws.CloseStatus(err) {
|
||||
case coderws.StatusNormalClosure, coderws.StatusGoingAway,
|
||||
coderws.StatusNoStatusRcvd, coderws.StatusAbnormalClosure:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// GrokVoice handles xAI Voice HTTP endpoints. endpoint is "tts", "stt", or "custom-voices".
|
||||
func (h *OpenAIGatewayHandler) GrokVoice(c *gin.Context, endpoint string) {
|
||||
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
|
||||
if !ok || apiKey.Group == nil || apiKey.Group.Platform != service.PlatformGrok {
|
||||
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Voice API is not supported for this platform")
|
||||
return
|
||||
}
|
||||
if !h.ensureResponsesDependencies(c, nil) {
|
||||
return
|
||||
}
|
||||
subscription, _ := middleware2.GetSubscriptionFromContext(c)
|
||||
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
|
||||
status, code, message, retryAfter := billingErrorDetails(err)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
h.errorResponse(c, status, code, message)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := readGrokVoiceGatewayBody(c)
|
||||
if err != nil {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
if endpoint == "tts" {
|
||||
subject, _ := middleware2.GetAuthSubjectFromContext(c)
|
||||
reqLog := requestLogger(c, "handler.openai_gateway.grok_voice", zap.String("endpoint", endpoint))
|
||||
// TTS bodies use {"input":"..."} (and variants). Normalize to chat messages so
|
||||
// content moderation extractors see the spoken text.
|
||||
auditBody := body
|
||||
if input := extractGrokTTSInputText(body); input != "" {
|
||||
if b, err := json.Marshal(map[string]any{
|
||||
"messages": []map[string]any{{"role": "user", "content": input}},
|
||||
}); err == nil {
|
||||
auditBody = b
|
||||
}
|
||||
}
|
||||
if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, "grok-4.5", auditBody); decision != nil && !decision.AllowNextStage {
|
||||
h.openAISecurityAuditError(c, decision)
|
||||
return
|
||||
}
|
||||
}
|
||||
contentType := c.GetHeader("Content-Type")
|
||||
if strings.TrimSpace(contentType) == "" {
|
||||
contentType = "application/json"
|
||||
}
|
||||
|
||||
failed := map[int64]struct{}{}
|
||||
var last *service.UpstreamFailoverError
|
||||
reqLog := requestLogger(c, "handler.openai_gateway.grok_voice", zap.String("endpoint", endpoint))
|
||||
selectionModel := "grok-4.5"
|
||||
|
||||
for attempts := 0; attempts < 4; attempts++ {
|
||||
selection, _, selectErr := h.gatewayService.SelectAccountWithSchedulerForCapability(
|
||||
c.Request.Context(),
|
||||
apiKey.GroupID,
|
||||
"",
|
||||
"",
|
||||
selectionModel,
|
||||
failed,
|
||||
service.OpenAIUpstreamTransportHTTPSSE,
|
||||
service.OpenAIEndpointCapabilityChatCompletions,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
service.PlatformGrok,
|
||||
)
|
||||
if selectErr != nil || selection == nil || selection.Account == nil {
|
||||
if last != nil {
|
||||
h.handleFailoverExhausted(c, last, false)
|
||||
} else {
|
||||
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available Grok accounts")
|
||||
}
|
||||
return
|
||||
}
|
||||
account := selection.Account
|
||||
var started bool
|
||||
release, status := h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", selection, false, &started, reqLog)
|
||||
if status == openAISlotAcquireProfitVetoed {
|
||||
failed[account.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if status != openAISlotAcquireOK {
|
||||
// Failed already wrote error response (or transient reject).
|
||||
if status == openAISlotAcquireFailed && len(failed) == 0 {
|
||||
// Slot path wrote the response; stop.
|
||||
return
|
||||
}
|
||||
failed[account.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
result, forwardErr := func() (*service.OpenAIForwardResult, error) {
|
||||
defer release()
|
||||
return h.gatewayService.ForwardGrokVoice(c.Request.Context(), c, account, endpoint, body, contentType)
|
||||
}()
|
||||
if forwardErr == nil {
|
||||
h.recordGrokVoiceUsage(c, apiKey, account, subscription, endpoint, body, result)
|
||||
return
|
||||
}
|
||||
var failoverErr *service.UpstreamFailoverError
|
||||
if errors.As(forwardErr, &failoverErr) && failoverErr.ShouldRetryNextAccount() {
|
||||
failed[account.ID] = struct{}{}
|
||||
last = failoverErr
|
||||
continue
|
||||
}
|
||||
// Non-failover errors: handleGrokMediaErrorResponse / transport already wrote response.
|
||||
return
|
||||
}
|
||||
if last != nil {
|
||||
h.handleFailoverExhausted(c, last, false)
|
||||
}
|
||||
}
|
||||
|
||||
// recordGrokVoiceUsage bills TTS/STT/realtime via group audio prices when AudioUsage is set.
|
||||
func (h *OpenAIGatewayHandler) recordGrokVoiceUsage(
|
||||
c *gin.Context,
|
||||
apiKey *service.APIKey,
|
||||
account *service.Account,
|
||||
subscription *service.UserSubscription,
|
||||
endpoint string,
|
||||
body []byte,
|
||||
result *service.OpenAIForwardResult,
|
||||
) {
|
||||
if h == nil || c == nil || apiKey == nil || account == nil || result == nil {
|
||||
return
|
||||
}
|
||||
if result.AudioUsage == nil {
|
||||
return
|
||||
}
|
||||
// Ensure forced durable request ids even if callers forget (realtime/tts/stt money path).
|
||||
if mode := strings.TrimSpace(result.AudioUsage.Mode); mode == "realtime" {
|
||||
result.RequestID = service.StableGrokRealtimeBillingRequestID(result.RequestID)
|
||||
} else {
|
||||
result.RequestID = service.StableGrokAudioBillingRequestID(result.RequestID)
|
||||
}
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
clientIP := ip.GetClientIP(c)
|
||||
sessionID := service.ExtractClientSessionID(c)
|
||||
requestPayloadHash := service.HashUsageRequestPayload(body)
|
||||
if requestPayloadHash == "" {
|
||||
requestPayloadHash = service.HashUsageRequestPayload([]byte(endpoint))
|
||||
}
|
||||
inboundEndpoint := GetInboundEndpoint(c)
|
||||
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
|
||||
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
|
||||
model := strings.TrimSpace(result.Model)
|
||||
if model == "" {
|
||||
model = endpoint
|
||||
}
|
||||
|
||||
h.submitMandatoryUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
|
||||
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
|
||||
Result: result,
|
||||
APIKey: apiKey,
|
||||
User: apiKey.User,
|
||||
Account: account,
|
||||
Subscription: subscription,
|
||||
InboundEndpoint: inboundEndpoint,
|
||||
UpstreamEndpoint: upstreamEndpoint,
|
||||
UserAgent: userAgent,
|
||||
IPAddress: clientIP,
|
||||
RequestPayloadHash: requestPayloadHash,
|
||||
APIKeyService: h.apiKeyService,
|
||||
QuotaPlatform: quotaPlatform,
|
||||
SessionID: sessionID,
|
||||
ChannelUsageFields: clientRequestedUsageFields(c, service.ChannelMappingResult{}, model, result.UpstreamModel),
|
||||
}); err != nil {
|
||||
logger.L().With(
|
||||
zap.String("component", "handler.openai_gateway.grok_voice"),
|
||||
zap.Int64("user_id", apiKey.User.ID),
|
||||
zap.Int64("api_key_id", apiKey.ID),
|
||||
zap.Any("group_id", apiKey.GroupID),
|
||||
zap.String("endpoint", endpoint),
|
||||
zap.Int64("account_id", account.ID),
|
||||
).Error("grok_voice.record_usage_failed", zap.Error(err))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func readGrokVoiceGatewayBody(c *gin.Context) ([]byte, error) {
|
||||
if c == nil || c.Request == nil {
|
||||
return nil, errors.New("request body is required")
|
||||
}
|
||||
if c.Request.Body == nil {
|
||||
if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodDelete {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.New("request body is required")
|
||||
}
|
||||
return io.ReadAll(c.Request.Body)
|
||||
}
|
||||
|
||||
// extractGrokTTSInputText pulls the primary spoken text from a TTS JSON body.
|
||||
func extractGrokTTSInputText(body []byte) string {
|
||||
if len(body) == 0 {
|
||||
return ""
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"input", "text", "prompt"} {
|
||||
if v, ok := payload[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//go:build unit
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
coderws "github.com/coder/websocket"
|
||||
)
|
||||
|
||||
func TestIsExpectedGrokRealtimeClose(t *testing.T) {
|
||||
for _, status := range []coderws.StatusCode{
|
||||
coderws.StatusNormalClosure,
|
||||
coderws.StatusGoingAway,
|
||||
coderws.StatusNoStatusRcvd,
|
||||
coderws.StatusAbnormalClosure,
|
||||
} {
|
||||
if !isExpectedGrokRealtimeClose(coderws.CloseError{Code: status}) {
|
||||
t.Fatalf("status %v should be treated as an expected session close", status)
|
||||
}
|
||||
}
|
||||
if isExpectedGrokRealtimeClose(coderws.CloseError{Code: coderws.StatusPolicyViolation}) {
|
||||
t.Fatal("policy violations must not be treated as billable normal closes")
|
||||
}
|
||||
}
|
||||
@@ -188,6 +188,10 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
var oauth429FailoverState service.OpenAIOAuth429FailoverState
|
||||
mediaEligibilityRejected := false
|
||||
switchCount := 0
|
||||
videoCreateStartedAt := ""
|
||||
if isGrokVideoCreateEndpoint(endpoint) {
|
||||
videoCreateStartedAt = service.GrokVideoPendingCreatedAtNow()
|
||||
}
|
||||
maxAccountSwitches := h.maxAccountSwitches
|
||||
if maxAccountSwitches <= 0 {
|
||||
maxAccountSwitches = 3
|
||||
@@ -406,7 +410,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
}
|
||||
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, grokMediaScheduleModel(account, routingModel, result), true, nil)
|
||||
if endpoint.IsGenerationRequest() && strings.TrimSpace(result.ResponseID) != "" {
|
||||
if isGrokVideoCreateEndpoint(endpoint) && strings.TrimSpace(result.ResponseID) != "" {
|
||||
if err := h.gatewayService.BindGrokMediaVideoRequestAccount(
|
||||
requestCtx, apiKey.GroupID, result.ResponseID, subject.UserID, apiKey.ID, account.ID,
|
||||
); err != nil {
|
||||
@@ -416,8 +420,44 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
// Defer billing until status polling observes video.url. Persist create-time
|
||||
// model/duration/resolution so status can still price if upstream omits them.
|
||||
// Retry once: missing pending causes silent underpricing (status omits resolution).
|
||||
pending := service.GrokVideoPendingBilling{
|
||||
Model: requestModel,
|
||||
BillingModel: firstNonEmptyString(result.BillingModel, requestModel),
|
||||
UpstreamModel: result.UpstreamModel,
|
||||
VideoResolution: result.VideoResolution,
|
||||
VideoDurationSeconds: result.VideoDurationSeconds,
|
||||
OriginalModel: clientRequestedModel(c, requestModel),
|
||||
// Wall-clock start for usage duration_ms: create accepted → first done discovery.
|
||||
CreatedAt: videoCreateStartedAt,
|
||||
}
|
||||
if err := h.gatewayService.StoreGrokVideoPendingBilling(requestCtx, result.ResponseID, subject.UserID, apiKey.ID, pending); err != nil {
|
||||
reqLog.Warn("grok_media.store_video_pending_billing_failed_retrying",
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.String("request_id", result.ResponseID),
|
||||
zap.Error(err),
|
||||
)
|
||||
if err2 := h.gatewayService.StoreGrokVideoPendingBilling(requestCtx, result.ResponseID, subject.UserID, apiKey.ID, pending); err2 != nil {
|
||||
// Response body may already be committed; completion path will fail-closed
|
||||
// when pending is still missing and status cannot price duration.
|
||||
reqLog.Error("grok_media.store_video_pending_billing_failed",
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.String("request_id", result.ResponseID),
|
||||
zap.Error(err2),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if shouldRecordGrokMediaUsage(endpoint, requestModel) {
|
||||
// Status poll OR content download can observe official done+video.url.
|
||||
// Both paths share the same claim key so the customer is charged once.
|
||||
if endpoint == service.GrokMediaEndpointVideoStatus || endpoint == service.GrokMediaEndpointVideoContent {
|
||||
taskID := strings.TrimSpace(requestID)
|
||||
if billResult := prepareGrokVideoCompletionBilling(requestCtx, h, reqLog, apiKey, subject, taskID, result); billResult != nil {
|
||||
recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, billResult, billResult.Model, body, taskID)
|
||||
}
|
||||
} else if shouldRecordGrokMediaUsage(endpoint, requestModel, result) {
|
||||
recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, result, requestModel, body, requestID)
|
||||
}
|
||||
reqLog.Debug("grok_media.request_completed",
|
||||
@@ -459,8 +499,147 @@ func grokMediaScheduleModel(account *service.Account, routingModel string, resul
|
||||
return account.GetMappedModel(routingModel)
|
||||
}
|
||||
|
||||
func shouldRecordGrokMediaUsage(endpoint service.GrokMediaEndpoint, requestModel string) bool {
|
||||
return endpoint.IsGenerationRequest() && strings.TrimSpace(requestModel) != ""
|
||||
func isGrokVideoCreateEndpoint(endpoint service.GrokMediaEndpoint) bool {
|
||||
switch endpoint {
|
||||
case service.GrokMediaEndpointVideosGenerations,
|
||||
service.GrokMediaEndpointVideosEdits,
|
||||
service.GrokMediaEndpointVideosExtensions:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// shouldRecordGrokMediaUsage gates usage writes for immediate (image) generation.
|
||||
// Async video create never bills here — status polling does on official
|
||||
// status=done with video.url (docs.x.ai Video Generation).
|
||||
// Status/content polls, empty model, and failed generations with zero billable
|
||||
// image units never bill via this helper.
|
||||
func shouldRecordGrokMediaUsage(endpoint service.GrokMediaEndpoint, requestModel string, result *service.OpenAIForwardResult) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
if isGrokVideoCreateEndpoint(endpoint) || endpoint.IsVideoLookupRequest() {
|
||||
return false
|
||||
}
|
||||
if !endpoint.IsGenerationRequest() || strings.TrimSpace(requestModel) == "" {
|
||||
return false
|
||||
}
|
||||
return result.ImageCount > 0
|
||||
}
|
||||
|
||||
// prepareGrokVideoCompletionBilling claims one-shot billing for official done+video.url
|
||||
// observations (status poll or content download). Duration/model prefer status body;
|
||||
// resolution uses create-time request (status response does not document resolution).
|
||||
func prepareGrokVideoCompletionBilling(
|
||||
ctx context.Context,
|
||||
h *OpenAIGatewayHandler,
|
||||
reqLog *zap.Logger,
|
||||
apiKey *service.APIKey,
|
||||
subject middleware2.AuthSubject,
|
||||
taskRequestID string,
|
||||
statusResult *service.OpenAIForwardResult,
|
||||
) *service.OpenAIForwardResult {
|
||||
if h == nil || h.gatewayService == nil || apiKey == nil || statusResult == nil {
|
||||
return nil
|
||||
}
|
||||
// Forward already set VideoCount only when status=done && video.url (official).
|
||||
if statusResult.VideoCount <= 0 {
|
||||
return nil
|
||||
}
|
||||
taskRequestID = strings.TrimSpace(firstNonEmptyString(taskRequestID, statusResult.ResponseID))
|
||||
if taskRequestID == "" {
|
||||
return nil
|
||||
}
|
||||
// Load create-time snapshot before claim so we can fail-closed without burning the claim
|
||||
// when Redis lost pending and status cannot price the job.
|
||||
pending, loadErr := h.gatewayService.LoadGrokVideoPendingBilling(ctx, taskRequestID, subject.UserID, apiKey.ID)
|
||||
if loadErr != nil {
|
||||
reqLog.Warn("grok_media.video_pending_billing_load_failed", zap.String("request_id", taskRequestID), zap.Error(loadErr))
|
||||
}
|
||||
if pending == nil {
|
||||
// Status omits resolution; without pending we would silently default to 480p and underbill.
|
||||
// Allow billing only when official status carries duration (still may default resolution).
|
||||
if statusResult.VideoDurationSeconds <= 0 {
|
||||
reqLog.Error("grok_media.video_billing_skipped_missing_pending",
|
||||
zap.String("request_id", taskRequestID),
|
||||
zap.String("reason", "no create-time snapshot and status has no video.duration"),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
reqLog.Error("grok_media.video_billing_without_pending",
|
||||
zap.String("request_id", taskRequestID),
|
||||
zap.Int("status_duration_seconds", statusResult.VideoDurationSeconds),
|
||||
zap.String("note", "resolution falls back to default 480p; investigate pending store failures"),
|
||||
)
|
||||
}
|
||||
claimed, err := h.gatewayService.ClaimGrokVideoBilling(ctx, taskRequestID, subject.UserID, apiKey.ID)
|
||||
if err != nil {
|
||||
reqLog.Warn("grok_media.video_billing_claim_failed", zap.String("request_id", taskRequestID), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
if !claimed {
|
||||
reqLog.Debug("grok_media.video_billing_already_claimed", zap.String("request_id", taskRequestID))
|
||||
return nil
|
||||
}
|
||||
// Re-merge with pending: resolution is request-only; model/duration fill gaps.
|
||||
merged := *statusResult
|
||||
if pending != nil {
|
||||
if strings.TrimSpace(merged.Model) == "" {
|
||||
merged.Model = firstNonEmptyString(pending.BillingModel, pending.Model, pending.OriginalModel)
|
||||
}
|
||||
if strings.TrimSpace(merged.BillingModel) == "" {
|
||||
merged.BillingModel = firstNonEmptyString(pending.BillingModel, pending.Model, merged.Model)
|
||||
}
|
||||
if strings.TrimSpace(merged.UpstreamModel) == "" {
|
||||
merged.UpstreamModel = pending.UpstreamModel
|
||||
}
|
||||
// Official status omits resolution — always prefer create request.
|
||||
if strings.TrimSpace(pending.VideoResolution) != "" {
|
||||
merged.VideoResolution = pending.VideoResolution
|
||||
}
|
||||
if merged.VideoDurationSeconds <= 0 {
|
||||
merged.VideoDurationSeconds = pending.VideoDurationSeconds
|
||||
}
|
||||
if strings.TrimSpace(merged.ResponseID) == "" {
|
||||
merged.ResponseID = taskRequestID
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(merged.Model) == "" {
|
||||
merged.Model = "grok-imagine-video"
|
||||
}
|
||||
if strings.TrimSpace(merged.BillingModel) == "" {
|
||||
merged.BillingModel = merged.Model
|
||||
}
|
||||
// Always force durable task id so usage_billing_dedup survives multi-poll +
|
||||
// context-local request ids (do not prefer empty-only fill).
|
||||
merged.RequestID = service.StableGrokVideoBillingRequestID(firstNonEmptyString(merged.ResponseID, taskRequestID))
|
||||
merged.ResponseID = firstNonEmptyString(merged.ResponseID, taskRequestID)
|
||||
merged.VideoCount = 1
|
||||
// Pure video: do not keep legacy ImageCount (avoids image-path heuristics).
|
||||
merged.ImageCount = 0
|
||||
// Official default resolution is 480p when the create request omitted it.
|
||||
merged.VideoResolution = service.NormalizeVideoBillingResolutionOrDefault(merged.VideoResolution)
|
||||
// Official default duration is 8s when neither status nor create provided it.
|
||||
merged.VideoDurationSeconds = service.NormalizeVideoBillingDurationSecondsOrDefault(merged.VideoDurationSeconds)
|
||||
// E2E latency for async video: create accept → this discovery of done+url.
|
||||
// Bill on discovery (status/content), not after further client polls; duration
|
||||
// must not be only the single discovery hop (~hundreds of ms).
|
||||
if pending != nil {
|
||||
if e2e := service.GrokVideoE2EDuration(pending.CreatedAt, time.Now()); e2e > 0 {
|
||||
merged.Duration = e2e
|
||||
}
|
||||
}
|
||||
return &merged
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, v := range values {
|
||||
if s := strings.TrimSpace(v); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func recordGrokMediaUsage(
|
||||
@@ -493,6 +672,18 @@ func recordGrokMediaUsage(
|
||||
OriginalModel: clientRequestedModel(c, requestModel),
|
||||
ChannelMappedModel: requestModel,
|
||||
}
|
||||
// Async video: force durable task request id and release claim if billing fails.
|
||||
videoTaskID := ""
|
||||
if result != nil && result.VideoCount > 0 {
|
||||
videoTaskID = strings.TrimSpace(firstNonEmptyString(requestID, result.ResponseID))
|
||||
if stable := service.StableGrokVideoBillingRequestID(firstNonEmptyString(result.ResponseID, requestID)); stable != "" {
|
||||
result.RequestID = stable
|
||||
}
|
||||
// Prefer task id hash for payload fingerprint stability across status/content.
|
||||
if len(body) == 0 && videoTaskID != "" {
|
||||
payloadForHash = []byte(videoTaskID)
|
||||
}
|
||||
}
|
||||
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
|
||||
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
|
||||
Result: result,
|
||||
@@ -510,6 +701,14 @@ func recordGrokMediaUsage(
|
||||
SessionID: sessionID,
|
||||
ChannelUsageFields: channelUsageFields,
|
||||
}); err != nil {
|
||||
if videoTaskID != "" {
|
||||
if releaseErr := h.gatewayService.ReleaseGrokVideoBilling(ctx, videoTaskID, subject.UserID, apiKey.ID); releaseErr != nil {
|
||||
reqLog.Warn("grok_media.video_billing_claim_release_failed",
|
||||
zap.String("request_id", videoTaskID),
|
||||
zap.Error(releaseErr),
|
||||
)
|
||||
}
|
||||
}
|
||||
logger.L().With(
|
||||
zap.String("component", "handler.openai_gateway.grok_media"),
|
||||
zap.Int64("user_id", subject.UserID),
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -41,13 +42,13 @@ func TestShouldRecordGrokMediaUsage(t *testing.T) {
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "video generation records usage",
|
||||
name: "video generation defers usage until status",
|
||||
endpoint: service.GrokMediaEndpointVideosGenerations,
|
||||
model: "grok-imagine-video-1.5",
|
||||
want: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "video status skips empty model usage",
|
||||
name: "video status skips immediate helper (status path claims separately)",
|
||||
endpoint: service.GrokMediaEndpointVideoStatus,
|
||||
model: "",
|
||||
want: false,
|
||||
@@ -68,7 +69,18 @@ func TestShouldRecordGrokMediaUsage(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, shouldRecordGrokMediaUsage(tt.endpoint, tt.model))
|
||||
// Nil result must never bill.
|
||||
require.False(t, shouldRecordGrokMediaUsage(tt.endpoint, tt.model, nil))
|
||||
// Immediate helper only bills image generation (async video bills on status).
|
||||
result := &service.OpenAIForwardResult{ImageCount: 1, VideoCount: 0}
|
||||
if tt.endpoint.IsGenerationRequest() && !isGrokVideoCreateEndpoint(tt.endpoint) && strings.TrimSpace(tt.model) != "" {
|
||||
require.Equal(t, tt.want, shouldRecordGrokMediaUsage(tt.endpoint, tt.model, result))
|
||||
} else {
|
||||
require.False(t, shouldRecordGrokMediaUsage(tt.endpoint, tt.model, result))
|
||||
}
|
||||
// Zero billable units never bill even for generation + model.
|
||||
empty := &service.OpenAIForwardResult{}
|
||||
require.False(t, shouldRecordGrokMediaUsage(tt.endpoint, tt.model, empty))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2415,7 +2415,9 @@ func (h *OpenAIGatewayHandler) submitUsageRecordTask(parent context.Context, tas
|
||||
}
|
||||
|
||||
func (h *OpenAIGatewayHandler) submitOpenAIUsageRecordTask(parent context.Context, result *service.OpenAIForwardResult, task service.UsageRecordTask) {
|
||||
if result != nil && result.ImageCount > 0 {
|
||||
// Money-critical bills never drop on pool overflow: media, search surcharge, voice.
|
||||
if result != nil && (result.ImageCount > 0 || result.VideoCount > 0 ||
|
||||
result.SearchCount > 0 || result.WebSearchCalls > 0 || result.AudioUsage != nil) {
|
||||
h.submitMandatoryUsageRecordTask(parent, task)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
coderws "github.com/coder/websocket"
|
||||
@@ -643,6 +644,9 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("grok_group_maps_claude_cli_model_to_grok_default", func(t *testing.T) {
|
||||
original := xai.RuntimeModelMappingOptions()
|
||||
t.Cleanup(func() { xai.SetRuntimeModelMappingOptions(original) })
|
||||
xai.SetRuntimeModelMappingOptions(xai.ModelMappingOptions{EnableCrossClientMap: true})
|
||||
apiKey := &service.APIKey{
|
||||
Group: &service.Group{
|
||||
Platform: service.PlatformGrok,
|
||||
|
||||
@@ -144,7 +144,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
|
||||
}
|
||||
|
||||
sessionHash := h.gatewayService.GenerateExplicitSessionHash(c, body)
|
||||
requestCtx := service.WithOpenAIImageGenerationIntent(c.Request.Context())
|
||||
requestCtx := service.WithOpenAIImagesEndpoint(service.WithOpenAIImageGenerationIntent(c.Request.Context()))
|
||||
|
||||
maxAccountSwitches := h.maxAccountSwitches
|
||||
switchCount := 0
|
||||
|
||||
@@ -224,6 +224,8 @@ func TestUserUsageListKeepsUserBillingAndIPWithoutAdminCostFields(t *testing.T)
|
||||
require.NotContains(t, body, "account_rate_multiplier")
|
||||
require.NotContains(t, body, "account_stats_cost")
|
||||
require.NotContains(t, body, "upstream_model")
|
||||
require.NotContains(t, body, "upstream_response_model")
|
||||
require.NotContains(t, body, "upstream_model_mismatch")
|
||||
require.NotContains(t, body, "billing_tier")
|
||||
require.NotContains(t, body, "channel_id")
|
||||
require.NotContains(t, body, `"account":`)
|
||||
|
||||
@@ -189,3 +189,33 @@ func TestOpenAIGatewayHandlerSubmitOpenAIUsageRecordTask_ImageResultUsesMandator
|
||||
|
||||
require.True(t, called.Load(), "image usage task must be mandatory when async submit is dropped")
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayHandlerSubmitOpenAIUsageRecordTask_SearchCountUsesMandatoryFallback(t *testing.T) {
|
||||
pool := service.NewUsageRecordWorkerPoolWithOptions(service.UsageRecordWorkerPoolOptions{
|
||||
WorkerCount: 1,
|
||||
QueueSize: 1,
|
||||
TaskTimeout: time.Second,
|
||||
OverflowPolicy: "drop",
|
||||
OverflowSamplePercent: 0,
|
||||
AutoScaleEnabled: false,
|
||||
})
|
||||
t.Cleanup(pool.Stop)
|
||||
h := &OpenAIGatewayHandler{usageRecordWorkerPool: pool}
|
||||
|
||||
block := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
pool.Submit(func(ctx context.Context) {
|
||||
close(block)
|
||||
<-release
|
||||
})
|
||||
<-block
|
||||
pool.Submit(func(ctx context.Context) {})
|
||||
|
||||
var called atomic.Bool
|
||||
h.submitOpenAIUsageRecordTask(context.Background(), &service.OpenAIForwardResult{SearchCount: 3}, func(ctx context.Context) {
|
||||
called.Store(true)
|
||||
})
|
||||
close(release)
|
||||
|
||||
require.True(t, called.Load(), "search surcharge usage task must be mandatory when async submit is dropped")
|
||||
}
|
||||
|
||||
@@ -178,6 +178,11 @@ var geminiModels = []modelDef{
|
||||
{ID: "gemini-3.1-pro-high", DisplayName: "Gemini 3.1 Pro High", CreatedAt: "2026-02-19T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3.1-flash-image", DisplayName: "Gemini 3.1 Flash Image", CreatedAt: "2026-02-19T00:00:00Z"},
|
||||
{ID: "gemini-3.1-flash-image-preview", DisplayName: "Gemini 3.1 Flash Image Preview", CreatedAt: "2026-02-19T00:00:00Z"},
|
||||
{ID: "gemini-3.6-flash", DisplayName: "Gemini 3.6 Flash", CreatedAt: "2026-07-21T00:00:00Z"},
|
||||
{ID: "gemini-3.6-flash-high", DisplayName: "Gemini 3.6 Flash High", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3.6-flash-low", DisplayName: "Gemini 3.6 Flash Low", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3.6-flash-medium", DisplayName: "Gemini 3.6 Flash Medium", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3.6-flash-tiered", DisplayName: "Gemini 3.6 Flash", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3-pro-preview", DisplayName: "Gemini 3 Pro Preview", CreatedAt: "2025-06-01T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3-pro-image", DisplayName: "Gemini 3 Pro Image", CreatedAt: "2025-06-01T00:00:00Z"},
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@ func TestDefaultModels_ContainsNewAndLegacyImageModels(t *testing.T) {
|
||||
"gemini-3.1-flash-image",
|
||||
"gemini-3.1-flash-image-preview",
|
||||
"gemini-3-pro-image", // legacy compatibility
|
||||
"gemini-3.6-flash",
|
||||
"gemini-3.6-flash-high",
|
||||
"gemini-3.6-flash-low",
|
||||
"gemini-3.6-flash-medium",
|
||||
"gemini-3.6-flash-tiered",
|
||||
}
|
||||
|
||||
for _, id := range requiredIDs {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package apicompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// anthropicInboundBlockTypes 是 Anthropic Messages 请求体里合法的 content block
|
||||
// 类型。转换结果只能落在这个集合内——发出集合外的类型,上游一律回
|
||||
// 400 "Request body format invalid"(见 issue #5329)。
|
||||
var anthropicInboundBlockTypes = map[string]bool{
|
||||
"text": true,
|
||||
"image": true,
|
||||
"document": true,
|
||||
"tool_use": true,
|
||||
"tool_result": true,
|
||||
"thinking": true,
|
||||
"redacted_thinking": true,
|
||||
}
|
||||
|
||||
func responsesToAnthropicMessages(t *testing.T, input string) []AnthropicMessage {
|
||||
t.Helper()
|
||||
var req ResponsesRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"model":"glm-5.2","input":`+input+`}`), &req))
|
||||
out, err := ResponsesToAnthropicRequest(&req)
|
||||
require.NoError(t, err)
|
||||
return out.Messages
|
||||
}
|
||||
|
||||
// requireAnthropicMessagesAreSendable 断言消息序列不含 Anthropic 会拒收的形态:
|
||||
// 未知 block 类型、空内容消息、纯空白 text 块。
|
||||
func requireAnthropicMessagesAreSendable(t *testing.T, messages []AnthropicMessage) {
|
||||
t.Helper()
|
||||
for i, m := range messages {
|
||||
raw := strings.TrimSpace(string(m.Content))
|
||||
require.NotContains(t, []string{"", "null", `""`, "[]"}, raw,
|
||||
"messages[%d] 内容为空,Anthropic 拒收空内容消息", i)
|
||||
|
||||
var s string
|
||||
if err := json.Unmarshal(m.Content, &s); err == nil {
|
||||
require.NotEmpty(t, strings.TrimSpace(s), "messages[%d] 字符串内容不能全为空白", i)
|
||||
continue
|
||||
}
|
||||
blocks := parseContentBlocks(m.Content)
|
||||
require.NotEmpty(t, blocks, "messages[%d] 解析不出任何 block", i)
|
||||
for j, b := range blocks {
|
||||
require.True(t, anthropicInboundBlockTypes[b.Type],
|
||||
"messages[%d].content[%d] 是 Anthropic 不认识的 block 类型 %q", i, j, b.Type)
|
||||
if b.Type == "text" {
|
||||
require.NotEmpty(t, strings.TrimSpace(b.Text),
|
||||
"messages[%d].content[%d] 是空白 text 块,Anthropic 拒收", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// issue #5329:工具执行后的下一轮,Codex 会把 reasoning item 一起回放。
|
||||
// 该 item 带 content 数组时,reasoning_text 块以前会被原样塞进 Anthropic 请求体。
|
||||
func TestResponsesToAnthropic_ReasoningItemWithContentIsDropped(t *testing.T) {
|
||||
messages := responsesToAnthropicMessages(t, `[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"run a shell command"}]},
|
||||
{"type":"reasoning","id":"rs_1","summary":[],"content":[{"type":"reasoning_text","text":"let me think"}]}
|
||||
]`)
|
||||
|
||||
requireAnthropicMessagesAreSendable(t, messages)
|
||||
require.Len(t, messages, 1)
|
||||
require.NotContains(t, string(messages[0].Content), "reasoning_text")
|
||||
require.NotContains(t, string(messages[0].Content), "let me think")
|
||||
}
|
||||
|
||||
// Codex 的常见 reasoning 形态(只有 summary + encrypted_content)本来就会被丢弃,
|
||||
// 这条守卫确保行为没有被改变。
|
||||
func TestResponsesToAnthropic_ReasoningItemSummaryOnlyStillDropped(t *testing.T) {
|
||||
messages := responsesToAnthropicMessages(t, `[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]},
|
||||
{"type":"reasoning","id":"rs_1","summary":[{"type":"summary_text","text":"s"}],"encrypted_content":"gAAAA"}
|
||||
]`)
|
||||
|
||||
requireAnthropicMessagesAreSendable(t, messages)
|
||||
require.Len(t, messages, 1)
|
||||
require.NotContains(t, string(messages[0].Content), "gAAAA")
|
||||
}
|
||||
|
||||
// 未知 item type 的 content 以前会被逐字透传,把 Responses 专有分片带进上游请求。
|
||||
func TestResponsesToAnthropic_UnknownItemTypeContentIsSanitized(t *testing.T) {
|
||||
messages := responsesToAnthropicMessages(t, `[
|
||||
{"type":"web_search_call","id":"ws_1","content":[{"type":"web_search_result","text":"payload"}]}
|
||||
]`)
|
||||
|
||||
requireAnthropicMessagesAreSendable(t, messages)
|
||||
require.Empty(t, messages, "整条内容都无法映射时不应发出消息")
|
||||
}
|
||||
|
||||
// 未知 item type 里夹带的可识别文本仍然保留,不做无谓丢弃。
|
||||
func TestResponsesToAnthropic_UnknownItemTypeKeepsRecognizableText(t *testing.T) {
|
||||
messages := responsesToAnthropicMessages(t, `[
|
||||
{"type":"some_future_item","content":[
|
||||
{"type":"input_text","text":"keep me"},
|
||||
{"type":"reasoning_text","text":"drop me"}
|
||||
]}
|
||||
]`)
|
||||
|
||||
requireAnthropicMessagesAreSendable(t, messages)
|
||||
require.Len(t, messages, 1)
|
||||
require.Contains(t, string(messages[0].Content), "keep me")
|
||||
require.NotContains(t, string(messages[0].Content), "drop me")
|
||||
}
|
||||
|
||||
// user 消息的分片全部不可识别时,以前会退化成 content:"",Anthropic 拒收空内容消息。
|
||||
func TestResponsesToAnthropic_UserMessageWithOnlyUnknownPartsIsDropped(t *testing.T) {
|
||||
messages := responsesToAnthropicMessages(t, `[
|
||||
{"type":"message","role":"user","content":[{"type":"input_file","file_id":"file_1"}]}
|
||||
]`)
|
||||
|
||||
requireAnthropicMessagesAreSendable(t, messages)
|
||||
require.Empty(t, messages)
|
||||
}
|
||||
|
||||
// assistant 侧同理:以前会退化成单个空 text 块,Anthropic 同样拒收。
|
||||
func TestResponsesToAnthropic_AssistantMessageWithOnlyUnknownPartsIsDropped(t *testing.T) {
|
||||
messages := responsesToAnthropicMessages(t, `[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]},
|
||||
{"type":"message","role":"assistant","content":[{"type":"refusal","refusal":"no"}]}
|
||||
]`)
|
||||
|
||||
requireAnthropicMessagesAreSendable(t, messages)
|
||||
require.Len(t, messages, 1)
|
||||
require.Equal(t, "user", messages[0].Role)
|
||||
}
|
||||
|
||||
// 完整的 Codex 工具续接回放:tool_use / tool_result 配对必须保持不变,
|
||||
// 同时整个序列满足可发送不变式。
|
||||
func TestResponsesToAnthropic_CodexToolRoundStaysIntactAndSendable(t *testing.T) {
|
||||
messages := responsesToAnthropicMessages(t, `[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"run ls"}]},
|
||||
{"type":"reasoning","id":"rs_1","summary":[],"content":[{"type":"reasoning_text","text":"plan"}],"encrypted_content":"gAAAA"},
|
||||
{"type":"function_call","id":"fc_1","call_id":"call_1","name":"shell","arguments":"{\"cmd\":\"ls\"}"},
|
||||
{"type":"function_call_output","call_id":"call_1","output":"file1"},
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}
|
||||
]`)
|
||||
|
||||
requireAnthropicMessagesAreSendable(t, messages)
|
||||
|
||||
var sawToolUse, sawToolResult bool
|
||||
for _, m := range messages {
|
||||
for _, b := range parseContentBlocks(m.Content) {
|
||||
switch b.Type {
|
||||
case "tool_use":
|
||||
sawToolUse = true
|
||||
require.Equal(t, "call_1", b.ID)
|
||||
require.Equal(t, "shell", b.Name)
|
||||
case "tool_result":
|
||||
sawToolResult = true
|
||||
require.Equal(t, "call_1", b.ToolUseID)
|
||||
}
|
||||
}
|
||||
}
|
||||
require.True(t, sawToolUse, "function_call 必须转成 tool_use")
|
||||
require.True(t, sawToolResult, "function_call_output 必须转成 tool_result")
|
||||
require.NotContains(t, string(mustMarshal(t, messages)), "reasoning_text")
|
||||
require.NotContains(t, string(mustMarshal(t, messages)), "gAAAA")
|
||||
}
|
||||
|
||||
func mustMarshal(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
require.NoError(t, err)
|
||||
return b
|
||||
}
|
||||
|
||||
func TestAnthropicContentIsEmpty(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
want bool
|
||||
}{
|
||||
{``, true},
|
||||
{`""`, true},
|
||||
{`null`, true},
|
||||
{`[]`, true},
|
||||
{` [] `, true},
|
||||
{`"hi"`, false},
|
||||
{`[{"type":"text","text":"hi"}]`, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
require.Equal(t, tc.want, anthropicContentIsEmpty(json.RawMessage(tc.raw)), "raw=%q", tc.raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicContentIsOnlyBlankText(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
want bool
|
||||
}{
|
||||
{`[{"type":"text","text":""}]`, true},
|
||||
{`[{"type":"text","text":" "}]`, true},
|
||||
{`[{"type":"text","text":""},{"type":"text","text":" "}]`, true},
|
||||
{`[{"type":"text","text":"hi"}]`, false},
|
||||
{`[{"type":"text","text":""},{"type":"image","source":{}}]`, false},
|
||||
{`[]`, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
require.Equal(t, tc.want, anthropicContentIsOnlyBlankText(json.RawMessage(tc.raw)), "raw=%q", tc.raw)
|
||||
}
|
||||
}
|
||||
@@ -164,11 +164,24 @@ func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMess
|
||||
Content: blockJSON,
|
||||
})
|
||||
|
||||
case item.Type == "reasoning":
|
||||
// Anthropic 无法摄入 OpenAI 的 reasoning:encrypted_content 是不透明的,
|
||||
// 而 thinking 块的重放需要 Anthropic 自己签发的 signature,无法伪造。
|
||||
// Codex 常见形态(只带 summary + encrypted_content)本来就会被丢弃,
|
||||
// 这里让带 content 数组的形态保持同样行为——否则 reasoning_text 块会被
|
||||
// 原样塞进 Anthropic 请求体,上游直接回 400。
|
||||
|
||||
case item.Role == "user":
|
||||
content, err := convertResponsesUserToAnthropicContent(item.Content)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// 内容里只有网关不认识的分片时,sanitize 会得到空串。Anthropic 拒收
|
||||
// 空内容消息("all messages must have non-empty content"),整条丢掉
|
||||
// 比发一条必然 400 的消息更可用。
|
||||
if anthropicContentIsEmpty(content) {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, AnthropicMessage{
|
||||
Role: "user",
|
||||
Content: content,
|
||||
@@ -179,19 +192,35 @@ func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMess
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// 同上:分片全不认识时会退化成单个空 text 块,而 Anthropic 拒收
|
||||
// 空文本块("text content blocks must contain non-whitespace text")。
|
||||
if anthropicContentIsEmpty(content) || anthropicContentIsOnlyBlankText(content) {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, AnthropicMessage{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
})
|
||||
|
||||
default:
|
||||
// Unknown role/type — attempt as user message
|
||||
if item.Content != nil {
|
||||
messages = append(messages, AnthropicMessage{
|
||||
Role: "user",
|
||||
Content: item.Content,
|
||||
})
|
||||
// 未知 role/type —— 尽量当作 user 消息保留其中的文本/图片。
|
||||
// 必须走与真实 user 消息同一套白名单转换:直接透传 item.Content 会把
|
||||
// Responses 专有的分片类型(reasoning_text、web_search_call 的载荷等)
|
||||
// 原样发给 Anthropic,上游只会回 400 把整轮打挂。
|
||||
if item.Content == nil {
|
||||
continue
|
||||
}
|
||||
content, err := convertResponsesUserToAnthropicContent(item.Content)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if anthropicContentIsEmpty(content) {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, AnthropicMessage{
|
||||
Role: "user",
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,6 +422,32 @@ func extractTextFromContent(raw json.RawMessage) string {
|
||||
|
||||
// convertResponsesUserToAnthropicContent converts a Responses user message
|
||||
// content field into Anthropic content blocks JSON.
|
||||
// anthropicContentIsEmpty 判断转换结果是否为"空内容"。
|
||||
// convertResponsesUserToAnthropicContent 在没有任何可识别分片时返回 JSON 空串,
|
||||
// 而 Anthropic 拒收空内容消息。
|
||||
func anthropicContentIsEmpty(content json.RawMessage) bool {
|
||||
trimmed := strings.TrimSpace(string(content))
|
||||
switch trimmed {
|
||||
case "", "null", `""`, "[]":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// anthropicContentIsOnlyBlankText 判断内容是否只由空白 text 块组成。
|
||||
func anthropicContentIsOnlyBlankText(content json.RawMessage) bool {
|
||||
blocks := parseContentBlocks(content)
|
||||
if len(blocks) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, b := range blocks {
|
||||
if b.Type != "text" || strings.TrimSpace(b.Text) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func convertResponsesUserToAnthropicContent(raw json.RawMessage) (json.RawMessage, error) {
|
||||
if len(raw) == 0 {
|
||||
return json.Marshal("") // empty string content
|
||||
|
||||
@@ -50,6 +50,12 @@ const (
|
||||
// OpenAIImageGenerationIntent 标识 OpenAI 请求会触发生图能力(用于图片能力维度限流)
|
||||
OpenAIImageGenerationIntent Key = "ctx_openai_image_generation_intent"
|
||||
|
||||
// OpenAIImagesEndpoint 标识请求是从 /v1/images/* 入站的。
|
||||
// 与 OpenAIImageGenerationIntent 的区别:后者只表示"这次请求会生图",
|
||||
// /v1/responses 带图片模型时也会置位;本 key 只在专用生图端点置位,
|
||||
// 用于区分"用错端点"与"端点用对了但账号没能力"。
|
||||
OpenAIImagesEndpoint Key = "ctx_openai_images_endpoint"
|
||||
|
||||
// Group 认证后的分组信息,由 API Key 认证中间件设置
|
||||
Group Key = "ctx_group"
|
||||
|
||||
|
||||
@@ -16,10 +16,29 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
const (
|
||||
// socks5DialTimeout 限制到 SOCKS5 代理自身的 TCP 建连耗时。
|
||||
socks5DialTimeout = 10 * time.Second
|
||||
// socks5DialKeepAlive 与 Go 默认 keepalive 探测间隔保持一致。
|
||||
socks5DialKeepAlive = 30 * time.Second
|
||||
)
|
||||
|
||||
// socks5ForwardDialer 是 SOCKS5 dialer 的底层拨号器。
|
||||
//
|
||||
// proxy.FromURL 的默认 forward dialer 是 proxy.Direct(零值 net.Dialer,无超时),
|
||||
// 代理地址不可达时会一直卡到内核 TCP 重传耗尽(Linux 约 130 秒)。SOCKS5 分支会
|
||||
// 覆盖 Transport.DialContext,因此调用方在 Transport 上设置的建连超时对这条路径
|
||||
// 无效,必须在这里补上。
|
||||
var socks5ForwardDialer = &net.Dialer{
|
||||
Timeout: socks5DialTimeout,
|
||||
KeepAlive: socks5DialKeepAlive,
|
||||
}
|
||||
|
||||
// ConfigureTransportProxy 根据代理 URL 配置 Transport
|
||||
//
|
||||
// 支持的协议:
|
||||
@@ -45,7 +64,7 @@ func ConfigureTransportProxy(transport *http.Transport, proxyURL *url.URL) error
|
||||
return nil
|
||||
|
||||
case "socks5", "socks5h":
|
||||
dialer, err := proxy.FromURL(proxyURL, proxy.Direct)
|
||||
dialer, err := proxy.FromURL(proxyURL, socks5ForwardDialer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create socks5 dialer: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package proxyutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var errStub = errors.New("stub dial")
|
||||
|
||||
// 回归:SOCKS5 分支覆盖了调用方在 Transport 上设置的 DialContext,
|
||||
// 底层 forward dialer 必须自带建连超时。proxy.Direct 是零值 net.Dialer,
|
||||
// 代理不可达时会一直卡到内核 TCP 重传耗尽(Linux 约 130 秒)。
|
||||
func TestSOCKS5ForwardDialerHasBoundedTimeout(t *testing.T) {
|
||||
require.Greater(t, socks5ForwardDialer.Timeout, time.Duration(0))
|
||||
require.Equal(t, socks5DialTimeout, socks5ForwardDialer.Timeout)
|
||||
require.Equal(t, socks5DialKeepAlive, socks5ForwardDialer.KeepAlive)
|
||||
}
|
||||
|
||||
func TestConfigureTransportProxySOCKS5SetsDialContext(t *testing.T) {
|
||||
for _, scheme := range []string{"socks5", "socks5h"} {
|
||||
t.Run(scheme, func(t *testing.T) {
|
||||
proxyURL, err := url.Parse(scheme + "://127.0.0.1:1080")
|
||||
require.NoError(t, err)
|
||||
|
||||
transport := &http.Transport{}
|
||||
require.NoError(t, ConfigureTransportProxy(transport, proxyURL))
|
||||
require.NotNil(t, transport.DialContext)
|
||||
require.Nil(t, transport.Proxy, "SOCKS5 不应设置 Transport.Proxy")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP 代理走 Transport.Proxy,不得覆盖调用方设置的 DialContext。
|
||||
func TestConfigureTransportProxyHTTPPreservesDialContext(t *testing.T) {
|
||||
proxyURL, err := url.Parse("http://127.0.0.1:8080")
|
||||
require.NoError(t, err)
|
||||
|
||||
called := false
|
||||
transport := &http.Transport{}
|
||||
transport.DialContext = func(_ context.Context, _, _ string) (net.Conn, error) {
|
||||
called = true
|
||||
return nil, errStub
|
||||
}
|
||||
|
||||
require.NoError(t, ConfigureTransportProxy(transport, proxyURL))
|
||||
require.NotNil(t, transport.Proxy)
|
||||
require.NotNil(t, transport.DialContext)
|
||||
|
||||
_, _ = transport.DialContext(context.Background(), "tcp", "127.0.0.1:1")
|
||||
require.True(t, called, "HTTP 代理分支不应替换调用方的 DialContext")
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Package redissession provides a multi-instance OAuth session backend.
|
||||
package redissession
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var ErrNotConfigured = errors.New("redis session store not configured")
|
||||
|
||||
// Store persists JSON sessions and single-use markers under one namespace.
|
||||
type Store struct {
|
||||
rdb *redis.Client
|
||||
prefix string
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func New(rdb *redis.Client, prefix string, ttl time.Duration) *Store {
|
||||
if ttl <= 0 {
|
||||
ttl = 30 * time.Minute
|
||||
}
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if prefix == "" {
|
||||
prefix = "oauth:session"
|
||||
}
|
||||
if !strings.HasSuffix(prefix, ":") {
|
||||
prefix += ":"
|
||||
}
|
||||
return &Store{rdb: rdb, prefix: prefix, ttl: ttl}
|
||||
}
|
||||
|
||||
func (s *Store) dataKey(id string) string { return s.prefix + strings.TrimSpace(id) }
|
||||
func (s *Store) usedKey(id string) string { return s.prefix + "used:" + strings.TrimSpace(id) }
|
||||
|
||||
func (s *Store) Set(ctx context.Context, id string, value any) error {
|
||||
if s == nil || s.rdb == nil {
|
||||
return ErrNotConfigured
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return errors.New("session id is required")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.rdb.Set(ctx, s.dataKey(id), raw, s.ttl).Err()
|
||||
}
|
||||
|
||||
func (s *Store) Get(ctx context.Context, id string, dest any) (bool, error) {
|
||||
if s == nil || s.rdb == nil {
|
||||
return false, ErrNotConfigured
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return false, nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
raw, err := s.rdb.Get(ctx, s.dataKey(id)).Bytes()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := json.Unmarshal(raw, dest); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Store) Delete(ctx context.Context, id string) error {
|
||||
if s == nil || s.rdb == nil {
|
||||
return ErrNotConfigured
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return s.rdb.Del(ctx, s.dataKey(id), s.usedKey(id)).Err()
|
||||
}
|
||||
|
||||
// TryConsume returns true only for the first claim while the session exists.
|
||||
func (s *Store) TryConsume(ctx context.Context, id string) (bool, error) {
|
||||
if s == nil || s.rdb == nil {
|
||||
return false, ErrNotConfigured
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return false, nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ttl := s.ttl
|
||||
if remaining, err := s.rdb.TTL(ctx, s.dataKey(id)).Result(); err == nil && remaining > 0 {
|
||||
ttl = remaining
|
||||
}
|
||||
ok, err := s.rdb.SetNX(ctx, s.usedKey(id), "1", ttl).Result()
|
||||
if err != nil || !ok {
|
||||
return ok, err
|
||||
}
|
||||
exists, err := s.rdb.Exists(ctx, s.dataKey(id)).Result()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if exists == 0 {
|
||||
_ = s.rdb.Del(ctx, s.usedKey(id)).Err()
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//go:build unit
|
||||
|
||||
package redissession
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStoreRoundTripAndSingleUse(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = rdb.Close() })
|
||||
store := New(rdb, "oauth:test", time.Minute)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, store.Set(ctx, "sid", map[string]string{"state": "state"}))
|
||||
var got map[string]string
|
||||
ok, err := store.Get(ctx, "sid", &got)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "state", got["state"])
|
||||
|
||||
ok, err = store.TryConsume(ctx, "sid")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
ok, err = store.TryConsume(ctx, "sid")
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok)
|
||||
|
||||
require.NoError(t, store.Delete(ctx, "sid"))
|
||||
ok, err = store.Get(ctx, "sid", &got)
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok)
|
||||
}
|
||||
@@ -274,13 +274,14 @@ type UsageLogFilters struct {
|
||||
RequestID string
|
||||
Model string
|
||||
// ModelFilterSource controls how Model is matched. Empty preserves raw usage_logs.model semantics.
|
||||
ModelFilterSource string
|
||||
RequestType *int16
|
||||
Stream *bool
|
||||
BillingType *int8
|
||||
BillingMode string
|
||||
StartTime *time.Time
|
||||
EndTime *time.Time
|
||||
ModelFilterSource string
|
||||
RequestType *int16
|
||||
Stream *bool
|
||||
BillingType *int8
|
||||
BillingMode string
|
||||
UpstreamModelMismatch *bool
|
||||
StartTime *time.Time
|
||||
EndTime *time.Time
|
||||
// ExactTotal requests exact COUNT(*) for pagination. Default false for fast large-table paging.
|
||||
ExactTotal bool
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@ const (
|
||||
// one bump here covers OAuth traffic and billing probes together.
|
||||
// Keep in sync with https://x.ai/cli/stable.
|
||||
CLIClientVersion = "0.2.114"
|
||||
CLIUserAgent = "grok-pager/" + CLIClientVersion + " grok-shell/" + CLIClientVersion + " (macos; aarch64)"
|
||||
// billingCLIUserAgent is the legacy pager/shell UA used by billing probes.
|
||||
// Distinct from CLIUserAgent() in cli_identity.go (workspace-style UA).
|
||||
billingCLIUserAgent = "grok-pager/" + CLIClientVersion + " grok-shell/" + CLIClientVersion + " (macos; aarch64)"
|
||||
|
||||
BillingWeeklyPath = "/billing?format=credits"
|
||||
BillingMonthlyPath = "/billing"
|
||||
@@ -43,14 +45,22 @@ type BillingProductUsage struct {
|
||||
}
|
||||
|
||||
// BillingConfig is the nested config object from /v1/billing responses.
|
||||
// Weekly (`?format=credits`) and monthly (`/billing`) share this shape; absolute
|
||||
// money fields typically appear on the credits (prepaid/on-demand) or monthly
|
||||
// (limit/used) responses.
|
||||
type BillingConfig struct {
|
||||
CurrentPeriod *BillingPeriod `json:"currentPeriod,omitempty"`
|
||||
CreditUsagePercent *float64 `json:"creditUsagePercent,omitempty"`
|
||||
ProductUsage []BillingProductUsage `json:"productUsage,omitempty"`
|
||||
MonthlyLimit json.RawMessage `json:"monthlyLimit,omitempty"`
|
||||
Used json.RawMessage `json:"used,omitempty"`
|
||||
BillingPeriodStart string `json:"billingPeriodStart,omitempty"`
|
||||
BillingPeriodEnd string `json:"billingPeriodEnd,omitempty"`
|
||||
CurrentPeriod *BillingPeriod `json:"currentPeriod,omitempty"`
|
||||
CreditUsagePercent *float64 `json:"creditUsagePercent,omitempty"`
|
||||
ProductUsage []BillingProductUsage `json:"productUsage,omitempty"`
|
||||
MonthlyLimit json.RawMessage `json:"monthlyLimit,omitempty"`
|
||||
Used json.RawMessage `json:"used,omitempty"`
|
||||
OnDemandCap json.RawMessage `json:"onDemandCap,omitempty"`
|
||||
OnDemandUsed json.RawMessage `json:"onDemandUsed,omitempty"`
|
||||
PrepaidBalance json.RawMessage `json:"prepaidBalance,omitempty"`
|
||||
IsUnifiedBillingUser bool `json:"isUnifiedBillingUser,omitempty"`
|
||||
TopUpMethod string `json:"topUpMethod,omitempty"`
|
||||
BillingPeriodStart string `json:"billingPeriodStart,omitempty"`
|
||||
BillingPeriodEnd string `json:"billingPeriodEnd,omitempty"`
|
||||
}
|
||||
|
||||
// BillingPayload is the top-level body from /v1/billing.
|
||||
@@ -65,6 +75,8 @@ type BillingProductSummary struct {
|
||||
}
|
||||
|
||||
// BillingSummary is the merged weekly + monthly billing view.
|
||||
// Cents fields remain the authoritative monthly numbers; dollar fields are the
|
||||
// operator-facing absolute money view (prepaid / on-demand / monthly $).
|
||||
type BillingSummary struct {
|
||||
PeriodType string `json:"period_type,omitempty"` // weekly | monthly | unknown
|
||||
UsagePercent *float64 `json:"usage_percent,omitempty"`
|
||||
@@ -77,17 +89,26 @@ type BillingSummary struct {
|
||||
BillingPeriodStart string `json:"billing_period_start,omitempty"`
|
||||
BillingPeriodEnd string `json:"billing_period_end,omitempty"`
|
||||
UsedPercent *float64 `json:"used_percent,omitempty"`
|
||||
Plan string `json:"plan,omitempty"` // SuperGrok | SuperGrok Heavy | ""
|
||||
StatusCode int `json:"status_code,omitempty"`
|
||||
WeeklyStatusCode int `json:"weekly_status_code,omitempty"`
|
||||
MonthlyStatusCode int `json:"monthly_status_code,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
FetchedAt string `json:"fetched_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
WeeklyUpdatedAt string `json:"weekly_updated_at,omitempty"`
|
||||
MonthlyUpdatedAt string `json:"monthly_updated_at,omitempty"`
|
||||
Partial bool `json:"partial,omitempty"`
|
||||
FailedWindows []string `json:"failed_windows,omitempty"`
|
||||
// Absolute money (USD). Prepaid/on-demand come from credits probe as dollars.
|
||||
// MonthlyLimit/MonthlyUsed are cents/100 for consistent $ display.
|
||||
PrepaidBalance *float64 `json:"prepaid_balance,omitempty"`
|
||||
MonthlyLimit *float64 `json:"monthly_limit,omitempty"`
|
||||
MonthlyUsed *float64 `json:"monthly_used,omitempty"`
|
||||
OnDemandCap *float64 `json:"on_demand_cap,omitempty"`
|
||||
OnDemandUsed *float64 `json:"on_demand_used,omitempty"`
|
||||
TopUpMethod string `json:"top_up_method,omitempty"`
|
||||
IsUnifiedBillingUser bool `json:"is_unified_billing_user,omitempty"`
|
||||
Plan string `json:"plan,omitempty"` // SuperGrok | SuperGrok Heavy | ""
|
||||
StatusCode int `json:"status_code,omitempty"`
|
||||
WeeklyStatusCode int `json:"weekly_status_code,omitempty"`
|
||||
MonthlyStatusCode int `json:"monthly_status_code,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
FetchedAt string `json:"fetched_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
WeeklyUpdatedAt string `json:"weekly_updated_at,omitempty"`
|
||||
MonthlyUpdatedAt string `json:"monthly_updated_at,omitempty"`
|
||||
Partial bool `json:"partial,omitempty"`
|
||||
FailedWindows []string `json:"failed_windows,omitempty"`
|
||||
}
|
||||
|
||||
// BuildBillingURL builds weekly or monthly billing URL against the CLI chat proxy.
|
||||
@@ -127,7 +148,7 @@ func ApplyCLIBillingHeaders(req *http.Request, accessToken string) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set(CLITokenAuthHeader, CLITokenAuthValue)
|
||||
req.Header.Set(CLIClientVersionHeader, CLIClientVersion)
|
||||
req.Header.Set("User-Agent", CLIUserAgent)
|
||||
req.Header.Set("User-Agent", billingCLIUserAgent)
|
||||
}
|
||||
|
||||
// ParseBillingPayload unmarshals a billing API response body.
|
||||
@@ -152,18 +173,15 @@ func BuildBillingSummary(config *BillingConfig) *BillingSummary {
|
||||
periodType := resolvePeriodType(period)
|
||||
creditUsage := cloneFloat(config.CreditUsagePercent)
|
||||
|
||||
// Weekly period bounds must not fall back to monthly billing period ends —
|
||||
// that would park accounts on a multi-week horizon when weekly UsagePercent
|
||||
// is high (scheduler seven_day uses PeriodEnd).
|
||||
periodStart := ""
|
||||
periodEnd := ""
|
||||
if period != nil {
|
||||
periodStart = strings.TrimSpace(period.Start)
|
||||
periodEnd = strings.TrimSpace(period.End)
|
||||
}
|
||||
if periodStart == "" {
|
||||
periodStart = strings.TrimSpace(config.BillingPeriodStart)
|
||||
}
|
||||
if periodEnd == "" {
|
||||
periodEnd = strings.TrimSpace(config.BillingPeriodEnd)
|
||||
}
|
||||
|
||||
products := make([]BillingProductSummary, 0, len(config.ProductUsage))
|
||||
for _, item := range config.ProductUsage {
|
||||
@@ -179,6 +197,11 @@ func BuildBillingSummary(config *BillingConfig) *BillingSummary {
|
||||
|
||||
monthlyLimit := parseCentValue(config.MonthlyLimit)
|
||||
used := parseCentValue(config.Used)
|
||||
// Absolute money on credits responses is dollar-denominated ({"val": 12}).
|
||||
// Monthly limit/used are cents (same as MonthlyLimitCents / UsedCents).
|
||||
prepaid := parseCentValue(config.PrepaidBalance)
|
||||
onDemandCap := parseCentValue(config.OnDemandCap)
|
||||
onDemandUsed := parseCentValue(config.OnDemandUsed)
|
||||
billingStart := strings.TrimSpace(config.BillingPeriodStart)
|
||||
billingEnd := strings.TrimSpace(config.BillingPeriodEnd)
|
||||
|
||||
@@ -198,7 +221,7 @@ func BuildBillingSummary(config *BillingConfig) *BillingSummary {
|
||||
usedPercent = &v
|
||||
}
|
||||
|
||||
hasWeekly := creditUsage != nil || periodType == "weekly" || len(products) > 0
|
||||
hasWeekly := creditUsage != nil || periodType == "weekly" || len(products) > 0 || prepaid != nil || onDemandCap != nil || onDemandUsed != nil
|
||||
hasMonthly := monthlyLimit != nil || used != nil || (!hasWeekly && billingEnd != "")
|
||||
if !hasWeekly && !hasMonthly {
|
||||
return nil
|
||||
@@ -228,6 +251,24 @@ func BuildBillingSummary(config *BillingConfig) *BillingSummary {
|
||||
summary.BillingPeriodEnd = billingEnd
|
||||
}
|
||||
summary.UsedPercent = usedPercent
|
||||
summary.PrepaidBalance = prepaid
|
||||
if onDemandCap != nil {
|
||||
summary.OnDemandCap = onDemandCap
|
||||
}
|
||||
if onDemandUsed != nil {
|
||||
summary.OnDemandUsed = onDemandUsed
|
||||
}
|
||||
// Expose monthly cents as dollars for UI absolute rows.
|
||||
if monthlyLimit != nil {
|
||||
v := *monthlyLimit / 100
|
||||
summary.MonthlyLimit = &v
|
||||
}
|
||||
if used != nil {
|
||||
v := *used / 100
|
||||
summary.MonthlyUsed = &v
|
||||
}
|
||||
summary.TopUpMethod = strings.TrimSpace(config.TopUpMethod)
|
||||
summary.IsUnifiedBillingUser = config.IsUnifiedBillingUser
|
||||
summary.Plan = resolvePlan(monthlyLimit)
|
||||
return summary
|
||||
}
|
||||
@@ -257,6 +298,22 @@ func MergeBillingProbeResult(previous, weekly, monthly *BillingSummary, weeklyOK
|
||||
out.PeriodStart = weekly.PeriodStart
|
||||
out.PeriodEnd = weekly.PeriodEnd
|
||||
out.ProductUsage = weekly.ProductUsage
|
||||
// Absolute prepaid / on-demand usually ride the credits (weekly) response.
|
||||
if weekly.PrepaidBalance != nil {
|
||||
out.PrepaidBalance = weekly.PrepaidBalance
|
||||
}
|
||||
if weekly.OnDemandCap != nil {
|
||||
out.OnDemandCap = weekly.OnDemandCap
|
||||
}
|
||||
if weekly.OnDemandUsed != nil {
|
||||
out.OnDemandUsed = weekly.OnDemandUsed
|
||||
}
|
||||
if weekly.TopUpMethod != "" {
|
||||
out.TopUpMethod = weekly.TopUpMethod
|
||||
}
|
||||
if weekly.IsUnifiedBillingUser {
|
||||
out.IsUnifiedBillingUser = true
|
||||
}
|
||||
out.WeeklyUpdatedAt = now
|
||||
}
|
||||
if monthlyOK && monthly != nil {
|
||||
@@ -269,6 +326,15 @@ func MergeBillingProbeResult(previous, weekly, monthly *BillingSummary, weeklyOK
|
||||
out.BillingPeriodStart = monthly.BillingPeriodStart
|
||||
out.BillingPeriodEnd = monthly.BillingPeriodEnd
|
||||
out.UsedPercent = monthly.UsedPercent
|
||||
out.MonthlyLimit = monthly.MonthlyLimit
|
||||
out.MonthlyUsed = monthly.MonthlyUsed
|
||||
// Monthly probe may also carry on-demand cap when credits omitted it.
|
||||
if monthly.OnDemandCap != nil && out.OnDemandCap == nil {
|
||||
out.OnDemandCap = monthly.OnDemandCap
|
||||
}
|
||||
if monthly.OnDemandUsed != nil && out.OnDemandUsed == nil {
|
||||
out.OnDemandUsed = monthly.OnDemandUsed
|
||||
}
|
||||
out.Plan = monthly.Plan
|
||||
out.MonthlyUpdatedAt = now
|
||||
}
|
||||
|
||||
@@ -48,7 +48,11 @@ func TestBuildBillingSummaryWeeklyAndMonthly(t *testing.T) {
|
||||
"config": {
|
||||
"currentPeriod": {"type":"WEEKLY","start":"2026-07-09T03:25:00Z","end":"2026-07-16T03:25:00Z"},
|
||||
"creditUsagePercent": 2.0,
|
||||
"productUsage": [{"product":"Api","usagePercent":2.0}]
|
||||
"productUsage": [{"product":"Api","usagePercent":2.0}],
|
||||
"prepaidBalance": {"val": 12},
|
||||
"onDemandCap": {"val": 100},
|
||||
"onDemandUsed": {"val": 5},
|
||||
"isUnifiedBillingUser": true
|
||||
}
|
||||
}`)
|
||||
monthlyBody := []byte(`{
|
||||
@@ -72,10 +76,16 @@ func TestBuildBillingSummaryWeeklyAndMonthly(t *testing.T) {
|
||||
require.Equal(t, "weekly", weekly.PeriodType)
|
||||
require.InDelta(t, 2.0, *weekly.UsagePercent, 1e-9)
|
||||
require.Equal(t, "Api", weekly.ProductUsage[0].Product)
|
||||
require.InDelta(t, 12, *weekly.PrepaidBalance, 1e-9)
|
||||
require.InDelta(t, 100, *weekly.OnDemandCap, 1e-9)
|
||||
require.InDelta(t, 5, *weekly.OnDemandUsed, 1e-9)
|
||||
require.True(t, weekly.IsUnifiedBillingUser)
|
||||
require.Equal(t, "SuperGrok", monthly.Plan)
|
||||
require.InDelta(t, 15000, *monthly.MonthlyLimitCents, 1e-9)
|
||||
require.InDelta(t, 78, *monthly.UsedCents, 1e-9)
|
||||
require.InDelta(t, 0.52, *monthly.UsedPercent, 1e-2)
|
||||
require.InDelta(t, 150, *monthly.MonthlyLimit, 1e-9)
|
||||
require.InDelta(t, 0.78, *monthly.MonthlyUsed, 1e-9)
|
||||
|
||||
merged := MergeBillingProbeResult(nil, weekly, monthly, true, true)
|
||||
require.Equal(t, "weekly", merged.PeriodType)
|
||||
@@ -83,6 +93,10 @@ func TestBuildBillingSummaryWeeklyAndMonthly(t *testing.T) {
|
||||
require.Equal(t, "SuperGrok", merged.Plan)
|
||||
require.InDelta(t, 15000, *merged.MonthlyLimitCents, 1e-9)
|
||||
require.Equal(t, "2026-08-01T00:00:00Z", merged.BillingPeriodEnd)
|
||||
require.InDelta(t, 12, *merged.PrepaidBalance, 1e-9)
|
||||
require.InDelta(t, 100, *merged.OnDemandCap, 1e-9)
|
||||
require.InDelta(t, 150, *merged.MonthlyLimit, 1e-9)
|
||||
require.InDelta(t, 0.78, *merged.MonthlyUsed, 1e-9)
|
||||
}
|
||||
|
||||
func TestParseCentValueBareNumber(t *testing.T) {
|
||||
@@ -105,6 +119,27 @@ func TestBuildBillingSummaryMonthlyOnlyKeepsWeeklyUsageEmpty(t *testing.T) {
|
||||
require.InDelta(t, 50, *summary.UsedPercent, 1e-9)
|
||||
}
|
||||
|
||||
func TestBuildBillingSummaryWeeklyDoesNotInheritMonthlyPeriodEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Weekly usage without currentPeriod.end must not copy billingPeriodEnd (monthly).
|
||||
payload, err := ParseBillingPayload([]byte(`{
|
||||
"config": {
|
||||
"creditUsagePercent": 95.0,
|
||||
"productUsage": [{"product":"Api","usagePercent":95.0}],
|
||||
"billingPeriodStart": "2026-07-01T00:00:00Z",
|
||||
"billingPeriodEnd": "2026-08-01T00:00:00Z",
|
||||
"monthlyLimit": {"val": 15000},
|
||||
"used": {"val": 1000}
|
||||
}
|
||||
}`))
|
||||
require.NoError(t, err)
|
||||
summary := BuildBillingSummary(payload.Config)
|
||||
require.NotNil(t, summary)
|
||||
require.Equal(t, "weekly", summary.PeriodType)
|
||||
require.Equal(t, "", summary.PeriodEnd)
|
||||
require.Equal(t, "2026-08-01T00:00:00Z", summary.BillingPeriodEnd)
|
||||
}
|
||||
|
||||
func TestMergeBillingProbeResultRetainsFailedWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
previous := &BillingSummary{
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package xai
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// Fixed Grok Build / CLI-chat-proxy client identity.
|
||||
// These values are intentionally pinned in-binary (not scraped from live CLI).
|
||||
// Operators may bump the version via XAI_GROK_CLI_VERSION without a release.
|
||||
const (
|
||||
// CLIProxyHost is the hostname that requires the official CLI identity headers.
|
||||
CLIProxyHost = "cli-chat-proxy.grok.com"
|
||||
|
||||
// CLIStableVersion is the known-good minimum client version accepted by cli-chat-proxy.
|
||||
CLIStableVersion = "0.2.93"
|
||||
|
||||
// CLIVersionEnv is the optional operator override for CLIStableVersion.
|
||||
CLIVersionEnv = "XAI_GROK_CLI_VERSION"
|
||||
|
||||
// CLITokenAuth is required by cli-chat-proxy for Grok Build OAuth tokens.
|
||||
CLITokenAuth = "xai-grok-cli"
|
||||
|
||||
// CLIClientIdentifier is the x-grok-client-identifier value used by Grok shell/CLI.
|
||||
CLIClientIdentifier = "grok-shell"
|
||||
|
||||
// CLIClientMode is used by billing / quota probes on the CLI surface.
|
||||
CLIClientMode = "cli"
|
||||
)
|
||||
|
||||
// ResolveCLIVersion returns a supported CLI client version.
|
||||
// Empty or invalid overrides fall back to CLIClientVersion (the pinned
|
||||
// preferred client pin in billing.go). CLIStableVersion is only the minimum
|
||||
// accepted by IsSupportedCLIVersion, not the default identity we advertise.
|
||||
func ResolveCLIVersion() string {
|
||||
version := strings.TrimSpace(os.Getenv(CLIVersionEnv))
|
||||
if !IsSupportedCLIVersion(version) {
|
||||
return CLIClientVersion
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
// IsSupportedCLIVersion reports whether version is a valid semver string at or
|
||||
// above CLIStableVersion (prereleases below a higher release are rejected when
|
||||
// they compare less than the stable pin).
|
||||
func IsSupportedCLIVersion(version string) bool {
|
||||
canonical := "v" + version
|
||||
minimum := "v" + CLIStableVersion
|
||||
return semver.IsValid(canonical) &&
|
||||
semver.Canonical(canonical) == canonical &&
|
||||
semver.Compare(canonical, minimum) >= 0
|
||||
}
|
||||
|
||||
// CLIUserAgent builds the workspace-style User-Agent for a CLI client version.
|
||||
func CLIUserAgent(version string) string {
|
||||
if strings.TrimSpace(version) == "" {
|
||||
version = CLIClientVersion
|
||||
}
|
||||
return "xai-grok-workspace/" + version
|
||||
}
|
||||
|
||||
// ApplyCLIProxyHeaders stamps the fixed Grok CLI identity when the request
|
||||
// targets cli-chat-proxy. Direct api.x.ai traffic is left unchanged.
|
||||
func ApplyCLIProxyHeaders(req *http.Request) {
|
||||
if req == nil || req.URL == nil || !strings.EqualFold(strings.TrimSpace(req.URL.Hostname()), CLIProxyHost) {
|
||||
return
|
||||
}
|
||||
if req.Header == nil {
|
||||
req.Header = make(http.Header)
|
||||
}
|
||||
version := ResolveCLIVersion()
|
||||
req.Header.Set("X-XAI-Token-Auth", CLITokenAuth)
|
||||
req.Header.Set("x-grok-client-version", version)
|
||||
req.Header.Set("x-grok-client-identifier", CLIClientIdentifier)
|
||||
req.Header.Set("User-Agent", CLIUserAgent(version))
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package xai
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestResolveCLIVersionDefaultsToPinnedClientVersion(t *testing.T) {
|
||||
t.Setenv(CLIVersionEnv, "")
|
||||
// Default advertise pin is CLIClientVersion; CLIStableVersion is only the floor.
|
||||
require.Equal(t, CLIClientVersion, ResolveCLIVersion())
|
||||
require.True(t, IsSupportedCLIVersion(CLIClientVersion))
|
||||
require.True(t, IsSupportedCLIVersion(CLIStableVersion))
|
||||
}
|
||||
|
||||
func TestResolveCLIVersionAcceptsValidOverride(t *testing.T) {
|
||||
t.Setenv(CLIVersionEnv, "0.2.95-alpha.1")
|
||||
require.Equal(t, "0.2.95-alpha.1", ResolveCLIVersion())
|
||||
}
|
||||
|
||||
func TestResolveCLIVersionRejectsUnsafeOrTooOld(t *testing.T) {
|
||||
for _, version := range []string{
|
||||
"0.2.92",
|
||||
"0.2.93-beta.1",
|
||||
"0.2.95\r\nX-Injected: true",
|
||||
"0.2.093",
|
||||
"0.3",
|
||||
"1",
|
||||
} {
|
||||
t.Run(version, func(t *testing.T) {
|
||||
t.Setenv(CLIVersionEnv, version)
|
||||
require.Equal(t, CLIClientVersion, ResolveCLIVersion())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCLIProxyHeaders(t *testing.T) {
|
||||
t.Setenv(CLIVersionEnv, "")
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("User-Agent", "sub2api-grok/1.0")
|
||||
|
||||
ApplyCLIProxyHeaders(req)
|
||||
|
||||
require.Equal(t, CLIClientVersion, req.Header.Get("x-grok-client-version"))
|
||||
require.Equal(t, CLIClientIdentifier, req.Header.Get("x-grok-client-identifier"))
|
||||
require.Equal(t, CLITokenAuth, req.Header.Get("X-XAI-Token-Auth"))
|
||||
require.Equal(t, CLIUserAgent(CLIClientVersion), req.Header.Get("User-Agent"))
|
||||
}
|
||||
|
||||
func TestApplyCLIProxyHeadersLeavesAPIHostUnchanged(t *testing.T) {
|
||||
t.Setenv(CLIVersionEnv, "0.2.95")
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, "https://api.x.ai/v1/responses", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("User-Agent", "sub2api-grok/1.0")
|
||||
|
||||
ApplyCLIProxyHeaders(req)
|
||||
|
||||
require.Empty(t, req.Header.Get("x-grok-client-version"))
|
||||
require.Empty(t, req.Header.Get("x-grok-client-identifier"))
|
||||
require.Empty(t, req.Header.Get("X-XAI-Token-Auth"))
|
||||
require.Equal(t, "sub2api-grok/1.0", req.Header.Get("User-Agent"))
|
||||
}
|
||||
@@ -1,28 +1,130 @@
|
||||
package xai
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// runtimeMappingOpts holds operator-configured defaults applied when Grok
|
||||
// accounts leave credentials.model_mapping empty. Updated from settings.
|
||||
var runtimeMappingOpts atomic.Value // ModelMappingOptions
|
||||
var runtimeMappingVersion atomic.Uint64
|
||||
|
||||
func init() {
|
||||
runtimeMappingOpts.Store(ModelMappingOptions{})
|
||||
runtimeMappingVersion.Store(1)
|
||||
}
|
||||
|
||||
// SetRuntimeModelMappingOptions updates process-wide defaults used by
|
||||
// DefaultModelMapping (e.g. after settings load). Safe for concurrent use.
|
||||
func SetRuntimeModelMappingOptions(opts ModelMappingOptions) {
|
||||
runtimeMappingOpts.Store(opts)
|
||||
runtimeMappingVersion.Add(1)
|
||||
}
|
||||
|
||||
// RuntimeModelMappingVersion changes whenever runtime mapping options change.
|
||||
// Account-level caches include it so settings updates take effect without a restart.
|
||||
func RuntimeModelMappingVersion() uint64 {
|
||||
return runtimeMappingVersion.Load()
|
||||
}
|
||||
|
||||
// RuntimeModelMappingOptions returns the last options set via SetRuntimeModelMappingOptions.
|
||||
func RuntimeModelMappingOptions() ModelMappingOptions {
|
||||
if v := runtimeMappingOpts.Load(); v != nil {
|
||||
if opts, ok := v.(ModelMappingOptions); ok {
|
||||
return opts
|
||||
}
|
||||
}
|
||||
return ModelMappingOptions{}
|
||||
}
|
||||
|
||||
// Model describes an xAI model in OpenAI-compatible /models shape.
|
||||
type Model struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Created int64 `json:"created,omitempty"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultTextModel is the built-in fallback for empty model fields and Grok
|
||||
// text aliases (e.g. "grok", "grok-latest"). Operators may override the runtime
|
||||
// default via settings key grok_default_text_model.
|
||||
const DefaultTextModel = "grok-4.5"
|
||||
|
||||
// Official Imagine model IDs (https://docs.x.ai/docs/models).
|
||||
const (
|
||||
DefaultImagineImageQualityModel = "grok-imagine-image-quality"
|
||||
DefaultImagineImageFastModel = "grok-imagine-image"
|
||||
DefaultImagineVideoModel = "grok-imagine-video"
|
||||
DefaultImagineVideo15LegacyModel = "grok-imagine-video-1.5"
|
||||
DefaultImagineVideo15Model = "grok-imagine-video-1.5-preview"
|
||||
)
|
||||
|
||||
// ModelMappingOptions controls optional expansions of the default mapping.
|
||||
// Cross-client wildcards (gpt-*/claude-*) default ON via settings
|
||||
// grok_cross_client_model_map_enabled so Codex/Claude clients keep working
|
||||
// against Grok groups (map to DefaultText / grok-4.5). Operators may disable.
|
||||
type ModelMappingOptions struct {
|
||||
// DefaultText is the target for empty models and optional cross-client maps.
|
||||
// Empty → DefaultTextModel (grok-4.5).
|
||||
DefaultText string
|
||||
// EnableCrossClientMap merges gpt-*/codex-*/o*/claude-* → DefaultText.
|
||||
EnableCrossClientMap bool
|
||||
}
|
||||
|
||||
func (o ModelMappingOptions) defaultText() string {
|
||||
if t := strings.TrimSpace(o.DefaultText); t != "" {
|
||||
return t
|
||||
}
|
||||
return DefaultTextModel
|
||||
}
|
||||
|
||||
var defaultModels = []Model{
|
||||
{ID: "grok-4.5", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.5"},
|
||||
{ID: "grok-4.3", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.3"},
|
||||
{ID: "grok-build-0.1", Object: "model", OwnedBy: "xai", DisplayName: "Grok Build 0.1"},
|
||||
{ID: "grok-composer-2.5-fast", Object: "model", OwnedBy: "xai", DisplayName: "Grok Composer 2.5 Fast"},
|
||||
{ID: "grok-4.20-0309-reasoning", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Reasoning"},
|
||||
{ID: "grok-4.20-0309-non-reasoning", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Non Reasoning"},
|
||||
{ID: "grok-4.20-multi-agent-0309", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Multi Agent"},
|
||||
{ID: "grok-imagine", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine"},
|
||||
{ID: "grok-imagine-image", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Image"},
|
||||
{ID: "grok-imagine-image-quality", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Image Quality"},
|
||||
{ID: "grok-imagine-edit", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Edit"},
|
||||
{ID: "grok-imagine-video", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video"},
|
||||
{ID: "grok-imagine-video-1.5", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video 1.5"},
|
||||
// Text
|
||||
{ID: "grok-4.5", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.5"},
|
||||
{ID: "grok-4.3", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.3"},
|
||||
{ID: "grok-3-mini", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 3 Mini"},
|
||||
{ID: "grok-3-mini-fast", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 3 Mini Fast"},
|
||||
{ID: "grok-build-0.1", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Build 0.1"},
|
||||
{ID: "grok-composer-2.5-fast", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Composer 2.5 Fast"},
|
||||
{ID: "grok-4.20-0309-reasoning", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Reasoning"},
|
||||
{ID: "grok-4.20-0309-non-reasoning", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Non Reasoning"},
|
||||
{ID: "grok-4.20-multi-agent-0309", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Multi Agent"},
|
||||
// Imagine
|
||||
{ID: DefaultImagineImageQualityModel, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Image Quality"},
|
||||
{ID: DefaultImagineImageFastModel, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Image"},
|
||||
{ID: DefaultImagineVideoModel, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video"},
|
||||
{ID: DefaultImagineVideo15Model, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video 1.5 Preview"},
|
||||
{ID: DefaultImagineVideo15LegacyModel, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video 1.5 Legacy"},
|
||||
}
|
||||
|
||||
// grokTextResponsesModelAliases is the source of truth for Grok text models
|
||||
// accepted by the Responses path: client-facing / undated aliases → canonical
|
||||
// upstream ID. Used by DefaultModelMapping and IsGrokTextResponsesModelID.
|
||||
var grokTextResponsesModelAliases = map[string]string{
|
||||
"grok": DefaultTextModel,
|
||||
"grok-latest": DefaultTextModel,
|
||||
"grok-4.5": DefaultTextModel,
|
||||
"grok-4.5-latest": DefaultTextModel,
|
||||
"grok-4.3": "grok-4.3",
|
||||
"grok-4.3-latest": "grok-4.3",
|
||||
"grok-3-mini": "grok-3-mini",
|
||||
"grok-3-mini-fast": "grok-3-mini-fast",
|
||||
"grok-build": "grok-build-0.1",
|
||||
"grok-build-latest": DefaultTextModel,
|
||||
"grok-build-0.1": "grok-build-0.1",
|
||||
"grok-composer-2.5-fast": "grok-composer-2.5-fast",
|
||||
"grok-composer": "grok-composer-2.5-fast",
|
||||
"composer-2.5": "grok-composer-2.5-fast",
|
||||
"grok-4.20-reasoning": "grok-4.20-0309-reasoning",
|
||||
"grok-4.20-0309-reasoning": "grok-4.20-0309-reasoning",
|
||||
"grok-4.20-non-reasoning": "grok-4.20-0309-non-reasoning",
|
||||
"grok-4.20-0309-non-reasoning": "grok-4.20-0309-non-reasoning",
|
||||
"grok-4.20-multi-agent": "grok-4.20-multi-agent-0309",
|
||||
"grok-4.20-multi-agent-latest": "grok-4.20-multi-agent-0309",
|
||||
"grok-4.20-multi-agent-0309": "grok-4.20-multi-agent-0309",
|
||||
}
|
||||
|
||||
func DefaultModels() []Model {
|
||||
@@ -40,19 +142,163 @@ func DefaultModelIDs() []string {
|
||||
return ids
|
||||
}
|
||||
|
||||
// DefaultModelMapping returns native Grok/Imagine identity + aliases, using
|
||||
// runtime options (default text model / optional cross-client wildcards).
|
||||
// Does NOT enable gpt-*/claude-* unless SetRuntimeModelMappingOptions enables them.
|
||||
func DefaultModelMapping() map[string]string {
|
||||
mapping := make(map[string]string, len(defaultModels)+5)
|
||||
return ModelMappingWithOptions(RuntimeModelMappingOptions())
|
||||
}
|
||||
|
||||
// ModelMappingWithOptions builds the default Grok mapping with optional
|
||||
// cross-client wildcards and a configurable default text model.
|
||||
func ModelMappingWithOptions(opts ModelMappingOptions) map[string]string {
|
||||
defaultText := opts.defaultText()
|
||||
mapping := make(map[string]string, len(defaultModels)+len(grokTextResponsesModelAliases)+48)
|
||||
for _, model := range defaultModels {
|
||||
mapping[model.ID] = model.ID
|
||||
}
|
||||
mapping["grok"] = "grok-4.5"
|
||||
mapping["grok-latest"] = "grok-4.5"
|
||||
mapping["grok-4.5-latest"] = "grok-4.5"
|
||||
mapping["grok-build"] = "grok-build-0.1"
|
||||
mapping["grok-build-latest"] = "grok-4.5"
|
||||
mapping["grok-composer"] = "grok-composer-2.5-fast"
|
||||
mapping["composer-2.5"] = "grok-composer-2.5-fast"
|
||||
mapping["grok-4.20-reasoning"] = "grok-4.20-0309-reasoning"
|
||||
mapping["grok-4.20-non-reasoning"] = "grok-4.20-0309-non-reasoning"
|
||||
for alias, canonical := range grokTextResponsesModelAliases {
|
||||
// Remap aliases that pointed at DefaultTextModel constant to runtime default.
|
||||
if canonical == DefaultTextModel {
|
||||
mapping[alias] = defaultText
|
||||
} else {
|
||||
mapping[alias] = canonical
|
||||
}
|
||||
}
|
||||
// Imagine aliases / legacy IDs → official catalog.
|
||||
mapping["grok-imagine"] = DefaultImagineImageQualityModel
|
||||
mapping["grok-imagine-1"] = DefaultImagineImageQualityModel
|
||||
// Backward-compatible client alias; xAI exposes image editing through the
|
||||
// image-quality model rather than a separate grok-imagine-edit model.
|
||||
mapping["grok-imagine-edit"] = DefaultImagineImageQualityModel
|
||||
mapping["grok-imagine-image"] = DefaultImagineImageFastModel
|
||||
mapping["grok-imagine-image-quality"] = DefaultImagineImageQualityModel
|
||||
// Keep official IDs as identity so client-requested model strings are not
|
||||
// rewritten on the wire (pricing still canonicalizes 1.5* via CanonicalImagineVideoModel).
|
||||
mapping["grok-imagine-video"] = DefaultImagineVideoModel
|
||||
mapping["grok-imagine-video-1.5"] = DefaultImagineVideo15LegacyModel
|
||||
mapping["grok-imagine-video-1.5-preview"] = DefaultImagineVideo15Model
|
||||
// Informal alias only:
|
||||
mapping["grok-video-1.5"] = DefaultImagineVideo15Model
|
||||
|
||||
if opts.EnableCrossClientMap {
|
||||
// Codex / OpenAI Responses client defaults (wildcard patterns).
|
||||
mapping["gpt-*"] = defaultText
|
||||
mapping["codex-*"] = defaultText
|
||||
mapping["o1*"] = defaultText
|
||||
mapping["o3*"] = defaultText
|
||||
mapping["o4*"] = defaultText
|
||||
// Claude Code defaults when operators intentionally enable bridging.
|
||||
mapping["claude-*"] = defaultText
|
||||
}
|
||||
addGrokProviderPrefixedMappings(mapping)
|
||||
return mapping
|
||||
}
|
||||
|
||||
func addGrokProviderPrefixedMappings(mapping map[string]string) {
|
||||
snapshot := make(map[string]string, len(mapping))
|
||||
for key, value := range mapping {
|
||||
snapshot[key] = value
|
||||
}
|
||||
for key, value := range snapshot {
|
||||
if !isGrokNativeOrAlias(key) {
|
||||
continue
|
||||
}
|
||||
for _, prefix := range []string{"xai/", "x-ai/", "grok/"} {
|
||||
mapping[prefix+key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isGrokNativeOrAlias(model string) bool {
|
||||
model = strings.ToLower(strings.TrimSpace(model))
|
||||
if model == "" || strings.Contains(model, "*") {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(model, "grok") ||
|
||||
strings.HasPrefix(model, "imagine") ||
|
||||
strings.HasPrefix(model, "composer")
|
||||
}
|
||||
|
||||
// StripGrokProviderPrefix removes common provider prefixes accepted for
|
||||
// xAI/Grok models, returning the native model ID.
|
||||
func StripGrokProviderPrefix(model string) string {
|
||||
trimmed := strings.TrimSpace(model)
|
||||
lower := strings.ToLower(trimmed)
|
||||
for _, prefix := range []string{"xai/", "x-ai/", "grok/"} {
|
||||
if strings.HasPrefix(lower, prefix) {
|
||||
return strings.TrimSpace(trimmed[len(prefix):])
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// IsGrokModelID reports whether model looks like a native Grok/xAI model id
|
||||
// (including aliases). Claude/OpenAI model names return false.
|
||||
func IsGrokModelID(model string) bool {
|
||||
normalized := strings.ToLower(StripGrokProviderPrefix(model))
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(normalized, "grok") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(normalized, "imagine") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsGrokTextResponsesModelID reports whether model is a known Grok text model
|
||||
// for the Responses API. Imagine image/video and unknown custom ids return false.
|
||||
func IsGrokTextResponsesModelID(model string) bool {
|
||||
normalized := strings.ToLower(StripGrokProviderPrefix(model))
|
||||
_, ok := grokTextResponsesModelAliases[normalized]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResolveGrokTextResponsesModelID canonicalizes a Grok text alias before upstream.
|
||||
// empty or bare aliases that resolve via DefaultTextModel use defaultText when set.
|
||||
func ResolveGrokTextResponsesModelID(model string, defaultText ...string) string {
|
||||
fallback := DefaultTextModel
|
||||
if len(defaultText) > 0 && strings.TrimSpace(defaultText[0]) != "" {
|
||||
fallback = strings.TrimSpace(defaultText[0])
|
||||
}
|
||||
trimmed := strings.TrimSpace(model)
|
||||
if trimmed == "" {
|
||||
return fallback
|
||||
}
|
||||
normalized := strings.ToLower(StripGrokProviderPrefix(trimmed))
|
||||
if canonical, ok := grokTextResponsesModelAliases[normalized]; ok {
|
||||
if canonical == DefaultTextModel {
|
||||
return fallback
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
return StripGrokProviderPrefix(trimmed)
|
||||
}
|
||||
|
||||
// ResolveDefaultTextModel returns defaultText (or DefaultTextModel) when model is empty.
|
||||
func ResolveDefaultTextModel(model string, defaultText ...string) string {
|
||||
if trimmed := strings.TrimSpace(model); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
if len(defaultText) > 0 && strings.TrimSpace(defaultText[0]) != "" {
|
||||
return strings.TrimSpace(defaultText[0])
|
||||
}
|
||||
return DefaultTextModel
|
||||
}
|
||||
|
||||
// CanonicalImagineVideoModel normalizes video model ids for pricing tables.
|
||||
// Legacy "grok-imagine-video-1.5" shares the 1.5 price family with preview.
|
||||
func CanonicalImagineVideoModel(model string) string {
|
||||
m := strings.ToLower(StripGrokProviderPrefix(model))
|
||||
switch {
|
||||
case m == "" || m == DefaultImagineVideoModel || m == "grok-imagine-video-preview":
|
||||
return DefaultImagineVideoModel
|
||||
case strings.HasPrefix(m, "grok-imagine-video-1.5") || m == "grok-video-1.5":
|
||||
return DefaultImagineVideo15Model
|
||||
default:
|
||||
return m
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package xai
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDefaultModelMappingExcludesCrossClientWildcards(t *testing.T) {
|
||||
original := RuntimeModelMappingOptions()
|
||||
t.Cleanup(func() { SetRuntimeModelMappingOptions(original) })
|
||||
SetRuntimeModelMappingOptions(ModelMappingOptions{})
|
||||
mapping := DefaultModelMapping()
|
||||
|
||||
require.Equal(t, "grok-4.5", mapping["grok"])
|
||||
require.Equal(t, "grok-4.5", mapping["grok-latest"])
|
||||
require.Equal(t, "grok-build-0.1", mapping["grok-build"])
|
||||
require.Equal(t, DefaultTextModel, mapping["grok-build-latest"])
|
||||
require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine-edit"])
|
||||
require.Equal(t, DefaultImagineVideo15LegacyModel, mapping["grok-imagine-video-1.5"])
|
||||
require.Equal(t, DefaultImagineVideo15Model, mapping["grok-imagine-video-1.5-preview"])
|
||||
require.Equal(t, "grok-4.5", mapping["xai/grok"])
|
||||
|
||||
// Cross-vendor wildcards must stay opt-in.
|
||||
_, hasGPT := mapping["gpt-*"]
|
||||
_, hasClaude := mapping["claude-*"]
|
||||
require.False(t, hasGPT)
|
||||
require.False(t, hasClaude)
|
||||
}
|
||||
|
||||
func TestModelMappingWithOptionsCrossClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
mapping := ModelMappingWithOptions(ModelMappingOptions{
|
||||
DefaultText: "grok-4.3",
|
||||
EnableCrossClientMap: true,
|
||||
})
|
||||
require.Equal(t, "grok-4.3", mapping["grok"])
|
||||
require.Equal(t, "grok-4.3", mapping["gpt-*"])
|
||||
require.Equal(t, "grok-4.3", mapping["claude-*"])
|
||||
require.Equal(t, "grok-4.3", mapping["codex-*"])
|
||||
}
|
||||
|
||||
func TestCanonicalImagineVideoModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, DefaultImagineVideoModel, CanonicalImagineVideoModel("grok-imagine-video"))
|
||||
require.Equal(t, DefaultImagineVideo15Model, CanonicalImagineVideoModel("grok-imagine-video-1.5"))
|
||||
require.Equal(t, DefaultImagineVideo15Model, CanonicalImagineVideoModel("grok-imagine-video-1.5-preview"))
|
||||
require.Equal(t, DefaultImagineVideo15Model, CanonicalImagineVideoModel("xai/grok-video-1.5"))
|
||||
require.Equal(t, "grok-imagine-video-2", CanonicalImagineVideoModel("grok-imagine-video-2"))
|
||||
}
|
||||
|
||||
func TestIsGrokModelID(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.True(t, IsGrokModelID("grok-4.5"))
|
||||
require.True(t, IsGrokModelID("x-ai/grok-4.3"))
|
||||
require.False(t, IsGrokModelID("gpt-5"))
|
||||
require.False(t, IsGrokModelID("claude-sonnet-4"))
|
||||
}
|
||||
|
||||
func TestResolveGrokTextResponsesModelID(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, "grok-4.5", ResolveGrokTextResponsesModelID(""))
|
||||
require.Equal(t, "grok-4.3", ResolveGrokTextResponsesModelID("grok", "grok-4.3"))
|
||||
require.Equal(t, "grok-4.20-multi-agent-0309", ResolveGrokTextResponsesModelID("grok-4.20-multi-agent"))
|
||||
}
|
||||
@@ -1,33 +1,40 @@
|
||||
package xai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/redissession"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/logredact"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
OAuthIssuer = "https://auth.x.ai"
|
||||
DiscoveryURL = OAuthIssuer + "/.well-known/openid-configuration"
|
||||
DefaultAuthorizeURL = OAuthIssuer + "/oauth2/authorize"
|
||||
DefaultTokenURL = OAuthIssuer + "/oauth2/token"
|
||||
DefaultBaseURL = "https://api.x.ai/v1"
|
||||
DefaultCLIBaseURL = "https://cli-chat-proxy.grok.com/v1"
|
||||
DefaultClientID = "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
DefaultScope = "openid profile email offline_access grok-cli:access api:access"
|
||||
DefaultRedirectURI = "http://127.0.0.1:56121/callback"
|
||||
SessionTTL = 30 * time.Minute
|
||||
OAuthIssuer = "https://auth.x.ai"
|
||||
DiscoveryURL = OAuthIssuer + "/.well-known/openid-configuration"
|
||||
DefaultAuthorizeURL = OAuthIssuer + "/oauth2/authorize"
|
||||
DefaultTokenURL = OAuthIssuer + "/oauth2/token"
|
||||
DefaultBaseURL = "https://api.x.ai/v1"
|
||||
DefaultCLIBaseURL = "https://cli-chat-proxy.grok.com/v1"
|
||||
DefaultUSEast1BaseURL = "https://us-east-1.api.x.ai/v1"
|
||||
DefaultUSWest2BaseURL = "https://us-west-2.api.x.ai/v1"
|
||||
DefaultEUWest1BaseURL = "https://eu-west-1.api.x.ai/v1"
|
||||
DefaultClientID = "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
DefaultScope = "openid profile email offline_access grok-cli:access api:access"
|
||||
DefaultRedirectURI = "http://127.0.0.1:56121/callback"
|
||||
SessionTTL = 30 * time.Minute
|
||||
|
||||
EnvAuthorizeURL = "XAI_OAUTH_AUTHORIZE_URL"
|
||||
EnvTokenURL = "XAI_OAUTH_TOKEN_URL"
|
||||
@@ -56,32 +63,110 @@ type OAuthSession struct {
|
||||
ProxyURL string `json:"proxy_url,omitempty"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
mu sync.Mutex
|
||||
consumed bool
|
||||
}
|
||||
|
||||
// SessionStore manages xAI OAuth sessions in memory.
|
||||
func (s *OAuthSession) TryConsume() bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.consumed {
|
||||
return false
|
||||
}
|
||||
s.consumed = true
|
||||
return true
|
||||
}
|
||||
|
||||
// SessionStore manages xAI OAuth sessions with an optional Redis backend.
|
||||
type SessionStore struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*OAuthSession
|
||||
stopOnce sync.Once
|
||||
stopCh chan struct{}
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*OAuthSession
|
||||
localOnly map[string]struct{}
|
||||
stopOnce sync.Once
|
||||
stopCh chan struct{}
|
||||
remote *redissession.Store
|
||||
}
|
||||
|
||||
type oauthSessionDTO struct {
|
||||
State string `json:"state"`
|
||||
CodeVerifier string `json:"code_verifier"`
|
||||
CodeChallenge string `json:"code_challenge"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ProxyURL string `json:"proxy_url,omitempty"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func NewSessionStore() *SessionStore {
|
||||
store := &SessionStore{
|
||||
sessions: make(map[string]*OAuthSession),
|
||||
stopCh: make(chan struct{}),
|
||||
sessions: make(map[string]*OAuthSession),
|
||||
localOnly: make(map[string]struct{}),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
go store.cleanup()
|
||||
return store
|
||||
}
|
||||
|
||||
func NewRedisSessionStore(rdb *redis.Client) *SessionStore {
|
||||
store := NewSessionStore()
|
||||
if rdb != nil {
|
||||
store.remote = redissession.New(rdb, "oauth:session:xai", SessionTTL)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
func (s *SessionStore) Set(sessionID string, session *OAuthSession) {
|
||||
if session == nil {
|
||||
return
|
||||
}
|
||||
var remoteErr error
|
||||
if s != nil && s.remote != nil {
|
||||
remoteErr = s.remote.Set(context.Background(), sessionID, oauthSessionDTO{
|
||||
State: session.State, CodeVerifier: session.CodeVerifier, CodeChallenge: session.CodeChallenge,
|
||||
ClientID: session.ClientID, Scope: session.Scope, ProxyURL: session.ProxyURL,
|
||||
RedirectURI: session.RedirectURI, CreatedAt: session.CreatedAt,
|
||||
})
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessions[sessionID] = session
|
||||
if remoteErr != nil {
|
||||
s.localOnly[sessionID] = struct{}{}
|
||||
slog.Warn("xai oauth session Redis write failed; using process-local fallback", "error", remoteErr)
|
||||
} else {
|
||||
delete(s.localOnly, sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SessionStore) Get(sessionID string) (*OAuthSession, bool) {
|
||||
if s.isLocalOnly(sessionID) {
|
||||
return s.getMemory(sessionID)
|
||||
}
|
||||
if s != nil && s.remote != nil {
|
||||
var dto oauthSessionDTO
|
||||
ok, err := s.remote.Get(context.Background(), sessionID, &dto)
|
||||
if err != nil || !ok || time.Since(dto.CreatedAt) > SessionTTL {
|
||||
return nil, false
|
||||
}
|
||||
session := &OAuthSession{
|
||||
State: dto.State, CodeVerifier: dto.CodeVerifier, CodeChallenge: dto.CodeChallenge,
|
||||
ClientID: dto.ClientID, Scope: dto.Scope, ProxyURL: dto.ProxyURL,
|
||||
RedirectURI: dto.RedirectURI, CreatedAt: dto.CreatedAt,
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.sessions[sessionID] = session
|
||||
s.mu.Unlock()
|
||||
return session, true
|
||||
}
|
||||
return s.getMemory(sessionID)
|
||||
}
|
||||
|
||||
func (s *SessionStore) getMemory(sessionID string) (*OAuthSession, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
session, ok := s.sessions[sessionID]
|
||||
@@ -95,9 +180,39 @@ func (s *SessionStore) Get(sessionID string) (*OAuthSession, bool) {
|
||||
}
|
||||
|
||||
func (s *SessionStore) Delete(sessionID string) {
|
||||
if s != nil && s.remote != nil {
|
||||
_ = s.remote.Delete(context.Background(), sessionID)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.sessions, sessionID)
|
||||
delete(s.localOnly, sessionID)
|
||||
}
|
||||
|
||||
func (s *SessionStore) TryConsumeSession(sessionID string) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
if s.isLocalOnly(sessionID) {
|
||||
return s.tryConsumeMemory(sessionID)
|
||||
}
|
||||
if s.remote != nil {
|
||||
ok, err := s.remote.TryConsume(context.Background(), sessionID)
|
||||
return err == nil && ok
|
||||
}
|
||||
return s.tryConsumeMemory(sessionID)
|
||||
}
|
||||
|
||||
func (s *SessionStore) isLocalOnly(sessionID string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
_, ok := s.localOnly[sessionID]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *SessionStore) tryConsumeMemory(sessionID string) bool {
|
||||
session, ok := s.getMemory(sessionID)
|
||||
return ok && session.TryConsume()
|
||||
}
|
||||
|
||||
func (s *SessionStore) Stop() {
|
||||
@@ -118,6 +233,7 @@ func (s *SessionStore) cleanup() {
|
||||
for id, session := range s.sessions {
|
||||
if time.Since(session.CreatedAt) > SessionTTL {
|
||||
delete(s.sessions, id)
|
||||
delete(s.localOnly, id)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
//go:build unit
|
||||
|
||||
package xai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSessionStoreRedisFallbackIsLimitedToFailedWrites(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr(), MaxRetries: -1})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
store := NewRedisSessionStore(client)
|
||||
defer store.Stop()
|
||||
session := func(state string) *OAuthSession { return &OAuthSession{State: state, CreatedAt: time.Now()} }
|
||||
|
||||
store.Set("remote", session("remote"))
|
||||
require.NoError(t, store.remote.Delete(context.Background(), "remote"))
|
||||
_, ok := store.Get("remote")
|
||||
require.False(t, ok, "a remote miss must not revive the stale local copy")
|
||||
|
||||
mr.Close()
|
||||
store.Set("local-only", session("local"))
|
||||
got, ok := store.Get("local-only")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "local", got.State)
|
||||
require.True(t, store.TryConsumeSession("local-only"))
|
||||
require.False(t, store.TryConsumeSession("local-only"))
|
||||
}
|
||||
@@ -255,6 +255,14 @@ func TestBuildResponsesURLWithValidatorUsesCallerPolicy(t *testing.T) {
|
||||
require.Equal(t, "http://grok.example.test/v1/responses", target)
|
||||
}
|
||||
|
||||
func TestValidateTrustedBaseURLAcceptsOfficialRegionalHosts(t *testing.T) {
|
||||
for _, raw := range []string{DefaultUSEast1BaseURL, DefaultUSWest2BaseURL, DefaultEUWest1BaseURL} {
|
||||
got, err := ValidateTrustedBaseURL(raw)
|
||||
require.NoError(t, err, raw)
|
||||
require.Equal(t, raw, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponsesURLPreservesUnsafeOverrideCustomPath(t *testing.T) {
|
||||
t.Setenv(EnvAllowUnsafeURLOverrides, "true")
|
||||
|
||||
@@ -338,8 +346,9 @@ func TestRuntimeSanityReportsInvalidOverridesWithoutSecrets(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDefaultModelMappingIncludesGrokAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
original := RuntimeModelMappingOptions()
|
||||
t.Cleanup(func() { SetRuntimeModelMappingOptions(original) })
|
||||
SetRuntimeModelMappingOptions(ModelMappingOptions{})
|
||||
mapping := DefaultModelMapping()
|
||||
require.Equal(t, "grok-4.5", mapping["grok"])
|
||||
require.Equal(t, "grok-4.5", mapping["grok-latest"])
|
||||
@@ -352,10 +361,13 @@ func TestDefaultModelMappingIncludesGrokAliases(t *testing.T) {
|
||||
require.Equal(t, "grok-4.20-0309-reasoning", mapping["grok-4.20-reasoning"])
|
||||
require.Equal(t, "grok-4.20-0309-non-reasoning", mapping["grok-4.20-non-reasoning"])
|
||||
require.Equal(t, "grok-4.20-multi-agent-0309", mapping["grok-4.20-multi-agent-0309"])
|
||||
require.Equal(t, "grok-imagine", mapping["grok-imagine"])
|
||||
require.Equal(t, "grok-imagine-image", mapping["grok-imagine-image"])
|
||||
require.Equal(t, "grok-imagine-image-quality", mapping["grok-imagine-image-quality"])
|
||||
require.Equal(t, "grok-imagine-edit", mapping["grok-imagine-edit"])
|
||||
require.Equal(t, "grok-imagine-video", mapping["grok-imagine-video"])
|
||||
require.Equal(t, "grok-imagine-video-1.5", mapping["grok-imagine-video-1.5"])
|
||||
require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine"])
|
||||
require.Equal(t, DefaultImagineImageFastModel, mapping["grok-imagine-image"])
|
||||
require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine-image-quality"])
|
||||
require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine-edit"])
|
||||
require.Equal(t, DefaultImagineVideoModel, mapping["grok-imagine-video"])
|
||||
require.Equal(t, DefaultImagineVideo15LegacyModel, mapping["grok-imagine-video-1.5"])
|
||||
require.Equal(t, DefaultImagineVideo15Model, mapping["grok-imagine-video-1.5-preview"])
|
||||
_, hasGPT := mapping["gpt-*"]
|
||||
require.False(t, hasGPT, "cross-client wildcards must be opt-in")
|
||||
}
|
||||
|
||||
@@ -7,10 +7,14 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const GrokFreeRolling24hTokenLimit int64 = 1_000_000
|
||||
// GrokFreeRolling24hTokenLimit is the operator soft-gate nominal Free allowance
|
||||
// (rolling 24h). Soft-gate default matches this; upstream header limits may
|
||||
// still report historical 1M/2M Free snapshots.
|
||||
const GrokFreeRolling24hTokenLimit int64 = 500_000
|
||||
|
||||
var grokFreeRolling24hTokenLimits = map[int64]struct{}{
|
||||
GrokFreeRolling24hTokenLimit: {},
|
||||
1_000_000: {}, // Observed Free limit variants.
|
||||
2_000_000: {}, // Legacy Free limit observed before July 2026.
|
||||
}
|
||||
|
||||
@@ -61,11 +65,27 @@ var quotaHeaderAllowlist = []string{
|
||||
"x-ratelimit-limit-tokens",
|
||||
"x-ratelimit-remaining-tokens",
|
||||
"x-ratelimit-reset-tokens",
|
||||
"x-rate-limit-limit-requests",
|
||||
"x-rate-limit-remaining-requests",
|
||||
"x-rate-limit-reset-requests",
|
||||
"x-rate-limit-limit-tokens",
|
||||
"x-rate-limit-remaining-tokens",
|
||||
"x-rate-limit-reset-tokens",
|
||||
"retry-after",
|
||||
"x-subscription-tier",
|
||||
"xai-subscription-tier",
|
||||
"x-xai-subscription-tier",
|
||||
"x-xai-user-tier",
|
||||
"xai-user-tier",
|
||||
"xai-tier",
|
||||
"x-user-tier",
|
||||
"x-plan-tier",
|
||||
"x-subscription-plan",
|
||||
"x-entitlement-status",
|
||||
"xai-entitlement-status",
|
||||
"x-xai-entitlement-status",
|
||||
"x-xai-user-entitlement-status",
|
||||
"x-user-entitlement-status",
|
||||
}
|
||||
|
||||
func ParseQuotaHeaders(headers http.Header, statusCode int) *QuotaSnapshot {
|
||||
@@ -95,8 +115,24 @@ func parseQuotaHeaders(headers http.Header, statusCode int, source string, keepE
|
||||
if retryAfter := parseRetryAfter(headers.Get("retry-after")); retryAfter != nil {
|
||||
snapshot.RetryAfterSeconds = retryAfter
|
||||
}
|
||||
snapshot.SubscriptionTier = firstHeader(headers, "xai-subscription-tier", "x-subscription-tier")
|
||||
snapshot.EntitlementStatus = firstHeader(headers, "xai-entitlement-status", "x-entitlement-status")
|
||||
snapshot.SubscriptionTier = firstHeader(headers,
|
||||
"xai-subscription-tier",
|
||||
"x-subscription-tier",
|
||||
"x-xai-subscription-tier",
|
||||
"x-xai-user-tier",
|
||||
"xai-user-tier",
|
||||
"xai-tier",
|
||||
"x-user-tier",
|
||||
"x-plan-tier",
|
||||
"x-subscription-plan",
|
||||
)
|
||||
snapshot.EntitlementStatus = firstHeader(headers,
|
||||
"xai-entitlement-status",
|
||||
"x-entitlement-status",
|
||||
"x-xai-entitlement-status",
|
||||
"x-xai-user-entitlement-status",
|
||||
"x-user-entitlement-status",
|
||||
)
|
||||
|
||||
for _, name := range quotaHeaderAllowlist {
|
||||
if value := strings.TrimSpace(headers.Get(name)); value != "" {
|
||||
@@ -121,11 +157,23 @@ func parseQuotaHeaders(headers http.Header, statusCode int, source string, keepE
|
||||
}
|
||||
|
||||
func parseQuotaWindow(headers http.Header, dimension string) *QuotaWindow {
|
||||
limitHeader := firstHeader(headers,
|
||||
"x-ratelimit-limit-"+dimension,
|
||||
"x-rate-limit-limit-"+dimension,
|
||||
)
|
||||
remainingHeader := firstHeader(headers,
|
||||
"x-ratelimit-remaining-"+dimension,
|
||||
"x-rate-limit-remaining-"+dimension,
|
||||
)
|
||||
resetHeader := firstHeader(headers,
|
||||
"x-ratelimit-reset-"+dimension,
|
||||
"x-rate-limit-reset-"+dimension,
|
||||
)
|
||||
window := &QuotaWindow{
|
||||
Limit: parseInt64Ptr(headers.Get("x-ratelimit-limit-" + dimension)),
|
||||
Remaining: parseInt64Ptr(headers.Get("x-ratelimit-remaining-" + dimension)),
|
||||
Limit: parseInt64Ptr(limitHeader),
|
||||
Remaining: parseInt64Ptr(remainingHeader),
|
||||
}
|
||||
if reset := parseResetHeader(headers.Get("x-ratelimit-reset-" + dimension)); reset != nil {
|
||||
if reset := parseResetHeader(resetHeader); reset != nil {
|
||||
window.ResetUnix = reset
|
||||
window.ResetAt = time.Unix(*reset, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
@@ -141,11 +189,27 @@ func parseResetHeader(raw string) *int64 {
|
||||
return nil
|
||||
}
|
||||
if value, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
if value > 1_000_000_000_000 {
|
||||
// xAI (and OpenAI-compatible upstreams) may express the reset as a
|
||||
// millisecond epoch, a second epoch, or a *relative* number of seconds
|
||||
// until reset (e.g. "60"). Disambiguate by magnitude, mirroring the
|
||||
// Kiro reset parser, so a relative "60" is not misread as 1970-01-01.
|
||||
switch {
|
||||
case value >= 1_000_000_000_000: // milliseconds epoch → seconds
|
||||
value = value / 1000
|
||||
case value >= 1_000_000_000: // already a plausible unix-seconds epoch (>= 2001-09)
|
||||
// keep as-is
|
||||
default: // relative seconds from now
|
||||
value = time.Now().Unix() + value
|
||||
}
|
||||
return &value
|
||||
}
|
||||
if duration, err := time.ParseDuration(raw); err == nil && duration > 0 {
|
||||
if duration < time.Second {
|
||||
duration = time.Second
|
||||
}
|
||||
value := time.Now().Add(duration).Unix()
|
||||
return &value
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||
value := t.Unix()
|
||||
return &value
|
||||
|
||||
@@ -5,6 +5,7 @@ package xai
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -41,6 +42,104 @@ func TestParseQuotaHeaders(t *testing.T) {
|
||||
require.NotContains(t, snapshot.Headers, "authorization")
|
||||
}
|
||||
|
||||
func TestParseQuotaHeadersAcceptsXAITierAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set("x-xai-user-tier", "supergrok-heavy")
|
||||
headers.Set("x-xai-user-entitlement-status", "enabled")
|
||||
|
||||
snapshot := ParseQuotaHeaders(headers, http.StatusOK)
|
||||
require.NotNil(t, snapshot)
|
||||
require.True(t, snapshot.HeadersObserved)
|
||||
require.Equal(t, "supergrok-heavy", snapshot.SubscriptionTier)
|
||||
require.Equal(t, "enabled", snapshot.EntitlementStatus)
|
||||
require.Equal(t, "supergrok-heavy", snapshot.Headers["x-xai-user-tier"])
|
||||
require.Equal(t, "enabled", snapshot.Headers["x-xai-user-entitlement-status"])
|
||||
}
|
||||
|
||||
func TestParseQuotaHeadersAcceptsRateLimitAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set("x-rate-limit-limit-tokens", "500000")
|
||||
headers.Set("x-rate-limit-remaining-tokens", "100")
|
||||
headers.Set("x-rate-limit-reset-tokens", "1893456000")
|
||||
|
||||
snapshot := ParseQuotaHeaders(headers, http.StatusOK)
|
||||
require.NotNil(t, snapshot)
|
||||
require.NotNil(t, snapshot.Tokens)
|
||||
require.Equal(t, int64(500000), *snapshot.Tokens.Limit)
|
||||
require.Equal(t, int64(100), *snapshot.Tokens.Remaining)
|
||||
require.Equal(t, int64(1893456000), *snapshot.Tokens.ResetUnix)
|
||||
require.Contains(t, snapshot.Headers, "x-rate-limit-limit-tokens")
|
||||
}
|
||||
|
||||
func TestParseResetHeaderRelativeSecondsNotMisreadAsEpoch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
headers := http.Header{}
|
||||
// xAI may return the reset window as a relative number of seconds ("60").
|
||||
// It must resolve to ~now+60s, NOT 1970-01-01 (epoch 60).
|
||||
headers.Set("x-ratelimit-reset-requests", "60")
|
||||
headers.Set("x-ratelimit-remaining-requests", "0")
|
||||
|
||||
before := time.Now().Unix()
|
||||
snapshot := ParseQuotaHeaders(headers, http.StatusTooManyRequests)
|
||||
require.NotNil(t, snapshot)
|
||||
require.NotNil(t, snapshot.Requests)
|
||||
require.NotNil(t, snapshot.Requests.ResetUnix)
|
||||
got := *snapshot.Requests.ResetUnix
|
||||
require.GreaterOrEqual(t, got, before+59)
|
||||
require.LessOrEqual(t, got, time.Now().Unix()+61)
|
||||
}
|
||||
|
||||
func TestParseResetHeaderDurationWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set("x-ratelimit-reset-requests", "6m0s")
|
||||
headers.Set("x-ratelimit-remaining-requests", "0")
|
||||
|
||||
before := time.Now().Unix()
|
||||
snapshot := ParseQuotaHeaders(headers, http.StatusTooManyRequests)
|
||||
require.NotNil(t, snapshot)
|
||||
require.NotNil(t, snapshot.Requests)
|
||||
require.NotNil(t, snapshot.Requests.ResetUnix)
|
||||
got := *snapshot.Requests.ResetUnix
|
||||
require.GreaterOrEqual(t, got, before+359)
|
||||
require.LessOrEqual(t, got, time.Now().Unix()+361)
|
||||
}
|
||||
|
||||
func TestParseResetHeaderSubsecondDurationCeilsToFutureSecond(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set("x-rate-limit-reset-tokens", "250ms")
|
||||
headers.Set("x-rate-limit-remaining-tokens", "0")
|
||||
|
||||
before := time.Now().Unix()
|
||||
snapshot := ParseQuotaHeaders(headers, http.StatusTooManyRequests)
|
||||
require.NotNil(t, snapshot)
|
||||
require.NotNil(t, snapshot.Tokens)
|
||||
require.NotNil(t, snapshot.Tokens.ResetUnix)
|
||||
require.GreaterOrEqual(t, *snapshot.Tokens.ResetUnix, before)
|
||||
require.LessOrEqual(t, *snapshot.Tokens.ResetUnix, time.Now().Unix()+2)
|
||||
}
|
||||
|
||||
func TestParseResetHeaderMillisecondsEpochNormalized(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set("x-ratelimit-reset-tokens", "1893456000000") // ms epoch
|
||||
headers.Set("x-ratelimit-remaining-tokens", "0")
|
||||
|
||||
snapshot := ParseQuotaHeaders(headers, http.StatusTooManyRequests)
|
||||
require.NotNil(t, snapshot)
|
||||
require.NotNil(t, snapshot.Tokens)
|
||||
require.Equal(t, int64(1893456000), *snapshot.Tokens.ResetUnix)
|
||||
}
|
||||
|
||||
func TestParseQuotaHeadersReturnsNilForMissingHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -66,6 +165,8 @@ func TestIsGrokFreeRolling24hTokenLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.True(t, IsGrokFreeRolling24hTokenLimit(GrokFreeRolling24hTokenLimit))
|
||||
require.True(t, IsGrokFreeRolling24hTokenLimit(500_000))
|
||||
require.True(t, IsGrokFreeRolling24hTokenLimit(1_000_000), "observed Free limit variants remain classifiable")
|
||||
require.True(t, IsGrokFreeRolling24hTokenLimit(2_000_000), "legacy snapshots remain classifiable")
|
||||
require.False(t, IsGrokFreeRolling24hTokenLimit(3_000_000))
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -25,6 +26,7 @@ const (
|
||||
SSOConversionTimeout = 90 * time.Second
|
||||
|
||||
ssoMaxAuthBody = 2 << 20
|
||||
ssoMaxTokenLength = 16 << 10
|
||||
ssoDefaultUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
ssoDefaultTokenTTL = 6 * time.Hour
|
||||
)
|
||||
@@ -51,7 +53,7 @@ type SSODeviceOptions struct {
|
||||
type ssoDeviceFlow struct {
|
||||
client SSODeviceHTTPClient
|
||||
userAgent string
|
||||
cookies map[string]string
|
||||
cookieJar http.CookieJar
|
||||
sleep func(context.Context, time.Duration) error
|
||||
}
|
||||
|
||||
@@ -80,11 +82,16 @@ func ConvertSSOToBuild(ctx context.Context, ssoToken string, opts *SSODeviceOpti
|
||||
if sleep == nil {
|
||||
sleep = sleepContext
|
||||
}
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seedSSOCookies(jar, ssoToken)
|
||||
|
||||
flow := &ssoDeviceFlow{
|
||||
client: client,
|
||||
userAgent: userAgent,
|
||||
cookies: map[string]string{"sso": ssoToken, "sso-rw": ssoToken},
|
||||
cookieJar: jar,
|
||||
sleep: sleep,
|
||||
}
|
||||
return flow.convert(ctx)
|
||||
@@ -253,7 +260,7 @@ func (f *ssoDeviceFlow) do(ctx context.Context, method, endpoint string, form ur
|
||||
request.Header.Set("Accept", "application/json, text/html;q=0.9, */*;q=0.8")
|
||||
request.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
||||
request.Header.Set("User-Agent", f.userAgent)
|
||||
if cookie := f.cookieHeader(); cookie != "" {
|
||||
if cookie := f.cookieHeader(request.URL); cookie != "" {
|
||||
request.Header.Set("Cookie", cookie)
|
||||
}
|
||||
if currentForm != nil {
|
||||
@@ -264,7 +271,7 @@ func (f *ssoDeviceFlow) do(ctx context.Context, method, endpoint string, form ur
|
||||
if err != nil {
|
||||
return 0, currentURL, nil, err
|
||||
}
|
||||
f.captureCookies(response)
|
||||
f.captureCookies(request.URL, response)
|
||||
data, readErr := io.ReadAll(io.LimitReader(response.Body, ssoMaxAuthBody+1))
|
||||
_ = response.Body.Close()
|
||||
if readErr != nil {
|
||||
@@ -298,30 +305,49 @@ func (f *ssoDeviceFlow) do(ctx context.Context, method, endpoint string, form ur
|
||||
return 0, currentURL, nil, errors.New("xAI OAuth redirected too many times")
|
||||
}
|
||||
|
||||
func (f *ssoDeviceFlow) captureCookies(response *http.Response) {
|
||||
func seedSSOCookies(jar http.CookieJar, token string) {
|
||||
if jar == nil {
|
||||
return
|
||||
}
|
||||
for _, rawURL := range []string{SSOAccountsURL, OAuthIssuer + "/"} {
|
||||
target, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
jar.SetCookies(target, []*http.Cookie{
|
||||
{Name: "sso", Value: token, Path: "/", Secure: true, HttpOnly: true},
|
||||
{Name: "sso-rw", Value: token, Path: "/", Secure: true, HttpOnly: true},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (f *ssoDeviceFlow) captureCookies(requestURL *url.URL, response *http.Response) {
|
||||
if f == nil || f.cookieJar == nil || requestURL == nil || response == nil {
|
||||
return
|
||||
}
|
||||
cookies := make([]*http.Cookie, 0)
|
||||
for _, cookie := range response.Cookies() {
|
||||
name := strings.TrimSpace(cookie.Name)
|
||||
value := strings.TrimSpace(cookie.Value)
|
||||
if name == "" || len(name) > 128 || len(value) > 16384 || strings.ContainsAny(name+value, "\r\n\x00") {
|
||||
continue
|
||||
}
|
||||
if cookie.MaxAge < 0 {
|
||||
delete(f.cookies, name)
|
||||
continue
|
||||
}
|
||||
f.cookies[name] = value
|
||||
cookie.Name = name
|
||||
cookie.Value = value
|
||||
cookies = append(cookies, cookie)
|
||||
}
|
||||
f.cookieJar.SetCookies(requestURL, cookies)
|
||||
}
|
||||
|
||||
func (f *ssoDeviceFlow) cookieHeader() string {
|
||||
keys := make([]string, 0, len(f.cookies))
|
||||
for key := range f.cookies {
|
||||
keys = append(keys, key)
|
||||
func (f *ssoDeviceFlow) cookieHeader(requestURL *url.URL) string {
|
||||
if f == nil || f.cookieJar == nil || requestURL == nil {
|
||||
return ""
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, key+"="+f.cookies[key])
|
||||
cookies := f.cookieJar.Cookies(requestURL)
|
||||
sort.Slice(cookies, func(i, j int) bool { return cookies[i].Name < cookies[j].Name })
|
||||
parts := make([]string, 0, len(cookies))
|
||||
for _, cookie := range cookies {
|
||||
parts = append(parts, cookie.Name+"="+cookie.Value)
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
@@ -363,7 +389,11 @@ func NormalizeSSOToken(value string) string {
|
||||
}
|
||||
|
||||
func sanitizeSSOToken(value string) string {
|
||||
return strings.NewReplacer("\r", "", "\n", "", "\x00", "").Replace(strings.TrimSpace(value))
|
||||
value = strings.NewReplacer("\r", "", "\n", "", "\x00", "").Replace(strings.TrimSpace(value))
|
||||
if len(value) > ssoMaxTokenLength {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func DecodeJWTClaims(token string) map[string]any {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -25,7 +26,7 @@ func (c *ssoDeviceFakeClient) Do(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.String() {
|
||||
case SSOAccountsURL:
|
||||
require.Equal(c.t, http.MethodGet, req.Method)
|
||||
return ssoDeviceResponse(http.StatusOK, http.Header{"Set-Cookie": {"session=web-session; Path=/"}}, `{}`), nil
|
||||
return ssoDeviceResponse(http.StatusOK, http.Header{"Set-Cookie": {"session=web-session; Domain=x.ai; Path=/"}}, `{}`), nil
|
||||
case SSODeviceURL:
|
||||
require.Equal(c.t, http.MethodPost, req.Method)
|
||||
values := readSSODeviceForm(c.t, req)
|
||||
@@ -92,6 +93,33 @@ func TestNormalizeSSOTokenAcceptsCookieHeader(t *testing.T) {
|
||||
require.Equal(t, "token-1", NormalizeSSOToken("Cookie: foo=bar; sso=token-1; sso-rw=token-2"))
|
||||
require.Equal(t, "token-2", NormalizeSSOToken("sso-rw=token-2; foo=bar"))
|
||||
require.Equal(t, "raw-token", NormalizeSSOToken(" raw-token ; ignored=1"))
|
||||
require.Empty(t, NormalizeSSOToken(strings.Repeat("x", ssoMaxTokenLength+1)))
|
||||
}
|
||||
|
||||
func TestSSODeviceCookieJarHonorsDomainAndPath(t *testing.T) {
|
||||
jar, err := cookiejar.New(nil)
|
||||
require.NoError(t, err)
|
||||
flow := &ssoDeviceFlow{cookieJar: jar}
|
||||
accountsURL, err := url.Parse("https://accounts.x.ai/")
|
||||
require.NoError(t, err)
|
||||
authURL, err := url.Parse("https://auth.x.ai/oauth2/device/verify")
|
||||
require.NoError(t, err)
|
||||
|
||||
flow.captureCookies(accountsURL, ssoDeviceResponse(http.StatusOK, http.Header{"Set-Cookie": {
|
||||
"host-only=accounts; Path=/",
|
||||
"shared=all-xai; Domain=x.ai; Path=/",
|
||||
"narrow=oauth-only; Domain=x.ai; Path=/oauth2",
|
||||
}}, ""))
|
||||
|
||||
authCookies := flow.cookieHeader(authURL)
|
||||
require.NotContains(t, authCookies, "host-only=accounts")
|
||||
require.Contains(t, authCookies, "shared=all-xai")
|
||||
require.Contains(t, authCookies, "narrow=oauth-only")
|
||||
|
||||
accountsCookies := flow.cookieHeader(accountsURL)
|
||||
require.Contains(t, accountsCookies, "host-only=accounts")
|
||||
require.Contains(t, accountsCookies, "shared=all-xai")
|
||||
require.NotContains(t, accountsCookies, "narrow=oauth-only")
|
||||
}
|
||||
|
||||
func ssoDeviceResponse(status int, header http.Header, body string) *http.Response {
|
||||
|
||||
@@ -190,7 +190,12 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
|
||||
group.FieldVideoPrice480p,
|
||||
group.FieldVideoPrice720p,
|
||||
group.FieldVideoPrice1080p,
|
||||
group.FieldVideoModelPrices,
|
||||
group.FieldWebSearchPricePerCall,
|
||||
group.FieldSearchPricePer1k,
|
||||
group.FieldAudioRealtimePricePerMin,
|
||||
group.FieldAudioTtsPricePerMillionChars,
|
||||
group.FieldAudioSttPricePerHour,
|
||||
group.FieldClaudeCodeOnly,
|
||||
group.FieldFallbackGroupID,
|
||||
group.FieldFallbackGroupIDOnInvalidRequest,
|
||||
@@ -969,7 +974,12 @@ func groupEntityToService(g *dbent.Group) *service.Group {
|
||||
VideoPrice480P: g.VideoPrice480p,
|
||||
VideoPrice720P: g.VideoPrice720p,
|
||||
VideoPrice1080P: g.VideoPrice1080p,
|
||||
VideoModelPrices: service.NormalizeVideoModelPrices(g.VideoModelPrices),
|
||||
WebSearchPricePerCall: g.WebSearchPricePerCall,
|
||||
SearchPricePer1k: g.SearchPricePer1k,
|
||||
AudioRealtimePricePerMin: g.AudioRealtimePricePerMin,
|
||||
AudioTTSPricePerMillionChars: g.AudioTtsPricePerMillionChars,
|
||||
AudioSTTPricePerHour: g.AudioSttPricePerHour,
|
||||
DefaultValidityDays: g.DefaultValidityDays,
|
||||
ClaudeCodeOnly: g.ClaudeCodeOnly,
|
||||
FallbackGroupID: g.FallbackGroupID,
|
||||
|
||||
@@ -19,6 +19,9 @@ func TestGroupEntityToService_PreservesMessagesDispatchModelConfig(t *testing.T)
|
||||
RateMultiplier: 1,
|
||||
AllowMessagesDispatch: true,
|
||||
DefaultMappedModel: "gpt-5.4",
|
||||
VideoModelPrices: map[string]map[string]float64{
|
||||
service.VideoPriceFamilyGrokImagineVideo15: {service.VideoBillingResolution720P: 0.14},
|
||||
},
|
||||
MessagesDispatchModelConfig: service.OpenAIMessagesDispatchModelConfig{
|
||||
OpusMappedModel: "gpt-5.4-nano",
|
||||
SonnetMappedModel: "gpt-5.3-codex",
|
||||
@@ -32,6 +35,7 @@ func TestGroupEntityToService_PreservesMessagesDispatchModelConfig(t *testing.T)
|
||||
got := groupEntityToService(group)
|
||||
require.NotNil(t, got)
|
||||
require.Equal(t, group.MessagesDispatchModelConfig, got.MessagesDispatchModelConfig)
|
||||
require.Equal(t, group.VideoModelPrices, got.VideoModelPrices)
|
||||
}
|
||||
|
||||
func TestAPIKeyRepository_GetByKeyForAuth_PreservesMessagesDispatchModelConfig_SQLite(t *testing.T) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -64,6 +65,68 @@ func (c *gatewayCache) DeleteSessionAccountID(ctx context.Context, groupID int64
|
||||
return c.rdb.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
const (
|
||||
grokVideoPendingBillingPrefix = "grok_video_pending:"
|
||||
grokVideoBilledPrefix = "grok_video_billed:"
|
||||
)
|
||||
|
||||
func (c *gatewayCache) SetGrokVideoPendingBilling(ctx context.Context, key string, payload []byte, ttl time.Duration) error {
|
||||
if c == nil || c.rdb == nil {
|
||||
return errors.New("gateway cache unavailable")
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" || len(payload) == 0 {
|
||||
return errors.New("invalid grok video pending billing payload")
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = 24 * time.Hour
|
||||
}
|
||||
return c.rdb.Set(ctx, grokVideoPendingBillingPrefix+key, payload, ttl).Err()
|
||||
}
|
||||
|
||||
func (c *gatewayCache) GetGrokVideoPendingBilling(ctx context.Context, key string) ([]byte, error) {
|
||||
if c == nil || c.rdb == nil {
|
||||
return nil, errors.New("gateway cache unavailable")
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil, errors.New("invalid grok video pending billing key")
|
||||
}
|
||||
val, err := c.rdb.Get(ctx, grokVideoPendingBillingPrefix+key).Bytes()
|
||||
if err != nil {
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (c *gatewayCache) ClaimGrokVideoBilled(ctx context.Context, key string, ttl time.Duration) (bool, error) {
|
||||
if c == nil || c.rdb == nil {
|
||||
return false, errors.New("gateway cache unavailable")
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return false, errors.New("invalid grok video billed key")
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = 48 * time.Hour
|
||||
}
|
||||
return c.rdb.SetNX(ctx, grokVideoBilledPrefix+key, "1", ttl).Result()
|
||||
}
|
||||
|
||||
func (c *gatewayCache) ReleaseGrokVideoBilled(ctx context.Context, key string) error {
|
||||
if c == nil || c.rdb == nil {
|
||||
return errors.New("gateway cache unavailable")
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return errors.New("invalid grok video billed key")
|
||||
}
|
||||
return c.rdb.Del(ctx, grokVideoBilledPrefix+key).Err()
|
||||
}
|
||||
|
||||
// Compile-time assertion: gatewayCache must implement CyberSessionBlockStore.
|
||||
var _ service.CyberSessionBlockStore = (*gatewayCache)(nil)
|
||||
var _ service.LiveCallStore = (*gatewayCache)(nil)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -20,8 +25,24 @@ type grokOAuthClient struct {
|
||||
tokenURL string
|
||||
}
|
||||
|
||||
const (
|
||||
accountsBaseURL = "https://accounts.x.ai"
|
||||
loginRPCEndpoint = accountsBaseURL + "/api/rpc"
|
||||
turnstileWebsiteURL = accountsBaseURL
|
||||
turnstileWebsiteKey = "0x4AAAAAAAhr9JGVDZbrZOo0"
|
||||
yesCaptchaCreateTask = "https://api.yescaptcha.com/createTask"
|
||||
yesCaptchaGetResult = "https://api.yescaptcha.com/getTaskResult"
|
||||
)
|
||||
|
||||
func NewGrokOAuthClient() service.GrokOAuthClient {
|
||||
return &grokOAuthClient{tokenURL: xai.EffectiveTokenURL()}
|
||||
// Fail closed: never fall back to an unvalidated EffectiveTokenURL (env can
|
||||
// point at an attacker host and steal code/refresh tokens).
|
||||
tokenURL, err := xai.ValidatedTokenURL()
|
||||
if err != nil || strings.TrimSpace(tokenURL) == "" {
|
||||
// Official allowlisted endpoint only — never EffectiveTokenURL() (raw env).
|
||||
tokenURL = xai.DefaultTokenURL
|
||||
}
|
||||
return &grokOAuthClient{tokenURL: tokenURL}
|
||||
}
|
||||
|
||||
func (c *grokOAuthClient) ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI, proxyURL, clientID string) (*xai.TokenResponse, error) {
|
||||
@@ -90,6 +111,31 @@ func (c *grokOAuthClient) RefreshToken(ctx context.Context, refreshToken, proxyU
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
// LoginWithPassword authenticates against accounts.x.ai and returns an ephemeral SSO cookie.
|
||||
// Password and SSO must never be written to account credentials or logs.
|
||||
func (c *grokOAuthClient) LoginWithPassword(ctx context.Context, email, password, proxyURL string) (*service.GrokPasswordLoginResult, error) {
|
||||
turnstileToken, err := solveTurnstile(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient, err := createGrokHTTPClient(proxyURL, true)
|
||||
if err != nil {
|
||||
return nil, infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CLIENT_INIT_FAILED", "create HTTP client: %v", err)
|
||||
}
|
||||
cookieSetterURL, err := createGrokPasswordSession(ctx, httpClient, strings.TrimSpace(email), password, turnstileToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ssoToken, err := extractGrokSSOToken(ctx, httpClient, cookieSetterURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &service.GrokPasswordLoginResult{
|
||||
Email: strings.TrimSpace(email),
|
||||
SSOToken: ssoToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *grokOAuthClient) ConvertSSOToBuild(ctx context.Context, ssoToken, proxyURL string) (*xai.TokenResponse, error) {
|
||||
client, err := createGrokSSOHTTPClient(proxyURL)
|
||||
if err != nil {
|
||||
@@ -167,6 +213,20 @@ func grokOAuthStatusError(code, message string, resp *req.Response) error {
|
||||
|
||||
func grokOAuthHasExplicitEntitlementDenial(body string) bool {
|
||||
lower := strings.ToLower(body)
|
||||
// Billing exhaustion is recoverable. xAI may include a generic
|
||||
// access_denied code alongside the quota message, so it must win over the
|
||||
// entitlement marker during token refresh.
|
||||
for _, phrase := range []string{
|
||||
"spending limit",
|
||||
"run out of credits",
|
||||
"out of credits",
|
||||
"credits exhausted",
|
||||
"included free usage",
|
||||
} {
|
||||
if strings.Contains(lower, phrase) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
compact := strings.NewReplacer(" ", "", "\n", "", "\r", "", "\t", "").Replace(lower)
|
||||
for _, field := range []string{"error", "code", "reason"} {
|
||||
for _, value := range []string{"access_denied", "entitlement_denied", "subscription_required", "no_active_subscription"} {
|
||||
@@ -179,3 +239,199 @@ func grokOAuthHasExplicitEntitlementDenial(body string) bool {
|
||||
strings.Contains(lower, "subscription required") ||
|
||||
strings.Contains(lower, "no active grok subscription")
|
||||
}
|
||||
|
||||
func createGrokHTTPClient(proxyURL string, noRedirect bool) (*http.Client, error) {
|
||||
transport := &http.Transport{}
|
||||
if strings.TrimSpace(proxyURL) != "" {
|
||||
parsed, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport.Proxy = http.ProxyURL(parsed)
|
||||
}
|
||||
client := &http.Client{Timeout: 120 * time.Second, Transport: transport}
|
||||
if noRedirect {
|
||||
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func solveTurnstile(ctx context.Context) (string, error) {
|
||||
clientKey := strings.TrimSpace(os.Getenv("YESCAPTCHA_CLIENT_KEY"))
|
||||
if clientKey == "" {
|
||||
clientKey = strings.TrimSpace(os.Getenv("YESCAPTCHA_API_KEY"))
|
||||
}
|
||||
if clientKey == "" {
|
||||
return "", infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_CAPTCHA_KEY_REQUIRED", "yescaptcha client key is required for Grok password authorization")
|
||||
}
|
||||
createBody, err := json.Marshal(map[string]any{
|
||||
"clientKey": clientKey,
|
||||
"task": map[string]any{
|
||||
"type": "TurnstileTaskProxyless",
|
||||
"websiteURL": turnstileWebsiteURL,
|
||||
"websiteKey": turnstileWebsiteKey,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_CAPTCHA_FAILED", "encode captcha create request failed: %v", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, yesCaptchaCreateTask, bytes.NewReader(createBody))
|
||||
if err != nil {
|
||||
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "build captcha create request failed: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "create captcha task failed: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var createResp struct {
|
||||
ErrorID int `json:"errorId"`
|
||||
TaskID string `json:"taskId"`
|
||||
ErrorDescription string `json:"errorDescription"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&createResp); err != nil {
|
||||
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "decode captcha create response failed: %v", err)
|
||||
}
|
||||
if createResp.ErrorID != 0 || strings.TrimSpace(createResp.TaskID) == "" {
|
||||
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "captcha create failed: %s", createResp.ErrorDescription)
|
||||
}
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"clientKey": clientKey, "taskId": createResp.TaskID})
|
||||
if err != nil {
|
||||
return "", infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_CAPTCHA_FAILED", "encode captcha poll request failed: %v", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, yesCaptchaGetResult, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var pollResp struct {
|
||||
ErrorID int `json:"errorId"`
|
||||
Status string `json:"status"`
|
||||
ErrorDescription string `json:"errorDescription"`
|
||||
Solution struct {
|
||||
Token string `json:"token"`
|
||||
} `json:"solution"`
|
||||
}
|
||||
err = json.NewDecoder(resp.Body).Decode(&pollResp)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if pollResp.ErrorID != 0 {
|
||||
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "captcha poll failed: %s", pollResp.ErrorDescription)
|
||||
}
|
||||
if pollResp.Status == "ready" && strings.TrimSpace(pollResp.Solution.Token) != "" {
|
||||
return pollResp.Solution.Token, nil
|
||||
}
|
||||
}
|
||||
return "", infraerrors.New(http.StatusGatewayTimeout, "GROK_OAUTH_CAPTCHA_TIMEOUT", "captcha solve timed out")
|
||||
}
|
||||
|
||||
func createGrokPasswordSession(ctx context.Context, client *http.Client, email, password, turnstileToken string) (string, error) {
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"rpc": "createSession",
|
||||
"req": map[string]any{
|
||||
"createSessionRequest": map[string]any{
|
||||
"credentials": map[string]any{
|
||||
"case": "emailAndPassword",
|
||||
"value": map[string]any{
|
||||
"email": email,
|
||||
"clearTextPassword": password,
|
||||
},
|
||||
},
|
||||
},
|
||||
"turnstileToken": turnstileToken,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "encode password login request failed: %v", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, loginRPCEndpoint, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "build password login request failed: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Origin", accountsBaseURL)
|
||||
req.Header.Set("Referer", accountsBaseURL+"/sign-in?redirect=grok-com&email=true")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login request failed: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login returned status %d: %s", resp.StatusCode, logredact.RedactText(string(body)))
|
||||
}
|
||||
var loginResp struct {
|
||||
CookieSetterURL string `json:"cookieSetterUrl"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &loginResp); err != nil {
|
||||
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "decode password login response failed: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(loginResp.Error) != "" {
|
||||
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login error: %s", logredact.RedactText(loginResp.Error))
|
||||
}
|
||||
if strings.TrimSpace(loginResp.CookieSetterURL) == "" {
|
||||
return "", infraerrors.New(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login did not return cookieSetterUrl")
|
||||
}
|
||||
return loginResp.CookieSetterURL, nil
|
||||
}
|
||||
|
||||
func extractGrokSSOToken(ctx context.Context, client *http.Client, cookieSetterURL string) (string, error) {
|
||||
safeURL, err := validateGrokCookieSetterURL(cookieSetterURL)
|
||||
if err != nil {
|
||||
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "invalid cookie setter url: %v", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, safeURL.String(), nil)
|
||||
if err != nil {
|
||||
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "build cookie setter request: %v", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
req.Header.Set("Referer", accountsBaseURL+"/")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "follow cookie setter url failed: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
for _, cookie := range resp.Header.Values("Set-Cookie") {
|
||||
if token, ok := strings.CutPrefix(cookie, "sso="); ok {
|
||||
if idx := strings.Index(token, ";"); idx > 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
return strings.TrimSpace(token), nil
|
||||
}
|
||||
}
|
||||
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "no sso cookie found in response (status=%d)", resp.StatusCode)
|
||||
}
|
||||
|
||||
func validateGrokCookieSetterURL(rawURL string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parsed.Scheme != "https" || !strings.EqualFold(parsed.Hostname(), "accounts.x.ai") {
|
||||
return nil, fmt.Errorf("url must use https://accounts.x.ai")
|
||||
}
|
||||
if parsed.User != nil || parsed.Port() != "" || parsed.Fragment != "" || parsed.Opaque != "" {
|
||||
return nil, fmt.Errorf("url contains disallowed authority or fragment components")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ func TestGrokOAuthClientExchangeAndRefreshUseFormFields(t *testing.T) {
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
// Tests inject a loopback token endpoint; allowlist requires unsafe override.
|
||||
t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true")
|
||||
t.Setenv(xai.EnvTokenURL, server.URL)
|
||||
|
||||
client := NewGrokOAuthClient()
|
||||
@@ -80,6 +82,7 @@ func TestGrokOAuthClientRefreshForbiddenClassifiesOnlyExplicitEntitlement(t *tes
|
||||
_, _ = w.Write([]byte(tt.body))
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true")
|
||||
t.Setenv(xai.EnvTokenURL, server.URL)
|
||||
|
||||
client := NewGrokOAuthClient()
|
||||
@@ -96,6 +99,7 @@ func TestGrokOAuthClientStatusErrorRedactsSensitiveResponseBody(t *testing.T) {
|
||||
_, _ = w.Write([]byte(`{"error":"invalid_grant","access_token":"access-secret","refresh_token":"refresh-secret","code_verifier":"verifier-secret"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true")
|
||||
t.Setenv(xai.EnvTokenURL, server.URL)
|
||||
|
||||
client := NewGrokOAuthClient()
|
||||
@@ -118,4 +122,15 @@ func TestGrokOAuthEntitlementDenialRequiresExplicitEvidence(t *testing.T) {
|
||||
require.True(t, grokOAuthHasExplicitEntitlementDenial(`{"message":"no active Grok subscription"}`))
|
||||
require.False(t, grokOAuthHasExplicitEntitlementDenial(`{"error":"forbidden","message":"request forbidden"}`))
|
||||
require.False(t, grokOAuthHasExplicitEntitlementDenial(`<html>403 Forbidden</html>`))
|
||||
require.False(t, grokOAuthHasExplicitEntitlementDenial(`{"error":"access_denied","message":"You have run out of credits"}`))
|
||||
require.False(t, grokOAuthHasExplicitEntitlementDenial(`{"code":"subscription_required","message":"included free usage exhausted"}`))
|
||||
}
|
||||
|
||||
func TestNewGrokOAuthClient_UnvalidatedTokenURLFallsBackToDefault(t *testing.T) {
|
||||
// Without unsafe overrides, a random env TokenURL must not be used (fail-closed).
|
||||
t.Setenv(xai.EnvAllowUnsafeURLOverrides, "")
|
||||
t.Setenv(xai.EnvTokenURL, "https://evil.example/oauth/token")
|
||||
|
||||
client := NewGrokOAuthClient().(*grokOAuthClient)
|
||||
require.Equal(t, xai.DefaultTokenURL, client.tokenURL)
|
||||
}
|
||||
|
||||
@@ -82,7 +82,12 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi
|
||||
SetNillableVideoPrice480p(groupIn.VideoPrice480P).
|
||||
SetNillableVideoPrice720p(groupIn.VideoPrice720P).
|
||||
SetNillableVideoPrice1080p(groupIn.VideoPrice1080P).
|
||||
SetVideoModelPrices(service.NormalizeVideoModelPrices(groupIn.VideoModelPrices)).
|
||||
SetNillableWebSearchPricePerCall(groupIn.WebSearchPricePerCall).
|
||||
SetNillableSearchPricePer1k(groupIn.SearchPricePer1k).
|
||||
SetNillableAudioRealtimePricePerMin(groupIn.AudioRealtimePricePerMin).
|
||||
SetNillableAudioTtsPricePerMillionChars(groupIn.AudioTTSPricePerMillionChars).
|
||||
SetNillableAudioSttPricePerHour(groupIn.AudioSTTPricePerHour).
|
||||
SetDefaultValidityDays(groupIn.DefaultValidityDays).
|
||||
SetClaudeCodeOnly(groupIn.ClaudeCodeOnly).
|
||||
SetNillableFallbackGroupID(groupIn.FallbackGroupID).
|
||||
@@ -254,6 +259,7 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
|
||||
SetNillableVideoPrice480p(groupIn.VideoPrice480P).
|
||||
SetNillableVideoPrice720p(groupIn.VideoPrice720P).
|
||||
SetNillableVideoPrice1080p(groupIn.VideoPrice1080P).
|
||||
SetVideoModelPrices(service.NormalizeVideoModelPrices(groupIn.VideoModelPrices)).
|
||||
SetDefaultValidityDays(groupIn.DefaultValidityDays).
|
||||
SetClaudeCodeOnly(groupIn.ClaudeCodeOnly).
|
||||
SetModelRoutingEnabled(groupIn.ModelRoutingEnabled).
|
||||
@@ -327,6 +333,26 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
|
||||
} else {
|
||||
builder = builder.ClearWebSearchPricePerCall()
|
||||
}
|
||||
if groupIn.SearchPricePer1k != nil {
|
||||
builder = builder.SetSearchPricePer1k(*groupIn.SearchPricePer1k)
|
||||
} else {
|
||||
builder = builder.ClearSearchPricePer1k()
|
||||
}
|
||||
if groupIn.AudioRealtimePricePerMin != nil {
|
||||
builder = builder.SetAudioRealtimePricePerMin(*groupIn.AudioRealtimePricePerMin)
|
||||
} else {
|
||||
builder = builder.ClearAudioRealtimePricePerMin()
|
||||
}
|
||||
if groupIn.AudioTTSPricePerMillionChars != nil {
|
||||
builder = builder.SetAudioTtsPricePerMillionChars(*groupIn.AudioTTSPricePerMillionChars)
|
||||
} else {
|
||||
builder = builder.ClearAudioTtsPricePerMillionChars()
|
||||
}
|
||||
if groupIn.AudioSTTPricePerHour != nil {
|
||||
builder = builder.SetAudioSttPricePerHour(*groupIn.AudioSTTPricePerHour)
|
||||
} else {
|
||||
builder = builder.ClearAudioSttPricePerHour()
|
||||
}
|
||||
|
||||
// 处理 FallbackGroupID:nil 时清除,否则设置
|
||||
if groupIn.FallbackGroupID != nil {
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"golang.org/x/mod/semver"
|
||||
"golang.org/x/net/http2"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
@@ -33,7 +34,6 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// 默认配置常量
|
||||
@@ -55,6 +55,18 @@ const (
|
||||
// defaultResponseHeaderTimeout: 默认等待响应头超时时间(5分钟)
|
||||
// LLM 请求可能排队较久,需要较长超时
|
||||
defaultResponseHeaderTimeout = 300 * time.Second
|
||||
// defaultUpstreamDialTimeout: 默认 TCP/DNS 建连超时(10秒)
|
||||
// Transport 不设置 DialContext 时会退化为零值 net.Dialer(无超时),建连阶段
|
||||
// 只能依赖内核默认 TCP 重传(Linux 约 130 秒)。ResponseHeaderTimeout 只约束
|
||||
// 连接建立之后等待响应头的阶段,覆盖不到 DNS 解析与 TCP 握手。
|
||||
// 上游域名被解析到 443 不可达的 IP 时(DNS 污染/路由异常),单个账号就要卡满
|
||||
// 内核超时;而多账号故障转移是串行的,一次请求会阻塞数分钟且不写中间错误。
|
||||
defaultUpstreamDialTimeout = 10 * time.Second
|
||||
// defaultUpstreamDialKeepAlive: TCP keepalive 探测间隔,与 Go 默认值保持一致
|
||||
defaultUpstreamDialKeepAlive = 30 * time.Second
|
||||
// defaultUpstreamTLSHandshakeTimeout: TLS 握手超时(10秒)
|
||||
// 与建连超时同量级,避免 TCP 已连通但对端不推进握手时无限等待
|
||||
defaultUpstreamTLSHandshakeTimeout = 10 * time.Second
|
||||
// defaultMaxUpstreamClients: 默认最大客户端缓存数量
|
||||
// 超出后会淘汰最久未使用的客户端
|
||||
defaultMaxUpstreamClients = 5000
|
||||
@@ -73,12 +85,12 @@ const (
|
||||
openAIHTTP2PingTimeout = 15 * time.Second
|
||||
|
||||
// The Grok CLI proxy rejects requests that do not identify a supported
|
||||
// client version. Keep a known-good stable version in the binary while
|
||||
// allowing operators to bump it without waiting for a Sub2API release.
|
||||
grokCLIProxyHost = "cli-chat-proxy.grok.com"
|
||||
// client version. Host/env/version pins live in package xai so service,
|
||||
// billing, and transport layers advertise the same identity.
|
||||
grokCLIProxyHost = xai.CLIProxyHost
|
||||
grokOfficialAPIHost = "api.x.ai"
|
||||
grokCLIStableVersion = xai.CLIClientVersion
|
||||
grokCLIVersionOverride = "XAI_GROK_CLI_VERSION"
|
||||
grokCLIStableVersion = xai.CLIClientVersion // preferred pin (not the minimum floor)
|
||||
grokCLIVersionOverride = xai.CLIVersionEnv
|
||||
grokFallbackBodyLimit = 64 << 10
|
||||
)
|
||||
|
||||
@@ -438,6 +450,11 @@ type prefixedReadCloser struct {
|
||||
// the final shared transport boundary. Keying this behavior to the exact CLI
|
||||
// proxy host keeps direct api.x.ai traffic unchanged and automatically covers
|
||||
// Responses, Chat Completions, media, quota probes, and account tests.
|
||||
//
|
||||
// Operator overrides must be >= CLIClientVersion (the preferred pin). Package
|
||||
// xai.IsSupportedCLIVersion uses a lower floor (CLIStableVersion) for general
|
||||
// validation; transport is stricter so we never silently advertise an older pin
|
||||
// than the binary default.
|
||||
func applyGrokCLIProxyHeaders(req *http.Request) {
|
||||
if req == nil || req.URL == nil || !strings.EqualFold(strings.TrimSpace(req.URL.Hostname()), grokCLIProxyHost) {
|
||||
return
|
||||
@@ -449,14 +466,15 @@ func applyGrokCLIProxyHeaders(req *http.Request) {
|
||||
if !isSupportedGrokCLIVersion(version) {
|
||||
version = grokCLIStableVersion
|
||||
}
|
||||
req.Header.Set("X-XAI-Token-Auth", "xai-grok-cli")
|
||||
req.Header.Set("X-XAI-Token-Auth", xai.CLITokenAuth)
|
||||
req.Header.Set("x-grok-client-version", version)
|
||||
req.Header.Set("User-Agent", "xai-grok-workspace/"+version)
|
||||
req.Header.Set("x-grok-client-identifier", xai.CLIClientIdentifier)
|
||||
req.Header.Set("User-Agent", xai.CLIUserAgent(version))
|
||||
}
|
||||
|
||||
func isSupportedGrokCLIVersion(version string) bool {
|
||||
canonical := "v" + version
|
||||
minimum := "v" + grokCLIStableVersion
|
||||
minimum := "v" + xai.CLIClientVersion
|
||||
return semver.IsValid(canonical) &&
|
||||
semver.Canonical(canonical) == canonical &&
|
||||
semver.Compare(canonical, minimum) >= 0
|
||||
@@ -1246,6 +1264,17 @@ func defaultPoolSettings(cfg *config.Config) poolSettings {
|
||||
}
|
||||
}
|
||||
|
||||
// newUpstreamDialer 构建上游 Transport 的 TCP dialer。
|
||||
//
|
||||
// 必须显式提供:http.Transport 的 DialContext 为 nil 时使用零值 net.Dialer,
|
||||
// 建连没有任何超时上限,只能等内核 TCP 重传耗尽(Linux 约 130 秒)。
|
||||
func newUpstreamDialer() *net.Dialer {
|
||||
return &net.Dialer{
|
||||
Timeout: defaultUpstreamDialTimeout,
|
||||
KeepAlive: defaultUpstreamDialKeepAlive,
|
||||
}
|
||||
}
|
||||
|
||||
// buildUpstreamTransport 构建上游请求的 Transport
|
||||
// 使用配置文件中的连接池参数,支持生产环境调优
|
||||
//
|
||||
@@ -1258,6 +1287,8 @@ func defaultPoolSettings(cfg *config.Config) poolSettings {
|
||||
// - error: 代理配置错误
|
||||
//
|
||||
// Transport 参数说明:
|
||||
// - DialContext: DNS 解析 + TCP 建连超时(不设置则无上限,退化为内核默认重传)
|
||||
// - TLSHandshakeTimeout: TLS 握手超时
|
||||
// - MaxIdleConns: 所有主机的最大空闲连接总数
|
||||
// - MaxIdleConnsPerHost: 每主机最大空闲连接数(影响连接复用率)
|
||||
// - MaxConnsPerHost: 每主机最大连接数(达到后新请求等待)
|
||||
@@ -1265,6 +1296,8 @@ func defaultPoolSettings(cfg *config.Config) poolSettings {
|
||||
// - ResponseHeaderTimeout: 等待响应头超时(不影响流式传输)
|
||||
func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL, protocolMode string) (*http.Transport, error) {
|
||||
transport := &http.Transport{
|
||||
DialContext: newUpstreamDialer().DialContext,
|
||||
TLSHandshakeTimeout: defaultUpstreamTLSHandshakeTimeout,
|
||||
MaxIdleConns: settings.maxIdleConns,
|
||||
MaxIdleConnsPerHost: settings.maxIdleConnsPerHost,
|
||||
MaxConnsPerHost: settings.maxConnsPerHost,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 回归:上游 Transport 必须显式配置建连超时。
|
||||
//
|
||||
// http.Transport.DialContext 为 nil 时 Go 使用零值 net.Dialer(Timeout=0),
|
||||
// DNS 解析与 TCP 握手没有任何上限,只能等内核重传耗尽(Linux 约 130 秒)。
|
||||
// ResponseHeaderTimeout 只覆盖连接建立之后的阶段,管不到建连。
|
||||
// 上游域名被解析到不可达 IP 时,串行的多账号故障转移会把一次请求拖到数分钟。
|
||||
func TestBuildUpstreamTransportSetsDialTimeout(t *testing.T) {
|
||||
settings := defaultPoolSettings(nil)
|
||||
|
||||
transport, err := buildUpstreamTransport(settings, nil, upstreamProtocolModeDefault)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, transport.DialContext, "DialContext 缺失会退化为无超时的零值 dialer")
|
||||
require.Equal(t, defaultUpstreamTLSHandshakeTimeout, transport.TLSHandshakeTimeout)
|
||||
}
|
||||
|
||||
func TestNewUpstreamDialerHasBoundedTimeout(t *testing.T) {
|
||||
dialer := newUpstreamDialer()
|
||||
|
||||
require.Greater(t, dialer.Timeout, time.Duration(0), "建连超时必须有上限")
|
||||
require.Equal(t, defaultUpstreamDialTimeout, dialer.Timeout)
|
||||
require.Equal(t, defaultUpstreamDialKeepAlive, dialer.KeepAlive)
|
||||
}
|
||||
|
||||
// 建连超时对 HTTP 代理同样生效:Transport.Proxy 走的仍是 DialContext,
|
||||
// 代理地址不可达时必须快速失败而不是挂满内核超时。
|
||||
func TestBuildUpstreamTransportKeepsDialTimeoutWithHTTPProxy(t *testing.T) {
|
||||
proxyURL, err := url.Parse("http://127.0.0.1:1080")
|
||||
require.NoError(t, err)
|
||||
|
||||
transport, err := buildUpstreamTransport(defaultPoolSettings(nil), proxyURL, upstreamProtocolModeDefault)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, transport.Proxy)
|
||||
require.NotNil(t, transport.DialContext)
|
||||
}
|
||||
|
||||
// SOCKS5 分支会覆盖 Transport.DialContext,覆盖后仍必须是有超时的拨号器。
|
||||
func TestBuildUpstreamTransportKeepsDialContextWithSOCKS5Proxy(t *testing.T) {
|
||||
proxyURL, err := url.Parse("socks5h://127.0.0.1:1080")
|
||||
require.NoError(t, err)
|
||||
|
||||
transport, err := buildUpstreamTransport(defaultPoolSettings(nil), proxyURL, upstreamProtocolModeDefault)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, transport.DialContext)
|
||||
}
|
||||
|
||||
// Timeout 字段确实被 net.Dialer 用于建连:拨一个已被 close 的本地监听端口,
|
||||
// 断言 Dialer 走的是自己的超时路径而不是无限等待。
|
||||
// (不依赖外网可达性,CI 中确定性执行。)
|
||||
func TestUpstreamDialerRespectsContextCancellation(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
addr := listener.Addr().String()
|
||||
require.NoError(t, listener.Close())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
conn, err := newUpstreamDialer().DialContext(ctx, "tcp", addr)
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
require.Error(t, err, "已取消的 context 必须立即中止拨号")
|
||||
}
|
||||
@@ -57,6 +57,8 @@ const schedulerOutboxPendingDedupKeyMigration = "153_scheduler_outbox_pending_de
|
||||
const schedulerOutboxPendingDedupKeyIndex = "idx_scheduler_outbox_pending_dedup_key"
|
||||
const latestAPIKeyIPIndexMigration = "174_add_usage_logs_api_key_latest_ip_index_notx.sql"
|
||||
const latestAPIKeyIPIndex = "idx_usage_logs_api_key_latest_ip"
|
||||
const usageLogsUpstreamModelMismatchIndexMigration = "195_add_usage_log_upstream_model_mismatch_index_notx.sql"
|
||||
const usageLogsUpstreamModelMismatchIndex = "idx_usage_logs_upstream_model_mismatch_created_at"
|
||||
|
||||
type migrationChecksumCompatibilityRule struct {
|
||||
fileChecksum string
|
||||
@@ -84,6 +86,11 @@ var migrationChecksumCompatibilityRules = map[string]migrationChecksumCompatibil
|
||||
// 195 originally seeded mode=v2; flipped to v1 (safe default / opt-in v2). Existing DBs
|
||||
// that already applied the v2 seed keep their row and the historical checksum.
|
||||
"195_channel_monitor_mode.sql": newMigrationChecksumCompatibilityRule("13f3792f3e3e53ee96e26415c884cf8062c77172824b54fcc9a8c0c2b1f185ec", "4c74fe33ef2274cc72e1bb49671e651274532c034b29f5b2982c2a4c88d101a6"),
|
||||
// 220 originally cleared video prices for all non-grok platforms (including composite);
|
||||
// composite is now preserved because it may route to Grok accounts.
|
||||
"220_clear_non_grok_video_generation_config.sql": newMigrationChecksumCompatibilityRule("85e320b9ec64f2d3fcd8cf705b2b4e76a7b49f7a57140c14bff97f32691c818b", "3da48c8fdffe6390325f43d08b8e353e0a365df43d44a78dbbe655d0deb18402"),
|
||||
"219_group_search_price_per_1k.sql": newMigrationChecksumCompatibilityRule("e86786ebcc3b14206fd2d321380a4e50e80cdadbfcf4962c639255e6a14008db", "df6ffd71b97e30ec2c8fe7b95e15783042dea58c553e32701ee7c42a5619af80"),
|
||||
"218_group_audio_voice_pricing.sql": newMigrationChecksumCompatibilityRule("40ee9f3a2af0e0a5e99dabc878fd0fe98be1011f26bcfcefcac7197f7081f0e7", "c2a5e5b4ffd6968ad1c10593289fbc11192cdea19fec3ed9bce3a84eff9a8351"),
|
||||
}
|
||||
|
||||
// ApplyMigrations 将嵌入的 SQL 迁移文件应用到指定的数据库。
|
||||
@@ -286,6 +293,8 @@ func prepareNonTransactionalMigration(ctx context.Context, db migrationConnectio
|
||||
return dropInvalidIndexIfPresent(ctx, db, schedulerOutboxPendingDedupKeyIndex)
|
||||
case latestAPIKeyIPIndexMigration:
|
||||
return dropInvalidIndexIfPresent(ctx, db, latestAPIKeyIPIndex)
|
||||
case usageLogsUpstreamModelMismatchIndexMigration:
|
||||
return dropInvalidIndexIfPresent(ctx, db, usageLogsUpstreamModelMismatchIndex)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -155,6 +155,42 @@ CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_api_key_latest_ip
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestApplyMigrationsFS_NonTransactionalMigration_UsageModelMismatchIndexDropsInvalidIndexBeforeRetry(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
prepareMigrationsBootstrapExpectations(mock)
|
||||
mock.ExpectQuery("SELECT checksum FROM schema_migrations WHERE filename = \\$1").
|
||||
WithArgs(usageLogsUpstreamModelMismatchIndexMigration).
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
mock.ExpectQuery("SELECT EXISTS \\(").
|
||||
WithArgs(usageLogsUpstreamModelMismatchIndex).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
mock.ExpectExec("DROP INDEX CONCURRENTLY IF EXISTS idx_usage_logs_upstream_model_mismatch_created_at").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec("CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_upstream_model_mismatch_created_at").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec("INSERT INTO schema_migrations \\(filename, checksum\\) VALUES \\(\\$1, \\$2\\)").
|
||||
WithArgs(usageLogsUpstreamModelMismatchIndexMigration, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("SELECT pg_advisory_unlock\\(\\$1\\)").
|
||||
WithArgs(migrationsAdvisoryLockID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
usageLogsUpstreamModelMismatchIndexMigration: &fstest.MapFile{Data: []byte(`
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_upstream_model_mismatch_created_at
|
||||
ON usage_logs (created_at DESC, id DESC)
|
||||
WHERE upstream_model_mismatch IS TRUE;
|
||||
`)},
|
||||
}
|
||||
|
||||
err = applyMigrationsFS(context.Background(), db, fsys)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestApplyMigrationsFS_PaymentOrdersOutTradeNoUniqueMigration_FailsFastOnDuplicatePrecheck(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -76,6 +76,24 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) {
|
||||
requireColumn(t, tx, "usage_logs", "video_count", "integer", 0, false)
|
||||
requireColumn(t, tx, "usage_logs", "video_resolution", "character varying", 10, true)
|
||||
requireColumn(t, tx, "usage_logs", "video_duration_seconds", "integer", 0, true)
|
||||
requireColumn(t, tx, "usage_logs", "upstream_response_model", "character varying", 200, true)
|
||||
requireColumn(t, tx, "usage_logs", "upstream_model_mismatch", "boolean", 0, true)
|
||||
requireIndex(t, tx, "usage_logs", usageLogsUpstreamModelMismatchIndex)
|
||||
|
||||
var mismatchIndexDef string
|
||||
require.NoError(t, tx.QueryRowContext(context.Background(), `
|
||||
SELECT pg_get_indexdef(i.indexrelid)
|
||||
FROM pg_class idx
|
||||
JOIN pg_index i ON i.indexrelid = idx.oid
|
||||
JOIN pg_class tbl ON tbl.oid = i.indrelid
|
||||
JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
|
||||
WHERE ns.nspname = 'public'
|
||||
AND tbl.relname = 'usage_logs'
|
||||
AND idx.relname = $1
|
||||
`, usageLogsUpstreamModelMismatchIndex).Scan(&mismatchIndexDef))
|
||||
require.Contains(t, mismatchIndexDef, "created_at DESC")
|
||||
require.Contains(t, mismatchIndexDef, "id DESC")
|
||||
require.Contains(t, mismatchIndexDef, "WHERE (upstream_model_mismatch IS TRUE)")
|
||||
requireConstraintDefinitionContains(
|
||||
t,
|
||||
tx,
|
||||
|
||||
@@ -31,6 +31,8 @@ var usageLogInsertArgTypes = [...]string{
|
||||
"text", // model
|
||||
"text", // requested_model
|
||||
"text", // upstream_model
|
||||
"text", // upstream_response_model
|
||||
"boolean", // upstream_model_mismatch
|
||||
"bigint", // group_id
|
||||
"bigint", // subscription_id
|
||||
"integer", // input_tokens
|
||||
@@ -227,6 +229,8 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor,
|
||||
model,
|
||||
requested_model,
|
||||
upstream_model,
|
||||
upstream_response_model,
|
||||
upstream_model_mismatch,
|
||||
group_id,
|
||||
subscription_id,
|
||||
input_tokens,
|
||||
@@ -278,12 +282,12 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor,
|
||||
session_id,
|
||||
created_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7,
|
||||
$8, $9,
|
||||
$10, $11, $12, $13,
|
||||
$14, $15, $16, $17,
|
||||
$18, $19, $20, $21, $22, $23,
|
||||
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
||||
$10, $11,
|
||||
$12, $13, $14, $15,
|
||||
$16, $17, $18, $19,
|
||||
$20, $21, $22, $23, $24, $25,
|
||||
$26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59
|
||||
)
|
||||
ON CONFLICT (request_id, api_key_id) DO NOTHING
|
||||
RETURNING id, created_at
|
||||
@@ -682,6 +686,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
|
||||
model,
|
||||
requested_model,
|
||||
upstream_model,
|
||||
upstream_response_model,
|
||||
upstream_model_mismatch,
|
||||
group_id,
|
||||
subscription_id,
|
||||
input_tokens,
|
||||
@@ -734,9 +740,9 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
|
||||
created_at
|
||||
) AS (VALUES `)
|
||||
|
||||
// Each batch row prepends the synthetic input_index before the 57
|
||||
// Each batch row prepends the synthetic input_index before the 59
|
||||
// usage-log column values.
|
||||
args := make([]any, 0, len(keys)*58)
|
||||
args := make([]any, 0, len(keys)*60)
|
||||
argPos := 1
|
||||
for idx, key := range keys {
|
||||
if idx > 0 {
|
||||
@@ -772,6 +778,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
|
||||
model,
|
||||
requested_model,
|
||||
upstream_model,
|
||||
upstream_response_model,
|
||||
upstream_model_mismatch,
|
||||
group_id,
|
||||
subscription_id,
|
||||
input_tokens,
|
||||
@@ -831,6 +839,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
|
||||
model,
|
||||
requested_model,
|
||||
upstream_model,
|
||||
upstream_response_model,
|
||||
upstream_model_mismatch,
|
||||
group_id,
|
||||
subscription_id,
|
||||
input_tokens,
|
||||
@@ -930,6 +940,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
|
||||
model,
|
||||
requested_model,
|
||||
upstream_model,
|
||||
upstream_response_model,
|
||||
upstream_model_mismatch,
|
||||
group_id,
|
||||
subscription_id,
|
||||
input_tokens,
|
||||
@@ -982,7 +994,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
|
||||
created_at
|
||||
) AS (VALUES `)
|
||||
|
||||
args := make([]any, 0, len(preparedList)*57)
|
||||
args := make([]any, 0, len(preparedList)*59)
|
||||
argPos := 1
|
||||
for idx, prepared := range preparedList {
|
||||
if idx > 0 {
|
||||
@@ -1015,6 +1027,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
|
||||
model,
|
||||
requested_model,
|
||||
upstream_model,
|
||||
upstream_response_model,
|
||||
upstream_model_mismatch,
|
||||
group_id,
|
||||
subscription_id,
|
||||
input_tokens,
|
||||
@@ -1074,6 +1088,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
|
||||
model,
|
||||
requested_model,
|
||||
upstream_model,
|
||||
upstream_response_model,
|
||||
upstream_model_mismatch,
|
||||
group_id,
|
||||
subscription_id,
|
||||
input_tokens,
|
||||
@@ -1141,6 +1157,8 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared
|
||||
model,
|
||||
requested_model,
|
||||
upstream_model,
|
||||
upstream_response_model,
|
||||
upstream_model_mismatch,
|
||||
group_id,
|
||||
subscription_id,
|
||||
input_tokens,
|
||||
@@ -1192,12 +1210,12 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared
|
||||
session_id,
|
||||
created_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7,
|
||||
$8, $9,
|
||||
$10, $11, $12, $13,
|
||||
$14, $15, $16, $17,
|
||||
$18, $19, $20, $21, $22, $23,
|
||||
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
||||
$10, $11,
|
||||
$12, $13, $14, $15,
|
||||
$16, $17, $18, $19,
|
||||
$20, $21, $22, $23, $24, $25,
|
||||
$26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59
|
||||
)
|
||||
ON CONFLICT (request_id, api_key_id) DO NOTHING
|
||||
`, prepared.args...)
|
||||
@@ -1244,6 +1262,8 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared {
|
||||
requestedModel = strings.TrimSpace(log.Model)
|
||||
}
|
||||
upstreamModel := nullString(log.UpstreamModel)
|
||||
upstreamResponseModel := nullString(log.UpstreamResponseModel)
|
||||
upstreamModelMismatch := nullBool(log.UpstreamModelMismatch)
|
||||
|
||||
var requestIDArg any
|
||||
if requestID != "" {
|
||||
@@ -1263,6 +1283,8 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared {
|
||||
log.Model,
|
||||
nullString(&requestedModel),
|
||||
upstreamModel,
|
||||
upstreamResponseModel,
|
||||
upstreamModelMismatch,
|
||||
groupID,
|
||||
subscriptionID,
|
||||
log.InputTokens,
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, session_id, created_at"
|
||||
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, upstream_response_model, upstream_model_mismatch, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, session_id, created_at"
|
||||
|
||||
func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) {
|
||||
query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1"
|
||||
@@ -127,6 +127,9 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat
|
||||
args = append(args, int16(*filters.BillingType))
|
||||
}
|
||||
conditions, args = appendUsageLogBillingModeWhereCondition(conditions, args, filters.BillingMode)
|
||||
if filters.UpstreamModelMismatch != nil {
|
||||
conditions = append(conditions, upstreamModelMismatchCondition("upstream_model_mismatch", *filters.UpstreamModelMismatch))
|
||||
}
|
||||
if filters.StartTime != nil {
|
||||
conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1))
|
||||
args = append(args, *filters.StartTime)
|
||||
@@ -157,6 +160,13 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat
|
||||
return logs, page, nil
|
||||
}
|
||||
|
||||
func upstreamModelMismatchCondition(column string, mismatch bool) string {
|
||||
if mismatch {
|
||||
return column + " IS TRUE"
|
||||
}
|
||||
return column + " IS FALSE"
|
||||
}
|
||||
|
||||
func shouldUseFastUsageLogTotal(filters UsageLogFilters) bool {
|
||||
if filters.ExactTotal {
|
||||
return false
|
||||
@@ -437,6 +447,8 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
|
||||
model string
|
||||
requestedModel sql.NullString
|
||||
upstreamModel sql.NullString
|
||||
upstreamResponseModel sql.NullString
|
||||
upstreamModelMismatch sql.NullBool
|
||||
groupID sql.NullInt64
|
||||
subscriptionID sql.NullInt64
|
||||
inputTokens int
|
||||
@@ -498,6 +510,8 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
|
||||
&model,
|
||||
&requestedModel,
|
||||
&upstreamModel,
|
||||
&upstreamResponseModel,
|
||||
&upstreamModelMismatch,
|
||||
&groupID,
|
||||
&subscriptionID,
|
||||
&inputTokens,
|
||||
@@ -651,6 +665,13 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
|
||||
if upstreamModel.Valid {
|
||||
log.UpstreamModel = &upstreamModel.String
|
||||
}
|
||||
if upstreamResponseModel.Valid {
|
||||
log.UpstreamResponseModel = &upstreamResponseModel.String
|
||||
}
|
||||
if upstreamModelMismatch.Valid {
|
||||
value := upstreamModelMismatch.Bool
|
||||
log.UpstreamModelMismatch = &value
|
||||
}
|
||||
if channelID.Valid {
|
||||
value := channelID.Int64
|
||||
log.ChannelID = &value
|
||||
@@ -703,6 +724,13 @@ func nullString(v *string) sql.NullString {
|
||||
return sql.NullString{String: *v, Valid: true}
|
||||
}
|
||||
|
||||
func nullBool(v *bool) sql.NullBool {
|
||||
if v == nil {
|
||||
return sql.NullBool{}
|
||||
}
|
||||
return sql.NullBool{Bool: *v, Valid: true}
|
||||
}
|
||||
|
||||
func nullStringIntMapJSON(v map[string]int) any {
|
||||
if len(v) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -48,6 +48,8 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) {
|
||||
log.Model,
|
||||
log.RequestedModel,
|
||||
sqlmock.AnyArg(), // upstream_model
|
||||
sqlmock.AnyArg(), // upstream_response_model
|
||||
sqlmock.AnyArg(), // upstream_model_mismatch
|
||||
sqlmock.AnyArg(), // group_id
|
||||
sqlmock.AnyArg(), // subscription_id
|
||||
log.InputTokens,
|
||||
@@ -137,9 +139,11 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) {
|
||||
log.RequestID,
|
||||
log.Model,
|
||||
log.RequestedModel,
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(), // upstream_model
|
||||
sqlmock.AnyArg(), // upstream_response_model
|
||||
sqlmock.AnyArg(), // upstream_model_mismatch
|
||||
sqlmock.AnyArg(), // group_id
|
||||
sqlmock.AnyArg(), // subscription_id
|
||||
log.InputTokens,
|
||||
log.OutputTokens,
|
||||
log.CacheCreationTokens,
|
||||
@@ -211,8 +215,8 @@ func TestBuildUsageLogBestEffortInsertQuery_IncludesRequestedModelColumn(t *test
|
||||
query, args := buildUsageLogBestEffortInsertQuery([]usageLogInsertPrepared{prepared})
|
||||
|
||||
require.Contains(t, query, "INSERT INTO usage_logs (")
|
||||
require.Contains(t, query, "\n\t\t\tmodel,\n\t\t\trequested_model,\n\t\t\tupstream_model,")
|
||||
require.Contains(t, query, "\n\t\t\trequest_id,\n\t\t\tmodel,\n\t\t\trequested_model,\n\t\t\tupstream_model,")
|
||||
require.Contains(t, query, "\n\t\t\tmodel,\n\t\t\trequested_model,\n\t\t\tupstream_model,\n\t\t\tupstream_response_model,\n\t\t\tupstream_model_mismatch,")
|
||||
require.Contains(t, query, "\n\t\t\trequest_id,\n\t\t\tmodel,\n\t\t\trequested_model,\n\t\t\tupstream_model,\n\t\t\tupstream_response_model,\n\t\t\tupstream_model_mismatch,")
|
||||
require.Len(t, args, len(prepared.args))
|
||||
require.Equal(t, prepared.args[5], args[5])
|
||||
}
|
||||
@@ -273,11 +277,11 @@ func TestPrepareUsageLogInsert_PersistsImageSizeMetadata(t *testing.T) {
|
||||
CreatedAt: time.Date(2025, 1, 6, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
|
||||
require.Equal(t, sql.NullString{String: imageSize, Valid: true}, prepared.args[36])
|
||||
require.Equal(t, sql.NullString{String: inputSize, Valid: true}, prepared.args[37])
|
||||
require.Equal(t, sql.NullString{String: outputSize, Valid: true}, prepared.args[38])
|
||||
require.Equal(t, sql.NullString{String: source, Valid: true}, prepared.args[39])
|
||||
breakdownJSON, ok := prepared.args[40].(string)
|
||||
require.Equal(t, sql.NullString{String: imageSize, Valid: true}, prepared.args[38])
|
||||
require.Equal(t, sql.NullString{String: inputSize, Valid: true}, prepared.args[39])
|
||||
require.Equal(t, sql.NullString{String: outputSize, Valid: true}, prepared.args[40])
|
||||
require.Equal(t, sql.NullString{String: source, Valid: true}, prepared.args[41])
|
||||
breakdownJSON, ok := prepared.args[42].(string)
|
||||
require.True(t, ok)
|
||||
require.JSONEq(t, `{"1K":1,"4K":1}`, breakdownJSON)
|
||||
}
|
||||
@@ -809,6 +813,8 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
|
||||
"gpt-image-2",
|
||||
sql.NullString{Valid: true, String: "gpt-image-2"},
|
||||
sql.NullString{},
|
||||
sql.NullString{},
|
||||
sql.NullBool{},
|
||||
sql.NullInt64{},
|
||||
sql.NullInt64{},
|
||||
0, 0, 0, 0, 0, 0,
|
||||
@@ -872,6 +878,8 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
|
||||
"gpt-5", // model
|
||||
sql.NullString{Valid: true, String: "gpt-5"}, // requested_model
|
||||
sql.NullString{}, // upstream_model
|
||||
sql.NullString{}, // upstream_response_model
|
||||
sql.NullBool{}, // upstream_model_mismatch
|
||||
sql.NullInt64{}, // group_id
|
||||
sql.NullInt64{}, // subscription_id
|
||||
1, // input_tokens
|
||||
@@ -942,6 +950,8 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
|
||||
"gpt-5",
|
||||
sql.NullString{Valid: true, String: "gpt-5"},
|
||||
sql.NullString{},
|
||||
sql.NullString{},
|
||||
sql.NullBool{},
|
||||
sql.NullInt64{},
|
||||
sql.NullInt64{},
|
||||
1, 2, 3, 4, 5, 6,
|
||||
@@ -1000,6 +1010,8 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
|
||||
"gpt-5.4",
|
||||
sql.NullString{Valid: true, String: "gpt-5.4"},
|
||||
sql.NullString{},
|
||||
sql.NullString{},
|
||||
sql.NullBool{},
|
||||
sql.NullInt64{},
|
||||
sql.NullInt64{},
|
||||
1, 2, 3, 4, 5, 6,
|
||||
|
||||
@@ -683,6 +683,9 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us
|
||||
args = append(args, int16(*filters.BillingType))
|
||||
}
|
||||
conditions, args = appendUsageLogBillingModeWhereCondition(conditions, args, filters.BillingMode)
|
||||
if filters.UpstreamModelMismatch != nil {
|
||||
conditions = append(conditions, upstreamModelMismatchCondition("upstream_model_mismatch", *filters.UpstreamModelMismatch))
|
||||
}
|
||||
if filters.StartTime != nil {
|
||||
conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1))
|
||||
args = append(args, *filters.StartTime)
|
||||
|
||||
@@ -4,6 +4,7 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -12,6 +13,66 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUsageLog_UpstreamModelMismatchFilterAndPartialIndex(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
client := tx.Client()
|
||||
repo := newUsageLogRepositoryWithSQL(client, tx)
|
||||
|
||||
user := mustCreateUser(t, client, &service.User{Email: "model-audit@test.com"})
|
||||
apiKey := mustCreateApiKey(t, client, &service.APIKey{UserID: user.ID, Key: "sk-model-audit", Name: "model-audit"})
|
||||
account := mustCreateAccount(t, client, &service.Account{Name: "model-audit-account"})
|
||||
now := time.Now().UTC()
|
||||
responseModel := "gpt-5.4"
|
||||
for _, mismatch := range []bool{true, false} {
|
||||
mismatchValue := mismatch
|
||||
_, err := repo.Create(ctx, &service.UsageLog{
|
||||
UserID: user.ID, APIKeyID: apiKey.ID, AccountID: account.ID,
|
||||
Model: "gpt-5.5", InputTokens: 1, OutputTokens: 1,
|
||||
UpstreamResponseModel: &responseModel, UpstreamModelMismatch: &mismatchValue,
|
||||
CreatedAt: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
start := now.Add(-time.Hour)
|
||||
end := now.Add(time.Hour)
|
||||
trueValue := true
|
||||
stats, err := repo.GetStatsWithFilters(ctx, usagestats.UsageLogFilters{
|
||||
UserID: user.ID, StartTime: &start, EndTime: &end, UpstreamModelMismatch: &trueValue,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), stats.TotalRequests)
|
||||
|
||||
trend, err := repo.GetUsageTrendWithUsageFilters(ctx, start, end, "hour", usagestats.UsageLogFilters{
|
||||
UserID: user.ID, UpstreamModelMismatch: &trueValue,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, trend, 1)
|
||||
require.Equal(t, int64(1), trend[0].Requests)
|
||||
|
||||
_, err = tx.ExecContext(ctx, "SET LOCAL enable_seqscan = off")
|
||||
require.NoError(t, err)
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
EXPLAIN (COSTS OFF)
|
||||
SELECT id
|
||||
FROM usage_logs
|
||||
WHERE upstream_model_mismatch IS TRUE
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 100
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, rows.Close()) }()
|
||||
var planLines []string
|
||||
for rows.Next() {
|
||||
var line string
|
||||
require.NoError(t, rows.Scan(&line))
|
||||
planLines = append(planLines, line)
|
||||
}
|
||||
require.NoError(t, rows.Err())
|
||||
require.Contains(t, strings.Join(planLines, "\n"), usageLogsUpstreamModelMismatchIndex)
|
||||
}
|
||||
|
||||
func TestUsageLog_GetStatsWithFilters_AggregatesAndEndpoints(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
|
||||
@@ -265,20 +265,20 @@ func (r *usageLogRepository) GetUserUsageTrendByUserID(ctx context.Context, user
|
||||
|
||||
// GetUserModelStats 获取指定用户的模型统计
|
||||
func (r *usageLogRepository) GetUserModelStats(ctx context.Context, userID int64, startTime, endTime time.Time) (results []ModelStat, err error) {
|
||||
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, 0, 0, 0, "", nil, nil, nil, usagestats.ModelSourceRequested, "")
|
||||
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, 0, 0, 0, "", nil, nil, nil, usagestats.ModelSourceRequested, "", nil)
|
||||
}
|
||||
|
||||
// GetUsageTrendWithFilters returns usage trend data with optional filters
|
||||
func (r *usageLogRepository) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) (results []TrendDataPoint, err error) {
|
||||
return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "")
|
||||
return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "", nil)
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) GetUsageTrendWithUsageFilters(ctx context.Context, startTime, endTime time.Time, granularity string, filters UsageLogFilters) (results []TrendDataPoint, err error) {
|
||||
return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode)
|
||||
return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode, filters.UpstreamModelMismatch)
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []TrendDataPoint, err error) {
|
||||
if shouldUsePreaggregatedTrend(granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, billingMode) {
|
||||
func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool) (results []TrendDataPoint, err error) {
|
||||
if shouldUsePreaggregatedTrend(granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, billingMode, upstreamModelMismatch) {
|
||||
aggregated, aggregatedErr := r.getUsageTrendFromAggregates(ctx, startTime, endTime, granularity)
|
||||
if aggregatedErr == nil && len(aggregated) > 0 {
|
||||
return aggregated, nil
|
||||
@@ -326,6 +326,9 @@ func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, start
|
||||
args = append(args, int16(*billingType))
|
||||
}
|
||||
query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "")
|
||||
if upstreamModelMismatch != nil {
|
||||
query += " AND " + upstreamModelMismatchCondition("upstream_model_mismatch", *upstreamModelMismatch)
|
||||
}
|
||||
query += " GROUP BY date ORDER BY date ASC"
|
||||
|
||||
rows, err := r.sql.QueryContext(ctx, query, args...)
|
||||
@@ -348,7 +351,7 @@ func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, start
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string) bool {
|
||||
func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool) bool {
|
||||
if granularity != "day" && granularity != "hour" {
|
||||
return false
|
||||
}
|
||||
@@ -360,7 +363,8 @@ func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID
|
||||
requestType == nil &&
|
||||
stream == nil &&
|
||||
billingType == nil &&
|
||||
billingMode == ""
|
||||
billingMode == "" &&
|
||||
upstreamModelMismatch == nil
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) getUsageTrendFromAggregates(ctx context.Context, startTime, endTime time.Time, granularity string) (results []TrendDataPoint, err error) {
|
||||
@@ -425,20 +429,20 @@ func (r *usageLogRepository) getUsageTrendFromAggregates(ctx context.Context, st
|
||||
|
||||
// GetModelStatsWithFilters returns model statistics with optional filters
|
||||
func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []ModelStat, err error) {
|
||||
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, usagestats.ModelSourceRequested, "")
|
||||
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, usagestats.ModelSourceRequested, "", nil)
|
||||
}
|
||||
|
||||
// GetModelStatsWithFiltersBySource returns model statistics with optional filters and model source dimension.
|
||||
// source: requested | upstream | mapping.
|
||||
func (r *usageLogRepository) GetModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8, source string) (results []ModelStat, err error) {
|
||||
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, source, "")
|
||||
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, source, "", nil)
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) GetModelStatsWithUsageFiltersBySource(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters, source string) (results []ModelStat, err error) {
|
||||
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, source, filters.BillingMode)
|
||||
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, source, filters.BillingMode, filters.UpstreamModelMismatch)
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, source string, billingMode string) (results []ModelStat, err error) {
|
||||
func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, source string, billingMode string, upstreamModelMismatch *bool) (results []ModelStat, err error) {
|
||||
actualCostExpr := "COALESCE(SUM(actual_cost), 0) as actual_cost"
|
||||
// 当仅按 account_id 聚合时,实际费用使用账号倍率(total_cost * account_rate_multiplier)。
|
||||
if accountID > 0 && userID == 0 && apiKeyID == 0 {
|
||||
@@ -490,6 +494,9 @@ func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Contex
|
||||
args = append(args, int16(*billingType))
|
||||
}
|
||||
query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "")
|
||||
if upstreamModelMismatch != nil {
|
||||
query += " AND " + upstreamModelMismatchCondition("upstream_model_mismatch", *upstreamModelMismatch)
|
||||
}
|
||||
query += fmt.Sprintf(" GROUP BY %s ORDER BY total_tokens DESC", modelExpr)
|
||||
|
||||
rows, err := r.sql.QueryContext(ctx, query, args...)
|
||||
@@ -514,14 +521,14 @@ func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Contex
|
||||
|
||||
// GetGroupStatsWithFilters returns group usage statistics with optional filters
|
||||
func (r *usageLogRepository) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []usagestats.GroupStat, err error) {
|
||||
return r.getGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, "")
|
||||
return r.getGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, "", nil)
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) GetGroupStatsWithUsageFilters(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters) (results []usagestats.GroupStat, err error) {
|
||||
return r.getGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode)
|
||||
return r.getGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode, filters.UpstreamModelMismatch)
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []usagestats.GroupStat, err error) {
|
||||
func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool) (results []usagestats.GroupStat, err error) {
|
||||
query := `
|
||||
SELECT
|
||||
COALESCE(ul.group_id, 0) as group_id,
|
||||
@@ -564,6 +571,9 @@ func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, start
|
||||
args = append(args, int16(*billingType))
|
||||
}
|
||||
query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "ul")
|
||||
if upstreamModelMismatch != nil {
|
||||
query += " AND " + upstreamModelMismatchCondition("ul.upstream_model_mismatch", *upstreamModelMismatch)
|
||||
}
|
||||
query += " GROUP BY ul.group_id, g.name ORDER BY total_tokens DESC"
|
||||
|
||||
rows, err := r.sql.QueryContext(ctx, query, args...)
|
||||
|
||||
@@ -32,7 +32,7 @@ func newSessionIDUsageLog(sessionID *string) *service.UsageLog {
|
||||
// arg slice / arg-type table so the five INSERT column lists stay in sync. session_id
|
||||
// is the penultimate arg (created_at is always last).
|
||||
func TestPrepareUsageLogInsert_SessionIDArgWiring(t *testing.T) {
|
||||
require.Len(t, usageLogInsertArgTypes, 57, "arg-type table must include session_id")
|
||||
require.Len(t, usageLogInsertArgTypes, 59, "arg-type table must include session_id")
|
||||
|
||||
sessionID := "sess-persisted-123"
|
||||
prepared := prepareUsageLogInsert(newSessionIDUsageLog(&sessionID))
|
||||
|
||||
@@ -368,7 +368,7 @@ func (r *userSubscriptionRepository) UpdateNotes(ctx context.Context, subscripti
|
||||
return translatePersistenceError(err, service.ErrSubscriptionNotFound, nil)
|
||||
}
|
||||
|
||||
func (r *userSubscriptionRepository) ActivateWindows(ctx context.Context, id int64, start time.Time) error {
|
||||
func (r *userSubscriptionRepository) ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
n, err := client.UserSubscription.Update().
|
||||
Where(
|
||||
@@ -377,24 +377,24 @@ func (r *userSubscriptionRepository) ActivateWindows(ctx context.Context, id int
|
||||
usersubscription.WeeklyWindowStartIsNil(),
|
||||
usersubscription.MonthlyWindowStartIsNil(),
|
||||
).
|
||||
SetDailyWindowStart(start).
|
||||
SetWeeklyWindowStart(start).
|
||||
SetMonthlyWindowStart(start).
|
||||
SetDailyWindowStart(dailyStart).
|
||||
SetWeeklyWindowStart(periodicStart).
|
||||
SetMonthlyWindowStart(periodicStart).
|
||||
Save(ctx)
|
||||
return r.translateConditionalWindowReset(ctx, client, id, n, err)
|
||||
}
|
||||
|
||||
func (r *userSubscriptionRepository) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, newWindowStart time.Time) error {
|
||||
func (r *userSubscriptionRepository) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, dailyStart, periodicStart time.Time) error {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
update := client.UserSubscription.UpdateOneID(id)
|
||||
if resetDaily {
|
||||
update.SetDailyUsageUsd(0).SetDailyWindowStart(newWindowStart)
|
||||
update.SetDailyUsageUsd(0).SetDailyWindowStart(dailyStart)
|
||||
}
|
||||
if resetWeekly {
|
||||
update.SetWeeklyUsageUsd(0).SetWeeklyWindowStart(newWindowStart)
|
||||
update.SetWeeklyUsageUsd(0).SetWeeklyWindowStart(periodicStart)
|
||||
}
|
||||
if resetMonthly {
|
||||
update.SetMonthlyUsageUsd(0).SetMonthlyWindowStart(newWindowStart)
|
||||
update.SetMonthlyUsageUsd(0).SetMonthlyWindowStart(periodicStart)
|
||||
}
|
||||
_, err := update.Save(ctx)
|
||||
return translatePersistenceError(err, service.ErrSubscriptionNotFound, nil)
|
||||
|
||||
@@ -451,8 +451,9 @@ func (s *UserSubscriptionRepoSuite) TestActivateWindows() {
|
||||
group := s.mustCreateGroup("g-activate")
|
||||
sub := s.mustCreateSubscription(user.ID, group.ID, nil)
|
||||
|
||||
dailyStart := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
activateAt := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
err := s.repo.ActivateWindows(s.ctx, sub.ID, activateAt)
|
||||
err := s.repo.ActivateWindows(s.ctx, sub.ID, dailyStart, activateAt)
|
||||
s.Require().NoError(err, "ActivateWindows")
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, sub.ID)
|
||||
@@ -460,7 +461,9 @@ func (s *UserSubscriptionRepoSuite) TestActivateWindows() {
|
||||
s.Require().NotNil(got.DailyWindowStart)
|
||||
s.Require().NotNil(got.WeeklyWindowStart)
|
||||
s.Require().NotNil(got.MonthlyWindowStart)
|
||||
s.Require().WithinDuration(activateAt, *got.DailyWindowStart, time.Microsecond)
|
||||
s.Require().WithinDuration(dailyStart, *got.DailyWindowStart, time.Microsecond)
|
||||
s.Require().WithinDuration(activateAt, *got.WeeklyWindowStart, time.Microsecond)
|
||||
s.Require().WithinDuration(activateAt, *got.MonthlyWindowStart, time.Microsecond)
|
||||
}
|
||||
|
||||
func (s *UserSubscriptionRepoSuite) TestActivateWindows_StaleActivationPreservesExistingWindows() {
|
||||
@@ -469,15 +472,16 @@ func (s *UserSubscriptionRepoSuite) TestActivateWindows_StaleActivationPreserves
|
||||
sub := s.mustCreateSubscription(user.ID, group.ID, nil)
|
||||
activatedAt := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
manualResetAt := activatedAt.Add(2 * time.Hour)
|
||||
manualDailyStart := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt))
|
||||
s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, true, true, manualResetAt))
|
||||
s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt, activatedAt))
|
||||
s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, true, true, manualDailyStart, manualResetAt))
|
||||
// Simulate a concurrent request carrying the original unactivated snapshot.
|
||||
s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt.Add(time.Hour)))
|
||||
s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt.Add(time.Hour), activatedAt.Add(time.Hour)))
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, sub.ID)
|
||||
s.Require().NoError(err)
|
||||
s.Require().WithinDuration(manualResetAt, *got.DailyWindowStart, time.Microsecond)
|
||||
s.Require().WithinDuration(manualDailyStart, *got.DailyWindowStart, time.Microsecond)
|
||||
s.Require().WithinDuration(manualResetAt, *got.WeeklyWindowStart, time.Microsecond)
|
||||
s.Require().WithinDuration(manualResetAt, *got.MonthlyWindowStart, time.Microsecond)
|
||||
}
|
||||
@@ -535,7 +539,7 @@ func (s *UserSubscriptionRepoSuite) TestResetUsageWindows_ClearsUsageAfterAutoma
|
||||
newWindowStart := oldWindowStart.Add(24 * time.Hour)
|
||||
s.Require().NoError(s.repo.ResetDailyUsage(s.ctx, sub.ID, &oldWindowStart, newWindowStart))
|
||||
s.Require().NoError(s.repo.IncrementUsage(s.ctx, sub.ID, 3))
|
||||
s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, false, false, newWindowStart))
|
||||
s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, false, false, newWindowStart, newWindowStart))
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, sub.ID)
|
||||
s.Require().NoError(err)
|
||||
@@ -770,7 +774,7 @@ func (s *UserSubscriptionRepoSuite) TestActiveExpiredBoundaries_UsageAndReset_Ba
|
||||
s.Require().Equal(active.ID, got.ID, "expected active subscription")
|
||||
|
||||
activateAt := time.Now().Add(-25 * time.Hour)
|
||||
s.Require().NoError(s.repo.ActivateWindows(s.ctx, active.ID, activateAt), "ActivateWindows")
|
||||
s.Require().NoError(s.repo.ActivateWindows(s.ctx, active.ID, activateAt, activateAt), "ActivateWindows")
|
||||
s.Require().NoError(s.repo.IncrementUsage(s.ctx, active.ID, 1.25), "IncrementUsage")
|
||||
|
||||
after, err := s.repo.GetByID(s.ctx, active.ID)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user