Merge remote-tracking branch 'upstream/main' into codex-overdraft

# Conflicts:
#	DEV_GUIDE.md
#	backend/internal/service/openai_gateway_scheduling.go
This commit is contained in:
bmiddle1.backend.zhangfucheng committed 2026-08-19 09:36:51 +08:00
commit 12e67d823b
299 files changed
+19909 -1019

No files matched your search

+1 -1
View File
@@ -53,7 +53,7 @@ npm install -g pnpm
### CI 要求
- Go 版本必须是 **1.26.6**:三个 workflow 都用 `go-version-file: backend/go.mod` 取版本,随后硬断言 `go version | grep -q 'go1.26.6'`。升级 Go 时要同时改 `backend/go.mod` 和 `backend-ci.yml`(两处)、`release.yml`、`security-scan.yml` 里的这句断言,否则 CI 会在版本校验步骤直接失败。
- Go 版本必须是 **1.26.6**:三个 workflow 都用 `go-version-file: backend/go.mod` 取版本,随后硬断言 `go version | grep -q 'go1.26.6'`。升级 Go 时要同时改 `backend/go.mod`、`backend-ci.yml`(两处)、`release.yml`、`security-scan.yml` 里的这句断言,**以及三个 Dockerfile 里的 Go 构建镜像**(`Dockerfile` / `deploy/Dockerfile` 的 `ARG GOLANG_IMAGE`、`backend/Dockerfile` 的 `FROM golang:`)。前者漏了 CI 会在版本校验步骤直接失败;**后者漏了 CI 不会报,而是等到有人用这些 Dockerfile 构建时才失败**(`go.mod requires go >= X (running Y; GOTOOLCHAIN=local)`)。
- 前端使用 `pnpm install --frozen-lockfile`,必须提交 `pnpm-lock.yaml`
### 本地测试命令
+1 -1
View File
@@ -1 +1 @@
0.1.177
0.1.178
+7
View File
@@ -88,6 +88,7 @@ func provideCleanup(
schedulerSnapshot *service.SchedulerSnapshotService,
tokenRefresh *service.TokenRefreshService,
accountExpiry *service.AccountExpiryService,
cnProviderBalanceCheck *service.CNProviderBalanceCheckService,
codexVersionSync *service.OpenAICodexVersionSyncService,
proxyExpiry *service.ProxyExpiryService,
subscriptionExpiry *service.SubscriptionExpiryService,
@@ -238,6 +239,12 @@ func provideCleanup(
accountExpiry.Stop()
return nil
}},
{"CNProviderBalanceCheckService", func() error {
if cnProviderBalanceCheck != nil {
cnProviderBalanceCheck.Stop()
}
return nil
}},
{"OpenAICodexVersionSyncService", func() error {
codexVersionSync.Stop()
return nil
+15 -3
View File
@@ -218,6 +218,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI, openAIGatewayService)
grokOAuthHandler := admin.NewGrokOAuthHandler(grokOAuthService, adminService, grokQuotaService, tokenRefreshService)
cnProviderQuotaService := service.ProvideCNProviderQuotaService(accountRepository, proxyRepository, httpUpstream, configConfig)
cnProviderBalanceService := service.ProvideCNProviderBalanceService(accountRepository, proxyRepository, httpUpstream, configConfig)
cnProviderHandler := admin.NewCNProviderHandler(cnProviderQuotaService, cnProviderBalanceService)
proxyHandler := admin.NewProxyHandler(adminService)
adminRedeemHandler := admin.NewRedeemHandler(adminService, redeemService)
promoHandler := admin.NewPromoHandler(promoService)
@@ -277,7 +280,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService)
upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db)
ollamaCloudUsageService := service.ProvideOllamaCloudUsageService(accountRepository, httpUpstream, settingService, secretEncryptor, configConfig, leaderLockCache, db)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
@@ -327,16 +330,18 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
opsIngressRejectAggregator := service.ProvideOpsIngressRejectAggregator(opsRepository, opsService)
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
cnProviderBalanceCheckService := service.ProvideCNProviderBalanceCheckService(accountRepository, cnProviderBalanceService, cnProviderQuotaService, configConfig)
openAICodexVersionSyncService := service.ProvideOpenAICodexVersionSyncService(settingRepository, settingService, gitHubReleaseClient)
proxyExpiryService := service.ProvideProxyExpiryService(proxyRepository)
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, settingRepository, notificationEmailService, leaderLockCache, db)
batchImageWorkerRuntime := service.ProvideBatchImageWorkerRuntime(batchImageRepository, accountRepository, batchImageQueue, usageBillingRepository, usageLogRepository, batchImageModelPricingResolver, apiKeyAuthCacheInvalidator, configConfig)
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db)
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
channelMonitorQuotaFetcher := service.NewChannelMonitorQuotaFetcher(accountUsageService, cnProviderQuotaService, cnProviderBalanceService, accountRepository)
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService, channelMonitorQuotaFetcher)
channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService)
userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService)
application := &Application{
Server: httpServer,
PromptAudit: promptService,
@@ -380,6 +385,7 @@ func provideCleanup(
schedulerSnapshot *service.SchedulerSnapshotService,
tokenRefresh *service.TokenRefreshService,
accountExpiry *service.AccountExpiryService,
cnProviderBalanceCheck *service.CNProviderBalanceCheckService,
codexVersionSync *service.OpenAICodexVersionSyncService,
proxyExpiry *service.ProxyExpiryService,
subscriptionExpiry *service.SubscriptionExpiryService,
@@ -529,6 +535,12 @@ func provideCleanup(
accountExpiry.Stop()
return nil
}},
{"CNProviderBalanceCheckService", func() error {
if cnProviderBalanceCheck != nil {
cnProviderBalanceCheck.Stop()
}
return nil
}},
{"OpenAICodexVersionSyncService", func() error {
codexVersionSync.Stop()
return nil
+1
View File
@@ -66,6 +66,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
schedulerSnapshotSvc,
tokenRefreshSvc,
accountExpirySvc,
nil, // cnProviderBalanceCheck
codexVersionSyncSvc,
proxyExpirySvc,
subscriptionExpirySvc,
+28 -3
View File
@@ -27,9 +27,13 @@ type ChannelMonitor struct {
Name string `json:"name,omitempty"`
// Provider holds the value of the "provider" field.
Provider channelmonitor.Provider `json:"provider,omitempty"`
// probe = LLM probe (default); quota = account usage only; quota_probe = both
CheckMode string `json:"check_mode,omitempty"`
// AccountID holds the value of the "account_id" field.
AccountID *int64 `json:"account_id,omitempty"`
// OpenAI request protocol: chat_completions or responses; non-OpenAI uses chat_completions
APIMode string `json:"api_mode,omitempty"`
// Provider base origin, e.g. https://api.openai.com
// Provider base origin, e.g. https://api.openai.com; empty for quota-only monitors
Endpoint string `json:"endpoint,omitempty"`
// AES-256-GCM encrypted API key
APIKeyEncrypted string `json:"-"`
@@ -114,9 +118,9 @@ func (*ChannelMonitor) scanValues(columns []string) ([]any, error) {
values[i] = new([]byte)
case channelmonitor.FieldEnabled:
values[i] = new(sql.NullBool)
case channelmonitor.FieldID, channelmonitor.FieldIntervalSeconds, channelmonitor.FieldJitterSeconds, channelmonitor.FieldCreatedBy, channelmonitor.FieldTemplateID:
case channelmonitor.FieldID, channelmonitor.FieldAccountID, channelmonitor.FieldIntervalSeconds, channelmonitor.FieldJitterSeconds, channelmonitor.FieldCreatedBy, channelmonitor.FieldTemplateID:
values[i] = new(sql.NullInt64)
case channelmonitor.FieldName, channelmonitor.FieldProvider, channelmonitor.FieldAPIMode, channelmonitor.FieldEndpoint, channelmonitor.FieldAPIKeyEncrypted, channelmonitor.FieldPrimaryModel, channelmonitor.FieldGroupName, channelmonitor.FieldBodyOverrideMode:
case channelmonitor.FieldName, channelmonitor.FieldProvider, channelmonitor.FieldCheckMode, channelmonitor.FieldAPIMode, channelmonitor.FieldEndpoint, channelmonitor.FieldAPIKeyEncrypted, channelmonitor.FieldPrimaryModel, channelmonitor.FieldGroupName, channelmonitor.FieldBodyOverrideMode:
values[i] = new(sql.NullString)
case channelmonitor.FieldCreatedAt, channelmonitor.FieldUpdatedAt, channelmonitor.FieldLastCheckedAt:
values[i] = new(sql.NullTime)
@@ -165,6 +169,19 @@ func (_m *ChannelMonitor) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.Provider = channelmonitor.Provider(value.String)
}
case channelmonitor.FieldCheckMode:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field check_mode", values[i])
} else if value.Valid {
_m.CheckMode = value.String
}
case channelmonitor.FieldAccountID:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field account_id", values[i])
} else if value.Valid {
_m.AccountID = new(int64)
*_m.AccountID = value.Int64
}
case channelmonitor.FieldAPIMode:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field api_mode", values[i])
@@ -326,6 +343,14 @@ func (_m *ChannelMonitor) String() string {
builder.WriteString("provider=")
builder.WriteString(fmt.Sprintf("%v", _m.Provider))
builder.WriteString(", ")
builder.WriteString("check_mode=")
builder.WriteString(_m.CheckMode)
builder.WriteString(", ")
if v := _m.AccountID; v != nil {
builder.WriteString("account_id=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
builder.WriteString("api_mode=")
builder.WriteString(_m.APIMode)
builder.WriteString(", ")
+29 -5
View File
@@ -23,6 +23,10 @@ const (
FieldName = "name"
// FieldProvider holds the string denoting the provider field in the database.
FieldProvider = "provider"
// FieldCheckMode holds the string denoting the check_mode field in the database.
FieldCheckMode = "check_mode"
// FieldAccountID holds the string denoting the account_id field in the database.
FieldAccountID = "account_id"
// FieldAPIMode holds the string denoting the api_mode field in the database.
FieldAPIMode = "api_mode"
// FieldEndpoint holds the string denoting the endpoint field in the database.
@@ -91,6 +95,8 @@ var Columns = []string{
FieldUpdatedAt,
FieldName,
FieldProvider,
FieldCheckMode,
FieldAccountID,
FieldAPIMode,
FieldEndpoint,
FieldAPIKeyEncrypted,
@@ -127,6 +133,10 @@ var (
UpdateDefaultUpdatedAt func() time.Time
// NameValidator is a validator for the "name" field. It is called by the builders before save.
NameValidator func(string) error
// DefaultCheckMode holds the default value on creation for the "check_mode" field.
DefaultCheckMode string
// CheckModeValidator is a validator for the "check_mode" field. It is called by the builders before save.
CheckModeValidator func(string) error
// DefaultAPIMode holds the default value on creation for the "api_mode" field.
DefaultAPIMode string
// APIModeValidator is a validator for the "api_mode" field. It is called by the builders before save.
@@ -164,10 +174,14 @@ type Provider string
// Provider values.
const (
ProviderOpenai Provider = "openai"
ProviderAnthropic Provider = "anthropic"
ProviderGemini Provider = "gemini"
ProviderGrok Provider = "grok"
ProviderOpenai Provider = "openai"
ProviderAnthropic Provider = "anthropic"
ProviderGemini Provider = "gemini"
ProviderGrok Provider = "grok"
ProviderAntigravity Provider = "antigravity"
ProviderKimi Provider = "kimi"
ProviderZhipu Provider = "zhipu"
ProviderDeepseek Provider = "deepseek"
)
func (pr Provider) String() string {
@@ -177,7 +191,7 @@ func (pr Provider) String() string {
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
func ProviderValidator(pr Provider) error {
switch pr {
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok:
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek:
return nil
default:
return fmt.Errorf("channelmonitor: invalid enum value for provider field: %q", pr)
@@ -212,6 +226,16 @@ func ByProvider(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldProvider, opts...).ToFunc()
}
// ByCheckMode orders the results by the check_mode field.
func ByCheckMode(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldCheckMode, opts...).ToFunc()
}
// ByAccountID orders the results by the account_id field.
func ByAccountID(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldAccountID, opts...).ToFunc()
}
// ByAPIMode orders the results by the api_mode field.
func ByAPIMode(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldAPIMode, opts...).ToFunc()
+125
View File
@@ -70,6 +70,16 @@ func Name(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldName, v))
}
// CheckMode applies equality check predicate on the "check_mode" field. It's identical to CheckModeEQ.
func CheckMode(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldCheckMode, v))
}
// AccountID applies equality check predicate on the "account_id" field. It's identical to AccountIDEQ.
func AccountID(v int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldAccountID, v))
}
// APIMode applies equality check predicate on the "api_mode" field. It's identical to APIModeEQ.
func APIMode(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldAPIMode, v))
@@ -295,6 +305,121 @@ func ProviderNotIn(vs ...Provider) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldNotIn(FieldProvider, vs...))
}
// CheckModeEQ applies the EQ predicate on the "check_mode" field.
func CheckModeEQ(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldCheckMode, v))
}
// CheckModeNEQ applies the NEQ predicate on the "check_mode" field.
func CheckModeNEQ(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldNEQ(FieldCheckMode, v))
}
// CheckModeIn applies the In predicate on the "check_mode" field.
func CheckModeIn(vs ...string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldIn(FieldCheckMode, vs...))
}
// CheckModeNotIn applies the NotIn predicate on the "check_mode" field.
func CheckModeNotIn(vs ...string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldNotIn(FieldCheckMode, vs...))
}
// CheckModeGT applies the GT predicate on the "check_mode" field.
func CheckModeGT(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldGT(FieldCheckMode, v))
}
// CheckModeGTE applies the GTE predicate on the "check_mode" field.
func CheckModeGTE(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldGTE(FieldCheckMode, v))
}
// CheckModeLT applies the LT predicate on the "check_mode" field.
func CheckModeLT(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldLT(FieldCheckMode, v))
}
// CheckModeLTE applies the LTE predicate on the "check_mode" field.
func CheckModeLTE(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldLTE(FieldCheckMode, v))
}
// CheckModeContains applies the Contains predicate on the "check_mode" field.
func CheckModeContains(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldContains(FieldCheckMode, v))
}
// CheckModeHasPrefix applies the HasPrefix predicate on the "check_mode" field.
func CheckModeHasPrefix(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldHasPrefix(FieldCheckMode, v))
}
// CheckModeHasSuffix applies the HasSuffix predicate on the "check_mode" field.
func CheckModeHasSuffix(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldHasSuffix(FieldCheckMode, v))
}
// CheckModeEqualFold applies the EqualFold predicate on the "check_mode" field.
func CheckModeEqualFold(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEqualFold(FieldCheckMode, v))
}
// CheckModeContainsFold applies the ContainsFold predicate on the "check_mode" field.
func CheckModeContainsFold(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldContainsFold(FieldCheckMode, v))
}
// AccountIDEQ applies the EQ predicate on the "account_id" field.
func AccountIDEQ(v int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldAccountID, v))
}
// AccountIDNEQ applies the NEQ predicate on the "account_id" field.
func AccountIDNEQ(v int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldNEQ(FieldAccountID, v))
}
// AccountIDIn applies the In predicate on the "account_id" field.
func AccountIDIn(vs ...int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldIn(FieldAccountID, vs...))
}
// AccountIDNotIn applies the NotIn predicate on the "account_id" field.
func AccountIDNotIn(vs ...int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldNotIn(FieldAccountID, vs...))
}
// AccountIDGT applies the GT predicate on the "account_id" field.
func AccountIDGT(v int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldGT(FieldAccountID, v))
}
// AccountIDGTE applies the GTE predicate on the "account_id" field.
func AccountIDGTE(v int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldGTE(FieldAccountID, v))
}
// AccountIDLT applies the LT predicate on the "account_id" field.
func AccountIDLT(v int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldLT(FieldAccountID, v))
}
// AccountIDLTE applies the LTE predicate on the "account_id" field.
func AccountIDLTE(v int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldLTE(FieldAccountID, v))
}
// AccountIDIsNil applies the IsNil predicate on the "account_id" field.
func AccountIDIsNil() predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldIsNull(FieldAccountID))
}
// AccountIDNotNil applies the NotNil predicate on the "account_id" field.
func AccountIDNotNil() predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldNotNull(FieldAccountID))
}
// APIModeEQ applies the EQ predicate on the "api_mode" field.
func APIModeEQ(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldAPIMode, v))
+168
View File
@@ -65,6 +65,34 @@ func (_c *ChannelMonitorCreate) SetProvider(v channelmonitor.Provider) *ChannelM
return _c
}
// SetCheckMode sets the "check_mode" field.
func (_c *ChannelMonitorCreate) SetCheckMode(v string) *ChannelMonitorCreate {
_c.mutation.SetCheckMode(v)
return _c
}
// SetNillableCheckMode sets the "check_mode" field if the given value is not nil.
func (_c *ChannelMonitorCreate) SetNillableCheckMode(v *string) *ChannelMonitorCreate {
if v != nil {
_c.SetCheckMode(*v)
}
return _c
}
// SetAccountID sets the "account_id" field.
func (_c *ChannelMonitorCreate) SetAccountID(v int64) *ChannelMonitorCreate {
_c.mutation.SetAccountID(v)
return _c
}
// SetNillableAccountID sets the "account_id" field if the given value is not nil.
func (_c *ChannelMonitorCreate) SetNillableAccountID(v *int64) *ChannelMonitorCreate {
if v != nil {
_c.SetAccountID(*v)
}
return _c
}
// SetAPIMode sets the "api_mode" field.
func (_c *ChannelMonitorCreate) SetAPIMode(v string) *ChannelMonitorCreate {
_c.mutation.SetAPIMode(v)
@@ -303,6 +331,10 @@ func (_c *ChannelMonitorCreate) defaults() {
v := channelmonitor.DefaultUpdatedAt()
_c.mutation.SetUpdatedAt(v)
}
if _, ok := _c.mutation.CheckMode(); !ok {
v := channelmonitor.DefaultCheckMode
_c.mutation.SetCheckMode(v)
}
if _, ok := _c.mutation.APIMode(); !ok {
v := channelmonitor.DefaultAPIMode
_c.mutation.SetAPIMode(v)
@@ -357,6 +389,14 @@ func (_c *ChannelMonitorCreate) check() error {
return &ValidationError{Name: "provider", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.provider": %w`, err)}
}
}
if _, ok := _c.mutation.CheckMode(); !ok {
return &ValidationError{Name: "check_mode", err: errors.New(`ent: missing required field "ChannelMonitor.check_mode"`)}
}
if v, ok := _c.mutation.CheckMode(); ok {
if err := channelmonitor.CheckModeValidator(v); err != nil {
return &ValidationError{Name: "check_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.check_mode": %w`, err)}
}
}
if _, ok := _c.mutation.APIMode(); !ok {
return &ValidationError{Name: "api_mode", err: errors.New(`ent: missing required field "ChannelMonitor.api_mode"`)}
}
@@ -473,6 +513,14 @@ func (_c *ChannelMonitorCreate) createSpec() (*ChannelMonitor, *sqlgraph.CreateS
_spec.SetField(channelmonitor.FieldProvider, field.TypeEnum, value)
_node.Provider = value
}
if value, ok := _c.mutation.CheckMode(); ok {
_spec.SetField(channelmonitor.FieldCheckMode, field.TypeString, value)
_node.CheckMode = value
}
if value, ok := _c.mutation.AccountID(); ok {
_spec.SetField(channelmonitor.FieldAccountID, field.TypeInt64, value)
_node.AccountID = &value
}
if value, ok := _c.mutation.APIMode(); ok {
_spec.SetField(channelmonitor.FieldAPIMode, field.TypeString, value)
_node.APIMode = value
@@ -666,6 +714,42 @@ func (u *ChannelMonitorUpsert) UpdateProvider() *ChannelMonitorUpsert {
return u
}
// SetCheckMode sets the "check_mode" field.
func (u *ChannelMonitorUpsert) SetCheckMode(v string) *ChannelMonitorUpsert {
u.Set(channelmonitor.FieldCheckMode, v)
return u
}
// UpdateCheckMode sets the "check_mode" field to the value that was provided on create.
func (u *ChannelMonitorUpsert) UpdateCheckMode() *ChannelMonitorUpsert {
u.SetExcluded(channelmonitor.FieldCheckMode)
return u
}
// SetAccountID sets the "account_id" field.
func (u *ChannelMonitorUpsert) SetAccountID(v int64) *ChannelMonitorUpsert {
u.Set(channelmonitor.FieldAccountID, v)
return u
}
// UpdateAccountID sets the "account_id" field to the value that was provided on create.
func (u *ChannelMonitorUpsert) UpdateAccountID() *ChannelMonitorUpsert {
u.SetExcluded(channelmonitor.FieldAccountID)
return u
}
// AddAccountID adds v to the "account_id" field.
func (u *ChannelMonitorUpsert) AddAccountID(v int64) *ChannelMonitorUpsert {
u.Add(channelmonitor.FieldAccountID, v)
return u
}
// ClearAccountID clears the value of the "account_id" field.
func (u *ChannelMonitorUpsert) ClearAccountID() *ChannelMonitorUpsert {
u.SetNull(channelmonitor.FieldAccountID)
return u
}
// SetAPIMode sets the "api_mode" field.
func (u *ChannelMonitorUpsert) SetAPIMode(v string) *ChannelMonitorUpsert {
u.Set(channelmonitor.FieldAPIMode, v)
@@ -975,6 +1059,48 @@ func (u *ChannelMonitorUpsertOne) UpdateProvider() *ChannelMonitorUpsertOne {
})
}
// SetCheckMode sets the "check_mode" field.
func (u *ChannelMonitorUpsertOne) SetCheckMode(v string) *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.SetCheckMode(v)
})
}
// UpdateCheckMode sets the "check_mode" field to the value that was provided on create.
func (u *ChannelMonitorUpsertOne) UpdateCheckMode() *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.UpdateCheckMode()
})
}
// SetAccountID sets the "account_id" field.
func (u *ChannelMonitorUpsertOne) SetAccountID(v int64) *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.SetAccountID(v)
})
}
// AddAccountID adds v to the "account_id" field.
func (u *ChannelMonitorUpsertOne) AddAccountID(v int64) *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.AddAccountID(v)
})
}
// UpdateAccountID sets the "account_id" field to the value that was provided on create.
func (u *ChannelMonitorUpsertOne) UpdateAccountID() *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.UpdateAccountID()
})
}
// ClearAccountID clears the value of the "account_id" field.
func (u *ChannelMonitorUpsertOne) ClearAccountID() *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.ClearAccountID()
})
}
// SetAPIMode sets the "api_mode" field.
func (u *ChannelMonitorUpsertOne) SetAPIMode(v string) *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
@@ -1487,6 +1613,48 @@ func (u *ChannelMonitorUpsertBulk) UpdateProvider() *ChannelMonitorUpsertBulk {
})
}
// SetCheckMode sets the "check_mode" field.
func (u *ChannelMonitorUpsertBulk) SetCheckMode(v string) *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.SetCheckMode(v)
})
}
// UpdateCheckMode sets the "check_mode" field to the value that was provided on create.
func (u *ChannelMonitorUpsertBulk) UpdateCheckMode() *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.UpdateCheckMode()
})
}
// SetAccountID sets the "account_id" field.
func (u *ChannelMonitorUpsertBulk) SetAccountID(v int64) *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.SetAccountID(v)
})
}
// AddAccountID adds v to the "account_id" field.
func (u *ChannelMonitorUpsertBulk) AddAccountID(v int64) *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.AddAccountID(v)
})
}
// UpdateAccountID sets the "account_id" field to the value that was provided on create.
func (u *ChannelMonitorUpsertBulk) UpdateAccountID() *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.UpdateAccountID()
})
}
// ClearAccountID clears the value of the "account_id" field.
func (u *ChannelMonitorUpsertBulk) ClearAccountID() *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.ClearAccountID()
})
}
// SetAPIMode sets the "api_mode" field.
func (u *ChannelMonitorUpsertBulk) SetAPIMode(v string) *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
+116
View File
@@ -66,6 +66,47 @@ func (_u *ChannelMonitorUpdate) SetNillableProvider(v *channelmonitor.Provider)
return _u
}
// SetCheckMode sets the "check_mode" field.
func (_u *ChannelMonitorUpdate) SetCheckMode(v string) *ChannelMonitorUpdate {
_u.mutation.SetCheckMode(v)
return _u
}
// SetNillableCheckMode sets the "check_mode" field if the given value is not nil.
func (_u *ChannelMonitorUpdate) SetNillableCheckMode(v *string) *ChannelMonitorUpdate {
if v != nil {
_u.SetCheckMode(*v)
}
return _u
}
// SetAccountID sets the "account_id" field.
func (_u *ChannelMonitorUpdate) SetAccountID(v int64) *ChannelMonitorUpdate {
_u.mutation.ResetAccountID()
_u.mutation.SetAccountID(v)
return _u
}
// SetNillableAccountID sets the "account_id" field if the given value is not nil.
func (_u *ChannelMonitorUpdate) SetNillableAccountID(v *int64) *ChannelMonitorUpdate {
if v != nil {
_u.SetAccountID(*v)
}
return _u
}
// AddAccountID adds value to the "account_id" field.
func (_u *ChannelMonitorUpdate) AddAccountID(v int64) *ChannelMonitorUpdate {
_u.mutation.AddAccountID(v)
return _u
}
// ClearAccountID clears the value of the "account_id" field.
func (_u *ChannelMonitorUpdate) ClearAccountID() *ChannelMonitorUpdate {
_u.mutation.ClearAccountID()
return _u
}
// SetAPIMode sets the "api_mode" field.
func (_u *ChannelMonitorUpdate) SetAPIMode(v string) *ChannelMonitorUpdate {
_u.mutation.SetAPIMode(v)
@@ -453,6 +494,11 @@ func (_u *ChannelMonitorUpdate) check() error {
return &ValidationError{Name: "provider", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.provider": %w`, err)}
}
}
if v, ok := _u.mutation.CheckMode(); ok {
if err := channelmonitor.CheckModeValidator(v); err != nil {
return &ValidationError{Name: "check_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.check_mode": %w`, err)}
}
}
if v, ok := _u.mutation.APIMode(); ok {
if err := channelmonitor.APIModeValidator(v); err != nil {
return &ValidationError{Name: "api_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.api_mode": %w`, err)}
@@ -517,6 +563,18 @@ func (_u *ChannelMonitorUpdate) sqlSave(ctx context.Context) (_node int, err err
if value, ok := _u.mutation.Provider(); ok {
_spec.SetField(channelmonitor.FieldProvider, field.TypeEnum, value)
}
if value, ok := _u.mutation.CheckMode(); ok {
_spec.SetField(channelmonitor.FieldCheckMode, field.TypeString, value)
}
if value, ok := _u.mutation.AccountID(); ok {
_spec.SetField(channelmonitor.FieldAccountID, field.TypeInt64, value)
}
if value, ok := _u.mutation.AddedAccountID(); ok {
_spec.AddField(channelmonitor.FieldAccountID, field.TypeInt64, value)
}
if _u.mutation.AccountIDCleared() {
_spec.ClearField(channelmonitor.FieldAccountID, field.TypeInt64)
}
if value, ok := _u.mutation.APIMode(); ok {
_spec.SetField(channelmonitor.FieldAPIMode, field.TypeString, value)
}
@@ -755,6 +813,47 @@ func (_u *ChannelMonitorUpdateOne) SetNillableProvider(v *channelmonitor.Provide
return _u
}
// SetCheckMode sets the "check_mode" field.
func (_u *ChannelMonitorUpdateOne) SetCheckMode(v string) *ChannelMonitorUpdateOne {
_u.mutation.SetCheckMode(v)
return _u
}
// SetNillableCheckMode sets the "check_mode" field if the given value is not nil.
func (_u *ChannelMonitorUpdateOne) SetNillableCheckMode(v *string) *ChannelMonitorUpdateOne {
if v != nil {
_u.SetCheckMode(*v)
}
return _u
}
// SetAccountID sets the "account_id" field.
func (_u *ChannelMonitorUpdateOne) SetAccountID(v int64) *ChannelMonitorUpdateOne {
_u.mutation.ResetAccountID()
_u.mutation.SetAccountID(v)
return _u
}
// SetNillableAccountID sets the "account_id" field if the given value is not nil.
func (_u *ChannelMonitorUpdateOne) SetNillableAccountID(v *int64) *ChannelMonitorUpdateOne {
if v != nil {
_u.SetAccountID(*v)
}
return _u
}
// AddAccountID adds value to the "account_id" field.
func (_u *ChannelMonitorUpdateOne) AddAccountID(v int64) *ChannelMonitorUpdateOne {
_u.mutation.AddAccountID(v)
return _u
}
// ClearAccountID clears the value of the "account_id" field.
func (_u *ChannelMonitorUpdateOne) ClearAccountID() *ChannelMonitorUpdateOne {
_u.mutation.ClearAccountID()
return _u
}
// SetAPIMode sets the "api_mode" field.
func (_u *ChannelMonitorUpdateOne) SetAPIMode(v string) *ChannelMonitorUpdateOne {
_u.mutation.SetAPIMode(v)
@@ -1155,6 +1254,11 @@ func (_u *ChannelMonitorUpdateOne) check() error {
return &ValidationError{Name: "provider", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.provider": %w`, err)}
}
}
if v, ok := _u.mutation.CheckMode(); ok {
if err := channelmonitor.CheckModeValidator(v); err != nil {
return &ValidationError{Name: "check_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.check_mode": %w`, err)}
}
}
if v, ok := _u.mutation.APIMode(); ok {
if err := channelmonitor.APIModeValidator(v); err != nil {
return &ValidationError{Name: "api_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.api_mode": %w`, err)}
@@ -1236,6 +1340,18 @@ func (_u *ChannelMonitorUpdateOne) sqlSave(ctx context.Context) (_node *ChannelM
if value, ok := _u.mutation.Provider(); ok {
_spec.SetField(channelmonitor.FieldProvider, field.TypeEnum, value)
}
if value, ok := _u.mutation.CheckMode(); ok {
_spec.SetField(channelmonitor.FieldCheckMode, field.TypeString, value)
}
if value, ok := _u.mutation.AccountID(); ok {
_spec.SetField(channelmonitor.FieldAccountID, field.TypeInt64, value)
}
if value, ok := _u.mutation.AddedAccountID(); ok {
_spec.AddField(channelmonitor.FieldAccountID, field.TypeInt64, value)
}
if _u.mutation.AccountIDCleared() {
_spec.ClearField(channelmonitor.FieldAccountID, field.TypeInt64)
}
if value, ok := _u.mutation.APIMode(); ok {
_spec.SetField(channelmonitor.FieldAPIMode, field.TypeString, value)
}
+17
View File
@@ -3,6 +3,7 @@
package ent
import (
"encoding/json"
"fmt"
"strings"
"time"
@@ -11,6 +12,7 @@ import (
"entgo.io/ent/dialect/sql"
"github.com/Wei-Shaw/sub2api/ent/channelmonitor"
"github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory"
"github.com/Wei-Shaw/sub2api/internal/domain"
)
// ChannelMonitorHistory is the model entity for the ChannelMonitorHistory schema.
@@ -30,6 +32,8 @@ type ChannelMonitorHistory struct {
PingLatencyMs *int `json:"ping_latency_ms,omitempty"`
// Message holds the value of the "message" field.
Message string `json:"message,omitempty"`
// Quota holds the value of the "quota" field.
Quota *domain.MonitorQuotaSnapshot `json:"quota,omitempty"`
// CheckedAt holds the value of the "checked_at" field.
CheckedAt time.Time `json:"checked_at,omitempty"`
// Edges holds the relations/edges for other nodes in the graph.
@@ -63,6 +67,8 @@ func (*ChannelMonitorHistory) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
case channelmonitorhistory.FieldQuota:
values[i] = new([]byte)
case channelmonitorhistory.FieldID, channelmonitorhistory.FieldMonitorID, channelmonitorhistory.FieldLatencyMs, channelmonitorhistory.FieldPingLatencyMs:
values[i] = new(sql.NullInt64)
case channelmonitorhistory.FieldModel, channelmonitorhistory.FieldStatus, channelmonitorhistory.FieldMessage:
@@ -128,6 +134,14 @@ func (_m *ChannelMonitorHistory) assignValues(columns []string, values []any) er
} else if value.Valid {
_m.Message = value.String
}
case channelmonitorhistory.FieldQuota:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field quota", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.Quota); err != nil {
return fmt.Errorf("unmarshal field quota: %w", err)
}
}
case channelmonitorhistory.FieldCheckedAt:
if value, ok := values[i].(*sql.NullTime); !ok {
return fmt.Errorf("unexpected type %T for field checked_at", values[i])
@@ -197,6 +211,9 @@ func (_m *ChannelMonitorHistory) String() string {
builder.WriteString("message=")
builder.WriteString(_m.Message)
builder.WriteString(", ")
builder.WriteString("quota=")
builder.WriteString(fmt.Sprintf("%v", _m.Quota))
builder.WriteString(", ")
builder.WriteString("checked_at=")
builder.WriteString(_m.CheckedAt.Format(time.ANSIC))
builder.WriteByte(')')
@@ -27,6 +27,8 @@ const (
FieldPingLatencyMs = "ping_latency_ms"
// FieldMessage holds the string denoting the message field in the database.
FieldMessage = "message"
// FieldQuota holds the string denoting the quota field in the database.
FieldQuota = "quota"
// FieldCheckedAt holds the string denoting the checked_at field in the database.
FieldCheckedAt = "checked_at"
// EdgeMonitor holds the string denoting the monitor edge name in mutations.
@@ -51,6 +53,7 @@ var Columns = []string{
FieldLatencyMs,
FieldPingLatencyMs,
FieldMessage,
FieldQuota,
FieldCheckedAt,
}
@@ -365,6 +365,16 @@ func MessageContainsFold(v string) predicate.ChannelMonitorHistory {
return predicate.ChannelMonitorHistory(sql.FieldContainsFold(FieldMessage, v))
}
// QuotaIsNil applies the IsNil predicate on the "quota" field.
func QuotaIsNil() predicate.ChannelMonitorHistory {
return predicate.ChannelMonitorHistory(sql.FieldIsNull(FieldQuota))
}
// QuotaNotNil applies the NotNil predicate on the "quota" field.
func QuotaNotNil() predicate.ChannelMonitorHistory {
return predicate.ChannelMonitorHistory(sql.FieldNotNull(FieldQuota))
}
// CheckedAtEQ applies the EQ predicate on the "checked_at" field.
func CheckedAtEQ(v time.Time) predicate.ChannelMonitorHistory {
return predicate.ChannelMonitorHistory(sql.FieldEQ(FieldCheckedAt, v))
@@ -13,6 +13,7 @@ import (
"entgo.io/ent/schema/field"
"github.com/Wei-Shaw/sub2api/ent/channelmonitor"
"github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory"
"github.com/Wei-Shaw/sub2api/internal/domain"
)
// ChannelMonitorHistoryCreate is the builder for creating a ChannelMonitorHistory entity.
@@ -83,6 +84,12 @@ func (_c *ChannelMonitorHistoryCreate) SetNillableMessage(v *string) *ChannelMon
return _c
}
// SetQuota sets the "quota" field.
func (_c *ChannelMonitorHistoryCreate) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryCreate {
_c.mutation.SetQuota(v)
return _c
}
// SetCheckedAt sets the "checked_at" field.
func (_c *ChannelMonitorHistoryCreate) SetCheckedAt(v time.Time) *ChannelMonitorHistoryCreate {
_c.mutation.SetCheckedAt(v)
@@ -226,6 +233,10 @@ func (_c *ChannelMonitorHistoryCreate) createSpec() (*ChannelMonitorHistory, *sq
_spec.SetField(channelmonitorhistory.FieldMessage, field.TypeString, value)
_node.Message = value
}
if value, ok := _c.mutation.Quota(); ok {
_spec.SetField(channelmonitorhistory.FieldQuota, field.TypeJSON, value)
_node.Quota = value
}
if value, ok := _c.mutation.CheckedAt(); ok {
_spec.SetField(channelmonitorhistory.FieldCheckedAt, field.TypeTime, value)
_node.CheckedAt = value
@@ -401,6 +412,24 @@ func (u *ChannelMonitorHistoryUpsert) ClearMessage() *ChannelMonitorHistoryUpser
return u
}
// SetQuota sets the "quota" field.
func (u *ChannelMonitorHistoryUpsert) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpsert {
u.Set(channelmonitorhistory.FieldQuota, v)
return u
}
// UpdateQuota sets the "quota" field to the value that was provided on create.
func (u *ChannelMonitorHistoryUpsert) UpdateQuota() *ChannelMonitorHistoryUpsert {
u.SetExcluded(channelmonitorhistory.FieldQuota)
return u
}
// ClearQuota clears the value of the "quota" field.
func (u *ChannelMonitorHistoryUpsert) ClearQuota() *ChannelMonitorHistoryUpsert {
u.SetNull(channelmonitorhistory.FieldQuota)
return u
}
// SetCheckedAt sets the "checked_at" field.
func (u *ChannelMonitorHistoryUpsert) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpsert {
u.Set(channelmonitorhistory.FieldCheckedAt, v)
@@ -572,6 +601,27 @@ func (u *ChannelMonitorHistoryUpsertOne) ClearMessage() *ChannelMonitorHistoryUp
})
}
// SetQuota sets the "quota" field.
func (u *ChannelMonitorHistoryUpsertOne) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpsertOne {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
s.SetQuota(v)
})
}
// UpdateQuota sets the "quota" field to the value that was provided on create.
func (u *ChannelMonitorHistoryUpsertOne) UpdateQuota() *ChannelMonitorHistoryUpsertOne {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
s.UpdateQuota()
})
}
// ClearQuota clears the value of the "quota" field.
func (u *ChannelMonitorHistoryUpsertOne) ClearQuota() *ChannelMonitorHistoryUpsertOne {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
s.ClearQuota()
})
}
// SetCheckedAt sets the "checked_at" field.
func (u *ChannelMonitorHistoryUpsertOne) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpsertOne {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
@@ -909,6 +959,27 @@ func (u *ChannelMonitorHistoryUpsertBulk) ClearMessage() *ChannelMonitorHistoryU
})
}
// SetQuota sets the "quota" field.
func (u *ChannelMonitorHistoryUpsertBulk) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpsertBulk {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
s.SetQuota(v)
})
}
// UpdateQuota sets the "quota" field to the value that was provided on create.
func (u *ChannelMonitorHistoryUpsertBulk) UpdateQuota() *ChannelMonitorHistoryUpsertBulk {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
s.UpdateQuota()
})
}
// ClearQuota clears the value of the "quota" field.
func (u *ChannelMonitorHistoryUpsertBulk) ClearQuota() *ChannelMonitorHistoryUpsertBulk {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
s.ClearQuota()
})
}
// SetCheckedAt sets the "checked_at" field.
func (u *ChannelMonitorHistoryUpsertBulk) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpsertBulk {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
@@ -14,6 +14,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/channelmonitor"
"github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory"
"github.com/Wei-Shaw/sub2api/ent/predicate"
"github.com/Wei-Shaw/sub2api/internal/domain"
)
// ChannelMonitorHistoryUpdate is the builder for updating ChannelMonitorHistory entities.
@@ -145,6 +146,18 @@ func (_u *ChannelMonitorHistoryUpdate) ClearMessage() *ChannelMonitorHistoryUpda
return _u
}
// SetQuota sets the "quota" field.
func (_u *ChannelMonitorHistoryUpdate) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpdate {
_u.mutation.SetQuota(v)
return _u
}
// ClearQuota clears the value of the "quota" field.
func (_u *ChannelMonitorHistoryUpdate) ClearQuota() *ChannelMonitorHistoryUpdate {
_u.mutation.ClearQuota()
return _u
}
// SetCheckedAt sets the "checked_at" field.
func (_u *ChannelMonitorHistoryUpdate) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpdate {
_u.mutation.SetCheckedAt(v)
@@ -267,6 +280,12 @@ func (_u *ChannelMonitorHistoryUpdate) sqlSave(ctx context.Context) (_node int,
if _u.mutation.MessageCleared() {
_spec.ClearField(channelmonitorhistory.FieldMessage, field.TypeString)
}
if value, ok := _u.mutation.Quota(); ok {
_spec.SetField(channelmonitorhistory.FieldQuota, field.TypeJSON, value)
}
if _u.mutation.QuotaCleared() {
_spec.ClearField(channelmonitorhistory.FieldQuota, field.TypeJSON)
}
if value, ok := _u.mutation.CheckedAt(); ok {
_spec.SetField(channelmonitorhistory.FieldCheckedAt, field.TypeTime, value)
}
@@ -435,6 +454,18 @@ func (_u *ChannelMonitorHistoryUpdateOne) ClearMessage() *ChannelMonitorHistoryU
return _u
}
// SetQuota sets the "quota" field.
func (_u *ChannelMonitorHistoryUpdateOne) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpdateOne {
_u.mutation.SetQuota(v)
return _u
}
// ClearQuota clears the value of the "quota" field.
func (_u *ChannelMonitorHistoryUpdateOne) ClearQuota() *ChannelMonitorHistoryUpdateOne {
_u.mutation.ClearQuota()
return _u
}
// SetCheckedAt sets the "checked_at" field.
func (_u *ChannelMonitorHistoryUpdateOne) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpdateOne {
_u.mutation.SetCheckedAt(v)
@@ -587,6 +618,12 @@ func (_u *ChannelMonitorHistoryUpdateOne) sqlSave(ctx context.Context) (_node *C
if _u.mutation.MessageCleared() {
_spec.ClearField(channelmonitorhistory.FieldMessage, field.TypeString)
}
if value, ok := _u.mutation.Quota(); ok {
_spec.SetField(channelmonitorhistory.FieldQuota, field.TypeJSON, value)
}
if _u.mutation.QuotaCleared() {
_spec.ClearField(channelmonitorhistory.FieldQuota, field.TypeJSON)
}
if value, ok := _u.mutation.CheckedAt(); ok {
_spec.SetField(channelmonitorhistory.FieldCheckedAt, field.TypeTime, value)
}
@@ -100,10 +100,14 @@ type Provider string
// Provider values.
const (
ProviderOpenai Provider = "openai"
ProviderAnthropic Provider = "anthropic"
ProviderGemini Provider = "gemini"
ProviderGrok Provider = "grok"
ProviderOpenai Provider = "openai"
ProviderAnthropic Provider = "anthropic"
ProviderGemini Provider = "gemini"
ProviderGrok Provider = "grok"
ProviderAntigravity Provider = "antigravity"
ProviderKimi Provider = "kimi"
ProviderZhipu Provider = "zhipu"
ProviderDeepseek Provider = "deepseek"
)
func (pr Provider) String() string {
@@ -113,7 +117,7 @@ func (pr Provider) String() string {
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
func ProviderValidator(pr Provider) error {
switch pr {
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok:
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek:
return nil
default:
return fmt.Errorf("channelmonitorrequesttemplate: invalid enum value for provider field: %q", pr)
+1 -1
View File
@@ -97,7 +97,7 @@ type Group struct {
AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars,omitempty"`
// STT 每小时价格(USD)
AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour,omitempty"`
// 是否按上下文长度应用模型阶梯价格
// 是否按上下文长度应用模型阶梯价格;默认开启以保持官方/渠道长上下文价
LongContextPricingEnabled bool `json:"long_context_pricing_enabled,omitempty"`
// 分组逐模型定价;优先级高于渠道和内置定价
ModelPricing json.RawMessage `json:"model_pricing,omitempty"`
+18 -10
View File
@@ -623,7 +623,9 @@ var (
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "name", Type: field.TypeString, Size: 100},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok"}},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}},
{Name: "check_mode", Type: field.TypeString, Size: 32, Default: "probe"},
{Name: "account_id", Type: field.TypeInt64, Nullable: true},
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
{Name: "endpoint", Type: field.TypeString, Size: 500},
{Name: "api_key_encrypted", Type: field.TypeString},
@@ -648,7 +650,7 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "channel_monitors_channel_monitor_request_templates_request_template",
Columns: []*schema.Column{ChannelMonitorsColumns[19]},
Columns: []*schema.Column{ChannelMonitorsColumns[21]},
RefColumns: []*schema.Column{ChannelMonitorRequestTemplatesColumns[0]},
OnDelete: schema.SetNull,
},
@@ -657,7 +659,7 @@ var (
{
Name: "channelmonitor_enabled_last_checked_at",
Unique: false,
Columns: []*schema.Column{ChannelMonitorsColumns[11], ChannelMonitorsColumns[14]},
Columns: []*schema.Column{ChannelMonitorsColumns[13], ChannelMonitorsColumns[16]},
},
{
Name: "channelmonitor_provider",
@@ -667,17 +669,22 @@ var (
{
Name: "channelmonitor_provider_api_mode",
Unique: false,
Columns: []*schema.Column{ChannelMonitorsColumns[4], ChannelMonitorsColumns[5]},
Columns: []*schema.Column{ChannelMonitorsColumns[4], ChannelMonitorsColumns[7]},
},
{
Name: "channelmonitor_group_name",
Unique: false,
Columns: []*schema.Column{ChannelMonitorsColumns[10]},
Columns: []*schema.Column{ChannelMonitorsColumns[12]},
},
{
Name: "channelmonitor_template_id",
Unique: false,
Columns: []*schema.Column{ChannelMonitorsColumns[19]},
Columns: []*schema.Column{ChannelMonitorsColumns[21]},
},
{
Name: "channelmonitor_account_id",
Unique: false,
Columns: []*schema.Column{ChannelMonitorsColumns[6]},
},
},
}
@@ -733,6 +740,7 @@ var (
{Name: "latency_ms", Type: field.TypeInt, Nullable: true},
{Name: "ping_latency_ms", Type: field.TypeInt, Nullable: true},
{Name: "message", Type: field.TypeString, Nullable: true, Size: 500, Default: ""},
{Name: "quota", Type: field.TypeJSON, Nullable: true},
{Name: "checked_at", Type: field.TypeTime},
{Name: "monitor_id", Type: field.TypeInt64},
}
@@ -744,7 +752,7 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "channel_monitor_histories_channel_monitors_history",
Columns: []*schema.Column{ChannelMonitorHistoriesColumns[7]},
Columns: []*schema.Column{ChannelMonitorHistoriesColumns[8]},
RefColumns: []*schema.Column{ChannelMonitorsColumns[0]},
OnDelete: schema.Cascade,
},
@@ -753,12 +761,12 @@ var (
{
Name: "channelmonitorhistory_monitor_id_model_checked_at",
Unique: false,
Columns: []*schema.Column{ChannelMonitorHistoriesColumns[7], ChannelMonitorHistoriesColumns[1], ChannelMonitorHistoriesColumns[6]},
Columns: []*schema.Column{ChannelMonitorHistoriesColumns[8], ChannelMonitorHistoriesColumns[1], ChannelMonitorHistoriesColumns[7]},
},
{
Name: "channelmonitorhistory_checked_at",
Unique: false,
Columns: []*schema.Column{ChannelMonitorHistoriesColumns[6]},
Columns: []*schema.Column{ChannelMonitorHistoriesColumns[7]},
},
},
}
@@ -768,7 +776,7 @@ var (
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "name", Type: field.TypeString, Size: 100},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok"}},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}},
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
{Name: "description", Type: field.TypeString, Nullable: true, Size: 500, Default: ""},
{Name: "extra_headers", Type: field.TypeJSON},
+236 -2
View File
@@ -14608,6 +14608,9 @@ type ChannelMonitorMutation struct {
updated_at *time.Time
name *string
provider *channelmonitor.Provider
check_mode *string
account_id *int64
addaccount_id *int64
api_mode *string
endpoint *string
api_key_encrypted *string
@@ -14882,6 +14885,112 @@ func (m *ChannelMonitorMutation) ResetProvider() {
m.provider = nil
}
// SetCheckMode sets the "check_mode" field.
func (m *ChannelMonitorMutation) SetCheckMode(s string) {
m.check_mode = &s
}
// CheckMode returns the value of the "check_mode" field in the mutation.
func (m *ChannelMonitorMutation) CheckMode() (r string, exists bool) {
v := m.check_mode
if v == nil {
return
}
return *v, true
}
// OldCheckMode returns the old "check_mode" field's value of the ChannelMonitor entity.
// If the ChannelMonitor 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 *ChannelMonitorMutation) OldCheckMode(ctx context.Context) (v string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldCheckMode is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldCheckMode requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldCheckMode: %w", err)
}
return oldValue.CheckMode, nil
}
// ResetCheckMode resets all changes to the "check_mode" field.
func (m *ChannelMonitorMutation) ResetCheckMode() {
m.check_mode = nil
}
// SetAccountID sets the "account_id" field.
func (m *ChannelMonitorMutation) SetAccountID(i int64) {
m.account_id = &i
m.addaccount_id = nil
}
// AccountID returns the value of the "account_id" field in the mutation.
func (m *ChannelMonitorMutation) AccountID() (r int64, exists bool) {
v := m.account_id
if v == nil {
return
}
return *v, true
}
// OldAccountID returns the old "account_id" field's value of the ChannelMonitor entity.
// If the ChannelMonitor 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 *ChannelMonitorMutation) OldAccountID(ctx context.Context) (v *int64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldAccountID is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldAccountID requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldAccountID: %w", err)
}
return oldValue.AccountID, nil
}
// AddAccountID adds i to the "account_id" field.
func (m *ChannelMonitorMutation) AddAccountID(i int64) {
if m.addaccount_id != nil {
*m.addaccount_id += i
} else {
m.addaccount_id = &i
}
}
// AddedAccountID returns the value that was added to the "account_id" field in this mutation.
func (m *ChannelMonitorMutation) AddedAccountID() (r int64, exists bool) {
v := m.addaccount_id
if v == nil {
return
}
return *v, true
}
// ClearAccountID clears the value of the "account_id" field.
func (m *ChannelMonitorMutation) ClearAccountID() {
m.account_id = nil
m.addaccount_id = nil
m.clearedFields[channelmonitor.FieldAccountID] = struct{}{}
}
// AccountIDCleared returns if the "account_id" field was cleared in this mutation.
func (m *ChannelMonitorMutation) AccountIDCleared() bool {
_, ok := m.clearedFields[channelmonitor.FieldAccountID]
return ok
}
// ResetAccountID resets all changes to the "account_id" field.
func (m *ChannelMonitorMutation) ResetAccountID() {
m.account_id = nil
m.addaccount_id = nil
delete(m.clearedFields, channelmonitor.FieldAccountID)
}
// SetAPIMode sets the "api_mode" field.
func (m *ChannelMonitorMutation) SetAPIMode(s string) {
m.api_mode = &s
@@ -15731,7 +15840,7 @@ func (m *ChannelMonitorMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *ChannelMonitorMutation) Fields() []string {
fields := make([]string, 0, 19)
fields := make([]string, 0, 21)
if m.created_at != nil {
fields = append(fields, channelmonitor.FieldCreatedAt)
}
@@ -15744,6 +15853,12 @@ func (m *ChannelMonitorMutation) Fields() []string {
if m.provider != nil {
fields = append(fields, channelmonitor.FieldProvider)
}
if m.check_mode != nil {
fields = append(fields, channelmonitor.FieldCheckMode)
}
if m.account_id != nil {
fields = append(fields, channelmonitor.FieldAccountID)
}
if m.api_mode != nil {
fields = append(fields, channelmonitor.FieldAPIMode)
}
@@ -15805,6 +15920,10 @@ func (m *ChannelMonitorMutation) Field(name string) (ent.Value, bool) {
return m.Name()
case channelmonitor.FieldProvider:
return m.Provider()
case channelmonitor.FieldCheckMode:
return m.CheckMode()
case channelmonitor.FieldAccountID:
return m.AccountID()
case channelmonitor.FieldAPIMode:
return m.APIMode()
case channelmonitor.FieldEndpoint:
@@ -15852,6 +15971,10 @@ func (m *ChannelMonitorMutation) OldField(ctx context.Context, name string) (ent
return m.OldName(ctx)
case channelmonitor.FieldProvider:
return m.OldProvider(ctx)
case channelmonitor.FieldCheckMode:
return m.OldCheckMode(ctx)
case channelmonitor.FieldAccountID:
return m.OldAccountID(ctx)
case channelmonitor.FieldAPIMode:
return m.OldAPIMode(ctx)
case channelmonitor.FieldEndpoint:
@@ -15919,6 +16042,20 @@ func (m *ChannelMonitorMutation) SetField(name string, value ent.Value) error {
}
m.SetProvider(v)
return nil
case channelmonitor.FieldCheckMode:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetCheckMode(v)
return nil
case channelmonitor.FieldAccountID:
v, ok := value.(int64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetAccountID(v)
return nil
case channelmonitor.FieldAPIMode:
v, ok := value.(string)
if !ok {
@@ -16032,6 +16169,9 @@ func (m *ChannelMonitorMutation) SetField(name string, value ent.Value) error {
// this mutation.
func (m *ChannelMonitorMutation) AddedFields() []string {
var fields []string
if m.addaccount_id != nil {
fields = append(fields, channelmonitor.FieldAccountID)
}
if m.addinterval_seconds != nil {
fields = append(fields, channelmonitor.FieldIntervalSeconds)
}
@@ -16049,6 +16189,8 @@ func (m *ChannelMonitorMutation) AddedFields() []string {
// was not set, or was not defined in the schema.
func (m *ChannelMonitorMutation) AddedField(name string) (ent.Value, bool) {
switch name {
case channelmonitor.FieldAccountID:
return m.AddedAccountID()
case channelmonitor.FieldIntervalSeconds:
return m.AddedIntervalSeconds()
case channelmonitor.FieldJitterSeconds:
@@ -16064,6 +16206,13 @@ func (m *ChannelMonitorMutation) AddedField(name string) (ent.Value, bool) {
// type.
func (m *ChannelMonitorMutation) AddField(name string, value ent.Value) error {
switch name {
case channelmonitor.FieldAccountID:
v, ok := value.(int64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddAccountID(v)
return nil
case channelmonitor.FieldIntervalSeconds:
v, ok := value.(int)
if !ok {
@@ -16093,6 +16242,9 @@ func (m *ChannelMonitorMutation) AddField(name string, value ent.Value) error {
// mutation.
func (m *ChannelMonitorMutation) ClearedFields() []string {
var fields []string
if m.FieldCleared(channelmonitor.FieldAccountID) {
fields = append(fields, channelmonitor.FieldAccountID)
}
if m.FieldCleared(channelmonitor.FieldGroupName) {
fields = append(fields, channelmonitor.FieldGroupName)
}
@@ -16119,6 +16271,9 @@ func (m *ChannelMonitorMutation) FieldCleared(name string) bool {
// error if the field is not defined in the schema.
func (m *ChannelMonitorMutation) ClearField(name string) error {
switch name {
case channelmonitor.FieldAccountID:
m.ClearAccountID()
return nil
case channelmonitor.FieldGroupName:
m.ClearGroupName()
return nil
@@ -16151,6 +16306,12 @@ func (m *ChannelMonitorMutation) ResetField(name string) error {
case channelmonitor.FieldProvider:
m.ResetProvider()
return nil
case channelmonitor.FieldCheckMode:
m.ResetCheckMode()
return nil
case channelmonitor.FieldAccountID:
m.ResetAccountID()
return nil
case channelmonitor.FieldAPIMode:
m.ResetAPIMode()
return nil
@@ -17756,6 +17917,7 @@ type ChannelMonitorHistoryMutation struct {
ping_latency_ms *int
addping_latency_ms *int
message *string
quota **domain.MonitorQuotaSnapshot
checked_at *time.Time
clearedFields map[string]struct{}
monitor *int64
@@ -18160,6 +18322,55 @@ func (m *ChannelMonitorHistoryMutation) ResetMessage() {
delete(m.clearedFields, channelmonitorhistory.FieldMessage)
}
// SetQuota sets the "quota" field.
func (m *ChannelMonitorHistoryMutation) SetQuota(dqs *domain.MonitorQuotaSnapshot) {
m.quota = &dqs
}
// Quota returns the value of the "quota" field in the mutation.
func (m *ChannelMonitorHistoryMutation) Quota() (r *domain.MonitorQuotaSnapshot, exists bool) {
v := m.quota
if v == nil {
return
}
return *v, true
}
// OldQuota returns the old "quota" field's value of the ChannelMonitorHistory entity.
// If the ChannelMonitorHistory 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 *ChannelMonitorHistoryMutation) OldQuota(ctx context.Context) (v *domain.MonitorQuotaSnapshot, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldQuota is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldQuota requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldQuota: %w", err)
}
return oldValue.Quota, nil
}
// ClearQuota clears the value of the "quota" field.
func (m *ChannelMonitorHistoryMutation) ClearQuota() {
m.quota = nil
m.clearedFields[channelmonitorhistory.FieldQuota] = struct{}{}
}
// QuotaCleared returns if the "quota" field was cleared in this mutation.
func (m *ChannelMonitorHistoryMutation) QuotaCleared() bool {
_, ok := m.clearedFields[channelmonitorhistory.FieldQuota]
return ok
}
// ResetQuota resets all changes to the "quota" field.
func (m *ChannelMonitorHistoryMutation) ResetQuota() {
m.quota = nil
delete(m.clearedFields, channelmonitorhistory.FieldQuota)
}
// SetCheckedAt sets the "checked_at" field.
func (m *ChannelMonitorHistoryMutation) SetCheckedAt(t time.Time) {
m.checked_at = &t
@@ -18257,7 +18468,7 @@ func (m *ChannelMonitorHistoryMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *ChannelMonitorHistoryMutation) Fields() []string {
fields := make([]string, 0, 7)
fields := make([]string, 0, 8)
if m.monitor != nil {
fields = append(fields, channelmonitorhistory.FieldMonitorID)
}
@@ -18276,6 +18487,9 @@ func (m *ChannelMonitorHistoryMutation) Fields() []string {
if m.message != nil {
fields = append(fields, channelmonitorhistory.FieldMessage)
}
if m.quota != nil {
fields = append(fields, channelmonitorhistory.FieldQuota)
}
if m.checked_at != nil {
fields = append(fields, channelmonitorhistory.FieldCheckedAt)
}
@@ -18299,6 +18513,8 @@ func (m *ChannelMonitorHistoryMutation) Field(name string) (ent.Value, bool) {
return m.PingLatencyMs()
case channelmonitorhistory.FieldMessage:
return m.Message()
case channelmonitorhistory.FieldQuota:
return m.Quota()
case channelmonitorhistory.FieldCheckedAt:
return m.CheckedAt()
}
@@ -18322,6 +18538,8 @@ func (m *ChannelMonitorHistoryMutation) OldField(ctx context.Context, name strin
return m.OldPingLatencyMs(ctx)
case channelmonitorhistory.FieldMessage:
return m.OldMessage(ctx)
case channelmonitorhistory.FieldQuota:
return m.OldQuota(ctx)
case channelmonitorhistory.FieldCheckedAt:
return m.OldCheckedAt(ctx)
}
@@ -18375,6 +18593,13 @@ func (m *ChannelMonitorHistoryMutation) SetField(name string, value ent.Value) e
}
m.SetMessage(v)
return nil
case channelmonitorhistory.FieldQuota:
v, ok := value.(*domain.MonitorQuotaSnapshot)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetQuota(v)
return nil
case channelmonitorhistory.FieldCheckedAt:
v, ok := value.(time.Time)
if !ok {
@@ -18448,6 +18673,9 @@ func (m *ChannelMonitorHistoryMutation) ClearedFields() []string {
if m.FieldCleared(channelmonitorhistory.FieldMessage) {
fields = append(fields, channelmonitorhistory.FieldMessage)
}
if m.FieldCleared(channelmonitorhistory.FieldQuota) {
fields = append(fields, channelmonitorhistory.FieldQuota)
}
return fields
}
@@ -18471,6 +18699,9 @@ func (m *ChannelMonitorHistoryMutation) ClearField(name string) error {
case channelmonitorhistory.FieldMessage:
m.ClearMessage()
return nil
case channelmonitorhistory.FieldQuota:
m.ClearQuota()
return nil
}
return fmt.Errorf("unknown ChannelMonitorHistory nullable field %s", name)
}
@@ -18497,6 +18728,9 @@ func (m *ChannelMonitorHistoryMutation) ResetField(name string) error {
case channelmonitorhistory.FieldMessage:
m.ResetMessage()
return nil
case channelmonitorhistory.FieldQuota:
m.ResetQuota()
return nil
case channelmonitorhistory.FieldCheckedAt:
m.ResetCheckedAt()
return nil
+19 -27
View File
@@ -635,36 +635,28 @@ func init() {
return nil
}
}()
// channelmonitorDescCheckMode is the schema descriptor for check_mode field.
channelmonitorDescCheckMode := channelmonitorFields[2].Descriptor()
// channelmonitor.DefaultCheckMode holds the default value on creation for the check_mode field.
channelmonitor.DefaultCheckMode = channelmonitorDescCheckMode.Default.(string)
// channelmonitor.CheckModeValidator is a validator for the "check_mode" field. It is called by the builders before save.
channelmonitor.CheckModeValidator = channelmonitorDescCheckMode.Validators[0].(func(string) error)
// channelmonitorDescAPIMode is the schema descriptor for api_mode field.
channelmonitorDescAPIMode := channelmonitorFields[2].Descriptor()
channelmonitorDescAPIMode := channelmonitorFields[4].Descriptor()
// channelmonitor.DefaultAPIMode holds the default value on creation for the api_mode field.
channelmonitor.DefaultAPIMode = channelmonitorDescAPIMode.Default.(string)
// channelmonitor.APIModeValidator is a validator for the "api_mode" field. It is called by the builders before save.
channelmonitor.APIModeValidator = channelmonitorDescAPIMode.Validators[0].(func(string) error)
// channelmonitorDescEndpoint is the schema descriptor for endpoint field.
channelmonitorDescEndpoint := channelmonitorFields[3].Descriptor()
channelmonitorDescEndpoint := channelmonitorFields[5].Descriptor()
// channelmonitor.EndpointValidator is a validator for the "endpoint" field. It is called by the builders before save.
channelmonitor.EndpointValidator = func() func(string) error {
validators := channelmonitorDescEndpoint.Validators
fns := [...]func(string) error{
validators[0].(func(string) error),
validators[1].(func(string) error),
}
return func(endpoint string) error {
for _, fn := range fns {
if err := fn(endpoint); err != nil {
return err
}
}
return nil
}
}()
channelmonitor.EndpointValidator = channelmonitorDescEndpoint.Validators[0].(func(string) error)
// channelmonitorDescAPIKeyEncrypted is the schema descriptor for api_key_encrypted field.
channelmonitorDescAPIKeyEncrypted := channelmonitorFields[4].Descriptor()
channelmonitorDescAPIKeyEncrypted := channelmonitorFields[6].Descriptor()
// channelmonitor.APIKeyEncryptedValidator is a validator for the "api_key_encrypted" field. It is called by the builders before save.
channelmonitor.APIKeyEncryptedValidator = channelmonitorDescAPIKeyEncrypted.Validators[0].(func(string) error)
// channelmonitorDescPrimaryModel is the schema descriptor for primary_model field.
channelmonitorDescPrimaryModel := channelmonitorFields[5].Descriptor()
channelmonitorDescPrimaryModel := channelmonitorFields[7].Descriptor()
// channelmonitor.PrimaryModelValidator is a validator for the "primary_model" field. It is called by the builders before save.
channelmonitor.PrimaryModelValidator = func() func(string) error {
validators := channelmonitorDescPrimaryModel.Validators
@@ -682,35 +674,35 @@ func init() {
}
}()
// channelmonitorDescExtraModels is the schema descriptor for extra_models field.
channelmonitorDescExtraModels := channelmonitorFields[6].Descriptor()
channelmonitorDescExtraModels := channelmonitorFields[8].Descriptor()
// channelmonitor.DefaultExtraModels holds the default value on creation for the extra_models field.
channelmonitor.DefaultExtraModels = channelmonitorDescExtraModels.Default.([]string)
// channelmonitorDescGroupName is the schema descriptor for group_name field.
channelmonitorDescGroupName := channelmonitorFields[7].Descriptor()
channelmonitorDescGroupName := channelmonitorFields[9].Descriptor()
// channelmonitor.DefaultGroupName holds the default value on creation for the group_name field.
channelmonitor.DefaultGroupName = channelmonitorDescGroupName.Default.(string)
// channelmonitor.GroupNameValidator is a validator for the "group_name" field. It is called by the builders before save.
channelmonitor.GroupNameValidator = channelmonitorDescGroupName.Validators[0].(func(string) error)
// channelmonitorDescEnabled is the schema descriptor for enabled field.
channelmonitorDescEnabled := channelmonitorFields[8].Descriptor()
channelmonitorDescEnabled := channelmonitorFields[10].Descriptor()
// channelmonitor.DefaultEnabled holds the default value on creation for the enabled field.
channelmonitor.DefaultEnabled = channelmonitorDescEnabled.Default.(bool)
// channelmonitorDescIntervalSeconds is the schema descriptor for interval_seconds field.
channelmonitorDescIntervalSeconds := channelmonitorFields[9].Descriptor()
channelmonitorDescIntervalSeconds := channelmonitorFields[11].Descriptor()
// channelmonitor.IntervalSecondsValidator is a validator for the "interval_seconds" field. It is called by the builders before save.
channelmonitor.IntervalSecondsValidator = channelmonitorDescIntervalSeconds.Validators[0].(func(int) error)
// channelmonitorDescJitterSeconds is the schema descriptor for jitter_seconds field.
channelmonitorDescJitterSeconds := channelmonitorFields[10].Descriptor()
channelmonitorDescJitterSeconds := channelmonitorFields[12].Descriptor()
// channelmonitor.DefaultJitterSeconds holds the default value on creation for the jitter_seconds field.
channelmonitor.DefaultJitterSeconds = channelmonitorDescJitterSeconds.Default.(int)
// channelmonitor.JitterSecondsValidator is a validator for the "jitter_seconds" field. It is called by the builders before save.
channelmonitor.JitterSecondsValidator = channelmonitorDescJitterSeconds.Validators[0].(func(int) error)
// channelmonitorDescExtraHeaders is the schema descriptor for extra_headers field.
channelmonitorDescExtraHeaders := channelmonitorFields[14].Descriptor()
channelmonitorDescExtraHeaders := channelmonitorFields[16].Descriptor()
// channelmonitor.DefaultExtraHeaders holds the default value on creation for the extra_headers field.
channelmonitor.DefaultExtraHeaders = channelmonitorDescExtraHeaders.Default.(map[string]string)
// channelmonitorDescBodyOverrideMode is the schema descriptor for body_override_mode field.
channelmonitorDescBodyOverrideMode := channelmonitorFields[15].Descriptor()
channelmonitorDescBodyOverrideMode := channelmonitorFields[17].Descriptor()
// channelmonitor.DefaultBodyOverrideMode holds the default value on creation for the body_override_mode field.
channelmonitor.DefaultBodyOverrideMode = channelmonitorDescBodyOverrideMode.Default.(string)
// channelmonitor.BodyOverrideModeValidator is a validator for the "body_override_mode" field. It is called by the builders before save.
@@ -808,7 +800,7 @@ func init() {
// channelmonitorhistory.MessageValidator is a validator for the "message" field. It is called by the builders before save.
channelmonitorhistory.MessageValidator = channelmonitorhistoryDescMessage.Validators[0].(func(string) error)
// channelmonitorhistoryDescCheckedAt is the schema descriptor for checked_at field.
channelmonitorhistoryDescCheckedAt := channelmonitorhistoryFields[6].Descriptor()
channelmonitorhistoryDescCheckedAt := channelmonitorhistoryFields[7].Descriptor()
// channelmonitorhistory.DefaultCheckedAt holds the default value on creation for the checked_at field.
channelmonitorhistory.DefaultCheckedAt = channelmonitorhistoryDescCheckedAt.Default.(func() time.Time)
channelmonitorrequesttemplateMixin := schema.ChannelMonitorRequestTemplate{}.Mixin()
+21 -3
View File
@@ -35,15 +35,32 @@ func (ChannelMonitor) Fields() []ent.Field {
NotEmpty().
MaxLen(100),
field.Enum("provider").
Values("openai", "anthropic", "gemini", "grok"),
Values("openai", "anthropic", "gemini", "grok",
"antigravity", "kimi", "zhipu", "deepseek"),
// check_mode: 'probe' | 'quota' | 'quota_probe'
// probe - LLM 探活(默认,原有行为)
// quota - 仅查关联账号的用量/余额(零 LLM 成本;endpoint/api_key 可空)
// quota_probe - 探活 + 配额并存(配额快照挂到主模型历史行)
// antigravity 无探活 adapter,仅允许 quota。
field.String("check_mode").
Default("probe").
MaxLen(32).
Comment("probe = LLM probe (default); quota = account usage only; quota_probe = both"),
// account_id: 配额模式的数据源账号(复用账号侧用量服务,不直接对接上游)。
// 普通字段而非 edge(FK 由 SQL 迁移管理);账号删除时数据库置空,
// 监控保留并报「账号未关联」。
field.Int64("account_id").
Optional().
Nillable(),
field.String("api_mode").
Default("chat_completions").
MaxLen(32).
Comment("OpenAI request protocol: chat_completions or responses; non-OpenAI uses chat_completions"),
// endpoint: 探活模式必填(service 层校验);quota 模式存空串
// (列保持 NOT NULL,去掉 NotEmpty 校验器即可)。
field.String("endpoint").
NotEmpty().
MaxLen(500).
Comment("Provider base origin, e.g. https://api.openai.com"),
Comment("Provider base origin, e.g. https://api.openai.com; empty for quota-only monitors"),
field.String("api_key_encrypted").
NotEmpty().
Sensitive().
@@ -115,5 +132,6 @@ func (ChannelMonitor) Indexes() []ent.Index {
index.Fields("provider", "api_mode"),
index.Fields("group_name"),
index.Fields("template_id"),
index.Fields("account_id"),
}
}
@@ -3,6 +3,8 @@ package schema
import (
"time"
"github.com/Wei-Shaw/sub2api/internal/domain"
"entgo.io/ent"
"entgo.io/ent/dialect/entsql"
"entgo.io/ent/schema"
@@ -43,6 +45,10 @@ func (ChannelMonitorHistory) Fields() []ent.Field {
Optional().
Default("").
MaxLen(500),
// quota: 配额模式(check_mode = quota / quota_probe)检测时附带的
// 归一化配额快照(domain.MonitorQuotaSnapshot,JSONB);探活模式为 NULL。
field.JSON("quota", &domain.MonitorQuotaSnapshot{}).
Optional(),
field.Time("checked_at").
Default(time.Now),
}
@@ -39,7 +39,8 @@ func (ChannelMonitorRequestTemplate) Fields() []ent.Field {
NotEmpty().
MaxLen(100),
field.Enum("provider").
Values("openai", "anthropic", "gemini", "grok"),
Values("openai", "anthropic", "gemini", "grok",
"antigravity", "kimi", "zhipu", "deepseek"),
field.String("api_mode").
Default("chat_completions").
MaxLen(32).
+2 -1
View File
@@ -41,7 +41,8 @@ func (UserPlatformQuota) Fields() []ent.Field {
// 注意:平台列表的单一权威源为 service.AllowedQuotaPlatforms;
// 此处为 ent 构建期约束,需与 service.AllowedQuotaPlatforms 保持同步。
switch s {
case "anthropic", "openai", "gemini", "antigravity", "grok":
case "anthropic", "openai", "gemini", "antigravity", "grok",
"kimi", "zhipu", "deepseek":
return nil
default:
return fmt.Errorf("platform %q is not allowed", s)
+20
View File
@@ -1028,6 +1028,10 @@ type GatewayConfig struct {
// Grok: Grok/xAI gateway scheduling and free-tier soft-gate settings.
Grok GatewayGrokConfig `mapstructure:"grok"`
// CNProviders: 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)的余额检测配置。
// 仅作用于 payg(按量付费)账号:周期探测余额,低于阈值则临时停调。
CNProviders GatewayCNProvidersConfig `mapstructure:"cn_providers"`
}
// GatewayGrokConfig holds Grok-specific gateway scheduling knobs.
@@ -1060,6 +1064,18 @@ type GatewayGrokConfig struct {
FreeQuotaStatsCacheSeconds int `mapstructure:"free_quota_stats_cache_seconds"`
}
// GatewayCNProvidersConfig 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)的余额检测配置。
//
// 仅作用于 payg(按量付费)账号(kimi/deepseek 有公开余额端点;zhipu 无,仅靠响应式 429/402)。
// - balance_check_enabled: 是否启用周期余额检测(默认 true)
// - balance_threshold: 余额低于此值(账户货币单位,默认 0.5)触发临时停调
// - balance_check_interval_minutes: 余额检测周期(分钟,默认 10)
type GatewayCNProvidersConfig struct {
BalanceCheckEnabled bool `mapstructure:"balance_check_enabled"`
BalanceThreshold float64 `mapstructure:"balance_threshold"`
BalanceCheckIntervalMinutes int `mapstructure:"balance_check_interval_minutes"`
}
type GatewayLiveConfig struct {
// MaxSessionDurationSeconds 是 Live 会话的硬上限。
MaxSessionDurationSeconds int `mapstructure:"max_session_duration_seconds"`
@@ -2359,6 +2375,10 @@ func setDefaults() {
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)
// 国产供应商余额检测(kimi/deepseek payg;zhipu 无余额端点,仅靠响应式 429/402)。
viper.SetDefault("gateway.cn_providers.balance_check_enabled", true)
viper.SetDefault("gateway.cn_providers.balance_threshold", 0.5)
viper.SetDefault("gateway.cn_providers.balance_check_interval_minutes", 10)
viper.SetDefault("gateway.image_concurrency.enabled", false)
viper.SetDefault("gateway.image_concurrency.max_concurrent_requests", 0)
viper.SetDefault("gateway.image_concurrency.overflow_mode", ImageConcurrencyOverflowModeReject)
@@ -0,0 +1,64 @@
package domain
import "time"
// 渠道监控「配额模式」的归一化配额快照类型。
//
// 配额模式监控不直接对接上游,而是关联一个已有账号,复用账号侧的用量服务
// (AccountUsageService / CNProviderQuotaService / CNProviderBalanceService),
// 把各平台形态各异的用量数据归一成 MonitorQuotaSnapshot,随检测历史持久化
// 到 channel_monitor_histories.quota(JSONB),供管理端与用户端渲染。
//
// 类型放在 domain 包是因为 ent schema(internal/domain 的下游)需要引用它做
// field.JSON 序列化;service 不能被 ent import(会造成循环依赖)。
// MonitorQuotaTier 单个用量窗口的快照。
//
// Window 取值约定(与前端 monitorCommon.quota.windows.* 标签一一对应):
// - "5h" 5 小时滚动窗口(Claude/Codex/Kimi/Zhipu coding plan)
// - "7d" 7 天窗口(Claude/Codex)
// - "7d-sonnet" Claude 7 天 Sonnet 独立额度
// - "7d-fable" Claude 7 天 Fable 独立额度
// - "weekly" 周窗口(Kimi/Zhipu coding plan)
// - "daily" 日窗口(Gemini 日配额 / Grok 日请求)
// - "30d" 30 天窗口(Grok 月度)
// - "total" 无窗口语义的总量额度(Antigravity per-model 等)
//
// 同一 Window 可能出现多条(Gemini 多档日配额、Antigravity per-model、
// Grok requests/tokens),用 Label 区分:Label 是机器 token(requests/tokens/
// shared/pro/flash 或模型名),前端已知 token 走 i18n,未知原样展示。
type MonitorQuotaTier struct {
Window string `json:"window"`
Label string `json:"label,omitempty"`
UsedPercent float64 `json:"used_percent"` // 0-100+;仅有绝对值时按 used/limit 计算
Used float64 `json:"used,omitempty"`
Limit float64 `json:"limit,omitempty"`
ResetAt string `json:"reset_at,omitempty"` // RFC3339;未知时留空
}
// MonitorQuotaSnapshot 一次配额查询的完整快照。
//
// Source 取值:
// - "usage" 海外平台(AccountUsageService.GetUsage)
// - "cn_quota" 国产 Coding Plan(CNProviderQuotaService.QueryUsage)
// - "cn_balance" 国产按量付费余额(CNProviderBalanceService.QueryBalance)
type MonitorQuotaSnapshot struct {
Source string `json:"source"`
Success bool `json:"success"`
Tiers []MonitorQuotaTier `json:"tiers,omitempty"`
Balance *float64 `json:"balance,omitempty"` // cn_balance 主余额
Balances []MonitorBalance `json:"balances,omitempty"` // 多币种余额(如 DeepSeek CNY+USD)
Currency string `json:"currency,omitempty"` // 主余额币种
PlanLevel string `json:"plan_level,omitempty"` // 套餐等级(如智谱 level)
// CredentialInvalid 上游 401/403 鉴权失败(区别于网络/解析错误),
// 检测状态据此推导 failed 而非 error。
CredentialInvalid bool `json:"credential_invalid,omitempty"`
Error string `json:"error,omitempty"` // Success=false 时的错误摘要
FetchedAt time.Time `json:"fetched_at"`
}
// MonitorBalance 单币种余额条目。
type MonitorBalance struct {
Currency string `json:"currency"`
Balance float64 `json:"balance"`
}
+21 -1
View File
@@ -23,7 +23,27 @@ const (
PlatformGemini = "gemini"
PlatformAntigravity = "antigravity"
PlatformGrok = "grok"
PlatformComposite = "composite"
// 国产 OpenAI 兼容供应商(经 OpenAI 网关转发,按 Chat Completions 协议)。
PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot)
PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel)
PlatformDeepseek = "deepseek" // DeepSeek
PlatformComposite = "composite"
)
// Account mode constants 区分国产供应商的「按量付费(余额)」与「Coding Plan」两种接入方式。
// 存储于 credentials["account_mode"],决定 base_url 预设与额度监控方式。
const (
AccountModePayG = "payg" // 按量付费:消耗余额,做余额检测冷却
AccountModeCoding = "coding" // Coding Plan:滚动用量窗口冷却(5h / weekly)
)
// API protocol constants 国产供应商的上游 API 协议维度。存储于
// credentials["api_protocol"],与 account_mode 正交:协议决定转发端点与格式,
// 模式决定额度监控方式。同协议请求零转换直通;跨协议组合才走转换链。
const (
APIProtocolChatCompletions = "chat_completions" // OpenAI Chat Completions(默认)
APIProtocolAnthropic = "anthropic" // 原生 Anthropic /v1/messages(适配 Claude Code)
APIProtocolResponses = "responses" // OpenAI Responses(仅 deepseek,适配 Codex)
)
// Account type constants
@@ -1025,7 +1025,8 @@ func (h *AccountHandler) Update(c *gin.Context) {
// 当前请求。探测错误仅记录日志,不向上下文传播:探测失败时标记保持缺失,
// 网关会按"现状即证据"默认走 Responses。
func (h *AccountHandler) scheduleOpenAIResponsesProbe(account *service.Account) {
if account == nil || account.Platform != service.PlatformOpenAI || account.Type != service.AccountTypeAPIKey {
if account == nil || account.Type != service.AccountTypeAPIKey ||
(account.Platform != service.PlatformOpenAI && !service.IsCNProvider(account.Platform)) {
return
}
if h.accountTestService == nil {
@@ -57,17 +57,29 @@ type updateChannelRequest struct {
}
type channelModelPricingRequest struct {
Platform string `json:"platform" binding:"omitempty,max=50"`
Models []string `json:"models" binding:"required,min=1,max=100"`
BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"`
InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"`
OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"`
CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"`
CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"`
ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"`
ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"`
PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"`
Intervals []pricingIntervalRequest `json:"intervals"`
Platform string `json:"platform" binding:"omitempty,max=50"`
Models []string `json:"models" binding:"required,min=1,max=100"`
BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"`
InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"`
OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"`
CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"`
CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"`
ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"`
ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"`
PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"`
Intervals []pricingIntervalRequest `json:"intervals"`
TimePricing *channelTimePricingRequest `json:"time_pricing"`
}
type channelTimePricingRequest struct {
Timezone string `json:"timezone"`
Periods []channelTimePricingPeriodRequest `json:"periods"`
}
type channelTimePricingPeriodRequest struct {
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Multiplier float64 `json:"multiplier"`
}
type pricingIntervalRequest struct {
@@ -108,18 +120,30 @@ type channelResponse struct {
}
type channelModelPricingResponse struct {
ID int64 `json:"id"`
Platform string `json:"platform"`
Models []string `json:"models"`
BillingMode string `json:"billing_mode"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
ImageInputPrice *float64 `json:"image_input_price"`
ImageOutputPrice *float64 `json:"image_output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
Intervals []pricingIntervalResponse `json:"intervals"`
ID int64 `json:"id"`
Platform string `json:"platform"`
Models []string `json:"models"`
BillingMode string `json:"billing_mode"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
ImageInputPrice *float64 `json:"image_input_price"`
ImageOutputPrice *float64 `json:"image_output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
Intervals []pricingIntervalResponse `json:"intervals"`
TimePricing *channelTimePricingResponse `json:"time_pricing"`
}
type channelTimePricingResponse struct {
Timezone string `json:"timezone"`
Periods []channelTimePricingPeriodResponse `json:"periods"`
}
type channelTimePricingPeriodResponse struct {
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Multiplier float64 `json:"multiplier"`
}
type pricingIntervalResponse struct {
@@ -228,9 +252,25 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon
ImageOutputPrice: p.ImageOutputPrice,
PerRequestPrice: p.PerRequestPrice,
Intervals: intervals,
TimePricing: timePricingToResponse(p.TimePricing),
}
}
func timePricingToResponse(value *service.ChannelTimePricing) *channelTimePricingResponse {
if value == nil {
return nil
}
periods := make([]channelTimePricingPeriodResponse, 0, len(value.Periods))
for _, period := range value.Periods {
periods = append(periods, channelTimePricingPeriodResponse{
StartTime: period.StartTime,
EndTime: period.EndTime,
Multiplier: period.Multiplier,
})
}
return &channelTimePricingResponse{Timezone: value.Timezone, Periods: periods}
}
func intervalToResponse(iv service.PricingInterval) pricingIntervalResponse {
return pricingIntervalResponse{
ID: iv.ID,
@@ -280,11 +320,27 @@ func pricingRequestToService(reqs []channelModelPricingRequest) []service.Channe
ImageOutputPrice: r.ImageOutputPrice,
PerRequestPrice: r.PerRequestPrice,
Intervals: intervals,
TimePricing: timePricingRequestToService(r.TimePricing),
})
}
return result
}
func timePricingRequestToService(value *channelTimePricingRequest) *service.ChannelTimePricing {
if value == nil {
return nil
}
periods := make([]service.ChannelTimePricingPeriod, 0, len(value.Periods))
for _, period := range value.Periods {
periods = append(periods, service.ChannelTimePricingPeriod{
StartTime: period.StartTime,
EndTime: period.EndTime,
Multiplier: period.Multiplier,
})
}
return &service.ChannelTimePricing{Timezone: value.Timezone, Periods: periods}
}
func accountStatsPricingRuleRequestToService(r accountStatsPricingRuleRequest) service.AccountStatsPricingRule {
return service.AccountStatsPricingRule{
Name: r.Name,
@@ -512,9 +568,12 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) {
var platformToLiteLLMProvider = map[string]string{
service.PlatformAnthropic: "anthropic",
service.PlatformOpenAI: "openai",
service.PlatformGemini: "google",
service.PlatformGemini: "gemini",
service.PlatformAntigravity: "anthropic",
service.PlatformGrok: "xai",
service.PlatformKimi: "moonshot",
service.PlatformZhipu: "zhipu",
service.PlatformDeepseek: "deepseek",
}
// SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表
@@ -421,6 +421,49 @@ func TestPricingRequestToService_NilPriceFields(t *testing.T) {
require.Nil(t, r.PerRequestPrice)
}
func TestPricingRequestToService_TimePricing(t *testing.T) {
req := channelModelPricingRequest{
Models: []string{"gpt-5"},
BillingMode: "token",
TimePricing: &channelTimePricingRequest{
Timezone: "Asia/Shanghai",
Periods: []channelTimePricingPeriodRequest{{
StartTime: "09:00", EndTime: "12:00", Multiplier: 2,
}},
},
}
got := pricingRequestToService([]channelModelPricingRequest{req})
require.Equal(t, "Asia/Shanghai", got[0].TimePricing.Timezone)
require.Equal(t, 2.0, got[0].TimePricing.Periods[0].Multiplier)
}
func TestPricingRequestToService_TimePricingNil(t *testing.T) {
got := pricingRequestToService([]channelModelPricingRequest{{Models: []string{"gpt-5"}}})
require.Nil(t, got[0].TimePricing)
}
func TestPricingToResponse_TimePricing(t *testing.T) {
got := pricingToResponse(&service.ChannelModelPricing{
BillingMode: service.BillingModeToken,
TimePricing: &service.ChannelTimePricing{
Timezone: "Asia/Shanghai",
Periods: []service.ChannelTimePricingPeriod{{
StartTime: "14:00", EndTime: "18:00", Multiplier: 1.25,
}},
},
})
require.NotNil(t, got.TimePricing)
require.Equal(t, "Asia/Shanghai", got.TimePricing.Timezone)
require.Equal(t, 1.25, got.TimePricing.Periods[0].Multiplier)
}
func TestPricingToResponse_TimePricingNil(t *testing.T) {
got := pricingToResponse(&service.ChannelModelPricing{})
require.Nil(t, got.TimePricing)
}
// ---------------------------------------------------------------------------
// 3. SyncPricingModels handler
// ---------------------------------------------------------------------------
@@ -459,7 +502,7 @@ func TestSyncPricingModels_ValidPlatform_EmptyService(t *testing.T) {
svc := service.NewPricingService(nil, nil)
router := setupSyncPricingModelsRouter(svc)
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity"} {
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek"} {
req := httptest.NewRequest(http.MethodGet, "/channels/pricing/sync-models?platform="+platform, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -7,6 +7,7 @@ import (
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
@@ -39,10 +40,10 @@ func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *Ch
type channelMonitorCreateRequest struct {
Name string `json:"name" binding:"required,max=100"`
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok"`
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"`
APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
Endpoint string `json:"endpoint" binding:"required,max=500"`
APIKey string `json:"api_key" binding:"required,max=2000"`
Endpoint string `json:"endpoint" binding:"omitempty,max=500"`
APIKey string `json:"api_key" binding:"omitempty,max=2000"`
PrimaryModel string `json:"primary_model" binding:"max=200"`
ExtraModels []string `json:"extra_models"`
GroupName string `json:"group_name" binding:"max=100"`
@@ -53,11 +54,17 @@ type channelMonitorCreateRequest struct {
ExtraHeaders map[string]string `json:"extra_headers"`
BodyOverrideMode string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"`
BodyOverride map[string]any `json:"body_override"`
// CheckMode: probe(默认)/ quota / quota_probe。quota 模式 endpoint/api_key
// 可空(条件必填校验在 service 层按模式分支)。
CheckMode string `json:"check_mode" binding:"omitempty,oneof=probe quota quota_probe"`
// AccountID: 配额模式关联的账号 ID。
AccountID *int64 `json:"account_id"`
}
type channelMonitorUpdateRequest struct {
Name *string `json:"name" binding:"omitempty,max=100"`
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok"`
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"`
APIMode *string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
Endpoint *string `json:"endpoint" binding:"omitempty,max=500"`
APIKey *string `json:"api_key" binding:"omitempty,max=2000"`
@@ -72,6 +79,10 @@ type channelMonitorUpdateRequest struct {
ExtraHeaders *map[string]string `json:"extra_headers"`
BodyOverrideMode *string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"`
BodyOverride *map[string]any `json:"body_override"`
// CheckMode/AccountID:nil = 不更新;AccountID 指向 0 = 清空关联。
CheckMode *string `json:"check_mode" binding:"omitempty,oneof=probe quota quota_probe"`
AccountID *int64 `json:"account_id"`
}
type channelMonitorResponse struct {
@@ -101,25 +112,33 @@ type channelMonitorResponse struct {
ExtraHeaders map[string]string `json:"extra_headers"`
BodyOverrideMode string `json:"body_override_mode"`
BodyOverride map[string]any `json:"body_override"`
// 配额模式:check_mode + 关联账号 + 主模型最近配额快照
// (LatestQuota 由 List handler 批量聚合后填充;管理端不受 channel_monitor_show_quota 影响)。
CheckMode string `json:"check_mode"`
AccountID *int64 `json:"account_id"`
LatestQuota *domain.MonitorQuotaSnapshot `json:"latest_quota,omitempty"`
}
type channelMonitorCheckResultResponse struct {
Model string `json:"model"`
Status string `json:"status"`
LatencyMs *int `json:"latency_ms"`
PingLatencyMs *int `json:"ping_latency_ms"`
Message string `json:"message"`
CheckedAt string `json:"checked_at"`
Model string `json:"model"`
Status string `json:"status"`
LatencyMs *int `json:"latency_ms"`
PingLatencyMs *int `json:"ping_latency_ms"`
Message string `json:"message"`
CheckedAt string `json:"checked_at"`
Quota *domain.MonitorQuotaSnapshot `json:"quota,omitempty"`
}
type channelMonitorHistoryItemResponse struct {
ID int64 `json:"id"`
Model string `json:"model"`
Status string `json:"status"`
LatencyMs *int `json:"latency_ms"`
PingLatencyMs *int `json:"ping_latency_ms"`
Message string `json:"message"`
CheckedAt string `json:"checked_at"`
ID int64 `json:"id"`
Model string `json:"model"`
Status string `json:"status"`
LatencyMs *int `json:"latency_ms"`
PingLatencyMs *int `json:"ping_latency_ms"`
Message string `json:"message"`
CheckedAt string `json:"checked_at"`
Quota *domain.MonitorQuotaSnapshot `json:"quota,omitempty"`
}
// maskAPIKey 对 API Key 明文做脱敏:前 4 字符 + "***",长度 ≤ 4 时只显示 "***"。
@@ -163,7 +182,10 @@ func channelMonitorToResponse(m *service.ChannelMonitor) *channelMonitorResponse
ExtraHeaders: headers,
BodyOverrideMode: m.BodyOverrideMode,
BodyOverride: m.BodyOverride,
// PrimaryStatus / PrimaryLatencyMs / Availability7d 由 List handler 在批量聚合后填充。
CheckMode: m.CheckMode,
AccountID: m.AccountID,
// PrimaryStatus / PrimaryLatencyMs / Availability7d / LatestQuota
// 由 List handler 在批量聚合后填充。
}
if m.LastCheckedAt != nil {
s := m.LastCheckedAt.UTC().Format(time.RFC3339)
@@ -180,6 +202,7 @@ func checkResultToResponse(r *service.CheckResult) channelMonitorCheckResultResp
PingLatencyMs: r.PingLatencyMs,
Message: r.Message,
CheckedAt: r.CheckedAt.UTC().Format(time.RFC3339),
Quota: r.Quota,
}
}
@@ -192,6 +215,7 @@ func historyEntryToResponse(e *service.ChannelMonitorHistoryEntry) channelMonito
PingLatencyMs: e.PingLatencyMs,
Message: e.Message,
CheckedAt: e.CheckedAt.UTC().Format(time.RFC3339),
Quota: e.Quota,
}
}
@@ -270,6 +294,7 @@ func buildListItemResponse(m *service.ChannelMonitor, summary service.MonitorSta
resp.PrimaryStatus = summary.PrimaryStatus
resp.PrimaryLatencyMs = summary.PrimaryLatencyMs
resp.Availability7d = summary.Availability7d
resp.LatestQuota = summary.LatestQuota
resp.ExtraModelsStatus = make([]dto.ChannelMonitorExtraModelStatus, 0, len(summary.ExtraModels))
for _, e := range summary.ExtraModels {
resp.ExtraModelsStatus = append(resp.ExtraModelsStatus, dto.ChannelMonitorExtraModelStatus{
@@ -327,6 +352,8 @@ func (h *ChannelMonitorHandler) Create(c *gin.Context) {
ExtraHeaders: req.ExtraHeaders,
BodyOverrideMode: req.BodyOverrideMode,
BodyOverride: req.BodyOverride,
CheckMode: req.CheckMode,
AccountID: req.AccountID,
})
if err != nil {
response.ErrorFrom(c, err)
@@ -421,6 +448,8 @@ func (h *ChannelMonitorHandler) Update(c *gin.Context) {
ExtraHeaders: req.ExtraHeaders,
BodyOverrideMode: req.BodyOverrideMode,
BodyOverride: req.BodyOverride,
CheckMode: req.CheckMode,
AccountID: req.AccountID,
})
if err != nil {
response.ErrorFrom(c, err)
@@ -0,0 +1,69 @@
package admin
import (
"strconv"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// CNProviderHandler 暴露国产供应商(kimi/zhipu/deepseek)的额度与余额查询端点。
//
// - GET /admin/cn-providers/accounts/:id/quota Coding Plan 滚动窗口用量(kimi/zhipu)
// - GET /admin/cn-providers/accounts/:id/balance payg 账号余额(kimi/deepseek)
//
// 智谱(zhipu)无余额端点,故同一账号仅 quota 或 balance 其一可用:服务端按账号
// platform + account_mode 校验并返回明确错误(见 CNProvider*Service 的 load*Account)。
type CNProviderHandler struct {
quotaService *service.CNProviderQuotaService
balanceService *service.CNProviderBalanceService
}
func NewCNProviderHandler(
quotaService *service.CNProviderQuotaService,
balanceService *service.CNProviderBalanceService,
) *CNProviderHandler {
return &CNProviderHandler{
quotaService: quotaService,
balanceService: balanceService,
}
}
// QueryQuota 查询 Coding Plan 滚动窗口用量(5h + weekly)。
func (h *CNProviderHandler) QueryQuota(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
if h == nil || h.quotaService == nil {
response.BadRequest(c, "cn provider quota service is not enabled")
return
}
result, err := h.quotaService.QueryUsage(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
// QueryBalance 查询 payg 账号余额。
func (h *CNProviderHandler) QueryBalance(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
if h == nil || h.balanceService == nil {
response.BadRequest(c, "cn provider balance service is not enabled")
return
}
result, err := h.balanceService.QueryBalance(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
@@ -98,7 +98,7 @@ func NewGroupHandler(adminService service.AdminService, dashboardService *servic
type CreateGroupRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
@@ -166,7 +166,7 @@ type CreateGroupRequest struct {
type UpdateGroupRequest struct {
Name string `json:"name"`
Description *string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"`
RateMultiplier *float64 `json:"rate_multiplier"`
IsExclusive *bool `json:"is_exclusive"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
@@ -0,0 +1,83 @@
//go:build unit
package admin
import (
"bytes"
"fmt"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// 回归分组平台枚举:kimi/zhipu/deepseek 必须能通过 Create/Update 的 binding 校验
// (历史 bug:调度/路由链路已支持 CN 平台分组,但 oneof 白名单漏加三平台,导致
// 平台分组无法创建、CN 账号"无可用分组");非法值仍须被拒。
func bindGroupPlatformJSON(t *testing.T, target any, body string) error {
t.Helper()
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
return c.ShouldBindJSON(target)
}
func TestGroupPlatformBinding_AllowedPlatforms(t *testing.T) {
allowed := []string{
"anthropic", "openai", "gemini", "antigravity", "grok",
"kimi", "zhipu", "deepseek", "composite",
}
for _, platform := range allowed {
t.Run("create_"+platform, func(t *testing.T) {
var req CreateGroupRequest
body := fmt.Sprintf(`{"name":"g","platform":%q}`, platform)
require.NoError(t, bindGroupPlatformJSON(t, &req, body),
"platform %q 应通过 CreateGroupRequest 校验", platform)
require.Equal(t, platform, req.Platform)
})
t.Run("update_"+platform, func(t *testing.T) {
var req UpdateGroupRequest
body := fmt.Sprintf(`{"platform":%q}`, platform)
require.NoError(t, bindGroupPlatformJSON(t, &req, body),
"platform %q 应通过 UpdateGroupRequest 校验", platform)
require.Equal(t, platform, req.Platform)
})
}
}
func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) {
invalid := []string{
"moonshot", // 厂商别名,不是平台标识
"Kimi", // 大小写敏感
"openai ", // 尾随空格
"glm",
"bogus",
}
for _, platform := range invalid {
t.Run("create_"+platform, func(t *testing.T) {
var req CreateGroupRequest
body := fmt.Sprintf(`{"name":"g","platform":%q}`, platform)
require.Error(t, bindGroupPlatformJSON(t, &req, body),
"platform %q 应被 CreateGroupRequest 拒绝", platform)
})
t.Run("update_"+platform, func(t *testing.T) {
var req UpdateGroupRequest
body := fmt.Sprintf(`{"platform":%q}`, platform)
require.Error(t, bindGroupPlatformJSON(t, &req, body),
"platform %q 应被 UpdateGroupRequest 拒绝", platform)
})
}
}
// 守住 composite 路由目标不放行 CN:CN 平台不可作为 composite 路由目标
// (DetectModelPlatform/isConcreteRequestPlatform 均无 CN 分支,放行即打开半实现路径)。
func TestCompositeRouteTargetPlatform_StillExcludesCNProviders(t *testing.T) {
for _, platform := range []string{"kimi", "zhipu", "deepseek"} {
var req CompositeRouteRequest
body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform)
require.Error(t, bindGroupPlatformJSON(t, &req, body),
"composite target_platform %q 应保持被拒", platform)
}
}
@@ -374,6 +374,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
ChannelMonitorMode: settings.ChannelMonitorMode,
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota,
GrokDefaultTextModel: settings.GrokDefaultTextModel,
GrokCrossClientModelMapEnabled: settings.GrokCrossClientModelMapEnabled,
@@ -332,6 +332,7 @@ type UpdateSettingsRequest struct {
ChannelMonitorMode *string `json:"channel_monitor_mode"`
ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorHideThroughput *bool `json:"channel_monitor_hide_throughput"`
ChannelMonitorShowQuota *bool `json:"channel_monitor_show_quota"`
// Grok model mapping policy
GrokDefaultTextModel *string `json:"grok_default_text_model"`
@@ -1889,6 +1890,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.ChannelMonitorHideThroughput
}(),
ChannelMonitorShowQuota: func() bool {
if req.ChannelMonitorShowQuota != nil {
return *req.ChannelMonitorShowQuota
}
return previousSettings.ChannelMonitorShowQuota
}(),
GrokDefaultTextModel: func() string {
if req.GrokDefaultTextModel != nil {
return *req.GrokDefaultTextModel
@@ -2342,6 +2349,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
ChannelMonitorMode: updatedSettings.ChannelMonitorMode,
ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds,
ChannelMonitorHideThroughput: updatedSettings.ChannelMonitorHideThroughput,
ChannelMonitorShowQuota: updatedSettings.ChannelMonitorShowQuota,
GrokDefaultTextModel: updatedSettings.GrokDefaultTextModel,
GrokCrossClientModelMapEnabled: updatedSettings.GrokCrossClientModelMapEnabled,
@@ -112,12 +112,13 @@ func TestUpdateUserPlatformQuotas_Success(t *testing.T) {
if len(repo.upsertCalls) != 1 {
t.Fatalf("UpsertForUser should be called once, got %d", len(repo.upsertCalls))
}
if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != len(service.AllowedQuotaPlatforms) {
// upsert 记录数 = 请求体中给出的平台数(未给出的平台不落库)。
if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != 5 {
t.Errorf("unexpected upsert call: %+v", repo.upsertCalls[0])
}
// 缓存失效:按全部允许平台统一失效。
if len(cache.deleteCalls) != 5 {
t.Errorf("expected 5 cache delete calls, got %d: %+v", len(cache.deleteCalls), cache.deleteCalls)
// 缓存失效:按全部允许平台统一失效(含 kimi/zhipu/deepseek)。
if len(cache.deleteCalls) != len(service.AllowedQuotaPlatforms) {
t.Errorf("expected %d cache delete calls, got %d: %+v", len(service.AllowedQuotaPlatforms), len(cache.deleteCalls), cache.deleteCalls)
}
}
@@ -3,6 +3,7 @@ package handler
import (
"time"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
@@ -39,6 +40,15 @@ func (h *ChannelMonitorUserHandler) featureEnabled(c *gin.Context) bool {
return runtime.Enabled && runtime.Mode == service.ChannelMonitorModeV1
}
// quotaVisible 返回用户端是否展示配额/余额快照(channel_monitor_show_quota,
// fail-closed:未配置/非 "true" 一律视为关闭)。settingService 为 nil 时 fail-closed。
func (h *ChannelMonitorUserHandler) quotaVisible(c *gin.Context) bool {
if h.settingService == nil {
return false
}
return h.settingService.GetChannelMonitorRuntime(c.Request.Context()).ShowQuota
}
// --- Response ---
type channelMonitorUserListItem struct {
@@ -53,6 +63,9 @@ type channelMonitorUserListItem struct {
Availability7d float64 `json:"availability_7d"`
ExtraModels []dto.ChannelMonitorExtraModelStatus `json:"extra_models"`
Timeline []channelMonitorUserTimelinePoint `json:"timeline"`
// LatestQuota 主模型最近配额快照;channel_monitor_show_quota=false 时
// 由 userMonitorViewToItem 的调用方传入 false 剥离(服务端脱敏,非仅前端隐藏)。
LatestQuota *domain.MonitorQuotaSnapshot `json:"latest_quota,omitempty"`
}
// channelMonitorUserTimelinePoint 主模型最近一次检测的 timeline 点。
@@ -82,7 +95,7 @@ type channelMonitorUserModelStat struct {
AvgLatency7dMs *int `json:"avg_latency_7d_ms"`
}
func userMonitorViewToItem(v *service.UserMonitorView) channelMonitorUserListItem {
func userMonitorViewToItem(v *service.UserMonitorView, includeQuota bool) channelMonitorUserListItem {
extras := make([]dto.ChannelMonitorExtraModelStatus, 0, len(v.ExtraModels))
for _, e := range v.ExtraModels {
extras = append(extras, dto.ChannelMonitorExtraModelStatus{
@@ -100,7 +113,7 @@ func userMonitorViewToItem(v *service.UserMonitorView) channelMonitorUserListIte
CheckedAt: p.CheckedAt.UTC().Format(time.RFC3339),
})
}
return channelMonitorUserListItem{
item := channelMonitorUserListItem{
ID: v.ID,
Name: v.Name,
Provider: v.Provider,
@@ -113,6 +126,10 @@ func userMonitorViewToItem(v *service.UserMonitorView) channelMonitorUserListIte
ExtraModels: extras,
Timeline: timeline,
}
if includeQuota {
item.LatestQuota = v.LatestQuota
}
return item
}
func userMonitorDetailToResponse(d *service.UserMonitorDetail) *channelMonitorUserDetailResponse {
@@ -150,9 +167,10 @@ func (h *ChannelMonitorUserHandler) List(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
includeQuota := h.quotaVisible(c)
items := make([]channelMonitorUserListItem, 0, len(views))
for _, v := range views {
items = append(items, userMonitorViewToItem(v))
items = append(items, userMonitorViewToItem(v, includeQuota))
}
response.Success(c, gin.H{"items": items})
}
+2
View File
@@ -305,6 +305,7 @@ type SystemSettings struct {
ChannelMonitorMode string `json:"channel_monitor_mode"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"`
// Grok model mapping policy (admin settings; empty account mapping falls back to these).
GrokDefaultTextModel string `json:"grok_default_text_model"`
@@ -414,6 +415,7 @@ type PublicSettings struct {
ChannelMonitorMode string `json:"channel_monitor_mode"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"`
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
+1
View File
@@ -19,6 +19,7 @@ type AdminHandlers struct {
GeminiOAuth *admin.GeminiOAuthHandler
AntigravityOAuth *admin.AntigravityOAuthHandler
GrokOAuth *admin.GrokOAuthHandler
CNProvider *admin.CNProviderHandler
Proxy *admin.ProxyHandler
Redeem *admin.RedeemHandler
Promo *admin.PromoHandler
@@ -256,6 +256,48 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
if err == nil && result != nil && result.FirstTokenMs != nil {
service.SetOpsLatencyMs(c, service.OpsTimeToFirstTokenMsKey, int64(*result.FirstTokenMs))
}
// #5148 对齐:错误返回携带的部分 result(流中断前上游已计量的 usage)照常
// 入账;failover 错误恒定 result=nil,不会重复计费。
submitChatUsage := func(res *service.OpenAIForwardResult) {
if res == nil {
return
}
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, res)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(c.Request.Context(), res, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: res,
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, res.UpstreamModel),
PricingAt: pricingAt,
CyberBlocked: cyberBlocked,
}); err != nil {
logger.L().With(
zap.String("component", "handler.openai_gateway.chat_completions"),
zap.Int64("user_id", subject.UserID),
zap.Int64("api_key_id", apiKey.ID),
zap.Any("group_id", apiKey.GroupID),
zap.String("model", reqModel),
zap.Int64("account_id", account.ID),
).Error("openai_chat_completions.record_usage_failed", zap.Error(err))
}
})
}
if err != nil {
if result != nil && result.ImageCount > 0 {
reqLog.Warn("openai_chat_completions.forward_partial_error_with_image_result",
@@ -340,6 +382,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
zap.Bool("upstream_error_response_already_written", upstreamErrorAlreadyCommunicated),
zap.Error(err),
)
submitChatUsage(result)
return
}
}
@@ -349,42 +392,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), true, nil, c.Request.Context())
}
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, 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,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
PricingAt: pricingAt,
CyberBlocked: cyberBlocked,
}); err != nil {
logger.L().With(
zap.String("component", "handler.openai_gateway.chat_completions"),
zap.Int64("user_id", subject.UserID),
zap.Int64("api_key_id", apiKey.ID),
zap.Any("group_id", apiKey.GroupID),
zap.String("model", reqModel),
zap.Int64("account_id", account.ID),
).Error("openai_chat_completions.record_usage_failed", zap.Error(err))
}
})
submitChatUsage(result)
reqLog.Debug("openai_chat_completions.request_completed",
zap.Int64("account_id", account.ID),
zap.Int("switch_count", switchCount),
@@ -0,0 +1,29 @@
package handler
// CN 分组 /v1/messages 调度闸门回归(修复:正常途径创建的 CN 分组曾恒 403):
// sanitizeGroupMessagesDispatchFields 对非 openai 平台强制 AllowMessagesDispatch
// =false,故 CN 分组必须与 grok 一样在闸门处豁免,否则原生 Anthropic 直通
//(Claude Code 主用例)永远不可达。
import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestAllowOpenAICompatibleMessagesDispatch_CNProvidersExempt(t *testing.T) {
require.True(t, allowOpenAICompatibleMessagesDispatch(nil), "无 key 保持放行")
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformGrok} {
apiKey := &service.APIKey{Group: &service.Group{Platform: platform, AllowMessagesDispatch: false}}
require.True(t, allowOpenAICompatibleMessagesDispatch(apiKey),
"%s 分组必须豁免 allow_messages_dispatch 闸门", platform)
}
// 非回归:openai 分组仍受开关控制。
openaiOff := &service.APIKey{Group: &service.Group{Platform: service.PlatformOpenAI, AllowMessagesDispatch: false}}
require.False(t, allowOpenAICompatibleMessagesDispatch(openaiOff))
openaiOn := &service.APIKey{Group: &service.Group{Platform: service.PlatformOpenAI, AllowMessagesDispatch: true}}
require.True(t, allowOpenAICompatibleMessagesDispatch(openaiOn))
}
@@ -78,7 +78,7 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) {
zap.Any("group_id", apiKey.GroupID),
)
if apiKey.Group != nil && !apiKey.Group.AllowMessagesDispatch {
if !allowOpenAICompatibleMessagesDispatch(apiKey) {
h.anthropicErrorResponse(c, http.StatusForbidden, "permission_error",
"This group does not allow /v1/messages dispatch")
return
@@ -164,13 +164,11 @@ func wrapUsageRecordTaskContext(parent context.Context, task service.UsageRecord
func openAICompatibleRequestPlatform(ctx context.Context, apiKey *service.APIKey) string {
if platform, ok := service.ResolvedTargetPlatformFromContext(ctx); ok {
if platform == service.PlatformGrok {
return service.PlatformGrok
}
return service.PlatformOpenAI
// 保留 grok 与国产供应商原值,其他归一为 openai(与调度器精确匹配语义一致)。
return service.NormalizeOpenAICompatiblePlatform(platform)
}
if apiKey != nil && apiKey.Group != nil && apiKey.Group.Platform == service.PlatformGrok {
return service.PlatformGrok
if apiKey != nil && apiKey.Group != nil {
return service.NormalizeOpenAICompatiblePlatform(apiKey.Group.Platform)
}
return service.PlatformOpenAI
}
@@ -203,11 +201,20 @@ func allowOpenAICompatibleMessagesDispatch(apiKey *service.APIKey) bool {
if apiKey.Group.Platform == service.PlatformGrok {
return true
}
// 国产供应商分组与 grok 同语义:/v1/messages 就是其主要服务形态(anthropic
// 协议账号原生直通 Claude Code),无需 allow_messages_dispatch 开关授权——
// 该开关对非 openai 平台恒被 sanitizeGroupMessagesDispatchFields 置 false,
// 若不豁免,CN 分组将永远 403。
if service.IsCNProvider(apiKey.Group.Platform) {
return true
}
return apiKey.Group.AllowMessagesDispatch
}
func openAICompatibleTextTargetAllowed(c *gin.Context, apiKey *service.APIKey, model string) bool {
return compositeTargetPlatformAllowed(c, apiKey, model, service.PlatformOpenAI, service.PlatformGrok)
return compositeTargetPlatformAllowed(c, apiKey, model,
service.PlatformOpenAI, service.PlatformGrok,
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek)
}
// NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler
@@ -605,6 +612,50 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
if err == nil && result != nil && result.FirstTokenMs != nil {
service.SetOpsLatencyMs(c, service.OpsTimeToFirstTokenMsKey, int64(*result.FirstTokenMs))
}
// #5148 对齐:错误返回携带的部分 result(流中断前上游已计量的 usage)照常
// 入账;failover 错误恒定 result=nil,不会重复计费。
submitResponsesUsage := func(res *service.OpenAIForwardResult) {
if res == nil {
return
}
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
requestPayloadHash := service.HashUsageRequestPayload(body)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, res)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(c.Request.Context(), res, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: res,
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, channelMapping, reqModel, res.UpstreamModel),
PricingAt: pricingAt,
CyberBlocked: cyberBlocked,
}); err != nil {
logger.L().With(
zap.String("component", "handler.openai_gateway.responses"),
zap.Int64("user_id", subject.UserID),
zap.Int64("api_key_id", apiKey.ID),
zap.Any("group_id", apiKey.GroupID),
zap.String("model", reqModel),
zap.Int64("account_id", account.ID),
).Error("openai.record_usage_failed", zap.Error(err))
}
})
}
if err != nil {
if result != nil && result.ImageCount > 0 {
reqLog.Warn("openai.forward_partial_error_with_image_result",
@@ -704,6 +755,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
zap.Bool("upstream_error_response_already_written", upstreamErrorAlreadyCommunicated),
zap.Error(err),
}
submitResponsesUsage(result)
if shouldLogOpenAIForwardFailureAsWarn(c, wroteFallback) {
reqLog.Warn("openai.forward_failed", fields...)
return
@@ -722,46 +774,8 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), openAIForwardSucceededForScheduling(result), nil, c.Request.Context())
}
// 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context)
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
requestPayloadHash := service.HashUsageRequestPayload(body)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
// 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, 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, channelMapping, reqModel, result.UpstreamModel),
PricingAt: pricingAt,
CyberBlocked: cyberBlocked,
}); err != nil {
logger.L().With(
zap.String("component", "handler.openai_gateway.responses"),
zap.Int64("user_id", subject.UserID),
zap.Int64("api_key_id", apiKey.ID),
zap.Any("group_id", apiKey.GroupID),
zap.String("model", reqModel),
zap.Int64("account_id", account.ID),
).Error("openai.record_usage_failed", zap.Error(err))
}
})
submitResponsesUsage(result)
reqLog.Debug("openai.request_completed",
zap.Int64("account_id", account.ID),
zap.Int("switch_count", switchCount),
@@ -1154,6 +1168,51 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
if err == nil && result != nil && result.FirstTokenMs != nil {
service.SetOpsLatencyMs(c, service.OpsTimeToFirstTokenMsKey, int64(*result.FirstTokenMs))
}
// Forward 与错误一起返回的部分结果:流中断/客户端断开排水前上游已计量的
// usage 照常入账,避免上游已产生消耗的请求完全漏记(#5148,对齐 anthropic
// 网关同名修复)。failover 错误恒定 result=nil,不会重复计费。
submitMessagesUsage := func(res *service.OpenAIForwardResult) {
if res == nil {
return
}
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
requestPayloadHash := service.HashUsageRequestPayload(body)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, res)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(c.Request.Context(), res, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: res,
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, channelMappingMsg, reqModel, res.UpstreamModel),
PricingAt: pricingAt,
CyberBlocked: cyberBlocked,
}); err != nil {
logger.L().With(
zap.String("component", "handler.openai_gateway.messages"),
zap.Int64("user_id", subject.UserID),
zap.Int64("api_key_id", apiKey.ID),
zap.Any("group_id", apiKey.GroupID),
zap.String("model", reqModel),
zap.Int64("account_id", account.ID),
).Error("openai_messages.record_usage_failed", zap.Error(err))
}
})
}
if err != nil {
if result != nil && result.ImageCount > 0 {
reqLog.Warn("openai_messages.forward_partial_error_with_image_result",
@@ -1228,6 +1287,9 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
zap.Int64("account_id", account.ID),
zap.Error(err),
)
// 断开排水期间上游已计量的 usage 必须入账(此前直接 return 丢弃,
// payg 上游照常计费而平台漏记)。
submitMessagesUsage(result)
return
}
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), false, nil)
@@ -1237,6 +1299,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
zap.Bool("fallback_error_response_written", wroteFallback),
zap.Error(err),
)
submitMessagesUsage(result)
return
}
}
@@ -1246,44 +1309,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), true, nil, c.Request.Context())
}
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
requestPayloadHash := service.HashUsageRequestPayload(body)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, 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, channelMappingMsg, reqModel, result.UpstreamModel),
PricingAt: pricingAt,
CyberBlocked: cyberBlocked,
}); err != nil {
logger.L().With(
zap.String("component", "handler.openai_gateway.messages"),
zap.Int64("user_id", subject.UserID),
zap.Int64("api_key_id", apiKey.ID),
zap.Any("group_id", apiKey.GroupID),
zap.String("model", reqModel),
zap.Int64("account_id", account.ID),
).Error("openai_messages.record_usage_failed", zap.Error(err))
}
})
submitMessagesUsage(result)
reqLog.Debug("openai_messages.request_completed",
zap.Int64("account_id", account.ID),
zap.Int("switch_count", switchCount),
@@ -1429,12 +1455,10 @@ const (
// 由 BeforeTurn 在每个 turn 开始时冻结,AfterTurn 的用量提交读取它;turn 在
// 连接内串行推进,互斥锁只为跨用量提交 goroutine 的读取安全。
//
// 零值语义(重要):ws_v2 passthrough ingress 只实现了 AfterTurn,没有任何
// turn 起始回调,BeforeTurn 永远不会被调用。此时本值保持零,RecordUsage 经
// openAIUsagePricingAt 回退到记录时刻——与引入分组利润控制前的基线一致。
// 绝不能用建连时刻初始化:那会把透传连接的所有 turn 钉死在建连时的高峰因子,
// 客户端只要峰前一分钟建连并保活,整条连接就能全程按谷价结算,正是利润控制
// 想堵的漏洞。透传 ingress 目前不做 turn 级利润复核,只有建连时的准入门。
// ws_v2 passthrough ingress 没有 BeforeTurn,因此本值会保持零;AfterTurn 必须
// 以 TurnStarted 已记录的所属 turn 开始时刻为回退,而不是用建连或记录时刻。
// 这样每个 passthrough turn 都按自己的开始时刻计价,但不改变其仅在建连时执行
// 准入门、没有 turn 级利润复核的既有行为。
type openAIWSTurnPricing struct {
mu sync.Mutex
at time.Time
@@ -1446,10 +1470,13 @@ func (p *openAIWSTurnPricing) freeze(at time.Time) {
p.mu.Unlock()
}
func (p *openAIWSTurnPricing) current() time.Time {
func (p *openAIWSTurnPricing) currentOr(fallback time.Time) time.Time {
p.mu.Lock()
defer p.mu.Unlock()
return p.at
if !p.at.IsZero() {
return p.at
}
return fallback
}
// recordOpenAIProfitVeto 记录 OpenAI 侧选号循环的一次利润门终检否决:把账号
@@ -1712,6 +1739,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "missing first response.create message")
return
}
firstTurnStartedAt := time.Now()
if msgType != coderws.MessageText && msgType != coderws.MessageBinary {
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "unsupported websocket message type")
return
@@ -2044,19 +2072,37 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
maxReasoningEffort, reasoningEffortMappings, _ := openAIReasoningEffortPolicyForRequest(c, apiKey)
var requestPayloadHash string
var turnStartsMu sync.Mutex
turnStarts := make(map[int]time.Time, 4)
recordTurnStart := func(turn int, startedAt time.Time) {
if turn <= 0 || startedAt.IsZero() {
return
}
turnStartsMu.Lock()
turnStarts[turn] = startedAt
turnStartsMu.Unlock()
}
getTurnStart := func(turn int) time.Time {
turnStartsMu.Lock()
startedAt := turnStarts[turn]
delete(turnStarts, turn)
turnStartsMu.Unlock()
return startedAt
}
// Passthrough rejects overlapping response.create frames, so one immutable
// turn-tagged slot preserves the exact mapping used for the in-flight request.
var turnChannelMapping atomic.Pointer[openAIWSTurnChannelMappingSnapshot]
turnChannelMapping.Store(&openAIWSTurnChannelMappingSnapshot{turn: 1, mapping: channelMappingWS})
// turn 级定价:BeforeTurn 重新冻结 pricingAt 并按最新门复核当前账号,
// AfterTurn 的计费读取所属 turn 的时刻。零值起步的语义见
// openAIWSTurnPricing 的注释——绝不能用建连时刻初始化。
// turn 级定价:BeforeTurn 重新冻结 pricingAt 并按最新门复核当前账号;
// passthrough 没有 BeforeTurn 时,AfterTurn 回退到 TurnStarted 的所属 turn 时刻。
var turnPricing openAIWSTurnPricing
hooks := &service.OpenAIWSIngressHooks{
ClientLifecycleContext: clientLifecycleCtx,
InitialRequestModel: reqModel,
InitialTurnStartedAt: firstTurnStartedAt,
MaxReasoningEffort: maxReasoningEffort,
ReasoningEffortMappings: reasoningEffortMappings,
TurnStarted: recordTurnStart,
BeforeRequest: func(turn int, payload []byte, originalModel string) error {
c.Set(securityAuditWSTurnContextKey, turn)
if turn == 1 {
@@ -2142,6 +2188,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
return nil
},
AfterTurn: func(turn int, result *service.OpenAIForwardResult, turnErr error) {
turnStart := getTurnStart(turn)
// F1: cyber 标记按 turn 生命周期清理——defer 保证任意早返回路径都执行;
// CyberBlocked 必须在 submit 前同步预捕获(task 闭包由 worker 池异步执行,
// 届时 defer 已清除标记)。
@@ -2209,7 +2256,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
turnRecordPricingAt := turnPricing.current()
turnRecordPricingAt := turnPricing.currentOr(turnStart)
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(ctx, result, func(taskCtx context.Context) {
if err := h.gatewayService.RecordUsage(taskCtx, &service.OpenAIRecordUsageInput{
@@ -7,16 +7,20 @@ import (
"github.com/stretchr/testify/require"
)
// TestOpenAIWSTurnPricingZeroValue 钉死 WS turn 定价的零值语义:
// 没有 turn 起始回调的 ingress 模式(ws_v2 passthrough 只实现 AfterTurn)
// 必须让 pricingAt 保持零,由 RecordUsage 回退到记录时刻。
//
// 反例(本 PR 引入的回归):用建连时刻初始化,会把透传连接的所有 turn 钉死在
// 建连时的高峰因子——客户端峰前一分钟建连并保活,整条连接就按谷价结算。
func TestOpenAIWSTurnPricingZeroValue(t *testing.T) {
var p openAIWSTurnPricing
require.True(t, p.current().IsZero(),
"未经 turn 起始回调冻结时必须保持零值,交由 RecordUsage 回退记录时刻")
func TestOpenAIWSTurnPricingCurrentOr(t *testing.T) {
fallback := time.Date(2024, time.January, 2, 2, 0, 0, 0, time.UTC)
t.Run("frozen time takes precedence", func(t *testing.T) {
frozen := fallback.Add(time.Minute)
var p openAIWSTurnPricing
p.freeze(frozen)
require.Equal(t, frozen, p.currentOr(fallback))
})
t.Run("zero value falls back to turn start", func(t *testing.T) {
var p openAIWSTurnPricing
require.Equal(t, fallback, p.currentOr(fallback))
})
}
// TestOpenAIWSTurnPricingFreezePerTurn 钉死每个 turn 的 BeforeTurn 都会覆盖
@@ -27,8 +31,8 @@ func TestOpenAIWSTurnPricingFreezePerTurn(t *testing.T) {
turn2 := time.Now()
p.freeze(turn1)
require.Equal(t, turn1, p.current())
require.Equal(t, turn1, p.currentOr(time.Time{}))
p.freeze(turn2)
require.Equal(t, turn2, p.current(), "后续 turn 必须使用自己的定价时刻")
require.Equal(t, turn2, p.currentOr(time.Time{}), "后续 turn 必须使用自己的定价时刻")
}
@@ -107,6 +107,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
ChannelMonitorMode: settings.ChannelMonitorMode,
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
+3
View File
@@ -23,6 +23,7 @@ func ProvideAdminHandlers(
geminiOAuthHandler *admin.GeminiOAuthHandler,
antigravityOAuthHandler *admin.AntigravityOAuthHandler,
grokOAuthHandler *admin.GrokOAuthHandler,
cnProviderHandler *admin.CNProviderHandler,
proxyHandler *admin.ProxyHandler,
redeemHandler *admin.RedeemHandler,
promoHandler *admin.PromoHandler,
@@ -63,6 +64,7 @@ func ProvideAdminHandlers(
GeminiOAuth: geminiOAuthHandler,
AntigravityOAuth: antigravityOAuthHandler,
GrokOAuth: grokOAuthHandler,
CNProvider: cnProviderHandler,
Proxy: proxyHandler,
Redeem: redeemHandler,
Promo: promoHandler,
@@ -253,6 +255,7 @@ var ProviderSet = wire.NewSet(
admin.NewGeminiOAuthHandler,
admin.NewAntigravityOAuthHandler,
admin.NewGrokOAuthHandler,
admin.NewCNProviderHandler,
admin.NewProxyHandler,
admin.NewRedeemHandler,
admin.NewPromoHandler,
@@ -112,7 +112,8 @@ type GeminiImageSearch struct {
// GeminiToolConfig Gemini 工具配置
type GeminiToolConfig struct {
FunctionCallingConfig *GeminiFunctionCallingConfig `json:"functionCallingConfig,omitempty"`
FunctionCallingConfig *GeminiFunctionCallingConfig `json:"functionCallingConfig,omitempty"`
IncludeServerSideToolInvocations *bool `json:"includeServerSideToolInvocations,omitempty"`
}
// GeminiFunctionCallingConfig 函数调用配置
@@ -156,6 +156,13 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
Mode: "VALIDATED",
},
}
// 内置工具(googleSearch)与函数调用混用时,上游要求显式开启
// includeServerSideToolInvocations,否则返回 400(issue #5709)。
// 与 raw 透传路的 enableMixedGeminiToolInvocations 注入保持同一语义。
if hasMixedToolInvocations(tools) {
enabled := true
innerRequest.ToolConfig.IncludeServerSideToolInvocations = &enabled
}
}
if systemInstruction != nil {
@@ -703,6 +710,21 @@ func isWebSearchTool(tool ClaudeTool) bool {
}
}
// hasMixedToolInvocations 判断构建后的工具声明是否同时包含函数声明与内置工具
// (googleSearch)。仅在两者并存时需要开启 includeServerSideToolInvocations。
func hasMixedToolInvocations(declarations []GeminiToolDeclaration) bool {
hasFunc, hasBuiltin := false, false
for _, d := range declarations {
if len(d.FunctionDeclarations) > 0 {
hasFunc = true
}
if d.GoogleSearch != nil {
hasBuiltin = true
}
}
return hasFunc && hasBuiltin
}
// buildTools 构建 tools
func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
if len(tools) == 0 {
@@ -565,3 +565,59 @@ func TestTransformClaudeToGeminiWithOptions_PreservesWebSearchAlongsideFunctions
require.Equal(t, "get_weather", req.Request.Tools[0].FunctionDeclarations[0].Name)
require.NotNil(t, req.Request.Tools[1].GoogleSearch)
}
func TestGeminiToolConfig_IncludeServerSideToolInvocations(t *testing.T) {
functionTool := ClaudeTool{
Name: "get_weather",
Description: "Get weather information",
InputSchema: map[string]any{"type": "object"},
}
webSearchTool := ClaudeTool{
Type: "web_search_20250305",
Name: "web_search",
}
transform := func(t *testing.T, tools []ClaudeTool) (V1InternalRequest, string) {
t.Helper()
body, err := TransformClaudeToGeminiWithOptions(&ClaudeRequest{
Model: "claude-3-5-sonnet-latest",
Messages: []ClaudeMessage{
{
Role: "user",
Content: json.RawMessage(`[{"type":"text","text":"hello"}]`),
},
},
Tools: tools,
}, "project-1", "gemini-2.5-flash", DefaultTransformOptions())
require.NoError(t, err)
var req V1InternalRequest
require.NoError(t, json.Unmarshal(body, &req))
return req, string(body)
}
t.Run("mixed builtin and function tools enable server-side tool invocations", func(t *testing.T) {
req, raw := transform(t, []ClaudeTool{functionTool, webSearchTool})
require.NotNil(t, req.Request.ToolConfig)
require.NotNil(t, req.Request.ToolConfig.IncludeServerSideToolInvocations)
require.True(t, *req.Request.ToolConfig.IncludeServerSideToolInvocations)
require.Contains(t, raw, `"includeServerSideToolInvocations":true`)
})
t.Run("function tools only leave the flag unset", func(t *testing.T) {
req, raw := transform(t, []ClaudeTool{functionTool})
require.NotNil(t, req.Request.ToolConfig)
require.Nil(t, req.Request.ToolConfig.IncludeServerSideToolInvocations)
require.NotContains(t, raw, "includeServerSideToolInvocations")
})
t.Run("web search only leaves the flag unset", func(t *testing.T) {
req, raw := transform(t, []ClaudeTool{webSearchTool})
require.NotNil(t, req.Request.ToolConfig)
require.Nil(t, req.Request.ToolConfig.IncludeServerSideToolInvocations)
require.NotContains(t, raw, "includeServerSideToolInvocations")
})
}
@@ -430,7 +430,7 @@ func (r *ResponsesClientToolStreamRestorer) RestoreEvent(payload []byte) ([][]by
if err := json.Unmarshal(payload, &wire); err != nil {
return nil, false, err
}
if wire.Type == "response.completed" || wire.Type == "response.incomplete" || wire.Type == "response.failed" {
if isResponsesClientToolTerminalEvent(wire.Type) {
restored, changed, err := RestoreResponsesClientToolPayload(payload, r.adapter)
if err != nil {
return nil, false, err
@@ -465,6 +465,15 @@ func (r *ResponsesClientToolStreamRestorer) RestoreEvent(payload []byte) ([][]by
return result, true, nil
}
func isResponsesClientToolTerminalEvent(typ string) bool {
switch strings.TrimSpace(typ) {
case "response.completed", "response.done", "response.incomplete", "response.failed", "response.cancelled", "response.canceled":
return true
default:
return false
}
}
func (r *ResponsesClientToolStreamRestorer) clientToolEventPayload(payload []byte) bool {
var raw struct {
ItemID string `json:"item_id"`
@@ -174,3 +174,30 @@ func TestResponsesClientToolStreamRestorer_RawEventsPreserveUnknownFieldsAndOutp
require.Len(t, done, 2)
require.Equal(t, "pwd", done[1].Input)
}
func TestResponsesClientToolStreamRestorer_RestoresAllTerminalEvents(t *testing.T) {
for _, eventType := range []string{
"response.completed",
"response.done",
"response.incomplete",
"response.failed",
"response.cancelled",
"response.canceled",
} {
t.Run(eventType, func(t *testing.T) {
restorer := NewResponsesClientToolStreamRestorer(ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}})
payload := []byte(`{"type":"` + eventType + `","sequence_number":7,"response":{"id":"resp_tools","output":[{"type":"function_call","id":"item_exec","call_id":"call_exec","name":"exec","arguments":"{\"input\":\"pwd\"}"}]}}`)
restored, changed, err := restorer.RestoreEvent(payload)
require.NoError(t, err)
require.True(t, changed)
require.Len(t, restored, 1)
require.Equal(t, eventType, gjson.GetBytes(restored[0], "type").String())
require.Equal(t, int64(7), gjson.GetBytes(restored[0], "sequence_number").Int())
require.Equal(t, "custom_tool_call", gjson.GetBytes(restored[0], "response.output.0.type").String())
require.Equal(t, "pwd", gjson.GetBytes(restored[0], "response.output.0.input").String())
require.False(t, gjson.GetBytes(restored[0], "response.output.0.arguments").Exists())
})
}
}
+3
View File
@@ -45,6 +45,9 @@ func DefaultModelIDs() []string {
// DefaultTestModel default model for testing OpenAI accounts
const DefaultTestModel = "gpt-5.4"
// CodexUsageProbeModel is the model used for OAuth Codex usage probes.
const CodexUsageProbeModel = "codex-auto-review"
// DefaultInstructions default instructions for non-Codex CLI requests.
// 内容为真实 Codex CLI 的 GPT-5-Codex base prompt(codex 系模型默认)。
//
+42 -1
View File
@@ -72,6 +72,39 @@ var schedulerNeutralExtraKeys = map[string]struct{}{
const postgresParameterBatchSize = 50000
const codexFingerprintSeedCanonicalPattern = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
const codexFingerprintNilSeed = "00000000-0000-0000-0000-000000000000"
func codexFingerprintSeedValidSQL(extraExpr string) string {
value := "(" + extraExpr + " ->> 'codex_fingerprint_seed')"
return "(" + value + " ~ '" + codexFingerprintSeedCanonicalPattern + "' AND " + value + " <> '" + codexFingerprintNilSeed + "')"
}
func ensureCodexFingerprintSeedSQL(extraExpr string) string {
return "CASE WHEN platform = 'openai' AND type = 'oauth' THEN " +
"jsonb_set(" + extraExpr + ", '{codex_fingerprint_seed}', " +
"CASE WHEN " + codexFingerprintSeedValidSQL("extra") +
" THEN to_jsonb(extra ->> 'codex_fingerprint_seed') ELSE to_jsonb(gen_random_uuid()::text) END, true) " +
"ELSE " + extraExpr + " END"
}
func stripCodexFingerprintSeedFromExtraUpdate(extra map[string]any) map[string]any {
if extra == nil {
return nil
}
if _, exists := extra["codex_fingerprint_seed"]; !exists {
return extra
}
stripped := make(map[string]any, len(extra)-1)
for key, value := range extra {
if key == "codex_fingerprint_seed" {
continue
}
stripped[key] = value
}
return stripped
}
// NewAccountRepository 创建账户仓储实例。
// 这是对外暴露的构造函数,返回接口类型以便于依赖注入。
func NewAccountRepository(client *dbent.Client, sqlDB *sql.DB, schedulerCache service.SchedulerCache) service.AccountRepository {
@@ -2521,6 +2554,7 @@ func (r *accountRepository) AutoPauseExpiredAccounts(ctx context.Context, now ti
}
func (r *accountRepository) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error {
updates = stripCodexFingerprintSeedFromExtraUpdate(updates)
if len(updates) == 0 {
return nil
}
@@ -2553,6 +2587,9 @@ func (r *accountRepository) UpdateExtra(ctx context.Context, id int64, updates m
if clearProbeSnapshot {
extraExpression = "(" + extraExpression + ") - 'upstream_billing_probe'"
}
if service.ShouldEnsureCodexFingerprintSeedForExtraUpdates(updates) {
extraExpression = ensureCodexFingerprintSeedSQL(extraExpression)
}
result, err := client.ExecContext(
ctx,
"UPDATE accounts SET extra = "+extraExpression+", updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL",
@@ -2794,6 +2831,7 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates
if len(ids) == 0 {
return 0, nil
}
updates.Extra = stripCodexFingerprintSeedFromExtraUpdate(updates.Extra)
setClauses := make([]string, 0, 8)
args := make([]any, 0, 8)
@@ -2881,7 +2919,7 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates
" AND "+ollamaCloudBaseURLMatchesSQL(credentialPlaceholder+"::jsonb ->> 'base_url'")+")")
}
if len(updates.Extra) > 0 || len(ollamaGroupIdentityChanges) > 0 || ollamaProxyIdentityChanged != "" {
if len(updates.Extra) > 0 || len(ollamaGroupIdentityChanges) > 0 || ollamaProxyIdentityChanged != "" || updates.EnsureCodexFingerprintSeed {
extraExpression := "COALESCE(extra, '{}'::jsonb)"
if len(updates.Extra) > 0 {
payload, err := json.Marshal(updates.Extra)
@@ -2920,6 +2958,9 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates
} else if snapshotIdentityChanged != "" {
extraExpression = "CASE WHEN " + snapshotIdentityChanged + " THEN (" + extraExpression + ") - 'ollama_cloud_usage_snapshot' ELSE " + extraExpression + " END"
}
if updates.EnsureCodexFingerprintSeed {
extraExpression = ensureCodexFingerprintSeedSQL(extraExpression)
}
setClauses = append(setClauses, "extra = "+extraExpression)
}
@@ -0,0 +1,123 @@
package repository
import (
"context"
"errors"
"regexp"
"testing"
"github.com/DATA-DOG/go-sqlmock"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
)
func TestBulkUpdateEnsuresCodexFingerprintSeedWithPerRowSQL(t *testing.T) {
exec := &recordingSQLExecutor{result: rowsAffectedResult(0)}
repo := newAccountRepositoryWithSQL(nil, exec, nil)
_, err := repo.BulkUpdate(context.Background(), []int64{27, 28}, service.AccountBulkUpdate{
Extra: map[string]any{
"codex_fingerprint_mode": "session",
"codex_fingerprint_seed": "22222222-2222-4222-8222-222222222222",
},
EnsureCodexFingerprintSeed: true,
})
require.NoError(t, err)
require.NotEmpty(t, exec.execQueries)
query := normalizeSQLWhitespace(exec.execQueries[0])
require.Contains(t, query, "jsonb_set")
require.Contains(t, query, "gen_random_uuid()::text")
require.Contains(t, query, "platform = 'openai' AND type = 'oauth'")
require.Contains(t, query, "to_jsonb(extra ->> 'codex_fingerprint_seed')")
require.Contains(t, query, codexFingerprintSeedCanonicalPattern)
require.NotContains(t, query, "22222222-2222-4222-8222-222222222222")
require.NotEmpty(t, exec.execArgs)
payload, ok := exec.execArgs[0][0].([]byte)
require.True(t, ok)
require.Equal(t, `{"codex_fingerprint_mode":"session"}`, string(payload))
}
func TestUpdateExtraEnsuresCodexFingerprintSeedAtomicallyWhenEnabling(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db)))
t.Cleanup(func() { _ = client.Close() })
mock.ExpectBegin()
mock.ExpectExec(`(?s)UPDATE accounts SET extra = .*jsonb_set.*gen_random_uuid\(\)::text.*WHERE id = \$2 AND deleted_at IS NULL`).
WithArgs(`{"codex_fingerprint_mode":"device"}`, int64(27)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")).
WithArgs(service.SchedulerOutboxEventAccountChanged, int64(27), nil, nil, sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
repo := newAccountRepositoryWithSQL(client, db, nil)
err = repo.UpdateExtra(context.Background(), 27, map[string]any{
"codex_fingerprint_mode": "device",
"codex_fingerprint_seed": "22222222-2222-4222-8222-222222222222",
})
require.NoError(t, err)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestBulkUpdateCodexFingerprintSeedRollsBackWhenUpdateFails(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db)))
t.Cleanup(func() { _ = client.Close() })
mock.ExpectBegin()
mock.ExpectExec(`(?s)UPDATE accounts SET extra = .*gen_random_uuid\(\)::text.*WHERE id = ANY\(\$2\)`).
WithArgs(sqlmock.AnyArg(), `{27,28}`).
WillReturnError(errors.New("update failed"))
mock.ExpectRollback()
repo := newAccountRepositoryWithSQL(client, db, nil)
rows, err := repo.BulkUpdate(context.Background(), []int64{27, 28}, service.AccountBulkUpdate{
Extra: map[string]any{
"codex_fingerprint_mode": "session",
},
EnsureCodexFingerprintSeed: true,
})
require.EqualError(t, err, "update failed")
require.Zero(t, rows)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestBulkUpdateCodexFingerprintSeedRollsBackWhenOutboxFails(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db)))
t.Cleanup(func() { _ = client.Close() })
mock.ExpectBegin()
mock.ExpectExec(`(?s)UPDATE accounts SET extra = .*gen_random_uuid\(\)::text.*WHERE id = ANY\(\$2\)`).
WithArgs(sqlmock.AnyArg(), `{27,28}`).
WillReturnResult(sqlmock.NewResult(0, 2))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")).
WillReturnError(errors.New("outbox failed"))
mock.ExpectRollback()
repo := newAccountRepositoryWithSQL(client, db, nil)
rows, err := repo.BulkUpdate(context.Background(), []int64{27, 28}, service.AccountBulkUpdate{
Extra: map[string]any{
"codex_fingerprint_mode": "full",
},
EnsureCodexFingerprintSeed: true,
})
require.EqualError(t, err, "outbox failed")
require.Zero(t, rows)
require.NoError(t, mock.ExpectationsWereMet())
}
@@ -0,0 +1,155 @@
//go:build integration
package repository
import (
"context"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
// 配额模式 repo 层集成测试:
// - Create/GetByID/Update 的 check_mode/account_id 往返
// - InsertHistoryBatch → ListHistory / ListLatestForMonitorIDs 的 quota JSONB 回读
// (裸 SQL 列 + scanMonitorQuota),以及探活模式旧行 quota=NULL 的兼容
//
// 注意 channelMonitorRepository 的 GetByID/裸 SQL 走全局 client(不识别 tx ctx),
// 因此本文件用 integrationEntClient 直连 + t.Cleanup 显式清理,不走 testEntTx 回滚。
func TestChannelMonitorQuotaModeRoundTrip(t *testing.T) {
ctx := context.Background()
repo := NewChannelMonitorRepository(integrationEntClient, integrationDB)
account := mustCreateAccount(t, integrationEntClient, &service.Account{
Name: "quota-linked-kimi", Platform: domain.PlatformKimi, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "sk-kimi", "account_mode": service.AccountModeCoding},
})
t.Cleanup(func() {
_ = integrationEntClient.Account.DeleteOneID(account.ID).Exec(ctx)
})
created := &service.ChannelMonitor{
Name: "kimi-quota-roundtrip",
Provider: service.MonitorProviderKimi,
APIMode: service.MonitorAPIModeChatCompletions,
Endpoint: "",
APIKey: "encrypted-empty",
PrimaryModel: "quota",
Enabled: true,
IntervalSeconds: 60,
CheckMode: service.MonitorCheckModeQuota,
AccountID: &account.ID,
BodyOverrideMode: service.MonitorBodyOverrideModeOff,
}
require.NoError(t, repo.Create(ctx, created))
t.Cleanup(func() {
_ = repo.Delete(ctx, created.ID)
})
loaded, err := repo.GetByID(ctx, created.ID)
require.NoError(t, err)
require.Equal(t, service.MonitorCheckModeQuota, loaded.CheckMode)
require.NotNil(t, loaded.AccountID)
require.Equal(t, account.ID, *loaded.AccountID)
// Update:切换模式并清空关联账号(probe 化)。
loaded.CheckMode = service.MonitorCheckModeProbe
loaded.AccountID = nil
loaded.Endpoint = "https://api.moonshot.cn"
require.NoError(t, repo.Update(ctx, loaded))
reloaded, err := repo.GetByID(ctx, created.ID)
require.NoError(t, err)
require.Equal(t, service.MonitorCheckModeProbe, reloaded.CheckMode)
require.Nil(t, reloaded.AccountID)
// 重新绑定账号。
reloaded.CheckMode = service.MonitorCheckModeQuotaProbe
reloaded.AccountID = &account.ID
require.NoError(t, repo.Update(ctx, reloaded))
final, err := repo.GetByID(ctx, created.ID)
require.NoError(t, err)
require.Equal(t, service.MonitorCheckModeQuotaProbe, final.CheckMode)
require.NotNil(t, final.AccountID)
}
func TestChannelMonitorHistoryQuotaRoundTrip(t *testing.T) {
ctx := context.Background()
repo := NewChannelMonitorRepository(integrationEntClient, integrationDB)
monitor := &service.ChannelMonitor{
Name: "quota-history-roundtrip",
Provider: service.MonitorProviderOpenAI,
APIMode: service.MonitorAPIModeChatCompletions,
Endpoint: "https://api.openai.com",
APIKey: "encrypted",
PrimaryModel: "gpt-test",
ExtraModels: []string{"gpt-extra"},
Enabled: true,
IntervalSeconds: 60,
BodyOverrideMode: service.MonitorBodyOverrideModeOff,
}
require.NoError(t, repo.Create(ctx, monitor))
t.Cleanup(func() {
_ = repo.Delete(ctx, monitor.ID) // histories 级联删除
})
now := time.Now().UTC()
rows := []*service.ChannelMonitorHistoryRow{
{
MonitorID: monitor.ID, Model: "gpt-test", Status: service.MonitorStatusOperational,
Message: "ok", CheckedAt: now,
Quota: &domain.MonitorQuotaSnapshot{
Source: "usage",
Success: true,
PlanLevel: "PRO",
Tiers: []domain.MonitorQuotaTier{
{Window: "5h", UsedPercent: 42.5, Used: 17, Limit: 40, ResetAt: now.Add(time.Hour).Format(time.RFC3339)},
},
FetchedAt: now,
},
},
{
// 探活模式旧行:无 quota(NULL 兼容)。
MonitorID: monitor.ID, Model: "gpt-extra", Status: service.MonitorStatusOperational,
Message: "ok", CheckedAt: now,
},
}
require.NoError(t, repo.InsertHistoryBatch(ctx, rows))
history, err := repo.ListHistory(ctx, monitor.ID, "", 10)
require.NoError(t, err)
require.Len(t, history, 2)
byModel := map[string]*service.ChannelMonitorHistoryEntry{}
for _, entry := range history {
byModel[entry.Model] = entry
}
withQuota := byModel["gpt-test"]
require.NotNil(t, withQuota.Quota)
require.True(t, withQuota.Quota.Success)
require.Equal(t, "usage", withQuota.Quota.Source)
require.Equal(t, "PRO", withQuota.Quota.PlanLevel)
require.Len(t, withQuota.Quota.Tiers, 1)
require.Equal(t, "5h", withQuota.Quota.Tiers[0].Window)
require.InDelta(t, 42.5, withQuota.Quota.Tiers[0].UsedPercent, 0.001)
require.Nil(t, byModel["gpt-extra"].Quota, "probe rows must read back as NULL quota")
// 用户视图聚合:主模型最近一行带快照。
latest, err := repo.ListLatestForMonitorIDs(ctx, []int64{monitor.ID})
require.NoError(t, err)
primaryRows := latest[monitor.ID]
require.NotEmpty(t, primaryRows)
var primaryQuota *domain.MonitorQuotaSnapshot
for _, row := range primaryRows {
if row.Model == "gpt-test" {
primaryQuota = row.Quota
}
}
require.NotNil(t, primaryQuota, "ListLatestForMonitorIDs must surface quota for the primary model")
require.True(t, primaryQuota.Success)
}
@@ -3,6 +3,7 @@ package repository
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
@@ -10,6 +11,7 @@ import (
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/channelmonitor"
"github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/lib/pq"
@@ -51,10 +53,14 @@ func (r *channelMonitorRepository) Create(ctx context.Context, m *service.Channe
SetJitterSeconds(m.JitterSeconds).
SetCreatedBy(m.CreatedBy).
SetExtraHeaders(channelMonitorHeadersForPersistence(m)).
SetBodyOverrideMode(defaultBodyModeRepo(m.BodyOverrideMode))
SetBodyOverrideMode(defaultBodyModeRepo(m.BodyOverrideMode)).
SetCheckMode(defaultCheckModeRepo(m.CheckMode))
if m.TemplateID != nil {
builder = builder.SetTemplateID(*m.TemplateID)
}
if m.AccountID != nil {
builder = builder.SetAccountID(*m.AccountID)
}
if m.BodyOverride != nil {
builder = builder.SetBodyOverride(m.BodyOverride)
}
@@ -118,12 +124,18 @@ func (r *channelMonitorRepository) Update(ctx context.Context, m *service.Channe
SetIntervalSeconds(m.IntervalSeconds).
SetJitterSeconds(m.JitterSeconds).
SetExtraHeaders(channelMonitorHeadersForPersistence(m)).
SetBodyOverrideMode(defaultBodyModeRepo(m.BodyOverrideMode))
SetBodyOverrideMode(defaultBodyModeRepo(m.BodyOverrideMode)).
SetCheckMode(defaultCheckModeRepo(m.CheckMode))
if m.TemplateID != nil {
updater = updater.SetTemplateID(*m.TemplateID)
} else {
updater = updater.ClearTemplateID()
}
if m.AccountID != nil {
updater = updater.SetAccountID(*m.AccountID)
} else {
updater = updater.ClearAccountID()
}
if m.BodyOverride != nil {
updater = updater.SetBodyOverride(m.BodyOverride)
} else {
@@ -237,6 +249,9 @@ func (r *channelMonitorRepository) InsertHistoryBatch(ctx context.Context, rows
if row.PingLatencyMs != nil {
c = c.SetPingLatencyMs(*row.PingLatencyMs)
}
if row.Quota != nil {
c = c.SetQuota(row.Quota)
}
bulk = append(bulk, c)
}
if _, err := client.ChannelMonitorHistory.CreateBulk(bulk...).Save(ctx); err != nil {
@@ -276,6 +291,7 @@ func (r *channelMonitorRepository) ListHistory(ctx context.Context, monitorID in
PingLatencyMs: row.PingLatencyMs,
Message: row.Message,
CheckedAt: row.CheckedAt,
Quota: row.Quota,
}
out = append(out, entry)
}
@@ -324,6 +340,20 @@ func assignNullInt(dst **int, n sql.NullInt64) {
*dst = &v
}
// scanMonitorQuota 把裸 SQL 读出的 JSONB quota 列解包为配额快照。
// NULL(探活模式旧行)返回 nil;解析失败也返回 nil 并由调用方日志感知,
// 不阻断列表渲染(与聚合层"失败仅日志"的原则一致)。
func scanMonitorQuota(data []byte) *domain.MonitorQuotaSnapshot {
if len(data) == 0 {
return nil
}
snapshot := &domain.MonitorQuotaSnapshot{}
if err := json.Unmarshal(data, snapshot); err != nil {
return nil
}
return snapshot
}
// ComputeAvailability 计算指定窗口内每个模型的可用率与平均延迟。
// "可用" = status IN (operational, degraded)。
//
@@ -396,7 +426,7 @@ func (r *channelMonitorRepository) ListLatestForMonitorIDs(ctx context.Context,
}
const q = `
SELECT DISTINCT ON (monitor_id, model)
monitor_id, model, status, latency_ms, ping_latency_ms, checked_at
monitor_id, model, status, latency_ms, ping_latency_ms, checked_at, quota
FROM channel_monitor_histories
WHERE monitor_id = ANY($1)
ORDER BY monitor_id, model, checked_at DESC
@@ -411,11 +441,13 @@ func (r *channelMonitorRepository) ListLatestForMonitorIDs(ctx context.Context,
var monitorID int64
l := &service.ChannelMonitorLatest{}
var latency, ping sql.NullInt64
if err := rows.Scan(&monitorID, &l.Model, &l.Status, &latency, &ping, &l.CheckedAt); err != nil {
var quota []byte
if err := rows.Scan(&monitorID, &l.Model, &l.Status, &latency, &ping, &l.CheckedAt, &quota); err != nil {
return nil, fmt.Errorf("scan latest batch row: %w", err)
}
assignNullInt(&l.LatencyMs, latency)
assignNullInt(&l.PingLatencyMs, ping)
l.Quota = scanMonitorQuota(quota)
out[monitorID] = append(out[monitorID], l)
}
if err := rows.Err(); err != nil {
@@ -757,12 +789,17 @@ func entToServiceMonitor(row *dbent.ChannelMonitor) *service.ChannelMonitor {
ExtraHeaders: headers,
BodyOverrideMode: row.BodyOverrideMode,
BodyOverride: row.BodyOverride,
CheckMode: defaultCheckModeRepo(row.CheckMode),
DuplicateOperationID: duplicateOperationID,
}
if row.TemplateID != nil {
id := *row.TemplateID
out.TemplateID = &id
}
if row.AccountID != nil {
id := *row.AccountID
out.AccountID = &id
}
return out
}
@@ -807,6 +844,14 @@ func defaultAPIModeRepo(apiMode string) string {
return apiMode
}
// defaultCheckModeRepo 空串归一为 probe(存量行有列默认值,这里兜底防御)。
func defaultCheckModeRepo(checkMode string) string {
if checkMode == "" {
return "probe"
}
return checkMode
}
func emptySliceIfNil(in []string) []string {
if in == nil {
return []string{}
@@ -16,7 +16,7 @@ import (
func (r *channelRepository) ListModelPricing(ctx context.Context, channelID int64) ([]service.ChannelModelPricing, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price, created_at, updated_at
`SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at
FROM channel_model_pricing WHERE channel_id = $1 ORDER BY id`, channelID,
)
if err != nil {
@@ -51,16 +51,20 @@ func (r *channelRepository) UpdateModelPricing(ctx context.Context, pricing *ser
if err != nil {
return fmt.Errorf("marshal models: %w", err)
}
timePricingJSON, err := marshalChannelTimePricing(pricing.TimePricing)
if err != nil {
return err
}
billingMode := pricing.BillingMode
if billingMode == "" {
billingMode = service.BillingModeToken
}
result, err := r.db.ExecContext(ctx,
`UPDATE channel_model_pricing
SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_read_price = $6, image_input_price = $7, image_output_price = $8, per_request_price = $9, platform = $10, updated_at = NOW()
WHERE id = $11`,
SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_read_price = $6, image_input_price = $7, image_output_price = $8, per_request_price = $9, time_pricing = $10, platform = $11, updated_at = NOW()
WHERE id = $12`,
modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheReadPrice,
pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, pricing.Platform, pricing.ID,
pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, timePricingJSON, pricing.Platform, pricing.ID,
)
if err != nil {
return fmt.Errorf("update model pricing: %w", err)
@@ -91,7 +95,7 @@ func (r *channelRepository) ReplaceModelPricing(ctx context.Context, channelID i
// batchLoadModelPricing 批量加载多个渠道的模型定价(含区间)
func (r *channelRepository) batchLoadModelPricing(ctx context.Context, channelIDs []int64) (map[int64][]service.ChannelModelPricing, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price, created_at, updated_at
`SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at
FROM channel_model_pricing WHERE channel_id = ANY($1) ORDER BY channel_id, id`,
pq.Array(channelIDs),
)
@@ -169,16 +173,22 @@ func scanModelPricingRows(rows *sql.Rows) ([]service.ChannelModelPricing, []int6
for rows.Next() {
var p service.ChannelModelPricing
var modelsJSON []byte
var timePricingJSON []byte
if err := rows.Scan(
&p.ID, &p.ChannelID, &p.Platform, &modelsJSON, &p.BillingMode,
&p.InputPrice, &p.OutputPrice, &p.CacheWritePrice, &p.CacheReadPrice,
&p.ImageInputPrice, &p.ImageOutputPrice, &p.PerRequestPrice, &p.CreatedAt, &p.UpdatedAt,
&p.ImageInputPrice, &p.ImageOutputPrice, &p.PerRequestPrice, &timePricingJSON, &p.CreatedAt, &p.UpdatedAt,
); err != nil {
return nil, nil, fmt.Errorf("scan model pricing: %w", err)
}
if err := json.Unmarshal(modelsJSON, &p.Models); err != nil {
p.Models = []string{}
}
timePricing, err := unmarshalChannelTimePricing(timePricingJSON)
if err != nil {
return nil, nil, err
}
p.TimePricing = timePricing
pricingIDs = append(pricingIDs, p.ID)
result = append(result, p)
}
@@ -220,6 +230,10 @@ func createModelPricingExec(ctx context.Context, exec dbExec, pricing *service.C
if err != nil {
return fmt.Errorf("marshal models: %w", err)
}
timePricingJSON, err := marshalChannelTimePricing(pricing.TimePricing)
if err != nil {
return err
}
billingMode := pricing.BillingMode
if billingMode == "" {
billingMode = service.BillingModeToken
@@ -229,11 +243,11 @@ func createModelPricingExec(ctx context.Context, exec dbExec, pricing *service.C
platform = "anthropic"
}
err = exec.QueryRowContext(ctx,
`INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at`,
`INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price, time_pricing)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, updated_at`,
pricing.ChannelID, platform, modelsJSON, billingMode,
pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheReadPrice,
pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice,
pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, timePricingJSON,
).Scan(&pricing.ID, &pricing.CreatedAt, &pricing.UpdatedAt)
if err != nil {
return fmt.Errorf("insert model pricing: %w", err)
@@ -249,6 +263,28 @@ func createModelPricingExec(ctx context.Context, exec dbExec, pricing *service.C
return nil
}
func marshalChannelTimePricing(config *service.ChannelTimePricing) (any, error) {
if config == nil || len(config.Periods) == 0 {
return nil, nil
}
data, err := json.Marshal(config)
if err != nil {
return nil, fmt.Errorf("marshal time pricing: %w", err)
}
return string(data), nil
}
func unmarshalChannelTimePricing(data []byte) (*service.ChannelTimePricing, error) {
if len(data) == 0 {
return nil, nil
}
var config service.ChannelTimePricing
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("unmarshal time pricing: %w", err)
}
return &config, nil
}
func createIntervalExec(ctx context.Context, exec dbExec, iv *service.PricingInterval) error {
return exec.QueryRowContext(ctx,
`INSERT INTO channel_pricing_intervals
@@ -0,0 +1,181 @@
//go:build unit
package repository
import (
"context"
"regexp"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
var channelModelPricingTimePricingColumns = []string{
"id", "channel_id", "platform", "models", "billing_mode", "input_price", "output_price",
"cache_write_price", "cache_read_price", "image_input_price", "image_output_price",
"per_request_price", "time_pricing", "created_at", "updated_at",
}
const channelModelPricingTimePricingJSON = `{"timezone":"Asia/Shanghai","periods":[{"start_time":"09:00","end_time":"12:00","multiplier":2}]}`
func newChannelModelPricingTimePricingRepo(t *testing.T) (*channelRepository, sqlmock.Sqlmock) {
t.Helper()
db, mock, err := sqlmock.New()
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
return &channelRepository{db: db}, mock
}
func modelPricingTimePricingRow(timePricing any) *sqlmock.Rows {
return sqlmock.NewRows(channelModelPricingTimePricingColumns).AddRow(
int64(11), int64(7), "openai", `["gpt-5"]`, service.BillingModeToken,
nil, nil, nil, nil, nil, nil, nil, timePricing,
time.Date(2026, 8, 17, 0, 0, 0, 0, time.UTC), time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC),
)
}
func expectEmptyModelPricingIntervals(mock sqlmock.Sqlmock) {
mock.ExpectQuery(`SELECT id, pricing_id, min_tokens, max_tokens, tier_label`).
WithArgs(sqlmock.AnyArg()).
WillReturnRows(sqlmock.NewRows([]string{"id"}))
}
func TestChannelModelPricingTimePricingListRoundTrip(t *testing.T) {
repo, mock := newChannelModelPricingTimePricingRepo(t)
mock.ExpectQuery(`(?s)SELECT .*per_request_price, time_pricing, created_at, updated_at.*FROM channel_model_pricing.*channel_id = \$1`).
WithArgs(int64(7)).
WillReturnRows(modelPricingTimePricingRow(channelModelPricingTimePricingJSON))
expectEmptyModelPricingIntervals(mock)
pricing, err := repo.ListModelPricing(context.Background(), 7)
require.NoError(t, err)
require.Len(t, pricing, 1)
require.NotNil(t, pricing[0].TimePricing)
require.Equal(t, "Asia/Shanghai", pricing[0].TimePricing.Timezone)
require.Len(t, pricing[0].TimePricing.Periods, 1)
require.Equal(t, 2.0, pricing[0].TimePricing.Periods[0].Multiplier)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestChannelModelPricingTimePricingListNullAndMalformed(t *testing.T) {
t.Run("SQL NULL maps to nil", func(t *testing.T) {
repo, mock := newChannelModelPricingTimePricingRepo(t)
mock.ExpectQuery(`(?s)SELECT .*per_request_price, time_pricing, created_at, updated_at.*FROM channel_model_pricing.*channel_id = \$1`).
WithArgs(int64(7)).
WillReturnRows(modelPricingTimePricingRow(nil))
expectEmptyModelPricingIntervals(mock)
pricing, err := repo.ListModelPricing(context.Background(), 7)
require.NoError(t, err)
require.Len(t, pricing, 1)
require.Nil(t, pricing[0].TimePricing)
require.NoError(t, mock.ExpectationsWereMet())
})
t.Run("malformed JSON returns repository error", func(t *testing.T) {
repo, mock := newChannelModelPricingTimePricingRepo(t)
mock.ExpectQuery(`(?s)SELECT .*per_request_price, time_pricing, created_at, updated_at.*FROM channel_model_pricing.*channel_id = \$1`).
WithArgs(int64(7)).
WillReturnRows(modelPricingTimePricingRow(`{"timezone":`))
_, err := repo.ListModelPricing(context.Background(), 7)
require.Error(t, err)
require.True(t, strings.Contains(err.Error(), "unmarshal time pricing"), "unexpected error: %v", err)
require.NoError(t, mock.ExpectationsWereMet())
})
}
func TestChannelModelPricingTimePricingCreateAndUpdateRoundTrip(t *testing.T) {
pricing := &service.ChannelModelPricing{
ID: 11,
ChannelID: 7,
Platform: "openai",
Models: []string{"gpt-5"},
TimePricing: &service.ChannelTimePricing{
Timezone: "Asia/Shanghai",
Periods: []service.ChannelTimePricingPeriod{{
StartTime: "09:00", EndTime: "12:00", Multiplier: 2,
}},
},
}
t.Run("create writes JSON", func(t *testing.T) {
repo, mock := newChannelModelPricingTimePricingRepo(t)
mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price, time_pricing)")).
WithArgs(
int64(7), "openai", []byte(`["gpt-5"]`), service.BillingModeToken,
nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON,
).
WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{}))
require.NoError(t, repo.CreateModelPricing(context.Background(), pricing))
require.NoError(t, mock.ExpectationsWereMet())
})
t.Run("update writes JSON and entry ID", func(t *testing.T) {
repo, mock := newChannelModelPricingTimePricingRepo(t)
mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$9, time_pricing = \$10, platform = \$11.*WHERE id = \$12`).
WithArgs(
[]byte(`["gpt-5"]`), service.BillingModeToken,
nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, "openai", int64(11),
).
WillReturnResult(sqlmock.NewResult(0, 1))
require.NoError(t, repo.UpdateModelPricing(context.Background(), pricing))
require.NoError(t, mock.ExpectationsWereMet())
})
}
func TestChannelModelPricingTimePricingCreateAndUpdateWriteNullWhenDisabled(t *testing.T) {
tests := []struct {
name string
timePricing *service.ChannelTimePricing
}{
{name: "nil", timePricing: nil},
{name: "empty periods", timePricing: &service.ChannelTimePricing{Timezone: "Asia/Shanghai"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
newPricing := func() *service.ChannelModelPricing {
return &service.ChannelModelPricing{
ID: 11,
ChannelID: 7,
Platform: "openai",
Models: []string{"gpt-5"},
TimePricing: tt.timePricing,
}
}
t.Run("create writes SQL NULL", func(t *testing.T) {
repo, mock := newChannelModelPricingTimePricingRepo(t)
mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price, time_pricing)")).
WithArgs(
int64(7), "openai", []byte(`["gpt-5"]`), service.BillingModeToken,
nil, nil, nil, nil, nil, nil, nil, nil,
).
WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{}))
require.NoError(t, repo.CreateModelPricing(context.Background(), newPricing()))
require.NoError(t, mock.ExpectationsWereMet())
})
t.Run("update writes SQL NULL", func(t *testing.T) {
repo, mock := newChannelModelPricingTimePricingRepo(t)
mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$9, time_pricing = \$10, platform = \$11.*WHERE id = \$12`).
WithArgs(
[]byte(`["gpt-5"]`), service.BillingModeToken,
nil, nil, nil, nil, nil, nil, nil, nil, "openai", int64(11),
).
WillReturnResult(sqlmock.NewResult(0, 1))
require.NoError(t, repo.UpdateModelPricing(context.Background(), newPricing()))
require.NoError(t, mock.ExpectationsWereMet())
})
})
}
}
@@ -0,0 +1,150 @@
//go:build integration
package repository
import (
"context"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
dbmigrations "github.com/Wei-Shaw/sub2api/migrations"
"github.com/google/uuid"
"github.com/lib/pq"
"github.com/stretchr/testify/require"
)
func requireCanonicalUUIDString(t *testing.T, value string) {
t.Helper()
parsed, err := uuid.Parse(value)
require.NoError(t, err)
require.NotEqual(t, uuid.Nil, parsed)
require.Equal(t, parsed.String(), value)
}
func TestMigration225BackfillsOnlyEnabledOpenAIOAuthMissingOrMalformedSeeds(t *testing.T) {
tx := testTx(t)
ctx := context.Background()
migrationSQL, err := dbmigrations.FS.ReadFile("225_backfill_codex_fingerprint_seed.sql")
require.NoError(t, err)
var missingID, blankID, malformedID, validID, offID, apiKeyID int64
require.NoError(t, tx.QueryRowContext(ctx, `
INSERT INTO accounts (name, platform, type, extra)
VALUES ('migration-225-missing', 'openai', 'oauth', '{"codex_fingerprint_mode":"session"}'::jsonb)
RETURNING id
`).Scan(&missingID))
require.NoError(t, tx.QueryRowContext(ctx, `
INSERT INTO accounts (name, platform, type, extra)
VALUES ('migration-225-blank', 'openai', 'oauth', '{"codex_fingerprint_mode":"device","codex_fingerprint_seed":""}'::jsonb)
RETURNING id
`).Scan(&blankID))
require.NoError(t, tx.QueryRowContext(ctx, `
INSERT INTO accounts (name, platform, type, extra)
VALUES ('migration-225-malformed', 'openai', 'oauth', '{"codex_fingerprint_mode":"full","codex_fingerprint_seed":"BAD"}'::jsonb)
RETURNING id
`).Scan(&malformedID))
require.NoError(t, tx.QueryRowContext(ctx, `
INSERT INTO accounts (name, platform, type, extra)
VALUES ('migration-225-valid', 'openai', 'oauth', '{"codex_fingerprint_mode":"session","codex_fingerprint_seed":"11111111-1111-4111-8111-111111111111"}'::jsonb)
RETURNING id
`).Scan(&validID))
require.NoError(t, tx.QueryRowContext(ctx, `
INSERT INTO accounts (name, platform, type, extra)
VALUES ('migration-225-off', 'openai', 'oauth', '{"codex_fingerprint_mode":"off"}'::jsonb)
RETURNING id
`).Scan(&offID))
require.NoError(t, tx.QueryRowContext(ctx, `
INSERT INTO accounts (name, platform, type, extra)
VALUES ('migration-225-apikey', 'openai', 'apikey', '{"codex_fingerprint_mode":"session"}'::jsonb)
RETURNING id
`).Scan(&apiKeyID))
_, err = tx.ExecContext(ctx, string(migrationSQL))
require.NoError(t, err)
seedsAfterFirst := map[int64]string{}
for _, id := range []int64{missingID, blankID, malformedID, validID} {
var seed string
require.NoError(t, tx.QueryRowContext(ctx, `SELECT extra->>'codex_fingerprint_seed' FROM accounts WHERE id = $1`, id).Scan(&seed))
requireCanonicalUUIDString(t, seed)
seedsAfterFirst[id] = seed
}
require.Equal(t, "11111111-1111-4111-8111-111111111111", seedsAfterFirst[validID])
for _, id := range []int64{offID, apiKeyID} {
var hasSeed bool
require.NoError(t, tx.QueryRowContext(ctx, `SELECT extra ? 'codex_fingerprint_seed' FROM accounts WHERE id = $1`, id).Scan(&hasSeed))
require.False(t, hasSeed)
}
_, err = tx.ExecContext(ctx, string(migrationSQL))
require.NoError(t, err)
for id, want := range seedsAfterFirst {
var got string
require.NoError(t, tx.QueryRowContext(ctx, `SELECT extra->>'codex_fingerprint_seed' FROM accounts WHERE id = $1`, id).Scan(&got))
require.Equal(t, want, got)
}
}
func TestBulkUpdateGeneratesDistinctStableCodexFingerprintSeedsPerEligibleRow(t *testing.T) {
ctx := context.Background()
testName := "bulk-codex-seed-" + uuid.NewString()
type fixture struct {
name string
accountType string
extra string
}
fixtures := []fixture{
{name: testName + "-missing-a", accountType: service.AccountTypeOAuth, extra: `{}`},
{name: testName + "-missing-b", accountType: service.AccountTypeOAuth, extra: `{"codex_fingerprint_seed":"BAD"}`},
{name: testName + "-valid", accountType: service.AccountTypeOAuth, extra: `{"codex_fingerprint_seed":"11111111-1111-4111-8111-111111111111"}`},
{name: testName + "-apikey", accountType: service.AccountTypeAPIKey, extra: `{}`},
}
ids := make([]int64, 0, len(fixtures))
for _, f := range fixtures {
var id int64
require.NoError(t, integrationDB.QueryRowContext(ctx, `
INSERT INTO accounts (name, platform, type, extra)
VALUES ($1, 'openai', $2, $3::jsonb)
RETURNING id
`, f.name, f.accountType, f.extra).Scan(&id))
ids = append(ids, id)
}
t.Cleanup(func() {
_, _ = integrationDB.ExecContext(context.Background(), `DELETE FROM scheduler_outbox WHERE account_id = ANY($1)`, pq.Array(ids))
_, _ = integrationDB.ExecContext(context.Background(), `DELETE FROM accounts WHERE id = ANY($1)`, pq.Array(ids))
})
repo := newAccountRepositoryWithSQL(testEntClient(t), integrationDB, nil)
updates := service.AccountBulkUpdate{
Extra: map[string]any{
"codex_fingerprint_mode": "session",
},
EnsureCodexFingerprintSeed: true,
}
rows, err := repo.BulkUpdate(ctx, ids, updates)
require.NoError(t, err)
require.Equal(t, int64(len(ids)), rows)
readSeed := func(id int64) string {
t.Helper()
var seed string
require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT COALESCE(extra->>'codex_fingerprint_seed', '') FROM accounts WHERE id = $1`, id).Scan(&seed))
return seed
}
firstSeeds := []string{readSeed(ids[0]), readSeed(ids[1]), readSeed(ids[2]), readSeed(ids[3])}
requireCanonicalUUIDString(t, firstSeeds[0])
requireCanonicalUUIDString(t, firstSeeds[1])
require.NotEqual(t, firstSeeds[0], firstSeeds[1], "gen_random_uuid must be evaluated per eligible row")
require.Equal(t, "11111111-1111-4111-8111-111111111111", firstSeeds[2])
require.Empty(t, firstSeeds[3], "API-key accounts must not receive a Codex fingerprint seed")
rows, err = repo.BulkUpdate(ctx, ids, updates)
require.NoError(t, err)
require.Equal(t, int64(len(ids)), rows)
for i, want := range firstSeeds {
require.Equal(t, want, readSeed(ids[i]), "retry must not rotate an existing valid seed")
}
}
@@ -46,9 +46,11 @@ func (s *openaiOAuthService) ExchangeCode(ctx context.Context, code, codeVerifie
var tokenResp openai.TokenResponse
authUA, authOriginator := service.CodexCanonicalAuthIdentity()
resp, err := client.R().
SetContext(ctx).
SetHeader("User-Agent", "codex-cli/0.91.0").
SetHeader("User-Agent", authUA).
SetHeader("originator", authOriginator).
SetFormDataFromValues(formData).
SetSuccessResult(&tokenResp).
Post(s.tokenURL)
@@ -94,9 +96,11 @@ func (s *openaiOAuthService) refreshTokenWithClientID(ctx context.Context, refre
var tokenResp openai.TokenResponse
authUA, authOriginator := service.CodexCanonicalAuthIdentity()
resp, err := client.R().
SetContext(ctx).
SetHeader("User-Agent", "codex-cli/0.91.0").
SetHeader("User-Agent", authUA).
SetHeader("originator", authOriginator).
SetFormDataFromValues(formData).
SetSuccessResult(&tokenResp).
Post(s.tokenURL)
@@ -10,6 +10,7 @@ import (
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
@@ -77,6 +78,17 @@ func (s *OpenAIOAuthServiceSuite) TestExchangeCode_DefaultRedirectURI() {
w.WriteHeader(http.StatusBadRequest)
return
}
wantUA, wantOriginator := service.CodexCanonicalAuthIdentity()
if got := r.Header.Get("User-Agent"); got != wantUA {
errCh <- "user-agent mismatch"
w.WriteHeader(http.StatusBadRequest)
return
}
if got := r.Header.Get("originator"); got != wantOriginator {
errCh <- "originator mismatch"
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"access_token":"at","refresh_token":"rt","token_type":"bearer","expires_in":3600}`)
@@ -121,6 +133,17 @@ func (s *OpenAIOAuthServiceSuite) TestRefreshToken_FormFields() {
w.WriteHeader(http.StatusBadRequest)
return
}
wantUA, wantOriginator := service.CodexCanonicalAuthIdentity()
if got := r.Header.Get("User-Agent"); got != wantUA {
errCh <- "user-agent mismatch"
w.WriteHeader(http.StatusBadRequest)
return
}
if got := r.Header.Get("originator"); got != wantOriginator {
errCh <- "originator mismatch"
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"access_token":"at2","refresh_token":"rt2","token_type":"bearer","expires_in":3600}`)
@@ -1000,6 +1000,8 @@ func filterSchedulerExtra(extra map[string]any) map[string]any {
"openai_ws_force_http",
"openai_responses_mode",
"openai_responses_supported",
"codex_fingerprint_mode",
"codex_fingerprint_seed",
"codex_5h_used_percent",
"codex_7d_used_percent",
"codex_5h_reset_at",
@@ -309,6 +309,8 @@ func TestBuildSchedulerMetadataAccount_KeepsOpenAIWSFlags(t *testing.T) {
"openai_ws_force_http": true,
"openai_responses_mode": "force_chat_completions",
"openai_responses_supported": false,
"codex_fingerprint_mode": "session",
"codex_fingerprint_seed": "11111111-1111-4111-8111-111111111111",
"mixed_scheduling": true,
"unused_large_field": "drop-me",
},
@@ -321,6 +323,8 @@ func TestBuildSchedulerMetadataAccount_KeepsOpenAIWSFlags(t *testing.T) {
require.Equal(t, true, got.Extra["openai_ws_force_http"])
require.Equal(t, "force_chat_completions", got.Extra["openai_responses_mode"])
require.Equal(t, false, got.Extra["openai_responses_supported"])
require.Equal(t, "session", got.Extra["codex_fingerprint_mode"])
require.Equal(t, "11111111-1111-4111-8111-111111111111", got.Extra["codex_fingerprint_seed"])
require.Equal(t, true, got.Extra["mixed_scheduling"])
require.Nil(t, got.Extra["unused_large_field"])
}
@@ -98,6 +98,35 @@ func TestUserPlatformQuotaRepository_BulkInsertInitial_GrokAllowed(t *testing.T)
require.InDelta(t, 9.0, *rec.DailyLimitUSD, 1e-9)
}
// TestUserPlatformQuotaRepository_BulkInsertInitial_CNProvidersAllowed 回归迁移 224:
// kimi/zhipu/deepseek 平台必须能写入 user_platform_quotas(CHECK 约束已含国产供应商)。
// 历史 bug:三个平台不在约束内 → 注册预填充 8 平台默认配额时整条多行 INSERT 中止 →
// fail-open 吞错 → 新用户拿到零条配额记录(缺失配额行 = 无限额)。
func TestUserPlatformQuotaRepository_BulkInsertInitial_CNProvidersAllowed(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
txCtx := dbent.NewTxContext(ctx, tx)
client := tx.Client()
userID := mustCreateUserForQuota(t, client)
repo := NewUserPlatformQuotaRepository(client)
daily := 12.0
records := []UserPlatformQuotaRecord{
{UserID: userID, Platform: "kimi", DailyLimitUSD: &daily},
{UserID: userID, Platform: "zhipu"},
{UserID: userID, Platform: "deepseek"},
}
require.NoError(t, repo.BulkInsertInitial(txCtx, records),
"kimi/zhipu/deepseek 平台应可写入(迁移 224 后 CHECK 约束已含国产供应商)")
for _, platform := range []string{"kimi", "zhipu", "deepseek"} {
rec, err := repo.GetByUserPlatform(txCtx, userID, platform)
require.NoError(t, err)
require.NotNil(t, rec, "%s 配额行应已写入", platform)
}
}
func TestUserPlatformQuotaRepository_GetByUserPlatform(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
+22 -15
View File
@@ -70,23 +70,30 @@ func (r *userRepository) create(ctx context.Context, userIn *service.User, guard
// 统一使用 ent 的事务:保证用户与允许分组的更新原子化,
// 并避免基于 *sql.Tx 手动构造 ent client 导致的 ExecQuerier 断言错误。
tx, err := r.client.Tx(ctx)
if err != nil && !errors.Is(err, dbent.ErrTxStarted) {
return err
}
//
// 注意:ent 的 Client.Tx 不感知上下文中的事务(只检查 driver 类型),
// 因此必须显式检查 TxFromContext:当调用方已开启外部事务(如注册时的
// “建用户 + 占用邀请码”原子事务),直接复用其 client,由调用方统一提交/回滚,
// 否则用户写入会落入独立事务并自行提交,导致外层事务无法回滚(孤儿用户)。
var txClient *dbent.Client
txCtx := ctx
if err == nil {
defer func() { _ = tx.Rollback() }()
txClient = tx.Client()
txCtx = dbent.NewTxContext(ctx, tx)
var ownedTx *dbent.Tx
if existingTx := dbent.TxFromContext(ctx); existingTx != nil {
txClient = existingTx.Client()
} else {
// 已处于外部事务中(ErrTxStarted),复用当前事务 client 并由调用方负责提交/回滚。
if existingTx := dbent.TxFromContext(ctx); existingTx != nil {
txClient = existingTx.Client()
} else {
tx, err := r.client.Tx(ctx)
switch {
case errors.Is(err, dbent.ErrTxStarted):
// r.client 本身已是事务绑定 client(client 注入式事务,如集成测试
// 夹具 tx.Client()):直接复用,提交/回滚由 client 的持有方负责。
txClient = r.client
case err != nil:
return err
default:
ownedTx = tx
defer func() { _ = ownedTx.Rollback() }()
txClient = tx.Client()
txCtx = dbent.NewTxContext(ctx, tx)
}
}
@@ -158,8 +165,8 @@ func (r *userRepository) create(ctx context.Context, userIn *service.User, guard
return err
}
if tx != nil {
if err := tx.Commit(); err != nil {
if ownedTx != nil {
if err := ownedTx.Commit(); err != nil {
return err
}
}
@@ -0,0 +1,113 @@
//go:build integration
package repository
import (
"context"
"testing"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
"github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
// TestCreateWithEmailAliasGuardJoinsOuterTransaction 验证用户创建会加入调用方开启的
// 外部 ent 事务(注册流程“建用户 + 占用邀请码”原子性的基础):
// - 外层事务回滚后,用户与邀请码占用必须一并撤销(不得残留孤儿账号);
// - 外层事务提交后,用户与邀请码占用同时生效。
//
// 回归背景:此前 create() 通过 r.client.Tx(ctx) 自开事务且自行 Commit —— ent 的
// Client.Tx 不感知上下文事务(只检查 driver 类型),ErrTxStarted 分支实际是死代码,
// 导致外层事务包不住用户写入;并发注册同一邀请码时,败者用户的写入已自行提交,
// 即使注册被拒绝也会留下可登录的孤儿账号(1 个邀请码仍可生成任意数量账号)。
func TestCreateWithEmailAliasGuardJoinsOuterTransaction(t *testing.T) {
client := testEntClient(t)
userRepo := NewUserRepository(client, integrationDB)
redeemRepo := NewRedeemCodeRepository(client)
ctx := context.Background()
// 清理:本测试会真实提交少量数据,确保不影响同包其它集成测试。
var committedUserEmails []string
var seededCodeIDs []int64
t.Cleanup(func() {
if len(committedUserEmails) > 0 {
_, _ = client.User.Delete().Where(user.EmailIn(committedUserEmails...)).Exec(ctx)
}
if len(seededCodeIDs) > 0 {
_, _ = client.RedeemCode.Delete().Where(redeemcode.IDIn(seededCodeIDs...)).Exec(ctx)
}
})
seedCode := func(code string) int64 {
_, err := client.RedeemCode.Create().
SetCode(code).
SetType(service.RedeemTypeInvitation).
SetStatus(service.StatusUnused).
SetValue(0).
Save(ctx)
require.NoError(t, err, "seed redeem code")
c, err := client.RedeemCode.Query().Where(redeemcode.CodeEQ(code)).Only(ctx)
require.NoError(t, err)
seededCodeIDs = append(seededCodeIDs, c.ID)
return c.ID
}
t.Run("rollback removes user and releases claim", func(t *testing.T) {
codeID := seedCode("ITX-RACE-ROLLBACK-001")
tx, err := client.Tx(ctx)
require.NoError(t, err)
txCtx := dbent.NewTxContext(ctx, tx)
u := &service.User{
Email: "itx-rollback@example.com",
PasswordHash: "test-password-hash",
Role: service.RoleUser,
Status: service.StatusActive,
Balance: 0,
Concurrency: 1,
}
require.NoError(t, userRepo.CreateWithEmailAliasGuard(txCtx, u))
require.Greater(t, u.ID, int64(0), "create 应回填用户 ID")
require.NoError(t, redeemRepo.Use(txCtx, codeID, u.ID))
require.NoError(t, tx.Rollback())
exists, err := userRepo.ExistsByEmail(ctx, "itx-rollback@example.com")
require.NoError(t, err)
require.False(t, exists, "回滚后不得残留孤儿用户")
after, err := client.RedeemCode.Get(ctx, codeID)
require.NoError(t, err)
require.Equal(t, service.StatusUnused, after.Status, "回滚后邀请码应保持 unused")
})
t.Run("commit persists user and claim together", func(t *testing.T) {
codeID := seedCode("ITX-RACE-COMMIT-001")
tx, err := client.Tx(ctx)
require.NoError(t, err)
txCtx := dbent.NewTxContext(ctx, tx)
u := &service.User{
Email: "itx-commit@example.com",
PasswordHash: "test-password-hash",
Role: service.RoleUser,
Status: service.StatusActive,
Balance: 0,
Concurrency: 1,
}
require.NoError(t, userRepo.CreateWithEmailAliasGuard(txCtx, u))
require.NoError(t, redeemRepo.Use(txCtx, codeID, u.ID))
require.NoError(t, tx.Commit())
committedUserEmails = append(committedUserEmails, u.Email)
exists, err := userRepo.ExistsByEmail(ctx, "itx-commit@example.com")
require.NoError(t, err)
require.True(t, exists, "提交后用户应存在")
after, err := client.RedeemCode.Get(ctx, codeID)
require.NoError(t, err)
require.Equal(t, service.StatusUsed, after.Status, "提交后邀请码应为 used")
})
}
+4 -2
View File
@@ -861,7 +861,7 @@ func TestAPIContracts(t *testing.T) {
"force_email_on_third_party_signup": false,
"default_concurrency": 5,
"default_balance": 1.25,
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null}},
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"auth_source_default_email_platform_quotas": null,
"auth_source_default_github_platform_quotas": null,
"auth_source_default_google_platform_quotas": null,
@@ -988,6 +988,7 @@ func TestAPIContracts(t *testing.T) {
"channel_monitor_enabled": true,
"channel_monitor_mode": "v1",
"channel_monitor_hide_throughput": true,
"channel_monitor_show_quota": false,
"channel_monitor_default_interval_seconds": 60,
"available_channels_enabled": false,
"model_plaza_enabled": false,
@@ -1175,7 +1176,7 @@ func TestAPIContracts(t *testing.T) {
"purchase_subscription_url": "",
"table_default_page_size": 20,
"table_page_size_options": [10, 20, 50],
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null}},
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"auth_source_default_email_platform_quotas": null,
"auth_source_default_github_platform_quotas": null,
"auth_source_default_google_platform_quotas": null,
@@ -1298,6 +1299,7 @@ func TestAPIContracts(t *testing.T) {
"channel_monitor_enabled": true,
"channel_monitor_mode": "v1",
"channel_monitor_hide_throughput": true,
"channel_monitor_show_quota": false,
"channel_monitor_default_interval_seconds": 60,
"available_channels_enabled": false,
"model_plaza_enabled": false,
+14
View File
@@ -58,6 +58,9 @@ func RegisterAdminRoutes(
// Grok OAuth
registerGrokOAuthRoutes(admin, h)
// 国产供应商(kimi/zhipu/deepseek)额度与余额
registerCNProviderRoutes(admin, h)
// 代理管理
registerProxyRoutes(admin, h, stepUpAuth)
@@ -482,6 +485,17 @@ func registerGrokOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
}
}
// registerCNProviderRoutes 注册国产供应商(kimi/zhipu/deepseek)的额度与余额查询端点。
func registerCNProviderRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
cn := admin.Group("/cn-providers")
{
// Coding Plan 滚动窗口用量(kimi/zhipu coding 账号)。
cn.GET("/accounts/:id/quota", h.Admin.CNProvider.QueryQuota)
// payg 账号余额(kimi/deepseek;zhipu 无余额端点)。
cn.GET("/accounts/:id/balance", h.Admin.CNProvider.QueryBalance)
}
}
func registerProxyRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAuth middleware.StepUpAuthMiddleware) {
proxies := admin.Group("/proxies")
{
+4 -2
View File
@@ -47,7 +47,9 @@ func RegisterGatewayRoutes(
isOpenAIResponsesCompatibleGatewayPlatform := func(c *gin.Context) bool {
switch getGroupPlatform(c) {
case service.PlatformOpenAI, service.PlatformGrok:
case service.PlatformOpenAI, service.PlatformGrok,
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek:
// 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)与 openai/grok 一样经 OpenAI 网关转发。
return true
default:
return false
@@ -58,7 +60,7 @@ func RegisterGatewayRoutes(
}
countTokensHandler := func(c *gin.Context) {
switch getGroupPlatform(c) {
case service.PlatformOpenAI:
case service.PlatformOpenAI, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek:
h.OpenAIGateway.CountTokens(c)
case service.PlatformGrok:
h.OpenAIGateway.GrokCountTokens(c)
+189 -6
View File
@@ -271,8 +271,30 @@ func (a *Account) IsGrokOAuth() bool {
return a.IsGrok() && a.Type == AccountTypeOAuth
}
// IsKimi / IsZhipu / IsDeepseek 标识国产 OpenAI 兼容供应商账号。
func (a *Account) IsKimi() bool {
return a.Platform == PlatformKimi
}
func (a *Account) IsZhipu() bool {
return a.Platform == PlatformZhipu
}
func (a *Account) IsDeepseek() bool {
return a.Platform == PlatformDeepseek
}
// IsCNProvider 报告是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)。
func (a *Account) IsCNProvider() bool {
return a != nil && IsCNProvider(a.Platform)
}
// IsOpenAICompatible 报告账号是否走 OpenAI 网关(OpenAI 协议族)。
// openai/grok 原生走 OpenAI 网关;kimi/zhipu/deepseek 同为 OpenAI Chat Completions
// 兼容上游,也经 OpenAI 网关转发。
func (a *Account) IsOpenAICompatible() bool {
return a != nil && (a.Platform == PlatformOpenAI || a.Platform == PlatformGrok)
return a != nil && (a.Platform == PlatformOpenAI || a.Platform == PlatformGrok ||
a.Platform == PlatformKimi || a.Platform == PlatformZhipu || a.Platform == PlatformDeepseek)
}
func (a *Account) GeminiOAuthType() string {
@@ -1281,17 +1303,161 @@ func (a *Account) IsOpenAIApiKey() bool {
return a.IsOpenAI() && a.Type == AccountTypeAPIKey
}
// GetOpenAIBaseURL 解析 OpenAI 协议族账号的上游 base_url。
// 适用 openai 与国产 OpenAI 兼容供应商(kimi/zhipu/deepseek);grok 走 GetGrokBaseURL,
// 此处对 grok 返回 "" 以保持原有行为。
func (a *Account) GetOpenAIBaseURL() string {
if !a.IsOpenAI() {
if !a.IsOpenAI() && !a.IsCNProvider() {
return ""
}
if a.Type == AccountTypeAPIKey {
baseURL := a.GetCredential("base_url")
if baseURL != "" {
if a.Type == AccountTypeAPIKey || a.Type == AccountTypeUpstream {
if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" {
return baseURL
}
}
return "https://api.openai.com"
// 平台默认 base_url:CN 供应商按 account_mode 选择 payg / coding 默认值。
switch a.Platform {
case PlatformKimi:
if a.GetAccountMode() == AccountModeCoding {
return DefaultKimiCodingBaseURL
}
return DefaultKimiPayGBaseURL
case PlatformZhipu:
if a.GetAccountMode() == AccountModeCoding {
return DefaultZhipuCodingBaseURL
}
return DefaultZhipuPayGBaseURL
case PlatformDeepseek:
return DefaultDeepseekBaseURL
default:
return "https://api.openai.com"
}
}
// GetAccountMode 返回国产供应商账号的接入模式(payg / coding);非国产供应商或未设置时
// 返回空串。存储于 credentials["account_mode"]。
func (a *Account) GetAccountMode() string {
if a == nil {
return ""
}
mode := strings.TrimSpace(a.GetCredential("account_mode"))
if mode == AccountModePayG || mode == AccountModeCoding {
return mode
}
return ""
}
// IsCodingPlan 报告账号是否为 Coding Plan 模式(用于滚动用量窗口冷却)。
func (a *Account) IsCodingPlan() bool {
return a.GetAccountMode() == AccountModeCoding
}
// GetAPIProtocol 返回国产供应商账号的上游 API 协议。存储于
// credentials["api_protocol"];缺失或与平台不匹配时回退 chat_completions
// (与既有行为完全一致)。responses 协议仅 deepseek 支持(官方原生 /responses
// 端点,适配 Codex);kimi/zhipu 无此端点。
func (a *Account) GetAPIProtocol() string {
if a == nil || !a.IsCNProvider() {
return APIProtocolChatCompletions
}
switch strings.TrimSpace(a.GetCredential("api_protocol")) {
case APIProtocolAnthropic:
return APIProtocolAnthropic
case APIProtocolResponses:
if a.Platform == PlatformDeepseek {
return APIProtocolResponses
}
case APIProtocolChatCompletions:
return APIProtocolChatCompletions
}
return APIProtocolChatCompletions
}
// IsAnthropicProtocol 报告账号是否以原生 Anthropic 协议接入上游
// (/v1/messages 直通,适配 Claude Code 等客户端)。
func (a *Account) IsAnthropicProtocol() bool {
return a.GetAPIProtocol() == APIProtocolAnthropic
}
// GetAnthropicProtocolBaseURL 返回 Anthropic 协议账号的上游 base_url
// (上游路径为 {base}/v1/messages)。优先取凭证 base_url,缺失时按
// 供应商 × 接入模式返回默认端点。非 Anthropic 协议账号返回空串。
func (a *Account) GetAnthropicProtocolBaseURL() string {
if a == nil || !a.IsAnthropicProtocol() {
return ""
}
if a.Type == AccountTypeAPIKey || a.Type == AccountTypeUpstream {
if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" {
return baseURL
}
}
switch a.Platform {
case PlatformKimi:
if a.GetAccountMode() == AccountModeCoding {
return DefaultKimiCodingAnthropicBaseURL
}
return DefaultKimiPayGAnthropicBaseURL
case PlatformZhipu:
return DefaultZhipuAnthropicBaseURL
case PlatformDeepseek:
return DefaultDeepseekAnthropicBaseURL
default:
return ""
}
}
// GetOpenAIFormatBaseURL 返回供 OpenAI 格式端点(/v1/models、/v1/chat/completions
// 等)使用的 base。chat_completions / responses 协议下与 GetOpenAIBaseURL
// 一致(凭证 base_url 或平台默认);anthropic 协议下凭证 base_url 指向 Anthropic
// 端点,不能拿来拼 OpenAI 路径,此时返回该供应商 × 模式的 Chat Completions
// 默认 base(模型同步等协议族共用路径仍可用)。
func (a *Account) GetOpenAIFormatBaseURL() string {
if a == nil || !a.IsAnthropicProtocol() {
return a.GetOpenAIBaseURL()
}
switch a.Platform {
case PlatformKimi:
if a.GetAccountMode() == AccountModeCoding {
return DefaultKimiCodingBaseURL
}
return DefaultKimiPayGBaseURL
case PlatformZhipu:
if a.GetAccountMode() == AccountModeCoding {
return DefaultZhipuCodingBaseURL
}
return DefaultZhipuPayGBaseURL
case PlatformDeepseek:
return DefaultDeepseekBaseURL
default:
return a.GetOpenAIBaseURL()
}
}
// GetCNAPIKey 返回国产 OpenAI 兼容供应商账号的 api_key 凭据(kimi/zhipu/deepseek)。
// 与 openai 的 GetOpenAIApiKey 区分:后者仅对 openai 平台返回。
func (a *Account) GetCNAPIKey() string {
if a == nil || !a.IsCNProvider() {
return ""
}
return a.GetCredential("api_key")
}
// GetCodingPlanProvider 根据 base_url 识别 Coding Plan 供应商(kimi / zhipu),
// 用于路由到对应的额度查询端点。非 coding 模式或无法识别时返回空串。
// 判定规则与 cc-switch coding_plan.rs::detect_provider 保持一致。
func (a *Account) GetCodingPlanProvider() string {
if a == nil || a.GetAccountMode() != AccountModeCoding {
return ""
}
baseURL := strings.ToLower(a.GetOpenAIBaseURL())
switch {
case strings.Contains(baseURL, "api.kimi.com/coding"):
return PlatformKimi
case strings.Contains(baseURL, "bigmodel.cn"), strings.Contains(baseURL, "api.z.ai"):
return PlatformZhipu
default:
return ""
}
}
func (a *Account) GetOpenAIAccessToken() string {
@@ -1404,6 +1570,23 @@ func (a *Account) GetOpenAIApiKey() string {
return a.GetCredential("api_key")
}
// GetOpenAIProtocolAPIKey 返回 OpenAI 协议族 APIKey 账号的密钥。
// 覆盖 openai 原生账号与国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)账号,
// 供转发鉴权、模型列表同步等协议族共用路径使用。注意 IsOpenAIApiKey 语义上
// 仅指 openai 平台账号,调度倍率/WS 能力门控继续以其为准,不受本方法影响。
func (a *Account) GetOpenAIProtocolAPIKey() string {
if a == nil {
return ""
}
if a.IsCNProvider() {
if a.Type != AccountTypeAPIKey {
return ""
}
return a.GetCredential("api_key")
}
return a.GetOpenAIApiKey()
}
func (a *Account) GetOpenAIUserAgent() string {
if !a.IsOpenAI() {
return ""
@@ -0,0 +1,245 @@
package service
import (
"context"
"testing"
"github.com/stretchr/testify/require"
)
const userSuppliedCodexFingerprintSeed = "22222222-2222-4222-8222-222222222222"
func requireValidCodexFingerprintSeed(t *testing.T, extra map[string]any) string {
t.Helper()
seed, ok := codexFingerprintSeed(extra)
require.True(t, ok, "expected valid canonical Codex fingerprint seed")
return seed
}
func TestAdminCreateAccountStripsUserSeedAndCreatesFreshSeedWhenEnabled(t *testing.T) {
repo := &upstreamBillingProbeAccountRepo{}
svc := &adminServiceImpl{accountRepo: repo}
created, err := svc.CreateAccount(context.Background(), &CreateAccountInput{
Name: "codex-oauth",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
SkipDefaultGroupBind: true,
Extra: map[string]any{
codexFingerprintModeExtraKey: "session",
codexFingerprintSeedExtraKey: userSuppliedCodexFingerprintSeed,
},
})
require.NoError(t, err)
seed := requireValidCodexFingerprintSeed(t, created.Extra)
require.NotEqual(t, userSuppliedCodexFingerprintSeed, seed)
require.Equal(t, "session", created.Extra[codexFingerprintModeExtraKey])
}
func TestAdminUpdateAccountPreservesExistingSeedAndStripsUserSeed(t *testing.T) {
accountID := int64(201)
repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
accountID: {
ID: accountID,
Name: "before",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
Extra: map[string]any{
codexFingerprintModeExtraKey: "session",
codexFingerprintSeedExtraKey: testCodexFingerprintSeed,
},
},
}}
svc := &adminServiceImpl{accountRepo: repo}
updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
Extra: map[string]any{
codexFingerprintModeExtraKey: "full",
codexFingerprintSeedExtraKey: userSuppliedCodexFingerprintSeed,
"custom": "value",
},
})
require.NoError(t, err)
require.Equal(t, testCodexFingerprintSeed, requireValidCodexFingerprintSeed(t, updated.Extra))
require.Equal(t, "full", updated.Extra[codexFingerprintModeExtraKey])
require.Equal(t, "value", updated.Extra["custom"])
}
func TestAdminUpdateAccountInitializesSeedWhenFullEditEnables(t *testing.T) {
accountID := int64(202)
repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
accountID: {
ID: accountID,
Name: "before",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
Extra: map[string]any{
codexFingerprintModeExtraKey: "off",
codexFingerprintSeedExtraKey: "not-a-seed",
},
},
}}
updated, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
Extra: map[string]any{codexFingerprintModeExtraKey: "device"},
})
require.NoError(t, err)
require.NotEqual(t, "not-a-seed", requireValidCodexFingerprintSeed(t, updated.Extra))
require.Equal(t, "device", updated.Extra[codexFingerprintModeExtraKey])
}
func TestAdminUpdateAccountDisableReenablePreservesValidSeed(t *testing.T) {
accountID := int64(203)
repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
accountID: {
ID: accountID,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
Extra: map[string]any{
codexFingerprintModeExtraKey: "session",
codexFingerprintSeedExtraKey: testCodexFingerprintSeed,
},
},
}}
svc := &adminServiceImpl{accountRepo: repo}
disabled, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
Extra: map[string]any{codexFingerprintModeExtraKey: "off"},
})
require.NoError(t, err)
require.Equal(t, testCodexFingerprintSeed, requireValidCodexFingerprintSeed(t, disabled.Extra))
reenabled, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
Extra: map[string]any{codexFingerprintModeExtraKey: "session"},
})
require.NoError(t, err)
require.Equal(t, testCodexFingerprintSeed, requireValidCodexFingerprintSeed(t, reenabled.Extra))
}
func TestAdminUpdateAccountExtraStripsSeedAndLeavesAtomicEnsureToRepository(t *testing.T) {
accountID := int64(204)
repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
accountID: {
ID: accountID,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Extra: map[string]any{},
},
}}
err := (&adminServiceImpl{accountRepo: repo}).UpdateAccountExtra(context.Background(), accountID, map[string]any{
codexFingerprintModeExtraKey: "device",
codexFingerprintSeedExtraKey: userSuppliedCodexFingerprintSeed,
})
require.NoError(t, err)
require.Len(t, repo.updates[accountID], 1)
require.Equal(t, "device", repo.updates[accountID][0][codexFingerprintModeExtraKey])
require.NotContains(t, repo.updates[accountID][0], codexFingerprintSeedExtraKey)
}
func TestBulkUpdateAccountsDoesNotPrewriteCodexSeed(t *testing.T) {
repo := &upstreamBillingProbeAccountRepo{}
result, err := (&adminServiceImpl{accountRepo: repo}).BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
AccountIDs: []int64{301, 302},
Extra: map[string]any{
codexFingerprintModeExtraKey: "session",
codexFingerprintSeedExtraKey: userSuppliedCodexFingerprintSeed,
},
})
require.NoError(t, err)
require.Equal(t, 2, result.Success)
require.Empty(t, repo.updates, "bulk enable must not loop through UpdateExtra before BulkUpdate")
require.Len(t, repo.bulkUpdates, 1)
require.True(t, repo.bulkUpdates[0].EnsureCodexFingerprintSeed)
require.Equal(t, "session", repo.bulkUpdates[0].Extra[codexFingerprintModeExtraKey])
require.NotContains(t, repo.bulkUpdates[0].Extra, codexFingerprintSeedExtraKey)
}
type codexSeedDuplicateRepo struct {
*upstreamBillingProbeAccountRepo
}
func (r *codexSeedDuplicateRepo) CreateWithAccountGroups(ctx context.Context, account *Account, _ []AccountGroup) error {
return r.Create(ctx, account)
}
func TestDuplicateAccountDoesNotCopyCodexFingerprintSeed(t *testing.T) {
ctx := context.Background()
repo := &codexSeedDuplicateRepo{upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{accounts: make(map[int64]*Account)}}
svc := &adminServiceImpl{accountRepo: repo, accountDuplicateRepo: repo}
source := &Account{
Name: "source",
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Extra: map[string]any{
codexFingerprintModeExtraKey: "session",
codexFingerprintSeedExtraKey: testCodexFingerprintSeed,
},
}
require.NoError(t, repo.Create(ctx, source))
duplicate, err := svc.DuplicateAccount(ctx, source.ID, "admin:1", "")
require.NoError(t, err)
require.NotEqual(t, source.ID, duplicate.ID)
require.NotContains(t, duplicate.Extra, codexFingerprintSeedExtraKey)
require.Equal(t, "session", duplicate.Extra[codexFingerprintModeExtraKey])
}
func TestDuplicateCreatePathMintsFreshSeedWhenEligible(t *testing.T) {
extra, err := duplicateAccountExtra(map[string]any{
codexFingerprintModeExtraKey: "session",
codexFingerprintSeedExtraKey: testCodexFingerprintSeed,
})
require.NoError(t, err)
account, err := buildAccountForCreate(&CreateAccountInput{
Name: "eligible-copy",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Extra: extra,
}, extra)
require.NoError(t, err)
require.NotEqual(t, testCodexFingerprintSeed, requireValidCodexFingerprintSeed(t, account.Extra))
require.Equal(t, "session", account.Extra[codexFingerprintModeExtraKey])
}
func TestAccountServiceCreateAndUpdateCodexSeedLifecycle(t *testing.T) {
ctx := context.Background()
repo := &upstreamBillingProbeAccountRepo{accounts: make(map[int64]*Account)}
svc := NewAccountService(repo, nil)
created, err := svc.Create(ctx, CreateAccountRequest{
Name: "legacy-create",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Extra: map[string]any{
codexFingerprintModeExtraKey: "session",
codexFingerprintSeedExtraKey: userSuppliedCodexFingerprintSeed,
},
})
require.NoError(t, err)
createdSeed := requireValidCodexFingerprintSeed(t, created.Extra)
require.NotEqual(t, userSuppliedCodexFingerprintSeed, createdSeed)
updateSeed := userSuppliedCodexFingerprintSeed
updated, err := svc.Update(ctx, created.ID, UpdateAccountRequest{
Extra: &map[string]any{
codexFingerprintModeExtraKey: "full",
codexFingerprintSeedExtraKey: updateSeed,
},
})
require.NoError(t, err)
require.Equal(t, createdSeed, requireValidCodexFingerprintSeed(t, updated.Extra))
require.Equal(t, "full", updated.Extra[codexFingerprintModeExtraKey])
}
@@ -258,18 +258,20 @@ func TestAdminServiceBulkUpdateAccountsRejectsMalformedOpenAILongContextBillingV
require.Zero(t, repo.bulkUpdateCalls)
}
func TestAdminServiceBulkUpdateAccountsAllowsProviderOwnedValueForNonOpenAIAccounts(t *testing.T) {
func TestAdminServiceBulkUpdateAccountsRejectsOpenAILongContextKeyForNonOpenAIAccounts(t *testing.T) {
repo := &longContextBillingRepoStub{account: &Account{ID: 1, Platform: PlatformGrok}}
svc := &adminServiceImpl{accountRepo: repo}
result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
AccountIDs: []int64{1},
Extra: map[string]any{openAILongContextBillingEnabledKey: []string{"provider-owned"}},
Extra: map[string]any{openAILongContextBillingEnabledKey: true},
})
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, 1, repo.bulkUpdateCalls)
require.Nil(t, result)
var appErr *infraerrors.ApplicationError
require.ErrorAs(t, err, &appErr)
require.Equal(t, "OPENAI_BULK_TARGET_INVALID", appErr.Reason)
require.Zero(t, repo.bulkUpdateCalls)
}
func TestAdminServiceBulkUpdateAccountsRejectsMalformedValueForMixedTargetsIncludingOpenAI(t *testing.T) {
@@ -58,6 +58,10 @@ func EvaluateAccountSchedulingThreshold(account *Account, thresholds map[string]
winner = pickLatestResetSchedulingCandidate(anthropicThresholdCandidates(account), threshold, now)
case PlatformGrok:
winner = pickLatestResetSchedulingCandidate(grokThresholdCandidates(account), threshold, now)
case PlatformKimi:
winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, PlatformKimi), threshold, now)
case PlatformZhipu:
winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, PlatformZhipu), threshold, now)
default:
return decision
}
@@ -303,6 +307,45 @@ func grokThresholdCandidates(account *Account) []*accountSchedulingThresholdCand
}
}
// cnProviderThresholdCandidates 读取国产供应商 Coding Plan 账号的 5h / weekly 滚动窗口
// 用量快照(由 CNProviderQuotaService 写入 account.Extra,键形如
// <provider>_5h_used_percent / <provider>_weekly_reset_at)。payg 账号无此快照,
// 候选为空 → 不触发阈值停调(余额型走余额检测)。与 openai 的快照驱动停调一致:
// 仅当用量超阈值且窗口尚未重置时才停调。
func cnProviderThresholdCandidates(account *Account, provider string) []*accountSchedulingThresholdCandidate {
if account == nil || len(account.Extra) == 0 {
return nil
}
return []*accountSchedulingThresholdCandidate{
cnThresholdCandidate(account.Extra, provider, "5h"),
cnThresholdCandidate(account.Extra, provider, "weekly"),
}
}
func cnThresholdCandidate(extra map[string]any, provider, window string) *accountSchedulingThresholdCandidate {
var usedKey, resetKey string
switch window {
case "5h":
usedKey = cnExtraKey(provider, cnExtraSuffix5hUsed)
resetKey = cnExtraKey(provider, cnExtraSuffix5hReset)
case "weekly":
usedKey = cnExtraKey(provider, cnExtraSuffixWeeklyUsed)
resetKey = cnExtraKey(provider, cnExtraSuffixWeeklyReset)
default:
return nil
}
usedPercent, ok := extra[usedKey]
if !ok {
return nil
}
return &accountSchedulingThresholdCandidate{
window: window,
scope: provider,
usedPercent: schedulingPercentValue(usedPercent),
until: parseSchedulingResetAt(extra[resetKey]),
}
}
func pickLatestResetSchedulingCandidate(candidates []*accountSchedulingThresholdCandidate, threshold int, now time.Time) *accountSchedulingThresholdCandidate {
var winner *accountSchedulingThresholdCandidate
for _, candidate := range candidates {
+10 -2
View File
@@ -166,6 +166,9 @@ type AccountBulkUpdate struct {
Credentials map[string]any
Extra map[string]any
ProbeEnabled *bool
// EnsureCodexFingerprintSeed asks the repository to atomically preserve an
// existing valid Codex fingerprint seed or create one for eligible rows.
EnsureCodexFingerprintSeed bool
}
// CreateAccountRequest 创建账号请求
@@ -233,7 +236,7 @@ func (s *AccountService) Create(ctx context.Context, req CreateAccountRequest) (
Platform: req.Platform,
Type: req.Type,
Credentials: SanitizeStoredCredentials(req.Platform, req.Credentials),
Extra: req.Extra,
Extra: prepareCodexFingerprintExtraForCreate(req.Platform, req.Type, req.Extra),
ProxyID: req.ProxyID,
Concurrency: req.Concurrency,
Priority: req.Priority,
@@ -336,7 +339,9 @@ func (s *AccountService) Update(ctx context.Context, id int64, req UpdateAccount
delete(extra, OllamaCloudUsageSessionExtraKey)
delete(extra, OllamaCloudUsageAutoRefreshExtraKey)
delete(extra, OllamaCloudUsageSnapshotExtraKey)
account.Extra = extra
account.Extra = prepareCodexFingerprintExtraForUpdate(account, extra)
} else {
account.Extra = prepareCodexFingerprintExtraForUpdate(account, account.Extra)
}
if req.ProxyID != nil {
@@ -509,6 +514,9 @@ func (s *AccountService) TestCredentials(ctx context.Context, id int64) error {
case PlatformGrok:
// Grok OAuth credentials are validated via token exchange/refresh and request-path probes.
return nil
case PlatformKimi, PlatformZhipu, PlatformDeepseek:
// 国产 OpenAI 兼容供应商:凭证为 API Key,实际可用性经余额/额度探测与转发路径验证。
return nil
default:
return fmt.Errorf("unsupported platform: %s", account.Platform)
}
@@ -733,11 +733,12 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
req.Host = "chatgpt.com"
req.Header.Set("accept", "text/event-stream")
req.Header.Set("OpenAI-Beta", "responses=experimental")
req.Header.Set("Originator", openai.CodexDefaultOriginator)
canonical := resolveCodexOutboundIdentity("")
req.Header.Set("Originator", canonical.originator)
if customUA := strings.TrimSpace(credentialAccount.GetOpenAIUserAgent()); customUA != "" {
req.Header.Set("User-Agent", customUA)
} else {
req.Header.Set("User-Agent", codexCLIUserAgent)
req.Header.Set("User-Agent", canonical.userAgent)
}
setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount)
// 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入,否则测试用的身份
@@ -2070,6 +2071,9 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account
req.Header.Set("Authorization", "Bearer "+authToken)
}
applyOpenAICodexProbeHeaders(req.Header)
if isOAuth {
enforceCodexIdentityHeadersWithUA(req.Header, credentialAccount.GetOpenAIUserAgent())
}
probeSessionID := compactProbeSessionID(account.ID)
req.Header.Set("Session_ID", probeSessionID)
req.Header.Set("Conversation_ID", probeSessionID)
@@ -2976,11 +2980,12 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("OpenAI-Beta", "responses=experimental")
req.Header.Set("originator", openai.CodexDefaultOriginator)
canonical := resolveCodexOutboundIdentity("")
req.Header.Set("originator", canonical.originator)
if customUA := strings.TrimSpace(credentialAccount.GetOpenAIUserAgent()); customUA != "" {
req.Header.Set("User-Agent", customUA)
} else {
req.Header.Set("User-Agent", codexCLIUserAgent)
req.Header.Set("User-Agent", canonical.userAgent)
}
setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount)
// 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入,否则测试用的身份
@@ -285,7 +285,10 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactProbeIdentityMatc
"chatgpt_account_id": "chatgpt-acc",
},
// 收敛是显式 opt-in(#5610),这里显式开启以验证探测身份与真实流量同构。
Extra: map[string]any{"codex_fingerprint_mode": "session"},
Extra: map[string]any{
"codex_fingerprint_mode": "session",
codexFingerprintSeedExtraKey: testCodexFingerprintSeed,
},
}
repo := &snapshotUpdateAccountRepo{
stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
@@ -305,10 +308,12 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactProbeIdentityMatc
require.NoError(t, svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact))
// 显式 session 收敛模式:出站身份 = 账号级收敛值
converged := resolveConvergedSessionID(&account)
seed, ok := codexFingerprintSeed(account.Extra)
require.True(t, ok)
converged := resolveConvergedSessionID(seed)
require.Equal(t, converged, upstream.lastReq.Header.Get("session-id"))
require.Equal(t, converged, upstream.lastReq.Header.Get("session_id"))
require.Equal(t, resolveConvergedInstallationID(&account), upstream.lastReq.Header.Get("x-codex-installation-id"),
require.Equal(t, resolveConvergedInstallationID(&account, seed), upstream.lastReq.Header.Get("x-codex-installation-id"),
"真实 Codex 每个请求必带 installation-id,探测不得缺失")
require.NotContains(t, upstream.lastReq.Header.Get("session-id"), "probe_compact",
"探测标识不得是可被上游一眼识别的字面量")
@@ -105,15 +105,14 @@ type antigravityUsageCache struct {
}
const (
apiCacheTTL = 3 * time.Minute
apiErrorCacheTTL = 1 * time.Minute // 负缓存 TTL:429 等错误缓存 1 分钟
antigravityErrorTTL = 1 * time.Minute // Antigravity 错误缓存 TTL(可恢复错误)
apiQueryMaxJitter = 800 * time.Millisecond // 用量查询最大随机延迟
windowStatsCacheTTL = 1 * time.Minute
openAIProbeCacheTTL = 10 * time.Minute
grokProbeRetryTTL = 1 * time.Minute
grokFreeQuotaWindow = 24 * time.Hour
openAICodexProbeVersion = codexCLIVersion // 与网关出站身份同源,避免两处硬编码版本各自漂移
apiCacheTTL = 3 * time.Minute
apiErrorCacheTTL = 1 * time.Minute // 负缓存 TTL:429 等错误缓存 1 分钟
antigravityErrorTTL = 1 * time.Minute // Antigravity 错误缓存 TTL(可恢复错误)
apiQueryMaxJitter = 800 * time.Millisecond // 用量查询最大随机延迟
windowStatsCacheTTL = 1 * time.Minute
openAIProbeCacheTTL = 10 * time.Minute
grokProbeRetryTTL = 1 * time.Minute
grokFreeQuotaWindow = 24 * time.Hour
)
// UsageCache 封装账户使用量相关的缓存
@@ -843,7 +842,7 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco
if accessToken == "" && !account.IsOpenAIAgentIdentity() {
return nil, fmt.Errorf("no access token available")
}
modelID := openaipkg.DefaultTestModel
modelID := openaipkg.CodexUsageProbeModel
payload := createOpenAITestPayload(modelID, true)
payloadBytes, err := json.Marshal(payload)
if err != nil {
@@ -873,9 +872,10 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco
}
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("OpenAI-Beta", "responses=experimental")
req.Header.Set("Originator", openaipkg.CodexDefaultOriginator)
req.Header.Set("Version", openAICodexProbeVersion)
req.Header.Set("User-Agent", codexCLIUserAgent)
canonical := resolveCodexOutboundIdentity("")
req.Header.Set("Originator", canonical.originator)
req.Header.Set("Version", canonical.version)
req.Header.Set("User-Agent", canonical.userAgent)
if s.identityCache != nil {
if fp, fpErr := s.identityCache.GetFingerprint(reqCtx, account.ID); fpErr == nil && fp != nil && strings.TrimSpace(fp.UserAgent) != "" {
req.Header.Set("User-Agent", strings.TrimSpace(fp.UserAgent))
+47 -39
View File
@@ -128,22 +128,24 @@ var duplicateAccountDiscardedExtraKeys = map[string]struct{}{
"drive_storage_limit": {},
"drive_storage_usage": {},
"drive_tier_updated_at": {},
"codex_primary_used_percent": {},
"codex_primary_reset_after_seconds": {},
"codex_primary_window_minutes": {},
"codex_secondary_used_percent": {},
"codex_secondary_reset_after_seconds": {},
"codex_secondary_window_minutes": {},
"codex_primary_over_secondary_percent": {},
"codex_usage_updated_at": {},
"codex_5h_used_percent": {},
"codex_5h_reset_after_seconds": {},
"codex_5h_window_minutes": {},
"codex_5h_reset_at": {},
"codex_7d_used_percent": {},
"codex_7d_reset_after_seconds": {},
"codex_7d_window_minutes": {},
"codex_7d_reset_at": {},
// Codex fingerprint convergence uses a per-account random seed, never copied from another account.
codexFingerprintSeedExtraKey: {},
"codex_primary_used_percent": {},
"codex_primary_reset_after_seconds": {},
"codex_primary_window_minutes": {},
"codex_secondary_used_percent": {},
"codex_secondary_reset_after_seconds": {},
"codex_secondary_window_minutes": {},
"codex_primary_over_secondary_percent": {},
"codex_usage_updated_at": {},
"codex_5h_used_percent": {},
"codex_5h_reset_after_seconds": {},
"codex_5h_window_minutes": {},
"codex_5h_reset_at": {},
"codex_7d_used_percent": {},
"codex_7d_reset_after_seconds": {},
"codex_7d_window_minutes": {},
"codex_7d_reset_at": {},
}
func duplicateAccountExtra(value map[string]any) (map[string]any, error) {
@@ -404,6 +406,7 @@ func buildAccountForCreate(input *CreateAccountInput, accountExtra map[string]an
delete(accountExtra, OllamaCloudUsageSessionExtraKey)
delete(accountExtra, OllamaCloudUsageAutoRefreshExtraKey)
delete(accountExtra, OllamaCloudUsageSnapshotExtraKey)
accountExtra = prepareCodexFingerprintExtraForCreate(input.Platform, input.Type, accountExtra)
account := &Account{
Name: input.Name,
Notes: normalizeAccountNotes(input.Notes),
@@ -651,6 +654,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
normalizedExtra[key] = v
}
}
normalizedExtra = prepareCodexFingerprintExtraForUpdate(account, normalizedExtra)
account.Extra = normalizedExtra
if account.Platform == PlatformAntigravity && wasOveragesEnabled && !account.IsOveragesEnabled() {
delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态
@@ -670,6 +674,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
ComputeQuotaResetAt(account.Extra)
NormalizeFixedQuotaWindows(account.Extra)
}
if input.Extra == nil {
account.Extra = prepareCodexFingerprintExtraForUpdate(account, account.Extra)
}
if requestedRateSyncEnabledUpdate != nil && *requestedRateSyncEnabledUpdate {
if requestedProbeEnabledUpdate != nil && !*requestedProbeEnabledUpdate {
return nil, infraerrors.BadRequest(
@@ -852,6 +859,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
// UpdateAccountExtra 仅对 Extra JSONB 做 key 级合并,避免覆盖其它运行态键
// (如 model_rate_limits / passive_usage_* 等)。
func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, updates map[string]any) error {
updates = sanitizedCodexFingerprintExtraUpdates(updates)
delete(updates, UpstreamBillingProbeEnabledExtraKey)
delete(updates, UpstreamBillingRateSyncEnabledExtraKey)
delete(updates, UpstreamBillingProbeExtraKey)
@@ -877,6 +885,7 @@ func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, upd
// It merges credentials/extra keys instead of overwriting the whole object.
func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) {
// Managed probe/session state may only enter through dedicated typed endpoints.
input.Extra = sanitizedCodexFingerprintExtraUpdates(input.Extra)
delete(input.Extra, UpstreamBillingProbeEnabledExtraKey)
delete(input.Extra, UpstreamBillingRateSyncEnabledExtraKey)
delete(input.Extra, UpstreamBillingProbeExtraKey)
@@ -906,26 +915,36 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
return nil, err
}
}
openAISettings, err := normalizeBulkOpenAISettings(input)
if err != nil {
return nil, err
}
needMixedChannelCheck := input.GroupIDs != nil && !input.SkipMixedChannelCheck
_, hasLongContextBillingUpdate := input.Extra[openAILongContextBillingEnabledKey]
// 预取所有目标账号,供凭据守卫/代理守卫/混合渠道检查共用,避免多次 DB 查询。
var cachedTargets []*Account
if len(input.Credentials) > 0 || input.ProxyID != nil || needMixedChannelCheck || hasLongContextBillingUpdate || input.ProbeEnabled != nil || input.RateMultiplier != nil {
if len(input.Credentials) > 0 || input.ProxyID != nil || needMixedChannelCheck || openAISettings.any() || input.ProbeEnabled != nil || input.RateMultiplier != nil {
loaded, err := s.accountRepo.GetByIDs(ctx, input.AccountIDs)
if err != nil {
return nil, err
}
cachedTargets = loaded
}
if input.ProbeEnabled != nil {
targetsByID := make(map[int64]*Account, len(cachedTargets))
for _, account := range cachedTargets {
if account != nil {
targetsByID[account.ID] = account
}
targetsByID := make(map[int64]*Account, len(cachedTargets))
for _, account := range cachedTargets {
if account != nil {
targetsByID[account.ID] = account
}
}
if openAISettings.any() {
inheritedCount, err := validateBulkOpenAISettingsTargets(input, openAISettings, targetsByID)
if err != nil {
return nil, err
}
result.LongContextInheritedCount = inheritedCount
}
if input.ProbeEnabled != nil {
for _, accountID := range input.AccountIDs {
account, ok := targetsByID[accountID]
if !ok {
@@ -936,18 +955,6 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
}
}
}
if hasLongContextBillingUpdate {
for _, account := range cachedTargets {
if account == nil || account.Platform != PlatformOpenAI {
continue
}
if err := ValidateOpenAILongContextBillingExtra(account.Platform, input.Extra); err != nil {
return nil, err
}
break
}
}
// 影子账号绝不持有凭据:批量更新携带凭据时,目标中不得含影子(外审 G5,与单账号
// UpdateAccount 守卫对齐)。覆盖显式 IDs 与 filter 解析出的 IDs(此处 AccountIDs 已解析完成)。
if len(input.Credentials) > 0 {
@@ -1027,9 +1034,10 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
// Prepare bulk updates for columns and JSONB fields.
repoUpdates := AccountBulkUpdate{
Credentials: input.Credentials,
Extra: input.Extra,
ProbeEnabled: input.ProbeEnabled,
Credentials: input.Credentials,
Extra: input.Extra,
ProbeEnabled: input.ProbeEnabled,
EnsureCodexFingerprintSeed: ShouldEnsureCodexFingerprintSeedForExtraUpdates(input.Extra),
}
if input.ProbeEnabled != nil {
if repoUpdates.Extra == nil {
+6
View File
@@ -987,6 +987,12 @@ func normalizeGroupModelPricing(platform string, pricing []ChannelModelPricing)
out[i] = pricing[i].Clone()
out[i].ID = 0
out[i].ChannelID = 0
if out[i].TimePricing != nil && len(out[i].TimePricing.Periods) > 0 {
return nil, infraerrors.BadRequest(
"GROUP_MODEL_TIME_PRICING_UNSUPPORTED",
"group model pricing does not support time pricing",
)
}
if strings.TrimSpace(out[i].Platform) == "" {
out[i].Platform = platform
}
+6 -5
View File
@@ -477,11 +477,12 @@ type UserGroupRPMStatus struct {
// BulkUpdateAccountsResult is the aggregated response for bulk updates.
type BulkUpdateAccountsResult struct {
Success int `json:"success"`
Failed int `json:"failed"`
SuccessIDs []int64 `json:"success_ids"`
FailedIDs []int64 `json:"failed_ids"`
Results []BulkUpdateAccountResult `json:"results"`
Success int `json:"success"`
Failed int `json:"failed"`
SuccessIDs []int64 `json:"success_ids"`
FailedIDs []int64 `json:"failed_ids"`
Results []BulkUpdateAccountResult `json:"results"`
LongContextInheritedCount int `json:"long_context_inherited_count,omitempty"`
}
type CreateProxyInput struct {
@@ -18,6 +18,8 @@ type accountRepoStubForBulkUpdate struct {
accountRepoStub
bulkUpdateErr error
bulkUpdateIDs []int64
bulkUpdateCalls int
lastBulkUpdate AccountBulkUpdate
bindGroupErrByID map[int64]error
bindGroupsCalls []int64
bindGroupsByAccount map[int64][]int64
@@ -50,14 +52,23 @@ type accountRepoStubForBulkUpdate struct {
}
}
func (s *accountRepoStubForBulkUpdate) BulkUpdate(_ context.Context, ids []int64, _ AccountBulkUpdate) (int64, error) {
func (s *accountRepoStubForBulkUpdate) BulkUpdate(_ context.Context, ids []int64, updates AccountBulkUpdate) (int64, error) {
s.bulkUpdateCalls++
s.bulkUpdateIDs = append([]int64{}, ids...)
s.lastBulkUpdate = updates
if s.bulkUpdateErr != nil {
return 0, s.bulkUpdateErr
}
return int64(len(ids)), nil
}
func requireApplicationErrorReason(t *testing.T, err error, reason string) {
t.Helper()
var appErr *infraerrors.ApplicationError
require.ErrorAs(t, err, &appErr)
require.Equal(t, reason, appErr.Reason)
}
func (s *accountRepoStubForBulkUpdate) Create(_ context.Context, account *Account) error {
s.createAccount = account
if s.createID > 0 {
@@ -307,3 +318,280 @@ func TestAdminServiceBulkUpdateAccounts_ResolvesIDsFromFilters(t *testing.T) {
require.Equal(t, 0, result.Failed)
require.Equal(t, []int64{7, 11}, result.SuccessIDs)
}
func TestAdminServiceBulkUpdateAccounts_NormalizesOpenAISettings(t *testing.T) {
repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{
{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
}}
svc := &adminServiceImpl{accountRepo: repo}
result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
AccountIDs: []int64{1, 2},
Credentials: map[string]any{
openAIEndpointCapabilitiesCredentialKey: []any{"chat_completions", "embeddings"},
},
Extra: map[string]any{
openAILongContextBillingEnabledKey: true,
"openai_responses_mode": "auto",
},
})
require.NoError(t, err)
require.Equal(t, 2, result.Success)
require.Zero(t, result.LongContextInheritedCount)
require.Equal(t, 1, repo.bulkUpdateCalls)
require.Contains(t, repo.lastBulkUpdate.Credentials, openAIEndpointCapabilitiesCredentialKey)
require.Nil(t, repo.lastBulkUpdate.Credentials[openAIEndpointCapabilitiesCredentialKey])
require.Equal(t, true, repo.lastBulkUpdate.Extra[openAILongContextBillingEnabledKey])
require.Contains(t, repo.lastBulkUpdate.Extra, "openai_responses_mode")
require.Nil(t, repo.lastBulkUpdate.Extra["openai_responses_mode"])
}
func TestAdminServiceBulkUpdateAccounts_AcceptsLongContextAccountTypes(t *testing.T) {
for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken, AccountTypeAPIKey} {
t.Run(accountType, func(t *testing.T) {
repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{{
ID: 1, Platform: PlatformOpenAI, Type: accountType,
}}}
svc := &adminServiceImpl{accountRepo: repo}
result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
AccountIDs: []int64{1},
Extra: map[string]any{openAILongContextBillingEnabledKey: false},
})
require.NoError(t, err)
require.Equal(t, 1, result.Success)
require.Equal(t, 1, repo.bulkUpdateCalls)
})
}
}
func TestAdminServiceBulkUpdateAccounts_EmbeddingsOnlyResetsResponsesMode(t *testing.T) {
repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{
{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
}}
svc := &adminServiceImpl{accountRepo: repo}
_, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
AccountIDs: []int64{1},
Credentials: map[string]any{
openAIEndpointCapabilitiesCredentialKey: []string{"embeddings"},
},
})
require.NoError(t, err)
require.Equal(t, []string{"embeddings"}, repo.lastBulkUpdate.Credentials[openAIEndpointCapabilitiesCredentialKey])
require.Contains(t, repo.lastBulkUpdate.Extra, "openai_responses_mode")
require.Nil(t, repo.lastBulkUpdate.Extra["openai_responses_mode"])
}
func TestAdminServiceBulkUpdateAccounts_RejectsInvalidOpenAISettingValuesBeforeWrite(t *testing.T) {
tests := []struct {
name string
credentials map[string]any
extra map[string]any
reason string
}{
{name: "long context type", extra: map[string]any{openAILongContextBillingEnabledKey: "true"}, reason: "OPENAI_LONG_CONTEXT_BILLING_INVALID"},
{name: "empty capabilities", credentials: map[string]any{openAIEndpointCapabilitiesCredentialKey: []any{}}, reason: "OPENAI_ENDPOINT_CAPABILITIES_INVALID"},
{name: "unknown capability", credentials: map[string]any{openAIEndpointCapabilitiesCredentialKey: []any{"responses"}}, reason: "OPENAI_ENDPOINT_CAPABILITIES_INVALID"},
{name: "capabilities type", credentials: map[string]any{openAIEndpointCapabilitiesCredentialKey: "chat_completions"}, reason: "OPENAI_ENDPOINT_CAPABILITIES_INVALID"},
{name: "responses mode", extra: map[string]any{"openai_responses_mode": "sometimes"}, reason: "OPENAI_RESPONSES_MODE_INVALID"},
{name: "responses type", extra: map[string]any{"openai_responses_mode": true}, reason: "OPENAI_RESPONSES_MODE_INVALID"},
{
name: "embeddings conflict",
credentials: map[string]any{openAIEndpointCapabilitiesCredentialKey: []any{"embeddings"}},
extra: map[string]any{"openai_responses_mode": "force_responses"},
reason: "OPENAI_RESPONSES_MODE_INVALID",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := &accountRepoStubForBulkUpdate{}
svc := &adminServiceImpl{accountRepo: repo}
result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
AccountIDs: []int64{1},
Credentials: tt.credentials,
Extra: tt.extra,
})
require.Nil(t, result)
requireApplicationErrorReason(t, err, tt.reason)
require.Zero(t, repo.bulkUpdateCalls)
})
}
}
func TestAdminServiceBulkUpdateAccounts_RejectsInvalidOpenAITargetsBeforeWrite(t *testing.T) {
tests := []struct {
name string
accounts []*Account
input *BulkUpdateAccountsInput
}{
{
name: "missing account",
accounts: []*Account{{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}},
input: &BulkUpdateAccountsInput{
AccountIDs: []int64{1, 2},
Extra: map[string]any{openAILongContextBillingEnabledKey: true},
},
},
{
name: "mixed platform long context",
accounts: []*Account{{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeOAuth}},
input: &BulkUpdateAccountsInput{
AccountIDs: []int64{1},
Extra: map[string]any{openAILongContextBillingEnabledKey: true},
},
},
{
name: "oauth endpoint capabilities",
accounts: []*Account{{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}},
input: &BulkUpdateAccountsInput{
AccountIDs: []int64{1},
Credentials: map[string]any{openAIEndpointCapabilitiesCredentialKey: nil},
},
},
{
name: "unsupported OpenAI long context account type",
accounts: []*Account{{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeServiceAccount}},
input: &BulkUpdateAccountsInput{
AccountIDs: []int64{1},
Extra: map[string]any{openAILongContextBillingEnabledKey: true},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: tt.accounts}
svc := &adminServiceImpl{accountRepo: repo}
result, err := svc.BulkUpdateAccounts(context.Background(), tt.input)
require.Nil(t, result)
requireApplicationErrorReason(t, err, "OPENAI_BULK_TARGET_INVALID")
require.Zero(t, repo.bulkUpdateCalls)
})
}
}
func TestAdminServiceBulkUpdateAccounts_ForcedResponsesRequiresChatCapability(t *testing.T) {
repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{{
ID: 1,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
openAIEndpointCapabilitiesCredentialKey: []any{"embeddings"},
},
}}}
svc := &adminServiceImpl{accountRepo: repo}
result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
AccountIDs: []int64{1},
Extra: map[string]any{"openai_responses_mode": "force_chat_completions"},
})
require.Nil(t, result)
requireApplicationErrorReason(t, err, "OPENAI_BULK_TARGET_INVALID")
require.Zero(t, repo.bulkUpdateCalls)
}
func TestAdminServiceBulkUpdateAccounts_ForcedResponsesAcceptsChatCapabilityUpdate(t *testing.T) {
repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{{
ID: 1,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
openAIEndpointCapabilitiesCredentialKey: []any{"embeddings"},
},
}}}
svc := &adminServiceImpl{accountRepo: repo}
_, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
AccountIDs: []int64{1},
Credentials: map[string]any{
openAIEndpointCapabilitiesCredentialKey: []any{"chat_completions"},
},
Extra: map[string]any{"openai_responses_mode": "force_responses"},
})
require.NoError(t, err)
require.Equal(t, 1, repo.bulkUpdateCalls)
}
func TestAdminServiceBulkUpdateAccounts_ReportsLongContextShadowInheritance(t *testing.T) {
parentID := int64(1)
repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{
{ID: parentID, Platform: PlatformOpenAI, Type: AccountTypeOAuth},
{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parentID},
}}
svc := &adminServiceImpl{accountRepo: repo}
result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
AccountIDs: []int64{parentID, 2},
Extra: map[string]any{openAILongContextBillingEnabledKey: true},
})
require.NoError(t, err)
require.Equal(t, 1, result.LongContextInheritedCount)
require.Equal(t, 1, repo.bulkUpdateCalls)
}
func TestAdminServiceBulkUpdateAccounts_RequiresParentForShadowOnlyLongContextUpdate(t *testing.T) {
parentID := int64(10)
repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{
{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parentID},
{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parentID},
}}
svc := &adminServiceImpl{accountRepo: repo}
result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
AccountIDs: []int64{1, 2},
Extra: map[string]any{openAILongContextBillingEnabledKey: true},
})
require.Nil(t, result)
requireApplicationErrorReason(t, err, "OPENAI_LONG_CONTEXT_PARENT_REQUIRED")
require.Zero(t, repo.bulkUpdateCalls)
}
func TestAdminServiceBulkUpdateAccounts_ShadowLongContextAllowsOtherUpdates(t *testing.T) {
parentID := int64(10)
repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{{
ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parentID,
}}}
svc := &adminServiceImpl{accountRepo: repo}
status := StatusDisabled
result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
AccountIDs: []int64{1},
Status: status,
Extra: map[string]any{openAILongContextBillingEnabledKey: false},
})
require.NoError(t, err)
require.Equal(t, 1, result.LongContextInheritedCount)
require.Equal(t, 1, repo.bulkUpdateCalls)
require.NotNil(t, repo.lastBulkUpdate.Status)
require.Equal(t, status, *repo.lastBulkUpdate.Status)
}
func TestAdminServiceBulkUpdateAccounts_ValidatesFilterResolvedOpenAITargets(t *testing.T) {
repo := &accountRepoStubForBulkUpdate{
listData: []Account{{ID: 7}},
listResult: &pagination.PaginationResult{Total: 1},
getByIDsAccounts: []*Account{{ID: 7, Platform: PlatformAnthropic, Type: AccountTypeOAuth}},
}
svc := &adminServiceImpl{accountRepo: repo}
result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
Filters: &BulkUpdateAccountFilters{Platform: PlatformOpenAI},
Extra: map[string]any{openAILongContextBillingEnabledKey: true},
})
require.Nil(t, result)
requireApplicationErrorReason(t, err, "OPENAI_BULK_TARGET_INVALID")
require.Equal(t, []int64{7}, repo.getByIDsIDs)
require.Zero(t, repo.bulkUpdateCalls)
}
@@ -4,8 +4,10 @@ package service
import (
"context"
"net/http"
"testing"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/stretchr/testify/require"
)
@@ -156,6 +158,61 @@ func (s *groupRepoStubForAdmin) UpdateSortOrders(_ context.Context, _ []GroupSor
return nil
}
func TestAdminService_CreateGroup_RejectsTimePricing(t *testing.T) {
repo := &groupRepoStubForAdmin{createID: 51}
svc := &adminServiceImpl{groupRepo: repo}
_, err := svc.CreateGroup(context.Background(), &CreateGroupInput{
Name: "time-pricing-group",
Platform: PlatformOpenAI,
RateMultiplier: 1,
ModelPricing: []ChannelModelPricing{{
Platform: PlatformOpenAI,
Models: []string{"gpt-5"},
BillingMode: BillingModeToken,
TimePricing: validTimePricingForTest(),
}},
})
require.Error(t, err)
appErr := infraerrors.FromError(err)
require.Equal(t, int32(http.StatusBadRequest), appErr.Code)
require.Equal(t, "GROUP_MODEL_TIME_PRICING_UNSUPPORTED", appErr.Reason)
require.Nil(t, repo.created)
}
func TestAdminService_UpdateGroup_RejectsTimePricing(t *testing.T) {
existing := &Group{ID: 1, Name: "existing", Platform: PlatformOpenAI, Status: StatusActive}
repo := &groupRepoStubForAdmin{getByID: existing}
svc := &adminServiceImpl{groupRepo: repo}
pricing := []ChannelModelPricing{{
Platform: PlatformOpenAI,
Models: []string{"gpt-5"},
BillingMode: BillingModeToken,
TimePricing: validTimePricingForTest(),
}}
_, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{ModelPricing: &pricing})
require.Error(t, err)
appErr := infraerrors.FromError(err)
require.Equal(t, int32(http.StatusBadRequest), appErr.Code)
require.Equal(t, "GROUP_MODEL_TIME_PRICING_UNSUPPORTED", appErr.Reason)
require.Nil(t, repo.updated)
}
func TestNormalizeGroupModelPricing_NormalizesEmptyTimePricing(t *testing.T) {
pricing, err := normalizeGroupModelPricing(PlatformOpenAI, []ChannelModelPricing{{
Models: []string{"gpt-5"},
BillingMode: BillingModeToken,
TimePricing: &ChannelTimePricing{Timezone: "Asia/Shanghai"},
}})
require.NoError(t, err)
require.Len(t, pricing, 1)
require.Nil(t, pricing[0].TimePricing)
}
type compositeRouteRepoStubForAdmin struct {
routes []CompositeModelRoute
created *CompositeModelRoute
@@ -14,9 +14,14 @@ const (
// GetAnthropicAPIKeyAuthScheme returns the upstream authentication scheme for
// Anthropic API-key accounts. Missing or invalid values keep the historical
// x-api-key behavior.
// x-api-key behavior. CN providers using their native Anthropic endpoints
// (api_protocol=anthropic) share the same override knob — Kimi/DeepSeek default
// to x-api-key, Zhipu can opt into Authorization: Bearer.
func (a *Account) GetAnthropicAPIKeyAuthScheme() string {
if a == nil || a.Platform != PlatformAnthropic || a.Type != AccountTypeAPIKey {
if a == nil || a.Type != AccountTypeAPIKey {
return AnthropicAPIKeyAuthSchemeXAPIKey
}
if a.Platform != PlatformAnthropic && !a.IsCNProvider() {
return AnthropicAPIKeyAuthSchemeXAPIKey
}
@@ -264,6 +264,10 @@ func (s *AntigravityGatewayService) buildAntigravityCompatGeminiBody(
if err != nil {
return nil, err
}
body, err = enableMixedGeminiToolInvocations(body)
if err != nil {
return nil, err
}
body = ensureGeminiFunctionCallThoughtSignatures(body)
body, err = injectIdentityPatchToGeminiRequest(body)
if err != nil {
@@ -280,6 +284,38 @@ func (s *AntigravityGatewayService) buildAntigravityCompatGeminiBody(
return antigravity.TransformClaudeToGeminiWithOptions(claudeRequest, projectID, mappedModel, options)
}
func enableMixedGeminiToolInvocations(body []byte) ([]byte, error) {
var request map[string]any
if err := json.Unmarshal(body, &request); err != nil {
return nil, err
}
var hasGoogleSearch, hasFunctionDeclarations bool
if tools, ok := request["tools"].([]any); ok {
for _, rawTool := range tools {
tool, ok := rawTool.(map[string]any)
if !ok {
continue
}
_, hasSearch := tool["googleSearch"]
declarations, hasFunctions := tool["functionDeclarations"].([]any)
hasGoogleSearch = hasGoogleSearch || hasSearch
hasFunctionDeclarations = hasFunctionDeclarations || hasFunctions && len(declarations) > 0
}
}
if !hasGoogleSearch || !hasFunctionDeclarations {
return body, nil
}
toolConfig, _ := request["toolConfig"].(map[string]any)
if toolConfig == nil {
toolConfig = make(map[string]any)
request["toolConfig"] = toolConfig
}
toolConfig["includeServerSideToolInvocations"] = true
return json.Marshal(request)
}
func antigravityCompatProxyURL(account *Account) string {
if account.ProxyID == nil || account.Proxy == nil {
return ""
@@ -3,6 +3,7 @@ package service
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
@@ -222,6 +223,51 @@ func TestAntigravityCompatRejectsUnsupportedAccountType(t *testing.T) {
}
}
func TestBuildAntigravityCompatGeminiBody_ConfiguresMixedToolInvocations(t *testing.T) {
svc := &AntigravityGatewayService{}
tests := []struct {
name string
tools string
wantField bool
}{
{
name: "mixed server and client tools",
tools: `[{"name":"get_weather","input_schema":{"type":"object"}},{"type":"web_search_20250305","name":"web_search"}]`,
wantField: true,
},
{
name: "client tools only",
tools: `[{"name":"get_weather","input_schema":{"type":"object"}}]`,
},
{
name: "server tools only",
tools: `[{"type":"web_search_20250305","name":"web_search"}]`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
claudeBody := []byte(`{"messages":[{"role":"user","content":"hello"}],"tools":` + tt.tools + `}`)
claudeBody = bytes.ReplaceAll(claudeBody, []byte{92}, nil)
body, err := svc.buildAntigravityCompatGeminiBody(context.Background(), claudeBody, nil, "project-1", "gemini-2.5-flash")
require.NoError(t, err)
var wrapped map[string]any
require.NoError(t, json.Unmarshal(body, &wrapped))
request, ok := wrapped["request"].(map[string]any)
require.True(t, ok)
toolConfig, exists := request["toolConfig"].(map[string]any)
if !tt.wantField {
require.False(t, exists)
return
}
require.True(t, exists)
require.Equal(t, true, toolConfig["includeServerSideToolInvocations"])
require.NotContains(t, toolConfig, "include_server_side_tool_invocations")
})
}
}
func TestAntigravityCompatPreservesChatTokenLimit(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
@@ -340,6 +340,44 @@ func TestAntigravityGatewayService_ForwardGemini_UsesConfiguredProjectFallback(t
require.Equal(t, "configured-project", wrapped["project"])
}
func TestAntigravityGatewayService_ForwardGemini_PreservesServerSideToolInvocationConfig(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"contents":[{"role":"user","parts":[{"text":"hello"}]}],"tools":[{"functionDeclarations":[{"name":"get_weather","parameters":{"type":"object","additionalProperties":false}}]},{"googleSearch":{}}],"toolConfig":{"includeServerSideToolInvocations":true}}`)
writer := httptest.NewRecorder()
c, _ := gin.CreateTestContext(writer)
body = bytes.ReplaceAll(body, []byte{92}, nil)
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", bytes.NewReader(body))
upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{}}}\n\n")),
}}}
svc := &AntigravityGatewayService{
settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
tokenProvider: &AntigravityTokenProvider{},
httpUpstream: upstream,
}
account := &Account{
ID: 103, Name: "native-gemini", Platform: PlatformAntigravity, Type: AccountTypeOAuth, Status: StatusActive, Concurrency: 1,
Credentials: map[string]any{"access_token": "token", "project_id": "project-103", "model_mapping": map[string]any{"gemini-2.5-flash": "gemini-2.5-flash"}},
}
result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "generateContent", false, body, false)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, upstream.requestBodies, 1)
var wrapped map[string]any
require.NoError(t, json.Unmarshal(upstream.requestBodies[0], &wrapped))
request, ok := wrapped["request"].(map[string]any)
require.True(t, ok)
toolConfig, ok := request["toolConfig"].(map[string]any)
require.True(t, ok)
require.Equal(t, true, toolConfig["includeServerSideToolInvocations"])
require.NotContains(t, toolConfig, "include_server_side_tool_invocations")
}
func TestAntigravityGatewayService_ForwardGemini_MissingProjectReturnsLocalError(t *testing.T) {
gin.SetMode(gin.TestMode)
writer := httptest.NewRecorder()
@@ -84,6 +84,8 @@ type APIKeyAuthGroupSnapshot struct {
AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min,omitempty"`
AudioTTSPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars,omitempty"`
AudioSTTPricePerHour *float64 `json:"audio_stt_price_per_hour,omitempty"`
LongContextPricingEnabled bool `json:"long_context_pricing_enabled"`
ModelPricing []ChannelModelPricing `json:"model_pricing,omitempty"`
ClaudeCodeOnly bool `json:"claude_code_only"`
FallbackGroupID *int64 `json:"fallback_group_id,omitempty"`
FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request,omitempty"`
@@ -14,7 +14,7 @@ import (
"github.com/dgraph-io/ristretto"
)
const apiKeyAuthSnapshotVersion = 19 // v19: group search/audio/video_model_prices billing fields (force refresh of pre-fix snapshots)
const apiKeyAuthSnapshotVersion = 20 // v20: group long-context and model pricing fields (force refresh of pre-fix snapshots)
type apiKeyAuthCacheConfig struct {
l1Size int
@@ -406,6 +406,8 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey)
AudioRealtimePricePerMin: apiKey.Group.AudioRealtimePricePerMin,
AudioTTSPricePerMillionChars: apiKey.Group.AudioTTSPricePerMillionChars,
AudioSTTPricePerHour: apiKey.Group.AudioSTTPricePerHour,
LongContextPricingEnabled: apiKey.Group.LongContextPricingEnabled,
ModelPricing: apiKey.Group.ModelPricing,
ClaudeCodeOnly: apiKey.Group.ClaudeCodeOnly,
FallbackGroupID: apiKey.Group.FallbackGroupID,
FallbackGroupIDOnInvalidRequest: apiKey.Group.FallbackGroupIDOnInvalidRequest,
@@ -501,6 +503,8 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
AudioRealtimePricePerMin: snapshot.Group.AudioRealtimePricePerMin,
AudioTTSPricePerMillionChars: snapshot.Group.AudioTTSPricePerMillionChars,
AudioSTTPricePerHour: snapshot.Group.AudioSTTPricePerHour,
LongContextPricingEnabled: snapshot.Group.LongContextPricingEnabled,
ModelPricing: snapshot.Group.ModelPricing,
ClaudeCodeOnly: snapshot.Group.ClaudeCodeOnly,
FallbackGroupID: snapshot.Group.FallbackGroupID,
FallbackGroupIDOnInvalidRequest: snapshot.Group.FallbackGroupIDOnInvalidRequest,
@@ -0,0 +1,50 @@
package service
import (
"context"
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestAPIKeyAuthSnapshotGroupPricingRoundtrip(t *testing.T) {
groupID := int64(50)
inputPrice := 1e-6
outputPrice := 2e-6
apiKey := &APIKey{
ID: 82, UserID: 40, GroupID: &groupID, Key: "sk-pricing-roundtrip", Status: StatusActive,
User: &User{ID: 40, Status: StatusActive},
Group: &Group{
ID: groupID, Name: "pricing-roundtrip", Platform: PlatformAnthropic, Status: StatusActive,
LongContextPricingEnabled: true,
ModelPricing: []ChannelModelPricing{{
Models: []string{"claude-sonnet-*"}, BillingMode: BillingModeToken,
InputPrice: &inputPrice, OutputPrice: &outputPrice,
}},
},
}
svc := &APIKeyService{}
payload, err := json.Marshal(&APIKeyAuthCacheEntry{Snapshot: svc.snapshotFromAPIKey(context.Background(), apiKey)})
require.NoError(t, err)
var cached APIKeyAuthCacheEntry
require.NoError(t, json.Unmarshal(payload, &cached))
materialized, used, err := svc.applyAuthCacheEntry(apiKey.Key, &cached)
require.NoError(t, err)
require.True(t, used)
require.NotNil(t, materialized.Group)
require.True(t, materialized.Group.LongContextPricingEnabled)
require.Equal(t, apiKey.Group.ModelPricing, materialized.Group.ModelPricing)
billing := &BillingService{fallbackPrices: map[string]*ModelPricing{
"claude-sonnet-4": {InputPricePerToken: 3e-6, OutputPricePerToken: 15e-6},
}}
resolver := NewModelPricingResolver(nil, billing)
resolved := resolver.Resolve(context.Background(), PricingInput{Model: "claude-sonnet-4", Group: materialized.Group})
require.Equal(t, PricingSourceGroup, resolved.Source)
require.True(t, resolved.longContextPricingEnabled)
require.InDelta(t, inputPrice, resolved.BasePricing.InputPricePerToken, 1e-12)
require.InDelta(t, outputPrice, resolved.BasePricing.OutputPricePerToken, 1e-12)
}
@@ -53,7 +53,7 @@ func TestAPIKeyAuthSnapshotProfitControlRoundtrip(t *testing.T) {
snapshot := svc.snapshotFromAPIKey(context.Background(), apiKey)
require.NotNil(t, snapshot)
require.Equal(t, apiKeyAuthSnapshotVersion, snapshot.Version)
require.Equal(t, 19, snapshot.Version, "v19 起认证快照携带 search/audio/video_model_prices 计费字段")
require.Equal(t, 20, snapshot.Version, "v20 起认证快照携带分组长上下文与模型定价字段")
// 模拟 L2 缓存的完整 JSON 往返(与 apiKeyCache.SetAuthCache/GetAuthCache 同构)。
payload, err := json.Marshal(&APIKeyAuthCacheEntry{Snapshot: snapshot})
+63 -8
View File
@@ -245,13 +245,15 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
Status: StatusActive,
}
if err := s.createUserWithRegistrationEmailGuard(ctx, user); err != nil {
if err := s.createUserAndClaimInvitation(ctx, user, invitationRedeemCode); err != nil {
// 优先检查邮箱冲突错误(竞态条件下可能发生)
switch {
case errors.Is(err, ErrEmailExists):
return "", nil, ErrEmailExists
case errors.Is(err, ErrEmailDomainRegistrationLimit):
return "", nil, ErrEmailDomainRegistrationLimit
case errors.Is(err, ErrInvitationCodeInvalid):
return "", nil, ErrInvitationCodeInvalid
default:
logger.LegacyPrintf("service.auth", "[Auth] Database error creating user: %v", err)
return "", nil, ErrServiceUnavailable
@@ -273,13 +275,8 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
}
}
// 标记邀请码为已使用(如果使用了邀请码)
if invitationRedeemCode != nil {
if err := s.redeemRepo.Use(ctx, invitationRedeemCode.ID, user.ID); err != nil {
// 邀请码标记失败不影响注册,只记录日志
logger.LegacyPrintf("service.auth", "[Auth] Failed to mark invitation code as used for user %d: %v", user.ID, err)
}
}
// 邀请码占用已由 createUserAndClaimInvitation 在“用户创建 + 邀请码占用”的
// 同一个数据库事务内原子完成(一次性约束,见函数注释),此处不再单独标记。
// 应用优惠码(如果提供且功能已启用)
if promoCode != "" && s.promoService != nil && s.settingService != nil && s.settingService.IsPromoCodeEnabled(ctx) {
if err := s.promoService.ApplyPromoCode(ctx, user.ID, promoCode); err != nil {
@@ -1274,6 +1271,64 @@ func (s *AuthService) createUserWithRegistrationEmailGuard(ctx context.Context,
return quotaRepo.CreateWithEmailAliasGuardAndDomainLimit(ctx, user, domain)
}
// createUserAndClaimInvitation 原子化完成“用户创建 + 邀请码占用”。
//
// 背景:邀请码属于一次性凭证,必须保证“一个邀请码最多注册一个账号”。旧实现先检查
// CanUse()、再创建用户、最后才 redeemRepo.Use()(且失败仅记日志),检查与消耗分离且
// 不在同一事务,并发注册可在同一邀请码上同时通过检查并各自创建账号(TOCTOU 竞态)。
//
// 本实现把两者放入同一个数据库事务:
// - 占用走 redeemRepo.Use 的条件更新(WHERE status='unused',乐观锁);
// - 并发下只有一个事务能占用成功,其余事务回滚——既不产生多余账号,也不让码被烧掉;
// - 事务回滚同时撤销用户创建,避免“账号已建、码被占用”的中间态。
//
// 无邀请码时保持原单次创建路径(不开事务);entClient 缺失的异常配置下退化为顺序执行,
// 并发正确性仍由 Use 的条件更新兜底(可能产生孤儿用户,但不会放行第二个注册)。
func (s *AuthService) createUserAndClaimInvitation(ctx context.Context, user *User, invitation *RedeemCode) error {
commitUser := func(execCtx context.Context) error {
if err := s.createUserWithRegistrationEmailGuard(execCtx, user); err != nil {
return err
}
if invitation == nil {
return nil
}
// createUserWithRegistrationEmailGuard 会回填 user.ID(applyUserEntityToService),
// 直接以其原子占用邀请码;占用失败即整体回滚(含用户创建,见 user_repo.create
// 对外部事务的复用)。
if err := s.redeemRepo.Use(execCtx, invitation.ID, user.ID); err != nil {
// 并发下唯一的合法失败路径:另一个注册已占用该码
logger.LegacyPrintf("service.auth",
"[Auth] Rejected registration: invitation code %s already claimed (user_id=%d err=%v)",
invitation.Code, user.ID, err)
return ErrInvitationCodeInvalid
}
return nil
}
if invitation == nil {
return commitUser(ctx)
}
if s.entClient == nil {
return commitUser(ctx)
}
tx, err := s.entClient.Tx(ctx)
if err != nil {
logger.LegacyPrintf("service.auth", "[Auth] Failed to start registration transaction: %v", err)
return ErrServiceUnavailable
}
defer func() { _ = tx.Rollback() }()
execCtx := dbent.NewTxContext(ctx, tx)
if err := commitUser(execCtx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
logger.LegacyPrintf("service.auth", "[Auth] Failed to commit registration transaction: %v", err)
return ErrServiceUnavailable
}
return nil
}
func buildEmailSuffixNotAllowedError(whitelist []string) error {
if len(whitelist) == 0 {
return ErrEmailSuffixNotAllowed
@@ -0,0 +1,236 @@
//go:build unit
package service
import (
"context"
"errors"
"fmt"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// raceSafeUserRepo 是仅覆盖注册路径的并发安全用户仓储桩。
// 未用到的方法走嵌入接口(调用即 panic,注册路径不会触发)。
type raceSafeUserRepo struct {
UserRepository
mu sync.Mutex
nextID int64
byEmail map[string]*User
byID map[int64]*User
}
func newRaceSafeUserRepo() *raceSafeUserRepo {
return &raceSafeUserRepo{nextID: 1, byEmail: map[string]*User{}, byID: map[int64]*User{}}
}
func (s *raceSafeUserRepo) ExistsByEmail(_ context.Context, email string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.byEmail[email]
return ok, nil
}
func (s *raceSafeUserRepo) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) {
return s.ExistsByEmail(ctx, email)
}
func (s *raceSafeUserRepo) CreateWithEmailAliasGuard(_ context.Context, user *User) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.byEmail[user.Email]; ok {
return ErrEmailExists
}
user.ID = s.nextID
s.nextID++
clone := *user
s.byEmail[user.Email] = &clone
s.byID[user.ID] = &clone
return nil
}
func (s *raceSafeUserRepo) GetByEmail(_ context.Context, email string) (*User, error) {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.byEmail[email]
if !ok {
return nil, ErrUserNotFound
}
clone := *u
return &clone, nil
}
func (s *raceSafeUserRepo) GetByID(_ context.Context, id int64) (*User, error) {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.byID[id]
if !ok {
return nil, ErrUserNotFound
}
clone := *u
return &clone, nil
}
func (s *raceSafeUserRepo) Update(context.Context, *User, UserUpdateFields) error {
return nil
}
// raceSafeRedeemRepo 是并发安全的兑换码仓储桩:Use 以互斥锁 + 状态条件
// 模拟数据库的条件更新(WHERE status='unused'),语义与线上实现一致。
type raceSafeRedeemRepo struct {
RedeemCodeRepository
mu sync.Mutex
codes map[string]*RedeemCode
}
func (s *raceSafeRedeemRepo) GetByCode(_ context.Context, code string) (*RedeemCode, error) {
s.mu.Lock()
defer s.mu.Unlock()
c, ok := s.codes[code]
if !ok {
return nil, ErrRedeemCodeNotFound
}
clone := *c
return &clone, nil
}
func (s *raceSafeRedeemRepo) Use(_ context.Context, id, userID int64) error {
s.mu.Lock()
defer s.mu.Unlock()
for _, c := range s.codes {
if c.ID != id {
continue
}
if c.Status != StatusUnused {
return ErrRedeemCodeUsed
}
now := time.Now().UTC()
c.Status = StatusUsed
c.UsedBy = &userID
c.UsedAt = &now
return nil
}
return ErrRedeemCodeNotFound
}
// TestAuthService_Register_InvitationCodeSingleUseUnderConcurrency 回归测试:
// 同一邀请码并发注册必须恰好成功 1 次,其余请求以 INVITATION_CODE_INVALID 拒绝。
//
// 修复前:邀请码“检查(CanUse) 与 标记已用(Use)”分离且不在同一事务,Use 失败被吞,
// 并发请求全部注册成功(一个邀请码可创建任意数量账号)。此测试在该实现下必然失败。
// 修复后:用户创建与邀请码占用在同一事务内原子完成(或退化路径下由 Use 条件更新
// 兜底),并发下仅最先占码的注册成功。
func TestAuthService_Register_InvitationCodeSingleUseUnderConcurrency(t *testing.T) {
const code = "INV-RACE-001"
userRepo := newRaceSafeUserRepo()
redeemRepo := &raceSafeRedeemRepo{codes: map[string]*RedeemCode{
code: {ID: 1, Code: code, Type: RedeemTypeInvitation, Status: StatusUnused},
}}
settings := map[string]string{
"registration_enabled": "true",
"invitation_code_enabled": "true",
}
svc := newOAuthEmailFlowAuthService(
userRepo,
redeemRepo,
&refreshTokenCacheStub{},
settings,
nil, // emailCache:注册不要求邮箱验证,保持关闭
&userPlatformQuotaRepoStub{},
)
const n = 8
ctx := context.Background()
start := make(chan struct{})
results := make(chan error, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
email := fmt.Sprintf("race-%d@example.com", i)
_, _, err := svc.RegisterWithVerification(ctx, email, "Password123!", "", "", code, "")
results <- err
}(i)
}
close(start)
wg.Wait()
close(results)
successes := 0
rejected := 0
for err := range results {
switch {
case err == nil:
successes++
case errors.Is(err, ErrInvitationCodeInvalid):
rejected++
default:
t.Fatalf("unexpected registration error: %v", err)
}
}
require.Equal(t, 1, successes, "同一邀请码并发注册必须恰好成功 1 次")
require.Equal(t, n-1, rejected, "其余并发请求必须以 INVITATION_CODE_INVALID 拒绝")
claimed, err := redeemRepo.GetByCode(ctx, code)
require.NoError(t, err)
require.Equal(t, StatusUsed, claimed.Status, "邀请码最终必须处于 used 状态")
require.NotNil(t, claimed.UsedBy, "used_by 必须记录实际注册用户")
}
// TestAuthService_Register_InvitationCodeRejectedWhenAlreadyUsed 顺序路径回归:
// 已使用过的邀请码再次注册(即便换邮箱)必须被拒绝。
func TestAuthService_Register_InvitationCodeRejectedWhenAlreadyUsed(t *testing.T) {
const code = "INV-RACE-002"
userRepo := newRaceSafeUserRepo()
redeemRepo := &raceSafeRedeemRepo{codes: map[string]*RedeemCode{
code: {ID: 2, Code: code, Type: RedeemTypeInvitation, Status: StatusUsed},
}}
settings := map[string]string{
"registration_enabled": "true",
"invitation_code_enabled": "true",
}
svc := newOAuthEmailFlowAuthService(
userRepo,
redeemRepo,
&refreshTokenCacheStub{},
settings,
nil,
&userPlatformQuotaRepoStub{},
)
_, _, err := svc.RegisterWithVerification(context.Background(), "later@example.com", "Password123!", "", "", code, "")
require.ErrorIs(t, err, ErrInvitationCodeInvalid)
}
// TestAuthService_Register_InvitationCodeMissingWhenEnabled 门控回归:
// 邀请码开启时,不带邀请码的注册必须被拒绝(不产生用户)。
func TestAuthService_Register_InvitationCodeMissingWhenEnabled(t *testing.T) {
userRepo := newRaceSafeUserRepo()
redeemRepo := &raceSafeRedeemRepo{codes: map[string]*RedeemCode{}}
settings := map[string]string{
"registration_enabled": "true",
"invitation_code_enabled": "true",
}
svc := newOAuthEmailFlowAuthService(
userRepo,
redeemRepo,
&refreshTokenCacheStub{},
settings,
nil,
&userPlatformQuotaRepoStub{},
)
_, _, err := svc.RegisterWithVerification(context.Background(), "no-invite@example.com", "Password123!", "", "", "", "")
require.ErrorIs(t, err, ErrInvitationCodeRequired)
ok, err := userRepo.ExistsByEmail(context.Background(), "no-invite@example.com")
require.NoError(t, err)
require.False(t, ok, "被拒绝的注册不应产生用户")
}
@@ -5,6 +5,7 @@ package service
import (
"context"
"errors"
"sync"
"testing"
"time"
@@ -14,6 +15,7 @@ import (
)
type settingRepoStub struct {
mu sync.Mutex
values map[string]string
err error
getValueCalls int
@@ -25,6 +27,8 @@ func (s *settingRepoStub) Get(ctx context.Context, key string) (*Setting, error)
}
func (s *settingRepoStub) GetValue(ctx context.Context, key string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.getValueCalls++
if s.err != nil {
return "", s.err
@@ -40,6 +44,8 @@ func (s *settingRepoStub) Set(ctx context.Context, key, value string) error {
}
func (s *settingRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.getMultipleCalls++
if s.err != nil {
return nil, s.err
+1 -1
View File
@@ -139,7 +139,7 @@ func (s *GatewayService) handleBedrockStreamingResponse(
sseData = transformBedrockInvocationMetrics(sseData)
// 解析 SSE 事件数据提取 usage
s.parseSSEUsagePassthrough(string(sseData), usage)
parseSSEUsagePassthrough(string(sseData), usage)
// 确定 SSE event type
eventType := gjson.GetBytes(sseData, "type").String()
+25 -1
View File
@@ -167,6 +167,27 @@ type CostBreakdown struct {
LongContextBillingApplied bool
}
func applyCostBreakdownMultiplier(cost *CostBreakdown, multiplier float64) {
if cost == nil || multiplier == 1 {
return
}
cost.InputCost *= multiplier
cost.ImageInputCost *= multiplier
cost.OutputCost *= multiplier
cost.ImageOutputCost *= multiplier
cost.CacheCreationCost *= multiplier
cost.CacheReadCost *= multiplier
cost.TotalCost *= multiplier
cost.ActualCost *= multiplier
}
func resolvedChannelTimeMultiplier(resolved *ResolvedPricing, at time.Time) float64 {
if resolved == nil || resolved.Source != PricingSourceChannel || resolved.channelPricing == nil {
return 1
}
return resolved.channelPricing.TimePricing.MultiplierAt(at)
}
// ErrModelPricingUnavailable indicates that none of the configured pricing
// sources can price the requested model.
var ErrModelPricingUnavailable = errors.New("pricing not found")
@@ -1030,6 +1051,7 @@ type CostInput struct {
UsageUnits float64 // 音频等连续计量单位(分钟/小时/百万字符)
SizeTier string // 按次/图片模式的层级标签("1K","2K","4K","HD" 等)
RateMultiplier float64
PricingAt time.Time // 渠道分时定价使用的计费时刻
ServiceTier string // "priority","flex","" 等
Resolver *ModelPricingResolver // 定价解析器
Resolved *ResolvedPricing // 可选:预解析的定价结果(避免重复 Resolve 调用)
@@ -1104,7 +1126,9 @@ func (s *BillingService) calculateTokenCost(resolved *ResolvedPricing, input Cos
applyLongCtx = applyLongCtx && *input.LongContextBillingEnabled
}
return s.computeTokenBreakdown(pricing, input.Tokens, input.RateMultiplier, input.ServiceTier, applyLongCtx), nil
breakdown := s.computeTokenBreakdown(pricing, input.Tokens, input.RateMultiplier, input.ServiceTier, applyLongCtx)
applyCostBreakdownMultiplier(breakdown, resolvedChannelTimeMultiplier(resolved, input.PricingAt))
return breakdown, nil
}
// computeTokenBreakdown 是 token 计费的核心逻辑,由 calculateTokenCost 和 calculateCostInternal 共用。
@@ -169,6 +169,154 @@ func TestCalculateCostUnified_ImageMode(t *testing.T) {
require.Equal(t, string(BillingModeImage), cost.BillingMode)
}
func channelTimeResolvedForTest(base *ModelPricing, intervals []PricingInterval) *ResolvedPricing {
return &ResolvedPricing{
Mode: BillingModeToken,
BasePricing: base,
Intervals: intervals,
Source: PricingSourceChannel,
channelPricing: &ChannelModelPricing{
BillingMode: BillingModeToken,
TimePricing: &ChannelTimePricing{
Timezone: "Asia/Shanghai",
Periods: []ChannelTimePricingPeriod{{
StartTime: "09:00",
EndTime: "12:00",
Multiplier: 2,
}},
},
},
longContextPricingEnabled: true,
}
}
func TestCalculateCostUnified_ChannelTimePricingScalesBaseAndActualCost(t *testing.T) {
billing := NewBillingService(&config.Config{}, nil)
resolved := channelTimeResolvedForTest(&ModelPricing{InputPricePerToken: 0.001}, nil)
cost, err := billing.CalculateCostUnified(CostInput{
Ctx: context.Background(),
Model: "model",
Tokens: UsageTokens{InputTokens: 1000},
RateMultiplier: 0.8,
Resolver: &ModelPricingResolver{},
Resolved: resolved,
PricingAt: time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC),
})
require.NoError(t, err)
require.InDelta(t, 2.0, cost.InputCost, 1e-12)
require.InDelta(t, 2.0, cost.TotalCost, 1e-12)
require.InDelta(t, 1.6, cost.ActualCost, 1e-12)
}
func TestCalculateCostUnified_ChannelTimePricingScalesMatchingInterval(t *testing.T) {
intervalInputPrice := 0.003
resolved := channelTimeResolvedForTest(
&ModelPricing{InputPricePerToken: 0.001},
[]PricingInterval{{MinTokens: 0, InputPrice: &intervalInputPrice}},
)
billing := NewBillingService(&config.Config{}, nil)
cost, err := billing.CalculateCostUnified(CostInput{
Ctx: context.Background(),
Model: "model",
Tokens: UsageTokens{InputTokens: 1000},
Resolver: &ModelPricingResolver{},
Resolved: resolved,
PricingAt: time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC),
})
require.NoError(t, err)
require.InDelta(t, 6.0, cost.InputCost, 1e-12)
require.InDelta(t, 6.0, cost.TotalCost, 1e-12)
}
func TestCalculateCostUnified_ChannelTimePricingScalesBaseOnUnmatchedInterval(t *testing.T) {
intervalInputPrice := 0.003
resolved := channelTimeResolvedForTest(
&ModelPricing{InputPricePerToken: 0.001},
[]PricingInterval{{MinTokens: 2000, InputPrice: &intervalInputPrice}},
)
billing := NewBillingService(&config.Config{}, nil)
cost, err := billing.CalculateCostUnified(CostInput{
Ctx: context.Background(),
Model: "model",
Tokens: UsageTokens{InputTokens: 1000},
Resolver: &ModelPricingResolver{},
Resolved: resolved,
PricingAt: time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC),
})
require.NoError(t, err)
require.InDelta(t, 2.0, cost.InputCost, 1e-12)
require.InDelta(t, 2.0, cost.TotalCost, 1e-12)
}
func TestCalculateCostUnified_ChannelTimePricingDoesNotApplyToGroupPricing(t *testing.T) {
resolved := channelTimeResolvedForTest(&ModelPricing{InputPricePerToken: 0.001}, nil)
resolved.Source = PricingSourceGroup
billing := NewBillingService(&config.Config{}, nil)
cost, err := billing.CalculateCostUnified(CostInput{
Ctx: context.Background(),
Model: "model",
Tokens: UsageTokens{InputTokens: 1000},
Resolver: &ModelPricingResolver{},
Resolved: resolved,
PricingAt: time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC),
})
require.NoError(t, err)
require.InDelta(t, 1.0, cost.TotalCost, 1e-12)
}
func TestCalculateCostUnified_ChannelTimePricingDoesNotApplyOutsideMatchingTime(t *testing.T) {
resolved := channelTimeResolvedForTest(&ModelPricing{InputPricePerToken: 0.001}, nil)
billing := NewBillingService(&config.Config{}, nil)
for _, pricingAt := range []time.Time{
time.Time{},
time.Date(2026, 8, 17, 5, 0, 0, 0, time.UTC),
} {
cost, err := billing.CalculateCostUnified(CostInput{
Ctx: context.Background(),
Model: "model",
Tokens: UsageTokens{InputTokens: 1000},
Resolver: &ModelPricingResolver{},
Resolved: resolved,
PricingAt: pricingAt,
})
require.NoError(t, err)
require.InDelta(t, 1.0, cost.TotalCost, 1e-12)
}
}
func TestApplyCostBreakdownMultiplierScalesAllMonetaryFields(t *testing.T) {
cost := &CostBreakdown{
InputCost: 1,
ImageInputCost: 2,
OutputCost: 3,
ImageOutputCost: 4,
CacheCreationCost: 5,
CacheReadCost: 6,
TotalCost: 21,
ActualCost: 42,
BillingMode: string(BillingModeToken),
LongContextBillingApplied: true,
}
applyCostBreakdownMultiplier(cost, 1.5)
require.InDelta(t, 1.5, cost.InputCost, 1e-12)
require.InDelta(t, 3.0, cost.ImageInputCost, 1e-12)
require.InDelta(t, 4.5, cost.OutputCost, 1e-12)
require.InDelta(t, 6.0, cost.ImageOutputCost, 1e-12)
require.InDelta(t, 7.5, cost.CacheCreationCost, 1e-12)
require.InDelta(t, 9.0, cost.CacheReadCost, 1e-12)
require.InDelta(t, 31.5, cost.TotalCost, 1e-12)
require.InDelta(t, 63.0, cost.ActualCost, 1e-12)
require.Equal(t, string(BillingModeToken), cost.BillingMode)
require.True(t, cost.LongContextBillingApplied)
}
// TestCalculateCostUnified_RateMultiplierZeroProducesZero 锁定新行为:
// 保存时强制 > 0;若 0 仍泄漏到计费层,按 0 计费(而非历史上的 1.0)。
func TestCalculateCostUnified_RateMultiplierZeroProducesZero(t *testing.T) {
+35 -15
View File
@@ -87,21 +87,35 @@ type AccountStatsPricingRule struct {
// ChannelModelPricing 渠道模型定价条目
type ChannelModelPricing struct {
ID int64 `json:"id,omitempty"`
ChannelID int64 `json:"channel_id,omitempty"`
Platform string `json:"platform"` // 所属平台(anthropic/openai/gemini/...)
Models []string `json:"models"`
BillingMode BillingMode `json:"billing_mode"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
ImageInputPrice *float64 `json:"image_input_price"`
ImageOutputPrice *float64 `json:"image_output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
Intervals []PricingInterval `json:"intervals"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
ID int64 `json:"id,omitempty"`
ChannelID int64 `json:"channel_id,omitempty"`
Platform string `json:"platform"` // 所属平台(anthropic/openai/gemini/...)
Models []string `json:"models"`
BillingMode BillingMode `json:"billing_mode"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
ImageInputPrice *float64 `json:"image_input_price"`
ImageOutputPrice *float64 `json:"image_output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
Intervals []PricingInterval `json:"intervals"`
TimePricing *ChannelTimePricing `json:"time_pricing,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
// ChannelTimePricing 渠道模型定价的分时倍率配置。
type ChannelTimePricing struct {
Timezone string `json:"timezone"`
Periods []ChannelTimePricingPeriod `json:"periods"`
}
// ChannelTimePricingPeriod 是秒级的左闭右开分时倍率区间,并兼容历史 HH:mm 数据。
type ChannelTimePricingPeriod struct {
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Multiplier float64 `json:"multiplier"`
}
// PricingInterval 定价区间(token 区间 / 按次分层 / 图片分辨率分层)
@@ -195,6 +209,12 @@ func (p ChannelModelPricing) Clone() ChannelModelPricing {
cp.Intervals = make([]PricingInterval, len(p.Intervals))
copy(cp.Intervals, p.Intervals)
}
if p.TimePricing != nil {
cp.TimePricing = &ChannelTimePricing{Timezone: p.TimePricing.Timezone}
if p.TimePricing.Periods != nil {
cp.TimePricing.Periods = append([]ChannelTimePricingPeriod(nil), p.TimePricing.Periods...)
}
}
return cp
}
@@ -204,6 +204,8 @@ func buildStatusSummary(
if l, ok := latestByModel[primary]; ok {
summary.PrimaryStatus = l.Status
summary.PrimaryLatencyMs = l.LatencyMs
// 配额快照只挂主模型行(quota 模式唯一行 / quota_probe 的主行)。
summary.LatestQuota = l.Quota
}
if a, ok := availByModel[primary]; ok {
summary.Availability7d = a.AvailabilityPct
@@ -242,6 +244,7 @@ func buildUserViewFromSummary(
}
if primaryLatest != nil {
view.PrimaryPingLatencyMs = primaryLatest.PingLatencyMs
view.LatestQuota = primaryLatest.Quota
}
return view
}
@@ -170,6 +170,11 @@ type providerAdapter struct {
var providerAdapters = map[string]providerAdapter{
MonitorProviderOpenAI: providerOpenAIChatAdapter,
MonitorProviderGrok: providerGrokChatAdapter,
// 国产 3 家(配额模式引入):均为 OpenAI 兼容 Chat Completions,
// 仅智谱路径前缀不同(/api/paas/v4/chat/completions)。
MonitorProviderKimi: providerKimiChatAdapter,
MonitorProviderZhipu: providerZhipuChatAdapter,
MonitorProviderDeepseek: providerDeepseekChatAdapter,
MonitorProviderAnthropic: {
buildPath: func(string) string { return providerAnthropicPath },
buildBody: func(model, prompt string) ([]byte, error) {
@@ -212,6 +217,15 @@ var providerOpenAIChatAdapter = newOpenAICompatibleChatAdapter(providerOpenAIPat
//nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。
var providerGrokChatAdapter = newOpenAICompatibleChatAdapter(providerGrokPath)
//nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。
var providerKimiChatAdapter = newOpenAICompatibleChatAdapter(providerOpenAIPath)
//nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。
var providerZhipuChatAdapter = newOpenAICompatibleChatAdapter(providerZhipuPath)
//nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。
var providerDeepseekChatAdapter = newOpenAICompatibleChatAdapter(providerOpenAIPath)
func newOpenAICompatibleChatAdapter(path string) providerAdapter {
return providerAdapter{
buildPath: func(string) string { return path },
@@ -257,13 +271,6 @@ func providerAdapterFor(provider, apiMode string) (providerAdapter, string, bool
return adapter, MonitorAPIModeChatCompletions, ok
}
// isSupportedProvider 校验 provider 字符串是否在 adapter 表中。
// 供 validate.go 的 validateProvider 复用,避免两份 switch 漂移。
func isSupportedProvider(p string) bool {
_, ok := providerAdapters[p]
return ok
}
// callProvider 通过 providerAdapters 分发到具体实现。
// opts 承载用户的自定义 headers / body 覆盖(可为 nil)。
//
@@ -447,6 +454,10 @@ var bodyMergeKeyDenyList = map[string]map[string]bool{
MonitorProviderGrok: {"model": true, "messages": true, "stream": true},
MonitorProviderAnthropic: {"model": true, "messages": true},
MonitorProviderGemini: {"contents": true},
// 国产 3 家与 OpenAI Chat Completions 同构。
MonitorProviderKimi: {"model": true, "messages": true, "stream": true},
MonitorProviderZhipu: {"model": true, "messages": true, "stream": true},
MonitorProviderDeepseek: {"model": true, "messages": true, "stream": true},
}
func checkAPIMode(opts *CheckOptions) string {
@@ -463,8 +474,20 @@ func bodyMergeDenyKey(provider, apiMode string) string {
return provider
}
// isOpenAICompatibleChatProvider 该 provider 的探活请求是否为 OpenAI Chat
// Completions 同构(replace 模式的 body 校验按 messages 必填处理)。
func isOpenAICompatibleChatProvider(provider string) bool {
switch provider {
case MonitorProviderOpenAI, MonitorProviderGrok,
MonitorProviderKimi, MonitorProviderZhipu, MonitorProviderDeepseek:
return true
default:
return false
}
}
func validateReplaceRequestBody(provider, apiMode string, body map[string]any) error {
if provider != MonitorProviderOpenAI && provider != MonitorProviderGrok {
if !isOpenAICompatibleChatProvider(provider) {
return nil
}
switch defaultAPIMode(apiMode) {
Loaded 100 of 299 files, more files were not shown because too many files have changed in this diff. Show more