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:
shaw
2026-08-09 11:55:04 +08:00
352 changed files with 25184 additions and 2128 deletions
+3
View File
@@ -143,3 +143,6 @@ docs/*
frontend/coverage/
aicodex
output/
# Vitest / Vite cache at repo root
.vite/
+1 -1
View File
@@ -1 +1 @@
0.1.171
0.1.172
+3 -3
View File
@@ -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
View File
@@ -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 callsweb_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(", ")
+43
View File
@@ -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()
+230
View File
@@ -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))
+482
View File
@@ -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) {
+364
View File
@@ -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)
}
+22 -15
View File
@@ -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
View File
@@ -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
+68 -48
View File
@@ -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()
+32
View File
@@ -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 callsweb_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).
+11
View File
@@ -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
View File
@@ -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))
+18
View File
@@ -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()
+105
View File
@@ -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))
+161
View File
@@ -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) {
+114
View File
@@ -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)
}
+10
View File
@@ -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=
+56
View File
@@ -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)
}
+13
View File
@@ -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)
+6
View File
@@ -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) {
+70 -50
View File
@@ -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 {
+48 -26
View File
@@ -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",
+7
View File
@@ -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) {
+8
View File
@@ -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 = platformnil/缺省 = 不限制)
DefaultPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"default_platform_quotas,omitempty"`
// 系统全局账号自动停调阈值(key = platform100 = disabled
AccountSchedulingThresholds map[string]int `json:"account_scheduling_thresholds,omitempty"`
// 允许终端用户在用量页查看自己的失败请求
AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"`
}
+11 -1
View File
@@ -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.")
}
+338
View File
@@ -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")
}
}
+203 -4
View File
@@ -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),
+16 -4
View File
@@ -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,
+1 -1
View File
@@ -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 的 reasoningencrypted_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
+6
View File
@@ -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"
+20 -1
View File
@@ -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")
}
+125
View File
@@ -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
}
+93 -27
View File
@@ -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
}
+36 -1
View File
@@ -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{
+79
View File
@@ -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"))
}
+269 -23
View File
@@ -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
}
}
+65
View File
@@ -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"))
}
+133 -17
View File
@@ -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"))
}
+20 -8
View File
@@ -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")
}
+71 -7
View File
@@ -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
+101
View File
@@ -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))
}
+49 -19
View File
@@ -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 {
+29 -1
View File
@@ -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)
}
+26
View File
@@ -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()
}
// 处理 FallbackGroupIDnil 时清除,否则设置
if groupIn.FallbackGroupID != nil {
+42 -9
View File
@@ -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.DialerTimeout=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