diff --git a/DEV_GUIDE.md b/DEV_GUIDE.md index 7d3802f153e1..2da87eb2b758 100644 --- a/DEV_GUIDE.md +++ b/DEV_GUIDE.md @@ -55,7 +55,7 @@ npm install -g pnpm ### CI 要求 -- Go 版本必须是 **1.26.5**:三个 workflow 都用 `go-version-file: backend/go.mod` 取版本,随后硬断言 `go version | grep -q 'go1.26.5'`。升级 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` ### 本地测试命令 diff --git a/Dockerfile b/Dockerfile index 76bdd1c90d73..58a692e74657 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ # ============================================================================= ARG NODE_IMAGE=node:24-alpine -ARG GOLANG_IMAGE=golang:1.26.5-alpine +ARG GOLANG_IMAGE=golang:1.26.6-alpine ARG ALPINE_IMAGE=alpine:3.21 ARG POSTGRES_IMAGE=postgres:18-alpine ARG GOPROXY=https://goproxy.cn,direct diff --git a/backend/Dockerfile b/backend/Dockerfile index 9976abe46b54..1efbd3859da8 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.5-alpine +FROM golang:1.26.6-alpine WORKDIR /app diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 86cabbc4e507..a8f9b2cb7a9a 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.177-clarence.4 +0.1.178-clarence.1 diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index de8cf6a72998..78a5dc813c57 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -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, @@ -239,6 +240,12 @@ func provideCleanup( accountExpiry.Stop() return nil }}, + {"CNProviderBalanceCheckService", func() error { + if cnProviderBalanceCheck != nil { + cnProviderBalanceCheck.Stop() + } + return nil + }}, {"OpenAICodexVersionSyncService", func() error { codexVersionSync.Stop() return nil diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 6b667d102d79..8c69d36b2005 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -220,6 +220,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) @@ -279,7 +282,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) @@ -329,6 +332,7 @@ 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) @@ -336,10 +340,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { 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, quotaRefreshNotifyService, 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, quotaRefreshNotifyService, 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, @@ -383,6 +388,7 @@ func provideCleanup( schedulerSnapshot *service.SchedulerSnapshotService, tokenRefresh *service.TokenRefreshService, accountExpiry *service.AccountExpiryService, + cnProviderBalanceCheck *service.CNProviderBalanceCheckService, codexVersionSync *service.OpenAICodexVersionSyncService, proxyExpiry *service.ProxyExpiryService, subscriptionExpiry *service.SubscriptionExpiryService, @@ -533,6 +539,12 @@ func provideCleanup( accountExpiry.Stop() return nil }}, + {"CNProviderBalanceCheckService", func() error { + if cnProviderBalanceCheck != nil { + cnProviderBalanceCheck.Stop() + } + return nil + }}, {"OpenAICodexVersionSyncService", func() error { codexVersionSync.Stop() return nil diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index b2c17ed36346..e3ae9544e4f9 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -66,6 +66,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { schedulerSnapshotSvc, tokenRefreshSvc, accountExpirySvc, + nil, // cnProviderBalanceCheck codexVersionSyncSvc, proxyExpirySvc, subscriptionExpirySvc, diff --git a/backend/ent/channelmonitor.go b/backend/ent/channelmonitor.go index e5697863f0d1..e74b6528ccaf 100644 --- a/backend/ent/channelmonitor.go +++ b/backend/ent/channelmonitor.go @@ -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(", ") diff --git a/backend/ent/channelmonitor/channelmonitor.go b/backend/ent/channelmonitor/channelmonitor.go index 711e6217e2ff..3224c6eb0474 100644 --- a/backend/ent/channelmonitor/channelmonitor.go +++ b/backend/ent/channelmonitor/channelmonitor.go @@ -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() diff --git a/backend/ent/channelmonitor/where.go b/backend/ent/channelmonitor/where.go index 6575aa5df6ad..9370a0b5ea2e 100644 --- a/backend/ent/channelmonitor/where.go +++ b/backend/ent/channelmonitor/where.go @@ -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)) diff --git a/backend/ent/channelmonitor_create.go b/backend/ent/channelmonitor_create.go index 6c7e65491722..cfd63437ff99 100644 --- a/backend/ent/channelmonitor_create.go +++ b/backend/ent/channelmonitor_create.go @@ -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) { diff --git a/backend/ent/channelmonitor_update.go b/backend/ent/channelmonitor_update.go index ccc6fa8cc2d8..f5b52e4e1dee 100644 --- a/backend/ent/channelmonitor_update.go +++ b/backend/ent/channelmonitor_update.go @@ -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) } diff --git a/backend/ent/channelmonitorhistory.go b/backend/ent/channelmonitorhistory.go index 70dde5422a7d..fef5109528c8 100644 --- a/backend/ent/channelmonitorhistory.go +++ b/backend/ent/channelmonitorhistory.go @@ -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(')') diff --git a/backend/ent/channelmonitorhistory/channelmonitorhistory.go b/backend/ent/channelmonitorhistory/channelmonitorhistory.go index 6a9dc006703b..ceb11cee67d4 100644 --- a/backend/ent/channelmonitorhistory/channelmonitorhistory.go +++ b/backend/ent/channelmonitorhistory/channelmonitorhistory.go @@ -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, } diff --git a/backend/ent/channelmonitorhistory/where.go b/backend/ent/channelmonitorhistory/where.go index afa73f35c875..f7afd7c319a4 100644 --- a/backend/ent/channelmonitorhistory/where.go +++ b/backend/ent/channelmonitorhistory/where.go @@ -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)) diff --git a/backend/ent/channelmonitorhistory_create.go b/backend/ent/channelmonitorhistory_create.go index 71034865c970..4d4222d6d355 100644 --- a/backend/ent/channelmonitorhistory_create.go +++ b/backend/ent/channelmonitorhistory_create.go @@ -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) { diff --git a/backend/ent/channelmonitorhistory_update.go b/backend/ent/channelmonitorhistory_update.go index a85a8072a9e6..58d7214068ae 100644 --- a/backend/ent/channelmonitorhistory_update.go +++ b/backend/ent/channelmonitorhistory_update.go @@ -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) } diff --git a/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go b/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go index 5989d0e74386..f0e9dbf16e16 100644 --- a/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go +++ b/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go @@ -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) diff --git a/backend/ent/group.go b/backend/ent/group.go index 110f06742a38..c9795fb58f04 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -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"` diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 658a1485e2d1..b20c50a7288b 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -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}, diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 8f5c476b700a..6d91933eb167 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -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 diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index abbc2602b6af..5a93a54d0c38 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -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() diff --git a/backend/ent/schema/channel_monitor.go b/backend/ent/schema/channel_monitor.go index cb62079316e9..e14416b76541 100644 --- a/backend/ent/schema/channel_monitor.go +++ b/backend/ent/schema/channel_monitor.go @@ -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"), } } diff --git a/backend/ent/schema/channel_monitor_history.go b/backend/ent/schema/channel_monitor_history.go index 4366e79a672f..c2ec4dc25c14 100644 --- a/backend/ent/schema/channel_monitor_history.go +++ b/backend/ent/schema/channel_monitor_history.go @@ -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), } diff --git a/backend/ent/schema/channel_monitor_request_template.go b/backend/ent/schema/channel_monitor_request_template.go index cf7fe0515803..332a01007c0a 100644 --- a/backend/ent/schema/channel_monitor_request_template.go +++ b/backend/ent/schema/channel_monitor_request_template.go @@ -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). diff --git a/backend/ent/schema/user_platform_quota.go b/backend/ent/schema/user_platform_quota.go index a0b5598600c3..123d7c0bfa3f 100644 --- a/backend/ent/schema/user_platform_quota.go +++ b/backend/ent/schema/user_platform_quota.go @@ -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) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 2c5d5cf545fe..e437b1115001 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1025,6 +1025,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. @@ -1057,6 +1061,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"` @@ -2355,6 +2371,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) diff --git a/backend/internal/domain/channel_monitor_quota.go b/backend/internal/domain/channel_monitor_quota.go new file mode 100644 index 000000000000..72c989c38fb3 --- /dev/null +++ b/backend/internal/domain/channel_monitor_quota.go @@ -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"` +} diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index 148c1cd5d301..3640d1b82e03 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -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 diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 0de53f0a7ec2..f748b8be666f 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -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 { diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index ade8f0c95cab..dfce024fa357 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -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,7 +252,23 @@ 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 { @@ -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 定价目录中指定平台的最新模型列表 diff --git a/backend/internal/handler/admin/channel_handler_test.go b/backend/internal/handler/admin/channel_handler_test.go index d05a1a6a3b21..6c5ddedc92e7 100644 --- a/backend/internal/handler/admin/channel_handler_test.go +++ b/backend/internal/handler/admin/channel_handler_test.go @@ -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) diff --git a/backend/internal/handler/admin/channel_monitor_handler.go b/backend/internal/handler/admin/channel_monitor_handler.go index ec5735f0ca35..4832be2decd7 100644 --- a/backend/internal/handler/admin/channel_monitor_handler.go +++ b/backend/internal/handler/admin/channel_monitor_handler.go @@ -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) diff --git a/backend/internal/handler/admin/cn_provider_handler.go b/backend/internal/handler/admin/cn_provider_handler.go new file mode 100644 index 000000000000..7c8d67848aa6 --- /dev/null +++ b/backend/internal/handler/admin/cn_provider_handler.go @@ -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) +} diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index dfbd4eba6f42..1c3ecb07e13b 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -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"` diff --git a/backend/internal/handler/admin/group_handler_platform_test.go b/backend/internal/handler/admin/group_handler_platform_test.go new file mode 100644 index 000000000000..ca1703180cba --- /dev/null +++ b/backend/internal/handler/admin/group_handler_platform_test.go @@ -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) + } +} diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index a5f3d41d9065..01a066165ba4 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -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, diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 5884dc392e17..e2399d0891ee 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -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, diff --git a/backend/internal/handler/admin/user_platform_quota_admin_test.go b/backend/internal/handler/admin/user_platform_quota_admin_test.go index 0211480b8281..f1d1b5b605fb 100644 --- a/backend/internal/handler/admin/user_platform_quota_admin_test.go +++ b/backend/internal/handler/admin/user_platform_quota_admin_test.go @@ -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) } } diff --git a/backend/internal/handler/channel_monitor_user_handler.go b/backend/internal/handler/channel_monitor_user_handler.go index 7e42c5a74d27..173ca56dfe01 100644 --- a/backend/internal/handler/channel_monitor_user_handler.go +++ b/backend/internal/handler/channel_monitor_user_handler.go @@ -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}) } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 5ab5eaa68b6b..f0284eb55ec7 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -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"` diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 359aff007188..7c0d3921ebd7 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -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 diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index bf609f480772..0efc24c80b94 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -255,6 +255,51 @@ 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) + edgeName, entryHost := ip.GetEdgeIngress(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, + EdgeName: edgeName, + EntryHost: entryHost, + 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", @@ -339,6 +384,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { zap.Bool("upstream_error_response_already_written", upstreamErrorAlreadyCommunicated), zap.Error(err), ) + submitChatUsage(result) return } } @@ -348,45 +394,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), true, nil) } - 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) - edgeName, entryHost := ip.GetEdgeIngress(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, - EdgeName: edgeName, - EntryHost: entryHost, - 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), diff --git a/backend/internal/handler/openai_gateway_cn_dispatch_test.go b/backend/internal/handler/openai_gateway_cn_dispatch_test.go new file mode 100644 index 000000000000..b7f11c5462e2 --- /dev/null +++ b/backend/internal/handler/openai_gateway_cn_dispatch_test.go @@ -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)) +} diff --git a/backend/internal/handler/openai_gateway_count_tokens.go b/backend/internal/handler/openai_gateway_count_tokens.go index ac6deda4aa0f..70e61e24f1ba 100644 --- a/backend/internal/handler/openai_gateway_count_tokens.go +++ b/backend/internal/handler/openai_gateway_count_tokens.go @@ -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 diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 102ed74bb6fd..0c59ed79b252 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -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 } @@ -199,11 +197,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 @@ -597,6 +604,53 @@ 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) + edgeName, entryHost := ip.GetEdgeIngress(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, + EdgeName: edgeName, + EntryHost: entryHost, + 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", @@ -696,6 +750,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 @@ -714,49 +769,8 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), openAIForwardSucceededForScheduling(result), nil) } - // 捕获请求信息(用于异步记录,避免在 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) - edgeName, entryHost := ip.GetEdgeIngress(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, - EdgeName: edgeName, - EntryHost: entryHost, - 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), @@ -1148,6 +1162,54 @@ 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) + edgeName, entryHost := ip.GetEdgeIngress(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, + EdgeName: edgeName, + EntryHost: entryHost, + 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", @@ -1222,6 +1284,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) @@ -1231,6 +1296,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { zap.Bool("fallback_error_response_written", wroteFallback), zap.Error(err), ) + submitMessagesUsage(result) return } } @@ -1240,47 +1306,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(currentRoutingModel), true, nil) } - 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) - edgeName, entryHost := ip.GetEdgeIngress(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, - EdgeName: edgeName, - EntryHost: entryHost, - 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), @@ -1426,12 +1452,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 @@ -1443,10 +1467,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 侧选号循环的一次利润门终检否决:把账号 @@ -1709,6 +1736,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 @@ -2040,19 +2068,37 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { // latestCyberRequestBody 保存当前 turn 的客户端请求体副本,供 cyber 命中后落库复盘。 // WS 多 turn 顺序执行,无需额外锁;BeforeRequest 会覆盖,AfterTurn 只读快照。 latestCyberRequestBody := append([]byte(nil), firstMessage...) + 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 { if len(payload) > 0 { latestCyberRequestBody = append([]byte(nil), payload...) @@ -2141,6 +2187,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 已清除标记)。 @@ -2211,7 +2258,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) sessionID := service.ExtractClientSessionID(c) edgeName, entryHost := ip.GetEdgeIngress(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{ diff --git a/backend/internal/handler/openai_ws_turn_pricing_test.go b/backend/internal/handler/openai_ws_turn_pricing_test.go index b9bb204fd60b..3a59f323ba4b 100644 --- a/backend/internal/handler/openai_ws_turn_pricing_test.go +++ b/backend/internal/handler/openai_ws_turn_pricing_test.go @@ -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 必须使用自己的定价时刻") } diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index a415d67e3484..f0785de9961a 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -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, diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 36fdb8528014..1eef8c0019b3 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -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, diff --git a/backend/internal/pkg/antigravity/gemini_types.go b/backend/internal/pkg/antigravity/gemini_types.go index 033dccbd5d3d..4fd7882adc1b 100644 --- a/backend/internal/pkg/antigravity/gemini_types.go +++ b/backend/internal/pkg/antigravity/gemini_types.go @@ -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 函数调用配置 diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go index 7d0a707959b8..9da3cc33a297 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -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 { diff --git a/backend/internal/pkg/antigravity/request_transformer_test.go b/backend/internal/pkg/antigravity/request_transformer_test.go index ea95cc7ea656..05764ef7ff60 100644 --- a/backend/internal/pkg/antigravity/request_transformer_test.go +++ b/backend/internal/pkg/antigravity/request_transformer_test.go @@ -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") + }) +} diff --git a/backend/internal/pkg/apicompat/responses_client_tools.go b/backend/internal/pkg/apicompat/responses_client_tools.go index daffc984d5d0..c4b1e0018edd 100644 --- a/backend/internal/pkg/apicompat/responses_client_tools.go +++ b/backend/internal/pkg/apicompat/responses_client_tools.go @@ -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"` diff --git a/backend/internal/pkg/apicompat/responses_client_tools_test.go b/backend/internal/pkg/apicompat/responses_client_tools_test.go index 91ee0890a624..cc1ed467ba7c 100644 --- a/backend/internal/pkg/apicompat/responses_client_tools_test.go +++ b/backend/internal/pkg/apicompat/responses_client_tools_test.go @@ -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()) + }) + } +} diff --git a/backend/internal/pkg/openai/constants.go b/backend/internal/pkg/openai/constants.go index a863a19f8192..772143ac9223 100644 --- a/backend/internal/pkg/openai/constants.go +++ b/backend/internal/pkg/openai/constants.go @@ -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 系模型默认)。 // diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 010442eb6bcf..664a8454608c 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -71,6 +71,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 { @@ -2520,6 +2553,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 } @@ -2552,6 +2586,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", @@ -2793,6 +2830,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) @@ -2880,7 +2918,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) @@ -2919,6 +2957,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) } diff --git a/backend/internal/repository/account_repo_codex_fingerprint_seed_test.go b/backend/internal/repository/account_repo_codex_fingerprint_seed_test.go new file mode 100644 index 000000000000..1ad1723748d4 --- /dev/null +++ b/backend/internal/repository/account_repo_codex_fingerprint_seed_test.go @@ -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()) +} diff --git a/backend/internal/repository/channel_monitor_quota_integration_test.go b/backend/internal/repository/channel_monitor_quota_integration_test.go new file mode 100644 index 000000000000..2ffc2ae69280 --- /dev/null +++ b/backend/internal/repository/channel_monitor_quota_integration_test.go @@ -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) +} diff --git a/backend/internal/repository/channel_monitor_repo.go b/backend/internal/repository/channel_monitor_repo.go index aa8b12ff8bea..c4586bc38c27 100644 --- a/backend/internal/repository/channel_monitor_repo.go +++ b/backend/internal/repository/channel_monitor_repo.go @@ -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{} diff --git a/backend/internal/repository/channel_repo_pricing.go b/backend/internal/repository/channel_repo_pricing.go index 995621139e9d..b7a9fd6b02ad 100644 --- a/backend/internal/repository/channel_repo_pricing.go +++ b/backend/internal/repository/channel_repo_pricing.go @@ -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 diff --git a/backend/internal/repository/channel_repo_pricing_time_test.go b/backend/internal/repository/channel_repo_pricing_time_test.go new file mode 100644 index 000000000000..d7af1886e7e8 --- /dev/null +++ b/backend/internal/repository/channel_repo_pricing_time_test.go @@ -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()) + }) + }) + } +} diff --git a/backend/internal/repository/codex_fingerprint_seed_migration_integration_test.go b/backend/internal/repository/codex_fingerprint_seed_migration_integration_test.go new file mode 100644 index 000000000000..70ae61eb8eee --- /dev/null +++ b/backend/internal/repository/codex_fingerprint_seed_migration_integration_test.go @@ -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") + } +} diff --git a/backend/internal/repository/openai_oauth_service.go b/backend/internal/repository/openai_oauth_service.go index acb270a3b33f..e7a34ad6a04a 100644 --- a/backend/internal/repository/openai_oauth_service.go +++ b/backend/internal/repository/openai_oauth_service.go @@ -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) diff --git a/backend/internal/repository/openai_oauth_service_test.go b/backend/internal/repository/openai_oauth_service_test.go index b43e2b52fc76..d43b208d4e86 100644 --- a/backend/internal/repository/openai_oauth_service_test.go +++ b/backend/internal/repository/openai_oauth_service_test.go @@ -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}`) diff --git a/backend/internal/repository/scheduler_cache.go b/backend/internal/repository/scheduler_cache.go index b03e4e663718..b53c51d43f0e 100644 --- a/backend/internal/repository/scheduler_cache.go +++ b/backend/internal/repository/scheduler_cache.go @@ -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", diff --git a/backend/internal/repository/scheduler_cache_unit_test.go b/backend/internal/repository/scheduler_cache_unit_test.go index d0373f16c7b2..354cf6d8aa91 100644 --- a/backend/internal/repository/scheduler_cache_unit_test.go +++ b/backend/internal/repository/scheduler_cache_unit_test.go @@ -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"]) } diff --git a/backend/internal/repository/user_platform_quota_repo_integration_test.go b/backend/internal/repository/user_platform_quota_repo_integration_test.go index 4a8d1b58967f..1bd4b08786c9 100644 --- a/backend/internal/repository/user_platform_quota_repo_integration_test.go +++ b/backend/internal/repository/user_platform_quota_repo_integration_test.go @@ -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) diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index def7462c12e1..c072f09b2790 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -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 } } diff --git a/backend/internal/repository/user_repo_invitation_claim_test.go b/backend/internal/repository/user_repo_invitation_claim_test.go new file mode 100644 index 000000000000..ba24c365b992 --- /dev/null +++ b/backend/internal/repository/user_repo_invitation_claim_test.go @@ -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") + }) +} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 79dfb919a68b..7f3b25f76378 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -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, diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index dd0badaff52d..c453c9cd4fb3 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -58,6 +58,9 @@ func RegisterAdminRoutes( // Grok OAuth registerGrokOAuthRoutes(admin, h) + // 国产供应商(kimi/zhipu/deepseek)额度与余额 + registerCNProviderRoutes(admin, h) + // 代理管理 registerProxyRoutes(admin, h, stepUpAuth) @@ -483,6 +486,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") { diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index 6d99d2c7ea39..3c4b33f519fc 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -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) diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index bd98f9de0398..bcabca82eea6 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -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 || a.Type == AccountTypeUpstream { + if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" { + return baseURL + } + } + // 平台默认 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 { - 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" + 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 "" diff --git a/backend/internal/service/account_codex_fingerprint_seed_lifecycle_test.go b/backend/internal/service/account_codex_fingerprint_seed_lifecycle_test.go new file mode 100644 index 000000000000..029b4fb009f9 --- /dev/null +++ b/backend/internal/service/account_codex_fingerprint_seed_lifecycle_test.go @@ -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]) +} diff --git a/backend/internal/service/account_long_context_billing_test.go b/backend/internal/service/account_long_context_billing_test.go index 709559d932aa..08bd3f0b3f05 100644 --- a/backend/internal/service/account_long_context_billing_test.go +++ b/backend/internal/service/account_long_context_billing_test.go @@ -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) { diff --git a/backend/internal/service/account_scheduling_threshold_eval.go b/backend/internal/service/account_scheduling_threshold_eval.go index b8a9dae80d10..7dde1fd163b2 100644 --- a/backend/internal/service/account_scheduling_threshold_eval.go +++ b/backend/internal/service/account_scheduling_threshold_eval.go @@ -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,键形如 +// _5h_used_percent / _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 { diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index 65d4d3f26846..96689e5000fd 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -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) } diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 496ef57aed85..8e21ba149f58 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -731,11 +731,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 同样作为管理员显式配置传入,否则测试用的身份 @@ -2063,6 +2064,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) @@ -2968,11 +2972,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 同样作为管理员显式配置传入,否则测试用的身份 diff --git a/backend/internal/service/account_test_service_openai_compact_test.go b/backend/internal/service/account_test_service_openai_compact_test.go index 5b4772508439..aa2b5f70bd86 100644 --- a/backend/internal/service/account_test_service_openai_compact_test.go +++ b/backend/internal/service/account_test_service_openai_compact_test.go @@ -284,7 +284,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}}, @@ -304,10 +307,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", "探测标识不得是可被上游一眼识别的字面量") diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 02f713ef43b0..001411f66121 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -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 封装账户使用量相关的缓存 @@ -847,7 +846,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 { @@ -877,9 +876,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)) diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index 9958846658ae..cc09fb808fa7 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -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 { diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index ac6259509d1a..f8f9acb4e72a 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -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 } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 3030b945b968..be56680764fc 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -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 { diff --git a/backend/internal/service/admin_service_bulk_update_test.go b/backend/internal/service/admin_service_bulk_update_test.go index 206efb21f1b5..e935beaa9877 100644 --- a/backend/internal/service/admin_service_bulk_update_test.go +++ b/backend/internal/service/admin_service_bulk_update_test.go @@ -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) +} diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index c0b4faef5232..64bfdaeb0200 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -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 diff --git a/backend/internal/service/anthropic_apikey_auth.go b/backend/internal/service/anthropic_apikey_auth.go index 7752d6fe3a02..044d5be96ca7 100644 --- a/backend/internal/service/anthropic_apikey_auth.go +++ b/backend/internal/service/anthropic_apikey_auth.go @@ -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 } diff --git a/backend/internal/service/antigravity_gateway_compat.go b/backend/internal/service/antigravity_gateway_compat.go index ab8f0d17a2a4..49ca07688489 100644 --- a/backend/internal/service/antigravity_gateway_compat.go +++ b/backend/internal/service/antigravity_gateway_compat.go @@ -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 "" diff --git a/backend/internal/service/antigravity_gateway_compat_test.go b/backend/internal/service/antigravity_gateway_compat_test.go index ff3d195cbf63..3df0b0081a7a 100644 --- a/backend/internal/service/antigravity_gateway_compat_test.go +++ b/backend/internal/service/antigravity_gateway_compat_test.go @@ -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 { diff --git a/backend/internal/service/antigravity_gateway_service_test.go b/backend/internal/service/antigravity_gateway_service_test.go index 00fee73a1152..d1c389c7a525 100644 --- a/backend/internal/service/antigravity_gateway_service_test.go +++ b/backend/internal/service/antigravity_gateway_service_test.go @@ -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() diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index deaae082ab27..30a41eece6cc 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -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"` @@ -130,11 +132,6 @@ type APIKeyAuthGroupSnapshot struct { ProfitControlEnabled bool `json:"profit_control_enabled"` ProfitMinMargin float64 `json:"profit_min_margin"` ProfitSafetyBuffer float64 `json:"profit_safety_buffer"` - - // 长上下文阶梯:CalculateCostUnified / 定价解析器读的就是这份快照物化出的 - // Group。漏掉该字段时热路径零值为 false,官方 272k/200k 阶梯会被静默关掉, - // 后台分组开关开着也不生效。必须与 GetByKeyForAuth 投影同步。 - LongContextPricingEnabled bool `json:"long_context_pricing_enabled"` } // APIKeyAuthCacheEntry 缓存条目,支持负缓存 diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index 0e9049bddf6b..7cb37baf4874 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -14,7 +14,7 @@ import ( "github.com/dgraph-io/ristretto" ) -const apiKeyAuthSnapshotVersion = 20 // v20: group long_context_pricing_enabled (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, @@ -428,7 +430,6 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) ProfitControlEnabled: apiKey.Group.ProfitControlEnabled, ProfitMinMargin: apiKey.Group.ProfitMinMargin, ProfitSafetyBuffer: apiKey.Group.ProfitSafetyBuffer, - LongContextPricingEnabled: apiKey.Group.LongContextPricingEnabled, } } return snapshot @@ -502,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, @@ -524,7 +527,6 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho ProfitControlEnabled: snapshot.Group.ProfitControlEnabled, ProfitMinMargin: snapshot.Group.ProfitMinMargin, ProfitSafetyBuffer: snapshot.Group.ProfitSafetyBuffer, - LongContextPricingEnabled: snapshot.Group.LongContextPricingEnabled, } } s.compileAPIKeyIPRules(apiKey) diff --git a/backend/internal/service/api_key_auth_cache_pricing_test.go b/backend/internal/service/api_key_auth_cache_pricing_test.go new file mode 100644 index 000000000000..22aa636aa725 --- /dev/null +++ b/backend/internal/service/api_key_auth_cache_pricing_test.go @@ -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) +} diff --git a/backend/internal/service/api_key_auth_cache_profit_test.go b/backend/internal/service/api_key_auth_cache_profit_test.go index d784aa17c04b..fbcd96a42ae6 100644 --- a/backend/internal/service/api_key_auth_cache_profit_test.go +++ b/backend/internal/service/api_key_auth_cache_profit_test.go @@ -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, 20, snapshot.Version, "v20 起认证快照携带 long_context_pricing_enabled") + require.Equal(t, 20, snapshot.Version, "v20 起认证快照携带分组长上下文与模型定价字段") // 模拟 L2 缓存的完整 JSON 往返(与 apiKeyCache.SetAuthCache/GetAuthCache 同构)。 payload, err := json.Marshal(&APIKeyAuthCacheEntry{Snapshot: snapshot}) diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 2beba0d402c3..f17cfbb4d784 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -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 diff --git a/backend/internal/service/auth_service_invitation_race_test.go b/backend/internal/service/auth_service_invitation_race_test.go new file mode 100644 index 000000000000..e1e7c82aef6f --- /dev/null +++ b/backend/internal/service/auth_service_invitation_race_test.go @@ -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, "被拒绝的注册不应产生用户") +} diff --git a/backend/internal/service/auth_service_register_test.go b/backend/internal/service/auth_service_register_test.go index 04d7efaeb960..c29fdac0c38b 100644 --- a/backend/internal/service/auth_service_register_test.go +++ b/backend/internal/service/auth_service_register_test.go @@ -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 diff --git a/backend/internal/service/bedrock_stream.go b/backend/internal/service/bedrock_stream.go index 98196d27ec86..9ce6b87e242d 100644 --- a/backend/internal/service/bedrock_stream.go +++ b/backend/internal/service/bedrock_stream.go @@ -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() diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 0356f698a009..6ead8da02131 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -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 共用。 diff --git a/backend/internal/service/billing_service_unified_test.go b/backend/internal/service/billing_service_unified_test.go index eabbab3dfc7e..e61a8995511a 100644 --- a/backend/internal/service/billing_service_unified_test.go +++ b/backend/internal/service/billing_service_unified_test.go @@ -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) { diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index 4d5523b0a37d..5345693eb024 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -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 } diff --git a/backend/internal/service/channel_monitor_aggregator.go b/backend/internal/service/channel_monitor_aggregator.go index 09020f5fa427..b6f632bf4b17 100644 --- a/backend/internal/service/channel_monitor_aggregator.go +++ b/backend/internal/service/channel_monitor_aggregator.go @@ -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 } diff --git a/backend/internal/service/channel_monitor_checker.go b/backend/internal/service/channel_monitor_checker.go index 910a6e80d99b..71177db0bdb8 100644 --- a/backend/internal/service/channel_monitor_checker.go +++ b/backend/internal/service/channel_monitor_checker.go @@ -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) { diff --git a/backend/internal/service/channel_monitor_checker_body_test.go b/backend/internal/service/channel_monitor_checker_body_test.go index 3375ee6ac998..235764633705 100644 --- a/backend/internal/service/channel_monitor_checker_body_test.go +++ b/backend/internal/service/channel_monitor_checker_body_test.go @@ -199,7 +199,7 @@ func TestGrokMonitorConfiguration(t *testing.T) { if err := validateProvider(MonitorProviderGrok); err != nil { t.Fatalf("grok provider should be supported: %v", err) } - if got := normalizeMonitorPrimaryModel(MonitorProviderGrok, ""); got != MonitorDefaultGrokModel { + if got := normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeProbe, ""); got != MonitorDefaultGrokModel { t.Fatalf("expected default Grok model %q, got %q", MonitorDefaultGrokModel, got) } if err := validateAPIMode(MonitorProviderGrok, MonitorAPIModeChatCompletions); err != nil { diff --git a/backend/internal/service/channel_monitor_const.go b/backend/internal/service/channel_monitor_const.go index 6a41add2980e..bf7676ed5068 100644 --- a/backend/internal/service/channel_monitor_const.go +++ b/backend/internal/service/channel_monitor_const.go @@ -45,10 +45,12 @@ const ( monitorChallengeMin = 1 monitorChallengeMax = 50 - // providerOpenAIPath OpenAI Chat Completions 路径。 + // providerOpenAIPath OpenAI Chat Completions 路径(Kimi / DeepSeek 同为 OpenAI 兼容)。 providerOpenAIPath = "/v1/chat/completions" // providerGrokPath Grok OpenAI-compatible Chat Completions 路径。 providerGrokPath = "/v1/chat/completions" + // providerZhipuPath 智谱 OpenAI 兼容 Chat Completions 路径(前缀与官方不同)。 + providerZhipuPath = "/api/paas/v4/chat/completions" // providerOpenAIResponsesPath OpenAI Responses API 路径。 providerOpenAIResponsesPath = "/v1/responses" // providerAnthropicPath Anthropic Messages 路径。 @@ -56,11 +58,42 @@ const ( // providerGeminiPathTemplate Gemini generateContent 路径模板(含 model 占位)。 providerGeminiPathTemplate = "/v1beta/models/%s:generateContent" - // MonitorProviderOpenAI / Anthropic / Gemini / Grok provider 字符串常量(也是 ent enum 的实际值)。 - MonitorProviderOpenAI = "openai" - MonitorProviderAnthropic = "anthropic" - MonitorProviderGemini = "gemini" - MonitorProviderGrok = "grok" + // MonitorProviderOpenAI 等 provider 字符串常量(也是 ent enum 的实际值)。 + // 后 4 个 provider(antigravity/kimi/zhipu/deepseek)为配额模式引入: + // antigravity 无探活 adapter(仅配额),其余 3 个复用 OpenAI 兼容探活。 + MonitorProviderOpenAI = "openai" + MonitorProviderAnthropic = "anthropic" + MonitorProviderGemini = "gemini" + MonitorProviderGrok = "grok" + MonitorProviderAntigravity = "antigravity" + MonitorProviderKimi = "kimi" + MonitorProviderZhipu = "zhipu" + MonitorProviderDeepseek = "deepseek" + + // MonitorCheckMode 检测模式(channel_monitors.check_mode)。 + // probe - LLM 探活(默认,原有行为) + // quota - 仅查关联账号用量/余额,零 LLM 成本 + // quota_probe - 探活 + 配额并存(配额快照挂到主模型历史行) + MonitorCheckModeProbe = "probe" + MonitorCheckModeQuota = "quota" + MonitorCheckModeQuotaProbe = "quota_probe" + + // MonitorDefaultQuotaModel 是 quota 模式监控未显式指定模型时占位的虚拟模型名 + // (primary_model 列 NotEmpty,用 "quota" 让历史行/时间线机制无需特判)。 + MonitorDefaultQuotaModel = "quota" + + // monitorQuotaFetchCacheTTL 配额快照缓存时长。多个监控可能关联同一账号, + // 而 interval 最小 15s 且国产配额服务无缓存,TTL 防止打爆上游配额端点。 + monitorQuotaFetchCacheTTL = 5 * time.Minute + // monitorQuotaErrorCacheTTL 失败快照的负缓存时长:失败也短缓存,避免 + // 故障/凭据失效期间每次调度(最小 15s)都带真实凭据打上游;到期自动重试。 + monitorQuotaErrorCacheTTL = 60 * time.Second + // monitorQuotaFetchTimeout singleflight 内单次配额抓取的总超时 + // (脱离调用方 ctx,防止某个监控的取消波及共享同一账号的其他监控)。 + monitorQuotaFetchTimeout = 45 * time.Second + // monitorQuotaDegradedUsedPercent 任一用量窗口使用率超过该阈值时, + // 配额检查状态记为 degraded(对齐账号页展示阈值)。 + monitorQuotaDegradedUsedPercent = 90.0 // MonitorDefaultGrokModel 是新增 Grok 监控未显式指定模型时使用的轻量测活模型。 MonitorDefaultGrokModel = "grok-4.5" @@ -118,7 +151,16 @@ var ( "CHANNEL_MONITOR_NOT_FOUND", "channel monitor not found", ) ErrChannelMonitorInvalidProvider = infraerrors.BadRequest( - "CHANNEL_MONITOR_INVALID_PROVIDER", "provider must be one of openai/anthropic/gemini/grok", + "CHANNEL_MONITOR_INVALID_PROVIDER", "provider must be one of openai/anthropic/gemini/grok/antigravity/kimi/zhipu/deepseek", + ) + ErrChannelMonitorInvalidCheckMode = infraerrors.BadRequest( + "CHANNEL_MONITOR_INVALID_CHECK_MODE", "check_mode must be one of probe/quota/quota_probe; antigravity only supports quota", + ) + ErrChannelMonitorAccountRequired = infraerrors.BadRequest( + "CHANNEL_MONITOR_ACCOUNT_REQUIRED", "account_id is required for quota-based check_mode", + ) + ErrChannelMonitorProviderIncompatible = infraerrors.BadRequest( + "CHANNEL_MONITOR_PROVIDER_INCOMPATIBLE", "monitor provider must match the linked account platform", ) ErrChannelMonitorInvalidAPIMode = infraerrors.BadRequest( "CHANNEL_MONITOR_INVALID_API_MODE", "api_mode must be chat_completions or responses; responses is only supported for openai", diff --git a/backend/internal/service/channel_monitor_quota_fetcher.go b/backend/internal/service/channel_monitor_quota_fetcher.go new file mode 100644 index 000000000000..a4fa992c3069 --- /dev/null +++ b/backend/internal/service/channel_monitor_quota_fetcher.go @@ -0,0 +1,506 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" + "golang.org/x/sync/singleflight" +) + +// 渠道监控「配额模式」的配额抓取器。 +// +// 不直接对接上游,而是把账号侧现成的用量服务归一成 domain.MonitorQuotaSnapshot: +// - 海外 5 家(anthropic/openai/gemini/antigravity/grok)→ AccountUsageService.GetUsage +// - 国产 coding plan(kimi/zhipu/deepseek)→ CNProviderQuotaService.QueryUsage +// - 国产 payg(kimi/deepseek)→ CNProviderBalanceService.QueryBalance +// (zhipu payg 无公开余额端点,QueryBalance 会返回该错误,原样透出) +// +// Fetch 永不返回 error:所有失败都降级为 Success=false 的快照照常入库, +// 由 deriveQuotaCheckResult 推导为 failed/error 状态。 +// +// 多个监控可能关联同一账号,而 interval 最小 15s 且国产配额服务自身无缓存, +// 所以快照统一带 TTL 缓存(成功 monitorQuotaFetchCacheTTL、失败 +// monitorQuotaErrorCacheTTL 负缓存),防止打爆上游配额端点;同账号的并发 +// 抓取由 singleflight 合并为一次上游查询。 + +// monitorUsageSource 海外平台账号用量查询(AccountUsageService 天然满足)。 +type monitorUsageSource interface { + GetUsage(ctx context.Context, accountID int64, force ...bool) (*UsageInfo, error) +} + +// monitorCNQuotaSource 国产 coding plan 滚动窗口额度探测(CNProviderQuotaService 天然满足)。 +type monitorCNQuotaSource interface { + QueryUsage(ctx context.Context, accountID int64) (*CNProviderQuotaProbeResult, error) +} + +// monitorCNBalanceSource 国产 payg 余额探测(CNProviderBalanceService 天然满足)。 +type monitorCNBalanceSource interface { + QueryBalance(ctx context.Context, accountID int64) (*CNProviderBalanceResult, error) +} + +// monitorAccountSource 账号加载(AccountRepository 天然满足)。 +type monitorAccountSource interface { + GetByID(ctx context.Context, id int64) (*Account, error) +} + +// ChannelMonitorQuotaFetcher 配额抓取器(成功/失败快照均带 TTL 缓存, +// 同账号并发抓取由 singleflight 合并)。 +type ChannelMonitorQuotaFetcher struct { + usage monitorUsageSource + cnQuota monitorCNQuotaSource + cnBalance monitorCNBalanceSource + accounts monitorAccountSource + + mu sync.Mutex + cache map[int64]monitorQuotaCacheEntry + flight singleflight.Group +} + +type monitorQuotaCacheEntry struct { + snapshot *domain.MonitorQuotaSnapshot + expiry time.Time +} + +// NewChannelMonitorQuotaFetcher 构造配额抓取器。 +// 参数取具体服务类型以便 wire 直连;单元测试在同包内用 struct 字面量注入 stub。 +func NewChannelMonitorQuotaFetcher( + usage *AccountUsageService, + cnQuota *CNProviderQuotaService, + cnBalance *CNProviderBalanceService, + accounts AccountRepository, +) *ChannelMonitorQuotaFetcher { + f := &ChannelMonitorQuotaFetcher{cache: make(map[int64]monitorQuotaCacheEntry)} + if usage != nil { + f.usage = usage + } + if cnQuota != nil { + f.cnQuota = cnQuota + } + if cnBalance != nil { + f.cnBalance = cnBalance + } + if accounts != nil { + f.accounts = accounts + } + return f +} + +// LoadAccount 加载账号(不走缓存)。供 Create/Update 时校验 +// provider 与 account.platform 一致;账号不存在时返回错误。 +func (f *ChannelMonitorQuotaFetcher) LoadAccount(ctx context.Context, id int64) (*Account, error) { + if f == nil || f.accounts == nil { + return nil, fmt.Errorf("quota fetcher is not configured") + } + return f.accounts.GetByID(ctx, id) +} + +// Fetch 抓取账号的最新配额快照。永不返回 error:失败降级为 +// Success=false 快照(Error 带摘要),保证检测历史的时间线连续。 +func (f *ChannelMonitorQuotaFetcher) Fetch(ctx context.Context, accountID int64) *domain.MonitorQuotaSnapshot { + if f == nil { + // fail-closed:fetcher 未注入(存量测试构造)时不 panic,降级为错误快照。 + return quotaErrorSnapshot("usage", "quota fetcher is not configured", time.Now()) + } + + now := time.Now() + + if cached, ok := f.cachedSnapshot(accountID, now); ok { + return cached + } + + // singleflight 合并同账号并发抓取;脱离调用方 ctx(仿 CN 配额服务), + // 避免某个监控的取消波及共享同一账号的其他监控。 + key := "monitor-quota:" + strconv.FormatInt(accountID, 10) + ch := f.flight.DoChan(key, func() (any, error) { + fetchCtx, cancel := context.WithTimeout(context.Background(), monitorQuotaFetchTimeout) + defer cancel() + snapshot := f.fetchUncached(fetchCtx, accountID, time.Now()) + // 失败也进短 TTL 负缓存:凭据失效/故障期间不必每次调度都打上游。 + ttl := monitorQuotaFetchCacheTTL + if !snapshot.Success { + ttl = monitorQuotaErrorCacheTTL + } + f.storeSnapshot(accountID, snapshot, time.Now().Add(ttl)) + return snapshot, nil + }) + select { + case <-ctx.Done(): + return quotaErrorSnapshot("usage", "context canceled", now) + case res := <-ch: + snapshot, ok := res.Val.(*domain.MonitorQuotaSnapshot) + if res.Err != nil || !ok || snapshot == nil { + return quotaErrorSnapshot("usage", "quota fetch failed", now) + } + return snapshot + } +} + +func (f *ChannelMonitorQuotaFetcher) cachedSnapshot(accountID int64, now time.Time) (*domain.MonitorQuotaSnapshot, bool) { + f.mu.Lock() + defer f.mu.Unlock() + entry, ok := f.cache[accountID] + if !ok || now.After(entry.expiry) { + return nil, false + } + return entry.snapshot, true +} + +func (f *ChannelMonitorQuotaFetcher) storeSnapshot(accountID int64, snapshot *domain.MonitorQuotaSnapshot, expiry time.Time) { + f.mu.Lock() + defer f.mu.Unlock() + f.cache[accountID] = monitorQuotaCacheEntry{snapshot: snapshot, expiry: expiry} +} + +func (f *ChannelMonitorQuotaFetcher) fetchUncached(ctx context.Context, accountID int64, now time.Time) *domain.MonitorQuotaSnapshot { + if f == nil { + return quotaErrorSnapshot("usage", "quota fetcher is not configured", now) + } + + account, err := f.LoadAccount(ctx, accountID) + if err != nil || account == nil { + // FK ON DELETE SET NULL 后 account_id 可能为空/失效;显式报「账号未关联」, + // 推导为 degraded(配置问题,不是渠道故障)。 + slog.Warn("channel_monitor: load linked account failed", + "account_id", accountID, "error", err) + return quotaErrorSnapshot("usage", "linked account not found", now) + } + + switch account.Platform { + case domain.PlatformKimi, domain.PlatformZhipu, domain.PlatformDeepseek: + if account.IsCodingPlan() { + return f.fetchCNQuota(ctx, accountID, now) + } + return f.fetchCNBalance(ctx, accountID, now) + default: + return f.fetchUsage(ctx, accountID, now) + } +} + +// fetchUsage 海外平台:AccountUsageService.GetUsage → 快照。 +func (f *ChannelMonitorQuotaFetcher) fetchUsage(ctx context.Context, accountID int64, now time.Time) *domain.MonitorQuotaSnapshot { + if f.usage == nil { + return quotaErrorSnapshot("usage", "usage service is not configured", now) + } + usage, err := f.usage.GetUsage(ctx, accountID) + if err != nil { + msg := truncateMessage(sanitizeErrorMessage(err.Error())) + return &domain.MonitorQuotaSnapshot{ + Source: "usage", + Success: false, + CredentialInvalid: isCredentialErrorMessage(msg), + Error: msg, + FetchedAt: now, + } + } + if usage == nil { + return quotaErrorSnapshot("usage", "usage service returned no data", now) + } + // openai/gemini/antigravity/grok 的失败多走「值通道」(err==nil 但错误 + // 降级在 UsageInfo 字段里),必须显式识别,否则会被误判为 operational。 + if failed, credInvalid, msg := usageFailureInfo(usage); failed { + return &domain.MonitorQuotaSnapshot{ + Source: "usage", + Success: false, + CredentialInvalid: credInvalid, + Error: truncateMessage(sanitizeErrorMessage(msg)), + FetchedAt: now, + } + } + snapshot := &domain.MonitorQuotaSnapshot{ + Source: "usage", + Success: true, + PlanLevel: usage.SubscriptionTier, + Tiers: usageQuotaTiers(usage), + FetchedAt: now, + } + if snapshot.PlanLevel == "" { + snapshot.PlanLevel = usage.SubscriptionTierRaw + } + return snapshot +} + +// usageQuotaTiers 把 UsageInfo 的各平台窗口归一为 tier 列表(无数据的窗口跳过)。 +func usageQuotaTiers(usage *UsageInfo) []domain.MonitorQuotaTier { + if usage == nil { + return nil + } + tiers := make([]domain.MonitorQuotaTier, 0, 8) + appendProgressTier(&tiers, "5h", "", usage.FiveHour) + appendProgressTier(&tiers, "7d", "", usage.SevenDay) + appendProgressTier(&tiers, "7d-sonnet", "", usage.SevenDaySonnet) + appendProgressTier(&tiers, "7d-fable", "", usage.SevenDayFable) + appendProgressTier(&tiers, "30d", "", usage.ThirtyDay) + // Gemini 多档日配额:同 Window 不同 Label。 + appendProgressTier(&tiers, "daily", "shared", usage.GeminiSharedDaily) + appendProgressTier(&tiers, "daily", "pro", usage.GeminiProDaily) + appendProgressTier(&tiers, "daily", "flash", usage.GeminiFlashDaily) + // Grok requests/tokens 两个日窗口 + 月度计费窗口。 + appendQuotaWindowTier(&tiers, "daily", "requests", usage.GrokRequestQuota) + appendQuotaWindowTier(&tiers, "daily", "tokens", usage.GrokTokenQuota) + // Antigravity per-model 总量额度,Label = 模型名(按名排序保证输出稳定)。 + for _, model := range sortedQuotaModelNames(usage.AntigravityQuota) { + q := usage.AntigravityQuota[model] + if q == nil { + continue + } + tiers = append(tiers, domain.MonitorQuotaTier{ + Window: "total", + Label: model, + UsedPercent: float64(q.Utilization), + ResetAt: q.ResetTime, + }) + } + if len(tiers) == 0 { + return nil + } + return tiers +} + +func appendProgressTier(tiers *[]domain.MonitorQuotaTier, window, label string, p *UsageProgress) { + if p == nil { + return + } + tier := domain.MonitorQuotaTier{ + Window: window, + Label: label, + UsedPercent: p.Utilization, + } + if p.ResetsAt != nil { + tier.ResetAt = p.ResetsAt.UTC().Format(time.RFC3339) + } + if p.LimitRequests > 0 { + tier.Used = float64(p.UsedRequests) + tier.Limit = float64(p.LimitRequests) + } + *tiers = append(*tiers, tier) +} + +func appendQuotaWindowTier(tiers *[]domain.MonitorQuotaTier, window, label string, q *xai.QuotaWindow) { + if q == nil || q.Limit == nil || *q.Limit <= 0 { + return + } + used := *q.Limit + if q.Remaining != nil { + used = *q.Limit - *q.Remaining + if used < 0 { + used = 0 + } + } + tier := domain.MonitorQuotaTier{ + Window: window, + Label: label, + Used: float64(used), + Limit: float64(*q.Limit), + UsedPercent: float64(used) / float64(*q.Limit) * 100, + } + if q.ResetAt != "" { + tier.ResetAt = q.ResetAt + } else if q.ResetUnix != nil && *q.ResetUnix > 0 { + tier.ResetAt = time.Unix(*q.ResetUnix, 0).UTC().Format(time.RFC3339) + } + *tiers = append(*tiers, tier) +} + +func sortedQuotaModelNames(quotas map[string]*AntigravityModelQuota) []string { + names := make([]string, 0, len(quotas)) + for name := range quotas { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// fetchCNQuota 国产 coding plan:CNProviderQuotaService.QueryUsage → 快照。 +func (f *ChannelMonitorQuotaFetcher) fetchCNQuota(ctx context.Context, accountID int64, now time.Time) *domain.MonitorQuotaSnapshot { + if f.cnQuota == nil { + return quotaErrorSnapshot("cn_quota", "cn quota service is not configured", now) + } + result, err := f.cnQuota.QueryUsage(ctx, accountID) + if err != nil { + msg := truncateMessage(sanitizeErrorMessage(err.Error())) + return &domain.MonitorQuotaSnapshot{ + Source: "cn_quota", + Success: false, + CredentialInvalid: isCredentialErrorMessage(msg), + Error: msg, + FetchedAt: now, + } + } + snapshot := &domain.MonitorQuotaSnapshot{ + Source: "cn_quota", + Success: result.Success, + PlanLevel: result.PlanLevel, + Error: result.Error, + FetchedAt: now, + } + if !result.Success && !result.CredentialValid { + snapshot.CredentialInvalid = true + } + if len(result.Tiers) > 0 { + snapshot.Tiers = make([]domain.MonitorQuotaTier, 0, len(result.Tiers)) + for _, t := range result.Tiers { + snapshot.Tiers = append(snapshot.Tiers, domain.MonitorQuotaTier{ + Window: t.Window, + UsedPercent: t.UsedPercent, + ResetAt: t.ResetAt, + }) + } + } + if !snapshot.Success { + snapshot.Error = firstNonEmpty(snapshot.Error, "cn quota probe failed") + } + return snapshot +} + +// fetchCNBalance 国产 payg:CNProviderBalanceService.QueryBalance → 快照。 +func (f *ChannelMonitorQuotaFetcher) fetchCNBalance(ctx context.Context, accountID int64, now time.Time) *domain.MonitorQuotaSnapshot { + if f.cnBalance == nil { + return quotaErrorSnapshot("cn_balance", "cn balance service is not configured", now) + } + result, err := f.cnBalance.QueryBalance(ctx, accountID) + if err != nil { + msg := truncateMessage(sanitizeErrorMessage(err.Error())) + return &domain.MonitorQuotaSnapshot{ + Source: "cn_balance", + Success: false, + CredentialInvalid: isCredentialErrorMessage(msg), + Error: msg, + FetchedAt: now, + } + } + snapshot := &domain.MonitorQuotaSnapshot{ + Source: "cn_balance", + Success: result.Success, + Currency: result.Currency, + Error: result.Error, + FetchedAt: now, + } + if result.Success { + balance := result.Balance + snapshot.Balance = &balance + } else if result.StatusCode == 401 || result.StatusCode == 403 { + snapshot.CredentialInvalid = true + } + if len(result.Balances) > 0 { + snapshot.Balances = make([]domain.MonitorBalance, 0, len(result.Balances)) + for _, b := range result.Balances { + snapshot.Balances = append(snapshot.Balances, domain.MonitorBalance{ + Currency: b.Currency, + Balance: b.Balance, + }) + } + } + if !snapshot.Success { + snapshot.Error = firstNonEmpty(snapshot.Error, "cn balance probe failed") + } + return snapshot +} + +// quotaErrorSnapshot 构造统一错误快照。 +func quotaErrorSnapshot(source, message string, now time.Time) *domain.MonitorQuotaSnapshot { + return &domain.MonitorQuotaSnapshot{ + Source: source, + Success: false, + Error: truncateMessage(sanitizeErrorMessage(message)), + FetchedAt: now, + } +} + +// isCredentialErrorMessage 上游 401/403 鉴权失败的启发式识别 +// (海外 GetUsage 的错误没有结构化状态码,只能看文本)。 +func isCredentialErrorMessage(msg string) bool { + msg = strings.ToLower(msg) + return strings.Contains(msg, "401") || + strings.Contains(msg, "403") || + strings.Contains(msg, "unauthorized") || + strings.Contains(msg, "forbidden") || + strings.Contains(msg, "invalid_api_key") || + strings.Contains(msg, "authentication") +} + +// usageFailureInfo 识别 GetUsage 经「值通道」返回的失败:antigravity/grok +// 等平台 err==nil 但把错误降级在 UsageInfo 字段里(Error/ErrorCode/状态标记)。 +// 返回 failed=false 表示可用;credentialInvalid 表示凭据失效(401/403 语义, +// 推导为 failed 状态);msg 为失败摘要。 +// +// grok 的 ErrorCode=quota_unknown 是「尚未观测到计费快照/限流头」的已知未知态, +// 不是失败(严格按 ErrorCode 判会把健康 grok 账号永久判 error),显式豁免。 +func usageFailureInfo(usage *UsageInfo) (failed, credentialInvalid bool, msg string) { + if usage == nil { + return false, false, "" + } + if usage.ErrorCode == "quota_unknown" { + return false, false, "" + } + failed = usage.Error != "" || usage.NeedsReauth || usage.IsBanned || + usage.IsForbidden || usage.ErrorCode != "" + if !failed { + return false, false, "" + } + credentialInvalid = usage.NeedsReauth || usage.IsBanned || usage.IsForbidden || + usage.ErrorCode == errorCodeUnauthenticated || usage.ErrorCode == errorCodeForbidden + msg = firstNonEmpty(usage.Error, usage.ForbiddenReason, usage.ErrorCode, "usage fetch failed") + return failed, credentialInvalid, msg +} + +// deriveQuotaCheckResult 把配额快照推导为检测状态(复用既有 status 枚举, +// 时间线/可用率机制自动生效): +// - 查询成功且无告警 → operational +// - 任一窗口使用率 >= 阈值或余额耗尽 → degraded +// - 账号未关联(配置问题) → degraded +// - 凭据失效(401/403) → failed +// - 网络/解析等其他错误 → error +func deriveQuotaCheckResult(snapshot *domain.MonitorQuotaSnapshot, model string, checkedAt time.Time) *CheckResult { + res := &CheckResult{Model: model, CheckedAt: checkedAt} + if snapshot == nil { + res.Status = MonitorStatusError + res.Message = "quota snapshot missing" + return res + } + + switch { + case !snapshot.Success && snapshot.CredentialInvalid: + res.Status = MonitorStatusFailed + res.Message = snapshot.Error + case !snapshot.Success && strings.Contains(snapshot.Error, "linked account not found"): + res.Status = MonitorStatusDegraded + res.Message = snapshot.Error + case !snapshot.Success: + res.Status = MonitorStatusError + res.Message = snapshot.Error + default: + if hint := quotaDegradedHint(snapshot); hint != "" { + res.Status = MonitorStatusDegraded + res.Message = hint + } else { + res.Status = MonitorStatusOperational + } + } + return res +} + +// quotaDegradedHint 生成 degraded 的 message(指出触发告警的窗口/余额); +// 空串表示无告警。 +func quotaDegradedHint(snapshot *domain.MonitorQuotaSnapshot) string { + for _, tier := range snapshot.Tiers { + if tier.UsedPercent >= monitorQuotaDegradedUsedPercent { + name := tier.Window + if tier.Label != "" { + name = tier.Label + "/" + tier.Window + } + return fmt.Sprintf("quota high: %s at %s%%", name, strconv.FormatFloat(tier.UsedPercent, 'f', 1, 64)) + } + } + if snapshot.Balance != nil && *snapshot.Balance <= 0 { + return fmt.Sprintf("balance depleted (%s)", firstNonEmpty(snapshot.Currency, "?")) + } + return "" +} diff --git a/backend/internal/service/channel_monitor_quota_fetcher_test.go b/backend/internal/service/channel_monitor_quota_fetcher_test.go new file mode 100644 index 000000000000..f2e6ff998cf1 --- /dev/null +++ b/backend/internal/service/channel_monitor_quota_fetcher_test.go @@ -0,0 +1,518 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" + "github.com/stretchr/testify/require" +) + +// --- fetcher 依赖 stub --- + +type stubMonitorUsageSource struct { + usage *UsageInfo + err error + // block 非 nil 时 GetUsage 阻塞在该 channel 上,用于并发/singleflight 测试。 + block chan struct{} + + mu sync.Mutex + calls int + lastCtx context.Context +} + +func (s *stubMonitorUsageSource) GetUsage(ctx context.Context, accountID int64, force ...bool) (*UsageInfo, error) { + s.mu.Lock() + s.calls++ + s.lastCtx = ctx + s.mu.Unlock() + if s.block != nil { + <-s.block + } + return s.usage, s.err +} + +func (s *stubMonitorUsageSource) getCalls() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.calls +} + +type stubMonitorCNQuotaSource struct { + result *CNProviderQuotaProbeResult + err error + calls int +} + +func (s *stubMonitorCNQuotaSource) QueryUsage(ctx context.Context, accountID int64) (*CNProviderQuotaProbeResult, error) { + s.calls++ + return s.result, s.err +} + +type stubMonitorCNBalanceSource struct { + result *CNProviderBalanceResult + err error + calls int +} + +func (s *stubMonitorCNBalanceSource) QueryBalance(ctx context.Context, accountID int64) (*CNProviderBalanceResult, error) { + s.calls++ + return s.result, s.err +} + +type stubMonitorAccountSource struct { + accounts map[int64]*Account + err error + calls int +} + +func (s *stubMonitorAccountSource) GetByID(ctx context.Context, id int64) (*Account, error) { + s.calls++ + if s.err != nil { + return nil, s.err + } + return s.accounts[id], nil +} + +func newQuotaFetcherTestSetup(t *testing.T) (*ChannelMonitorQuotaFetcher, *stubMonitorUsageSource, *stubMonitorCNQuotaSource, *stubMonitorCNBalanceSource, *stubMonitorAccountSource) { + t.Helper() + usage := &stubMonitorUsageSource{} + cnQuota := &stubMonitorCNQuotaSource{} + cnBalance := &stubMonitorCNBalanceSource{} + accounts := &stubMonitorAccountSource{accounts: make(map[int64]*Account)} + fetcher := &ChannelMonitorQuotaFetcher{ + usage: usage, + cnQuota: cnQuota, + cnBalance: cnBalance, + accounts: accounts, + cache: make(map[int64]monitorQuotaCacheEntry), + } + return fetcher, usage, cnQuota, cnBalance, accounts +} + +// --- 分派 --- + +func TestQuotaFetcher_OverseasAccountUsesUsageService(t *testing.T) { + fetcher, usage, _, cnQuota, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[7] = &Account{ID: 7, Platform: domain.PlatformAnthropic} + resets := time.Now().Add(2 * time.Hour).UTC() + usage.usage = &UsageInfo{ + FiveHour: &UsageProgress{Utilization: 42.5, UsedRequests: 17, LimitRequests: 40, ResetsAt: &resets}, + SevenDay: &UsageProgress{Utilization: 10}, + SubscriptionTier: "PRO", + } + + snapshot := fetcher.Fetch(context.Background(), 7) + + require.True(t, snapshot.Success) + require.Equal(t, "usage", snapshot.Source) + require.Equal(t, "PRO", snapshot.PlanLevel) + require.False(t, snapshot.CredentialInvalid) + require.Empty(t, snapshot.Error) + require.Len(t, snapshot.Tiers, 2) + + fiveHour := snapshot.Tiers[0] + require.Equal(t, "5h", fiveHour.Window) + require.Empty(t, fiveHour.Label) + require.InDelta(t, 42.5, fiveHour.UsedPercent, 0.001) + require.Equal(t, float64(17), fiveHour.Used) + require.Equal(t, float64(40), fiveHour.Limit) + require.NotEmpty(t, fiveHour.ResetAt) + + require.Equal(t, "7d", snapshot.Tiers[1].Window) + require.Equal(t, 1, usage.getCalls()) + require.Equal(t, 0, cnQuota.calls) +} + +func TestQuotaFetcher_CodingPlanAccountUsesCNQuota(t *testing.T) { + fetcher, _, cnQuota, cnBalance, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[9] = &Account{ + ID: 9, + Platform: domain.PlatformKimi, + Credentials: map[string]any{"account_mode": AccountModeCoding}, + } + cnQuota.result = &CNProviderQuotaProbeResult{ + Success: true, + CredentialValid: true, + PlanLevel: "", + Tiers: []CNQuotaTier{ + {Window: "5h", UsedPercent: 33.3, ResetAt: "2026-08-18T06:00:00Z"}, + {Window: "weekly", UsedPercent: 12}, + }, + } + + snapshot := fetcher.Fetch(context.Background(), 9) + + require.True(t, snapshot.Success) + require.Equal(t, "cn_quota", snapshot.Source) + require.Len(t, snapshot.Tiers, 2) + require.Equal(t, "5h", snapshot.Tiers[0].Window) + require.InDelta(t, 33.3, snapshot.Tiers[0].UsedPercent, 0.001) + require.Equal(t, "weekly", snapshot.Tiers[1].Window) + require.Equal(t, 1, cnQuota.calls) + require.Equal(t, 0, cnBalance.calls) +} + +func TestQuotaFetcher_PayGAccountUsesCNBalance(t *testing.T) { + fetcher, _, _, cnBalance, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[11] = &Account{ + ID: 11, + Platform: domain.PlatformDeepseek, + Credentials: map[string]any{"account_mode": AccountModePayG}, + } + cnBalance.result = &CNProviderBalanceResult{ + Success: true, + Balance: 12.34, + Currency: "CNY", + Balances: []CNProviderBalanceEntry{ + {Currency: "CNY", Balance: 12.34}, + {Currency: "USD", Balance: 1.5}, + }, + } + + snapshot := fetcher.Fetch(context.Background(), 11) + + require.True(t, snapshot.Success) + require.Equal(t, "cn_balance", snapshot.Source) + require.NotNil(t, snapshot.Balance) + require.InDelta(t, 12.34, *snapshot.Balance, 0.001) + require.Equal(t, "CNY", snapshot.Currency) + require.Len(t, snapshot.Balances, 2) + require.Equal(t, "USD", snapshot.Balances[1].Currency) + require.Empty(t, snapshot.Error) +} + +// --- 失败路径(Fetch 永不返回 error) --- + +func TestQuotaFetcher_AccountMissingYieldsLinkedAccountSnapshot(t *testing.T) { + fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t) + accounts.err = errors.New("not found") + + snapshot := fetcher.Fetch(context.Background(), 404) + + require.False(t, snapshot.Success) + require.Equal(t, "linked account not found", snapshot.Error) + require.Equal(t, 0, usage.getCalls()) // 未走到数据源 +} + +func TestQuotaFetcher_UsageAuthErrorMarksCredentialInvalid(t *testing.T) { + fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[3] = &Account{ID: 3, Platform: domain.PlatformOpenAI} + usage.err = errors.New("API returned 401: unauthorized") + + snapshot := fetcher.Fetch(context.Background(), 3) + + require.False(t, snapshot.Success) + require.True(t, snapshot.CredentialInvalid) + require.Contains(t, snapshot.Error, "401") +} + +// 值通道失败:antigravity/grok 等平台 err==nil 但错误降级在 UsageInfo 字段里, +// 必须识别为失败快照,否则会被误判为 operational。 +func TestQuotaFetcher_UsageValueChannelFailureYieldsFailureSnapshot(t *testing.T) { + fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t) + + // 凭据失效(401 语义)→ failed。 + accounts.accounts[3] = &Account{ID: 3, Platform: domain.PlatformAnthropic} + usage.usage = &UsageInfo{Error: "usage API error: HTTP 401", ErrorCode: errorCodeUnauthenticated, NeedsReauth: true} + snapshot := fetcher.Fetch(context.Background(), 3) + require.False(t, snapshot.Success) + require.True(t, snapshot.CredentialInvalid) + require.Contains(t, snapshot.Error, "401") + require.Equal(t, MonitorStatusFailed, deriveQuotaCheckResult(snapshot, "quota", time.Now()).Status) + + // 限流等非凭据失败 → error(而非 operational)。 + accounts.accounts[13] = &Account{ID: 13, Platform: domain.PlatformAnthropic} + usage.usage = &UsageInfo{Error: "usage API error: HTTP 429", ErrorCode: errorCodeRateLimited} + snapshot = fetcher.Fetch(context.Background(), 13) + require.False(t, snapshot.Success) + require.False(t, snapshot.CredentialInvalid) + require.Contains(t, snapshot.Error, "429") + require.Equal(t, MonitorStatusError, deriveQuotaCheckResult(snapshot, "quota", time.Now()).Status) + + // grok 已知未知态(尚未观测到计费/限流头)不算失败。 + accounts.accounts[14] = &Account{ID: 14, Platform: domain.PlatformGrok} + usage.usage = &UsageInfo{ErrorCode: "quota_unknown", Error: "Grok quota is unknown until billing is probed"} + snapshot = fetcher.Fetch(context.Background(), 14) + require.True(t, snapshot.Success) + require.Empty(t, snapshot.Error) + require.Empty(t, snapshot.Tiers) + require.Equal(t, MonitorStatusOperational, deriveQuotaCheckResult(snapshot, "quota", time.Now()).Status) +} + +func TestUsageFailureInfo_ClassificationMatrix(t *testing.T) { + cases := []struct { + name string + usage *UsageInfo + failed bool + credentialInvalid bool + msg string + }{ + {name: "nil usage", usage: nil}, + {name: "healthy empty", usage: &UsageInfo{}}, + {name: "error text only", usage: &UsageInfo{Error: "boom"}, failed: true, msg: "boom"}, + {name: "needs reauth", usage: &UsageInfo{NeedsReauth: true}, failed: true, credentialInvalid: true, msg: "usage fetch failed"}, + {name: "banned", usage: &UsageInfo{IsBanned: true}, failed: true, credentialInvalid: true, msg: "usage fetch failed"}, + {name: "forbidden with reason", usage: &UsageInfo{IsForbidden: true, ForbiddenReason: "usage limited"}, failed: true, credentialInvalid: true, msg: "usage limited"}, + {name: "error code unauthenticated", usage: &UsageInfo{ErrorCode: errorCodeUnauthenticated}, failed: true, credentialInvalid: true, msg: errorCodeUnauthenticated}, + {name: "error code forbidden", usage: &UsageInfo{ErrorCode: errorCodeForbidden}, failed: true, credentialInvalid: true, msg: errorCodeForbidden}, + {name: "error code rate limited", usage: &UsageInfo{ErrorCode: errorCodeRateLimited}, failed: true, msg: errorCodeRateLimited}, + {name: "error code network error", usage: &UsageInfo{ErrorCode: errorCodeNetworkError}, failed: true, msg: errorCodeNetworkError}, + {name: "grok quota unknown exempted", usage: &UsageInfo{ErrorCode: "quota_unknown", Error: "Grok quota is unknown until billing is probed"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + failed, credentialInvalid, msg := usageFailureInfo(tc.usage) + require.Equal(t, tc.failed, failed) + require.Equal(t, tc.credentialInvalid, credentialInvalid) + if tc.msg != "" { + require.Equal(t, tc.msg, msg) + } + }) + } +} + +func TestQuotaFetcher_CNQuotaCredentialInvalidFlagPropagates(t *testing.T) { + fetcher, _, cnQuota, _, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[5] = &Account{ + ID: 5, + Platform: domain.PlatformZhipu, + Credentials: map[string]any{"account_mode": AccountModeCoding}, + } + cnQuota.result = &CNProviderQuotaProbeResult{Success: false, CredentialValid: false, Error: "api key expired"} + + snapshot := fetcher.Fetch(context.Background(), 5) + + require.False(t, snapshot.Success) + require.True(t, snapshot.CredentialInvalid) + require.Equal(t, "api key expired", snapshot.Error) +} + +func TestQuotaFetcher_CNBalanceHTTP403MarksCredentialInvalid(t *testing.T) { + fetcher, _, _, cnBalance, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[6] = &Account{ID: 6, Platform: domain.PlatformKimi} + cnBalance.result = &CNProviderBalanceResult{Success: false, StatusCode: 403, Error: "forbidden"} + + snapshot := fetcher.Fetch(context.Background(), 6) + + require.False(t, snapshot.Success) + require.True(t, snapshot.CredentialInvalid) +} + +func TestQuotaFetcher_NilDependenciesProduceErrorSnapshots(t *testing.T) { + // fetcher 本体为 nil:直接降级为错误快照,不 panic。 + var nilFetcher *ChannelMonitorQuotaFetcher + snapshot := nilFetcher.Fetch(context.Background(), 1) + require.False(t, snapshot.Success) + require.Equal(t, "quota fetcher is not configured", snapshot.Error) + + // 数据源缺失:账号能加载,但对应服务未注入。 + fetcher, _, _, _, accounts := newQuotaFetcherTestSetup(t) + fetcher.usage = nil + accounts.accounts[2] = &Account{ID: 2, Platform: domain.PlatformOpenAI} + snapshot = fetcher.Fetch(context.Background(), 2) + require.False(t, snapshot.Success) + require.Contains(t, snapshot.Error, "not configured") +} + +// --- TTL 缓存 --- + +func TestQuotaFetcher_CachesSuccessSnapshotPerAccount(t *testing.T) { + fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[8] = &Account{ID: 8, Platform: domain.PlatformOpenAI} + usage.usage = &UsageInfo{FiveHour: &UsageProgress{Utilization: 10}} + + for i := 0; i < 3; i++ { + snapshot := fetcher.Fetch(context.Background(), 8) + require.True(t, snapshot.Success) + } + require.Equal(t, 1, usage.getCalls(), "success snapshots should be served from cache") + + // 缓存过期后重新拉取。 + fetcher.mu.Lock() + entry := fetcher.cache[8] + entry.expiry = time.Now().Add(-time.Second) + fetcher.cache[8] = entry + fetcher.mu.Unlock() + + _ = fetcher.Fetch(context.Background(), 8) + require.Equal(t, 2, usage.getCalls()) +} + +func TestQuotaFetcher_CachesFailureSnapshotWithShortTTL(t *testing.T) { + fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[4] = &Account{ID: 4, Platform: domain.PlatformOpenAI} + usage.err = errors.New("boom") + + for i := 0; i < 2; i++ { + snapshot := fetcher.Fetch(context.Background(), 4) + require.False(t, snapshot.Success) + } + require.Equal(t, 1, usage.getCalls(), "failure snapshots should be served from the short negative cache") + + // 失败快照的 TTL 是负缓存时长(而非成功 TTL)。 + fetcher.mu.Lock() + entry := fetcher.cache[4] + require.WithinDuration(t, entry.snapshot.FetchedAt.Add(monitorQuotaErrorCacheTTL), entry.expiry, time.Second) + entry.expiry = time.Now().Add(-time.Second) + fetcher.cache[4] = entry + fetcher.mu.Unlock() + + _ = fetcher.Fetch(context.Background(), 4) + require.Equal(t, 2, usage.getCalls(), "expired negative cache should refetch") +} + +func TestQuotaFetcher_ConcurrentFetchesShareSingleFlight(t *testing.T) { + fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[12] = &Account{ID: 12, Platform: domain.PlatformOpenAI} + usage.usage = &UsageInfo{FiveHour: &UsageProgress{Utilization: 10}} + usage.block = make(chan struct{}) + + var wg sync.WaitGroup + snapshots := make([]*domain.MonitorQuotaSnapshot, 5) + for i := 0; i < 5; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + snapshots[idx] = fetcher.Fetch(context.Background(), 12) + }(i) + } + + // 上游被 block 卡住时,5 个并发 Fetch 应只产生 1 次真实查询。 + require.Eventually(t, func() bool { return usage.getCalls() == 1 }, + 5*time.Second, 10*time.Millisecond) + close(usage.block) + wg.Wait() + + for _, snapshot := range snapshots { + require.NotNil(t, snapshot) + require.True(t, snapshot.Success) + } + require.Equal(t, 1, usage.getCalls()) + + // 成功快照已缓存:再取一次仍不打上游。 + _ = fetcher.Fetch(context.Background(), 12) + require.Equal(t, 1, usage.getCalls()) +} + +// --- UsageInfo → tiers 归一 --- + +func TestUsageQuotaTiers_MapsAllWindowKinds(t *testing.T) { + limit := int64(1000) + remaining := int64(400) + resetUnix := int64(1777283883) + usage := &UsageInfo{ + FiveHour: &UsageProgress{Utilization: 50}, + SevenDay: &UsageProgress{Utilization: 60}, + SevenDaySonnet: &UsageProgress{Utilization: 70}, + SevenDayFable: &UsageProgress{Utilization: 80}, + ThirtyDay: &UsageProgress{Utilization: 20}, + GeminiSharedDaily: &UsageProgress{Utilization: 11}, + GeminiProDaily: &UsageProgress{Utilization: 22}, + GeminiFlashDaily: &UsageProgress{Utilization: 33}, + GrokRequestQuota: &xai.QuotaWindow{Limit: &limit, Remaining: &remaining, ResetUnix: &resetUnix}, + GrokTokenQuota: &xai.QuotaWindow{Limit: &limit, Remaining: &remaining, ResetAt: "2026-08-19T00:00:00Z"}, + AntigravityQuota: map[string]*AntigravityModelQuota{ + "gemini-3-pro": {Utilization: 45}, + "gemini-3-flash": {Utilization: 55}, + }, + } + + tiers := usageQuotaTiers(usage) + + // 5h/7d/7d-sonnet/7d-fable/30d + gemini×3 + grok×2 + antigravity×2 + require.Len(t, tiers, 12) + + byKey := make(map[string]domain.MonitorQuotaTier, len(tiers)) + for _, tier := range tiers { + key := tier.Window + if tier.Label != "" { + key = tier.Window + "/" + tier.Label + } + byKey[key] = tier + } + + require.Contains(t, byKey, "5h") + require.Contains(t, byKey, "7d") + require.Contains(t, byKey, "7d-sonnet") + require.Contains(t, byKey, "7d-fable") + require.Contains(t, byKey, "30d") + require.Contains(t, byKey, "daily/shared") + require.Contains(t, byKey, "daily/pro") + require.Contains(t, byKey, "daily/flash") + require.Contains(t, byKey, "daily/requests") + require.Contains(t, byKey, "daily/tokens") + require.Contains(t, byKey, "total/gemini-3-pro") + require.Contains(t, byKey, "total/gemini-3-flash") + + // grok requests 窗口:used = limit - remaining,百分比 60%。 + requests := byKey["daily/requests"] + require.Equal(t, float64(600), requests.Used) + require.Equal(t, float64(1000), requests.Limit) + require.InDelta(t, 60.0, requests.UsedPercent, 0.001) + require.NotEmpty(t, requests.ResetAt, "ResetUnix should fall back to RFC3339") + + tokens := byKey["daily/tokens"] + require.Equal(t, "2026-08-19T00:00:00Z", tokens.ResetAt) +} + +func TestUsageQuotaTiers_NilAndEmptyInputs(t *testing.T) { + require.Nil(t, usageQuotaTiers(nil)) + require.Nil(t, usageQuotaTiers(&UsageInfo{})) + + // Grok 窗口 limit<=0 时跳过,避免除零。 + var zero int64 + tiers := usageQuotaTiers(&UsageInfo{ + GrokRequestQuota: &xai.QuotaWindow{Limit: &zero, Remaining: &zero}, + }) + require.Nil(t, tiers) +} + +// --- 状态推导 --- + +func TestDeriveQuotaCheckResult_StatusMatrix(t *testing.T) { + now := time.Now() + + healthy := &domain.MonitorQuotaSnapshot{Success: true, Tiers: []domain.MonitorQuotaTier{{Window: "5h", UsedPercent: 40}}} + res := deriveQuotaCheckResult(healthy, "quota", now) + require.Equal(t, MonitorStatusOperational, res.Status) + require.Equal(t, "quota", res.Model) + require.Empty(t, res.Message) + + highUsage := &domain.MonitorQuotaSnapshot{Success: true, Tiers: []domain.MonitorQuotaTier{ + {Window: "5h", UsedPercent: 30}, + {Window: "daily", Label: "pro", UsedPercent: 95}, + }} + res = deriveQuotaCheckResult(highUsage, "quota", now) + require.Equal(t, MonitorStatusDegraded, res.Status) + require.Contains(t, res.Message, "pro/daily") + require.Contains(t, res.Message, "95.0%") + + balance := -0.5 + depleted := &domain.MonitorQuotaSnapshot{Success: true, Balance: &balance, Currency: "CNY"} + res = deriveQuotaCheckResult(depleted, "quota", now) + require.Equal(t, MonitorStatusDegraded, res.Status) + require.Contains(t, res.Message, "balance depleted") + + invalid := &domain.MonitorQuotaSnapshot{Success: false, CredentialInvalid: true, Error: "401 unauthorized"} + res = deriveQuotaCheckResult(invalid, "quota", now) + require.Equal(t, MonitorStatusFailed, res.Status) + + unlinked := &domain.MonitorQuotaSnapshot{Success: false, Error: "linked account not found"} + res = deriveQuotaCheckResult(unlinked, "quota", now) + require.Equal(t, MonitorStatusDegraded, res.Status) + + other := &domain.MonitorQuotaSnapshot{Success: false, Error: "connection refused"} + res = deriveQuotaCheckResult(other, "quota", now) + require.Equal(t, MonitorStatusError, res.Status) + require.Equal(t, "connection refused", res.Message) + + res = deriveQuotaCheckResult(nil, "quota", now) + require.Equal(t, MonitorStatusError, res.Status) +} diff --git a/backend/internal/service/channel_monitor_quota_mode_test.go b/backend/internal/service/channel_monitor_quota_mode_test.go new file mode 100644 index 000000000000..0e4b3ea7da98 --- /dev/null +++ b/backend/internal/service/channel_monitor_quota_mode_test.go @@ -0,0 +1,463 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/stretchr/testify/require" +) + +// --- repo / fetcher 装配 --- + +// quotaModeRepoStub 记录 RunCheck 落库行为(历史行 + MarkChecked)。 +type quotaModeRepoStub struct { + ChannelMonitorRepository + monitor *ChannelMonitor + history []*ChannelMonitorHistoryRow + markedIDs []int64 + updated []*ChannelMonitor +} + +func (r *quotaModeRepoStub) GetByID(_ context.Context, id int64) (*ChannelMonitor, error) { + if r.monitor == nil || r.monitor.ID != id { + return nil, ErrChannelMonitorNotFound + } + clone := *r.monitor + return &clone, nil +} + +func (r *quotaModeRepoStub) InsertHistoryBatch(_ context.Context, rows []*ChannelMonitorHistoryRow) error { + r.history = append(r.history, rows...) + return nil +} + +func (r *quotaModeRepoStub) MarkChecked(_ context.Context, id int64, _ time.Time) error { + r.markedIDs = append(r.markedIDs, id) + return nil +} + +func (r *quotaModeRepoStub) Update(_ context.Context, m *ChannelMonitor) error { + clone := *m + r.updated = append(r.updated, &clone) + return nil +} + +// newQuotaModeService 构造启用 V1 探活的 service(复用 retirement/duplicate 测试的 stub)。 +func newQuotaModeService(repo *quotaModeRepoStub) *ChannelMonitorService { + svc := NewChannelMonitorService(repo, &duplicateChannelMonitorEncryptor{}) + svc.SetRuntimeReader(channelMonitorRuntimeStub{rt: ChannelMonitorRuntime{ + Enabled: true, + Mode: ChannelMonitorModeV1, + }}) + return svc +} + +func newQuotaModeFetcher(accounts map[int64]*Account, usage *stubMonitorUsageSource) *ChannelMonitorQuotaFetcher { + if accounts == nil { + accounts = make(map[int64]*Account) + } + if usage == nil { + usage = &stubMonitorUsageSource{} + } + return &ChannelMonitorQuotaFetcher{ + usage: usage, + accounts: &stubMonitorAccountSource{accounts: accounts}, + cache: make(map[int64]monitorQuotaCacheEntry), + } +} + +// --- RunCheck 分派 --- + +func TestRunCheck_QuotaModeProducesSingleQuotaResult(t *testing.T) { + repo := "aModeRepoStub{monitor: &ChannelMonitor{ + ID: 1, + Name: "kimi-quota", + Provider: MonitorProviderKimi, + APIMode: MonitorAPIModeChatCompletions, + PrimaryModel: "quota", + Enabled: true, + IntervalSeconds: 60, + CheckMode: MonitorCheckModeQuota, + AccountID: int64Ptr(9), + }} + svc := newQuotaModeService(repo) + fetcher := newQuotaModeFetcher(map[int64]*Account{ + 9: {ID: 9, Platform: domain.PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}}, + }, nil) + fetcher.cnQuota = &stubMonitorCNQuotaSource{result: &CNProviderQuotaProbeResult{ + Success: true, + CredentialValid: true, + Tiers: []CNQuotaTier{{Window: "5h", UsedPercent: 30}}, + }} + svc.SetQuotaFetcher(fetcher) + + results, err := svc.RunCheck(context.Background(), 1) + require.NoError(t, err) + require.Len(t, results, 1) + + res := results[0] + require.Equal(t, "quota", res.Model) + require.Equal(t, MonitorStatusOperational, res.Status) + require.Nil(t, res.LatencyMs) + require.Nil(t, res.PingLatencyMs) + require.NotNil(t, res.Quota) + require.True(t, res.Quota.Success) + require.Equal(t, "cn_quota", res.Quota.Source) + + // 历史行携带配额快照,并推进 last_checked_at。 + require.Len(t, repo.history, 1) + require.Equal(t, "quota", repo.history[0].Model) + require.NotNil(t, repo.history[0].Quota) + require.Equal(t, []int64{1}, repo.markedIDs) +} + +func TestRunCheck_QuotaModeUnlinkedAccountDegrades(t *testing.T) { + repo := "aModeRepoStub{monitor: &ChannelMonitor{ + ID: 2, + Provider: MonitorProviderDeepseek, + APIMode: MonitorAPIModeChatCompletions, + Endpoint: "", + PrimaryModel: "quota", + Enabled: true, + IntervalSeconds: 60, + CheckMode: MonitorCheckModeQuota, + AccountID: nil, // FK ON DELETE SET NULL 后的形态 + }} + svc := newQuotaModeService(repo) + svc.SetQuotaFetcher(newQuotaModeFetcher(nil, nil)) + + results, err := svc.RunCheck(context.Background(), 2) + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, MonitorStatusDegraded, results[0].Status) + require.Contains(t, results[0].Message, "linked account not found") + require.False(t, results[0].Quota.Success) +} + +func TestRunCheck_QuotaModeNilFetcherFailsClosed(t *testing.T) { + repo := "aModeRepoStub{monitor: &ChannelMonitor{ + ID: 3, + Provider: MonitorProviderZhipu, + APIMode: MonitorAPIModeChatCompletions, + PrimaryModel: "quota", + Enabled: true, + IntervalSeconds: 60, + CheckMode: MonitorCheckModeQuota, + AccountID: int64Ptr(5), + }} + svc := newQuotaModeService(repo) // 不注入 fetcher + + results, err := svc.RunCheck(context.Background(), 3) + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, MonitorStatusError, results[0].Status) + require.Contains(t, results[0].Message, "not configured") +} + +func TestRunCheck_QuotaProbeAttachesSnapshotToPrimaryRowOnly(t *testing.T) { + h := &openAICaptureHandler{} + endpoint := setupFakeOpenAI(t, h) + repo := "aModeRepoStub{monitor: &ChannelMonitor{ + ID: 4, + Provider: MonitorProviderOpenAI, + APIMode: MonitorAPIModeChatCompletions, + Endpoint: endpoint, + APIKey: "OLD:sk-openai", + PrimaryModel: "gpt-test", + ExtraModels: []string{"gpt-extra"}, + Enabled: true, + IntervalSeconds: 60, + CheckMode: MonitorCheckModeQuotaProbe, + AccountID: int64Ptr(12), + }} + svc := newQuotaModeService(repo) + usage := &stubMonitorUsageSource{usage: &UsageInfo{ + FiveHour: &UsageProgress{Utilization: 20}, + }} + svc.SetQuotaFetcher(newQuotaModeFetcher(map[int64]*Account{ + 12: {ID: 12, Platform: domain.PlatformOpenAI}, + }, usage)) + + results, err := svc.RunCheck(context.Background(), 4) + require.NoError(t, err) + require.Len(t, results, 2) + + // 探活状态为准,配额只挂主模型行。 + require.Equal(t, MonitorStatusOperational, results[0].Status) + require.NotNil(t, results[0].Quota) + require.True(t, results[0].Quota.Success) + require.Equal(t, "usage", results[0].Quota.Source) + require.Nil(t, results[1].Quota, "extra model rows must not carry quota") + + // 历史落库时同样只有主模型行带快照。 + require.Len(t, repo.history, 2) + require.NotNil(t, repo.history[0].Quota) + require.Equal(t, "gpt-test", repo.history[0].Model) + require.Nil(t, repo.history[1].Quota) +} + +func TestRunCheck_QuotaProbeQuotaFailureKeepsProbeStatus(t *testing.T) { + h := &openAICaptureHandler{} + endpoint := setupFakeOpenAI(t, h) + repo := "aModeRepoStub{monitor: &ChannelMonitor{ + ID: 5, + Provider: MonitorProviderOpenAI, + APIMode: MonitorAPIModeChatCompletions, + Endpoint: endpoint, + APIKey: "OLD:sk-openai", + PrimaryModel: "gpt-test", + Enabled: true, + IntervalSeconds: 60, + CheckMode: MonitorCheckModeQuotaProbe, + AccountID: nil, // 配额侧失效 + }} + svc := newQuotaModeService(repo) + svc.SetQuotaFetcher(newQuotaModeFetcher(nil, nil)) + + results, err := svc.RunCheck(context.Background(), 5) + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, MonitorStatusOperational, results[0].Status, "quota failure must not flip probe status") + require.False(t, results[0].Quota.Success) +} + +// --- attachQuotaSnapshot 细节 --- + +func TestAttachQuotaSnapshot_NoteOnlyWhenProbeMessageEmpty(t *testing.T) { + results := []*CheckResult{ + {Model: "primary", Status: MonitorStatusOperational, Message: "challenge passed"}, + {Model: "extra"}, + } + failed := &domain.MonitorQuotaSnapshot{Success: false, Error: "boom"} + + attachQuotaSnapshot(results, failed) + + require.Equal(t, "challenge passed", results[0].Message, "existing probe message wins") + require.Equal(t, failed, results[0].Quota) + require.Nil(t, results[1].Quota) + + quiet := []*CheckResult{{Model: "primary", Status: MonitorStatusOperational}} + attachQuotaSnapshot(quiet, failed) + require.Contains(t, quiet[0].Message, "quota fetch failed: boom") + + attachQuotaSnapshot(nil, failed) // 空结果不 panic + attachQuotaSnapshot(results, nil) // 空快照不动结果 +} + +// --- 校验矩阵 --- + +func TestValidateCreateParams_CheckModeMatrix(t *testing.T) { + accountID := int64(9) + + cases := []struct { + name string + params ChannelMonitorCreateParams + wantErr error + }{ + { + name: "probe requires endpoint", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderOpenAI, CheckMode: MonitorCheckModeProbe, + APIKey: "sk", IntervalSeconds: 60, PrimaryModel: "gpt-5", + }, + wantErr: ErrChannelMonitorInvalidEndpoint, + }, + { + name: "probe requires api key", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderOpenAI, CheckMode: MonitorCheckModeProbe, + Endpoint: "https://api.openai.com", IntervalSeconds: 60, PrimaryModel: "gpt-5", + }, + wantErr: ErrChannelMonitorMissingAPIKey, + }, + { + name: "quota drops endpoint and api key requirements", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderAntigravity, CheckMode: MonitorCheckModeQuota, + IntervalSeconds: 60, AccountID: &accountID, + }, + wantErr: nil, // primary_model 默认 "quota" + }, + { + name: "quota requires account", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota, + IntervalSeconds: 60, PrimaryModel: "quota", + }, + wantErr: ErrChannelMonitorAccountRequired, + }, + { + name: "quota_probe requires endpoint and api key too", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuotaProbe, + IntervalSeconds: 60, AccountID: &accountID, PrimaryModel: "kimi-k2", + }, + wantErr: ErrChannelMonitorInvalidEndpoint, + }, + { + name: "antigravity probe unsupported", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderAntigravity, CheckMode: MonitorCheckModeProbe, + Endpoint: "https://example.com", APIKey: "k", + IntervalSeconds: 60, AccountID: &accountID, PrimaryModel: "gemini-3-pro", + }, + wantErr: ErrChannelMonitorInvalidCheckMode, + }, + { + name: "antigravity quota_probe unsupported", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderAntigravity, CheckMode: MonitorCheckModeQuotaProbe, + Endpoint: "https://example.com", APIKey: "k", + IntervalSeconds: 60, AccountID: &accountID, PrimaryModel: "gemini-3-pro", + }, + wantErr: ErrChannelMonitorInvalidCheckMode, + }, + { + name: "unknown mode rejected", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderOpenAI, CheckMode: "auto", + Endpoint: "https://api.openai.com", APIKey: "sk", + IntervalSeconds: 60, PrimaryModel: "gpt-5", + }, + wantErr: ErrChannelMonitorInvalidCheckMode, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateCreateParams(tc.params) + if tc.wantErr == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, tc.wantErr) + } + }) + } +} + +func TestNormalizeMonitorPrimaryModel_QuotaDefault(t *testing.T) { + require.Equal(t, "quota", normalizeMonitorPrimaryModel(MonitorProviderKimi, MonitorCheckModeQuota, "")) + require.Equal(t, "quota", normalizeMonitorPrimaryModel(MonitorProviderAntigravity, MonitorCheckModeQuota, " ")) + // 探活模式沿用原语义:grok 默认模型,其余必填(空串报错在 validateCreateParams)。 + require.Equal(t, MonitorDefaultGrokModel, normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeProbe, "")) + require.Equal(t, "kimi-k2", normalizeMonitorPrimaryModel(MonitorProviderKimi, MonitorCheckModeQuotaProbe, "kimi-k2")) +} + +func TestProviderProbeCapabilityMatrix(t *testing.T) { + require.False(t, providerSupportsProbe(MonitorProviderAntigravity)) + for _, p := range []string{ + MonitorProviderOpenAI, MonitorProviderAnthropic, MonitorProviderGemini, + MonitorProviderGrok, MonitorProviderKimi, MonitorProviderZhipu, MonitorProviderDeepseek, + } { + require.True(t, providerSupportsProbe(p), p) + } + for _, p := range []string{ + MonitorProviderOpenAI, MonitorProviderAnthropic, MonitorProviderGemini, + MonitorProviderGrok, MonitorProviderAntigravity, + MonitorProviderKimi, MonitorProviderZhipu, MonitorProviderDeepseek, + } { + require.NoError(t, validateProvider(p), p) + } +} + +// --- 关联账号校验 --- + +func TestValidateLinkedAccount_Matrix(t *testing.T) { + svc := NewChannelMonitorService(nil, nil) + fetcher := newQuotaModeFetcher(map[int64]*Account{ + 1: {ID: 1, Platform: domain.PlatformKimi}, + }, nil) + svc.SetQuotaFetcher(fetcher) + + require.NoError(t, svc.validateLinkedAccount(context.Background(), MonitorProviderKimi, nil)) + require.NoError(t, svc.validateLinkedAccount(context.Background(), MonitorProviderKimi, int64Ptr(0))) + require.NoError(t, svc.validateLinkedAccount(context.Background(), MonitorProviderKimi, int64Ptr(1))) + require.ErrorIs(t, svc.validateLinkedAccount(context.Background(), MonitorProviderZhipu, int64Ptr(1)), ErrChannelMonitorProviderIncompatible) + require.ErrorIs(t, svc.validateLinkedAccount(context.Background(), MonitorProviderKimi, int64Ptr(404)), ErrChannelMonitorAccountRequired) + + noFetcher := NewChannelMonitorService(nil, nil) + require.ErrorIs(t, noFetcher.validateLinkedAccount(context.Background(), MonitorProviderKimi, int64Ptr(1)), ErrChannelMonitorAccountRequired) +} + +func TestRevalidateLinkedAccount_QuotaErrorsProbeUnbinds(t *testing.T) { + fetcher := newQuotaModeFetcher(nil, nil) // 账号一律加载失败 + svc := NewChannelMonitorService(nil, nil) + svc.SetQuotaFetcher(fetcher) + + quota := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota, AccountID: int64Ptr(9)} + require.ErrorIs(t, svc.revalidateLinkedAccount(context.Background(), quota), ErrChannelMonitorAccountRequired) + require.NotNil(t, quota.AccountID) + + probe := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeProbe, AccountID: int64Ptr(9)} + require.NoError(t, svc.revalidateLinkedAccount(context.Background(), probe)) + require.Nil(t, probe.AccountID, "probe mode should silently unbind stale account") + + quotaNoAccount := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota} + require.ErrorIs(t, svc.revalidateLinkedAccount(context.Background(), quotaNoAccount), ErrChannelMonitorAccountRequired) +} + +func TestRevalidateLinkedAccount_PlatformMismatch(t *testing.T) { + svc := NewChannelMonitorService(nil, nil) + svc.SetQuotaFetcher(newQuotaModeFetcher(map[int64]*Account{ + 2: {ID: 2, Platform: domain.PlatformDeepseek}, + }, nil)) + + quota := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota, AccountID: int64Ptr(2)} + require.ErrorIs(t, svc.revalidateLinkedAccount(context.Background(), quota), ErrChannelMonitorProviderIncompatible) + + probe := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeProbe, AccountID: int64Ptr(2)} + require.NoError(t, svc.revalidateLinkedAccount(context.Background(), probe)) + require.Nil(t, probe.AccountID) +} + +// --- quota → probe 切换的 key 管控(validateProbeAPIKey) --- + +func TestValidateProbeAPIKey_QuotaToProbeRequiresFreshKey(t *testing.T) { + svc := NewChannelMonitorService(nil, &duplicateChannelMonitorEncryptor{}) + + quota := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota, APIKey: "NEW:"} + require.NoError(t, svc.validateProbeAPIKey(quota, "")) // quota 模式不管 key + + quota.CheckMode = MonitorCheckModeProbe + // 存量密文解出空明文(quota 监控存的加密空串)→ 必须重填 key。 + require.ErrorIs(t, svc.validateProbeAPIKey(quota, ""), ErrChannelMonitorMissingAPIKey) + // 提供新明文 key → 放行。 + require.NoError(t, svc.validateProbeAPIKey(quota, "sk-fresh")) + // 密文解出非空明文 → 放行。 + require.NoError(t, svc.validateProbeAPIKey( + &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeProbe, APIKey: "OLD:sk-live"}, "")) +} + +// --- Duplicate:quota 模式空明文重加密 --- + +func TestDuplicateChannelMonitorQuotaModeReencryptsEmptyKey(t *testing.T) { + accountID := int64(9) + source := &ChannelMonitor{ + ID: 42, + Name: "kimi-quota", + Provider: MonitorProviderKimi, + APIMode: MonitorAPIModeChatCompletions, + Endpoint: "", + APIKey: "OLD:", // 解密为空串(quota 监控的加密空 key) + PrimaryModel: "quota", + Enabled: true, + IntervalSeconds: 60, + CheckMode: MonitorCheckModeQuota, + AccountID: &accountID, + } + repo := &duplicateChannelMonitorRepoStub{source: source} + service := NewChannelMonitorService(repo, &duplicateChannelMonitorEncryptor{}) + + dup, err := service.Duplicate(context.Background(), 42, 7, "admin:7", "op-1") + require.NoError(t, err) + require.Equal(t, MonitorCheckModeQuota, dup.CheckMode) + require.NotNil(t, dup.AccountID) + require.Equal(t, accountID, *dup.AccountID) + require.Empty(t, dup.APIKey, "plaintext stays empty for quota monitors") + require.Len(t, repo.created, 1) + require.Equal(t, "NEW:", repo.created[0].APIKey, "empty key must be re-encrypted, not dropped") +} diff --git a/backend/internal/service/channel_monitor_service.go b/backend/internal/service/channel_monitor_service.go index 248b72133dfc..9b0bb0763980 100644 --- a/backend/internal/service/channel_monitor_service.go +++ b/backend/internal/service/channel_monitor_service.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "github.com/Wei-Shaw/sub2api/internal/domain" "golang.org/x/sync/errgroup" ) @@ -77,6 +78,10 @@ type ChannelMonitorService struct { // scheduler 由 wire 通过 SetScheduler 注入;CRUD 后调用对应钩子即时同步任务。 // 测试或未注入场景下保持 nil,所有钩子调用变为 no-op。 scheduler MonitorScheduler + // quotaFetcher 由 wire 通过 SetQuotaFetcher 注入(accountUsage/CN 服务在本服务 + // 之后构造,构造参数注入会破坏既有依赖顺序)。nil 时 fail-closed: + // 配额模式的检测产出「未配置」错误快照,Create/Update 关联账号直接报错。 + quotaFetcher *ChannelMonitorQuotaFetcher } const maxChannelMonitorNameRunes = 100 @@ -150,6 +155,10 @@ func (s *ChannelMonitorService) Create(ctx context.Context, p ChannelMonitorCrea if err := validateExtraHeaders(p.ExtraHeaders); err != nil { return nil, err } + if err := s.validateLinkedAccount(ctx, p.Provider, p.AccountID); err != nil { + return nil, err + } + checkMode := defaultCheckMode(p.CheckMode) encrypted, err := s.encryptor.Encrypt(p.APIKey) if err != nil { return nil, fmt.Errorf("encrypt api key: %w", err) @@ -160,7 +169,7 @@ func (s *ChannelMonitorService) Create(ctx context.Context, p ChannelMonitorCrea APIMode: defaultAPIMode(p.APIMode), Endpoint: normalizeEndpoint(p.Endpoint), APIKey: encrypted, // 注意:传入 repository 时该字段为密文 - PrimaryModel: normalizeMonitorPrimaryModel(p.Provider, p.PrimaryModel), + PrimaryModel: normalizeMonitorPrimaryModel(p.Provider, checkMode, p.PrimaryModel), ExtraModels: normalizeModels(p.ExtraModels), GroupName: strings.TrimSpace(p.GroupName), Enabled: p.Enabled, @@ -171,6 +180,8 @@ func (s *ChannelMonitorService) Create(ctx context.Context, p ChannelMonitorCrea ExtraHeaders: emptyHeadersIfNil(p.ExtraHeaders), BodyOverrideMode: defaultBodyMode(p.BodyOverrideMode), BodyOverride: p.BodyOverride, + CheckMode: checkMode, + AccountID: cloneInt64Pointer(p.AccountID), } if err := s.repo.Create(ctx, m); err != nil { return nil, fmt.Errorf("create channel monitor: %w", err) @@ -236,6 +247,8 @@ func (s *ChannelMonitorService) Duplicate( ExtraHeaders: cloneChannelMonitorHeaders(source.ExtraHeaders), BodyOverrideMode: source.BodyOverrideMode, BodyOverride: bodyOverride, + CheckMode: defaultCheckMode(source.CheckMode), + AccountID: cloneInt64Pointer(source.AccountID), DuplicateOperationID: operationID, } if err := s.repo.Create(ctx, duplicate); err != nil { @@ -290,11 +303,22 @@ func (s *ChannelMonitorService) decryptAPIKeyForDuplicate(source *ChannelMonitor return "", ErrChannelMonitorAPIKeyDecryptFailed } plain, err := s.encryptor.Decrypt(source.APIKey) - if err != nil || strings.TrimSpace(plain) == "" { + if err != nil { slog.Warn("channel_monitor: decrypt api key for duplicate failed", "monitor_id", source.ID, "error", err) return "", ErrChannelMonitorAPIKeyDecryptFailed } + // quota 模式明文为空串是合法状态(api_key_encrypted 存的是加密空串): + // 重加密空串即可。若在此报错,克隆出的配额监控会被 runner 当作 + // 解密失败而 Unschedule,静默停摆。 + if strings.TrimSpace(plain) == "" { + if monitorCheckModeUsesQuota(defaultCheckMode(source.CheckMode)) { + return "", nil + } + slog.Warn("channel_monitor: decrypted api key for duplicate is empty", + "monitor_id", source.ID) + return "", ErrChannelMonitorAPIKeyDecryptFailed + } return plain, nil } @@ -343,10 +367,16 @@ func cloneChannelMonitorJSONMap(source map[string]any) (map[string]any, error) { } // validateCreateParams 把 Create 入参的所有校验聚拢为一个函数,避免 Create 主体超过 30 行。 +// 按 check_mode 分支:probe 沿用 endpoint+api_key 必填;quota 只需关联账号; +// quota_probe 两者皆需。 func validateCreateParams(p ChannelMonitorCreateParams) error { if err := validateProvider(p.Provider); err != nil { return err } + checkMode := defaultCheckMode(p.CheckMode) + if err := validateCheckMode(p.Provider, checkMode); err != nil { + return err + } if err := validateAPIMode(p.Provider, p.APIMode); err != nil { return err } @@ -356,18 +386,45 @@ func validateCreateParams(p ChannelMonitorCreateParams) error { if err := validateJitter(p.JitterSeconds, p.IntervalSeconds); err != nil { return err } - if err := validateEndpoint(p.Endpoint); err != nil { - return err + usesQuota := monitorCheckModeUsesQuota(checkMode) + // probe 分支(含 quota_probe 的探活部分)仍需 endpoint + api_key; + // quota 模式 endpoint/api_key 留空,避免要求用户填无意义的占位值。 + if checkMode != MonitorCheckModeQuota { + if err := validateEndpoint(p.Endpoint); err != nil { + return err + } + if strings.TrimSpace(p.APIKey) == "" { + return ErrChannelMonitorMissingAPIKey + } } - if strings.TrimSpace(p.APIKey) == "" { - return ErrChannelMonitorMissingAPIKey + if usesQuota && (p.AccountID == nil || *p.AccountID <= 0) { + return ErrChannelMonitorAccountRequired } - if normalizeMonitorPrimaryModel(p.Provider, p.PrimaryModel) == "" { + if normalizeMonitorPrimaryModel(p.Provider, checkMode, p.PrimaryModel) == "" { return ErrChannelMonitorMissingPrimaryModel } return nil } +// validateLinkedAccount 校验关联账号存在且平台与监控 provider 一致。 +// fetcher 未注入时 fail-closed(拒绝创建配额监控,而不是创建后静默坏)。 +func (s *ChannelMonitorService) validateLinkedAccount(ctx context.Context, provider string, accountID *int64) error { + if accountID == nil || *accountID <= 0 { + return nil + } + if s.quotaFetcher == nil { + return ErrChannelMonitorAccountRequired + } + account, err := s.quotaFetcher.LoadAccount(ctx, *accountID) + if err != nil || account == nil { + return ErrChannelMonitorAccountRequired + } + if account.Platform != provider { + return ErrChannelMonitorProviderIncompatible + } + return nil +} + // Update 更新监控。APIKey 字段:nil 或空字符串 = 不修改;非空 = 加密后覆盖。 func (s *ChannelMonitorService) Update(ctx context.Context, id int64, p ChannelMonitorUpdateParams) (*ChannelMonitor, error) { existing, err := s.repo.GetByID(ctx, id) @@ -382,6 +439,14 @@ func (s *ChannelMonitorService) Update(ctx context.Context, id int64, p ChannelM if err != nil { return nil, err } + if err := s.validateProbeAPIKey(existing, newPlainAPIKey); err != nil { + return nil, err + } + if p.Provider != nil || p.CheckMode != nil || p.AccountID != nil { + if err := s.revalidateLinkedAccount(ctx, existing); err != nil { + return nil, err + } + } if err := s.repo.Update(ctx, existing); err != nil { return nil, fmt.Errorf("update channel monitor: %w", err) @@ -401,6 +466,76 @@ func (s *ChannelMonitorService) Update(ctx context.Context, id int64, p ChannelM return existing, nil } +// validateMonitorModeFields 校验 check_mode 与其它字段的组合约束 +// (在 provider/check_mode/account_id/endpoint 全部应用后调用): +// - quota / quota_probe 必须关联账号 +// - probe / quota_probe 必须持有 endpoint(探活目标) +func validateMonitorModeFields(m *ChannelMonitor) error { + checkMode := defaultCheckMode(m.CheckMode) + if monitorCheckModeUsesQuota(checkMode) && m.AccountID == nil { + return ErrChannelMonitorAccountRequired + } + if checkMode != MonitorCheckModeQuota && strings.TrimSpace(m.Endpoint) == "" { + return ErrChannelMonitorInvalidEndpoint + } + return nil +} + +// validateProbeAPIKey 探活模式(probe / quota_probe)必须持有可用明文 key: +// 存量密文解密为空串(quota 监控切回探活但未重填 key)时拒绝。 +// 密文损坏的情况交给既有 APIKeyDecryptFailed 链路(Get/RunCheck 会显式报错)。 +func (s *ChannelMonitorService) validateProbeAPIKey(m *ChannelMonitor, newPlainKey string) error { + if defaultCheckMode(m.CheckMode) == MonitorCheckModeQuota { + return nil + } + if strings.TrimSpace(newPlainKey) != "" { + return nil + } + if strings.TrimSpace(m.APIKey) == "" { + return ErrChannelMonitorMissingAPIKey + } + plain, err := s.encryptor.Decrypt(m.APIKey) + if err != nil { + return nil + } + if strings.TrimSpace(plain) == "" { + return ErrChannelMonitorMissingAPIKey + } + return nil +} + +// revalidateLinkedAccount 在 provider/check_mode/account_id 任一变化后复核关联账号: +// - 账号已被删除或平台失配:probe 模式自动解绑(静默修复), +// quota 模式显式报错(配额监控必须有可用数据源) +func (s *ChannelMonitorService) revalidateLinkedAccount(ctx context.Context, m *ChannelMonitor) error { + usesQuota := monitorCheckModeUsesQuota(defaultCheckMode(m.CheckMode)) + if m.AccountID == nil { + if usesQuota { + return ErrChannelMonitorAccountRequired + } + return nil + } + if s.quotaFetcher == nil { + return ErrChannelMonitorAccountRequired + } + account, err := s.quotaFetcher.LoadAccount(ctx, *m.AccountID) + if err != nil || account == nil { + if usesQuota { + return ErrChannelMonitorAccountRequired + } + m.AccountID = nil + return nil + } + if account.Platform != m.Provider { + if usesQuota { + return ErrChannelMonitorProviderIncompatible + } + m.AccountID = nil + return nil + } + return nil +} + // applyAPIKeyUpdate 处理 Update 中的 APIKey 字段: // - 入参 raw 为 nil 或空白:不修改 existing.APIKey(仍为密文),返回 updated=false // - 非空:加密后写入 existing.APIKey;同时把明文返回给调用方, @@ -454,6 +589,9 @@ func (s *ChannelMonitorService) ListHistory(ctx context.Context, id int64, model // 写历史记录并更新 last_checked_at。返回每个模型的检测结果。 // 仅当 channel_monitor_enabled=true 且 channel_monitor_mode=v1 时真正探测; // mode=v2 时返回 ErrChannelMonitorActiveProbesRetired,不产生上游流量。 +// +// 按 check_mode 分派:probe(默认,现状探活)/ quota(仅查关联账号配额, +// 零 LLM 成本)/ quota_probe(探活 + 配额快照挂主模型行)。 func (s *ChannelMonitorService) RunCheck(ctx context.Context, id int64) ([]*CheckResult, error) { rt := s.probeRuntime(ctx) if !rt.Enabled { @@ -466,14 +604,59 @@ func (s *ChannelMonitorService) RunCheck(ctx context.Context, id int64) ([]*Chec if err != nil { return nil, err } - if m.APIKeyDecryptFailed { + checkMode := defaultCheckMode(m.CheckMode) + if checkMode != MonitorCheckModeQuota && m.APIKeyDecryptFailed { return nil, ErrChannelMonitorAPIKeyDecryptFailed } - results := s.runChecksConcurrent(ctx, m) + + var results []*CheckResult + switch checkMode { + case MonitorCheckModeQuota: + results = s.runQuotaOnlyCheck(ctx, m) + case MonitorCheckModeQuotaProbe: + results = s.runChecksConcurrent(ctx, m) + attachQuotaSnapshot(results, s.fetchQuotaSnapshot(ctx, m)) + default: + results = s.runChecksConcurrent(ctx, m) + } s.persistCheckResults(ctx, m, results) return results, nil } +// runQuotaOnlyCheck quota 模式:一次配额抓取 → 单条 CheckResult +// (Model=PrimaryModel,默认 "quota";无 ping/latency,状态由快照推导)。 +func (s *ChannelMonitorService) runQuotaOnlyCheck(ctx context.Context, m *ChannelMonitor) []*CheckResult { + snapshot := s.fetchQuotaSnapshot(ctx, m) + res := deriveQuotaCheckResult(snapshot, m.PrimaryModel, time.Now()) + res.Quota = snapshot + return []*CheckResult{res} +} + +// fetchQuotaSnapshot 抓取关联账号配额。未关联账号 / fetcher 未注入时返回 +// 显式错误快照(不返回 error,保证检测周期与历史时间线连续)。 +func (s *ChannelMonitorService) fetchQuotaSnapshot(ctx context.Context, m *ChannelMonitor) *domain.MonitorQuotaSnapshot { + if m.AccountID == nil { + return quotaErrorSnapshot("usage", "linked account not found", time.Now()) + } + if s.quotaFetcher == nil { + return quotaErrorSnapshot("usage", "quota fetcher is not configured", time.Now()) + } + return s.quotaFetcher.Fetch(ctx, *m.AccountID) +} + +// attachQuotaSnapshot quota_probe:把配额快照挂到主模型行(results[0])。 +// 配额失败不改变探活状态,仅在探活 message 为空时附注失败原因。 +func attachQuotaSnapshot(results []*CheckResult, snapshot *domain.MonitorQuotaSnapshot) { + if len(results) == 0 || snapshot == nil { + return + } + primary := results[0] + primary.Quota = snapshot + if !snapshot.Success && strings.TrimSpace(primary.Message) == "" { + primary.Message = truncateMessage("quota fetch failed: " + snapshot.Error) + } +} + // persistCheckResults 写入本次检测的历史记录并更新 last_checked_at。 // 任一写库失败都只记日志,不影响调用方拿到 results(与 MVP 期望一致:宁可漏记历史也要先返回结果)。 func (s *ChannelMonitorService) persistCheckResults(ctx context.Context, m *ChannelMonitor, results []*CheckResult) { @@ -487,6 +670,7 @@ func (s *ChannelMonitorService) persistCheckResults(ctx context.Context, m *Chan PingLatencyMs: r.PingLatencyMs, Message: r.Message, CheckedAt: r.CheckedAt, + Quota: r.Quota, }) } if err := s.repo.InsertHistoryBatch(ctx, rows); err != nil { @@ -541,6 +725,14 @@ func (s *ChannelMonitorService) SetScheduler(sched MonitorScheduler) { s.scheduler = sched } +// SetQuotaFetcher 由 wire 注入配额抓取器(账号侧用量服务聚合)。 +func (s *ChannelMonitorService) SetQuotaFetcher(fetcher *ChannelMonitorQuotaFetcher) { + if s == nil { + return + } + s.quotaFetcher = fetcher +} + // ListEnabledMonitors 返回所有 enabled=true 的监控(解密后),供 runner 启动时建立任务表。 func (s *ChannelMonitorService) ListEnabledMonitors(ctx context.Context) ([]*ChannelMonitor, error) { all, err := s.repo.ListEnabled(ctx) @@ -693,14 +885,36 @@ func applyMonitorUpdate(existing *ChannelMonitor, p ChannelMonitorUpdateParams) providerChanged = existing.Provider != *p.Provider existing.Provider = *p.Provider } - if p.Endpoint != nil { - if err := validateEndpoint(*p.Endpoint); err != nil { + if p.CheckMode != nil { + mode := defaultCheckMode(*p.CheckMode) + if err := validateCheckMode(existing.Provider, mode); err != nil { return err } + existing.CheckMode = mode + } + if p.AccountID != nil { + if *p.AccountID > 0 { + id := *p.AccountID + existing.AccountID = &id + } else { + existing.AccountID = nil // 0 = 清空关联 + } + } + if p.Endpoint != nil { + // quota 模式允许清空 endpoint(校验由 validateMonitorModeFields 兜底)。 + if strings.TrimSpace(*p.Endpoint) != "" { + if err := validateEndpoint(*p.Endpoint); err != nil { + return err + } + } existing.Endpoint = normalizeEndpoint(*p.Endpoint) } + // 模式与字段的组合校验(provider/check_mode/account_id/endpoint 全部应用后)。 + if err := validateMonitorModeFields(existing); err != nil { + return err + } if p.PrimaryModel != nil { - primaryModel := normalizeMonitorPrimaryModel(existing.Provider, *p.PrimaryModel) + primaryModel := normalizeMonitorPrimaryModel(existing.Provider, defaultCheckMode(existing.CheckMode), *p.PrimaryModel) if primaryModel == "" { return ErrChannelMonitorMissingPrimaryModel } diff --git a/backend/internal/service/channel_monitor_service_grok_test.go b/backend/internal/service/channel_monitor_service_grok_test.go index 20c9db266663..e3a89075f485 100644 --- a/backend/internal/service/channel_monitor_service_grok_test.go +++ b/backend/internal/service/channel_monitor_service_grok_test.go @@ -9,6 +9,7 @@ func TestApplyMonitorUpdate_ProviderOnlySwitchToGrokUsesDefaultModel(t *testing. existing := &ChannelMonitor{ Provider: MonitorProviderOpenAI, APIMode: MonitorAPIModeResponses, + Endpoint: "https://api.openai.com/v1", PrimaryModel: "gpt-5", IntervalSeconds: 60, } @@ -31,6 +32,7 @@ func TestApplyMonitorUpdate_SwitchToGrokPreservesExplicitModel(t *testing.T) { existing := &ChannelMonitor{ Provider: MonitorProviderOpenAI, APIMode: MonitorAPIModeChatCompletions, + Endpoint: "https://api.openai.com/v1", PrimaryModel: "gpt-5", IntervalSeconds: 60, } @@ -52,6 +54,7 @@ func TestApplyMonitorUpdate_SameGrokProviderDoesNotResetExistingModel(t *testing existing := &ChannelMonitor{ Provider: MonitorProviderGrok, APIMode: MonitorAPIModeChatCompletions, + Endpoint: "https://api.x.ai", PrimaryModel: "grok-4.3", IntervalSeconds: 60, } diff --git a/backend/internal/service/channel_monitor_types.go b/backend/internal/service/channel_monitor_types.go index 20ab1da935a8..138593e0bd65 100644 --- a/backend/internal/service/channel_monitor_types.go +++ b/backend/internal/service/channel_monitor_types.go @@ -1,6 +1,10 @@ package service -import "time" +import ( + "time" + + "github.com/Wei-Shaw/sub2api/internal/domain" +) // MonitorBodyOverrideMode 自定义请求体处理模式。 // @@ -45,6 +49,11 @@ type ChannelMonitor struct { CreatedAt time.Time UpdatedAt time.Time + // 配额模式(check_mode = quota / quota_probe): + // 关联已有账号复用账号侧用量服务,Endpoint/APIKey 可为空(quota 模式)。 + CheckMode string // probe(默认)/ quota / quota_probe;空串按 probe 处理 + AccountID *int64 // 关联账号 ID;账号删除后被 DB 置空(监控保留并报「账号未关联」) + // 请求自定义快照(来自模板拷贝 or 用户手填,运行时直接读取) TemplateID *int64 // 仅用于 UI 分组 + 一键应用,运行时不用 ExtraHeaders map[string]string // 与 adapter 默认 headers 合并,用户优先 @@ -89,6 +98,10 @@ type ChannelMonitorCreateParams struct { ExtraHeaders map[string]string BodyOverrideMode string BodyOverride map[string]any + + // 配额模式:CheckMode 空串默认 probe;quota/quota_probe 必须关联账号。 + CheckMode string + AccountID *int64 } // ChannelMonitorUpdateParams 更新参数(指针字段表示"未提供则不更新")。 @@ -112,6 +125,11 @@ type ChannelMonitorUpdateParams struct { ExtraHeaders *map[string]string BodyOverrideMode *string BodyOverride *map[string]any + + // 配额模式:CheckMode nil = 不更新;AccountID nil = 不更新, + // 指向 0 = 清空关联(退回 probe 模式时由 CheckMode 分支兜底)。 + CheckMode *string + AccountID *int64 } // CheckResult 单个模型一次检测的结果。 @@ -122,6 +140,8 @@ type CheckResult struct { PingLatencyMs *int Message string CheckedAt time.Time + // Quota 配额模式附带快照(quota 模式唯一数据;quota_probe 挂在主模型行)。 + Quota *domain.MonitorQuotaSnapshot } // UserMonitorView 用户只读视图:监控概览(含主模型最近状态 + 7d 可用率 + 附加模型最近状态)。 @@ -137,6 +157,9 @@ type UserMonitorView struct { Availability7d float64 // 0-100 ExtraModels []ExtraModelStatus Timeline []UserMonitorTimelinePoint // 主模型最近 N 个历史点(按 checked_at DESC,最新在前) + // LatestQuota 主模型最近一次配额快照;channel_monitor_show_quota=false + // 时由 handler 服务端剥离。 + LatestQuota *domain.MonitorQuotaSnapshot } // UserMonitorTimelinePoint 用户视图 timeline 单点数据(去除 message 以减小响应体)。 @@ -183,6 +206,7 @@ type ChannelMonitorHistoryRow struct { PingLatencyMs *int Message string CheckedAt time.Time + Quota *domain.MonitorQuotaSnapshot } // ChannelMonitorHistoryEntry 历史记录查询返回行(含 ent 主键 ID)。 @@ -194,6 +218,7 @@ type ChannelMonitorHistoryEntry struct { PingLatencyMs *int Message string CheckedAt time.Time + Quota *domain.MonitorQuotaSnapshot } // ChannelMonitorLatest 最近一次检测的简明信息(用于 UserMonitorView 聚合)。 @@ -203,6 +228,7 @@ type ChannelMonitorLatest struct { LatencyMs *int PingLatencyMs *int CheckedAt time.Time + Quota *domain.MonitorQuotaSnapshot } // ChannelMonitorAvailability 单个模型在某窗口内的可用率与平均延迟(用于 UserMonitorDetail 聚合)。 @@ -223,4 +249,5 @@ type MonitorStatusSummary struct { PrimaryLatencyMs *int Availability7d float64 // 0-100,无历史时为 0 ExtraModels []ExtraModelStatus + LatestQuota *domain.MonitorQuotaSnapshot // 主模型最近配额快照(配额模式) } diff --git a/backend/internal/service/channel_monitor_validate.go b/backend/internal/service/channel_monitor_validate.go index 7740dc83b77d..faf7247fbc8a 100644 --- a/backend/internal/service/channel_monitor_validate.go +++ b/backend/internal/service/channel_monitor_validate.go @@ -9,15 +9,81 @@ import ( // 渠道监控参数校验与归一化辅助函数。 // 校验失败一律返回 channel_monitor_const.go 中预定义的 Err* 错误,错误信息不含具体 IP/hostname,避免泄露内网拓扑。 +// monitorProviders 渠道监控支持的全部 provider(与迁移 226 的 CHECK 约束一致)。 +// 不再以 adapter 表为唯一来源:antigravity 没有探活 adapter,但支持配额模式。 +// +//nolint:gochecknoglobals // 静态查表,初始化后不变。 +var monitorProviders = map[string]struct{}{ + MonitorProviderOpenAI: {}, + MonitorProviderAnthropic: {}, + MonitorProviderGemini: {}, + MonitorProviderGrok: {}, + MonitorProviderAntigravity: {}, + MonitorProviderKimi: {}, + MonitorProviderZhipu: {}, + MonitorProviderDeepseek: {}, +} + +// probeCapableProviders 支持探活(probe / quota_probe)的 provider。 +// antigravity 上游无 Chat/Responses 可打(仅 IDE 代理形态),只允许配额模式。 +// +//nolint:gochecknoglobals // 静态查表,初始化后不变。 +var probeCapableProviders = map[string]struct{}{ + MonitorProviderOpenAI: {}, + MonitorProviderAnthropic: {}, + MonitorProviderGemini: {}, + MonitorProviderGrok: {}, + MonitorProviderKimi: {}, + MonitorProviderZhipu: {}, + MonitorProviderDeepseek: {}, +} + // validateProvider 校验 provider 字符串。 -// 唯一来源于 providerAdapters:新增 provider 只需要在 channel_monitor_checker.go 注册 adapter。 func validateProvider(p string) error { - if !isSupportedProvider(p) { + if _, ok := monitorProviders[p]; !ok { return ErrChannelMonitorInvalidProvider } return nil } +// providerSupportsProbe 该 provider 是否注册了探活 adapter(antigravity 为 false)。 +func providerSupportsProbe(p string) bool { + _, ok := probeCapableProviders[p] + return ok +} + +// defaultCheckMode 空串归一为 probe,保证存量数据与旧客户端兼容。 +func defaultCheckMode(checkMode string) string { + if strings.TrimSpace(checkMode) == "" { + return MonitorCheckModeProbe + } + return strings.TrimSpace(checkMode) +} + +// monitorCheckModeUsesQuota 该模式是否需要关联账号查配额。 +func monitorCheckModeUsesQuota(checkMode string) bool { + return checkMode == MonitorCheckModeQuota || checkMode == MonitorCheckModeQuotaProbe +} + +// validateCheckMode 校验 check_mode 与 provider 的组合矩阵: +// +// provider | probe | quota | quota_probe +// ------------------------+-------+-------+------------ +// openai/anthropic/... | Y | Y | Y +// antigravity(无 adapter)| N | Y | N +func validateCheckMode(provider, checkMode string) error { + checkMode = defaultCheckMode(checkMode) + switch checkMode { + case MonitorCheckModeProbe, MonitorCheckModeQuota, MonitorCheckModeQuotaProbe: + default: + return ErrChannelMonitorInvalidCheckMode + } + if checkMode != MonitorCheckModeQuota && !providerSupportsProbe(provider) { + return ErrChannelMonitorInvalidCheckMode + } + return nil +} + // validateAPIMode 校验 provider 与 api_mode 的组合。 // responses 只对 OpenAI 有意义;其它 provider 使用 chat_completions 作为默认占位。 func validateAPIMode(provider, apiMode string) error { @@ -124,13 +190,18 @@ func normalizeModels(in []string) []string { return out } -// normalizeMonitorPrimaryModel applies the Grok health-check default while -// preserving the existing required-model behavior for every other provider. -func normalizeMonitorPrimaryModel(provider, model string) string { +// normalizeMonitorPrimaryModel applies provider/check_mode defaults while +// preserving the existing required-model behavior: +// - Grok 探活默认轻量测活模型 +// - quota 模式占位 "quota"(primary_model 列 NotEmpty;历史行/时间线机制无需特判) +func normalizeMonitorPrimaryModel(provider, checkMode, model string) string { model = strings.TrimSpace(model) if model == "" && provider == MonitorProviderGrok { return MonitorDefaultGrokModel } + if model == "" && monitorCheckModeUsesQuota(defaultCheckMode(checkMode)) { + return MonitorDefaultQuotaModel + } return model } diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index 3a8c5556add6..a2c4bf6ebfba 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -646,7 +646,47 @@ func validatePricingEntries(pricing []ChannelModelPricing) error { if err := validatePricingIntervals(pricing); err != nil { return err } - return validatePricingBillingMode(pricing) + if err := validatePricingBillingMode(pricing); err != nil { + return err + } + return validatePricingTimePricing(pricing) +} + +func validatePricingTimePricing(pricing []ChannelModelPricing) error { + for i := range pricing { + config := pricing[i].TimePricing + if config == nil { + continue + } + if len(config.Periods) == 0 { + pricing[i].TimePricing = nil + continue + } + mode := pricing[i].BillingMode + if mode != "" && mode != BillingModeToken { + return infraerrors.BadRequest("TIME_PRICING_UNSUPPORTED_MODE", "time pricing only supports token billing mode") + } + if err := validateChannelTimePricing(config); err != nil { + return infraerrors.BadRequest("INVALID_TIME_PRICING", fmt.Sprintf( + "invalid time pricing for platform '%s' models %v: %v", pricing[i].Platform, pricing[i].Models, err)) + } + } + return nil +} + +func validateAccountStatsPricingRules(rules []AccountStatsPricingRule) error { + for i := range rules { + for _, pricing := range rules[i].Pricing { + if pricing.TimePricing != nil && len(pricing.TimePricing.Periods) > 0 { + return fmt.Errorf("account stats pricing rule #%d: %w", i+1, + infraerrors.BadRequest("ACCOUNT_STATS_TIME_PRICING_UNSUPPORTED", "account stats pricing does not support time pricing")) + } + } + if err := validatePricingEntries(rules[i].Pricing); err != nil { + return fmt.Errorf("account stats pricing rule #%d: %w", i+1, err) + } + } + return nil } // validatePricingBillingMode 校验计费模式配置:按次/图片模式必须配价格或区间,所有价格字段不能为负,区间至少有一个价格字段。 @@ -755,10 +795,8 @@ func (s *ChannelService) Create(ctx context.Context, input *CreateChannelInput) if err := validateChannelConfig(channel.ModelPricing, channel.ModelMapping); err != nil { return nil, err } - for i, rule := range channel.AccountStatsPricingRules { - if err := validatePricingEntries(rule.Pricing); err != nil { - return nil, fmt.Errorf("account stats pricing rule #%d: %w", i+1, err) - } + if err := validateAccountStatsPricingRules(channel.AccountStatsPricingRules); err != nil { + return nil, err } if err := s.repo.Create(ctx, channel); err != nil { @@ -799,10 +837,8 @@ func (s *ChannelService) Update(ctx context.Context, id int64, input *UpdateChan if err := validateChannelConfig(channel.ModelPricing, channel.ModelMapping); err != nil { return nil, err } - for i, rule := range channel.AccountStatsPricingRules { - if err := validatePricingEntries(rule.Pricing); err != nil { - return nil, fmt.Errorf("account stats pricing rule #%d: %w", i+1, err) - } + if err := validateAccountStatsPricingRules(channel.AccountStatsPricingRules); err != nil { + return nil, err } oldGroupIDs := s.getOldGroupIDs(ctx, id) diff --git a/backend/internal/service/channel_service_test.go b/backend/internal/service/channel_service_test.go index f56f61e9173e..9a81913b04d1 100644 --- a/backend/internal/service/channel_service_test.go +++ b/backend/internal/service/channel_service_test.go @@ -5,8 +5,10 @@ package service import ( "context" "errors" + "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" "github.com/tidwall/gjson" @@ -2460,6 +2462,66 @@ func TestValidatePricingBillingMode(t *testing.T) { } } +func validTimePricingForTest() *ChannelTimePricing { + return &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: []ChannelTimePricingPeriod{ + {StartTime: "09:00", EndTime: "12:00", Multiplier: 2}, + }} +} + +func TestValidatePricingTimePricing(t *testing.T) { + token := []ChannelModelPricing{{BillingMode: BillingModeToken, TimePricing: validTimePricingForTest()}} + require.NoError(t, validatePricingTimePricing(token)) + + implicitToken := []ChannelModelPricing{{TimePricing: validTimePricingForTest()}} + require.NoError(t, validatePricingTimePricing(implicitToken)) + + image := []ChannelModelPricing{{BillingMode: BillingModeImage, TimePricing: validTimePricingForTest()}} + modeErr := infraerrors.FromError(validatePricingTimePricing(image)) + require.Equal(t, int32(http.StatusBadRequest), modeErr.Code) + require.Equal(t, "TIME_PRICING_UNSUPPORTED_MODE", modeErr.Reason) + + invalid := []ChannelModelPricing{{ + Platform: PlatformOpenAI, + Models: []string{"gpt-5"}, + BillingMode: BillingModeToken, + TimePricing: &ChannelTimePricing{Timezone: "UTC+8", Periods: validTimePricingForTest().Periods}, + }} + invalidErr := infraerrors.FromError(validatePricingTimePricing(invalid)) + require.Equal(t, int32(http.StatusBadRequest), invalidErr.Code) + require.Equal(t, "INVALID_TIME_PRICING", invalidErr.Reason) + require.Contains(t, invalidErr.Message, "platform 'openai'") + require.Contains(t, invalidErr.Message, "models [gpt-5]") + + invalidMultiplier := []ChannelModelPricing{{ + Platform: PlatformOpenAI, + Models: []string{"gpt-5"}, + BillingMode: BillingModeToken, + TimePricing: &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: []ChannelTimePricingPeriod{{ + StartTime: "09:00", EndTime: "12:00", Multiplier: 1e-12, + }}}, + }} + invalidMultiplierRawErr := validatePricingTimePricing(invalidMultiplier) + require.Error(t, invalidMultiplierRawErr) + invalidMultiplierErr := infraerrors.FromError(invalidMultiplierRawErr) + require.Equal(t, int32(http.StatusBadRequest), invalidMultiplierErr.Code) + require.Equal(t, "INVALID_TIME_PRICING", invalidMultiplierErr.Reason) + + empty := []ChannelModelPricing{{BillingMode: BillingModeToken, TimePricing: &ChannelTimePricing{Timezone: "Asia/Shanghai"}}} + require.NoError(t, validatePricingTimePricing(empty)) + require.Nil(t, empty[0].TimePricing) +} + +func TestValidateAccountStatsPricingRulesRejectsTimePricing(t *testing.T) { + rules := []AccountStatsPricingRule{{Pricing: []ChannelModelPricing{{ + BillingMode: BillingModeToken, + TimePricing: validTimePricingForTest(), + }}}} + + appErr := infraerrors.FromError(validateAccountStatsPricingRules(rules)) + require.Equal(t, int32(http.StatusBadRequest), appErr.Code) + require.Equal(t, "ACCOUNT_STATS_TIME_PRICING_UNSUPPORTED", appErr.Reason) +} + // --------------------------------------------------------------------------- // 12. Antigravity wildcard mapping isolation // --------------------------------------------------------------------------- diff --git a/backend/internal/service/channel_test.go b/backend/internal/service/channel_test.go index 2f371f8a1c8d..19e45a02db65 100644 --- a/backend/internal/service/channel_test.go +++ b/backend/internal/service/channel_test.go @@ -197,6 +197,14 @@ func TestChannelModelPricingClone(t *testing.T) { Intervals: []PricingInterval{ {MinTokens: 0, TierLabel: "tier1"}, }, + TimePricing: &ChannelTimePricing{ + Timezone: "Asia/Shanghai", + Periods: []ChannelTimePricingPeriod{{ + StartTime: "09:00", + EndTime: "12:00", + Multiplier: 2, + }}, + }, } cloned := original.Clone() @@ -207,6 +215,13 @@ func TestChannelModelPricingClone(t *testing.T) { cloned.Intervals[0].TierLabel = "hacked" require.Equal(t, "tier1", original.Intervals[0].TierLabel) + + cloned.TimePricing.Timezone = "America/New_York" + cloned.TimePricing.Periods[0].StartTime = "10:00" + cloned.TimePricing.Periods[0].Multiplier = 3 + require.Equal(t, "Asia/Shanghai", original.TimePricing.Timezone) + require.Equal(t, "09:00", original.TimePricing.Periods[0].StartTime) + require.Equal(t, 2.0, original.TimePricing.Periods[0].Multiplier) } // --- BillingMode.IsValid --- @@ -513,7 +528,6 @@ func TestSupportedModels_WildcardExpandedFromPricing(t *testing.T) { } } - func TestSupportedModels_MissingPricingKeepsNilPricing(t *testing.T) { ch := &Channel{ ModelMapping: map[string]map[string]string{ diff --git a/backend/internal/service/cn_provider_balance_check_service.go b/backend/internal/service/cn_provider_balance_check_service.go new file mode 100644 index 000000000000..d5ddd6712e89 --- /dev/null +++ b/backend/internal/service/cn_provider_balance_check_service.go @@ -0,0 +1,279 @@ +package service + +import ( + "context" + "fmt" + "log" + "strings" + "sync" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" +) + +// cnQuotaProber 抽象额度探测(*CNProviderQuotaService 实现,测试可替换)。 +type cnQuotaProber interface { + QueryUsage(ctx context.Context, accountID int64) (*CNProviderQuotaProbeResult, error) +} + +// cnQuotaProbeConcurrency 周期任务并发探测额度账号的并发度。 +const cnQuotaProbeConcurrency = 4 + +// CNProviderBalanceCheckService 周期性探测国产供应商账号: +// - payg(按量付费):余额低于阈值则临时停调,恢复则清除(仅清除本服务写入的停调); +// - coding plan:调用 CNProviderQuotaService 探测 5h/weekly 滚动窗口并落 extra 快照, +// 调度阈值评估(cnProviderThresholdCandidates)据此自动停调/恢复。 +// +// 克隆自 AccountExpiryService 的 Start/Stop/runOnce + ticker 骨架。 +// 余额探测仅覆盖有公开余额端点的 kimi / deepseek;智谱无余额端点,仅靠响应式 429/402。 +// 额度探测覆盖 kimi / zhipu 的 coding plan 账号(deepseek 无 coding 套餐)。 +type CNProviderBalanceCheckService struct { + accountRepo AccountRepository + balanceService *CNProviderBalanceService + quotaService cnQuotaProber + cfg *config.Config + interval time.Duration + stopCh chan struct{} + stopOnce sync.Once + wg sync.WaitGroup +} + +// NewCNProviderBalanceCheckService 构造周期余额/额度检测服务。 +// interval <= 0 时 Start() 直接返回(不启动),便于通过配置关闭。 +func NewCNProviderBalanceCheckService( + accountRepo AccountRepository, + balanceService *CNProviderBalanceService, + quotaService *CNProviderQuotaService, + cfg *config.Config, + interval time.Duration, +) *CNProviderBalanceCheckService { + return &CNProviderBalanceCheckService{ + accountRepo: accountRepo, + balanceService: balanceService, + quotaService: quotaService, + cfg: cfg, + interval: interval, + stopCh: make(chan struct{}), + } +} + +func (s *CNProviderBalanceCheckService) Start() { + if s == nil || s.accountRepo == nil || s.balanceService == nil || s.cfg == nil { + return + } + if !s.cfg.Gateway.CNProviders.BalanceCheckEnabled { + return + } + if s.interval <= 0 { + return + } + log.Printf("[CNBalance] started (interval=%s threshold=%.2f)", s.interval, s.cfg.Gateway.CNProviders.BalanceThreshold) + s.wg.Add(1) + go func() { + defer s.wg.Done() + ticker := time.NewTicker(s.interval) + defer ticker.Stop() + + // 启动后先等待一个周期再首次探测,避免与进程启动峰重叠。 + for { + select { + case <-ticker.C: + s.runOnce() + case <-s.stopCh: + return + } + } + }() +} + +func (s *CNProviderBalanceCheckService) Stop() { + if s == nil { + return + } + s.stopOnce.Do(func() { + close(s.stopCh) + }) + s.wg.Wait() +} + +func (s *CNProviderBalanceCheckService) runOnce() { + // 收集 coding 探测目标(kimi/deepseek + 智谱)与 payg 检查队列。 + // coding 探测统一在收集完成后按 4 并发执行:单账号探测 15-20s,串行 × + // 多账号会耗尽整体预算(120s 上限),排在后面的账号快照会饥饿, + // 连锁影响阈值停调的新鲜度判定。 + type quotaTarget struct { + id int64 + platform string + } + var quotaTargets []quotaTarget + var paygTargets []*Account + collect := func(platform string, accounts []Account) { + for i := range accounts { + account := &accounts[i] + if !account.IsActive() { + continue + } + // coding 账号:探测滚动窗口并落快照(不要求 Schedulable——已被 + // 阈值停调的账号也需要新鲜快照决定是否续停)。 + if account.IsCodingPlan() { + quotaTargets = append(quotaTargets, quotaTarget{id: account.ID, platform: account.Platform}) + continue + } + // payg 余额探测仅 kimi/deepseek(智谱无公开余额端点,payg 账号 + // 依赖响应式 402/429 处理)。 + if platform != PlatformZhipu && account.Schedulable { + paygTargets = append(paygTargets, account) + } + } + } + for _, platform := range s.platforms() { + accounts, err := s.accountRepo.ListByPlatform(context.Background(), platform) + if err != nil { + log.Printf("[CNBalance] list %s accounts failed: %v", platform, err) + continue + } + collect(platform, accounts) + } + // 智谱无余额端点,仅进额度探测。 + if s.quotaService != nil { + accounts, err := s.accountRepo.ListByPlatform(context.Background(), PlatformZhipu) + if err != nil { + log.Printf("[CNBalance] list %s accounts failed: %v", PlatformZhipu, err) + } else { + collect(PlatformZhipu, accounts) + } + } + + // 预算按工作量放大:4 并发 × 15s/批 + payg 每账号 5s,下限 30s 上限 300s。 + batches := (len(quotaTargets) + cnQuotaProbeConcurrency - 1) / cnQuotaProbeConcurrency + timeout := 30*time.Second + time.Duration(batches)*15*time.Second + time.Duration(len(paygTargets))*5*time.Second + if timeout > 300*time.Second { + timeout = 300 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + threshold := s.cfg.Gateway.CNProviders.BalanceThreshold + paused, cleared := 0, 0 + for _, account := range paygTargets { + switch s.checkOne(ctx, account, threshold) { + case cnBalancePaused: + paused++ + case cnBalanceCleared: + cleared++ + } + } + + if len(quotaTargets) > 0 && s.quotaService != nil { + sem := make(chan struct{}, cnQuotaProbeConcurrency) + var wg sync.WaitGroup + for _, target := range quotaTargets { + wg.Add(1) + go func(t quotaTarget) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + s.probeQuota(ctx, t.id, t.platform) + }(target) + } + wg.Wait() + } + + if paused > 0 || cleared > 0 { + log.Printf("[CNBalance] paused=%d cleared=%d (threshold=%.2f)", paused, cleared, threshold) + } +} + +// probeQuota 探测单个 coding plan 账号的滚动窗口用量并落 extra 快照。 +// 不在此处做停调/恢复决策:调度阈值评估读取快照统一判定(含暂停账号的续停)。 +func (s *CNProviderBalanceCheckService) probeQuota(ctx context.Context, accountID int64, platform string) { + if s.quotaService == nil { + return + } + result, err := s.quotaService.QueryUsage(ctx, accountID) + if err != nil { + log.Printf("[CNBalance] quota probe account %d (%s) failed: %v", accountID, platform, err) + return + } + if result != nil && !result.Success && result.Error != "" { + log.Printf("[CNBalance] quota probe account %d (%s) error: %s", accountID, platform, result.Error) + } +} + +type cnBalanceCheckOutcome int + +const ( + cnBalanceNoChange cnBalanceCheckOutcome = iota + cnBalancePaused + cnBalanceCleared +) + +// checkOne 探测单账号余额并决定停调/恢复。探测失败时不动现状(避免瞬时网络抖动 +// 误解除或误停调)。 +func (s *CNProviderBalanceCheckService) checkOne(ctx context.Context, account *Account, threshold float64) cnBalanceCheckOutcome { + result, err := s.balanceService.QueryBalance(ctx, account.ID) + if err != nil || result == nil || !result.Success { + return cnBalanceNoChange + } + + // 双币种(deepseek CNY+USD)任一币种余额达标即可继续调度;仅当全部低于 + // 阈值(或不可用)才停调。 + low := !result.Available || allCNBalancesBelowThreshold(result, threshold) + if low { + // 已被(任何来源)停调时不覆盖其 reason。 + if !account.IsSchedulable() { + return cnBalanceNoChange + } + reason := cnBalanceLowReason(fmt.Sprintf("余额 %.4g %s 低于阈值 %.2f", result.Balance, result.Currency, threshold)) + if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, time.Now().Add(s.cooldown()), reason); err != nil { + log.Printf("[CNBalance] pause account %d failed: %v", account.ID, err) + return cnBalanceNoChange + } + log.Printf("[CNBalance] paused account %d (%s): balance=%.4g %s", account.ID, account.Platform, result.Balance, result.Currency) + return cnBalancePaused + } + + // 余额健康:仅清除「本服务写入」的临时停调(reason 前缀匹配),不触碰其他子系统。 + if account.TempUnschedulableUntil != nil && strings.HasPrefix(account.TempUnschedulableReason, cnBalanceLowReasonPrefix) { + if err := s.accountRepo.ClearTempUnschedulable(ctx, account.ID); err != nil { + log.Printf("[CNBalance] clear account %d failed: %v", account.ID, err) + return cnBalanceNoChange + } + log.Printf("[CNBalance] reactivated account %d (%s): balance=%.4g %s", account.ID, account.Platform, result.Balance, result.Currency) + return cnBalanceCleared + } + return cnBalanceNoChange +} + +func (s *CNProviderBalanceCheckService) platforms() []string { + return []string{PlatformKimi, PlatformDeepseek} +} + +// allCNBalancesBelowThreshold 判断全部币种余额是否均低于阈值。 +// 无明细时退回主币种判定(与旧行为一致)。 +func allCNBalancesBelowThreshold(result *CNProviderBalanceResult, threshold float64) bool { + if len(result.Balances) == 0 { + return result.Balance < threshold + } + for _, entry := range result.Balances { + if entry.Balance >= threshold { + return false + } + } + return true +} + +// cooldown 返回临时停调持续时长(= 2× 检测周期),与响应式 402/429 路径一致。 +func (s *CNProviderBalanceCheckService) cooldown() time.Duration { + minutes := 10 + if s.cfg != nil { + if cfgMin := s.cfg.Gateway.CNProviders.BalanceCheckIntervalMinutes; cfgMin > 0 { + minutes = cfgMin + } + } + cooldown := time.Duration(minutes) * time.Minute * 2 + if cooldown < time.Minute { + cooldown = 10 * time.Minute + } + return cooldown +} diff --git a/backend/internal/service/cn_provider_balance_check_service_test.go b/backend/internal/service/cn_provider_balance_check_service_test.go new file mode 100644 index 000000000000..26f7b69ea296 --- /dev/null +++ b/backend/internal/service/cn_provider_balance_check_service_test.go @@ -0,0 +1,101 @@ +package service + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +// 周期任务对 coding plan 账号的额度探测行为(runOnce 集成路径): +// - kimi coding 账号(含已被阈值停调的)→ 额度探测被调用; +// - 智谱 coding 账号 → 额度探测被调用(智谱不进 kimi/deepseek 余额循环); +// - payg 账号不经过额度探测(走余额路径,本测试不放 payg 账号避免真实网络); +// - 非激活账号完全跳过。 + +type fakeCNQuotaProber struct { + probed []int64 +} + +func (f *fakeCNQuotaProber) QueryUsage(ctx context.Context, accountID int64) (*CNProviderQuotaProbeResult, error) { + f.probed = append(f.probed, accountID) + return &CNProviderQuotaProbeResult{Success: true, Persisted: true}, nil +} + +type fakeCNCheckRepo struct { + AccountRepository + byPlatform map[string][]Account +} + +func (r *fakeCNCheckRepo) ListByPlatform(ctx context.Context, platform string) ([]Account, error) { + return r.byPlatform[platform], nil +} + +func TestCNProviderBalanceCheckRunOnceProbesCodingPlanQuota(t *testing.T) { + kimiActive := Account{ID: 1, Platform: PlatformKimi, Type: AccountTypeAPIKey, Status: StatusActive, + Credentials: map[string]any{"account_mode": "coding"}} + // 已被阈值停调的 coding 账号也要刷新快照(决定是否续停)。 + kimiPaused := Account{ID: 2, Platform: PlatformKimi, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: false, + Credentials: map[string]any{"account_mode": "coding"}} + // 非激活账号跳过。 + kimiInactive := Account{ID: 3, Platform: PlatformKimi, Type: AccountTypeAPIKey, Status: StatusDisabled, + Credentials: map[string]any{"account_mode": "coding"}} + zhipuCoding := Account{ID: 4, Platform: PlatformZhipu, Type: AccountTypeAPIKey, Status: StatusActive, + Credentials: map[string]any{"account_mode": "coding"}} + + repo := &fakeCNCheckRepo{byPlatform: map[string][]Account{ + PlatformKimi: {kimiActive, kimiPaused, kimiInactive}, + PlatformZhipu: {zhipuCoding}, + }} + prober := &fakeCNQuotaProber{} + svc := &CNProviderBalanceCheckService{ + accountRepo: repo, + quotaService: prober, + cfg: &config.Config{}, + } + + svc.runOnce() + + require.ElementsMatch(t, []int64{1, 2, 4}, prober.probed) +} + +// runOnceZhipuQuota 在 quotaService 缺失时安全跳过(Start 门控不启动的老部署路径)。 +func TestCNProviderBalanceCheckRunOnceWithoutQuotaService(t *testing.T) { + repo := &fakeCNCheckRepo{byPlatform: map[string][]Account{ + PlatformZhipu: {{ID: 4, Platform: PlatformZhipu, Type: AccountTypeAPIKey, Status: StatusActive, + Credentials: map[string]any{"account_mode": "coding"}}}, + }} + svc := &CNProviderBalanceCheckService{accountRepo: repo, cfg: &config.Config{}} + require.NotPanics(t, func() { svc.runOnce() }) +} + +// 双币种(deepseek CNY+USD)停调判定:任一币种达标即不停调,全部低于阈值才停; +// 无明细时退回主币种(兼容旧结果)。 +func TestAllCNBalancesBelowThreshold(t *testing.T) { + dualLow := &CNProviderBalanceResult{ + Balance: 1.0, + Currency: "CNY", + Balances: []CNProviderBalanceEntry{ + {Currency: "CNY", Balance: 1.0}, + {Currency: "USD", Balance: 0.5}, + }, + } + require.True(t, allCNBalancesBelowThreshold(dualLow, 5.0)) + + dualMixed := &CNProviderBalanceResult{ + Balance: 1.0, + Currency: "CNY", + Balances: []CNProviderBalanceEntry{ + {Currency: "CNY", Balance: 1.0}, + {Currency: "USD", Balance: 20.0}, + }, + } + require.False(t, allCNBalancesBelowThreshold(dualMixed, 5.0)) + + // 无明细:按主币种判定(旧行为)。 + singleLow := &CNProviderBalanceResult{Balance: 1.0, Currency: "CNY"} + require.True(t, allCNBalancesBelowThreshold(singleLow, 5.0)) + singleOK := &CNProviderBalanceResult{Balance: 10.0, Currency: "CNY"} + require.False(t, allCNBalancesBelowThreshold(singleOK, 5.0)) +} diff --git a/backend/internal/service/cn_provider_balance_service.go b/backend/internal/service/cn_provider_balance_service.go new file mode 100644 index 000000000000..d01c9b6d087c --- /dev/null +++ b/backend/internal/service/cn_provider_balance_service.go @@ -0,0 +1,277 @@ +package service + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/tidwall/gjson" + "golang.org/x/sync/singleflight" +) + +// 国产供应商 payg(按量付费)账号余额探测服务。 +// +// 仅覆盖有公开余额端点的供应商: +// - Kimi/Moonshot:GET https://api.moonshot.cn/v1/users/me/balance (Bearer) → data.available_balance +// - DeepSeek: GET https://api.deepseek.com/user/balance (Bearer) → balance_infos[].total_balance + is_available +// +// 智谱(zhipu)无公开余额端点(OpenAPI 规格验证),仅靠响应式 429/402(见 +// ratelimit_cn_providers.go)。解析逻辑对齐 cc-switch services/balance.rs::query_deepseek。 +const ( + cnBalanceUpstreamTimeout = 15 * time.Second + cnBalanceMaxBodyBytes = 256 * 1024 + + // Extra 余额快照键后缀(加 provider 前缀)。 + cnBalanceExtraSuffixBalance = "balance" + cnBalanceExtraSuffixCurrency = "balance_currency" + cnBalanceExtraSuffixAvailable = "balance_available" // deepseek is_available 健康标记 + cnBalanceExtraSuffixUpdated = "balance_updated_at" + cnBalanceExtraSuffixBalances = "balances" // 多币种明细(deepseek USD+CNY) +) + +// CNProviderBalanceEntry 是单一币种的余额明细。 +type CNProviderBalanceEntry struct { + Currency string `json:"currency"` + Balance float64 `json:"balance"` +} + +// CNProviderBalanceResult 是余额探测的返回结构(管理端 + UI 消费)。 +type CNProviderBalanceResult struct { + Provider string `json:"provider"` + Success bool `json:"success"` + // Balance/Currency 为主币种(balance_infos 首条,兼容单币种消费方); + // 完整明细见 Balances(deepseek 双币种账号含 CNY + USD 两条)。 + Balance float64 `json:"balance"` + Currency string `json:"currency,omitempty"` + Balances []CNProviderBalanceEntry `json:"balances,omitempty"` + Available bool `json:"available"` // 健康标记(deepseek is_available;kimi 无此概念恒 true) + StatusCode int `json:"status_code,omitempty"` + FetchedAt int64 `json:"fetched_at"` + Persisted bool `json:"persisted"` + Error string `json:"error,omitempty"` +} + +// CNProviderBalanceService 探测 Kimi / DeepSeek payg 账号的账户余额。 +type CNProviderBalanceService struct { + accountRepo AccountRepository + proxyRepo ProxyRepository + httpUpstream HTTPUpstream + cfg *config.Config + flight singleflight.Group +} + +// NewCNProviderBalanceService 构造余额探测服务。 +func NewCNProviderBalanceService( + accountRepo AccountRepository, + proxyRepo ProxyRepository, + httpUpstream HTTPUpstream, + cfg *config.Config, +) *CNProviderBalanceService { + return &CNProviderBalanceService{ + accountRepo: accountRepo, + proxyRepo: proxyRepo, + httpUpstream: httpUpstream, + cfg: cfg, + } +} + +// QueryBalance 探测指定 payg 账号的余额并落 Extra 快照。 +func (s *CNProviderBalanceService) QueryBalance(ctx context.Context, accountID int64) (*CNProviderBalanceResult, error) { + if s == nil || s.accountRepo == nil || s.httpUpstream == nil { + return nil, infraerrors.New(http.StatusInternalServerError, "CN_BALANCE_NOT_CONFIGURED", "cn provider balance service is not configured") + } + key := "cn_balance:" + strconv.FormatInt(accountID, 10) + resultCh := s.flight.DoChan(key, func() (any, error) { + probeCtx, cancel := context.WithTimeout(context.Background(), cnBalanceUpstreamTimeout+5*time.Second) + defer cancel() + return s.queryBalance(probeCtx, accountID) + }) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case flightResult := <-resultCh: + if flightResult.Err != nil { + return nil, flightResult.Err + } + result, ok := flightResult.Val.(*CNProviderBalanceResult) + if !ok || result == nil { + return nil, infraerrors.New(http.StatusInternalServerError, "CN_BALANCE_PROBE_RESULT_INVALID", "invalid cn provider balance probe result") + } + cloned := *result + return &cloned, nil + } +} + +func (s *CNProviderBalanceService) queryBalance(ctx context.Context, accountID int64) (*CNProviderBalanceResult, error) { + account, err := s.loadPayGAccount(ctx, accountID) + if err != nil { + return nil, err + } + provider := account.Platform + if provider != PlatformKimi && provider != PlatformDeepseek { + return nil, infraerrors.New(http.StatusBadRequest, "CN_BALANCE_NO_ENDPOINT", "account provider has no balance endpoint") + } + + apiKey := strings.TrimSpace(account.GetCNAPIKey()) + if apiKey == "" { + return nil, infraerrors.New(http.StatusBadRequest, "CN_BALANCE_NO_APIKEY", "account api_key is empty") + } + + targetURL := cnBalanceURL(account) + // 探测发起前过出站 URL 安全策略(与网关转发/Grok 探测同一套校验): + // DeepSeek 端点由账号 base_url 衍生,不得把 API key 发往策略外主机。 + validatedURL, err := cnValidateProbeURL(s.cfg, targetURL) + if err != nil { + return nil, infraerrors.New(http.StatusForbidden, "CN_BALANCE_URL_REJECTED", err.Error()) + } + targetURL = validatedURL + proxyURL := s.resolveProxyURL(ctx, account) + callCtx, cancel := context.WithTimeout(ctx, cnBalanceUpstreamTimeout) + defer cancel() + req, err := http.NewRequestWithContext(callCtx, http.MethodGet, targetURL, nil) + if err != nil { + return nil, infraerrors.Newf(http.StatusInternalServerError, "CN_BALANCE_REQUEST_BUILD_FAILED", "build request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Accept", "application/json") + account.ApplyHeaderOverrides(req.Header) + + resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, maxInt(account.Concurrency, 1)) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadGateway, "CN_BALANCE_REQUEST_FAILED", "upstream request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, cnBalanceMaxBodyBytes)) + + now := time.Now().UTC() + result := &CNProviderBalanceResult{ + Provider: provider, + FetchedAt: now.Unix(), + StatusCode: resp.StatusCode, + Available: true, + } + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + result.Error = fmt.Sprintf("Authentication failed (HTTP %d)", resp.StatusCode) + return result, nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + result.Error = fmt.Sprintf("API error (HTTP %d): %s", resp.StatusCode, truncate(strings.TrimSpace(string(bodyBytes)), 240)) + return result, nil + } + + var entries []CNProviderBalanceEntry + available := true + switch provider { + case PlatformKimi: + // Moonshot:code==0 成功;data.available_balance(number),单币种 CNY。 + balance, _ := cnParseF64(gjson.GetBytes(bodyBytes, "data.available_balance").Value()) + entries = append(entries, CNProviderBalanceEntry{Currency: "CNY", Balance: balance}) + case PlatformDeepseek: + // is_available 缺省视为 true(健康);显式存在时取其值。 + if v := gjson.GetBytes(bodyBytes, "is_available"); v.Exists() { + available = v.Bool() + } + // balance_infos 逐条解析:双币种账号同时返回 CNY + USD(数组顺序即 + // 主次序,首条为主币种)。 + gjson.GetBytes(bodyBytes, "balance_infos").ForEach(func(_, info gjson.Result) bool { + currency := strings.ToUpper(strings.TrimSpace(info.Get("currency").String())) + balance, _ := cnParseF64(info.Get("total_balance").Value()) + if currency == "" { + currency = "CNY" + } + entries = append(entries, CNProviderBalanceEntry{Currency: currency, Balance: balance}) + return true + }) + if len(entries) == 0 { + entries = append(entries, CNProviderBalanceEntry{Currency: "CNY"}) + } + } + result.Balances = entries + result.Balance = entries[0].Balance + result.Currency = entries[0].Currency + result.Available = available + result.Success = true + + balanceUpdates := make([]any, 0, len(entries)) + for _, entry := range entries { + balanceUpdates = append(balanceUpdates, map[string]any{ + "currency": entry.Currency, + "balance": entry.Balance, + }) + } + updates := map[string]any{ + cnExtraKey(provider, cnBalanceExtraSuffixBalance): result.Balance, + cnExtraKey(provider, cnBalanceExtraSuffixCurrency): result.Currency, + cnExtraKey(provider, cnBalanceExtraSuffixAvailable): available, + cnExtraKey(provider, cnBalanceExtraSuffixUpdated): now.Format(time.RFC3339), + cnExtraKey(provider, cnBalanceExtraSuffixBalances): balanceUpdates, + // 余额探测成功即清除响应式 402/429 写下的 balance_low 标记。 + cnExtraKey(provider, cnBalanceExtraSuffixLow): false, + } + if err := s.accountRepo.UpdateExtra(ctx, account.ID, updates); err != nil { + slog.Warn("cn_balance_persist_failed", "account_id", account.ID, "provider", provider, "error", err) + } else { + result.Persisted = true + } + return result, nil +} + +// loadPayGAccount 加载 payg 模式的国产供应商账号(余额仅对 payg 有意义;coding 走额度)。 +func (s *CNProviderBalanceService) loadPayGAccount(ctx context.Context, accountID int64) (*Account, error) { + account, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil { + return nil, infraerrors.Newf(http.StatusNotFound, "CN_BALANCE_ACCOUNT_NOT_FOUND", "account not found: %v", err) + } + if account == nil { + return nil, infraerrors.New(http.StatusNotFound, "CN_BALANCE_ACCOUNT_NOT_FOUND", "account not found") + } + if !account.IsCNProvider() { + return nil, infraerrors.New(http.StatusBadRequest, "CN_BALANCE_INVALID_PLATFORM", "account is not a CN provider account") + } + // coding 账号走额度探测,余额端点不适用。 + if account.IsCodingPlan() { + return nil, infraerrors.New(http.StatusBadRequest, "CN_BALANCE_CODING_PLAN", "coding plan account has no balance endpoint; use quota probe") + } + return account, nil +} + +func (s *CNProviderBalanceService) resolveProxyURL(ctx context.Context, account *Account) string { + if account == nil || account.ProxyID == nil { + return "" + } + if account.Proxy != nil { + return account.Proxy.URL() + } + if s != nil && s.proxyRepo != nil { + if proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && proxy != nil { + account.Proxy = proxy + return proxy.URL() + } + } + return "" +} + +// cnBalanceURL 解析账号的余额端点。 +// +// - Kimi:固定 https://api.moonshot.cn/v1/users/me/balance(与 base_url 无关,Moonshot 仅此一处) +// - DeepSeek:基于 base_url 拼接 /user/balance(支持自定义域名) +func cnBalanceURL(account *Account) string { + switch account.Platform { + case PlatformKimi: + return "https://api.moonshot.cn/v1/users/me/balance" + case PlatformDeepseek: + // Anthropic 协议账号的凭证 base_url 指向 /anthropic 端点,余额探测需回退 + // 到 OpenAI 格式 base(协议感知)再拼接 /user/balance。 + return strings.TrimRight(account.GetOpenAIFormatBaseURL(), "/") + "/user/balance" + default: + return "" + } +} diff --git a/backend/internal/service/cn_provider_probe_url.go b/backend/internal/service/cn_provider_probe_url.go new file mode 100644 index 000000000000..95b0ff783fcd --- /dev/null +++ b/backend/internal/service/cn_provider_probe_url.go @@ -0,0 +1,48 @@ +package service + +// CN 供应商探测端点的出站 URL 安全策略校验(配额/余额探测共用)。 +// +// 背景(review B4):这两条探测路径会把账号 API key 发往 base_url 衍生端点, +// 此前完全绕过 security.url_allowlist——在加固部署里构成任意外发与内网探测 +// 面(本项目此前发生过账号测试 SSRF 生产事件)。与网关转发 +// (validateUpstreamBaseURL)、Grok 探测(grokOperatorPolicyValidator)一致, +// 探测发起前必须过同一套运营者策略。 + +import ( + "errors" + "fmt" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" +) + +// cnValidateProbeURL 按全局出站 URL 安全策略校验探测端点,返回规范化 URL。 +// 白名单开启时强制 UpstreamHosts(阻断私网与未列名主机);关闭时仅做格式 +// 校验(HTTP 允许与否跟随配置);cfg 为 nil 时退化为纯格式校验。 +func cnValidateProbeURL(cfg *config.Config, raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", errors.New("probe url is required") + } + if cfg != nil && cfg.Security.URLAllowlist.Enabled { + normalized, err := urlvalidator.ValidateHTTPSURL(trimmed, urlvalidator.ValidationOptions{ + AllowedHosts: cfg.Security.URLAllowlist.UpstreamHosts, + RequireAllowlist: true, + AllowPrivate: cfg.Security.URLAllowlist.AllowPrivateHosts, + }) + if err != nil { + return "", fmt.Errorf("probe target rejected by URL security policy: %w", err) + } + return normalized, nil + } + var allowInsecureHTTP bool + if cfg != nil { + allowInsecureHTTP = cfg.Security.URLAllowlist.AllowInsecureHTTP + } + normalized, err := urlvalidator.ValidateURLFormat(trimmed, allowInsecureHTTP) + if err != nil { + return "", fmt.Errorf("probe target rejected by URL security policy: %w", err) + } + return normalized, nil +} diff --git a/backend/internal/service/cn_provider_probe_url_test.go b/backend/internal/service/cn_provider_probe_url_test.go new file mode 100644 index 000000000000..f28508840053 --- /dev/null +++ b/backend/internal/service/cn_provider_probe_url_test.go @@ -0,0 +1,129 @@ +package service + +// CN 供应商探测端点 URL 安全策略回归测试(review B4): +// 配额/余额探测不得绕过 security.url_allowlist——base_url 衍生的探测端点 +// 必须先过运营者策略,被拒绝时不得发起任何上游请求(API key 不出站)。 + +import ( + "context" + "net/http" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/stretchr/testify/require" +) + +func cnProbeAllowlistConfig(hosts ...string) *config.Config { + return &config.Config{ + Security: config.SecurityConfig{ + URLAllowlist: config.URLAllowlistConfig{ + Enabled: true, + UpstreamHosts: hosts, + }, + }, + } +} + +func TestCNValidateProbeURL_AllowlistPolicy(t *testing.T) { + cfg := cnProbeAllowlistConfig("api.moonshot.cn", "api.deepseek.com") + + // 白名单内主机放行(保留完整路径)。 + ok, err := cnValidateProbeURL(cfg, "https://api.moonshot.cn/v1/users/me/balance") + require.NoError(t, err) + require.Equal(t, "https://api.moonshot.cn/v1/users/me/balance", ok) + + // 白名单外主机拒绝。 + _, err = cnValidateProbeURL(cfg, "https://relay.attacker.example/v1/usages") + require.Error(t, err) + require.Contains(t, err.Error(), "rejected by URL security policy") + + // 私网主机拒绝(内网探测面)。 + _, err = cnValidateProbeURL(cfg, "http://169.254.169.254/latest/meta-data") + require.Error(t, err) + + // 白名单关闭:仅格式校验,任意 https 主机放行。 + formatOnly, err := cnValidateProbeURL(&config.Config{ + Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}, + }, "https://relay.attacker.example/v1/usages") + require.NoError(t, err) + require.Equal(t, "https://relay.attacker.example/v1/usages", formatOnly) +} + +// recordingHTTPUpstream 断言探测被策略拒绝时没有任何上游请求发出。 +type recordingHTTPUpstream struct{ calls int } + +func (u *recordingHTTPUpstream) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) { + u.calls++ + return nil, context.DeadlineExceeded +} + +func (u *recordingHTTPUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) { + u.calls++ + return nil, context.DeadlineExceeded +} + +type fakeCNProbeAccountRepo struct { + AccountRepository + account *Account +} + +func (r *fakeCNProbeAccountRepo) GetByID(ctx context.Context, id int64) (*Account, error) { + return r.account, nil +} + +// kimi coding 账号的 base_url 指向中转(含 api.kimi.com/coding 路径段即可被识别 +// 为 kimi coding plan)→ 衍生额度端点落在中转主机上,白名单未列名必须拒绝。 +func TestCNProviderQuotaService_RejectsURLBlockedByPolicy(t *testing.T) { + repo := &fakeCNProbeAccountRepo{account: &Account{ + ID: 1, Platform: PlatformKimi, Type: AccountTypeAPIKey, Status: StatusActive, + Credentials: map[string]any{ + "account_mode": "coding", + "api_key": "sk-test", + "base_url": "https://relay.attacker.example/api.kimi.com/coding", + }, + }} + upstream := &recordingHTTPUpstream{} + svc := NewCNProviderQuotaService(repo, nil, upstream, cnProbeAllowlistConfig("api.kimi.com")) + + _, err := svc.QueryUsage(context.Background(), 1) + require.Error(t, err) + require.Contains(t, err.Error(), "CN_QUOTA_URL_REJECTED") + require.Zero(t, upstream.calls, "probe must not issue any upstream request when URL policy rejects the target") +} + +// deepseek payg 账号自定义 base_url → 余额端点落在中转主机上,必须先过策略。 +func TestCNProviderBalanceService_RejectsURLBlockedByPolicy(t *testing.T) { + repo := &fakeCNProbeAccountRepo{account: &Account{ + ID: 2, Platform: PlatformDeepseek, Type: AccountTypeAPIKey, Status: StatusActive, + Credentials: map[string]any{ + "account_mode": "payg", + "api_key": "sk-test", + "base_url": "https://relay.attacker.example", + }, + }} + upstream := &recordingHTTPUpstream{} + svc := NewCNProviderBalanceService(repo, nil, upstream, cnProbeAllowlistConfig("api.deepseek.com")) + + _, err := svc.QueryBalance(context.Background(), 2) + require.Error(t, err) + require.Contains(t, err.Error(), "CN_BALANCE_URL_REJECTED") + require.Zero(t, upstream.calls, "probe must not issue any upstream request when URL policy rejects the target") +} + +// 白名单包含官方主机的正常路径:URL 校验通过后才发出上游请求(此处允许到达 +// httpUpstream 层即视为通过校验,不发真实网络)。 +func TestCNProviderBalanceService_OfficialHostPassesValidation(t *testing.T) { + repo := &fakeCNProbeAccountRepo{account: &Account{ + ID: 3, Platform: PlatformDeepseek, Type: AccountTypeAPIKey, Status: StatusActive, + Credentials: map[string]any{ + "account_mode": "payg", + "api_key": "sk-test", + }, + }} + upstream := &recordingHTTPUpstream{} + svc := NewCNProviderBalanceService(repo, nil, upstream, cnProbeAllowlistConfig("api.deepseek.com")) + + _, _ = svc.QueryBalance(context.Background(), 3) + require.Equal(t, 1, upstream.calls, "official host must pass URL policy and reach the upstream layer") +} diff --git a/backend/internal/service/cn_provider_quota_service.go b/backend/internal/service/cn_provider_quota_service.go new file mode 100644 index 000000000000..f4cda8d6d121 --- /dev/null +++ b/backend/internal/service/cn_provider_quota_service.go @@ -0,0 +1,565 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/tidwall/gjson" + "golang.org/x/sync/singleflight" +) + +// 国产供应商 Coding Plan 滚动窗口额度探测服务(Kimi For Coding / 智谱 GLM Coding Plan)。 +// +// 与 grok_quota_service 不同:CN 供应商走数据面 API Key(无 OAuth token provider), +// 额度端点为只读 GET,解析 5h + weekly 两档滚动窗口并落 account.Extra 快照, +// 供账号调度阈值评估(account_scheduling_threshold_eval.go)做主动停调。 +// +// 解析逻辑对齐 cc-switch(farion1231/cc-switch)services/coding_plan.rs 的 +// query_kimi / query_zhipu,包括智谱 unit 字段优先分类与 reset 兜底启发式。 +const ( + cnQuotaUpstreamTimeout = 15 * time.Second + cnQuotaMaxBodyBytes = 256 * 1024 + + // Extra 快照键后缀(加 provider 前缀,如 kimi_5h_used_percent)。 + cnExtraSuffix5hUsed = "5h_used_percent" + cnExtraSuffix5hReset = "5h_reset_at" + cnExtraSuffixWeeklyUsed = "weekly_used_percent" + cnExtraSuffixWeeklyReset = "weekly_reset_at" + cnExtraSuffixUsageUpdated = "usage_updated_at" +) + +// cnExtraKey 拼接 provider 维度的 extra 键。 +func cnExtraKey(provider, suffix string) string { return provider + "_" + suffix } + +// CNQuotaTier 表示一个滚动用量窗口档位(5h / weekly)。 +type CNQuotaTier struct { + Window string `json:"window"` // "5h" | "weekly" + UsedPercent float64 `json:"used_percent"` // 已用百分比(0-100+,不做裁剪) + ResetAt string `json:"reset_at,omitempty"` // RFC3339,空表示无重置时间 +} + +// CNProviderQuotaProbeResult 是 Coding Plan 额度探测的返回结构(管理端 + UI 消费)。 +type CNProviderQuotaProbeResult struct { + Provider string `json:"provider"` + Source string `json:"source"` + Success bool `json:"success"` + CredentialValid bool `json:"credential_valid"` // false = 401/403 鉴权失败 + Tiers []CNQuotaTier `json:"tiers,omitempty"` + PlanLevel string `json:"plan_level,omitempty"` // 智谱套餐等级 + StatusCode int `json:"status_code,omitempty"` + FetchedAt int64 `json:"fetched_at"` + Persisted bool `json:"persisted"` + Error string `json:"error,omitempty"` +} + +// CNProviderQuotaService 探测 Kimi / Zhipu Coding Plan 的滚动窗口用量。 +type CNProviderQuotaService struct { + accountRepo AccountRepository + proxyRepo ProxyRepository + httpUpstream HTTPUpstream + cfg *config.Config + flight singleflight.Group +} + +// NewCNProviderQuotaService 构造 Coding Plan 额度探测服务。 +func NewCNProviderQuotaService( + accountRepo AccountRepository, + proxyRepo ProxyRepository, + httpUpstream HTTPUpstream, + cfg *config.Config, +) *CNProviderQuotaService { + return &CNProviderQuotaService{ + accountRepo: accountRepo, + proxyRepo: proxyRepo, + httpUpstream: httpUpstream, + cfg: cfg, + } +} + +// QueryUsage 探测指定账号的 Coding Plan 滚动窗口用量并落 Extra 快照。 +// 同一账号的并发探测会被 singleflight 合并。 +func (s *CNProviderQuotaService) QueryUsage(ctx context.Context, accountID int64) (*CNProviderQuotaProbeResult, error) { + if s == nil || s.accountRepo == nil || s.httpUpstream == nil { + return nil, infraerrors.New(http.StatusInternalServerError, "CN_QUOTA_NOT_CONFIGURED", "cn provider quota service is not configured") + } + key := "cn_quota:" + strconv.FormatInt(accountID, 10) + resultCh := s.flight.DoChan(key, func() (any, error) { + probeCtx, cancel := context.WithTimeout(context.Background(), cnQuotaUpstreamTimeout+5*time.Second) + defer cancel() + return s.queryUsage(probeCtx, accountID) + }) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case flightResult := <-resultCh: + if flightResult.Err != nil { + return nil, flightResult.Err + } + result, ok := flightResult.Val.(*CNProviderQuotaProbeResult) + if !ok || result == nil { + return nil, infraerrors.New(http.StatusInternalServerError, "CN_QUOTA_PROBE_RESULT_INVALID", "invalid cn provider quota probe result") + } + cloned := *result + return &cloned, nil + } +} + +func (s *CNProviderQuotaService) queryUsage(ctx context.Context, accountID int64) (*CNProviderQuotaProbeResult, error) { + account, err := s.loadCodingPlanAccount(ctx, accountID) + if err != nil { + return nil, err + } + + provider := account.GetCodingPlanProvider() + if provider != PlatformKimi && provider != PlatformZhipu { + return nil, infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "account is not a kimi/zhipu coding plan account") + } + + apiKey := strings.TrimSpace(account.GetCNAPIKey()) + if apiKey == "" { + return nil, infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NO_APIKEY", "account api_key is empty") + } + + baseURL := account.GetOpenAIBaseURL() + var ( + targetURL string + authHeader string + ) + switch provider { + case PlatformKimi: + targetURL = kimiQuotaURL(baseURL) + authHeader = "Bearer " + apiKey + case PlatformZhipu: + targetURL = zhipuQuotaURL(baseURL) + authHeader = apiKey // 智谱额度端点鉴权不加 Bearer 前缀 + } + + // 探测发起前过出站 URL 安全策略(与网关转发/Grok 探测同一套校验): + // 端点多由账号 base_url 衍生,不得把 API key 发往策略外主机。 + validatedURL, err := cnValidateProbeURL(s.cfg, targetURL) + if err != nil { + return nil, infraerrors.New(http.StatusForbidden, "CN_QUOTA_URL_REJECTED", err.Error()) + } + targetURL = validatedURL + + proxyURL := s.resolveProxyURL(ctx, account) + callCtx, cancel := context.WithTimeout(ctx, cnQuotaUpstreamTimeout) + defer cancel() + req, err := http.NewRequestWithContext(callCtx, http.MethodGet, targetURL, nil) + if err != nil { + return nil, infraerrors.Newf(http.StatusInternalServerError, "CN_QUOTA_REQUEST_BUILD_FAILED", "build request: %v", err) + } + req.Header.Set("Authorization", authHeader) + req.Header.Set("Accept", "application/json") + if provider == PlatformZhipu { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept-Language", "en-US,en") + } + // 探测与真实转发保持同一套账号级请求头覆写,避免探测通过但转发失败。 + account.ApplyHeaderOverrides(req.Header) + + resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, maxInt(account.Concurrency, 1)) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadGateway, "CN_QUOTA_REQUEST_FAILED", "upstream request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, cnQuotaMaxBodyBytes)) + + now := time.Now().UTC() + result := &CNProviderQuotaProbeResult{ + Provider: provider, + Source: "coding_plan", + FetchedAt: now.Unix(), + StatusCode: resp.StatusCode, + } + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + // 鉴权失败:不落快照(不覆盖之前的有效值),仅返回失败结果供前端提示。 + result.Error = fmt.Sprintf("Authentication failed (HTTP %d)", resp.StatusCode) + return result, nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + result.Error = fmt.Sprintf("API error (HTTP %d): %s", resp.StatusCode, truncate(strings.TrimSpace(string(bodyBytes)), 240)) + return result, nil + } + + // 智谱业务级错误(HTTP 2xx 但 success=false)。 + if provider == PlatformZhipu { + if success := gjson.GetBytes(bodyBytes, "success"); success.Exists() && !success.Bool() { + msg := strings.TrimSpace(gjson.GetBytes(bodyBytes, "msg").String()) + if msg == "" { + msg = "unknown zhipu quota error" + } + result.Error = "API error: " + msg + return result, nil + } + } + + var tiers []CNQuotaTier + switch provider { + case PlatformKimi: + tiers = parseKimiUsageTiers(bodyBytes) + case PlatformZhipu: + tiers = parseZhipuTokenTiers(gjson.GetBytes(bodyBytes, "data")) + result.PlanLevel = strings.TrimSpace(gjson.GetBytes(bodyBytes, "data.level").String()) + } + result.Tiers = tiers + result.Success = true + result.CredentialValid = true + + updates := cnQuotaExtraUpdates(provider, tiers, now) + if err := s.accountRepo.UpdateExtra(ctx, account.ID, updates); err != nil { + slog.Warn("cn_quota_persist_failed", "account_id", account.ID, "provider", provider, "error", err) + } else { + result.Persisted = true + } + return result, nil +} + +func (s *CNProviderQuotaService) loadCodingPlanAccount(ctx context.Context, accountID int64) (*Account, error) { + account, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil { + return nil, infraerrors.Newf(http.StatusNotFound, "CN_QUOTA_ACCOUNT_NOT_FOUND", "account not found: %v", err) + } + if account == nil { + return nil, infraerrors.New(http.StatusNotFound, "CN_QUOTA_ACCOUNT_NOT_FOUND", "account not found") + } + if !account.IsCNProvider() { + return nil, infraerrors.New(http.StatusBadRequest, "CN_QUOTA_INVALID_PLATFORM", "account is not a CN provider account") + } + if !account.IsCodingPlan() { + return nil, infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "account is not a coding plan account") + } + return account, nil +} + +func (s *CNProviderQuotaService) resolveProxyURL(ctx context.Context, account *Account) string { + if account == nil || account.ProxyID == nil { + return "" + } + if account.Proxy != nil { + return account.Proxy.URL() + } + if s != nil && s.proxyRepo != nil { + if proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && proxy != nil { + account.Proxy = proxy + return proxy.URL() + } + } + return "" +} + +// zhipuQuotaURL 根据 base_url 解析智谱额度端点(与数据面推理域名同主机)。 +func zhipuQuotaURL(baseURL string) string { + return zhipuQuotaHost(baseURL) + "/api/monitor/usage/quota/limit" +} + +// kimiQuotaURL 根据 base_url 解析 Kimi For Coding 额度端点。 +// cc-switch query_kimi 固定探测 https://api.kimi.com/coding/v1/usages +// (实测 /coding/usages 无 /v1 → 404)。coding/v1(CC 协议默认)与 +// coding(Anthropic 协议默认)两种 base 统一剥掉尾部后拼回 /v1/usages, +// 协议切换不影响额度探测端点。 +func kimiQuotaURL(baseURL string) string { + base := strings.TrimSuffix(strings.TrimRight(baseURL, "/"), "/v1") + return base + "/v1/usages" +} + +func zhipuQuotaHost(baseURL string) string { + switch u := strings.ToLower(baseURL); { + case strings.Contains(u, "bigmodel.cn"): + return "https://open.bigmodel.cn" + case strings.Contains(u, "z.ai"): + return "https://api.z.ai" + default: + // 国产优先:未知域名回落国内站(与前端 zhipu 预设一致)。 + return "https://open.bigmodel.cn" + } +} + +// parseKimiUsageTiers 解析 Kimi For Coding 的 /usages 响应。 +// +// - limits[].detail.{limit,remaining,resetTime} → 5h 窗口(取首个 detail) +// - usage.{limit,remaining,resetTime} → 周窗口 +// +// utilization = (limit-remaining)/limit*100。 +func parseKimiUsageTiers(body []byte) []CNQuotaTier { + var tiers []CNQuotaTier + + if limits := gjson.GetBytes(body, "limits"); limits.IsArray() { + limits.ForEach(func(_, item gjson.Result) bool { + detail := item.Get("detail") + if !detail.Exists() { + return true + } + limit, _ := cnParseF64(detail.Get("limit").Value()) + remaining, _ := cnParseF64(detail.Get("remaining").Value()) + used := limit - remaining + if used < 0 { + used = 0 + } + var util float64 + if limit > 0 { + util = used / limit * 100 + } + tiers = append(tiers, CNQuotaTier{ + Window: "5h", + UsedPercent: util, + ResetAt: cnNormalizeResetTime(detail.Get("resetTime").Value()), + }) + return false // 取首个 detail 作为 5h 窗口 + }) + } + + if usage := gjson.GetBytes(body, "usage"); usage.Exists() { + limit, _ := cnParseF64(usage.Get("limit").Value()) + remaining, _ := cnParseF64(usage.Get("remaining").Value()) + used := limit - remaining + if used < 0 { + used = 0 + } + var util float64 + if limit > 0 { + util = used / limit * 100 + } + tiers = append(tiers, CNQuotaTier{ + Window: "weekly", + UsedPercent: util, + ResetAt: cnNormalizeResetTime(usage.Get("resetTime").Value()), + }) + } + + return tiers +} + +// cnZhipuWindow 标识智谱 TOKENS_LIMIT 条目所属窗口。 +type cnZhipuWindow int + +const ( + cnZhipuWindowUnknown cnZhipuWindow = iota + cnZhipuWindow5h + cnZhipuWindowWeekly +) + +// classifyZhipuWindowUnit 按 unit 字段判定窗口类型(3=5h,6=weekly)。 +// unit 缺失或未识别时返回 Unknown,由调用方走 reset 时间启发式兜底。 +func classifyZhipuWindowUnit(unit int64) cnZhipuWindow { + switch unit { + case 3: + return cnZhipuWindow5h + case 6: + return cnZhipuWindowWeekly + default: + return cnZhipuWindowUnknown + } +} + +// parseZhipuTokenTiers 解析智谱额度响应 data.limits 为 5h + weekly 两档。 +// +// 分类优先级(对齐 cc-switch parse_zhipu_token_tiers,issue #3036): +// 1. 显式 unit 字段(3=5h / 6=weekly)——不能用 reset 排序代替,周期末尾 +// 周窗口会比 5h 更早重置,时间排序必然标反。 +// 2. unit 缺失/未识别:无 nextResetTime 的条目优先归 5h(0% 状态下 5h 桶可能 +// 没有 reset),其余按 reset 升序依次填入仍空缺的槽位。 +// +// CREDIT_LIMIT(信用额度)与 TOKENS_LIMIT(token 窗口)度量不同:两者同时返回时 +// 只让 TOKENS_LIMIT 参与 5h/weekly 槽位竞争,避免信用额度百分比污染阈值停调 +// 快照;仅当无任何 TOKENS_LIMIT 条目时才降级用 CREDIT_LIMIT 展示。 +// 老套餐只回 1 条 TOKENS_LIMIT,自然降级为仅 5h;新套餐回 2 条。 +func parseZhipuTokenTiers(data gjson.Result) []CNQuotaTier { + type entry struct { + resetMs int64 + hasReset bool + percentage float64 + resetISO string + } + var ( + fiveHour entry + fiveHourSet bool + weekly entry + weeklySet bool + unclassified []entry + ) + + classify := func(item gjson.Result, e entry) { + switch classifyZhipuWindowUnit(item.Get("unit").Int()) { + case cnZhipuWindow5h: + if !fiveHourSet { + fiveHour, fiveHourSet = e, true + } else { + unclassified = append(unclassified, e) + } + case cnZhipuWindowWeekly: + if !weeklySet { + weekly, weeklySet = e, true + } else { + unclassified = append(unclassified, e) + } + default: + unclassified = append(unclassified, e) + } + } + var creditFallback []entry + hasTokensLimit := false + + data.Get("limits").ForEach(func(_, item gjson.Result) bool { + limitType := strings.ToUpper(strings.TrimSpace(item.Get("type").String())) + if limitType != "TOKENS_LIMIT" && limitType != "CREDIT_LIMIT" { + return true + } + percentage := 0.0 + if p, ok := cnParseF64(item.Get("percentage").Value()); ok { + percentage = p + } + var ( + resetMs int64 + hasReset bool + resetISO string + ) + if nr := item.Get("nextResetTime"); nr.Exists() { + switch nr.Type { + case gjson.Number: + resetMs = nr.Int() + hasReset = resetMs > 0 + resetISO = cnMillisToRFC3339(resetMs) + case gjson.String: + resetISO = cnNormalizeResetTime(nr.String()) + hasReset = resetISO != "" + } + } + e := entry{resetMs: resetMs, hasReset: hasReset, percentage: percentage, resetISO: resetISO} + if limitType == "TOKENS_LIMIT" { + hasTokensLimit = true + classify(item, e) + } else { + creditFallback = append(creditFallback, e) + } + return true + }) + + // 无任何 TOKENS_LIMIT 条目(部分套餐只报信用额度):降级用 CREDIT_LIMIT 展示。 + if !hasTokensLimit { + unclassified = append(unclassified, creditFallback...) + } + + // 无 reset 的条目排前,再按 reset 升序,依次填入仍空缺的槽位。 + sort.SliceStable(unclassified, func(i, j int) bool { + if unclassified[i].hasReset != unclassified[j].hasReset { + return !unclassified[i].hasReset + } + return unclassified[i].resetMs < unclassified[j].resetMs + }) + for _, e := range unclassified { + switch { + case !fiveHourSet: + fiveHour, fiveHourSet = e, true + case !weeklySet: + weekly, weeklySet = e, true + } + } + + var tiers []CNQuotaTier + if fiveHourSet { + tiers = append(tiers, CNQuotaTier{Window: "5h", UsedPercent: fiveHour.percentage, ResetAt: fiveHour.resetISO}) + } + if weeklySet { + tiers = append(tiers, CNQuotaTier{Window: "weekly", UsedPercent: weekly.percentage, ResetAt: weekly.resetISO}) + } + return tiers +} + +// cnQuotaExtraUpdates 根据 tier 列表构造 provider 维度的 Extra 快照更新。 +func cnQuotaExtraUpdates(provider string, tiers []CNQuotaTier, now time.Time) map[string]any { + updates := map[string]any{ + cnExtraKey(provider, cnExtraSuffixUsageUpdated): now.Format(time.RFC3339), + } + for _, t := range tiers { + switch t.Window { + case "5h": + updates[cnExtraKey(provider, cnExtraSuffix5hUsed)] = t.UsedPercent + if t.ResetAt != "" { + updates[cnExtraKey(provider, cnExtraSuffix5hReset)] = t.ResetAt + } + case "weekly": + updates[cnExtraKey(provider, cnExtraSuffixWeeklyUsed)] = t.UsedPercent + if t.ResetAt != "" { + updates[cnExtraKey(provider, cnExtraSuffixWeeklyReset)] = t.ResetAt + } + } + } + return updates +} + +// cnParseF64 把 JSON 数值或字符串解析为 float64(兼容 "100" 与 100)。 +func cnParseF64(raw any) (float64, bool) { + switch v := raw.(type) { + case float64: + return v, true + case float32: + return float64(v), true + case int: + return float64(v), true + case int64: + return float64(v), true + case json.Number: + f, err := v.Float64() + return f, err == nil + case string: + f, err := strconv.ParseFloat(strings.TrimSpace(v), 64) + return f, err == nil + default: + return 0, false + } +} + +// cnNormalizeResetTime 把上游重置时间(ISO8601 字符串 / 秒级 / 毫秒级数字)归一化为 +// RFC3339 字符串;无法识别或非正时间戳返回空串。 +func cnNormalizeResetTime(raw any) string { + switch v := raw.(type) { + case string: + s := strings.TrimSpace(v) + if s == "" { + return "" + } + if ts, err := parseSchedulingTime(s); err == nil { + return ts.UTC().Format(time.RFC3339) + } + return "" + case float64: + return cnMillisToRFC3339(int64(v)) + case int: + return cnMillisToRFC3339(int64(v)) + case int64: + return cnMillisToRFC3339(v) + case json.Number: + if n, err := v.Int64(); err == nil { + return cnMillisToRFC3339(n) + } + return "" + default: + return "" + } +} + +// cnMillisToRFC3339 把秒级(<1e12)或毫秒级时间戳转为 RFC3339 字符串;非正返回空串。 +func cnMillisToRFC3339(n int64) string { + if n <= 0 { + return "" + } + var ms int64 + if n < 1_000_000_000_000 { + ms = n * 1000 + } else { + ms = n + } + return time.UnixMilli(ms).UTC().Format(time.RFC3339) +} diff --git a/backend/internal/service/cn_providers_test.go b/backend/internal/service/cn_providers_test.go new file mode 100644 index 000000000000..24b8bd440541 --- /dev/null +++ b/backend/internal/service/cn_providers_test.go @@ -0,0 +1,625 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "github.com/Wei-Shaw/sub2api/internal/config" +) + +// TestCNExtraKey 验证 provider 维度的 Extra 快照键由前缀 + 后缀拼接。 +func TestCNExtraKey(t *testing.T) { + t.Parallel() + require.Equal(t, "kimi_5h_used_percent", cnExtraKey(PlatformKimi, cnExtraSuffix5hUsed)) + require.Equal(t, "zhipu_weekly_reset_at", cnExtraKey(PlatformZhipu, cnExtraSuffixWeeklyReset)) + require.Equal(t, "deepseek_balance", cnExtraKey(PlatformDeepseek, cnBalanceExtraSuffixBalance)) +} + +// TestCNParseF64 兼容 JSON 数值与字符串(cc-switch 与上游字段类型不一致)。 +func TestCNParseF64(t *testing.T) { + t.Parallel() + cases := []struct { + name string + raw any + want float64 + ok bool + }{ + {"float64", float64(12.5), 12.5, true}, + {"int", 100, 100, true}, + {"numeric string", "33.3", 33.3, true}, + {"trim string", " 7 ", 7, true}, + {"non-numeric string", "abc", 0, false}, + {"nil", nil, 0, false}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, ok := cnParseF64(tc.raw) + require.Equal(t, tc.ok, ok) + if ok { + require.InDelta(t, tc.want, got, 1e-9) + } + }) + } +} + +// TestCNMillisToRFC3339 秒级(<1e12)按秒、毫秒级按毫秒处理;非正返回空串。 +func TestCNMillisToRFC3339(t *testing.T) { + t.Parallel() + // 1700000000 秒 = 1700000000000 毫秒 + want := time.UnixMilli(1700000000000).UTC().Format(time.RFC3339) + require.Equal(t, want, cnMillisToRFC3339(1700000000)) // 秒级 + require.Equal(t, want, cnMillisToRFC3339(1700000000000)) // 毫秒级 + require.Equal(t, "", cnMillisToRFC3339(0)) // 非正 + require.Equal(t, "", cnMillisToRFC3339(-1)) +} + +// TestCnnormalizeResetTime 覆盖 ISO8601 字符串 / 数字(秒、毫秒)/ 非法输入。 +func TestCnnormalizeResetTime(t *testing.T) { + t.Parallel() + // ISO8601 字符串归一化为 RFC3339(UTC)。 + require.Equal(t, "2026-08-14T10:00:00Z", cnNormalizeResetTime("2026-08-14T10:00:00Z")) + // 毫秒级 float64。 + require.Equal(t, + time.UnixMilli(1700000000000).UTC().Format(time.RFC3339), + cnNormalizeResetTime(float64(1700000000000))) + // 非法字符串。 + require.Equal(t, "", cnNormalizeResetTime("not-a-time")) + require.Equal(t, "", cnNormalizeResetTime("")) +} + +// TestParseKimiUsageTiers 验证 Kimi For Coding /usages 解析: +// - 首个 limits[].detail → 5h 桶,utilization=(limit-remaining)/limit*100 +// - usage → weekly 桶 +// - 仅取首个 detail(多个 detail 时不应重复产出 5h) +func TestParseKimiUsageTiers(t *testing.T) { + t.Parallel() + body := []byte(`{ + "limits": [ + {"name": "5h", "detail": {"limit": 1000, "remaining": 600, "resetTime": "2026-08-14T15:00:00Z"}}, + {"name": "ignored-second-detail", "detail": {"limit": 999, "remaining": 0, "resetTime": "2026-08-14T20:00:00Z"}} + ], + "usage": {"limit": 10000, "remaining": 4000, "resetTime": "2026-08-18T00:00:00Z"} + }`) + tiers := parseKimiUsageTiers(body) + require.Len(t, tiers, 2) + require.Equal(t, "5h", tiers[0].Window) + require.InDelta(t, 40.0, tiers[0].UsedPercent, 1e-9) // (1000-600)/1000*100 + require.Equal(t, "2026-08-14T15:00:00Z", tiers[0].ResetAt) + require.Equal(t, "weekly", tiers[1].Window) + require.InDelta(t, 60.0, tiers[1].UsedPercent, 1e-9) // (10000-4000)/10000*100 + require.Equal(t, "2026-08-18T00:00:00Z", tiers[1].ResetAt) +} + +// TestParseKimiUsageTiers_LimitZero 不应除零:limit=0 → utilization=0。 +func TestParseKimiUsageTiers_LimitZero(t *testing.T) { + t.Parallel() + body := []byte(`{"limits":[{"detail":{"limit":0,"remaining":0,"resetTime":"2026-08-14T15:00:00Z"}}]}`) + tiers := parseKimiUsageTiers(body) + require.Len(t, tiers, 1) + require.InDelta(t, 0.0, tiers[0].UsedPercent, 1e-9) +} + +// TestParseZhipuTokenTiers_UnitClassification 显式 unit(3=5h / 6=weekly)优先分类, +// 不能被 reset 时间排序覆盖(周期末尾周窗口会更早重置)。 +func TestParseZhipuTokenTiers_UnitClassification(t *testing.T) { + t.Parallel() + // weekly 的 nextResetTime 早于 5h(模拟周期末尾),但 unit 必须胜出。 + data := gjson.Parse(`{ + "limits": [ + {"type":"TOKENS_LIMIT","unit":6,"percentage":70,"nextResetTime":1700000000000}, + {"type":"TOKENS_LIMIT","unit":3,"percentage":20,"nextResetTime":1700000099999} + ] + }`) + tiers := parseZhipuTokenTiers(data) + require.Len(t, tiers, 2) + require.Equal(t, "5h", tiers[0].Window) + require.InDelta(t, 20.0, tiers[0].UsedPercent, 1e-9) + require.Equal(t, "weekly", tiers[1].Window) + require.InDelta(t, 70.0, tiers[1].UsedPercent, 1e-9) +} + +// TestParseZhipuTokenTiers_SingleTierOldPlan 老套餐仅回 1 条 → 降级为仅 5h。 +func TestParseZhipuTokenTiers_SingleTierOldPlan(t *testing.T) { + t.Parallel() + data := gjson.Parse(`{"limits":[{"type":"TOKENS_LIMIT","unit":3,"percentage":15,"nextResetTime":1700000000000}]}`) + tiers := parseZhipuTokenTiers(data) + require.Len(t, tiers, 1) + require.Equal(t, "5h", tiers[0].Window) +} + +// TestParseZhipuTokenTiers_FallbackHeuristic unit 缺失时:无 reset 的条目优先归 5h, +// 其余按 reset 升序填入剩余槽位。 +func TestParseZhipuTokenTiers_FallbackHeuristic(t *testing.T) { + t.Parallel() + // 无 unit:A 无 reset、B 有 reset。A 先填 5h,B 填 weekly。 + data := gjson.Parse(`{ + "limits": [ + {"type":"TOKENS_LIMIT","percentage":50,"nextResetTime":1700000000000}, + {"type":"TOKENS_LIMIT","percentage":10} + ] + }`) + tiers := parseZhipuTokenTiers(data) + require.Len(t, tiers, 2) + require.Equal(t, "5h", tiers[0].Window) + require.InDelta(t, 10.0, tiers[0].UsedPercent, 1e-9) // 无 reset 优先 5h + require.Equal(t, "weekly", tiers[1].Window) + require.InDelta(t, 50.0, tiers[1].UsedPercent, 1e-9) +} + +// TestParseZhipuTokenTiers_IgnoresNonTokenEntries 非 TOKENS_LIMIT/CREDIT_LIMIT 条目跳过。 +func TestParseZhipuTokenTiers_IgnoresNonTokenEntries(t *testing.T) { + t.Parallel() + data := gjson.Parse(`{"limits":[{"type":"OTHER_LIMIT","unit":3,"percentage":99}]}`) + require.Empty(t, parseZhipuTokenTiers(data)) +} + +// TestCNQuotaExtraUpdates 验证 tier 列表落 Extra 快照键的 provider 前缀与窗口映射。 +func TestCNQuotaExtraUpdates(t *testing.T) { + t.Parallel() + now := time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC) + tiers := []CNQuotaTier{ + {Window: "5h", UsedPercent: 40, ResetAt: "2026-08-14T15:00:00Z"}, + {Window: "weekly", UsedPercent: 60, ResetAt: "2026-08-18T00:00:00Z"}, + } + updates := cnQuotaExtraUpdates(PlatformKimi, tiers, now) + require.Equal(t, 40.0, updates["kimi_5h_used_percent"]) + require.Equal(t, "2026-08-14T15:00:00Z", updates["kimi_5h_reset_at"]) + require.Equal(t, 60.0, updates["kimi_weekly_used_percent"]) + require.Equal(t, "2026-08-18T00:00:00Z", updates["kimi_weekly_reset_at"]) + require.Equal(t, now.Format(time.RFC3339), updates["kimi_usage_updated_at"]) +} + +// TestCNProviderResponseIndicatesInsufficientBalance 覆盖中英文余额不足文案与否定用例。 +func TestCNProviderResponseIndicatesInsufficientBalance(t *testing.T) { + t.Parallel() + positive := []string{ + `{"error":{"message":"余额不足"}}`, + `{"error":{"message":"Insufficient balance"}}`, + `{"code":"insufficient_credit"}`, + `"balance is not enough"`, + `"no enough balance"`, + } + for _, body := range positive { + require.True(t, cnProviderResponseIndicatesInsufficientBalance([]byte(body)), body) + } + negative := []string{ + `{"error":{"message":"rate limit exceeded"}}`, + `{"error":{"message":"quota exhausted"}}`, + ``, + } + for _, body := range negative { + require.False(t, cnProviderResponseIndicatesInsufficientBalance([]byte(body)), body) + } +} + +// TestCNBalanceLowReason 验证稳定前缀(供周期检测任务识别并清除)。 +func TestCNBalanceLowReason(t *testing.T) { + t.Parallel() + require.Equal(t, "cn_balance_low: upstream said x", + cnBalanceLowReason("upstream said x")) + require.Equal(t, "cn_balance_low: 余额不足,账号临时停调", + cnBalanceLowReason(" ")) + require.True(t, len(cnBalanceLowReason("")) > len(cnBalanceLowReasonPrefix)) +} + +// TestZhipuQuotaHost 按域名路由智谱额度端点主机(bigmodel.cn / z.ai / 默认国内站)。 +func TestZhipuQuotaHost(t *testing.T) { + t.Parallel() + require.Equal(t, "https://open.bigmodel.cn", zhipuQuotaHost("https://open.bigmodel.cn/api/paas/v4")) + require.Equal(t, "https://api.z.ai", zhipuQuotaHost("https://api.z.ai/api/paas/v4")) + require.Equal(t, "https://open.bigmodel.cn", zhipuQuotaHost("https://custom.example.com")) // 默认国内站 + require.Equal(t, "https://open.bigmodel.cn/api/monitor/usage/quota/limit", zhipuQuotaURL("https://open.bigmodel.cn/api/paas/v4")) +} + +// TestKimiQuotaURL 两种协议默认 base(coding/v1 与 coding)都归一到 /coding/v1/usages +// (cc-switch 固定端点;无 /v1 的 /coding/usages 实测 404)。 +func TestKimiQuotaURL(t *testing.T) { + t.Parallel() + require.Equal(t, "https://api.kimi.com/coding/v1/usages", kimiQuotaURL("https://api.kimi.com/coding/v1")) + require.Equal(t, "https://api.kimi.com/coding/v1/usages", kimiQuotaURL("https://api.kimi.com/coding")) + require.Equal(t, "https://api.kimi.com/coding/v1/usages", kimiQuotaURL("https://api.kimi.com/coding/")) + require.Equal(t, "https://api.kimi.com/coding/v1/usages", kimiQuotaURL("https://api.kimi.com/coding/v1/")) +} + +// TestCNBalanceURL Kimi 固定端点;DeepSeek 基于 base_url 拼接。 +func TestCNBalanceURL(t *testing.T) { + t.Parallel() + kimi := &Account{Platform: PlatformKimi} + require.Equal(t, "https://api.moonshot.cn/v1/users/me/balance", cnBalanceURL(kimi)) + + deepseek := &Account{ + Platform: PlatformDeepseek, + Credentials: map[string]any{"base_url": "https://api.deepseek.com"}, + } + require.Equal(t, "https://api.deepseek.com/user/balance", cnBalanceURL(deepseek)) +} + +// TestCNProviderThresholdCandidates 从 Extra 快照读取 5h / weekly 候选。 +func TestCNProviderThresholdCandidates(t *testing.T) { + t.Parallel() + account := &Account{ + Platform: PlatformKimi, + Extra: map[string]any{ + "kimi_5h_used_percent": 90.0, + "kimi_5h_reset_at": "2026-08-14T15:00:00Z", + "kimi_weekly_used_percent": 50.0, + "kimi_weekly_reset_at": "2026-08-18T00:00:00Z", + }, + } + cands := cnProviderThresholdCandidates(account, PlatformKimi) + // 仅返回非 nil 候选(两窗口均存在 → 2 条)。 + var present []*accountSchedulingThresholdCandidate + for _, c := range cands { + if c != nil { + present = append(present, c) + } + } + require.Len(t, present, 2) + + // 缺少 used 键的窗口不产生候选。 + partial := &Account{ + Platform: PlatformKimi, + Extra: map[string]any{"kimi_5h_reset_at": "2026-08-14T15:00:00Z"}, // 无 used_percent + } + require.Empty(t, filterNil(cnProviderThresholdCandidates(partial, PlatformKimi))) + + // 空 Extra / nil account 安全返回。 + require.Empty(t, filterNil(cnProviderThresholdCandidates(&Account{Platform: PlatformKimi}, PlatformKimi))) +} + +func filterNil(cands []*accountSchedulingThresholdCandidate) []*accountSchedulingThresholdCandidate { + var out []*accountSchedulingThresholdCandidate + for _, c := range cands { + if c != nil { + out = append(out, c) + } + } + return out +} + +// TestEvaluateAccountSchedulingThreshold_KimiCodingPlan 集成验证:kimi coding 账号 +// 5h 用量超阈值且窗口未重置 → 主动停调至 5h 重置点。 +func TestEvaluateAccountSchedulingThreshold_KimiCodingPlan(t *testing.T) { + t.Parallel() + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + reset := now.Add(3 * time.Hour) + account := &Account{ + Platform: PlatformKimi, + Extra: map[string]any{ + "kimi_5h_used_percent": 90.0, + "kimi_5h_reset_at": reset.Format(time.RFC3339), + "kimi_weekly_used_percent": 30.0, + "kimi_weekly_reset_at": now.Add(7 * 24 * time.Hour).Format(time.RFC3339), + }, + } + decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformKimi: 80}, now) + require.True(t, decision.ShouldPause) + require.Equal(t, PlatformKimi, decision.Platform) + require.Equal(t, "5h", decision.Window) + require.InDelta(t, 90.0, decision.UsedPercent, 1e-9) + require.NotNil(t, decision.Until) + require.True(t, reset.Equal(*decision.Until)) +} + +// TestEvaluateAccountSchedulingThreshold_CNWindowResetSkipped 窗口已重置(reset<=now) +// 或用量低于阈值 → 不停调(candidateMatchesThreshold 要求 until.After(now))。 +func TestEvaluateAccountSchedulingThreshold_CNWindowResetSkipped(t *testing.T) { + t.Parallel() + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + // 重置时间已过。 + expired := &Account{ + Platform: PlatformZhipu, + Extra: map[string]any{ + "zhipu_5h_used_percent": 99.0, + "zhipu_5h_reset_at": now.Add(-1 * time.Hour).Format(time.RFC3339), + }, + } + require.False(t, EvaluateAccountSchedulingThreshold(expired, map[string]int{PlatformZhipu: 80}, now).ShouldPause) + + // 用量低于阈值。 + low := &Account{ + Platform: PlatformZhipu, + Extra: map[string]any{ + "zhipu_5h_used_percent": 20.0, + "zhipu_5h_reset_at": now.Add(3 * time.Hour).Format(time.RFC3339), + }, + } + require.False(t, EvaluateAccountSchedulingThreshold(low, map[string]int{PlatformZhipu: 80}, now).ShouldPause) +} + +// TestCNProviderQuotaSnapshotReset Coding Plan 429 冷却:取快照中最早的「仍在未来」窗口重置点。 +func TestCNProviderQuotaSnapshotReset(t *testing.T) { + t.Parallel() + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + future5h := now.Add(2 * time.Hour) + futureWeekly := now.Add(3 * 24 * time.Hour) + pastWeekly := now.Add(-24 * time.Hour) + + // 5h 在未来、weekly 已过期 → 返回 5h。 + account := &Account{ + Platform: PlatformKimi, + Credentials: map[string]any{"account_mode": AccountModeCoding}, + Extra: map[string]any{ + "kimi_5h_reset_at": future5h.Format(time.RFC3339), + "kimi_weekly_reset_at": pastWeekly.Format(time.RFC3339), + }, + } + got := cnProviderQuotaSnapshotReset(account, now) + require.NotNil(t, got) + require.True(t, future5h.Equal(*got)) + + // 两窗口均在未来 → 取较早者(429 多由 5h 窗口触发,避免冷却到 weekly 重置)。 + both := &Account{ + Platform: PlatformKimi, + Credentials: map[string]any{"account_mode": AccountModeCoding}, + Extra: map[string]any{ + "kimi_5h_reset_at": future5h.Format(time.RFC3339), + "kimi_weekly_reset_at": futureWeekly.Format(time.RFC3339), + }, + } + gotBoth := cnProviderQuotaSnapshotReset(both, now) + require.NotNil(t, gotBoth) + require.True(t, future5h.Equal(*gotBoth)) + + // 两窗口均过期 → nil。 + expired := &Account{ + Platform: PlatformKimi, + Credentials: map[string]any{"account_mode": AccountModeCoding}, + Extra: map[string]any{ + "kimi_5h_reset_at": pastWeekly.Format(time.RFC3339), + "kimi_weekly_reset_at": pastWeekly.Format(time.RFC3339), + }, + } + require.Nil(t, cnProviderQuotaSnapshotReset(expired, now)) + + // payg 账号(非 coding)→ nil(余额型走余额检测)。 + payg := &Account{ + Platform: PlatformKimi, + Credentials: map[string]any{"account_mode": AccountModePayG}, + Extra: map[string]any{"kimi_5h_reset_at": future5h.Format(time.RFC3339)}, + } + require.Nil(t, cnProviderQuotaSnapshotReset(payg, now)) +} + +// TestNormalizeOpenAICompatiblePlatform_SchedulerExactMatch 回归保护: +// grok 与国产供应商原样保留,其余归一为 openai —— 保证 kimi/zhipu/deepseek 分组请求 +// 精确匹配同名账号(与 openai/grok 当前行为一致),不会错误并入 openai 池。 +func TestNormalizeOpenAICompatiblePlatform_SchedulerExactMatch(t *testing.T) { + t.Parallel() + require.Equal(t, PlatformGrok, NormalizeOpenAICompatiblePlatform(PlatformGrok)) + require.Equal(t, PlatformKimi, NormalizeOpenAICompatiblePlatform(PlatformKimi)) + require.Equal(t, PlatformZhipu, NormalizeOpenAICompatiblePlatform(PlatformZhipu)) + require.Equal(t, PlatformDeepseek, NormalizeOpenAICompatiblePlatform(PlatformDeepseek)) + // 其他平台(含空、anthropic、未知)一律归一为 openai。 + require.Equal(t, PlatformOpenAI, NormalizeOpenAICompatiblePlatform("")) + require.Equal(t, PlatformOpenAI, NormalizeOpenAICompatiblePlatform(PlatformAnthropic)) + require.Equal(t, PlatformOpenAI, NormalizeOpenAICompatiblePlatform("something-else")) +} + +// TestGetOpenAIProtocolAPIKey_CNProviders 验证 OpenAI 协议族密钥读取覆盖国产供应商, +// 同时保持 IsOpenAIApiKey 的 openai-only 语义(调度倍率/WS 门控不受影响)。 +func TestGetOpenAIProtocolAPIKey_CNProviders(t *testing.T) { + t.Parallel() + + kimi := &Account{ + Platform: PlatformKimi, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-kimi"}, + } + require.Equal(t, "sk-kimi", kimi.GetOpenAIProtocolAPIKey()) + require.False(t, kimi.IsOpenAIApiKey(), "IsOpenAIApiKey stays openai-only for scheduling gates") + + // 非 APIKey 类型的 CN 账号不返回密钥 + notAPIKey := &Account{ + Platform: PlatformDeepseek, + Type: AccountTypeOAuth, + Credentials: map[string]any{"api_key": "sk-leak"}, + } + require.Equal(t, "", notAPIKey.GetOpenAIProtocolAPIKey()) + + // openai 原生账号行为不变 + openai := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-openai"}, + } + require.Equal(t, "sk-openai", openai.GetOpenAIProtocolAPIKey()) +} + +// TestBuildUpstreamModelsRequest_CNProviders 验证“同步上游支持的模型”对国产供应商可用: +// 密钥经 GetOpenAIProtocolAPIKey 读取,/models 端点拼接到账号 base_url(含默认值)。 +func TestBuildUpstreamModelsRequest_CNProviders(t *testing.T) { + t.Parallel() + + svc := &AccountTestService{cfg: &config.Config{}} + cases := []struct { + name string + platform string + mode string + wantURL string + }{ + {"kimi default", PlatformKimi, "", "https://api.moonshot.cn/v1/models"}, + {"kimi coding", PlatformKimi, AccountModeCoding, "https://api.kimi.com/coding/v1/models"}, + {"zhipu default", PlatformZhipu, "", "https://open.bigmodel.cn/api/paas/v4/models"}, + {"zhipu coding", PlatformZhipu, AccountModeCoding, "https://open.bigmodel.cn/api/coding/paas/v4/models"}, + {"deepseek", PlatformDeepseek, "", "https://api.deepseek.com/v1/models"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + creds := map[string]any{"api_key": "sk-test"} + if tc.mode != "" { + creds["account_mode"] = tc.mode + } + account := &Account{ID: 1, Platform: tc.platform, Type: AccountTypeAPIKey, Credentials: creds} + req, err := svc.buildUpstreamModelsRequest(context.Background(), account) + require.NoError(t, err) + require.Equal(t, tc.wantURL, req.URL.String()) + require.Equal(t, "Bearer sk-test", req.Header.Get("Authorization")) + }) + } +} + +// TestGetAPIProtocol 验证协议凭证维度的平台校验矩阵: +// responses 仅 deepseek;缺失/非法值回退 chat_completions(与旧行为一致)。 +func TestGetAPIProtocol(t *testing.T) { + t.Parallel() + + mk := func(platform, protocol string) *Account { + creds := map[string]any{"api_key": "sk-test"} + if protocol != "" { + creds["api_protocol"] = protocol + } + return &Account{Platform: platform, Type: AccountTypeAPIKey, Credentials: creds} + } + + require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, "").GetAPIProtocol(), "缺失回退默认") + require.Equal(t, APIProtocolAnthropic, mk(PlatformZhipu, APIProtocolAnthropic).GetAPIProtocol()) + require.Equal(t, APIProtocolAnthropic, mk(PlatformKimi, APIProtocolAnthropic).GetAPIProtocol()) + require.Equal(t, APIProtocolAnthropic, mk(PlatformDeepseek, APIProtocolAnthropic).GetAPIProtocol()) + require.Equal(t, APIProtocolResponses, mk(PlatformDeepseek, APIProtocolResponses).GetAPIProtocol()) + require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, APIProtocolResponses).GetAPIProtocol(), "kimi 无 responses 端点") + require.Equal(t, APIProtocolChatCompletions, mk(PlatformZhipu, APIProtocolResponses).GetAPIProtocol(), "zhipu 无 responses 端点") + require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, "bogus").GetAPIProtocol(), "非法值回退默认") + require.Equal(t, APIProtocolChatCompletions, (&Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}).GetAPIProtocol(), "非 CN 供应商恒为默认") +} + +// TestAnthropicProtocolBaseURL 验证 Anthropic 协议默认端点与协议感知的 +// OpenAI 格式 base 回退。 +func TestAnthropicProtocolBaseURL(t *testing.T) { + t.Parallel() + + // 默认端点(按供应商 × 模式) + require.Equal(t, "https://api.moonshot.cn/anthropic", (&Account{ + Platform: PlatformKimi, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_protocol": APIProtocolAnthropic}, + }).GetAnthropicProtocolBaseURL()) + require.Equal(t, "https://api.kimi.com/coding", (&Account{ + Platform: PlatformKimi, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_protocol": APIProtocolAnthropic, "account_mode": AccountModeCoding}, + }).GetAnthropicProtocolBaseURL()) + require.Equal(t, "https://open.bigmodel.cn/api/anthropic", (&Account{ + Platform: PlatformZhipu, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_protocol": APIProtocolAnthropic}, + }).GetAnthropicProtocolBaseURL()) + require.Equal(t, "https://api.deepseek.com/anthropic", (&Account{ + Platform: PlatformDeepseek, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_protocol": APIProtocolAnthropic}, + }).GetAnthropicProtocolBaseURL()) + + // 凭证 base_url 覆盖默认值 + require.Equal(t, "https://custom.example.com/anthropic", (&Account{ + Platform: PlatformZhipu, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_protocol": APIProtocolAnthropic, "base_url": "https://custom.example.com/anthropic"}, + }).GetAnthropicProtocolBaseURL()) + + // 非 Anthropic 协议返回空串 + require.Empty(t, (&Account{ + Platform: PlatformZhipu, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://open.bigmodel.cn/api/paas/v4"}, + }).GetAnthropicProtocolBaseURL()) +} + +// TestGetOpenAIFormatBaseURL_ProtocolAware anthropic 协议账号的凭证 base_url +// 指向 Anthropic 端点,OpenAI 格式路径(模型同步等)必须回退到 CC 默认 base。 +func TestGetOpenAIFormatBaseURL_ProtocolAware(t *testing.T) { + t.Parallel() + + zhipuAnthropic := &Account{ + Platform: PlatformZhipu, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_protocol": APIProtocolAnthropic, + "base_url": "https://open.bigmodel.cn/api/anthropic", + }, + } + require.Equal(t, "https://open.bigmodel.cn/api/paas/v4", zhipuAnthropic.GetOpenAIFormatBaseURL()) + + kimiCodingAnthropic := &Account{ + Platform: PlatformKimi, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_protocol": APIProtocolAnthropic, + "account_mode": AccountModeCoding, + "base_url": "https://api.kimi.com/coding", + }, + } + require.Equal(t, "https://api.kimi.com/coding/v1", kimiCodingAnthropic.GetOpenAIFormatBaseURL()) + + // chat_completions 协议下行为不变(凭证 base_url 原样返回) + ccAccount := &Account{ + Platform: PlatformDeepseek, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://ds-relay.example.com"}, + } + require.Equal(t, "https://ds-relay.example.com", ccAccount.GetOpenAIFormatBaseURL()) +} + +// TestBuildUpstreamModelsRequest_AnthropicProtocol 模型同步使用协议感知 base。 +func TestBuildUpstreamModelsRequest_AnthropicProtocol(t *testing.T) { + t.Parallel() + svc := &AccountTestService{cfg: &config.Config{}} + account := &Account{ + ID: 1, Platform: PlatformZhipu, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "api_protocol": APIProtocolAnthropic, + "base_url": "https://open.bigmodel.cn/api/anthropic", + }, + } + req, err := svc.buildUpstreamModelsRequest(context.Background(), account) + require.NoError(t, err) + require.Equal(t, "https://open.bigmodel.cn/api/paas/v4/models", req.URL.String()) +} + +// TestBuildOpenAIResponsesURLForPlatform deepseek 官方端点为 /responses(无 /v1)。 +func TestBuildOpenAIResponsesURLForPlatform(t *testing.T) { + t.Parallel() + require.Equal(t, "https://api.deepseek.com/responses", buildOpenAIResponsesURLForPlatform(PlatformDeepseek, "https://api.deepseek.com")) + require.Equal(t, "https://api.openai.com/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformOpenAI, "https://api.openai.com")) + require.Equal(t, "https://open.bigmodel.cn/api/paas/v4/responses", buildOpenAIResponsesURLForPlatform(PlatformZhipu, "https://open.bigmodel.cn/api/paas/v4")) +} + +// TestNormalizeDeepSeekResponsesRequestBody 无状态适配:强制 store=false、 +// 清除 previous_response_id;非 deepseek responses 协议原样返回。 +func TestNormalizeDeepSeekResponsesRequestBody(t *testing.T) { + t.Parallel() + + deepseekResponses := &Account{ + Platform: PlatformDeepseek, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_protocol": APIProtocolResponses}, + } + body := []byte(`{"model":"deepseek-v4-pro","store":true,"previous_response_id":"resp_123","input":"hi"}`) + normalized := normalizeDeepSeekResponsesRequestBody(deepseekResponses, body) + require.False(t, gjson.GetBytes(normalized, "store").Bool()) + require.False(t, gjson.GetBytes(normalized, "previous_response_id").Exists()) + require.Equal(t, "deepseek-v4-pro", gjson.GetBytes(normalized, "model").String()) + + // 非 responses 协议(deepseek CC 账号)原样返回 + deepseekCC := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey} + require.Equal(t, string(body), string(normalizeDeepSeekResponsesRequestBody(deepseekCC, body))) + + // openai 账号原样返回 + openai := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + require.Equal(t, string(body), string(normalizeDeepSeekResponsesRequestBody(openai, body))) +} + +// TestGetAnthropicAPIKeyAuthScheme_CNProvider CN 账号可经 extra 覆写鉴权方案, +// 默认保持 x-api-key。 +func TestGetAnthropicAPIKeyAuthScheme_CNProvider(t *testing.T) { + t.Parallel() + + zhipu := &Account{ + Platform: PlatformZhipu, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_protocol": APIProtocolAnthropic}, + } + require.Equal(t, AnthropicAPIKeyAuthSchemeXAPIKey, zhipu.GetAnthropicAPIKeyAuthScheme()) + + zhipu.Extra = map[string]any{"anthropic_apikey_auth_scheme": "authorization_bearer"} + require.Equal(t, AnthropicAPIKeyAuthSchemeAuthorizationBearer, zhipu.GetAnthropicAPIKeyAuthScheme()) +} diff --git a/backend/internal/service/custom_channel_time_pricing.go b/backend/internal/service/custom_channel_time_pricing.go new file mode 100644 index 000000000000..759eac987eff --- /dev/null +++ b/backend/internal/service/custom_channel_time_pricing.go @@ -0,0 +1,140 @@ +package service + +import ( + "fmt" + "math" + "sort" + "strings" + "sync" + "time" +) + +var channelTimePricingLocations sync.Map + +type parsedChannelTimePeriod struct { + start int + end int + multiplier float64 +} + +// validateChannelTimePricing 校验分时倍率配置。nil 或空 periods 表示未启用。 +func validateChannelTimePricing(config *ChannelTimePricing) error { + if config == nil || len(config.Periods) == 0 { + return nil + } + if _, err := loadChannelTimePricingLocation(config.Timezone); err != nil { + return fmt.Errorf("timezone: %w", err) + } + _, err := parseChannelTimePeriods(config.Periods) + return err +} + +// MultiplierAt 返回 at 对应的分时倍率。无配置或脏配置均安全降级为 1。 +func (config *ChannelTimePricing) MultiplierAt(at time.Time) float64 { + if config == nil || len(config.Periods) == 0 || at.IsZero() { + return 1.0 + } + if err := validateChannelTimePricing(config); err != nil { + return 1.0 + } + location, err := loadChannelTimePricingLocation(config.Timezone) + if err != nil { + return 1.0 + } + periods, err := parseChannelTimePeriods(config.Periods) + if err != nil { + return 1.0 + } + + local := at.In(location) + second := local.Hour()*60*60 + local.Minute()*60 + local.Second() + for _, period := range periods { + if second >= period.start && second < period.end { + return period.multiplier + } + } + return 1.0 +} + +func loadChannelTimePricingLocation(name string) (*time.Location, error) { + if strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("timezone is required") + } + if name == "Local" { + return nil, fmt.Errorf("local is not a supported timezone") + } + if cached, ok := channelTimePricingLocations.Load(name); ok { + location, valid := cached.(*time.Location) + if valid && location != nil { + return location, nil + } + channelTimePricingLocations.Delete(name) + } + location, err := time.LoadLocation(name) + if err != nil { + return nil, err + } + actual, _ := channelTimePricingLocations.LoadOrStore(name, location) + actualLocation, ok := actual.(*time.Location) + if !ok || actualLocation == nil { + return nil, fmt.Errorf("invalid cached timezone %q", name) + } + return actualLocation, nil +} + +func parseChannelTime(value string, end bool) (int, error) { + if end && (value == "00:00" || value == "00:00:00") { + return 24 * 60 * 60, nil + } + layout := "15:04:05" + if len(value) == len("15:04") { + layout = "15:04" + } + parsed, err := time.Parse(layout, value) + if err != nil || parsed.Format(layout) != value { + return 0, fmt.Errorf("time %q must use HH:mm or HH:mm:ss format", value) + } + return parsed.Hour()*60*60 + parsed.Minute()*60 + parsed.Second(), nil +} + +func parseChannelTimePeriods(periods []ChannelTimePricingPeriod) ([]parsedChannelTimePeriod, error) { + parsed := make([]parsedChannelTimePeriod, 0, len(periods)) + for _, period := range periods { + if math.IsNaN(period.Multiplier) || math.IsInf(period.Multiplier, 0) || period.Multiplier <= 0 { + return nil, fmt.Errorf("multiplier must be finite and greater than 0") + } + if period.Multiplier < 0.01 { + return nil, fmt.Errorf("multiplier must be at least 0.01") + } + scaled := period.Multiplier * 100 + if math.IsNaN(scaled) || math.IsInf(scaled, 0) { + return nil, fmt.Errorf("multiplier must remain finite when scaled") + } + if math.Abs(scaled-math.Round(scaled)) > 1e-9 { + return nil, fmt.Errorf("multiplier must have at most two decimal places") + } + + start, err := parseChannelTime(period.StartTime, false) + if err != nil { + return nil, err + } + end, err := parseChannelTime(period.EndTime, true) + if err != nil { + return nil, err + } + if period.StartTime == period.EndTime || start >= end { + return nil, fmt.Errorf("start time must be before end time") + } + parsed = append(parsed, parsedChannelTimePeriod{start: start, end: end, multiplier: period.Multiplier}) + } + + sort.Slice(parsed, func(i, j int) bool { + return parsed[i].start < parsed[j].start + }) + for i := 1; i < len(parsed); i++ { + if parsed[i].start < parsed[i-1].end { + return nil, fmt.Errorf("time pricing periods overlap") + } + } + return parsed, nil +} diff --git a/backend/internal/service/custom_channel_time_pricing_test.go b/backend/internal/service/custom_channel_time_pricing_test.go new file mode 100644 index 000000000000..f1038af0d372 --- /dev/null +++ b/backend/internal/service/custom_channel_time_pricing_test.go @@ -0,0 +1,166 @@ +//go:build unit + +package service + +import ( + "math" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func timeConfig(periods ...ChannelTimePricingPeriod) *ChannelTimePricing { + return &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: periods} +} + +func onePeriod() []ChannelTimePricingPeriod { + return []ChannelTimePricingPeriod{{StartTime: "09:00", EndTime: "12:00", Multiplier: 2}} +} + +func TestValidateChannelTimePricing(t *testing.T) { + tests := []struct { + name string + config *ChannelTimePricing + wantErr string + }{ + {name: "nil disabled", config: nil}, + {name: "empty disabled", config: &ChannelTimePricing{Timezone: "Asia/Shanghai"}}, + {name: "adjacent", config: timeConfig( + ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 2}, + ChannelTimePricingPeriod{StartTime: "12:00", EndTime: "14:00", Multiplier: 1.5})}, + {name: "midnight split", config: timeConfig( + ChannelTimePricingPeriod{StartTime: "22:00", EndTime: "00:00", Multiplier: 2}, + ChannelTimePricingPeriod{StartTime: "00:00", EndTime: "02:00", Multiplier: 2})}, + {name: "second precision", config: timeConfig( + ChannelTimePricingPeriod{StartTime: "09:00:00", EndTime: "12:00:00", Multiplier: 2}, + ChannelTimePricingPeriod{StartTime: "14:00:00", EndTime: "18:00:00", Multiplier: 2})}, + {name: "second precision overlap", config: timeConfig( + ChannelTimePricingPeriod{StartTime: "09:00:00", EndTime: "12:00:00", Multiplier: 2}, + ChannelTimePricingPeriod{StartTime: "11:59:59", EndTime: "14:00:00", Multiplier: 2}), wantErr: "overlap"}, + {name: "empty timezone", config: &ChannelTimePricing{Periods: onePeriod()}, wantErr: "timezone"}, + {name: "whitespace timezone", config: &ChannelTimePricing{Timezone: " ", Periods: onePeriod()}, wantErr: "timezone"}, + {name: "timezone", config: &ChannelTimePricing{Timezone: "UTC+8", Periods: onePeriod()}, wantErr: "timezone"}, + {name: "format", config: timeConfig(ChannelTimePricingPeriod{StartTime: "9:00", EndTime: "12:00", Multiplier: 2}), wantErr: "HH:mm"}, + {name: "equal midnight", config: timeConfig(ChannelTimePricingPeriod{StartTime: "00:00", EndTime: "00:00", Multiplier: 2}), wantErr: "before"}, + {name: "cross midnight", config: timeConfig(ChannelTimePricingPeriod{StartTime: "22:00", EndTime: "02:00", Multiplier: 2}), wantErr: "before"}, + {name: "overlap", config: timeConfig( + ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 2}, + ChannelTimePricingPeriod{StartTime: "11:59", EndTime: "14:00", Multiplier: 2}), wantErr: "overlap"}, + {name: "zero", config: timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 0}), wantErr: "greater than 0"}, + {name: "minimum positive", config: timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 0.01})}, + {name: "tiny positive", config: timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 1e-12}), wantErr: "at least 0.01"}, + {name: "below minimum", config: timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 0.001}), wantErr: "at least 0.01"}, + {name: "three decimals", config: timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 1.001}), wantErr: "decimal"}, + {name: "scaled overflow", config: timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: math.MaxFloat64}), wantErr: "finite"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateChannelTimePricing(tt.config) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), tt.wantErr), "error %q does not contain %q", err, tt.wantErr) + }) + } +} + +func TestChannelTimePricingMultiplierAt(t *testing.T) { + config := timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 2}) + tests := []struct { + name string + at time.Time + want float64 + }{ + {name: "Shanghai 08:59", at: time.Date(2026, 6, 29, 0, 59, 0, 0, time.UTC), want: 1}, + {name: "Shanghai 09:00", at: time.Date(2026, 6, 29, 1, 0, 0, 0, time.UTC), want: 2}, + {name: "Shanghai 11:59", at: time.Date(2026, 6, 29, 3, 59, 0, 0, time.UTC), want: 2}, + {name: "Shanghai 12:00", at: time.Date(2026, 6, 29, 4, 0, 0, 0, time.UTC), want: 1}, + {name: "Shanghai 14:00", at: time.Date(2026, 6, 29, 6, 0, 0, 0, time.UTC), want: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, config.MultiplierAt(tt.at)) + }) + } + + newYork := &ChannelTimePricing{Timezone: "America/New_York", Periods: onePeriod()} + at := time.Date(2026, 6, 29, 14, 0, 0, 0, time.UTC) + require.Equal(t, 1.0, config.MultiplierAt(at)) + require.Equal(t, 2.0, newYork.MultiplierAt(at)) +} + +func TestChannelTimePricingMultiplierAtSecondPrecision(t *testing.T) { + config := timeConfig(ChannelTimePricingPeriod{StartTime: "09:00:30", EndTime: "09:00:45", Multiplier: 2}) + shanghai, err := time.LoadLocation("Asia/Shanghai") + require.NoError(t, err) + + tests := []struct { + name string + at time.Time + want float64 + }{ + {name: "before", at: time.Date(2026, 6, 29, 9, 0, 29, 0, shanghai), want: 1}, + {name: "start", at: time.Date(2026, 6, 29, 9, 0, 30, 0, shanghai), want: 2}, + {name: "last matching second", at: time.Date(2026, 6, 29, 9, 0, 44, 999_999_999, shanghai), want: 2}, + {name: "end", at: time.Date(2026, 6, 29, 9, 0, 45, 0, shanghai), want: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, config.MultiplierAt(tt.at)) + }) + } +} + +func TestChannelTimePricingMultiplierAtMidnightSplit(t *testing.T) { + config := timeConfig( + ChannelTimePricingPeriod{StartTime: "22:00", EndTime: "00:00", Multiplier: 2}, + ChannelTimePricingPeriod{StartTime: "00:00", EndTime: "02:00", Multiplier: 3}, + ) + shanghai, err := time.LoadLocation("Asia/Shanghai") + require.NoError(t, err) + tests := []struct { + name string + at time.Time + want float64 + }{ + {name: "23:59", at: time.Date(2026, 6, 29, 23, 59, 0, 0, shanghai), want: 2}, + {name: "next day 00:00", at: time.Date(2026, 6, 30, 0, 0, 0, 0, shanghai), want: 3}, + {name: "02:00", at: time.Date(2026, 6, 30, 2, 0, 0, 0, shanghai), want: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, config.MultiplierAt(tt.at)) + }) + } +} + +func TestChannelTimePricingMultiplierAtDegradesForInvalidConfigurations(t *testing.T) { + var nilConfig *ChannelTimePricing + zeroTime := time.Time{} + validAt := time.Date(2026, 6, 29, 1, 0, 0, 0, time.UTC) + + require.Equal(t, 1.0, nilConfig.MultiplierAt(validAt)) + require.Equal(t, 1.0, timeConfig().MultiplierAt(validAt)) + require.Equal(t, 1.0, timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 2}).MultiplierAt(zeroTime)) + require.Equal(t, 1.0, (&ChannelTimePricing{Periods: onePeriod()}).MultiplierAt(validAt)) + require.Equal(t, 1.0, (&ChannelTimePricing{Timezone: " ", Periods: onePeriod()}).MultiplierAt(validAt)) + require.Equal(t, 1.0, (&ChannelTimePricing{Timezone: "UTC+8", Periods: onePeriod()}).MultiplierAt(validAt)) + require.Equal(t, 1.0, timeConfig(ChannelTimePricingPeriod{StartTime: "22:00", EndTime: "02:00", Multiplier: 2}).MultiplierAt(validAt)) +} + +func TestChannelTimePricingRejectsLocalTimezone(t *testing.T) { + config := &ChannelTimePricing{Timezone: "Local", Periods: onePeriod()} + + err := validateChannelTimePricing(config) + require.Error(t, err) + require.Contains(t, err.Error(), "timezone") + require.Equal(t, 1.0, config.MultiplierAt(time.Date(2026, 6, 29, 1, 0, 0, 0, time.UTC))) +} diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 2d5311bb2442..ba35301be08d 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -43,12 +43,58 @@ const ( PlatformGemini = domain.PlatformGemini PlatformAntigravity = domain.PlatformAntigravity PlatformGrok = domain.PlatformGrok - PlatformComposite = domain.PlatformComposite + // 国产 OpenAI 兼容供应商(与 grok 一样经 OpenAI 网关转发)。 + PlatformKimi = domain.PlatformKimi + PlatformZhipu = domain.PlatformZhipu + PlatformDeepseek = domain.PlatformDeepseek + PlatformComposite = domain.PlatformComposite // PlatformKiro is retained for unsupported-platform threshold tests and legacy // account rows. Scheduling-threshold evaluation never pauses kiro accounts. PlatformKiro = "kiro" ) +// 账号接入模式(国产供应商):按量付费 vs Coding Plan。 +const ( + AccountModePayG = domain.AccountModePayG + AccountModeCoding = domain.AccountModeCoding +) + +// 上游 API 协议(国产供应商):决定转发端点与格式,与接入模式正交。 +const ( + APIProtocolChatCompletions = domain.APIProtocolChatCompletions + APIProtocolAnthropic = domain.APIProtocolAnthropic + APIProtocolResponses = domain.APIProtocolResponses +) + +// 国产 OpenAI 兼容供应商各模式的默认 base_url。 +// 与前端 credentialsBuilder.ts 中的预设保持一致。 +const ( + DefaultKimiPayGBaseURL = "https://api.moonshot.cn/v1" + DefaultKimiCodingBaseURL = "https://api.kimi.com/coding/v1" + DefaultZhipuPayGBaseURL = "https://open.bigmodel.cn/api/paas/v4" + DefaultZhipuCodingBaseURL = "https://open.bigmodel.cn/api/coding/paas/v4" + DefaultDeepseekBaseURL = "https://api.deepseek.com" +) + +// 国产供应商 Anthropic 协议端点的默认 base_url(上游路径为 {base}/v1/messages)。 +// 与前端 credentialsBuilder.ts 中的预设保持一致。 +const ( + DefaultKimiPayGAnthropicBaseURL = "https://api.moonshot.cn/anthropic" + DefaultKimiCodingAnthropicBaseURL = "https://api.kimi.com/coding" + DefaultZhipuAnthropicBaseURL = "https://open.bigmodel.cn/api/anthropic" + DefaultDeepseekAnthropicBaseURL = "https://api.deepseek.com/anthropic" +) + +// IsCNProvider 报告 platform 是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)。 +func IsCNProvider(platform string) bool { + switch platform { + case PlatformKimi, PlatformZhipu, PlatformDeepseek: + return true + default: + return false + } +} + // AllowedQuotaPlatforms 是允许设置 user × platform quota 的平台列表(单一权威来源)。 // ent/schema/user_platform_quota.go 的 Validate 函数独立维护(构建期约束), // 若新增平台需同步修改该 schema。 @@ -58,14 +104,20 @@ var AllowedQuotaPlatforms = []string{ PlatformGemini, PlatformAntigravity, PlatformGrok, + PlatformKimi, + PlatformZhipu, + PlatformDeepseek, } // AllowedSchedulingThresholdPlatforms 是允许设置账号自动停调阈值的平台列表。 -// 仅 openai / anthropic / grok 有原生用量窗口可供评估;其他平台写入阈值无效果。 +// openai/anthropic/grok 有原生用量窗口;kimi/zhipu 的 Coding Plan 同样暴露 5h/weekly +// 滚动窗口,纳入阈值评估。deepseek 为余额型,走余额检测而非阈值。 var AllowedSchedulingThresholdPlatforms = []string{ PlatformOpenAI, PlatformAnthropic, PlatformGrok, + PlatformKimi, + PlatformZhipu, } // IsAllowedQuotaPlatform 报告 s 是否为合法的 quota platform 标识。 @@ -425,6 +477,13 @@ const ( // Default false (show rates). Admin endpoints always keep full metrics. SettingKeyChannelMonitorHideThroughput = "channel_monitor_hide_throughput" + // SettingKeyChannelMonitorShowQuota controls whether quota/balance snapshots + // attached to channel monitors (check_mode=quota/quota_probe) are exposed on + // the user-facing monitor APIs and UI. Default false (hidden); parsed + // fail-closed (only the literal "true" enables it). Admin endpoints always + // keep the full snapshots regardless of this flag. + SettingKeyChannelMonitorShowQuota = "channel_monitor_show_quota" + // SettingKeyGrokDefaultTextModel is the fallback Grok text model for empty // request models and built-in Grok aliases (e.g. "grok" → this id). Default grok-4.5. SettingKeyGrokDefaultTextModel = "grok_default_text_model" diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_benchmark_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_benchmark_test.go index 37fd709f84cb..a7b05f175c90 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_benchmark_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_benchmark_test.go @@ -14,13 +14,12 @@ func BenchmarkGatewayService_ParseSSEUsage_MessageStart(b *testing.B) { } func BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageStart(b *testing.B) { - svc := &GatewayService{} data := `{"type":"message_start","message":{"usage":{"input_tokens":123,"cache_creation_input_tokens":45,"cache_read_input_tokens":6,"cached_tokens":6,"cache_creation":{"ephemeral_5m_input_tokens":20,"ephemeral_1h_input_tokens":25}}}}` b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { usage := &ClaudeUsage{} - svc.parseSSEUsagePassthrough(data, usage) + parseSSEUsagePassthrough(data, usage) } } @@ -36,13 +35,12 @@ func BenchmarkGatewayService_ParseSSEUsage_MessageDelta(b *testing.B) { } func BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageDelta(b *testing.B) { - svc := &GatewayService{} data := `{"type":"message_delta","usage":{"output_tokens":456,"cache_creation_input_tokens":30,"cache_read_input_tokens":7,"cached_tokens":7,"cache_creation":{"ephemeral_5m_input_tokens":10,"ephemeral_1h_input_tokens":20}}}` b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { usage := &ClaudeUsage{} - svc.parseSSEUsagePassthrough(data, usage) + parseSSEUsagePassthrough(data, usage) } } diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 4fa52cc9ce80..2fb7c4baa3b4 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -769,6 +769,32 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_BuildRequestRejectsInvalidBas require.Error(t, err) } +func TestGatewayService_AnthropicAPIKeyPassthrough_StripsDeferredToolCacheControl(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + svc := &GatewayService{cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}} + account := &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey} + body := []byte(`{"tools":[{"name":"deferred","custom":{"defer_loading":true},"cache_control":{"type":"ephemeral"}},{"name":"top-level-deferred","defer_loading":true,"cache_control":{"type":"ephemeral"}},{"name":"ordinary","defer_loading":false,"cache_control":{"type":"ephemeral"}},{"name":"malformed","defer_loading":"true","cache_control":{"type":"ephemeral"}}]}`) + + _, wireBody, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, body, "k") + require.NoError(t, err) + require.False(t, gjson.GetBytes(wireBody, "tools.0.cache_control").Exists()) + require.False(t, gjson.GetBytes(wireBody, "tools.1.cache_control").Exists()) + require.True(t, gjson.GetBytes(wireBody, "tools.2.cache_control").Exists()) + require.True(t, gjson.GetBytes(wireBody, "tools.3.cache_control").Exists()) + + countReq, err := svc.buildCountTokensRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, body, "k") + require.NoError(t, err) + countBody, err := io.ReadAll(countReq.Body) + require.NoError(t, err) + require.False(t, gjson.GetBytes(countBody, "tools.0.cache_control").Exists()) + require.False(t, gjson.GetBytes(countBody, "tools.1.cache_control").Exists()) + require.True(t, gjson.GetBytes(countBody, "tools.2.cache_control").Exists()) + require.True(t, gjson.GetBytes(countBody, "tools.3.cache_control").Exists()) +} + func TestGatewayService_AnthropicOAuth_NotAffectedByAPIKeyPassthroughToggle(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() @@ -1256,11 +1282,10 @@ func TestExtractAnthropicSSEDataLine(t *testing.T) { } func TestGatewayService_ParseSSEUsagePassthrough_MessageStartFallbacks(t *testing.T) { - svc := &GatewayService{} usage := &ClaudeUsage{} data := `{"type":"message_start","message":{"usage":{"input_tokens":12,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cached_tokens":9,"cache_creation":{"ephemeral_5m_input_tokens":3,"ephemeral_1h_input_tokens":4}}}}` - svc.parseSSEUsagePassthrough(data, usage) + parseSSEUsagePassthrough(data, usage) require.Equal(t, 12, usage.InputTokens) require.Equal(t, 9, usage.CacheReadInputTokens, "应兼容 cached_tokens 字段") @@ -1270,7 +1295,6 @@ func TestGatewayService_ParseSSEUsagePassthrough_MessageStartFallbacks(t *testin } func TestGatewayService_ParseSSEUsagePassthrough_MessageDeltaSelectiveOverwrite(t *testing.T) { - svc := &GatewayService{} usage := &ClaudeUsage{ InputTokens: 10, CacheCreation5mTokens: 2, @@ -1278,7 +1302,7 @@ func TestGatewayService_ParseSSEUsagePassthrough_MessageDeltaSelectiveOverwrite( } data := `{"type":"message_delta","usage":{"input_tokens":0,"output_tokens":5,"cache_creation_input_tokens":8,"cache_read_input_tokens":0,"cached_tokens":11,"cache_creation":{"ephemeral_5m_input_tokens":1,"ephemeral_1h_input_tokens":0}}}` - svc.parseSSEUsagePassthrough(data, usage) + parseSSEUsagePassthrough(data, usage) require.Equal(t, 10, usage.InputTokens, "message_delta 中 0 值不应覆盖已有 input_tokens") require.Equal(t, 5, usage.OutputTokens) @@ -1289,28 +1313,26 @@ func TestGatewayService_ParseSSEUsagePassthrough_MessageDeltaSelectiveOverwrite( } func TestGatewayService_ParseSSEUsagePassthrough_NoopCases(t *testing.T) { - svc := &GatewayService{} usage := &ClaudeUsage{InputTokens: 3} - svc.parseSSEUsagePassthrough("", usage) + parseSSEUsagePassthrough("", usage) require.Equal(t, 3, usage.InputTokens) - svc.parseSSEUsagePassthrough("[DONE]", usage) + parseSSEUsagePassthrough("[DONE]", usage) require.Equal(t, 3, usage.InputTokens) - svc.parseSSEUsagePassthrough("not-json", usage) + parseSSEUsagePassthrough("not-json", usage) require.Equal(t, 3, usage.InputTokens) // nil usage 不应 panic - svc.parseSSEUsagePassthrough(`{"type":"message_start"}`, nil) + parseSSEUsagePassthrough(`{"type":"message_start"}`, nil) } func TestGatewayService_ParseSSEUsagePassthrough_FallbackFromUsageNode(t *testing.T) { - svc := &GatewayService{} usage := &ClaudeUsage{} data := `{"type":"content_block_delta","usage":{"cached_tokens":6,"cache_creation":{"ephemeral_5m_input_tokens":2,"ephemeral_1h_input_tokens":1}}}` - svc.parseSSEUsagePassthrough(data, usage) + parseSSEUsagePassthrough(data, usage) require.Equal(t, 6, usage.CacheReadInputTokens) require.Equal(t, 3, usage.CacheCreationInputTokens) diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index c18d5bf047cd..533e780017c5 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -310,6 +310,7 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( body []byte, token string, ) (*http.Request, []byte, error) { + body = stripDeferredToolCacheControl(body) targetURL := claudeAPIURL baseURL := account.GetBaseURL() if baseURL != "" { @@ -546,7 +547,7 @@ func (s *GatewayService) handleStreamingResponseAnthropicAPIKeyPassthrough( ms := int(time.Since(startTime).Milliseconds()) firstTokenMs = &ms } - s.parseSSEUsagePassthrough(data, usage) + parseSSEUsagePassthrough(data, usage) } else { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "event:") && anthropicStreamEventIsTerminal(strings.TrimSpace(strings.TrimPrefix(trimmed, "event:")), "") { @@ -625,7 +626,9 @@ func extractAnthropicSSEDataLine(line string) (string, bool) { return line[start:], true } -func (s *GatewayService) parseSSEUsagePassthrough(data string, usage *ClaudeUsage) { +// parseSSEUsagePassthrough 从 Anthropic SSE data 行提取 usage(包级函数: +// Anthropic 平台 passthrough 与国产供应商原生 Anthropic 直通共用)。 +func parseSSEUsagePassthrough(data string, usage *ClaudeUsage) { if usage == nil || data == "" || data == "[DONE]" { return } @@ -730,8 +733,12 @@ func parseClaudeUsageFromResponseBody(body []byte) *ClaudeUsage { return usage } -func (s *GatewayService) invalidNonStreamingJSONFailoverError( +// invalidNonStreamingJSONFailoverError 把"上游 2xx 返回非 JSON body"归一为 +// failover 错误(包级函数:Anthropic 平台 passthrough 与国产供应商原生 +// Anthropic 直通共用)。 +func invalidNonStreamingJSONFailoverError( ctx context.Context, + rateLimitService *RateLimitService, resp *http.Response, account *Account, body []byte, @@ -759,11 +766,11 @@ func (s *GatewayService) invalidNonStreamingJSONFailoverError( parseErr, ) - if s.rateLimitService != nil && account != nil { + if rateLimitService != nil && account != nil { if len(requestedModel) > 0 { - s.rateLimitService.HandleUpstreamError(ctx, account, statusCode, resp.Header, body, requestedModel[0]) + rateLimitService.HandleUpstreamError(ctx, account, statusCode, resp.Header, body, requestedModel[0]) } else { - s.rateLimitService.HandleUpstreamError(ctx, account, statusCode, resp.Header, body) + rateLimitService.HandleUpstreamError(ctx, account, statusCode, resp.Header, body) } } @@ -798,7 +805,7 @@ func (s *GatewayService) handleNonStreamingResponseAnthropicAPIKeyPassthrough( if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { var raw json.RawMessage if err := json.Unmarshal(body, &raw); err != nil { - return nil, s.invalidNonStreamingJSONFailoverError(ctx, resp, account, body, err) + return nil, invalidNonStreamingJSONFailoverError(ctx, s.rateLimitService, resp, account, body, err) } } diff --git a/backend/internal/service/gateway_context_management_test.go b/backend/internal/service/gateway_context_management_test.go index bdeb2c600b8b..b7ab3e942976 100644 --- a/backend/internal/service/gateway_context_management_test.go +++ b/backend/internal/service/gateway_context_management_test.go @@ -4,6 +4,7 @@ package service import ( "context" + "fmt" "io" "net/http" "net/http/httptest" @@ -650,6 +651,51 @@ func TestBuildCountTokensRequest_APIKeyHaiku_StripsContextManagementEndToEnd(t * "count_tokens API-key + 客户端未带 beta token → body strip") } +func TestBuildCountTokensRequest_StripsCacheControlOnlyFromLiteralDeferredTools(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"claude-haiku-4-5","messages":[],"tools":[{"name":"deferred","custom":{"defer_loading":true},"cache_control":{"type":"ephemeral"}},{"name":"ordinary","custom":{"defer_loading":false},"cache_control":{"type":"ephemeral"}},{"name":"string","custom":{"defer_loading":"true"},"cache_control":{"type":"ephemeral"}},{"name":"number","custom":{"defer_loading":1},"cache_control":{"type":"ephemeral"}},{"name":"object","custom":{"defer_loading":{}},"cache_control":{"type":"ephemeral"}}]}`) + + tests := []struct { + name string + account *Account + token string + tokenType string + }{ + { + name: "generic API key", + account: &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey}, + token: "sk-ant-test", + tokenType: "apikey", + }, + { + name: "recognized Claude Code OAuth without mimicry", + account: &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}, + token: "oauth-token", + tokenType: "oauth", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil) + svc := &GatewayService{cfg: &config.Config{}} + + req, wireBody, err := svc.buildCountTokensRequest( + context.Background(), c, tt.account, body, + tt.token, tt.tokenType, "claude-haiku-4-5", false, + ) + require.NoError(t, err) + require.False(t, gjson.GetBytes(wireBody, "tools.0.cache_control").Exists()) + for idx := 1; idx < 5; idx++ { + require.Equal(t, "ephemeral", gjson.GetBytes(wireBody, fmt.Sprintf("tools.%d.cache_control.type", idx)).String()) + } + require.JSONEq(t, string(wireBody), string(readUpstreamBodyForTest(t, req))) + }) + } +} + // count_tokens passthrough preserve 测试 func TestBuildCountTokensRequestAnthropicAPIKeyPassthrough_PreservesContextManagementWhenClientHeaderHasBeta(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go index 7b66ac9596e4..003cbb7c1e99 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -366,6 +366,7 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough( body []byte, token string, ) (*http.Request, error) { + body = stripDeferredToolCacheControl(body) targetURL := claudeAPICountTokensURL baseURL := account.GetBaseURL() if baseURL != "" { @@ -429,6 +430,7 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough( // buildCountTokensRequest 构建 count_tokens 上游请求 func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, mimicClaudeCode bool) (*http.Request, []byte, error) { + body = stripDeferredToolCacheControl(body) // 确定目标 URL targetURL := claudeAPICountTokensURL if account.Type == AccountTypeAPIKey { diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index d10cb1ea3be5..863e4d2e8340 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -14,6 +14,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/tidwall/gjson" "github.com/gin-gonic/gin" ) @@ -800,14 +801,23 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A var firstTokenMs *int var clientDisconnect bool if reqStream { + writerSizeBeforeStream := c.Writer.Size() streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, reqModel, shouldMimicClaudeCode) if err != nil { var sseErr *sseStreamErrorEventError if errors.As(err, &sseErr) { // 上游 HTTP 200 + SSE 流体内出现 event:error 帧。 - // 保留 StatusCode=403 以兼容既有 failover/客户端响应语义, - // 但补全 ResponseBody 与 ops 上下文,让运维日志能反映上游真实错误。 body := []byte(sseErr.RawData) + semanticStatus := http.StatusForbidden + if c.Writer.Size() == writerSizeBeforeStream && gjson.GetBytes(body, "error.type").String() == "overloaded_error" { + semanticStatus = 529 + syntheticResp := &http.Response{ + StatusCode: semanticStatus, + Header: resp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(body)), + } + s.handleFailoverSideEffects(ctx, syntheticResp, account, reqModel) + } upstreamMsg := sanitizeUpstreamErrorMessage( strings.TrimSpace(extractUpstreamErrorMessage(body)), @@ -826,7 +836,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A Platform: account.Platform, AccountID: account.ID, AccountName: account.Name, - UpstreamStatusCode: 403, + UpstreamStatusCode: semanticStatus, UpstreamRequestID: resp.Header.Get("x-request-id"), Kind: "stream_error", Message: upstreamMsg, @@ -840,7 +850,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A ) return nil, &UpstreamFailoverError{ - StatusCode: 403, + StatusCode: semanticStatus, ResponseBody: body, } } diff --git a/backend/internal/service/gateway_forward_partial_usage_test.go b/backend/internal/service/gateway_forward_partial_usage_test.go index f6701ca8b008..2b7666378e74 100644 --- a/backend/internal/service/gateway_forward_partial_usage_test.go +++ b/backend/internal/service/gateway_forward_partial_usage_test.go @@ -8,12 +8,37 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) +type gatewayForwardErrorPolicyRepoStub struct { + AccountRepository + tempCalls int + modelRateLimitCalls []gatewayForwardModelRateLimitCall +} + +type gatewayForwardModelRateLimitCall struct { + accountID int64 + scope string +} + +func (r *gatewayForwardErrorPolicyRepoStub) SetTempUnschedulable(context.Context, int64, time.Time, string) error { + r.tempCalls++ + return nil +} + +func (r *gatewayForwardErrorPolicyRepoStub) SetModelRateLimit(_ context.Context, id int64, scope string, _ time.Time, _ ...string) error { + r.modelRateLimitCalls = append(r.modelRateLimitCalls, gatewayForwardModelRateLimitCall{ + accountID: id, + scope: scope, + }) + return nil +} + // 本文件覆盖 issue #5148:流式转发中途出错(缺失 terminal 事件、读错误等)时, // 已观测到的上游 usage 不得随错误一起被丢弃,Forward 必须把部分结果与错误一同 // 返回,供 handler 照常提交 usage 记录。 @@ -183,6 +208,95 @@ func TestGatewayService_Forward_FailoverErrorKeepsNilResult(t *testing.T) { require.Nil(t, result, "failover 错误必须保持 result=nil,防止重试成功后双重计费") } +func TestGatewayService_Forward_PreOutputSSEOverloadedErrorUsesSemantic529(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + + body := []byte(`{"model":"claude-3-5-sonnet-latest","stream":true,"messages":[{"role":"user","content":"hello"}]}`) + parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic) + require.NoError(t, err) + + const errorJSON = `{"type":"error","error":{"details":null,"type":"overloaded_error","message":"Overloaded"},"request_id":"req_01"}` + upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader("event: error\ndata: " + errorJSON + "\n\n")), + }} + repo := &gatewayForwardErrorPolicyRepoStub{} + cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}} + svc := &GatewayService{ + cfg: cfg, + responseHeaderFilter: compileResponseHeaderFilter(cfg), + httpUpstream: upstream, + rateLimitService: NewRateLimitService(repo, nil, cfg, nil, nil), + deferredService: &DeferredService{}, + } + account := newAnthropicOAuthAccountForPartialUsageTest() + account.Credentials["temp_unschedulable_enabled"] = true + account.Credentials["temp_unschedulable_rules"] = []any{map[string]any{ + "error_code": float64(529), + "keywords": []any{"Overloaded"}, + "duration_minutes": float64(10), + }} + + result, err := svc.Forward(context.Background(), c, account, parsed) + require.Error(t, err) + require.Nil(t, result) + + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, 529, failoverErr.StatusCode) + require.JSONEq(t, errorJSON, string(failoverErr.ResponseBody)) + require.Len(t, repo.modelRateLimitCalls, 1, "synthetic 529 must participate in temp-unschedulable rules") + require.Equal(t, account.ID, repo.modelRateLimitCalls[0].accountID) + require.Equal(t, parsed.Model, repo.modelRateLimitCalls[0].scope) + require.Empty(t, rec.Body.String(), "pre-output overload must remain eligible for account failover") +} + +func TestGatewayService_Forward_PostOutputSSEOverloadedErrorKeepsExistingStatus(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + + body := []byte(`{"model":"claude-3-5-sonnet-latest","stream":true,"messages":[{"role":"user","content":"hello"}]}`) + parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic) + require.NoError(t, err) + + const errorJSON = `{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}` + fixture := "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":1}}}\n\n" + + "event: error\ndata: " + errorJSON + "\n\n" + upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(fixture)), + }} + repo := &gatewayForwardErrorPolicyRepoStub{} + cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}} + svc := &GatewayService{ + cfg: cfg, + responseHeaderFilter: compileResponseHeaderFilter(cfg), + httpUpstream: upstream, + rateLimitService: NewRateLimitService(repo, nil, cfg, nil, nil), + deferredService: &DeferredService{}, + } + + result, err := svc.Forward(context.Background(), c, newAnthropicOAuthAccountForPartialUsageTest(), parsed) + require.Error(t, err) + require.Nil(t, result) + + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusForbidden, failoverErr.StatusCode) + require.JSONEq(t, errorJSON, string(failoverErr.ResponseBody)) + require.Zero(t, repo.tempCalls) + require.Contains(t, rec.Body.String(), "message_start") +} + func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardStreamMissingTerminalPreservesPartialUsage(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/gateway_hotpath_optimization_test.go b/backend/internal/service/gateway_hotpath_optimization_test.go index 48cfc358a6fd..d9a00710a38c 100644 --- a/backend/internal/service/gateway_hotpath_optimization_test.go +++ b/backend/internal/service/gateway_hotpath_optimization_test.go @@ -602,6 +602,97 @@ func TestGetAvailableModels_ErrorAndGlobalListBranches(t *testing.T) { require.Equal(t, int64(1), okRepo.listAllCalls.Load()) } +func TestGetAvailableModels_OpenAIPassthroughUsesDefaultFallback(t *testing.T) { + groupID := int64(10) + + tests := []struct { + name string + accounts []Account + want []string + }{ + { + name: "passthrough only ignores stale mapping", + accounts: []Account{ + { + ID: 1, + Platform: PlatformOpenAI, + Credentials: map[string]any{"model_mapping": map[string]any{"stale-model": "upstream-model"}}, + Extra: map[string]any{"openai_passthrough": true}, + }, + }, + want: nil, + }, + { + name: "passthrough wins over ordinary account mapping", + accounts: []Account{ + { + ID: 2, + Platform: PlatformOpenAI, + Credentials: map[string]any{"model_mapping": map[string]any{"configured-model": "configured-upstream"}}, + }, + { + ID: 3, + Platform: PlatformOpenAI, + Credentials: map[string]any{"model_mapping": map[string]any{"stale-model": "upstream-model"}}, + Extra: map[string]any{"openai_passthrough": true}, + }, + }, + want: nil, + }, + { + name: "ordinary accounts preserve mapped whitelist", + accounts: []Account{ + { + ID: 4, + Platform: PlatformOpenAI, + Credentials: map[string]any{"model_mapping": map[string]any{"configured-model": "configured-upstream"}}, + }, + }, + want: []string{"configured-model"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &modelsListAccountRepoStub{byGroup: map[int64][]Account{groupID: tt.accounts}} + svc := &GatewayService{ + accountRepo: repo, + modelsListCache: gocache.New(time.Minute, time.Minute), + modelsListCacheTTL: time.Minute, + } + + require.Equal(t, tt.want, svc.GetAvailableModels(context.Background(), &groupID, PlatformOpenAI)) + }) + } +} + +func TestGetAvailableModels_GlobalListPreservesMappedModelsWithOpenAIPassthrough(t *testing.T) { + groupID := int64(11) + repo := &modelsListAccountRepoStub{ + byGroup: map[int64][]Account{ + groupID: { + { + ID: 1, + Platform: PlatformOpenAI, + Extra: map[string]any{"openai_passthrough": true}, + }, + { + ID: 2, + Platform: PlatformAnthropic, + Credentials: map[string]any{"model_mapping": map[string]any{"claude-mapped": "claude-upstream"}}, + }, + }, + }, + } + svc := &GatewayService{ + accountRepo: repo, + modelsListCache: gocache.New(time.Minute, time.Minute), + modelsListCacheTTL: time.Minute, + } + + require.Equal(t, []string{"claude-mapped"}, svc.GetAvailableModels(context.Background(), &groupID, "")) +} + func TestGatewayHotpathHelpers_CacheTTLAndStickyContext(t *testing.T) { t.Run("resolve_user_group_rate_cache_ttl", func(t *testing.T) { require.Equal(t, defaultUserGroupRateCacheTTL, resolveUserGroupRateCacheTTL(nil)) diff --git a/backend/internal/service/gateway_record_usage_test.go b/backend/internal/service/gateway_record_usage_test.go index 517d4723cbdd..53616447452e 100644 --- a/backend/internal/service/gateway_record_usage_test.go +++ b/backend/internal/service/gateway_record_usage_test.go @@ -345,6 +345,38 @@ func TestGatewayServiceRecordUsage_PeakRateAffectsTokenModeImageOutputTokens(t * require.InDelta(t, expectedActual, userRepo.lastAmount, 1e-12) } +func TestGatewayServiceRecordUsage_TimePricingUsesPricingAt(t *testing.T) { + groupID := int64(904) + requestStart := time.Date(2024, time.January, 2, 2, 0, 0, 0, time.UTC) // 上海 10:00 + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}) + svc.resolver = newOpenAITokenImageChannelPricingResolverWithTimeForTest(t, groupID, "gpt-5.1", &ChannelTimePricing{ + Timezone: "Asia/Shanghai", + Periods: []ChannelTimePricingPeriod{{StartTime: "09:00", EndTime: "12:00", Multiplier: 2}}, + }) + + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gateway_time_pricing_request_start", + Model: "gpt-5.1", + Usage: ClaudeUsage{InputTokens: 1000, OutputTokens: 500}, + }, + APIKey: &APIKey{ID: 804, GroupID: i64p(groupID), Group: &Group{ + ID: groupID, RateMultiplier: 0.8, SubscriptionType: SubscriptionTypeSubscription, + }}, + User: &User{ID: 604}, + Account: &Account{ID: 704}, + PricingAt: requestStart, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + baseCost := 1000*3e-6 + 500*15e-6 + require.InDelta(t, baseCost*2, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, baseCost*2*0.8, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, 0.8, usageRepo.lastLog.RateMultiplier, 1e-12) +} func TestGatewayServiceRecordUsage_UsesExplicitPricingAtForPeakRate(t *testing.T) { for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformGrok, PlatformAntigravity} { t.Run(platform, func(t *testing.T) { diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 0f3415fab513..7e42a74cbd92 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -1379,6 +1379,17 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, hasAnyMapping := false for _, acc := range accounts { + // Passthrough routing accepts models independently of model_mapping. A stale + // mapping on any eligible passthrough account therefore cannot define the + // public whitelist; return nil so the handler uses its default model set. + if platform == PlatformOpenAI && acc.IsOpenAIPassthroughEnabled() { + if s.modelsListCache != nil { + s.modelsListCache.Set(cacheKey, []string(nil), s.modelsListCacheTTL) + modelsListCacheStoreTotal.Add(1) + } + return nil + } + mapping := acc.GetModelMapping() if len(mapping) > 0 { hasAnyMapping = true diff --git a/backend/internal/service/gateway_tool_rewrite.go b/backend/internal/service/gateway_tool_rewrite.go index da62daf96172..d1b69937232e 100644 --- a/backend/internal/service/gateway_tool_rewrite.go +++ b/backend/internal/service/gateway_tool_rewrite.go @@ -248,14 +248,18 @@ func applyToolNameRewriteToBody(body []byte, rw *ToolNameRewrite) []byte { return body } -// applyToolsLastCacheBreakpoint 在 tools 数组最后一个工具上注入 cache_control -// 断点,对齐 Parrot `tools[-1]["cache_control"] = {"type":"ephemeral","ttl":"1h"}` -// 行为,但 ttl 按本仓规则: +// applyToolsLastCacheBreakpoint 在最后一个非延迟加载工具上注入 cache_control +// 断点。Anthropic 不允许 defer_loading=true 的工具携带 cache_control, +// 因此会先清理所有延迟加载工具上的客户端断点。兼容官方顶层字段和 +// Claude Code 使用的 custom.defer_loading 字段。其余行为对齐 Parrot +// `tools[-1]["cache_control"] = {"type":"ephemeral","ttl":"1h"}`, +// 但 ttl 按本仓规则: // - 客户端已为该 tool 显式设置 cache_control.ttl → 完全透传不覆盖 // - 否则注入 {"type":"ephemeral","ttl": claude.DefaultCacheControlTTL} // // 纯副作用函数,tools 不存在或为空数组时 no-op。 func applyToolsLastCacheBreakpoint(body []byte) []byte { + body = stripDeferredToolCacheControl(body) tools := gjson.GetBytes(body, "tools") if !tools.IsArray() { return body @@ -264,7 +268,17 @@ func applyToolsLastCacheBreakpoint(body []byte) []byte { if len(arr) == 0 { return body } - lastIdx := len(arr) - 1 + lastIdx := -1 + for idx, tool := range arr { + if isDeferredLoadingTool(tool) { + continue + } + lastIdx = idx + } + if lastIdx == -1 { + return body + } + existingCC := arr[lastIdx].Get("cache_control") if existingCC.Exists() && existingCC.Get("ttl").String() != "" { @@ -285,6 +299,29 @@ func applyToolsLastCacheBreakpoint(body []byte) []byte { return body } +func isDeferredLoadingTool(tool gjson.Result) bool { + return tool.Get("defer_loading").Type == gjson.True || + tool.Get("custom.defer_loading").Type == gjson.True +} + +// stripDeferredToolCacheControl removes the cache marker Anthropic rejects on +// deferred tools. Only the literal JSON boolean true enables deferred loading. +func stripDeferredToolCacheControl(body []byte) []byte { + tools := gjson.GetBytes(body, "tools") + if !tools.IsArray() { + return body + } + for idx, tool := range tools.Array() { + if !isDeferredLoadingTool(tool) || !tool.Get("cache_control").Exists() { + continue + } + if next, err := sjson.DeleteBytes(body, fmt.Sprintf("tools.%d.cache_control", idx)); err == nil { + body = next + } + } + return body +} + // restoreToolNamesInBytes 对 bytes chunk 做逆向还原:假名 → 真名。 // 按 ReverseOrdered 的假名长度倒序逐个 bytes.Replace,防止子串冲突 // (与 Parrot _restore_tool_names_in_chunk 的 sorted(..., reverse=True) 等价)。 diff --git a/backend/internal/service/gateway_tool_rewrite_test.go b/backend/internal/service/gateway_tool_rewrite_test.go index 9e6f6806da53..c894ef708c63 100644 --- a/backend/internal/service/gateway_tool_rewrite_test.go +++ b/backend/internal/service/gateway_tool_rewrite_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "fmt" "strings" "testing" @@ -149,6 +150,34 @@ func TestApplyToolsLastCacheBreakpoint_PassesThroughClientTTL(t *testing.T) { require.Equal(t, "1h", gjson.GetBytes(out, "tools.0.cache_control.ttl").String()) } +func TestApplyToolsLastCacheBreakpoint_StripsDeferredToolCacheControl(t *testing.T) { + body := []byte(`{"tools":[{"name":"a","custom":{"defer_loading":true},"cache_control":{"type":"ephemeral","ttl":"1h"}},{"name":"b","custom":{"defer_loading":true}}]}`) + out := applyToolsLastCacheBreakpoint(body) + + require.False(t, gjson.GetBytes(out, "tools.0.cache_control").Exists()) + require.False(t, gjson.GetBytes(out, "tools.1.cache_control").Exists()) +} + +func TestApplyToolsLastCacheBreakpoint_SkipsDeferredFinalTool(t *testing.T) { + body := []byte(`{"tools":[{"name":"a","input_schema":{}},{"name":"b","defer_loading":true}]}`) + out := applyToolsLastCacheBreakpoint(body) + + require.Equal(t, "ephemeral", gjson.GetBytes(out, "tools.0.cache_control.type").String()) + require.Equal(t, "5m", gjson.GetBytes(out, "tools.0.cache_control.ttl").String()) + require.False(t, gjson.GetBytes(out, "tools.1.cache_control").Exists()) +} + +func TestApplyToolsLastCacheBreakpoint_OnlyLiteralTrueIsDeferred(t *testing.T) { + body := []byte(`{"tools":[{"name":"custom-true","custom":{"defer_loading":true},"cache_control":{"type":"ephemeral"}},{"name":"top-level-true","defer_loading":true,"cache_control":{"type":"ephemeral"}},{"name":"false","defer_loading":false,"cache_control":{"type":"ephemeral"}},{"name":"string","defer_loading":"true","cache_control":{"type":"ephemeral"}},{"name":"number","defer_loading":1,"cache_control":{"type":"ephemeral"}},{"name":"object","defer_loading":{},"cache_control":{"type":"ephemeral"}}]}`) + out := stripDeferredToolCacheControl(body) + + require.False(t, gjson.GetBytes(out, "tools.0.cache_control").Exists()) + require.False(t, gjson.GetBytes(out, "tools.1.cache_control").Exists()) + for idx := 2; idx < 6; idx++ { + require.Equal(t, "ephemeral", gjson.GetBytes(out, fmt.Sprintf("tools.%d.cache_control.type", idx)).String()) + } +} + func TestStripMessageCacheControl(t *testing.T) { body := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral"}}]}]}`) out := stripMessageCacheControl(body) diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index c5d962f20bc1..0a8efbdd5e57 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -19,6 +19,7 @@ import ( ) func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, reqStream bool, mimicClaudeCode bool) (*http.Request, []byte, error) { + body = stripDeferredToolCacheControl(body) if account.Platform == PlatformAnthropic && account.Type == AccountTypeServiceAccount { req, err := s.buildUpstreamRequestAnthropicVertex(ctx, c, account, body, token, modelID, reqStream) return req, body, err diff --git a/backend/internal/service/gateway_upstream_response.go b/backend/internal/service/gateway_upstream_response.go index 5a9fedf96433..20af32314f82 100644 --- a/backend/internal/service/gateway_upstream_response.go +++ b/backend/internal/service/gateway_upstream_response.go @@ -1395,7 +1395,7 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h } if err := json.Unmarshal(body, &response); err != nil { if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { - return nil, s.invalidNonStreamingJSONFailoverError(ctx, resp, account, body, err, mappedModel) + return nil, invalidNonStreamingJSONFailoverError(ctx, s.rateLimitService, resp, account, body, err, mappedModel) } return nil, fmt.Errorf("parse response: %w", err) } diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 547e32de4bc4..81e11af345bc 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -848,7 +848,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage } // 计算费用 - cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, opts) + cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, pricingAt, opts) // response_model:按上游成功响应自报的模型计费(渠道显式开启才生效)。 // 采纳条件见 responseModelBillingDeclaration + hasIdentifiedResponseModelPricing // + responseModelBillingAdoptable。任一条件不满足都静默回落基线,即开启本模式前的 @@ -860,7 +860,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage result.ImageCount > 0 || result.AudioUsage != nil || result.SearchCount > 0, ); responseModel != "" && !strings.EqualFold(responseModel, strings.TrimSpace(billingModel)) { if identified, responseChannelPriced := s.hasIdentifiedResponseModelPricing(ctx, responseModel, apiKey); identified { - responseCost := s.calculateRecordUsageCost(ctx, result, apiKey, responseModel, multiplier, imageMultiplier, opts) + responseCost := s.calculateRecordUsageCost(ctx, result, apiKey, responseModel, multiplier, imageMultiplier, pricingAt, opts) baselineChannelPriced := s.resolveChannelPricing(ctx, billingModel, apiKey) != nil if responseModelBillingAdoptable(cost, responseCost, baselineChannelPriced, responseChannelPriced) { // billingModel 到此为止只是定价查表的入参,后续流程只消费 cost, @@ -949,12 +949,13 @@ func (s *GatewayService) calculateRecordUsageCost( billingModel string, multiplier float64, imageMultiplier float64, + pricingAt time.Time, opts *recordUsageOpts, ) *CostBreakdown { // 图片生成:渠道定价为 token 计费时走 token 路径,否则走图片计费 if result.ImageCount > 0 { if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil && resolved.Mode == BillingModeToken { - return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, opts) + return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt, opts) } return s.calculateImageCost(ctx, result, apiKey, billingModel, imageMultiplier) } @@ -978,7 +979,7 @@ func (s *GatewayService) calculateRecordUsageCost( } // Token 计费;SearchCount 为叠加 surcharge(不替代 token)。 - tokenCost := s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, opts) + tokenCost := s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt, opts) if result.SearchCount > 0 { price := groupSearchPricePer1kFromAPIKey(apiKey) if price != nil && *price == 0 { @@ -1137,6 +1138,7 @@ func (s *GatewayService) calculateTokenCost( apiKey *APIKey, billingModel string, multiplier float64, + pricingAt time.Time, opts *recordUsageOpts, ) *CostBreakdown { tokens := UsageTokens{ @@ -1164,6 +1166,7 @@ func (s *GatewayService) calculateTokenCost( Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, + PricingAt: pricingAt, Resolver: s.resolver, Resolved: resolved, }) @@ -1174,7 +1177,7 @@ func (s *GatewayService) calculateTokenCost( gid := apiKey.Group.ID cost, err = s.billingService.CalculateCostUnified(CostInput{ Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, - Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, Resolver: s.resolver, + Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, PricingAt: pricingAt, Resolver: s.resolver, }) } else { cost, err = s.billingService.CalculateCost(billingModel, tokens, multiplier) diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index e76fbcee15be..3ee7a4af9e4d 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -240,6 +240,11 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions( } } + if policy == ErrorPolicySkipped && account.IsCustomErrorCodesEnabled() { + return nil, s.writeGeminiCustomCodeSkippedError(c, account, resp.StatusCode, requestID, evBody, func() { + _ = s.writeChatCompletionsError(c, http.StatusInternalServerError, "api_error", geminiCustomCodeSkippedClientMessage) + }) + } return nil, s.writeGeminiChatCompletionsMappedError(c, account, resp.StatusCode, requestID, evBody) } @@ -856,8 +861,13 @@ func (s *GeminiMessagesCompatService) writeGeminiChatCompletionsMappedError( if errType == "upstream_error" { errType = "invalid_request_error" } + // 400 是确定性的请求错误:回传上游 message(已脱敏),客户端据此定位非法字段。 if errMsg == "Upstream request failed" { - errMsg = "Invalid request" + if upstreamMsg != "" { + errMsg = upstreamMsg + } else { + errMsg = "Invalid request" + } } case http.StatusNotFound: statusCode = http.StatusNotFound diff --git a/backend/internal/service/gemini_error_policy_skipped_write_test.go b/backend/internal/service/gemini_error_policy_skipped_write_test.go new file mode 100644 index 000000000000..41c37e24da3d --- /dev/null +++ b/backend/internal/service/gemini_error_policy_skipped_write_test.go @@ -0,0 +1,213 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// ErrorPolicySkipped 的客户端写出契约(与 OpenAI 网关路径对齐): +// - 池模式:不可 failover 的 4xx 按上游原始状态码/响应体保真写出,不改写成 5xx; +// - 自定义错误码未命中:统一 500 + 固定文案,上游细节只进 ops 错误日志; +// - 可 failover 的状态码(两种账号)一律换号,不透传。 +// --------------------------------------------------------------------------- + +const geminiSkippedTestUpstreamMsg = "antigravity executor: invalid Gemini function call history" + +func geminiSkippedTestUpstreamBody() string { + return `{"error":{"code":null,"message":"` + geminiSkippedTestUpstreamMsg + `","param":"","type":"invalid_request_error"}}` +} + +func newGeminiSkippedWriteService(status int, body string) (*GeminiMessagesCompatService, *geminiCompatHTTPUpstreamStub) { + httpStub := &geminiCompatHTTPUpstreamStub{ + response: &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + }, + } + svc := &GeminiMessagesCompatService{ + httpUpstream: httpStub, + cfg: &config.Config{}, + rateLimitService: NewRateLimitService(&errorPolicyRepoStub{}, nil, &config.Config{}, nil, nil), + } + return svc, httpStub +} + +func geminiPoolModeAPIKeyAccount() *Account { + return &Account{ + ID: 700, + Platform: PlatformGemini, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-key", + "pool_mode": true, + }, + } +} + +func geminiCustomCodesAPIKeyAccount() *Account { + return &Account{ + ID: 701, + Platform: PlatformGemini, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-key", + "custom_error_codes_enabled": true, + "custom_error_codes": []any{float64(429)}, + }, + } +} + +func newGeminiNativeTestContext(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", strings.NewReader("{}")) + return c, rec +} + +func TestGeminiForwardNative_PoolModeSkipped400PassthroughRealStatus(t *testing.T) { + gin.SetMode(gin.TestMode) + upstreamBody := geminiSkippedTestUpstreamBody() + svc, _ := newGeminiSkippedWriteService(http.StatusBadRequest, upstreamBody) + c, rec := newGeminiNativeTestContext(t) + + result, err := svc.ForwardNative(context.Background(), c, geminiPoolModeAPIKeyAccount(), + "gemini-2.5-flash", "generateContent", false, []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)) + + require.Nil(t, result) + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr), "池模式 400 不应换号") + require.Contains(t, err.Error(), "gemini upstream error: 400") + require.Equal(t, http.StatusBadRequest, rec.Code, "状态码应保真为上游 400") + require.Equal(t, upstreamBody, rec.Body.String(), "响应体应原样透传") +} + +func TestGeminiForwardNative_PoolModeSkipped503Failover(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, _ := newGeminiSkippedWriteService(http.StatusServiceUnavailable, `{"error":{"message":"Upstream service temporarily unavailable"}}`) + c, rec := newGeminiNativeTestContext(t) + + result, err := svc.ForwardNative(context.Background(), c, geminiPoolModeAPIKeyAccount(), + "gemini-2.5-flash", "generateContent", false, []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)) + + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.True(t, errors.As(err, &failoverErr), "池模式 503 应换号") + require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode) + require.Zero(t, rec.Body.Len(), "换号场景不应写客户端响应") +} + +func TestGeminiForwardNative_CustomCodesMiss400HiddenAs500(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, _ := newGeminiSkippedWriteService(http.StatusBadRequest, geminiSkippedTestUpstreamBody()) + c, rec := newGeminiNativeTestContext(t) + + result, err := svc.ForwardNative(context.Background(), c, geminiCustomCodesAPIKeyAccount(), + "gemini-2.5-flash", "generateContent", false, []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)) + + require.Nil(t, result) + require.Error(t, err) + require.Contains(t, err.Error(), "not in custom error codes") + require.Equal(t, http.StatusInternalServerError, rec.Code) + + var got map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + errObj, ok := got["error"].(map[string]any) + require.True(t, ok) + require.Equal(t, geminiCustomCodeSkippedClientMessage, errObj["message"]) + require.NotContains(t, rec.Body.String(), geminiSkippedTestUpstreamMsg, "上游细节不应透传给客户端") +} + +func TestGeminiForwardNative_CustomCodesMiss500Failover(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, _ := newGeminiSkippedWriteService(http.StatusInternalServerError, `{"error":{"message":"internal"}}`) + c, rec := newGeminiNativeTestContext(t) + + result, err := svc.ForwardNative(context.Background(), c, geminiCustomCodesAPIKeyAccount(), + "gemini-2.5-flash", "generateContent", false, []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)) + + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.True(t, errors.As(err, &failoverErr), "自定义错误码未命中的 500 应换号") + require.Equal(t, http.StatusInternalServerError, failoverErr.StatusCode) + require.False(t, failoverErr.RetryableOnSameAccount, "非池模式不应同账号重试") + require.Zero(t, rec.Body.Len()) +} + +func TestGeminiForwardAsChatCompletions_CustomCodesMiss400HiddenAs500(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, _ := newGeminiSkippedWriteService(http.StatusBadRequest, geminiSkippedTestUpstreamBody()) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"hi"}]}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(string(body))) + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiCustomCodesAPIKeyAccount(), body) + + require.Nil(t, result) + require.Error(t, err) + require.Contains(t, err.Error(), "not in custom error codes") + require.Equal(t, http.StatusInternalServerError, rec.Code) + + var got map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + errObj, ok := got["error"].(map[string]any) + require.True(t, ok) + require.Equal(t, "api_error", errObj["type"]) + require.Equal(t, geminiCustomCodeSkippedClientMessage, errObj["message"]) +} + +func TestGeminiForwardAsChatCompletions_PoolMode400KeepsUpstreamMessage(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, _ := newGeminiSkippedWriteService(http.StatusBadRequest, geminiSkippedTestUpstreamBody()) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"hi"}]}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(string(body))) + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiPoolModeAPIKeyAccount(), body) + + require.Nil(t, result) + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, rec.Code, "状态码应保真为上游 400") + + var got map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + errObj, ok := got["error"].(map[string]any) + require.True(t, ok) + require.Equal(t, "invalid_request_error", errObj["type"]) + require.Equal(t, geminiSkippedTestUpstreamMsg, errObj["message"], "应回传上游 message") +} + +func TestWriteGeminiMappedError_400KeepsUpstreamMessage(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := &GeminiMessagesCompatService{cfg: &config.Config{}} + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + + err := svc.writeGeminiMappedError(c, &Account{ID: 702, Platform: PlatformGemini}, http.StatusBadRequest, "req-1", []byte(geminiSkippedTestUpstreamBody())) + + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, rec.Code) + var got map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + errObj, ok := got["error"].(map[string]any) + require.True(t, ok) + require.Equal(t, geminiSkippedTestUpstreamMsg, errObj["message"], "应回传上游 message") +} diff --git a/backend/internal/service/gemini_error_policy_test.go b/backend/internal/service/gemini_error_policy_test.go index 46024077a589..807ed7056b14 100644 --- a/backend/internal/service/gemini_error_policy_test.go +++ b/backend/internal/service/gemini_error_policy_test.go @@ -218,7 +218,7 @@ func TestGeminiErrorPolicyIntegration(t *testing.T) { expectHandleError: true, }, { - name: "custom_codes_skipped_500_no_failover", + name: "custom_codes_skipped_500_failover", account: &Account{ ID: 201, Type: AccountTypeAPIKey, @@ -230,6 +230,22 @@ func TestGeminiErrorPolicyIntegration(t *testing.T) { }, statusCode: 500, respBody: []byte(`{"error":"internal"}`), + expectFailover: true, + expectHandleError: false, + }, + { + name: "custom_codes_skipped_400_no_failover", + account: &Account{ + ID: 205, + Type: AccountTypeAPIKey, + Platform: PlatformGemini, + Credentials: map[string]any{ + "custom_error_codes_enabled": true, + "custom_error_codes": []any{float64(429)}, + }, + }, + statusCode: 400, + respBody: []byte(`{"error":"bad request"}`), expectFailover: false, expectHandleError: false, }, @@ -311,9 +327,9 @@ func TestGeminiErrorPolicyIntegration(t *testing.T) { policy := svc.rateLimitService.CheckErrorPolicy(ctx, account, statusCode, respBody, "gemini-2.5-pro") switch policy { case ErrorPolicySkipped: - // Skipped → return error directly (no handleGeminiUpstreamError, no failover) - gotFailover = false + // Skipped → 不标记账号状态;可 failover 的状态码仍换号 handleErrorCalled = false + gotFailover = svc.skippedErrorPolicyFailoverError(c, account, statusCode, respBody, "req-test") != nil goto verify case ErrorPolicyMatched: svc.handleGeminiUpstreamError(ctx, account, statusCode, headers, respBody) @@ -353,12 +369,12 @@ func TestGeminiErrorPolicyIntegration(t *testing.T) { } // --------------------------------------------------------------------------- -// TestPoolModeSkippedFailoverError — pool-mode accounts hitting -// ErrorPolicySkipped must failover (align with other platform forwards) -// instead of passing the upstream error through to the client. +// TestSkippedErrorPolicyFailoverError — ErrorPolicySkipped(池模式、或自定义 +// 错误码未命中)不豁免换号:可 failover 的状态码返回 UpstreamFailoverError, +// 仅池模式账号可携带同账号重试标记。 // --------------------------------------------------------------------------- -func TestPoolModeSkippedFailoverError(t *testing.T) { +func TestSkippedErrorPolicyFailoverError(t *testing.T) { gin.SetMode(gin.TestMode) svc := &GeminiMessagesCompatService{} @@ -369,6 +385,13 @@ func TestPoolModeSkippedFailoverError(t *testing.T) { } return &Account{ID: 300, Type: AccountTypeAPIKey, Platform: PlatformGemini, Credentials: creds} } + customCodesAccount := &Account{ + ID: 301, Type: AccountTypeAPIKey, Platform: PlatformGemini, + Credentials: map[string]any{ + "custom_error_codes_enabled": true, + "custom_error_codes": []any{float64(429)}, + }, + } tests := []struct { name string @@ -383,13 +406,8 @@ func TestPoolModeSkippedFailoverError(t *testing.T) { "pool_mode_retry_status_codes": []any{float64(500)}, }), 500, true, true}, {"pool_400_not_failover_worthy", poolAccount(nil), 400, false, false}, - {"non_pool_account_keeps_passthrough", &Account{ - ID: 301, Type: AccountTypeAPIKey, Platform: PlatformGemini, - Credentials: map[string]any{ - "custom_error_codes_enabled": true, - "custom_error_codes": []any{float64(429)}, - }, - }, 500, false, false}, + {"custom_codes_miss_500_failover_no_same_account_retry", customCodesAccount, 500, true, false}, + {"custom_codes_miss_400_not_failover_worthy", customCodesAccount, 400, false, false}, } for _, tt := range tests { @@ -399,7 +417,7 @@ func TestPoolModeSkippedFailoverError(t *testing.T) { c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) body := []byte(`{"error":{"code":"bad_response_status_code","message":"openai_error"}}`) - failoverErr := svc.poolModeSkippedFailoverError(c, tt.account, tt.statusCode, body, "req-1") + failoverErr := svc.skippedErrorPolicyFailoverError(c, tt.account, tt.statusCode, body, "req-1") if !tt.expectFailover { require.Nil(t, failoverErr) diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 0abd14937aff..82cf9a20f3bc 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -947,10 +947,16 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex if upstreamReqID == "" { upstreamReqID = resp.Header.Get("x-goog-request-id") } - if failoverErr := s.poolModeSkippedFailoverError(c, account, resp.StatusCode, respBody, upstreamReqID); failoverErr != nil { + if failoverErr := s.skippedErrorPolicyFailoverError(c, account, resp.StatusCode, respBody, upstreamReqID); failoverErr != nil { return nil, failoverErr } - return nil, s.writeGeminiMappedError(c, account, http.StatusInternalServerError, upstreamReqID, respBody) + if account.IsCustomErrorCodesEnabled() { + return nil, s.writeGeminiCustomCodeSkippedError(c, account, resp.StatusCode, upstreamReqID, respBody, func() { + _ = s.writeClaudeError(c, http.StatusInternalServerError, "api_error", geminiCustomCodeSkippedClientMessage) + }) + } + // 池模式:客户端写出与 ErrorPolicyNone 相同(按上游真实状态码映射),仅跳过账号状态标记。 + return nil, s.writeGeminiMappedError(c, account, resp.StatusCode, upstreamReqID, respBody) case ErrorPolicyMatched, ErrorPolicyTempUnscheduled: if policy == ErrorPolicyMatched { s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) @@ -1460,17 +1466,16 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. policy := s.rateLimitService.CheckErrorPolicy(ctx, account, resp.StatusCode, respBody, mappedModel) switch policy { case ErrorPolicySkipped: - if failoverErr := s.poolModeSkippedFailoverError(c, account, resp.StatusCode, respBody, requestID); failoverErr != nil { + if failoverErr := s.skippedErrorPolicyFailoverError(c, account, resp.StatusCode, respBody, requestID); failoverErr != nil { return nil, failoverErr } - respBody = unwrapIfNeeded(isOAuth, respBody) - contentType := resp.Header.Get("Content-Type") - if contentType == "" { - contentType = "application/json" + if account.IsCustomErrorCodesEnabled() { + return nil, s.writeGeminiCustomCodeSkippedError(c, account, resp.StatusCode, requestID, respBody, func() { + _ = s.writeGoogleError(c, http.StatusInternalServerError, geminiCustomCodeSkippedClientMessage) + }) } - MarkResponseCommitted(c) - c.Data(http.StatusInternalServerError, contentType, respBody) - return nil, fmt.Errorf("gemini upstream error: %d (skipped by error policy)", resp.StatusCode) + // 池模式:客户端写出与 ErrorPolicyNone 相同(状态码/响应体保真),仅跳过账号状态标记。 + return nil, s.writeGeminiNativeUpstreamError(c, account, resp, respBody, requestID, isOAuth) case ErrorPolicyMatched, ErrorPolicyTempUnscheduled: if policy == ErrorPolicyMatched { s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) @@ -1555,40 +1560,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: evBody} } - respBody = unwrapIfNeeded(isOAuth, respBody) - upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) - upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - upstreamDetail := "" - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes - if maxBytes <= 0 { - maxBytes = 2048 - } - upstreamDetail = truncateString(string(respBody), maxBytes) - logger.LegacyPrintf("service.gemini_messages_compat", "[Gemini] native upstream error %d: %s", resp.StatusCode, truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)) - } - setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, - UpstreamRequestID: requestID, - Kind: "http_error", - Message: upstreamMsg, - Detail: upstreamDetail, - }) - - contentType := resp.Header.Get("Content-Type") - if contentType == "" { - contentType = "application/json" - } - MarkResponseCommitted(c) - c.Data(resp.StatusCode, contentType, respBody) - if upstreamMsg == "" { - return nil, fmt.Errorf("gemini upstream error: %d", resp.StatusCode) - } - return nil, fmt.Errorf("gemini upstream error: %d message=%s", resp.StatusCode, upstreamMsg) + return nil, s.writeGeminiNativeUpstreamError(c, account, resp, respBody, requestID, isOAuth) } var usage *ClaudeUsage @@ -1695,22 +1667,16 @@ func (s *GeminiMessagesCompatService) shouldFailoverGeminiUpstreamError(statusCo } } -// poolModeSkippedFailoverError 池模式账号命中 ErrorPolicySkipped 时构造 failover 错误: -// 可 failover 的状态码返回 UpstreamFailoverError,交给 handler 层按 pool_mode_retry_count -// 同账号重试后换号;返回 nil 表示不适用(非池模式或状态码不可 failover),由调用方透传。 -func (s *GeminiMessagesCompatService) poolModeSkippedFailoverError(c *gin.Context, account *Account, statusCode int, respBody []byte, upstreamRequestID string) *UpstreamFailoverError { - if !account.IsPoolMode() || !s.shouldFailoverGeminiUpstreamError(statusCode) { +// skippedErrorPolicyFailoverError 命中 ErrorPolicySkipped(池模式、或自定义错误码未命中) +// 时构造 failover 错误:可 failover 的状态码返回 UpstreamFailoverError,交给 handler 层换号 +// (池模式账号按 pool_mode_retry_count 先同账号重试);返回 nil 表示状态码不可 failover, +// 由调用方决定客户端写出。Skipped 只豁免账号状态标记,不豁免换号,与 OpenAI 网关路径一致。 +func (s *GeminiMessagesCompatService) skippedErrorPolicyFailoverError(c *gin.Context, account *Account, statusCode int, respBody []byte, upstreamRequestID string) *UpstreamFailoverError { + if !s.shouldFailoverGeminiUpstreamError(statusCode) { return nil } upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) - upstreamDetail := "" - if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes - if maxBytes <= 0 { - maxBytes = 2048 - } - upstreamDetail = truncateString(string(respBody), maxBytes) - } + upstreamDetail := s.upstreamErrorDetail(respBody) appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ Platform: account.Platform, AccountID: account.ID, @@ -1724,8 +1690,83 @@ func (s *GeminiMessagesCompatService) poolModeSkippedFailoverError(c *gin.Contex return &UpstreamFailoverError{ StatusCode: statusCode, ResponseBody: respBody, - RetryableOnSameAccount: account.IsPoolModeRetryableStatus(statusCode), + RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode), + } +} + +// geminiCustomCodeSkippedClientMessage 自定义错误码未命中时对客户端隐藏上游细节的固定文案, +// 与 OpenAI 网关路径同场景的文案一致。 +const geminiCustomCodeSkippedClientMessage = "Upstream gateway error" + +// upstreamErrorDetail 按配置截断上游错误响应体,用于 ops 错误日志的 Detail 字段; +// 未开启 LogUpstreamErrorBody 时返回空。 +func (s *GeminiMessagesCompatService) upstreamErrorDetail(body []byte) string { + if s.cfg == nil || !s.cfg.Gateway.LogUpstreamErrorBody { + return "" + } + maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes + if maxBytes <= 0 { + maxBytes = 2048 + } + return truncateString(string(body), maxBytes) +} + +// writeGeminiCustomCodeSkippedError 处理自定义错误码未命中且不可 failover 的上游错误: +// 客户端统一收到 500 + 固定文案(由 write 按端点格式写出),不透传上游细节; +// 上游真实状态码与错误信息仅记录到 ops 错误日志。 +func (s *GeminiMessagesCompatService) writeGeminiCustomCodeSkippedError(c *gin.Context, account *Account, upstreamStatus int, upstreamRequestID string, body []byte, write func()) error { + upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(body))) + upstreamDetail := s.upstreamErrorDetail(body) + setOpsUpstreamError(c, upstreamStatus, upstreamMsg, upstreamDetail) + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: upstreamStatus, + UpstreamRequestID: upstreamRequestID, + Kind: "http_error", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + write() + if upstreamMsg == "" { + return fmt.Errorf("gemini upstream error: %d (not in custom error codes)", upstreamStatus) + } + return fmt.Errorf("gemini upstream error: %d (not in custom error codes) message=%s", upstreamStatus, upstreamMsg) +} + +// writeGeminiNativeUpstreamError 将不可 failover 的上游错误按原始状态码与响应体透传给客户端, +// 并记录 ops 错误事件。状态码保真:下游据此区分请求级错误与可重试的链路故障。 +func (s *GeminiMessagesCompatService) writeGeminiNativeUpstreamError(c *gin.Context, account *Account, resp *http.Response, respBody []byte, requestID string, isOAuth bool) error { + respBody = unwrapIfNeeded(isOAuth, respBody) + upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) + upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + upstreamDetail := s.upstreamErrorDetail(respBody) + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + logger.LegacyPrintf("service.gemini_messages_compat", "[Gemini] native upstream error %d: %s", resp.StatusCode, truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)) + } + setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: requestID, + Kind: "http_error", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/json" } + MarkResponseCommitted(c) + c.Data(resp.StatusCode, contentType, respBody) + if upstreamMsg == "" { + return fmt.Errorf("gemini upstream error: %d", resp.StatusCode) + } + return fmt.Errorf("gemini upstream error: %d message=%s", resp.StatusCode, upstreamMsg) } func sleepGeminiBackoff(attempt int) { @@ -1827,6 +1868,10 @@ func (s *GeminiMessagesCompatService) writeGeminiMappedError(c *gin.Context, acc if errType == "" { errType = "invalid_request_error" } + // 400 是确定性的请求错误:回传上游 message(已脱敏),客户端据此定位非法字段。 + if errMsg == "" { + errMsg = upstreamMsg + } if errMsg == "" { errMsg = "Invalid request" } diff --git a/backend/internal/service/leader_lock_test.go b/backend/internal/service/leader_lock_test.go index aba1c62c3ee3..a2adf0126733 100644 --- a/backend/internal/service/leader_lock_test.go +++ b/backend/internal/service/leader_lock_test.go @@ -92,10 +92,10 @@ func TestSubscriptionExpiryService_ReminderSkipsScanWhenNotLeader(t *testing.T) _, _ = cache.TryAcquireLeaderLock(context.Background(), subscriptionExpiryReminderLeaderLockKey, "peer", time.Minute) repo := &subscriptionExpiryRepoStub{} - settingRepo := &subscriptionExpirySettingRepoStub{values: map[string]string{}} + settingRepo := &subscriptionExpirySettingRepoStub{values: map[string]string{SettingKeySMTPHost: "smtp.example.com"}} svc := NewSubscriptionExpiryService(repo, time.Minute) svc.SetSettingRepository(settingRepo) - svc.SetNotificationEmailService(NewNotificationEmailService(settingRepo, nil)) + svc.SetNotificationEmailService(NewNotificationEmailService(settingRepo, NewEmailService(settingRepo, nil))) svc.SetLeaderLock(cache, nil) svc.sendExpiryReminders(context.Background()) @@ -105,10 +105,10 @@ func TestSubscriptionExpiryService_ReminderSkipsScanWhenNotLeader(t *testing.T) func TestSubscriptionExpiryService_ReminderScansWhenLeader(t *testing.T) { repo := &subscriptionExpiryRepoStub{} - settingRepo := &subscriptionExpirySettingRepoStub{values: map[string]string{}} + settingRepo := &subscriptionExpirySettingRepoStub{values: map[string]string{SettingKeySMTPHost: "smtp.example.com"}} svc := NewSubscriptionExpiryService(repo, time.Minute) svc.SetSettingRepository(settingRepo) - svc.SetNotificationEmailService(NewNotificationEmailService(settingRepo, nil)) + svc.SetNotificationEmailService(NewNotificationEmailService(settingRepo, NewEmailService(settingRepo, nil))) svc.SetLeaderLock(&fakeLeaderLockCache{}, nil) svc.sendExpiryReminders(context.Background()) @@ -127,10 +127,10 @@ func TestSubscriptionExpiryService_ReminderRunsEveryCycleSingleInstance(t *testi for name, cache := range cases { t.Run(name, func(t *testing.T) { repo := &subscriptionExpiryRepoStub{} - settingRepo := &subscriptionExpirySettingRepoStub{values: map[string]string{}} + settingRepo := &subscriptionExpirySettingRepoStub{values: map[string]string{SettingKeySMTPHost: "smtp.example.com"}} svc := NewSubscriptionExpiryService(repo, time.Minute) svc.SetSettingRepository(settingRepo) - svc.SetNotificationEmailService(NewNotificationEmailService(settingRepo, nil)) + svc.SetNotificationEmailService(NewNotificationEmailService(settingRepo, NewEmailService(settingRepo, nil))) svc.SetLeaderLock(cache, nil) // Three consecutive cycles, mimicking the ticker loop. diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go index ace8366a0eb0..2822374257fc 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath.go @@ -72,6 +72,10 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont if s == nil || account == nil { return false } + // Team 联动熔断必须先于 model-not-found 与账户级临时不可调度规则的早退。 + if s.rateLimitService != nil { + s.rateLimitService.maybeHandleOpenAITeamLinkedError(stateCtx, account, statusCode, responseBody) + } stateCtx = withTempUnschedulableModel(stateCtx, canonicalModel) if s.rateLimitService != nil && len(canonicalModel) > 0 && s.rateLimitService.HandleUpstreamModelNotFound(stateCtx, account, canonicalModel[0], statusCode, responseBody) { return true diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index 6db0db766d1c..1daf81988cba 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -381,7 +381,7 @@ func (s *defaultOpenAIAccountScheduler) Select( }() previousResponseID := strings.TrimSpace(req.PreviousResponseID) - if previousResponseID != "" && normalizeOpenAICompatiblePlatform(req.Platform) == PlatformOpenAI && + if previousResponseID != "" && NormalizeOpenAICompatiblePlatform(req.Platform) == PlatformOpenAI && (!req.StickyWeighted || !req.PreviousResponseCanMove) { selection, err := s.service.selectAccountByPreviousResponseIDForCapability( ctx, @@ -486,7 +486,7 @@ func (s *defaultOpenAIAccountScheduler) selectBySessionHash( _ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash) return nil, false, nil } - if shouldClearStickySession(account, req.RequestedModel) || account.Platform != normalizeOpenAICompatiblePlatform(req.Platform) || !account.IsOpenAICompatible() || !account.IsSchedulable() { + if shouldClearStickySession(account, req.RequestedModel) || account.Platform != NormalizeOpenAICompatiblePlatform(req.Platform) || !account.IsOpenAICompatible() || !account.IsSchedulable() { _ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash) return nil, false, nil } @@ -1406,7 +1406,7 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance( filterStats.exclude("not_schedulable") continue } - if account.Platform != normalizeOpenAICompatiblePlatform(req.Platform) || !account.IsOpenAICompatible() { + if account.Platform != NormalizeOpenAICompatiblePlatform(req.Platform) || !account.IsOpenAICompatible() { filterStats.exclude("platform_mismatch") continue } @@ -2125,7 +2125,7 @@ func (s *OpenAIGatewayService) selectAccountWithScheduler( return selection, decision, err } // The circuit only ever quarantines PlatformOpenAI accounts. - if normalizeOpenAICompatiblePlatform(platform) != PlatformOpenAI { + if NormalizeOpenAICompatiblePlatform(platform) != PlatformOpenAI { return selection, decision, err } blocked := s.getOpenAIProxyStreamCircuit().activeBlockCount(time.Now()) @@ -2161,7 +2161,7 @@ func (s *OpenAIGatewayService) selectAccountWithSchedulerOnce( if requiredImageCapability == "" { ctx = s.withOpenAIProfitControlGate(ctx, groupID) } - platform = normalizeOpenAICompatiblePlatform(platform) + platform = NormalizeOpenAICompatiblePlatform(platform) decision := OpenAIAccountScheduleDecision{} scheduler := s.getOpenAIAccountScheduler(ctx) if scheduler == nil { diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go index 0ddc8f525551..7e7a13f3b5c0 100644 --- a/backend/internal/service/openai_alpha_search.go +++ b/backend/internal/service/openai_alpha_search.go @@ -11,7 +11,6 @@ import ( "strings" "time" - "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" ) @@ -239,25 +238,26 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(c if turnMetadata := openAIAlphaSearchInboundHeader(c, "X-Codex-Turn-Metadata"); turnMetadata != "" { req.Header.Set("X-Codex-Turn-Metadata", turnMetadata) } + canonical := resolveCodexOutboundIdentity("") if version := openAIAlphaSearchInboundHeader(c, "Version"); version != "" { req.Header.Set("Version", version) } else { - req.Header.Set("Version", codexCLIVersion) + req.Header.Set("Version", canonical.version) } if originator := openAIAlphaSearchInboundHeader(c, "Originator"); originator != "" { req.Header.Set("Originator", originator) } else { - req.Header.Set("Originator", openai.CodexDefaultOriginator) + req.Header.Set("Originator", canonical.originator) } if customUA := account.GetOpenAIUserAgent(); customUA != "" { req.Header.Set("User-Agent", customUA) } else if userAgent := openAIAlphaSearchInboundHeader(c, "User-Agent"); userAgent != "" { req.Header.Set("User-Agent", userAgent) } else { - req.Header.Set("User-Agent", codexCLIUserAgent) + req.Header.Set("User-Agent", canonical.userAgent) } if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { - req.Header.Set("User-Agent", codexCLIUserAgent) + req.Header.Set("User-Agent", canonical.userAgent) } apiKeyID := getAPIKeyIDFromContext(c) if sessionID := strings.TrimSpace(gjson.GetBytes(alphaBody, "id").String()); sessionID != "" { @@ -382,25 +382,26 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchRequest(ctx context.Context if turnMetadata := openAIAlphaSearchInboundHeader(c, "X-Codex-Turn-Metadata"); turnMetadata != "" { req.Header.Set("X-Codex-Turn-Metadata", turnMetadata) } + canonical := resolveCodexOutboundIdentity("") if version := openAIAlphaSearchInboundHeader(c, "Version"); version != "" { req.Header.Set("Version", version) } else { - req.Header.Set("Version", codexCLIVersion) + req.Header.Set("Version", canonical.version) } if originator := openAIAlphaSearchInboundHeader(c, "Originator"); originator != "" { req.Header.Set("Originator", originator) } else { - req.Header.Set("Originator", openai.CodexDefaultOriginator) + req.Header.Set("Originator", canonical.originator) } if customUA := account.GetOpenAIUserAgent(); customUA != "" { req.Header.Set("User-Agent", customUA) } else if userAgent := openAIAlphaSearchInboundHeader(c, "User-Agent"); userAgent != "" { req.Header.Set("User-Agent", userAgent) } else { - req.Header.Set("User-Agent", codexCLIUserAgent) + req.Header.Set("User-Agent", canonical.userAgent) } if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { - req.Header.Set("User-Agent", codexCLIUserAgent) + req.Header.Set("User-Agent", canonical.userAgent) } enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account)) } diff --git a/backend/internal/service/openai_alpha_search_billing_test.go b/backend/internal/service/openai_alpha_search_billing_test.go index 99cacc88f7d7..b8c6236d9dba 100644 --- a/backend/internal/service/openai_alpha_search_billing_test.go +++ b/backend/internal/service/openai_alpha_search_billing_test.go @@ -5,6 +5,7 @@ package service import ( "context" "testing" + "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/stretchr/testify/require" @@ -50,7 +51,7 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) { // 即使 token 倍率(含高峰,3.0)更高也不采用。 apiKey := &APIKey{ID: 1, GroupID: &groupID, Group: &Group{ID: groupID, Platform: PlatformOpenAI}} result := &OpenAIForwardResult{Model: "gpt-5.6-sol", UpstreamModel: "gpt-5.6-sol", WebSearchCalls: 1} - cost, err := svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 3.0, 1.0, 1.0, 2.0, UsageTokens{}, "", boolPtr(false)) + cost, err := svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 3.0, 1.0, 1.0, 2.0, UsageTokens{}, "", boolPtr(false), time.Time{}) require.NoError(t, err) require.Equal(t, string(BillingModePerRequest), cost.BillingMode) require.InDelta(t, 0.01, cost.TotalCost, 1e-12) @@ -58,7 +59,7 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) { // 分组配置单价 0.005 apiKey.Group.WebSearchPricePerCall = float64Ptr(0.005) - cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{}, "", boolPtr(false)) + cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{}, "", boolPtr(false), time.Time{}) require.NoError(t, err) require.InDelta(t, 0.005, cost.TotalCost, 1e-12) require.InDelta(t, 0.005, cost.ActualCost, 1e-12) @@ -66,7 +67,7 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) { // WebSearchCalls = 0 时不得走按次分支(无定价数据会返回 pricing 错误, // 证明回落到了 token 路径而不是被按次分支吞掉)。 result.WebSearchCalls = 0 - _, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 10}, "", boolPtr(false)) + _, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 10}, "", boolPtr(false), time.Time{}) require.Error(t, err) } diff --git a/backend/internal/service/openai_apikey_responses_probe.go b/backend/internal/service/openai_apikey_responses_probe.go index 79db519e6f6f..645a581694a4 100644 --- a/backend/internal/service/openai_apikey_responses_probe.go +++ b/backend/internal/service/openai_apikey_responses_probe.go @@ -119,7 +119,27 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte logger.LegacyPrintf("service.openai_probe", "probe_load_account_failed: account_id=%d err=%v", accountID, err) return } - if account.Platform != PlatformOpenAI || account.Type != AccountTypeAPIKey { + if account.Type != AccountTypeAPIKey { + return + } + if account.IsCNProvider() { + // 国产 OpenAI 兼容上游(kimi/zhipu/deepseek)普遍仅支持 /v1/chat/completions, + // 不存在 /v1/responses 端点。直接落标 false 走 Chat Completions 直转,跳过网络探测。 + // 例外:deepseek 的 responses 协议账号(api_protocol=responses)使用官方原生 + // /responses 端点,落标 force_responses 强制走 Responses 路径。 + if account.GetAPIProtocol() == APIProtocolResponses { + _ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceResponses), + openai_compat.ExtraKeyResponsesSupported: true, + }) + return + } + _ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ + openai_compat.ExtraKeyResponsesSupported: false, + }) + return + } + if account.Platform != PlatformOpenAI { // 仅 OpenAI APIKey 账号需要探测;其他账号类型无能力差异。 return } diff --git a/backend/internal/service/openai_bulk_account_settings.go b/backend/internal/service/openai_bulk_account_settings.go new file mode 100644 index 000000000000..f418e575f42f --- /dev/null +++ b/backend/internal/service/openai_bulk_account_settings.go @@ -0,0 +1,225 @@ +package service + +import ( + "fmt" + "strconv" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" +) + +type bulkOpenAISettings struct { + longContextBilling bool + endpointCapabilities bool + responsesMode bool + capabilitiesIncludeChat bool + forcedResponsesMode bool +} + +func (s bulkOpenAISettings) any() bool { + return s.longContextBilling || s.endpointCapabilities || s.responsesMode +} + +func normalizeBulkOpenAISettings(input *BulkUpdateAccountsInput) (bulkOpenAISettings, error) { + var settings bulkOpenAISettings + if input == nil { + return settings, nil + } + + if _, exists := input.Extra[openAILongContextBillingEnabledKey]; exists { + settings.longContextBilling = true + if err := ValidateOpenAILongContextBillingExtra(PlatformOpenAI, input.Extra); err != nil { + return settings, err + } + } + + if raw, exists := input.Credentials[openAIEndpointCapabilitiesCredentialKey]; exists { + settings.endpointCapabilities = true + capabilities, includeChat, err := normalizeBulkOpenAIEndpointCapabilities(raw) + if err != nil { + return settings, err + } + settings.capabilitiesIncludeChat = includeChat + input.Credentials[openAIEndpointCapabilitiesCredentialKey] = capabilities + } + + if raw, exists := input.Extra[openai_compat.ExtraKeyResponsesMode]; exists { + settings.responsesMode = true + mode, forced, err := normalizeBulkOpenAIResponsesMode(raw) + if err != nil { + return settings, err + } + settings.forcedResponsesMode = forced + input.Extra[openai_compat.ExtraKeyResponsesMode] = mode + } + + if settings.endpointCapabilities && !settings.capabilitiesIncludeChat { + if settings.forcedResponsesMode { + return settings, infraerrors.BadRequest( + "OPENAI_RESPONSES_MODE_INVALID", + "a forced Responses route requires the chat_completions endpoint capability", + ) + } + if input.Extra == nil { + input.Extra = make(map[string]any, 1) + } + input.Extra[openai_compat.ExtraKeyResponsesMode] = nil + settings.responsesMode = true + } + + return settings, nil +} + +func normalizeBulkOpenAIEndpointCapabilities(raw any) (any, bool, error) { + if raw == nil { + return nil, true, nil + } + + values := make([]string, 0, 2) + switch typed := raw.(type) { + case []any: + for _, item := range typed { + value, ok := item.(string) + if !ok { + return nil, false, invalidBulkOpenAIEndpointCapabilities() + } + values = append(values, value) + } + case []string: + values = append(values, typed...) + default: + return nil, false, invalidBulkOpenAIEndpointCapabilities() + } + + selected := make(map[string]bool, 2) + for _, value := range values { + switch OpenAIEndpointCapability(value) { + case OpenAIEndpointCapabilityChatCompletions, OpenAIEndpointCapabilityEmbeddings: + selected[value] = true + default: + return nil, false, invalidBulkOpenAIEndpointCapabilities() + } + } + if len(selected) == 0 { + return nil, false, invalidBulkOpenAIEndpointCapabilities() + } + + includeChat := selected[string(OpenAIEndpointCapabilityChatCompletions)] + if includeChat && selected[string(OpenAIEndpointCapabilityEmbeddings)] { + return nil, true, nil + } + if includeChat { + return []string{string(OpenAIEndpointCapabilityChatCompletions)}, true, nil + } + return []string{string(OpenAIEndpointCapabilityEmbeddings)}, false, nil +} + +func invalidBulkOpenAIEndpointCapabilities() error { + return infraerrors.BadRequest( + "OPENAI_ENDPOINT_CAPABILITIES_INVALID", + "openai_capabilities must contain chat_completions, embeddings, or both", + ) +} + +func normalizeBulkOpenAIResponsesMode(raw any) (any, bool, error) { + if raw == nil { + return nil, false, nil + } + mode, ok := raw.(string) + if !ok { + return nil, false, invalidBulkOpenAIResponsesMode() + } + switch openai_compat.ResponsesSupportMode(mode) { + case openai_compat.ResponsesSupportModeAuto: + return nil, false, nil + case openai_compat.ResponsesSupportModeForceResponses, + openai_compat.ResponsesSupportModeForceChatCompletions: + return mode, true, nil + default: + return nil, false, invalidBulkOpenAIResponsesMode() + } +} + +func invalidBulkOpenAIResponsesMode() error { + return infraerrors.BadRequest( + "OPENAI_RESPONSES_MODE_INVALID", + "openai_responses_mode must be auto, force_responses, force_chat_completions, or null", + ) +} + +func validateBulkOpenAISettingsTargets( + input *BulkUpdateAccountsInput, + settings bulkOpenAISettings, + targetsByID map[int64]*Account, +) (int, error) { + if input == nil || !settings.any() { + return 0, nil + } + + inheritedCount := 0 + for _, accountID := range input.AccountIDs { + account, ok := targetsByID[accountID] + if !ok || account == nil { + return 0, invalidBulkOpenAITarget(accountID, "account does not exist") + } + + if settings.longContextBilling { + if account.Platform != PlatformOpenAI || !supportsOpenAILongContextBilling(account.Type) { + return 0, invalidBulkOpenAITarget(accountID, "long-context billing requires an OpenAI OAuth, setup-token, or API-key account") + } + if account.IsShadow() { + inheritedCount++ + } + } + + if settings.endpointCapabilities || settings.responsesMode { + if account.Platform != PlatformOpenAI || account.Type != AccountTypeAPIKey { + return 0, invalidBulkOpenAITarget(accountID, "endpoint capabilities and Responses routing require an OpenAI API-key account") + } + } + + if settings.forcedResponsesMode && !settings.capabilitiesIncludeChat && + !settings.endpointCapabilities && + !account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions) { + return 0, invalidBulkOpenAITarget(accountID, "a forced Responses route requires the chat_completions endpoint capability") + } + } + + if settings.longContextBilling && inheritedCount == len(input.AccountIDs) && bulkUpdateOnlyChangesLongContext(input) { + return 0, infraerrors.BadRequest( + "OPENAI_LONG_CONTEXT_PARENT_REQUIRED", + "long-context billing is owned by parent accounts; select at least one parent account", + ) + } + return inheritedCount, nil +} + +func supportsOpenAILongContextBilling(accountType string) bool { + switch accountType { + case AccountTypeOAuth, AccountTypeSetupToken, AccountTypeAPIKey: + return true + default: + return false + } +} + +func invalidBulkOpenAITarget(accountID int64, message string) error { + return infraerrors.BadRequest( + "OPENAI_BULK_TARGET_INVALID", + fmt.Sprintf("account %d: %s", accountID, message), + ).WithMetadata(map[string]string{"account_id": strconv.FormatInt(accountID, 10)}) +} + +func bulkUpdateOnlyChangesLongContext(input *BulkUpdateAccountsInput) bool { + if input == nil || input.Name != "" || input.ProxyID != nil || input.Concurrency != nil || + input.Priority != nil || input.RateMultiplier != nil || input.LoadFactor != nil || + input.Status != "" || input.Schedulable != nil || input.GroupIDs != nil || + len(input.Credentials) != 0 || input.ProbeEnabled != nil { + return false + } + if len(input.Extra) != 1 { + return false + } + _, ok := input.Extra[openAILongContextBillingEnabledKey] + return ok +} diff --git a/backend/internal/service/openai_capacity_shed_test.go b/backend/internal/service/openai_capacity_shed_test.go index e68a48db1cb5..eef8261bb665 100644 --- a/backend/internal/service/openai_capacity_shed_test.go +++ b/backend/internal/service/openai_capacity_shed_test.go @@ -250,7 +250,6 @@ func TestCodexOutboundVersionHasSingleSource(t *testing.T) { strings.HasPrefix(codexCLIUserAgent, openai.CodexDefaultOriginator+"/"+codexCLIVersion+" "), "codexCLIUserAgent=%q 必须以 codexCLIVersion=%q 作为版本段", codexCLIUserAgent, codexCLIVersion, ) - require.Equal(t, codexCLIVersion, openAICodexProbeVersion) require.GreaterOrEqual(t, CompareVersions(codexCLIVersion, codexUpstreamMinVersion), 0, "codexCLIVersion=%q 不得低于上游最低门槛 %q", codexCLIVersion, codexUpstreamMinVersion, ) diff --git a/backend/internal/service/openai_codex_fingerprint.go b/backend/internal/service/openai_codex_fingerprint.go index c74669c00c40..0f841fb2fe29 100644 --- a/backend/internal/service/openai_codex_fingerprint.go +++ b/backend/internal/service/openai_codex_fingerprint.go @@ -5,6 +5,7 @@ import ( "encoding/binary" "encoding/json" "fmt" + "maps" "net/http" "strings" "time" @@ -30,20 +31,30 @@ func stageCodexFingerprintIDs(c *gin.Context, ids *codexFingerprintIDs) { } } -// applyStagedCodexFingerprintHeaders 读取 context 暂存的收敛 ID 并改写出站头。 -// 非透传与透传两个请求构造器共用本函数,防止应用语义漂移。仅 OAuth 账号 -// 生效(stale 键在账号类型混合 failover 下由该门挡住)。 -func applyStagedCodexFingerprintHeaders(c *gin.Context, account *Account, h http.Header) { +func stagedCodexFingerprintIDs(c *gin.Context, account *Account) *codexFingerprintIDs { if c == nil || account == nil || account.Type != AccountTypeOAuth { - return + return nil } value, ok := c.Get(codexFingerprintIDsContextKey) if !ok { - return + return nil } - if ids, ok := value.(*codexFingerprintIDs); ok { - applyCodexFingerprintHeaders(h, ids) + ids, ok := value.(*codexFingerprintIDs) + if !ok || ids == nil || ids.accountID != account.ID { + return nil } + return ids +} + +// applyStagedCodexFingerprintHeaders 读取 context 暂存的收敛 ID 并改写出站头。 +// 非透传与透传两个请求构造器共用本函数,防止应用语义漂移。仅解析该 +// snapshot 的 OAuth 账号可读取,避免 stale context 跨账号 failover 泄漏。 +func applyStagedCodexFingerprintHeaders(c *gin.Context, account *Account, h http.Header) { + applyCodexFingerprintHeaders(h, stagedCodexFingerprintIDs(c, account)) +} + +func applyStagedCodexFingerprintClientMetadata(c *gin.Context, account *Account, reqBody map[string]any) bool { + return applyCodexFingerprintClientMetadata(reqBody, stagedCodexFingerprintIDs(c, account)) } // codexFingerprintMode 控制 OAuth 账号出站请求的设备指纹收敛强度。 @@ -68,7 +79,117 @@ const ( codexFingerprintFull codexFingerprintMode = "full" ) -const codexFingerprintModeExtraKey = "codex_fingerprint_mode" +const ( + codexFingerprintModeExtraKey = "codex_fingerprint_mode" + codexFingerprintSeedExtraKey = "codex_fingerprint_seed" +) + +func canonicalCodexFingerprintSeed(value any) (string, bool) { + raw, ok := value.(string) + if !ok { + return "", false + } + trimmed := strings.TrimSpace(raw) + parsed, err := uuid.Parse(trimmed) + if err != nil || parsed == uuid.Nil || trimmed != parsed.String() { + return "", false + } + return trimmed, true +} + +func newCodexFingerprintSeed() string { + return uuid.NewString() +} + +func stripCodexFingerprintSeed(extra map[string]any) map[string]any { + if extra == nil { + return nil + } + stripped := maps.Clone(extra) + delete(stripped, codexFingerprintSeedExtraKey) + return stripped +} + +func codexFingerprintModeFromExtra(extra map[string]any) codexFingerprintMode { + if extra == nil { + return codexFingerprintOff + } + raw, _ := extra[codexFingerprintModeExtraKey].(string) + switch codexFingerprintMode(strings.TrimSpace(raw)) { + case codexFingerprintOff, codexFingerprintDevice, codexFingerprintSession, codexFingerprintFull: + return codexFingerprintMode(strings.TrimSpace(raw)) + default: + return codexFingerprintOff + } +} + +func codexFingerprintModeRequiresSeed(mode codexFingerprintMode) bool { + switch mode { + case codexFingerprintDevice, codexFingerprintSession, codexFingerprintFull: + return true + default: + return false + } +} + +func codexFingerprintSeed(extra map[string]any) (string, bool) { + if extra == nil { + return "", false + } + return canonicalCodexFingerprintSeed(extra[codexFingerprintSeedExtraKey]) +} + +func prepareCodexFingerprintExtraForCreate(platform, accountType string, extra map[string]any) map[string]any { + prepared := stripCodexFingerprintSeed(extra) + if platform != PlatformOpenAI || accountType != AccountTypeOAuth || !codexFingerprintModeRequiresSeed(codexFingerprintModeFromExtra(prepared)) { + return prepared + } + if prepared == nil { + prepared = make(map[string]any, 1) + } + prepared[codexFingerprintSeedExtraKey] = newCodexFingerprintSeed() + return prepared +} + +func prepareCodexFingerprintExtraForUpdate(account *Account, extra map[string]any) map[string]any { + prepared := stripCodexFingerprintSeed(extra) + if account == nil || account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth { + return prepared + } + if seed, ok := codexFingerprintSeed(account.Extra); ok { + if prepared == nil { + prepared = make(map[string]any, 1) + } + prepared[codexFingerprintSeedExtraKey] = seed + return prepared + } + if codexFingerprintModeRequiresSeed(codexFingerprintModeFromExtra(prepared)) { + if prepared == nil { + prepared = make(map[string]any, 1) + } + prepared[codexFingerprintSeedExtraKey] = newCodexFingerprintSeed() + } + return prepared +} + +func sanitizedCodexFingerprintExtraUpdates(updates map[string]any) map[string]any { + if updates == nil { + return nil + } + sanitized := maps.Clone(updates) + delete(sanitized, codexFingerprintSeedExtraKey) + return sanitized +} + +// ShouldEnsureCodexFingerprintSeedForExtraUpdates reports whether a JSONB key-level +// extra update is enabling Codex fingerprint convergence and therefore must atomically +// preserve or create the system-managed per-account seed in the repository update. +func ShouldEnsureCodexFingerprintSeedForExtraUpdates(updates map[string]any) bool { + if updates == nil { + return false + } + return codexFingerprintModeRequiresSeed(codexFingerprintModeFromExtra(updates)) +} // GetCodexFingerprintMode 从账号 extra JSON 读取指纹收敛模式。 // @@ -85,13 +206,7 @@ func (a *Account) GetCodexFingerprintMode() codexFingerprintMode { if a == nil || !a.IsOpenAIOAuth() { return codexFingerprintOff } - raw := strings.TrimSpace(a.GetExtraString(codexFingerprintModeExtraKey)) - switch codexFingerprintMode(raw) { - case codexFingerprintOff, codexFingerprintDevice, codexFingerprintSession, codexFingerprintFull: - return codexFingerprintMode(raw) - default: - return codexFingerprintOff - } + return codexFingerprintModeFromExtra(a.Extra) } // deriveStableUUIDv4 从种子确定性派生一个 UUIDv4 格式的字符串。 @@ -110,45 +225,53 @@ func deriveStableUUIDv4(seed string) string { } // resolveConvergedInstallationID 返回账号级恒定的 installation_id。 -// 优先使用管理员配置的真实 device_id,无则从 accountID 确定性派生。 -func resolveConvergedInstallationID(account *Account) string { +// 优先使用管理员配置的真实 device_id,无则从系统管理的账号随机种子确定性派生。 +func resolveConvergedInstallationID(account *Account, seed string) string { if account == nil { return "" } if deviceID := account.GetOpenAIDeviceID(); deviceID != "" { return deviceID } - return deriveStableUUIDv4(fmt.Sprintf("sub2api:codex-install-id:v1:%d", account.ID)) + if seed == "" { + return "" + } + return deriveStableUUIDv4("sub2api:codex-install-id:v2:" + seed) } // resolveConvergedSessionID 返回账号级恒定的 session_id。 -func resolveConvergedSessionID(account *Account) string { - if account == nil { +func resolveConvergedSessionID(seed string) string { + if seed == "" { return "" } - return deriveStableUUIDv4(fmt.Sprintf("sub2api:codex-session-id:v1:%d", account.ID)) + return deriveStableUUIDv4("sub2api:codex-session-id:v2:" + seed) } // resolveConvergedThreadID 按客户端原始 session-id 确定性派生 thread_id。 // 每个真实 Codex 会话(不同客户端启动实例)获得一个独立线程, // 模拟正常用户 spawn 子代理或开多窗口的模式。 -func resolveConvergedThreadID(account *Account, clientSessionID string) string { - if account == nil || clientSessionID == "" { +func resolveConvergedThreadID(seed, clientSessionID string) string { + if seed == "" || clientSessionID == "" { return "" } - return deriveStableUUIDv4(fmt.Sprintf("sub2api:codex-thread-id:v1:%d:%s", account.ID, clientSessionID)) + return deriveStableUUIDv4("sub2api:codex-thread-id:v2:" + seed + ":" + clientSessionID) } // codexFingerprintIDs 收敛后的完整 ID 集合。 // 由 resolveCodexFingerprintIDs 一次性生成,同一个实例在头改写和体改写之间共享, -// 确保所有载体中的 turn_id 等随机字段一致。 +// 确保所有载体中的 turn_id 等随机字段一致。体改写时还会补记原始 +// client_metadata.session_id,用于识别 root prompt_cache_key 的默认值。 type codexFingerprintIDs struct { - mode codexFingerprintMode - installationID string - sessionID string - threadID string - turnID string - windowID string + accountID int64 + mode codexFingerprintMode + installationID string + sessionID string + threadID string + turnID string + windowID string + turnStartedAtUnixMs int64 + originalBodySessionID string + originalBodySessionIDCaptured bool } // resolveCodexFingerprintIDs 按收敛模式计算出站 ID 集合。 @@ -157,13 +280,21 @@ type codexFingerprintIDs struct { // 返回 nil 表示 off 模式,不需要改写。 // 注意:包含随机生成的 turn_id,调用方必须只调用一次并共享结果给头改写和体改写。 func resolveCodexFingerprintIDs(account *Account, clientSessionID string, mode codexFingerprintMode) *codexFingerprintIDs { - if mode == codexFingerprintOff { + if account == nil || mode == codexFingerprintOff { + return nil + } + seed, ok := codexFingerprintSeed(account.Extra) + if !ok { return nil } - ids := &codexFingerprintIDs{mode: mode} + ids := &codexFingerprintIDs{ + accountID: account.ID, + mode: mode, + turnStartedAtUnixMs: time.Now().UnixMilli(), + } - ids.installationID = resolveConvergedInstallationID(account) + ids.installationID = resolveConvergedInstallationID(account, seed) if ids.installationID == "" { return nil } @@ -173,8 +304,8 @@ func resolveCodexFingerprintIDs(account *Account, clientSessionID string, mode c return ids case codexFingerprintSession: - ids.sessionID = resolveConvergedSessionID(account) - ids.threadID = resolveConvergedThreadID(account, clientSessionID) + ids.sessionID = resolveConvergedSessionID(seed) + ids.threadID = resolveConvergedThreadID(seed, clientSessionID) if ids.threadID == "" { ids.threadID = ids.sessionID } @@ -183,7 +314,7 @@ func resolveCodexFingerprintIDs(account *Account, clientSessionID string, mode c return ids case codexFingerprintFull: - ids.sessionID = resolveConvergedSessionID(account) + ids.sessionID = resolveConvergedSessionID(seed) ids.threadID = ids.sessionID ids.turnID = uuid.Must(uuid.NewV7()).String() ids.windowID = ids.threadID + ":0" @@ -252,20 +383,21 @@ func applyCodexFingerprintHeaders(h http.Header, ids *codexFingerprintIDs) { "thread_id": ids.threadID, "turn_id": ids.turnID, "window_id": ids.windowID, - "turn_started_at_unix_ms": time.Now().UnixMilli(), + "turn_started_at_unix_ms": ids.turnStartedAtUnixMs, }) } // rewriteCodexTurnMetadataFields 解析 x-codex-turn-metadata 头中的 JSON, -// 替换指定字段后回写。保留未指定字段原样(如 sandbox、thread_source 等)。 +// 替换指定字段后回写。合法对象保留未指定字段(如 sandbox、thread_source); +// 非法/非对象值重建为最小合法 metadata,避免 flat 与 embedded identity 分裂。 func rewriteCodexTurnMetadataFields(h http.Header, fields map[string]any) { raw := strings.TrimSpace(h.Get("x-codex-turn-metadata")) if raw == "" { return } var metadata map[string]any - if err := json.Unmarshal([]byte(raw), &metadata); err != nil { - return + if err := json.Unmarshal([]byte(raw), &metadata); err != nil || metadata == nil { + metadata = make(map[string]any, len(fields)) } for k, v := range fields { metadata[k] = v @@ -284,16 +416,21 @@ func applyCodexFingerprintClientMetadata(reqBody map[string]any, ids *codexFinge return false } + captureCodexFingerprintOriginalBodySessionID(ids, reqBody["client_metadata"]) existing, _ := reqBody["client_metadata"].(map[string]any) if existing == nil { existing = make(map[string]any) } - if !applyCodexFingerprintToClientMetadataMap(existing, ids) { - return false + modified := false + if applyCodexFingerprintToClientMetadataMap(existing, ids) { + reqBody["client_metadata"] = existing + modified = true } - reqBody["client_metadata"] = existing - return true + if applyCodexFingerprintPromptCacheKey(reqBody, ids) { + modified = true + } + return modified } // applyCodexFingerprintToClientMetadataMap 是 client_metadata 改写的共享核心, @@ -330,59 +467,130 @@ func applyCodexFingerprintToClientMetadataMap(existing map[string]any, ids *code "thread_id": ids.threadID, "turn_id": ids.turnID, "window_id": ids.windowID, - "turn_started_at_unix_ms": time.Now().UnixMilli(), + "turn_started_at_unix_ms": ids.turnStartedAtUnixMs, }) return true } +func captureCodexFingerprintOriginalBodySessionID(ids *codexFingerprintIDs, clientMetadata any) { + if ids == nil || ids.originalBodySessionIDCaptured { + return + } + ids.originalBodySessionIDCaptured = true + if clientMetadata == nil { + return + } + switch metadata := clientMetadata.(type) { + case map[string]any: + if sessionID, ok := metadata["session_id"].(string); ok { + ids.originalBodySessionID = strings.TrimSpace(sessionID) + } + case map[string]string: + ids.originalBodySessionID = strings.TrimSpace(metadata["session_id"]) + } +} + +func captureCodexFingerprintOriginalBodySessionIDRaw(ids *codexFingerprintIDs, value gjson.Result) { + if ids == nil || ids.originalBodySessionIDCaptured { + return + } + ids.originalBodySessionIDCaptured = true + if value.Exists() && value.Type == gjson.String { + ids.originalBodySessionID = strings.TrimSpace(value.String()) + } +} + +func shouldRewriteCodexFingerprintPromptCacheKey(ids *codexFingerprintIDs, promptCacheKey string) bool { + if ids == nil || !ids.originalBodySessionIDCaptured || ids.originalBodySessionID == "" || ids.sessionID == "" { + return false + } + if ids.mode != codexFingerprintSession && ids.mode != codexFingerprintFull { + return false + } + return promptCacheKey == ids.originalBodySessionID +} + +func applyCodexFingerprintPromptCacheKey(reqBody map[string]any, ids *codexFingerprintIDs) bool { + if reqBody == nil { + return false + } + promptCacheKey, ok := reqBody["prompt_cache_key"].(string) + if !ok || strings.TrimSpace(promptCacheKey) == "" || !shouldRewriteCodexFingerprintPromptCacheKey(ids, promptCacheKey) { + return false + } + if promptCacheKey == ids.sessionID { + return false + } + reqBody["prompt_cache_key"] = ids.sessionID + return true +} + // applyCodexFingerprintClientMetadataRaw 在原始 JSON 字节上改写 client_metadata, // 供透传路径使用——透传是热路径,禁止对可能高达数十 MB 的 body 做全量 // Unmarshal(见 forwardOpenAIPassthrough 的轻量提取注释)。实现为:gjson 提取 // client_metadata 小对象单独解码,经共享核心改写后 sjson 一次性拼回,body -// 其余字节原样保留。语义与 applyCodexFingerprintClientMetadata 逐点一致 -// (含"非对象值整体替换为收敛集合"的行为)。 +// 其余字节原样保留;root prompt_cache_key 仅在可证明是 body session 默认值时 +// 做标量改写。语义与 applyCodexFingerprintClientMetadata 逐点一致(含 +// "非对象值整体替换为收敛集合"的行为)。 func applyCodexFingerprintClientMetadataRaw(body []byte, ids *codexFingerprintIDs) ([]byte, bool, error) { if len(body) == 0 || ids == nil { return body, false, nil } // 非 JSON 对象的 body(数组/标量/畸形)没有 client_metadata 语义, // sjson 在这类根上写字段会改写整体结构,直接放行保持原样。 - if !gjson.ParseBytes(body).IsObject() { + root := gjson.ParseBytes(body) + if !root.IsObject() { + captureCodexFingerprintOriginalBodySessionIDRaw(ids, gjson.Result{}) return body, false, nil } existing := map[string]any{} if cm := gjson.GetBytes(body, "client_metadata"); cm.IsObject() { + captureCodexFingerprintOriginalBodySessionIDRaw(ids, gjson.GetBytes(body, "client_metadata.session_id")) if err := json.Unmarshal([]byte(cm.Raw), &existing); err != nil { return body, false, fmt.Errorf("decode client_metadata for fingerprint: %w", err) } + } else { + captureCodexFingerprintOriginalBodySessionIDRaw(ids, gjson.Result{}) } - if !applyCodexFingerprintToClientMetadataMap(existing, ids) { - return body, false, nil - } - - raw, err := json.Marshal(existing) - if err != nil { - return body, false, fmt.Errorf("encode converged client_metadata: %w", err) + next := body + modified := false + if applyCodexFingerprintToClientMetadataMap(existing, ids) { + raw, err := json.Marshal(existing) + if err != nil { + return body, false, fmt.Errorf("encode converged client_metadata: %w", err) + } + var setErr error + next, setErr = sjson.SetRawBytes(body, "client_metadata", raw) + if setErr != nil { + return body, false, fmt.Errorf("splice converged client_metadata: %w", setErr) + } + modified = true } - next, err := sjson.SetRawBytes(body, "client_metadata", raw) - if err != nil { - return body, false, fmt.Errorf("splice converged client_metadata: %w", err) + promptCacheKey := gjson.GetBytes(body, "prompt_cache_key") + if promptCacheKey.Exists() && promptCacheKey.Type == gjson.String && strings.TrimSpace(promptCacheKey.String()) != "" && shouldRewriteCodexFingerprintPromptCacheKey(ids, promptCacheKey.String()) { + rewritten, err := sjson.SetBytes(next, "prompt_cache_key", ids.sessionID) + if err != nil { + return body, false, fmt.Errorf("splice converged prompt_cache_key: %w", err) + } + next = rewritten + modified = true } - return next, true, nil + return next, modified, nil } // rewriteClientMetadataEmbeddedTurnMetadata 改写 client_metadata 中内嵌的 -// x-codex-turn-metadata JSON 字符串里的指定字段。 +// x-codex-turn-metadata JSON 字符串里的指定字段。非法/非对象值会重建, +// 避免 flat client_metadata 与 embedded metadata 暴露两套身份。 func rewriteClientMetadataEmbeddedTurnMetadata(clientMetadata map[string]any, fields map[string]any) { raw, ok := clientMetadata["x-codex-turn-metadata"].(string) if !ok || raw == "" { return } var metadata map[string]any - if err := json.Unmarshal([]byte(raw), &metadata); err != nil { - return + if err := json.Unmarshal([]byte(raw), &metadata); err != nil || metadata == nil { + metadata = make(map[string]any, len(fields)) } for k, v := range fields { metadata[k] = v diff --git a/backend/internal/service/openai_codex_fingerprint_test.go b/backend/internal/service/openai_codex_fingerprint_test.go index 8e74bfa8bf12..841ee3437d49 100644 --- a/backend/internal/service/openai_codex_fingerprint_test.go +++ b/backend/internal/service/openai_codex_fingerprint_test.go @@ -13,7 +13,17 @@ import ( "github.com/stretchr/testify/require" ) +const testCodexFingerprintSeed = "11111111-1111-4111-8111-111111111111" + func newTestOAuthAccount(id int64, extra map[string]any) *Account { + if codexFingerprintModeRequiresSeed(codexFingerprintModeFromExtra(extra)) { + if extra == nil { + extra = make(map[string]any) + } + if _, exists := extra[codexFingerprintSeedExtraKey]; !exists { + extra[codexFingerprintSeedExtraKey] = testCodexFingerprintSeed + } + } return &Account{ ID: id, Platform: PlatformOpenAI, @@ -75,42 +85,40 @@ func TestGetCodexFingerprintMode(t *testing.T) { func TestResolveConvergedInstallationID_UsesDeviceID(t *testing.T) { account := newTestOAuthAccount(1, map[string]any{"openai_device_id": "real-device-id"}) - assert.Equal(t, "real-device-id", resolveConvergedInstallationID(account)) + assert.Equal(t, "real-device-id", resolveConvergedInstallationID(account, testCodexFingerprintSeed)) } -func TestResolveConvergedInstallationID_DerivesFromAccountID(t *testing.T) { +func TestResolveConvergedInstallationID_DerivesFromSeed(t *testing.T) { account := newTestOAuthAccount(42, nil) - result := resolveConvergedInstallationID(account) + result := resolveConvergedInstallationID(account, testCodexFingerprintSeed) _, err := uuid.Parse(result) require.NoError(t, err, "派生值应为合法 UUID") - assert.Equal(t, result, resolveConvergedInstallationID(account), "确定性") + assert.Equal(t, result, resolveConvergedInstallationID(account, testCodexFingerprintSeed), "确定性") } -func TestResolveConvergedInstallationID_DifferentAccounts(t *testing.T) { - a := resolveConvergedInstallationID(newTestOAuthAccount(1, nil)) - b := resolveConvergedInstallationID(newTestOAuthAccount(2, nil)) +func TestResolveConvergedInstallationID_DifferentSeeds(t *testing.T) { + account := newTestOAuthAccount(1, nil) + a := resolveConvergedInstallationID(account, testCodexFingerprintSeed) + b := resolveConvergedInstallationID(account, "22222222-2222-4222-8222-222222222222") assert.NotEqual(t, a, b) } // --- resolveConvergedThreadID --- func TestResolveConvergedThreadID_PerClientSession(t *testing.T) { - account := newTestOAuthAccount(1, nil) - a := resolveConvergedThreadID(account, "session-aaa") - b := resolveConvergedThreadID(account, "session-bbb") + a := resolveConvergedThreadID(testCodexFingerprintSeed, "session-aaa") + b := resolveConvergedThreadID(testCodexFingerprintSeed, "session-bbb") assert.NotEqual(t, a, b, "不同客户端 session 应得到不同 thread_id") } func TestResolveConvergedThreadID_Deterministic(t *testing.T) { - account := newTestOAuthAccount(1, nil) - a := resolveConvergedThreadID(account, "session-aaa") - b := resolveConvergedThreadID(account, "session-aaa") + a := resolveConvergedThreadID(testCodexFingerprintSeed, "session-aaa") + b := resolveConvergedThreadID(testCodexFingerprintSeed, "session-aaa") assert.Equal(t, a, b, "同一客户端 session 应得到相同 thread_id") } func TestResolveConvergedThreadID_EmptySession(t *testing.T) { - account := newTestOAuthAccount(1, nil) - assert.Equal(t, "", resolveConvergedThreadID(account, "")) + assert.Equal(t, "", resolveConvergedThreadID(testCodexFingerprintSeed, "")) } // --- off 模式:resolveCodexFingerprintIDsFromRequest 返回 nil --- @@ -141,6 +149,25 @@ func TestResolveCodexFingerprintIDsFromRequest_ExplicitOptInHonored(t *testing.T } } +func TestResolveCodexFingerprintIDsFromRequest_EnabledModesRequireValidSeed(t *testing.T) { + for _, tt := range []struct { + name string + extra map[string]any + }{ + {name: "missing", extra: map[string]any{codexFingerprintModeExtraKey: "device"}}, + {name: "missing with device override", extra: map[string]any{codexFingerprintModeExtraKey: "device", "openai_device_id": "real-device"}}, + {name: "blank", extra: map[string]any{codexFingerprintModeExtraKey: "session", codexFingerprintSeedExtraKey: ""}}, + {name: "uppercase", extra: map[string]any{codexFingerprintModeExtraKey: "full", codexFingerprintSeedExtraKey: "11111111-1111-4111-8111-AAAAAAAAAAAA"}}, + {name: "nil uuid", extra: map[string]any{codexFingerprintModeExtraKey: "device", codexFingerprintSeedExtraKey: "00000000-0000-0000-0000-000000000000"}}, + {name: "non string", extra: map[string]any{codexFingerprintModeExtraKey: "session", codexFingerprintSeedExtraKey: 123}}, + } { + t.Run(tt.name, func(t *testing.T) { + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: tt.extra} + require.Nil(t, resolveCodexFingerprintIDsFromRequest(account, nil)) + }) + } +} + // --- applyCodexFingerprintHeaders: off 模式 --- func TestApplyCodexFingerprintHeaders_OffMode(t *testing.T) { @@ -199,9 +226,11 @@ func TestApplyCodexFingerprintHeaders_SessionMode(t *testing.T) { ids := resolveCodexFingerprintIDsFromRequest(account, clientHeaders) applyCodexFingerprintHeaders(h, ids) - convergedInstall := resolveConvergedInstallationID(account) - convergedSession := resolveConvergedSessionID(account) - convergedThread := resolveConvergedThreadID(account, "client-session-aaa") + seed, ok := codexFingerprintSeed(account.Extra) + require.True(t, ok) + convergedInstall := resolveConvergedInstallationID(account, seed) + convergedSession := resolveConvergedSessionID(seed) + convergedThread := resolveConvergedThreadID(seed, "client-session-aaa") assert.Equal(t, convergedInstall, h.Get("x-codex-installation-id")) assert.Equal(t, convergedSession, h.Get("session-id")) @@ -257,7 +286,9 @@ func TestApplyCodexFingerprintHeaders_FullMode(t *testing.T) { account := newTestOAuthAccount(1, map[string]any{ codexFingerprintModeExtraKey: "full", }) - convergedSession := resolveConvergedSessionID(account) + seed, ok := codexFingerprintSeed(account.Extra) + require.True(t, ok) + convergedSession := resolveConvergedSessionID(seed) clientA := http.Header{} clientA.Set("session-id", "client-A") @@ -329,6 +360,41 @@ func TestFingerprintIDs_HeaderAndBody_TurnID_Consistent(t *testing.T) { assert.Equal(t, headerTurnID, bodyTurnID, "头和体的 turn_id 必须一致") assert.Equal(t, headerTurnID, bodyEmbeddedTurnID, "头和体内嵌 turn-metadata 的 turn_id 必须一致") assert.Equal(t, ids.turnID, headerTurnID, "所有 turn_id 都应来自同一份 ids") + assert.Equal(t, headerMeta["turn_started_at_unix_ms"], bodyMeta["turn_started_at_unix_ms"], "头和体的 timestamp 必须一致") + assert.Equal(t, float64(ids.turnStartedAtUnixMs), headerMeta["turn_started_at_unix_ms"]) +} + +func TestFingerprintIDs_MalformedEmbeddedMetadataRebuiltConsistently(t *testing.T) { + account := newTestOAuthAccount(2, map[string]any{codexFingerprintModeExtraKey: "session"}) + clientHeaders := make(http.Header) + clientHeaders.Set("session-id", "client-session-malformed") + ids := resolveCodexFingerprintIDsFromRequest(account, clientHeaders) + require.NotNil(t, ids) + + h := make(http.Header) + h.Set("x-codex-turn-metadata", "{malformed") + applyCodexFingerprintHeaders(h, ids) + + reqBody := map[string]any{ + "client_metadata": map[string]any{ + "session_id": "client-session-malformed", + "x-codex-turn-metadata": "[malformed", + }, + } + require.True(t, applyCodexFingerprintClientMetadata(reqBody, ids)) + + var headerMeta map[string]any + require.NoError(t, json.Unmarshal([]byte(h.Get("x-codex-turn-metadata")), &headerMeta)) + clientMetadata, ok := reqBody["client_metadata"].(map[string]any) + require.True(t, ok) + bodyRaw, ok := clientMetadata["x-codex-turn-metadata"].(string) + require.True(t, ok) + var bodyMeta map[string]any + require.NoError(t, json.Unmarshal([]byte(bodyRaw), &bodyMeta)) + + for _, key := range []string{"installation_id", "session_id", "thread_id", "turn_id", "window_id", "turn_started_at_unix_ms"} { + assert.Equal(t, headerMeta[key], bodyMeta[key], "rebuilt metadata field %s must match", key) + } } // --- applyCodexFingerprintClientMetadata --- @@ -400,9 +466,11 @@ func TestApplyCodexFingerprintClientMetadata_SessionMode(t *testing.T) { cm, ok := reqBody["client_metadata"].(map[string]any) require.True(t, ok) - convergedInstall := resolveConvergedInstallationID(account) - convergedSession := resolveConvergedSessionID(account) - convergedThread := resolveConvergedThreadID(account, "client-session-aaa") + seed, ok := codexFingerprintSeed(account.Extra) + require.True(t, ok) + convergedInstall := resolveConvergedInstallationID(account, seed) + convergedSession := resolveConvergedSessionID(seed) + convergedThread := resolveConvergedThreadID(seed, "client-session-aaa") assert.Equal(t, convergedInstall, cm["x-codex-installation-id"]) assert.Equal(t, convergedSession, cm["session_id"]) @@ -441,7 +509,9 @@ func TestApplyCodexFingerprintClientMetadata_FullMode(t *testing.T) { cm, ok := reqBody["client_metadata"].(map[string]any) require.True(t, ok) - convergedSession := resolveConvergedSessionID(account) + seed, ok := codexFingerprintSeed(account.Extra) + require.True(t, ok) + convergedSession := resolveConvergedSessionID(seed) assert.Equal(t, convergedSession, cm["session_id"]) assert.Equal(t, convergedSession, cm["thread_id"], "full 模式 thread_id 应等于 session_id") @@ -496,6 +566,182 @@ func rawVsMapClientMetadata(t *testing.T, body []byte, ids *codexFingerprintIDs) return mapCM, rawCM } +func cloneCodexFingerprintIDsForTest(ids *codexFingerprintIDs) *codexFingerprintIDs { + if ids == nil { + return nil + } + cloned := *ids + cloned.originalBodySessionID = "" + cloned.originalBodySessionIDCaptured = false + return &cloned +} + +func applyMapAndRawFingerprintBodiesForTest(t *testing.T, body []byte, ids *codexFingerprintIDs) (map[string]any, map[string]any) { + t.Helper() + + mapIDs := cloneCodexFingerprintIDsForTest(ids) + rawIDs := cloneCodexFingerprintIDsForTest(ids) + + var decoded map[string]any + require.NoError(t, json.Unmarshal(body, &decoded)) + applyCodexFingerprintClientMetadata(decoded, mapIDs) + + rawBody, _, err := applyCodexFingerprintClientMetadataRaw(body, rawIDs) + require.NoError(t, err) + var rawDecoded map[string]any + require.NoError(t, json.Unmarshal(rawBody, &rawDecoded)) + return decoded, rawDecoded +} + +func TestApplyCodexFingerprintPromptCacheKey_MapRawEquivalence(t *testing.T) { + for _, mode := range []codexFingerprintMode{codexFingerprintSession, codexFingerprintFull} { + t.Run(string(mode)+"/default", func(t *testing.T) { + account := newTestOAuthAccount(4300, map[string]any{codexFingerprintModeExtraKey: string(mode)}) + ids := resolveCodexFingerprintIDs(account, "header-session", mode) + require.NotNil(t, ids) + + body := []byte(`{"model":"gpt-5.6-sol","prompt_cache_key":"body-session","client_metadata":{"session_id":" body-session ","trace":"keep"},"input":[]}`) + mapBody, rawBody := applyMapAndRawFingerprintBodiesForTest(t, body, ids) + + require.Equal(t, mapBody["prompt_cache_key"], rawBody["prompt_cache_key"]) + require.Equal(t, ids.sessionID, mapBody["prompt_cache_key"]) + mapCM, _ := mapBody["client_metadata"].(map[string]any) + rawCM, _ := rawBody["client_metadata"].(map[string]any) + require.Equal(t, ids.sessionID, mapCM["session_id"]) + require.Equal(t, mapCM["session_id"], rawCM["session_id"]) + require.Equal(t, "keep", rawCM["trace"]) + }) + } + + t.Run("explicit override", func(t *testing.T) { + account := newTestOAuthAccount(4301, map[string]any{codexFingerprintModeExtraKey: "session"}) + ids := resolveCodexFingerprintIDs(account, "header-session", codexFingerprintSession) + require.NotNil(t, ids) + + body := []byte(`{"model":"gpt-5.6-sol","prompt_cache_key":"explicit-cache","client_metadata":{"session_id":"body-session"},"input":[]}`) + mapBody, rawBody := applyMapAndRawFingerprintBodiesForTest(t, body, ids) + + require.Equal(t, "explicit-cache", mapBody["prompt_cache_key"]) + require.Equal(t, "explicit-cache", rawBody["prompt_cache_key"]) + mapCM, _ := mapBody["client_metadata"].(map[string]any) + rawCM, _ := rawBody["client_metadata"].(map[string]any) + require.Equal(t, ids.sessionID, mapCM["session_id"]) + require.Equal(t, ids.sessionID, rawCM["session_id"]) + }) +} + +func TestApplyCodexFingerprintPromptCacheKey_Negatives(t *testing.T) { + sessionAccount := newTestOAuthAccount(4310, map[string]any{codexFingerprintModeExtraKey: "session"}) + sessionIDs := resolveCodexFingerprintIDs(sessionAccount, "header-session", codexFingerprintSession) + require.NotNil(t, sessionIDs) + deviceAccount := newTestOAuthAccount(4311, map[string]any{codexFingerprintModeExtraKey: "device"}) + deviceIDs := resolveCodexFingerprintIDs(deviceAccount, "header-session", codexFingerprintDevice) + require.NotNil(t, deviceIDs) + + tests := []struct { + name string + body []byte + ids *codexFingerprintIDs + wantExists bool + wantCacheKey any + wantRawString string + }{ + { + name: "missing key is not injected", + body: []byte(`{"client_metadata":{"session_id":"body-session"}}`), + ids: sessionIDs, + wantExists: false, + }, + { + name: "empty key preserved", + body: []byte(`{"prompt_cache_key":"","client_metadata":{"session_id":"body-session"}}`), + ids: sessionIDs, + wantExists: true, + wantCacheKey: "", + }, + { + name: "whitespace-different key is an explicit override", + body: []byte(`{"prompt_cache_key":" body-session ","client_metadata":{"session_id":"body-session"}}`), + ids: sessionIDs, + wantExists: true, + wantCacheKey: " body-session ", + }, + { + name: "non-string key preserved", + body: []byte(`{"prompt_cache_key":123,"client_metadata":{"session_id":"body-session"}}`), + ids: sessionIDs, + wantExists: true, + wantCacheKey: float64(123), + }, + { + name: "missing source metadata preserves key", + body: []byte(`{"prompt_cache_key":"body-session"}`), + ids: sessionIDs, + wantExists: true, + wantCacheKey: "body-session", + }, + { + name: "non-string source session preserves key", + body: []byte(`{"prompt_cache_key":"123","client_metadata":{"session_id":123}}`), + ids: sessionIDs, + wantExists: true, + wantCacheKey: "123", + }, + { + name: "non-object source metadata preserves key", + body: []byte(`{"prompt_cache_key":"body-session","client_metadata":"bad"}`), + ids: sessionIDs, + wantExists: true, + wantCacheKey: "body-session", + }, + { + name: "device mode preserves key", + body: []byte(`{"prompt_cache_key":"body-session","client_metadata":{"session_id":"body-session"}}`), + ids: deviceIDs, + wantExists: true, + wantCacheKey: "body-session", + }, + { + name: "off mode preserves body", + body: []byte(`{"prompt_cache_key":"body-session","client_metadata":{"session_id":"body-session"}}`), + ids: nil, + wantExists: true, + wantCacheKey: "body-session", + wantRawString: `{"prompt_cache_key":"body-session","client_metadata":{"session_id":"body-session"}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var mapBody map[string]any + require.NoError(t, json.Unmarshal(tt.body, &mapBody)) + changedMap := applyCodexFingerprintClientMetadata(mapBody, cloneCodexFingerprintIDsForTest(tt.ids)) + + rawBody, changedRaw, err := applyCodexFingerprintClientMetadataRaw(tt.body, cloneCodexFingerprintIDsForTest(tt.ids)) + require.NoError(t, err) + if tt.ids == nil { + require.False(t, changedMap) + require.False(t, changedRaw) + require.JSONEq(t, tt.wantRawString, string(rawBody)) + return + } + require.True(t, changedMap) + require.True(t, changedRaw) + + rawDecoded := map[string]any{} + require.NoError(t, json.Unmarshal(rawBody, &rawDecoded)) + _, mapExists := mapBody["prompt_cache_key"] + _, rawExists := rawDecoded["prompt_cache_key"] + require.Equal(t, tt.wantExists, mapExists) + require.Equal(t, tt.wantExists, rawExists) + if tt.wantExists { + require.Equal(t, tt.wantCacheKey, mapBody["prompt_cache_key"]) + require.Equal(t, tt.wantCacheKey, rawDecoded["prompt_cache_key"]) + } + }) + } +} + func TestApplyCodexFingerprintClientMetadataRaw_MatchesMapVariant(t *testing.T) { embedded := `{\"installation_id\":\"real-install\",\"session_id\":\"real-session\",\"sandbox\":\"seatbelt\"}` bodies := map[string]string{ @@ -504,7 +750,7 @@ func TestApplyCodexFingerprintClientMetadataRaw_MatchesMapVariant(t *testing.T) "non_object_value": `{"model":"gpt-5.6-sol","client_metadata":"bogus","stream":true}`, } for _, mode := range []codexFingerprintMode{codexFingerprintDevice, codexFingerprintSession, codexFingerprintFull} { - account := newTestOAuthAccount(4242, nil) + account := newTestOAuthAccount(4242, map[string]any{codexFingerprintModeExtraKey: string(mode)}) ids := resolveCodexFingerprintIDs(account, "client-sess-raw", mode) require.NotNil(t, ids) for name, body := range bodies { @@ -517,7 +763,7 @@ func TestApplyCodexFingerprintClientMetadataRaw_MatchesMapVariant(t *testing.T) } func TestApplyCodexFingerprintClientMetadataRaw_PreservesUnrelatedFields(t *testing.T) { - account := newTestOAuthAccount(4243, nil) + account := newTestOAuthAccount(4243, map[string]any{codexFingerprintModeExtraKey: "session"}) ids := resolveCodexFingerprintIDs(account, "client-sess-preserve", codexFingerprintSession) require.NotNil(t, ids) @@ -562,7 +808,7 @@ func newFingerprintStageTestContext(t *testing.T) *gin.Context { func TestStageCodexFingerprintIDs_NilOverwritesPreviousAccount(t *testing.T) { c := newFingerprintStageTestContext(t) - accountA := newTestOAuthAccount(1001, nil) + accountA := newTestOAuthAccount(1001, map[string]any{codexFingerprintModeExtraKey: "session"}) idsA := resolveCodexFingerprintIDs(accountA, "sess-x", codexFingerprintSession) require.NotNil(t, idsA) stageCodexFingerprintIDs(c, idsA) @@ -578,9 +824,30 @@ func TestStageCodexFingerprintIDs_NilOverwritesPreviousAccount(t *testing.T) { assert.Empty(t, h.Get("x-codex-installation-id")) } +func TestApplyStagedCodexFingerprintRejectsDifferentOAuthAccount(t *testing.T) { + c := newFingerprintStageTestContext(t) + accountA := newTestOAuthAccount(1003, map[string]any{codexFingerprintModeExtraKey: "session"}) + idsA := resolveCodexFingerprintIDs(accountA, "sess-a", codexFingerprintSession) + require.NotNil(t, idsA) + stageCodexFingerprintIDs(c, idsA) + + accountB := newTestOAuthAccount(1004, map[string]any{codexFingerprintModeExtraKey: "session"}) + h := make(http.Header) + h.Set("session-id", "account-b-session") + applyStagedCodexFingerprintHeaders(c, accountB, h) + assert.Equal(t, "account-b-session", h.Get("session-id")) + assert.Empty(t, h.Get("x-codex-installation-id")) + + body := map[string]any{"client_metadata": map[string]any{"session_id": "account-b-session"}} + assert.False(t, applyStagedCodexFingerprintClientMetadata(c, accountB, body)) + clientMetadata, ok := body["client_metadata"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "account-b-session", clientMetadata["session_id"]) +} + func TestApplyStagedCodexFingerprintHeaders_SkipsNonOAuthAccount(t *testing.T) { c := newFingerprintStageTestContext(t) - oauthIDs := resolveCodexFingerprintIDs(newTestOAuthAccount(1003, nil), "sess-y", codexFingerprintSession) + oauthIDs := resolveCodexFingerprintIDs(newTestOAuthAccount(1003, map[string]any{codexFingerprintModeExtraKey: "session"}), "sess-y", codexFingerprintSession) require.NotNil(t, oauthIDs) stageCodexFingerprintIDs(c, oauthIDs) @@ -643,12 +910,12 @@ func TestBuildUpstreamRequestOpenAIPassthrough_OffModeKeepsIsolatedSession(t *te require.NoError(t, err) assert.NotEmpty(t, req.Header.Get("session_id")) - assert.NotEqual(t, resolveConvergedSessionID(account), req.Header.Get("session_id"), "off 模式不得收敛 session_id") + assert.NotEqual(t, resolveConvergedSessionID(testCodexFingerprintSeed), req.Header.Get("session_id"), "off 模式不得收敛 session_id") assert.Empty(t, req.Header.Get("x-codex-window-id")) } func TestApplyCodexFingerprintClientMetadataRaw_NonObjectBodyUntouched(t *testing.T) { - account := newTestOAuthAccount(4244, nil) + account := newTestOAuthAccount(4244, map[string]any{codexFingerprintModeExtraKey: "session"}) ids := resolveCodexFingerprintIDs(account, "client-sess-nonobj", codexFingerprintSession) require.NotNil(t, ids) diff --git a/backend/internal/service/openai_codex_identity.go b/backend/internal/service/openai_codex_identity.go index 510ee07cf29a..2cb8a45c402a 100644 --- a/backend/internal/service/openai_codex_identity.go +++ b/backend/internal/service/openai_codex_identity.go @@ -76,6 +76,38 @@ func SetCodexCanonicalUserAgentResolver(resolver func() string) { codexCanonicalUAResolver = resolver } +// CodexCanonicalUserAgent 返回当前生效的规范 Codex User-Agent。 +// 取值走与推理相同的解析链:面板 UA 指纹 + 面板/自动同步版本号 + 编译期兜底。 +// 供无账号句柄的出站路径(OAuth 换 Token / 刷新)使用。 +func CodexCanonicalUserAgent() string { + return resolveCodexOutboundIdentity("").userAgent +} + +// CodexCanonicalAuthIdentity 返回凭据面(auth.openai.com:换 Token / 刷新 / whoami) +// 出站请求的身份对:规范 User-Agent 与配套 originator,与推理解析链同源。 +// 凭据面不发 version 头——真实 Codex 客户端在该面只携带 originator 与 User-Agent +// (codex-rs login/default_client.rs 的 default_headers()),version 门槛 +// (issue #3901)只存在于 /backend-api/codex 推理面。 +func CodexCanonicalAuthIdentity() (userAgent, originator string) { + identity := resolveCodexOutboundIdentity("") + return identity.userAgent, identity.originator +} + +// ApplyCodexCanonicalAuthIdentity 为凭据面出站请求写入身份对(不含 version)。 +func ApplyCodexCanonicalAuthIdentity(h http.Header) { + if h == nil { + return + } + userAgent, originator := CodexCanonicalAuthIdentity() + h.Set("user-agent", userAgent) + h.Set("originator", originator) +} + +// CodexCanonicalClientVersion 返回当前生效的 Codex 客户端版本号。 +func CodexCanonicalClientVersion() string { + return resolveCodexOutboundIdentity("").version +} + // codexCanonicalUserAgent 返回出站规范 User-Agent。 func codexCanonicalUserAgent() string { codexCanonicalUAMu.RLock() @@ -210,6 +242,6 @@ func pairCodexIdentityHeaders(h http.Header) { h.Set("user-agent", pairedUA) h.Set("originator", originator) if v := strings.TrimSpace(h.Get("version")); v != "" && CompareVersions(v, codexUpstreamMinVersion) < 0 { - h.Set("version", codexCLIVersion) + h.Set("version", resolveCodexOutboundIdentity("").version) } } diff --git a/backend/internal/service/openai_codex_identity_test.go b/backend/internal/service/openai_codex_identity_test.go index 9b295241ce11..15768e5ea5e0 100644 --- a/backend/internal/service/openai_codex_identity_test.go +++ b/backend/internal/service/openai_codex_identity_test.go @@ -322,3 +322,27 @@ func TestBuildCodexCLIUserAgent(t *testing.T) { require.Equal(t, codexCLIUserAgent, buildCodexCLIUserAgent("bogus version")) require.Equal(t, codexCLIUserAgent, buildCodexCLIUserAgent("")) } + +func TestCodexCanonicalUserAgentFollowsResolver(t *testing.T) { + SetCodexCanonicalUserAgentResolver(func() string { + return "codex_cli_rs/0.200.1" + codexCLIUserAgentSuffix + }) + t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) }) + + require.Equal(t, "codex_cli_rs/0.200.1"+codexCLIUserAgentSuffix, CodexCanonicalUserAgent()) + require.Equal(t, "0.200.1", CodexCanonicalClientVersion()) + + h := make(http.Header) + ApplyCodexCanonicalAuthIdentity(h) + require.Equal(t, "codex_cli_rs", h.Get("originator")) + require.Equal(t, "codex_cli_rs/0.200.1"+codexCLIUserAgentSuffix, h.Get("user-agent")) + // 凭据面不发 version 头(真实客户端在 auth.openai.com 只带 originator + UA)。 + require.Empty(t, h.Get("version")) +} + +func TestCodexCanonicalUserAgentFallsBackWithoutResolver(t *testing.T) { + SetCodexCanonicalUserAgentResolver(nil) + + require.Equal(t, codexCLIUserAgent, CodexCanonicalUserAgent()) + require.Equal(t, codexCLIVersion, CodexCanonicalClientVersion()) +} diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index c36c46eb134e..ee5200301704 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -18,7 +18,6 @@ import ( infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" - "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "golang.org/x/net/http2" "golang.org/x/sync/singleflight" ) @@ -243,7 +242,7 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc clientVersion = strings.TrimSpace(clientVersion) if clientVersion == "" { - clientVersion = openAICodexProbeVersion + clientVersion = CodexCanonicalClientVersion() } requestEndpoint := chatgptCodexModelsURL @@ -305,9 +304,22 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc setOpenAIChatGPTAccountHeaders(headers, credAccount) } headers.Set("Accept", "application/json") - headers.Set("Originator", openai.CodexDefaultOriginator) - headers.Set("Version", clientVersion) - headers.Set("User-Agent", codexCLIUserAgent) + overrideUA := "" + if !useAPIKeyUpstream { + overrideUA = credAccount.GetOpenAIUserAgent() + } + identity := resolveCodexOutboundIdentity(overrideUA) + headers.Set("Originator", identity.originator) + headers.Set("User-Agent", identity.userAgent) + // Version 头优先与 client_version 查询参数同源:客户端自报版本合法且不低于上游 + // 门槛时原样使用;否则回退规范版本,避免陈旧 version 触发上游 404(issue #3901)。 + // client_version 查询参数本身始终按客户端原值透传(内容协商语义,契约见 + // TestFetchCodexModelsManifestPassthrough)。 + headerVersion := NormalizeCodexClientVersion(clientVersion) + if headerVersion == "" || CompareVersions(headerVersion, codexUpstreamMinVersion) < 0 { + headerVersion = identity.version + } + headers.Set("Version", headerVersion) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 585ad95d5e48..8f4c4957559f 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -348,8 +348,8 @@ func TestFetchCodexModelsManifestDefaultClientVersion(t *testing.T) { if _, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "", ""); err != nil { t.Fatalf("FetchCodexModelsManifest returned error: %v", err) } - if gotClientVersion != openAICodexProbeVersion { - t.Errorf("default client_version: got %q, want %q", gotClientVersion, openAICodexProbeVersion) + if gotClientVersion != CodexCanonicalClientVersion() { + t.Errorf("default client_version: got %q, want %q", gotClientVersion, CodexCanonicalClientVersion()) } } @@ -452,9 +452,9 @@ func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) { t.Errorf("originator header: got %q", gotRequest.Header.Get("Originator")) } if gotRequest.Header.Get("Version") != "0.144.0" { - t.Errorf("version header: got %q", gotRequest.Header.Get("Version")) + t.Errorf("version header must match the client_version query param: got %q", gotRequest.Header.Get("Version")) } - if gotRequest.Header.Get("User-Agent") != codexCLIUserAgent { + if gotRequest.Header.Get("User-Agent") != CodexCanonicalUserAgent() { t.Errorf("user-agent header: got %q", gotRequest.Header.Get("User-Agent")) } if gotRequest.Header.Get("chatgpt-account-id") != "" { diff --git a/backend/internal/service/openai_codex_pat_service.go b/backend/internal/service/openai_codex_pat_service.go index dce6785920f3..fa89aa962dd6 100644 --- a/backend/internal/service/openai_codex_pat_service.go +++ b/backend/internal/service/openai_codex_pat_service.go @@ -10,7 +10,6 @@ import ( infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" - "github.com/Wei-Shaw/sub2api/internal/pkg/openai" ) const openAICodexPATWhoamiURLDefault = "https://auth.openai.com/api/accounts/v1/user-auth-credential/whoami" @@ -59,8 +58,7 @@ func (s *OpenAIOAuthService) ValidateCodexPersonalAccessToken(ctx context.Contex } req.Header.Set("authorization", "Bearer "+accessToken) req.Header.Set("accept", "application/json") - req.Header.Set("originator", openai.CodexDefaultOriginator) - req.Header.Set("user-agent", codexCLIUserAgent) + ApplyCodexCanonicalAuthIdentity(req.Header) resp, err := client.Do(req) if err != nil { diff --git a/backend/internal/service/openai_codex_pat_service_test.go b/backend/internal/service/openai_codex_pat_service_test.go index e7656868ba8e..3210ed3062ec 100644 --- a/backend/internal/service/openai_codex_pat_service_test.go +++ b/backend/internal/service/openai_codex_pat_service_test.go @@ -40,7 +40,7 @@ func TestOpenAIOAuthService_ValidateCodexPersonalAccessToken(t *testing.T) { require.NoError(t, err) require.Equal(t, "Bearer at-test-token", gotAuthorization) require.Equal(t, openai.CodexDefaultOriginator, gotOriginator) - require.Equal(t, codexCLIUserAgent, gotUserAgent) + require.Equal(t, CodexCanonicalUserAgent(), gotUserAgent) require.Equal(t, OpenAIAuthModePersonalAccessToken, info.AuthMode) require.Equal(t, "user@example.com", info.Email) require.Equal(t, "user-123", info.ChatGPTUserID) diff --git a/backend/internal/service/openai_codex_version_consistency_test.go b/backend/internal/service/openai_codex_version_consistency_test.go index aede2ae4329c..fe95994237ce 100644 --- a/backend/internal/service/openai_codex_version_consistency_test.go +++ b/backend/internal/service/openai_codex_version_consistency_test.go @@ -11,9 +11,6 @@ import ( ) func TestCodexVersionConstants_Consistency(t *testing.T) { - require.Equal(t, codexCLIVersion, openAICodexProbeVersion, - "codexCLIVersion and openAICodexProbeVersion must stay in sync") - require.True(t, strings.Contains(codexCLIUserAgent, openai.CodexDefaultOriginator+"/"+codexCLIVersion), "codexCLIUserAgent must embed codexCLIVersion") diff --git a/backend/internal/service/openai_embeddings.go b/backend/internal/service/openai_embeddings.go index c0c59002977d..e68017a45373 100644 --- a/backend/internal/service/openai_embeddings.go +++ b/backend/internal/service/openai_embeddings.go @@ -46,11 +46,13 @@ func (s *OpenAIGatewayService) ForwardEmbeddings( zap.String("upstream_model", upstreamModel), ) - apiKey := account.GetOpenAIApiKey() + apiKey := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) if apiKey == "" { return nil, fmt.Errorf("account %d missing api_key", account.ID) } - baseURL := account.GetOpenAIBaseURL() + // 协议感知:Anthropic 协议账号的凭证 base_url 指向 /anthropic 端点, + // embeddings 需使用 OpenAI 格式 base。 + baseURL := account.GetOpenAIFormatBaseURL() if baseURL == "" { baseURL = "https://api.openai.com" } diff --git a/backend/internal/service/openai_gateway_anthropic_native_pump.go b/backend/internal/service/openai_gateway_anthropic_native_pump.go new file mode 100644 index 000000000000..8a1967770568 --- /dev/null +++ b/backend/internal/service/openai_gateway_anthropic_native_pump.go @@ -0,0 +1,126 @@ +package service + +// 国产供应商 Anthropic 协议转换路径的上游 SSE 行泵。 +// +// 这两条转换链(CC×anthropic / Responses×anthropic)的上游 ctx 是 +// WithoutCancel(detachStreamUpstreamContext)、http.Client 无整体 Timeout, +// 客户端断开后的排水阶段若上游挂住 SSE(不发数据也不断连), +// scanner.Scan() 将永久阻塞:goroutine 钉死、resp.Body 不归还、连接池位 +// 被占用、usage 永不落库。 +// +// 与 handleAnthropicStreamingResponse / readOpenAICompatBufferedTerminal 的 +// 同类排水一致,本泵用 gateway.stream_data_interval_timeout(默认 180s)作为 +// 逐行读间隔上限,超时即向调用方返回 errAnthropicNativeStreamIdle,由调用方 +// 关闭 resp.Body 解除阻塞的读并结束排水。 + +import ( + "bufio" + "errors" + "io" + "time" +) + +// errAnthropicNativeStreamIdle 表示上游流读间隔超时(见上方文件注释)。 +var errAnthropicNativeStreamIdle = errors.New("stream data interval timeout") + +// anthropicNativeLineEvent 是行泵交付的单次读取结果:line 为一行 SSE 文本, +// err 为 scanner 读错误(流自然结束时 next 返回 io.EOF,不经过本字段)。 +type anthropicNativeLineEvent struct { + line string + err error +} + +// anthropicNativeLinePump 以独立 goroutine 泵送 scanner 的行,并对逐行到达 +// 间隔施加 interval 上限(<=0 表示禁用,保持无界读的旧行为)。 +type anthropicNativeLinePump struct { + events chan anthropicNativeLineEvent + done chan struct{} + timer *time.Timer + interval time.Duration +} + +// newAnthropicNativeLinePump 启动泵 goroutine;调用方 defer pump.stop()。 +func newAnthropicNativeLinePump(scanner *bufio.Scanner, interval time.Duration) *anthropicNativeLinePump { + p := &anthropicNativeLinePump{ + events: make(chan anthropicNativeLineEvent, 16), + done: make(chan struct{}), + interval: interval, + } + if interval > 0 { + p.timer = time.NewTimer(interval) + } + go func() { + defer close(p.events) + for scanner.Scan() { + select { + case p.events <- anthropicNativeLineEvent{line: scanner.Text()}: + case <-p.done: + return + } + } + if err := scanner.Err(); err != nil { + select { + case p.events <- anthropicNativeLineEvent{err: err}: + case <-p.done: + } + } + }() + return p +} + +// next 阻塞返回下一行。返回 io.EOF 表示上游正常收流;errAnthropicNativeStreamIdle +// 表示 interval 内无任何数据到达(计时从收到上一行时起算,事件处理耗时不算入, +// 与 readOpenAICompatBufferedTerminal 的 resetTimeout 语义一致)。 +func (p *anthropicNativeLinePump) next() (string, error) { + var timeoutCh <-chan time.Time + if p.timer != nil { + timeoutCh = p.timer.C + } + select { + case ev, ok := <-p.events: + if !ok { + return "", io.EOF + } + p.resetTimer() + return ev.line, ev.err + case <-timeoutCh: + return "", errAnthropicNativeStreamIdle + } +} + +// resetTimer 在收到一行后重启间隔计时器。 +func (p *anthropicNativeLinePump) resetTimer() { + if p.timer == nil { + return + } + if !p.timer.Stop() { + select { + case <-p.timer.C: + default: + } + } + p.timer.Reset(p.interval) +} + +// stop 终止泵 goroutine。注意:goroutine 若正阻塞在 scanner.Read 上,需由 +// 调用方关闭 resp.Body(间隔超时分支已做)才能真正退出。 +func (p *anthropicNativeLinePump) stop() { + close(p.done) + if p.timer != nil { + if !p.timer.Stop() { + select { + case <-p.timer.C: + default: + } + } + } +} + +// anthropicNativeStreamInterval 返回本组转换路径适用的读间隔上限; +// gateway.stream_data_interval_timeout <= 0 时视为禁用。 +func (s *OpenAIGatewayService) anthropicNativeStreamInterval() time.Duration { + if s.cfg != nil && s.cfg.Gateway.StreamDataIntervalTimeout > 0 { + return time.Duration(s.cfg.Gateway.StreamDataIntervalTimeout) * time.Second + } + return 0 +} diff --git a/backend/internal/service/openai_gateway_anthropic_native_pump_test.go b/backend/internal/service/openai_gateway_anthropic_native_pump_test.go new file mode 100644 index 000000000000..1e9e1b39a635 --- /dev/null +++ b/backend/internal/service/openai_gateway_anthropic_native_pump_test.go @@ -0,0 +1,244 @@ +package service + +// 国产供应商 Anthropic 协议转换路径的上游读间隔超时回归测试(B3): +// 上游挂住 SSE(不发数据也不断连)时,CC×anthropic / Responses×anthropic +// 的读循环必须按 gateway.stream_data_interval_timeout 结束,而不是永久阻塞。 + +import ( + "bufio" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/gin-gonic/gin" +) + +func newNativeAnthropicHangTestService(intervalSec int) *OpenAIGatewayService { + return &OpenAIGatewayService{ + cfg: &config.Config{ + Gateway: config.GatewayConfig{ + StreamDataIntervalTimeout: intervalSec, + MaxLineSize: defaultMaxLineSize, + }, + }, + } +} + +func newHangingUpstreamResponse() (*http.Response, *io.PipeReader, *io.PipeWriter) { + pr, pw := io.Pipe() + return &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}, pr, pw +} + +// miniAnthropicSSEStream 是一段最小可转换的 Anthropic 事件流。 +func miniAnthropicSSEStream() string { + return strings.Join([]string{ + "event: message_start", + `data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"model":"glm-4.7","usage":{"input_tokens":10,"output_tokens":1}}}`, + "", + "event: content_block_start", + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, + "", + "event: content_block_delta", + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}`, + "", + "event: content_block_stop", + `data: {"type":"content_block_stop","index":0}`, + "", + "event: message_delta", + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}`, + "", + "event: message_stop", + `data: {"type":"message_stop"}`, + "", + "", + }, "\n") +} + +func TestAnthropicNativeLinePump_TimesOutWithoutData(t *testing.T) { + pr, _ := io.Pipe() + scanner := bufio.NewScanner(pr) + defer func() { _ = pr.Close() }() + + pump := newAnthropicNativeLinePump(scanner, 50*time.Millisecond) + defer pump.stop() + + start := time.Now() + _, err := pump.next() + if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") { + t.Fatalf("expected interval timeout, got %v", err) + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("timeout not respected: %v", elapsed) + } +} + +func TestAnthropicNativeLinePump_DataResetsTimer(t *testing.T) { + pr, pw := io.Pipe() + scanner := bufio.NewScanner(pr) + pump := newAnthropicNativeLinePump(scanner, 1*time.Second) + defer pump.stop() + + go func() { + _, _ = pw.Write([]byte("event: ping\n")) + // 保持流打开且不再发数据:第二次 next 必须超时。 + time.Sleep(3 * time.Second) + _ = pw.Close() + }() + defer func() { _ = pr.Close() }() + + line, err := pump.next() + if err != nil || line != "event: ping" { + t.Fatalf("expected first line, got %q err=%v", line, err) + } + + start := time.Now() + _, err = pump.next() + if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") { + t.Fatalf("expected interval timeout after data stops, got %v", err) + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("timeout not respected: %v", elapsed) + } +} + +func TestCCStreamingFromNativeAnthropic_HangTimesOut(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newNativeAnthropicHangTestService(1) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + + resp, pr, pw := newHangingUpstreamResponse() + start := time.Now() + res, err := svc.handleCCStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, start, true) + _ = pw.Close() + _ = pr.Close() + + if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") { + t.Fatalf("expected stream timeout error, got %v", err) + } + if res == nil { + t.Fatalf("expected result carrying accumulated usage") + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("handler did not respect interval bound: %v", elapsed) + } +} + +func TestCCBufferedFromNativeAnthropic_HangTimesOut(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newNativeAnthropicHangTestService(1) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + + resp, pr, pw := newHangingUpstreamResponse() + start := time.Now() + _, err := svc.handleCCBufferedFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, start) + _ = pw.Close() + _ = pr.Close() + + if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") { + t.Fatalf("expected stream timeout error, got %v", err) + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("handler did not respect interval bound: %v", elapsed) + } + if !strings.Contains(rec.Body.String(), "Upstream stream data interval timeout") { + t.Fatalf("expected 502 error body, got %q", rec.Body.String()) + } +} + +func TestResponsesStreamingFromNativeAnthropic_HangTimesOut(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newNativeAnthropicHangTestService(1) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + + resp, pr, pw := newHangingUpstreamResponse() + start := time.Now() + res, err := svc.handleResponsesStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, start, apicompat.ResponsesClientToolMapping{}) + _ = pw.Close() + _ = pr.Close() + + if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") { + t.Fatalf("expected stream timeout error, got %v", err) + } + if res == nil { + t.Fatalf("expected result carrying accumulated usage") + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("handler did not respect interval bound: %v", elapsed) + } +} + +func TestCCStreamingFromNativeAnthropic_HappyPathStillConverts(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newNativeAnthropicHangTestService(5) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + + resp, pr, pw := newHangingUpstreamResponse() + go func() { + _, _ = pw.Write([]byte(miniAnthropicSSEStream())) + _ = pw.Close() + }() + defer func() { _ = pr.Close() }() + + res, err := svc.handleCCStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now(), true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res == nil { + t.Fatalf("expected result") + } + body := rec.Body.String() + if !strings.Contains(body, "Hello") { + t.Fatalf("expected converted text chunk, got %q", body) + } + if !strings.Contains(body, "data: [DONE]") { + t.Fatalf("expected [DONE] terminator, got %q", body) + } +} + +func TestCCBufferedFromNativeAnthropic_HappyPathStillConverts(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newNativeAnthropicHangTestService(5) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + + resp, pr, pw := newHangingUpstreamResponse() + go func() { + _, _ = pw.Write([]byte(miniAnthropicSSEStream())) + _ = pw.Close() + }() + defer func() { _ = pr.Close() }() + + res, err := svc.handleCCBufferedFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res == nil { + t.Fatalf("expected result") + } + body := rec.Body.String() + if !strings.Contains(body, "Hello") { + t.Fatalf("expected converted text in buffered response, got %q", body) + } + if res.Usage.InputTokens != 10 || res.Usage.OutputTokens != 5 { + t.Fatalf("expected usage 10/5, got %+v", res.Usage) + } +} diff --git a/backend/internal/service/openai_gateway_cc_pipeline.go b/backend/internal/service/openai_gateway_cc_pipeline.go index 8080d49e5c35..ce9dd54d370c 100644 --- a/backend/internal/service/openai_gateway_cc_pipeline.go +++ b/backend/internal/service/openai_gateway_cc_pipeline.go @@ -150,7 +150,7 @@ func (s *OpenAIGatewayService) openAIChatCompletionsTargetURL(account *Account) // resolveCCFallbackTarget 解析两条 CC 回退路径共用的账号凭证与上游端点 // (回退路径仅面向 APIKey 账号,凭证恒为 openai api_key)。 func (s *OpenAIGatewayService) resolveCCFallbackTarget(account *Account) (apiKey string, targetURL string, err error) { - apiKey = account.GetOpenAIApiKey() + apiKey = strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) if apiKey == "" { return "", "", fmt.Errorf("account %d missing api_key", account.ID) } diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 87973304c0a6..fed651bd7016 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -87,6 +87,14 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) } + // 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点, + // CC 入站请求经 CC→Responses→Anthropic 转换链直通该端点。必须先于 + // ShouldUseResponsesAPI 分流:该类账号经 probe 落标 + // openai_responses_supported=false,会先命中下方的 CC 直转分支。 + if account.IsAnthropicProtocol() { + return s.forwardChatCompletionsViaNativeAnthropic(ctx, c, account, body, defaultMappedModel) + } + // 入口分流:APIKey 账号 + 强制或已探测确认上游不支持 Responses,走 CC 直转。 // 自动模式下标记缺失(未探测)按"现状即证据"原则继续走下方原 Responses 转换路径。 if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) { diff --git a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go new file mode 100644 index 000000000000..b41bc644c62e --- /dev/null +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -0,0 +1,486 @@ +package service + +// 国产供应商 Anthropic 协议账号的 CC 入站反向路径。 +// +// 客户端说 OpenAI Chat Completions、上游是供应商原生 Anthropic 端点 +// (api_protocol=anthropic)时的交叉组合:请求 CC→Responses→Anthropic 转换, +// 响应 Anthropic→Responses→CC 转换。转换链与 Anthropic 平台的 +// gateway_forward_as_chat_completions.go 完全一致(复用同一组 apicompat +// 状态机),仅上游发送/错误处理对齐 OpenAI 网关语义。 + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// forwardChatCompletionsViaNativeAnthropic serves OpenAI /v1/chat/completions +// clients through a CN provider's native Anthropic endpoint. +// +// Conversion chain: +// +// Request: Chat Completions → Responses → Anthropic (chained) +// Response: Anthropic events → Responses events → CC chunks (chained state machines) +func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( + ctx context.Context, + c *gin.Context, + account *Account, + body []byte, + defaultMappedModel string, +) (*OpenAIForwardResult, error) { + startTime := time.Now() + + // 1. Parse Chat Completions request + var ccReq apicompat.ChatCompletionsRequest + if err := json.Unmarshal(body, &ccReq); err != nil { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body") + return nil, fmt.Errorf("parse chat completions request: %w", err) + } + originalModel := ccReq.Model + if strings.TrimSpace(originalModel) == "" { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", "model is required") + return nil, fmt.Errorf("missing model in request") + } + clientStream := ccReq.Stream + includeUsage := ccReq.StreamOptions != nil && ccReq.StreamOptions.IncludeUsage + + // 2. Convert CC → Responses → Anthropic (chained conversion) + responsesReq, err := apicompat.ChatCompletionsToResponses(&ccReq) + if err != nil { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", "Failed to convert request") + return nil, fmt.Errorf("convert chat completions to responses: %w", err) + } + anthropicReq, err := apicompat.ResponsesToAnthropicRequest(responsesReq) + if err != nil { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", "Failed to convert request") + return nil, fmt.Errorf("convert responses to anthropic: %w", err) + } + + // 3. Model mapping(OpenAI 网关统一入口的映射语义) + billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) + upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) + anthropicReq.Model = upstreamModel + + // 4. Force upstream streaming(客户端原始终决定响应格式; + // 上游恒为流式,非流式由缓冲路径组装)。 + anthropicReq.Stream = true + reqStream := true + + logger.L().Debug("openai chat_completions: forwarding via native anthropic endpoint", + zap.Int64("account_id", account.ID), + zap.String("original_model", originalModel), + zap.String("billing_model", billingModel), + zap.String("upstream_model", upstreamModel), + zap.Bool("client_stream", clientStream), + ) + + anthropicBody, err := json.Marshal(anthropicReq) + if err != nil { + return nil, fmt.Errorf("marshal anthropic request: %w", err) + } + + // 与 /v1/messages 直通路径相同的 pre-filter。 + anthropicBody = StripEmptyTextBlocks(anthropicBody) + anthropicBody = FilterWebSearchHistoryBlocks(anthropicBody, upstreamModel) + anthropicBody = enforceCacheControlLimit(anthropicBody) + + apiKey := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) + if apiKey == "" { + return nil, fmt.Errorf("account %d missing api_key", account.ID) + } + targetURL, err := s.nativeAnthropicTargetURL(account) + if err != nil { + return nil, err + } + + proxyURL := "" + if account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream) + upstreamReq, _, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL) + releaseUpstreamCtx() + if err != nil { + return nil, fmt.Errorf("build upstream request: %w", err) + } + + resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + if err != nil { + return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + respBody, upstreamMsg := s.readOpenAIUpstreamError(resp) + if foErr := s.failoverOpenAIUpstreamHTTPError(ctx, c, account, resp, respBody, upstreamMsg, upstreamModel); foErr != nil { + return nil, foErr + } + writeChatCompletionsError(c, mapUpstreamStatusCode(resp.StatusCode), "server_error", upstreamMsg) + return nil, fmt.Errorf("upstream error: %d %s", resp.StatusCode, upstreamMsg) + } + + reasoningEffort := extractCCReasoningEffortFromBody(body) + reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel) + + if clientStream { + return s.handleCCStreamingFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime, includeUsage) + } + return s.handleCCBufferedFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime) +} + +// handleCCBufferedFromNativeAnthropic reads Anthropic SSE events, assembles the +// full response, then converts Anthropic → Responses → Chat Completions. +func (s *OpenAIGatewayService) handleCCBufferedFromNativeAnthropic( + resp *http.Response, + c *gin.Context, + originalModel string, + billingModel string, + upstreamModel string, + reasoningEffort *string, + startTime time.Time, +) (*OpenAIForwardResult, error) { + requestID := resp.Header.Get("x-request-id") + + scanner := bufio.NewScanner(resp.Body) + maxLineSize := defaultMaxLineSize + if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.cfg.Gateway.MaxLineSize + } + scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize) + + var finalResp *apicompat.AnthropicResponse + var usage ClaudeUsage + + // 读间隔上限:上游挂住 SSE 时中止组装(缓冲路径尚未提交响应头,可回 502)。 + streamInterval := s.anthropicNativeStreamInterval() + pump := newAnthropicNativeLinePump(scanner, streamInterval) + defer pump.stop() + + logReadErr := func(err error) { + if !errors.Is(err, io.EOF) && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + logger.L().Warn("openai cc via native anthropic buffered: read error", + zap.Error(err), + zap.String("request_id", requestID), + ) + } + } + onIdle := func() (*OpenAIForwardResult, error) { + _ = resp.Body.Close() + logger.L().Warn("openai cc via native anthropic buffered: data interval timeout", + zap.String("request_id", requestID), + zap.Duration("interval", streamInterval), + ) + writeChatCompletionsError(c, http.StatusBadGateway, "server_error", "Upstream stream data interval timeout") + return nil, fmt.Errorf("stream data interval timeout") + } + + for { + line, rerr := pump.next() + if rerr != nil { + if errors.Is(rerr, errAnthropicNativeStreamIdle) { + return onIdle() + } + logReadErr(rerr) + break + } + // SSE 规范允许 `event:xxx`(冒号后无空格):Kimi 等 Anthropic 兼容上游 + // 返回紧凑格式,严格匹配 "event: " 会丢弃全部事件(#4653 同根因)。 + if _, ok := extractOpenAISSEEventLine(line); !ok { + continue + } + + dataLine, rerr := pump.next() + if rerr != nil { + if errors.Is(rerr, errAnthropicNativeStreamIdle) { + return onIdle() + } + logReadErr(rerr) + break + } + payload, ok := extractOpenAISSEDataLine(dataLine) + if !ok { + continue + } + + var event apicompat.AnthropicStreamEvent + if err := json.Unmarshal([]byte(payload), &event); err != nil { + continue + } + + if event.Type == "message_start" && event.Message != nil { + finalResp = event.Message + mergeAnthropicUsage(&usage, event.Message.Usage) + } + if event.Type == "message_delta" { + if event.Usage != nil { + mergeAnthropicUsage(&usage, *event.Usage) + } + if event.Delta != nil && event.Delta.StopReason != "" && finalResp != nil { + finalResp.StopReason = apicompat.AnthropicStopReasonPtr(event.Delta.StopReason) + } + } + if event.Type == "content_block_start" && event.ContentBlock != nil && finalResp != nil { + finalResp.Content = append(finalResp.Content, *event.ContentBlock) + } + if event.Type == "content_block_delta" && event.Delta != nil && finalResp != nil && event.Index != nil { + idx := *event.Index + if idx < len(finalResp.Content) { + switch event.Delta.Type { + case "text_delta": + finalResp.Content[idx].Text += event.Delta.Text + case "thinking_delta": + finalResp.Content[idx].Thinking += event.Delta.Thinking + case "input_json_delta": + finalResp.Content[idx].Input = appendRawJSON(finalResp.Content[idx].Input, event.Delta.PartialJSON) + } + } + } + } + + if finalResp == nil { + writeChatCompletionsError(c, http.StatusBadGateway, "server_error", "Upstream stream ended without a response") + return nil, fmt.Errorf("upstream stream ended without response") + } + + if usage.InputTokens > 0 || usage.OutputTokens > 0 { + finalResp.Usage = apicompat.AnthropicUsage{ + InputTokens: usage.InputTokens, + OutputTokens: usage.OutputTokens, + CacheCreationInputTokens: usage.CacheCreationInputTokens, + CacheReadInputTokens: usage.CacheReadInputTokens, + } + } + + responsesResp := apicompat.AnthropicToResponsesResponse(finalResp) + ccResp := apicompat.ResponsesToChatCompletions(responsesResp, originalModel) + + if s.responseHeaderFilter != nil { + responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + } + // 非流式响应必须是 application/json(上游被强制流式,透传头会污染)。 + c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") + if respBytes, err := json.Marshal(ccResp); err == nil { + respBytes = reverseToolNamesIfPresent(c, respBytes) + c.Data(http.StatusOK, "application/json; charset=utf-8", respBytes) + } else { + c.JSON(http.StatusOK, ccResp) + } + + return &OpenAIForwardResult{ + RequestID: requestID, + Usage: claudeUsageToOpenAIUsage(&usage), + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamEndpoint: "/v1/messages", + ReasoningEffort: reasoningEffort, + Stream: false, + Duration: time.Since(startTime), + }, nil +} + +// handleCCStreamingFromNativeAnthropic reads Anthropic SSE events, converts each +// to Responses events, then to Chat Completions chunks, and writes them. +func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( + resp *http.Response, + c *gin.Context, + originalModel string, + billingModel string, + upstreamModel string, + reasoningEffort *string, + startTime time.Time, + includeUsage bool, +) (*OpenAIForwardResult, error) { + requestID := resp.Header.Get("x-request-id") + + if s.responseHeaderFilter != nil { + responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + } + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("X-Accel-Buffering", "no") + c.Writer.WriteHeader(http.StatusOK) + + anthState := apicompat.NewAnthropicEventToResponsesState() + anthState.Model = originalModel + ccState := apicompat.NewResponsesEventToChatState() + ccState.Model = originalModel + ccState.IncludeUsage = includeUsage + + var usage ClaudeUsage + var firstTokenMs *int + firstChunk := true + clientDisconnected := false + + scanner := bufio.NewScanner(resp.Body) + maxLineSize := defaultMaxLineSize + if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.cfg.Gateway.MaxLineSize + } + scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize) + + resultWithUsage := func() *OpenAIForwardResult { + return &OpenAIForwardResult{ + RequestID: requestID, + Usage: claudeUsageToOpenAIUsage(&usage), + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamEndpoint: "/v1/messages", + ReasoningEffort: reasoningEffort, + Stream: true, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnected, + } + } + + // 读间隔上限:上游挂住 SSE(不发数据也不断连)时结束排水。上游 ctx 为 + // WithoutCancel 且 http.Client 无整体 Timeout,无此界限则客户端断开后 + // scanner.Scan() 永久阻塞(见 anthropic native pump 文件注释)。 + streamInterval := s.anthropicNativeStreamInterval() + pump := newAnthropicNativeLinePump(scanner, streamInterval) + defer pump.stop() + + logReadErr := func(err error) { + if !errors.Is(err, io.EOF) && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + logger.L().Warn("openai cc via native anthropic stream: read error", + zap.Error(err), + zap.String("request_id", requestID), + ) + } + } + // onIdle 关闭上游连接(解除阻塞的读、归还连接池位),并按已累计 usage + // 返回——与 messages 主路径 "stream usage incomplete after timeout" 同语义。 + onIdle := func() (*OpenAIForwardResult, error) { + _ = resp.Body.Close() + if !clientDisconnected { + logger.L().Warn("openai cc via native anthropic stream: data interval timeout", + zap.String("request_id", requestID), + zap.Duration("interval", streamInterval), + ) + } + return resultWithUsage(), fmt.Errorf("stream data interval timeout") + } + + writeChunk := func(chunk apicompat.ChatCompletionsChunk) bool { + if clientDisconnected { + return false // 已断开:不再写客户端,只排水上游累计 usage + } + sse, err := apicompat.ChatChunkToSSE(chunk) + if err != nil { + return false + } + out := string(reverseToolNamesIfPresent(c, []byte(sse))) + if _, err := fmt.Fprint(c.Writer, out); err != nil { + clientDisconnected = true + return false + } + return false + } + + processAnthropicEvent := func(event *apicompat.AnthropicStreamEvent) bool { + if firstChunk { + firstChunk = false + ms := int(time.Since(startTime).Milliseconds()) + firstTokenMs = &ms + } + + // usage 恒累计(含客户端断开后的排水阶段,payg 上游照常计费)。 + if event.Type == "message_delta" && event.Usage != nil { + mergeAnthropicUsage(&usage, *event.Usage) + } + if event.Type == "message_start" && event.Message != nil { + mergeAnthropicUsage(&usage, event.Message.Usage) + } + + // 客户端已断开:跳过转换与写出,继续读上游直到流结束(usage 完整、 + // 连接及时归还),不再提前 return。 + if clientDisconnected { + return false + } + + responsesEvents := apicompat.AnthropicEventToResponsesEvents(event, anthState) + for _, resEvt := range responsesEvents { + ccChunks := apicompat.ResponsesEventToChatChunks(&resEvt, ccState) + for _, chunk := range ccChunks { + writeChunk(chunk) + } + } + if len(responsesEvents) > 0 { + c.Writer.Flush() + } + return false + } + + for { + line, rerr := pump.next() + if rerr != nil { + if errors.Is(rerr, errAnthropicNativeStreamIdle) { + return onIdle() + } + logReadErr(rerr) + break + } + if _, ok := extractOpenAISSEEventLine(line); !ok { + continue + } + + dataLine, rerr := pump.next() + if rerr != nil { + if errors.Is(rerr, errAnthropicNativeStreamIdle) { + return onIdle() + } + // EOF / 读错误:事件行后流终止,进入 finalize。 + logReadErr(rerr) + break + } + payload, ok := extractOpenAISSEDataLine(dataLine) + if !ok { + continue + } + + var event apicompat.AnthropicStreamEvent + if err := json.Unmarshal([]byte(payload), &event); err != nil { + continue + } + + if processAnthropicEvent(&event) { + return resultWithUsage(), nil + } + } + + // Finalize both state machines(客户端已断开时仍执行,保证 usage 汇总完整)。 + finalResEvents := apicompat.FinalizeAnthropicResponsesStream(anthState) + for _, resEvt := range finalResEvents { + ccChunks := apicompat.ResponsesEventToChatChunks(&resEvt, ccState) + for _, chunk := range ccChunks { + writeChunk(chunk) //nolint:errcheck + } + } + finalCCChunks := apicompat.FinalizeResponsesChatStream(ccState) + for _, chunk := range finalCCChunks { + writeChunk(chunk) //nolint:errcheck + } + + if !clientDisconnected { + fmt.Fprint(c.Writer, "data: [DONE]\n\n") //nolint:errcheck + c.Writer.Flush() + } + + return resultWithUsage(), nil +} diff --git a/backend/internal/service/openai_gateway_cn_fixes_test.go b/backend/internal/service/openai_gateway_cn_fixes_test.go new file mode 100644 index 000000000000..f5cd28ee3965 --- /dev/null +++ b/backend/internal/service/openai_gateway_cn_fixes_test.go @@ -0,0 +1,144 @@ +//go:build unit + +package service + +// 国产供应商功能修复回归测试: +// 1. CN 分组不适用 /v1/messages 调度级模型映射(openai 的 gpt-5.x 默认值发给 +// CN 上游必错); +// 2. 计费候选链对 CN 账号过滤 claude-* 兜底候选(防按 Claude 原价误计 CN 流量); +// 3. 空候选按 ErrModelPricingUnavailable 处理(零成本落账而非丢弃 usage 记录); +// 4. Responses×anthropic 流式转换器客户端断开后继续排水、usage 汇总完整。 + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestResolveMessagesDispatchModel_CNProvidersNoDispatchMapping(t *testing.T) { + for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} { + g := &Group{Platform: platform} + require.Empty(t, g.ResolveMessagesDispatchModel("claude-sonnet-4-5"), + "CN 分组(%s)不得返回调度级映射模型(openai 默认值会发给 CN 上游)", platform) + require.Empty(t, g.ResolveMessagesDispatchModel("claude-opus-4-1"), platform) + } + // 非回归:openai 分组保持原有默认映射行为。 + openaiGroup := &Group{Platform: PlatformOpenAI} + require.NotEmpty(t, openaiGroup.ResolveMessagesDispatchModel("claude-sonnet-4-5"), + "openai 分组的调度默认映射不应受 CN 修复影响") +} + +func TestFilterCNProviderBillingModelCandidates(t *testing.T) { + svc := &OpenAIGatewayService{} // resolver 为 nil → 无显式分组/渠道定价 + apiKey := &APIKey{Group: &Group{ID: 1, Platform: PlatformKimi}} + + cnAccount := &Account{ID: 1, Platform: PlatformKimi} + filtered := svc.filterCNProviderBillingModelCandidates(context.Background(), cnAccount, apiKey, + []string{"kimi-k2-0905-preview", "claude-sonnet-4-5", "moonshot-v1-8k"}) + require.Equal(t, []string{"kimi-k2-0905-preview", "moonshot-v1-8k"}, filtered, + "无显式定价时 claude-* 候选必须被过滤") + + allClaude := svc.filterCNProviderBillingModelCandidates(context.Background(), cnAccount, apiKey, + []string{"claude-sonnet-4-5", "claude-sonnet-4-5"}) + require.Empty(t, allClaude, "全 claude 候选应被清空(上层走零成本+告警落账)") + + // 非 CN 账号完全不受影响。 + openaiAccount := &Account{ID: 2, Platform: PlatformOpenAI} + passthrough := svc.filterCNProviderBillingModelCandidates(context.Background(), openaiAccount, apiKey, + []string{"claude-sonnet-4-5", "gpt-5.4"}) + require.Equal(t, []string{"claude-sonnet-4-5", "gpt-5.4"}, passthrough) + + require.Nil(t, svc.filterCNProviderBillingModelCandidates(context.Background(), nil, apiKey, nil)) +} + +func TestCalculateOpenAIRecordUsageCost_EmptyCandidatesIsPricingUnavailable(t *testing.T) { + svc := &OpenAIGatewayService{} + apiKey := &APIKey{Group: &Group{ID: 1, Platform: PlatformKimi}} + + _, err := svc.calculateOpenAIRecordUsageCost( + context.Background(), nil, apiKey, nil, + 1.0, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 100}, "", nil, time.Time{}, + ) + require.Error(t, err) + require.True(t, isUsagePricingUnavailableError(err), + "空候选必须按无价可循处理(上层零成本落账),而不是丢弃整条 usage 记录: %v", err) +} + +func TestResponsesStreamingFromNativeAnthropic_ClientDisconnectDrainsUsage(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newNativeAnthropicHangTestService(5) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + // failAfter=0:首次写出即失败,模拟客户端断开(复用测试包既有 failingGinWriter)。 + failWriter := &failingGinWriter{ResponseWriter: c.Writer, failAfter: 0} + c.Writer = failWriter + + resp, pr, pw := newHangingUpstreamResponse() + go func() { + // 首事件触发客户端写失败后,末尾 message_delta 才携带最终 output_tokens: + // 断开即弃会把整段生成记成 1 token。 + _, _ = pw.Write([]byte(miniAnthropicSSEStream())) + _ = pw.Close() + }() + defer func() { _ = pr.Close() }() + + res, err := svc.handleResponsesStreamingFromNativeAnthropic( + resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) + + require.NoError(t, err, "断开排水至上游自然结束应返回 nil error(usage 走成功路径落账)") + require.NotNil(t, res) + require.True(t, res.ClientDisconnect) + require.Equal(t, 10, res.Usage.InputTokens, "input_tokens 应来自 message_start") + require.Equal(t, 5, res.Usage.OutputTokens, + "output_tokens 必须来自排水读到的末尾 message_delta(断开即弃时会是 1)") +} + +func TestHandle403_CNProviderHTMLBodySkipsAccountPenalty(t *testing.T) { + for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} { + repo := &rateLimitAccountRepoStub{} + service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + account := &Account{ID: 401, Platform: platform, Type: AccountTypeAPIKey} + + shouldDisable := service.HandleUpstreamError( + context.Background(), + account, + http.StatusForbidden, + http.Header{}, + []byte("Access denied by CDN"), + ) + + require.False(t, shouldDisable, "%s: HTML 403(CDN/代理拦截页)不得作为账号失效证据", platform) + require.Equal(t, 0, repo.setErrorCalls, "%s: 不得永久禁用账号", platform) + require.Equal(t, 0, repo.tempCalls, "%s: 不得临时停调账号", platform) + } +} + +func TestHandle403_CNProviderStructured403TempUnschedulableFirstHit(t *testing.T) { + repo := &rateLimitAccountRepoStub{} + counter := &openAI403CounterCacheStub{counts: []int64{1}} + service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + service.SetOpenAI403CounterCache(counter) + account := &Account{ID: 402, Platform: PlatformKimi, Type: AccountTypeAPIKey} + + shouldDisable := service.HandleUpstreamError( + context.Background(), + account, + http.StatusForbidden, + http.Header{}, + []byte(`{"error":{"message":"forbidden"}}`), + ) + + require.True(t, shouldDisable) + require.Equal(t, 0, repo.setErrorCalls, "首次结构化 403 应临时停调而非永久禁用") + require.Equal(t, 1, repo.tempCalls) + require.Contains(t, repo.lastTempReason, "(1/3)") +} diff --git a/backend/internal/service/openai_gateway_count_tokens.go b/backend/internal/service/openai_gateway_count_tokens.go index e56b33df4506..bcdaa457b824 100644 --- a/backend/internal/service/openai_gateway_count_tokens.go +++ b/backend/internal/service/openai_gateway_count_tokens.go @@ -43,6 +43,12 @@ type openAIInputTokensCountPrepared struct { // locally. Grok does not expose a compatible token-counting endpoint, so this // path deliberately avoids account selection, credentials, and upstream calls. func EstimateGrokCountTokens(body []byte) (int, error) { + return estimateAnthropicCountTokensLocally(body) +} + +// estimateAnthropicCountTokensLocally 走 Anthropic→Responses→tiktoken 链本地估算 +// count_tokens,不发任何上游请求(上游无兼容端点的平台使用)。 +func estimateAnthropicCountTokensLocally(body []byte) (int, error) { var anthropicReq apicompat.AnthropicRequest if err := json.Unmarshal(body, &anthropicReq); err != nil { return 0, fmt.Errorf("parse anthropic count_tokens request: %w", err) @@ -64,7 +70,7 @@ func EstimateGrokCountTokens(body []byte) (int, error) { ToolChoice: responsesReq.ToolChoice, }) if err != nil { - return 0, fmt.Errorf("estimate grok input tokens: %w", err) + return 0, fmt.Errorf("estimate input tokens: %w", err) } if estimated < openAIInputTokensFallbackMinimum { estimated = openAIInputTokensFallbackMinimum @@ -86,6 +92,29 @@ func (s *OpenAIGatewayService) ForwardCountTokensAsAnthropic( return fmt.Errorf("count_tokens: missing account") } + // 国产供应商(全部协议,含 anthropic):一律本地估算,不发上游请求。 + // 依据(2026-08 核实):三家的 Anthropic 兼容层均未提供 + // /v1/messages/count_tokens——DeepSeek 官方 anthropic_api 文档无此端点 + // (且注明 anthropic-version 头被忽略),聚合网关 OpenModel 明确标注 + // count_tokens 为 "Anthropic only",Kimi/智谱亦无任何文档承诺。转发上游 + // 只会常态 404,且错误还会流入账号处置逻辑误伤整账号调度;Claude Code + // 高频调用此端点,本地 tiktoken 估算是与 Grok 一致的既有方案。 + if account.IsCNProvider() { + estimated, err := estimateAnthropicCountTokensLocally(body) + if err != nil { + writeAnthropicCountTokensError(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body") + return fmt.Errorf("count_tokens: estimate cn provider input tokens: %w", err) + } + logger.L().Debug("openai count_tokens: cn provider local estimate", + zap.Int64("account_id", account.ID), + zap.Int("estimated_input_tokens", estimated), + ) + c.JSON(http.StatusOK, gin.H{ + "input_tokens": estimated, + }) + return nil + } + prepared, err := prepareOpenAIInputTokensCountRequest(body, account, defaultMappedModel) if err != nil { writeAnthropicCountTokensError(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body") diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index a3ba6ebab910..47c02d866eb5 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -21,6 +21,7 @@ import ( func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) { beginUpstreamResponseModelObservation(c) clearGrokResponsesClientToolMapping(c) + clearOpenAIResponsesClientToolMapping(c) clearOpenAIResponsesNamespaceNames(c) startTime := time.Now() // 固定渠道映射后的请求级 canonical body;账号 normalize/strip 不得改写跨 failover hint。 @@ -108,6 +109,13 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco return s.forwardGrokResponses(ctx, c, account, body, originalModel, reqStream, startTime) } + // CN 供应商 anthropic 协议账号:/v1/responses 入站是交叉协议组合 + // (Responses 客户端 × Anthropic 上游),转成 Anthropic 请求走原生端点。 + // 不能落到下面的 raw-CC 分支——其 URL 构造会把 anthropic base 当 CC base 用。 + if account.IsAnthropicProtocol() { + return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, reqModel) + } + if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body) } @@ -414,6 +422,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if !isCompactRequest && applyCodexClientMetadata(decoded, account) { markDecodedModified() } + stageCodexFingerprintIDs(c, nil) // 指纹收敛:一次性解析收敛 ID,请求体和出站头共享同一份 IDs(保证 turn_id 等随机字段一致)。 // fingerprintIDs 在此处解析,后续 buildUpstreamRequest 中使用同一份。 if !isCompactRequest { @@ -435,7 +444,9 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if codexResult.NormalizedModel != "" { upstreamModel = codexResult.NormalizedModel } - if codexResult.PromptCacheKey != "" { + if currentPromptCacheKey, ok := decoded["prompt_cache_key"].(string); ok && currentPromptCacheKey != "" { + promptCacheKey = currentPromptCacheKey + } else if codexResult.PromptCacheKey != "" { promptCacheKey = codexResult.PromptCacheKey } } @@ -1051,13 +1062,17 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. if err != nil { return nil, err } - targetURL = buildOpenAIResponsesURL(validatedURL) + targetURL = buildOpenAIResponsesURLForPlatform(account.Platform, validatedURL) } default: targetURL = openaiPlatformAPIURL } targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c)) + // DeepSeek 原生 Responses 端点为无状态实现:强制 store=false、清除 + // previous_response_id,避免携带状态字段被上游拒绝。 + body = normalizeDeepSeekResponsesRequestBody(account, body) + req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) if err != nil { return nil, err @@ -1114,7 +1129,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. if isOpenAIResponsesCompactPath(c) { req.Header.Set("accept", "application/json") if req.Header.Get("version") == "" { - req.Header.Set("version", codexCLIVersion) + req.Header.Set("version", CodexCanonicalClientVersion()) } compactSession := resolveOpenAICompactSessionID(c) req.Header.Set("session_id", isolateOpenAISessionID(apiKeyID, compactSession)) @@ -1140,10 +1155,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. req.Header.Set("user-agent", customUA) } - // 若开启 ForceCodexCLI,则强制将上游 User-Agent 伪装为 Codex CLI。 + // 若开启 ForceCodexCLI,则强制将上游 User-Agent 伪装为规范 Codex 身份。 // 用于网关未透传/改写 User-Agent 时,仍能命中 Codex 侧识别逻辑。 if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { - req.Header.Set("user-agent", codexCLIUserAgent) + req.Header.Set("user-agent", CodexCanonicalUserAgent()) } // 指纹收敛:使用 Forward() 中预计算的收敛 ID 改写出站头,与请求体使用同一份 IDs。 diff --git a/backend/internal/service/openai_gateway_grok_tool_protocol.go b/backend/internal/service/openai_gateway_grok_tool_protocol.go index 1a8dfc35a6bd..11122172ae9c 100644 --- a/backend/internal/service/openai_gateway_grok_tool_protocol.go +++ b/backend/internal/service/openai_gateway_grok_tool_protocol.go @@ -15,12 +15,12 @@ import ( const grokResponsesClientToolMappingContextKey = "grok_responses_client_tool_mapping" -func adaptGrokResponsesClientTools(body []byte) ([]byte, apicompat.ResponsesClientToolMapping, error) { +func adaptResponsesClientToolsForFunctionUpstream(body []byte, upstream string) ([]byte, apicompat.ResponsesClientToolMapping, error) { decoder := json.NewDecoder(bytes.NewReader(body)) decoder.UseNumber() var requestBody map[string]any if err := decoder.Decode(&requestBody); err != nil { - return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("decode Grok Responses client tools: %w", err) + return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("decode %s Responses client tools: %w", upstream, err) } mapping, changed, err := apicompat.AdaptResponsesClientTools(requestBody) @@ -32,15 +32,23 @@ func adaptGrokResponsesClientTools(body []byte) ([]byte, apicompat.ResponsesClie } rebuilt, err := marshalOpenAIUpstreamJSON(requestBody) if err != nil { - return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("encode Grok Responses client tools: %w", err) + return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("encode %s Responses client tools: %w", upstream, err) } return rebuilt, mapping, nil } -func hasGrokResponsesClientToolMapping(mapping apicompat.ResponsesClientToolMapping) bool { +func adaptGrokResponsesClientTools(body []byte) ([]byte, apicompat.ResponsesClientToolMapping, error) { + return adaptResponsesClientToolsForFunctionUpstream(body, "Grok") +} + +func hasResponsesClientToolMapping(mapping apicompat.ResponsesClientToolMapping) bool { return len(mapping.CustomTools) > 0 || mapping.ToolSearch || len(mapping.NamespaceTools) > 0 } +func hasGrokResponsesClientToolMapping(mapping apicompat.ResponsesClientToolMapping) bool { + return hasResponsesClientToolMapping(mapping) +} + func setGrokResponsesClientToolMapping(c *gin.Context, mapping apicompat.ResponsesClientToolMapping) { if c == nil { return @@ -83,12 +91,12 @@ func restoreGrokResponsesClientToolPayload(c *gin.Context, payload []byte) ([]by return restored, err } -type grokResponsesClientToolStreamBody struct { +type responsesClientToolStreamBody struct { *io.PipeReader source io.Closer } -func (b *grokResponsesClientToolStreamBody) Close() error { +func (b *responsesClientToolStreamBody) Close() error { readerErr := b.PipeReader.Close() sourceErr := b.source.Close() if readerErr != nil { @@ -97,18 +105,26 @@ func (b *grokResponsesClientToolStreamBody) Close() error { return sourceErr } -func newGrokResponsesClientToolStreamBody( +func newResponsesClientToolStreamBody( source io.ReadCloser, mapping apicompat.ResponsesClientToolMapping, maxLineSize int, ) io.ReadCloser { reader, writer := io.Pipe() - body := &grokResponsesClientToolStreamBody{PipeReader: reader, source: source} - go transformGrokResponsesClientToolStream(source, writer, mapping, maxLineSize) + body := &responsesClientToolStreamBody{PipeReader: reader, source: source} + go transformResponsesClientToolStream(source, writer, mapping, maxLineSize) return body } -func transformGrokResponsesClientToolStream( +func newGrokResponsesClientToolStreamBody( + source io.ReadCloser, + mapping apicompat.ResponsesClientToolMapping, + maxLineSize int, +) io.ReadCloser { + return newResponsesClientToolStreamBody(source, mapping, maxLineSize) +} + +func transformResponsesClientToolStream( source io.ReadCloser, destination *io.PipeWriter, mapping apicompat.ResponsesClientToolMapping, @@ -192,7 +208,7 @@ func transformGrokResponsesClientToolStream( payloads, _, err = restorer.RestoreEvent(payload) if err != nil { _ = buffered.Flush() - _ = destination.CloseWithError(fmt.Errorf("restore Grok Responses client tool event: %w", err)) + _ = destination.CloseWithError(fmt.Errorf("restore Responses client tool event: %w", err)) return } } diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 268aec7b5a7e..43f4977f2d84 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -36,6 +36,15 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( ) (*OpenAIForwardResult, error) { beginUpstreamResponseModelObservation(c) + // 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点时, + // /v1/messages 请求零转换直通(仅模型名映射 + 少量 body 清洗),完整保留 + // thinking / tool_use / cache 语义,适配 Claude Code 等原生客户端。 + // 必须先于 ShouldUseResponsesAPI 分流:Anthropic 协议账号经 probe 落标 + // openai_responses_supported=false,会先命中下方的 CC 直转分支。 + if account.IsAnthropicProtocol() { + return s.forwardAnthropicViaNativeAnthropicEndpoint(ctx, c, account, body, defaultMappedModel) + } + // 入口分流:APIKey 账号 + 上游不支持 Responses API → 走 CC 直转(与 // ForwardAsChatCompletions 对称)。缺少此分流时,/v1/messages 入站请求 // 会被无条件转为 Responses 格式发往上游 /v1/responses,导致只支持 diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go new file mode 100644 index 000000000000..497cfe09ab10 --- /dev/null +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -0,0 +1,530 @@ +package service + +// 国产供应商(kimi/zhipu/deepseek)原生 Anthropic 端点直通路径。 +// +// 当账号 credentials["api_protocol"] = "anthropic" 时,入站 /v1/messages 请求 +// 不再做 Anthropic→CC→Anthropic 双重转换,而是零转换直通供应商的官方 +// Anthropic 兼容端点(如 https://open.bigmodel.cn/api/anthropic/v1/messages), +// 适配 Claude Code 等原生 Anthropic 客户端。转发骨架以 +// gateway_anthropic_passthrough.go 的 APIKey 透传为模板(字节级 SSE 中继 + +// usage 解析),错误/failover 语义对齐 OpenAI 网关其他路径 +// (failoverOpenAIUpstreamHTTPError / handleAnthropicErrorResponse)。 + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync/atomic" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// forwardAnthropicViaNativeAnthropicEndpoint 将 Anthropic Messages 请求零转换 +// 直通到国产供应商的原生 Anthropic 端点。仅做模型名映射与少量 body 清洗 +// (空文本块 / web-search 历史块),协议本身不转换。 +func (s *OpenAIGatewayService) forwardAnthropicViaNativeAnthropicEndpoint( + ctx context.Context, + c *gin.Context, + account *Account, + body []byte, + defaultMappedModel string, +) (*OpenAIForwardResult, error) { + startTime := time.Now() + + originalModel := strings.TrimSpace(gjson.GetBytes(body, "model").String()) + if originalModel == "" { + writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", "model is required") + return nil, fmt.Errorf("missing model in request") + } + clientStream := gjson.GetBytes(body, "stream").Bool() + + billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) + upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) + if upstreamModel != originalModel { + rewritten, err := sjson.SetBytes(body, "model", upstreamModel) + if err != nil { + return nil, fmt.Errorf("rewrite model: %w", err) + } + body = rewritten + } + + // 与 Anthropic 平台 passthrough 相同的 pre-filter:剥离空文本块与上游 + // 无法接受的 web-search 历史块(GLM/Kimi/DeepSeek 对 server_tool_use 400)。 + body = StripEmptyTextBlocks(body) + body = FilterWebSearchHistoryBlocks(body, upstreamModel) + + logger.LegacyPrintf("service.gateway", "[CN Anthropic 直通] account=%d(%s) platform=%s model=%s upstream=%s stream=%v", + account.ID, account.Name, account.Platform, originalModel, upstreamModel, clientStream) + + apiKey := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) + if apiKey == "" { + return nil, fmt.Errorf("account %d missing api_key", account.ID) + } + targetURL, err := s.nativeAnthropicTargetURL(account) + if err != nil { + return nil, err + } + + proxyURL := "" + if account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, clientStream) + upstreamReq, _, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, body, apiKey, targetURL) + releaseUpstreamCtx() + if err != nil { + return nil, err + } + + resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + if err != nil { + return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + respBody, upstreamMsg := s.readOpenAIUpstreamError(resp) + if foErr := s.failoverOpenAIUpstreamHTTPError(ctx, c, account, resp, respBody, upstreamMsg, upstreamModel); foErr != nil { + return nil, foErr + } + // 非 failover 错误:经共享 compat handler 以 Anthropic 格式回写 + // (透传规则、ops 记录、cyber_policy 与 CC 回退路径一致)。 + return s.handleAnthropicErrorResponse(resp, c, account, billingModel) + } + + if clientStream { + return s.handleNativeAnthropicStreamingResponse(ctx, resp, c, account, originalModel, billingModel, upstreamModel, startTime) + } + return s.handleNativeAnthropicBufferedResponse(ctx, resp, c, account, originalModel, billingModel, upstreamModel, startTime) +} + +// nativeAnthropicTargetURL 组装国产供应商原生 Anthropic messages 端点。 +// 第三方端点保持朴素路径,不附加 ?beta=true。 +func (s *OpenAIGatewayService) nativeAnthropicTargetURL(account *Account) (string, error) { + baseURL := strings.TrimSpace(account.GetAnthropicProtocolBaseURL()) + if baseURL == "" { + return "", fmt.Errorf("account %d has no anthropic protocol base url", account.ID) + } + validatedURL, err := s.validateUpstreamBaseURL(baseURL) + if err != nil { + return "", fmt.Errorf("invalid base_url: %w", err) + } + return strings.TrimRight(validatedURL, "/") + "/v1/messages", nil +} + +func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest( + ctx context.Context, + c *gin.Context, + account *Account, + body []byte, + apiKey string, + targetURL string, +) (*http.Request, []byte, error) { + // 能力维度 body sanitize:与 Anthropic 平台 passthrough 相同,按 beta + // header 决定是否保留 body 中的 beta 能力字段,避免客户端"body 带字段但 + // header 忘带 token"的 bug 让第三方上游 400。 + clientBeta := "" + if c != nil && c.Request != nil { + clientBeta = getHeaderRaw(c.Request.Header, "anthropic-beta") + } + if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok { + clientBeta = beta + } + if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, clientBeta); changed { + body = sanitized + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body)) + if err != nil { + return nil, nil, err + } + + if c != nil && c.Request != nil { + for key, values := range c.Request.Header { + lowerKey := strings.ToLower(strings.TrimSpace(key)) + if !allowedHeaders[lowerKey] { + continue + } + wireKey := resolveWireCasing(key) + for _, v := range values { + addHeaderRaw(req.Header, wireKey, v) + } + } + } + + // 覆盖入站鉴权残留,注入上游认证(默认 x-api-key;可经 extra + // anthropic_apikey_auth_scheme 切换 Authorization: Bearer)。 + req.Header.Del("authorization") + req.Header.Del("x-api-key") + req.Header.Del("x-goog-api-key") + req.Header.Del("cookie") + setAnthropicAPIKeyAuthHeader(req.Header, account, apiKey) + + if getHeaderRaw(req.Header, "content-type") == "" { + setHeaderRaw(req.Header, "content-type", "application/json") + } + if getHeaderRaw(req.Header, "anthropic-version") == "" { + setHeaderRaw(req.Header, "anthropic-version", "2023-06-01") + } + + // 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头) + account.ApplyHeaderOverrides(req.Header) + + return req, body, nil +} + +// handleNativeAnthropicBufferedResponse 处理非流式原生 Anthropic 响应: +// 校验 JSON、解析 usage、透传响应头后原样回写(仅工具名反向还原)。 +func (s *OpenAIGatewayService) handleNativeAnthropicBufferedResponse( + ctx context.Context, + resp *http.Response, + c *gin.Context, + account *Account, + originalModel string, + billingModel string, + upstreamModel string, + startTime time.Time, +) (*OpenAIForwardResult, error) { + if s.rateLimitService != nil { + s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header) + } + + body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, anthropicTooLargeError) + if err != nil { + return nil, err + } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + observer.ObserveAnthropic(body) + + var raw json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + return nil, invalidNonStreamingJSONFailoverError(ctx, s.rateLimitService, resp, account, body, err, billingModel) + } + + usage := parseClaudeUsageFromResponseBody(body) + if IsForceCacheBilling(ctx) && usage.InputTokens > 0 { + body, err = classifyAnthropicResponseInputAsCacheRead(body, usage) + if err != nil { + return nil, err + } + } + + writeAnthropicPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) + if contentType == "" { + contentType = "application/json" + } + body = reverseToolNamesIfPresent(c, body) + c.Data(resp.StatusCode, contentType, body) + + return &OpenAIForwardResult{ + RequestID: resp.Header.Get("x-request-id"), + Usage: claudeUsageToOpenAIUsage(usage), + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamEndpoint: "/v1/messages", + Stream: false, + Duration: time.Since(startTime), + }, nil +} + +// handleNativeAnthropicStreamingResponse 处理流式原生 Anthropic 响应: +// 字节级 SSE 中继(逐行透传、按事件边界 flush),同时解析 usage。 +// 骨架与 handleStreamingResponseAnthropicAPIKeyPassthrough 一致。 +func (s *OpenAIGatewayService) handleNativeAnthropicStreamingResponse( + ctx context.Context, + resp *http.Response, + c *gin.Context, + account *Account, + originalModel string, + billingModel string, + upstreamModel string, + startTime time.Time, +) (*OpenAIForwardResult, error) { + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + if s.rateLimitService != nil { + s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header) + } + + writeAnthropicPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + + contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) + if contentType == "" { + contentType = "text/event-stream" + } + c.Header("Content-Type", contentType) + if c.Writer.Header().Get("Cache-Control") == "" { + c.Header("Cache-Control", "no-cache") + } + if c.Writer.Header().Get("Connection") == "" { + c.Header("Connection", "keep-alive") + } + c.Header("X-Accel-Buffering", "no") + if v := resp.Header.Get("x-request-id"); v != "" { + c.Header("x-request-id", v) + } + + w := c.Writer + flusher, ok := w.(http.Flusher) + if !ok { + return nil, errors.New("streaming not supported") + } + + usage := &ClaudeUsage{} + var firstTokenMs *int + clientDisconnected := false + sawTerminalEvent := false + + scanner := bufio.NewScanner(resp.Body) + maxLineSize := defaultMaxLineSize + if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.cfg.Gateway.MaxLineSize + } + scanBuf := getSSEScannerBuf64K() + scanner.Buffer(scanBuf[:0], maxLineSize) + + type scanEvent struct { + line string + err error + } + events := make(chan scanEvent, 16) + done := make(chan struct{}) + sendEvent := func(ev scanEvent) bool { + select { + case events <- ev: + return true + case <-done: + return false + } + } + var lastReadAt int64 + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + go func(scanBuf *sseScannerBuf64K) { + defer putSSEScannerBuf64K(scanBuf) + defer close(events) + for scanner.Scan() { + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + if !sendEvent(scanEvent{line: scanner.Text()}) { + return + } + } + if err := scanner.Err(); err != nil { + _ = sendEvent(scanEvent{err: err}) + } + }(scanBuf) + defer close(done) + + streamInterval := time.Duration(0) + if s.cfg != nil && s.cfg.Gateway.StreamDataIntervalTimeout > 0 { + streamInterval = time.Duration(s.cfg.Gateway.StreamDataIntervalTimeout) * time.Second + } + var intervalTicker *time.Ticker + if streamInterval > 0 { + intervalTicker = time.NewTicker(streamInterval) + defer intervalTicker.Stop() + } + var intervalCh <-chan time.Time + if intervalTicker != nil { + intervalCh = intervalTicker.C + } + + keepaliveInterval := time.Duration(0) + if s.cfg != nil && s.cfg.Gateway.StreamKeepaliveInterval > 0 { + keepaliveInterval = time.Duration(s.cfg.Gateway.StreamKeepaliveInterval) * time.Second + } + var keepaliveTimer *time.Timer + if keepaliveInterval > 0 { + keepaliveTimer = time.NewTimer(keepaliveInterval) + defer keepaliveTimer.Stop() + } + var keepaliveCh <-chan time.Time + if keepaliveTimer != nil { + keepaliveCh = keepaliveTimer.C + } + lastDataAt := time.Now() + resetKeepaliveTimer := func() { + if keepaliveTimer == nil { + return + } + if !keepaliveTimer.Stop() { + select { + case <-keepaliveTimer.C: + default: + } + } + keepaliveTimer.Reset(keepaliveInterval) + } + inPartialEvent := false + + for { + select { + case ev, ok := <-events: + if !ok { + if !clientDisconnected { + flusher.Flush() + } + if !sawTerminalEvent { + return s.nativeAnthropicStreamResult(c, resp, usage, firstTokenMs, clientDisconnected, originalModel, billingModel, upstreamModel, startTime), + fmt.Errorf("stream usage incomplete: missing terminal event") + } + return s.nativeAnthropicStreamResult(c, resp, usage, firstTokenMs, clientDisconnected, originalModel, billingModel, upstreamModel, startTime), nil + } + if ev.err != nil { + if sawTerminalEvent { + return s.nativeAnthropicStreamResult(c, resp, usage, firstTokenMs, clientDisconnected, originalModel, billingModel, upstreamModel, startTime), nil + } + if clientDisconnected { + return s.nativeAnthropicStreamResult(c, resp, usage, firstTokenMs, clientDisconnected, originalModel, billingModel, upstreamModel, startTime), + fmt.Errorf("stream usage incomplete after disconnect: %w", ev.err) + } + if errors.Is(ev.err, context.Canceled) || errors.Is(ev.err, context.DeadlineExceeded) { + return s.nativeAnthropicStreamResult(c, resp, usage, firstTokenMs, clientDisconnected, originalModel, billingModel, upstreamModel, startTime), + fmt.Errorf("stream usage incomplete: %w", ev.err) + } + if errors.Is(ev.err, bufio.ErrTooLong) { + logger.LegacyPrintf("service.gateway", "[CN Anthropic 直通] SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, ev.err) + return s.nativeAnthropicStreamResult(c, resp, usage, firstTokenMs, clientDisconnected, originalModel, billingModel, upstreamModel, startTime), ev.err + } + return s.nativeAnthropicStreamResult(c, resp, usage, firstTokenMs, clientDisconnected, originalModel, billingModel, upstreamModel, startTime), + fmt.Errorf("stream read error: %w", ev.err) + } + + line := ev.line + if data, ok := extractAnthropicSSEDataLine(line); ok { + trimmed := strings.TrimSpace(data) + observer.ObserveAnthropic([]byte(trimmed)) + if anthropicStreamEventIsTerminal("", trimmed) { + sawTerminalEvent = true + } + if firstTokenMs == nil && trimmed != "" && trimmed != "[DONE]" { + ms := int(time.Since(startTime).Milliseconds()) + firstTokenMs = &ms + } + parseSSEUsagePassthrough(data, usage) + } else { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "event:") && anthropicStreamEventIsTerminal(strings.TrimSpace(strings.TrimPrefix(trimmed, "event:")), "") { + sawTerminalEvent = true + } + } + + if !clientDisconnected { + restored := string(reverseToolNamesIfPresent(c, []byte(line))) + if _, err := io.WriteString(w, restored); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.gateway", "[CN Anthropic 直通] Client disconnected during streaming, continue draining upstream for usage: account=%d", account.ID) + } else if _, err := io.WriteString(w, "\n"); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.gateway", "[CN Anthropic 直通] Client disconnected during streaming, continue draining upstream for usage: account=%d", account.ID) + } else if line == "" { + // 按 SSE 事件边界刷出,减少每行 flush 带来的 syscall 开销。 + flusher.Flush() + lastDataAt = time.Now() + resetKeepaliveTimer() + inPartialEvent = false + } else { + inPartialEvent = true + } + } + + case <-intervalCh: + lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) + if time.Since(lastRead) < streamInterval { + continue + } + if clientDisconnected { + return s.nativeAnthropicStreamResult(c, resp, usage, firstTokenMs, clientDisconnected, originalModel, billingModel, upstreamModel, startTime), + fmt.Errorf("stream usage incomplete after timeout") + } + logger.LegacyPrintf("service.gateway", "[CN Anthropic 直通] Stream data interval timeout: account=%d model=%s interval=%s", account.ID, upstreamModel, streamInterval) + if s.rateLimitService != nil { + s.rateLimitService.HandleStreamTimeout(ctx, account, upstreamModel) + } + return s.nativeAnthropicStreamResult(c, resp, usage, firstTokenMs, clientDisconnected, originalModel, billingModel, upstreamModel, startTime), + fmt.Errorf("stream data interval timeout") + + case <-keepaliveCh: + if clientDisconnected { + continue + } + if inPartialEvent { + resetKeepaliveTimer() + continue + } + if time.Since(lastDataAt) < keepaliveInterval { + resetKeepaliveTimer() + continue + } + if _, err := fmt.Fprint(w, "event: ping\ndata: {\"type\": \"ping\"}\n\n"); err != nil { + clientDisconnected = true + logger.LegacyPrintf("service.gateway", "[CN Anthropic 直通] Client disconnected during keepalive ping, continue draining upstream for usage: account=%d", account.ID) + continue + } + flusher.Flush() + lastDataAt = time.Now() + resetKeepaliveTimer() + } + } +} + +// nativeAnthropicStreamResult 组装流式直通结果;流中断时同样返回已观测到的 +// usage 与错误一起带出,避免上游已计量的请求漏记漏计费(对齐 issue #5148 语义)。 +func (s *OpenAIGatewayService) nativeAnthropicStreamResult( + c *gin.Context, + resp *http.Response, + usage *ClaudeUsage, + firstTokenMs *int, + clientDisconnect bool, + originalModel string, + billingModel string, + upstreamModel string, + startTime time.Time, +) *OpenAIForwardResult { + if usage == nil { + usage = &ClaudeUsage{} + } + return &OpenAIForwardResult{ + RequestID: resp.Header.Get("x-request-id"), + Usage: claudeUsageToOpenAIUsage(usage), + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamEndpoint: "/v1/messages", + Stream: true, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnect, + } +} + +// claudeUsageToOpenAIUsage 把 Anthropic 格式 usage 映射到 OpenAI 网关统一的 +// 用量结构(字段一一对应)。 +func claudeUsageToOpenAIUsage(u *ClaudeUsage) OpenAIUsage { + if u == nil { + return OpenAIUsage{} + } + return OpenAIUsage{ + InputTokens: u.InputTokens, + OutputTokens: u.OutputTokens, + CacheCreationInputTokens: u.CacheCreationInputTokens, + CacheReadInputTokens: u.CacheReadInputTokens, + } +} diff --git a/backend/internal/service/openai_gateway_model_availability.go b/backend/internal/service/openai_gateway_model_availability.go index a665052e602e..35adea0d723d 100644 --- a/backend/internal/service/openai_gateway_model_availability.go +++ b/backend/internal/service/openai_gateway_model_availability.go @@ -34,7 +34,7 @@ func (s *OpenAIGatewayService) DiagnoseModelAvailabilityForPlatform( return ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: true} } - platform = normalizeOpenAICompatiblePlatform(platform) + platform = NormalizeOpenAICompatiblePlatform(platform) queryGroupID := groupID includeGrouped := false if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple { diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index 1fac0440f685..f23a656f1764 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -16,8 +16,8 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" - "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" @@ -25,6 +25,84 @@ import ( "go.uber.org/zap" ) +const openAIResponsesClientToolMappingContextKey = "openai_responses_client_tool_mapping" + +func hasOpenAIResponsesClientToolMapping(mapping apicompat.ResponsesClientToolMapping) bool { + return len(mapping.CustomTools) > 0 || mapping.ToolSearch || len(mapping.NamespaceTools) > 0 +} + +func adaptOpenAIResponsesClientTools(body []byte) ([]byte, apicompat.ResponsesClientToolMapping, error) { + if !needsOpenAIResponsesClientToolAdaptation(body) { + return body, apicompat.ResponsesClientToolMapping{}, nil + } + + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + var requestBody map[string]any + if err := decoder.Decode(&requestBody); err != nil { + return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("decode OpenAI Responses client tools: %w", err) + } + var trailingValue any + if err := decoder.Decode(&trailingValue); !errors.Is(err, io.EOF) { + if err == nil { + err = errors.New("multiple JSON values") + } + return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("decode OpenAI Responses client tools trailing data: %w", err) + } + mapping, changed, err := apicompat.AdaptResponsesClientTools(requestBody) + if err != nil || !changed { + return body, mapping, err + } + rebuilt, err := marshalOpenAIUpstreamJSON(requestBody) + if err != nil { + return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("encode OpenAI Responses client tools: %w", err) + } + return rebuilt, mapping, nil +} + +func needsOpenAIResponsesClientToolAdaptation(body []byte) bool { + needsAdaptation := false + var visit func(gjson.Result) bool + visit = func(value gjson.Result) bool { + if value.IsObject() { + switch strings.TrimSpace(value.Get("type").String()) { + case "custom", "custom_tool_call", "custom_tool_call_output", + "tool_search", "tool_search_call", "tool_search_output": + needsAdaptation = true + return false + } + } + if value.IsObject() || value.IsArray() { + value.ForEach(func(_, child gjson.Result) bool { + return visit(child) + }) + } + return !needsAdaptation + } + visit(gjson.ParseBytes(body)) + return needsAdaptation +} + +func openAIResponsesClientToolMapping(c *gin.Context) (apicompat.ResponsesClientToolMapping, bool) { + if c == nil { + return apicompat.ResponsesClientToolMapping{}, false + } + value, ok := c.Get(openAIResponsesClientToolMappingContextKey) + mapping, typed := value.(apicompat.ResponsesClientToolMapping) + return mapping, ok && typed && hasOpenAIResponsesClientToolMapping(mapping) +} + +// clearOpenAIResponsesClientToolMapping removes mapping state from the prior +// forwarding attempt. Forward retries accounts on the same Gin context. +func clearOpenAIResponsesClientToolMapping(c *gin.Context) { + if c == nil { + return + } + if _, exists := c.Get(openAIResponsesClientToolMappingContextKey); exists { + c.Set(openAIResponsesClientToolMappingContextKey, apicompat.ResponsesClientToolMapping{}) + } +} + func (s *OpenAIGatewayService) forwardOpenAIPassthrough( ctx context.Context, c *gin.Context, @@ -81,6 +159,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } reqStream = gjson.GetBytes(body, "stream").Bool() + stageCodexFingerprintIDs(c, nil) // 指纹收敛:与非透传路径同门控(仅 OAuth、legacy compact 形态跳过)。 // 一次性解析收敛 ID:请求体 client_metadata 在此改写(raw 字节外科 // 手术,透传热路径禁全量 Unmarshal),出站头改写由请求构造器读取 @@ -104,6 +183,16 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } } + if account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey && + !isOpenAIResponsesCompactPath(c) && needsOpenAIResponsesClientToolAdaptation(body) { + adaptedBody, mapping, adaptErr := adaptOpenAIResponsesClientTools(body) + if adaptErr != nil { + return nil, adaptErr + } + body = adaptedBody + c.Set(openAIResponsesClientToolMappingContextKey, mapping) + } + sanitizedBody, sanitized, err := sanitizeEmptyBase64InputImagesInOpenAIBody(body) if err != nil { return nil, err @@ -258,6 +347,13 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( return nil, s.handleErrorResponsePassthrough(ctx, resp, c, account, body, probeBody) } defer func() { _ = resp.Body.Close() }() + if mapping, ok := openAIResponsesClientToolMapping(c); ok && isEventStreamResponse(resp.Header) { + maxLineSize := defaultMaxLineSize + if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.cfg.Gateway.MaxLineSize + } + resp.Body = newGrokResponsesClientToolStreamBody(resp.Body, mapping, maxLineSize) + } serviceTier := extractOpenAIServiceTierFromBody(body) @@ -380,11 +476,14 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( if err != nil { return nil, err } - targetURL = buildOpenAIResponsesURL(validatedURL) + targetURL = buildOpenAIResponsesURLForPlatform(account.Platform, validatedURL) } } targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c)) + // DeepSeek 原生 Responses 端点为无状态实现(见 normalizeDeepSeekResponsesRequestBody)。 + body = normalizeDeepSeekResponsesRequestBody(account, body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body)) if err != nil { return nil, err @@ -441,7 +540,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( if isOpenAIResponsesCompactPath(c) { req.Header.Set("accept", "application/json") if req.Header.Get("version") == "" { - req.Header.Set("version", codexCLIVersion) + req.Header.Set("version", CodexCanonicalClientVersion()) } if clientSessionID == "" { clientSessionID = resolveOpenAICompactSessionID(c) @@ -450,7 +549,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( req.Header.Set("accept", "text/event-stream") } if req.Header.Get("originator") == "" { - req.Header.Set("originator", openai.CodexDefaultOriginator) + req.Header.Set("originator", resolveCodexOutboundIdentity("").originator) } // 用隔离后的 session 标识符覆盖客户端透传值,防止跨用户会话碰撞。 if clientSessionID == "" { @@ -478,7 +577,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( req.Header.Set("user-agent", customUA) } if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { - req.Header.Set("user-agent", codexCLIUserAgent) + req.Header.Set("user-agent", CodexCanonicalUserAgent()) } // 指纹收敛:使用 forwardOpenAIPassthrough 中预计算的收敛 ID 改写出站头, // 与请求体 client_metadata 共享同一份 IDs(与非透传路径相同的相对位置: @@ -1560,6 +1659,12 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough( if err != nil { return nil, fmt.Errorf("restore OpenAI passthrough namespace response: %w", err) } + if mapping, ok := openAIResponsesClientToolMapping(c); ok && json.Valid(body) { + body, _, err = apicompat.RestoreResponsesClientToolPayload(body, mapping) + if err != nil { + return nil, fmt.Errorf("restore OpenAI Responses client tools: %w", err) + } + } if !writeOpenAICompactSSEBridge(c, resp.StatusCode, body) { c.Data(resp.StatusCode, contentType, body) } diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index e59b3b205a27..21e5cd409676 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -504,6 +504,71 @@ func TestOpenAIGatewayServiceRecordUsage_PeakRateAffectsTokenModeImageOutputToke require.InDelta(t, expectedActual, userRepo.lastAmount, 1e-12) } +func TestOpenAIGatewayServiceRecordUsage_TimePricingUsesPricingAt(t *testing.T) { + groupID := int64(16) + requestStart := time.Date(2024, time.January, 2, 2, 0, 0, 0, time.UTC) // 上海 10:00 + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil) + svc.resolver = newOpenAITokenImageChannelPricingResolverWithTimeForTest(t, groupID, "gpt-5.1", &ChannelTimePricing{ + Timezone: "Asia/Shanghai", + Periods: []ChannelTimePricingPeriod{{StartTime: "09:00", EndTime: "12:00", Multiplier: 2}}, + }) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "openai_time_pricing_request_start", + Model: "gpt-5.1", + Usage: OpenAIUsage{InputTokens: 1000, OutputTokens: 500}, + }, + APIKey: &APIKey{ID: 1006, GroupID: i64p(groupID), Group: &Group{ + ID: groupID, RateMultiplier: 0.8, SubscriptionType: SubscriptionTypeSubscription, + }}, + User: &User{ID: 2006}, + Account: &Account{ID: 3006}, + PricingAt: requestStart, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + baseCost := 1000*3e-6 + 500*15e-6 + require.InDelta(t, baseCost*2, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, baseCost*2*0.8, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, 0.8, usageRepo.lastLog.RateMultiplier, 1e-12) +} + +func TestOpenAIGatewayServiceRecordUsage_TimePricingUsesExplicitPricingAt(t *testing.T) { + groupID := int64(17) + pricingAt := time.Date(2024, time.January, 2, 0, 0, 0, 0, time.UTC) // 上海 08:00 + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil) + svc.resolver = newOpenAITokenImageChannelPricingResolverWithTimeForTest(t, groupID, "gpt-5.1", &ChannelTimePricing{ + Timezone: "Asia/Shanghai", + Periods: []ChannelTimePricingPeriod{{StartTime: "09:00", EndTime: "12:00", Multiplier: 2}}, + }) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "openai_time_pricing_explicit", + Model: "gpt-5.1", + Usage: OpenAIUsage{InputTokens: 1000, OutputTokens: 500}, + }, + APIKey: &APIKey{ID: 1007, GroupID: i64p(groupID), Group: &Group{ + ID: groupID, RateMultiplier: 0.8, SubscriptionType: SubscriptionTypeSubscription, + }}, + User: &User{ID: 2007}, + Account: &Account{ID: 3007}, + PricingAt: pricingAt, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + baseCost := 1000*3e-6 + 500*15e-6 + require.InDelta(t, baseCost, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, baseCost*0.8, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, 0.8, usageRepo.lastLog.RateMultiplier, 1e-12) +} func TestOpenAIGatewayServiceRecordUsage_IncludesEndpointMetadata(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} @@ -2640,6 +2705,20 @@ func newOpenAITokenImageChannelPricingResolverForTest(t *testing.T, groupID int6 return NewModelPricingResolver(cs, NewBillingService(&config.Config{}, nil)) } +func newOpenAITokenImageChannelPricingResolverWithTimeForTest( + t *testing.T, + groupID int64, + model string, + timePricing *ChannelTimePricing, +) *ModelPricingResolver { + t.Helper() + resolver := newOpenAITokenImageChannelPricingResolverForTest(t, groupID, model) + cached, ok := resolver.channelService.cache.Load().(*channelCache) + require.True(t, ok) + cached.pricingByGroupModel[channelModelKey{groupID: groupID, model: model}].TimePricing = timePricing + return resolver +} + type openAIMediaPriceGroupRepoStub struct { GroupRepository group *Group @@ -2668,6 +2747,7 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesImageCoun "gemini-image", 0.15, 1.0, + time.Time{}, nil, ) @@ -2707,6 +2787,7 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesSizeTier( "gemini-image", 1.0, 1.0, + time.Time{}, nil, ) @@ -2739,6 +2820,7 @@ func TestGatewayServiceCalculateRecordUsageCost_GroupImagePriceOverridesChannelI "gemini-image", 1.0, 1.0, + time.Time{}, nil, ) @@ -2802,6 +2884,7 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingNormalizesMis "gemini-image", 1.0, 1.0, + time.Time{}, nil, ) diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 8be2d0cf2b70..3559fb400d4d 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -45,6 +45,33 @@ func buildOpenAIResponsesURL(base string) string { return buildOpenAIEndpointURL(base, "/v1/responses") } +// buildOpenAIResponsesURLForPlatform 组装 Responses 端点(平台感知)。 +// DeepSeek 官方 Responses 端点为 /responses(无 /v1 前缀,适配 Codex); +// 其余平台维持 /v1/responses。 +func buildOpenAIResponsesURLForPlatform(platform string, base string) string { + if platform == PlatformDeepseek { + return buildOpenAIEndpointURL(base, "/responses") + } + return buildOpenAIResponsesURL(base) +} + +// normalizeDeepSeekResponsesRequestBody 适配 DeepSeek 无状态 Responses 端点: +// 强制 store=false 并清除 previous_response_id(官方 /responses 不支持服务端 +// 状态存储,携带这些字段会被拒绝)。非 deepseek responses 协议账号原样返回。 +func normalizeDeepSeekResponsesRequestBody(account *Account, body []byte) []byte { + if account == nil || account.Platform != PlatformDeepseek || account.GetAPIProtocol() != APIProtocolResponses { + return body + } + normalized, err := sjson.SetBytes(body, "store", false) + if err != nil { + return body + } + if stripped, err := sjson.DeleteBytes(normalized, "previous_response_id"); err == nil { + normalized = stripped + } + return normalized +} + func trimOpenAIEncryptedReasoningItems(reqBody map[string]any) bool { if len(reqBody) == 0 { return false diff --git a/backend/internal/service/openai_gateway_responses_anthropic_native.go b/backend/internal/service/openai_gateway_responses_anthropic_native.go new file mode 100644 index 000000000000..04cd7fc27484 --- /dev/null +++ b/backend/internal/service/openai_gateway_responses_anthropic_native.go @@ -0,0 +1,492 @@ +package service + +// 国产供应商 Anthropic 协议账号的 Responses 入站反向路径。 +// +// 客户端说 OpenAI Responses(/v1/responses,Codex 等)、上游是供应商原生 +// Anthropic 端点(api_protocol=anthropic)时的交叉组合:请求 Responses→Anthropic +// 单次转换,响应 Anthropic 事件→Responses 事件转换。转换链与 Anthropic 平台的 +// gateway_forward_as_responses.go 完全一致(复用同一组 apicompat 状态机),仅上游 +// 发送/错误处理对齐 OpenAI 网关语义(模型映射、failover、transport error)。 + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "go.uber.org/zap" +) + +// forwardResponsesViaNativeAnthropic serves OpenAI /v1/responses clients through +// a CN provider's native Anthropic endpoint. +// +// Conversion chain: +// +// Request: Responses → Anthropic (single conversion) +// Response: Anthropic events → Responses events (stream state machine) +func (s *OpenAIGatewayService) forwardResponsesViaNativeAnthropic( + ctx context.Context, + c *gin.Context, + account *Account, + body []byte, + defaultMappedModel string, +) (*OpenAIForwardResult, error) { + startTime := time.Now() + + // 1. Lower Codex client-side tools to function tools understood by Anthropic. + adaptedBody, clientToolMapping, err := adaptResponsesClientToolsForAnthropic(body) + if err != nil { + writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", "Failed to adapt request tools") + return nil, fmt.Errorf("adapt responses client tools: %w", err) + } + + // 2. Parse Responses request + var responsesReq apicompat.ResponsesRequest + if err := json.Unmarshal(adaptedBody, &responsesReq); err != nil { + writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body") + return nil, fmt.Errorf("parse responses request: %w", err) + } + originalModel := responsesReq.Model + if strings.TrimSpace(originalModel) == "" { + writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", "model is required") + return nil, fmt.Errorf("missing model in request") + } + clientStream := responsesReq.Stream + + // 3. Convert Responses → Anthropic + anthropicReq, err := apicompat.ResponsesToAnthropicRequest(&responsesReq) + if err != nil { + writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", "Failed to convert request") + return nil, fmt.Errorf("convert responses to anthropic: %w", err) + } + + // 4. Model mapping(OpenAI 网关统一入口的映射语义) + billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) + upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) + anthropicReq.Model = upstreamModel + + reasoningEffort := ExtractResponsesReasoningEffortFromBody(body) + reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel) + + // 5. Force upstream streaming(客户端原始终决定响应格式; + // 上游恒为流式,非流式由缓冲路径组装)。 + anthropicReq.Stream = true + reqStream := true + + logger.L().Debug("openai responses: forwarding via native anthropic endpoint", + zap.Int64("account_id", account.ID), + zap.String("original_model", originalModel), + zap.String("billing_model", billingModel), + zap.String("upstream_model", upstreamModel), + zap.Bool("client_stream", clientStream), + ) + + anthropicBody, err := json.Marshal(anthropicReq) + if err != nil { + return nil, fmt.Errorf("marshal anthropic request: %w", err) + } + + // 与 /v1/messages 直通路径相同的 pre-filter。 + anthropicBody = StripEmptyTextBlocks(anthropicBody) + anthropicBody = FilterWebSearchHistoryBlocks(anthropicBody, upstreamModel) + anthropicBody = enforceCacheControlLimit(anthropicBody) + + apiKey := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) + if apiKey == "" { + return nil, fmt.Errorf("account %d missing api_key", account.ID) + } + targetURL, err := s.nativeAnthropicTargetURL(account) + if err != nil { + return nil, err + } + + proxyURL := "" + if account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream) + upstreamReq, _, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL) + releaseUpstreamCtx() + if err != nil { + return nil, fmt.Errorf("build upstream request: %w", err) + } + + resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + if err != nil { + return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + respBody, upstreamMsg := s.readOpenAIUpstreamError(resp) + if foErr := s.failoverOpenAIUpstreamHTTPError(ctx, c, account, resp, respBody, upstreamMsg, upstreamModel); foErr != nil { + return nil, foErr + } + writeResponsesError(c, mapUpstreamStatusCode(resp.StatusCode), "server_error", upstreamMsg) + return nil, fmt.Errorf("upstream error: %d %s", resp.StatusCode, upstreamMsg) + } + + if clientStream { + return s.handleResponsesStreamingFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime, clientToolMapping) + } + return s.handleResponsesBufferedFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime, clientToolMapping) +} + +// handleResponsesBufferedFromNativeAnthropic reads Anthropic SSE events, assembles +// the full response, then converts Anthropic → Responses. +func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic( + resp *http.Response, + c *gin.Context, + originalModel string, + billingModel string, + upstreamModel string, + reasoningEffort *string, + startTime time.Time, + clientToolMapping apicompat.ResponsesClientToolMapping, +) (*OpenAIForwardResult, error) { + requestID := resp.Header.Get("x-request-id") + + scanner := bufio.NewScanner(resp.Body) + maxLineSize := defaultMaxLineSize + if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.cfg.Gateway.MaxLineSize + } + scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize) + + var finalResp *apicompat.AnthropicResponse + var usage ClaudeUsage + + // 读间隔上限:上游挂住 SSE 时中止组装(缓冲路径尚未提交响应头,可回 502)。 + streamInterval := s.anthropicNativeStreamInterval() + pump := newAnthropicNativeLinePump(scanner, streamInterval) + defer pump.stop() + + logReadErr := func(err error) { + if !errors.Is(err, io.EOF) && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + logger.L().Warn("openai responses via native anthropic buffered: read error", + zap.Error(err), + zap.String("request_id", requestID), + ) + } + } + onIdle := func() (*OpenAIForwardResult, error) { + _ = resp.Body.Close() + logger.L().Warn("openai responses via native anthropic buffered: data interval timeout", + zap.String("request_id", requestID), + zap.Duration("interval", streamInterval), + ) + writeResponsesError(c, http.StatusBadGateway, "server_error", "Upstream stream data interval timeout") + return nil, fmt.Errorf("stream data interval timeout") + } + + for { + line, rerr := pump.next() + if rerr != nil { + if errors.Is(rerr, errAnthropicNativeStreamIdle) { + return onIdle() + } + logReadErr(rerr) + break + } + // SSE 规范允许 `event:xxx`(冒号后无空格):Kimi 等上游返回紧凑格式。 + if _, ok := extractOpenAISSEEventLine(line); !ok { + continue + } + + dataLine, rerr := pump.next() + if rerr != nil { + if errors.Is(rerr, errAnthropicNativeStreamIdle) { + return onIdle() + } + logReadErr(rerr) + break + } + payload, ok := extractOpenAISSEDataLine(dataLine) + if !ok { + continue + } + + var event apicompat.AnthropicStreamEvent + if err := json.Unmarshal([]byte(payload), &event); err != nil { + continue + } + + if event.Type == "message_start" && event.Message != nil { + finalResp = event.Message + mergeAnthropicUsage(&usage, event.Message.Usage) + } + if event.Type == "message_delta" { + if event.Usage != nil { + mergeAnthropicUsage(&usage, *event.Usage) + } + if event.Delta != nil && event.Delta.StopReason != "" && finalResp != nil { + finalResp.StopReason = apicompat.AnthropicStopReasonPtr(event.Delta.StopReason) + } + } + if event.Type == "content_block_start" && event.ContentBlock != nil && finalResp != nil { + finalResp.Content = append(finalResp.Content, *event.ContentBlock) + } + if event.Type == "content_block_delta" && event.Delta != nil && finalResp != nil && event.Index != nil { + idx := *event.Index + if idx < len(finalResp.Content) { + switch event.Delta.Type { + case "text_delta": + finalResp.Content[idx].Text += event.Delta.Text + case "thinking_delta": + finalResp.Content[idx].Thinking += event.Delta.Thinking + case "input_json_delta": + finalResp.Content[idx].Input = appendRawJSON(finalResp.Content[idx].Input, event.Delta.PartialJSON) + } + } + } + } + + if finalResp == nil { + writeResponsesError(c, http.StatusBadGateway, "server_error", "Upstream stream ended without a response") + return nil, fmt.Errorf("upstream stream ended without response") + } + + if usage.InputTokens > 0 || usage.OutputTokens > 0 { + finalResp.Usage = apicompat.AnthropicUsage{ + InputTokens: usage.InputTokens, + OutputTokens: usage.OutputTokens, + CacheCreationInputTokens: usage.CacheCreationInputTokens, + CacheReadInputTokens: usage.CacheReadInputTokens, + } + } + + responsesResp := apicompat.AnthropicToResponsesResponse(finalResp) + responsesResp.Model = originalModel + + if s.responseHeaderFilter != nil { + responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + } + // 非流式响应必须是 application/json(上游被强制流式,透传头会污染)。 + c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") + if respBytes, err := json.Marshal(responsesResp); err == nil { + respBytes = reverseToolNamesIfPresent(c, respBytes) + respBytes, _, err = apicompat.RestoreResponsesClientToolPayload(respBytes, clientToolMapping) + if err != nil { + return nil, fmt.Errorf("restore responses client tools: %w", err) + } + c.Data(http.StatusOK, "application/json; charset=utf-8", respBytes) + } else { + c.JSON(http.StatusOK, responsesResp) + } + + return &OpenAIForwardResult{ + RequestID: requestID, + Usage: claudeUsageToOpenAIUsage(&usage), + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamEndpoint: "/v1/messages", + ReasoningEffort: reasoningEffort, + Stream: false, + Duration: time.Since(startTime), + }, nil +} + +// handleResponsesStreamingFromNativeAnthropic reads Anthropic SSE events, converts +// each to Responses SSE events, and writes them to the client. +func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic( + resp *http.Response, + c *gin.Context, + originalModel string, + billingModel string, + upstreamModel string, + reasoningEffort *string, + startTime time.Time, + clientToolMapping apicompat.ResponsesClientToolMapping, +) (*OpenAIForwardResult, error) { + requestID := resp.Header.Get("x-request-id") + + if s.responseHeaderFilter != nil { + responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + } + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("X-Accel-Buffering", "no") + c.Writer.WriteHeader(http.StatusOK) + + state := apicompat.NewAnthropicEventToResponsesState() + state.Model = originalModel + clientToolRestorer := apicompat.NewResponsesClientToolStreamRestorer(clientToolMapping) + + var usage ClaudeUsage + var firstTokenMs *int + firstChunk := true + clientDisconnected := false + + scanner := bufio.NewScanner(resp.Body) + maxLineSize := defaultMaxLineSize + if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.cfg.Gateway.MaxLineSize + } + scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize) + + resultWithUsage := func() *OpenAIForwardResult { + return &OpenAIForwardResult{ + RequestID: requestID, + Usage: claudeUsageToOpenAIUsage(&usage), + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamEndpoint: "/v1/messages", + ReasoningEffort: reasoningEffort, + Stream: true, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnected, + } + } + + // 读间隔上限:上游挂住 SSE(不发数据也不断连)时结束转换循环。上游 ctx 为 + // WithoutCancel 且 http.Client 无整体 Timeout,无此界限则 scanner.Scan() + // 永久阻塞(见 anthropic native pump 文件注释)。 + streamInterval := s.anthropicNativeStreamInterval() + pump := newAnthropicNativeLinePump(scanner, streamInterval) + defer pump.stop() + + logReadErr := func(err error) { + if !errors.Is(err, io.EOF) && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + logger.L().Warn("openai responses via native anthropic stream: read error", + zap.Error(err), + zap.String("request_id", requestID), + ) + } + } + onIdle := func() (*OpenAIForwardResult, error) { + _ = resp.Body.Close() + logger.L().Warn("openai responses via native anthropic stream: data interval timeout", + zap.String("request_id", requestID), + zap.Duration("interval", streamInterval), + ) + return resultWithUsage(), fmt.Errorf("stream data interval timeout") + } + + // 与 CC 姊妹路径(handleCCStreamingFromNativeAnthropic.writeChunk)同语义: + // 客户端断开后不再写出,但继续排水上游至流自然结束——Anthropic 的最终 + // output_tokens 只在末尾 message_delta 携带,提前退出会把整段生成记成 ~1 + // token,payg 上游照常计费而平台漏记。状态机照常推进以保证 finalize 一致。 + processAnthropicEvent := func(event *apicompat.AnthropicStreamEvent) { + if firstChunk { + firstChunk = false + ms := int(time.Since(startTime).Milliseconds()) + firstTokenMs = &ms + } + + if event.Type == "message_delta" && event.Usage != nil { + mergeAnthropicUsage(&usage, *event.Usage) + } + if event.Type == "message_start" && event.Message != nil { + mergeAnthropicUsage(&usage, event.Message.Usage) + } + + events := apicompat.AnthropicEventToResponsesEvents(event, state) + if clientDisconnected { + return + } + for _, evt := range events { + payload, err := json.Marshal(evt) + if err != nil { + continue + } + payload = reverseToolNamesIfPresent(c, payload) + payloads, _, err := clientToolRestorer.RestoreEvent(payload) + if err != nil { + continue + } + for _, restored := range payloads { + eventType := gjson.GetBytes(restored, "type").String() + if _, err := fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", eventType, restored); err != nil { + clientDisconnected = true + return + } + } + } + if len(events) > 0 { + c.Writer.Flush() + } + } + + for { + line, rerr := pump.next() + if rerr != nil { + if errors.Is(rerr, errAnthropicNativeStreamIdle) { + return onIdle() + } + logReadErr(rerr) + break + } + if _, ok := extractOpenAISSEEventLine(line); !ok { + continue + } + + dataLine, rerr := pump.next() + if rerr != nil { + if errors.Is(rerr, errAnthropicNativeStreamIdle) { + return onIdle() + } + logReadErr(rerr) + break + } + payload, ok := extractOpenAISSEDataLine(dataLine) + if !ok { + continue + } + + var event apicompat.AnthropicStreamEvent + if err := json.Unmarshal([]byte(payload), &event); err != nil { + continue + } + + processAnthropicEvent(&event) + } + + // Finalize state machine(客户端已断开时仍推进,保证 usage 汇总完整;仅在 + // 客户端仍连接时写出)。终态帧与逐事件路径一致过工具名反转与客户端工具还原, + // 避免流截断时终态帧携带改写后的工具名。 + if finalEvents := apicompat.FinalizeAnthropicResponsesStream(state); len(finalEvents) > 0 && !clientDisconnected { + wrote := false + for _, evt := range finalEvents { + payload, err := json.Marshal(evt) + if err != nil { + continue + } + payload = reverseToolNamesIfPresent(c, payload) + payloads, _, err := clientToolRestorer.RestoreEvent(payload) + if err != nil { + continue + } + for _, restored := range payloads { + eventType := gjson.GetBytes(restored, "type").String() + if _, err := fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", eventType, restored); err != nil { + clientDisconnected = true + break + } + wrote = true + } + if clientDisconnected { + break + } + } + if wrote { + c.Writer.Flush() + } + } + + return resultWithUsage(), nil +} diff --git a/backend/internal/service/openai_gateway_responses_client_tools_test.go b/backend/internal/service/openai_gateway_responses_client_tools_test.go new file mode 100644 index 000000000000..cfb4f6eb624f --- /dev/null +++ b/backend/internal/service/openai_gateway_responses_client_tools_test.go @@ -0,0 +1,146 @@ +package service + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func openAIClientToolsRequest(stream bool) []byte { + streamValue := "false" + if stream { + streamValue = "true" + } + return []byte(`{"model":"gpt-5.4","input":"fix it","stream":` + streamValue + `,"tools":[{"type":"custom","name":"exec"},{"type":"custom","name":"apply_patch"}]}`) +} + +func assertOpenAIClientToolsLowered(t *testing.T, body []byte) { + t.Helper() + for index, name := range []string{"exec", "apply_patch"} { + tool := gjson.GetBytes(body, "tools."+string(rune('0'+index))) + require.Equal(t, "function", tool.Get("type").String()) + require.Equal(t, name, tool.Get("name").String()) + require.Equal(t, "string", tool.Get("parameters.properties.input.type").String()) + } +} + +func openAIClientToolsTestService(upstream *httpUpstreamRecorder) *OpenAIGatewayService { + return &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{ + URLAllowlist: config.URLAllowlistConfig{Enabled: false}, + }}, + } +} + +func TestAdaptOpenAIResponsesClientToolsLeavesNamespaceOnlyBodyUnchanged(t *testing.T) { + body := []byte(`{ + "model": "gpt-5.5", + "tools": [{"type": "namespace", "name": "code_tools", "tools": [{"type": "function", "name": "run"}]}], + "tool_choice": "auto" + }`) + + adapted, mapping, err := adaptOpenAIResponsesClientTools(body) + + require.NoError(t, err) + require.Equal(t, body, adapted) + require.Empty(t, mapping.CustomTools) + require.Empty(t, mapping.NamespaceTools) + require.False(t, mapping.ToolSearch) +} + +func TestAdaptOpenAIResponsesClientToolsRejectsTrailingData(t *testing.T) { + tests := map[string][]byte{ + "trailing garbage": append(openAIClientToolsRequest(false), []byte(` garbage`)...), + "second JSON document": append(openAIClientToolsRequest(false), []byte(` {"model":"other"}`)...), + } + + for name, body := range tests { + t.Run(name, func(t *testing.T) { + adapted, mapping, err := adaptOpenAIResponsesClientTools(body) + + require.ErrorContains(t, err, "decode OpenAI Responses client tools trailing data") + require.Equal(t, body, adapted) + require.Empty(t, mapping) + }) + } +} + +func TestClearOpenAIResponsesClientToolMappingRemovesStaleContextState(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Set(openAIResponsesClientToolMappingContextKey, apicompat.ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}}) + + clearOpenAIResponsesClientToolMapping(c) + + _, ok := openAIResponsesClientToolMapping(c) + require.False(t, ok) +} + +func TestOpenAIPassthroughAPIKeyRestoresClientToolsNonStreaming(t *testing.T) { + gin.SetMode(gin.TestMode) + body := openAIClientToolsRequest(false) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp_tools","status":"completed","output":[ + {"type":"function_call","id":"i1","call_id":"c1","name":"exec","arguments":"{\"input\":\"pwd\"}"}, + {"type":"function_call","id":"i2","call_id":"c2","name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\"}"}],"usage":{}}`)), + }} + svc := openAIClientToolsTestService(upstream) + account := &Account{ID: 5659, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "test-key"}} + + result, err := svc.forwardOpenAIPassthrough(context.Background(), c, account, body, body, "gpt-5.4", false, nil, false, time.Now()) + + require.NoError(t, err) + require.NotNil(t, result) + assertOpenAIClientToolsLowered(t, upstream.lastBody) + require.Equal(t, "custom_tool_call", gjson.Get(recorder.Body.String(), "output.0.type").String()) + require.Equal(t, "pwd", gjson.Get(recorder.Body.String(), "output.0.input").String()) + require.Equal(t, "custom_tool_call", gjson.Get(recorder.Body.String(), "output.1.type").String()) + require.Equal(t, "*** Begin Patch", gjson.Get(recorder.Body.String(), "output.1.input").String()) +} + +func TestOpenAIPassthroughAPIKeyRestoresClientToolsStreaming(t *testing.T) { + gin.SetMode(gin.TestMode) + body := openAIClientToolsRequest(true) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + + sse := strings.Join([]string{ + `data: {"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"type":"function_call","id":"i1","call_id":"c1","name":"apply_patch","status":"in_progress"}}`, + `data: {"type":"response.function_call_arguments.done","sequence_number":1,"item_id":"i1","call_id":"c1","name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\"}"}`, + `data: {"type":"response.output_item.done","sequence_number":2,"output_index":0,"item":{"type":"function_call","id":"i1","call_id":"c1","name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\"}","status":"completed"}}`, + `data: {"type":"response.completed","sequence_number":3,"response":{"id":"resp_stream_tools","status":"completed","output":[{"type":"function_call","id":"i1","call_id":"c1","name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\"}"}],"usage":{"input_tokens":1,"output_tokens":1}}}`, + }, "\n\n") + "\n\n" + upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(sse))}} + svc := openAIClientToolsTestService(upstream) + account := &Account{ID: 5660, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "test-key"}} + + result, err := svc.forwardOpenAIPassthrough(context.Background(), c, account, body, body, "gpt-5.4", false, nil, true, time.Now()) + + require.NoError(t, err) + require.NotNil(t, result) + assertOpenAIClientToolsLowered(t, upstream.lastBody) + output := recorder.Body.String() + require.Contains(t, output, `"type":"custom_tool_call"`) + require.Contains(t, output, `"type":"response.custom_tool_call_input.done"`) + require.Contains(t, output, `"input":"*** Begin Patch"`) + require.NotContains(t, output, `"input":{`) +} diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go index f7dff0909f62..7dd541472476 100644 --- a/backend/internal/service/openai_gateway_scheduling.go +++ b/backend/internal/service/openai_gateway_scheduling.go @@ -243,15 +243,22 @@ func (s *OpenAIGatewayService) SelectAccountForModelWithExclusions(ctx context.C return s.selectAccountForModelWithExclusions(s.withOpenAIQuotaAutoPauseContext(ctx), groupID, PlatformOpenAI, sessionHash, requestedModel, excludedIDs, false, 0, "", false) } -// noAvailableOpenAISelectionError builds the standard "no account available" error -// while preserving the legacy /responses/compact error when applicable. -func normalizeOpenAICompatiblePlatform(platform string) string { - if platform == PlatformGrok { - return PlatformGrok +// NormalizeOpenAICompatiblePlatform 保留 grok 与国产 OpenAI 兼容供应商(kimi/zhipu/ +// deepseek)的原值,其他值一律归一为 openai。调度器据此对账号与请求做精确平台匹配: +// kimi 分组请求只命中 kimi 账号,语义与 openai/grok 一致。 +// (upstream 曾将本函数改为未导出 normalizeOpenAICompatiblePlatform,本分支的 +// handler 调度入口仍需导出,保持导出名。) +func NormalizeOpenAICompatiblePlatform(platform string) string { + switch platform { + case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek: + return platform + default: + return PlatformOpenAI } - return PlatformOpenAI } +// noAvailableOpenAISelectionError builds the standard "no account available" error +// while preserving the legacy /responses/compact error when applicable. // details carries an optional machine-parseable exclusion summary (e.g. // "pool=2, filtered: quota_auto_pause_7d=1 runtime_blocked=1") appended in // parentheses. It is for server-side logs / ops diagnostics only: handlers @@ -327,7 +334,7 @@ func isOpenAICompatibleAccountEligibleForRequest(ctx context.Context, account *A // ordinary scheduling gate. Legacy selection uses it before classifying the // profit veto so earlier failures retain their actual reason. func isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx context.Context, account *Account, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) bool { - platform = normalizeOpenAICompatiblePlatform(platform) + platform = NormalizeOpenAICompatiblePlatform(platform) if account == nil || account.Platform != platform || !account.IsOpenAICompatible() || !account.IsSchedulableForModelWithContext(ctx, requestedModel) { return false } @@ -726,7 +733,7 @@ func resolveOpenAIAccountUpstreamModelForRequest(account *Account, requestedMode } func (s *OpenAIGatewayService) selectAccountForModelWithExclusions(ctx context.Context, groupID *int64, platform string, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool, stickyAccountID int64, requiredCapability OpenAIEndpointCapability, preferLowUpstreamRate bool) (*Account, error) { - platform = normalizeOpenAICompatiblePlatform(platform) + platform = NormalizeOpenAICompatiblePlatform(platform) if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) { slog.Warn("channel pricing restriction blocked request", "group_id", derefGroupID(groupID), @@ -779,7 +786,7 @@ func (s *OpenAIGatewayService) tryStickySessionHit(ctx context.Context, groupID if sessionHash == "" { return nil } - platform = normalizeOpenAICompatiblePlatform(platform) + platform = NormalizeOpenAICompatiblePlatform(platform) accountID := stickyAccountID if accountID <= 0 { @@ -846,7 +853,7 @@ func (s *OpenAIGatewayService) tryStickySessionHit(ctx context.Context, groupID // true); the third contains deterministic // exclusion diagnostics for the evaluated snapshot. func (s *OpenAIGatewayService) selectBestAccount(ctx context.Context, groupID *int64, platform string, accounts []Account, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool, requiredCapability OpenAIEndpointCapability, preferLowUpstreamRate bool) (*Account, bool, openAISelectionFilterStats) { - platform = normalizeOpenAICompatiblePlatform(platform) + platform = NormalizeOpenAICompatiblePlatform(platform) compactBlocked := false filterStats := openAISelectionFilterStats{pool: len(accounts)} needsUpstreamCheck := s.needsUpstreamChannelRestrictionCheck(ctx, groupID) @@ -958,7 +965,7 @@ func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Contex } func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Context, groupID *int64, platform string, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool, requiredCapability OpenAIEndpointCapability, useUpstreamTokenCost bool) (*AccountSelectionResult, error) { - platform = normalizeOpenAICompatiblePlatform(platform) + platform = NormalizeOpenAICompatiblePlatform(platform) if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) { slog.Warn("channel pricing restriction blocked request", "group_id", derefGroupID(groupID), @@ -1304,7 +1311,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex } func (s *OpenAIGatewayService) listSchedulableAccounts(ctx context.Context, groupID *int64, platform string) ([]Account, error) { - platform = normalizeOpenAICompatiblePlatform(platform) + platform = NormalizeOpenAICompatiblePlatform(platform) if s.schedulerSnapshot != nil { accounts, _, err := s.schedulerSnapshot.ListSchedulableAccounts(ctx, groupID, platform, false) if err != nil { @@ -1357,7 +1364,7 @@ func (s *OpenAIGatewayService) resolveFreshSchedulableOpenAIAccountBeforeProfit( if account == nil { return nil } - platform = normalizeOpenAICompatiblePlatform(platform) + platform = NormalizeOpenAICompatiblePlatform(platform) fresh := account if s.schedulerSnapshot != nil { @@ -1415,7 +1422,7 @@ func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDBBeforeProfit(ct if account == nil { return nil } - platform = normalizeOpenAICompatiblePlatform(platform) + platform = NormalizeOpenAICompatiblePlatform(platform) if s.schedulerSnapshot == nil || s.accountRepo == nil { if !isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx, account, platform, requestedModel, requireCompact, requiredCapability) { return nil diff --git a/backend/internal/service/openai_gateway_search_surcharge_test.go b/backend/internal/service/openai_gateway_search_surcharge_test.go index 2633a5b930f1..d2d069ff5c26 100644 --- a/backend/internal/service/openai_gateway_search_surcharge_test.go +++ b/backend/internal/service/openai_gateway_search_surcharge_test.go @@ -5,6 +5,7 @@ package service import ( "context" "testing" + "time" "github.com/stretchr/testify/require" ) @@ -37,6 +38,7 @@ func TestCalculateOpenAIRecordUsageCost_SearchIsAdditiveToTokens(t *testing.T) { UsageTokens{InputTokens: 1000, OutputTokens: 500}, "", boolPtr(false), + time.Time{}, ) require.NoError(t, err) require.NotNil(t, cost) @@ -67,6 +69,7 @@ func TestCalculateOpenAIRecordUsageCost_SearchOnlyWhenNoTokenPricing(t *testing. UsageTokens{}, "", boolPtr(false), + time.Time{}, ) require.NoError(t, err) require.NotNil(t, cost) @@ -112,6 +115,7 @@ func TestCalculateOpenAIRecordUsageCost_TokenPricingErrorNotSwallowedBySearch(t UsageTokens{InputTokens: 1000, OutputTokens: 500}, "", boolPtr(false), + time.Time{}, ) require.Error(t, err) require.Nil(t, cost) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 32483ea4c13c..3b9a8f24bbfa 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -1213,7 +1213,7 @@ func (s *OpenAIGatewayService) GetAccessToken(ctx context.Context, account *Acco } return apiKey, "apikey", nil } - apiKey := account.GetOpenAIApiKey() + apiKey := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) if apiKey == "" { return "", "", errors.New("api_key not found in credentials") } diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index a59e15d7381c..b391983551b3 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -184,7 +184,8 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec // 变价);未装配 PricingAt 的路径回退记录时刻,保持既有行为。不并入上面的 // Resolve,以免污染 user:group 倍率缓存。 baseMultiplier := multiplier - multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, baseMultiplier, openAIUsagePricingAt(input)) + pricingAt := openAIUsagePricingAt(input) + multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, baseMultiplier, pricingAt) videoMultiplier := resolveVideoRateMultiplier(apiKey, baseMultiplier) var cost *CostBreakdown @@ -207,6 +208,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec result.UpstreamModel, result.Model, ) + billingModels = s.filterCNProviderBillingModelCandidates(ctx, account, apiKey, billingModels) serviceTier := "" if result.ServiceTier != nil { serviceTier = strings.TrimSpace(*result.ServiceTier) @@ -231,6 +233,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec tokens, serviceTier, longContextBillingGate, + pricingAt, ) if err != nil { if !isUsagePricingUnavailableError(err) { @@ -260,10 +263,10 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec result.AudioUsage != nil || result.SearchCount > 0, ); responseModel != "" && !strings.EqualFold(responseModel, baselineBillingModel) { if identified, responseChannelPriced := s.hasIdentifiedOpenAIResponsePricing(ctx, responseModel, apiKey); identified { - responseModels := usageBillingModelCandidates(responseModel) + responseModels := s.filterCNProviderBillingModelCandidates(ctx, account, apiKey, usageBillingModelCandidates(responseModel)) responseCost, responseErr := s.calculateOpenAIRecordUsageCost( ctx, result, apiKey, responseModels, multiplier, imageMultiplier, - videoMultiplier, baseMultiplier, tokens, serviceTier, longContextBillingGate, + videoMultiplier, baseMultiplier, tokens, serviceTier, longContextBillingGate, pricingAt, ) // 基线定价源以 baselineBillingModel 为准:它正是 calculateOpenAIRecordUsageCost // 内部做渠道定价判断时使用的模型,且"首候选有渠道价"必然意味着首候选就是实际 @@ -516,6 +519,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( tokens UsageTokens, serviceTier string, longContextBillingGate *bool, + pricingAt time.Time, ) (*CostBreakdown, error) { billingModel := firstUsageBillingModel(billingModels) if result != nil && result.WebSearchCalls > 0 { @@ -565,6 +569,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( apiKey, candidate, multiplier, + pricingAt, tokens, serviceTier, longContextBillingGate, @@ -596,7 +601,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( if tokenCost == nil { if tokenBillingAttempted { if lastErr == nil { - lastErr = errors.New("no non-empty billing model candidates") + lastErr = fmt.Errorf("%w: no non-empty billing model candidates", ErrModelPricingUnavailable) } return nil, fmt.Errorf("calculate OpenAI usage cost failed for billing models %s: %w", strings.Join(billingModels, ","), lastErr) } @@ -604,8 +609,11 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( if searchCost != nil { return searchCost, nil } + // 空候选按「无价可循」处理并携带 ErrModelPricingUnavailable:上层据此走 + // 零成本+告警落账,而不是丢弃整条 usage 记录。CN 账号的 claude-* 候选被 + // filterCNProviderBillingModelCandidates 全数过滤后即落到这里。 if lastErr == nil { - lastErr = errors.New("openai usage billing model is empty") + lastErr = fmt.Errorf("%w: openai usage billing model is empty", ErrModelPricingUnavailable) } return nil, fmt.Errorf("calculate OpenAI usage cost failed for billing models %s: %w", strings.Join(billingModels, ","), lastErr) } @@ -654,6 +662,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( apiKey *APIKey, billingModel string, multiplier float64, + pricingAt time.Time, tokens UsageTokens, serviceTier string, longContextBillingGate *bool, @@ -662,7 +671,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( gid := apiKey.Group.ID return s.billingService.CalculateCostUnified(CostInput{ Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, - Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, + Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, PricingAt: pricingAt, ServiceTier: serviceTier, Resolver: s.resolver, LongContextBillingEnabled: longContextBillingGate, }) @@ -852,6 +861,36 @@ func groupMediaPricingLooksIncomplete(group *Group) bool { group.VideoPrice480P == nil && group.VideoPrice720P == nil && group.VideoPrice1080P == nil } +// filterCNProviderBillingModelCandidates 过滤国产供应商(kimi/zhipu/deepseek) +// 账号的计费候选模型名:claude-* 候选仅在运营者显式配置了分组/渠道定价时保留。 +// +// 背景:候选链的兜底候选含客户端请求的原始模型名。CN 上游的 Anthropic 兼容端点 +// 接受 claude-* 模型名但从不真正服务 Claude 模型;若放行,目录里的 Claude 价卡 +// 与 getFallbackPricing 的 "claude"→Sonnet 统一兜底会把 CN 流量按 Claude 原价 +// (数倍~数十倍)静默误计,且 usage 日志显示的正是 claude-* 名,无从察觉。 +// 候选全部落空时走既有的零成本+告警路径(openai_usage.pricing_missing_record_ +// zero_cost),与定价层「未知型号不回退以避免误计价」的既有设计意图一致; +// 运营者的修复手段是配置账号级 model_mapping(映射到已定价的 CN 模型)或 +// 分组/渠道显式定价。 +func (s *OpenAIGatewayService) filterCNProviderBillingModelCandidates(ctx context.Context, account *Account, apiKey *APIKey, candidates []string) []string { + if account == nil || !account.IsCNProvider() { + return candidates + } + out := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + trimmed := strings.TrimSpace(candidate) + if trimmed == "" { + continue + } + if strings.Contains(strings.ToLower(trimmed), "claude") && + s.resolveOpenAIChannelPricing(ctx, trimmed, apiKey) == nil { + continue + } + out = append(out, candidate) + } + return out +} + func (s *OpenAIGatewayService) resolveOpenAIChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing { if s.resolver == nil || apiKey == nil || apiKey.Group == nil { return nil diff --git a/backend/internal/service/openai_messages_dispatch.go b/backend/internal/service/openai_messages_dispatch.go index aedfb1b3f7f5..562e9583c51f 100644 --- a/backend/internal/service/openai_messages_dispatch.go +++ b/backend/internal/service/openai_messages_dispatch.go @@ -79,6 +79,13 @@ func (g *Group) ResolveMessagesDispatchModel(requestedModel string) string { return xai.ModelMappingWithOptions(opts)["claude-*"] } + // 国产供应商分组:调度级模型映射不适用(其配置被 sanitize 置空,且下方的 + // gpt-5.x 默认值是 openai 专属,发给 CN 上游必错)。模型改写完全交给账号级 + // model_mapping;anthropic 协议上游本身接受 claude-* 模型名。 + if IsCNProvider(g.Platform) { + return "" + } + cfg := normalizeOpenAIMessagesDispatchModelConfig(g.MessagesDispatchModelConfig) if mappedModel := strings.TrimSpace(cfg.ExactModelMappings[requestedModel]); mappedModel != "" { return mappedModel diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index d2c030c5c517..c7dc77b3aa31 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -1948,6 +1948,212 @@ func TestOpenAIGatewayService_OAuthPassthrough_CodexTuiIdentityUnified(t *testin require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("version")) } +func TestOpenAIGatewayService_CodexFingerprintHTTPTransformedHeaderBodyParityAndDefaultCacheKey(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set("originator", "codex_cli_rs") + c.Request.Header.Set("session-id", "header-session") + c.Request.Header.Set("x-codex-turn-metadata", `{"installation_id":"header-install","session_id":"header-session","thread_id":"header-thread","turn_id":"header-turn","window_id":"header-window","sandbox":"seatbelt"}`) + + body := []byte(`{"model":"gpt-5.2","stream":false,"prompt_cache_key":"body-session","client_metadata":{"session_id":"body-session","x-codex-turn-metadata":"{\"installation_id\":\"body-install\",\"session_id\":\"body-session\",\"thread_id\":\"body-thread\",\"turn_id\":\"body-turn\",\"window_id\":\"body-window\",\"sandbox\":\"seatbelt\"}"},"input":[{"type":"message","role":"user","content":"hi"}]}`) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid"}}, + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + toolCorrector: NewCodexToolCorrector(), + } + account := newTestOAuthAccount(4401, map[string]any{codexFingerprintModeExtraKey: "session"}) + account.Name = "oauth-transformed" + account.Status = StatusActive + account.Schedulable = true + account.Concurrency = 1 + account.Credentials = map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"} + + _, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, upstream.lastReq) + + seed, ok := codexFingerprintSeed(account.Extra) + require.True(t, ok) + wantInstall := resolveConvergedInstallationID(account, seed) + wantSession := resolveConvergedSessionID(seed) + wantThread := resolveConvergedThreadID(seed, "header-session") + + require.Equal(t, wantInstall, upstream.lastReq.Header.Get("x-codex-installation-id")) + require.Equal(t, wantSession, upstream.lastReq.Header.Get("session-id")) + require.Equal(t, wantSession, upstream.lastReq.Header.Get("session_id")) + require.Equal(t, wantThread, upstream.lastReq.Header.Get("thread-id")) + require.Equal(t, wantThread, upstream.lastReq.Header.Get("x-client-request-id")) + require.Equal(t, wantThread+":0", upstream.lastReq.Header.Get("x-codex-window-id")) + + require.Equal(t, wantSession, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) + require.Equal(t, wantInstall, gjson.GetBytes(upstream.lastBody, "client_metadata.x-codex-installation-id").String()) + require.Equal(t, wantSession, gjson.GetBytes(upstream.lastBody, "client_metadata.session_id").String()) + require.Equal(t, wantThread, gjson.GetBytes(upstream.lastBody, "client_metadata.thread_id").String()) + require.Equal(t, wantThread+":0", gjson.GetBytes(upstream.lastBody, "client_metadata.x-codex-window-id").String()) + + bodyTurnMetadata := gjson.GetBytes(upstream.lastBody, "client_metadata.x-codex-turn-metadata").String() + headerTurnMetadata := upstream.lastReq.Header.Get("x-codex-turn-metadata") + require.Equal(t, wantSession, gjson.Get(bodyTurnMetadata, "session_id").String()) + require.Equal(t, wantSession, gjson.Get(headerTurnMetadata, "session_id").String()) + require.Equal(t, gjson.Get(bodyTurnMetadata, "turn_id").String(), gjson.Get(headerTurnMetadata, "turn_id").String()) +} + +func TestOpenAIGatewayService_CodexFingerprintHTTPRawPassthroughHeaderBodyParityAndDefaultCacheKey(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set("originator", "codex_cli_rs") + c.Request.Header.Set("session-id", "header-session") + c.Request.Header.Set("x-codex-turn-metadata", `{"installation_id":"header-install","session_id":"header-session","thread_id":"header-thread","turn_id":"header-turn","window_id":"header-window","sandbox":"seatbelt"}`) + + body := []byte(`{"model":"gpt-5.6-sol","stream":false,"prompt_cache_key":"body-session","client_metadata":{"session_id":"body-session","x-codex-turn-metadata":"{\"installation_id\":\"body-install\",\"session_id\":\"body-session\",\"thread_id\":\"body-thread\",\"turn_id\":\"body-turn\",\"window_id\":\"body-window\",\"sandbox\":\"seatbelt\"}"},"input":[{"type":"message","role":"user","content":"hi"}]}`) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid"}}, + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + } + account := newTestOAuthAccount(4402, map[string]any{ + codexFingerprintModeExtraKey: "session", + "openai_oauth_passthrough": true, + }) + account.Name = "oauth-raw" + account.Status = StatusActive + account.Schedulable = true + account.Concurrency = 1 + account.Credentials = map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"} + + _, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, upstream.lastReq) + + seed, ok := codexFingerprintSeed(account.Extra) + require.True(t, ok) + wantInstall := resolveConvergedInstallationID(account, seed) + wantSession := resolveConvergedSessionID(seed) + wantThread := resolveConvergedThreadID(seed, "header-session") + + require.Equal(t, wantInstall, upstream.lastReq.Header.Get("x-codex-installation-id")) + require.Equal(t, wantSession, upstream.lastReq.Header.Get("session-id")) + require.Equal(t, wantSession, upstream.lastReq.Header.Get("session_id")) + require.Equal(t, wantThread, upstream.lastReq.Header.Get("thread-id")) + require.Equal(t, wantThread, upstream.lastReq.Header.Get("x-client-request-id")) + require.Equal(t, wantThread+":0", upstream.lastReq.Header.Get("x-codex-window-id")) + + require.Equal(t, wantSession, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) + require.Equal(t, wantInstall, gjson.GetBytes(upstream.lastBody, "client_metadata.x-codex-installation-id").String()) + require.Equal(t, wantSession, gjson.GetBytes(upstream.lastBody, "client_metadata.session_id").String()) + require.Equal(t, wantThread, gjson.GetBytes(upstream.lastBody, "client_metadata.thread_id").String()) + require.Equal(t, wantThread+":0", gjson.GetBytes(upstream.lastBody, "client_metadata.x-codex-window-id").String()) + + bodyTurnMetadata := gjson.GetBytes(upstream.lastBody, "client_metadata.x-codex-turn-metadata").String() + headerTurnMetadata := upstream.lastReq.Header.Get("x-codex-turn-metadata") + require.Equal(t, wantSession, gjson.Get(bodyTurnMetadata, "session_id").String()) + require.Equal(t, wantSession, gjson.Get(headerTurnMetadata, "session_id").String()) + require.Equal(t, gjson.Get(bodyTurnMetadata, "turn_id").String(), gjson.Get(headerTurnMetadata, "turn_id").String()) +} + +func TestOpenAIGatewayService_CodexFingerprintCompactDoesNotRewriteBodyCacheKeyOrMetadata(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set("originator", "codex_cli_rs") + c.Request.Header.Set("session-id", "header-session") + + body := []byte(`{"model":"gpt-5.4","stream":false,"prompt_cache_key":"body-session","client_metadata":{"session_id":"body-session"},"input":[{"type":"message","role":"user","content":"compress"}]}`) + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid"}}, + Body: io.NopCloser(strings.NewReader(compactProbeSSESuccessBody)), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + toolCorrector: NewCodexToolCorrector(), + } + account := newTestOAuthAccount(4403, map[string]any{codexFingerprintModeExtraKey: "session"}) + account.Name = "oauth-compact" + account.Status = StatusActive + account.Schedulable = true + account.Concurrency = 1 + account.Credentials = map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"} + staleIDs := resolveCodexFingerprintIDs(account, "stale-session", codexFingerprintSession) + require.NotNil(t, staleIDs) + stageCodexFingerprintIDs(c, staleIDs) + + _, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, upstream.lastReq) + + seed, ok := codexFingerprintSeed(account.Extra) + require.True(t, ok) + require.NotEqual(t, resolveConvergedSessionID(seed), gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) + require.Equal(t, "body-session", gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) + require.Equal(t, "body-session", gjson.GetBytes(upstream.lastBody, "client_metadata.session_id").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "client_metadata.x-codex-installation-id").Exists()) + require.Empty(t, upstream.lastReq.Header.Get("x-codex-window-id")) +} + +func TestOpenAIGatewayService_CodexFingerprintMessagesBridgeDoesNotInjectBodyPromptCacheKey(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set("originator", "codex_cli_rs") + c.Request.Header.Set("session-id", "header-session") + + body := []byte(`{"model":"gpt-5.5","stream":true,"prompt_cache_key":"anthropic-metadata-session-1","client_metadata":{"session_id":"anthropic-metadata-session-1"},"input":[{"type":"message","role":"developer","content":[{"type":"input_text","text":"` + openAICompatClaudeCodeTodoGuardMarker + `"}]},{"type":"message","role":"user","content":"hello"}]}`) + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid"}}, + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + toolCorrector: NewCodexToolCorrector(), + } + account := newTestOAuthAccount(4404, map[string]any{codexFingerprintModeExtraKey: "session"}) + account.Name = "oauth-messages-bridge" + account.Status = StatusActive + account.Schedulable = true + account.Concurrency = 1 + account.Credentials = map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"} + + _, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, upstream.lastReq) + + seed, ok := codexFingerprintSeed(account.Extra) + require.True(t, ok) + wantSession := resolveConvergedSessionID(seed) + require.False(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").Exists()) + require.Equal(t, wantSession, gjson.GetBytes(upstream.lastBody, "client_metadata.session_id").String()) + require.Equal(t, wantSession, upstream.lastReq.Header.Get("session_id")) +} + func TestOpenAIGatewayService_CodexCLIOnly_RejectsNonCodexClient(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_team_linked_error.go b/backend/internal/service/openai_team_linked_error.go new file mode 100644 index 000000000000..2225e59a5b5b --- /dev/null +++ b/backend/internal/service/openai_team_linked_error.go @@ -0,0 +1,99 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/tidwall/gjson" +) + +const ( + openAITeamLinkedErrorDedupTTL = 60 * time.Second + openAITeamLinkedErrorFanoutTimeout = 30 * time.Second + openAITeamLinkedErrorBlockReason = "team_linked_error" +) + +// maybeHandleOpenAITeamLinkedError 在 OpenAI OAuth 账户收到 402 deactivated_workspace +// (ChatGPT Team 工作区被停用)时,把同一 Team(credentials.chatgpt_account_id 相同) +// 的其余 active 账户一并置为 error 并立即熔断。触发账户自身不在 fan-out 范围内, +// 仍由常规 402 处理标记。 +func (s *RateLimitService) maybeHandleOpenAITeamLinkedError(ctx context.Context, account *Account, statusCode int, responseBody []byte) { + if s == nil || s.accountRepo == nil || statusCode != http.StatusPaymentRequired || !isOpenAIOAuthAccount(account) { + return + } + if gjson.GetBytes(responseBody, "detail.code").String() != "deactivated_workspace" { + return + } + teamID := strings.TrimSpace(account.GetChatGPTAccountID()) + if teamID == "" { + return + } + if !s.markOpenAITeamLinkedFired(teamID) { + return + } + // 上游报错场景请求 ctx 往往已被取消,落库需要独立生命周期。 + opCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAITeamLinkedErrorFanoutTimeout) + defer cancel() + + accounts, err := s.accountRepo.ListByPlatform(opCtx, PlatformOpenAI) + if err != nil { + slog.Warn("openai_team_linked_error_list_failed", "trigger_account_id", account.ID, "error", err) + return + } + var targets []*Account + for i := range accounts { + acc := &accounts[i] + if acc.ID == account.ID || acc.IsShadow() || strings.TrimSpace(acc.GetChatGPTAccountID()) != teamID { + continue + } + targets = append(targets, acc) + } + if len(targets) == 0 { + return + } + // 先全部进程内熔断(微秒级生效),再逐个落库,避免后面的账户等待前面的 DB 写入。 + for _, acc := range targets { + s.notifyAccountSchedulingBlocked(acc, time.Time{}, openAITeamLinkedErrorBlockReason) + } + errorMsg := fmt.Sprintf("Workspace deactivated (402): team-linked error triggered by account #%d", account.ID) + marked := 0 + for _, acc := range targets { + // 单账户写入失败不中断其余账户;进程内熔断已先行,且该账户仍为 active, + // 下一个 402 在去重 TTL 过期后会重新触发 fan-out。 + if err := s.accountRepo.SetError(opCtx, acc.ID, errorMsg); err != nil { + slog.Warn("openai_team_linked_error_set_error_failed", "account_id", acc.ID, "error", err) + continue + } + marked++ + } + slog.Warn("openai_team_linked_error_fanout", + "trigger_account_id", account.ID, + "chatgpt_account_id", teamID, + "affected", marked, + "targets", len(targets), + ) +} + +// markOpenAITeamLinkedFired 以 teamID 为键做进程内去重:TTL 内同一 Team 只允许一次 fan-out。 +func (s *RateLimitService) markOpenAITeamLinkedFired(teamID string) bool { + now := time.Now() + s.openaiTeamLinkedMu.Lock() + defer s.openaiTeamLinkedMu.Unlock() + if expiry, ok := s.openaiTeamLinkedRecent[teamID]; ok && expiry.After(now) { + return false + } + if s.openaiTeamLinkedRecent == nil { + s.openaiTeamLinkedRecent = make(map[string]time.Time) + } + for k, v := range s.openaiTeamLinkedRecent { + if !v.After(now) { + delete(s.openaiTeamLinkedRecent, k) + } + } + s.openaiTeamLinkedRecent[teamID] = now.Add(openAITeamLinkedErrorDedupTTL) + return true +} diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go index 51898060acae..1928c1d6e4a8 100644 --- a/backend/internal/service/openai_ws_forwarder.go +++ b/backend/internal/service/openai_ws_forwarder.go @@ -215,10 +215,13 @@ type OpenAIWSIngressHooks struct { // before channel or account mapping. Ingress modes preserve it for usage // attribution while MapRequestModel determines the upstream model. InitialRequestModel string + // InitialTurnStartedAt freezes when the first response.create was accepted. + InitialTurnStartedAt time.Time // MaxReasoningEffort limits explicit reasoning effort values for this WS session. MaxReasoningEffort string // ReasoningEffortMappings rewrites explicit effort values for this WS session. ReasoningEffortMappings []ReasoningEffortMapping + TurnStarted func(turn int, startedAt time.Time) BeforeTurn func(turn int) error BeforeRequest func(turn int, payload []byte, originalModel string) error // MapRequestModel resolves the current turn's client model to the model diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index ce7e8f038666..bdd59f5e1cb1 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -92,11 +92,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( if wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 { return fmt.Errorf("websocket ingress requires ws_v2 transport, got=%s", wsDecision.Transport) } - // 注意:透传 relay 只回调 hooks.AfterTurn,没有 turn 起始回调, - // 因此下面这条路径永远不会触发 hooks.BeforeTurn——分组利润控制的 - // turn 级复核与 turn 级 pricingAt 冻结都不覆盖透传 ingress, - // 只有建连时的准入门生效。handler 侧据此把 turn 定价留作零值, - // 由 RecordUsage 回退到记录时刻(见 openAIWSTurnPricing 注释)。 + // 透传 relay 通过 TurnStarted 记录每个 turn 的开始时刻,但不触发 + // BeforeTurn;因此仍只有建连时的利润准入门,没有 turn 级复核。 + // handler 计费在 turn 定价未冻结时回退到对应的 turn 开始时刻。 return s.proxyResponsesWebSocketV2Passthrough( ctx, c, diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go index 4c02cb30f186..7eabe7d31f1a 100644 --- a/backend/internal/service/openai_ws_forwarder_payload.go +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -42,7 +42,7 @@ func (s *OpenAIGatewayService) buildOpenAIResponsesWSURL(account *Account) (stri if err != nil { return "", err } - targetURL = buildOpenAIResponsesURL(validatedURL) + targetURL = buildOpenAIResponsesURLForPlatform(account.Platform, validatedURL) } default: targetURL = openaiPlatformAPIURL @@ -93,7 +93,13 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( headers.Add("x-codex-beta-features", value) } } - for _, name := range [...]string{"x-codex-window-id", "x-codex-installation-id"} { + for _, name := range [...]string{ + "x-codex-window-id", + "x-codex-installation-id", + "session-id", + "thread-id", + "x-client-request-id", + } { if value := c.Request.Header.Get(name); strings.TrimSpace(value) != "" { headers.Set(name, value) } @@ -128,6 +134,7 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( if metadata := strings.TrimSpace(turnMetadata); metadata != "" { headers.Set(openAIWSTurnMetadataHeader, metadata) } + applyStagedCodexFingerprintHeaders(c, account, headers) if account != nil && account.Type == AccountTypeOAuth { if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, headers, account); err != nil { @@ -154,7 +161,7 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( } } if s != nil && s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { - headers.Set("user-agent", codexCLIUserAgent) + headers.Set("user-agent", CodexCanonicalUserAgent()) } // 终态收口:WS 握手与 HTTP 出站共用同一套身份语义,账号级自定义 UA 同样作为 // 管理员显式配置传入(上面写进 headers 的值只在强制统一被关闭时才参与配对)。 diff --git a/backend/internal/service/openai_ws_forwarder_success_test.go b/backend/internal/service/openai_ws_forwarder_success_test.go index d8270dfe4009..2c6977638297 100644 --- a/backend/internal/service/openai_ws_forwarder_success_test.go +++ b/backend/internal/service/openai_ws_forwarder_success_test.go @@ -400,6 +400,9 @@ func TestOpenAIGatewayService_BuildOpenAIWSHeadersPreservesCodexIdentity(t *test c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") c.Request.Header.Set("X-Codex-Window-ID", "window-ws") c.Request.Header.Set("X-Codex-Installation-ID", "installation-ws") + c.Request.Header.Set("session-id", "session-ws") + c.Request.Header.Set("thread-id", "thread-ws") + c.Request.Header.Set("x-client-request-id", "client-request-ws") c.Request.Header.Set("X-Test", "blocked") svc := &OpenAIGatewayService{} @@ -421,9 +424,53 @@ func TestOpenAIGatewayService_BuildOpenAIWSHeadersPreservesCodexIdentity(t *test require.NoError(t, err) require.Equal(t, "window-ws", headers.Get("X-Codex-Window-ID")) require.Equal(t, "installation-ws", headers.Get("X-Codex-Installation-ID")) + require.Equal(t, "session-ws", headers.Get("session-id")) + require.Equal(t, "thread-ws", headers.Get("thread-id")) + require.Equal(t, "client-request-ws", headers.Get("x-client-request-id")) require.Empty(t, headers.Get("X-Test")) } +func TestOpenAIGatewayService_BuildOpenAIWSHeadersDeviceModePreservesClientSessionIdentity(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set("X-Codex-Installation-ID", "client-installation") + c.Request.Header.Set("X-Codex-Window-ID", "client-window") + c.Request.Header.Set("session-id", "client-session") + c.Request.Header.Set("thread-id", "client-thread") + c.Request.Header.Set("x-client-request-id", "client-request") + + account := newTestOAuthAccount(1300, map[string]any{codexFingerprintModeExtraKey: "device"}) + ids := resolveCodexFingerprintIDsFromRequest(account, c.Request.Header) + require.NotNil(t, ids) + stageCodexFingerprintIDs(c, ids) + + svc := &OpenAIGatewayService{} + headers, _, err := svc.buildOpenAIWSHeaders( + context.Background(), + c, + account, + "token", + OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2}, + true, + "", + "", + "", + "", + "", + ) + + require.NoError(t, err) + require.Equal(t, ids.installationID, headers.Get("x-codex-installation-id")) + require.NotEqual(t, "client-installation", headers.Get("x-codex-installation-id")) + require.Equal(t, "client-window", headers.Get("x-codex-window-id")) + require.Equal(t, "client-session", headers.Get("session-id")) + require.Equal(t, "client-thread", headers.Get("thread-id")) + require.Equal(t, "client-request", headers.Get("x-client-request-id")) +} + func TestLogOpenAIWSBindResponseAccountWarn(t *testing.T) { require.NotPanics(t, func() { logOpenAIWSBindResponseAccountWarn(1, 2, "resp_ok", nil) @@ -983,6 +1030,96 @@ func TestOpenAIGatewayService_Forward_WSv2_HeaderSessionFallbackFromPromptCacheK require.True(t, gjson.Get(requestToJSONString(captureConn.lastWrite), "stream").Exists()) } +func TestOpenAIGatewayService_Forward_WSv2_CodexFingerprintHandshakeBodyParityAndDefaultCacheKey(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set("originator", "codex_cli_rs") + c.Request.Header.Set("session-id", "header-session") + c.Request.Header.Set("x-codex-turn-metadata", `{"installation_id":"header-install","session_id":"header-session","thread_id":"header-thread","turn_id":"header-turn","window_id":"header-window","sandbox":"seatbelt"}`) + + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.OAuthEnabled = true + cfg.Gateway.OpenAIWS.APIKeyEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1 + + captureConn := &openAIWSCaptureConn{ + events: [][]byte{ + []byte(`{"type":"response.completed","response":{"id":"resp_ws_fingerprint","model":"gpt-5.2","usage":{"input_tokens":2,"output_tokens":1}}}`), + }, + } + captureDialer := &openAIWSCaptureDialer{conn: captureConn} + pool := newOpenAIWSConnPool(cfg) + pool.setClientDialerForTest(captureDialer) + + svc := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: &httpUpstreamRecorder{}, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + openaiWSPool: pool, + } + account := newTestOAuthAccount(4405, map[string]any{ + codexFingerprintModeExtraKey: "session", + "responses_websockets_v2_enabled": true, + }) + account.Name = "oauth-ws-fingerprint" + account.Status = StatusActive + account.Schedulable = true + account.Concurrency = 1 + account.Credentials = map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"} + + body := []byte(`{"model":"gpt-5.2","stream":true,"prompt_cache_key":"body-session","client_metadata":{"session_id":"body-session","x-codex-turn-metadata":"{\"installation_id\":\"body-install\",\"session_id\":\"body-session\",\"thread_id\":\"body-thread\",\"turn_id\":\"body-turn\",\"window_id\":\"body-window\",\"sandbox\":\"seatbelt\"}"},"input":[{"type":"input_text","text":"hi"}]}`) + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "resp_ws_fingerprint", result.RequestID) + require.NotNil(t, captureConn.lastWrite) + + seed, ok := codexFingerprintSeed(account.Extra) + require.True(t, ok) + wantInstall := resolveConvergedInstallationID(account, seed) + wantSession := resolveConvergedSessionID(seed) + wantThread := resolveConvergedThreadID(seed, "header-session") + payloadJSON := requestToJSONString(captureConn.lastWrite) + + require.Equal(t, wantInstall, captureDialer.lastHeaders.Get("x-codex-installation-id")) + require.Equal(t, wantSession, captureDialer.lastHeaders.Get("session-id")) + require.Equal(t, wantSession, captureDialer.lastHeaders.Get("session_id")) + require.Equal(t, wantThread, captureDialer.lastHeaders.Get("thread-id")) + require.Equal(t, wantThread, captureDialer.lastHeaders.Get("x-client-request-id")) + require.Equal(t, wantThread+":0", captureDialer.lastHeaders.Get("x-codex-window-id")) + + require.Equal(t, wantSession, gjson.Get(payloadJSON, "prompt_cache_key").String()) + require.Equal(t, wantInstall, gjson.Get(payloadJSON, "client_metadata.x-codex-installation-id").String()) + require.Equal(t, wantSession, gjson.Get(payloadJSON, "client_metadata.session_id").String()) + require.Equal(t, wantThread, gjson.Get(payloadJSON, "client_metadata.thread_id").String()) + require.Equal(t, wantThread+":0", gjson.Get(payloadJSON, "client_metadata.x-codex-window-id").String()) + + bodyTurnMetadata := gjson.Get(payloadJSON, "client_metadata.x-codex-turn-metadata").String() + headerTurnMetadata := captureDialer.lastHeaders.Get("x-codex-turn-metadata") + require.Equal(t, wantInstall, gjson.Get(bodyTurnMetadata, "installation_id").String()) + require.Equal(t, wantSession, gjson.Get(bodyTurnMetadata, "session_id").String()) + require.Equal(t, wantThread, gjson.Get(bodyTurnMetadata, "thread_id").String()) + require.Equal(t, wantSession, gjson.Get(headerTurnMetadata, "session_id").String()) + require.Equal(t, gjson.Get(bodyTurnMetadata, "turn_id").String(), gjson.Get(headerTurnMetadata, "turn_id").String()) + require.NotZero(t, gjson.Get(bodyTurnMetadata, "turn_started_at_unix_ms").Int()) + require.Equal(t, + gjson.Get(bodyTurnMetadata, "turn_started_at_unix_ms").Int(), + gjson.Get(headerTurnMetadata, "turn_started_at_unix_ms").Int(), + ) +} + func TestOpenAIGatewayService_Forward_WSv2_ResponseDoneUsageParsed(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index e0dbd58bd4f5..020d409d0b9c 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -62,6 +62,14 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( payload := s.buildOpenAIWSCreatePayload(reqBody, account) payloadStrategy, removedKeys := applyOpenAIWSRetryPayloadStrategy(payload, attempt) + turnState := "" + turnMetadata := "" + if c != nil && c.Request != nil { + turnState = strings.TrimSpace(c.GetHeader(openAIWSTurnStateHeader)) + turnMetadata = strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)) + } + setOpenAIWSTurnMetadata(payload, turnMetadata) + applyStagedCodexFingerprintClientMetadata(c, account, payload) previousResponseID := openAIWSPayloadString(payload, "previous_response_id") previousResponseIDKind := ClassifyOpenAIPreviousResponseIDKind(previousResponseID) promptCacheKey := openAIWSPayloadString(payload, "prompt_cache_key") @@ -79,13 +87,6 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( if raw, ok := payload["stream"]; ok { streamValue = normalizeOpenAIWSLogValue(strings.TrimSpace(fmt.Sprintf("%v", raw))) } - turnState := "" - turnMetadata := "" - if c != nil && c.Request != nil { - turnState = strings.TrimSpace(c.GetHeader(openAIWSTurnStateHeader)) - turnMetadata = strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)) - } - setOpenAIWSTurnMetadata(payload, turnMetadata) payloadEventType := openAIWSPayloadString(payload, "type") if payloadEventType == "" { payloadEventType = "response.create" diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 87abf1ef04f7..98eee9c7db0c 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -12,6 +12,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" ) @@ -186,6 +187,13 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( if err != nil { return nil, fmt.Errorf("prepare http bridge body: %w", err) } + var clientToolMapping apicompat.ResponsesClientToolMapping + if account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey { + body, clientToolMapping, err = adaptResponsesClientToolsForFunctionUpstream(body, "OpenAI WS HTTP bridge") + if err != nil { + return nil, fmt.Errorf("adapt OpenAI WS HTTP bridge client tools: %w", err) + } + } upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) var upstreamReq *http.Request @@ -329,11 +337,14 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( return result } - scanner := bufio.NewScanner(resp.Body) maxLineSize := defaultMaxLineSize if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { maxLineSize = s.cfg.Gateway.MaxLineSize } + if hasResponsesClientToolMapping(clientToolMapping) { + resp.Body = newResponsesClientToolStreamBody(resp.Body, clientToolMapping, maxLineSize) + } + scanner := bufio.NewScanner(resp.Body) scanBuf := getSSEScannerBuf64K() scanner.Buffer(scanBuf[:0], maxLineSize) defer putSSEScannerBuf64K(scanBuf) diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 1e352f012efb..54786723166b 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -41,6 +41,135 @@ func TestPrepareOpenAIWSHTTPBridgeBodyStripsWSFields(t *testing.T) { require.Equal(t, "hi", gjson.GetBytes(body, "input").String()) } +func TestProxyOpenAIWSHTTPBridgeTurnAPIKeyAdaptsClientTools(t *testing.T) { + gin.SetMode(gin.TestMode) + + sse := strings.Join([]string{ + `data: {"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"type":"function_call","id":"item_exec","call_id":"call_exec","name":"exec","status":"in_progress"}}`, + ``, + `data: {"type":"response.function_call_arguments.done","sequence_number":1,"output_index":0,"item_id":"item_exec","call_id":"call_exec","name":"exec","arguments":"{\"input\":\"pwd\"}"}`, + ``, + `data: {"type":"response.output_item.done","sequence_number":2,"output_index":0,"item":{"type":"function_call","id":"item_exec","call_id":"call_exec","name":"exec","arguments":"{\"input\":\"pwd\"}","status":"completed"}}`, + ``, + `data: {"type":"response.completed","sequence_number":3,"response":{"id":"resp_tools","status":"completed","output":[{"type":"function_call","id":"item_exec","call_id":"call_exec","name":"exec","arguments":"{\"input\":\"pwd\"}","status":"completed"}],"usage":{"input_tokens":1,"output_tokens":1}}}`, + ``, + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(sse)), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}, + httpUpstream: upstream, + } + account := &Account{ID: 5659, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1} + payload := []byte(`{ + "type":"response.create","model":"gpt-5","stream":true, + "tools":[{"type":"custom","name":"exec","description":"Run a command"}], + "input":[ + {"type":"custom_tool_call","id":"previous_item","call_id":"previous_call","name":"exec","input":"echo ready"}, + {"type":"custom_tool_call_output","call_id":"previous_call","output":"ready"} + ] + }`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + var events [][]byte + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "test-token", payload, len(payload), + "gpt-5", "", "", "", "", 2, + func(message []byte) error { + events = append(events, append([]byte(nil), message...)) + return nil + }, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "function", gjson.GetBytes(upstream.lastBody, "tools.0.type").String()) + require.Equal(t, "function_call", gjson.GetBytes(upstream.lastBody, "input.0.type").String()) + require.JSONEq(t, `{"input":"echo ready"}`, gjson.GetBytes(upstream.lastBody, "input.0.arguments").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input.0.input").Exists()) + require.Equal(t, "function_call_output", gjson.GetBytes(upstream.lastBody, "input.1.type").String()) + + var outputDone, completed []byte + for _, event := range events { + switch gjson.GetBytes(event, "type").String() { + case "response.output_item.done": + outputDone = event + case "response.completed": + completed = event + } + } + require.NotEmpty(t, outputDone) + require.Equal(t, "custom_tool_call", gjson.GetBytes(outputDone, "item.type").String()) + require.Equal(t, "pwd", gjson.GetBytes(outputDone, "item.input").String()) + require.False(t, gjson.GetBytes(outputDone, "item.arguments").Exists()) + require.NotEmpty(t, completed) + require.Equal(t, "custom_tool_call", gjson.GetBytes(completed, "response.output.0.type").String()) + require.Equal(t, "pwd", gjson.GetBytes(completed, "response.output.0.input").String()) + require.True(t, result.wsReplayInputExists) + require.Len(t, result.wsReplayInput, 1) + require.Equal(t, "custom_tool_call", gjson.GetBytes(result.wsReplayInput[0], "type").String()) + require.Equal(t, "pwd", gjson.GetBytes(result.wsReplayInput[0], "input").String()) +} + +func TestProxyOpenAIWSHTTPBridgeTurnAPIKeyRestoresClientToolsInResponseDone(t *testing.T) { + gin.SetMode(gin.TestMode) + + sse := strings.Join([]string{ + `data: {"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"type":"function_call","id":"item_exec","call_id":"call_exec","name":"exec","status":"in_progress"}}`, + ``, + `data: {"type":"response.function_call_arguments.done","sequence_number":1,"output_index":0,"item_id":"item_exec","call_id":"call_exec","name":"exec","arguments":"{\"input\":\"pwd\"}"}`, + ``, + `data: {"type":"response.done","sequence_number":2,"response":{"id":"resp_tools","status":"completed","output":[{"type":"function_call","id":"item_exec","call_id":"call_exec","name":"exec","arguments":"{\"input\":\"pwd\"}","status":"completed"}],"usage":{"input_tokens":1,"output_tokens":1}}}`, + ``, + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(sse)), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}, + httpUpstream: upstream, + } + account := &Account{ID: 5764, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1} + payload := []byte(`{ + "type":"response.create","model":"gpt-5","stream":true, + "tools":[{"type":"custom","name":"exec","description":"Run a command"}], + "input":"run pwd" + }`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + var events [][]byte + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "test-token", payload, len(payload), + "gpt-5", "", "", "", "", 1, + func(message []byte) error { + events = append(events, append([]byte(nil), message...)) + return nil + }, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, events, 4) + terminal := events[len(events)-1] + require.Equal(t, "response.done", gjson.GetBytes(terminal, "type").String()) + require.Equal(t, int64(3), gjson.GetBytes(terminal, "sequence_number").Int()) + require.Equal(t, "custom_tool_call", gjson.GetBytes(terminal, "response.output.0.type").String()) + require.Equal(t, "pwd", gjson.GetBytes(terminal, "response.output.0.input").String()) + require.False(t, gjson.GetBytes(terminal, "response.output.0.arguments").Exists()) + require.True(t, result.wsReplayInputExists) + require.Len(t, result.wsReplayInput, 1) + require.Equal(t, "custom_tool_call", gjson.GetBytes(result.wsReplayInput[0], "type").String()) +} + func TestOpenAIWSHTTPBridgeDecisionKeepsSmallFramesOnWS(t *testing.T) { svc := &OpenAIGatewayService{ cfg: &config.Config{ diff --git a/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go b/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go index bb6e5ebde604..16be2bd4c62d 100644 --- a/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go +++ b/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go @@ -60,18 +60,13 @@ func startPassthroughHookRecordingServer( return server, serverErr } -// TestPassthroughIngressNeverCallsBeforeTurn 钉死 ws_v2 透传 ingress 与 handler -// 侧 turn 定价的耦合:透传 relay 只回调 AfterTurn,没有任何 turn 起始回调, -// 因此 hooks.BeforeTurn 永远不会触发。 +// TestPassthroughIngressReportsTurnStartedBeforeAfterTurnWithoutBeforeTurn 钉死 +// ws_v2 透传 ingress 与 handler 侧 turn 定价的耦合:透传 relay 不触发 +// BeforeTurn,但会在每个 AfterTurn 前通过 TurnStarted 报告同一 turn 的开始时刻。 // -// handler 依赖这一点:openAIWSTurnPricing 零值起步,透传连接的每个 turn 都拿 -// 不到冻结的 pricingAt,RecordUsage 回退到记录时刻——与引入分组利润控制前的 -// 基线一致。若把 turn 定价初始化成建连时刻,透传连接的所有 turn 就会被钉死在 -// 建连时的高峰因子,客户端峰前建连保活即可全程按谷价结算。 -// -// 若本断言因为透传补齐了 turn 起始回调而失败:这是好事,请同步复核 -// openAIWSTurnPricing 的零值语义与透传路径的 turn 级利润复核。 -func TestPassthroughIngressNeverCallsBeforeTurn(t *testing.T) { +// handler 的 recordTurnStart 保存该时刻,AfterTurn 再用 currentOr(turnStart) +// 作为计费 PricingAt;不触发 BeforeTurn 也意味着透传仍没有 turn 级利润复核。 +func TestPassthroughIngressReportsTurnStartedBeforeAfterTurnWithoutBeforeTurn(t *testing.T) { gin.SetMode(gin.TestMode) controlCtx, cancelControl := context.WithCancelCause(context.Background()) defer cancelControl(context.Canceled) @@ -81,17 +76,29 @@ func TestPassthroughIngressNeverCallsBeforeTurn(t *testing.T) { var hooksMu sync.Mutex beforeTurnCalls := 0 - afterTurnCalls := 0 + expectedTurnStartedAt := time.Date(2026, time.August, 17, 9, 59, 59, 0, time.UTC) + type hookEvent struct { + name string + turn int + startedAt time.Time + } + var hookEvents []hookEvent hooks := &OpenAIWSIngressHooks{ + InitialTurnStartedAt: expectedTurnStartedAt, + TurnStarted: func(turn int, startedAt time.Time) { + hooksMu.Lock() + hookEvents = append(hookEvents, hookEvent{name: "TurnStarted", turn: turn, startedAt: startedAt}) + hooksMu.Unlock() + }, BeforeTurn: func(int) error { hooksMu.Lock() beforeTurnCalls++ hooksMu.Unlock() return nil }, - AfterTurn: func(int, *OpenAIForwardResult, error) { + AfterTurn: func(turn int, _ *OpenAIForwardResult, _ error) { hooksMu.Lock() - afterTurnCalls++ + hookEvents = append(hookEvents, hookEvent{name: "AfterTurn", turn: turn}) hooksMu.Unlock() }, } @@ -120,9 +127,109 @@ func TestPassthroughIngressNeverCallsBeforeTurn(t *testing.T) { } hooksMu.Lock() - gotBefore, gotAfter := beforeTurnCalls, afterTurnCalls + gotBefore := beforeTurnCalls + gotEvents := append([]hookEvent(nil), hookEvents...) hooksMu.Unlock() - require.Zero(t, gotBefore, "透传 ingress 没有 turn 起始回调,BeforeTurn 不应被调用") - require.Positive(t, gotAfter, "透传 ingress 仍应回调 AfterTurn 提交用量") + require.Zero(t, gotBefore, "透传 ingress 不应调用 BeforeTurn") + require.GreaterOrEqual(t, len(gotEvents), 2, "透传 ingress 应报告 TurnStarted 和 AfterTurn") + require.Equal(t, "TurnStarted", gotEvents[0].name) + require.Equal(t, expectedTurnStartedAt, gotEvents[0].startedAt, "TurnStarted 必须携带入口冻结的首轮开始时刻") + require.Equal(t, "AfterTurn", gotEvents[1].name) + require.Equal(t, gotEvents[0].turn, gotEvents[1].turn, "TurnStarted 后应提交同一 turn 的 AfterTurn") +} + +func TestPassthroughIngressFreezesSubsequentTurnBeforeRequestPolicy(t *testing.T) { + testPassthroughIngressFreezesSubsequentTurnBeforeRequestPolicy(t, coderws.MessageText) +} + +func TestPassthroughIngressFreezesBinarySubsequentTurnBeforeRequestPolicy(t *testing.T) { + testPassthroughIngressFreezesSubsequentTurnBeforeRequestPolicy(t, coderws.MessageBinary) +} + +func testPassthroughIngressFreezesSubsequentTurnBeforeRequestPolicy(t *testing.T, secondMessageType coderws.MessageType) { + t.Helper() + gin.SetMode(gin.TestMode) + controlCtx, cancelControl := context.WithCancelCause(context.Background()) + defer cancelControl(context.Canceled) + + upstream := newStagedPassthroughConn() + upstream.Send(`{"type":"response.completed","response":{"id":"resp_first","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) + + type turnStart struct { + turn int + startedAt time.Time + } + turnStarts := make(chan turnStart, 2) + beforeRequestEntered := make(chan time.Time, 1) + releaseBeforeRequest := make(chan struct{}) + hooks := &OpenAIWSIngressHooks{ + InitialTurnStartedAt: time.Now(), + TurnStarted: func(turn int, startedAt time.Time) { + turnStarts <- turnStart{turn: turn, startedAt: startedAt} + }, + BeforeRequest: func(turn int, _ []byte, _ string) error { + if turn == 2 { + beforeRequestEntered <- time.Now() + <-releaseBeforeRequest + } + return nil + }, + } + + server, serverErr := startPassthroughHookRecordingServer( + t, + controlCtx, + newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), + passthroughLifecycleAccount(), + hooks, + ) + defer server.Close() + clientConn := dialPassthroughLifecycleClient(t, server) + defer func() { _ = clientConn.CloseNow() }() + + require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, 3*time.Second), "type").String()) + firstCompleted, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) + require.NoError(t, err) + require.Equal(t, "resp_first", gjson.GetBytes(firstCompleted, "response.id").String()) + select { + case first := <-turnStarts: + require.Equal(t, 1, first.turn) + case <-time.After(time.Second): + t.Fatal("first turn start was not reported") + } + + writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) + err = clientConn.Write(writeCtx, secondMessageType, []byte(`{"type":"response.create","model":"gpt-5.1","previous_response_id":"resp_first"}`)) + cancelWrite() + require.NoError(t, err) + + var policyEnteredAt time.Time + select { + case policyEnteredAt = <-beforeRequestEntered: + case <-time.After(time.Second): + t.Fatal("second turn did not enter BeforeRequest") + } + close(releaseBeforeRequest) + require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, 3*time.Second), "type").String()) + upstream.Send(`{"type":"response.completed","response":{"id":"resp_second","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) + secondCompleted, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) + require.NoError(t, err) + require.Equal(t, "resp_second", gjson.GetBytes(secondCompleted, "response.id").String()) + + select { + case second := <-turnStarts: + require.Equal(t, 2, second.turn) + require.False(t, second.startedAt.After(policyEnteredAt), "第二轮开始时刻必须在 BeforeRequest 策略执行前冻结") + case <-time.After(time.Second): + t.Fatal("second turn start was not reported") + } + + _ = clientConn.CloseNow() + cancelControl(context.Canceled) + select { + case <-serverErr: + case <-time.After(3 * time.Second): + t.Fatal("passthrough ingress did not exit") + } } diff --git a/backend/internal/service/openai_ws_pool.go b/backend/internal/service/openai_ws_pool.go index 048031d64066..2c62b6b4c07a 100644 --- a/backend/internal/service/openai_ws_pool.go +++ b/backend/internal/service/openai_ws_pool.go @@ -77,7 +77,13 @@ type openAIWSAcquireRequest struct { } type openAIWSHandshakeCompatibilityKey struct { - betaFeatures string + betaFeatures string + codexInstallationID string + sessionIDHyphen string + sessionIDUnderscore string + threadID string + clientRequestID string + codexWindowID string } type openAIWSConnLease struct { @@ -855,7 +861,7 @@ func (p *openAIWSConnPool) acquire(ctx context.Context, req openAIWSAcquireReque retryAcquire: accountID := req.Account.ID - compatibility := normalizeOpenAIWSHandshakeCompatibility(req.Headers) + compatibility := normalizeOpenAIWSHandshakeCompatibility(req.Account, req.Headers) routingAffinity := normalizeOpenAIWSRoutingAffinity(req.Headers) effectiveMaxConns := p.effectiveMaxConnsByAccount(req.Account) if effectiveMaxConns <= 0 { @@ -1015,37 +1021,39 @@ retryAcquire: p.ensureTargetIdleAsync(accountID) return lease, nil } - for _, conn := range ap.conns { - if conn == nil || conn == best || !conn.matchesHandshakeCompatibility(compatibility) || !conn.matchesRoutingAffinity(routingAffinity) { - continue - } - if conn.tryAcquire() { - connPick := time.Since(pickStartedAt) - p.recordConnPickDuration(connPick) - ap.mu.Unlock() - closeOpenAIWSConns(evicted) - if p.shouldHealthCheckConn(conn) { - if err := conn.pingWithTimeout(openAIWSConnHealthCheckTO); err != nil { - conn.close() - p.evictConn(accountID, conn.id) - if retry < 1 { - return p.acquire(ctx, req, retry+1) + if routingAffinity == "" || len(ap.conns)+ap.creating >= effectiveMaxConns { + for _, conn := range ap.conns { + if conn == nil || conn == best || !conn.matchesHandshakeCompatibility(compatibility) { + continue + } + if conn.tryAcquire() { + connPick := time.Since(pickStartedAt) + p.recordConnPickDuration(connPick) + ap.mu.Unlock() + closeOpenAIWSConns(evicted) + if p.shouldHealthCheckConn(conn) { + if err := conn.pingWithTimeout(openAIWSConnHealthCheckTO); err != nil { + conn.close() + p.evictConn(accountID, conn.id) + if retry < 1 { + return p.acquire(ctx, req, retry+1) + } + return nil, err } - return nil, err } + lease := &openAIWSConnLease{pool: p, accountID: accountID, conn: conn, connPick: connPick, reused: true} + p.metrics.acquireReuseTotal.Add(1) + p.recordLastSuccessfulAcquire(accountID, acquireGeneration, req) + p.ensureTargetIdleAsync(accountID) + return lease, nil } - lease := &openAIWSConnLease{pool: p, accountID: accountID, conn: conn, connPick: connPick, reused: true} - p.metrics.acquireReuseTotal.Add(1) - p.recordLastSuccessfulAcquire(accountID, acquireGeneration, req) - p.ensureTargetIdleAsync(accountID) - return lease, nil } } } if !req.ForceNewConn && len(ap.conns)+ap.creating >= effectiveMaxConns { affine := p.pickLeastBusyConnWithRoutingAffinityLocked(ap, compatibility, routingAffinity) - if idle := p.pickOldestIdleConnWithoutHandshakeCompatibilityOrRoutingAffinityLocked(ap, compatibility, routingAffinity); idle != nil { + if idle := p.pickOldestIdleConnWithoutHandshakeCompatibilityLocked(ap, compatibility); idle != nil { delete(ap.conns, idle.id) evicted = append(evicted, idle) p.metrics.scaleDownTotal.Add(1) @@ -1241,10 +1249,9 @@ func (p *openAIWSConnPool) pickOldestIdleConnLocked(ap *openAIWSAccountPool) *op return oldest } -func (p *openAIWSConnPool) pickOldestIdleConnWithoutHandshakeCompatibilityOrRoutingAffinityLocked( +func (p *openAIWSConnPool) pickOldestIdleConnWithoutHandshakeCompatibilityLocked( ap *openAIWSAccountPool, compatibility openAIWSHandshakeCompatibilityKey, - routingAffinity string, ) *openAIWSConn { if ap == nil || len(ap.conns) == 0 { return nil @@ -1252,7 +1259,7 @@ func (p *openAIWSConnPool) pickOldestIdleConnWithoutHandshakeCompatibilityOrRout var oldest *openAIWSConn for _, conn := range ap.conns { if conn == nil || - (conn.matchesHandshakeCompatibility(compatibility) && conn.matchesRoutingAffinity(routingAffinity)) || + conn.matchesHandshakeCompatibility(compatibility) || conn.isLeased() || conn.waiters.Load() > 0 || p.isConnPinnedLocked(ap, conn.id) { continue } @@ -1800,7 +1807,7 @@ func (p *openAIWSConnPool) dialConn(ctx context.Context, req openAIWSAcquireRequ } id := p.nextConnID(req.Account.ID) pooledConn := newOpenAIWSConn(id, req.Account.ID, conn, handshakeHeaders) - pooledConn.handshakeCompatibility = normalizeOpenAIWSHandshakeCompatibility(req.Headers) + pooledConn.handshakeCompatibility = normalizeOpenAIWSHandshakeCompatibility(req.Account, req.Headers) pooledConn.routingAffinity = normalizeOpenAIWSRoutingAffinity(req.Headers) return pooledConn, nil } @@ -1983,7 +1990,7 @@ func cloneOpenAIWSAcquireRequestPtr(req *openAIWSAcquireRequest) *openAIWSAcquir func sameOpenAIWSPrewarmTarget(a, b openAIWSAcquireRequest) bool { return stringsTrim(a.WSURL) == stringsTrim(b.WSURL) && stringsTrim(a.ProxyURL) == stringsTrim(b.ProxyURL) && - normalizeOpenAIWSHandshakeCompatibility(a.Headers) == normalizeOpenAIWSHandshakeCompatibility(b.Headers) + normalizeOpenAIWSHandshakeCompatibility(a.Account, a.Headers) == normalizeOpenAIWSHandshakeCompatibility(b.Account, b.Headers) } func normalizeOpenAIWSBetaFeatures(headers http.Header) string { @@ -2011,10 +2018,41 @@ func normalizeOpenAIWSBetaFeatures(headers http.Header) string { return strings.Join(normalized, ",") } -func normalizeOpenAIWSHandshakeCompatibility(headers http.Header) openAIWSHandshakeCompatibilityKey { - return openAIWSHandshakeCompatibilityKey{ +func normalizeOpenAIWSHandshakeCompatibility(account *Account, headers http.Header) openAIWSHandshakeCompatibilityKey { + key := openAIWSHandshakeCompatibilityKey{ betaFeatures: normalizeOpenAIWSBetaFeatures(headers), } + mode := activeCodexFingerprintMode(account) + if mode == codexFingerprintOff { + return key + } + key.codexInstallationID = normalizeOpenAIWSStableIdentityHeader(headers, "x-codex-installation-id") + if mode == codexFingerprintDevice { + return key + } + key.sessionIDHyphen = normalizeOpenAIWSStableIdentityHeader(headers, "session-id") + key.sessionIDUnderscore = normalizeOpenAIWSStableIdentityHeader(headers, "session_id") + key.threadID = normalizeOpenAIWSStableIdentityHeader(headers, "thread-id") + key.clientRequestID = normalizeOpenAIWSStableIdentityHeader(headers, "x-client-request-id") + key.codexWindowID = normalizeOpenAIWSStableIdentityHeader(headers, "x-codex-window-id") + return key +} + +func activeCodexFingerprintMode(account *Account) codexFingerprintMode { + if account == nil || account.GetCodexFingerprintMode() == codexFingerprintOff { + return codexFingerprintOff + } + if _, ok := codexFingerprintSeed(account.Extra); !ok { + return codexFingerprintOff + } + return account.GetCodexFingerprintMode() +} + +func normalizeOpenAIWSStableIdentityHeader(headers http.Header, name string) string { + if headers == nil { + return "" + } + return strings.TrimSpace(headers.Get(name)) } func normalizeOpenAIWSRoutingAffinity(headers http.Header) string { diff --git a/backend/internal/service/openai_ws_pool_test.go b/backend/internal/service/openai_ws_pool_test.go index f04b81d40d1d..ada18af8d3b9 100644 --- a/backend/internal/service/openai_ws_pool_test.go +++ b/backend/internal/service/openai_ws_pool_test.go @@ -623,6 +623,206 @@ func TestOpenAIWSConnPool_AcquireReusesOnlyMatchingBetaFeatures(t *testing.T) { require.Equal(t, 2, dialer.DialCount()) } +func activeCodexFingerprintPoolAccountForTest(id int64) *Account { + return &Account{ + ID: id, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{ + codexFingerprintModeExtraKey: "session", + codexFingerprintSeedExtraKey: "11111111-1111-4111-8111-111111111111", + }, + } +} + +func stableOpenAIWSIdentityHeadersForTest() http.Header { + headers := make(http.Header) + headers.Set("X-Codex-Beta-Features", "remote_compaction_v2,responses_websockets_v2") + headers.Set("X-Codex-Installation-ID", "install-a") + headers.Set("session-id", "session-hyphen-a") + headers.Set("session_id", "session-underscore-a") + headers.Set("thread-id", "thread-a") + headers.Set("x-client-request-id", "client-request-a") + headers.Set("x-codex-window-id", "window-a") + return headers +} + +func TestOpenAIWSConnPool_AcquireReusesSameStableIdentityWithDifferentTurnMetadata(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1 + + pool := newOpenAIWSConnPool(cfg) + dialer := &openAIWSCountingDialer{} + pool.setClientDialerForTest(dialer) + account := activeCodexFingerprintPoolAccountForTest(132) + headers := stableOpenAIWSIdentityHeadersForTest() + headers.Set("Authorization", "Bearer token-a") + headers.Set("x-codex-turn-metadata", `{"turn_id":"turn-a"}`) + + first, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + Headers: headers, + }) + require.NoError(t, err) + firstConnID := first.ConnID() + first.Release() + + nextHeaders := stableOpenAIWSIdentityHeadersForTest() + nextHeaders.Set("Authorization", "Bearer token-b") + nextHeaders.Set("x-codex-turn-metadata", `{"turn_id":"turn-b"}`) + nextHeaders.Set(openAICodexRoutingHintHeader, "model=gpt-5.6-codex;tier=priority") + second, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + Headers: nextHeaders, + }) + require.NoError(t, err) + require.True(t, second.Reused()) + require.Equal(t, firstConnID, second.ConnID()) + second.Release() + require.Equal(t, 1, dialer.DialCount(), "stable identity match should ignore auth, turn metadata, and soft routing hints") +} + +func TestOpenAIWSConnPool_AcquireDoesNotReuseDifferentStableIdentity(t *testing.T) { + for _, tt := range []struct { + name string + header string + value string + }{ + {name: "installation", header: "x-codex-installation-id", value: "install-b"}, + {name: "session hyphen", header: "session-id", value: "session-hyphen-b"}, + {name: "session underscore", header: "session_id", value: "session-underscore-b"}, + {name: "thread", header: "thread-id", value: "thread-b"}, + {name: "client request", header: "x-client-request-id", value: "client-request-b"}, + {name: "window", header: "x-codex-window-id", value: "window-b"}, + } { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 2 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 2 + + pool := newOpenAIWSConnPool(cfg) + dialer := &openAIWSCountingDialer{} + pool.setClientDialerForTest(dialer) + account := activeCodexFingerprintPoolAccountForTest(133) + + first, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + Headers: stableOpenAIWSIdentityHeadersForTest(), + }) + require.NoError(t, err) + firstConnID := first.ConnID() + first.Release() + + nextHeaders := stableOpenAIWSIdentityHeadersForTest() + nextHeaders.Set(tt.header, tt.value) + second, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + Headers: nextHeaders, + }) + require.NoError(t, err) + require.False(t, second.Reused()) + require.NotEqual(t, firstConnID, second.ConnID()) + second.Release() + require.Equal(t, 2, dialer.DialCount()) + }) + } +} + +func TestOpenAIWSConnPool_AcquireRoutingHintRemainsSoftAffinity(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1 + + pool := newOpenAIWSConnPool(cfg) + dialer := &openAIWSCountingDialer{} + pool.setClientDialerForTest(dialer) + account := activeCodexFingerprintPoolAccountForTest(134) + + firstHeaders := stableOpenAIWSIdentityHeadersForTest() + firstHeaders.Set(openAICodexRoutingHintHeader, "model=gpt-5.6-codex") + first, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + Headers: firstHeaders, + }) + require.NoError(t, err) + firstConnID := first.ConnID() + first.Release() + + secondHeaders := stableOpenAIWSIdentityHeadersForTest() + secondHeaders.Set(openAICodexRoutingHintHeader, "model=gpt-5.6-codex;tier=priority") + second, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + Headers: secondHeaders, + }) + require.NoError(t, err) + require.True(t, second.Reused()) + require.Equal(t, firstConnID, second.ConnID()) + second.Release() + require.Equal(t, 1, dialer.DialCount()) +} + +func TestOpenAIWSConnPool_DeviceModeKeysOnlyInstallationIdentity(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 2 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 2 + + pool := newOpenAIWSConnPool(cfg) + dialer := &openAIWSCountingDialer{} + pool.setClientDialerForTest(dialer) + account := activeCodexFingerprintPoolAccountForTest(135) + account.Extra[codexFingerprintModeExtraKey] = "device" + + firstHeaders := stableOpenAIWSIdentityHeadersForTest() + first, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + Headers: firstHeaders, + }) + require.NoError(t, err) + firstConnID := first.ConnID() + first.Release() + + sessionChanged := stableOpenAIWSIdentityHeadersForTest() + sessionChanged.Set("session-id", "session-hyphen-b") + sessionChanged.Set("session_id", "session-underscore-b") + sessionChanged.Set("thread-id", "thread-b") + sessionChanged.Set("x-client-request-id", "client-request-b") + sessionChanged.Set("x-codex-window-id", "window-b") + second, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + Headers: sessionChanged, + }) + require.NoError(t, err) + require.True(t, second.Reused()) + require.Equal(t, firstConnID, second.ConnID()) + second.Release() + + installationChanged := sessionChanged.Clone() + installationChanged.Set("x-codex-installation-id", "install-b") + third, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + Headers: installationChanged, + }) + require.NoError(t, err) + require.False(t, third.Reused()) + require.NotEqual(t, firstConnID, third.ConnID()) + third.Release() + require.Equal(t, 2, dialer.DialCount()) +} + func TestOpenAIWSConnPool_AcquireReplacesIdleConnWithDifferentBetaFeatures(t *testing.T) { cfg := &config.Config{} cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay.go b/backend/internal/service/openai_ws_v2/passthrough_relay.go index 626faf891ae7..abc94e8f1cdb 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay.go @@ -50,6 +50,7 @@ type RelayTurnResult struct { Usage Usage RequestID string TerminalEventType string + StartedAt time.Time Duration time.Duration FirstTokenMs *int } @@ -65,6 +66,8 @@ type RelayOptions struct { WriteTimeout time.Duration IdleTimeout time.Duration UpstreamDrainTimeout time.Duration + FirstTurnStartedAt time.Time + TakeNextTurnStartedAt func() time.Time FirstMessageType coderws.MessageType FirstMessageSent bool StartClientAfterFirstDownstream bool @@ -93,6 +96,7 @@ type relayState struct { usage Usage requestModelMu sync.RWMutex requestModel string + pendingTurnStart atomic.Pointer[time.Time] lastResponseID string lastResponseModel string responseConflict bool @@ -114,6 +118,7 @@ type observedUpstreamEvent struct { eventType string responseID string usage Usage + startedAt time.Time responseModel string responseConflict bool duration time.Duration @@ -161,6 +166,13 @@ func Relay( } startAt := nowFn() state := &relayState{requestModel: result.RequestModel} + if isClientResponseCreateFrame(firstMessageType, firstClientMessage) { + firstTurnStartedAt := options.FirstTurnStartedAt + if firstTurnStartedAt.IsZero() { + firstTurnStartedAt = startAt + } + state.setPendingTurnStartedAt(firstTurnStartedAt) + } onTrace := options.OnTrace relayCtx, relayCancel := context.WithCancel(ctx) @@ -178,8 +190,16 @@ func Relay( return upstreamConn.WriteFrame(writeCtx, msgType, payload) } writeClientFrameUpstream := func(msgType coderws.MessageType, payload []byte) error { - if msgType == coderws.MessageText && strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create" { + if isClientResponseCreateFrame(msgType, payload) { state.setRequestModel(strings.TrimSpace(gjson.GetBytes(payload, "model").String())) + turnStartedAt := time.Time{} + if options.TakeNextTurnStartedAt != nil { + turnStartedAt = options.TakeNextTurnStartedAt() + } + if turnStartedAt.IsZero() { + turnStartedAt = nowFn() + } + state.setPendingTurnStartedAt(turnStartedAt) } return writeUpstream(msgType, payload) } @@ -412,6 +432,13 @@ func Relay( return result, nil } +func isClientResponseCreateFrame(msgType coderws.MessageType, payload []byte) bool { + if msgType != coderws.MessageText && msgType != coderws.MessageBinary { + return false + } + return strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create" +} + func runClientToUpstream( ctx context.Context, clientConn FrameConn, @@ -724,6 +751,7 @@ func observeUpstreamMessage( if duration < 0 { duration = 0 } + observed.startedAt = turnTiming.startAt observed.duration = duration observed.firstToken = openAIWSRelayCloneIntPtr(turnTiming.firstTokenMs) } @@ -754,6 +782,7 @@ func emitTurnComplete( Usage: observed.usage, RequestID: responseID, TerminalEventType: observed.eventType, + StartedAt: observed.startedAt, Duration: observed.duration, FirstTokenMs: openAIWSRelayCloneIntPtr(observed.firstToken), }) @@ -815,7 +844,11 @@ func openAIWSRelayGetOrInitTurnTiming(state *relayState, responseID string, now } timing, ok := state.turnTimingByID[responseID] if !ok || timing == nil || timing.startAt.IsZero() { - timing = &relayTurnTiming{startAt: now} + startAt := state.consumePendingTurnStartedAt() + if startAt.IsZero() { + startAt = now + } + timing = &relayTurnTiming{startAt: startAt} state.turnTimingByID[responseID] = timing state.activeTurn = timing return timing @@ -823,6 +856,25 @@ func openAIWSRelayGetOrInitTurnTiming(state *relayState, responseID string, now return timing } +func (s *relayState) setPendingTurnStartedAt(startedAt time.Time) { + if s == nil || startedAt.IsZero() { + return + } + startedAtCopy := startedAt + s.pendingTurnStart.Store(&startedAtCopy) +} + +func (s *relayState) consumePendingTurnStartedAt() time.Time { + if s == nil { + return time.Time{} + } + startedAt := s.pendingTurnStart.Swap(nil) + if startedAt == nil { + return time.Time{} + } + return *startedAt +} + func openAIWSRelayDeleteTurnTiming(state *relayState, responseID string) (relayTurnTiming, bool) { if state == nil || state.turnTimingByID == nil { return relayTurnTiming{}, false diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay_test.go b/backend/internal/service/openai_ws_v2/passthrough_relay_test.go index c41e7d293b04..12e550669802 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay_test.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay_test.go @@ -565,6 +565,143 @@ func TestRelay_OnTurnComplete_ProvidesTurnMetrics(t *testing.T) { require.Greater(t, result.Duration.Milliseconds(), int64(0)) } +func TestRelay_OnTurnComplete_UsesResponseCreateTimeAcrossPricingBoundary(t *testing.T) { + t.Parallel() + + clientConn := newPassthroughTestFrameConn(nil, false) + upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{ + { + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.completed","response":{"id":"resp_boundary","usage":{"input_tokens":1,"output_tokens":1}}}`), + }, + }, true) + + responseCreateAt := time.Date(2026, time.August, 17, 9, 59, 59, 0, time.UTC) + upstreamResponseAt := responseCreateAt.Add(time.Second) + var nowCalls atomic.Int64 + nowFn := func() time.Time { + if nowCalls.Add(1) == 1 { + return responseCreateAt + } + return upstreamResponseAt + } + + var turn RelayTurnResult + _, relayExit := Relay( + context.Background(), + clientConn, + upstreamConn, + []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`), + RelayOptions{ + Now: nowFn, + OnTurnComplete: func(current RelayTurnResult) { turn = current }, + }, + ) + + require.Nil(t, relayExit) + require.Equal(t, responseCreateAt, turn.StartedAt) +} + +func TestRelay_OnTurnComplete_UsesExplicitFirstTurnStartedAt(t *testing.T) { + t.Parallel() + + clientConn := newPassthroughTestFrameConn(nil, false) + upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{ + { + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.completed","response":{"id":"resp_initial_boundary","usage":{"input_tokens":1,"output_tokens":1}}}`), + }, + }, true) + + responseCreateAt := time.Date(2026, time.August, 17, 9, 59, 59, 0, time.UTC) + relayStartedAt := responseCreateAt.Add(time.Second) + var turn RelayTurnResult + _, relayExit := Relay( + context.Background(), + clientConn, + upstreamConn, + []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`), + RelayOptions{ + FirstTurnStartedAt: responseCreateAt, + Now: func() time.Time { return relayStartedAt }, + OnTurnComplete: func(current RelayTurnResult) { turn = current }, + }, + ) + + require.Nil(t, relayExit) + require.Equal(t, responseCreateAt, turn.StartedAt) +} + +func TestRelay_OnTurnComplete_UsesSubsequentResponseCreateTimeAcrossPricingBoundary(t *testing.T) { + t.Parallel() + + clientConn := newPassthroughTestFrameConn(nil, false) + upstreamConn := newPassthroughTestFrameConn(nil, false) + firstTurnAt := time.Date(2026, time.August, 17, 9, 0, 0, 0, time.UTC) + secondTurnAt := time.Date(2026, time.August, 17, 9, 59, 59, 0, time.UTC) + secondResponseAt := secondTurnAt.Add(time.Second) + var clock atomic.Int64 + clock.Store(firstTurnAt.UnixNano()) + + turns := make(chan RelayTurnResult, 2) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan struct{}) + go func() { + defer close(done) + _, _ = Relay( + ctx, + clientConn, + upstreamConn, + []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`), + RelayOptions{ + Now: func() time.Time { return time.Unix(0, clock.Load()).UTC() }, + OnTurnComplete: func(current RelayTurnResult) { + turns <- current + }, + }, + ) + }() + + require.Eventually(t, func() bool { return len(upstreamConn.Writes()) == 1 }, time.Second, time.Millisecond) + upstreamConn.readCh <- passthroughTestFrame{ + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.completed","response":{"id":"resp_first","usage":{"input_tokens":1,"output_tokens":1}}}`), + } + select { + case <-turns: + case <-time.After(time.Second): + t.Fatal("first turn did not complete") + } + + clock.Store(secondTurnAt.UnixNano()) + clientConn.readCh <- passthroughTestFrame{ + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`), + } + require.Eventually(t, func() bool { return len(upstreamConn.Writes()) == 2 }, time.Second, time.Millisecond) + clock.Store(secondResponseAt.UnixNano()) + upstreamConn.readCh <- passthroughTestFrame{ + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.completed","response":{"id":"resp_second","usage":{"input_tokens":1,"output_tokens":1}}}`), + } + + var secondTurn RelayTurnResult + select { + case secondTurn = <-turns: + case <-time.After(time.Second): + t.Fatal("second turn did not complete") + } + require.Equal(t, secondTurnAt, secondTurn.StartedAt) + + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("relay did not stop after cancellation") + } +} + func TestRelay_BinaryFramePassthrough(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 775e26980ce0..df094a336743 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -915,6 +915,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( completedTurns := atomic.Int32{} turnLifecycle := newOpenAIWSPassthroughTurnLifecycle(true) + var acceptedTurnStartedAt atomic.Pointer[time.Time] clientFrameConn := &openAIWSClientFrameConn{ conn: clientConn, controlCtx: ctx, @@ -936,13 +937,15 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( // capturedSessionModel 的读写都发生在该 goroutine 内,因此无需 // 加锁/原子化。 filter: func(msgType coderws.MessageType, payload []byte) (out []byte, blocked *OpenAIFastBlockedError, filterErr error) { - if msgType != coderws.MessageText { + if msgType != coderws.MessageText && msgType != coderws.MessageBinary { return payload, nil, nil } eventType := strings.TrimSpace(gjson.GetBytes(payload, "type").String()) isResponseCreate := eventType == "response.create" + responseCreateAt := time.Time{} acceptedTurn := false if isResponseCreate { + responseCreateAt = time.Now() if !turnLifecycle.beginResponseCreate(clientFrameConn.markTurnStarted) { err := errors.New("overlapping response.create is not supported") return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, err.Error(), err) @@ -1035,6 +1038,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( // service_tier 时按 default 处理,billing 应如实反映。 if policyErr == nil && blocked == nil && isResponseCreate { usageMeta.updateFromResponseCreate(out, model, requestModelForThisFrame) + responseCreateAtCopy := responseCreateAt + acceptedTurnStartedAt.Store(&responseCreateAtCopy) acceptedTurn = true } return out, blocked, policyErr @@ -1072,7 +1077,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( if readErr != nil { return msgType, payload, readErr } - if msgType == coderws.MessageText && strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create" { + if (msgType == coderws.MessageText || msgType == coderws.MessageBinary) && strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create" { return msgType, payload, nil } if writeErr := upstreamFrameConn.WriteFrame(readCtx, msgType, payload); writeErr != nil { @@ -1081,13 +1086,25 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } } + firstTurnStartedAt := time.Time{} + if hooks != nil { + firstTurnStartedAt = hooks.InitialTurnStartedAt + } relayResult, relayExit := openaiwsv2.RunEntry(openaiwsv2.EntryInput{ Ctx: ctx, ClientConn: policyClientConn, UpstreamConn: relayUpstreamFrameConn, FirstClientMessage: firstClientMessage, Options: openaiwsv2.RelayOptions{ - WriteTimeout: s.openAIWSWriteTimeout(), + WriteTimeout: s.openAIWSWriteTimeout(), + FirstTurnStartedAt: firstTurnStartedAt, + TakeNextTurnStartedAt: func() time.Time { + startedAt := acceptedTurnStartedAt.Swap(nil) + if startedAt == nil { + return time.Time{} + } + return *startedAt + }, // Passthrough idle is enforced only after a completed turn by // clientFrameConn. The relay-wide activity watchdog would also // terminate a healthy active upstream turn. @@ -1105,6 +1122,9 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( }, OnTurnComplete: func(turn openaiwsv2.RelayTurnResult) { turnNo := int(completedTurns.Add(1)) + if hooks != nil && hooks.TurnStarted != nil && !turn.StartedAt.IsZero() { + hooks.TurnStarted(turnNo, turn.StartedAt) + } turnRequestModel, turnUpstreamModel := usageMeta.turnModels(turn.RequestModel) turnResult := &OpenAIForwardResult{ RequestID: turn.RequestID, @@ -1265,6 +1285,9 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( ) // 正常路径按 terminal 事件逐 turn 已回调;仅在零 turn 场景兜底回调一次。 if turnCount == 0 && hooks != nil && hooks.AfterTurn != nil { + if hooks.TurnStarted != nil { + hooks.TurnStarted(1, time.Now().Add(-result.Duration)) + } hooks.AfterTurn(1, result, nil) } return nil @@ -1331,6 +1354,9 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( relayExit.WroteDownstream, ) if hooks != nil && hooks.AfterTurn != nil { + if hooks.TurnStarted != nil { + hooks.TurnStarted(turnCount+1, time.Now().Add(-result.Duration)) + } hooks.AfterTurn(turnCount+1, nil, turnErr) } return turnErr diff --git a/backend/internal/service/ops_service.go b/backend/internal/service/ops_service.go index b1d9b9f57c92..c651b9184d90 100644 --- a/backend/internal/service/ops_service.go +++ b/backend/internal/service/ops_service.go @@ -439,17 +439,8 @@ func (s *OpsService) RecordErrorBatch(ctx context.Context, entries []*OpsInsertE } if _, err := s.opsRepo.BatchInsertErrorLogs(ctx, prepared); err != nil { - log.Printf("[Ops] RecordErrorBatch failed, fallback to single inserts: %v", err) - var firstErr error - for _, entry := range prepared { - if _, insertErr := s.opsRepo.InsertErrorLog(ctx, entry); insertErr != nil { - log.Printf("[Ops] RecordErrorBatch fallback insert failed: %v", insertErr) - if firstErr == nil { - firstErr = insertErr - } - } - } - return firstErr + log.Printf("[Ops] RecordErrorBatch failed: %v", err) + return err } return nil } diff --git a/backend/internal/service/ops_service_batch_test.go b/backend/internal/service/ops_service_batch_test.go index a027f4873729..e6a87191dc46 100644 --- a/backend/internal/service/ops_service_batch_test.go +++ b/backend/internal/service/ops_service_batch_test.go @@ -69,7 +69,7 @@ func TestOpsServiceRecordErrorBatch_SanitizesAndBatches(t *testing.T) { require.False(t, second.CreatedAt.IsZero()) } -func TestOpsServiceRecordErrorBatch_FallsBackToSingleInsert(t *testing.T) { +func TestOpsServiceRecordErrorBatch_DoesNotFallbackToSingleInsertsWhenBatchFails(t *testing.T) { t.Parallel() var ( @@ -92,9 +92,9 @@ func TestOpsServiceRecordErrorBatch_FallsBackToSingleInsert(t *testing.T) { {ErrorMessage: "first"}, {ErrorMessage: "second"}, }) - require.NoError(t, err) + require.Error(t, err) require.Equal(t, 1, batchCalls) - require.Equal(t, 2, singleCalls) + require.Zero(t, singleCalls) } func TestOpsServiceRecordErrorPersistsExplicitAccountAuthStatusZero(t *testing.T) { diff --git a/backend/internal/service/ratelimit_cn_providers.go b/backend/internal/service/ratelimit_cn_providers.go new file mode 100644 index 000000000000..4aab5a87510c --- /dev/null +++ b/backend/internal/service/ratelimit_cn_providers.go @@ -0,0 +1,154 @@ +package service + +import ( + "context" + "log/slog" + "net/http" + "strings" + "time" +) + +// 国产供应商(kimi/zhipu/deepseek)的响应式冷却辅助。 +// +// 与 openai/anthropic 不同: +// - 余额不足是「可恢复」状态(充值/检测恢复后自动重新调度),不能走 handleAuthError +// 永久置 status=error。这里改为 SetTempUnschedulable,由 CN 余额检测周期任务 +// (cn_provider_balance_check_service.go)在余额恢复后 ClearTempUnschedulable。 +// - Coding Plan 滚动窗口耗尽(429)的冷却终点应是真实的窗口重置时间(已由 +// CNProviderQuotaService 落入 account.Extra 快照),而非默认的秒级兜底。 + +// cnBalanceExtraSuffixLow 标记账号响应过「余额不足」,供余额检测任务区分 +// 「确属余额不足」与「尚未探测」。 +const cnBalanceExtraSuffixLow = "balance_low" + +// cnBalanceLowReasonPrefix 是余额不足临时停调 reason 的稳定前缀。 +// 周期余额检测任务据此识别「是我们停调的」并在余额恢复后安全清除——不会误清 +// 其他子系统(阈值/限流/401)写入的临时停调。 +const cnBalanceLowReasonPrefix = "cn_balance_low" + +// cnBalanceLowReason 构造余额不足临时停调的 reason(带稳定前缀)。 +func cnBalanceLowReason(upstreamMsg string) string { + if upstreamMsg = strings.TrimSpace(upstreamMsg); upstreamMsg != "" { + return cnBalanceLowReasonPrefix + ": " + upstreamMsg + } + return cnBalanceLowReasonPrefix + ": 余额不足,账号临时停调" +} + +// cnProviderResponseIndicatesInsufficientBalance 通过响应体文案识别余额不足 +// (智谱 payg 无独立余额端点,仅能靠响应文案识别)。 +func cnProviderResponseIndicatesInsufficientBalance(body []byte) bool { + if len(body) == 0 { + return false + } + s := strings.ToLower(string(body)) + return strings.Contains(s, "余额不足") || + strings.Contains(s, "insufficient balance") || + strings.Contains(s, "insufficient_credit") || + strings.Contains(s, "balance is not enough") || + strings.Contains(s, "no enough balance") +} + +// handleCNProviderInsufficientBalance 把余额不足标记为可恢复的临时停调: +// 写入 balance_low 快照 + SetTempUnschedulable 一个余额检测周期, +// 由周期任务在余额恢复后清除。返回前已通知调度阻塞。 +func (s *RateLimitService) handleCNProviderInsufficientBalance( + ctx context.Context, + account *Account, + upstreamMsg string, +) { + msg := cnBalanceLowReason(upstreamMsg) + + if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ + cnExtraKey(account.Platform, cnBalanceExtraSuffixLow): true, + }); err != nil { + slog.Warn("cn_balance_low_mark_failed", "account_id", account.ID, "error", err) + } + + until := time.Now().Add(s.cnBalanceCooldownDuration()) + s.notifyAccountSchedulingBlocked(account, until, "cn_insufficient_balance") + if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, msg); err != nil { + slog.Warn("cn_balance_set_temp_unschedulable_failed", "account_id", account.ID, "error", err) + return + } + slog.Info("cn_provider_insufficient_balance", + "account_id", account.ID, + "platform", account.Platform, + "until", until.UTC(), + ) +} + +// cnBalanceCooldownDuration 返回余额不足临时停调的持续时长(= 2× 余额检测周期, +// 默认 20 分钟)。周期任务会在余额恢复后提前清除,故此处只需保证冷却覆盖到下一次 +// 周期检测即可。 +func (s *RateLimitService) cnBalanceCooldownDuration() time.Duration { + minutes := 10 + if s != nil && s.cfg != nil { + if cfgMin := s.cfg.Gateway.CNProviders.BalanceCheckIntervalMinutes; cfgMin > 0 { + minutes = cfgMin + } + } + cooldown := time.Duration(minutes) * time.Minute * 2 + if cooldown < time.Minute { + cooldown = 10 * time.Minute + } + return cooldown +} + +// cnProviderQuotaSnapshotReset 读取 Coding Plan 账号快照中最早一个仍在未来的窗口 +// 重置时间(5h / weekly)。429 多数由 5h 滚动窗口触发,取较早的重置点可避免 +// 把账号冷却到 weekly 重置(可达数天)的过度停调;如果确是 weekly 窗口耗尽, +// 周期额度探测刷新快照后阈值评估会再次停调到正确的时间点。 +// 无快照或均已过期返回 nil。 +func cnProviderQuotaSnapshotReset(account *Account, now time.Time) *time.Time { + if account == nil || !account.IsCNProvider() || !account.IsCodingPlan() || len(account.Extra) == 0 { + return nil + } + provider := account.Platform + var earliest *time.Time + for _, suffix := range []string{cnExtraSuffix5hReset, cnExtraSuffixWeeklyReset} { + t := parseSchedulingResetAt(account.Extra[cnExtraKey(provider, suffix)]) + if t == nil || !t.After(now) { + continue + } + if earliest == nil || t.Before(*earliest) { + earliest = t + } + } + return earliest +} + +// applyCNProviderReactive429 处理国产供应商的 429 响应。 +// 返回 true 表示已处理(调用方应 return),false 表示未命中、继续走默认 429 逻辑。 +func (s *RateLimitService) applyCNProviderReactive429( + ctx context.Context, + account *Account, + headers http.Header, + responseBody []byte, +) bool { + if !account.IsCNProvider() { + return false + } + // 1) 余额不足文案:可恢复临时停调(含智谱 payg 这类无余额端点的场景)。 + if cnProviderResponseIndicatesInsufficientBalance(responseBody) { + s.handleCNProviderInsufficientBalance(ctx, account, extractUpstreamErrorMessage(responseBody)) + return true + } + // 2) Coding Plan 窗口耗尽:冷却到快照中最早的窗口重置点(见 + // cnProviderQuotaSnapshotReset:429 多由 5h 窗口触发,取较早点避免过度停调)。 + if account.IsCodingPlan() { + if until := cnProviderQuotaSnapshotReset(account, time.Now()); until != nil { + s.notifyAccountSchedulingBlocked(account, *until, "429") + if err := s.accountRepo.SetRateLimited(ctx, account.ID, *until); err != nil { + slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err) + return true + } + slog.Info("cn_coding_plan_rate_limited", + "account_id", account.ID, + "platform", account.Platform, + "reset_at", *until, + ) + return true + } + } + return false +} diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index ee5b09bbb4b1..c61e382d6201 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -32,6 +32,10 @@ type RateLimitService struct { runtimeBlocker AccountRuntimeBlocker usageCacheMu sync.RWMutex usageCache map[int64]*geminiUsageCacheEntry + + // OpenAI Team 联动熔断的进程内去重:teamID → 去重窗口截止时间 + openaiTeamLinkedMu sync.Mutex + openaiTeamLinkedRecent map[string]time.Time } type AccountRuntimeBlocker interface { @@ -268,6 +272,9 @@ func (s *RateLimitService) CheckErrorPolicy(ctx context.Context, account *Accoun // 返回是否应该停止该账号的调度 func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Account, statusCode int, headers http.Header, responseBody []byte, requestedModel ...string) (shouldDisable bool) { ctx = withTempUnschedulableModel(ctx, requestedModel) + // Team 联动熔断必须先于池模式/自定义错误码/临时不可调度的各类早退; + // 同请求内与 fastpath 调用点的重复触发由方法内去重吸收。 + s.maybeHandleOpenAITeamLinkedError(ctx, account, statusCode, responseBody) customErrorCodesEnabled := account.IsCustomErrorCodesEnabled() // 池模式默认不标记本地账号状态;但管理员显式配置的临时不可调度规则优先。 @@ -436,6 +443,13 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc shouldDisable = true } case 402: + // 国产供应商:余额不足是可恢复状态(充值/检测恢复后由周期任务自动解除), + // 不能走 handleAuthError 永久置 status=error。改为可恢复的临时停调。 + if account.IsCNProvider() { + s.handleCNProviderInsufficientBalance(ctx, account, upstreamMsg) + shouldDisable = true + break + } // OpenAI: deactivated_workspace 表示工作区已停用,直接标记 error if account.Platform == PlatformOpenAI && gjson.GetBytes(responseBody, "detail.code").String() == "deactivated_workspace" { msg := "Workspace deactivated (402): workspace has been deactivated" @@ -899,7 +913,10 @@ func (s *RateLimitService) handle403(ctx context.Context, account *Account, upst if account.Platform == PlatformAntigravity { return s.handleAntigravity403(ctx, account, upstreamMsg, responseBody) } - if account.Platform == PlatformOpenAI { + // 国产供应商与 openai 同口径:HTML 403(CDN/代理拦截页)不构成账号失效证据, + // 且 403 在 failover 状态集里会被逐账号重放——直接 SetError 会让一个坏请求/ + // 一层坏代理连环永久禁用整组账号。走 HTML 豁免 + N 次累计 + 临时冷却。 + if account.Platform == PlatformOpenAI || IsCNProvider(account.Platform) { return s.handleOpenAI403(ctx, account, upstreamMsg, responseBody) } // 非 Antigravity 平台:保持原有行为 @@ -1048,6 +1065,13 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head if account.IsShadow() { return } + // 国产供应商(kimi/zhipu/deepseek)的 429 走专用可恢复路径:余额不足 → 临时停调, + // Coding Plan 窗口耗尽 → 冷却到快照重置点。未命中则继续默认 429 逻辑。 + if account.IsCNProvider() { + if s.applyCNProviderReactive429(ctx, account, headers, responseBody) { + return + } + } // 1. OpenAI 平台:优先尝试解析 x-codex-* 响应头(用于 rate_limit_exceeded) if account.Platform == PlatformOpenAI { persistOpenAI429PlanType(ctx, s.accountRepo, account, responseBody) diff --git a/backend/internal/service/ratelimit_service_openai_team_linked_test.go b/backend/internal/service/ratelimit_service_openai_team_linked_test.go new file mode 100644 index 000000000000..e72763e2638c --- /dev/null +++ b/backend/internal/service/ratelimit_service_openai_team_linked_test.go @@ -0,0 +1,192 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +const teamLinkedDeactivatedBody = `{"detail":{"code":"deactivated_workspace","message":"This workspace has been deactivated."}}` + +type teamLinkedAccountRepoStub struct { + mockAccountRepoForGemini + teamAccounts []Account + listErr error + listCalls int + setErrorIDs []int64 + setErrorMsgs map[int64]string + failSetError map[int64]error +} + +// ListByPlatform 镜像真实仓库语义:仅返回该平台的 active 账户。 +func (r *teamLinkedAccountRepoStub) ListByPlatform(ctx context.Context, platform string) ([]Account, error) { + r.listCalls++ + if r.listErr != nil { + return nil, r.listErr + } + out := make([]Account, 0, len(r.teamAccounts)) + for _, acc := range r.teamAccounts { + if acc.Platform == platform && acc.Status == StatusActive { + out = append(out, acc) + } + } + return out, nil +} + +func (r *teamLinkedAccountRepoStub) SetError(ctx context.Context, id int64, errorMsg string) error { + if err, ok := r.failSetError[id]; ok { + return err + } + r.setErrorIDs = append(r.setErrorIDs, id) + if r.setErrorMsgs == nil { + r.setErrorMsgs = make(map[int64]string) + } + r.setErrorMsgs[id] = errorMsg + return nil +} + +func newTeamLinkedAccount(id int64, teamID string) Account { + return Account{ + ID: id, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Credentials: map[string]any{"chatgpt_account_id": teamID}, + } +} + +// newTeamLinkedFixture: #1 触发者(team-A) #2 同队 #3 异队 #4 apikey #5 影子 #6 同队 #7 同队但已 error +func newTeamLinkedFixture() []Account { + parentID := int64(1) + shadow := Account{ + ID: 5, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + ParentAccountID: &parentID, + } + apikey := newTeamLinkedAccount(4, "team-A") + apikey.Type = AccountTypeAPIKey + erroredSibling := newTeamLinkedAccount(7, "team-A") + erroredSibling.Status = StatusError + return []Account{ + newTeamLinkedAccount(1, "team-A"), + newTeamLinkedAccount(2, "team-A"), + newTeamLinkedAccount(3, "team-B"), + apikey, + shadow, + newTeamLinkedAccount(6, "team-A"), + erroredSibling, + } +} + +func newTeamLinkedTestService(repo *teamLinkedAccountRepoStub) (*RateLimitService, *runtimeBlockRecorder) { + rl := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + blocker := &runtimeBlockRecorder{} + rl.SetAccountRuntimeBlocker(blocker) + return rl, blocker +} + +func TestTeamLinkedError_FanoutMarksSameTeamAccounts(t *testing.T) { + repo := &teamLinkedAccountRepoStub{teamAccounts: newTeamLinkedFixture()} + rl, blocker := newTeamLinkedTestService(repo) + trigger := newTeamLinkedAccount(1, "team-A") + + shouldDisable := rl.HandleUpstreamError(context.Background(), &trigger, http.StatusPaymentRequired, http.Header{}, []byte(teamLinkedDeactivatedBody)) + + require.True(t, shouldDisable) + // fan-out 先标记同队兄弟(#2、#6),触发账户 #1 随后由常规 case 402 标记 + require.Equal(t, []int64{2, 6, 1}, repo.setErrorIDs) + require.Contains(t, repo.setErrorMsgs[2], "team-linked error triggered by account #1") + require.Contains(t, repo.setErrorMsgs[6], "team-linked error triggered by account #1") + require.Contains(t, repo.setErrorMsgs[1], "Workspace deactivated (402)") + require.NotContains(t, repo.setErrorMsgs[1], "team-linked") + // 熔断顺序:兄弟账户先于落库全部进程内熔断,触发账户走 auth_error + require.Equal(t, []string{openAITeamLinkedErrorBlockReason, openAITeamLinkedErrorBlockReason, "auth_error"}, blocker.reasons) + require.Equal(t, int64(2), blocker.accounts[0].ID) + require.Equal(t, int64(6), blocker.accounts[1].ID) + require.Equal(t, int64(1), blocker.accounts[2].ID) +} + +func TestTeamLinkedError_GenericPaymentErrorDoesNotFanout(t *testing.T) { + repo := &teamLinkedAccountRepoStub{teamAccounts: newTeamLinkedFixture()} + rl, _ := newTeamLinkedTestService(repo) + trigger := newTeamLinkedAccount(1, "team-A") + + rl.HandleUpstreamError(context.Background(), &trigger, http.StatusPaymentRequired, http.Header{}, []byte(`{"error":{"message":"insufficient balance"}}`)) + + require.Equal(t, []int64{1}, repo.setErrorIDs) + require.Contains(t, repo.setErrorMsgs[1], "Payment required (402)") + require.Zero(t, repo.listCalls) +} + +func TestTeamLinkedError_DedupWithinTTL(t *testing.T) { + repo := &teamLinkedAccountRepoStub{teamAccounts: newTeamLinkedFixture()} + rl, _ := newTeamLinkedTestService(repo) + first := newTeamLinkedAccount(1, "team-A") + second := newTeamLinkedAccount(2, "team-A") + + rl.HandleUpstreamError(context.Background(), &first, http.StatusPaymentRequired, http.Header{}, []byte(teamLinkedDeactivatedBody)) + rl.HandleUpstreamError(context.Background(), &second, http.StatusPaymentRequired, http.Header{}, []byte(teamLinkedDeactivatedBody)) + + // 第二次触发被去重:只有 #2 自身经 case 402 标记,未再次 fan-out + require.Equal(t, []int64{2, 6, 1, 2}, repo.setErrorIDs) + require.Equal(t, 1, repo.listCalls) +} + +func TestTeamLinkedError_APIKeyTriggerDoesNotFanout(t *testing.T) { + repo := &teamLinkedAccountRepoStub{teamAccounts: newTeamLinkedFixture()} + rl, _ := newTeamLinkedTestService(repo) + trigger := newTeamLinkedAccount(4, "team-A") + trigger.Type = AccountTypeAPIKey + + rl.HandleUpstreamError(context.Background(), &trigger, http.StatusPaymentRequired, http.Header{}, []byte(teamLinkedDeactivatedBody)) + + require.Equal(t, []int64{4}, repo.setErrorIDs) + require.Zero(t, repo.listCalls) +} + +func TestTeamLinkedError_DirectCallSkipsTriggerAccount(t *testing.T) { + // 直调对应 fastpath 调用点:账户级临时不可调度规则短路时联动仍然生效 + repo := &teamLinkedAccountRepoStub{teamAccounts: newTeamLinkedFixture()} + rl, blocker := newTeamLinkedTestService(repo) + trigger := newTeamLinkedAccount(1, "team-A") + + rl.maybeHandleOpenAITeamLinkedError(context.Background(), &trigger, http.StatusPaymentRequired, []byte(teamLinkedDeactivatedBody)) + + require.Equal(t, []int64{2, 6}, repo.setErrorIDs) + require.Equal(t, []string{openAITeamLinkedErrorBlockReason, openAITeamLinkedErrorBlockReason}, blocker.reasons) +} + +func TestTeamLinkedError_MissingTeamIDDoesNothing(t *testing.T) { + repo := &teamLinkedAccountRepoStub{teamAccounts: newTeamLinkedFixture()} + rl, blocker := newTeamLinkedTestService(repo) + trigger := Account{ID: 9, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive} + + rl.maybeHandleOpenAITeamLinkedError(context.Background(), &trigger, http.StatusPaymentRequired, []byte(teamLinkedDeactivatedBody)) + + require.Empty(t, repo.setErrorIDs) + require.Empty(t, blocker.reasons) + require.Zero(t, repo.listCalls) +} + +func TestTeamLinkedError_SetErrorFailureDoesNotAbortRemaining(t *testing.T) { + repo := &teamLinkedAccountRepoStub{ + teamAccounts: newTeamLinkedFixture(), + failSetError: map[int64]error{2: errors.New("db down")}, + } + rl, blocker := newTeamLinkedTestService(repo) + trigger := newTeamLinkedAccount(1, "team-A") + + rl.maybeHandleOpenAITeamLinkedError(context.Background(), &trigger, http.StatusPaymentRequired, []byte(teamLinkedDeactivatedBody)) + + require.Equal(t, []int64{6}, repo.setErrorIDs) + // 进程内熔断先于落库执行,两个账户都已被熔断 + require.Len(t, blocker.reasons, 2) +} diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index 283c76f8f4c2..9f3dfbddf243 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -190,6 +190,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyChannelMonitorMode: ChannelMonitorModeV1, SettingKeyChannelMonitorDefaultIntervalSeconds: "60", SettingKeyChannelMonitorHideThroughput: "true", + SettingKeyChannelMonitorShowQuota: "false", // Grok: safe defaults — no cross-vendor model rewrite unless operators enable it. SettingKeyGrokDefaultTextModel: "grok-4.5", @@ -798,6 +799,9 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin // 默认隐藏吞吐(迁移 206 的隐私默认):未配置时必须与 setting_public.go 的 // 公开读取路径给出同一个值,否则管理端看到“未隐藏”而用户端实际已隐藏。 result.ChannelMonitorHideThroughput = !isFalseSettingValue(settings[SettingKeyChannelMonitorHideThroughput]) + // 配额展示默认关闭且 fail-closed:仅字面 "true" 视为开启 + // (与 setting_public.go 公开读取路径保持一致)。 + result.ChannelMonitorShowQuota = settings[SettingKeyChannelMonitorShowQuota] == "true" // Grok default mapping policy result.GrokDefaultTextModel = strings.TrimSpace(settings[SettingKeyGrokDefaultTextModel]) diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go index f292e8ac5782..81d74aff0de0 100644 --- a/backend/internal/service/setting_public.go +++ b/backend/internal/service/setting_public.go @@ -231,6 +231,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyChannelMonitorMode, SettingKeyChannelMonitorDefaultIntervalSeconds, SettingKeyChannelMonitorHideThroughput, + SettingKeyChannelMonitorShowQuota, SettingKeyAvailableChannelsEnabled, SettingKeyModelPlazaEnabled, SettingKeyModelPlazaRequireAuth, @@ -355,6 +356,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ChannelMonitorMode: normalizeChannelMonitorMode(settings[SettingKeyChannelMonitorMode]), ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]), ChannelMonitorHideThroughput: !isFalseSettingValue(settings[SettingKeyChannelMonitorHideThroughput]), + ChannelMonitorShowQuota: settings[SettingKeyChannelMonitorShowQuota] == "true", AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true", @@ -422,6 +424,10 @@ type ChannelMonitorRuntime struct { DefaultIntervalSeconds int // HideThroughput: when true, user-facing V2 APIs omit RPM/TPM scale signals. HideThroughput bool + // ShowQuota: when true, user-facing monitor views keep the quota/balance + // snapshots; otherwise the user handler strips them server-side. + // Parsed fail-closed (only literal "true" enables). Admin always sees them. + ShowQuota bool } // ActiveProbesAllowed reports whether V1 active provider probes may run. @@ -450,6 +456,7 @@ func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMo SettingKeyChannelMonitorMode, SettingKeyChannelMonitorDefaultIntervalSeconds, SettingKeyChannelMonitorHideThroughput, + SettingKeyChannelMonitorShowQuota, }) if err != nil { return ChannelMonitorRuntime{ @@ -464,6 +471,7 @@ func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMo Mode: normalizeChannelMonitorMode(vals[SettingKeyChannelMonitorMode]), DefaultIntervalSeconds: parseChannelMonitorInterval(vals[SettingKeyChannelMonitorDefaultIntervalSeconds]), HideThroughput: !isFalseSettingValue(vals[SettingKeyChannelMonitorHideThroughput]), + ShowQuota: vals[SettingKeyChannelMonitorShowQuota] == "true", } } @@ -606,12 +614,15 @@ type PublicSettingsInjectionPayload struct { // ChannelMonitorHideThroughput is public so the user UI can hide RPM/TPM // without waiting for API redaction alone (defense in depth). ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` - AvailableChannelsEnabled bool `json:"available_channels_enabled"` - ModelPlazaEnabled bool `json:"model_plaza_enabled"` - ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` - AffiliateEnabled bool `json:"affiliate_enabled"` - RiskControlEnabled bool `json:"risk_control_enabled"` - AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"` + // ChannelMonitorShowQuota gates the user-facing quota/balance display on + // monitors; fail-closed (absent/false = hidden). Admin UI always shows it. + ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` + AvailableChannelsEnabled bool `json:"available_channels_enabled"` + ModelPlazaEnabled bool `json:"model_plaza_enabled"` + ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` + AffiliateEnabled bool `json:"affiliate_enabled"` + RiskControlEnabled bool `json:"risk_control_enabled"` + AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"` } // GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection. @@ -685,6 +696,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any ChannelMonitorMode: settings.ChannelMonitorMode, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput, + ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota, AvailableChannelsEnabled: settings.AvailableChannelsEnabled, ModelPlazaEnabled: settings.ModelPlazaEnabled, ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth, diff --git a/backend/internal/service/setting_service_public_test.go b/backend/internal/service/setting_service_public_test.go index 67bab243ca48..3fa0d40426b6 100644 --- a/backend/internal/service/setting_service_public_test.go +++ b/backend/internal/service/setting_service_public_test.go @@ -116,6 +116,29 @@ func TestSettingService_ChannelMonitorHideThroughputDefaultsToPrivate(t *testing } } +func TestSettingService_ChannelMonitorShowQuotaFailsClosed(t *testing.T) { + // 缺省(迁移插入 'false' / 老库无行)一律不展示。 + missingRuntime := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).GetChannelMonitorRuntime(context.Background()) + require.False(t, missingRuntime.ShowQuota) + missingPublic, err := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}). + GetPublicSettings(context.Background()) + require.NoError(t, err) + require.False(t, missingPublic.ChannelMonitorShowQuota) + + // 仅字面 "true" 视为开启;其余值(含异常值)fail-closed。 + runtime := NewSettingService(&settingPublicRepoStub{values: map[string]string{ + SettingKeyChannelMonitorShowQuota: "true", + }}, &config.Config{}).GetChannelMonitorRuntime(context.Background()) + require.True(t, runtime.ShowQuota) + + for _, value := range []string{"false", "TRUE", "1", "yes", "on", "garbage"} { + rt := NewSettingService(&settingPublicRepoStub{values: map[string]string{ + SettingKeyChannelMonitorShowQuota: value, + }}, &config.Config{}).GetChannelMonitorRuntime(context.Background()) + require.False(t, rt.ShowQuota, "value=%q", value) + } +} + func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t *testing.T) { repo := &settingPublicRepoStub{ values: map[string]string{ diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index f77a914fd73a..a4fadded0c06 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -417,6 +417,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyChannelMonitorDefaultIntervalSeconds] = strconv.Itoa(v) } updates[SettingKeyChannelMonitorHideThroughput] = strconv.FormatBool(settings.ChannelMonitorHideThroughput) + updates[SettingKeyChannelMonitorShowQuota] = strconv.FormatBool(settings.ChannelMonitorShowQuota) // Grok model mapping policy if v := strings.TrimSpace(settings.GrokDefaultTextModel); v != "" { diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 8580f0c8fb45..a939000947bb 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -201,6 +201,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 mapping falls back to these). GrokDefaultTextModel string `json:"grok_default_text_model"` @@ -378,6 +379,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"` // Grok model mapping policy (admin settings). GrokDefaultTextModel string `json:"grok_default_text_model"` diff --git a/backend/internal/service/subscription_expiry_service.go b/backend/internal/service/subscription_expiry_service.go index c93c763f029e..e27da3661d88 100644 --- a/backend/internal/service/subscription_expiry_service.go +++ b/backend/internal/service/subscription_expiry_service.go @@ -15,6 +15,7 @@ import ( ) const ( + subscriptionExpiryReminderSMTPWarningInterval = time.Minute // subscriptionExpiryReminderLeaderLockKey gates the per-cycle reminder scan so // that only one instance walks all active subscriptions and sends reminder // emails, avoiding redundant full scans and duplicate emails. @@ -37,6 +38,9 @@ type SubscriptionExpiryService struct { lockCache LeaderLockCache db *sql.DB instanceID string + + smtpWarningMu sync.Mutex + lastSMTPWarning time.Time } func NewSubscriptionExpiryService(userSubRepo UserSubscriptionRepository, interval time.Duration) *SubscriptionExpiryService { @@ -121,6 +125,9 @@ func (s *SubscriptionExpiryService) sendExpiryReminders(ctx context.Context) { if !s.expiryReminderEnabled(ctx) { return } + if !s.smtpConfigured(ctx) { + return + } // Multi-instance guard: only the leader walks every active subscription and // sends reminders, avoiding N× full scans and duplicate reminder emails. @@ -159,6 +166,28 @@ func (s *SubscriptionExpiryService) expiryReminderEnabled(ctx context.Context) b return !isFalseSettingValue(value) } +func (s *SubscriptionExpiryService) smtpConfigured(ctx context.Context) bool { + if s == nil || s.notificationEmailService == nil || s.notificationEmailService.emailService == nil { + return false + } + _, err := s.notificationEmailService.emailService.GetSMTPConfig(ctx) + if err == nil { + return true + } + if errors.Is(err, ErrEmailNotConfigured) { + s.smtpWarningMu.Lock() + defer s.smtpWarningMu.Unlock() + now := time.Now() + if s.lastSMTPWarning.IsZero() || now.Sub(s.lastSMTPWarning) >= subscriptionExpiryReminderSMTPWarningInterval { + log.Printf("[SubscriptionExpiry] SMTP is not configured; skipping expiry reminders") + s.lastSMTPWarning = now + } + return false + } + log.Printf("[SubscriptionExpiry] Read SMTP configuration failed; skipping expiry reminders: %v", err) + return false +} + func (s *SubscriptionExpiryService) sendExpiryReminderIfDue(ctx context.Context, sub *UserSubscription) { if sub == nil || sub.User == nil || sub.Group == nil || sub.User.Email == "" { return diff --git a/backend/internal/service/subscription_expiry_service_test.go b/backend/internal/service/subscription_expiry_service_test.go index 74b82e56c3b0..decb816e8490 100644 --- a/backend/internal/service/subscription_expiry_service_test.go +++ b/backend/internal/service/subscription_expiry_service_test.go @@ -1,8 +1,10 @@ package service import ( + "bytes" "context" "errors" + "log" "testing" "time" @@ -116,8 +118,9 @@ func (r *subscriptionExpiryRepoStub) BatchUpdateExpiredStatus(context.Context) ( } type subscriptionExpirySettingRepoStub struct { - values map[string]string - err error + values map[string]string + err error + multiErr error } func (r *subscriptionExpirySettingRepoStub) Get(context.Context, string) (*Setting, error) { @@ -139,8 +142,17 @@ func (r *subscriptionExpirySettingRepoStub) Set(context.Context, string, string) return nil } -func (r *subscriptionExpirySettingRepoStub) GetMultiple(context.Context, []string) (map[string]string, error) { - return nil, nil +func (r *subscriptionExpirySettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) { + if r.multiErr != nil { + return nil, r.multiErr + } + values := make(map[string]string, len(keys)) + for _, key := range keys { + if value, ok := r.values[key]; ok { + values[key] = value + } + } + return values, nil } func (r *subscriptionExpirySettingRepoStub) SetMultiple(context.Context, map[string]string) error { @@ -182,3 +194,44 @@ func TestSubscriptionExpiryService_ExpiryReminderSettingReadErrorFailsClosed(t * require.False(t, svc.expiryReminderEnabled(context.Background())) } + +func TestSubscriptionExpiryService_MissingSMTPSkipsReminderScanAndLogsOncePerInterval(t *testing.T) { + repo := &subscriptionExpiryRepoStub{} + settingRepo := &subscriptionExpirySettingRepoStub{values: map[string]string{}} + emailService := NewEmailService(settingRepo, nil) + svc := NewSubscriptionExpiryService(repo, time.Minute) + svc.SetSettingRepository(settingRepo) + svc.SetNotificationEmailService(NewNotificationEmailService(settingRepo, emailService)) + + var logs bytes.Buffer + previousWriter := log.Writer() + previousFlags := log.Flags() + log.SetOutput(&logs) + log.SetFlags(0) + t.Cleanup(func() { + log.SetOutput(previousWriter) + log.SetFlags(previousFlags) + }) + + svc.sendExpiryReminders(context.Background()) + svc.sendExpiryReminders(context.Background()) + + require.Zero(t, repo.listCalls) + require.Equal(t, 1, bytes.Count(logs.Bytes(), []byte("SMTP is not configured"))) +} + +func TestSubscriptionExpiryService_SMTPConfigReadErrorSkipsReminderScan(t *testing.T) { + repo := &subscriptionExpiryRepoStub{} + settingRepo := &subscriptionExpirySettingRepoStub{ + values: map[string]string{}, + multiErr: errors.New("db down"), + } + emailService := NewEmailService(settingRepo, nil) + svc := NewSubscriptionExpiryService(repo, time.Minute) + svc.SetSettingRepository(settingRepo) + svc.SetNotificationEmailService(NewNotificationEmailService(settingRepo, emailService)) + + svc.sendExpiryReminders(context.Background()) + + require.Zero(t, repo.listCalls) +} diff --git a/backend/internal/service/upstream_billing_probe.go b/backend/internal/service/upstream_billing_probe.go index d66cd905dc59..c6a62bab09a5 100644 --- a/backend/internal/service/upstream_billing_probe.go +++ b/backend/internal/service/upstream_billing_probe.go @@ -965,7 +965,9 @@ func decodeUpstreamBillingProbeSnapshot(extra map[string]any) *UpstreamBillingPr // IsUpstreamBillingProbeIdentity reports whether an account identity may opt // in to the upstream billing probe. `/v1/sub2api/billing` is a key-scoped -// sub2api convention shared by the five supported API-key platforms. +// sub2api convention shared by the supported API-key platforms (including the +// CN providers, whose official-domain accounts are short-circuited to +// "unsupported" by upstreamBillingProbeTargetIsOfficialAPI). // Non-sub2api upstreams return 404 and the snapshot records "unsupported". // Only AccountTypeAPIKey is in scope. OAuth/Bedrock hold no static API key to // present at all; AccountTypeUpstream (antigravity relay accounts) does carry @@ -978,7 +980,8 @@ func IsUpstreamBillingProbeIdentity(platform, accountType string) bool { return false } switch platform { - case PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok: + case PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok, + PlatformKimi, PlatformZhipu, PlatformDeepseek: return true default: return false @@ -1002,6 +1005,9 @@ func isUpstreamBillingProbeAccount(account *Account) bool { // ollama.com is a first-class configuration here (Ollama Cloud accounts are // platform openai/anthropic with base_url https://ollama.com/v1), and it is // an official provider API just like the rest, so it belongs on this list. +// CN provider domains (moonshot.cn / kimi.com / bigmodel.cn / deepseek.com) +// serve the same role: official APIs that can never host /v1/sub2api/billing, +// so their accounts short-circuit to "unsupported" without a request. var upstreamBillingProbeOfficialAPIDomains = []string{ "anthropic.com", "googleapis.com", @@ -1009,6 +1015,10 @@ var upstreamBillingProbeOfficialAPIDomains = []string{ "grok.com", "openai.com", "ollama.com", + "moonshot.cn", + "kimi.com", + "bigmodel.cn", + "deepseek.com", } func upstreamBillingProbeTargetIsOfficialAPI(baseURL string) bool { diff --git a/backend/internal/service/upstream_billing_probe_multiplatform_test.go b/backend/internal/service/upstream_billing_probe_multiplatform_test.go index 018f2b719857..6690a80c154c 100644 --- a/backend/internal/service/upstream_billing_probe_multiplatform_test.go +++ b/backend/internal/service/upstream_billing_probe_multiplatform_test.go @@ -11,11 +11,12 @@ import ( "github.com/stretchr/testify/require" ) -// 探测资格:/v1/sub2api/billing 是 key 级端点,五个 -// 受支持平台的 API-key 账号都可开启探测;OAuth/Bedrock 无静态 Key 仍不合格。 +// 探测资格:/v1/sub2api/billing 是 key 级端点,全部 +// 受支持平台(含国产供应商)的 API-key 账号都可开启探测;OAuth/Bedrock 无静态 Key 仍不合格。 func TestUpstreamBillingProbeIdentityCoversAllAPIKeyPlatforms(t *testing.T) { for _, platform := range []string{ PlatformOpenAI, PlatformGrok, PlatformAnthropic, PlatformGemini, PlatformAntigravity, + PlatformKimi, PlatformZhipu, PlatformDeepseek, } { require.True(t, IsUpstreamBillingProbeIdentity(platform, AccountTypeAPIKey), platform) require.True(t, isUpstreamBillingProbeAccount(&Account{Platform: platform, Type: AccountTypeAPIKey}), platform) @@ -124,6 +125,14 @@ func TestUpstreamBillingProbeOfficialAPIBaseURLIsUnsupportedWithoutRequest(t *te {PlatformAnthropic, "https://ollama.com/v1"}, {PlatformAnthropic, "https://ollama.com"}, {PlatformAnthropic, "https://www.ollama.com/v1"}, + // 国产供应商官方域(含各协议端点)同样是官方 API,创建即开探测也不发请求。 + {PlatformKimi, "https://api.moonshot.cn/v1"}, + {PlatformKimi, "https://api.moonshot.cn/anthropic"}, + {PlatformKimi, "https://api.kimi.com/coding"}, + {PlatformZhipu, "https://open.bigmodel.cn/api/paas/v4"}, + {PlatformZhipu, "https://open.bigmodel.cn/api/anthropic"}, + {PlatformDeepseek, "https://api.deepseek.com"}, + {PlatformDeepseek, "https://api.deepseek.com/anthropic"}, } for i, tc := range cases { account := &Account{ @@ -160,6 +169,11 @@ func TestUpstreamBillingProbeOfficialAPIHostMatchingIsNormalized(t *testing.T) { require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://ollama.com:443/v1")) require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://www.ollama.com/v1")) require.True(t, upstreamBillingProbeTargetIsOfficialAPI("HTTPS://OLLAMA.COM./v1")) + // 国产供应商官方域及子域。 + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.moonshot.cn/v1")) + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.kimi.com/coding/v1")) + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://open.bigmodel.cn/api/anthropic")) + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.deepseek.com/anthropic")) // 相似但不同的注册域不拦:中转完全可能叫 *-x.ai 之外的任何名字。 require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://relay.example/v1")) require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://notx.ai")) @@ -168,6 +182,11 @@ func TestUpstreamBillingProbeOfficialAPIHostMatchingIsNormalized(t *testing.T) { require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://notollama.com/v1")) require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://ollama.com.evil.example/v1")) require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://ollama.example/v1")) + require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://notmoonshot.cn/v1")) + require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://moonshot.cn.evil.example/v1")) + require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://kimi.example/v1")) + require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://notbigmodel.cn")) + require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://deepseek.example.com")) } // OpenAI 语义保持不变:无自定义 base 时仍探官方域,且沿用 openai 传输画像。 diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 9a4e4311b057..df926b02971e 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -137,7 +137,8 @@ func (s *AccountTestService) buildUpstreamModelsRequest(ctx context.Context, acc return s.buildAntigravityAPIKeyModelsRequest(ctx, account) case account.IsGrok(): return s.buildGrokUpstreamModelsRequest(ctx, account) - case account.IsOpenAI(): + case account.IsOpenAI() || account.IsCNProvider(): + // 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)复用 OpenAI /v1/models 探测。 return s.buildOpenAIUpstreamModelsRequest(ctx, account) case account.IsGemini(): return s.buildGeminiUpstreamModelsRequest(ctx, account) @@ -347,12 +348,14 @@ func (s *AccountTestService) buildOpenAIUpstreamModelsRequest(ctx context.Contex fmt.Sprintf("Unsupported OpenAI account type for upstream model sync: %s", account.Type), nil, ) } - apiKey := strings.TrimSpace(account.GetOpenAIApiKey()) + apiKey := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) if apiKey == "" { return nil, newUpstreamModelSyncConfigError("No OpenAI API key is available", nil) } - baseURL := account.GetOpenAIBaseURL() + // 协议感知:Anthropic 协议账号的凭证 base_url 指向 /anthropic 端点,模型 + // 列表同步需使用 OpenAI 格式 base(供应商 × 模式默认)。 + baseURL := account.GetOpenAIFormatBaseURL() if strings.TrimSpace(baseURL) == "" { baseURL = "https://api.openai.com" } diff --git a/backend/internal/service/upstream_response_model.go b/backend/internal/service/upstream_response_model.go index 81869c07d99f..f4e3bd4fe0a5 100644 --- a/backend/internal/service/upstream_response_model.go +++ b/backend/internal/service/upstream_response_model.go @@ -167,10 +167,33 @@ func upstreamModelMismatch(sentModel, responseModel string) *bool { return nil } sentModel = strings.TrimSpace(sentModel) - mismatch := sentModel == "" || !strings.EqualFold(sentModel, responseModel) + mismatch := sentModel == "" || !upstreamModelsMatchForAudit(sentModel, responseModel) return &mismatch } +func upstreamModelsMatchForAudit(sentModel, responseModel string) bool { + if strings.EqualFold(sentModel, responseModel) { + return true + } + + // xAI reports the runtime build ID for these supported public aliases. + // Canonicalize only for mismatch auditing; keep the raw response model for + // observability and for the separate response-model billing safeguards. + sentGrokModel := canonicalGrokBuildRuntimeModel(sentModel) + return sentGrokModel != "" && sentGrokModel == canonicalGrokBuildRuntimeModel(responseModel) +} + +func canonicalGrokBuildRuntimeModel(model string) string { + switch strings.ToLower(strings.TrimSpace(model)) { + case "grok-4.5", "grok-4.5-latest", "grok-4.5-build": + return "grok-4.5-build" + case "grok-4.6", "grok-4.6-latest", "grok-4.6-build": + return "grok-4.6-build" + default: + return "" + } +} + func upstreamSentModel(requestedModel, upstreamModel string) string { sentModel := strings.TrimSpace(upstreamModel) if sentModel == "" { diff --git a/backend/internal/service/upstream_response_model_test.go b/backend/internal/service/upstream_response_model_test.go index a831e74e65be..51374ec1f320 100644 --- a/backend/internal/service/upstream_response_model_test.go +++ b/backend/internal/service/upstream_response_model_test.go @@ -59,6 +59,77 @@ func TestUpstreamModelMismatchThreeStateAndCaseInsensitiveComparison(t *testing. require.True(t, *mismatched) } +func TestUpstreamModelMismatchTreatsGrokBuildRuntimeIDsAsAliases(t *testing.T) { + tests := []struct { + name string + sentModel string + responseModel string + }{ + { + name: "issue 5634 grok 4.6", + sentModel: "grok-4.6", + responseModel: "grok-4.6-build", + }, + { + name: "grok 4.6 latest", + sentModel: "grok-4.6-latest", + responseModel: "grok-4.6-build", + }, + { + name: "issue 5647 grok 4.5 latest", + sentModel: "grok-4.5-latest", + responseModel: "grok-4.5-build", + }, + { + name: "grok 4.5 canonical", + sentModel: "grok-4.5", + responseModel: "GROK-4.5-BUILD", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mismatch := upstreamModelMismatch(tt.sentModel, tt.responseModel) + + require.NotNil(t, mismatch) + require.False(t, *mismatch) + }) + } +} + +func TestUpstreamModelMismatchDoesNotCollapseDifferentModels(t *testing.T) { + tests := []struct { + name string + sentModel string + responseModel string + }{ + { + name: "different grok versions", + sentModel: "grok-4.5", + responseModel: "grok-4.6-build", + }, + { + name: "unrelated build suffix", + sentModel: "gpt-5.5", + responseModel: "gpt-5.5-build", + }, + { + name: "different grok runtime", + sentModel: "grok-build-0.1", + responseModel: "grok-4.5-build", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mismatch := upstreamModelMismatch(tt.sentModel, tt.responseModel) + + require.NotNil(t, mismatch) + require.True(t, *mismatch) + }) + } +} + func TestObserveOpenAISSEBodyIgnoresMalformedPayload(t *testing.T) { observer := &upstreamResponseModelObserver{} observeOpenAISSEBody(observer, "data: not-json\n\ndata: {\"type\":\"response.completed\",\"response\":{\"model\":\"gpt-5.4\"}}\n\n") diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index b3f3f5bb5388..58846813726e 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -273,6 +273,45 @@ func ProvideGrokQuotaService( return service } +// ProvideCNProviderQuotaService 构造国产供应商 Coding Plan 额度探测服务。 +func ProvideCNProviderQuotaService( + accountRepo AccountRepository, + proxyRepo ProxyRepository, + httpUpstream HTTPUpstream, + cfg *config.Config, +) *CNProviderQuotaService { + return NewCNProviderQuotaService(accountRepo, proxyRepo, httpUpstream, cfg) +} + +// ProvideCNProviderBalanceService 构造国产供应商余额探测服务。 +func ProvideCNProviderBalanceService( + accountRepo AccountRepository, + proxyRepo ProxyRepository, + httpUpstream HTTPUpstream, + cfg *config.Config, +) *CNProviderBalanceService { + return NewCNProviderBalanceService(accountRepo, proxyRepo, httpUpstream, cfg) +} + +// ProvideCNProviderBalanceCheckService 构造并启动周期余额/额度检测任务。 +// payg 账号探余额(低余额停调);coding plan 账号探 5h/weekly 滚动窗口 +// (落 extra 快照供调度阈值评估自动停调)。 +// 间隔取自 gateway.cn_providers.balance_check_interval_minutes;<=0 或关闭时不启动。 +func ProvideCNProviderBalanceCheckService( + accountRepo AccountRepository, + balanceService *CNProviderBalanceService, + quotaService *CNProviderQuotaService, + cfg *config.Config, +) *CNProviderBalanceCheckService { + minutes := 10 + if cfg != nil && cfg.Gateway.CNProviders.BalanceCheckIntervalMinutes > 0 { + minutes = cfg.Gateway.CNProviders.BalanceCheckIntervalMinutes + } + svc := NewCNProviderBalanceCheckService(accountRepo, balanceService, quotaService, cfg, time.Duration(minutes)*time.Minute) + svc.Start() + return svc +} + // ProvideGeminiTokenProvider creates GeminiTokenProvider with OAuthRefreshAPI injection func ProvideGeminiTokenProvider( accountRepo AccountRepository, @@ -827,6 +866,9 @@ var ProviderSet = wire.NewSet( ProvideCodexWindowRecorder, ProvideOpenAIQuotaService, ProvideGrokQuotaService, + ProvideCNProviderQuotaService, + ProvideCNProviderBalanceService, + ProvideCNProviderBalanceCheckService, ProvideClaudeTokenProvider, NewAntigravityGatewayService, ProvideRateLimitService, @@ -897,6 +939,7 @@ var ProviderSet = wire.NewSet( ProvideBalanceNotifyService, ProvideChannelMonitorService, ProvideChannelMonitorRunner, + NewChannelMonitorQuotaFetcher, ProvideChannelMonitorV2Service, ProvideChannelMonitorV2Aggregator, NewChannelMonitorRequestTemplateService, @@ -955,13 +998,20 @@ func ProvideChannelMonitorService( // 通过 SetScheduler 注入回 service 后再 Start,确保启动时加载所有 enabled monitor, // 后续 CRUD 也能即时同步任务表。Runner.Stop 由 cleanup function 调用。 // settingService 用于 runner 每次 fire 读取功能开关。 -func ProvideChannelMonitorRunner(svc *ChannelMonitorService, settingService *SettingService) *ChannelMonitorRunner { +// quotaFetcher(账号侧用量聚合)也在此注入:accountUsage/CN 服务在 wire 图中 +// 晚于 channelMonitorService 构造,走 setter 注入避免调整既有构造顺序。 +func ProvideChannelMonitorRunner( + svc *ChannelMonitorService, + settingService *SettingService, + quotaFetcher *ChannelMonitorQuotaFetcher, +) *ChannelMonitorRunner { r := NewChannelMonitorRunner(svc, settingService) if svc != nil { // Ensure runtime reader is set even if ProvideChannelMonitorService // was constructed without settings (tests / alternate providers). svc.SetRuntimeReader(settingService) svc.SetScheduler(r) + svc.SetQuotaFetcher(quotaFetcher) } r.Start() return r diff --git a/backend/migrations/224_user_platform_quotas_add_cn_providers.sql b/backend/migrations/224_user_platform_quotas_add_cn_providers.sql new file mode 100644 index 000000000000..011a7762afe7 --- /dev/null +++ b/backend/migrations/224_user_platform_quotas_add_cn_providers.sql @@ -0,0 +1,17 @@ +-- 把 kimi/zhipu/deepseek 平台加入 user_platform_quotas.platform 的 CHECK 约束。 +-- +-- 背景:国产供应商进入 AllowedQuotaPlatforms(internal/service/domain_constants.go), +-- 注册时 GetDefaultPlatformQuotas 会为全部 8 平台预填充默认配额行,但 157 号迁移的 +-- CHECK 仍只允许 5 平台。BulkInsertInitial 是单条多行 INSERT,任一违约行会中止整条 +-- 语句 → 注册路径 fail-open 吞错 → 新用户拿到零条配额记录(含原有 5 平台,缺失配额 +-- 行 = 无限额)。与 157 头注释记载的 grok 同型事故一致。 +-- +-- 修复:把约束与代码平台列表(PlatformKimi/PlatformZhipu/PlatformDeepseek)对齐。 +-- DROP ... IF EXISTS 保证可重入;新约束是旧约束的超集,存量行(仅 5 平台)瞬时校验通过。 +ALTER TABLE user_platform_quotas + DROP CONSTRAINT IF EXISTS user_platform_quotas_platform_check; + +ALTER TABLE user_platform_quotas + ADD CONSTRAINT user_platform_quotas_platform_check + CHECK (platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', + 'kimi', 'zhipu', 'deepseek')); diff --git a/backend/migrations/225_backfill_codex_fingerprint_seed.sql b/backend/migrations/225_backfill_codex_fingerprint_seed.sql new file mode 100644 index 000000000000..5c7bdbb2f6c7 --- /dev/null +++ b/backend/migrations/225_backfill_codex_fingerprint_seed.sql @@ -0,0 +1,21 @@ +-- Backfill system-managed Codex fingerprint seeds for enabled OpenAI OAuth accounts. +-- Idempotent: valid canonical seeds are preserved on rerun. +UPDATE accounts +SET extra = jsonb_set( + COALESCE(extra, '{}'::jsonb), + '{codex_fingerprint_seed}', + to_jsonb(gen_random_uuid()::text), + true +) +WHERE deleted_at IS NULL + AND platform = 'openai' + AND type = 'oauth' + AND COALESCE(extra->>'codex_fingerprint_mode', '') IN ('device', 'session', 'full') + AND ( + extra->>'codex_fingerprint_seed' IS NULL + OR btrim(extra->>'codex_fingerprint_seed') = '' + OR NOT ( + extra->>'codex_fingerprint_seed' ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + AND extra->>'codex_fingerprint_seed' <> '00000000-0000-0000-0000-000000000000' + ) + ); diff --git a/backend/migrations/225_channel_model_time_pricing.sql b/backend/migrations/225_channel_model_time_pricing.sql new file mode 100644 index 000000000000..dbfb84cde40b --- /dev/null +++ b/backend/migrations/225_channel_model_time_pricing.sql @@ -0,0 +1,5 @@ +ALTER TABLE channel_model_pricing + ADD COLUMN IF NOT EXISTS time_pricing JSONB NULL; + +COMMENT ON COLUMN channel_model_pricing.time_pricing IS + 'Optional IANA timezone and recurring daily multiplier periods for channel token pricing'; diff --git a/backend/migrations/226_channel_monitor_quota_mode.sql b/backend/migrations/226_channel_monitor_quota_mode.sql new file mode 100644 index 000000000000..a0774e3dbf6e --- /dev/null +++ b/backend/migrations/226_channel_monitor_quota_mode.sql @@ -0,0 +1,78 @@ +-- Migration: 226_channel_monitor_quota_mode +-- 渠道监控配额模式: +-- 1. provider 扩容到全部 8 平台(antigravity/kimi/zhipu/deepseek) +-- (antigravity 仅支持配额模式,无探活 adapter;国产 3 家复用 OpenAI 兼容探活) +-- 2. check_mode:probe(默认,现状探活)/ quota(仅查关联账号用量,零 LLM 成本) +-- / quota_probe(探活 + 配额并存) +-- 3. account_id 关联已有账号(配额模式的数据源,复用账号侧用量服务); +-- 账号删除时置空,监控保留并报「账号未关联」 +-- 4. channel_monitor_histories.quota 持久化归一化配额快照(JSONB) +-- 5. 新增公开设置 channel_monitor_show_quota(默认关闭): +-- 控制用户端监控页是否展示配额/余额;管理端始终可见 + +DO $$ +DECLARE + monitor_constraint_def TEXT; + template_constraint_def TEXT; +BEGIN + SELECT pg_get_constraintdef(c.oid) + INTO monitor_constraint_def + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + WHERE t.relname = 'channel_monitors' + AND c.conname = 'channel_monitors_provider_check'; + + IF monitor_constraint_def IS NULL OR position('kimi' IN monitor_constraint_def) = 0 THEN + ALTER TABLE channel_monitors + DROP CONSTRAINT IF EXISTS channel_monitors_provider_check; + ALTER TABLE channel_monitors + ADD CONSTRAINT channel_monitors_provider_check + CHECK (provider IN ('openai', 'anthropic', 'gemini', 'grok', + 'antigravity', 'kimi', 'zhipu', 'deepseek')); + END IF; + + SELECT pg_get_constraintdef(c.oid) + INTO template_constraint_def + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + WHERE t.relname = 'channel_monitor_request_templates' + AND c.conname = 'channel_monitor_request_templates_provider_check'; + + IF template_constraint_def IS NULL OR position('kimi' IN template_constraint_def) = 0 THEN + ALTER TABLE channel_monitor_request_templates + DROP CONSTRAINT IF EXISTS channel_monitor_request_templates_provider_check; + ALTER TABLE channel_monitor_request_templates + ADD CONSTRAINT channel_monitor_request_templates_provider_check + CHECK (provider IN ('openai', 'anthropic', 'gemini', 'grok', + 'antigravity', 'kimi', 'zhipu', 'deepseek')); + END IF; +END $$; + +ALTER TABLE channel_monitors + ADD COLUMN IF NOT EXISTS check_mode VARCHAR(32) NOT NULL DEFAULT 'probe'; + +ALTER TABLE channel_monitors + ADD CONSTRAINT channel_monitors_check_mode_check + CHECK (check_mode IN ('probe', 'quota', 'quota_probe')); + +ALTER TABLE channel_monitors + ADD COLUMN IF NOT EXISTS account_id BIGINT REFERENCES accounts(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_channel_monitors_account_id ON channel_monitors(account_id); + +COMMENT ON COLUMN channel_monitors.check_mode IS + 'probe = LLM 探活(默认);quota = 仅查关联账号用量;quota_probe = 探活 + 配额'; +COMMENT ON COLUMN channel_monitors.account_id IS + '配额模式关联的账号 ID(数据源);账号删除时置空'; + +ALTER TABLE channel_monitor_histories + ADD COLUMN IF NOT EXISTS quota JSONB; + +COMMENT ON COLUMN channel_monitor_histories.quota IS + '配额模式监控的归一化配额快照(domain.MonitorQuotaSnapshot);探活模式为 NULL'; + +-- 用户端是否展示配额/余额(默认关闭,fail-closed 解析:仅 "true" 视为开启)。 +-- 管理端不受此开关影响。 +INSERT INTO settings (key, value) +VALUES ('channel_monitor_show_quota', 'false') +ON CONFLICT (key) DO NOTHING; diff --git a/backend/migrations/channel_monitor_quota_mode_migration_test.go b/backend/migrations/channel_monitor_quota_mode_migration_test.go new file mode 100644 index 000000000000..69d2dacaef22 --- /dev/null +++ b/backend/migrations/channel_monitor_quota_mode_migration_test.go @@ -0,0 +1,37 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestChannelMonitorQuotaModeMigration(t *testing.T) { + content, err := FS.ReadFile("226_channel_monitor_quota_mode.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + + // provider CHECK 两张表扩到 8 平台,且带幂等守卫(仿 176 grok 迁移)。 + require.Contains(t, sql, "channel_monitors_provider_check") + require.Contains(t, sql, "channel_monitor_request_templates_provider_check") + require.Contains(t, sql, "CHECK (provider IN ('openai', 'anthropic', 'gemini', 'grok', 'antigravity', 'kimi', 'zhipu', 'deepseek'))") + require.Contains(t, sql, "position('kimi' IN monitor_constraint_def) = 0") + require.Contains(t, sql, "position('kimi' IN template_constraint_def) = 0") + + // check_mode 三态,默认 probe。 + require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS check_mode VARCHAR(32) NOT NULL DEFAULT 'probe'") + require.Contains(t, sql, "CHECK (check_mode IN ('probe', 'quota', 'quota_probe'))") + + // account_id 关联账号,账号删除置空(监控保留,运行时报「账号未关联」)。 + require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS account_id BIGINT REFERENCES accounts(id) ON DELETE SET NULL") + require.Contains(t, sql, "CREATE INDEX IF NOT EXISTS idx_channel_monitors_account_id ON channel_monitors(account_id)") + + // 历史表配额快照列。 + require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS quota JSONB") + + // 公开设置默认关闭。 + require.Contains(t, sql, "VALUES ('channel_monitor_show_quota', 'false')") + require.Contains(t, sql, "ON CONFLICT (key) DO NOTHING") +} diff --git a/backend/migrations/user_platform_quota_cn_providers_migration_test.go b/backend/migrations/user_platform_quota_cn_providers_migration_test.go new file mode 100644 index 000000000000..73bfd057677a --- /dev/null +++ b/backend/migrations/user_platform_quota_cn_providers_migration_test.go @@ -0,0 +1,22 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestUserPlatformQuotasCNProvidersMigration 校验 224 号迁移把 kimi/zhipu/deepseek +// 加入 user_platform_quotas.platform 的 CHECK 约束(对照 157 号 grok 迁移)。 +// 约束未放宽时,注册预填充 8 平台默认配额会整条 INSERT 中止 → 新用户零配额行 +// (缺失配额行 = 无限额),管理端设置国产平台配额直接 500。 +func TestUserPlatformQuotasCNProvidersMigration(t *testing.T) { + content, err := FS.ReadFile("224_user_platform_quotas_add_cn_providers.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + require.Contains(t, sql, "DROP CONSTRAINT IF EXISTS user_platform_quotas_platform_check") + require.Contains(t, sql, + "CHECK (platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek'))") +} diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 70cfd9960db6..93242bcb5624 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -7,7 +7,7 @@ # ============================================================================= ARG NODE_IMAGE=node:24-alpine -ARG GOLANG_IMAGE=golang:1.26.5-alpine +ARG GOLANG_IMAGE=golang:1.26.6-alpine ARG ALPINE_IMAGE=alpine:3.20 ARG GOPROXY=https://goproxy.cn,direct ARG GOSUMDB=sum.golang.google.cn diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 05a57f1ede5a..62cf678754b7 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -482,6 +482,7 @@ export async function bulkUpdate( failed: number success_ids?: number[] failed_ids?: number[] + long_context_inherited_count?: number results: Array<{ account_id: number; success: boolean; error?: string }> }> { const payload = Array.isArray(accountIdsOrPayload) @@ -495,6 +496,7 @@ export async function bulkUpdate( failed: number success_ids?: number[] failed_ids?: number[] + long_context_inherited_count?: number results: Array<{ account_id: number; success: boolean; error?: string }> }>('/admin/accounts/bulk-update', payload) return data diff --git a/frontend/src/api/admin/channelMonitor.ts b/frontend/src/api/admin/channelMonitor.ts index 5eb78b798fb0..f3353ebd45c8 100644 --- a/frontend/src/api/admin/channelMonitor.ts +++ b/frontend/src/api/admin/channelMonitor.ts @@ -5,10 +5,57 @@ import { apiClient } from '../client' -export type Provider = 'openai' | 'anthropic' | 'gemini' | 'grok' +export type Provider = + | 'openai' + | 'anthropic' + | 'gemini' + | 'grok' + | 'antigravity' + | 'kimi' + | 'zhipu' + | 'deepseek' export type MonitorStatus = 'operational' | 'degraded' | 'failed' | 'error' export type BodyOverrideMode = 'off' | 'merge' | 'replace' export type APIMode = 'chat_completions' | 'responses' +/** + * probe = LLM 探活(默认);quota = 仅查关联账号用量(零 LLM 成本); + * quota_probe = 探活 + 配额快照挂主模型行。 + */ +export type CheckMode = 'probe' | 'quota' | 'quota_probe' + +/** 配额快照中的单个用量窗口(与后端 domain.MonitorQuotaTier 一致)。 */ +export interface MonitorQuotaTier { + /** 5h | 7d | 7d-sonnet | 7d-fable | 30d | daily | weekly | total */ + window: string + /** 同窗口多档时的机器标识(gemini shared/pro/flash、grok requests/tokens、antigravity 模型名) */ + label?: string + used_percent: number + used?: number + limit?: number + /** RFC3339;空表示无重置时间 */ + reset_at?: string +} + +export interface MonitorBalance { + currency: string + balance: number +} + +/** 归一化配额快照(与后端 domain.MonitorQuotaSnapshot 一致)。 */ +export interface MonitorQuotaSnapshot { + /** usage | cn_quota | cn_balance */ + source: string + success: boolean + tiers?: MonitorQuotaTier[] + balance?: number | null + balances?: MonitorBalance[] + currency?: string + plan_level?: string + /** 401/403 鉴权失败标记(推导为 failed 状态) */ + credential_invalid?: boolean + error?: string + fetched_at: string +} export interface ChannelMonitor { id: number @@ -47,6 +94,12 @@ export interface ChannelMonitor { extra_headers: Record body_override_mode: BodyOverrideMode body_override: Record | null + /** 检测模式:probe(默认)/ quota / quota_probe */ + check_mode: CheckMode + /** 配额模式关联的账号 ID;探活模式为 null */ + account_id: number | null + /** 主模型最近一次配额快照(配额模式;无历史时为 null) */ + latest_quota?: MonitorQuotaSnapshot | null } export interface ExtraModelStatus { @@ -75,8 +128,14 @@ export interface CreateParams { name: string provider: Provider api_mode?: APIMode + /** 探活模式必填(base origin);quota 模式可留空 */ endpoint: string + /** 探活模式必填;quota 模式可留空 */ api_key: string + /** 缺省 probe;antigravity 仅支持 quota */ + check_mode?: CheckMode + /** 配额模式必填:数据源账号(provider 需与账号平台一致) */ + account_id?: number | null primary_model: string extra_models?: string[] group_name?: string @@ -101,6 +160,8 @@ export interface CheckResult { ping_latency_ms: number | null message: string checked_at: string + /** 配额模式(quota / quota_probe 主模型行)附带的配额快照 */ + quota?: MonitorQuotaSnapshot | null } export interface RunNowResponse { @@ -115,6 +176,8 @@ export interface HistoryItem { ping_latency_ms: number | null message: string checked_at: string + /** 配额快照(配额模式行;探活行为空) */ + quota?: MonitorQuotaSnapshot | null } export interface HistoryParams { diff --git a/frontend/src/api/admin/channels.ts b/frontend/src/api/admin/channels.ts index fdbeadf57a9a..6556417ced89 100644 --- a/frontend/src/api/admin/channels.ts +++ b/frontend/src/api/admin/channels.ts @@ -21,6 +21,17 @@ export interface PricingInterval { sort_order: number } +export interface ChannelTimePricingPeriod { + start_time: string + end_time: string + multiplier: number +} + +export interface ChannelTimePricing { + timezone: string + periods: ChannelTimePricingPeriod[] +} + export interface ChannelModelPricing { id?: number platform: string @@ -34,6 +45,7 @@ export interface ChannelModelPricing { image_output_price: number | null per_request_price: number | null intervals: PricingInterval[] + time_pricing: ChannelTimePricing | null } export interface AccountStatsPricingRule { diff --git a/frontend/src/api/admin/cnProviders.ts b/frontend/src/api/admin/cnProviders.ts new file mode 100644 index 000000000000..668f67a73ee9 --- /dev/null +++ b/frontend/src/api/admin/cnProviders.ts @@ -0,0 +1,70 @@ +/** + * Admin CN providers (Kimi / Zhipu / DeepSeek) API endpoints. + * Coding-plan rolling-window quota probe + payg balance probe. + */ + +import { apiClient } from '../client' + +/** 滚动用量窗口档(5 小时 / 每周),对齐后端 service.CNQuotaTier。 */ +export interface CNQuotaTier { + window: '5h' | 'weekly' + used_percent: number + reset_at?: string +} + +/** Coding Plan 额度探测结果(kimi / zhipu),对齐后端 CNProviderQuotaProbeResult。 */ +export interface CNProviderQuotaProbeResult { + provider: string + source?: string + success: boolean + credential_valid: boolean + tiers?: CNQuotaTier[] + plan_level?: string + status_code?: number + fetched_at: number + persisted: boolean + error?: string +} + +/** 单币种余额明细(deepseek 双币种账号含 CNY + USD 两条)。 */ +export interface CNProviderBalanceEntry { + currency: string + balance: number +} + +/** payg 余额探测结果(kimi / deepseek),对齐后端 CNProviderBalanceResult。 */ +export interface CNProviderBalanceResult { + provider: string + success: boolean + /** 主币种余额(balances 首条,兼容单币种展示)。 */ + balance: number + currency?: string + /** 多币种明细;缺省时按主币种展示。 */ + balances?: CNProviderBalanceEntry[] + available: boolean + status_code?: number + fetched_at: number + persisted: boolean + error?: string +} + +/** 查询 Coding Plan 滚动窗口用量(5h + weekly)。 */ +export async function queryQuota(id: number): Promise { + const { data } = await apiClient.get( + `/admin/cn-providers/accounts/${id}/quota` + ) + return data +} + +/** 查询 payg 账号余额。 */ +export async function queryBalance(id: number): Promise { + const { data } = await apiClient.get( + `/admin/cn-providers/accounts/${id}/balance` + ) + return data +} + +export default { + queryQuota, + queryBalance +} diff --git a/frontend/src/api/admin/index.ts b/frontend/src/api/admin/index.ts index 80a7073decbd..dd976daad54c 100644 --- a/frontend/src/api/admin/index.ts +++ b/frontend/src/api/admin/index.ts @@ -18,6 +18,7 @@ import usageAPI from './usage' import geminiAPI from './gemini' import antigravityAPI from './antigravity' import grokAPI from './grok' +import cnProvidersAPI from './cnProviders' import userAttributesAPI from './userAttributes' import opsAPI from './ops' import errorPassthroughAPI from './errorPassthrough' @@ -54,6 +55,7 @@ export const adminAPI = { gemini: geminiAPI, antigravity: antigravityAPI, grok: grokAPI, + cnProviders: cnProvidersAPI, userAttributes: userAttributesAPI, ops: opsAPI, errorPassthrough: errorPassthroughAPI, @@ -88,6 +90,7 @@ export { geminiAPI, antigravityAPI, grokAPI, + cnProvidersAPI, userAttributesAPI, opsAPI, errorPassthroughAPI, diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index b176024ae6bc..f5f19918a864 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -36,13 +36,19 @@ export type SchedulingThresholdPlatformType = | "openai" | "anthropic" | "grok" + | "kimi" + | "zhipu" export type AccountSchedulingThresholdsMap = Record +// 与后端 AllowedSchedulingThresholdPlatforms 保持一致(deepseek 为余额型, +// 走余额检测而非用量阈值)。 export const SCHEDULING_THRESHOLD_PLATFORMS: SchedulingThresholdPlatformType[] = [ "openai", "anthropic", "grok", + "kimi", + "zhipu", ] export function normalizeAccountSchedulingThresholdsMap( @@ -712,6 +718,7 @@ export interface SystemSettings { channel_monitor_mode?: 'v1' | 'v2'; channel_monitor_default_interval_seconds: number; channel_monitor_hide_throughput?: boolean; + channel_monitor_show_quota?: boolean; // Available Channels feature switch available_channels_enabled: boolean; @@ -1010,6 +1017,7 @@ export interface UpdateSettingsRequest { channel_monitor_mode?: 'v1' | 'v2'; channel_monitor_default_interval_seconds?: number; channel_monitor_hide_throughput?: boolean; + channel_monitor_show_quota?: boolean; // Available Channels feature switch available_channels_enabled?: boolean; diff --git a/frontend/src/api/channelMonitor.ts b/frontend/src/api/channelMonitor.ts index 38dd0c99a8ba..68076db77de0 100644 --- a/frontend/src/api/channelMonitor.ts +++ b/frontend/src/api/channelMonitor.ts @@ -4,7 +4,7 @@ */ import { apiClient } from './client' -import type { Provider, MonitorStatus } from './admin/channelMonitor' +import type { MonitorQuotaSnapshot, Provider, MonitorStatus } from './admin/channelMonitor' export type { Provider, MonitorStatus } from './admin/channelMonitor' @@ -33,6 +33,11 @@ export interface UserMonitorView { availability_7d: number extra_models: UserMonitorExtraModel[] timeline: MonitorTimelinePoint[] + /** + * 主模型最近配额快照。仅当系统开启 channel_monitor_show_quota 时 + * 服务端才会下发(关闭时服务端已剥离,前端 flag 仅作纵深防御)。 + */ + latest_quota?: MonitorQuotaSnapshot | null } export interface UserMonitorListResponse { diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 485b4b870102..966b0a5e084f 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -365,6 +365,7 @@ label="24h" :title="t('admin.accounts.usageWindow.grokFreeQuota24hHint', { limit: formatCompactNumber(grokFreeTokenBar.limit) })" :utilization="grokFreeTokenBar.utilization" + :window-stats="grokFreeQuotaUsage" :show-now-when-idle="true" color="emerald" /> @@ -378,6 +379,7 @@ label="7d" :utilization="grokWeeklyBillingBar.utilization" :resets-at="grokWeeklyBillingBar.resetsAt" + :window-stats="grokWeeklyBillingBar.windowStats" :show-now-when-idle="true" color="indigo" /> @@ -386,6 +388,7 @@ label="30d" :utilization="grokMonthlyBillingBar.utilization" :resets-at="grokMonthlyBillingBar.resetsAt" + :window-stats="grokMonthlyBillingBar.windowStats" :show-now-when-idle="true" color="indigo" /> @@ -394,12 +397,16 @@ class="flex flex-wrap items-center gap-1 text-[10px] text-gray-500 dark:text-gray-400" > {{ t('admin.accounts.usageWindow.grokPrepaid') }} ${{ grokPrepaidMoneyLine.prepaid }} - + {{ t('admin.accounts.usageWindow.grokUsed') }} {{ grokPrepaidMoneyLine.used }}/{{ grokPrepaidMoneyLine.limit }} @@ -422,6 +429,21 @@ + + + diff --git a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts index ff0e94d6647e..1ad6866dd7e1 100644 --- a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts +++ b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts @@ -72,7 +72,7 @@ describe('AccountUsageCell', () => { }) }) - it('renders eligible Ollama Cloud state inside the unified usage cell', () => { + it('renders eligible Ollama Cloud state and forwards query updates', async () => { const wrapper = mount(AccountUsageCell, { props: { account: makeAccount({ @@ -101,7 +101,8 @@ describe('AccountUsageCell', () => { stubs: { OllamaCloudUsageCell: { props: ['account'], - template: '
{{ account.ollama_cloud_usage.snapshot.data.five_hour.used_percent }}
' + emits: ['updated'], + template: '' }, UsageProgressBar: true, AccountQuotaInfo: true @@ -111,6 +112,12 @@ describe('AccountUsageCell', () => { expect(wrapper.get('[data-test="embedded-ollama"]').text()).toBe('12') expect(getUsage).not.toHaveBeenCalled() + + await wrapper.get('[data-test="embedded-ollama"]').trigger('click') + + const updatedAccount = wrapper.emitted('account-updated')?.[0]?.[0] + expect(updatedAccount?.id).toBe(9001) + expect(updatedAccount?.ollama_cloud_usage?.auto_refresh_enabled).toBe(false) }) it('Antigravity 图片用量会聚合新旧 image 模型', async () => { @@ -1010,6 +1017,245 @@ describe('AccountUsageCell', () => { expect(wrapper.text()).toContain('24h|100') }) + it('Grok Free 24h bar shows rolling local usage chips', async () => { + getUsage.mockResolvedValue({ + grok_free_token_limit: 1_000_000, + grok_billing: { period_type: 'weekly', usage_percent: null, plan: '' }, + grok_local_usage: { + requests: 2, + tokens: 250_000, + cost: 0, + standard_cost: 0 + }, + grok_local_usage_24h: { + requests: 12, + tokens: 750_000, + cost: 0.12, + standard_cost: 0.12, + user_cost: 0.04 + } + }) + + const wrapper = mount(AccountUsageCell, { + props: { + account: makeAccount({ id: 4410, platform: 'grok', type: 'oauth', extra: {} }) + }, + global: { + stubs: { + UsageProgressBar: { + props: ['label', 'utilization', 'windowStats'], + template: '
{{ label }}|{{ utilization }}|{{ windowStats?.tokens }}
' + }, + AccountQuotaInfo: true + } + } + }) + + await flushPromises() + expect(wrapper.text()).toContain('24h|75|750000') + expect(wrapper.text()).not.toContain('|250000') + expect(wrapper.text()).not.toContain('7d|') + }) + + it('Grok SuperGrok and Heavy bars show period-aligned local 7d and 30d usage', async () => { + getUsage.mockResolvedValue({ + subscription_tier: 'SuperGrok Heavy', + grok_billing: { + period_type: 'weekly', + usage_percent: 37, + used_percent: 12, + monthly_limit_cents: 150_000, + period_end: '2026-07-16T03:25:00Z', + billing_period_end: '2026-08-01T00:00:00Z', + plan: 'SuperGrok Heavy' + }, + grok_local_usage: { + requests: 1, + tokens: 99, + cost: 0, + standard_cost: 0 + }, + grok_local_usage_24h: { + requests: 2, + tokens: 100, + cost: 0, + standard_cost: 0 + }, + grok_local_usage_7d: { + requests: 8, + tokens: 2_200_000, + cost: 4.42, + standard_cost: 4.42 + }, + grok_local_usage_monthly: { + requests: 20, + tokens: 8_000_000, + cost: 18.5, + standard_cost: 18.5 + } + }) + + const wrapper = mount(AccountUsageCell, { + props: { + account: makeAccount({ id: 4411, platform: 'grok', type: 'oauth', extra: {} }) + }, + global: { + stubs: { + UsageProgressBar: { + props: ['label', 'utilization', 'windowStats'], + template: '
{{ label }}|{{ utilization }}|{{ windowStats?.tokens }}
' + }, + AccountQuotaInfo: true + } + } + }) + + await flushPromises() + expect(wrapper.text()).toContain('7d|37|2200000') + expect(wrapper.text()).toContain('30d|12|8000000') + expect(wrapper.text()).not.toContain('|99') + expect(wrapper.text()).not.toContain('|100') + expect(wrapper.text()).not.toContain('24h|') + }) + + it('Grok paid bars fall back to official seven_day and thirty_day window_stats', async () => { + getUsage.mockResolvedValue({ + subscription_tier: 'SuperGrok', + grok_billing: { + period_type: 'weekly', + usage_percent: 20, + used_percent: 8, + monthly_limit_cents: 25_000, + plan: 'SuperGrok' + }, + seven_day: { + utilization: 20, + window_stats: { + requests: 6, + tokens: 1_500_000, + cost: 3.1, + standard_cost: 3.1 + } + }, + thirty_day: { + utilization: 8, + window_stats: { + requests: 14, + tokens: 4_400_000, + cost: 9.2, + standard_cost: 9.2 + } + } + }) + + const wrapper = mount(AccountUsageCell, { + props: { + account: makeAccount({ id: 4412, platform: 'grok', type: 'oauth', extra: {} }) + }, + global: { + stubs: { + UsageProgressBar: { + props: ['label', 'utilization', 'windowStats'], + template: '
{{ label }}|{{ utilization }}|{{ windowStats?.tokens }}
' + }, + AccountQuotaInfo: true + } + } + }) + + await flushPromises() + expect(wrapper.text()).toContain('7d|20|1500000') + expect(wrapper.text()).toContain('30d|8|4400000') + }) + + it('Grok paid hides zero prepaid and hides used/limit when monthly limit is 0', async () => { + getUsage.mockResolvedValue({ + subscription_tier: 'SuperGrok', + grok_billing: { + period_type: 'weekly', + usage_percent: 20, + prepaid_balance: 0, + monthly_limit: 0, + monthly_used: 3.5, + plan: 'SuperGrok' + } + }) + + const wrapper = mount(AccountUsageCell, { + props: { + account: makeAccount({ id: 4413, platform: 'grok', type: 'oauth', extra: {} }) + }, + global: { + stubs: { + UsageProgressBar: true, + AccountQuotaInfo: true + } + } + }) + + await flushPromises() + expect(wrapper.text()).not.toContain('admin.accounts.usageWindow.grokPrepaid') + expect(wrapper.text()).not.toContain('admin.accounts.usageWindow.grokUsed') + expect(wrapper.text()).not.toContain('3.5/0') + }) + + it('Grok paid shows used/limit without prepaid, and prepaid without a zero monthly limit', async () => { + getUsage.mockResolvedValueOnce({ + subscription_tier: 'SuperGrok', + grok_billing: { + period_type: 'weekly', + usage_percent: 20, + monthly_limit: 25, + monthly_used: 3.5, + plan: 'SuperGrok' + } + }) + + const usedOnly = mount(AccountUsageCell, { + props: { + account: makeAccount({ id: 4414, platform: 'grok', type: 'oauth', extra: {} }) + }, + global: { + stubs: { + UsageProgressBar: true, + AccountQuotaInfo: true + } + } + }) + await flushPromises() + expect(usedOnly.text()).not.toContain('admin.accounts.usageWindow.grokPrepaid') + expect(usedOnly.text()).toContain('admin.accounts.usageWindow.grokUsed') + expect(usedOnly.text()).toContain('3.50/25.0') + + getUsage.mockResolvedValueOnce({ + subscription_tier: 'SuperGrok Heavy', + grok_billing: { + period_type: 'weekly', + usage_percent: 20, + prepaid_balance: 12.5, + monthly_limit: 0, + monthly_used: 8, + plan: 'SuperGrok Heavy' + } + }) + const prepaidOnly = mount(AccountUsageCell, { + props: { + account: makeAccount({ id: 4415, platform: 'grok', type: 'oauth', extra: {} }) + }, + global: { + stubs: { + UsageProgressBar: true, + AccountQuotaInfo: true + } + } + }) + await flushPromises() + expect(prepaidOnly.text()).toContain('admin.accounts.usageWindow.grokPrepaid') + expect(prepaidOnly.text()).toContain('$12.5') + expect(prepaidOnly.text()).not.toContain('admin.accounts.usageWindow.grokUsed') + expect(prepaidOnly.text()).not.toContain('8.00/0') + }) + it('Key 账号在 today stats loading 时显示骨架屏', async () => { const wrapper = mount(AccountUsageCell, { props: { diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts index 45b8473cbf4f..dec8be054a92 100644 --- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts @@ -1,17 +1,20 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' +import { nextTick } from 'vue' import BulkEditAccountModal from '../BulkEditAccountModal.vue' import ModelWhitelistSelector from '../ModelWhitelistSelector.vue' import { adminAPI } from '@/api/admin' -const { showError } = vi.hoisted(() => ({ - showError: vi.fn() +const { showError, showSuccess, translate } = vi.hoisted(() => ({ + showError: vi.fn(), + showSuccess: vi.fn(), + translate: vi.fn((key: string) => key) })) vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showError, - showSuccess: vi.fn(), + showSuccess, showInfo: vi.fn() }) })) @@ -34,7 +37,7 @@ vi.mock('vue-i18n', async () => { return { ...actual, useI18n: () => ({ - t: (key: string) => key + t: translate }) } }) @@ -82,6 +85,8 @@ describe('BulkEditAccountModal', () => { vi.mocked(adminAPI.accounts.bulkUpdate).mockReset() vi.mocked(adminAPI.accounts.checkMixedChannelRisk).mockReset() showError.mockReset() + showSuccess.mockReset() + translate.mockClear() vi.mocked(adminAPI.accounts.bulkUpdate).mockResolvedValue({ success: 2, @@ -403,6 +408,302 @@ describe('BulkEditAccountModal', () => { }) }) + it('OpenAI 支持类型展示长上下文设置,混合平台隐藏全部新增设置', () => { + for (const selectedTypes of [['oauth'], ['setup-token'], ['apikey'], ['oauth', 'setup-token', 'apikey']]) { + const wrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes + }) + expect(wrapper.find('#bulk-edit-openai-long-context-billing-enabled').exists()).toBe(true) + wrapper.unmount() + } + + const mixed = mountModal({ + selectedPlatforms: ['openai', 'anthropic'], + selectedTypes: ['apikey'] + }) + expect(mixed.find('#bulk-edit-openai-long-context-billing-enabled').exists()).toBe(false) + expect(mixed.find('#bulk-edit-openai-endpoint-capabilities-enabled').exists()).toBe(false) + expect(mixed.find('#bulk-edit-openai-responses-mode-enabled').exists()).toBe(false) + }) + + it('端点能力与 Responses 路由仅对全部 OpenAI API Key 目标展示', () => { + const apiKey = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['apikey'] + }) + expect(apiKey.find('#bulk-edit-openai-endpoint-capabilities-enabled').exists()).toBe(true) + expect(apiKey.find('#bulk-edit-openai-responses-mode-enabled').exists()).toBe(true) + apiKey.unmount() + + const oauth = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['oauth'] + }) + expect(oauth.find('#bulk-edit-openai-endpoint-capabilities-enabled').exists()).toBe(false) + expect(oauth.find('#bulk-edit-openai-responses-mode-enabled').exists()).toBe(false) + }) + + it('长上下文设置独立启用并提交布尔值', async () => { + const enabledWrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['oauth'] + }) + + await enabledWrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true) + await enabledWrapper.get('[data-testid="bulk-edit-openai-long-context-billing-toggle"]').trigger('click') + await enabledWrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).toHaveBeenLastCalledWith([1, 2], { + extra: { openai_long_context_billing_enabled: true } + }) + enabledWrapper.unmount() + + vi.mocked(adminAPI.accounts.bulkUpdate).mockClear() + const disabledWrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['setup-token'] + }) + await disabledWrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true) + await disabledWrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + extra: { openai_long_context_billing_enabled: false } + }) + }) + + it('端点能力默认值提交 null,表示恢复两个默认端点', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['apikey'] + }) + + await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(true) + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + credentials: { openai_capabilities: null } + }) + }) + + it('Responses 路由独立启用,auto 提交 null,强制模式提交明确值', async () => { + const autoWrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['apikey'] + }) + await autoWrapper.get('#bulk-edit-openai-responses-mode-enabled').setValue(true) + await autoWrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + expect(adminAPI.accounts.bulkUpdate).toHaveBeenLastCalledWith([1, 2], { + extra: { openai_responses_mode: null } + }) + autoWrapper.unmount() + + vi.mocked(adminAPI.accounts.bulkUpdate).mockClear() + const forcedWrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['apikey'] + }) + await forcedWrapper.get('#bulk-edit-openai-responses-mode-enabled').setValue(true) + await forcedWrapper.get('[data-testid="bulk-edit-openai-responses-mode-select"]').setValue('force_responses') + await forcedWrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + extra: { openai_responses_mode: 'force_responses' } + }) + }) + + it('仅启用 Embeddings 时恢复 Responses 自动模式并精确提交联动字段', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['apikey'] + }) + + await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(true) + await wrapper.get('#bulk-edit-openai-responses-mode-enabled').setValue(true) + await wrapper.get('[data-testid="bulk-edit-openai-responses-mode-select"]').setValue('force_chat_completions') + await wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-chat_completions"]').setValue(false) + + expect((wrapper.get('[data-testid="bulk-edit-openai-responses-mode-select"]').element as HTMLSelectElement).value) + .toBe('auto') + expect(wrapper.find('[data-testid="bulk-edit-openai-responses-mode-not-applicable"]').exists()).toBe(true) + + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + credentials: { openai_capabilities: ['embeddings'] }, + extra: { openai_responses_mode: null } + }) + }) + + it('关闭端点能力修改后 Responses 路由恢复独立可编辑', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['apikey'] + }) + + await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(true) + await wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-chat_completions"]').setValue(false) + await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(false) + await wrapper.get('#bulk-edit-openai-responses-mode-enabled').setValue(true) + + const select = wrapper.get('[data-testid="bulk-edit-openai-responses-mode-select"]') + expect(select.attributes('disabled')).toBeUndefined() + await select.setValue('force_responses') + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + extra: { openai_responses_mode: 'force_responses' } + }) + }) + + it('目标变化后不提交已经隐藏的 OpenAI 设置', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['apikey'] + }) + + await wrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true) + await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(true) + await wrapper.get('#bulk-edit-openai-responses-mode-enabled').setValue(true) + await wrapper.setProps({ selectedPlatforms: ['anthropic'], selectedTypes: ['apikey'] }) + await wrapper.get('#bulk-edit-status-enabled').setValue(true) + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + status: 'active' + }) + }) + + it('至少保留一个端点能力', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['apikey'] + }) + await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(true) + await wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-chat_completions"]').setValue(false) + await wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-embeddings"]').setValue(false) + + expect((wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-embeddings"]').element as HTMLInputElement).checked) + .toBe(true) + }) + + it('关闭弹窗后重置新增设置的启用状态和值', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['apikey'] + }) + await wrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true) + await wrapper.get('[data-testid="bulk-edit-openai-long-context-billing-toggle"]').trigger('click') + await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(true) + await wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-chat_completions"]').setValue(false) + await wrapper.get('#bulk-edit-openai-responses-mode-enabled').setValue(true) + + await wrapper.setProps({ show: false }) + await nextTick() + + expect((wrapper.get('#bulk-edit-openai-long-context-billing-enabled').element as HTMLInputElement).checked).toBe(false) + expect(wrapper.get('[data-testid="bulk-edit-openai-long-context-billing-toggle"]').attributes('aria-checked')).toBe('false') + expect((wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').element as HTMLInputElement).checked).toBe(false) + expect((wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-chat_completions"]').element as HTMLInputElement).checked).toBe(true) + expect((wrapper.get('[data-testid="bulk-edit-openai-responses-mode-select"]').element as HTMLSelectElement).value).toBe('auto') + }) + + it('筛选全量模式固定展示影子继承说明并按 filters 提交', async () => { + const wrapper = mountModal({ + accountIds: [], + selectedPlatforms: [], + selectedTypes: [], + target: { + mode: 'filtered', + filters: { platform: 'openai', type: 'oauth', status: 'active' }, + previewCount: 20, + selectedPlatforms: ['openai'], + selectedTypes: ['oauth'] + } + }) + + expect(wrapper.get('[data-testid="bulk-edit-openai-long-context-shadow-hint"]').text()) + .toContain('admin.accounts.bulkEdit.longContextShadowHint') + await wrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true) + await wrapper.get('[data-testid="bulk-edit-openai-long-context-billing-toggle"]').trigger('click') + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith({ + filters: { platform: 'openai', type: 'oauth', status: 'active' }, + extra: { openai_long_context_billing_enabled: true } + }) + }) + + it('成功响应包含影子继承数量时展示专用提示', async () => { + vi.mocked(adminAPI.accounts.bulkUpdate).mockResolvedValueOnce({ + success: 2, + failed: 0, + long_context_inherited_count: 1, + results: [] + } as any) + const wrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['oauth'] + }) + await wrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true) + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(showSuccess).toHaveBeenCalledWith('admin.accounts.bulkEdit.successWithInherited') + expect(translate).toHaveBeenCalledWith('admin.accounts.bulkEdit.successWithInherited', { + count: 2, + inherited: 1 + }) + }) + + it('部分成功且包含影子继承数量时展示组合提示', async () => { + vi.mocked(adminAPI.accounts.bulkUpdate).mockResolvedValueOnce({ + success: 1, + failed: 1, + long_context_inherited_count: 1, + results: [] + } as any) + const wrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['oauth'] + }) + await wrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true) + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(showError).toHaveBeenCalledWith('admin.accounts.bulkEdit.partialSuccessWithInherited') + expect(translate).toHaveBeenCalledWith('admin.accounts.bulkEdit.partialSuccessWithInherited', { + success: 1, + failed: 1, + inherited: 1 + }) + }) + + it('全影子长上下文错误使用专用提示并保持弹窗打开', async () => { + vi.mocked(adminAPI.accounts.bulkUpdate).mockRejectedValueOnce({ + status: 400, + reason: 'OPENAI_LONG_CONTEXT_PARENT_REQUIRED', + message: 'select parent' + }) + const wrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['oauth'] + }) + await wrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true) + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(showError).toHaveBeenCalledWith('admin.accounts.bulkEdit.longContextParentRequired') + expect(wrapper.emitted('close')).toBeUndefined() + }) + it('OpenAI API Key 批量编辑可统一开启上游倍率自动探测', async () => { const wrapper = mountModal({ selectedPlatforms: ['openai'], diff --git a/frontend/src/components/account/__tests__/OllamaCloudUsageCell.spec.ts b/frontend/src/components/account/__tests__/OllamaCloudUsageCell.spec.ts index 5f6dfee054ad..e38f3d3729ff 100644 --- a/frontend/src/components/account/__tests__/OllamaCloudUsageCell.spec.ts +++ b/frontend/src/components/account/__tests__/OllamaCloudUsageCell.spec.ts @@ -1,9 +1,21 @@ -import { mount } from '@vue/test-utils' -import { describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' import OllamaCloudUsageCell from '../OllamaCloudUsageCell.vue' import UsageProgressBar from '../UsageProgressBar.vue' import type { Account, OllamaCloudUsageState } from '@/types' +const { refreshOllamaCloudUsage } = vi.hoisted(() => ({ + refreshOllamaCloudUsage: vi.fn() +})) + +vi.mock('@/api/admin', () => ({ + adminAPI: { + accounts: { + refreshOllamaCloudUsage + } + } +})) + vi.mock('vue-i18n', async () => { const actual = await vi.importActual('vue-i18n') return { @@ -64,7 +76,11 @@ const account = (state = usageState()): Account => ({ }) describe('OllamaCloudUsageCell', () => { - it('renders only native 5h and 7d windows in a shrinkable mobile-safe cell', () => { + beforeEach(() => { + refreshOllamaCloudUsage.mockReset() + }) + + it('renders native 5h and 7d windows with the configured-account query action', () => { const wrapper = mount(OllamaCloudUsageCell, { props: { account: account() } }) const cell = wrapper.get('[data-testid="ollama-cloud-usage-cell"]') expect(cell.classes()).toEqual(expect.arrayContaining(['min-w-0', 'max-w-full'])) @@ -84,14 +100,15 @@ describe('OllamaCloudUsageCell', () => { }) expect(wrapper.find('[data-testid="ollama-cloud-usage-details"]').exists()).toBe(false) - expect(wrapper.find('[data-testid="ollama-cloud-usage-refresh"]').exists()).toBe(false) - expect(wrapper.findAll('button')).toHaveLength(0) + const query = wrapper.get('[data-testid="ollama-cloud-usage-query"]') + expect(query.classes()).toEqual(expect.arrayContaining(['text-blue-600', 'hover:bg-blue-50'])) + expect(query.text()).toContain('admin.accounts.usageWindow.activeQuery') expect(wrapper.text()).not.toContain('max') expect(wrapper.text()).not.toContain('$0') expect(wrapper.text()).not.toContain('gpt-oss:120b-cloud') }) - it('reacts to an account snapshot update without a list-cell refresh action', async () => { + it('reacts to an account snapshot update', async () => { const wrapper = mount(OllamaCloudUsageCell, { props: { account: account() } }) const next = usageState() next.snapshot!.data!.five_hour!.used_percent = 43 @@ -99,6 +116,28 @@ describe('OllamaCloudUsageCell', () => { await wrapper.setProps({ account: account(next) }) expect(wrapper.findAllComponents(UsageProgressBar)[0].props('utilization')).toBe(43) - expect(wrapper.findAll('button')).toHaveLength(0) + }) + + it('queries through the edit-page refresh endpoint and emits the updated state', async () => { + const next = usageState() + next.snapshot!.data!.five_hour!.used_percent = 43 + refreshOllamaCloudUsage.mockResolvedValueOnce(next) + const wrapper = mount(OllamaCloudUsageCell, { props: { account: account() } }) + + await wrapper.get('[data-testid="ollama-cloud-usage-query"]').trigger('click') + await flushPromises() + + expect(refreshOllamaCloudUsage).toHaveBeenCalledWith(7) + expect(wrapper.findAllComponents(UsageProgressBar)[0].props('utilization')).toBe(43) + expect(wrapper.emitted('updated')?.[0]?.[0]).toEqual(next) + }) + + it('hides the query action until a browser session is configured', () => { + const state = usageState() + state.configured = false + + const wrapper = mount(OllamaCloudUsageCell, { props: { account: account(state) } }) + + expect(wrapper.find('[data-testid="ollama-cloud-usage-query"]').exists()).toBe(false) }) }) diff --git a/frontend/src/components/account/credentialsBuilder.ts b/frontend/src/components/account/credentialsBuilder.ts index 32214152506b..054fd4623898 100644 --- a/frontend/src/components/account/credentialsBuilder.ts +++ b/frontend/src/components/account/credentialsBuilder.ts @@ -242,6 +242,91 @@ export const GROK_BASE_URL_PRESETS: GrokBaseUrlPreset[] = [ { label: 'eu-west-1', url: 'https://eu-west-1.api.x.ai/v1' } ] +// ========== 国产供应商(Kimi / Zhipu / DeepSeek)base_url 预设 ========== +// 与后端 service/domain_constants.go 的默认 base url 保持一致。 +// 账号类型(payg 按量付费 / coding 编程套餐)决定额度监控方式; +// API 协议(chat_completions / anthropic / responses)决定转发端点与格式, +// 两者正交。同协议请求零转换直通,跨协议组合才走转换链。 + +export type CnAccountMode = 'payg' | 'coding' + +/** 仅 deepseek 支持 responses 协议(官方原生 /responses 端点,适配 Codex)。 */ +export type CnApiProtocol = 'chat_completions' | 'anthropic' | 'responses' + +export interface CnBaseUrlPreset { + mode: CnAccountMode + protocol: CnApiProtocol + /** 专有名词,不参与 i18n */ + label: string + url: string +} + +/** 各供应商按账号类型 × API 协议分档的快捷端点(点击快速填充,输入框仍可自由填写)。 */ +export const CN_BASE_URL_PRESETS: Record<'kimi' | 'zhipu' | 'deepseek', CnBaseUrlPreset[]> = { + kimi: [ + { mode: 'payg', protocol: 'chat_completions', label: 'Moonshot', url: 'https://api.moonshot.cn/v1' }, + { mode: 'payg', protocol: 'anthropic', label: 'Moonshot Anthropic', url: 'https://api.moonshot.cn/anthropic' }, + { mode: 'coding', protocol: 'chat_completions', label: 'Kimi For Coding', url: 'https://api.kimi.com/coding/v1' }, + { mode: 'coding', protocol: 'anthropic', label: 'Kimi Coding Anthropic', url: 'https://api.kimi.com/coding' } + ], + zhipu: [ + { mode: 'payg', protocol: 'chat_completions', label: 'GLM PaaS', url: 'https://open.bigmodel.cn/api/paas/v4' }, + { mode: 'payg', protocol: 'anthropic', label: 'GLM Anthropic', url: 'https://open.bigmodel.cn/api/anthropic' }, + { mode: 'coding', protocol: 'chat_completions', label: 'GLM Coding', url: 'https://open.bigmodel.cn/api/coding/paas/v4' }, + { mode: 'coding', protocol: 'anthropic', label: 'GLM Coding Anthropic', url: 'https://open.bigmodel.cn/api/anthropic' } + ], + deepseek: [ + { mode: 'payg', protocol: 'chat_completions', label: 'DeepSeek', url: 'https://api.deepseek.com' }, + { mode: 'payg', protocol: 'anthropic', label: 'DeepSeek Anthropic', url: 'https://api.deepseek.com/anthropic' }, + { mode: 'payg', protocol: 'responses', label: 'DeepSeek Responses', url: 'https://api.deepseek.com' } + ] +} + +/** 返回指定供应商 + 账号类型 + API 协议的默认 base url。 */ +export function defaultCNBaseUrl( + platform: string, + mode: CnAccountMode, + protocol: CnApiProtocol = 'chat_completions' +): string { + if (protocol === 'anthropic') { + switch (platform) { + case 'kimi': + return mode === 'coding' ? 'https://api.kimi.com/coding' : 'https://api.moonshot.cn/anthropic' + case 'zhipu': + return 'https://open.bigmodel.cn/api/anthropic' + case 'deepseek': + return 'https://api.deepseek.com/anthropic' + default: + return '' + } + } + // responses 仅 deepseek:base 与 chat_completions 相同(端点路径差异由后端处理)。 + switch (platform) { + case 'kimi': + return mode === 'coding' ? 'https://api.kimi.com/coding/v1' : 'https://api.moonshot.cn/v1' + case 'zhipu': + return mode === 'coding' + ? 'https://open.bigmodel.cn/api/coding/paas/v4' + : 'https://open.bigmodel.cn/api/paas/v4' + case 'deepseek': + return 'https://api.deepseek.com' + default: + return '' + } +} + +// ===== 国产供应商用量单元格可见性(单一事实源) ===== +// CNProviderQuotaCell / CNProviderBalanceCell 与 AccountUsageCell 的占位符判定 +// 共用,避免多处复制条件后一处改另一处漏改。 + +export function cnQuotaCellVisible(platform: string, accountMode: string): boolean { + return (platform === 'kimi' || platform === 'zhipu') && accountMode === 'coding' +} + +export function cnBalanceCellVisible(platform: string, accountMode: string): boolean { + return (platform === 'kimi' || platform === 'deepseek') && accountMode !== 'coding' +} + /** * 将请求头覆写写入 credentials。 * create 模式:关闭时不写入任何字段;edit 模式:关闭时删除字段(全量替换语义)。 diff --git a/frontend/src/components/admin/channel/PricingEntryCard.vue b/frontend/src/components/admin/channel/PricingEntryCard.vue index d09e19feb296..f2a0d6502bc7 100644 --- a/frontend/src/components/admin/channel/PricingEntryCard.vue +++ b/frontend/src/components/admin/channel/PricingEntryCard.vue @@ -87,7 +87,12 @@ + +
+ +
+ +
+
+
@@ -30,7 +51,34 @@
-
+ +
+ +
+ @@ -58,7 +106,7 @@
-
+
@@ -77,7 +125,7 @@

{{ editing.api_key_masked }}

-
+
-
+
- -
+ +
{{ t('admin.channelMonitor.advanced.section') }} @@ -197,6 +245,7 @@ import type { ChannelMonitor, CreateParams, APIMode, + CheckMode, Provider, UpdateParams, } from '@/api/admin/channelMonitor' @@ -216,10 +265,20 @@ import { PROVIDER_ANTHROPIC, PROVIDER_GEMINI, PROVIDER_GROK, + PROVIDER_ANTIGRAVITY, + PROVIDER_KIMI, + PROVIDER_ZHIPU, + PROVIDER_DEEPSEEK, API_MODE_CHAT_COMPLETIONS, API_MODE_RESPONSES, + CHECK_MODE_PROBE, + CHECK_MODE_QUOTA, + CHECK_MODE_QUOTA_PROBE, DEFAULT_GROK_ENDPOINT, DEFAULT_GROK_MODEL, + DEFAULT_KIMI_ENDPOINT, + DEFAULT_ZHIPU_ENDPOINT, + DEFAULT_DEEPSEEK_ENDPOINT, DEFAULT_INTERVAL_SECONDS, } from '@/constants/channelMonitor' @@ -259,6 +318,8 @@ interface MonitorForm { name: string provider: Provider api_mode: APIMode + check_mode: CheckMode + account_id: number | null endpoint: string api_key: string primary_model: string @@ -278,6 +339,8 @@ const form = reactive({ name: '', provider: PROVIDER_ANTHROPIC, api_mode: API_MODE_CHAT_COMPLETIONS, + check_mode: CHECK_MODE_PROBE, + account_id: null, endpoint: '', api_key: '', primary_model: '', @@ -292,6 +355,10 @@ const form = reactive({ body_override: null, }) +// quota / quota_probe 需要关联账号;probe / quota_probe 需要探活字段。 +const usesQuotaMode = computed(() => form.check_mode !== CHECK_MODE_PROBE) +const usesProbePart = computed(() => form.check_mode !== CHECK_MODE_QUOTA) + // jitter 上限与后端校验一致:interval - jitter 不得低于最小检测间隔 15 秒。 const maxJitterSeconds = computed(() => Math.max(0, (form.interval_seconds || 0) - 15)) @@ -402,8 +469,194 @@ const providerOptions = computed(() => [ { value: PROVIDER_OPENAI, label: t('monitorCommon.providers.openai') }, { value: PROVIDER_GEMINI, label: t('monitorCommon.providers.gemini') }, { value: PROVIDER_GROK, label: t('monitorCommon.providers.grok') }, + { value: PROVIDER_ANTIGRAVITY, label: t('monitorCommon.providers.antigravity') }, + { value: PROVIDER_KIMI, label: t('monitorCommon.providers.kimi') }, + { value: PROVIDER_ZHIPU, label: t('monitorCommon.providers.zhipu') }, + { value: PROVIDER_DEEPSEEK, label: t('monitorCommon.providers.deepseek') }, ]) +// 国产 provider 预填的官方 endpoint(仅探活侧;配额模式 endpoint 可留空)。 +const PROVIDER_DEFAULT_ENDPOINTS: Partial> = { + [PROVIDER_KIMI]: DEFAULT_KIMI_ENDPOINT, + [PROVIDER_ZHIPU]: DEFAULT_ZHIPU_ENDPOINT, + [PROVIDER_DEEPSEEK]: DEFAULT_DEEPSEEK_ENDPOINT, +} + +interface CheckModeOption { + value: CheckMode + label: string + hint: string + disabled: boolean +} + +const checkModeOptions = computed(() => [ + { + value: CHECK_MODE_PROBE, + label: t('admin.channelMonitor.form.checkModeProbe'), + hint: t('admin.channelMonitor.form.checkModeProbeHint'), + // antigravity 无探活 adapter,仅配额模式。 + disabled: form.provider === PROVIDER_ANTIGRAVITY, + }, + { + value: CHECK_MODE_QUOTA, + label: t('admin.channelMonitor.form.checkModeQuota'), + hint: t('admin.channelMonitor.form.checkModeQuotaHint'), + disabled: false, + }, + { + value: CHECK_MODE_QUOTA_PROBE, + label: t('admin.channelMonitor.form.checkModeQuotaProbe'), + hint: t('admin.channelMonitor.form.checkModeQuotaProbeHint'), + // antigravity 无探活 adapter,只支持配额模式。 + disabled: form.provider === PROVIDER_ANTIGRAVITY, + }, +]) + +function checkModeButtonClass(mode: CheckMode): string { + const active = form.check_mode === mode + if (active) { + return 'border-primary-500 bg-white text-primary-700 shadow-sm dark:border-primary-400 dark:bg-primary-500/15 dark:text-primary-300' + } + return 'border-blue-100 bg-white/70 text-gray-600 hover:border-primary-300 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400' +} + +function selectCheckMode(mode: CheckMode) { + if (checkModeOptions.value.find((opt) => opt.value === mode)?.disabled) return + form.check_mode = mode + if (!usesQuotaMode.value) form.account_id = null +} + +// --- 关联账号选择器 --- + +interface LinkedAccount { + id: number + name: string +} + +const linkedAccounts = ref([]) +const accountsLoading = ref(false) +// 当前搜索词(用于空态文案区分「平台无账号」与「搜索无命中」)。 +const accountSearchQuery = ref('') +// 已绑定账号回填失败(getById 失败或平台失配)时提示用户重新选择。 +const accountHydrationFailed = ref(false) +// 固定选项:已绑定/已选中但不在当前结果页里的账号,保证搜索后 label 仍可见。 +const pinnedAccount = ref(null) +let accountSearchSeq = 0 +let accountSearchAbort: AbortController | null = null +const hydrationAttempted = new Set() + +const accountOptions = computed(() => { + const opts = linkedAccounts.value.map((a) => ({ + value: String(a.id), + label: `${a.name} (#${a.id})`, + })) + const pinned = pinnedAccount.value + if (pinned && !linkedAccounts.value.some((a) => a.id === pinned.id)) { + opts.unshift({ value: String(pinned.id), label: `${pinned.name} (#${pinned.id})` }) + } + return opts +}) + +// Select 组件绑定 string,与 number | null 互转。 +const accountSelectValue = computed({ + get: () => (form.account_id == null ? '' : String(form.account_id)), + set: (raw: string) => { + if (raw === '') { + form.account_id = null + pinnedAccount.value = null + accountHydrationFailed.value = false + return + } + const id = Number(raw) + if (Number.isFinite(id)) { + form.account_id = id + pinnedAccount.value = linkedAccounts.value.find((a) => a.id === id) ?? pinnedAccount.value + } + }, +}) + +// 服务端搜索当前 provider 平台的账号(支持关键字,避免大分页截断取不齐)。 +// seq + abort 防止快速切换 provider / 连续输入时乱序响应覆盖新结果。 +// 失败不阻塞表单:下拉为空 + 空态提示。 +async function loadLinkedAccounts(search = '') { + if (!usesQuotaMode.value || !props.show) return + accountSearchQuery.value = search + const seq = ++accountSearchSeq + accountSearchAbort?.abort() + const controller = new AbortController() + accountSearchAbort = controller + accountsLoading.value = true + try { + const res = await adminAPI.accounts.list( + 1, + 50, + { platform: form.provider, ...(search ? { search } : {}) }, + { signal: controller.signal }, + ) + if (seq !== accountSearchSeq) return + linkedAccounts.value = (res.items || []).map((a) => ({ id: a.id, name: a.name })) + await ensureSelectedAccountHydrated() + } catch (err: unknown) { + if (controller.signal.aborted) return + console.warn('load linked accounts failed', err) + if (!search) linkedAccounts.value = [] + } finally { + if (seq === accountSearchSeq) accountsLoading.value = false + } +} + +// 编辑已有 quota 监控时,已绑定账号可能不在搜索结果第一页:用 getById +// 回填为固定选项,绑定不因分页截断而丢失。仅当账号确实无法加载或平台 +// 失配时才清空绑定(带可见提示),否则绑定只在用户显式切换 provider 时清空。 +async function ensureSelectedAccountHydrated() { + const id = form.account_id + if (id == null || !usesQuotaMode.value) return + if (linkedAccounts.value.some((a) => a.id === id) || pinnedAccount.value?.id === id) return + if (hydrationAttempted.has(id)) return + hydrationAttempted.add(id) + try { + const account = await adminAPI.accounts.getById(id) + if (form.account_id !== id) return + if (String(account.platform) !== form.provider) { + form.account_id = null + pinnedAccount.value = null + accountHydrationFailed.value = true + return + } + pinnedAccount.value = { id: account.id, name: account.name } + } catch { + if (form.account_id === id) { + form.account_id = null + pinnedAccount.value = null + accountHydrationFailed.value = true + } + } +} + +function onAccountSearch(query: string) { + void loadLinkedAccounts(query) +} + +watch( + () => [props.show, form.provider, form.check_mode] as const, + ([show, provider], prev) => { + const [prevShow, prevProvider] = prev ?? [] + if (!show) { + accountSearchAbort?.abort() + return + } + // 弹窗重开 / provider 真正变化时重置回填状态(check_mode 变化不重置, + // 避免 probe↔quota 切换时无谓地重拉列表)。 + if (show !== prevShow || provider !== prevProvider) { + hydrationAttempted.clear() + accountHydrationFailed.value = false + pinnedAccount.value = null + } + void loadLinkedAccounts() + }, + { immediate: true }, +) + function selectProvider(provider: Provider) { if (form.provider === provider) return const previousProvider = form.provider @@ -411,14 +664,26 @@ function selectProvider(provider: Provider) { previousProvider === PROVIDER_GROK && form.endpoint === DEFAULT_GROK_ENDPOINT const clearGrokModel = previousProvider === PROVIDER_GROK && form.primary_model === DEFAULT_GROK_MODEL + const clearPrevDefaultEndpoint = + !!PROVIDER_DEFAULT_ENDPOINTS[previousProvider] && form.endpoint === PROVIDER_DEFAULT_ENDPOINTS[previousProvider] form.provider = provider + // 关联账号与平台绑定:切换 provider 时显式清空(这是唯一主动清空的入口)。 + form.account_id = null + pinnedAccount.value = null + accountHydrationFailed.value = false + // antigravity 仅配额模式:切到它时强制 quota(checkModeOptions 同步禁用其余项)。 + if (provider === PROVIDER_ANTIGRAVITY && form.check_mode !== CHECK_MODE_QUOTA) { + form.check_mode = CHECK_MODE_QUOTA + } if (provider === PROVIDER_GROK) { if (!form.endpoint.trim()) form.endpoint = DEFAULT_GROK_ENDPOINT if (!form.primary_model.trim()) form.primary_model = DEFAULT_GROK_MODEL return } - if (clearGrokEndpoint) form.endpoint = '' + if (clearGrokEndpoint || clearPrevDefaultEndpoint) form.endpoint = '' if (clearGrokModel) form.primary_model = '' + const defaultEndpoint = PROVIDER_DEFAULT_ENDPOINTS[provider] + if (defaultEndpoint && !form.endpoint.trim()) form.endpoint = defaultEndpoint } // Clear api_key whenever provider changes to avoid cross-provider key mismatch. @@ -447,6 +712,10 @@ function resetForm() { form.name = '' form.provider = PROVIDER_ANTHROPIC form.api_mode = API_MODE_CHAT_COMPLETIONS + form.check_mode = CHECK_MODE_PROBE + form.account_id = null + pinnedAccount.value = null + accountHydrationFailed.value = false form.endpoint = '' form.api_key = '' form.primary_model = '' @@ -467,6 +736,8 @@ function loadFromMonitor(m: ChannelMonitor) { form.name = m.name form.provider = m.provider form.api_mode = normalizeAPIMode(m.api_mode) + form.check_mode = m.check_mode || CHECK_MODE_PROBE + form.account_id = m.account_id ?? null form.endpoint = m.endpoint form.api_key = '' form.primary_model = m.primary_model @@ -533,15 +804,17 @@ function buildPayload(): CreateParams { name: form.name.trim(), provider: form.provider, api_mode: form.provider === PROVIDER_OPENAI ? form.api_mode : API_MODE_CHAT_COMPLETIONS, - endpoint: form.endpoint.trim(), - api_key: form.api_key.trim(), - primary_model: form.primary_model.trim(), - extra_models: form.extra_models, + check_mode: form.check_mode, + account_id: usesQuotaMode.value ? form.account_id : null, + endpoint: usesProbePart.value ? form.endpoint.trim() : '', + api_key: usesProbePart.value ? form.api_key.trim() : '', + primary_model: usesProbePart.value ? form.primary_model.trim() : 'quota', + extra_models: usesProbePart.value ? form.extra_models : [], group_name: form.group_name.trim(), enabled: form.enabled, interval_seconds: form.interval_seconds, jitter_seconds: form.jitter_seconds || 0, - template_id: form.template_id, + template_id: usesProbePart.value ? form.template_id : null, extra_headers: form.extra_headers, body_override_mode: form.body_override_mode, body_override: form.body_override, @@ -554,7 +827,11 @@ async function handleSubmit() { appStore.showError(t('admin.channelMonitor.nameRequired')) return } - if (!form.primary_model.trim()) { + if (usesQuotaMode.value && form.account_id == null) { + appStore.showError(t('admin.channelMonitor.linkedAccountRequired')) + return + } + if (usesProbePart.value && !form.primary_model.trim()) { appStore.showError(t('admin.channelMonitor.primaryModelRequired')) return } @@ -568,7 +845,7 @@ async function handleSubmit() { // Only send api_key if user typed a new value if (api_key) req.api_key = api_key // template_id=null 用 clear_template=true 明确告诉后端清空(pointer 语义) - if (form.template_id == null) { + if (usesProbePart.value && form.template_id == null) { req.clear_template = true delete req.template_id } diff --git a/frontend/src/components/admin/monitor/MonitorPrimaryModelCell.vue b/frontend/src/components/admin/monitor/MonitorPrimaryModelCell.vue index eccec8284873..67797904c7c7 100644 --- a/frontend/src/components/admin/monitor/MonitorPrimaryModelCell.vue +++ b/frontend/src/components/admin/monitor/MonitorPrimaryModelCell.vue @@ -1,7 +1,8 @@