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:
commit
12e67d823b
299 files changed
+19909
-1019
No files matched your search
+1
-1
@@ -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 @@
|
||||
0.1.177
|
||||
0.1.178
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -66,6 +66,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
|
||||
schedulerSnapshotSvc,
|
||||
tokenRefreshSvc,
|
||||
accountExpirySvc,
|
||||
nil, // cnProviderBalanceCheck
|
||||
codexVersionSyncSvc,
|
||||
proxyExpirySvc,
|
||||
subscriptionExpirySvc,
|
||||
|
||||
@@ -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(", ")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 系模型默认)。
|
||||
//
|
||||
|
||||
@@ -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, "a); 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)
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user